From b0d0fc9900dd14205934efee97c325037478e672 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:16:44 -0400 Subject: [PATCH] Model: datum planes and axes, and partitions by datum plane Datums are named planes (point, unit normal) and axes (point, unit direction); a partition names the datum plane that cuts and, optionally, the bodies. A datum a partition uses cannot be deleted or turned into an axis. Both are stored in model.json, partitions in the order they cut. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/model/src/datums.rs | 202 +++++++++++++++++++++++++++++++++++++ crates/model/src/lib.rs | 23 ++++- docs/formats/fieldcraft.md | 2 +- 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 crates/model/src/datums.rs diff --git a/crates/model/src/datums.rs b/crates/model/src/datums.rs new file mode 100644 index 0000000..efdfcc7 --- /dev/null +++ b/crates/model/src/datums.rs @@ -0,0 +1,202 @@ +//! Datum planes and axes, and the partitions that cut the geometry by datum planes. +//! +//! A datum is a construction reference in model coordinates: a plane (a point and its unit +//! normal) or an axis (a point and its unit direction). Partitions, mirrors and symmetry +//! conditions name them. A **partition** cuts bodies of the geometry by a datum plane into cells +//! (the geometry layer does the cutting); the model keeps the partitions in the order they were +//! made, because each cuts the cells the ones before it left. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing}; + +/// Most partitions a model holds. +pub const MAX_PARTITIONS: usize = 256; +/// Most bodies one partition names. +pub const MAX_PARTITION_BODIES: usize = 1000; +/// Longest body name a partition holds. +const MAX_BODY_NAME: usize = 256; + +/// A datum plane or axis. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Datum { + /// A point on the plane and its unit normal. + Plane { point: [f64; 3], normal: [f64; 3] }, + /// A point on the axis and its unit direction. + Axis { point: [f64; 3], direction: [f64; 3] }, +} + +impl Datum { + /// The datum with its point checked and its direction made a unit vector. + fn checked(self) -> Result { + let unit = |what: &str, v: [f64; 3]| -> Result<[f64; 3]> { + let len = v.iter().map(|x| x * x).sum::().sqrt(); + if v.iter().all(|x| x.is_finite()) && len > 1e-12 && len.is_finite() { + // A unit vector stays bit for bit, so a model read back equals the one written. + Ok(if (len - 1.0).abs() <= 1e-12 { v } else { v.map(|x| x / len) }) + } else { + Err(Error::Invalid(format!("the {what} must be a finite, non-zero vector"))) + } + }; + let point = |p: [f64; 3]| -> Result<[f64; 3]> { + crate::check_coordinates(&p)?; + Ok(p) + }; + Ok(match self { + Datum::Plane { point: p, normal } => Datum::Plane { point: point(p)?, normal: unit("normal", normal)? }, + Datum::Axis { point: p, direction } => Datum::Axis { point: point(p)?, direction: unit("direction", direction)? }, + }) + } + + /// The plane's point and unit normal; `None` for an axis. + pub fn plane(&self) -> Option<([f64; 3], [f64; 3])> { + match *self { + Datum::Plane { point, normal } => Some((point, normal)), + Datum::Axis { .. } => None, + } + } +} + +/// A partition: the bodies cut by a datum plane. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Partition { + pub name: String, + /// The datum plane that cuts. + pub datum: String, + /// The bodies it cuts, as they are when it comes (cells of earlier partitions included); + /// empty: every body the plane divides. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bodies: Vec, +} + +impl Model { + pub fn datums(&self) -> &BTreeMap { + &self.datums + } + + pub fn datum(&self, name: &str) -> Option<&Datum> { + self.datums.get(name) + } + + pub fn add_datum(&mut self, name: &str, datum: Datum) -> Result<()> { + let datum = datum.checked().map_err(|e| context("datum", name, e))?; + insert_new(&mut self.datums, "datum", name, datum) + } + + /// Replace a datum; a datum plane a partition uses stays a plane. + pub fn replace_datum(&mut self, name: &str, datum: Datum) -> Result<()> { + let datum = datum.checked().map_err(|e| context("datum", name, e))?; + if datum.plane().is_none() { + not_in_use("datum", name, self.datum_users(name))?; + } + replace_existing(&mut self.datums, "datum", name, datum) + } + + pub fn remove_datum(&mut self, name: &str) -> Result<()> { + not_in_use("datum", name, self.datum_users(name))?; + remove_existing(&mut self.datums, "datum", name).map(|_| ()) + } + + fn datum_users(&self, name: &str) -> Vec { + self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name)).collect() + } + + /// The partitions, in the order they cut. + pub fn partitions(&self) -> &[Partition] { + &self.partitions + } + + /// Add a partition after the others. + pub fn add_partition(&mut self, partition: Partition) -> Result<()> { + let name = partition.name.clone(); + check_name("partition", &name)?; + if self.partitions.iter().any(|p| p.name == name) { + return Err(Error::Invalid(format!("partition `{name}` already exists"))); + } + if self.partitions.len() >= MAX_PARTITIONS { + return Err(Error::Limit(format!("a model holds at most {MAX_PARTITIONS} partitions"))); + } + let invalid = |m: String| context("partition", &name, Error::Invalid(m)); + match self.datums.get(&partition.datum) { + None => return Err(context("partition", &name, Error::NotFound(format!("datum `{}`", partition.datum)))), + Some(d) if d.plane().is_none() => return Err(invalid(format!("datum `{}` is an axis; a partition cuts by a plane", partition.datum))), + Some(_) => {} + } + if partition.bodies.len() > MAX_PARTITION_BODIES { + return Err(invalid(format!("at most {MAX_PARTITION_BODIES} bodies"))); + } + for (i, b) in partition.bodies.iter().enumerate() { + if b.trim().is_empty() || b.len() > MAX_BODY_NAME { + return Err(invalid(format!("body names are 1 to {MAX_BODY_NAME} characters"))); + } + if partition.bodies.get(..i).is_some_and(|before| before.contains(b)) { + return Err(invalid(format!("body `{b}` is listed twice"))); + } + } + self.partitions.push(partition); + Ok(()) + } + + pub fn remove_partition(&mut self, name: &str) -> Result { + let i = self.partitions.iter().position(|p| p.name == name).ok_or_else(|| Error::NotFound(format!("partition `{name}`")))?; + Ok(self.partitions.remove(i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plane(z: f64) -> Datum { + Datum::Plane { point: [0.0, 0.0, z], normal: [0.0, 0.0, 2.0] } + } + + #[test] + fn datums_are_normalised_and_partitions_refer_to_planes() { + let mut m = Model::default(); + m.add_datum("Mid", plane(5.0)).unwrap(); + assert_eq!(m.datum("Mid"), Some(&Datum::Plane { point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0] })); + m.add_datum("Axis", Datum::Axis { point: [0.0; 3], direction: [1.0, 1.0, 0.0] }).unwrap(); + let Some(Datum::Axis { direction, .. }) = m.datum("Axis") else { panic!() }; + assert!((direction[0] - 0.5f64.sqrt()).abs() < 1e-15); + + let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![] }; + m.add_partition(p("Half", "Mid")).unwrap(); + assert!(m.add_partition(p("Half", "Mid")).is_err()); + assert!(m.add_partition(p("Bad", "Axis")).is_err()); + assert!(m.add_partition(p("Bad", "Nope")).is_err()); + assert!(m.add_partition(Partition { bodies: vec!["A".into(), "A".into()], ..p("Bad", "Mid") }).is_err()); + assert!(matches!(m.remove_datum("Mid"), Err(Error::InUse(_)))); + assert!(matches!(m.replace_datum("Mid", Datum::Axis { point: [0.0; 3], direction: [1.0, 0.0, 0.0] }), Err(Error::InUse(_)))); + m.replace_datum("Mid", plane(3.0)).unwrap(); + assert_eq!(m.partitions().len(), 1); + + // The model round-trips through JSON, partitions in order. + m.add_partition(Partition { bodies: vec!["Body1/a".into()], ..p("Quarter", "Mid") }).unwrap(); + let back: Model = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap(); + assert_eq!(back, m); + m.remove_partition("Half").unwrap(); + m.remove_partition("Quarter").unwrap(); + m.remove_datum("Mid").unwrap(); + } + + #[test] + fn hostile_datums_are_an_error() { + let mut m = Model::default(); + for d in [ + Datum::Plane { point: [0.0; 3], normal: [0.0; 3] }, + Datum::Plane { point: [f64::NAN, 0.0, 0.0], normal: [0.0, 0.0, 1.0] }, + Datum::Plane { point: [1e300, 0.0, 0.0], normal: [0.0, 0.0, 1.0] }, + Datum::Axis { point: [0.0; 3], direction: [f64::INFINITY, 0.0, 0.0] }, + Datum::Axis { point: [0.0; 3], direction: [1e-300, 0.0, 0.0] }, + ] { + assert!(m.add_datum("D", d).is_err(), "{d:?}"); + } + assert!(m.add_datum("1bad", plane(0.0)).is_err()); + assert!(m.datums().is_empty()); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index f24c165..ff92b02 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -35,6 +35,7 @@ pub mod bcs; pub mod check; pub mod constraints; pub mod contact; +pub mod datums; pub mod discrete; pub mod fluids; pub mod geometry; @@ -56,6 +57,7 @@ pub use bcs::{BoundaryCondition, Dofs, Symmetry, TEMPERATURE_DOF, Target}; pub use check::{Diagnostic, Resolved, ResolvedSet, Severity}; pub use constraints::{Coupling, CouplingKind, ReferencePoint}; pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration}; +pub use datums::{Datum, Partition}; pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring}; pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition}; pub use geometry::{Classification, Entity, GeometryRef}; @@ -141,6 +143,11 @@ pub struct Model { fluid_domains: BTreeMap, /// In analysis order. steps: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + datums: BTreeMap, + /// In the order they cut. + #[serde(skip_serializing_if = "Vec::is_empty")] + partitions: Vec, } /// A [`Model`] as stored, before it is checked. @@ -181,6 +188,10 @@ struct ModelData { fluid_domains: BTreeMap, #[serde(default)] steps: Vec, + #[serde(default)] + datums: BTreeMap, + #[serde(default)] + partitions: Vec, } impl TryFrom for Model { @@ -252,6 +263,12 @@ impl TryFrom for Model { for s in d.steps { m.add_step(s, None)?; } + for (n, v) in d.datums { + m.add_datum(&n, v)?; + } + for p in d.partitions { + m.add_partition(p)?; + } Ok(m) } } @@ -357,11 +374,13 @@ impl Model { loads: self.loads.clone(), fluid_domains: self.fluid_domains.clone(), steps: self.steps.clone(), + datums: self.datums.clone(), + partitions: self.partitions.clone(), } } /// Counts of each kind of definition, for summaries. - pub fn definition_counts(&self) -> [(&'static str, usize); 13] { + pub fn definition_counts(&self) -> [(&'static str, usize); 15] { [ ("sets", self.sets.len()), ("materials", self.materials.len()), @@ -376,6 +395,8 @@ impl Model { ("loads", self.loads.len()), ("fluid_domains", self.fluid_domains.len()), ("steps", self.steps.len()), + ("datums", self.datums.len()), + ("partitions", self.partitions.len()), ] } } diff --git a/docs/formats/fieldcraft.md b/docs/formats/fieldcraft.md index 1b64d90..bb6613a 100644 --- a/docs/formats/fieldcraft.md +++ b/docs/formats/fieldcraft.md @@ -13,7 +13,7 @@ unzip tool reads them: `unzip -p bracket.fieldcraft model.json`): | Part | Content | |---|---| | `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.5.7"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. | -| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads and steps. Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. | +| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads, steps, datum planes and axes, and the partitions that cut the geometry (in order; applied again when the design is opened). Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. | | `mesh.bin` | Nodes, elements and geometry tags, binary (below). | | `journal.json` | The commands that built the model, `[{"command": id, "params": {...}}, ...]`, one per line. It is a script `fieldcraft-cli run` replays. |