forked from 1b-agent/fieldcraft-upstream
ui: geometry.defeature.propose lists the small features with an on/off switch each (#433)
Holes by diameter, fillets by radius, chamfers by distance and bosses by height below thresholds, or under a geometry.check finding; the reply panel switches each feature off and on (geometry.defeature) and defeatures the whole proposal. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
2456912850
commit
5db21aba7b
7 files changed
+280
-16
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<Value> {
|
||||
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<f64>,
|
||||
fillet_radius: Option<f64>,
|
||||
chamfer: Option<f64>,
|
||||
boss_height: Option<f64>,
|
||||
}
|
||||
|
||||
impl Below {
|
||||
fn of(&self, category: &str) -> Option<f64> {
|
||||
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<Value> {
|
||||
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::<String>())))?;
|
||||
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<String> = 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<u64, (String, &'static str)> = 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<serde_json::Value> =
|
||||
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();
|
||||
|
||||
@@ -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-<body>"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+121
-1
@@ -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<u32>),
|
||||
/// 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<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<u64> {
|
||||
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<u64>) -> Result<Value, String> {
|
||||
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<Act>) {
|
||||
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<u64> = v["proposal"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
|
||||
let pending: Vec<u64> = 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]
|
||||
|
||||
+1
-1
@@ -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) | |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user