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 @@
+
+
diff --git a/assets/icons/geometry.defeature.vectorcraft b/assets/icons/geometry.defeature.vectorcraft
new file mode 100644
index 0000000..f1b7e3b
Binary files /dev/null and b/assets/icons/geometry.defeature.vectorcraft differ
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) | |