Add kernel solid operations and complete Solid3D (create and rigid move)

An additive HostApi::solid_operation builds cadkernel primitives and applies
rigid transforms, verifying each result lifts back losslessly and refusing a
payload it cannot lift. Exposed as doc.solids in Python; Solid3D, Body and
Region are mapped read-only with the opaque payload preserved. The lifecycle
test passes through the real runner with analytic volumes and exact extents as
oracles, DWG and DXF reopen with re-edit, and undo restores exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 17:29:06 +02:00
1 parent 07fe739571
commit 04cd9e462d
18 files changed
+717 -13

No files matched your search

BIN
View File
Binary file not shown.
@@ -493,6 +493,15 @@
"lower_right_corner", "lower_right_corner",
"lock_aspect", "lock_aspect",
"is_paper_space" "is_paper_space"
],
"Solid3D": [
"layer"
],
"Body": [
"layer"
],
"Region": [
"layer"
] ]
}, },
"readable": { "readable": {
@@ -603,6 +612,21 @@
"source_application", "source_application",
"ole_object_type", "ole_object_type",
"dwg_mode" "dwg_mode"
],
"Solid3D": [
"uid",
"point_of_reference",
"history_handle"
],
"Body": [
"uid",
"point_of_reference",
"history_handle"
],
"Region": [
"uid",
"point_of_reference",
"history_handle"
] ]
}, },
"aliases": { "aliases": {
@@ -2532,6 +2532,9 @@ mod tests {
| "ViewBorder" | "ViewBorder"
| "Light" | "Light"
| "Ole2Frame" | "Ole2Frame"
| "Solid3D"
| "Body"
| "Region"
) )
{ {
assert!( assert!(
+34
View File
@@ -60,6 +60,33 @@ pub enum LogLevel {
Error, Error,
} }
/// A solid primitive the host can build with its geometry kernel. Lengths are
/// in drawing units; `center`/`origin` are world coordinates.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SolidPrimitive {
/// Axis-aligned box from its center and full extents.
Box { center: [f64; 3], size: [f64; 3] },
/// Right triangular prism from its minimum corner and extents.
Wedge { origin: [f64; 3], size: [f64; 3] },
/// Cylinder standing on the plane through `center`.
Cylinder { center: [f64; 3], radius: f64, height: f64 },
Sphere { center: [f64; 3], radius: f64 },
Torus { center: [f64; 3], major: f64, minor: f64 },
/// Regular pyramid with `sides` base edges.
Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32 },
}
/// A kernel operation on ACIS-backed solids (API v7, additive). The host owns
/// the geometry: a script never sees or rewrites the payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SolidOperation {
/// Build a new solid on `layer` (layer `0` when `None`).
Create { primitive: SolidPrimitive, layer: Option<String> },
/// Apply a rigid transform, given as a column-major 4x4 matrix, to an
/// existing solid. Scaling and shear are refused.
Transform { handle: Handle, matrix: [f64; 16] },
}
/// Value of a host-managed drafting or document setting exposed to plugins. /// Value of a host-managed drafting or document setting exposed to plugins.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostSettingValue { pub enum HostSettingValue {
@@ -620,6 +647,13 @@ pub trait HostApi {
fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> { fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> {
Err("selection writes are not supported by this host".to_owned()) Err("selection writes are not supported by this host".to_owned())
} }
/// Run a kernel-backed solid operation and return the created or updated
/// entity's handle. Refused, without any change, when the kernel cannot
/// perform it losslessly (API v7, additive).
fn solid_operation(&mut self, _operation: SolidOperation) -> Result<Handle, String> {
Err("solid operations are not supported by this host".to_owned())
}
} }
/// Simplified, read-only entity kind exposed by [`DocumentReader`]. /// Simplified, read-only entity kind exposed by [`DocumentReader`].
+12
View File
@@ -470,6 +470,18 @@ impl HostApi for PluginHostApi {
} }
} }
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
match self.client.request(PluginRequest::SolidOperation { operation }) {
Ok(PluginResponse::SolidResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result, Ok(PluginResponse::SelectionResult(result)) => result,
@@ -149,6 +149,8 @@ pub enum PluginRequest {
/// V7: synchronous selection read/write for the dispatch tab. /// V7: synchronous selection read/write for the dispatch tab.
GetSelection, GetSelection,
SetSelection { handles: Vec<Handle> }, SetSelection { handles: Vec<Handle> },
/// V7 (additive): kernel-backed solid create or transform.
SolidOperation { operation: crate::host::SolidOperation },
} }
/// Responses the host sends back for `PluginRequest`. /// Responses the host sends back for `PluginRequest`.
@@ -180,6 +182,7 @@ pub enum PluginResponse {
EntityTransactionResult(Result<(), String>), EntityTransactionResult(Result<(), String>),
Selection(Vec<Handle>), Selection(Vec<Handle>),
SelectionResult(Result<(), String>), SelectionResult(Result<(), String>),
SolidResult(Result<Handle, String>),
} }
/// Messages sent from the host to the plugin runner. /// Messages sent from the host to the plugin runner.
+1
View File
@@ -107,5 +107,6 @@ pub fn handle_plugin_request(
), ),
GetSelection => PluginResponse::Selection(host.selection()), GetSelection => PluginResponse::Selection(host.selection()),
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)),
} }
} }
@@ -784,6 +784,18 @@ impl HostApi for V4PluginHostApi {
} }
} }
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
match self.request(PluginRequest::SolidOperation { operation }) {
Ok(PluginResponse::SolidResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result, Ok(PluginResponse::SelectionResult(result)) => result,
@@ -105,6 +105,27 @@ mod tests {
assert_eq!(decoded, changed); assert_eq!(decoded, changed);
} }
#[test]
fn solid_operation_request_and_response_roundtrip() {
use crate::host::{SolidOperation, SolidPrimitive};
for operation in [
SolidOperation::Create {
primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 },
layer: Some("SOLIDS".into()),
},
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
] {
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SolidOperation { operation: decoded } if decoded == operation));
}
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::SolidResult(decoded) if decoded == result));
}
}
#[test] #[test]
fn selection_request_and_response_roundtrip() { fn selection_request_and_response_roundtrip() {
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] }; let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
+2
View File
@@ -135,3 +135,5 @@ is refused with a clear message instead of being rewritten.
4. **Surface** (plane first). 4. **Surface** (plane first).
Phase 1 alone would move Solid3D from blocked to complete-in-OCS. Phase 1 alone would move Solid3D from blocked to complete-in-OCS.
**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Booleans, Region creation and Surface remain.
+5 -4
View File
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
As of 20 September 2026, the working branch is `plugin/host-model-api` at As of 20 September 2026, the working branch is `plugin/host-model-api` at
`a8dd3640` plus the Leader increment, published as draft PR `a8dd3640` plus the Leader increment, published as draft PR
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
v7. The Python feature has explicit writes for **38 of 43** canvas kinds: 36 v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 37
are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 completed are creatable (the legacy `Polyline`, `Ole2Frame`, `Body` and `Region` are update-only). This is **not** 41 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D and Solid3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows.
AttributeDefinition passes its host and real IPC lifecycle but remains short of AttributeDefinition passes its host and real IPC lifecycle but remains short of
`Complete` because the previously pinned CAD codec dropped optional ATTDEF `Complete` because the previously pinned CAD codec dropped optional ATTDEF
fields during DXF reads; revalidate that blocker against the current fields during DXF reads; revalidate that blocker against the current
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 5 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface) have only `layer` and are recorded as blocked `acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design)
writes through the document model. All 43 have raw typed snapshots. There are writes through the document model. All 43 have raw typed snapshots. There are
three internal records and two opaque fallbacks outside the 43 canvas kinds. three internal records and two opaque fallbacks outside the 43 canvas kinds.
@@ -177,3 +177,4 @@ exact queue above and update statuses with evidence after each increment.
- Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete.
- Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface.
- Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. - Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked.
- Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved.
+4 -4
View File
@@ -1,6 +1,6 @@
# OCS canvas kind coverage ledger # OCS canvas kind coverage ledger
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **38 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline` and `Ole2Frame`), **26 kinds are complete**, and 5 remain layer-only and blocked (SectionSymbol, Region, Body, Solid3D, Surface). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (37 creatable, plus update-only legacy `Polyline`, `Ole2Frame`, `Body` and `Region`), **27 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
@@ -44,9 +44,9 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. |
| SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. |
| Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. |
| Region | **Blocked, unblocked by design** | Not mapped yet. Transform uses the same pipeline; creation needs a "region from these curve handles" operation built on the REGION command's `add_region_model`. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Region | **Mapped read-only; transform untested** | Mapped like Solid3D and accepted by `doc.solids.transform`, which lifts the payload with `kernel_acis_body`, but no Region fixture exercises it yet. Creation from curve handles (the REGION command's `add_region_model`) is the next operation to add. |
| Body | **Blocked, unblocked by design** | Not mapped yet. Same pipeline as Solid3D: `kernel_acis_body` lifts a `Body` payload and the spike round-tripped it through DWG and DXF. A genuinely non-solid (sheet or wire) body may not lift and would stay layer-only. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. |
| Solid3D | **Blocked, unblocked by design** | Not mapped yet. The 2026-09-20 kernel investigation ([cadkernel-body-path.md](cadkernel-body-path.md)) shows OCS already creates the payload (nine primitives), transforms it and combines bodies through cadkernel, and that `solid_to_sat` and `kernel_acis_body` verify every result lifts back with no loss; spikes confirmed exact volumes and DWG and DXF round trips. What is missing is a script-to-host operation channel (recommended: an additive `solid_operation` host method covering create-primitive, transform and boolean), not geometry. Open items: boolean speed (about 23 s in a debug build, unmeasured in release), history-carrying solids, and AutoCAD acceptance, which cannot be tested here. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. |
| Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). |
| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. |
+2 -2
View File
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
catalog. All 43 canvas kinds also support nonempty `layer` changes through catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 38 geometry converters. Other writable properties canvas kinds outside its 41 geometry converters. Other writable properties
still report `type_conversion_only`; broader CAD range and cross-property still report `type_conversion_only`; broader CAD range and cross-property
checks remain outstanding. checks remain outstanding.
The host's `entity_snapshot` helper serializes any typed entity as a detached The host's `entity_snapshot` helper serializes any typed entity as a detached
@@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
token from another tab returns no result and does not consume it. token from another tab returns no result and does not consume it.
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
outside the 38-kind generated schema it contains only handle, kind, and layer; outside the 41-kind generated schema it contains only handle, kind, and layer;
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the `layer` is editable on canvas kinds. `doc.coverage()` enumerates the
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
covered kinds, the coverage object lists actual readable and editable covered kinds, the coverage object lists actual readable and editable
+1 -1
View File
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field, `doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to type, access, and current validation status. Geometry edits remain limited to
the 38 mapped kinds. the 41 mapped kinds.
For any entity, `entity.snapshot` returns a detached dictionary of its raw For any entity, `entity.snapshot` returns a detached dictionary of its raw
CAD fields, including `snapshot["common"]` and kind-specific nested values. CAD fields, including `snapshot["common"]` and kind-specific nested values.
It can inspect an unmapped kind; editing the dictionary does It can inspect an unmapped kind; editing the dictionary does
+85 -1
View File
@@ -37,7 +37,10 @@
"Viewport", "Viewport",
"ViewBorder", "ViewBorder",
"Light", "Light",
"Ole2Frame" "Ole2Frame",
"Solid3D",
"Body",
"Region"
], ],
"manual_kinds": [ "manual_kinds": [
"Dimension" "Dimension"
@@ -807,6 +810,87 @@
"read_only": true "read_only": true
} }
} }
},
"Solid3D": {
"update_only": true,
"update_only_message": "Solid3D is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
},
"Body": {
"update_only": true,
"update_only_message": "Body is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
},
"Region": {
"update_only": true,
"update_only_message": "Region is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
} }
} }
} }
@@ -84,10 +84,65 @@ class _Transaction:
return False return False
class _Solids:
"""Kernel-backed solid creation and rigid moves.
The host builds and edits the geometry; a script never sees the ACIS
payload. Every call is its own operation in the script's undo group and
fails, leaving the drawing unchanged, when the kernel cannot do it.
"""
def __init__(self, document):
self._document = document
def _create(self, primitive, values, layer):
handle = ocs.solid_create(primitive, [float(v) for v in values], layer)
return self._document.entities[handle]
def box(self, center=(0, 0, 0), size=(1, 1, 1), layer=None):
return self._create("box", tuple(center) + tuple(size), layer)
def wedge(self, origin=(0, 0, 0), size=(1, 1, 1), layer=None):
return self._create("wedge", tuple(origin) + tuple(size), layer)
def cylinder(self, center=(0, 0, 0), radius=1, height=1, layer=None):
return self._create("cylinder", tuple(center) + (radius, height), layer)
def sphere(self, center=(0, 0, 0), radius=1, layer=None):
return self._create("sphere", tuple(center) + (radius,), layer)
def torus(self, center=(0, 0, 0), major=2, minor=0.5, layer=None):
return self._create("torus", tuple(center) + (major, minor), layer)
def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None):
return self._create("pyramid", tuple(center) + (radius, height, sides), layer)
def transform(self, entity, matrix):
"""Apply a column-major 4x4 rigid transform (16 numbers) in place."""
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
ocs.solid_transform(handle, [float(v) for v in matrix])
return self._document.entities[handle]
def translate(self, entity, offset):
dx, dy, dz = (float(v) for v in offset)
return self.transform(entity, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, dz, 1])
def rotate(self, entity, axis, angle, about=(0, 0, 0)):
"""Turn about the world axis 'x', 'y' or 'z' through `about`."""
index = {"x": 0, "y": 1, "z": 2}[axis]
return self.transform(entity, ocs.rotation_matrix(index, float(angle), [float(v) for v in about]))
def mirror(self, entity, axis, about=(0, 0, 0)):
"""Reflect across the plane normal to the world axis through `about`."""
index = {"x": 0, "y": 1, "z": 2}[axis]
return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about]))
class _Document: class _Document:
def __init__(self): def __init__(self):
self._pending = None self._pending = None
self.entities = _Entities(self) self.entities = _Entities(self)
self.solids = _Solids(self)
def transaction(self, label): def transaction(self, label):
return _Transaction(self, label) return _Transaction(self, label)
+109 -1
View File
@@ -411,6 +411,114 @@ mod ocs {
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
} }
/// Build a kernel solid. `values` are the primitive's numbers in a fixed
/// order (see `document_model.py`); the host validates and builds it.
#[pyfunction]
fn solid_create(
primitive: String,
values: Vec<f64>,
layer: Option<String>,
vm: &VirtualMachine,
) -> PyResult<u64> {
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
let expect = |count: usize| -> PyResult<()> {
if values.len() == count {
Ok(())
} else {
Err(vm.new_value_error(format!(
"ocs.solid_create: {primitive} takes {count} numbers, got {}",
values.len()
)))
}
};
let v3 = |i: usize| [values[i], values[i + 1], values[i + 2]];
let primitive = match primitive.as_str() {
"box" => { expect(6)?; SolidPrimitive::Box { center: v3(0), size: v3(3) } }
"wedge" => { expect(6)?; SolidPrimitive::Wedge { origin: v3(0), size: v3(3) } }
"cylinder" => { expect(5)?; SolidPrimitive::Cylinder { center: v3(0), radius: values[3], height: values[4] } }
"sphere" => { expect(4)?; SolidPrimitive::Sphere { center: v3(0), radius: values[3] } }
"torus" => { expect(5)?; SolidPrimitive::Torus { center: v3(0), major: values[3], minor: values[4] } }
"pyramid" => {
expect(6)?;
if values[5] < 0.0 || values[5].fract() != 0.0 || values[5] > f64::from(u32::MAX) {
return Err(vm.new_value_error("ocs.solid_create: sides must be a whole number".to_owned()));
}
SolidPrimitive::Pyramid { center: v3(0), radius: values[3], height: values[4], sides: values[5] as u32 }
}
other => return Err(vm.new_value_error(format!("ocs.solid_create: unknown primitive {other:?}"))),
};
// The host records its own undo step once the operation validates, like
// an entity transaction, so a refused operation leaves no empty step.
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Create { primitive, layer })
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}")))
}
/// Apply a column-major 4x4 rigid transform to a solid, body or region.
#[pyfunction]
fn solid_transform(handle: u64, matrix: Vec<f64>, vm: &VirtualMachine) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let matrix: [f64; 16] = matrix.try_into().map_err(|_| {
vm.new_value_error("ocs.solid_transform: the matrix needs 16 numbers".to_owned())
})?;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Transform { handle: Handle::new(handle), matrix })
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_transform: {error}")))
}
/// Column-major matrix that turns about a world axis (0=X, 1=Y, 2=Z)
/// through the point `about`. The sandbox has no `math` module.
#[pyfunction]
fn rotation_matrix(axis: usize, angle: f64, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if axis > 2 || about.len() != 3 || !angle.is_finite() || about.iter().any(|v| !v.is_finite()) {
return Err(vm.new_value_error("ocs.rotation_matrix: axis 0-2, a finite angle and a 3-point".to_owned()));
}
let (sin, cos) = angle.sin_cos();
let (x, y, z) = match axis {
0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]),
1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]),
_ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]),
};
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
}
/// Column-major matrix that reflects across the plane normal to a world
/// axis through the point `about`.
#[pyfunction]
fn mirror_matrix(axis: usize, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if axis > 2 || about.len() != 3 || about.iter().any(|v| !v.is_finite()) {
return Err(vm.new_value_error("ocs.mirror_matrix: axis 0-2 and a finite 3-point".to_owned()));
}
let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
columns[axis][axis] = -1.0;
let [x, y, z] = columns;
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
}
fn matrix_to_py(vm: &VirtualMachine, matrix: Vec<f64>) -> PyObjectRef {
vm.ctx.new_list(matrix.into_iter().map(|value| vm.new_pyobj(value)).collect()).into()
}
/// A transform about `about` has origin `about - M * about`.
fn frame_matrix(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: &[f64]) -> Vec<f64> {
let mut origin = [about[0], about[1], about[2]];
for axis in 0..3 {
origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2];
}
vec![
x[0], x[1], x[2], 0.0, y[0], y[1], y[2], 0.0, z[0], z[1], z[2], 0.0,
origin[0], origin[1], origin[2], 1.0,
]
}
/// Delete one entity in the script's shared undo group. /// Delete one entity in the script's shared undo group.
#[pyfunction] #[pyfunction]
fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> { fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> {
@@ -4780,7 +4888,7 @@ mod tests {
); );
} }
} }
assert_eq!(emitted.len(), 38); assert_eq!(emitted.len(), 41);
} }
#[cfg(feature = "experimental-host-model")] #[cfg(feature = "experimental-host-model")]
+344
View File
@@ -530,6 +530,151 @@ impl<'a> HostSession<'a> {
} }
} }
/// Kernel-backed solid create or transform. Every result is verified by
/// `solid_to_sat` (the exported payload must lift back with no loss) and an
/// existing payload is only transformed when it lifts losslessly, so a
/// failure never changes the document.
pub fn solid_operation(
&mut self,
operation: ocs_plugin_api::host::SolidOperation,
) -> Result<Handle, String> {
use crate::scene::model::solid_model as model;
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
let positive = |name: &str, value: f64| {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(format!("{name} must be finite and greater than zero"))
}
};
let finite3 = |name: &str, value: [f64; 3]| {
if value.iter().all(|v| v.is_finite()) {
Ok(())
} else {
Err(format!("{name} must be finite"))
}
};
match operation {
SolidOperation::Create { primitive, layer } => {
let body = match primitive {
SolidPrimitive::Box { center, size } => {
finite3("box center", center)?;
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
positive(&format!("box {name}"), v)?;
}
model::box_solid(center, size[0], size[1], size[2])
}
SolidPrimitive::Wedge { origin, size } => {
finite3("wedge origin", origin)?;
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
positive(&format!("wedge {name}"), v)?;
}
model::wedge_solid(origin, size[0], size[1], size[2])
}
SolidPrimitive::Cylinder { center, radius, height } => {
finite3("cylinder center", center)?;
positive("cylinder radius", radius)?;
positive("cylinder height", height)?;
model::cylinder_solid(center, radius, height)
}
SolidPrimitive::Sphere { center, radius } => {
finite3("sphere center", center)?;
positive("sphere radius", radius)?;
model::sphere_solid(center, radius)
}
SolidPrimitive::Torus { center, major, minor } => {
finite3("torus center", center)?;
positive("torus major radius", major)?;
positive("torus minor radius", minor)?;
if minor >= major {
return Err("torus minor radius must be smaller than the major radius".into());
}
model::torus_solid(center, major, minor)
}
SolidPrimitive::Pyramid { center, radius, height, sides } => {
finite3("pyramid center", center)?;
positive("pyramid radius", radius)?;
positive("pyramid height", height)?;
if !(3..=1024).contains(&sides) {
return Err("pyramid sides must be between 3 and 1024".into());
}
model::pyramid_solid(center, radius, height, sides as usize)
}
}
.ok_or("the geometry kernel refused these dimensions")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the solid could not be exported losslessly")?;
let mut solid = acadrust::entities::Solid3D::new();
solid.wires = model::edge_wires(&body);
solid.set_sat_document(&sat);
if let Some(layer) = layer {
if layer.trim().is_empty() {
return Err("layer name is empty".into());
}
solid.common.layer = layer;
}
self.push_undo("Create solid");
let handle = self.add_entity(EntityType::Solid3D(solid));
if handle.is_null() {
return Err("the solid could not be added".into());
}
Ok(handle)
}
SolidOperation::Transform { handle, matrix } => {
if matrix.iter().any(|v| !v.is_finite()) {
return Err("transform matrix must be finite".into());
}
let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]];
let (x, y, z) = (column(0), column(4), column(8));
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6)
&& dot(x, y).abs() < 1e-6
&& dot(x, z).abs() < 1e-6
&& dot(y, z).abs() < 1e-6
&& matrix[3].abs() < 1e-9
&& matrix[7].abs() < 1e-9
&& matrix[11].abs() < 1e-9
&& (matrix[15] - 1.0).abs() < 1e-9;
if !rigid {
return Err("only rigid moves and mirrors are supported; scaling and shear are not".into());
}
let mut entity = self
.document()
.get_entity(handle)
.cloned()
.ok_or_else(|| format!("entity {handle:?} does not exist"))?;
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
macro_rules! retarget {
($value:expr) => {{
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data)
.ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?;
let moved = model::by_matrix(&body, matrix)
.ok_or("the geometry kernel refused this transform")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&moved)
.ok_or("the moved body could not be exported losslessly")?;
$value.wires = model::edge_wires(&moved);
$value.silhouettes.clear();
$value.history_handle = None;
$value.set_sat_document(&sat);
}};
}
match &mut entity {
EntityType::Solid3D(value) => retarget!(value),
EntityType::Body(value) => retarget!(value),
EntityType::Region(value) => retarget!(value),
_ => return Err("transform applies to Solid3D, Body and Region entities".into()),
}
self.push_undo("Transform solid");
if !self.update_entity(entity) {
return Err(format!("entity {handle:?} could not be updated"));
}
Ok(handle)
}
}
}
pub fn push_undo(&mut self, label: &str) { pub fn push_undo(&mut self, label: &str) {
self.app.push_undo_snapshot(self.tab, label); self.app.push_undo_snapshot(self.tab, label);
} }
@@ -622,6 +767,12 @@ impl HostApi for HostSession<'_> {
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
self.set_selection(handles) self.set_selection(handles)
} }
fn solid_operation(
&mut self,
operation: ocs_plugin_api::host::SolidOperation,
) -> Result<Handle, String> {
self.solid_operation(operation)
}
fn remove_entity(&mut self, handle: Handle) -> bool { fn remove_entity(&mut self, handle: Handle) -> bool {
self.remove_entity(handle) self.remove_entity(handle)
} }
@@ -4558,6 +4709,199 @@ mod tests {
time("volume(union)", &mut || format!("{:.1}", sm::volume(&result))); time("volume(union)", &mut || format!("{:.1}", sm::volume(&result)));
} }
#[test]
fn audit_python_solid3d_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_body;
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let solids = |host: &HostSession<'_>| -> Vec<Handle> {
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect();
list.sort_by_key(|h| h.value());
list
};
let body_of = |host: &HostSession<'_>, handle: Handle| {
let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") };
kernel_body(solid).expect("payload lifts losslessly")
};
let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle));
let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap();
let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0);
// C: every primitive, checked against its analytic volume.
let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"s = ocs.active_document.solids\n",
"s.box(center=(0, 0, 0), size=(10, 6, 4))\n",
"s.cylinder(center=(20, 0, 0), radius=2, height=5)\n",
"s.sphere(center=(40, 0, 0), radius=3)\n",
"s.torus(center=(60, 0, 0), major=5, minor=1)\n",
"s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n",
"s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n",
"s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let list = solids(&host);
assert_eq!(list.len(), 7, "{}", last(&host));
let pi = std::f64::consts::PI;
let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin();
for (index, expected, tolerance) in [
(0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02),
(3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9),
] {
let actual = volume(&host, list[index]);
assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}");
}
let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() };
assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0"));
assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS"));
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
let read = |host: &mut HostSession<'_>, expression: &str| {
dispatch(host, &format!("PY_EVAL {expression}"));
last(host)
};
assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D"));
// E: rigid moves keep the volume and move the extent exactly.
let box_handle = list[0];
let before = host.document().get_entity(box_handle).unwrap().clone();
let undo_before = host.app.tabs[0].history.undo_stack.len();
let (low, high) = extent(&host, box_handle);
assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value()));
let (low, high) = extent(&host, box_handle);
assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value()));
assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume");
let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() };
assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle));
assert_eq!(moved.common.layer, "0");
assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move");
// V: refused operations change nothing and leave no undo step.
let snapshot = host.document().get_entity(box_handle).unwrap().clone();
let count_before = solids(&host).len();
let undo_now = host.app.tabs[0].history.undo_stack.len();
let empty_payload = acadrust::entities::Solid3D::new();
let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone()));
let unliftable_before = host.document().get_entity(unliftable).unwrap().clone();
for (command, message) in [
("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"),
("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"),
("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"),
("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"),
("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"),
("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"),
("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"),
("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"),
(format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"),
(format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"),
(format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"),
(format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"),
("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!(host.document().get_entity(box_handle), Some(&snapshot));
assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before));
assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added");
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
// Transforming a non-solid is refused.
let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value()));
assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host));
// W: both formats reopen the moved solid losslessly; the reopened
// payload can be moved again.
let moved_volume = volume(&host, box_handle);
let moved_extent = extent(&host, box_handle);
let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") };
let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift"));
assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}");
let reopened_extent = sm::extent(&body).unwrap();
for axis in 0..3 {
assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent");
}
let again = host.add_entity(EntityType::Solid3D(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
let (low, _) = extent(&host, again);
assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit");
}
// D and U: delete, then undo and redo restore the moved state.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value()));
assert!(host.document().get_entity(box_handle).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
// Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete.
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid");
app.undo_steps(5);
assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly");
app.redo_steps(6);
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete");
}
#[test]
fn audit_python_body_transform_over_real_ipc() {
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
// A Body entity carrying a kernel box; scripts cannot create one.
let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap();
let mut body = acadrust::entities::Body::new();
body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap());
body.wires = sm::edge_wires(&boxed);
let handle = host.add_entity(EntityType::Body(body));
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let extent = |host: &HostSession<'_>| {
let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") };
sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap()
};
assert!((extent(&host).0[0] + 2.0).abs() < 1e-6);
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value()));
assert!(host.app.command_line.history.last().unwrap().text.contains("Body"));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value()));
assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host));
let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap();
let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") };
let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts");
assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6);
// Scripts cannot create a Body and get the explanatory message.
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')");
assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text);
}
#[test] #[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() { fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {