diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index de9737c..3b2184b 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -322,8 +322,8 @@ pub static CATALOGUE: &[Capability] = &[ "Defeature", Geometry, "Prepare", - "Take small holes, fillets, chamfers and bosses out for meshing: the design rebuilt without their features", - &["geometry.defeature.delete"], + "Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off", + &["geometry.defeature.propose", "geometry.defeature.delete"], ), covering( "geometry.stitch", diff --git a/crates/engine/src/cmd/defeature.rs b/crates/engine/src/cmd/defeature.rs index 06de212..d744f1e 100644 --- a/crates/engine/src/cmd/defeature.rs +++ b/crates/engine/src/cmd/defeature.rs @@ -1,14 +1,16 @@ //! 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). +//! when a feature cannot be suppressed on its own), and `geometry.defeature.propose` lists the +//! candidates of a body against size thresholds and the last geometry.check. //! //! 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 std::collections::{BTreeMap, BTreeSet}; +use fieldcraft_geometry::defeature::{Candidate, candidate}; use fieldcraft_model::Defeature; use serde::Deserialize; use serde_json::{Value, json}; @@ -19,7 +21,7 @@ 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"#, + params: r#"{"name": "Small", "features": [3, "F7"] (feature ids, as geometry.features and geometry.defeature.propose list 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], @@ -36,6 +38,16 @@ pub const DEFEATURE_DELETE: CommandSpec = CommandSpec { run: delete, }; +pub const DEFEATURE_PROPOSE: CommandSpec = CommandSpec { + id: "geometry.defeature.propose", + label: "Propose Defeaturing", + params: r#"{"body": "Base", "below"?: {"hole_diameter"?: 6, "fillet_radius"?: 2, "chamfer"?: 1, "boss_height"?: 2} (mm), "from_check"?: false, "name"?: "Defeature"} — lists the features of a body (as opened) that defeaturing may take out (read-only): holes (a hole, or a cut of a circle or cylinder) by diameter, fillets by radius, chamfers by distance, bosses (a join of an extrude, cylinder or box) by height. A candidate is `proposed` when it is smaller than its `below` threshold, or with `from_check` when one of its faces is a finding of the last geometry.check (still marked). Replies `candidates` ([{id, feature, name, type, category, size, faces, proposed, suppressed, by, method}]: `suppressed` when a defeature takes it out, `by` that defeature, `method` suppressed or virtual), `proposal` (the proposed ids) and `name`, the defeature the panel's on/off switches edit (geometry.defeature with that name)"#, + form: Some(super::forms::DEFEATURE_PROPOSE), + kind: CommandKind::Model, + requires: &[Requirement::Design], + run: propose, +}; + /// A feature id: `3` or `"F3"`. #[derive(Deserialize)] #[serde(untagged)] @@ -126,6 +138,106 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(cleared.report_if_any(s, json!({"deleted": name, "features": removed.features, "bodies": bodies}))) } +/// Size thresholds of a proposal (mm). +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct Below { + hole_diameter: Option, + fillet_radius: Option, + chamfer: Option, + boss_height: Option, +} + +impl Below { + fn of(&self, category: &str) -> Option { + match category { + "hole" => self.hole_diameter, + "fillet" => self.fillet_radius, + "chamfer" => self.chamfer, + "boss" => self.boss_height, + _ => None, + } + } +} + +fn propose(s: &mut Session, p: &Value) -> Result { + let body: String = super::req(p, "body")?; + let below: Below = super::opt(p, "below")?.unwrap_or_default(); + for (k, v) in [ + ("hole_diameter", below.hole_diameter), + ("fillet_radius", below.fillet_radius), + ("chamfer", below.chamfer), + ("boss_height", below.boss_height), + ] { + if v.is_some_and(|v| !(0.0..=fieldcraft_model::MAX_COORD).contains(&v)) { + return Err(EngineError::invalid("below", format!("`{k}` is a length of 0 or more"))); + } + } + let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false); + let name = match p.get("name") { + None | Some(Value::Null) => "Defeature".to_string(), + Some(_) => super::name(p)?.to_string(), + }; + let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; + let source = design + .source_of(&body) + .ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::())))?; + let graph = design.undefeatured().features(Some(source))?; + // The faces the last check marked on the body (merged faces by their B-rep faces). + let mut flagged: BTreeSet = BTreeSet::new(); + if from_check { + 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()))?; + for (b, i) in marks.marks.iter().flat_map(|m| m.faces.iter()) { + if design.source_of(b) != Some(source) { + continue; + } + if let Some(f) = design.body(b).and_then(|shape| shape.faces().get(*i as usize)) { + flagged.insert(f.name.split('#').next().unwrap_or(&f.name).to_string()); + flagged.extend(f.members.iter().cloned()); + } + } + } + // Which defeature takes each feature out, and how. + let by: BTreeMap = design.defeat_report().iter().map(|r| (r.feature, (r.defeature.clone(), r.method))).collect(); + let mut candidates = Vec::new(); + let mut proposal = Vec::new(); + for c in graph.iter().flat_map(|b| b.features.iter()).filter_map(candidate) { + let Candidate { id, name: fname, kind, category, size, faces } = c; + let small = below.of(category).is_some_and(|t| size < t); + let hit = faces.iter().any(|f| flagged.contains(f)); + let proposed = small || hit; + if proposed { + proposal.push(id); + } + let taken = by.get(&id); + candidates.push(json!({ + "id": id, + "feature": format!("F{id}"), + "name": fname, + "type": kind, + "category": category, + "size": size, + "faces": faces, + "proposed": proposed, + "suppressed": taken.is_some(), + "by": taken.map(|t| t.0.clone()), + "method": taken.map(|t| t.1), + })); + } + let off = candidates.iter().filter(|c| c["suppressed"] == true).count(); + let summary = format!( + "{} on `{source}`: {} proposed, {} defeatured", + match candidates.len() { + 1 => "1 candidate".to_string(), + n => format!("{n} candidates"), + }, + proposal.len(), + off + ); + Ok(json!({"body": source, "name": name, "candidates": candidates, "proposal": proposal, "summary": summary})) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -137,11 +249,13 @@ mod tests { } 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() + let body = s.design.as_ref().unwrap().bodies[0].name.clone(); + let r = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 12}})).unwrap(); + assert_eq!(r["proposal"].as_array().map(Vec::len), Some(1), "{r}"); + r["proposal"][0].as_u64().unwrap() } - /// The sample plate: suppressing its hole gives a block meshed + /// The sample plate: its hole is proposed below 12 mm, suppressing it 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] @@ -149,6 +263,8 @@ mod tests { 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 none = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 5}})).unwrap(); + assert_eq!((none["candidates"].as_array().map(Vec::len), none["proposal"].as_array().map(Vec::len)), (Some(1), Some(0)), "{none}"); let id = hole(&mut s); s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap(); let with_hole = s.model.elements().len(); @@ -156,6 +272,9 @@ mod tests { 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); + // The proposal shows it off, by the defeature. + let p = s.execute("geometry.defeature.propose", &json!({"body": body})).unwrap(); + assert_eq!((p["candidates"][0]["suppressed"].as_bool(), p["candidates"][0]["by"].as_str()), (Some(true), Some("Holes")), "{p}"); // 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; @@ -198,10 +317,14 @@ mod tests { 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); + for below in [json!({"hole_diameter": -1}), json!({"hole_diameter": "x"}), json!({"nope": 1})] { + assert!(s.execute("geometry.defeature.propose", &json!({"body": body, "below": below})).is_err(), "{below}"); + } + assert!(s.execute("geometry.defeature.propose", &json!({"body": "Nope"})).is_err()); std::fs::remove_dir_all(dir).ok(); } - /// The bracket's 6 mm fillet is defeatured back to a sharp corner and + /// The bracket's 6 mm fillet is proposed below 8 mm, defeatured back to a sharp corner and /// meshed; its holes stay. #[test] fn a_bracket_is_meshed_with_its_small_fillet_defeatured() { @@ -214,9 +337,10 @@ mod tests { {"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(); + let p = s.execute("geometry.defeature.propose", &json!({"body": "Bracket", "below": {"fillet_radius": 8}})).unwrap(); + let cats: Vec<&str> = p["candidates"].as_array().unwrap().iter().filter_map(|c| c["category"].as_str()).collect(); + assert_eq!(cats, ["fillet", "hole", "hole"], "{p}"); + let fillet = p["proposal"].as_array().unwrap().clone(); assert_eq!(fillet.len(), 1); let before = faces(&s); let r = s.execute("geometry.defeature", &json!({"name": "Fillets", "features": fillet})).unwrap(); diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 233e604..4dfe012 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1783,10 +1783,26 @@ 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.") + .help("The feature ids to take out for meshing (3 or F3), as Propose Defeaturing and the feature graph list them; separate them with commas.") .req(), ]; pub const DEFEATURE_DELETE: &[Field] = existing!("Defeature", C::Defeature); +pub const DEFEATURE_PROPOSE: &[Field] = &[ + reference("body", "Body", C::Body).help("The body whose holes, fillets, chamfers and bosses to list.").req(), + group( + "below", + "Propose below", + &[ + positive("hole_diameter", "Hole diameter", D::Millimetre).help("Propose holes narrower than this."), + positive("fillet_radius", "Fillet radius", D::Millimetre).help("Propose fillets with a smaller radius."), + positive("chamfer", "Chamfer", D::Millimetre).help("Propose chamfers smaller than this."), + positive("boss_height", "Boss height", D::Millimetre).help("Propose bosses lower than this."), + ], + ) + .help("Size thresholds: a feature below its threshold is proposed."), + flag("from_check", "From check").help("Also propose the features with a face the last Check Geometry found."), + text("name", "Defeature").help("The defeature the list's on/off switches edit.").hint("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 d9736f6..ced07dc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -82,6 +82,7 @@ pub static ALL: &[CommandSpec] = &[ virtual_faces::FACE_SPLIT_DELETE, defeature::DEFEATURE, defeature::DEFEATURE_DELETE, + defeature::DEFEATURE_PROPOSE, stitch::STITCH, stitch::STITCH_DELETE, mesh::NODE_ADD, diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index 0f130b1..0e6b396 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -28,7 +28,7 @@ pub struct Reply { const SOLVES: [&str; 6] = ["solve.static", "solve.nonlinear", "solve.heat", "solve.frequency", "solve.buckle", "solve.explicit"]; /// Replies that only read the model, refreshed when it changes; any other reply closes then. -pub const LIVE: [&str; 2] = ["model.check", "model.activity"]; +pub const LIVE: [&str; 3] = ["model.check", "model.activity", "geometry.defeature.propose"]; /// The status line's text (after the command's label) is cut to this many characters. const MAX_LINE: usize = 120; @@ -221,6 +221,17 @@ enum Act { Elements(Vec), /// Open Relax Nodes round an element (Mesh Quality's worst). Relax(u32), + /// Switch a feature on (kept in the mesh) or off (defeatured by the named defeature). + Defeature { + name: String, + feature: u64, + on: bool, + }, + /// Defeature the proposed features with the named defeature. + Propose { + name: String, + features: Vec, + }, } /// The reply's section of the context panel. @@ -248,6 +259,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { "model.activity" => activity(ui, t, &reply.value), "geometry.bodies" => bodies(ui, t, &reply.value, &mut acts), "mesh.quality" => quality(ui, t, &reply.value, &mut acts), + "geometry.defeature.propose" => defeature_list(ui, t, &reply.value, &mut acts), _ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0), } for a in acts { @@ -267,10 +279,94 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { app.show_elements(&[element]); app.open_form("mesh.node.relax", serde_json::json!({"around_element": element})); } + Act::Defeature { name, feature, on } => { + let mut features = defeatured(app, &name); + features.retain(|f| *f != feature); + if !on { + features.push(feature); + } + let _ = set_defeature(app, &name, features); + } + Act::Propose { name, features } => { + let mut all = defeatured(app, &name); + for f in features { + if !all.contains(&f) { + all.push(f); + } + } + let _ = set_defeature(app, &name, all); + } } } } +/// The features the defeature `name` takes out (none when the model has no such defeature). +fn defeatured(app: &ModelApp, name: &str) -> Vec { + app.session.model.defeatures().iter().find(|d| d.name == name).map(|d| d.features.clone()).unwrap_or_default() +} + +/// Give the defeature `name` these features (none: delete it). +fn set_defeature(app: &mut ModelApp, name: &str, features: Vec) -> Result { + match features.is_empty() { + true if defeatured(app, name).is_empty() => Ok(Value::Null), + true => app.run("geometry.defeature.delete", serde_json::json!({"name": name})), + false => app.run("geometry.defeature", serde_json::json!({"name": name, "features": features})), + } +} + +/// Propose Defeaturing: each candidate with a switch (on: in the mesh; off: defeatured), its +/// kind, size and the defeature that takes it out, and a button that defeatures the proposal. +fn defeature_list(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec) { + let name = v["name"].as_str().unwrap_or("Defeature").to_string(); + let body = v["body"].as_str().unwrap_or_default(); + let candidates = v["candidates"].as_array().map(Vec::as_slice).unwrap_or_default(); + let proposal: Vec = v["proposal"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + let pending: Vec = candidates.iter().filter(|c| c["proposed"] == true && c["suppressed"] != true).filter_map(|c| c["id"].as_u64()).collect(); + if !proposal.is_empty() { + let button = ui.add_enabled(!pending.is_empty(), egui::Button::new(format!("Defeature the {} proposed", pending.len()))); + if button.on_hover_text(format!("Take them out with defeature {name}")).clicked() { + acts.push(Act::Propose { name: name.clone(), features: pending }); + } + ui.add_space(4.0); + } + egui::Grid::new("fc_defeature").num_columns(4).spacing(vec2(10.0, 2.0)).striped(true).show(ui, |ui| { + for h in ["In mesh", "Feature", "Kind", "Size"] { + ui.label(RichText::new(h).color(t.text_dim)); + } + ui.end_row(); + for c in candidates.iter().take(MAX_ENTITIES) { + let Some(id) = c["id"].as_u64() else { continue }; + let mut on = c["suppressed"] != true; + let by = c["by"].as_str().unwrap_or(&name).to_string(); + let hover = if on { format!("Switch off: defeature {name} takes it out") } else { format!("Switch on: defeature {by} keeps it") }; + if ui.checkbox(&mut on, "").on_hover_text(hover).changed() { + acts.push(Act::Defeature { name: if on { by } else { name.clone() }, feature: id, on }); + } + let label = format!("F{id} {}", c["name"].as_str().unwrap_or_default()); + let text = if c["proposed"] == true { RichText::new(label).color(t.warm) } else { RichText::new(label) }; + match c["faces"].as_array().and_then(|f| f.first()).and_then(Value::as_str).filter(|_| on) { + Some(face) => { + if ui.link(text).on_hover_text("Pick its face in the viewport").clicked() { + let entity = fieldcraft_model::Entity::Face(face.to_string()); + acts.push(Act::Pick(fieldcraft_model::GeometryRef { body: body.to_string(), entity })); + } + } + None => { + ui.label(text); + } + } + let method = match c["method"].as_str() { + Some("virtual") => " (virtual)", + _ => "", + }; + ui.label(format!("{}{method}", c["category"].as_str().unwrap_or_default())); + ui.label(format!("{} mm", crate::describe::quantity(sig(c["size"].as_f64().unwrap_or(0.0))))); + ui.end_row(); + } + }); + more_than(ui, t, candidates.len(), MAX_ENTITIES); +} + /// The definition a diagnostic's subject names (``section `web` ``), when the model has it. fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> { let (kind, rest) = subject.split_once(" `")?; @@ -817,6 +913,30 @@ mod tests { use super::*; + /// Propose Defeaturing stays in the panel and follows the model: switching the plate's hole + /// off fills it in the viewport's bodies and the list shows it off; on again brings it back. + #[test] + fn the_defeature_list_switches_a_hole_off_and_on() { + let dir = std::env::temp_dir().join(format!("fieldcraft-ui-defeature-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("plate.solidcraft"); + std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); + let mut app = ModelApp::new(fieldcraft_engine::Session::default()); + app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap(); + let body = app.session.design.as_ref().unwrap().bodies[0].name.clone(); + app.run("geometry.defeature.propose", json!({"body": body, "below": {"hole_diameter": 12}})).unwrap(); + let hole = app.reply.as_ref().unwrap().value["proposal"][0].as_u64().unwrap(); + let faces = |app: &ModelApp| app.session.design.as_ref().unwrap().bodies[0].faces().len(); + set_defeature(&mut app, "Defeature", vec![hole]).unwrap(); + let reply = app.reply.as_ref().expect("the list stays open"); + assert_eq!((faces(&app), reply.value["candidates"][0]["suppressed"].as_bool()), (6, Some(true))); + assert_eq!(defeatured(&app, "Defeature"), [hole]); + set_defeature(&mut app, "Defeature", Vec::new()).unwrap(); + assert_eq!((faces(&app), app.reply.as_ref().unwrap().value["candidates"][0]["suppressed"].as_bool()), (8, Some(false))); + assert!(app.session.model.defeatures().is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } + /// Mesh Quality's reply draws as counts, histograms and worst elements, and a worst element /// is selected and framed (#113). #[test] diff --git a/docs/features.md b/docs/features.md index 252c289..3b29f36 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,7 +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 | Defeature — Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off | ready | `geometry.defeature`, (`geometry.defeature.propose`), (`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) | | diff --git a/skills/fieldcraft-meshing/SKILL.md b/skills/fieldcraft-meshing/SKILL.md index 3a6833f..b36caf1 100644 --- a/skills/fieldcraft-meshing/SKILL.md +++ b/skills/fieldcraft-meshing/SKILL.md @@ -21,7 +21,10 @@ pictures → report.** Never skip a step because the last mesh looked fine. `size`): short edges, small and sliver faces, near faces (gaps, overlaps, walls thinner than the gap). Each finding names its faces and a point `at`, and is marked in pictures. Defeature or resize before meshing; never mesh over a - finding you have not explained. + finding you have not explained. `geometry.defeature.propose {"body", "below": {"hole_diameter", + "fillet_radius", "chamfer", "boss_height"}, "from_check": true}` lists the holes, fillets, + chamfers and bosses below a size or under a finding; `geometry.defeature {"name", "features"}` + takes them out (the design rebuilt without them), `geometry.defeature.delete` puts them back. 3. Take pictures. Headless: `fieldcraft-cli snapshot script.json --out a.png` with `--view iso|front|top|…` or `--camera '{"yaw":-45,"pitch":30,"target":[x,y,z],"fit":true}'`, and `--section x|y|z[=VALUE]`. Over MCP: `screenshot {view | camera: {yaw, pitch, target,