forked from 1b-agent/fieldcraft-upstream
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:
1 parent
f8e0be970e
commit
e8cada94d0
12 files changed
+295
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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] = &[
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user