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 <[email protected]>
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"),
// ---- 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",
+1 -1
View File
@@ -254,7 +254,7 @@ fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
#[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.
+17
View File
@@ -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] = &[
+12 -2
View File
@@ -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<Value> {
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<Value> {
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
"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<String>,
/// Sets naming a body or a face that is not there.
sets: Vec<String>,
/// Edge seeds on an edge the design lacks.
seeds: Vec<String>,
}
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(),
}
}
+4
View File
@@ -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,
+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 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);
+5
View File
@@ -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",
+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, \
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<dyn Backend>,
+1 -1
View File
@@ -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::<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()
.filter(|(_, n)| !n.is_empty())
.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,
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) | |
+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).
## 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
The server treats its input as hostile. A message line may be at most 16 MiB (the control