From 07fe7395713a617c8d5aab6fbfaaa928bff3148f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:10:51 +0200 Subject: [PATCH] Investigate the cadkernel path for ACIS kinds; add spikes and findings OCS already creates, transforms and combines bodies through cadkernel, and solid_to_sat / kernel_acis_body verify each result lifts back losslessly. Two ignored spike tests measure volumes and DWG/DXF round trips. The blocker is an operation channel, not geometry; the note recommends an additive solid_operation host method and phases the work. Co-Authored-By: Claude Sonnet 5 --- docs/cadkernel-body-path.md | 137 ++++++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 8 +- src/app/plugin_host.rs | 109 +++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 docs/cadkernel-body-path.md diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md new file mode 100644 index 000000000..87133ad05 --- /dev/null +++ b/docs/cadkernel-body-path.md @@ -0,0 +1,137 @@ +# Scripting ACIS bodies through cadkernel: findings and plan + +Investigation of the blocker on **Solid3D, Body, Region and Surface** +(`docs/plugin-host-model-coverage-ledger.md`). The blocker said a script can +neither produce a valid ACIS payload nor edit one, and that the handover +forbids rewriting an opaque payload without a proven lossless path. This note +records what OCS's own kernel path can already do, measured with two spike tests +(`spike_kernel_body_round_trip`, `spike_kernel_timings` in +`src/app/plugin_host.rs`, both `#[ignore]` because they are slow in a debug +build). Measured on cadcodec `5b682ed` / cadkernel `6f046af`, debug build. + +**Conclusion:** the blocker was overstated. OCS already owns a kernel-backed +create/transform/boolean pipeline whose output is verified lossless before it is +accepted. The missing piece is not geometry but an *operation channel* from a +script to that pipeline. Solid3D and Body are then straightforward; Region and +Surface need a little more; the one real cost is boolean speed. + +## What exists + +| Capability | Where | Notes | +|---|---|---| +| Primitives: box, wedge, cylinder, cone/frustum, elliptical cylinder, sphere, torus, pyramid, pyramid frustum | `scene/model/solid_model.rs` (`box_solid`, ...) | return a kernel `Body` | +| Transforms: place, turn about an axis, mirror, column-major 4x4 | `placed`, `turned`, `mirrored`, `by_matrix` | analytic surfaces move with their frames, no point rewriting | +| Booleans: union, subtract, intersect | `boolean_result` | refuses with a reason instead of returning a broken solid | +| Body to entity payload | `acis_export::solid_to_sat` | **self-verifying**: it lifts its own SAT text and SAB binary back and returns `None` unless both are lossless (`loss.is_empty()`) and validate | +| Entity payload to body | `solid3d_tess::kernel_body` / `kernel_region_body` / `kernel_surface_body` / `kernel_acis_body` | returns `None` when the lift is lossy or has more than one body | +| Regenerating derived data | `app/model_ops.rs::entity_with_boolean_body` | rewrites `acis_data`, `wires`, clears `silhouettes` and `history_handle` for Solid3D, Region and Surface | +| Volume, extent, edge wires | `volume`, `extent`, `edge_wires` | used as oracles below | + +`kernel_acis_body` is exactly the "proven lossless path" the handover asked for: +a payload is edited only if it lifts with no loss, otherwise it is left alone. + +## Spike results + +Everything below passed, with the kernel's own volume as the oracle. + +| Test | Result | +|---|---| +| Box 10x6x4 to Solid3D | volume 240, 12 edge wires, lifts back | +| DWG save/reopen | lifts back, volume 240, 12 wires kept. `acis_data` changes from SAT text to **SAB binary** (same solid, different bytes) | +| DXF save/reopen | lifts back, volume 240. Cached `wires` are dropped (0) and are regenerated on demand; silhouettes were already empty | +| Translate by (5,5,5) | volume 240, extent shifted exactly, lifts back | +| Rotate 45 degrees about Z | volume 240, extent -5.657..5.657, lifts back | +| Mirror in X | volume 240, lifts back | +| Box union cylinder | volume 315.277, 16 wires, lifts back | +| Box subtract cylinder | volume 189.815, 14 wires, lifts back | +| Box intersect cylinder | volume 50.185, 2 wires, lifts back | +| Sphere, torus, wedge, pyramid | volumes 112.696, 98.415, 12.000, 28.532; all lift back | +| Same payload in a `Body` entity | lifts back; still lifts back after DWG and after DXF | + +Timings (debug build): primitive, transform, `solid_to_sat` and `edge_wires` are +effectively instant (under 0.05 s). **Each boolean took about 23 s**, most likely +a debug-build artifact for a kernel doing heavy numeric work, but it has not been +measured in release and must be before booleans are exposed. + +Two things to understand about "lossless": + +- DWG rewrites SAT text as SAB binary. The solid is identical (same volume, + topology, faces) but the bytes are not, so the honest oracle is "lifts back with + no loss and the same volume", not byte equality. An untouched body that a + script never edits is written exactly as OCS writes any other body. +- DXF does not carry the cached wires; they are derived data. + +## Design + +The obstacle is the channel. The generic entity path moves an acadrust +`EntityType` from the plugin to the host; the plugin has no kernel and must never +see or rewrite ACIS bytes. Three ways to give a script the operations: + +**A. Host-side normalization with a request carrier (no ABI change).** A +hand-written converter (as for Dimension) turns `create_entity('Solid3D', +primitive='box', ...)` into a Solid3D that carries the request in an otherwise +unused field, and the host's `normalize_scripted_entity` replaces it with the real +payload, exactly as it already does for MLine geometry, Helix splines and image +definitions. It works inside today's v7 protocol, but the carrier field is a hack. + +**B. New host operation (recommended).** Add one additive operation to `HostApi` +and the v4 IPC protocol, for example `solid_operation(op) -> Result`, +with `op` one of: create primitive, transform, boolean, and (later) region from +curves. The host runs it through the pipeline above inside an undo step and +returns the new or updated handle. Because it is a new method with a default +"unsupported" implementation it is backward compatible; bump the minor/negotiate +by capability rather than the major version. Python side: + +```python +box = doc.solids.box(center=(0, 0, 0), size=(10, 6, 4)) +cyl = doc.solids.cylinder(base=(0, 0, -3), radius=2, height=10) +hole = box.subtract(cyl) # new Solid3D; operands untouched +box.transform(translate=(5, 5, 5)) # in-place, transactional, undoable +``` + +**C. Do nothing but layer edits** (the current state). + +Recommended: **B**, with `solid_operation` covering create, transform and boolean. +Every operation goes through `solid_to_sat` (self-verifying) and the kernel's own +refusal path, so a failure is an error before any mutation, and an untouched +payload is never rewritten. In-place edits of an existing entity are gated by +`kernel_acis_body` returning `Some`: a payload the kernel cannot lift losslessly +is refused with a clear message instead of being rewritten. + +## Per-kind feasibility + +| Kind | Create | Edit | Notes | +|---|---|---|---| +| **Solid3D** | 9 primitives, boolean of two solids | transform, boolean | Straightforward. Regenerate `wires`; clear `silhouettes`; `history_handle` is cleared by `entity_with_boolean_body`, so a history-carrying solid needs a decision (refuse, or accept losing parametric history). | +| **Body** | same payload container | transform | Same pipeline, `kernel_acis_body` works on a `Body` payload (spike). A true non-solid (sheet or wire) body may not lift; those stay layer-only. | +| **Region** | from closed curve entities (the REGION command's `add_region_model`) | transform | Needs a "region from these curve handles" operation and planar-face construction. | +| **Surface** | plane, extruded, revolved from a profile | transform | The most involved: surface kinds carry `surface_data` besides `acis_data`, and only `Plane` keeps its kind on regeneration (`SurfaceKind::Generic` otherwise). | + +## Risks and open questions + +1. **Boolean speed.** About 23 s each in a debug build. Time them in release + before deciding whether they can run on the host thread (the GUI BOOLEAN + command has the same cost) or need a background task. Everything else is + instant. +2. **Interoperability cannot be verified here.** `solid_to_sat` is verified by + OCS's own lifter. Whether AutoCAD accepts the SAT/SAB that OCS writes is not + testable without AutoCAD, so completion should be scoped as "OCS round trip", + as for RasterImage. +3. **Parametric history.** Regenerating a payload clears `history_handle`. +4. **Frames.** Some solids carry a body-to-world transform in the SAT + (`body_transform` in `kernel_acis_body`); the lift already applies it, and the + spike's translated/rotated bodies confirm it, but a foreign DWG with a + scaled or sheared body transform should be added as a fixture. +5. **API surface.** `solid_operation` is a protocol addition; decide whether it is + one generic operation enum or separate methods before starting. + +## Suggested phases + +1. `solid_operation` protocol and host executor for **create primitive** and + **transform** on Solid3D, with the full lifecycle test through the real runner + (volume oracle, DWG and DXF reopen, undo, refusal of a lossy payload). +2. **Booleans** (after release-build timing) and the **Body** container. +3. **Region** from curve handles. +4. **Surface** (plane first). + +Phase 1 alone would move Solid3D from blocked to complete-in-OCS. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d8f370b87..104d6b4c5 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -176,3 +176,4 @@ exact queue above and update statuses with evidence after each increment. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. +- Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 2389c4171..4a46f9c8c 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -44,10 +44,10 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | -| Region | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Body | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Solid3D | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Surface | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Region | **Blocked, unblocked by design** | Not mapped yet. Transform uses the same pipeline; creation needs a "region from these curve handles" operation built on the REGION command's `add_region_model`. See [cadkernel-body-path.md](cadkernel-body-path.md). | +| Body | **Blocked, unblocked by design** | Not mapped yet. Same pipeline as Solid3D: `kernel_acis_body` lifts a `Body` payload and the spike round-tripped it through DWG and DXF. A genuinely non-solid (sheet or wire) body may not lift and would stay layer-only. See [cadkernel-body-path.md](cadkernel-body-path.md). | +| Solid3D | **Blocked, unblocked by design** | Not mapped yet. The 2026-09-20 kernel investigation ([cadkernel-body-path.md](cadkernel-body-path.md)) shows OCS already creates the payload (nine primitives), transforms it and combines bodies through cadkernel, and that `solid_to_sat` and `kernel_acis_body` verify every result lifts back with no loss; spikes confirmed exact volumes and DWG and DXF round trips. What is missing is a script-to-host operation channel (recommended: an additive `solid_operation` host method covering create-primitive, transform and boolean), not geometry. Open items: boolean speed (about 23 s in a debug build, unmeasured in release), history-carrying solids, and AutoCAD acceptance, which cannot be tested here. | +| Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | After each increment, update the row with test names, fixture paths, exact diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a1f7d6859..0fb2bae30 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4449,6 +4449,115 @@ mod tests { }); } + /// Evidence for docs/cadkernel-body-path.md: primitive, transform and + /// boolean bodies become Solid3D/Body payloads that lift back losslessly + /// and survive DWG and DXF. Slow in a debug build (booleans take ~23 s). + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_body_round_trip() { + use crate::scene::model::solid_model as sm; + use crate::scene::convert::solid3d_tess::{kernel_acis_body, kernel_body}; + let make = |body: &cadkernel::brep::Body| -> acadrust::entities::Solid3D { + let sat = crate::scene::convert::acis_export::solid_to_sat(body).expect("solid_to_sat"); + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = sm::edge_wires(body); + solid.set_sat_document(&sat); + solid + }; + let report = |label: &str, solid: &acadrust::entities::Solid3D| { + let body = kernel_body(solid); + eprintln!("SPIKE {label}: lift={} volume={:?} wires={} sat_bytes={} binary={}", + body.is_some(), body.as_ref().map(sm::volume), solid.wires.len(), + solid.acis_data.sat_data.len(), solid.acis_data.is_binary); + body + }; + let boxed = sm::box_solid([0.0, 0.0, 0.0], 10.0, 6.0, 4.0).unwrap(); + let mut doc = acadrust::CadDocument::new(); + let solid = make(&boxed); + report("created box", &solid); + let handle = doc.add_entity(EntityType::Solid3D(solid.clone())).unwrap(); + + // Round trip both formats and compare payloads. + let dwg = crate::io::load_bytes("s.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("s.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Solid3D(back)) = reopened.get_entity(handle) else { panic!("{label} lost the solid") }; + report(&format!("{label} reopen"), back); + eprintln!("SPIKE {label}: acis_data equal={} wires equal={} silhouettes {}", back.acis_data == solid.acis_data, + back.wires.len() == solid.wires.len(), back.silhouettes.len()); + } + + // Transforms: translate, rotate, mirror, matrix. + let moved = sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).unwrap(); + let turned = sm::turned(&boxed, 2, std::f64::consts::FRAC_PI_4, [0.0, 0.0, 0.0]).unwrap(); + let mirrored = sm::mirrored(&boxed, 0, [0.0, 0.0, 0.0]).unwrap(); + for (label, body) in [("moved", &moved), ("turned", &turned), ("mirrored", &mirrored)] { + let solid = make(body); + let extent = sm::extent(body); + eprintln!("SPIKE {label}: volume={:.3} extent={:?} lifts_back={}", sm::volume(body), extent, kernel_body(&solid).is_some()); + } + + // Booleans and primitives. + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + for (label, op) in [("union", sm::Bool::Union), ("subtract", sm::Bool::Subtract), ("intersect", sm::Bool::Intersect)] { + match sm::boolean_result(op, &boxed, &cyl) { + Ok(result) => { let solid = make(&result); eprintln!("SPIKE box {label} cylinder: volume={:.3} faces_wires={} lifts_back={}", sm::volume(&result), solid.wires.len(), kernel_body(&solid).is_some()); } + Err(snag) => eprintln!("SPIKE box {label} cylinder: refused {snag:?}"), + } + } + for (label, body) in [ + ("sphere", sm::sphere_solid([0.0; 3], 3.0)), ("torus", sm::torus_solid([0.0; 3], 5.0, 1.0)), + ("wedge", sm::wedge_solid([0.0; 3], 4.0, 3.0, 2.0)), ("pyramid", sm::pyramid_solid([0.0; 3], 3.0, 4.0, 5)), + ] { + match body { + Some(body) => { let solid = make(&body); eprintln!("SPIKE {label}: volume={:.3} lifts_back={}", sm::volume(&body), kernel_body(&solid).is_some()); } + None => eprintln!("SPIKE {label}: kernel returned None"), + } + } + + // The same payload inside a Body entity and a Region-like container. + let mut body_entity = acadrust::entities::Body::new(); + body_entity.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap()); + let lifted = kernel_acis_body(&body_entity.acis_data); + eprintln!("SPIKE Body entity: lift={} volume={:?}", lifted.is_some(), lifted.as_ref().map(sm::volume)); + let bh = doc.add_entity(EntityType::Body(body_entity)).unwrap(); + let dwg = crate::io::load_bytes("b.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("b.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + match reopened.get_entity(bh) { + Some(EntityType::Body(back)) => eprintln!("SPIKE Body {label}: lift={}", kernel_acis_body(&back.acis_data).is_some()), + other => eprintln!("SPIKE Body {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(30).collect::())), + } + } + } + + /// Per-operation kernel timings for docs/cadkernel-body-path.md. + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_timings() { + use crate::scene::model::solid_model as sm; + use std::time::Instant; + let time = |label: &str, f: &mut dyn FnMut() -> String| { + let start = Instant::now(); + let note = f(); + eprintln!("TIMING {label}: {:.2}s {note}", start.elapsed().as_secs_f64()); + }; + let boxed = sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).unwrap(); + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + time("box primitive", &mut || format!("{}", sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).is_some())); + time("sphere primitive", &mut || format!("{}", sm::sphere_solid([0.0; 3], 3.0).is_some())); + time("solid_to_sat(box)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&boxed).is_some())); + time("edge_wires(box)", &mut || format!("{}", sm::edge_wires(&boxed).len())); + time("transform (matrix)", &mut || format!("{}", sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).is_some())); + time("union", &mut || format!("{}", sm::boolean_result(sm::Bool::Union, &boxed, &cyl).is_ok())); + time("subtract", &mut || format!("{}", sm::boolean_result(sm::Bool::Subtract, &boxed, &cyl).is_ok())); + time("intersect", &mut || format!("{}", sm::boolean_result(sm::Bool::Intersect, &boxed, &cyl).is_ok())); + let result = sm::boolean_result(sm::Bool::Union, &boxed, &cyl).unwrap(); + time("solid_to_sat(union)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&result).is_some())); + time("edge_wires(union)", &mut || format!("{}", sm::edge_wires(&result).len())); + time("volume(union)", &mut || format!("{:.1}", sm::volume(&result))); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {