From 75b8c7e26ebee8c910111bd31699ccf9372800d7 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:30:48 -0400 Subject: [PATCH] engine: mesh.quality.mark marks the elements below quality limits as a set Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/mesh.quality.mark.svg | 10 ++ assets/icons/mesh.quality.mark.vectorcraft | Bin 0 -> 1758 bytes assets/icons/src/mesh.quality.mark.json | 1 + crates/engine/src/catalogue.rs | 7 + crates/engine/src/cmd/forms.rs | 19 +++ crates/engine/src/cmd/mesh.rs | 174 +++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/view.rs | 22 +++ docs/features.md | 3 +- 10 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 assets/icons/mesh.quality.mark.svg create mode 100644 assets/icons/mesh.quality.mark.vectorcraft create mode 100644 assets/icons/src/mesh.quality.mark.json diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 47dbd4f..e71fe39 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -93,6 +93,7 @@ is committed and listed here with its exports. | assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/mesh.quality.mark.svg, assets/icons/mesh.quality.mark.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.mark.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/mesh.quality.mark.svg b/assets/icons/mesh.quality.mark.svg new file mode 100644 index 0000000..1e4df40 --- /dev/null +++ b/assets/icons/mesh.quality.mark.svg @@ -0,0 +1,10 @@ + + + mesh.quality.mark + + + + + + + diff --git a/assets/icons/mesh.quality.mark.vectorcraft b/assets/icons/mesh.quality.mark.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..7f37fe2a1ef85d4315ff34b62cbaae973a49702f GIT binary patch literal 1758 zcmV<41|j($iwFP!00000|Lt0BZ`(Ey{wsz)8-b{I2k3_`E4rfHTBJjZp$H7xqHQj; zsOcq%7x=&L_@+dTn^Cmg0$T$(wn&Qi=OSuVQkLl?V1!Y$^FB9XzVq#VFd13lX`XbSBn{K zHfL*GUBf+XL7N)-KF~YDYG{~q#;fU?=i-jbD5NY2u5d9YVm=C!C`3xKx1fxHz!Fns3k{^^4DNpfL@1d-M@(<`<5CcVw}qJfPfm;Oa(lIXqWu!Xon!R{t#vySKb5*bfh(?!36p*a zfgkX>5|1aY5!%-v{&g*3*8tp@GhUNi8BSth^hR2!USSDF@DmqHCiHddoo9lNEB#&J zoKl!k(Dl&=roCGT(T|!nECo#(L+~Q}!HW~|0__qbK>Hec#6aJT8R#iBq94>aq02x| zUBr`?VAp8`-^*#BJ~iS~QezS!FiEV9l}c(v+E~HFN0S8ox19!xo;Zn~+wga(A#NC> z$$oBZ#Rr<}Tinc7M9I?O$CuxDL9X_wu7GwA9%ae+46}z2r{s+ZYsTFypI2ahC;f|O z6PlObGx@!>mHHFI^J_<|``X^VwzR#}OOm_NI*`ORR73tG^TE$)1W1xT%B+FBhqbpP zqXY0nv49X67aR+G2f}fMl9+WTp~PlyP*5JNLMtdQ*E1P9J0Z!U5QWA@iSjna_lTY# zj;(?GK^Wt=&PZTGj6;oN63Rd029f5{Kq#Zv6@fE>qo?fAB};{-obkM)Yge+wk+h1@ zOxhz$5UKsb8pt6U2?Difx}8d{=qFp1C6ZTbI3~0FK#vv!LqS1i3k#Gw>^b{8!R$Z? zk>BG6L~>$z+q;sNaM*W^D!XN}&m!94Sp&J);NqYqV>FUT?`Hf+A{7!2g5j!zGV|%JzLKN=0)v8D%`5HS^sAg{&TDS(vyFqK8B2g+` zs{QJ%_^qoeOV#py2+ibmXnd;u2IuTdjX7fJDS~cOQGuGhJLii9ab?C%%x+>tMT?;^ z`^AcbEZn$)35_C_D%9xh`YKGb2lEjZuCY(ra~_EzsWDG5kfRqI5*x0^hoonosFzI$ zRUl;V#V*_0)uZpk$f5EdhA1X(wMOz8v!Zq0UEW zt2dK!d0g9d$d!%LNx0oS04JYv@wmNprC zyu@>;k?H0&%}Xxf038HmGdFsE?tRIT=jRqFFO_Q4gYDQ2`oL^-kOtKp|5puuY>Rqt z%y%l>!ve)r>n<1 z)z;DbwqX$BI4eSxmB00K1Irpx(T1rjW}Se4l(!f zDz3*;i*?akt15V;s_2QTL`1t;&o~px5dNg4IWfO0DqjMrMf6;Nv@{ULsM}diZ>V_7 z6$WNo@itB^UL_@v6`VKqv@EF^OV^OchEex0svvjGRLq!cA9rm30mT5_eS<3i03#Vx A9RL6T literal 0 HcmV?d00001 diff --git a/assets/icons/src/mesh.quality.mark.json b/assets/icons/src/mesh.quality.mark.json new file mode 100644 index 0000000..9c3ee2c --- /dev/null +++ b/assets/icons/src/mesh.quality.mark.json @@ -0,0 +1 @@ +{"shapes": [{"rect": [3, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [13, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [3, 13, 8, 8], "fill": "soft", "stroke": "line"}, {"d": "M13 14 L21 13 L20 21 L14 19 Z", "fill": "warm", "stroke": "line"}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index a8933c0..a149666 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -405,6 +405,13 @@ pub static CATALOGUE: &[Capability] = &[ soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"), soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"), ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"), + ready( + "mesh.quality.mark", + "Mark Bad Elements", + Mesh, + "Verify", + "Mark the elements below quality limits as a set, located and drawn in the marker colour", + ), soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"), ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"), ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index f4050d9..2c98800 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1275,6 +1275,25 @@ pub const QUALITY: &[Field] = &[ int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"), ]; +pub const QUALITY_MARK: &[Field] = &[ + labels("elements", "Elements", LabelOf::Element).help("The elements to check; all when empty.").hint("all"), + group( + "limits", + "Limits", + &[ + num("scaled_jacobian", "Scaled Jacobian at least", D::None) + .help("Elements with a lower scaled Jacobian are marked (1 is a perfect element).") + .hint("0.2"), + num("min_dihedral", "Min dihedral at least", D::Degree).help("Tetrahedra with a smaller dihedral angle are marked.").hint("10"), + num("max_dihedral", "Max dihedral at most", D::Degree).help("Tetrahedra with a larger dihedral angle are marked.").hint("165"), + num("aspect_ratio", "Aspect ratio at most", D::None).help("Elements with a larger aspect ratio are marked.").hint("10"), + num("min_angle", "Min angle at least", D::Degree).help("Shells with a smaller corner angle are marked.").hint("15"), + num("max_angle", "Max angle at most", D::Degree).help("Shells with a larger corner angle are marked.").hint("165"), + ], + ) + .help("Elements worse than a limit are marked; a limit left empty keeps its default."), +]; + pub const SURFACE_CHECK: &[Field] = &[ reference("body", "Body", C::Body).help("The body whose surface to check.").req().hint("or an imported surface"), SURFACE, diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index b02d1fe..5e29ee5 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -67,6 +67,16 @@ pub const QUALITY: CommandSpec = CommandSpec { run: quality_report, }; +pub const QUALITY_MARK: CommandSpec = CommandSpec { + id: "mesh.quality.mark", + label: "Mark Bad Elements", + params: r#"{"limits"?: {"scaled_jacobian": 0.2, "min_dihedral": 10, "max_dihedral": 165, "aspect_ratio": 10, "min_angle": 15, "max_angle": 165} (the defaults; given limits are merged over them, null drops one; any metric of mesh.quality may be limited: a floor for those where higher is better, a ceiling for max_dihedral, aspect_ratio, edge_ratio, max_angle and skew; angles in degrees), "elements"?: [labels] (default all)} — after meshing, marks the elements that fail a limit (and every solid not proven valid) as the element set `quality_marked`, replaced on every run and removed when none fail; the viewport and `fieldcraft-cli snapshot` draw its elements in the marker colour. Replies `summary`, `measured`, `marked`, `invalid`, the `limits` used and `elements` (worst first: not valid, then lowest scaled Jacobian; at most 1000, `truncated` says when more): each `label`, `kind`, `body`, `at` (its centroid), `valid` and `failed` ({metric, value, limit} per limit it fails). One undo step"#, + form: Some(super::forms::QUALITY_MARK), + kind: CommandKind::Model, + requires: &[Requirement::Mesh], + run: quality_mark, +}; + pub const EXPORT_STL: CommandSpec = CommandSpec { id: "mesh.export.stl", label: "Export STL", @@ -650,6 +660,103 @@ fn quality_report(s: &mut Session, p: &Value) -> Result { })) } +/// The element set `mesh.quality.mark` keeps the marked elements in. +pub const MARKED_SET: &str = "quality_marked"; + +/// The default limits of `mesh.quality.mark`. +const MARK_LIMITS: [(quality::Metric, f64); 6] = [ + (quality::Metric::ScaledJacobian, 0.2), + (quality::Metric::MinDihedral, 10.0), + (quality::Metric::MaxDihedral, 165.0), + (quality::Metric::AspectRatio, 10.0), + (quality::Metric::MinAngle, 15.0), + (quality::Metric::MaxAngle, 165.0), +]; + +/// Most marked elements `mesh.quality.mark` lists. +const MAX_MARKED_LISTED: usize = 1000; + +fn quality_mark(s: &mut Session, p: &Value) -> Result { + let mut limits: std::collections::BTreeMap = MARK_LIMITS.into_iter().collect(); + match p.get("limits") { + None | Some(Value::Null) => {} + Some(Value::Object(given)) => { + for (name, v) in given { + let metric = quality::Metric::parse(name).ok_or_else(|| { + let known: Vec<&str> = quality::Metric::ALL.iter().map(|m| m.name()).collect(); + EngineError::Params(format!("no quality metric `{}` (one of {})", name.chars().take(40).collect::(), known.join(", "))) + })?; + match v { + Value::Null => { + limits.remove(&metric); + } + v => { + let limit = v + .as_f64() + .filter(|x| x.is_finite()) + .ok_or_else(|| EngineError::Params(format!("the limit of `{name}` must be a number")))?; + limits.insert(metric, limit); + } + } + } + } + Some(_) => return Err(EngineError::Params("`limits` must be an object of metric names and numbers".into())), + } + let selection = match p.get("elements") { + None => None, + Some(_) => Some(labels(p, "elements", MAX_ELEMENTS)?), + }; + let limits: Vec<(quality::Metric, f64)> = limits.into_iter().collect(); + let (measured, marked) = quality::mark_elements(&s.model, selection.as_deref(), &limits)?; + let set = fieldcraft_model::Set { elements: marked.iter().map(|m| m.label).collect(), ..Default::default() }; + if set.elements.is_empty() { + if s.model.set(MARKED_SET).is_some() { + s.model.remove_set(MARKED_SET)?; + } + } else if s.model.set(MARKED_SET).is_some() { + s.model.replace_set(MARKED_SET, set)?; + } else { + s.model.add_set(MARKED_SET, set)?; + } + let invalid = marked.iter().filter(|m| m.is_invalid()).count(); + let c = s.model.classification(); + let body = |label: u32| c.elements().get(&label).and_then(|t| t.region).and_then(|r| c.entity(r)).map(|g| g.body.clone()); + let elements: Vec = marked + .iter() + .take(MAX_MARKED_LISTED) + .map(|m| { + json!({ + "label": m.label, + "kind": m.kind.name(), + "body": body(m.label), + "at": m.centroid, + "valid": m.validity.map(|v| v == quality::Validity::Valid), + "failed": m.failed.iter().map(|(metric, v, limit)| json!({"metric": metric.name(), "value": number(*v), "limit": limit})).collect::>(), + }) + }) + .collect(); + let mut summary = format!("{} of {measured} elements marked", marked.len()); + if invalid > 0 { + summary.push_str(&format!(", {invalid} not valid")); + } + if let Some(worst) = marked.first().and_then(|m| m.scaled_jacobian) { + summary.push_str(&format!("; worst scaled Jacobian {worst:.3}")); + } + if !marked.is_empty() { + summary.push_str(&format!(" (set {MARKED_SET})")); + } + Ok(json!({ + "summary": summary, + "set": (!marked.is_empty()).then_some(MARKED_SET), + "measured": measured, + "marked": marked.len(), + "invalid": invalid, + "limits": limits.iter().map(|(m, v)| (m.name().to_string(), json!(v))).collect::>(), + "elements": elements, + "truncated": marked.len() > MAX_MARKED_LISTED, + })) +} + /// A quality report in a line, for the status bar and the CLI: the elements measured, how many /// are invalid or undetermined, and the scaled Jacobian's minimum and mean. fn quality_summary(r: &QualityReport) -> String { @@ -825,6 +932,73 @@ mod tests { } } + fn mark_session() -> Session { + let mut s = Session::default(); + let corners = json!([[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0], [0, 0, 1], [2, 0, 1], [2, 1, 1], [0, 1, 1]]); + s.execute("mesh.block", &json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [2, 1, 1]})).unwrap(); + s + } + + /// Two good hexes mark nothing; a corner pulled in marks its element, located, as the set + /// the viewport draws; looser limits empty it; undo brings it back. + #[test] + fn marks_bad_elements_as_a_set() { + let mut s = mark_session(); + let r = s.execute("mesh.quality.mark", &json!({})).unwrap(); + assert_eq!((r["measured"].as_u64(), r["marked"].as_u64()), (Some(2), Some(0)), "{r}"); + assert!(s.model.set(super::MARKED_SET).is_none() && r["set"].is_null()); + assert_eq!(r["limits"]["scaled_jacobian"], 0.2); + // Pull the far top corner of the second hex nearly onto its middle. + let (&corner, _) = s.model.nodes().iter().find(|(_, n)| n.xyz == [2.0, 1.0, 1.0]).unwrap(); + let (&bad, _) = s.model.elements().iter().find(|(_, e)| e.nodes.contains(&corner)).unwrap(); + s.execute("mesh.node.add", &json!({"label": corner, "xyz": [1.7, 0.7, 0.7]})).unwrap(); + let r = s.execute("mesh.quality.mark", &json!({})).unwrap(); + assert_eq!((r["marked"].as_u64(), r["invalid"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(r["set"], super::MARKED_SET); + let e = &r["elements"][0]; + assert_eq!((e["label"].as_u64(), e["kind"].as_str(), e["body"].as_str()), (Some(bad as u64), Some("hex8"), Some("Bar")), "{e}"); + assert!(e["failed"].as_array().unwrap().iter().any(|f| f["metric"] == "scaled_jacobian"), "{e}"); + let at: Vec = e["at"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!(at[0] > 1.0 && at[0] < 2.0, "{at:?}"); + assert!(r["summary"].as_str().unwrap().starts_with("1 of 2 elements marked"), "{r}"); + assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad]); + assert_eq!(crate::view::marked(&s).into_iter().collect::>(), [bad]); + // The picture draws its faces in the marker colour, and only its. + let p = crate::view::Palette::LIGHT; + let (scene, _, _) = crate::view::scene(&s, &p); + let red = scene.tris.iter().filter(|t| t.color == p.marked).count(); + assert_eq!(red, 5 * 2, "the bad hex's outer faces (all but the shared one), as triangles"); + // Without limits the (valid) element passes and the set goes. + let all_off = json!({"limits": {"scaled_jacobian": null, "min_dihedral": null, "max_dihedral": null, "aspect_ratio": null, "min_angle": null, "max_angle": null}}); + let r = s.execute("mesh.quality.mark", &all_off).unwrap(); + assert_eq!(r["marked"], 0, "{r}"); + assert!(s.model.set(super::MARKED_SET).is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad], "undo restores the marks"); + // Only the elements asked about. + let other = s.model.elements().keys().copied().find(|l| *l != bad).unwrap(); + assert_eq!(s.execute("mesh.quality.mark", &json!({"elements": [other]})).unwrap()["marked"], 0); + } + + #[test] + fn mark_rejects_hostile_parameters() { + let mut s = Session::default(); + assert!(s.execute("mesh.quality.mark", &json!({})).is_err(), "needs a mesh"); + let mut s = mark_session(); + for p in [ + json!({"limits": {"nope": 1}}), + json!({"limits": {"scaled_jacobian": "low"}}), + json!({"limits": {"scaled_jacobian": true}}), + json!({"limits": [0.2]}), + json!({"limits": {"x".repeat(10000): 1}}), + json!({"elements": [0]}), + json!({"elements": [99]}), + json!({"elements": "all"}), + ] { + assert!(s.execute("mesh.quality.mark", &p).is_err(), "{p}"); + } + } + #[test] fn reports_quality_and_inverted_elements() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e91601f..52161d0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -75,6 +75,7 @@ pub static ALL: &[CommandSpec] = &[ mesh::ELEMENT_REMOVE, replace::CLEAR, mesh::QUALITY, + mesh::QUALITY_MARK, mesh::EXPORT_STL, mesh::DELAUNAY_POINTS, mesh::SURFACE_CHECK, diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 0c8e44d..6c7034d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -41,6 +41,8 @@ pub struct Palette { pub contour: [Rgb; 5], /// Values above a clamped legend's maximum. pub contour_over: Rgb, + /// The elements `mesh.quality.mark` marked. + pub marked: Rgb, /// The analysis setup's symbols. pub glyphs: GlyphColors, } @@ -73,6 +75,7 @@ impl Palette { no_result: Rgb(0x9a, 0xa0, 0xaa), contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)], contour_over: CONTOUR_OVER, + marked: Rgb(0xe0, 0x2e, 0x2e), glyphs: GlyphColors { constraint: Rgb(0x00, 0x8a, 0xa8), load: Rgb(0xd0, 0x24, 0x24), @@ -218,6 +221,24 @@ fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene { s } +/// The elements `mesh.quality.mark` marked (its set), drawn in the marker colour. +pub fn marked(s: &Session) -> std::collections::BTreeSet { + s.model.set(crate::cmd::mesh::MARKED_SET).map(|set| set.elements.iter().copied().collect()).unwrap_or_default() +} + +/// Colour the faces of the `marked` elements in `scene`, whose triangles start with the element +/// faces of `display` in order (as [`model_scene`] and the app's viewport build them). +pub fn mark(scene: &mut Scene, display: &DisplayMesh, marked: &std::collections::BTreeSet, color: Rgb) { + if marked.is_empty() { + return; + } + for (t, d) in scene.tris.iter_mut().zip(&display.tris) { + if marked.contains(&d.element) { + t.color = color; + } + } +} + /// The model part of a picture: the model or the results view's plot, the shape the symbols sit /// on (the deformed one of a plot), the box around it and what it shows. struct Picture { @@ -251,6 +272,7 @@ fn picture(s: &Session, p: &Palette, section: Option
, toward: Vec3) -> let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step)); let Some((view, result)) = plot else { let mut scene = model_scene(&base, p); + mark(&mut scene, &base, &marked(s), p.marked); let bounds = base.bounds; let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n); let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene); diff --git a/docs/features.md b/docs/features.md index 6e01156..5f4fd3e 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. -76 ready, 39 coming soon. +77 ready, 39 coming soon. ## Geometry @@ -57,6 +57,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | | | Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | | | Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` | +| Verify | Mark Bad Elements — Mark the elements below quality limits as a set, located and drawn in the marker colour | ready | `mesh.quality.mark` | | Verify | Verify Set — Quality and validity per set | coming soon (M2) | | | Verify | Export STL — Write the mesh's outer surface (or a set's faces) as binary STL | ready | `mesh.export.stl` | | Verify | Tag Geometry — Tag elements, faces, edges and nodes with the geometry they mesh | ready | `mesh.tag`, `mesh.tag.clear` |