From d9824de4a505f450dd46f0411bff8b5d6918d6f9 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:26:39 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 2 + assets/icons/geometry.datum.axis.svg | 11 + assets/icons/geometry.datum.axis.vectorcraft | Bin 0 -> 2043 bytes assets/icons/geometry.datum.point.svg | 10 + assets/icons/geometry.datum.point.vectorcraft | Bin 0 -> 1933 bytes assets/icons/src/geometry.datum.axis.json | 1 + assets/icons/src/geometry.datum.point.json | 1 + crates/engine/src/catalogue.rs | 30 +- crates/engine/src/cmd/datum.rs | 377 ++++++++++++++++-- crates/engine/src/cmd/forms.rs | 78 +++- crates/engine/src/cmd/mod.rs | 5 + crates/engine/src/requirement.rs | 16 +- crates/engine/src/schema.rs | 10 +- docs/features.md | 8 +- 14 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 assets/icons/geometry.datum.axis.svg create mode 100644 assets/icons/geometry.datum.axis.vectorcraft create mode 100644 assets/icons/geometry.datum.point.svg create mode 100644 assets/icons/geometry.datum.point.vectorcraft create mode 100644 assets/icons/src/geometry.datum.axis.json create mode 100644 assets/icons/src/geometry.datum.point.json diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 683a40d..cc770aa 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/assets/icons/geometry.datum.axis.svg b/assets/icons/geometry.datum.axis.svg new file mode 100644 index 0000000..e11febb --- /dev/null +++ b/assets/icons/geometry.datum.axis.svg @@ -0,0 +1,11 @@ + + + geometry.datum.axis + + + + + + + + diff --git a/assets/icons/geometry.datum.axis.vectorcraft b/assets/icons/geometry.datum.axis.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..f51eef6f4b3e1ffc56dfd1dd791a3bac715601c7 GIT binary patch literal 2043 zcmV0U22%H z;8)#nQF_vkzqS>J zFI&1q#@f1r2dgbF)-@}8kpU1=RgBiOTrnJv!ypQglI-2`q5y#voJ&2=ngwy4?b5 zpJ^qJ_G%vh2cjF36Ac|DtFv;14Si2fM5$P)ZZ zwJNeK3DPJws*rl|lOtV_d3;tE5EyAADF?nG(x4;bJD@`#L)_k~Sb!R(1>cm5m8eVh z7;`SwWel=_|F~C1fWiqrIH!!*RT(xEYZf)dp&w^i5GI=AD9i&Ar-$E9p+Wqr-i6LU zl=^X0^*fxWNg5JD@;r*OI6~X#TGQ&!gD^<4Ec5+1B2g0D7zEy@oe&_jcQuR+@QxY$ z^i|!eMn93OTb~&mLV$n#M+OraYaTAc4JY9J#djrG3Nc>vRs`Fh*Yr4H4YhH1LLo0DD8E^ zqTjh9Aac1SW?N2jX_%%8W&Fk($&cllZ-grrRTGGfp(>9FQ=^$sgR@5SGZzJG9nlyR zWDrWF*QWM_eCX<`a1QE&k_nMvm1I2<$&bC`{*yvAzjX~F350DBm_Er7Lqr(co9ai= zIG1!(rja~n9V%6`*9(E4^REAW0@2$nXYbZ;V`#z>-@^@%rs{I1z988Tm zVi_rdeo;}0nteEjq9}8-3x_w9n>WK!m*ia=x_EGF(*SJRXCpmJc;)gL5lebzU`Gpo` z=e)YagPlw;blk}s&F`BE4j3EPl{XyNN}xi$FGt@-e%5t`xDU>6+u2@$B*I zbzfb4$4jVlLR-^{N|j{>=Oxq1CQz}e=!&;{{dmXMm5^}291yY@o17netT=LhY%FfU z<{!^=RcAZv20mzUbrFMVj6W-bpSrRc&G}x->@3S8FUO;09`qXIzF)D?MB(+gwcD$` z9J!YJp_e1~#?+t}n_6_$pb-5-P4{>`W~@m4(|5T)CE9~t_VD~p8mx6F|LMETSxPoR zO6n{noggJ~mXb`65<5$YCrF8$r9=~?gw9gJ2~q-QDZvCO#97L>QlFc_ZjNuZ?Q>qM zTAj})NWX{@qH08a!YFg>c*3Sr0=b|V|A<8{cz!nd)JS7^iCW?iLZm8-Q-NGkMjI|u zx&$!&^iBb}sBV=F%I_xG%B_xzBQdL+kxJyQN)$8k0S)K4A~<_3kE& z9)!Ns4WZG~&i?9xFBfrr+Y|4rE|%g!pIbjUZM}NmwFRU&-XG&Co9(=UGxPcfqhIz{ zKgt5xE8YmRuJ`^H+(bv*glD+H4aW8ob-&z+>r2sMedzso6}6`-I-v?P+Wl@R3M2#L zNypdBe6P8@3e;fsT!OSzUTbZ-<(hxtczql?W-xS38@!>-pend(n?+S|)0gj{jt#xO ZLa&lNv5FFet002gW>v8}9 literal 0 HcmV?d00001 diff --git a/assets/icons/geometry.datum.point.svg b/assets/icons/geometry.datum.point.svg new file mode 100644 index 0000000..dd5681d --- /dev/null +++ b/assets/icons/geometry.datum.point.svg @@ -0,0 +1,10 @@ + + + geometry.datum.point + + + + + + + diff --git a/assets/icons/geometry.datum.point.vectorcraft b/assets/icons/geometry.datum.point.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..e63fdbccc86d413f0593667397a32a5587bfc0fd GIT binary patch literal 1933 zcmV;82XgoyiwFP!00000|K(ciZre5#eicK%4MU>t0s5osimqt47Rk_J2m*t)Xq$^H zYPuxx0>AqXZ;E9*8AUroff-0_krdCT?_79zs7GhPsx@w$3+Il^8&=ILyl9*;Tvat? zg>w;%oh2zqg`pEheG{YJ@lhjpl1^M0o3VMjCIw9L_;jB)b{g8`1nyZ9wkA!rnPj+W z*OQXbf{SPi+SJhfl|GQXhK4!ISv6g=jNf#*oIw+=ni<1Y2IH#pOS^&V@U1$IeFoc<`JH6o@AEYdd!86}z9#0W$%CxVm*N7C5ho?xlJH#FO!Fc=Eiqr&fatIAsmpN`oC%UYF7d+#X zHB}1@^A*jriWHql1ca0&!4)p%gwID|5{XDkb`~_xL12lgREGjmvmEZ<0m4bnp`#Kk zSDY#@j)K^YK;8Vxcf%w>K3vZDW#FgC3*+r}JMLuFO~r1BrL-jCpIAvtk_g2?sDz~o z3J7!VY1XWO>{ zC|_HI?3YI3wEH&WftOzuzOwD;*f$YJj>+)DDXsSY`K z;USYr(n&si4B4w>9dICf_a;A#8?esMJ7Jsk3JXP(Jf~&N4dou9$t3JQ0z~K)Z;ssF z2h6afaQ8J|1O3PqlszmsGH7tsp9*P6XyD0B!XG1K4PGN&ULqh-!L{%ea1>bhg%;r+ zB)DOVg;KMmx7SlPa3i6Z!oF(MJzpx+kko#=Zf9ml&sl_rfQb&D^RSJa=77ewTSPT_}-dI{VB)uTT7F0l`*)=49ulolFT-) zBN5rP9eQ%DL<)f?qJEGV1Nj18wIrhx@IXib2!YPjLXPi5IJQtC?UVvas8dJ@WyeL0 zpuAnrM7nH+Bnf=a*E)*ia;q0#-h;x>7|0(Ga@oB#63ClFooA)K_~<;qQ?@h^%IHHy zU>{%^MtkOx#GEN>JfG>>)>vSn+20AyPlOP-J==f{A9ru_Q1bS#7TazRj6@>3={OPzLi|B6Z25?UE|A8dtU&=$sgSv1{%$LBs&R<;inPDRzOdH;i+*d1 zz!S@@rz;a)DhyL^UA5mBBl%clbjfV7$g)VN4OM!KA8XC{3Y;;TZyW9Mxn^i#n<(!mt$h;-Vi8i!y|-fbwxKMR?)#LQiECfSRPzPSI+$v8gd$R) zZc&kmnt3>fEh4eCjJ=#)=ZMlQhUV<=Rt#j}#<~`$iCD^1qqpkIG|e2$6)tRZpSTx1 z`X$K#4YbjB3`!QZ+lQ#9o}gE1@MR)o9>p%(+BTx^<;cE_AKFlK-fE2GJI;&NdUXkV zI~mk>+`${o_jL)UAGPhu>-TI0$xyG2)|Iw0fEZr+7>}MSMJy|vQ6f&Mx}O(aQR_1; zpf(15b;C+omg!ZGhLugA^i<+GZ8oZTO*45y#?664)^mF=&wZ>o^77ogo&}qKe&8er zn^`yTf%DNx49YqFuMA$bMSV2qdo8mwERURzN5eemHOT!kCr1;7*W=b~uXa9i4fjLm zBlkwvpr^}JEwapqCA_-wJ~@4&|TTB-T=r0a9XXDe(X) zk+qa)fRxZ$N;p7DU@avWAjP+q;t!C5tfim_Z4-WpOBwQ_5)lWlEI*hM(R^ zASczWl3w{;CtIn}agjJG1FnQl5`59&RFz0=mwm$u0v#c_U&gMHKILN7emF9hFhMju6i>=j>% zGp_gk7u*Cp-1skW!#5bKQ`CLF6W4RmVtnYm_tKPl>uZlHp3!dBGnR8QcsyxoM)dEB ziZ?uJo;?>JEf(*>)$J^!ca*=}3Ih|Ey23SoeUv~|aNg9@vZQ(}T|*rkMm@r)f;==+ TK4Y}q?b!YU6JB7OdMf|`9Lu%a literal 0 HcmV?d00001 diff --git a/assets/icons/src/geometry.datum.axis.json b/assets/icons/src/geometry.datum.axis.json new file mode 100644 index 0000000..30ce973 --- /dev/null +++ b/assets/icons/src/geometry.datum.axis.json @@ -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}]} diff --git a/assets/icons/src/geometry.datum.point.json b/assets/icons/src/geometry.datum.point.json new file mode 100644 index 0000000..b0854ff --- /dev/null +++ b/assets/icons/src/geometry.datum.point.json @@ -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]}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index b660366..a40d04f 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -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, diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 40e8dcb..e837bd9 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -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, + }, } #[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 { +/// 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 { + 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::(); + if !(n.iter().map(|x| x * x).sum::() > 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 { Ok(match by { PlaneBy::Offset { plane, offset } => { if !offset.is_finite() { @@ -136,22 +251,22 @@ fn plane_datum(by: PlaneBy) -> Result { }; 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::(); - if !(n.iter().map(|x| x * x).sum::() > 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 { + 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 { + 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 { + 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 { +fn datum_create(s: &mut Session, p: &Value, kind: &str) -> Result { 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 { + datum_create(s, p, "plane") +} + fn datum_axis(s: &mut Session, p: &Value) -> Result { + datum_create(s, p, "axis") +} + +fn datum_point(s: &mut Session, p: &Value) -> Result { + 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 { 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 { let name = super::name(p)?; + let cascade: bool = super::opt(p, "cascade")?.unwrap_or(false); + let partitions: Vec = 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, ¶ms).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(); diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 11530b1..2f63789 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -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(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a240482..28a1def 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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, diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs index 2be20ed..dc46ffd 100644 --- a/crates/engine/src/requirement.rs +++ b/crates/engine/src/requirement.rs @@ -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(); diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index e14582a..bc43888 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -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() diff --git a/docs/features.md b/docs/features.md index 3f8b829..0a74288 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -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