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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 20:58:17 -04:00
1 parent f8e0be970e
commit e8cada94d0
12 files changed
+295 -11

No files matched your search

+8 -1
View File
@@ -251,7 +251,14 @@ pub static CATALOGUE: &[Capability] = &[
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"), soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ---- // ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"), 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"), soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
ready( ready(
"mesh.tet.generate", "mesh.tet.generate",
+1 -1
View File
@@ -254,7 +254,7 @@ fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { pub(crate) mod tests {
use super::*; use super::*;
/// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open. /// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open.
+17
View File
@@ -809,6 +809,23 @@ pub const ELEMENT_ADD: &[Field] = &[
json("nodes", "Nodes").req().hint("[labels]"), 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 DELAUNAY_POINTS: &[Field] = &[json("points", "Points").req().hint("[[x, y, z], …]")];
pub const TAG: &[Field] = &[ pub const TAG: &[Field] = &[
+12 -2
View File
@@ -15,7 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const OPEN: CommandSpec = CommandSpec { pub const OPEN: CommandSpec = CommandSpec {
id: "geometry.open", id: "geometry.open",
label: "Open Design", 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), form: Some(super::forms::OPEN_DESIGN),
kind: CommandKind::Model, kind: CommandKind::Model,
requires: &[], requires: &[],
@@ -80,6 +80,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
s.set_document(Default::default()); s.set_document(Default::default());
fit.mesh_bodies.clear(); fit.mesh_bodies.clear();
fit.sets.clear(); fit.sets.clear();
fit.seeds.clear();
} }
let out = json!({ let out = json!({
"name": design.name, "name": design.name,
@@ -87,7 +88,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(), "errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
"model": if new_model { "new" } else { "kept" }, "model": if new_model { "new" } else { "kept" },
"related": fit.related, "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.design = Some(Arc::new(design));
s.sources.design = Some(source); s.sources.design = Some(source);
@@ -103,6 +104,8 @@ struct Fit {
mesh_bodies: Vec<String>, mesh_bodies: Vec<String>,
/// Sets naming a body or a face that is not there. /// Sets naming a body or a face that is not there.
sets: Vec<String>, sets: Vec<String>,
/// Edge seeds on an edge the design lacks.
seeds: Vec<String>,
} }
fn fit(s: &Session, design: &Design) -> Fit { 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)), related: referenced.is_empty() || referenced.iter().any(|b| has(b)),
mesh_bodies: tagged.into_iter().filter(|b| !has(b)).map(str::to_string).collect(), 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(), 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(),
} }
} }
+4
View File
@@ -18,6 +18,7 @@ mod mirror;
mod model; mod model;
pub(crate) mod octree; pub(crate) mod octree;
mod results; mod results;
pub(crate) mod seeds;
pub(crate) mod solve; pub(crate) mod solve;
mod symmetry; mod symmetry;
mod tags; mod tags;
@@ -57,6 +58,9 @@ pub static ALL: &[CommandSpec] = &[
hex::HEX_MAPPED, hex::HEX_MAPPED,
hex::HEX_BLOCKS, hex::HEX_BLOCKS,
cells::CELLS, cells::CELLS,
seeds::SEED_EDGE,
seeds::SEED_CLEAR,
seeds::SEED_LIST,
mirror::MIRROR, mirror::MIRROR,
tags::TAG, tags::TAG,
tags::TAG_CLEAR, tags::TAG_CLEAR,
+222
View File
@@ -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<Option<Vec<GeometryRef>>> {
let refs: Option<Vec<GeometryRef>> = 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<Vec<Edge>> {
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<Value> {
let refs = edge_refs(p, true)?.unwrap_or_default();
let divisions = match (opt::<u32>(p, "count")?, opt::<f64>(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<Bias> = 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<Value> {
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<Value> {
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<Value> {
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"));
}
}
+3 -3
View File
@@ -696,7 +696,7 @@ mod tests {
let before = s.model.clone(); let before = s.model.clone();
let r = s.execute("geometry.open", &json!({"path": path})).unwrap(); 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["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); assert_eq!(s.model, before);
} }
@@ -711,7 +711,7 @@ mod tests {
let mut kept = defined.clone(); let mut kept = defined.clone();
let r = kept.execute("geometry.open", &json!({"path": path})).unwrap(); 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["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); assert_eq!(kept.model, before);
// New: the mesh and every definition go, the units stay; one undo brings them back. // 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 before = fresh.model.clone();
let r = fresh.execute("geometry.open", &json!({"path": path, "model": "new"})).unwrap(); 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["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.elements().is_empty() && fresh.model.nodes().is_empty());
assert!(fresh.model.definition_counts().iter().all(|(_, n)| *n == 0)); assert!(fresh.model.definition_counts().iter().all(|(_, n)| *n == 0));
assert_eq!(fresh.model.units(), units); assert_eq!(fresh.model.units(), units);
+5
View File
@@ -30,6 +30,8 @@ pub enum Requirement {
DatumPlane, DatumPlane,
/// A partition of the design. /// A partition of the design.
Partition, Partition,
/// An edge seed.
Seed,
/// At least one mesh node. /// At least one mesh node.
Nodes, Nodes,
/// At least one element. /// At least one element.
@@ -73,6 +75,7 @@ impl Requirement {
Requirement::Datum => !m.datums().is_empty(), Requirement::Datum => !m.datums().is_empty(),
Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()), Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()),
Requirement::Partition => !m.partitions().is_empty(), Requirement::Partition => !m.partitions().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(), Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements, Requirement::Mesh => has_elements,
Requirement::Analysable => { Requirement::Analysable => {
@@ -110,6 +113,7 @@ impl Requirement {
Requirement::Datum => "a datum plane or axis", Requirement::Datum => "a datum plane or axis",
Requirement::DatumPlane => "a datum plane", Requirement::DatumPlane => "a datum plane",
Requirement::Partition => "a partition", Requirement::Partition => "a partition",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes", Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh", Requirement::Mesh | Requirement::Analysable => "a mesh",
Requirement::ElementMaterial | Requirement::Material => "a material", Requirement::ElementMaterial | Requirement::Material => "a material",
@@ -137,6 +141,7 @@ impl Requirement {
Requirement::Design | Requirement::Geometry => "geometry.open", Requirement::Design | Requirement::Geometry => "geometry.open",
Requirement::Datum | Requirement::DatumPlane => "geometry.datum", Requirement::Datum | Requirement::DatumPlane => "geometry.datum",
Requirement::Partition => "geometry.partition", Requirement::Partition => "geometry.partition",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add", Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
Requirement::ElementMaterial | Requirement::Material => "model.material.create", Requirement::ElementMaterial | Requirement::Material => "model.material.create",
+4 -1
View File
@@ -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, \ 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; \ 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 \ 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 { pub struct Server {
backend: Box<dyn Backend>, backend: Box<dyn Backend>,
+1 -1
View File
@@ -695,7 +695,7 @@ impl ModelApp {
/// empty when everything resolves. /// empty when everything resolves.
fn stale_note(stale: &Value) -> String { 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::<Vec<_>>().join(", "); let names = |k: &str| stale.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::<Vec<_>>().join(", ");
let parts: Vec<String> = [("mesh of", names("mesh_bodies")), ("sets", names("sets"))] let parts: Vec<String> = [("mesh of", names("mesh_bodies")), ("sets", names("sets")), ("seeds on", names("seeds"))]
.into_iter() .into_iter()
.filter(|(_, n)| !n.is_empty()) .filter(|(_, n)| !n.is_empty())
.map(|(what, n)| format!("{what} {n}")) .map(|(what, n)| format!("{what} {n}"))
+2 -2
View File
@@ -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, 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. 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 ## Geometry
@@ -26,7 +26,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Panel | Capability | Status | Command | | Panel | Capability | Status | Command |
|---|---|---|---| |---|---|---|---|
| Size | Global Size — One element size for the whole part | coming soon (M2) | | | 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) | | | 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 | 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) | | | Tet | Quadratic — Second-order elements with mid-side nodes on the geometry | coming soon (M2) | |
+16
View File
@@ -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). `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 (`<face>|<face>`, `~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 ## Limits
The server treats its input as hostile. A message line may be at most 16 MiB (the control The server treats its input as hostile. A message line may be at most 16 MiB (the control