Engine: geometry.datum, geometry.datum.axis and geometry.partition

Datum planes by principal plane and offset, three points, or point and
normal; datum axes by principal axis, two points, or point and direction.
geometry.partition cuts the open design by a datum plane and keeps the
partition in the model, so geometry.open, file.open and undo cut the design
again. Requirements, forms and the Prepare entries are wired; features.md
lists them as ready.

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-14 19:37:37 -04:00
1 parent b0d0fc9900
commit 1c16972cf2
10 files changed
+497 -8

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
+16 -2
View File
@@ -232,8 +232,22 @@ pub static CATALOGUE: &[Capability] = &[
"Inspect",
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
),
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
ready_more(
"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"],
),
ready_more(
"geometry.partition",
"Partition",
Geometry,
"Prepare",
"Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
&["geometry.partition.delete"],
),
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
+385
View File
@@ -0,0 +1,385 @@
//! Datum planes and axes, and partitions of the open design by datum planes.
//!
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). 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.
use std::sync::Arc;
use fieldcraft_geometry::{Cut, Design};
use fieldcraft_model::{Datum, Model, Partition};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::requirement::Requirement;
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"#,
form: Some(super::forms::DATUM_PLANE),
kind: CommandKind::Model,
requires: &[],
run: datum_plane,
};
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"#,
form: Some(super::forms::DATUM_AXIS),
kind: CommandKind::Model,
requires: &[],
run: datum_axis,
};
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"#,
form: Some(super::forms::DATUM_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Datum],
run: datum_delete,
};
pub const PARTITION: CommandSpec = CommandSpec {
id: "geometry.partition",
label: "Partition",
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a plane that divides no body, or a listed body it does not divide, is an error"#,
form: Some(super::forms::PARTITION),
kind: CommandKind::Model,
requires: &[Requirement::Design, Requirement::DatumPlane],
run: partition,
};
pub const PARTITION_DELETE: CommandSpec = CommandSpec {
id: "geometry.partition.delete",
label: "Delete Partition",
params: r#"{"name": "Half"} — removes a partition; the design is cut again by the ones left"#,
form: Some(super::forms::PARTITION_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Partition],
run: partition_delete,
};
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Principal {
Yz,
Zx,
Xy,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum PlaneBy {
Offset {
plane: Principal,
#[serde(default)]
offset: f64,
},
ThreePoints {
a: [f64; 3],
b: [f64; 3],
c: [f64; 3],
},
PointNormal {
point: [f64; 3],
normal: [f64; 3],
},
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Axis {
X,
Y,
Z,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum AxisBy {
Principal {
axis: Axis,
#[serde(default)]
through: [f64; 3],
},
TwoPoints {
a: [f64; 3],
b: [f64; 3],
},
PointDirection {
point: [f64; 3],
direction: [f64; 3],
},
}
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> {
Ok(match by {
PlaneBy::Offset { plane, offset } => {
if !offset.is_finite() {
return Err(EngineError::Params("`offset` must be a finite number".into()));
}
let normal = match plane {
Principal::Yz => [1.0, 0.0, 0.0],
Principal::Zx => [0.0, 1.0, 0.0],
Principal::Xy => [0.0, 0.0, 1.0],
};
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::PointNormal { point, normal } => Datum::Plane { point, normal },
})
}
fn axis_datum(by: AxisBy) -> Datum {
match by {
AxisBy::Principal { axis, through } => {
let direction = match axis {
Axis::X => [1.0, 0.0, 0.0],
Axis::Y => [0.0, 1.0, 0.0],
Axis::Z => [0.0, 0.0, 1.0],
};
Datum::Axis { point: through, direction }
}
AxisBy::TwoPoints { a, b } => Datum::Axis { point: a, direction: sub(b, a) },
AxisBy::PointDirection { point, direction } => Datum::Axis { point, direction },
}
}
fn datum_json(name: &str, d: &Datum) -> Value {
let mut v = serde_json::to_value(d).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert("name".into(), json!(name));
}
v
}
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let datum = plane_datum(super::req(p, "by")?)?;
s.model.add_datum(name, datum)?;
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
}
fn datum_axis(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let datum = axis_datum(super::req(p, "by")?);
s.model.add_datum(name, datum)?;
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)?;
s.model.remove_datum(name)?;
Ok(json!({"deleted": name}))
}
/// The model's partitions as cuts of the geometry.
fn cuts(model: &Model) -> Vec<Cut> {
model
.partitions()
.iter()
.filter_map(|p| {
let (point, normal) = model.datum(&p.datum)?.plane()?;
Some(Cut { name: p.name.clone(), point, normal, bodies: p.bodies.clone() })
})
.collect()
}
/// `design` cut by `model`'s partitions (the design as opened when the model has none).
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
Ok(design.partitioned(&cuts(model))?)
}
/// Cut the session's design by its model's partitions again.
fn recut(s: &mut Session) -> Result<()> {
if let Some(d) = &s.design {
let cut = partitioned(d, &s.model)?;
s.design = Some(Arc::new(cut));
}
Ok(())
}
fn partition(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let datum: String = super::req(p, "datum")?;
let bodies: Vec<String> = super::opt(p, "bodies")?.unwrap_or_default();
s.model.add_partition(Partition { name: name.clone(), datum, bodies })?;
recut(s)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let feature = format!("partition {name}");
let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect();
if !errors.is_empty() {
return Err(EngineError::Params(format!("partition `{name}`: {}", errors.join("; "))));
}
let report = design.cuts.iter().find(|c| c.name == name);
let split = report.map(|r| r.split.clone()).unwrap_or_default();
if split.is_empty() {
return Err(EngineError::Params(format!("partition `{name}`: the plane divides no body of the design")));
}
let cells: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
Ok(json!({
"name": name,
"split": split,
"cells": cells,
"cut_faces": report.map_or(0, |r| r.faces),
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
}))
}
fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
s.model.remove_partition(name)?;
recut(s)?;
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
Ok(json!({"deleted": name, "bodies": bodies}))
}
#[cfg(test)]
mod tests {
use super::*;
/// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open.
pub(crate) fn plate() -> (Session, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("fieldcraft-partition-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft");
let plate = fieldcraft_geometry::sample::Plate::default();
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&plate).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
(s, dir)
}
fn bodies(s: &Session) -> Vec<String> {
s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect()
}
#[test]
fn datums_by_each_definition() {
let mut s = Session::default();
let r = s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
assert_eq!(r, json!({"name": "Mid", "type": "plane", "point": [15.0, 0.0, 0.0], "normal": [1.0, 0.0, 0.0]}));
let r = s
.execute("geometry.datum", &json!({"name": "Tri", "by": {"type": "three_points", "a": [0, 0, 1], "b": [1, 0, 1], "c": [0, 1, 1]}}))
.unwrap();
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 3, 4]}})).unwrap();
assert_eq!(s.model.datum("Tilt"), Some(&Datum::Plane { point: [0.0; 3], normal: [0.0, 0.6, 0.8] }));
let r = s.execute("geometry.datum.axis", &json!({"name": "Z", "by": {"type": "principal", "axis": "z", "through": [1, 2, 0]}})).unwrap();
assert_eq!(r, json!({"name": "Z", "type": "axis", "point": [1.0, 2.0, 0.0], "direction": [0.0, 0.0, 1.0]}));
s.execute("geometry.datum.axis", &json!({"name": "D", "by": {"type": "two_points", "a": [0, 0, 0], "b": [0, 2, 0]}})).unwrap();
s.execute("geometry.datum.axis", &json!({"name": "E", "by": {"type": "point_direction", "point": [0, 0, 0], "direction": [5, 0, 0]}}))
.unwrap();
assert_eq!(s.model.datums().len(), 6);
s.execute("geometry.datum.delete", &json!({"name": "D"})).unwrap();
assert_eq!(s.model.datums().len(), 5);
}
#[test]
fn hostile_datum_params_change_nothing() {
let mut s = Session::default();
for p in [
json!({}),
json!({"name": "A"}),
json!({"name": "A", "by": {"type": "offset", "plane": "ab"}}),
json!({"name": "A", "by": {"type": "offset", "plane": "xy", "offset": "x"}}),
json!({"name": "A", "by": {"type": "three_points", "a": [0, 0, 0], "b": [1, 1, 1], "c": [2, 2, 2]}}),
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 0]}}),
json!({"name": "A", "by": {"type": "point_normal", "point": [1e300, 0, 0], "normal": [0, 0, 1]}}),
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 1], "extra": 1}}),
json!({"name": "1A", "by": {"type": "offset", "plane": "xy"}}),
json!({"name": "A", "by": {"type": "nope"}}),
] {
assert!(s.execute("geometry.datum", &p).is_err(), "{p}");
}
for p in [
json!({"name": "A", "by": {"type": "two_points", "a": [1, 1, 1], "b": [1, 1, 1]}}),
json!({"name": "A", "by": {"type": "principal", "axis": "w"}}),
] {
assert!(s.execute("geometry.datum.axis", &p).is_err(), "{p}");
}
assert!(s.model.datums().is_empty());
assert!(s.journal.is_empty());
}
#[test]
fn a_partition_cuts_the_design_and_is_replayed_when_it_opens_again() {
let (mut s, dir) = plate();
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
let r = s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
let body = r["split"][0].as_str().unwrap().to_string();
assert_eq!(r["cells"], json!([format!("{body}/a"), format!("{body}/b")]));
assert_eq!(r["cut_faces"], json!(4));
// The datum is in use.
assert!(s.execute("geometry.datum.delete", &json!({"name": "Mid"})).is_err());
// A set on a cut face selects the cut face's pieces.
let r = s.execute("geometry.bodies", &json!({})).unwrap();
let faces: Vec<&str> = r[0]["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert!(faces.contains(&"partition:Half:a#0"), "{faces:?}");
// Saved and opened again: the design is cut again by the model's partition.
let file = dir.join("plate.fieldcraft");
s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(bodies(&t), bodies(&s));
// geometry.open again keeps the model, so its partition.
let design = dir.join("plate.solidcraft");
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap();
assert_eq!(r["bodies"], json!(bodies(&s)));
// A new model drops the partitions with the rest of the model.
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap(), "model": "new"})).unwrap();
assert_eq!(r["bodies"], json!([body]));
// Undo brings the whole design back; deleting the partition does too.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bodies(&s), std::slice::from_ref(&body));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(bodies(&s).len(), 2);
s.execute("geometry.partition.delete", &json!({"name": "Half"})).unwrap();
assert_eq!(bodies(&s), [body]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_partition_that_cuts_nothing_changes_nothing() {
let (mut s, dir) = plate();
s.execute("geometry.datum", &json!({"name": "Out", "by": {"type": "offset", "plane": "yz", "offset": 100}})).unwrap();
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
let journal = s.journal.len();
for p in [
json!({"name": "P", "datum": "Out"}),
json!({"name": "P", "datum": "Ax"}),
json!({"name": "P", "datum": "Nope"}),
json!({"name": "P", "datum": "Out", "bodies": ["Nope"]}),
json!({"name": "P", "datum": "Out", "bodies": "x"}),
json!({"name": "", "datum": "Out"}),
] {
assert!(s.execute("geometry.partition", &p).is_err(), "{p}");
}
assert!(s.model.partitions().is_empty());
assert_eq!(bodies(&s).len(), 1);
assert_eq!(s.journal.len(), journal);
let _ = std::fs::remove_dir_all(&dir);
}
}
+10 -1
View File
@@ -199,7 +199,16 @@ impl Session {
let mut design: Option<Arc<Design>> = None;
if let Some(stored) = &file.geometry.design {
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
design = opened.map(Arc::new);
design = opened.and_then(|d| match super::datum::partitioned(&d, &file.model) {
Ok(cut) => Some(Arc::new(cut)),
Err(e) => {
warnings.push(format!("the design's partitions could not be applied ({e}); the model opens without the design"));
None
}
});
if let Some(d) = &design {
warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message)));
}
sources.design = Some(source);
}
let mut surfaces = Vec::new();
+48
View File
@@ -803,6 +803,54 @@ pub const OPEN_DESIGN: &[Field] = &[
path("path", "Design", false).req(),
choice("model", "Model", &[("keep", "Keep the current model"), ("new", "Start a new model")]).def("\"keep\""),
];
const POINT: Dim = D::Millimetre;
pub const DATUM_PLANE: &[Field] = &[
NAME,
variants(
"by",
"Definition",
&[
variant(
"offset",
"Principal plane",
&[
choice("plane", "Plane", &[("yz", "YZ (normal X)"), ("zx", "ZX (normal Y)"), ("xy", "XY (normal Z)")]).req(),
num("offset", "Offset", POINT).def("0"),
],
),
variant(
"three_points",
"Three points",
&[xyz("a", "Point A", POINT).req(), xyz("b", "Point B", POINT).req(), xyz("c", "Point C", POINT).req()],
),
variant("point_normal", "Point and normal", &[xyz("point", "Point", POINT).req(), xyz("normal", "Normal", D::None).req()]),
],
)
.req()
.def(r#"{"type": "offset"}"#),
];
pub const DATUM_AXIS: &[Field] = &[
NAME,
variants(
"by",
"Definition",
&[
variant(
"principal",
"Principal axis",
&[choice("axis", "Axis", &[("x", "X"), ("y", "Y"), ("z", "Z")]).req(), xyz("through", "Through", POINT).hint("the origin")],
),
variant("two_points", "Two points", &[xyz("a", "Point A", POINT).req(), xyz("b", "Point B", POINT).req()]),
variant("point_direction", "Point and direction", &[xyz("point", "Point", POINT).req(), xyz("direction", "Direction", D::None).req()]),
],
)
.req()
.def(r#"{"type": "principal"}"#),
];
pub const DATUM_DELETE: &[Field] = existing!("Datum", C::Datum);
pub const PARTITION: &[Field] =
&[NAME, reference("datum", "Datum plane", C::DatumPlane).req(), references("bodies", "Bodies", C::Body).hint("every body the plane divides")];
pub const PARTITION_DELETE: &[Field] = existing!("Partition", C::Partition);
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).req(), TOLERANCE];
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).req()];
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).req()];
+3 -1
View File
@@ -64,7 +64,9 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::file_param(s, p, "path", Access::Read)?;
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let design = open_design(&bytes)?;
let opened = open_design(&bytes)?;
// The model's partitions cut the design again (a new model has none).
let design = if new_model { opened } else { super::datum::partitioned(&opened, &s.model)? };
let mut fit = fit(s, &design);
if new_model {
let mut model = Model::default();
+6
View File
@@ -2,6 +2,7 @@
mod block;
mod contact;
pub(crate) mod datum;
pub(crate) mod deck;
pub(crate) mod define;
mod edit;
@@ -32,6 +33,11 @@ pub static ALL: &[CommandSpec] = &[
geometry::BODIES,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
datum::DATUM_AXIS,
datum::DATUM_DELETE,
datum::PARTITION,
datum::PARTITION_DELETE,
mesh::NODE_ADD,
mesh::NODE_REMOVE,
mesh::ELEMENT_ADD,
+14
View File
@@ -24,6 +24,12 @@ pub enum Requirement {
Design,
/// Geometry to mesh: an open design or an imported surface.
Geometry,
/// A datum plane or axis.
Datum,
/// A datum plane (what a partition cuts by).
DatumPlane,
/// A partition of the design.
Partition,
/// At least one mesh node.
Nodes,
/// At least one element.
@@ -64,6 +70,9 @@ impl Requirement {
match self {
Requirement::Design => s.design.is_some(),
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::Partition => !m.partitions().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
Requirement::Analysable => {
@@ -98,6 +107,9 @@ 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::DatumPlane => "a datum plane",
Requirement::Partition => "a partition",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
Requirement::ElementMaterial | Requirement::Material => "a material",
@@ -123,6 +135,8 @@ impl Requirement {
pub fn provider(self) -> &'static str {
match self {
Requirement::Design | Requirement::Geometry => "geometry.open",
Requirement::Datum | Requirement::DatumPlane => "geometry.datum",
Requirement::Partition => "geometry.partition",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
Requirement::ElementMaterial | Requirement::Material => "model.material.create",
+11
View File
@@ -118,6 +118,11 @@ pub enum Collection {
SolvedStep,
/// A body of the open design.
Body,
/// A datum plane or axis.
Datum,
/// A datum plane.
DatumPlane,
Partition,
}
impl Collection {
@@ -138,6 +143,9 @@ impl Collection {
Collection::Step => "step",
Collection::SolvedStep => "solved step",
Collection::Body => "body",
Collection::Datum => "datum",
Collection::DatumPlane => "datum plane",
Collection::Partition => "partition",
}
}
}
@@ -162,6 +170,9 @@ impl Session {
Collection::Step => Box::new(m.steps().iter().map(|s| s.name.as_str())),
Collection::SolvedStep => Box::new(self.solved_steps()),
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::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
+3 -3
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
60 ready, 44 coming soon.
62 ready, 42 coming soon.
## Geometry
@@ -17,8 +17,8 @@ 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 | Partition — Split bodies and faces as SolidCraft features | coming soon (M5) | |
| Prepare | Datum Plane — Construction planes for partitions | coming soon (M5) | |
| 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 | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
## Mesh