From 5a39052397ad1f308d5853e19ccf30abd97b70d0 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:58:38 +0200 Subject: [PATCH] Add kernel booleans: union, subtract and intersect for Solid3D SolidOperation::Boolean runs synchronously, surfaces the kernel's refusal with a plain reason and leaves the operands untouched, consumes them on success unless keep_operands, and records one undo step. The lifecycle test uses planar boxes with exact volumes and covers chaining, refusals, both formats and undo; an ignored test shows a curved refusal reaching the script. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 21 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 7 + docs/cadkernel-body-path.md | 2 + docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- plugins/opencad-python/src/document_model.py | 20 ++ plugins/opencad-python/src/ocs_module.rs | 34 +++ src/app/plugin_host.rs | 234 +++++++++++++++++++ 8 files changed, 320 insertions(+), 1 deletion(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 414487e4c..3a18d1fb0 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -76,6 +76,15 @@ pub enum SolidPrimitive { Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32 }, } +/// How two solids combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SolidBoolean { + Union, + /// Remove the second solid from the first. + Subtract, + Intersect, +} + /// A kernel operation on ACIS-backed solids (API v7, additive). The host owns /// the geometry: a script never sees or rewrites the payload. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -95,6 +104,18 @@ pub enum SolidOperation { /// 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 }, + /// Combine two solids into a new one. Both operands are consumed unless + /// `keep_operands`; the result goes on `layer` or the first operand's + /// layer. The kernel refuses some cases (coincident faces, cuts it has no + /// closed form for); a refusal changes nothing. Curved operands can take a + /// second or two, and the host thread is busy meanwhile. + Boolean { + first: Handle, + second: Handle, + operation: SolidBoolean, + layer: Option, + keep_operands: 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 94b023acd..8662e4888 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,13 @@ 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::Boolean { + first: acadrust::Handle::new(1), + second: acadrust::Handle::new(2), + operation: crate::host::SolidBoolean::Subtract, + layer: None, + keep_operands: false, + }, 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 }, ] { diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index ebcdc59c2..312e36960 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -196,3 +196,5 @@ Test it with **planar** operands (millisecond cost, safe in debug) plus one 30 s script budget and that chained cuts on a drilled body are often refused. Do not move booleans to a background thread yet: it would need cancellation and undo coordination for a cost that is one to two seconds. + +**Status (2026-09-20): implemented.** `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect` follow the recommendation above, with planar-operand lifecycle testing and an ignored curved refusal test. Every operation this note proposed is now built. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index f64267fe3..6008240c6 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -181,3 +181,4 @@ exact queue above and update statuses with evidence after each increment. - 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. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. +- Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. 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 75b6920bc..3d3444c37 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -46,7 +46,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | 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 | **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. | +| 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: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | | 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. | diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 0d0643e53..c50d58b41 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -146,6 +146,26 @@ 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 _boolean(self, operation, first, second, layer, keep_operands): + a = first.handle if isinstance(first, _Entity) else int(first) + b = second.handle if isinstance(second, _Entity) else int(second) + return self._document.entities[ocs.solid_boolean(a, b, operation, layer, bool(keep_operands))] + + def union(self, first, second, layer=None, keep_operands=False): + """Join two solids into a new one. The operands are consumed unless + `keep_operands`. The kernel may refuse (the error says why) and a + refusal changes nothing. Curved operands can take a second or two, + and a whole script has a 30 second budget.""" + return self._boolean("union", first, second, layer, keep_operands) + + def subtract(self, first, second, layer=None, keep_operands=False): + """Remove `second` from `first`, giving a new solid.""" + return self._boolean("subtract", first, second, layer, keep_operands) + + def intersect(self, first, second, layer=None, keep_operands=False): + """Keep only the volume both solids share, as a new solid.""" + return self._boolean("intersect", first, second, layer, keep_operands) + 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) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 471761b45..2ff6cfc59 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,40 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Combine two solids ("union", "subtract" or "intersect"). + #[pyfunction] + fn solid_boolean( + first: u64, + second: u64, + operation: String, + layer: Option, + keep_operands: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::{SolidBoolean, SolidOperation}; + let operation = match operation.as_str() { + "union" => SolidBoolean::Union, + "subtract" => SolidBoolean::Subtract, + "intersect" => SolidBoolean::Intersect, + other => { + return Err(vm.new_value_error(format!("ocs.solid_boolean: unknown operation {other:?}"))) + } + }; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Boolean { + first: Handle::new(first), + second: Handle::new(second), + operation, + layer, + keep_operands, + }) + }) + .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_boolean: {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 { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 899c0cc7d..80c76ace3 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -692,6 +692,71 @@ impl<'a> HostSession<'a> { }; self.commit_profile_result(result, source, delete_source, "Extrude profile") } + SolidOperation::Boolean { first, second, operation, layer, keep_operands } => { + use ocs_plugin_api::host::SolidBoolean; + if first == second { + return Err("a solid cannot be combined with itself".into()); + } + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let operand = |host: &Self, handle: Handle, which: &str| -> Result { + match host.document().get_entity(handle) { + Some(EntityType::Solid3D(solid)) => Ok(solid.clone()), + Some(_) => Err(format!("the {which} operand must be a Solid3D")), + None => Err(format!("entity {handle:?} does not exist")), + } + }; + let a = operand(self, first, "first")?; + let b = operand(self, second, "second")?; + if !keep_operands { + for handle in [first, second] { + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + } + } + let lift = |solid: &acadrust::entities::Solid3D, which: &str| { + crate::scene::convert::solid3d_tess::kernel_acis_body(&solid.acis_data).ok_or_else(|| { + format!("the {which} solid's payload cannot be lifted losslessly, so nothing was changed") + }) + }; + let (body_a, body_b) = (lift(&a, "first")?, lift(&b, "second")?); + let kind = match operation { + SolidBoolean::Union => model::Bool::Union, + SolidBoolean::Subtract => model::Bool::Subtract, + SolidBoolean::Intersect => model::Bool::Intersect, + }; + let combined = model::boolean_result(kind, &body_a, &body_b).map_err(|snag| { + let reason = match snag { + cadkernel::brep::Snag::Coincident => "two faces lie on the same surface, which the kernel cannot resolve", + cadkernel::brep::Snag::CutRefused => "a face could not be cut along the intersection curve", + cadkernel::brep::Snag::NoClosedForm => "the surfaces meet along a curve the kernel has no closed form for", + }; + format!("the geometry kernel refused this {operation:?} ({snag:?}): {reason}; nothing was changed") + })?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&combined) + .ok_or("the result could not be exported losslessly, so nothing was changed")?; + let mut solid = acadrust::entities::Solid3D::new(); + solid.common = a.common.clone(); + solid.common.handle = Handle::NULL; + solid.common.owner_handle = Handle::NULL; + solid.wires = model::edge_wires(&combined); + solid.set_sat_document(&sat); + if let Some(layer) = layer { + solid.common.layer = layer; + } + self.push_undo("Boolean solids"); + let handle = self.add_entity(EntityType::Solid3D(solid)); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if !keep_operands { + self.app.tabs[self.tab].scene.erase_entities(&[first, second]); + 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()); @@ -5327,6 +5392,175 @@ mod tests { assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly"); } + #[test] + fn audit_python_boolean_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_in = |document: &CadDocument| { + let mut list: Vec = 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_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { panic!("no solid {handle:?}") }; + kernel_body(solid).expect("payload lifts losslessly") + }; + let volume_in = |document: &CadDocument, handle: Handle| sm::volume(&body_in(document, handle)); + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_in(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6 * b.abs().max(1.0); + + // C: all three operations on two overlapping boxes, keeping the + // operands. A: x 0..10, B: x 5..15, both 6 by 4; the overlap is 120. + let script = std::env::temp_dir().join(format!("ocs_boolean_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "a = s.box(center=(5, 3, 2), size=(10, 6, 4))\n", + "b = s.box(center=(10, 3, 2), size=(10, 6, 4))\n", + "s.union(a, b, keep_operands=True)\n", + "s.subtract(a, b, keep_operands=True)\n", + "s.intersect(a, b, keep_operands=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = solids_in(host.document()); + assert_eq!(list.len(), 5, "operands kept and three results: {}", last(&host)); + let (a, b) = (list[0], list[1]); + for (index, expected) in [(2, 360.0), (3, 120.0), (4, 120.0)] { + let actual = volume_in(host.document(), list[index]); + assert!(close(actual, expected), "result {index}: volume {actual}, expected {expected}"); + } + let (low, high) = extent_in(host.document(), list[3]); + assert!(close(low[0], 0.0) && close(high[0], 5.0), "subtract keeps x 0..5: {low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[4]); + assert!(close(low[0], 5.0) && close(high[0], 10.0), "intersect keeps x 5..10: {low:?} {high:?}"); + let Some(EntityType::Solid3D(union)) = host.document().get_entity(list[2]) else { unreachable!() }; + assert_eq!((union.wires.is_empty(), union.history_handle), (false, None)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[2]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[2].value())); + assert!(last(&host).contains("Solid3D")); + + // Consuming operands: A minus B leaves only the result, on a new layer. + let before_consume = (host.document().get_entity(a).unwrap().clone(), host.document().get_entity(b).unwrap().clone()); + let undo_before = host.app.tabs[0].history.undo_stack.len(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}, layer='CUT').handle", a.value(), b.value())); + assert!(host.document().get_entity(a).is_none() && host.document().get_entity(b).is_none(), "operands are consumed"); + let cut = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), cut), 120.0)); + assert_eq!(host.document().get_entity(cut).unwrap().common().layer, "CUT"); + assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 1, "one undo step per boolean"); + + // A second planar operation chains on the result: drill a through-hole. + dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(2.5, 3, 2), size=(2, 2, 6)).handle"); + let hole = *solids_in(host.document()).last().unwrap(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}).handle", cut.value(), hole.value())); + let drilled = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), drilled), 104.0), "120 minus a 2x2x4 hole is 104"); + + // V: refusals change nothing and leave no undo step. + 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)))); + let unliftable = host.add_entity(EntityType::Solid3D(acadrust::entities::Solid3D::new())); + let mut plane = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + plane.acis_data = acadrust::entities::AcisData::new(); + let surface = host.add_entity(EntityType::Surface(plane)); + let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle"); + *solids_in(host.document()).last().unwrap() }; + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + let z = drilled.value(); + for (command, message) in [ + (format!("ocs.active_document.solids.union({z}, {z})"), "cannot be combined with itself"), + (format!("ocs.active_document.solids.union({z}, {})", line.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({}, {z})", surface.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({z}, 999999)"), "does not exist"), + (format!("ocs.active_document.solids.union({z}, {})", unliftable.value()), "cannot be lifted losslessly"), + (format!("ocs.active_document.solids.union({z}, {}, layer='')", far.value()), "layer name is empty"), + (format!("ocs.solid_boolean({z}, {}, 'xor', None, False)", far.value()), "unknown operation"), + (format!("ocs.active_document.solids.intersect({z}, {})", far.value()), "changed"), + ] { + 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"); + + // W: both formats reopen the drilled result losslessly; it moves again. + let expected_volume = volume_in(host.document(), drilled); + let expected_extent = extent_in(host.document(), drilled); + let dwg = crate::io::load_bytes("boolean.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("boolean.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert!(close(volume_in(document, drilled), expected_volume), "{format} volume"); + let reopened = extent_in(document, drilled); + for axis in 0..3 { + assert!(close(reopened.0[axis], expected_extent.0[axis]) && close(reopened.1[axis], expected_extent.1[axis]), "{format} extent"); + } + let Some(EntityType::Solid3D(back)) = document.get_entity(drilled) else { unreachable!() }; + 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())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U: delete, then undo restores it, and undoing the consuming + // boolean brings both operands back exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", drilled.value())); + assert!(host.document().get_entity(drilled).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(drilled).is_some(), "undo restores the deleted result"); + // Two re-edits, the far box, the drilling subtract and the hole box, then the consuming subtract. + app.undo_steps(2 + 1 + 1 + 1 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(a), Some(&before_consume.0), "undo restores operand A exactly"); + assert_eq!(app.tabs[0].scene.document.get_entity(b), Some(&before_consume.1), "undo restores operand B exactly"); + assert!(app.tabs[0].scene.document.get_entity(cut).is_none(), "undo removes the boolean result"); + } + + /// Release-only evidence for docs/cadkernel-body-path.md: the kernel's + /// refusals reach the script as messages. Curved booleans take about + /// 23 s in a debug build, so this is ignored by default. + #[test] + #[ignore = "curved boolean; slow in debug builds"] + fn audit_python_boolean_kernel_refusal_over_real_ipc() { + 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 script = std::env::temp_dir().join(format!("ocs_boolean_refusal_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "box = s.box(center=(0, 0, 0), size=(10, 6, 4))\n", + "sphere = s.sphere(center=(0, 0, 0), radius=3)\n", + "s.subtract(box, sphere)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let message = host.app.command_line.history.last().unwrap().text.clone(); + assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}"); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {