From 54e367970fd4171db1219f3112824f535ea523ac Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:51:42 -0400 Subject: [PATCH] engine: geometry.face.split, a face split along a datum plane (#508) geometry.face.split {name, face, datum} records a face split; it is applied after the merges whenever the design is opened or cut again, follows its datum (which it holds), undoes, and is saved with the model. geometry.face.split.delete removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/geometry.face.split.svg | 10 ++ assets/icons/geometry.face.split.vectorcraft | Bin 0 -> 1822 bytes assets/icons/src/geometry.face.split.json | 1 + crates/engine/src/catalogue.rs | 8 ++ crates/engine/src/cmd/datum.rs | 15 ++- crates/engine/src/cmd/forms.rs | 6 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/virtual_faces.rs | 125 ++++++++++++++++++- crates/engine/src/requirement.rs | 7 ++ crates/engine/src/schema.rs | 4 + docs/features.md | 3 +- 12 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 assets/icons/geometry.face.split.svg create mode 100644 assets/icons/geometry.face.split.vectorcraft create mode 100644 assets/icons/src/geometry.face.split.json diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6fe09c8..2a9e866 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -67,6 +67,7 @@ is committed and listed here with its exports. | 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.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.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 | diff --git a/assets/icons/geometry.face.split.svg b/assets/icons/geometry.face.split.svg new file mode 100644 index 0000000..416be76 --- /dev/null +++ b/assets/icons/geometry.face.split.svg @@ -0,0 +1,10 @@ + + + geometry.face.split + + + + + + + diff --git a/assets/icons/geometry.face.split.vectorcraft b/assets/icons/geometry.face.split.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..5040354a72b50296e4076ffbfbee88c37bac8d5e GIT binary patch literal 1822 zcmV+(2jTc1iwFP!00000|K(b3Z`(Ey{wsz)8-d;>2k3_`E4rfHTBJjZp$H6GqHQj+ zs8Lc9FYtfg9VyyY;-nPq3iA7Sp$Il(_jz``*b1qh-an79^nl)Txg3KFd3{RqF zymZdPv9q8h6$CnA)E7DG0!NM9v&oqYV{@LhD_X)N_q+GJvD2`opzzLu@)d2w`fN@z zdRA8jLkVrkni_gPu{&DS(2((hi|LBz_|D~G22DgXGfqSf-roa)Bp1*jB+CM+ z@{%}A+!(ZtPk|eysUN`O3?IW_;(JlD*=)vJS#=}$HMNwM#`u$T($X~clQ8OpO*$wb z%(-QGvjnowpfe*C8U}Amoc(Xk%I)LkYV(Nwioly=^$%L>c7`7nT|nTHcgloGzXf0) z>6v?lUk{%>9~#2F2H!h00{>j0ZDa+nX|5CpSR_rTvA|YiB`>M-v#^cNUi^?;-~BG# zYHf}mOL#niRA5iYPNk1#+|ywAV`y*^dZ4GM(_kW3XV_`bpSa#6_VyHr-~J^Pn1F$J zJ@v%U5xvQ5%IYw3@5kz`y=NzYV)?jz8bk4J`Sx$=WF!5%mk?6y-uCeIs!)fORA>nV(GU(KuK3mDi6pm_>2_0 zcwO;k4_G(L*Cp75m0r;&phD&Unf$*omHJCTvTI9gayv6}I|DM8dP#FzpW;ieqQiox zY}W4(c+%s9sWFg`upk$7bOfF-LI|NwK_bL=Bph2Pv5t`vN~Cim1!XT{8bNuzn#nxS z3P~CUUZ8apD_5@_Klb=hWDMjlS`_@&8VMwXk@k0!K>p|$fuBt2vCLlvxzfgHn#@2f?#O%vpbK9DCEA$hZcU0;zO>Cqyv zP#{RBo4%sM+-Cozq&N~n==N*_b~3oU&0Wdse_CR@tJ*G;LmKHEo-vS%H7So;GMbTC zdN=JyVo69a2!<^mvDAgHadg(u0qJ%o=8E~Z74%r+kp9)z>op0ay$%Tatt|pihFec> zxxI-rOr1jNbz_X=U7525x5c9N`;j(OwPJ!qYbNNx8Ke2actIOWG#Z81bER_CCgy~E zYDHC8JM~^U#FK8-OSecQKjsVfcLFuE#@30%;I=_v@*qbH5Mg9)s+W+4+lE?|X~a)i zhf3AVHN#5VMEPOT+BcCfkuKGI^$LD#>&g>JU=&TS3-%D1E3%{r=U&9pddlFKcMOmIiC=%HO|At)-*`q$Ji-k^xd;Ybo&n zDUr35Xn>T^T1q%TN?5MtvT5C{i@x>#O6R04P-Ak$=rfLRUhYEB!Gq9Kot+;%?c5%e zyE2G(cdYQ1*mTD4y1O+GPFqJG+eTj|_yO(&n0_z+neX)*B{%EQJ6Rxm!P{QO_1^!3 zn{bbt;2CakgRwkD-KRToJ>A6~!$a@g)t)U?;UlUrqg}6Nyg)M0pR_Ee`m7**1{W{NY$oBfW> Mf6oslPf{uX02};-`v3p{ literal 0 HcmV?d00001 diff --git a/assets/icons/src/geometry.face.split.json b/assets/icons/src/geometry.face.split.json new file mode 100644 index 0000000..915de3e --- /dev/null +++ b/assets/icons/src/geometry.face.split.json @@ -0,0 +1 @@ +{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 10, 16.5, 6], "stroke": "warm", "width": 2}, {"d": "M5 4.5 L9 2.5 M15 9.5 L19 7.5", "stroke": "accent", "width": 1.5, "dash": [1.5, 1.5]}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 18a76f3..ddc334f 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -309,6 +309,14 @@ pub static CATALOGUE: &[Capability] = &[ "Prepare", "Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found", ), + covering( + "geometry.face.split", + "Split Face", + Geometry, + "Prepare", + "Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them", + &["geometry.face.split.delete"], + ), covering( "geometry.stitch", "Stitch", diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 2192268..9329d9e 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -470,15 +470,24 @@ fn cuts(model: &Model) -> Vec { .collect() } -/// `design` cut by `model`'s partitions, then with its face merges (the design as opened when -/// the model has neither). +/// `design` cut by `model`'s partitions, then with its face merges and its face splits (those +/// along a suppressed datum left out; the design as opened when the model has none). 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(), into: m.into.clone() }) .collect(); - Ok(design.partitioned(&cuts(model))?.merged(&merges)?) + let splits: Vec = model + .face_splits() + .iter() + .filter(|s| !model.datum_suppressed(&s.datum)) + .filter_map(|s| { + let (point, normal) = model.datum(&s.datum)?.plane()?; + Some(fieldcraft_geometry::face_split::FaceSplit { name: s.name.clone(), body: s.body.clone(), face: s.face.clone(), point, normal }) + }) + .collect(); + Ok(design.partitioned(&cuts(model))?.merged(&merges)?.split(&splits)?) } /// Cut the session's design by its model's partitions again (and merge its faces again). The bodies that are gone (cells diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 83b77b4..a03bbe1 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1740,6 +1740,12 @@ pub const STITCH: &[Field] = &[ .req(), ]; pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch); +pub const FACE_SPLIT: &[Field] = &[ + NAME, + group("face", "Face", FACE_ROW).help("The face to split.").req(), + reference("datum", "Datum plane", C::DatumPlane).help("The plane the face is split along.").req(), +]; +pub const FACE_SPLIT_DELETE: &[Field] = existing!("Face split", C::FaceSplit); 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/mod.rs b/crates/engine/src/cmd/mod.rs index 97bca8a..2344213 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -76,6 +76,8 @@ pub static ALL: &[CommandSpec] = &[ virtual_faces::FACES_MERGE, virtual_faces::FACES_MERGE_DELETE, virtual_faces::FACE_ABSORB, + virtual_faces::FACE_SPLIT, + virtual_faces::FACE_SPLIT_DELETE, stitch::STITCH, stitch::STITCH_DELETE, mesh::NODE_ADD, diff --git a/crates/engine/src/cmd/virtual_faces.rs b/crates/engine/src/cmd/virtual_faces.rs index 77f88c7..6d45326 100644 --- a/crates/engine/src/cmd/virtual_faces.rs +++ b/crates/engine/src/cmd/virtual_faces.rs @@ -45,6 +45,26 @@ pub const FACE_ABSORB: CommandSpec = CommandSpec { run: absorb, }; +pub const FACE_SPLIT: CommandSpec = CommandSpec { + id: "geometry.face.split", + label: "Split Face", + params: r#"{"name": "Band", "face": {"body": "Base", "face": "F1:+z"}, "datum": "Mid" (a datum plane)} — splits a face along the curve where a datum plane crosses it, for meshing only (virtual topology; partitions cut whole bodies): the meshers, the checks and the viewport take it as two faces on its surface, `split::a` on the plane's negative side and `split::b` on the other, with the cut as an edge between them (a row of nodes along it; seed it like any edge), and the face's edges cut where the plane crosses them. The B-rep is not changed. Applied after the face merges, by persistent names, whenever the design is opened (a merge cannot name a split face); moving the datum moves the cut, and the datum cannot be deleted while a split uses it. The body loses its mesh (`replaced`); sets naming the face no longer resolve (`stale_sets`). A plane that does not cross the face is an error. Delete with geometry.face.split.delete. One undo step"#, + form: Some(super::forms::FACE_SPLIT), + kind: CommandKind::Model, + requires: &[Requirement::Design, Requirement::DatumPlane], + run: split, +}; + +pub const FACE_SPLIT_DELETE: CommandSpec = CommandSpec { + id: "geometry.face.split.delete", + label: "Delete Face Split", + params: r#"{"name": "Band"} — removes a face split: the face is one face again; the body loses its mesh (`replaced`)"#, + form: Some(super::forms::FACE_SPLIT_DELETE), + kind: CommandKind::Model, + requires: &[Requirement::FaceSplit], + run: split_delete, +}; + /// Most faces one merge lists. const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES; @@ -75,12 +95,12 @@ 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 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 { +/// Apply the model's partitions, merges and splits to the design again, failing with the error of +/// one of `features` (`merge `, `split `) when it has one; the meshes of `bodies` go +/// (their faces changed). +fn reapply(s: &mut Session, features: &[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 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() { @@ -110,7 +130,7 @@ fn merge(s: &mut Session, p: &Value) -> Result { 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, std::slice::from_ref(&name), std::slice::from_ref(&body), reply)?; + let mut reply = reapply(s, &[format!("merge {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() { @@ -200,7 +220,29 @@ fn absorb(s: &mut Session, p: &Value) -> Result { 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})) + let features: Vec = merges.iter().map(|m| format!("merge {m}")).collect(); + reapply(s, &features, &bodies, json!({"absorbed": absorbed, "skipped": skipped})) +} + +fn split(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?.to_string(); + let r: GeometryRef = super::req(p, "face")?; + let Entity::Face(face) = r.entity else { return Err(EngineError::invalid("face", "give {\"body\", \"face\"}")) }; + let datum: String = super::req(p, "datum")?; + super::datum::datum_plane_of(&s.model, &datum)?; + s.model.add_face_split(fieldcraft_model::FaceSplit { name: name.clone(), body: r.body.clone(), face: face.clone(), datum })?; + let feature = format!("split:{name}"); + let faces = [format!("{feature}:a"), format!("{feature}:b")]; + let reply = json!({"name": name, "body": r.body, "faces": faces}); + reapply(s, &[format!("split {name}")], std::slice::from_ref(&r.body), reply) +} + +fn split_delete(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?; + let removed = s.model.remove_face_split(name)?; + let mut cleared = super::datum::recut(s)?; + cleared.extend(super::replace::clear(&mut s.model, [removed.body.as_str()])); + Ok(cleared.report_if_any(s, json!({"deleted": name, "body": removed.body}))) } fn delete(s: &mut Session, p: &Value) -> Result { @@ -507,4 +549,75 @@ mod tests { assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}"); assert!(s.model.face_merges()[0].into.is_some()); } + + /// The plate's top face (z = 10), by name. + fn top(s: &Session) -> String { + let b = &s.design.as_ref().unwrap().bodies[0]; + b.faces() + .iter() + .zip(b.face_surfaces().unwrap()) + .find(|(_, x)| matches!(x, Some(FaceSurface::Plane { normal, point }) if normal[2] > 0.5 && point[2] > 9.0)) + .unwrap() + .0 + .name + .clone() + } + + /// Mesh nodes on the top along x = `x`. + fn nodes_along(s: &Session, x: f64) -> usize { + s.model.nodes().values().filter(|n| (n.xyz[0] - x).abs() < 1e-6 && (n.xyz[2] - 10.0).abs() < 1e-6).count() + } + + /// Splitting the top along a datum plane gives two faces the tet mesher tags, with a row of + /// nodes along the cut; the split follows the datum, holds it, is saved and replayed, and + /// undoes. + #[test] + fn a_split_face_meshes_with_a_node_row_along_the_cut() { + let (mut s, dir) = super::super::datum::tests::plate(); + let body = s.design.as_ref().unwrap().bodies[0].name.clone(); + let face = json!({"body": body, "face": top(&s)}); + s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 30.3}})).unwrap(); + let mesh = json!({"size": 3}); + s.execute("mesh.tet.generate", &mesh).unwrap(); + let before = nodes_along(&s, 30.3); + let r = s.execute("geometry.face.split", &json!({"name": "Band", "face": face, "datum": "Cut"})).unwrap(); + assert_eq!(r["faces"], json!(["split:Band:a", "split:Band:b"]), "{r}"); + assert!(r["replaced"].is_array(), "{r}"); + assert_eq!(faces(&s).len(), 9); + s.execute("mesh.tet.generate", &mesh).unwrap(); + let after = nodes_along(&s, 30.3); + assert!(after >= 10 && after > before, "a node row along the 30 mm cut: {before} -> {after}"); + 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("split:Band:a") && used.contains("split:Band:b"), "{used:?}"); + // The datum holds; moving it moves the cut. + assert!(s.execute("geometry.datum.delete", &json!({"name": "Cut"})).is_err()); + s.execute("geometry.datum.edit", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 35.5}})).unwrap(); + s.execute("mesh.tet.generate", &mesh).unwrap(); + assert!(nodes_along(&s, 35.5) >= 10); + // Saved and replayed. + let file = dir.join("split.fieldcraft"); + s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap(); + assert_eq!(faces(&t), faces(&s)); + // Bad splits change nothing; delete brings the face back. + let journal = s.journal.len(); + for p in [ + json!({"name": "X", "face": face, "datum": "nope"}), + json!({"name": "X", "face": {"body": body, "face": "nope"}, "datum": "Cut"}), + json!({"name": "X", "face": {"body": body}, "datum": "Cut"}), + json!({"name": "Band", "face": face, "datum": "Cut"}), + json!({"name": "X", "face": {"body": body, "face": "split:Band:b"}, "datum": "Cut"}), + ] { + assert!(s.execute("geometry.face.split", &p).is_err(), "{p}"); + } + assert_eq!(s.journal.len(), journal); + s.execute("geometry.face.split.delete", &json!({"name": "Band"})).unwrap(); + assert_eq!(faces(&s).len(), 8); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(faces(&s).len(), 9); + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs index ae01b69..25c7ee4 100644 --- a/crates/engine/src/requirement.rs +++ b/crates/engine/src/requirement.rs @@ -36,6 +36,8 @@ pub enum Requirement { FaceMerge, /// A stitch. Stitch, + /// A face split. + FaceSplit, /// An edge seed. Seed, /// At least one mesh node. @@ -85,6 +87,7 @@ impl Requirement { Requirement::Partition => !m.partitions().is_empty(), Requirement::FaceMerge => !m.face_merges().is_empty(), Requirement::Stitch => !m.stitches().is_empty(), + Requirement::FaceSplit => !m.face_splits().is_empty(), Requirement::Seed => !m.seeds().is_empty(), Requirement::Nodes => !m.nodes().is_empty(), Requirement::Mesh => has_elements, @@ -127,6 +130,7 @@ impl Requirement { Requirement::Partition => "a partition", Requirement::FaceMerge => "a face merge", Requirement::Stitch => "a stitch", + Requirement::FaceSplit => "a face split", Requirement::Seed => "an edge seed", Requirement::Nodes => "mesh nodes", Requirement::Mesh | Requirement::Analysable => "a mesh", @@ -159,6 +163,7 @@ impl Requirement { Requirement::Partition => "geometry.partition", Requirement::FaceMerge => "geometry.faces.merge", Requirement::Stitch => "geometry.stitch", + Requirement::FaceSplit => "geometry.face.split", Requirement::Seed => "mesh.seed.edge", Requirement::Nodes => "mesh.node.add", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", @@ -218,6 +223,7 @@ impl crate::schema::Collection { C::Partition => Requirement::Partition, C::FaceMerge => Requirement::FaceMerge, C::Stitch => Requirement::Stitch, + C::FaceSplit => Requirement::FaceSplit, } } } @@ -360,6 +366,7 @@ mod tests { C::Partition, C::FaceMerge, C::Stitch, + C::FaceSplit, ]; let empty = Session::default(); for c in all { diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index adb292b..5a6d9ce 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -221,6 +221,8 @@ pub enum Collection { FaceMerge, /// A stitch (`geometry.stitch`). Stitch, + /// A face split (`geometry.face.split`). + FaceSplit, } impl Collection { @@ -248,6 +250,7 @@ impl Collection { Collection::Partition => "partition", Collection::FaceMerge => "face merge", Collection::Stitch => "stitch", + Collection::FaceSplit => "face split", } } } @@ -279,6 +282,7 @@ impl Session { Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())), Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())), Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())), + Collection::FaceSplit => Box::new(m.face_splits().iter().map(|f| f.name.as_str())), }; names.take(MAX_NAMES).map(str::to_string).collect() } diff --git a/docs/features.md b/docs/features.md index 279975a..b5e46c1 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. -81 ready, 37 coming soon. +82 ready, 37 coming soon. ## Geometry @@ -24,6 +24,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | 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 | Split Face — Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them | ready | `geometry.face.split`, (`geometry.face.split.delete`) | | Prepare | Stitch — Close an imported surface's gaps under a tolerance: open points welded, T-junctions split | ready | `geometry.stitch`, (`geometry.stitch.delete`) | | Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |