From 9cd6653a58ecf406e0a6fac6a3a898ca2724646e Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:47:22 +0200 Subject: [PATCH] Complete Surface: plane surfaces, extrusions, isolines and moves Adds SurfaceFromProfile and Extrude kernel operations (doc.solids.surface and doc.solids.extrude), accepts surfaces in Transform, and maps Surface with editable isolines. Fixes a real bug found by the test: the surface kind field overwrote the entity-type kind key in Python dicts, so it is exposed as surface_kind, with a catalog test guarding against name collisions. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 14 + crates/ocs_plugin_api/src/entity_coverage.rs | 37 +++ crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 2 + docs/cadkernel-body-path.md | 2 +- docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 37 ++- plugins/opencad-python/src/document_model.py | 12 + plugins/opencad-python/src/ocs_module.rs | 46 ++- src/app/plugin_host.rs | 302 ++++++++++++++++-- 14 files changed, 441 insertions(+), 34 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 1d60b1952..8da98a6f8 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("Surface", "u_isolines" | "v_isolines") | ("Ole2Frame", _) | ("Underlay", _) | ("Viewport", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index c1a882a9b..2d7bfc98b 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -502,6 +502,11 @@ ], "Region": [ "layer" + ], + "Surface": [ + "layer", + "u_isolines", + "v_isolines" ] }, "readable": { @@ -627,6 +632,12 @@ "uid", "point_of_reference", "history_handle" + ], + "Surface": [ + "surface_kind", + "modeler_format_version", + "point_of_reference", + "history_handle" ] }, "aliases": { @@ -643,6 +654,9 @@ "MText": { "text": "value", "insertion": "insertion_point" + }, + "Surface": { + "surface_kind": "kind" } }, "synthetic": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index a92f6716d..e36ded41d 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1245,6 +1245,24 @@ fn validate_ole_frame( Ok(()) } +/// Surface: only the display isoline density is scriptable; the geometry lives +/// in the ACIS payload that the geometry kernel owns. +#[cfg(feature = "host")] +fn validate_surface( + old: Option<&crate::host::acadrust::entities::Surface>, + new: &crate::host::acadrust::entities::Surface, +) -> Result<(), String> { + if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) { + return Ok(()); + } + for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] { + if !(0..=200).contains(&value) { + return Err(format!("Surface.{name} must be between 0 and 200")); + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1371,6 +1389,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } } EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, + EntityType::Surface(value) => validate_surface(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1745,6 +1764,7 @@ pub fn validate_entity_mutation( return Err("Ole2Frame embedded storage cannot change".into()); } } + (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2486,6 +2506,20 @@ mod tests { } } + #[test] + fn exposed_property_names_are_unique_per_kind() { + // A field named like a base key (`kind`, `handle`, `layer`, ...) would + // overwrite it in every Python dict; such fields must be renamed. + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + for entry in &catalog.entity_kinds { + let mut seen = std::collections::HashSet::new(); + for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) { + assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name); + } + } + } + #[test] fn unsupported_kinds_have_no_editable_properties() { let catalog: EntityCoverageCatalog = @@ -2535,6 +2569,7 @@ mod tests { | "Solid3D" | "Body" | "Region" + | "Surface" ) { assert!( @@ -2824,6 +2859,8 @@ mod tests { "Ole2Frame.lower_right_corner".to_owned(), "Ole2Frame.lock_aspect".to_owned(), "Ole2Frame.is_paper_space".to_owned(), + "Surface.u_isolines".to_owned(), + "Surface.v_isolines".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index ec59c428e..414487e4c 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -90,6 +90,11 @@ pub enum SolidOperation { /// `layer`, or on the source's layer when `None`; `delete_source` erases /// the profile afterwards, as the REGION command does. RegionFromProfile { source: Handle, layer: Option, delete_source: bool }, + /// Build a plane surface from one closed planar profile entity. + SurfaceFromProfile { source: Handle, layer: Option, delete_source: bool }, + /// Extrude a planar profile along `direction`: a solid when the profile is + /// closed, a surface when it is open. + Extrude { source: Handle, direction: [f64; 3], layer: Option, delete_source: bool }, } /// Value of a host-managed drafting or document setting exposed to plugins. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 740aa9773..94b023acd 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,8 @@ mod tests { }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, + SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false }, + SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true }, ] { let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index ba6ad1e74..2bffcfde8 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -136,4 +136,4 @@ is refused with a clear message instead of being rewritten. 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. Region creation from a closed profile followed (`RegionFromProfile`). Booleans and Surface remain. +**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. Region creation from a closed profile followed (`RegionFromProfile`). Plane surfaces and extrusions (`SurfaceFromProfile`, `Extrude`) followed. Only booleans remain, plus lofted, revolved, swept and NURBS surfaces. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index c3b7e0567..c46764cce 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 **41 of 43** canvas kinds: 38 +v7. The Python feature has explicit writes for **42 of 43** canvas kinds: 39 are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` 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, Solid3D and Region 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, Solid3D, Region and Surface 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 remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design) +`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 canvas kind, SectionSymbol, has only `layer` and is recorded as blocked 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. @@ -179,3 +179,4 @@ exact queue above and update statuses with evidence after each increment. - 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. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. +- Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a35d9b2e6..75b6920bc 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: **41 of 43 canvas kinds have explicit geometry writes** (38 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **28 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`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **42 of 43 canvas kinds have explicit geometry writes** (39 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and 1 remains layer-only (SectionSymbol, blocked). `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. @@ -47,7 +47,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | 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). | +| Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | | 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. | After each increment, update the row with test names, fixture paths, exact diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 1b6d4598f..8bb6e0ca6 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 41 geometry converters. Other writable properties +canvas kinds outside its 42 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 41-kind generated schema it contains only handle, kind, and layer; +outside the 42-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 217e08f97..7f594836e 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 41 mapped kinds. +the 42 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 7accbdf77..014a750b6 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -40,7 +40,8 @@ "Ole2Frame", "Solid3D", "Body", - "Region" + "Region", + "Surface" ], "manual_kinds": [ "Dimension" @@ -891,6 +892,40 @@ "read_only": true } } + }, + "Surface": { + "update_only": true, + "update_only_message": "Surface is built by the geometry kernel: use doc.solids.surface(profile) for a plane surface or doc.solids.extrude(open_profile, direction) for an extruded one; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; never rewritten by a script." + }, + "surface_data": { + "exclude": true, + "_comment": "Kind-specific sweep/loft parameters and embedded profiles; preserved untouched." + }, + "wires": { + "exclude": true + }, + "silhouettes": { + "exclude": true + }, + "kind": { + "read_only": true, + "python_name": "surface_kind", + "_comment": "Renamed: a bare `kind` key names the entity type in every Python dict." + }, + "modeler_format_version": { + "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 19eadefff..0d0643e53 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -146,6 +146,18 @@ class _Solids: handle = profile.handle if isinstance(profile, _Entity) else int(profile) return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] + def surface(self, profile, layer=None, delete_source=False): + """Make a plane surface from one closed planar profile.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_surface(handle, layer, bool(delete_source))] + + def extrude(self, profile, direction, layer=None, delete_source=False): + """Extrude a planar profile along `direction`: a Solid3D when the + profile is closed, a Surface when it is open.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + vector = [float(v) for v in direction] + return self._document.entities[ocs.solid_extrude(handle, vector, layer, bool(delete_source))] + 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) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index dcfdb140b..471761b45 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,50 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Build a plane surface from a closed planar profile entity. + #[pyfunction] + fn solid_surface(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::SurfaceFromProfile { + source: Handle::new(source), + layer, + delete_source, + }) + }) + .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_surface: {error}"))) + } + + /// Extrude a planar profile: a solid if closed, a surface if open. + #[pyfunction] + fn solid_extrude( + source: u64, + direction: Vec, + layer: Option, + delete_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let direction: [f64; 3] = direction.try_into().map_err(|_| { + vm.new_value_error("ocs.solid_extrude: the direction needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Extrude { + source: Handle::new(source), + direction, + layer, + delete_source, + }) + }) + .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_extrude: {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 { @@ -4905,7 +4949,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 41); + assert_eq!(emitted.len(), 42); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 9db23fa3c..899c0cc7d 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -621,22 +621,10 @@ impl<'a> HostSession<'a> { Ok(handle) } SolidOperation::RegionFromProfile { source, layer, delete_source } => { - if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { - return Err("layer name is empty".into()); - } - let entity = self - .document() - .get_entity(source) - .cloned() - .ok_or_else(|| format!("entity {source:?} does not exist"))?; + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; if matches!(entity, EntityType::Region(_)) { return Err("the source is already a region".into()); } - if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { - return Err(format!("entity {source:?} is on a locked layer")); - } - let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) - .ok_or("the source is not a planar profile a region can be made from")?; if !closed { return Err("the profile must be closed".into()); } @@ -649,17 +637,60 @@ impl<'a> HostSession<'a> { acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); region.wires = model::edge_wires(&body); region.set_sat_document(&sat); - region.common.layer = layer.unwrap_or_else(|| entity.common().layer.clone()); - self.push_undo("Create region"); - let handle = self.add_entity(EntityType::Region(region)); - if handle.is_null() { - return Err("the region could not be added".into()); + region.common.layer = layer; + self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region") + } + SolidOperation::SurfaceFromProfile { source, layer, delete_source } => { + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("the source is already a region".into()); } - if delete_source { - self.app.tabs[self.tab].scene.erase_entities(&[source]); - self.publish_document_view(); + if !closed { + return Err("the profile must be closed".into()); } - Ok(handle) + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a surface from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the surface could not be exported losslessly")?; + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + surface.point_of_reference = + acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface") + } + SolidOperation::Extrude { source, direction, layer, delete_source } => { + if direction.iter().any(|v| !v.is_finite()) { + return Err("extrusion direction must be finite".into()); + } + if direction.iter().map(|v| v * v).sum::().sqrt() <= 1e-9 { + return Err("extrusion direction must be nonzero".into()); + } + let (entity, _plane, _loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("extrude a curve profile; a region cannot be extruded here".into()); + } + let body = crate::scene::model::presspull_model::extrusion_body(&entity, direction) + .ok_or("the geometry kernel could not extrude this profile in that direction")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the extrusion could not be exported losslessly")?; + let result = if closed { + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = model::edge_wires(&body); + solid.set_sat_document(&sat); + solid.common.layer = layer; + EntityType::Solid3D(solid) + } else { + // An open profile sweeps to a surface. The generic kind + // carries only the payload, as OCS does after a boolean. + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Generic); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + EntityType::Surface(surface) + }; + self.commit_profile_result(result, source, delete_source, "Extrude profile") } SolidOperation::Transform { handle, matrix } => { if matrix.iter().any(|v| !v.is_finite()) { @@ -705,7 +736,25 @@ impl<'a> HostSession<'a> { 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()), + EntityType::Surface(value) => { + let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&value.acis_data) + .ok_or("this surface'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 surface could not be exported losslessly")?; + value.wires = model::edge_wires(&moved); + value.silhouettes.clear(); + value.history_handle = None; + value.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + // A moved plane is still a plane; a swept surface no longer + // matches its stored sweep parameters, so it becomes generic. + if value.kind != acadrust::entities::SurfaceKind::Plane { + value.kind = acadrust::entities::SurfaceKind::Generic; + value.surface_data = acadrust::entities::SurfaceData::Generic; + } + } + _ => return Err("transform applies to Solid3D, Body, Region and Surface entities".into()), } self.push_undo("Transform solid"); if !self.update_entity(entity) { @@ -716,6 +765,56 @@ impl<'a> HostSession<'a> { } } + /// Look up a profile entity and its exact planar loops for the kernel. + /// Returns the entity, its plane, loops and closed flag, and the layer the + /// result should use (`layer`, or the profile's own). + #[allow(clippy::type_complexity)] + fn profile_source( + &self, + source: Handle, + layer: Option, + delete_source: bool, + ) -> Result<(EntityType, cadkernel::space::Plane, Vec>, bool, String), String> { + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let entity = self + .document() + .get_entity(source) + .cloned() + .ok_or_else(|| format!("entity {source:?} does not exist"))?; + if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { + return Err(format!("entity {source:?} is on a locked layer")); + } + if matches!(entity, EntityType::Solid3D(_) | EntityType::Body(_) | EntityType::Surface(_)) { + return Err("the source must be a curve profile, not an existing solid or surface".into()); + } + let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) + .ok_or("the source is not a planar profile")?; + let layer = layer.unwrap_or_else(|| entity.common().layer.clone()); + Ok((entity, plane, loops, closed, layer)) + } + + /// Record one undo step, add `result` and optionally erase the profile. + fn commit_profile_result( + &mut self, + result: EntityType, + source: Handle, + delete_source: bool, + label: &str, + ) -> Result { + self.push_undo(label); + let handle = self.add_entity(result); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if delete_source { + self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.publish_document_view(); + } + Ok(handle) + } + pub fn push_undo(&mut self, label: &str) { self.app.push_undo_snapshot(self.tab, label); } @@ -5071,6 +5170,163 @@ mod tests { assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly"); } + #[test] + fn audit_python_surface_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_body; + use crate::scene::model::solid_model as sm; + use acadrust::entities::SurfaceKind; + 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 sorted = |mut list: Vec| { list.sort_by_key(|h| h.value()); list }; + let surfaces_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Surface(s) => Some(s.common.handle), _ => None }).collect()); + let solids_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect()); + let body_of = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => kernel_acis_body(&s.acis_data).expect("surface payload lifts losslessly"), + Some(EntityType::Solid3D(s)) => kernel_acis_body(&s.acis_data).expect("solid payload lifts losslessly"), + other => panic!("no surface or solid {handle:?}: {other:?}"), + }; + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_of(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + let kind_in = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => s.kind, other => panic!("no surface {handle:?}: {other:?}") }; + + // C: plane surfaces and extrusions from real profile entities. + let script = std::env::temp_dir().join(format!("ocs_surface_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "s = doc.solids\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=3)\n", + "rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n", + "open_path = doc.create_entity('LwPolyline', vertices=[{'location': {'x': 50.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 4.0}}])\n", + "temp = doc.create_entity('Circle', center=(70, 0, 0), radius=2)\n", + "s.surface(circle)\n", + "s.surface(rect, layer='SURFACES')\n", + "s.extrude(rect, (0, 0, 4))\n", + "s.extrude(circle, (0, 0, 5))\n", + "s.extrude(open_path, (0, 0, 3))\n", + "s.extrude(temp, (0, 0, 2), delete_source=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let surfaces = surfaces_in(host.document()); + let solids = solids_in(host.document()); + assert_eq!((surfaces.len(), solids.len()), (3, 3), "{}", last(&host)); + // surfaces[0] plane of the circle, [1] plane of the rectangle, [2] extruded open path. + assert_eq!((kind_in(host.document(), surfaces[0]), kind_in(host.document(), surfaces[1]), kind_in(host.document(), surfaces[2])), + (SurfaceKind::Plane, SurfaceKind::Plane, SurfaceKind::Generic)); + let (low, high) = extent_in(host.document(), surfaces[0]); + assert!(close(low[0], -3.0) && close(high[1], 3.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), surfaces[2]); + assert!(close(low[0], 50.0) && close(high[0], 54.0) && close(low[2], 0.0) && close(high[2], 3.0), "open path extrusion {low:?} {high:?}"); + assert_eq!(host.document().get_entity(surfaces[1]).unwrap().common().layer, "SURFACES"); + let pi = std::f64::consts::PI; + for (index, expected, tolerance) in [(0, 240.0, 1e-9), (1, pi * 9.0 * 5.0, 0.02), (2, pi * 4.0 * 2.0, 0.02)] { + let volume = sm::volume(&body_of(host.document(), solids[index])); + assert!((volume - expected).abs() <= tolerance * expected, "solid {index}: {volume} vs {expected}"); + } + assert_eq!(host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(), 1, + "delete_source removed only the extruded temporary circle"); + assert!(!host.app.tabs[0].scene.wire_models_for(&[surfaces[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", surfaces[0].value())); + assert!(last(&host).contains("Surface")); + + // V: refusals change nothing and leave no undo step. + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let circle = host.document().entities().find_map(|e| match e { EntityType::Circle(c) => Some(c.common.handle), _ => None }).unwrap(); + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + for (command, message) in [ + (format!("ocs.active_document.solids.extrude({}, (0, 0, 0))", circle.value()), "nonzero"), + (format!("ocs.active_document.solids.extrude({}, (float('nan'), 0, 1))", circle.value()), "must be finite"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", solids[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", surfaces[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.surface({}, layer='')", circle.value()), "layer name is empty"), + ("ocs.active_document.solids.surface(999999)".to_string(), "does not exist"), + ("ocs.solid_extrude(1, [1.0], None, False)".to_string(), "3 numbers"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts, + "refusals change nothing and record no undo step"); + + // E: isoline density is scriptable and validated; kind and payload are not. + let plane = surfaces[0]; + let plane_before = host.document().get_entity(plane).unwrap().clone(); + let script = std::env::temp_dir().join(format!("ocs_surface_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "e = doc.entities[{}]\n", + "with doc.transaction('Edit surface'):\n", + " e.u_isolines = 8\n", + " e.v_isolines = 6\n", + ), plane.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let Some(EntityType::Surface(edited)) = host.document().get_entity(plane) else { unreachable!() }; + assert_eq!((edited.u_isolines, edited.v_isolines, edited.kind), (8, 6, SurfaceKind::Plane), "{}", last(&host)); + let edited_state = host.document().get_entity(plane).unwrap().clone(); + for (patch, message) in [("'u_isolines':500", "between 0 and 200"), ("'surface_kind':'Extruded'", "read-only"), + ("'surface_data':{}", "outside the editable schema"), ("'v_isolines':-1", "between 0 and 200")] { + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Reject surface', [{{'handle':{}, {patch}}}])", plane.value())); + assert_eq!(host.document().get_entity(plane), Some(&edited_state), "{patch}"); + assert!(last(&host).contains(message), "{patch}: {}", last(&host)); + } + // Moves keep a plane a plane and turn a swept surface generic. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", plane.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (0, 0, 7)).handle", surfaces[2].value())); + assert_eq!(kind_in(host.document(), plane), SurfaceKind::Plane); + let (low, high) = extent_in(host.document(), plane); + assert!(close(low[0], 7.0) && close(high[0], 13.0) && close(low[1], 2.0) && close(high[1], 8.0), "{low:?} {high:?}"); + let (low, _) = extent_in(host.document(), surfaces[2]); + assert!(close(low[2], 7.0), "{low:?}"); + + // W: both formats reopen surfaces that lift with the same extents, keep + // the isolines, and a reopened surface moves again. + let dwg = crate::io::load_bytes("surface.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("surface.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), plane); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!((surfaces_in(document).len(), solids_in(document).len()), (3, 3), "{format}"); + let reopened = extent_in(document, plane); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent"); + } + let Some(EntityType::Surface(back)) = document.get_entity(plane) else { unreachable!() }; + assert_eq!((back.u_isolines, back.v_isolines), (8, 6), "{format} isolines"); + assert_eq!(back.kind, SurfaceKind::Plane, "{format} keeps the plane kind"); + let again = host.add_entity(EntityType::Surface(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", surfaces[1].value())); + assert!(host.document().get_entity(surfaces[1]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(surfaces[1]).is_some(), "undo restores the deleted surface"); + // Two re-edits of reopened copies, two moves, and the isoline edit come before. + app.undo_steps(2 + 2 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly"); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {