forked from 1b-agent/opencadstudio-upstream
Add kernel solid operations and complete Solid3D (create and rigid move)
An additive HostApi::solid_operation builds cadkernel primitives and applies rigid transforms, verifying each result lifts back losslessly and refusing a payload it cannot lift. Exposed as doc.solids in Python; Solid3D, Body and Region are mapped read-only with the opaque payload preserved. The lifecycle test passes through the real runner with analytic volumes and exact extents as oracles, DWG and DXF reopen with re-edit, and undo restores exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
07fe739571
commit
04cd9e462d
18 files changed
+717
-13
No files matched your search
Vendored
BIN
Binary file not shown.
@@ -493,6 +493,15 @@
|
|||||||
"lower_right_corner",
|
"lower_right_corner",
|
||||||
"lock_aspect",
|
"lock_aspect",
|
||||||
"is_paper_space"
|
"is_paper_space"
|
||||||
|
],
|
||||||
|
"Solid3D": [
|
||||||
|
"layer"
|
||||||
|
],
|
||||||
|
"Body": [
|
||||||
|
"layer"
|
||||||
|
],
|
||||||
|
"Region": [
|
||||||
|
"layer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"readable": {
|
"readable": {
|
||||||
@@ -603,6 +612,21 @@
|
|||||||
"source_application",
|
"source_application",
|
||||||
"ole_object_type",
|
"ole_object_type",
|
||||||
"dwg_mode"
|
"dwg_mode"
|
||||||
|
],
|
||||||
|
"Solid3D": [
|
||||||
|
"uid",
|
||||||
|
"point_of_reference",
|
||||||
|
"history_handle"
|
||||||
|
],
|
||||||
|
"Body": [
|
||||||
|
"uid",
|
||||||
|
"point_of_reference",
|
||||||
|
"history_handle"
|
||||||
|
],
|
||||||
|
"Region": [
|
||||||
|
"uid",
|
||||||
|
"point_of_reference",
|
||||||
|
"history_handle"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
@@ -2532,6 +2532,9 @@ mod tests {
|
|||||||
| "ViewBorder"
|
| "ViewBorder"
|
||||||
| "Light"
|
| "Light"
|
||||||
| "Ole2Frame"
|
| "Ole2Frame"
|
||||||
|
| "Solid3D"
|
||||||
|
| "Body"
|
||||||
|
| "Region"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -60,6 +60,33 @@ pub enum LogLevel {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A solid primitive the host can build with its geometry kernel. Lengths are
|
||||||
|
/// in drawing units; `center`/`origin` are world coordinates.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SolidPrimitive {
|
||||||
|
/// Axis-aligned box from its center and full extents.
|
||||||
|
Box { center: [f64; 3], size: [f64; 3] },
|
||||||
|
/// Right triangular prism from its minimum corner and extents.
|
||||||
|
Wedge { origin: [f64; 3], size: [f64; 3] },
|
||||||
|
/// Cylinder standing on the plane through `center`.
|
||||||
|
Cylinder { center: [f64; 3], radius: f64, height: f64 },
|
||||||
|
Sphere { center: [f64; 3], radius: f64 },
|
||||||
|
Torus { center: [f64; 3], major: f64, minor: f64 },
|
||||||
|
/// Regular pyramid with `sides` base edges.
|
||||||
|
Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub enum SolidOperation {
|
||||||
|
/// Build a new solid on `layer` (layer `0` when `None`).
|
||||||
|
Create { primitive: SolidPrimitive, layer: Option<String> },
|
||||||
|
/// Apply a rigid transform, given as a column-major 4x4 matrix, to an
|
||||||
|
/// existing solid. Scaling and shear are refused.
|
||||||
|
Transform { handle: Handle, matrix: [f64; 16] },
|
||||||
|
}
|
||||||
|
|
||||||
/// Value of a host-managed drafting or document setting exposed to plugins.
|
/// Value of a host-managed drafting or document setting exposed to plugins.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum HostSettingValue {
|
pub enum HostSettingValue {
|
||||||
@@ -620,6 +647,13 @@ pub trait HostApi {
|
|||||||
fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> {
|
fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> {
|
||||||
Err("selection writes are not supported by this host".to_owned())
|
Err("selection writes are not supported by this host".to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a kernel-backed solid operation and return the created or updated
|
||||||
|
/// entity's handle. Refused, without any change, when the kernel cannot
|
||||||
|
/// perform it losslessly (API v7, additive).
|
||||||
|
fn solid_operation(&mut self, _operation: SolidOperation) -> Result<Handle, String> {
|
||||||
|
Err("solid operations are not supported by this host".to_owned())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
||||||
|
|||||||
@@ -470,6 +470,18 @@ impl HostApi for PluginHostApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
|
||||||
|
match self.client.request(PluginRequest::SolidOperation { operation }) {
|
||||||
|
Ok(PluginResponse::SolidResult(result)) => {
|
||||||
|
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
Ok(PluginResponse::Error(error)) => Err(error),
|
||||||
|
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||||
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
||||||
Ok(PluginResponse::SelectionResult(result)) => result,
|
Ok(PluginResponse::SelectionResult(result)) => result,
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ pub enum PluginRequest {
|
|||||||
/// V7: synchronous selection read/write for the dispatch tab.
|
/// V7: synchronous selection read/write for the dispatch tab.
|
||||||
GetSelection,
|
GetSelection,
|
||||||
SetSelection { handles: Vec<Handle> },
|
SetSelection { handles: Vec<Handle> },
|
||||||
|
/// V7 (additive): kernel-backed solid create or transform.
|
||||||
|
SolidOperation { operation: crate::host::SolidOperation },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Responses the host sends back for `PluginRequest`.
|
/// Responses the host sends back for `PluginRequest`.
|
||||||
@@ -180,6 +182,7 @@ pub enum PluginResponse {
|
|||||||
EntityTransactionResult(Result<(), String>),
|
EntityTransactionResult(Result<(), String>),
|
||||||
Selection(Vec<Handle>),
|
Selection(Vec<Handle>),
|
||||||
SelectionResult(Result<(), String>),
|
SelectionResult(Result<(), String>),
|
||||||
|
SolidResult(Result<Handle, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent from the host to the plugin runner.
|
/// Messages sent from the host to the plugin runner.
|
||||||
|
|||||||
@@ -107,5 +107,6 @@ pub fn handle_plugin_request(
|
|||||||
),
|
),
|
||||||
GetSelection => PluginResponse::Selection(host.selection()),
|
GetSelection => PluginResponse::Selection(host.selection()),
|
||||||
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
|
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
|
||||||
|
SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,6 +784,18 @@ impl HostApi for V4PluginHostApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
|
||||||
|
match self.request(PluginRequest::SolidOperation { operation }) {
|
||||||
|
Ok(PluginResponse::SolidResult(result)) => {
|
||||||
|
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
Ok(PluginResponse::Error(error)) => Err(error),
|
||||||
|
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||||
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
||||||
Ok(PluginResponse::SelectionResult(result)) => result,
|
Ok(PluginResponse::SelectionResult(result)) => result,
|
||||||
|
|||||||
@@ -105,6 +105,27 @@ mod tests {
|
|||||||
assert_eq!(decoded, changed);
|
assert_eq!(decoded, changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solid_operation_request_and_response_roundtrip() {
|
||||||
|
use crate::host::{SolidOperation, SolidPrimitive};
|
||||||
|
for operation in [
|
||||||
|
SolidOperation::Create {
|
||||||
|
primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 },
|
||||||
|
layer: Some("SOLIDS".into()),
|
||||||
|
},
|
||||||
|
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
|
||||||
|
] {
|
||||||
|
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
|
||||||
|
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||||
|
PluginRequest::SolidOperation { operation: decoded } if decoded == operation));
|
||||||
|
}
|
||||||
|
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
|
||||||
|
let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap();
|
||||||
|
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||||
|
PluginResponse::SolidResult(decoded) if decoded == result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selection_request_and_response_roundtrip() {
|
fn selection_request_and_response_roundtrip() {
|
||||||
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
|
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
|
||||||
|
|||||||
@@ -135,3 +135,5 @@ is refused with a clear message instead of being rewritten.
|
|||||||
4. **Surface** (plane first).
|
4. **Surface** (plane first).
|
||||||
|
|
||||||
Phase 1 alone would move Solid3D from blocked to complete-in-OCS.
|
Phase 1 alone would move Solid3D from blocked to complete-in-OCS.
|
||||||
|
|
||||||
|
**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Booleans, Region creation and Surface remain.
|
||||||
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
|
|||||||
As of 20 September 2026, the working branch is `plugin/host-model-api` at
|
As of 20 September 2026, the working branch is `plugin/host-model-api` at
|
||||||
`a8dd3640` plus the Leader increment, published as draft PR
|
`a8dd3640` plus the Leader increment, published as draft PR
|
||||||
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
|
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
|
||||||
v7. The Python feature has explicit writes for **38 of 43** canvas kinds: 36
|
v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 37
|
||||||
are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 completed
|
are creatable (the legacy `Polyline`, `Ole2Frame`, `Body` and `Region` are update-only). This is **not** 41 completed
|
||||||
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows.
|
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D and Solid3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows.
|
||||||
AttributeDefinition passes its host and real IPC lifecycle but remains short of
|
AttributeDefinition passes its host and real IPC lifecycle but remains short of
|
||||||
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
|
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
|
||||||
fields during DXF reads; revalidate that blocker against the current
|
fields during DXF reads; revalidate that blocker against the current
|
||||||
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 5 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface) have only `layer` and are recorded as blocked
|
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design)
|
||||||
writes through the document model. All 43 have raw typed snapshots. There are
|
writes through the document model. All 43 have raw typed snapshots. There are
|
||||||
three internal records and two opaque fallbacks outside the 43 canvas kinds.
|
three internal records and two opaque fallbacks outside the 43 canvas kinds.
|
||||||
|
|
||||||
@@ -177,3 +177,4 @@ exact queue above and update statuses with evidence after each increment.
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked.
|
||||||
|
- Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# OCS canvas kind coverage ledger
|
# OCS canvas kind coverage ledger
|
||||||
|
|
||||||
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **38 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline` and `Ole2Frame`), **26 kinds are complete**, and 5 remain layer-only and blocked (SectionSymbol, Region, Body, Solid3D, Surface). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
|
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (37 creatable, plus update-only legacy `Polyline`, `Ole2Frame`, `Body` and `Region`), **27 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
|
||||||
|
|
||||||
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
|
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
|
||||||
|
|
||||||
@@ -44,9 +44,9 @@ 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. |
|
| 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. |
|
| 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. |
|
| 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, 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). |
|
| Region | **Mapped read-only; transform untested** | Mapped like Solid3D and accepted by `doc.solids.transform`, which lifts the payload with `kernel_acis_body`, but no Region fixture exercises it yet. Creation from curve handles (the REGION command's `add_region_model`) is the next operation to add. |
|
||||||
| 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). |
|
| 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 | **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. |
|
| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. |
|
||||||
| Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). |
|
| Surface | **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. |
|
| 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. |
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
|
|||||||
catalog. All 43 canvas kinds also support nonempty `layer` changes through
|
catalog. All 43 canvas kinds also support nonempty `layer` changes through
|
||||||
the same undoable transaction; internal and opaque variants remain read-only
|
the same undoable transaction; internal and opaque variants remain read-only
|
||||||
through the document model. The adapter accepts only `layer` patches for
|
through the document model. The adapter accepts only `layer` patches for
|
||||||
canvas kinds outside its 38 geometry converters. Other writable properties
|
canvas kinds outside its 41 geometry converters. Other writable properties
|
||||||
still report `type_conversion_only`; broader CAD range and cross-property
|
still report `type_conversion_only`; broader CAD range and cross-property
|
||||||
checks remain outstanding.
|
checks remain outstanding.
|
||||||
The host's `entity_snapshot` helper serializes any typed entity as a detached
|
The host's `entity_snapshot` helper serializes any typed entity as a detached
|
||||||
@@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
|
|||||||
token from another tab returns no result and does not consume it.
|
token from another tab returns no result and does not consume it.
|
||||||
|
|
||||||
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
|
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
|
||||||
outside the 38-kind generated schema it contains only handle, kind, and layer;
|
outside the 41-kind generated schema it contains only handle, kind, and layer;
|
||||||
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the
|
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the
|
||||||
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
|
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
|
||||||
covered kinds, the coverage object lists actual readable and editable
|
covered kinds, the coverage object lists actual readable and editable
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
|
|||||||
traced field, including fields marked `unmapped`. Query it with
|
traced field, including fields marked `unmapped`. Query it with
|
||||||
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
|
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
|
||||||
type, access, and current validation status. Geometry edits remain limited to
|
type, access, and current validation status. Geometry edits remain limited to
|
||||||
the 38 mapped kinds.
|
the 41 mapped kinds.
|
||||||
For any entity, `entity.snapshot` returns a detached dictionary of its raw
|
For any entity, `entity.snapshot` returns a detached dictionary of its raw
|
||||||
CAD fields, including `snapshot["common"]` and kind-specific nested values.
|
CAD fields, including `snapshot["common"]` and kind-specific nested values.
|
||||||
It can inspect an unmapped kind; editing the dictionary does
|
It can inspect an unmapped kind; editing the dictionary does
|
||||||
|
|||||||
@@ -37,7 +37,10 @@
|
|||||||
"Viewport",
|
"Viewport",
|
||||||
"ViewBorder",
|
"ViewBorder",
|
||||||
"Light",
|
"Light",
|
||||||
"Ole2Frame"
|
"Ole2Frame",
|
||||||
|
"Solid3D",
|
||||||
|
"Body",
|
||||||
|
"Region"
|
||||||
],
|
],
|
||||||
"manual_kinds": [
|
"manual_kinds": [
|
||||||
"Dimension"
|
"Dimension"
|
||||||
@@ -807,6 +810,87 @@
|
|||||||
"read_only": true
|
"read_only": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Solid3D": {
|
||||||
|
"update_only": true,
|
||||||
|
"update_only_message": "Solid3D is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
|
||||||
|
"fields": {
|
||||||
|
"acis_data": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
|
||||||
|
},
|
||||||
|
"wires": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived edge wires; regenerated by the host."
|
||||||
|
},
|
||||||
|
"silhouettes": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived; regenerated by the host."
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"point_of_reference": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"history_handle": {
|
||||||
|
"read_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Body": {
|
||||||
|
"update_only": true,
|
||||||
|
"update_only_message": "Body is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
|
||||||
|
"fields": {
|
||||||
|
"acis_data": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
|
||||||
|
},
|
||||||
|
"wires": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived edge wires; regenerated by the host."
|
||||||
|
},
|
||||||
|
"silhouettes": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived; regenerated by the host."
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"point_of_reference": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"history_handle": {
|
||||||
|
"read_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Region": {
|
||||||
|
"update_only": true,
|
||||||
|
"update_only_message": "Region is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
|
||||||
|
"fields": {
|
||||||
|
"acis_data": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
|
||||||
|
},
|
||||||
|
"wires": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived edge wires; regenerated by the host."
|
||||||
|
},
|
||||||
|
"silhouettes": {
|
||||||
|
"exclude": true,
|
||||||
|
"_comment": "Derived; regenerated by the host."
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"point_of_reference": {
|
||||||
|
"read_only": true
|
||||||
|
},
|
||||||
|
"history_handle": {
|
||||||
|
"read_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,10 +84,65 @@ class _Transaction:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _Solids:
|
||||||
|
"""Kernel-backed solid creation and rigid moves.
|
||||||
|
|
||||||
|
The host builds and edits the geometry; a script never sees the ACIS
|
||||||
|
payload. Every call is its own operation in the script's undo group and
|
||||||
|
fails, leaving the drawing unchanged, when the kernel cannot do it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, document):
|
||||||
|
self._document = document
|
||||||
|
|
||||||
|
def _create(self, primitive, values, layer):
|
||||||
|
handle = ocs.solid_create(primitive, [float(v) for v in values], layer)
|
||||||
|
return self._document.entities[handle]
|
||||||
|
|
||||||
|
def box(self, center=(0, 0, 0), size=(1, 1, 1), layer=None):
|
||||||
|
return self._create("box", tuple(center) + tuple(size), layer)
|
||||||
|
|
||||||
|
def wedge(self, origin=(0, 0, 0), size=(1, 1, 1), layer=None):
|
||||||
|
return self._create("wedge", tuple(origin) + tuple(size), layer)
|
||||||
|
|
||||||
|
def cylinder(self, center=(0, 0, 0), radius=1, height=1, layer=None):
|
||||||
|
return self._create("cylinder", tuple(center) + (radius, height), layer)
|
||||||
|
|
||||||
|
def sphere(self, center=(0, 0, 0), radius=1, layer=None):
|
||||||
|
return self._create("sphere", tuple(center) + (radius,), layer)
|
||||||
|
|
||||||
|
def torus(self, center=(0, 0, 0), major=2, minor=0.5, layer=None):
|
||||||
|
return self._create("torus", tuple(center) + (major, minor), layer)
|
||||||
|
|
||||||
|
def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None):
|
||||||
|
return self._create("pyramid", tuple(center) + (radius, height, sides), layer)
|
||||||
|
|
||||||
|
def transform(self, entity, matrix):
|
||||||
|
"""Apply a column-major 4x4 rigid transform (16 numbers) in place."""
|
||||||
|
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
|
||||||
|
ocs.solid_transform(handle, [float(v) for v in matrix])
|
||||||
|
return self._document.entities[handle]
|
||||||
|
|
||||||
|
def translate(self, entity, offset):
|
||||||
|
dx, dy, dz = (float(v) for v in offset)
|
||||||
|
return self.transform(entity, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, dz, 1])
|
||||||
|
|
||||||
|
def rotate(self, entity, axis, angle, about=(0, 0, 0)):
|
||||||
|
"""Turn about the world axis 'x', 'y' or 'z' through `about`."""
|
||||||
|
index = {"x": 0, "y": 1, "z": 2}[axis]
|
||||||
|
return self.transform(entity, ocs.rotation_matrix(index, float(angle), [float(v) for v in about]))
|
||||||
|
|
||||||
|
def mirror(self, entity, axis, about=(0, 0, 0)):
|
||||||
|
"""Reflect across the plane normal to the world axis through `about`."""
|
||||||
|
index = {"x": 0, "y": 1, "z": 2}[axis]
|
||||||
|
return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about]))
|
||||||
|
|
||||||
|
|
||||||
class _Document:
|
class _Document:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._pending = None
|
self._pending = None
|
||||||
self.entities = _Entities(self)
|
self.entities = _Entities(self)
|
||||||
|
self.solids = _Solids(self)
|
||||||
|
|
||||||
def transaction(self, label):
|
def transaction(self, label):
|
||||||
return _Transaction(self, label)
|
return _Transaction(self, label)
|
||||||
|
|||||||
@@ -411,6 +411,114 @@ mod ocs {
|
|||||||
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
|
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a kernel solid. `values` are the primitive's numbers in a fixed
|
||||||
|
/// order (see `document_model.py`); the host validates and builds it.
|
||||||
|
#[pyfunction]
|
||||||
|
fn solid_create(
|
||||||
|
primitive: String,
|
||||||
|
values: Vec<f64>,
|
||||||
|
layer: Option<String>,
|
||||||
|
vm: &VirtualMachine,
|
||||||
|
) -> PyResult<u64> {
|
||||||
|
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
|
||||||
|
let expect = |count: usize| -> PyResult<()> {
|
||||||
|
if values.len() == count {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(vm.new_value_error(format!(
|
||||||
|
"ocs.solid_create: {primitive} takes {count} numbers, got {}",
|
||||||
|
values.len()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let v3 = |i: usize| [values[i], values[i + 1], values[i + 2]];
|
||||||
|
let primitive = match primitive.as_str() {
|
||||||
|
"box" => { expect(6)?; SolidPrimitive::Box { center: v3(0), size: v3(3) } }
|
||||||
|
"wedge" => { expect(6)?; SolidPrimitive::Wedge { origin: v3(0), size: v3(3) } }
|
||||||
|
"cylinder" => { expect(5)?; SolidPrimitive::Cylinder { center: v3(0), radius: values[3], height: values[4] } }
|
||||||
|
"sphere" => { expect(4)?; SolidPrimitive::Sphere { center: v3(0), radius: values[3] } }
|
||||||
|
"torus" => { expect(5)?; SolidPrimitive::Torus { center: v3(0), major: values[3], minor: values[4] } }
|
||||||
|
"pyramid" => {
|
||||||
|
expect(6)?;
|
||||||
|
if values[5] < 0.0 || values[5].fract() != 0.0 || values[5] > f64::from(u32::MAX) {
|
||||||
|
return Err(vm.new_value_error("ocs.solid_create: sides must be a whole number".to_owned()));
|
||||||
|
}
|
||||||
|
SolidPrimitive::Pyramid { center: v3(0), radius: values[3], height: values[4], sides: values[5] as u32 }
|
||||||
|
}
|
||||||
|
other => return Err(vm.new_value_error(format!("ocs.solid_create: unknown primitive {other:?}"))),
|
||||||
|
};
|
||||||
|
// The host records its own undo step once the operation validates, like
|
||||||
|
// an entity transaction, so a refused operation leaves no empty step.
|
||||||
|
let result = host_ctx::with_host(|host| {
|
||||||
|
host.solid_operation(SolidOperation::Create { primitive, layer })
|
||||||
|
})
|
||||||
|
.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_create: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a column-major 4x4 rigid transform to a solid, body or region.
|
||||||
|
#[pyfunction]
|
||||||
|
fn solid_transform(handle: u64, matrix: Vec<f64>, vm: &VirtualMachine) -> PyResult<u64> {
|
||||||
|
use ocs_plugin_api::host::SolidOperation;
|
||||||
|
let matrix: [f64; 16] = matrix.try_into().map_err(|_| {
|
||||||
|
vm.new_value_error("ocs.solid_transform: the matrix needs 16 numbers".to_owned())
|
||||||
|
})?;
|
||||||
|
let result = host_ctx::with_host(|host| {
|
||||||
|
host.solid_operation(SolidOperation::Transform { handle: Handle::new(handle), matrix })
|
||||||
|
})
|
||||||
|
.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_transform: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column-major matrix that turns about a world axis (0=X, 1=Y, 2=Z)
|
||||||
|
/// through the point `about`. The sandbox has no `math` module.
|
||||||
|
#[pyfunction]
|
||||||
|
fn rotation_matrix(axis: usize, angle: f64, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
|
||||||
|
if axis > 2 || about.len() != 3 || !angle.is_finite() || about.iter().any(|v| !v.is_finite()) {
|
||||||
|
return Err(vm.new_value_error("ocs.rotation_matrix: axis 0-2, a finite angle and a 3-point".to_owned()));
|
||||||
|
}
|
||||||
|
let (sin, cos) = angle.sin_cos();
|
||||||
|
let (x, y, z) = match axis {
|
||||||
|
0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]),
|
||||||
|
1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]),
|
||||||
|
_ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]),
|
||||||
|
};
|
||||||
|
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column-major matrix that reflects across the plane normal to a world
|
||||||
|
/// axis through the point `about`.
|
||||||
|
#[pyfunction]
|
||||||
|
fn mirror_matrix(axis: usize, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
|
||||||
|
if axis > 2 || about.len() != 3 || about.iter().any(|v| !v.is_finite()) {
|
||||||
|
return Err(vm.new_value_error("ocs.mirror_matrix: axis 0-2 and a finite 3-point".to_owned()));
|
||||||
|
}
|
||||||
|
let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||||
|
columns[axis][axis] = -1.0;
|
||||||
|
let [x, y, z] = columns;
|
||||||
|
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matrix_to_py(vm: &VirtualMachine, matrix: Vec<f64>) -> PyObjectRef {
|
||||||
|
vm.ctx.new_list(matrix.into_iter().map(|value| vm.new_pyobj(value)).collect()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transform about `about` has origin `about - M * about`.
|
||||||
|
fn frame_matrix(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: &[f64]) -> Vec<f64> {
|
||||||
|
let mut origin = [about[0], about[1], about[2]];
|
||||||
|
for axis in 0..3 {
|
||||||
|
origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2];
|
||||||
|
}
|
||||||
|
vec![
|
||||||
|
x[0], x[1], x[2], 0.0, y[0], y[1], y[2], 0.0, z[0], z[1], z[2], 0.0,
|
||||||
|
origin[0], origin[1], origin[2], 1.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete one entity in the script's shared undo group.
|
/// Delete one entity in the script's shared undo group.
|
||||||
#[pyfunction]
|
#[pyfunction]
|
||||||
fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> {
|
fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> {
|
||||||
@@ -4780,7 +4888,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(emitted.len(), 38);
|
assert_eq!(emitted.len(), 41);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "experimental-host-model")]
|
#[cfg(feature = "experimental-host-model")]
|
||||||
|
|||||||
@@ -530,6 +530,151 @@ impl<'a> HostSession<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kernel-backed solid create or transform. Every result is verified by
|
||||||
|
/// `solid_to_sat` (the exported payload must lift back with no loss) and an
|
||||||
|
/// existing payload is only transformed when it lifts losslessly, so a
|
||||||
|
/// failure never changes the document.
|
||||||
|
pub fn solid_operation(
|
||||||
|
&mut self,
|
||||||
|
operation: ocs_plugin_api::host::SolidOperation,
|
||||||
|
) -> Result<Handle, String> {
|
||||||
|
use crate::scene::model::solid_model as model;
|
||||||
|
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
|
||||||
|
let positive = |name: &str, value: f64| {
|
||||||
|
if value.is_finite() && value > 0.0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{name} must be finite and greater than zero"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let finite3 = |name: &str, value: [f64; 3]| {
|
||||||
|
if value.iter().all(|v| v.is_finite()) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{name} must be finite"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match operation {
|
||||||
|
SolidOperation::Create { primitive, layer } => {
|
||||||
|
let body = match primitive {
|
||||||
|
SolidPrimitive::Box { center, size } => {
|
||||||
|
finite3("box center", center)?;
|
||||||
|
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
|
||||||
|
positive(&format!("box {name}"), v)?;
|
||||||
|
}
|
||||||
|
model::box_solid(center, size[0], size[1], size[2])
|
||||||
|
}
|
||||||
|
SolidPrimitive::Wedge { origin, size } => {
|
||||||
|
finite3("wedge origin", origin)?;
|
||||||
|
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
|
||||||
|
positive(&format!("wedge {name}"), v)?;
|
||||||
|
}
|
||||||
|
model::wedge_solid(origin, size[0], size[1], size[2])
|
||||||
|
}
|
||||||
|
SolidPrimitive::Cylinder { center, radius, height } => {
|
||||||
|
finite3("cylinder center", center)?;
|
||||||
|
positive("cylinder radius", radius)?;
|
||||||
|
positive("cylinder height", height)?;
|
||||||
|
model::cylinder_solid(center, radius, height)
|
||||||
|
}
|
||||||
|
SolidPrimitive::Sphere { center, radius } => {
|
||||||
|
finite3("sphere center", center)?;
|
||||||
|
positive("sphere radius", radius)?;
|
||||||
|
model::sphere_solid(center, radius)
|
||||||
|
}
|
||||||
|
SolidPrimitive::Torus { center, major, minor } => {
|
||||||
|
finite3("torus center", center)?;
|
||||||
|
positive("torus major radius", major)?;
|
||||||
|
positive("torus minor radius", minor)?;
|
||||||
|
if minor >= major {
|
||||||
|
return Err("torus minor radius must be smaller than the major radius".into());
|
||||||
|
}
|
||||||
|
model::torus_solid(center, major, minor)
|
||||||
|
}
|
||||||
|
SolidPrimitive::Pyramid { center, radius, height, sides } => {
|
||||||
|
finite3("pyramid center", center)?;
|
||||||
|
positive("pyramid radius", radius)?;
|
||||||
|
positive("pyramid height", height)?;
|
||||||
|
if !(3..=1024).contains(&sides) {
|
||||||
|
return Err("pyramid sides must be between 3 and 1024".into());
|
||||||
|
}
|
||||||
|
model::pyramid_solid(center, radius, height, sides as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ok_or("the geometry kernel refused these dimensions")?;
|
||||||
|
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
|
||||||
|
.ok_or("the solid could not be exported losslessly")?;
|
||||||
|
let mut solid = acadrust::entities::Solid3D::new();
|
||||||
|
solid.wires = model::edge_wires(&body);
|
||||||
|
solid.set_sat_document(&sat);
|
||||||
|
if let Some(layer) = layer {
|
||||||
|
if layer.trim().is_empty() {
|
||||||
|
return Err("layer name is empty".into());
|
||||||
|
}
|
||||||
|
solid.common.layer = layer;
|
||||||
|
}
|
||||||
|
self.push_undo("Create solid");
|
||||||
|
let handle = self.add_entity(EntityType::Solid3D(solid));
|
||||||
|
if handle.is_null() {
|
||||||
|
return Err("the solid could not be added".into());
|
||||||
|
}
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
SolidOperation::Transform { handle, matrix } => {
|
||||||
|
if matrix.iter().any(|v| !v.is_finite()) {
|
||||||
|
return Err("transform matrix must be finite".into());
|
||||||
|
}
|
||||||
|
let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]];
|
||||||
|
let (x, y, z) = (column(0), column(4), column(8));
|
||||||
|
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6)
|
||||||
|
&& dot(x, y).abs() < 1e-6
|
||||||
|
&& dot(x, z).abs() < 1e-6
|
||||||
|
&& dot(y, z).abs() < 1e-6
|
||||||
|
&& matrix[3].abs() < 1e-9
|
||||||
|
&& matrix[7].abs() < 1e-9
|
||||||
|
&& matrix[11].abs() < 1e-9
|
||||||
|
&& (matrix[15] - 1.0).abs() < 1e-9;
|
||||||
|
if !rigid {
|
||||||
|
return Err("only rigid moves and mirrors are supported; scaling and shear are not".into());
|
||||||
|
}
|
||||||
|
let mut entity = self
|
||||||
|
.document()
|
||||||
|
.get_entity(handle)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("entity {handle:?} does not exist"))?;
|
||||||
|
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
|
||||||
|
return Err(format!("entity {handle:?} is on a locked layer"));
|
||||||
|
}
|
||||||
|
macro_rules! retarget {
|
||||||
|
($value:expr) => {{
|
||||||
|
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data)
|
||||||
|
.ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?;
|
||||||
|
let moved = model::by_matrix(&body, matrix)
|
||||||
|
.ok_or("the geometry kernel refused this transform")?;
|
||||||
|
let sat = crate::scene::convert::acis_export::solid_to_sat(&moved)
|
||||||
|
.ok_or("the moved body could not be exported losslessly")?;
|
||||||
|
$value.wires = model::edge_wires(&moved);
|
||||||
|
$value.silhouettes.clear();
|
||||||
|
$value.history_handle = None;
|
||||||
|
$value.set_sat_document(&sat);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
match &mut entity {
|
||||||
|
EntityType::Solid3D(value) => retarget!(value),
|
||||||
|
EntityType::Body(value) => retarget!(value),
|
||||||
|
EntityType::Region(value) => retarget!(value),
|
||||||
|
_ => return Err("transform applies to Solid3D, Body and Region entities".into()),
|
||||||
|
}
|
||||||
|
self.push_undo("Transform solid");
|
||||||
|
if !self.update_entity(entity) {
|
||||||
|
return Err(format!("entity {handle:?} could not be updated"));
|
||||||
|
}
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn push_undo(&mut self, label: &str) {
|
pub fn push_undo(&mut self, label: &str) {
|
||||||
self.app.push_undo_snapshot(self.tab, label);
|
self.app.push_undo_snapshot(self.tab, label);
|
||||||
}
|
}
|
||||||
@@ -622,6 +767,12 @@ impl HostApi for HostSession<'_> {
|
|||||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||||
self.set_selection(handles)
|
self.set_selection(handles)
|
||||||
}
|
}
|
||||||
|
fn solid_operation(
|
||||||
|
&mut self,
|
||||||
|
operation: ocs_plugin_api::host::SolidOperation,
|
||||||
|
) -> Result<Handle, String> {
|
||||||
|
self.solid_operation(operation)
|
||||||
|
}
|
||||||
fn remove_entity(&mut self, handle: Handle) -> bool {
|
fn remove_entity(&mut self, handle: Handle) -> bool {
|
||||||
self.remove_entity(handle)
|
self.remove_entity(handle)
|
||||||
}
|
}
|
||||||
@@ -4558,6 +4709,199 @@ mod tests {
|
|||||||
time("volume(union)", &mut || format!("{:.1}", sm::volume(&result)));
|
time("volume(union)", &mut || format!("{:.1}", sm::volume(&result)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audit_python_solid3d_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 = |host: &HostSession<'_>| -> Vec<Handle> {
|
||||||
|
let mut list: Vec<_> = host.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_of = |host: &HostSession<'_>, handle: Handle| {
|
||||||
|
let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") };
|
||||||
|
kernel_body(solid).expect("payload lifts losslessly")
|
||||||
|
};
|
||||||
|
let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle));
|
||||||
|
let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap();
|
||||||
|
let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0);
|
||||||
|
|
||||||
|
// C: every primitive, checked against its analytic volume.
|
||||||
|
let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id()));
|
||||||
|
std::fs::write(&script, concat!(
|
||||||
|
"s = ocs.active_document.solids\n",
|
||||||
|
"s.box(center=(0, 0, 0), size=(10, 6, 4))\n",
|
||||||
|
"s.cylinder(center=(20, 0, 0), radius=2, height=5)\n",
|
||||||
|
"s.sphere(center=(40, 0, 0), radius=3)\n",
|
||||||
|
"s.torus(center=(60, 0, 0), major=5, minor=1)\n",
|
||||||
|
"s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n",
|
||||||
|
"s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n",
|
||||||
|
"s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n",
|
||||||
|
)).unwrap();
|
||||||
|
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
let list = solids(&host);
|
||||||
|
assert_eq!(list.len(), 7, "{}", last(&host));
|
||||||
|
let pi = std::f64::consts::PI;
|
||||||
|
let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin();
|
||||||
|
for (index, expected, tolerance) in [
|
||||||
|
(0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02),
|
||||||
|
(3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9),
|
||||||
|
] {
|
||||||
|
let actual = volume(&host, list[index]);
|
||||||
|
assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}");
|
||||||
|
}
|
||||||
|
let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() };
|
||||||
|
assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0"));
|
||||||
|
assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS"));
|
||||||
|
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
|
||||||
|
let read = |host: &mut HostSession<'_>, expression: &str| {
|
||||||
|
dispatch(host, &format!("PY_EVAL {expression}"));
|
||||||
|
last(host)
|
||||||
|
};
|
||||||
|
assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D"));
|
||||||
|
|
||||||
|
// E: rigid moves keep the volume and move the extent exactly.
|
||||||
|
let box_handle = list[0];
|
||||||
|
let before = host.document().get_entity(box_handle).unwrap().clone();
|
||||||
|
let undo_before = host.app.tabs[0].history.undo_stack.len();
|
||||||
|
let (low, high) = extent(&host, box_handle);
|
||||||
|
assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}");
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value()));
|
||||||
|
let (low, high) = extent(&host, box_handle);
|
||||||
|
assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}");
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value()));
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value()));
|
||||||
|
assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume");
|
||||||
|
let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() };
|
||||||
|
assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle));
|
||||||
|
assert_eq!(moved.common.layer, "0");
|
||||||
|
assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move");
|
||||||
|
|
||||||
|
// V: refused operations change nothing and leave no undo step.
|
||||||
|
let snapshot = host.document().get_entity(box_handle).unwrap().clone();
|
||||||
|
let count_before = solids(&host).len();
|
||||||
|
let undo_now = host.app.tabs[0].history.undo_stack.len();
|
||||||
|
let empty_payload = acadrust::entities::Solid3D::new();
|
||||||
|
let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone()));
|
||||||
|
let unliftable_before = host.document().get_entity(unliftable).unwrap().clone();
|
||||||
|
for (command, message) in [
|
||||||
|
("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"),
|
||||||
|
("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"),
|
||||||
|
("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"),
|
||||||
|
("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"),
|
||||||
|
("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"),
|
||||||
|
("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"),
|
||||||
|
("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"),
|
||||||
|
("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"),
|
||||||
|
(format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"),
|
||||||
|
(format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"),
|
||||||
|
(format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"),
|
||||||
|
(format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"),
|
||||||
|
("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"),
|
||||||
|
] {
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL {command}"));
|
||||||
|
assert!(last(&host).contains(message), "{command}: {}", last(&host));
|
||||||
|
}
|
||||||
|
assert_eq!(host.document().get_entity(box_handle), Some(&snapshot));
|
||||||
|
assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before));
|
||||||
|
assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added");
|
||||||
|
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
|
||||||
|
// Transforming a non-solid is refused.
|
||||||
|
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))));
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value()));
|
||||||
|
assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host));
|
||||||
|
|
||||||
|
// W: both formats reopen the moved solid losslessly; the reopened
|
||||||
|
// payload can be moved again.
|
||||||
|
let moved_volume = volume(&host, box_handle);
|
||||||
|
let moved_extent = extent(&host, box_handle);
|
||||||
|
let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
|
||||||
|
let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
|
||||||
|
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
|
||||||
|
let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") };
|
||||||
|
let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift"));
|
||||||
|
assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}");
|
||||||
|
let reopened_extent = sm::extent(&body).unwrap();
|
||||||
|
for axis in 0..3 {
|
||||||
|
assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent");
|
||||||
|
}
|
||||||
|
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()));
|
||||||
|
let (low, _) = extent(&host, again);
|
||||||
|
assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit");
|
||||||
|
}
|
||||||
|
|
||||||
|
// D and U: delete, then undo and redo restore the moved state.
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value()));
|
||||||
|
assert!(host.document().get_entity(box_handle).is_none());
|
||||||
|
drop(process);
|
||||||
|
drop(host);
|
||||||
|
app.finish_pending_history(0);
|
||||||
|
// Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete.
|
||||||
|
app.undo_steps(1);
|
||||||
|
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid");
|
||||||
|
app.undo_steps(5);
|
||||||
|
assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly");
|
||||||
|
app.redo_steps(6);
|
||||||
|
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audit_python_body_transform_over_real_ipc() {
|
||||||
|
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);
|
||||||
|
// A Body entity carrying a kernel box; scripts cannot create one.
|
||||||
|
let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap();
|
||||||
|
let mut body = acadrust::entities::Body::new();
|
||||||
|
body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap());
|
||||||
|
body.wires = sm::edge_wires(&boxed);
|
||||||
|
let handle = host.add_entity(EntityType::Body(body));
|
||||||
|
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 extent = |host: &HostSession<'_>| {
|
||||||
|
let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") };
|
||||||
|
sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap()
|
||||||
|
};
|
||||||
|
assert!((extent(&host).0[0] + 2.0).abs() < 1e-6);
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value()));
|
||||||
|
assert!(host.app.command_line.history.last().unwrap().text.contains("Body"));
|
||||||
|
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value()));
|
||||||
|
assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host));
|
||||||
|
let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
|
||||||
|
let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap();
|
||||||
|
let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") };
|
||||||
|
let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts");
|
||||||
|
assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6);
|
||||||
|
// Scripts cannot create a Body and get the explanatory message.
|
||||||
|
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')");
|
||||||
|
assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn staged_python_point_pick_and_cancel_over_real_ipc() {
|
fn staged_python_point_pick_and_cancel_over_real_ipc() {
|
||||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||||
|
|||||||
Reference in new issue
Block a user