forked from 1b-agent/fieldcraft-upstream
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:
1 parent
e8e0b92d87
commit
1372094f8a
10 files changed
+564
-27
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct Outcome {
|
||||
pub create: Option<(Collection, String)>,
|
||||
/// The paths of the fields edited this frame.
|
||||
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> {
|
||||
@@ -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<String> =
|
||||
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<String>| 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<String> = 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<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
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user