Engine: geometry.datum.point; planes and axes through datum points; datum edits and cascading delete

A datum point by coordinates, at a mesh vertex, a fraction along a
design edge, at a face's centre, or where a datum axis crosses a datum
plane. Datum planes take three datum points or a normal to a datum axis;
axes take two datum points. geometry.datum.edit, .axis.edit and
.point.edit redefine a datum of their kind (partitions cut again);
geometry.datum.delete with cascade also deletes the partitions that cut
by it. The ribbon shows Datum Plane, Datum Axis, Datum Point and
Partition, each with its own icon; edit and delete leave the ribbon
(still commands, claimed by their entries).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 23:51:01 -04:00
1 parent fbaba5fecd
commit d9824de4a5
14 files changed
+513 -36

No files matched your search

+2
View File
@@ -38,6 +38,8 @@ is committed and listed here with its exports.
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.axis.svg, assets/icons/geometry.datum.axis.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.axis.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.point.svg, assets/icons/geometry.datum.point.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.point.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.import.mesh.svg, assets/icons/geometry.import.mesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.import.mesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.midsurface.svg, assets/icons/geometry.midsurface.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.midsurface.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.datum.axis</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M7 17 L19 5" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 4 L15 5.2 M20 4 L18.8 9" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 18.5 L2.5 21.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
<path d="M8.8 12 C8.8 10.233 10.233 8.8 12 8.8 C13.767 8.8 15.2 10.233 15.2 12 C15.2 13.767 13.767 15.2 12 15.2 C10.233 15.2 8.8 13.767 8.8 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6 12 C10.6 11.227 11.227 10.6 12 10.6 C12.773 10.6 13.4 11.227 13.4 12 C13.4 12.773 12.773 13.4 12 13.4 C11.227 13.4 10.6 12.773 10.6 12 Z" fill="#f0b44a"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.datum.point</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 16 L9 10 L21 10 L15 16 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 11 M8 7 L16 7" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.2 7 C10.2 6.006 11.006 5.2 12 5.2 C12.994 5.2 13.8 6.006 13.8 7 C13.8 7.994 12.994 8.8 12 8.8 C11.006 8.8 10.2 7.994 10.2 7 Z" fill="#f0b44a"/>
<path d="M12 12.5 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.
@@ -0,0 +1 @@
{"shapes": [{"d": "M7 17 L19 5", "stroke": "accent", "width": 2}, {"d": "M20 4 L15 5.2 M20 4 L18.8 9", "stroke": "accent", "width": 2}, {"d": "M5.5 18.5 L2.5 21.5", "stroke": "line", "dash": [1.5, 2]}, {"circle": [12, 12, 3.2], "fill": "soft", "stroke": "line", "width": 1.25}, {"circle": [12, 12, 1.4], "fill": "warm", "stroke": null}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 16 L9 10 L21 10 L15 16 Z", "fill": "soft", "stroke": "line", "width": 1.25}, {"d": "M12 3 L12 11 M8 7 L16 7", "stroke": "accent", "width": 2}, {"circle": [12, 7, 1.8], "fill": "warm", "stroke": null}, {"d": "M12 12.5 L12 20", "stroke": "line", "dash": [1.5, 2]}]}
+24 -6
View File
@@ -155,8 +155,9 @@ pub struct Capability {
pub status: Status,
/// Related commands in the entry's drop-down (edit, delete…).
pub more: &'static [&'static str],
/// Commands the entry's own form covers (an option of it does the same): claimed by the entry
/// for the command search and the ribbon's stage, but not listed in the ribbon.
/// Commands the entry's own form covers (an option of it does the same), or that act on what
/// it makes from the browser's item menu (edit, delete): claimed by the entry for the command
/// search and the ribbon's stage, but not listed in the ribbon.
pub covers: &'static [&'static str],
}
@@ -246,15 +247,32 @@ pub static CATALOGUE: &[Capability] = &[
"Inspect",
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
),
ready_more(
// Edit and delete of datums and partitions are on the browser's item menu, not the ribbon.
covering(
"geometry.datum",
"Datum Plane",
Geometry,
"Prepare",
"Datum planes (principal plane and offset, three points, point and normal) and axes",
&["geometry.datum.axis", "geometry.datum.delete"],
"A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis",
&["geometry.datum.edit", "geometry.datum.delete"],
),
ready_more(
covering(
"geometry.datum.axis",
"Datum Axis",
Geometry,
"Prepare",
"A datum axis: principal axis, two points or datum points, point and direction",
&["geometry.datum.axis.edit"],
),
covering(
"geometry.datum.point",
"Datum Point",
Geometry,
"Prepare",
"A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane",
&["geometry.datum.point.edit"],
),
covering(
"geometry.partition",
"Partition",
Geometry,
+354 -23
View File
@@ -1,6 +1,10 @@
//! Datum planes and axes, and partitions of the open design by datum planes.
//! Datum planes, axes and points, and partitions of the open design by datum planes.
//!
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A partition cuts the
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A datum point is
//! placed by coordinates, at a mesh vertex, along a design edge, at a design face's centre or
//! where a datum axis crosses a datum plane; planes and axes can be made through datum points.
//! Each takes the coordinates of what it is made from when it is made (a later edit of a datum
//! point leaves the planes and axes made through it where they are). A partition cuts the
//! design in memory (`fieldcraft_geometry::partition`); the model's partitions are applied again,
//! in order, whenever the design is opened (`geometry.open`, `file.open`, samples), so an edit
//! of the design followed by a re-open replays them.
@@ -19,7 +23,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const DATUM_PLANE: CommandSpec = CommandSpec {
id: "geometry.datum",
label: "Datum Plane",
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points (normal (b − a) × (c − a)), or through a point with a normal. Partitions (geometry.partition) cut by it; shown in the viewport"#,
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]} | {"type": "datum_points", "a": "P1", "b": "P2", "c": "P3"} | {"type": "normal_to_axis", "axis": "Spindle", "point"?: "P1"}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points or three datum points (normal (b − a) × (c − a)), through a point with a normal, or normal to a datum axis through a datum point (the axis's point by default). Partitions (geometry.partition) cut by it; shown in the viewport"#,
form: Some(super::forms::DATUM_PLANE),
kind: CommandKind::Model,
requires: &[],
@@ -29,17 +33,57 @@ pub const DATUM_PLANE: CommandSpec = CommandSpec {
pub const DATUM_AXIS: CommandSpec = CommandSpec {
id: "geometry.datum.axis",
label: "Datum Axis",
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]}} — a named datum axis, shown in the viewport"#,
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]} | {"type": "datum_points", "a": "P1", "b": "P2"}} — a named datum axis, shown in the viewport"#,
form: Some(super::forms::DATUM_AXIS),
kind: CommandKind::Model,
requires: &[],
run: datum_axis,
};
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"#,
form: Some(super::forms::DATUM_POINT),
kind: CommandKind::Model,
requires: &[],
run: datum_point,
};
pub const DATUM_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.edit",
label: "Edit Datum Plane",
params: r#"{"name": "Mid", "by": …} — redefines a datum plane (the parameters of geometry.datum); the partitions that cut by it cut again"#,
form: Some(super::forms::DATUM_PLANE),
kind: CommandKind::Model,
requires: &[Requirement::DatumPlane],
run: |s, p| datum_edit(s, p, "plane"),
};
pub const DATUM_AXIS_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.axis.edit",
label: "Edit Datum Axis",
params: r#"{"name": "Spindle", "by": …} — redefines a datum axis (the parameters of geometry.datum.axis)"#,
form: Some(super::forms::DATUM_AXIS),
kind: CommandKind::Model,
requires: &[Requirement::DatumAxis],
run: |s, p| datum_edit(s, p, "axis"),
};
pub const DATUM_POINT_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.point.edit",
label: "Edit Datum Point",
params: r#"{"name": "P1", "by": …} — redefines a datum point (the parameters of geometry.datum.point); planes and axes made through it stay where they are"#,
form: Some(super::forms::DATUM_POINT),
kind: CommandKind::Model,
requires: &[Requirement::DatumPoint],
run: |s, p| datum_edit(s, p, "point"),
};
pub const DATUM_DELETE: CommandSpec = CommandSpec {
id: "geometry.datum.delete",
label: "Delete Datum",
params: r#"{"name": "Mid"} — deletes a datum plane or axis no partition uses"#,
params: r#"{"name": "Mid", "cascade"?: false} — deletes a datum plane, axis or point; one a partition cuts by is refused (naming the partitions), unless `cascade` deletes those partitions too"#,
form: Some(super::forms::DATUM_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Datum],
@@ -91,6 +135,16 @@ enum PlaneBy {
point: [f64; 3],
normal: [f64; 3],
},
DatumPoints {
a: String,
b: String,
c: String,
},
NormalToAxis {
axis: String,
#[serde(default)]
point: Option<String>,
},
}
#[derive(Deserialize)]
@@ -117,13 +171,74 @@ enum AxisBy {
point: [f64; 3],
direction: [f64; 3],
},
DatumPoints {
a: String,
b: String,
},
}
fn half() -> f64 {
0.5
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum PointBy {
Coordinates {
point: [f64; 3],
},
Vertex {
body: String,
vertex: String,
},
Edge {
body: String,
edge: String,
#[serde(default = "half")]
fraction: f64,
},
FaceCentre {
body: String,
face: String,
},
AxisPlane {
axis: String,
plane: String,
},
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn plane_datum(by: PlaneBy) -> Result<Datum> {
/// The coordinates of datum point `name`.
fn datum_point_at(m: &Model, name: &str) -> Result<[f64; 3]> {
match m.datum(name) {
Some(d) => d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind()))),
None => Err(EngineError::Params(format!("no datum point `{name}`"))),
}
}
/// The point and unit direction of datum axis `name`.
fn datum_axis_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> {
match m.datum(name) {
Some(d) => d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind()))),
None => Err(EngineError::Params(format!("no datum axis `{name}`"))),
}
}
/// A plane through three points; collinear points are refused relative to their spread.
fn three_points(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Result<Datum> {
let (u, v) = (sub(b, a), sub(c, a));
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
return Err(EngineError::Params("the three points must not lie on one line".into()));
}
Ok(Datum::Plane { point: a, normal: n })
}
fn plane_datum(m: &Model, by: PlaneBy) -> Result<Datum> {
Ok(match by {
PlaneBy::Offset { plane, offset } => {
if !offset.is_finite() {
@@ -136,22 +251,22 @@ fn plane_datum(by: PlaneBy) -> Result<Datum> {
};
Datum::Plane { point: normal.map(|n| n * offset), normal }
}
PlaneBy::ThreePoints { a, b, c } => {
let (u, v) = (sub(b, a), sub(c, a));
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
// Collinear points: refused relative to the points' spread.
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
return Err(EngineError::Params("the three points must not lie on one line".into()));
}
Datum::Plane { point: a, normal: n }
}
PlaneBy::ThreePoints { a, b, c } => three_points(a, b, c)?,
PlaneBy::PointNormal { point, normal } => Datum::Plane { point, normal },
PlaneBy::DatumPoints { a, b, c } => three_points(datum_point_at(m, &a)?, datum_point_at(m, &b)?, datum_point_at(m, &c)?)?,
PlaneBy::NormalToAxis { axis, point } => {
let (on_axis, direction) = datum_axis_of(m, &axis)?;
let point = match point {
Some(p) => datum_point_at(m, &p)?,
None => on_axis,
};
Datum::Plane { point, normal: direction }
}
})
}
fn axis_datum(by: AxisBy) -> Datum {
match by {
fn axis_datum(m: &Model, by: AxisBy) -> Result<Datum> {
Ok(match by {
AxisBy::Principal { axis, through } => {
let direction = match axis {
Axis::X => [1.0, 0.0, 0.0],
@@ -162,6 +277,80 @@ fn axis_datum(by: AxisBy) -> Datum {
}
AxisBy::TwoPoints { a, b } => Datum::Axis { point: a, direction: sub(b, a) },
AxisBy::PointDirection { point, direction } => Datum::Axis { point, direction },
AxisBy::DatumPoints { a, b } => {
let a = datum_point_at(m, &a)?;
Datum::Axis { point: a, direction: sub(datum_point_at(m, &b)?, a) }
}
})
}
/// Most mesh nodes a vertex is looked for among.
const MAX_VERTEX_NODES: usize = 10_000_000;
fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
let shape = |body: &str| -> Result<&fieldcraft_geometry::Shape> {
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
design.body(body).ok_or_else(|| EngineError::Params(format!("the design has no body `{body}`")))
};
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
}
PointBy::Edge { body, edge, fraction } => {
if !(0.0..=1.0).contains(&fraction) {
return Err(EngineError::invalid("fraction", "is a number from 0 to 1".to_string()));
}
let edges = shape(&body)?.edges()?;
let e = edges.iter().find(|e| e.name == edge).ok_or_else(|| EngineError::Params(format!("body `{body}` has no edge `{edge}`")))?;
e.point_at(fraction).ok_or_else(|| EngineError::Params(format!("edge `{edge}` of body `{body}` has no points")))?
}
PointBy::FaceCentre { body, face } => shape(&body)?.face_centre(&face)?,
PointBy::AxisPlane { axis, plane } => {
let (p, d) = datum_axis_of(&s.model, &axis)?;
let (q, n) = match s.model.datum(&plane) {
Some(x) => x.plane().ok_or_else(|| EngineError::Params(format!("datum `{plane}` is a datum {}, not a plane", x.kind())))?,
None => return Err(EngineError::Params(format!("no datum plane `{plane}`"))),
};
let along = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let across = along(d, n);
// Unit vectors: the sine of the angle between the axis and the plane.
if across.abs() < 1e-9 {
return Err(EngineError::Params(format!("axis `{axis}` is parallel to plane `{plane}`: they do not cross")));
}
let t = along(sub(q, p), n) / across;
[p[0] + t * d[0], p[1] + t * d[1], p[2] + t * d[2]]
}
};
Ok(Datum::Point { point })
}
/// The datum `by` defines for a datum of `kind` (`plane`, `axis` or `point`).
fn datum_of(s: &Session, kind: &str, p: &Value) -> Result<Datum> {
match kind {
"plane" => plane_datum(&s.model, super::req(p, "by")?),
"axis" => axis_datum(&s.model, super::req(p, "by")?),
_ => point_datum(s, super::req(p, "by")?),
}
}
/// The parameters of the command that edits datum `name`, describing it as it is (a plane by
/// its point and normal, an axis by its point and direction, a point by its coordinates); and
/// that command.
pub fn edit_params(name: &str, d: &Datum) -> (&'static str, Value) {
match *d {
Datum::Plane { point, normal } => (DATUM_EDIT.id, json!({"name": name, "by": {"type": "point_normal", "point": point, "normal": normal}})),
Datum::Axis { point, direction } => {
(DATUM_AXIS_EDIT.id, json!({"name": name, "by": {"type": "point_direction", "point": point, "direction": direction}}))
}
Datum::Point { point } => (DATUM_POINT_EDIT.id, json!({"name": name, "by": {"type": "coordinates", "point": point}})),
}
}
@@ -173,24 +362,55 @@ fn datum_json(name: &str, d: &Datum) -> Value {
v
}
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
fn datum_create(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let name = super::name(p)?;
let datum = plane_datum(super::req(p, "by")?)?;
let datum = datum_of(s, kind, p)?;
s.model.add_datum(name, datum)?;
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
}
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "plane")
}
fn datum_axis(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "axis")
}
fn datum_point(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "point")
}
/// Redefine datum `name` of `kind`; the partitions that cut by a plane cut again.
fn datum_edit(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let name = super::name(p)?;
let datum = axis_datum(super::req(p, "by")?);
s.model.add_datum(name, datum)?;
match s.model.datum(name) {
Some(d) if d.kind() == kind => {}
Some(d) => return Err(EngineError::Params(format!("datum `{name}` is a datum {}, not a {kind}", d.kind()))),
None => return Err(EngineError::Params(format!("no datum {kind} `{name}`"))),
}
let datum = datum_of(s, kind, p)?;
s.model.replace_datum(name, datum)?;
if !s.model.datum_users(name).is_empty() {
recut(s)?;
}
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
}
fn datum_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let cascade: bool = super::opt(p, "cascade")?.unwrap_or(false);
let partitions: Vec<String> = s.model.partitions().iter().filter(|x| x.datum == name).map(|x| x.name.clone()).collect();
if cascade {
for partition in &partitions {
s.model.remove_partition(partition)?;
}
}
s.model.remove_datum(name)?;
Ok(json!({"deleted": name}))
if cascade && !partitions.is_empty() {
recut(s)?;
}
Ok(json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }}))
}
/// The model's partitions as cuts of the geometry.
@@ -362,6 +582,117 @@ pub(crate) mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn datum_points_by_each_definition_and_planes_and_axes_through_them() {
let (mut s, dir) = plate();
let point = |s: &Session, n: &str| s.model.datum(n).and_then(Datum::point).unwrap();
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
s.execute("geometry.datum.point", &json!({"name": "O", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
s.execute("geometry.datum.point", &json!({"name": "X", "by": {"type": "coordinates", "point": [10, 0, 0]}})).unwrap();
s.execute("geometry.datum.point", &json!({"name": "Y", "by": {"type": "coordinates", "point": [0, 10, 0]}})).unwrap();
// The plate's edge along x at y = 0, z = 0, its midpoint and a quarter along it.
let body = s.design.as_ref().unwrap().bodies[0].clone();
let edge = body.edges().unwrap().into_iter().find(|e| e.points.iter().all(|p| p[1].abs() < 1e-9 && p[2].abs() < 1e-9)).unwrap();
let by = |fraction: f64| json!({"type": "edge", "body": body.name, "edge": edge.name, "fraction": fraction});
s.execute("geometry.datum.point", &json!({"name": "Mid", "by": {"type": "edge", "body": body.name, "edge": edge.name}})).unwrap();
assert!(close(point(&s, "Mid"), [20.0, 0.0, 0.0]), "{:?}", point(&s, "Mid"));
s.execute("geometry.datum.point", &json!({"name": "Q", "by": by(0.25)})).unwrap();
let q = point(&s, "Q");
assert!((q[0] - 10.0).abs() < 1e-6 || (q[0] - 30.0).abs() < 1e-6, "{q:?}");
// The centre of the face at x = 0.
let side =
body.face_surfaces().unwrap().iter().position(
|f| matches!(f, Some(fieldcraft_geometry::FaceSurface::Plane { normal, point }) if normal[0] < -0.5 && point[0].abs() < 1e-9),
);
let face = body.faces()[side.unwrap()].name.clone();
s.execute("geometry.datum.point", &json!({"name": "C", "by": {"type": "face_centre", "body": body.name, "face": face}})).unwrap();
assert!(close(point(&s, "C"), [0.0, 15.0, 5.0]), "{:?}", point(&s, "C"));
// Planes and axes through datum points; a point where an axis crosses a plane.
let r = s.execute("geometry.datum", &json!({"name": "Base", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Y"}})).unwrap();
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "datum_points", "a": "O", "b": "X"}})).unwrap();
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [1.0, 0.0, 0.0] }));
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "normal_to_axis", "axis": "Ax", "point": "Mid"}})).unwrap();
assert_eq!(s.model.datum("Cut"), Some(&Datum::Plane { point: point(&s, "Mid"), normal: [1.0, 0.0, 0.0] }));
s.execute("geometry.datum.point", &json!({"name": "Hit", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Cut"}})).unwrap();
assert!(close(point(&s, "Hit"), [20.0, 0.0, 0.0]));
let journal = s.journal.len();
for p in [
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Base"}}),
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Base", "plane": "Cut"}}),
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": edge.name, "fraction": 2}}),
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": "nope"}}),
json!({"name": "Z", "by": {"type": "edge", "body": "Nope", "edge": edge.name}}),
json!({"name": "Z", "by": {"type": "face_centre", "body": body.name, "face": "nope"}}),
// No mesh: no vertex is named.
json!({"name": "Z", "by": {"type": "vertex", "body": body.name, "vertex": "v1"}}),
json!({"name": "Z", "by": {"type": "coordinates", "point": [f64::MAX, 0, 0]}}),
json!({"name": "Z", "by": {"type": "coordinates", "point": [0, 0]}}),
] {
assert!(s.execute("geometry.datum.point", &p).is_err(), "{p}");
}
for (command, p) in [
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Mid"}})),
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Ax"}})),
("geometry.datum", json!({"name": "Z", "by": {"type": "normal_to_axis", "axis": "Nope"}})),
("geometry.datum.axis", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "O"}})),
] {
assert!(s.execute(command, &p).is_err(), "{p}");
}
assert_eq!(s.journal.len(), journal);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_vertex_point_is_at_the_tagged_mesh_node() {
let mut s = Session::default();
s.execute(
"mesh.block",
&json!({"body": "Bar", "kind": "hex8", "corners": [[0,0,0],[4,0,0],[4,1,0],[0,1,0],[0,0,1],[4,0,1],[4,1,1],[0,1,1]], "divisions": [2, 1, 1], "vertices": {"7": "tip"}}),
)
.unwrap();
s.execute("geometry.datum.point", &json!({"name": "Tip", "by": {"type": "vertex", "body": "Bar", "vertex": "tip"}})).unwrap();
assert_eq!(s.model.datum("Tip").and_then(Datum::point), Some([4.0, 1.0, 1.0]));
}
#[test]
fn editing_and_deleting_datums_follow_their_partitions() {
let (mut s, dir) = plate();
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
let cut_at = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().bbox_max[0];
assert!((cut_at(&s) - 15.0).abs() < 1e-6);
// Moving the plane cuts again; an edit of the wrong kind or to an axis is refused.
s.execute("geometry.datum.edit", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 25}})).unwrap();
assert!((cut_at(&s) - 25.0).abs() < 1e-6);
assert!(s.execute("geometry.datum.axis.edit", &json!({"name": "Mid", "by": {"type": "principal", "axis": "y"}})).is_err());
assert!(s.execute("geometry.datum.edit", &json!({"name": "Ax", "by": {"type": "offset", "plane": "xy"}})).is_err());
s.execute("geometry.datum.axis.edit", &json!({"name": "Ax", "by": {"type": "principal", "axis": "y"}})).unwrap();
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [0.0, 1.0, 0.0] }));
// The edit commands take the parameters edit_params gives back.
for name in ["Mid", "Ax"] {
let (command, params) = edit_params(name, s.model.datum(name).unwrap());
s.execute(command, &params).unwrap();
}
// Deleting the plane names its partition, or deletes it too.
let e = s.execute("geometry.datum.delete", &json!({"name": "Mid"})).unwrap_err().to_string();
assert!(e.contains("partition `Half`"), "{e}");
let r = s.execute("geometry.datum.delete", &json!({"name": "Mid", "cascade": true})).unwrap();
assert_eq!(r["partitions"], json!(["Half"]));
assert!(s.model.partitions().is_empty() && s.model.datum("Mid").is_none());
assert_eq!(bodies(&s).len(), 1);
// One undo step brings both back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((s.model.partitions().len(), bodies(&s).len()), (1, 2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_partition_that_cuts_nothing_changes_nothing() {
let (mut s, dir) = plate();
+77 -1
View File
@@ -1277,6 +1277,25 @@ pub const DATUM_PLANE: &[Field] = &[
xyz("normal", "Normal", D::None).help("The plane's normal direction.").req(),
],
),
variant(
"datum_points",
"Three datum points",
&[
reference("a", "Point A", C::DatumPoint).help("First datum point.").req(),
reference("b", "Point B", C::DatumPoint).help("Second datum point.").req(),
reference("c", "Point C", C::DatumPoint).help("Third datum point.").req(),
],
),
variant(
"normal_to_axis",
"Normal to an axis",
&[
reference("axis", "Axis", C::DatumAxis).help("The datum axis the plane is normal to.").req(),
reference("point", "Through", C::DatumPoint)
.help("A datum point on the plane; the axis's own point when empty.")
.hint("the axis's point"),
],
),
],
)
.help("How the plane is defined.")
@@ -1313,13 +1332,70 @@ pub const DATUM_AXIS: &[Field] = &[
xyz("direction", "Direction", D::None).help("The axis direction.").req(),
],
),
variant(
"datum_points",
"Two datum points",
&[
reference("a", "Point A", C::DatumPoint).help("First datum point on the axis.").req(),
reference("b", "Point B", C::DatumPoint).help("Second datum point on the axis.").req(),
],
),
],
)
.help("How the axis is defined.")
.req()
.def(r#"{"type": "principal"}"#),
];
pub const DATUM_DELETE: &[Field] = existing!("Datum", C::Datum);
pub const DATUM_POINT: &[Field] = &[
NAME,
variants(
"by",
"Definition",
&[
variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]),
variant(
"vertex",
"Mesh 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(),
],
),
variant(
"edge",
"On an edge",
&[
reference("body", "Body", C::Body).help("The body the edge belongs to.").req(),
text("edge", "Edge").help("The edge's persistent name.").req(),
range("fraction", "Fraction", D::None, 0.0, 1.0).help("How far along the edge from its start: 0.5 is its midpoint.").def("0.5"),
],
),
variant(
"face_centre",
"Face centre",
&[
reference("body", "Body", C::Body).help("The body the face belongs to.").req(),
text("face", "Face").help("The face's persistent name.").req(),
],
),
variant(
"axis_plane",
"Axis and plane",
&[
reference("axis", "Axis", C::DatumAxis).help("The datum axis.").req(),
reference("plane", "Plane", C::DatumPlane).help("The datum plane the axis crosses.").req(),
],
),
],
)
.help("How the point is defined.")
.req()
.def(r#"{"type": "coordinates"}"#),
];
pub const DATUM_DELETE: &[Field] = &[
reference("name", "Datum", C::Datum).req().help("The Datum to act on, by name."),
flag("cascade", "Delete its partitions").help("Also delete the partitions that cut by it; without it, a datum in use is refused."),
];
pub const PARTITION: &[Field] = &[
NAME,
reference("datum", "Datum plane", C::DatumPlane).help("The plane that divides the bodies.").req(),
+5
View File
@@ -13,6 +13,7 @@ mod geometry;
mod hex;
pub(crate) mod mesh;
pub use datum::edit_params as datum_edit_params;
pub use mesh::analytic_surfaces;
mod mirror;
mod model;
@@ -41,6 +42,10 @@ pub static ALL: &[CommandSpec] = &[
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
datum::DATUM_AXIS,
datum::DATUM_POINT,
datum::DATUM_EDIT,
datum::DATUM_AXIS_EDIT,
datum::DATUM_POINT_EDIT,
datum::DATUM_DELETE,
datum::PARTITION,
datum::PARTITION_DELETE,
+14 -2
View File
@@ -24,10 +24,12 @@ pub enum Requirement {
Design,
/// Geometry to mesh: an open design or an imported surface.
Geometry,
/// A datum plane or axis.
/// A datum plane, axis or point.
Datum,
/// A datum plane (what a partition cuts by).
DatumPlane,
DatumAxis,
DatumPoint,
/// A partition of the design.
Partition,
/// An edge seed.
@@ -74,6 +76,8 @@ impl Requirement {
Requirement::Geometry => s.design.is_some() || !s.surfaces.is_empty(),
Requirement::Datum => !m.datums().is_empty(),
Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()),
Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()),
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
Requirement::Partition => !m.partitions().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
@@ -110,8 +114,10 @@ impl Requirement {
match self {
Requirement::Design => "an open design",
Requirement::Geometry => "geometry (an open design or an imported surface)",
Requirement::Datum => "a datum plane or axis",
Requirement::Datum => "a datum",
Requirement::DatumPlane => "a datum plane",
Requirement::DatumAxis => "a datum axis",
Requirement::DatumPoint => "a datum point",
Requirement::Partition => "a partition",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes",
@@ -140,6 +146,8 @@ impl Requirement {
match self {
Requirement::Design | Requirement::Geometry => "geometry.open",
Requirement::Datum | Requirement::DatumPlane => "geometry.datum",
Requirement::DatumAxis => "geometry.datum.axis",
Requirement::DatumPoint => "geometry.datum.point",
Requirement::Partition => "geometry.partition",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add",
@@ -195,6 +203,8 @@ impl crate::schema::Collection {
C::Body => Requirement::Design,
C::Datum => Requirement::Datum,
C::DatumPlane => Requirement::DatumPlane,
C::DatumAxis => Requirement::DatumAxis,
C::DatumPoint => Requirement::DatumPoint,
C::Partition => Requirement::Partition,
}
}
@@ -333,6 +343,8 @@ mod tests {
C::Body,
C::Datum,
C::DatumPlane,
C::DatumAxis,
C::DatumPoint,
C::Partition,
];
let empty = Session::default();
+9 -1
View File
@@ -124,10 +124,14 @@ pub enum Collection {
SolvedStep,
/// A body of the open design.
Body,
/// A datum plane or axis.
/// A datum plane, axis or point.
Datum,
/// A datum plane.
DatumPlane,
/// A datum axis.
DatumAxis,
/// A datum point.
DatumPoint,
Partition,
}
@@ -151,6 +155,8 @@ impl Collection {
Collection::Body => "body",
Collection::Datum => "datum",
Collection::DatumPlane => "datum plane",
Collection::DatumAxis => "datum axis",
Collection::DatumPoint => "datum point",
Collection::Partition => "partition",
}
}
@@ -178,6 +184,8 @@ impl Session {
Collection::Body => Box::new(self.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.as_str()))),
Collection::Datum => Box::new(m.datums().keys().map(String::as_str)),
Collection::DatumPlane => Box::new(m.datums().iter().filter(|(_, d)| d.plane().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumAxis => Box::new(m.datums().iter().filter(|(_, d)| d.axis().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())),
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
+5 -3
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
62 ready, 41 coming soon.
64 ready, 41 coming soon.
## Geometry
@@ -17,8 +17,10 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Inspect | Bodies — List the open design's bodies, volumes and named faces | ready | `geometry.bodies` |
| 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 — Datum planes (principal plane and offset, three points, point and normal) and axes | ready | `geometry.datum`, `geometry.datum.axis`, `geometry.datum.delete` |
| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, `geometry.partition.delete` |
| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`) |
| Prepare | Datum Axis — A datum axis: principal axis, two points or datum points, point and direction | ready | `geometry.datum.axis`, (`geometry.datum.axis.edit`) |
| Prepare | Datum Point — A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane | ready | `geometry.datum.point`, (`geometry.datum.point.edit`) |
| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`) |
| Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
## Mesh