forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
69047549e7
commit
5a39052397
8 files changed
+320
-1
No files matched your search
@@ -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<String>, 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<String>,
|
||||
keep_operands: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Value of a host-managed drafting or document setting exposed to plugins.
|
||||
|
||||
@@ -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 },
|
||||
] {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>,
|
||||
keep_operands: bool,
|
||||
vm: &VirtualMachine,
|
||||
) -> PyResult<u64> {
|
||||
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<String>, delete_source: bool, vm: &VirtualMachine) -> PyResult<u64> {
|
||||
|
||||
@@ -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<acadrust::entities::Solid3D, String> {
|
||||
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<Handle> = 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 {
|
||||
|
||||
Reference in new issue
Block a user