From 04cd9e462daa6b76d389439910b1db7667ed3c70 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:29:06 +0200 Subject: [PATCH] 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 --- crates/.DS_Store | Bin 0 -> 8196 bytes .../entity_coverage_policy.json | 24 ++ crates/ocs_plugin_api/src/entity_coverage.rs | 3 + crates/ocs_plugin_api/src/host.rs | 34 ++ crates/ocs_plugin_api/src/ipc/client.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 3 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 12 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 21 ++ docs/cadkernel-body-path.md | 2 + docs/plugin-host-model-completion-plan.md | 9 +- docs/plugin-host-model-coverage-ledger.md | 8 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 86 ++++- plugins/opencad-python/src/document_model.py | 55 +++ plugins/opencad-python/src/ocs_module.rs | 110 +++++- src/app/plugin_host.rs | 344 ++++++++++++++++++ 18 files changed, 717 insertions(+), 13 deletions(-) create mode 100644 crates/.DS_Store diff --git a/crates/.DS_Store b/crates/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bc57e5c5760aead09d636e1bf44001717d43cd9f GIT binary patch literal 8196 zcmeHMO-~d-6s*RLZZtuR95~Lw#Ax7vCyxVJZzP&<cpx0P zdhu8A2RL)adi^@?bY_8pL@?oX(p@v%UE5RjW~cYf0&vTt-Xbsupv@{c^MuWWBI8nP zWh(T{5*_)#7i?jK0W$2>W?P3jU=ElA=72e14*U-eaL<;~zF^q139EMl)^MwOb%%11&o9?Qodvb+#kyV>&6!nwuh!nK?7Z~) z8m>FY$2zaij`gOZcN#g?@fja6WW+{{B-Qb(%k%9><-@h(tL9g=&77Qn{jRaQbd9Q_ zrkqH}qYtr;g7;lU1qGGuW4C@6zBq_0QctInBH&JZx_=uVsbF2E^PvKjL(>3^GtvBJvx)X_?6mHLc2PP)rJPlYXa)ib9 ze{X{|=D?jf@IY6dll=eP#qa;`?D}kBbHE(<_YSyLXSK7;b#!a4FDSWo!up9-ip(n& lJ%x=-k$#_!6aW5U$m1l8r2$Jtj }, + /// 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. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum HostSettingValue { @@ -620,6 +647,13 @@ pub trait HostApi { fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> { 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 { + Err("solid operations are not supported by this host".to_owned()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 744232162..aa5048284 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -470,6 +470,18 @@ impl HostApi for PluginHostApi { } } + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + 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> { match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { Ok(PluginResponse::SelectionResult(result)) => result, diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index d6bd60a59..70a5b72b6 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -149,6 +149,8 @@ pub enum PluginRequest { /// V7: synchronous selection read/write for the dispatch tab. GetSelection, SetSelection { handles: Vec }, + /// V7 (additive): kernel-backed solid create or transform. + SolidOperation { operation: crate::host::SolidOperation }, } /// Responses the host sends back for `PluginRequest`. @@ -180,6 +182,7 @@ pub enum PluginResponse { EntityTransactionResult(Result<(), String>), Selection(Vec), SelectionResult(Result<(), String>), + SolidResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index ee96ceac9..b20262b02 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -107,5 +107,6 @@ pub fn handle_plugin_request( ), GetSelection => PluginResponse::Selection(host.selection()), SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), + SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 1de1631ae..cac660388 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -784,6 +784,18 @@ impl HostApi for V4PluginHostApi { } } + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + 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> { match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { Ok(PluginResponse::SelectionResult(result)) => result, diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 4b6ddd583..d9c3e8bae 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -105,6 +105,27 @@ mod tests { 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::(&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::(&bytes).unwrap(), + PluginResponse::SolidResult(decoded) if decoded == result)); + } + } + #[test] fn selection_request_and_response_roundtrip() { let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] }; diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index 87133ad05..ee70be7c1 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -135,3 +135,5 @@ is refused with a clear message instead of being rewritten. 4. **Surface** (plane first). 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. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 104d6b4c5..8adc7eab1 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -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 `a8dd3640` plus the Leader increment, published as draft PR [#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 -are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 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. +v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 37 +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, 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 `Complete` because the previously pinned CAD codec dropped optional ATTDEF 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 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. - 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. +- 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. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 4a46f9c8c..59d8363b5 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # 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. @@ -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. | | 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. | -| 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). | -| 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). | -| 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. | +| 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 | **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 | **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). | | 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. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 375e3bb3c..1b6d4598f 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -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 the same undoable transaction; internal and opaque variants remain read-only 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 checks remain outstanding. 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. 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 catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index bfe545089..217e08f97 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -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 `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, 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 CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 5bca21be9..7accbdf77 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -37,7 +37,10 @@ "Viewport", "ViewBorder", "Light", - "Ole2Frame" + "Ole2Frame", + "Solid3D", + "Body", + "Region" ], "manual_kinds": [ "Dimension" @@ -807,6 +810,87 @@ "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 + } + } } } } diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 1e5d4281d..440af6981 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -84,10 +84,65 @@ class _Transaction: 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: def __init__(self): self._pending = None self.entities = _Entities(self) + self.solids = _Solids(self) def transaction(self, label): return _Transaction(self, label) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 1d348c8b9..130f3da17 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -411,6 +411,114 @@ mod ocs { .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, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + 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, vm: &VirtualMachine) -> PyResult { + 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, vm: &VirtualMachine) -> PyResult { + 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, vm: &VirtualMachine) -> PyResult { + 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) -> 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 { + 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. #[pyfunction] 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")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0fb2bae30..950b8e2ff 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -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 { + 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) { 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> { self.set_selection(handles) } + fn solid_operation( + &mut self, + operation: ocs_plugin_api::host::SolidOperation, + ) -> Result { + self.solid_operation(operation) + } fn remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -4558,6 +4709,199 @@ mod tests { 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 { + 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] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {