From 12b3128f532201cf3ed6cedccbe586bf452abf2b Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:24:12 -0400 Subject: [PATCH] model: stitches of imported surfaces, kept in order (#507) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/model/src/lib.rs | 14 ++++++- crates/model/src/virtual_faces.rs | 63 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index f3fc35d..082ec39 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -85,7 +85,7 @@ pub use steps::{ }; pub use tie::Tie; pub use units::UnitSystem; -pub use virtual_faces::FaceMerge; +pub use virtual_faces::{FaceMerge, Stitch}; /// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight /// digits in a small field), the tightest of the three formats. @@ -177,6 +177,9 @@ pub struct Model { /// Face merges (virtual topology), in the order they apply. #[serde(skip_serializing_if = "Vec::is_empty")] face_merges: Vec, + /// Stitches of imported surfaces, in the order they apply. + #[serde(skip_serializing_if = "Vec::is_empty")] + stitches: Vec, } /// A [`Model`] as stored, before it is checked. @@ -231,6 +234,8 @@ struct ModelData { mesh_plans: BTreeMap, #[serde(default)] face_merges: Vec, + #[serde(default)] + stitches: Vec, } impl TryFrom for Model { @@ -323,6 +328,9 @@ impl TryFrom for Model { for v in d.face_merges { m.add_face_merge(v)?; } + for v in d.stitches { + m.add_stitch(v)?; + } Ok(m) } } @@ -456,6 +464,7 @@ impl Model { relaxations: self.relaxations.clone(), mesh_plans: self.mesh_plans.clone(), face_merges: self.face_merges.clone(), + stitches: self.stitches.clone(), } } @@ -506,7 +515,7 @@ impl Model { } /// Counts of each kind of definition, for summaries. - pub fn definition_counts(&self) -> [(&'static str, usize); 17] { + pub fn definition_counts(&self) -> [(&'static str, usize); 18] { [ ("sets", self.sets.len()), ("materials", self.materials.len()), @@ -525,6 +534,7 @@ impl Model { ("partitions", self.partitions.len()), ("seeds", self.seeds.len()), ("face_merges", self.face_merges.len()), + ("stitches", self.stitches.len()), ] } } diff --git a/crates/model/src/virtual_faces.rs b/crates/model/src/virtual_faces.rs index 1262ce1..b911ac9 100644 --- a/crates/model/src/virtual_faces.rs +++ b/crates/model/src/virtual_faces.rs @@ -5,6 +5,9 @@ //! model keeps the merges in the order they were made, because a merge may name the face an //! earlier one made (`merge:`); they are applied after the partitions, so a body named may //! be a partition cell. +//! +//! A **stitch** closes an imported surface's gaps under a tolerance for meshing (the geometry +//! layer welds its open points and splits T-junctions). use serde::{Deserialize, Serialize}; @@ -30,6 +33,52 @@ pub struct FaceMerge { pub into: Option, } +/// Most stitches a model holds. +pub const MAX_STITCHES: usize = 1024; + +/// An imported surface's gaps under `tolerance` (mm) closed for meshing (`geometry.stitch`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Stitch { + pub name: String, + /// The imported surface, by name. + pub surface: String, + pub tolerance: f64, +} + +impl Model { + /// The stitches, in the order they apply. + pub fn stitches(&self) -> &[Stitch] { + &self.stitches + } + + /// Add a stitch after the others. + pub fn add_stitch(&mut self, stitch: Stitch) -> Result<()> { + let name = stitch.name.clone(); + check_name("stitch", &name)?; + if self.stitches.iter().any(|s| s.name == name) { + return Err(Error::Invalid(format!("stitch `{name}` already exists"))); + } + if self.stitches.len() >= MAX_STITCHES { + return Err(Error::Limit(format!("a model holds at most {MAX_STITCHES} stitches"))); + } + let invalid = |m: String| context("stitch", &name, Error::Invalid(m)); + if stitch.surface.trim().is_empty() || stitch.surface.len() > MAX_GEOMETRY_NAME_LEN { + return Err(invalid(format!("the surface name is 1 to {MAX_GEOMETRY_NAME_LEN} bytes"))); + } + if !(stitch.tolerance > 0.0 && stitch.tolerance <= crate::MAX_COORD) { + return Err(invalid("the tolerance must be a positive length".into())); + } + self.stitches.push(stitch); + Ok(()) + } + + pub fn remove_stitch(&mut self, name: &str) -> Result { + let i = self.stitches.iter().position(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("stitch `{name}`")))?; + Ok(self.stitches.remove(i)) + } +} + impl Model { /// The face merges, in the order they apply. pub fn face_merges(&self) -> &[FaceMerge] { @@ -83,6 +132,20 @@ mod tests { FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None } } + #[test] + fn stitches_are_kept_in_order_and_checked() { + let mut m = Model::default(); + let st = |n: &str, t: f64| Stitch { name: n.into(), surface: "Mesh1".into(), tolerance: t }; + m.add_stitch(st("Gaps", 0.1)).unwrap(); + for bad in [st("Gaps", 0.1), st("A", 0.0), st("B", f64::NAN), st("C", -1.0), Stitch { surface: " ".into(), ..st("D", 0.1) }] { + assert!(m.add_stitch(bad).is_err()); + } + let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(back.stitches(), m.stitches()); + m.remove_stitch("Gaps").unwrap(); + assert!(m.stitches().is_empty()); + } + /// Merges keep their order, are saved and read back, and bad ones are refused. #[test] fn merges_are_kept_in_order_and_checked() {