engine: pick-driven points, cells' methods per cell, rigid walls as interactions

- geometry.bodies lists each body's vertices; geometry.datum.point by
  vertex takes the design's vertex names (labelled Vertex, not Mesh
  vertex) (#346).
- geometry.centre: the point for what is picked: a vertex, nodes, a
  hole's centre (cylindrical faces or circular edges), else a centroid
  (#351).
- mesh.cells' form: the cells first, and a method drop-down per cell
  (schema kind RefChoices) instead of JSON (#344).
- interaction.rigid_wall(.delete): rigid walls of an explicit step from
  the Interactions tab, no longer coming soon (#350).

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-15 09:27:39 -04:00
1 parent e8e0b92d87
commit 1372094f8a
10 files changed
+564 -27

No files matched your search

+16 -2
View File
@@ -238,7 +238,14 @@ pub static CATALOGUE: &[Capability] = &[
// ---- Geometry ---- // ---- Geometry ----
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"), ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"), ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"), ready_more(
"geometry.bodies",
"Bodies",
Geometry,
"Inspect",
"List the open design's bodies, volumes and named faces, edges and vertices",
&["geometry.centre"],
),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"), ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
ready( ready(
"mesh.surface.check", "mesh.surface.check",
@@ -493,7 +500,14 @@ pub static CATALOGUE: &[Capability] = &[
"Contact", "Contact",
"Find the parts of the mesh that touch, and make contact pairs of them", "Find the parts of the mesh that touch, and make contact pairs of them",
), ),
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"), ready_more(
"interaction.rigid_wall",
"Rigid Wall",
Interactions,
"Contact",
"A fixed rigid plane a set's nodes cannot pass, with friction, in an explicit dynamics step",
&["interaction.rigid_wall.delete"],
),
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"), soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
ready_more( ready_more(
"model.spring.create", "model.spring.create",
+116 -2
View File
@@ -1,5 +1,7 @@
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the //! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes. //! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes;
//! `interaction.rigid_wall*`: rigid walls, which belong to an explicit dynamics step (its
//! `rigid_walls`, also edited in the step's form).
//! //!
//! A contact's surfaces are sets of element faces, or of element edges in a 2D model //! A contact's surfaces are sets of element faces, or of element edges in a 2D model
//! ([`fieldcraft_model::contact`]). The commands //! ([`fieldcraft_model::contact`]). The commands
@@ -16,7 +18,7 @@ use crate::schema::ProblemKind;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use fieldcraft_fem::contact::{Grid, bounds, closest_point}; use fieldcraft_fem::contact::{Grid, bounds, closest_point};
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Set, Tie}; use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Procedure, RigidWall, Set, Step, Tie};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::{name, opt}; use super::{name, opt};
@@ -38,6 +40,88 @@ fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::param(key, ProblemKind::NotAName)) p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::param(key, ProblemKind::NotAName))
} }
pub const RIGID_WALL: CommandSpec = CommandSpec {
id: "interaction.rigid_wall",
label: "Rigid Wall",
params: r#"{"step": "Impact" (an explicit dynamics step; default: the model's only one), "set": "front" (the nodes the wall stops), "point": [x, y, z] (on the wall), "normal": [nx, ny, nz] (toward the side the nodes stay on), "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)} — a fixed rigid plane the set's nodes cannot pass, added to the step's rigid walls (the same list as model.step.create's `rigid_walls`). Replies the step and the wall's index in it"#,
form: Some(super::forms::RIGID_WALL),
kind: CommandKind::Model,
requires: &[Requirement::Step, Requirement::Set],
run: rigid_wall,
};
pub const RIGID_WALL_DELETE: CommandSpec = CommandSpec {
id: "interaction.rigid_wall.delete",
label: "Delete Rigid Wall",
params: r#"{"step": "Impact", "index": 0} — removes a rigid wall from an explicit dynamics step (its index in the step's `rigid_walls`, from 0)"#,
form: Some(super::forms::RIGID_WALL_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Step],
run: rigid_wall_delete,
};
/// The explicit dynamics step `step` names, or the model's only one when it names none.
fn explicit_step(m: &Model, p: &Value) -> Result<Step> {
let explicit = |s: &&Step| matches!(s.procedure, Procedure::ExplicitDynamic(_));
let step = match p.get("step") {
None | Some(Value::Null) => {
let mut all = m.steps().iter().filter(explicit);
match (all.next(), all.next()) {
(Some(s), None) => s,
(None, _) => {
return Err(EngineError::Params(
"the model has no explicit dynamics step, where rigid walls act: create one (Steps › Step, Dynamic, explicit)".into(),
));
}
(Some(_), Some(_)) => {
return Err(EngineError::param("step", ProblemKind::Invalid("name the explicit dynamics step: the model has several".into())));
}
}
}
Some(_) => {
let n = text(p, "step")?;
let s = m.steps().iter().find(|s| s.name == n).ok_or_else(|| EngineError::Params(format!("no step `{n}`")))?;
if !explicit(&s) {
return Err(EngineError::Params(format!("step `{n}` is not an explicit dynamics step: rigid walls act only there")));
}
s
}
};
Ok(step.clone())
}
fn rigid_wall(s: &mut Session, p: &Value) -> Result<Value> {
let mut step = explicit_step(&s.model, p)?;
let set = text(p, "set")?.to_string();
if s.model.set(&set).is_none() {
return Err(EngineError::Params(format!("no set `{set}`")));
}
let wall = RigidWall {
set,
point: super::req(p, "point")?,
normal: super::req(p, "normal")?,
friction: opt::<Friction>(p, "friction")?.unwrap_or(Friction::NONE),
};
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
e.rigid_walls.push(wall);
let index = e.rigid_walls.len() - 1;
let name = step.name.clone();
s.model.replace_step(step)?;
Ok(json!({"step": name, "index": index}))
}
fn rigid_wall_delete(s: &mut Session, p: &Value) -> Result<Value> {
let mut step = explicit_step(&s.model, p)?;
let index: usize = super::req(p, "index")?;
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
if index >= e.rigid_walls.len() {
return Err(EngineError::param("index", ProblemKind::Invalid(format!("step `{}` has {} rigid walls", step.name, e.rigid_walls.len()))));
}
e.rigid_walls.remove(index);
s.model.replace_step(step)?;
Ok(Value::Null)
}
pub const PAIR: CommandSpec = CommandSpec { pub const PAIR: CommandSpec = CommandSpec {
id: "interaction.contact.pair", id: "interaction.contact.pair",
label: "Contact Pair", label: "Contact Pair",
@@ -322,6 +406,36 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
mod tests { mod tests {
use super::*; use super::*;
/// Rigid walls go into the explicit step (the model's only one, or the one named) and come
/// out by index; a model without an explicit step, or a step of another kind, is refused.
#[test]
fn rigid_walls_belong_to_the_explicit_step() {
let mut s = Session::default();
super::super::define::tests::defined_bar(&mut s);
let wall = json!({"set": "end", "point": [3, 0, 0], "normal": [-1, 0, 0], "friction": {"static": 0.2}});
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "no explicit step yet");
let mut on_bend = wall.clone();
on_bend["step"] = json!("Bend");
assert!(s.execute("interaction.rigid_wall", &on_bend).is_err(), "a static step");
s.execute("model.step.create", &json!({"name": "Hit", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
let r = s.execute("interaction.rigid_wall", &wall).unwrap();
assert_eq!(r, json!({"step": "Hit", "index": 0}));
let walls = |s: &Session| match &s.model.step("Hit").unwrap().procedure {
Procedure::ExplicitDynamic(e) => e.rigid_walls.clone(),
_ => panic!(),
};
assert_eq!(walls(&s).len(), 1);
assert_eq!(walls(&s)[0].set, "end");
for bad in [json!({"set": "nope", "point": [0, 0, 0], "normal": [1, 0, 0]}), json!({"set": "end", "point": [0, 0, 0], "normal": [0, 0, 0]})] {
assert!(s.execute("interaction.rigid_wall", &bad).is_err(), "{bad}");
}
assert!(s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 1})).is_err());
s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 0})).unwrap();
assert!(walls(&s).is_empty());
s.execute("model.step.create", &json!({"name": "Hit2", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "two explicit steps: name one");
}
/// Two unit cubes of 2×2×2 hex8 each, the second resting on the first (separate nodes), and /// Two unit cubes of 2×2×2 hex8 each, the second resting on the first (separate nodes), and
/// a third cube far away. /// a third cube far away.
fn stacked(s: &mut Session) { fn stacked(s: &mut Session) {
+23 -9
View File
@@ -44,7 +44,7 @@ pub const DATUM_AXIS: CommandSpec = CommandSpec {
pub const DATUM_POINT: CommandSpec = CommandSpec { pub const DATUM_POINT: CommandSpec = CommandSpec {
id: "geometry.datum.point", id: "geometry.datum.point",
label: "Datum Point", label: "Datum Point",
params: r#"{"name": "P1", "by": {"type": "coordinates", "point": [x, y, z]} | {"type": "vertex", "body": "Body1", "vertex": "v3"} | {"type": "edge", "body": "Body1", "edge": "F1:top|F2:side", "fraction"?: 0.5} | {"type": "face_centre", "body": "Body1", "face": "F2:top"} | {"type": "axis_plane", "axis": "Spindle", "plane": "Mid"}} — a named datum point, shown in the viewport as a small cross: at coordinates, at a vertex of the mesh (as its geometry tags name it), a fraction along an edge of the open design (0.5: its midpoint), at the area centre of a face of the open design, or where a datum axis crosses a datum plane. Datum planes and axes can be made through datum points"#, params: r#"{"name": "P1", "by": {"type": "coordinates", "point": [x, y, z]} | {"type": "vertex", "body": "Body1", "vertex": "F1:top|F2:side:l1|F2:side:l2"} | {"type": "edge", "body": "Body1", "edge": "F1:top|F2:side", "fraction"?: 0.5} | {"type": "face_centre", "body": "Body1", "face": "F2:top"} | {"type": "axis_plane", "axis": "Spindle", "plane": "Mid"}} — a named datum point, shown in the viewport as a small cross: at coordinates, at a vertex of the open design (its persistent name, as geometry.bodies lists it and Select: Vertices picks it) or of the mesh (as its geometry tags name it), a fraction along an edge of the open design (0.5: its midpoint), at the area centre of a face of the open design, or where a datum axis crosses a datum plane. Datum planes and axes can be made through datum points"#,
form: Some(super::forms::DATUM_POINT), form: Some(super::forms::DATUM_POINT),
kind: CommandKind::Model, kind: CommandKind::Model,
requires: &[], requires: &[],
@@ -296,14 +296,28 @@ fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
let point = match by { let point = match by {
PointBy::Coordinates { point } => point, PointBy::Coordinates { point } => point,
PointBy::Vertex { body, vertex } => { PointBy::Vertex { body, vertex } => {
let m = &s.model; // A vertex of the open design by its persistent name, else one the mesh tags.
let at = fieldcraft_model::GeometryRef::new(body.clone(), fieldcraft_model::Entity::Vertex(vertex.clone()))?; let design_vertex = match s.design.as_deref().and_then(|d| d.body(&body)) {
let tags = m.classification().tags_selected_by(&at); Some(shape) => shape.vertices()?.into_iter().find(|v| v.name == vertex).map(|v| v.point),
let node = None => None,
m.classification().nodes().iter().take(MAX_VERTEX_NODES).find(|(_, t)| tags.contains(t)).and_then(|(n, _)| m.node(*n)).ok_or_else( };
|| EngineError::Params(format!("vertex `{vertex}` of body `{body}` is on no mesh node (vertices are named on meshed bodies)")), match design_vertex {
)?; Some(point) => point,
node.xyz None => {
let m = &s.model;
let at = fieldcraft_model::GeometryRef::new(body.clone(), fieldcraft_model::Entity::Vertex(vertex.clone()))?;
let tags = m.classification().tags_selected_by(&at);
let node = m
.classification()
.nodes()
.iter()
.take(MAX_VERTEX_NODES)
.find(|(_, t)| tags.contains(t))
.and_then(|(n, _)| m.node(*n))
.ok_or_else(|| EngineError::Params(format!("body `{body}` has no vertex `{vertex}` (geometry.bodies lists its vertices)")))?;
node.xyz
}
}
} }
PointBy::Edge { body, edge, fraction } => { PointBy::Edge { body, edge, fraction } => {
if !(0.0..=1.0).contains(&fraction) { if !(0.0..=1.0).contains(&fraction) {
+47 -6
View File
@@ -1050,10 +1050,22 @@ pub const HEX_BLOCKS: &[Field] = &[
FEATURE_ANGLE, FEATURE_ANGLE,
]; ];
const CELL_METHODS: &[(&str, &str)] = &[
("auto", "Auto (hex, else tet)"),
("hex", "Hex"),
("swept", "Swept"),
("extrude", "Extrude"),
("sweep", "Sweep"),
("revolve", "Revolve"),
("mapped", "Mapped"),
("tet", "Tet"),
];
pub const CELLS: &[Field] = &[ pub const CELLS: &[Field] = &[
json("methods", "Methods") references("bodies", "Cells", C::Body)
.help("The meshing method per cell, as JSON.") .help("The cells (bodies) to mesh: tick them, or pick them in the viewport (Select: Bodies) and Use selection. Every body of the design when none is ticked.")
.hint(r#"{"cell": "auto | hex | swept | extrude | sweep | revolve | mapped | tet"}"#), .hint("every body of the design"),
ref_choices("methods", "Methods", C::Body, CELL_METHODS).help("A meshing method for single cells; the others take Other cells' method."),
choice( choice(
"default", "default",
"Other cells", "Other cells",
@@ -1062,7 +1074,6 @@ pub const CELLS: &[Field] = &[
.help("The method for cells not listed in Methods. All hex meshes every cell as a mapped block, with the division counts of all cells assigned together so shared faces match (mesh.hex.blocks).") .help("The method for cells not listed in Methods. All hex meshes every cell as a mapped block, with the division counts of all cells assigned together so shared faces match (mesh.hex.blocks).")
.notes(&[("blocks", "All hex: every cell must have six sides, each with four edges; leave Methods empty.")]) .notes(&[("blocks", "All hex: every cell must have six sides, each with four edges; leave Methods empty.")])
.def("\"auto\""), .def("\"auto\""),
references("bodies", "Cells", C::Body).help("The cells (bodies) to mesh; every body of the design when empty.").hint("every body of the design"),
HEX_SIZE, HEX_SIZE,
HEX_ORDER, HEX_ORDER,
TOLERANCE, TOLERANCE,
@@ -1346,6 +1357,33 @@ pub const DATUM_AXIS: &[Field] = &[
.req() .req()
.def(r#"{"type": "principal"}"#), .def(r#"{"type": "principal"}"#),
]; ];
pub const RIGID_WALL: &[Field] = &[
reference("step", "Step", C::Step)
.help("The explicit dynamics step the wall acts in; the model's only one when empty.")
.hint("the explicit step"),
reference("set", "Set", C::Set).help("The nodes the wall stops: pick them and Use selection.").req(),
xyz("point", "Point", D::Length).help("A point on the wall.").req().def("[0, 0, 0]"),
xyz("normal", "Normal", D::None).help("The wall's normal, pointing to the side the nodes stay on.").req().def("[0, 0, 1]"),
FRICTION,
];
pub const RIGID_WALL_DELETE: &[Field] = &[
reference("step", "Step", C::Step).help("The explicit dynamics step.").req(),
int("index", "Wall", 0, MAX_ROWS as i64).help("The wall's place in the step's rigid walls, from 0.").req(),
];
pub const CENTRE: &[Field] = &[
rows(
"geometry",
"Geometry",
&[
reference("body", "Body", C::Body).help("A body of the open design.").req(),
text("face", "Face").help("A persistent face name of the body."),
text("edge", "Edge").help("A persistent edge name of the body."),
text("vertex", "Vertex").help("A persistent vertex name of the body."),
],
)
.help("Faces or edges (a hole's faces or rim give its centre), or one vertex."),
json("nodes", "Nodes").help("Mesh node labels, as a JSON list: their mean.").hint("[labels]"),
];
pub const DATUM_POINT: &[Field] = &[ pub const DATUM_POINT: &[Field] = &[
NAME, NAME,
variants( variants(
@@ -1355,10 +1393,12 @@ pub const DATUM_POINT: &[Field] = &[
variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]), variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]),
variant( variant(
"vertex", "vertex",
"Mesh vertex", "Vertex",
&[ &[
reference("body", "Body", C::Body).help("The body the vertex belongs to.").req(), reference("body", "Body", C::Body).help("The body the vertex belongs to.").req(),
text("vertex", "Vertex").help("The vertex's name, as the mesh tags it.").req(), text("vertex", "Vertex")
.help("The vertex's persistent name: pick it with Select: Vertices and Use selection (geometry.bodies lists every body's vertices).")
.req(),
], ],
), ),
variant( variant(
@@ -1720,6 +1760,7 @@ mod tests {
"`boundary_conditions` or `loads`", "`boundary_conditions` or `loads`",
"give one of", "give one of",
"is not an explicit", "is not an explicit",
"has no explicit dynamics step",
"has a result", "has a result",
"no crash report", "no crash report",
"FIELDCRAFT_CRASH_TEST", "FIELDCRAFT_CRASH_TEST",
+261 -1
View File
@@ -26,13 +26,23 @@ pub const OPEN: CommandSpec = CommandSpec {
pub const BODIES: CommandSpec = CommandSpec { pub const BODIES: CommandSpec = CommandSpec {
id: "geometry.bodies", id: "geometry.bodies",
label: "Bodies", label: "Bodies",
params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length)", params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length) and vertices (persistent name `<face>|<face>|<face>` after the faces that meet there, `~k` when several touch the same faces, and point)",
form: Some(&[]), form: Some(&[]),
kind: CommandKind::Model, kind: CommandKind::Model,
requires: &[], // No design is an answer too: no bodies. requires: &[], // No design is an answer too: no bodies.
run: bodies, run: bodies,
}; };
pub const CENTRE: CommandSpec = CommandSpec {
id: "geometry.centre",
label: "Centre of Selection",
params: r#"{"geometry": [{"body": "Body1", "face": "F3:hole"} | {"body", "edge"} | {"body", "vertex"}], "nodes": [labels]} — the point a reference point or datum point goes at for what is picked: a vertex's point; the mean of nodes; the centre of a hole (cylindrical faces on one axis, or circular edges on one circle: the axis point at their mid-depth, the circle's centre); otherwise the area centroid of the faces and the length centroid of the edges. Replies `point` and `from` (`vertex`, `nodes`, `circle`, `centroid`)"#,
form: Some(super::forms::CENTRE),
kind: CommandKind::Model,
requires: &[],
run: centre,
};
pub const TESSELLATE: CommandSpec = CommandSpec { pub const TESSELLATE: CommandSpec = CommandSpec {
id: "geometry.tessellate", id: "geometry.tessellate",
label: "Tessellate Body", label: "Tessellate Body",
@@ -145,6 +155,7 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
for b in &design.bodies { for b in &design.bodies {
let m = b.measure()?; let m = b.measure()?;
let edges: Vec<Value> = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect(); let edges: Vec<Value> = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect();
let vertices: Vec<Value> = b.vertices()?.iter().map(|v| json!({"name": v.name, "point": v.point})).collect();
out.push(json!({ out.push(json!({
"name": b.name, "name": b.name,
"volume": m.volume, "volume": m.volume,
@@ -153,11 +164,193 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
"bbox": [m.bbox_min, m.bbox_max], "bbox": [m.bbox_min, m.bbox_max],
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(), "faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
"edges": edges, "edges": edges,
"vertices": vertices,
})); }));
} }
Ok(Value::Array(out)) Ok(Value::Array(out))
} }
/// Most geometry items and nodes `geometry.centre` takes.
const MAX_CENTRE_ITEMS: usize = 10_000;
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale3(a: [f64; 3], k: f64) -> [f64; 3] {
a.map(|v| v * k)
}
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// The centre of the circle through three points, `None` when they lie on a line.
fn circumcentre(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Option<[f64; 3]> {
let (u, v) = (sub3(b, a), sub3(c, a));
let n = cross3(u, v);
let nn = dot3(n, n);
if !(nn > 1e-24 * dot3(u, u) * dot3(v, v)) {
return None;
}
let k = add3(scale3(cross3(n, u), dot3(v, v)), scale3(cross3(v, n), dot3(u, u)));
Some(add3(a, scale3(k, 0.5 / nn)))
}
/// The centre of an edge's polyline when it is an arc or circle (every point at one distance
/// from it, within a thousandth), else `None`.
fn circle_centre(points: &[[f64; 3]]) -> Option<[f64; 3]> {
let n = points.len();
if n < 3 {
return None;
}
let closed = points.first().zip(points.last()).is_some_and(|(a, b)| dot3(sub3(*a, *b), sub3(*a, *b)) < 1e-18);
let pick = |f: f64| points.get(((n - 1) as f64 * f).round() as usize).copied();
let (a, b, c) = if closed { (pick(0.0)?, pick(1.0 / 3.0)?, pick(2.0 / 3.0)?) } else { (pick(0.0)?, pick(0.5)?, pick(1.0)?) };
let centre = circumcentre(a, b, c)?;
let r = dot3(sub3(a, centre), sub3(a, centre)).sqrt();
let on = points.iter().all(|p| (dot3(sub3(*p, centre), sub3(*p, centre)).sqrt() - r).abs() <= 1e-3 * r);
on.then_some(centre)
}
/// The length-weighted centre of a polyline and its length.
fn polyline_centroid(points: &[[f64; 3]]) -> ([f64; 3], f64) {
let (mut sum, mut length) = ([0.0; 3], 0.0);
for w in points.windows(2) {
let l = dot3(sub3(w[1], w[0]), sub3(w[1], w[0])).sqrt();
sum = add3(sum, scale3(add3(w[0], w[1]), 0.5 * l));
length += l;
}
(if length > 0.0 { scale3(sum, 1.0 / length) } else { points.first().copied().unwrap_or_default() }, length)
}
fn centre(s: &mut Session, p: &Value) -> Result<Value> {
let geometry: Vec<GeometryRef> = super::opt(p, "geometry")?.unwrap_or_default();
let nodes: Vec<u32> = super::opt(p, "nodes")?.unwrap_or_default();
if geometry.len() + nodes.len() > MAX_CENTRE_ITEMS {
return Err(EngineError::Params(format!("geometry.centre takes at most {MAX_CENTRE_ITEMS} items")));
}
if geometry.is_empty() && nodes.is_empty() {
return Err(EngineError::Params("give one of `geometry` or `nodes`: what to find the centre of".into()));
}
if !geometry.is_empty() && !nodes.is_empty() {
return Err(EngineError::Params("give either `geometry` or `nodes`, not both".into()));
}
if !nodes.is_empty() {
let mut sum = [0.0; 3];
for n in &nodes {
let node = s.model.node(*n).ok_or_else(|| EngineError::Params(format!("no node {n}")))?;
sum = add3(sum, node.xyz);
}
return Ok(json!({"point": scale3(sum, 1.0 / nodes.len() as f64), "from": "nodes"}));
}
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let shape = |b: &str| design.body(b).ok_or_else(|| EngineError::Params(format!("the design has no body `{b}`")));
if let [g] = geometry.as_slice()
&& let Entity::Vertex(v) = &g.entity
{
let vertex = shape(&g.body)?.vertices()?.into_iter().find(|x| &x.name == v);
let vertex = vertex.ok_or_else(|| EngineError::Params(format!("body `{}` has no vertex `{v}`", g.body)))?;
return Ok(json!({"point": vertex.point, "from": "vertex"}));
}
// Hole centres: every face a cylinder about one axis, or every edge an arc of one circle.
let mut circles: Vec<[f64; 3]> = Vec::new();
let mut axis: Option<([f64; 3], [f64; 3])> = None;
let mut round = true;
// Centroids, weighted by area (faces) or length (edges).
let (mut faces_sum, mut faces_area, mut edges_sum, mut edges_length) = ([0.0; 3], 0.0, [0.0; 3], 0.0);
for g in &geometry {
let body = shape(&g.body)?;
match &g.entity {
Entity::Face(f) => {
let i = body.face_index(f).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{f}`", g.body)))?;
let c = body.face_centre(f)?;
let mesh = body.tessellate((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4))?;
let area: f64 = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, t)| **t == i)
.filter_map(|(t, _)| {
let q = |k: usize| mesh.positions.get(t[k] as usize).copied();
let (a, b, c) = (q(0)?, q(1)?, q(2)?);
let n = cross3(sub3(b, a), sub3(c, a));
Some(0.5 * dot3(n, n).sqrt())
})
.sum();
faces_sum = add3(faces_sum, scale3(c, area));
faces_area += area;
match body.face_surfaces()?.get(i as usize).copied().flatten() {
Some(fieldcraft_geometry::FaceSurface::Cylinder { origin, axis: d, .. }) => {
let len = dot3(d, d).sqrt();
let d = scale3(d, 1.0 / len.max(1e-300));
// The face centre's foot on the axis.
let foot = add3(origin, scale3(d, dot3(sub3(c, origin), d)));
let same = axis.is_none_or(|(o, a)| {
let off = sub3(foot, o);
let across = sub3(off, scale3(a, dot3(off, a)));
dot3(cross3(a, d), cross3(a, d)) < 1e-12 && dot3(across, across).sqrt() <= 1e-6 * body.size().unwrap_or(1.0).max(1e-9)
});
round &= same;
axis.get_or_insert((foot, d));
circles.push(foot);
}
_ => round = false,
}
}
Entity::Edge(e) => {
let edges = body.edges()?;
let edge = edges.iter().find(|x| &x.name == e).ok_or_else(|| EngineError::Params(format!("body `{}` has no edge `{e}`", g.body)))?;
let (c, l) = polyline_centroid(&edge.points);
edges_sum = add3(edges_sum, scale3(c, l));
edges_length += l;
match circle_centre(&edge.points) {
Some(c) => circles.push(c),
None => round = false,
}
}
Entity::Vertex(_) | Entity::Body => {
return Err(EngineError::Params("geometry.centre takes one vertex, or faces and edges".into()));
}
}
}
// Arcs of one circle share a centre; faces of one axis were checked as they came.
let spread = circles.iter().map(|c| circles.first().map_or(0.0, |f| dot3(sub3(*c, *f), sub3(*c, *f)).sqrt())).fold(0.0f64, f64::max);
let size = geometry.first().and_then(|g| design.body(&g.body)).and_then(|b| b.size().ok()).unwrap_or(1.0).max(1e-9);
let edges_only = geometry.iter().all(|g| matches!(g.entity, Entity::Edge(_)));
if round && !circles.is_empty() && (!edges_only || spread <= 1e-6 * size) {
let point = match axis {
// Cylinder faces: the axis point at the mean of their centres' feet.
Some((_, d)) => {
let mean = scale3(circles.iter().fold([0.0; 3], |a, c| add3(a, *c)), 1.0 / circles.len() as f64);
let o = circles.first().copied().unwrap_or(mean);
add3(o, scale3(d, dot3(sub3(mean, o), d)))
}
None => circles.first().copied().unwrap_or_default(),
};
return Ok(json!({"point": point, "from": "circle"}));
}
let point = if faces_area > 0.0 {
scale3(faces_sum, 1.0 / faces_area)
} else if edges_length > 0.0 {
scale3(edges_sum, 1.0 / edges_length)
} else {
return Err(EngineError::Params("what is picked has no area or length to find a centre of".into()));
};
if !point.iter().all(|v| v.is_finite()) {
return Err(EngineError::Params("the centre is not a finite point".into()));
}
Ok(json!({"point": point, "from": "centroid"}))
}
fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> { fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?; let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)"))) s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
@@ -214,6 +407,73 @@ mod tests {
zip.finish().unwrap().into_inner() zip.finish().unwrap().into_inner()
} }
/// The plate's hole (Ø10 at x = y = 15 through z 0..10): its faces give the axis point at
/// mid-depth, a rim arc its circle's centre, a corner vertex its point, a flat face its
/// centroid; datum points take the design's vertices by name.
#[test]
fn centres_of_holes_vertices_and_faces() {
use fieldcraft_geometry::FaceSurface;
use fieldcraft_geometry::sample::{Plate, plate_design};
let design = Arc::new(open_design(&plate_design(&Plate::default()).unwrap()).unwrap());
let mut s = Session { design: Some(design.clone()), ..Session::default() };
let body = &design.bodies[0];
let near = |v: &Value, p: [f64; 3]| {
let q: [f64; 3] = serde_json::from_value(v["point"].clone()).unwrap();
assert!((0..3).all(|k| (q[k] - p[k]).abs() < 1e-6), "{v} vs {p:?}");
};
let surfaces = body.face_surfaces().unwrap();
let hole: Vec<Value> = body
.faces()
.iter()
.zip(&surfaces)
.filter(|(_, s)| matches!(s, Some(FaceSurface::Cylinder { .. })))
.map(|(f, _)| json!({"body": body.name, "face": f.name}))
.collect();
assert_eq!(hole.len(), 2);
let r = s.execute("geometry.centre", &json!({"geometry": hole})).unwrap();
near(&r, [15.0, 15.0, 5.0]);
assert_eq!(r["from"], "circle");
let r = s.execute("geometry.centre", &json!({"geometry": [hole[0]]})).unwrap();
near(&r, [15.0, 15.0, 5.0]);
// A rim arc on top.
let edges = body.edges().unwrap();
let arc = edges.iter().find(|e| e.points.iter().all(|p| (p[2] - 10.0).abs() < 1e-9) && e.points.len() > 3).unwrap();
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "edge": arc.name}]})).unwrap();
near(&r, [15.0, 15.0, 10.0]);
assert_eq!(r["from"], "circle");
// The corner at the origin, and a datum point on it by name.
let corner = body.vertices().unwrap().into_iter().find(|v| v.point == [0.0; 3]).unwrap();
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "vertex": corner.name}]})).unwrap();
near(&r, [0.0; 3]);
s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "vertex", "body": body.name, "vertex": corner.name}})).unwrap();
assert_eq!(s.model.datum("P").and_then(|d| d.point()), Some([0.0; 3]));
let listed = s.execute("geometry.bodies", &json!({})).unwrap();
assert!(listed[0]["vertices"].as_array().unwrap().iter().any(|v| v["name"] == corner.name.as_str()), "{listed}");
// The bottom face (z = 0): its centroid, off the hole.
let bottom = body
.faces()
.iter()
.zip(&surfaces)
.find(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, point }) if normal[2].abs() > 0.99 && point[2].abs() < 1e-9))
.unwrap()
.0;
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "face": bottom.name}]})).unwrap();
assert_eq!(r["from"], "centroid");
// Nodes, and what is refused.
s.model.set_node(1, [1.0, 2.0, 3.0]).unwrap();
s.model.set_node(2, [3.0, 2.0, 1.0]).unwrap();
near(&s.execute("geometry.centre", &json!({"nodes": [1, 2]})).unwrap(), [2.0, 2.0, 2.0]);
for bad in [
json!({}),
json!({"nodes": [99]}),
json!({"geometry": [{"body": "nope"}]}),
json!({"geometry": [{"body": body.name}]}),
json!({"nodes": "x"}),
] {
assert!(s.execute("geometry.centre", &bad).is_err(), "{bad}");
}
}
#[test] #[test]
fn import_adds_tagged_surfaces_and_failures_change_nothing() { fn import_adds_tagged_surfaces_and_failures_change_nothing() {
let dir = std::env::temp_dir().join(format!("fieldcraft-import-mesh-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("fieldcraft-import-mesh-{}", std::process::id()));
+3
View File
@@ -42,6 +42,7 @@ pub static ALL: &[CommandSpec] = &[
model::INFO, model::INFO,
geometry::OPEN, geometry::OPEN,
geometry::BODIES, geometry::BODIES,
geometry::CENTRE,
geometry::TESSELLATE, geometry::TESSELLATE,
geometry::IMPORT_MESH, geometry::IMPORT_MESH,
datum::DATUM_PLANE, datum::DATUM_PLANE,
@@ -107,6 +108,8 @@ pub static ALL: &[CommandSpec] = &[
contact::TIE, contact::TIE,
contact::TIE_EDIT, contact::TIE_EDIT,
contact::TIE_DELETE, contact::TIE_DELETE,
contact::RIGID_WALL,
contact::RIGID_WALL_DELETE,
define::MASS_CREATE, define::MASS_CREATE,
define::MASS_EDIT, define::MASS_EDIT,
define::MASS_DELETE, define::MASS_DELETE,
+20 -1
View File
@@ -74,6 +74,9 @@ pub enum Kind {
Ref(Collection), Ref(Collection),
/// A list of such names. /// A list of such names.
RefList(Collection), RefList(Collection),
/// A choice per item: an object from the names of a collection's items to one of these
/// strings (value, label); an item left out takes the command's default.
RefChoices(Collection, &'static [(&'static str, &'static str)]),
/// A nested object. /// A nested object.
Group(&'static [Field]), Group(&'static [Field]),
/// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value /// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value
@@ -429,6 +432,10 @@ pub const fn reference(key: &'static str, label: &'static str, c: Collection) ->
pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field { pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field {
field(key, label, Kind::RefList(c)) field(key, label, Kind::RefList(c))
} }
pub const fn ref_choices(key: &'static str, label: &'static str, c: Collection, options: &'static [(&'static str, &'static str)]) -> Field {
field(key, label, Kind::RefChoices(c, options))
}
pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field { pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
field(key, label, Kind::Group(fields)) field(key, label, Kind::Group(fields))
} }
@@ -659,7 +666,9 @@ impl Problem {
ProblemKind::NotText => "text".into(), ProblemKind::NotText => "text".into(),
ProblemKind::NotABool => "true or false".into(), ProblemKind::NotABool => "true or false".into(),
ProblemKind::NotAChoice => match f.map(|f| f.kind) { ProblemKind::NotAChoice => match f.map(|f| f.kind) {
Some(Kind::Choice(options)) => format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", ")), Some(Kind::Choice(options) | Kind::RefChoices(_, options)) => {
format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", "))
}
Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")), Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")),
_ => "one of the choices".into(), _ => "one of the choices".into(),
}, },
@@ -914,6 +923,11 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
_ => Some(ProblemKind::NotAVector { len: labels.len() }), _ => Some(ProblemKind::NotAVector { len: labels.len() }),
}, },
Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then_some(ProblemKind::NotNames), Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then_some(ProblemKind::NotNames),
Kind::RefChoices(_, options) => match v.as_object() {
Some(o) if o.values().all(|x| x.as_str().is_some_and(|s| options.iter().any(|(opt, _)| *opt == s))) => None,
Some(_) => Some(ProblemKind::NotAChoice),
None => Some(ProblemKind::NotAnObject),
},
Kind::Group(fields) => match v.as_object() { Kind::Group(fields) => match v.as_object() {
Some(o) => { Some(o) => {
check(fields, o, path, out, depth + 1); check(fields, o, path, out, depth + 1);
@@ -1035,6 +1049,11 @@ fn field_schema(f: &Field, depth: usize) -> Value {
} }
Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}), Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}),
Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}), Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}),
Kind::RefChoices(c, options) => json!({
"type": "object",
"x-references": c.label(),
"additionalProperties": {"enum": options.iter().map(|(o, _)| *o).collect::<Vec<_>>()},
}),
Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1), Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1),
Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}), Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}),
Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind { Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind {
+13
View File
@@ -148,6 +148,19 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String {
Kind::Vector { labels, .. } => vector(labels, v, unit.as_deref()), Kind::Vector { labels, .. } => vector(labels, v, unit.as_deref()),
Kind::Ref(_) | Kind::Text | Kind::Path { .. } => v.as_str().map_or_else(|| crate::context::value_text(v), str::to_string), Kind::Ref(_) | Kind::Text | Kind::Path { .. } => v.as_str().map_or_else(|| crate::context::value_text(v), str::to_string),
Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)), Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)),
// `Bracket.3: Tet, Bracket.7: Swept`.
Kind::RefChoices(_, options) => v.as_object().map_or_else(
|| crate::context::value_text(v),
|o| {
o.iter()
.map(|(k, x)| {
let s = x.as_str().unwrap_or_default();
format!("{k}: {}", options.iter().find(|(o, _)| *o == s).map_or(s, |(_, l)| *l))
})
.collect::<Vec<_>>()
.join(", ")
},
),
Kind::Either(options) => match v { Kind::Either(options) => match v {
Value::String(s) => options.iter().find(|o| o.key == s).map_or_else(|| s.clone(), |o| o.label.to_string()), Value::String(s) => options.iter().find(|o| o.key == s).map_or_else(|| s.clone(), |o| o.label.to_string()),
Value::Object(o) => o Value::Object(o) => o
+62 -3
View File
@@ -50,6 +50,8 @@ pub struct Outcome {
pub create: Option<(Collection, String)>, pub create: Option<(Collection, String)>,
/// The paths of the fields edited this frame. /// The paths of the fields edited this frame.
pub touched: Vec<String>, pub touched: Vec<String>,
/// The body whose row in a list of bodies is under the pointer (the viewport highlights it).
pub hover_body: Option<String>,
} }
struct Form<'a> { struct Form<'a> {
@@ -298,6 +300,7 @@ impl Form<'_> {
Kind::Vector { labels, nullable, .. } => self.vector(ui, id, value, f, labels, nullable), Kind::Vector { labels, nullable, .. } => self.vector(ui, id, value, f, labels, nullable),
Kind::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required), Kind::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required),
Kind::RefList(c) => self.reference_list(ui, path, value, c), Kind::RefList(c) => self.reference_list(ui, path, value, c),
Kind::RefChoices(c, options) => self.reference_choices(ui, id, value, c, options),
Kind::Either(options) => { Kind::Either(options) => {
let key = match value { let key = match value {
Some(Value::String(s)) => Some(s.as_str()), Some(Value::String(s)) => Some(s.as_str()),
@@ -431,18 +434,74 @@ impl Form<'_> {
let chosen: Vec<String> = let chosen: Vec<String> =
value.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); value.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let names = self.names(c).to_vec(); let names = self.names(c).to_vec();
let shown = names.get(..names.len().min(MAX_ROWS)).unwrap_or_default();
let mut changed = None; let mut changed = None;
let list = |picked: Vec<String>| if picked.is_empty() { Value::Null } else { json!(picked) };
ui.vertical(|ui| { ui.vertical(|ui| {
if names.is_empty() { if names.is_empty() {
ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim)); ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim));
self.create_button(ui, c, path); self.create_button(ui, c, path);
} }
for n in names.iter().take(MAX_ROWS) { // All, none and the others, for long lists.
if shown.len() > 3 {
ui.horizontal(|ui| {
if ui.small_button("All").clicked() {
changed = Some(list(shown.to_vec()));
}
if ui.small_button("None").clicked() {
changed = Some(Value::Null);
}
if ui.small_button("Invert").on_hover_text("Tick the unticked ones and untick the others").clicked() {
changed = Some(list(shown.iter().filter(|n| !chosen.contains(n)).cloned().collect()));
}
});
}
for n in shown {
let mut on = chosen.contains(n); let mut on = chosen.contains(n);
if ui.checkbox(&mut on, n).changed() { let r = ui.checkbox(&mut on, n);
if r.hovered() && c == Collection::Body {
self.out.hover_body = Some(n.clone());
}
if r.changed() {
let mut next: Vec<String> = names.iter().filter(|m| if *m == n { on } else { chosen.contains(m) }).cloned().collect(); let mut next: Vec<String> = names.iter().filter(|m| if *m == n { on } else { chosen.contains(m) }).cloned().collect();
next.dedup(); next.dedup();
changed = Some(if next.is_empty() { Value::Null } else { json!(next) }); changed = Some(list(next));
}
}
});
changed
}
/// A drop-down per item of `c` (its row hovered highlights a body), "(default)" leaving it
/// out of the object.
fn reference_choices(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, c: Collection, options: &[(&str, &str)]) -> Option<Value> {
let current = value.and_then(Value::as_object).cloned().unwrap_or_default();
let names = self.names(c).to_vec();
let mut labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
labels.push("(default)");
let mut changed = None;
ui.vertical(|ui| {
if names.is_empty() {
ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim));
}
for (i, n) in names.iter().take(MAX_ROWS).enumerate() {
let chosen = current.get(n).and_then(Value::as_str);
let selected = options.iter().find(|(v, _)| Some(*v) == chosen).map_or("(default)", |(_, l)| *l);
let row = ui.horizontal(|ui| {
let width = (ui.available_width() * 0.5).max(FIELD_MIN);
ui.add_sized([width, ui.spacing().interact_size.y], egui::Label::new(n.as_str()).truncate());
fields::combo(ui, id.with(i), selected, &labels, fill(ui))
});
if row.response.contains_pointer() && c == Collection::Body {
self.out.hover_body = Some(n.clone());
}
if let Some(k) = row.inner {
let mut next = current.clone();
match options.get(k) {
Some((v, _)) => next.insert(n.clone(), json!(v)),
None => next.remove(n),
};
changed = Some(if next.is_empty() { Value::Null } else { Value::Object(next) });
} }
} }
}); });
+3 -3
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.
68 ready, 41 coming soon. 69 ready, 40 coming soon.
## Geometry ## Geometry
@@ -14,7 +14,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|---|---|---|---| |---|---|---|---|
| Import | Open Design — Open a SolidCraft design: its bodies with persistent face names | ready | `geometry.open` | | Import | Open Design — Open a SolidCraft design: its bodies with persistent face names | ready | `geometry.open` |
| Import | Tagged Mesh — Read a face-tagged surface mesh (3MF) | ready | `geometry.import.mesh` | | Import | Tagged Mesh — Read a face-tagged surface mesh (3MF) | ready | `geometry.import.mesh` |
| Inspect | Bodies — List the open design's bodies, volumes and named faces | ready | `geometry.bodies` | | Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre` |
| Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` | | Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` |
| Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` | | Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` |
| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`) | | Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`) |
@@ -109,7 +109,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Contact | General Contact — Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics and nonlinear static steps) | ready | `interaction.contact.general`, `interaction.contact.edit`, `interaction.contact.delete` | | Contact | General Contact — Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics and nonlinear static steps) | ready | `interaction.contact.general`, `interaction.contact.edit`, `interaction.contact.delete` |
| Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps | ready | `interaction.contact.pair` | | Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps | ready | `interaction.contact.pair` |
| Contact | Find Contacts — Find the parts of the mesh that touch, and make contact pairs of them | ready | `interaction.contact.find` | | Contact | Find Contacts — Find the parts of the mesh that touch, and make contact pairs of them | ready | `interaction.contact.find` |
| Contact | Rigid Wall — Rigid walls for explicit dynamics | coming soon (M10) | | | Contact | Rigid Wall — A fixed rigid plane a set's nodes cannot pass, with friction, in an explicit dynamics step | ready | `interaction.rigid_wall`, `interaction.rigid_wall.delete` |
| Connectors | Bolt Load — Bolt pretension | coming soon (M7) | | | Connectors | Bolt Load — Bolt pretension | coming soon (M7) | |
| Connectors | Spring — Spring or dashpot to the ground or between two points, along a direction or their line | ready | `model.spring.create`, `model.spring.edit`, `model.spring.delete` | | Connectors | Spring — Spring or dashpot to the ground or between two points, along a direction or their line | ready | `model.spring.create`, `model.spring.edit`, `model.spring.delete` |
| Connectors | Point Mass — Point mass and rotary inertia at nodes or a reference point | ready | `model.mass.create`, `model.mass.edit`, `model.mass.delete` | | Connectors | Point Mass — Point mass and rotary inertia at nodes or a reference point | ready | `model.mass.create`, `model.mass.edit`, `model.mass.delete` |