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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-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 ----
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",
+116 -2
View File
@@ -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<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 {
id: "interaction.contact.pair",
label: "Contact Pair",
@@ -322,6 +406,36 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
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) {
+23 -9
View File
@@ -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<Datum> {
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) {
+47 -6
View File
@@ -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",
+261 -1
View File
@@ -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 `<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(&[]),
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<Value> {
for b in &design.bodies {
let m = b.measure()?;
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!({
"name": b.name,
"volume": m.volume,
@@ -153,11 +164,193 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
"bbox": [m.bbox_min, m.bbox_max],
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
"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<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> {
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<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]
fn import_adds_tagged_surfaces_and_failures_change_nothing() {
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,
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,
+20 -1
View File
@@ -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::<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(", ")),
_ => "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() }),
},
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::<Vec<_>>()},
}),
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 {