From 1372094f8a7af16bd9d74ff0bc558faa251921ee Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:19:40 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 18 +- crates/engine/src/cmd/contact.rs | 118 +++++++++++++- crates/engine/src/cmd/datum.rs | 32 +++- crates/engine/src/cmd/forms.rs | 53 +++++- crates/engine/src/cmd/geometry.rs | 262 +++++++++++++++++++++++++++++- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/schema.rs | 21 ++- crates/ui-egui/src/describe.rs | 13 ++ crates/ui-egui/src/form.rs | 65 +++++++- docs/features.md | 6 +- 10 files changed, 564 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index c3e89f6..5d0b3b3 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -238,7 +238,14 @@ pub static CATALOGUE: &[Capability] = &[ // ---- Geometry ---- 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.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( "mesh.surface.check", @@ -493,7 +500,14 @@ pub static CATALOGUE: &[Capability] = &[ "Contact", "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"), ready_more( "model.spring.create", diff --git a/crates/engine/src/cmd/contact.rs b/crates/engine/src/cmd/contact.rs index 1a65efe..e942f33 100644 --- a/crates/engine/src/cmd/contact.rs +++ b/crates/engine/src/cmd/contact.rs @@ -1,5 +1,7 @@ //! `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 //! ([`fieldcraft_model::contact`]). The commands @@ -16,7 +18,7 @@ use crate::schema::ProblemKind; use std::collections::BTreeMap; 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 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)) } +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 { + 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 { + 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::(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 { + 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 { id: "interaction.contact.pair", label: "Contact Pair", @@ -322,6 +406,36 @@ fn find(s: &mut Session, p: &Value) -> Result { mod tests { 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 /// a third cube far away. fn stacked(s: &mut Session) { diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index b22ad17..44c9800 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -44,7 +44,7 @@ pub const DATUM_AXIS: CommandSpec = CommandSpec { pub const DATUM_POINT: CommandSpec = CommandSpec { id: "geometry.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), kind: CommandKind::Model, requires: &[], @@ -296,14 +296,28 @@ fn point_datum(s: &Session, by: PointBy) -> Result { let point = match by { PointBy::Coordinates { point } => point, PointBy::Vertex { body, vertex } => { - 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!("vertex `{vertex}` of body `{body}` is on no mesh node (vertices are named on meshed bodies)")), - )?; - node.xyz + // A vertex of the open design by its persistent name, else one the mesh tags. + let design_vertex = match s.design.as_deref().and_then(|d| d.body(&body)) { + Some(shape) => shape.vertices()?.into_iter().find(|v| v.name == vertex).map(|v| v.point), + None => None, + }; + match design_vertex { + Some(point) => point, + 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 } => { if !(0.0..=1.0).contains(&fraction) { diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index c1f03b7..7584d2d 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1050,10 +1050,22 @@ pub const HEX_BLOCKS: &[Field] = &[ 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] = &[ - json("methods", "Methods") - .help("The meshing method per cell, as JSON.") - .hint(r#"{"cell": "auto | hex | swept | extrude | sweep | revolve | mapped | tet"}"#), + references("bodies", "Cells", C::Body) + .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("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( "default", "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).") .notes(&[("blocks", "All hex: every cell must have six sides, each with four edges; leave Methods empty.")]) .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_ORDER, TOLERANCE, @@ -1346,6 +1357,33 @@ pub const DATUM_AXIS: &[Field] = &[ .req() .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] = &[ NAME, variants( @@ -1355,10 +1393,12 @@ pub const DATUM_POINT: &[Field] = &[ variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]), variant( "vertex", - "Mesh vertex", + "Vertex", &[ 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( @@ -1720,6 +1760,7 @@ mod tests { "`boundary_conditions` or `loads`", "give one of", "is not an explicit", + "has no explicit dynamics step", "has a result", "no crash report", "FIELDCRAFT_CRASH_TEST", diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 9f0d53a..89c7b8d 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -26,13 +26,23 @@ pub const OPEN: CommandSpec = CommandSpec { pub const BODIES: CommandSpec = CommandSpec { id: "geometry.bodies", label: "Bodies", - params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `|`, `~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 `|`, `~k` when several separate the same faces, and length) and vertices (persistent name `||` after the faces that meet there, `~k` when several touch the same faces, and point)", form: Some(&[]), kind: CommandKind::Model, requires: &[], // No design is an answer too: no 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 { id: "geometry.tessellate", label: "Tessellate Body", @@ -145,6 +155,7 @@ fn bodies(s: &mut Session, _: &Value) -> Result { for b in &design.bodies { let m = b.measure()?; let edges: Vec = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect(); + let vertices: Vec = b.vertices()?.iter().map(|v| json!({"name": v.name, "point": v.point})).collect(); out.push(json!({ "name": b.name, "volume": m.volume, @@ -153,11 +164,193 @@ fn bodies(s: &mut Session, _: &Value) -> Result { "bbox": [m.bbox_min, m.bbox_max], "faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::>(), "edges": edges, + "vertices": vertices, })); } 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 { + let geometry: Vec = super::opt(p, "geometry")?.unwrap_or_default(); + let nodes: Vec = 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> { 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)"))) @@ -214,6 +407,73 @@ mod tests { 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 = 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] fn import_adds_tagged_surfaces_and_failures_change_nothing() { let dir = std::env::temp_dir().join(format!("fieldcraft-import-mesh-{}", std::process::id())); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b081a4..99f2585 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -42,6 +42,7 @@ pub static ALL: &[CommandSpec] = &[ model::INFO, geometry::OPEN, geometry::BODIES, + geometry::CENTRE, geometry::TESSELLATE, geometry::IMPORT_MESH, datum::DATUM_PLANE, @@ -107,6 +108,8 @@ pub static ALL: &[CommandSpec] = &[ contact::TIE, contact::TIE_EDIT, contact::TIE_DELETE, + contact::RIGID_WALL, + contact::RIGID_WALL_DELETE, define::MASS_CREATE, define::MASS_EDIT, define::MASS_DELETE, diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index 0081250..ca5b2bf 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -74,6 +74,9 @@ pub enum Kind { Ref(Collection), /// A list of such names. 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. Group(&'static [Field]), /// 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 { 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 { field(key, label, Kind::Group(fields)) } @@ -659,7 +666,9 @@ impl Problem { ProblemKind::NotText => "text".into(), ProblemKind::NotABool => "true or false".into(), ProblemKind::NotAChoice => match f.map(|f| f.kind) { - Some(Kind::Choice(options)) => format!("one of {}", options.iter().map(|(o, _)| *o).collect::>().join(", ")), + Some(Kind::Choice(options) | Kind::RefChoices(_, options)) => { + format!("one of {}", options.iter().map(|(o, _)| *o).collect::>().join(", ")) + } Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::>().join(", ")), _ => "one of the choices".into(), }, @@ -914,6 +923,11 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec 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::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() { Some(o) => { 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::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::>()}, + }), 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::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind { diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index 3951bb9..d02ca05 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -148,6 +148,19 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { 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::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::>() + .join(", ") + }, + ), 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::Object(o) => o diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 7438a55..647ae60 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -50,6 +50,8 @@ pub struct Outcome { pub create: Option<(Collection, String)>, /// The paths of the fields edited this frame. pub touched: Vec, + /// The body whose row in a list of bodies is under the pointer (the viewport highlights it). + pub hover_body: Option, } struct Form<'a> { @@ -298,6 +300,7 @@ impl Form<'_> { 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::RefList(c) => self.reference_list(ui, path, value, c), + Kind::RefChoices(c, options) => self.reference_choices(ui, id, value, c, options), Kind::Either(options) => { let key = match value { Some(Value::String(s)) => Some(s.as_str()), @@ -431,18 +434,74 @@ impl Form<'_> { let chosen: Vec = 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 shown = names.get(..names.len().min(MAX_ROWS)).unwrap_or_default(); let mut changed = None; + let list = |picked: Vec| if picked.is_empty() { Value::Null } else { json!(picked) }; ui.vertical(|ui| { if names.is_empty() { ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim)); 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); - 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 = names.iter().filter(|m| if *m == n { on } else { chosen.contains(m) }).cloned().collect(); 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 { + 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) }); } } }); diff --git a/docs/features.md b/docs/features.md index 0de5312..2c01efb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -68 ready, 41 coming soon. +69 ready, 40 coming soon. ## 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 | 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 | 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`) | @@ -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 | 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 | 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 | 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` |