From c731d3a9a4719c8f45e359a371b8d8bbf9386d9c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:37:10 +0200 Subject: [PATCH] Complete Region: create from a profile, plus tuple and int friendliness RegionFromProfile reuses the REGION command's kernel calls; doc.solids.region exposes it with layer and delete_source options. The lifecycle test builds regions from real circle and polyline entities and covers refusals, a move, both formats and undo. create_entity now accepts coordinate tuples for point properties and every generated float setter accepts Python ints. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 1 + docs/cadkernel-body-path.md | 2 +- docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- plugins/opencad-python/build/generate.rs | 4 + plugins/opencad-python/src/document_model.py | 31 +++- plugins/opencad-python/src/ocs_module.rs | 17 ++ src/app/plugin_host.rs | 169 +++++++++++++++++++ 9 files changed, 233 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index dcd0402ce..ec59c428e 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -85,6 +85,11 @@ pub enum SolidOperation { /// 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] }, + /// Build a planar region from one closed planar profile entity (a circle, + /// ellipse, closed polyline or spline, ...). The new region goes on + /// `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 }, } /// 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 d9c3e8bae..740aa9773 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -114,6 +114,7 @@ mod tests { layer: Some("SOLIDS".into()), }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, + SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), 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 ee70be7c1..ba6ad1e74 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. Booleans, Region creation 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`). Booleans and Surface remain. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 8adc7eab1..c3b7e0567 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,9 +7,9 @@ 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: 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. +v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 38 +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. 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 @@ -178,3 +178,4 @@ exact queue above and update statuses with evidence after each increment. - 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. +- 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`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 59d8363b5..a35d9b2e6 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** (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`. +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`. 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,7 +44,7 @@ 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 | **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. | +| 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). | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index c07b9c39d..b8b511810 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -406,6 +406,10 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String /// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult` expression. fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { + if type_id == "f64" { + // RustPython's f64 conversion rejects ints; scripts write `radius=5`. + return format!("py_number_to_f64({value_expr}, vm)"); + } if type_id == "u64" { // serde reports Rust `usize` as `u64`; the inferred cast serves both. return format!("{value_expr}.try_into_value::(vm).map(|n| n as _)"); diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 440af6981..7c964ef80 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -1,5 +1,27 @@ """Thin Python view over OCS host API v7 entity transactions and coverage.""" +def _coordinates(value, width): + return dict(zip("xyz"[:width], (float(axis) for axis in value))) + + +def _coerce_points(kind, properties): + """Let create_entity take (x, y, z) tuples for point properties, as + transaction edits already do, by using the property types in the schema.""" + schema = ocs.entity_coverage(kind) + types = {row["name"]: row for row in schema["properties"]} if schema else {} + result = {} + for name, value in properties.items(): + row = types.get(name) + if row is not None and row["type"] in ("Vector2", "Vector3"): + width = 2 if row["type"] == "Vector2" else 3 + if row["sequence"]: + value = [_coordinates(item, width) if isinstance(item, (tuple, list)) else item for item in value] + elif isinstance(value, (tuple, list)): + value = _coordinates(value, width) + result[name] = value + return result + + class _Entity: def __init__(self, document, handle): object.__setattr__(self, "_document", document) @@ -117,6 +139,13 @@ class _Solids: 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 region(self, profile, layer=None, delete_source=False): + """Make a planar region from one closed planar profile (a circle, + ellipse, closed polyline or spline). Keeps the profile unless + `delete_source` is true, and uses the profile's layer by default.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_region(handle, 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) @@ -151,7 +180,7 @@ class _Document: """Create one mapped entity and return its live document descriptor.""" if "kind" in properties or "handle" in properties: raise ValueError("kind and handle are managed by create_entity") - handle = ocs.add(dict(kind=kind, **properties)) + handle = ocs.add(dict(kind=kind, **_coerce_points(kind, properties))) return self.entities[handle] def delete_entity(self, entity): diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 130f3da17..dcfdb140b 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -458,6 +458,23 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}"))) } + /// Build a planar region from a closed planar profile entity. + #[pyfunction] + fn solid_region(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::RegionFromProfile { + 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_region: {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 { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 950b8e2ff..9db23fa3c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -620,6 +620,47 @@ 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"))?; + 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()); + } + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a region from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the region could not be exported losslessly")?; + let mut region = acadrust::entities::Region::new(); + region.point_of_reference = + 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()); + } + if delete_source { + self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.publish_document_view(); + } + Ok(handle) + } SolidOperation::Transform { handle, matrix } => { if matrix.iter().any(|v| !v.is_finite()) { return Err("transform matrix must be finite".into()); @@ -4902,6 +4943,134 @@ mod tests { assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text); } + #[test] + fn audit_python_region_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_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 regions = |host: &HostSession<'_>| -> Vec { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Region(r) => Some(r.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let extent_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Region(region)) = document.get_entity(handle) else { panic!("no region {handle:?}") }; + let body = kernel_acis_body(®ion.acis_data).expect("region payload lifts losslessly"); + sm::extent(&body).unwrap() + }; + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + + // C: regions from a circle (source kept), a closed rectangle (source + // deleted) and a second circle on its own layer. + let script = std::env::temp_dir().join(format!("ocs_region_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=5)\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", + "doc.solids.region(circle)\n", + "doc.solids.region(rect, delete_source=True)\n", + "other = doc.create_entity('Circle', center=(50, 0, 0), radius=2, layer='PROFILES')\n", + "doc.solids.region(other, layer='REGIONS')\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = regions(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], -5.0) && close(high[0], 5.0) && close(low[1], -5.0) && close(high[1], 5.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[1]); + assert!(close(low[0], 20.0) && close(high[0], 30.0) && close(high[1], 6.0), "{low:?} {high:?}"); + let layer_of = |host: &HostSession<'_>, handle: Handle| host.document().get_entity(handle).unwrap().common().layer.clone(); + assert_eq!(layer_of(&host, list[0]), "0", "defaults to the profile's layer"); + assert_eq!(layer_of(&host, list[2]), "REGIONS"); + let circles = host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(); + let polylines = host.document().entities().filter(|e| matches!(e, EntityType::LwPolyline(_))).count(); + assert_eq!((circles, polylines), (2, 0), "delete_source removed only the rectangle"); + let Some(EntityType::Region(first)) = host.document().get_entity(list[0]) else { unreachable!() }; + assert_eq!((first.wires.is_empty(), first.point_of_reference.z), (false, 0.0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[0].value())); + assert!(last(&host).contains("Region")); + + // V: refusals change nothing and leave no undo step. + let open_line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(5.0, 0.0, 0.0)))); + let mut open_poly = acadrust::entities::LwPolyline::new(); + open_poly.vertices = vec![ + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 4.0)), + ]; + let open_poly = host.add_entity(EntityType::LwPolyline(open_poly)); + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let entity_count = host.document().entities().count(); + let undo_now = host.app.tabs[0].history.undo_stack.len(); + for (command, message) in [ + (format!("ocs.active_document.solids.region({})", open_line.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", open_poly.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.region({})", list[0].value()), "already a region"), + ("ocs.active_document.solids.region(999999)".to_string(), "does not exist"), + (format!("ocs.active_document.solids.region({}, layer='')", open_line.value()), "layer name is empty"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(host.document().entities().count(), entity_count); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step"); + + // E: a region moves through the same kernel channel. + let before = host.document().get_entity(list[0]).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", list[0].value())); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], 5.0) && close(high[0], 15.0) && close(low[1], 0.0) && close(high[1], 10.0), "{low:?} {high:?}"); + + // W: both formats reopen a region that lifts with the same extent, and + // a reopened region moves again. + let dwg = crate::io::load_bytes("region.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("region.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), list[0]); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!(regions_in(document).len(), 3, "{format}"); + let reopened = extent_in(document, list[0]); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent {reopened:?} vs {moved:?}"); + } + let Some(EntityType::Region(back)) = document.get_entity(list[0]) else { unreachable!() }; + let again = host.add_entity(EntityType::Region(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"); + } + fn regions_in(document: &CadDocument) -> Vec { + document.entities().filter_map(|e| match e { EntityType::Region(r) => Some(r.common.handle), _ => None }).collect() + } + + // D and U: delete, undo, and undo the creation steps exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", list[2].value())); + assert!(host.document().get_entity(list[2]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(list[2]).is_some(), "undo restores the deleted region"); + // Two re-edits of reopened copies, then the move of the first region. + app.undo_steps(3); + assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region 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 {