From e8cada94d0ee30a0122c9bb87a3bc94498df6e7f Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:53:54 -0400 Subject: [PATCH] Engine: mesh.seed.edge, mesh.seed.clear and mesh.seed.list Seed design edges by their persistent names with a count or a size, a bias and a constraint; one undo step each, saved with the model. Seeds on edges a reopened design lacks are reported as stale. Part of #230. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 9 +- crates/engine/src/cmd/datum.rs | 2 +- crates/engine/src/cmd/forms.rs | 17 +++ crates/engine/src/cmd/geometry.rs | 14 +- crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/seeds.rs | 222 ++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 6 +- crates/engine/src/requirement.rs | 5 + crates/mcp/src/server.rs | 5 +- crates/ui-egui/src/files.rs | 2 +- docs/features.md | 4 +- docs/mcp.md | 16 +++ 12 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/cmd/seeds.rs diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 4cf34f2..e3e1233 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -251,7 +251,14 @@ pub static CATALOGUE: &[Capability] = &[ soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"), // ---- Mesh ---- soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"), - soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"), + ready_more( + "mesh.seed.edge", + "Seed Edges", + Mesh, + "Size", + "Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them", + &["mesh.seed.clear", "mesh.seed.list"], + ), soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"), ready( "mesh.tet.generate", diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 268dea0..defc5f2 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -254,7 +254,7 @@ fn partition_delete(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; /// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open. diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 9c255fe..3ddb27a 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -809,6 +809,23 @@ pub const ELEMENT_ADD: &[Field] = &[ json("nodes", "Nodes").req().hint("[labels]"), ]; +pub const SEED_EDGE: &[Field] = &[ + json("edges", "Edges").req().hint("[{\"body\", \"edge\"}, …] — Use selection with Select: Edges"), + int("count", "Count", 1, 100_000).hint("or an element size"), + positive("size", "Element size", D::Length).hint("or a count"), + group( + "bias", + "Bias", + &[ + range("ratio", "Ratio", D::None, 1.0, 1e4).req().def("1"), + choice("toward", "Small toward", &[("start", "Start"), ("end", "End"), ("both", "Both ends")]).req().def("\"end\""), + ], + ), + choice("constraint", "Constraint", &[("exact", "Exact"), ("min", "At least"), ("free", "Free (a goal)")]).def("\"exact\""), +]; + +pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").hint("every seed")]; + pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").req().hint("[[x, y, z], …]")]; pub const TAG: &[Field] = &[ diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 62d9dd7..d054c8b 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -15,7 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const OPEN: CommandSpec = CommandSpec { id: "geometry.open", label: "Open Design", - params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) and `sets` (sets naming a body or face it lacks). One undo step"#, + params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) `sets` (sets naming a body or face it lacks) and `seeds` (edge seeds on edges it lacks). One undo step"#, form: Some(super::forms::OPEN_DESIGN), kind: CommandKind::Model, requires: &[], @@ -80,6 +80,7 @@ fn open(s: &mut Session, p: &Value) -> Result { s.set_document(Default::default()); fit.mesh_bodies.clear(); fit.sets.clear(); + fit.seeds.clear(); } let out = json!({ "name": design.name, @@ -87,7 +88,7 @@ fn open(s: &mut Session, p: &Value) -> Result { "errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::>(), "model": if new_model { "new" } else { "kept" }, "related": fit.related, - "stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets}, + "stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets, "seeds": fit.seeds}, }); s.design = Some(Arc::new(design)); s.sources.design = Some(source); @@ -103,6 +104,8 @@ struct Fit { mesh_bodies: Vec, /// Sets naming a body or a face that is not there. sets: Vec, + /// Edge seeds on an edge the design lacks. + seeds: Vec, } fn fit(s: &Session, design: &Design) -> Fit { @@ -118,6 +121,13 @@ fn fit(s: &Session, design: &Design) -> Fit { related: referenced.is_empty() || referenced.iter().any(|b| has(b)), mesh_bodies: tagged.into_iter().filter(|b| !has(b)).map(str::to_string).collect(), sets: s.model.sets().iter().filter(|(_, set)| !set.geometry.iter().all(resolves)).map(|(n, _)| n.clone()).collect(), + seeds: s + .model + .seeds() + .iter() + .filter(|seed| super::seeds::seeded_edges(design, &seed.edge).map_or(true, |e| e.is_empty())) + .map(|seed| seed.edge.to_string()) + .collect(), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0c0f12..d7d8f9b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -18,6 +18,7 @@ mod mirror; mod model; pub(crate) mod octree; mod results; +pub(crate) mod seeds; pub(crate) mod solve; mod symmetry; mod tags; @@ -57,6 +58,9 @@ pub static ALL: &[CommandSpec] = &[ hex::HEX_MAPPED, hex::HEX_BLOCKS, cells::CELLS, + seeds::SEED_EDGE, + seeds::SEED_CLEAR, + seeds::SEED_LIST, mirror::MIRROR, tags::TAG, tags::TAG_CLEAR, diff --git a/crates/engine/src/cmd/seeds.rs b/crates/engine/src/cmd/seeds.rs new file mode 100644 index 0000000..d10853c --- /dev/null +++ b/crates/engine/src/cmd/seeds.rs @@ -0,0 +1,222 @@ +//! Edge seeds: `mesh.seed.edge` sets a count or a size (with bias and constraint) on design +//! edges, `mesh.seed.clear` removes seeds and `mesh.seed.list` lists them with the count each +//! gives on its edge. Seeds live in the model (`fieldcraft_model::seeds`), are saved with it, and +//! the meshers honour them. + +use fieldcraft_geometry::{Design, Edge}; +use fieldcraft_model::{Bias, Divisions, Entity, GeometryRef, Seed, SeedConstraint}; +use serde_json::{Value, json}; + +use super::opt; +use crate::requirement::Requirement; +use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; + +/// Most edges one command names. +const MAX_EDGES: usize = 10_000; + +pub const SEED_EDGE: CommandSpec = CommandSpec { + id: "mesh.seed.edge", + label: "Seed Edges", + params: r#"{"edges": [{"body": "Body1", "edge": "F2:side:hi|F2:top"}, …], "count": 8 | "size": 2.5, "bias"?: {"ratio": 4, "toward": "start" | "end" | "both"}, "constraint"?: "exact" (default) | "min" | "free"} — seeds design edges (persistent edge names, geometry.bodies): `count` divisions, or `size` (the edge's length over it, rounded up). `bias` grades the elements geometrically, the largest `ratio` times the smallest, small at the edge's start, end or both ends. `constraint`: "exact" keeps the count (a mesher whose other edges cannot match refuses, naming the conflicting edges), "min" keeps at least it, "free" makes it a goal. Replaces a seed the edge had; an edge name without its `~k` number seeds every edge of that name. The hex meshers (sweep, mapped, blocks, cells) use the counts and spacing; the tet mesher uses the spacing on the edge and grades its size field from it"#, + form: Some(super::forms::SEED_EDGE), + kind: CommandKind::Model, + requires: &[Requirement::Design], + run: seed_edge, +}; + +pub const SEED_CLEAR: CommandSpec = CommandSpec { + id: "mesh.seed.clear", + label: "Clear Seeds", + params: r#"{"edges"?: [{"body", "edge"}, …]} — removes the seeds on these edges (by the names they were seeded with), or every seed"#, + form: Some(super::forms::SEED_CLEAR), + kind: CommandKind::Model, + requires: &[Requirement::Seed], + run: seed_clear, +}; + +pub const SEED_LIST: CommandSpec = CommandSpec { + id: "mesh.seed.list", + label: "Seeds", + params: "{} — the edge seeds, each with the edges of the open design it seeds, their lengths and the count it gives each (`stale`: the design has no such edge)", + form: Some(&[]), + kind: CommandKind::Model, + requires: &[], + run: seed_list, +}; + +/// The `edges` parameter: a list of edge references. +fn edge_refs(p: &Value, required: bool) -> Result>> { + let refs: Option> = opt(p, "edges")?; + let Some(refs) = refs else { + return if required { Err(EngineError::Params("`edges` is required: a list of {body, edge}".into())) } else { Ok(None) }; + }; + if refs.is_empty() || refs.len() > MAX_EDGES { + return Err(EngineError::Params(format!("`edges` must list 1 to {MAX_EDGES} edges"))); + } + if let Some(r) = refs.iter().find(|r| !matches!(r.entity, Entity::Edge(_))) { + return Err(EngineError::Params(format!("`edges` lists the {r}; name edges as {{body, edge}}"))); + } + Ok(Some(refs)) +} + +/// The edges of the design a reference selects (its `~k` pieces too). +pub(crate) fn seeded_edges(design: &Design, r: &GeometryRef) -> Result> { + let Some(body) = design.body(&r.body) else { return Ok(Vec::new()) }; + Ok(body.edges()?.into_iter().filter(|e| GeometryRef::new(r.body.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| r.selects(&g))).collect()) +} + +fn design(s: &Session) -> Result<&Design> { + s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first (geometry.open)".into())) +} + +/// A seed's report: its definition and, per edge it seeds, the length and the count it gives. +fn report(seed: &Seed, edges: &[Edge]) -> Value { + let mut v = json!(seed); + v["on"] = edges.iter().map(|e| json!({"edge": e.name, "length": e.length(), "count": seed.count(e.length())})).collect(); + v["stale"] = json!(edges.is_empty()); + v +} + +fn seed_edge(s: &mut Session, p: &Value) -> Result { + let refs = edge_refs(p, true)?.unwrap_or_default(); + let divisions = match (opt::(p, "count")?, opt::(p, "size")?) { + (Some(n), None) => Divisions::Count(n), + (None, Some(h)) => Divisions::Size(h), + _ => return Err(EngineError::Params("give one of `count` (divisions) or `size` (element length)".into())), + }; + let bias: Option = opt(p, "bias")?; + let constraint: SeedConstraint = opt(p, "constraint")?.unwrap_or_default(); + let design = design(s)?.clone(); + let mut out = Vec::with_capacity(refs.len()); + for r in refs { + let edges = seeded_edges(&design, &r)?; + if edges.is_empty() { + return Err(EngineError::Params(format!("the design has no {r} (geometry.bodies lists the edges)"))); + } + let seed = Seed::new(r, divisions, bias, constraint)?; + out.push(report(&seed, &edges)); + s.model.set_seed(seed)?; + } + Ok(json!({"seeded": out.len(), "seeds": out})) +} + +fn seed_clear(s: &mut Session, p: &Value) -> Result { + let cleared = match edge_refs(p, false)? { + None => s.model.clear_seeds(), + Some(refs) => { + if let Some(r) = refs.iter().find(|r| s.model.seed(r).is_none()) { + return Err(EngineError::Params(format!("the {r} has no seed"))); + } + refs.iter().filter(|r| s.model.remove_seed(r)).count() + } + }; + Ok(json!({"cleared": cleared, "left": s.model.seeds().len()})) +} + +fn seed_list(s: &mut Session, _: &Value) -> Result { + let mut out = Vec::with_capacity(s.model.seeds().len()); + for seed in s.model.seeds() { + let edges = match s.design.as_deref() { + Some(d) => seeded_edges(d, &seed.edge)?, + None => Vec::new(), + }; + out.push(report(seed, &edges)); + } + Ok(Value::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plate() -> Session { + super::super::datum::tests::plate().0 + } + + fn thickness_edges(s: &mut Session) -> Vec { + let bodies = s.execute("geometry.bodies", &json!({})).unwrap(); + bodies[0]["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| (e["length"].as_f64().unwrap() - 10.0).abs() < 1e-6) + .map(|e| json!({"body": "Body1", "edge": e["name"]})) + .collect() + } + + #[test] + fn seeds_are_set_replaced_listed_cleared_and_undone() { + let mut s = plate(); + let edges = thickness_edges(&mut s); + let r = s.execute("mesh.seed.edge", &json!({"edges": edges[..2], "count": 4, "bias": {"ratio": 3, "toward": "end"}})).unwrap(); + assert_eq!(r["seeded"], 2); + assert_eq!(r["seeds"][0]["on"][0]["count"], 4); + assert_eq!(r["seeds"][0]["constraint"], "exact"); + // A size gives the length over it, rounded up; it replaces the count. + let r = s.execute("mesh.seed.edge", &json!({"edges": [edges[0]], "size": 3.0, "constraint": "min"})).unwrap(); + assert_eq!(r["seeds"][0]["on"][0]["count"], 4); + assert_eq!(s.model.seeds().len(), 2); + let list = s.execute("mesh.seed.list", &json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert!(list.as_array().unwrap().iter().all(|l| l["stale"] == false)); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.model.seed(&serde_json::from_value(edges[0].clone()).unwrap()).unwrap().divisions, Divisions::Count(4)); + s.execute("mesh.seed.clear", &json!({"edges": [edges[1]]})).unwrap(); + assert_eq!(s.model.seeds().len(), 1); + assert!(s.execute("mesh.seed.clear", &json!({"edges": [edges[1]]})).is_err()); + assert_eq!(s.execute("mesh.seed.clear", &json!({})).unwrap()["cleared"], 1); + assert!(s.execute("mesh.seed.clear", &json!({})).is_err(), "nothing to clear"); + } + + #[test] + fn seeds_survive_save_open_and_a_design_edit() { + let (mut s, dir) = super::super::datum::tests::plate(); + let edges = thickness_edges(&mut s); + s.execute("mesh.seed.edge", &json!({"edges": edges, "count": 5, "bias": {"ratio": 2, "toward": "both"}})).unwrap(); + let file = dir.join("seeded.fieldcraft"); + s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap(); + let mut back = Session::default(); + back.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap(); + assert_eq!(back.model.seeds(), s.model.seeds()); + // The plate made thicker: the same edges, now 15 mm, keep their seeds. + let design = dir.join("plate.solidcraft"); + let thick = fieldcraft_geometry::sample::Plate { thickness: 15.0, ..Default::default() }; + std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&thick).unwrap()).unwrap(); + let r = back.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap(); + assert_eq!(r["stale"]["seeds"], json!([])); + let list = back.execute("mesh.seed.list", &json!({})).unwrap(); + for l in list.as_array().unwrap() { + assert_eq!(l["on"].as_array().unwrap().len(), 1, "{l}"); + assert!((l["on"][0]["length"].as_f64().unwrap() - 15.0).abs() < 1e-6, "{l}"); + } + } + + #[test] + fn hostile_seeds_are_refused_and_change_nothing() { + let mut s = plate(); + let edges = thickness_edges(&mut s); + let e = edges[0].clone(); + for p in [ + json!({}), + json!({"edges": [], "count": 2}), + json!({"edges": [e], "count": 2, "size": 1.0}), + json!({"edges": [e]}), + json!({"edges": [e], "count": 0}), + json!({"edges": [e], "count": -3}), + json!({"edges": [e], "count": 1e12}), + json!({"edges": [e], "size": 0.0}), + json!({"edges": [e], "count": 2, "bias": {"ratio": 1e9, "toward": "end"}}), + json!({"edges": [e], "count": 2, "bias": {"ratio": 2, "toward": "middle"}}), + json!({"edges": [e], "count": 2, "constraint": "loose"}), + json!({"edges": [{"body": "Body1", "face": "x"}], "count": 2}), + json!({"edges": [{"body": "Body1", "edge": "no|such"}], "count": 2}), + json!({"edges": [{"body": "Nope", "edge": "a|b"}], "count": 2}), + json!({"edges": "all", "count": 2}), + ] { + assert!(s.execute("mesh.seed.edge", &p).is_err(), "{p}"); + } + assert!(s.model.seeds().is_empty()); + assert!(s.journal.is_empty() || s.journal.iter().all(|(c, _)| c != "mesh.seed.edge")); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6221cdc..087ca3e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -696,7 +696,7 @@ mod tests { let before = s.model.clone(); let r = s.execute("geometry.open", &json!({"path": path})).unwrap(); assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("kept"), Some(true)), "{r}"); - assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": []})); + assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": [], "seeds": []})); assert_eq!(s.model, before); } @@ -711,7 +711,7 @@ mod tests { let mut kept = defined.clone(); let r = kept.execute("geometry.open", &json!({"path": path})).unwrap(); assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("kept"), Some(false)), "{r}"); - assert_eq!(r["stale"], json!({"mesh_bodies": ["Seal"], "sets": ["all", "hole"]})); + assert_eq!(r["stale"], json!({"mesh_bodies": ["Seal"], "sets": ["all", "hole"], "seeds": []})); assert_eq!(kept.model, before); // New: the mesh and every definition go, the units stay; one undo brings them back. @@ -721,7 +721,7 @@ mod tests { let before = fresh.model.clone(); let r = fresh.execute("geometry.open", &json!({"path": path, "model": "new"})).unwrap(); assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("new"), Some(false)), "{r}"); - assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": []})); + assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": [], "seeds": []})); assert!(fresh.model.elements().is_empty() && fresh.model.nodes().is_empty()); assert!(fresh.model.definition_counts().iter().all(|(_, n)| *n == 0)); assert_eq!(fresh.model.units(), units); diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs index 15be1d5..2be20ed 100644 --- a/crates/engine/src/requirement.rs +++ b/crates/engine/src/requirement.rs @@ -30,6 +30,8 @@ pub enum Requirement { DatumPlane, /// A partition of the design. Partition, + /// An edge seed. + Seed, /// At least one mesh node. Nodes, /// At least one element. @@ -73,6 +75,7 @@ impl Requirement { Requirement::Datum => !m.datums().is_empty(), Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()), Requirement::Partition => !m.partitions().is_empty(), + Requirement::Seed => !m.seeds().is_empty(), Requirement::Nodes => !m.nodes().is_empty(), Requirement::Mesh => has_elements, Requirement::Analysable => { @@ -110,6 +113,7 @@ impl Requirement { Requirement::Datum => "a datum plane or axis", Requirement::DatumPlane => "a datum plane", Requirement::Partition => "a partition", + Requirement::Seed => "an edge seed", Requirement::Nodes => "mesh nodes", Requirement::Mesh | Requirement::Analysable => "a mesh", Requirement::ElementMaterial | Requirement::Material => "a material", @@ -137,6 +141,7 @@ impl Requirement { Requirement::Design | Requirement::Geometry => "geometry.open", Requirement::Datum | Requirement::DatumPlane => "geometry.datum", Requirement::Partition => "geometry.partition", + Requirement::Seed => "mesh.seed.edge", Requirement::Nodes => "mesh.node.add", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", Requirement::ElementMaterial | Requirement::Material => "model.material.create", diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 16c742f..4aba277 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -36,7 +36,10 @@ datum plane, geometry.partition cuts the design's bodies into cells by it (cut f the model and applied again when the design opens); `mesh` method cells meshes the cells each with its own method, \ method mirror reflects a meshed half across a datum plane into one conforming mesh; \ model.boundary_condition.symmetry holds a half model on its symmetry plane (file.sample crank-mirror shows the \ -partition, half-mesh and mirror sequence). Failures leave the model unchanged and name what to fix."; +partition, half-mesh and mirror sequence). Edge seeds: mesh.seed.edge {edges: [{body, edge}], count | size, bias?, \ +constraint?} fixes the element count along design edges (inspect_model lists the edge names): seed a thin section's \ +thickness, a fillet or hole edge (biased toward the stress raiser), and partition cells' shared edges so their \ +counts match. Failures leave the model unchanged and name what to fix."; pub struct Server { backend: Box, diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 725df12..24463c0 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -695,7 +695,7 @@ impl ModelApp { /// empty when everything resolves. fn stale_note(stale: &Value) -> String { let names = |k: &str| stale.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::>().join(", "); - let parts: Vec = [("mesh of", names("mesh_bodies")), ("sets", names("sets"))] + let parts: Vec = [("mesh of", names("mesh_bodies")), ("sets", names("sets")), ("seeds on", names("seeds"))] .into_iter() .filter(|(_, n)| !n.is_empty()) .map(|(what, n)| format!("{what} {n}")) diff --git a/docs/features.md b/docs/features.md index d360f1d..4a4c0f9 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. -62 ready, 42 coming soon. +63 ready, 41 coming soon. ## Geometry @@ -26,7 +26,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Panel | Capability | Status | Command | |---|---|---|---| | Size | Global Size — One element size for the whole part | coming soon (M2) | | -| Size | Seed Edges — Element counts, sizes and bias per edge and face | coming soon (M2) | | +| Size | Seed Edges — Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them | ready | `mesh.seed.edge`, `mesh.seed.clear`, `mesh.seed.list` | | Size | Curvature — Refine by curvature and proximity | coming soon (M2) | | | Tet | Tet Mesh — Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged | ready | `mesh.tet.generate` | | Tet | Quadratic — Second-order elements with mid-side nodes on the geometry | coming soon (M2) | | diff --git a/docs/mcp.md b/docs/mcp.md index 1b03733..9855916 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -140,6 +140,22 @@ and `mesh` (each one's parameters: `list_commands`): `file.sample {"name": "crank-mirror"}` opens the crank arm built this way (docs/examples.md). +## Edge seeds + +Seed an edge when its element count matters more than the global size: through a thin +section's thickness (at least 2–3 elements for bending), around a fillet or hole where the +stress peaks (a count or a small size, biased toward the stress raiser), or to give the shared +edges of partition cells matching counts. + +1. `inspect_model` lists each body's edges by persistent name (`|`, `~k` when several + separate the same faces) with their lengths; the names follow the faces through edits and + partitions. +2. `execute` `mesh.seed.edge {edges: [{body, edge}], count | size, bias?: {ratio, toward: + start|end|both}, constraint?: exact|min|free}`. `exact` keeps the count, `min` keeps at least + it, `free` is a goal. The reply gives the count each edge gets. +3. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such + edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model. + ## Limits The server treats its input as hostile. A message line may be at most 16 MiB (the control