From f297ffca3ae54ba671bc9e1d44e8df0a13d617aa Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:48:27 -0400 Subject: [PATCH] engine: geometry.defeature takes features out of the design for meshing (#433) Suppresses the SolidCraft features named (holes filled, fillets and chamfers back to their sharp edges, bosses removed) and rebuilds the design; a feature that cannot be suppressed alone is merged into its neighbour for meshing only. Kept in the model, replayed on open, undoable; geometry.defeature.delete turns them back on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/geometry.defeature.svg | 10 + assets/icons/geometry.defeature.vectorcraft | Bin 0 -> 1851 bytes assets/icons/src/geometry.defeature.json | 1 + crates/engine/src/catalogue.rs | 8 + crates/engine/src/cmd/datum.rs | 19 +- crates/engine/src/cmd/defeature.rs | 228 ++++++++++++++++++++ crates/engine/src/cmd/forms.rs | 7 + crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/virtual_faces.rs | 2 +- crates/engine/src/requirement.rs | 7 + crates/engine/src/schema.rs | 4 + docs/features.md | 3 +- 13 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 assets/icons/geometry.defeature.svg create mode 100644 assets/icons/geometry.defeature.vectorcraft create mode 100644 assets/icons/src/geometry.defeature.json create mode 100644 crates/engine/src/cmd/defeature.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2d8656f..d5f4fad 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -68,6 +68,7 @@ is committed and listed here with its exports. | 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.defeature.svg, assets/icons/geometry.defeature.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.defeature.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.defeature.svg b/assets/icons/geometry.defeature.svg new file mode 100644 index 0000000..7436566 --- /dev/null +++ b/assets/icons/geometry.defeature.svg @@ -0,0 +1,10 @@ + + + geometry.defeature + + + + + + + diff --git a/assets/icons/geometry.defeature.vectorcraft b/assets/icons/geometry.defeature.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..f1b7e3b45f90ea81c7609c64698df2330d99e04c GIT binary patch literal 1851 zcmV-B2gLXviwFP!00000|K(b3Z`(Ey{wsz)8-_@|2k3_`E4rfHS|me@p$H6GqHQg* zsOhDN7x=&L_~ux#lTx%j6lj3f;){5Xo;x0oM?N@nRF-H)CnHq9qLS@c14YJ8j+;6y8}-wxVslo@8`R zN!!&FUh2xcZJ_Z}eou=ADpFRkdb(m6f8}a1gDO(DGe+tR`h9(H@=T0ck`?dqTD6T3 zbgTgtmwxX$7w*IzJ8S)LkDLqaiz@ZB89U9A-Az+KOhF*2Z=H)dDH^I9YSuKSlEHWYAj2rXSz1!?J4bXDhNOJ^6_#QL?V)soq1jqAh062RE7f5umaxS0m4ZxprIBl z*PJR8g?{9Qpln|9+#rsz2gMmL`d)%j5N$S_@m5yT*6fyAN{d7O7b$6R9OB3il(0lW z0b$Nvp0!ILdjgFqskmXVuH>Wt&GK?v+}v#T*sln@IhH?A+jKMjDCq(MmrN-W2L0-R zeZ&*@hJPL}BMcS(P6bAXdhq{lxNW2bYiK4F2UrA6Tw|WC#!6OF=VxJ?mr?kXT0;Df-);5Z6!;KgnNjjU_8onsvPI6~Nu{1EX*#me=HF_73Zm1Gj=p%Hj9 z-$Q;71#aL*Ay$oYXyi@8AdCYa`-z`mFIKJ3j2-l0tMVQ)HFmK7Rp@2h;STOSWZ^FE zy{`%n;eNq41wV#w^4uhLy~I;?86x42v~!Y;J-dv~>zpERehBTl9w2rcJnJXmAJjK@ zq)nGp4x{3NuYUkhH_s9XA6&2*DIQ~winTjFO^xzp33{>OR!rfA`kjg2jj7aM3XMX6^OUsATS2<7cB~QXN?4c+d%uA z#1nsdF+|Fi20|IVttkZSmJwRdT;hl`WsT=UzOprzz!zH4%tSq7f%Q#94aPu@VFY72 zX}0l-n9&CU79%8YR9IXq(^mupfUHDxp-q3~|nkA#8{Zx9SyK0=`jtZ^u7P(ai!%Um)4%CyK) z#v%MG*7G&;guM<}^gCMwNG!KVrw>pf3{!`3dfpf#c~@rng4tq`xr;y>s+=)iq&4Fy zaK>mp=d7TuB^r$a^<1f#wUId?pSrp#tetu@_NnFzoV7F6>Ij6Vz}rPd25RQ+oXzLd)-s;N>^er2W-&Bozg#kqMOy29u_j_6 zLyg|5FT*r*FxRBCjeVk?^XPBSeLT=cFBxPUY_|_#Pd$Dwi|NTg$lQxvb&ah@e-a~m z(tl_}(Q&IWk}sqvJL}aY=x-?%*S~2 zTq!)kdMAM#mA8s}>31D$B}T_Z;;0lXzgp($v}xBx-+F(g zbJ7)DW1`3Cn)h(dchTbDLFlQ@uMeJfK3<{_i@3Tk#&5h$XY5{`#XmW19ewQD0zw?; z$2gZa{Vx2%cl}1mxAo|qNRXZJ=9+Q7_y6F=-{Ho4fg8TTSRSM9)1A1U?uL=!p|_$6 z?@;CMQN<(L^=ifnP6qcUU7k_>T<79`N5iA%5~M}^@(jP&k>%fVe*Y9Y#xQhE8h*!< pLRK(sn`u?$x^KRMJT~-tfLN03aI@R6`43JGEmR#V005=Mgr@)i literal 0 HcmV?d00001 diff --git a/assets/icons/src/geometry.defeature.json b/assets/icons/src/geometry.defeature.json new file mode 100644 index 0000000..2d17cf7 --- /dev/null +++ b/assets/icons/src/geometry.defeature.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"}, {"d": "M9 8 A3 1.5 0 1 0 15 8 A3 1.5 0 1 0 9 8 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M7.5 11 L16.5 5", "stroke": "accent", "width": 1.75}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 59c33fa..de9737c 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -317,6 +317,14 @@ pub static CATALOGUE: &[Capability] = &[ "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.defeature", + "Defeature", + Geometry, + "Prepare", + "Take small holes, fillets, chamfers and bosses out for meshing: the design rebuilt without their features", + &["geometry.defeature.delete"], + ), covering( "geometry.stitch", "Stitch", diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 9329d9e..f28387b 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -470,14 +470,19 @@ fn cuts(model: &Model) -> Vec { .collect() } -/// `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). +/// `design` without the features `model` defeatures, cut by its partitions, then with its face +/// merges (after those of the features defeatured virtually) 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(); + let defeatures: Vec = + model.defeatures().iter().map(|d| fieldcraft_geometry::defeature::Defeature { name: d.name.clone(), features: d.features.clone() }).collect(); + let (design, mut merges) = design.defeatured(&defeatures)?; + merges.extend(model.face_merges().iter().map(|m| Merge { + name: m.name.clone(), + body: m.body.clone(), + faces: m.faces.clone(), + into: m.into.clone(), + })); let splits: Vec = model .face_splits() .iter() diff --git a/crates/engine/src/cmd/defeature.rs b/crates/engine/src/cmd/defeature.rs new file mode 100644 index 0000000..06de212 --- /dev/null +++ b/crates/engine/src/cmd/defeature.rs @@ -0,0 +1,228 @@ +//! Defeaturing for meshing: `geometry.defeature` takes holes, fillets, chamfers and small bosses +//! out of the open design by suppressing their SolidCraft features (virtually, as a face merge, +//! when a feature cannot be suppressed on its own). +//! +//! A defeature is part of the model (`fieldcraft_model::Defeature`, by feature id); the geometry +//! layer applies it to the design as opened (`fieldcraft_geometry::defeature`), before the +//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the +//! meshers, the viewport and the checks all see the part without them. + +use std::collections::BTreeSet; + +use fieldcraft_model::Defeature; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::requirement::Requirement; +use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; + +pub const DEFEATURE: CommandSpec = CommandSpec { + id: "geometry.defeature", + label: "Defeature", + params: r#"{"name": "Small", "features": [3, "F7"] (feature ids, as geometry.features lists them; or the text "3, F7")} — takes features of the open design out for meshing: the design is rebuilt without them, as SolidCraft builds it with them suppressed (a hole filled, a fillet or chamfer back to its sharp edge, a boss gone), and the other faces keep their persistent names. A feature that cannot be suppressed alone (a later feature needs it, or a body would go) is defeatured virtually instead: its faces are merged into the neighbour they share the longest edges with, for meshing only (virtual topology; the B-rep keeps it). A feature that builds a body or a sketch, or one the design lacks, is an error. Kept in the model by feature id and applied again whenever the design is opened (before the partitions); the same name again replaces its features. A suppressed feature affects every body it shaped. The bodies lose their meshes (`replaced`; remesh them); sets naming their faces are `stale_sets`. Replies `suppressed` (feature ids), `virtual` ([{feature, reason}]) and `bodies`. One undo step; geometry.defeature.delete turns them back on"#, + form: Some(super::forms::DEFEATURE), + kind: CommandKind::Model, + requires: &[Requirement::Design], + run: defeature, +}; + +pub const DEFEATURE_DELETE: CommandSpec = CommandSpec { + id: "geometry.defeature.delete", + label: "Delete Defeature", + params: r#"{"name": "Small"} — turns a defeature's features back on: the design is rebuilt with them; the bodies they shaped lose their meshes (`replaced`)"#, + form: Some(super::forms::DEFEATURE_DELETE), + kind: CommandKind::Model, + requires: &[Requirement::Defeature], + run: delete, +}; + +/// A feature id: `3` or `"F3"`. +#[derive(Deserialize)] +#[serde(untagged)] +enum FeatureId { + Id(u64), + Name(String), +} + +/// The feature ids of `features`: a list, or the form's text (`3, F7`). +fn ids(p: &Value) -> Result> { + let given: Vec = match p.get("features") { + Some(Value::String(t)) if t.len() <= 64 * 1024 => { + t.split([',', ' ', ';']).filter(|w| !w.is_empty()).map(|w| FeatureId::Name(w.to_string())).collect() + } + _ => super::req(p, "features")?, + }; + if given.is_empty() || given.len() > fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES { + return Err(EngineError::invalid("features", format!("1 to {} feature ids", fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES))); + } + let mut out = Vec::with_capacity(given.len()); + for f in given { + let id = match f { + FeatureId::Id(id) => Some(id), + FeatureId::Name(n) => n.strip_prefix('F').unwrap_or(&n).parse().ok(), + }; + match id { + Some(id) if !out.contains(&id) => out.push(id), + Some(_) => {} + None => return Err(EngineError::invalid("features", "feature ids: 3 or \"F3\"")), + } + } + Ok(out) +} + +/// The bodies of the open design (as opened) that features `ids` shaped. +fn shaped(s: &Session, ids: &BTreeSet) -> Vec { + let Some(design) = s.design.as_deref() else { return Vec::new() }; + let graph = design.undefeatured().features(None).unwrap_or_default(); + let sources: Vec = graph.into_iter().filter(|b| b.features.iter().any(|f| ids.contains(&f.id))).map(|b| b.body).collect(); + // A body the partitions cut: each of its cells. + design.bodies.iter().map(|b| b.name.clone()).filter(|n| design.source_of(n).is_some_and(|src| sources.iter().any(|x| x == src))).collect() +} + +/// What the design's defeatures did with the features of `name`, as the reply lists them. +fn outcome(s: &Session, name: &str) -> (Vec, Vec) { + let report = s.design.as_deref().map(|d| d.defeat_report().to_vec()).unwrap_or_default(); + let mine = report.into_iter().filter(|r| r.defeature == name); + let (mut suppressed, mut virt) = (Vec::new(), Vec::new()); + for r in mine { + if r.method == "suppressed" { + suppressed.push(r.feature); + } else { + virt.push(json!({"feature": r.feature, "reason": r.reason})); + } + } + (suppressed, virt) +} + +fn defeature(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?.to_string(); + let features = ids(p)?; + let before: BTreeSet = s.model.defeatures().iter().filter(|d| d.name == name).flat_map(|d| d.features.clone()).collect(); + s.model.set_defeature(Defeature { name: name.clone(), features: features.clone() })?; + let mut touched = before; + touched.extend(&features); + let bodies = shaped(s, &touched); + let merges: Vec = features.iter().map(|id| format!("merge {name}-F{id}")).collect(); + let mut errors = vec![format!("defeature {name}")]; + errors.extend(merges); + let reply = super::virtual_faces::reapply(s, &errors, &bodies, json!({"name": name}))?; + let (suppressed, virt) = outcome(s, &name); + let bodies = shaped(s, &touched); + let mut reply = reply; + if let Some(o) = reply.as_object_mut() { + o.insert("suppressed".into(), json!(suppressed)); + o.insert("virtual".into(), json!(virt)); + o.insert("bodies".into(), json!(bodies)); + } + Ok(reply) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?; + let removed = s.model.remove_defeature(name)?; + let bodies = shaped(s, &removed.features.iter().copied().collect()); + let mut cleared = super::datum::recut(s)?; + cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str))); + Ok(cleared.report_if_any(s, json!({"deleted": name, "features": removed.features, "bodies": bodies}))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn faces(s: &Session) -> usize { + s.design.as_ref().unwrap().bodies[0].faces().len() + } + + fn hole(s: &mut Session) -> u64 { + let r = s.execute("geometry.features", &json!({})).unwrap(); + r["bodies"][0]["features"].as_array().unwrap().iter().find(|f| f["operation"] == "cut").unwrap()["id"].as_u64().unwrap() + } + + /// The sample plate: suppressing its hole gives a block meshed + /// without it (tet and hex), undo and redo take it out and put it back, it is saved and + /// replayed on open, and deleting the defeature brings the hole back. + #[test] + fn the_plate_hole_is_suppressed_meshed_undone_saved_and_restored() { + let (mut s, dir) = super::super::datum::tests::plate(); + let body = s.design.as_ref().unwrap().bodies[0].name.clone(); + assert_eq!(faces(&s), 8); + let id = hole(&mut s); + s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap(); + let with_hole = s.model.elements().len(); + let r = s.execute("geometry.defeature", &json!({"name": "Holes", "features": [id]})).unwrap(); + assert_eq!(r["suppressed"], json!([id]), "{r}"); + assert_eq!(r["replaced"][0]["body"].as_str(), Some(body.as_str()), "{r}"); + assert_eq!(faces(&s), 6); + // Meshed without it: a tet mesh fills the whole block, and the block sweeps into hexes. + s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap(); + let volume = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume; + assert!((volume(&s) - 40.0 * 30.0 * 10.0).abs() < 1e-6, "{}", volume(&s)); + assert_ne!(s.model.elements().len(), with_hole); + s.execute("mesh.hex.sweep", &json!({"body": body, "size": 5})).unwrap(); + assert!(s.model.elements().values().all(|e| e.nodes.len() == 8), "a block sweeps into hexes only"); + // Undo the meshes and the defeature: the hole is back; redo takes it out again. + while !s.model.defeatures().is_empty() { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(faces(&s), 8); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(faces(&s), 6); + // Saved, and replayed on open of the model and on a re-open of the design. + let file = dir.join("defeatured.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!((t.model.defeatures().len(), faces(&t)), (1, 6)); + let design = s.sources.design.as_ref().unwrap().path().to_string(); + s.execute("geometry.open", &json!({"path": design})).unwrap(); + assert_eq!(faces(&s), 6); + // Switched back on. + let r = s.execute("geometry.defeature.delete", &json!({"name": "Holes"})).unwrap(); + assert_eq!(r["features"], json!([id]), "{r}"); + assert_eq!(faces(&s), 8); + std::fs::remove_dir_all(dir).ok(); + } + + /// Unknown features, body-building ones and bad ids are refused and leave the model as it was. + #[test] + fn bad_features_are_refused() { + let (mut s, dir) = super::super::datum::tests::plate(); + for features in [json!([999]), json!(["Fx"]), json!([]), json!("F1, x"), json!(7)] { + assert!(s.execute("geometry.defeature", &json!({"name": "Bad", "features": features})).is_err(), "{features}"); + } + let body = s.design.as_ref().unwrap().bodies[0].name.clone(); + let base = s.execute("geometry.features", &json!({"body": body})).unwrap()["bodies"][0]["features"][0]["id"].as_u64().unwrap(); + let e = s.execute("geometry.defeature", &json!({"name": "Bad", "features": [base]})).unwrap_err(); + assert!(e.to_string().contains("builds a body"), "{e}"); + assert!(s.model.defeatures().is_empty() && faces(&s) == 8); + std::fs::remove_dir_all(dir).ok(); + } + + /// The bracket's 6 mm fillet is defeatured back to a sharp corner and + /// meshed; its holes stay. + #[test] + fn a_bracket_is_meshed_with_its_small_fillet_defeatured() { + let (_dir, mut s) = super::super::datum::tests::scripted(json!([ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}}, + {"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}}, + {"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}}, + {"command": "solid.hole", "params": {"position": [8, 32, 20], "diameter": 8}} + ])); + let g = s.execute("geometry.features", &json!({"body": "Bracket"})).unwrap(); + let fillet: Vec = + g["bodies"][0]["features"].as_array().unwrap().iter().filter(|f| f["type"] == "fillet").map(|f| f["id"].clone()).collect(); + assert_eq!(fillet.len(), 1); + let before = faces(&s); + let r = s.execute("geometry.defeature", &json!({"name": "Fillets", "features": fillet})).unwrap(); + assert_eq!(r["suppressed"], json!(fillet), "{r}"); + assert_eq!(faces(&s), before - 1); + let r = s.execute("mesh.tet.generate", &json!({"body": "Bracket", "size": 4})).unwrap(); + assert!(r["elements"].as_u64().unwrap_or(0) > 0, "{r}"); + } +} diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 3f7d7f1..233e604 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1780,6 +1780,13 @@ pub const FACE_SPLIT: &[Field] = &[ ]; pub const FACE_SPLIT_DELETE: &[Field] = existing!("Face split", C::FaceSplit); pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge); +pub const DEFEATURE: &[Field] = &[ + NAME, + text("features", "Features") + .help("The feature ids to take out for meshing (3 or F3), as the feature graph (geometry.features) lists them; separate them with commas.") + .req(), +]; +pub const DEFEATURE_DELETE: &[Field] = existing!("Defeature", C::Defeature); 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(), text("name", "Name").help("The name the plan is saved under.").hint("Plan-"), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 707e653..d9736f6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ mod cells; mod contact; pub(crate) mod datum; pub(crate) mod deck; +mod defeature; pub(crate) mod define; pub mod diagnostics; mod edit; @@ -79,6 +80,8 @@ pub static ALL: &[CommandSpec] = &[ virtual_faces::FACE_ABSORB, virtual_faces::FACE_SPLIT, virtual_faces::FACE_SPLIT_DELETE, + defeature::DEFEATURE, + defeature::DEFEATURE_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 6d45326..38b6df1 100644 --- a/crates/engine/src/cmd/virtual_faces.rs +++ b/crates/engine/src/cmd/virtual_faces.rs @@ -98,7 +98,7 @@ fn stale_sets(s: &Session, body: &str) -> Vec { /// 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 { +pub(super) 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 errors: Vec = diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs index 9b3e3fe..c6c8b5e 100644 --- a/crates/engine/src/requirement.rs +++ b/crates/engine/src/requirement.rs @@ -38,6 +38,8 @@ pub enum Requirement { Stitch, /// A face split. FaceSplit, + /// A defeature. + Defeature, /// An edge seed. Seed, /// A size control. @@ -90,6 +92,7 @@ impl Requirement { Requirement::FaceMerge => !m.face_merges().is_empty(), Requirement::Stitch => !m.stitches().is_empty(), Requirement::FaceSplit => !m.face_splits().is_empty(), + Requirement::Defeature => !m.defeatures().is_empty(), Requirement::Seed => !m.seeds().is_empty(), Requirement::SizeControl => !m.size_controls().is_empty(), Requirement::Nodes => !m.nodes().is_empty(), @@ -134,6 +137,7 @@ impl Requirement { Requirement::FaceMerge => "a face merge", Requirement::Stitch => "a stitch", Requirement::FaceSplit => "a face split", + Requirement::Defeature => "a defeature", Requirement::Seed => "an edge seed", Requirement::SizeControl => "a local size", Requirement::Nodes => "mesh nodes", @@ -168,6 +172,7 @@ impl Requirement { Requirement::FaceMerge => "geometry.faces.merge", Requirement::Stitch => "geometry.stitch", Requirement::FaceSplit => "geometry.face.split", + Requirement::Defeature => "geometry.defeature", Requirement::Seed => "mesh.seed.edge", Requirement::SizeControl => "mesh.size.set", Requirement::Nodes => "mesh.node.add", @@ -230,6 +235,7 @@ impl crate::schema::Collection { C::Stitch => Requirement::Stitch, C::FaceSplit => Requirement::FaceSplit, C::SizeControl => Requirement::SizeControl, + C::Defeature => Requirement::Defeature, } } } @@ -373,6 +379,7 @@ mod tests { C::FaceMerge, C::Stitch, C::FaceSplit, + C::Defeature, ]; let empty = Session::default(); for c in all { diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index 968c2dc..f3c6b5c 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -225,6 +225,8 @@ pub enum Collection { FaceSplit, /// A size control (`mesh.size.set`). SizeControl, + /// A defeature (`geometry.defeature`). + Defeature, } impl Collection { @@ -254,6 +256,7 @@ impl Collection { Collection::Stitch => "stitch", Collection::FaceSplit => "face split", Collection::SizeControl => "size control", + Collection::Defeature => "defeature", } } } @@ -287,6 +290,7 @@ impl Session { 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())), Collection::SizeControl => Box::new(m.size_controls().keys().map(String::as_str)), + Collection::Defeature => Box::new(m.defeatures().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 7347e27..252c289 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. -83 ready, 37 coming soon. +84 ready, 37 coming soon. ## Geometry @@ -25,6 +25,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | 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 | Defeature — Take small holes, fillets, chamfers and bosses out for meshing: the design rebuilt without their features | ready | `geometry.defeature`, (`geometry.defeature.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) | |