From 58174ba16826399ab86aaedcdec9456a991b731a Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:10:45 -0400 Subject: [PATCH] geometry.face.absorb: tiny and sliver faces into a neighbour (#506) A face merge may keep one of its faces' names (Merge::into, model FaceMerge::into): absorbing a tiny face leaves the neighbour by its own name, so sets on it still select it. Shape::absorbing_neighbour finds the face a tiny one shares the longest edges with. geometry.face.absorb absorbs one face (into a named or the best neighbour) or, from_check, every small and sliver face the last geometry.check marked; check marks now carry the small face they found. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/geometry.face.absorb.svg | 10 + assets/icons/geometry.face.absorb.vectorcraft | Bin 0 -> 1751 bytes assets/icons/src/geometry.face.absorb.json | 1 + crates/engine/src/catalogue.rs | 7 + crates/engine/src/cmd/datum.rs | 7 +- crates/engine/src/cmd/forms.rs | 6 + crates/engine/src/cmd/geometry.rs | 8 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/virtual_faces.rs | 185 +++++++++++++++++- crates/engine/src/view.rs | 3 + crates/geometry/src/virtual_faces.rs | 66 ++++++- crates/model/src/virtual_faces.rs | 12 +- docs/features.md | 3 +- 14 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 assets/icons/geometry.face.absorb.svg create mode 100644 assets/icons/geometry.face.absorb.vectorcraft create mode 100644 assets/icons/src/geometry.face.absorb.json diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d384a55..253afc0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -65,6 +65,7 @@ is committed and listed here with its exports. | assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/geometry.face.absorb.svg b/assets/icons/geometry.face.absorb.svg new file mode 100644 index 0000000..4acd82a --- /dev/null +++ b/assets/icons/geometry.face.absorb.svg @@ -0,0 +1,10 @@ + + + geometry.face.absorb + + + + + + + diff --git a/assets/icons/geometry.face.absorb.vectorcraft b/assets/icons/geometry.face.absorb.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..9990298d9b40cdab7187d8c4bb568a83f54aee5b GIT binary patch literal 1751 zcmV;|1}OO-iwFP!00000|K(ciZre5#eicK%4MWu30rsQoimqt47U|GpC<23)Xq$^H zYLwK(3;gaoyxCUbq!jH89U7puMN&MUzjNW?A@95e<7?b{7v5Krw~Xf;FIsO5PrRY5 z@-CvWw9pCpqe+7&Y=I(}@pb3zm0lQo$s5`}fe;YiU~&cxOr2nzVc~S>T*Z z@VsGs4zG0;Z5!zRKyOLeKts+-#%F6*h?g#xVhrZ(oMB$TxG#4eE#$lvUW*Ygbl(WU zKn}Qs{9E6<@F)J*+n6u6$h!z4*`&X1$6m8yH?tft6A*~`wRf?=WkXB{XH7F(Vaglb zvAr65CEgHz49GjtAyHh@st_pJO)Wa)8|H_J_79vi1aJxnEm_wkZplt`&1u!*xuC4& z9W>-CS{9sCJCOkpQr84?T;)W}M`03$NJ;hH}JvZZ5tmx`4nH)5?TNzl2~P z*~GsRKTkZP02-o$h5#KKA^y7(wvm^tA%#*LV39NljfJint5`+6ABAo4jFS802L3nc zR=zoYDB|5>4tmFo;CEh8W(MiUChJT>O1FT;y?CGajcpdt|5o zv-k%8pt-rhZN4H(j$RGF{0>ZBJ<2dJQnEQN_bYDA+5=D4vwU8G;+)8rhP%|CbNRWo zmHKmu^J_rs zEbyHO#}!IqRyheJHrb7WaxjmnK)p~Zy*9Ncu+}w* zBoMYiV0te{3=Ri=?VW*sV3v)2r*TodK{No(IkqEv=dd++7!#x<0s z%6<2#nS2gSOtoL&oP()RM=T>nuv=7AqGlh?*Us?FBz01T(=KNPd!nub{DEd$Ucf)c8zO9 zzn3G2Dt;J4F?p*slFzuTI_K3T?(JlPzT*zwXufJ{D9ksmD{t7dl|Y4hW3;}ql>x+v z%Ex&0SSe!3aY4ml%Xt6wVpp_!M=Pj(L0{9dT9suWG^jHNS_x_M2D znn^fd4g#{78$CVuvEsT}iG&C%tmeavfBuk-l?+AX38sT$EPVU#&`JYdr)ft*l`-(!&ro}Udq zHPRT)QA_MYh*V{9D3DXiX#HhMmjH&J-YFm_)vdB#`Q0R2Q>)`5c~T0NU$1CBYuZiO zx87gK0(Yg*m>ewgt0005#eMw Vec { /// `design` cut by `model`'s partitions, then with its face merges (the design as opened when /// the model has neither). pub(crate) fn partitioned(design: &Design, model: &Model) -> Result { - let merges: Vec = - model.face_merges().iter().map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone() }).collect(); + let merges: Vec = model + .face_merges() + .iter() + .map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() }) + .collect(); Ok(design.partitioned(&cuts(model))?.merged(&merges)?) } diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 8f1b6f3..a3c0c62 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1717,6 +1717,12 @@ pub const FACES_MERGE: &[Field] = &[ .req() .hint("Shift-click faces"), ]; +pub const FACE_ABSORB: &[Field] = &[ + text("name", "Name").help("The absorption's name (with From check: the prefix of each one's, Name-1, Name-2, …).").hint("Absorb"), + group("face", "Face", FACE_ROW).help("The tiny or sliver face to absorb; leave empty with From check."), + text("into", "Into").help("The neighbour face that absorbs it, by persistent name.").hint("the neighbour sharing the longest edges"), + flag("from_check", "From check").help("Absorb every small and sliver face the last Check Geometry found, each into its best neighbour."), +]; pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge); pub const MESH_PLAN: &[Field] = &[ reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(), diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index e1f92f3..2f6ae52 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -444,7 +444,9 @@ fn check(s: &mut Session, p: &Value) -> Result { } else { f.face_indices.iter().map(|&i| (f.body.clone(), i)).collect() }; - crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at } + let small = + matches!(f.kind, Kind::SmallFace | Kind::SliverFace).then(|| f.faces.first().map(|n| (f.body.clone(), n.clone()))).flatten(); + crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at, small } }) .collect(); Arc::new(crate::view::GeometryMarks { design: Arc::downgrade(design), marks }) @@ -522,13 +524,13 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::io::{Cursor, Write}; use super::*; /// A session with the design the SolidCraft `commands` build open. - fn design(commands: Value) -> (crate::tests::TempDir, Session) { + pub(crate) fn design(commands: Value) -> (crate::tests::TempDir, Session) { let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap(); let (dir, path) = crate::tests::design_file(&bytes); let mut s = Session::default(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2f87a38..1ca1cb8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -73,6 +73,7 @@ pub static ALL: &[CommandSpec] = &[ datum::PARTITION_DELETE, virtual_faces::FACES_MERGE, virtual_faces::FACES_MERGE_DELETE, + virtual_faces::FACE_ABSORB, mesh::NODE_ADD, mesh::NODE_REMOVE, mesh::ELEMENT_ADD, diff --git a/crates/engine/src/cmd/virtual_faces.rs b/crates/engine/src/cmd/virtual_faces.rs index 5a12b4d..3197585 100644 --- a/crates/engine/src/cmd/virtual_faces.rs +++ b/crates/engine/src/cmd/virtual_faces.rs @@ -1,4 +1,6 @@ -//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`). +//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`), and tiny or sliver +//! faces absorbed into a neighbour (`geometry.face.absorb`, a merge that keeps the neighbour's +//! name). //! //! A face merge is part of the model (`fieldcraft_model::virtual_faces`); the geometry layer //! applies it to the open design's bodies (`fieldcraft_geometry::virtual_faces`), after the @@ -33,6 +35,16 @@ pub const FACES_MERGE_DELETE: CommandSpec = CommandSpec { run: delete, }; +pub const FACE_ABSORB: CommandSpec = CommandSpec { + id: "geometry.face.absorb", + label: "Absorb Faces", + params: r#"{"name": "Sliver", "face": {"body": "Base", "face": "F4:side#2"}, "into"?: "F1:+z" (default: the neighbour it shares the longest edges with)} | {"name"?: "Absorb", "from_check": true} — absorbs a tiny or sliver face into a neighbour for meshing only (virtual topology, as geometry.faces.merge): the meshers, the checks and the viewport take the two as one face that keeps the neighbour's name, so sets on the neighbour still select it and the tiny face no longer drives the mesh. The B-rep is not changed. `from_check`: every small and sliver face the last geometry.check found (and marked) on the open design, each into its best neighbour, as `-1`, `-2`, … in the check's order (a face already absorbed by an earlier one is `skipped`). Kept in the model and applied again by persistent names whenever the design is opened (delete one with geometry.faces.merge.delete); the bodies lose their meshes (`replaced`); sets naming an absorbed face are `stale_sets`. Replies `absorbed` ([{name, body, face, into}]) and `skipped`. One undo step"#, + form: Some(super::forms::FACE_ABSORB), + kind: CommandKind::Model, + requires: &[Requirement::Design], + run: absorb, +}; + /// Most faces one merge lists. const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES; @@ -63,17 +75,19 @@ fn stale_sets(s: &Session, body: &str) -> Vec { s.model.sets().iter().filter(|(_, set)| set.geometry.iter().any(gone)).map(|(n, _)| n.clone()).collect() } -/// Apply the model's partitions and merges to the design again, failing with `feature`'s error -/// when it has one; the meshes of `body` go (its faces changed). -fn reapply(s: &mut Session, feature: &str, body: &str, reply: Value) -> Result { +/// Apply the model's partitions and merges to the design again, failing with the error of a merge +/// named in `merges` when it has one; the meshes of `bodies` go (their faces changed). +fn reapply(s: &mut Session, merges: &[String], bodies: &[String], reply: Value) -> Result { let mut cleared = super::datum::recut(s)?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; - let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect(); + let features: Vec = merges.iter().map(|m| format!("merge {m}")).collect(); + let errors: Vec = + design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect(); if !errors.is_empty() { - return Err(EngineError::Params(format!("{feature}: {}", errors.join("; ")))); + return Err(EngineError::Params(errors.join("; "))); } - cleared.extend(super::replace::clear(&mut s.model, [body])); - let stale = stale_sets(s, body); + cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str))); + let stale: Vec = bodies.iter().flat_map(|b| stale_sets(s, b)).collect(); let mut reply = cleared.report_if_any(s, reply); if !stale.is_empty() && let Some(o) = reply.as_object_mut() @@ -93,10 +107,10 @@ fn merge(s: &mut Session, p: &Value) -> Result { if s.design.as_deref().and_then(|d| d.body(&body)).is_none() { return Err(EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::()))); } - s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone() })?; + s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone(), into: None })?; let face = merged_face_name(&name); let reply = json!({"name": name, "body": body, "face": face}); - let mut reply = reapply(s, &format!("merge {name}"), &body, reply)?; + let mut reply = reapply(s, std::slice::from_ref(&name), std::slice::from_ref(&body), reply)?; let shape = s.design.as_deref().and_then(|d| d.body(&body)); let brep = shape.and_then(|b| b.face_index(&face)).and_then(|f| shape?.merged_faces().into_iter().find(|(g, _)| *g == f)).map_or(0, |(_, n)| n); if let Some(o) = reply.as_object_mut() { @@ -105,6 +119,90 @@ fn merge(s: &mut Session, p: &Value) -> Result { Ok(reply) } +/// The faces to absorb, by body and name: the one named, or the small and sliver faces of the +/// last geometry.check. +fn to_absorb(s: &Session, p: &Value) -> Result> { + let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false); + match (p.get("face"), from_check) { + (Some(_), false) => { + let r: GeometryRef = super::req(p, "face")?; + match r.entity { + Entity::Face(f) => Ok(vec![(r.body, f)]), + _ => Err(EngineError::invalid("face", "give {\"body\", \"face\"}")), + } + } + (None, true) => { + let marks = crate::view::geometry_marks(s) + .ok_or_else(|| EngineError::Params("`from_check`: run geometry.check (with marks) on the open design first".into()))?; + let mut faces: Vec<(String, String)> = Vec::new(); + for f in marks.marks.iter().filter_map(|m| m.small.clone()) { + if !faces.contains(&f) { + faces.push(f); + } + } + if faces.is_empty() { + return Err(EngineError::Params("`from_check`: the last geometry.check found no small or sliver face".into())); + } + Ok(faces) + } + _ => Err(EngineError::Params("give either `face` or `from_check: true`".into())), + } +} + +fn absorb(s: &mut Session, p: &Value) -> Result { + let faces = to_absorb(s, p)?; + let one = faces.len() == 1 && p.get("face").is_some(); + let name = match p.get("name") { + None if !one => "Absorb".to_string(), + _ => super::name(p)?.to_string(), + }; + let into: Option = super::opt(p, "into")?; + if into.is_some() && !one { + return Err(EngineError::invalid("into", "goes with `face`")); + } + let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; + // The bodies as each absorption leaves them, to find the next one's neighbour. + let mut shapes: Vec = Vec::new(); + let (mut absorbed, mut skipped, mut merges) = (Vec::new(), Vec::new(), Vec::new()); + for (k, (body, face)) in faces.into_iter().enumerate() { + if !shapes.iter().any(|b| b.name == body) { + let shape = design + .body(&body) + .ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::())))?; + shapes.push(shape.clone()); + } + let Some(shape) = shapes.iter_mut().find(|b| b.name == body) else { continue }; + let target = match &into { + Some(i) => Ok(i.clone()), + None => shape.absorbing_neighbour(&face), + }; + let merged = target.map_err(EngineError::from).and_then(|t| Ok((shape.merge_faces(&t, &[face.clone(), t.clone()])?, t))); + let (next, target) = match merged { + Ok(m) => m, + Err(e) if !one => { + skipped.push(json!({"body": body, "face": face, "reason": e.to_string()})); + continue; + } + Err(e) => return Err(e), + }; + *shape = next; + let merge = if one { name.clone() } else { format!("{name}-{}", k + 1) }; + s.model.add_face_merge(FaceMerge { + name: merge.clone(), + body: body.clone(), + faces: vec![face.clone(), target.clone()], + into: Some(target.clone()), + })?; + absorbed.push(json!({"name": merge, "body": body, "face": face, "into": target})); + merges.push(merge); + } + if merges.is_empty() { + return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped)))); + } + let bodies: Vec = shapes.into_iter().map(|b| b.name).collect(); + reapply(s, &merges, &bodies, json!({"absorbed": absorbed, "skipped": skipped})) +} + fn delete(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; let removed = s.model.remove_face_merge(name)?; @@ -320,4 +418,71 @@ mod tests { assert_eq!(r["stale_sets"], json!(["Bore"]), "{r}"); std::fs::remove_dir_all(dir).ok(); } + + /// A block with a 0.4 mm step on its top: the riser is a sliver at size 5. + fn stepped() -> (crate::tests::TempDir, Session) { + super::super::geometry::tests::design(json!([ + {"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}}, + {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}}, + ])) + } + + fn small_faces(s: &mut Session) -> usize { + let r = s.execute("geometry.check", &json!({"size": 5})).unwrap(); + r["findings"].as_array().unwrap().iter().filter(|f| matches!(f["kind"].as_str(), Some("small_face" | "sliver_face"))).count() + } + + /// `from_check` absorbs every sliver the check found into its best neighbour, which keeps its + /// name; the check then finds none, the tet mesher tags no element face with the sliver, and + /// undo brings it back. + #[test] + fn absorbing_the_check_findings_clears_them() { + let (_dir, mut s) = stepped(); + let before = small_faces(&mut s); + assert!(before > 0); + let start = faces(&s); + assert!(s.execute("geometry.face.absorb", &json!({"from_check": true, "into": "x"})).is_err()); + let r = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap(); + let absorbed = r["absorbed"].as_array().unwrap(); + assert_eq!(absorbed.len(), before, "{r}"); + assert_eq!(absorbed[0]["name"], "Absorb-1"); + let (gone, into) = (absorbed[0]["face"].as_str().unwrap().to_string(), absorbed[0]["into"].as_str().unwrap().to_string()); + assert!(!faces(&s).contains(&gone) && faces(&s).contains(&into)); + assert_eq!(faces(&s).len(), start.len() - before); + assert_eq!(small_faces(&mut s), 0); + s.execute("mesh.tet.generate", &json!({"size": 2})).unwrap(); + let c = s.model.classification(); + let used: BTreeSet = + c.elements().values().flat_map(|t| t.faces.values()).filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect(); + assert!(used.contains(&into) && !used.contains(&gone), "{used:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(faces(&s), start); + } + + /// One face into a named neighbour, or its best one; bad requests change nothing. + #[test] + fn absorbing_one_face() { + let (_dir, mut s) = stepped(); + let r = s.execute("geometry.check", &json!({"size": 5})).unwrap(); + let sliver = + r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == "sliver_face" || f["kind"] == "small_face").unwrap()["faces"][0].clone(); + let face = json!({"body": "Base", "face": sliver}); + let journal = s.journal.len(); + for p in [ + json!({"name": "A"}), + json!({"name": "A", "face": face, "from_check": true}), + json!({"face": face}), + json!({"name": "A", "face": {"body": "Base", "face": "nope"}}), + json!({"name": "A", "face": face, "into": "nope"}), + json!({"name": "A", "face": {"body": "Nobody", "face": "x"}}), + json!({"name": "A", "face": face, "from_check": "yes"}), + ] { + assert!(s.execute("geometry.face.absorb", &p).is_err(), "{p}"); + } + assert_eq!(s.journal.len(), journal); + let r = s.execute("geometry.face.absorb", &json!({"name": "Riser", "face": face})).unwrap(); + assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}"); + assert!(s.model.face_merges()[0].into.is_some()); + } } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 404c834..bb41b15 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -226,6 +226,9 @@ pub struct GeometryMark { /// A short edge's polyline (empty for the other kinds). pub path: Vec<[f64; 3]>, pub at: [f64; 3], + /// A small or sliver face, by body and persistent name: what `geometry.face.absorb` + /// `from_check` absorbs. + pub small: Option<(String, String)>, } /// The findings of the last `geometry.check`, while the design it checked is still open. diff --git a/crates/geometry/src/virtual_faces.rs b/crates/geometry/src/virtual_faces.rs index f0119ba..d6c6896 100644 --- a/crates/geometry/src/virtual_faces.rs +++ b/crates/geometry/src/virtual_faces.rs @@ -37,6 +37,9 @@ pub struct Merge { pub body: String, /// The faces, by persistent name (a name also selects the pieces a partition cut it into). pub faces: Vec, + /// Absorb: the merged face keeps the name of this one of `faces` instead of `merge:` + /// (a tiny face absorbed into a neighbour leaves the neighbour, by its own name). + pub into: Option, } /// The name of the face merge `name` makes. @@ -65,7 +68,13 @@ impl Design { self.errors.push(FeatureError { feature, message: format!("no body `{}`", m.body), bodies: vec![m.body.clone()] }); continue; }; - match body.merge_faces(&merged_face_name(&m.name), &m.faces) { + let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name)); + if m.into.as_ref().is_some_and(|i| !m.faces.contains(i)) { + let message = format!("the face it keeps, `{name}`, is not one it merges"); + self.errors.push(FeatureError { feature, message, bodies: vec![m.body.clone()] }); + continue; + } + match body.merge_faces(&name, &m.faces) { Ok(merged) => *body = merged, Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![m.body.clone()] }), } @@ -105,14 +114,11 @@ impl Shape { } /// This shape with the faces named `faces` (two or more, touching along edges) made one face - /// named `name`, in place of the first of them. + /// named `name` (a new name, or the name of one of them), in place of the first of them. pub fn merge_faces(&self, name: &str, faces: &[String]) -> Result { if faces.len() > MAX_MERGE_FACES { return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces"))); } - if self.faces.iter().any(|f| f.name == name) { - return Err(Error::Invalid(format!("body `{}` already has a face `{name}`", self.name))); - } let mut members = BTreeSet::new(); for f in faces { let found = self.faces_named(f); @@ -121,6 +127,9 @@ impl Shape { } members.extend(found); } + if self.faces.iter().zip(0u32..).any(|(f, i)| f.name == name && !members.contains(&i)) { + return Err(Error::Invalid(format!("body `{}` already has a face `{name}`", self.name))); + } if members.len() < 2 { return Err(Error::Invalid("a merge needs two or more faces".into())); } @@ -156,6 +165,28 @@ impl Shape { Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map }) } + /// The neighbour face `face` shares the longest edges with (by name): where a tiny or sliver + /// face is best absorbed. An error for a face the body lacks or one with no neighbour. + pub fn absorbing_neighbour(&self, face: &str) -> Result { + let own = self.faces_named(face); + let [f] = own.as_slice() else { + return Err(Error::Invalid(format!("body `{}` has no face `{face}` (or several pieces of it: name one)", self.name))); + }; + let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?; + let mut shared: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (points, faces) in mesh.edges.iter().zip(&mesh.edge_faces) { + if !faces.contains(f) { + continue; + } + let length: f64 = points.windows(2).map(|w| (0..3).map(|k| (w[1][k] - w[0][k]).powi(2)).sum::().sqrt()).sum(); + for g in faces.iter().filter(|g| *g != f) { + *shared.entry(*g).or_default() += length; + } + } + let best = shared.into_iter().max_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))).map(|(g, _)| g); + best.map(|g| self.face_label(g)).ok_or_else(|| Error::Invalid(format!("face `{face}` of body `{}` has no neighbour to absorb it", self.name))) + } + /// An error unless `members` touch one another along edges, all in one piece. fn connected(&self, members: &BTreeSet) -> Result<()> { let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?; @@ -335,7 +366,7 @@ mod tests { assert_eq!(hole.len(), 2, "{hole:?}"); let before = d.bodies[0].tessellate(0.01).unwrap(); let body = d.bodies[0].name.clone(); - let d = d.merged(&[Merge { name: "Hole".into(), body, faces: hole.clone() }]).unwrap(); + let d = d.merged(&[Merge { name: "Hole".into(), body, faces: hole.clone(), into: None }]).unwrap(); assert!(d.errors.is_empty(), "{:?}", d.errors); let body = &d.bodies[0]; assert_eq!(body.faces().len(), 7); @@ -375,7 +406,7 @@ mod tests { .map(|(n, _)| n) .collect(); assert_eq!(planes.len(), 2, "top and bottom"); - let m = |name: &str, body: &str, faces: Vec| Merge { name: name.into(), body: body.into(), faces }; + let m = |name: &str, body: &str, faces: Vec| Merge { name: name.into(), body: body.into(), faces, into: None }; let merged = d .clone() .merged(&[ @@ -400,13 +431,13 @@ mod tests { let d = plate(); let body = d.bodies[0].name.clone(); let hole = halves(&d); - let d = d.merged(&[Merge { name: "Hole".into(), body: body.clone(), faces: hole }]).unwrap(); + let d = d.merged(&[Merge { name: "Hole".into(), body: body.clone(), faces: hole, into: None }]).unwrap(); let b = &d.bodies[0]; let f = b.face_index("merge:Hole").unwrap(); let mesh = b.tessellate(0.01).unwrap(); let next = mesh.edge_faces.iter().find(|fs| fs.contains(&f)).and_then(|fs| fs.iter().find(|&&g| g != f)).copied().unwrap(); let next = b.faces()[next as usize].name.clone(); - let d = d.merged(&[Merge { name: "More".into(), body, faces: vec!["merge:Hole".into(), next] }]).unwrap(); + let d = d.merged(&[Merge { name: "More".into(), body, faces: vec!["merge:Hole".into(), next], into: None }]).unwrap(); assert!(d.errors.is_empty(), "{:?}", d.errors); let b = &d.bodies[0]; let f = b.face_index("merge:More").unwrap(); @@ -414,4 +445,21 @@ mod tests { assert_eq!(b.merged_faces(), vec![(f, 3)]); assert_eq!(b.face_surfaces().unwrap()[f as usize], None); } + + /// The neighbour a half of the hole is best absorbed into is the other half (two 10 mm seams, + /// against a 15.7 mm rim arc with each cap); absorbing it keeps the other half's name. + #[test] + fn an_absorbed_face_leaves_its_neighbour_by_name() { + let d = plate(); + let body = d.bodies[0].name.clone(); + let hole = halves(&d); + let best = d.bodies[0].absorbing_neighbour(&hole[0]).unwrap(); + assert_eq!(best, hole[1]); + let d = d.merged(&[Merge { name: "A".into(), body, faces: vec![hole[0].clone(), hole[1].clone()], into: Some(hole[1].clone()) }]).unwrap(); + assert!(d.errors.is_empty(), "{:?}", d.errors); + let b = &d.bodies[0]; + assert_eq!(b.faces().len(), 7); + assert!(b.face_index(&hole[1]).is_some() && b.face_index(&hole[0]).is_none()); + assert!(b.absorbing_neighbour("nope").is_err()); + } } diff --git a/crates/model/src/virtual_faces.rs b/crates/model/src/virtual_faces.rs index f195b93..1262ce1 100644 --- a/crates/model/src/virtual_faces.rs +++ b/crates/model/src/virtual_faces.rs @@ -25,6 +25,9 @@ pub struct FaceMerge { /// Two or more faces by persistent name (a name also stands for the pieces a partition cut /// it into). pub faces: Vec, + /// Absorb (`geometry.face.absorb`): the merged face keeps this one's name (one of `faces`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub into: Option, } impl Model { @@ -59,6 +62,9 @@ impl Model { return Err(invalid(format!("face `{f}` is listed twice"))); } } + if merge.into.as_ref().is_some_and(|i| !merge.faces.contains(i)) { + return Err(invalid("the face it keeps is not one of its faces".into())); + } self.face_merges.push(merge); Ok(()) } @@ -74,7 +80,7 @@ mod tests { use super::*; fn merge(name: &str, faces: &[&str]) -> FaceMerge { - FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect() } + FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None } } /// Merges keep their order, are saved and read back, and bad ones are refused. @@ -89,10 +95,12 @@ mod tests { assert!(m.add_face_merge(merge("Blank", &["x", " "])).is_err()); assert!(m.add_face_merge(merge("9bad", &["x", "y"])).is_err()); assert!(m.add_face_merge(FaceMerge { body: String::new(), ..merge("NoBody", &["x", "y"]) }).is_err()); + assert!(m.add_face_merge(FaceMerge { into: Some("z".into()), ..merge("Into", &["x", "y"]) }).is_err()); + m.add_face_merge(FaceMerge { into: Some("y".into()), ..merge("Absorb", &["x", "y"]) }).unwrap(); let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); assert_eq!(back.face_merges(), m.face_merges()); assert_eq!(m.remove_face_merge("Fillet").unwrap().name, "Fillet"); assert!(m.remove_face_merge("Fillet").is_err()); - assert_eq!(m.face_merges().len(), 1); + assert_eq!(m.face_merges().len(), 2); } } diff --git a/docs/features.md b/docs/features.md index eeef909..facd535 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -79 ready, 38 coming soon. +80 ready, 38 coming soon. ## Geometry @@ -23,6 +23,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Prepare | Datum Point — A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane | ready | `geometry.datum.point`, (`geometry.datum.point.edit`) | | Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`), (`geometry.partition.suppress`) | | Prepare | Merge Faces — Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged | ready | `geometry.faces.merge`, (`geometry.faces.merge.delete`) | +| Prepare | Absorb Faces — Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found | ready | `geometry.face.absorb` | | Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | | ## Mesh