Complete Surface: plane surfaces, extrusions, isolines and moves

Adds SurfaceFromProfile and Extrude kernel operations (doc.solids.surface and
doc.solids.extrude), accepts surfaces in Transform, and maps Surface with
editable isolines. Fixes a real bug found by the test: the surface kind field
overwrote the entity-type kind key in Python dicts, so it is exposed as
surface_kind, with a catalog test guarding against name collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 17:47:22 +02:00
1 parent 918351f264
commit 9cd6653a58
14 files changed
+441 -34

No files matched your search

+1
View File
@@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
| ("Helix", _) | ("Helix", _)
| ("RasterImage", _) | ("RasterImage", _)
| ("Wipeout", _) | ("Wipeout", _)
| ("Surface", "u_isolines" | "v_isolines")
| ("Ole2Frame", _) | ("Ole2Frame", _)
| ("Underlay", _) | ("Underlay", _)
| ("Viewport", _) | ("Viewport", _)
@@ -502,6 +502,11 @@
], ],
"Region": [ "Region": [
"layer" "layer"
],
"Surface": [
"layer",
"u_isolines",
"v_isolines"
] ]
}, },
"readable": { "readable": {
@@ -627,6 +632,12 @@
"uid", "uid",
"point_of_reference", "point_of_reference",
"history_handle" "history_handle"
],
"Surface": [
"surface_kind",
"modeler_format_version",
"point_of_reference",
"history_handle"
] ]
}, },
"aliases": { "aliases": {
@@ -643,6 +654,9 @@
"MText": { "MText": {
"text": "value", "text": "value",
"insertion": "insertion_point" "insertion": "insertion_point"
},
"Surface": {
"surface_kind": "kind"
} }
}, },
"synthetic": { "synthetic": {
@@ -1245,6 +1245,24 @@ fn validate_ole_frame(
Ok(()) Ok(())
} }
/// Surface: only the display isoline density is scriptable; the geometry lives
/// in the ACIS payload that the geometry kernel owns.
#[cfg(feature = "host")]
fn validate_surface(
old: Option<&crate::host::acadrust::entities::Surface>,
new: &crate::host::acadrust::entities::Surface,
) -> Result<(), String> {
if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) {
return Ok(());
}
for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] {
if !(0..=200).contains(&value) {
return Err(format!("Surface.{name} must be between 0 and 200"));
}
}
Ok(())
}
/// Validate newly created geometry for the kinds whose mapped fields have /// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document. /// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")] #[cfg(feature = "host")]
@@ -1371,6 +1389,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
} }
} }
EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?,
EntityType::Surface(value) => validate_surface(None, value)?,
EntityType::AttributeDefinition(value) => { EntityType::AttributeDefinition(value) => {
finite_vector( finite_vector(
"AttributeDefinition.insertion_point", "AttributeDefinition.insertion_point",
@@ -1745,6 +1764,7 @@ pub fn validate_entity_mutation(
return Err("Ole2Frame embedded storage cannot change".into()); return Err("Ole2Frame embedded storage cannot change".into());
} }
} }
(EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?,
(EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
@@ -2486,6 +2506,20 @@ mod tests {
} }
} }
#[test]
fn exposed_property_names_are_unique_per_kind() {
// A field named like a base key (`kind`, `handle`, `layer`, ...) would
// overwrite it in every Python dict; such fields must be renamed.
let catalog: EntityCoverageCatalog =
serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
for entry in &catalog.entity_kinds {
let mut seen = std::collections::HashSet::new();
for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) {
assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name);
}
}
}
#[test] #[test]
fn unsupported_kinds_have_no_editable_properties() { fn unsupported_kinds_have_no_editable_properties() {
let catalog: EntityCoverageCatalog = let catalog: EntityCoverageCatalog =
@@ -2535,6 +2569,7 @@ mod tests {
| "Solid3D" | "Solid3D"
| "Body" | "Body"
| "Region" | "Region"
| "Surface"
) )
{ {
assert!( assert!(
@@ -2824,6 +2859,8 @@ mod tests {
"Ole2Frame.lower_right_corner".to_owned(), "Ole2Frame.lower_right_corner".to_owned(),
"Ole2Frame.lock_aspect".to_owned(), "Ole2Frame.lock_aspect".to_owned(),
"Ole2Frame.is_paper_space".to_owned(), "Ole2Frame.is_paper_space".to_owned(),
"Surface.u_isolines".to_owned(),
"Surface.v_isolines".to_owned(),
"Underlay.underlay_type".to_owned(), "Underlay.underlay_type".to_owned(),
"Underlay.definition_handle".to_owned(), "Underlay.definition_handle".to_owned(),
"Underlay.insertion_point".to_owned(), "Underlay.insertion_point".to_owned(),
+5
View File
@@ -90,6 +90,11 @@ pub enum SolidOperation {
/// `layer`, or on the source's layer when `None`; `delete_source` erases /// `layer`, or on the source's layer when `None`; `delete_source` erases
/// the profile afterwards, as the REGION command does. /// the profile afterwards, as the REGION command does.
RegionFromProfile { source: Handle, layer: Option<String>, delete_source: bool }, RegionFromProfile { source: Handle, layer: Option<String>, delete_source: bool },
/// Build a plane surface from one closed planar profile entity.
SurfaceFromProfile { source: Handle, layer: Option<String>, delete_source: bool },
/// Extrude a planar profile along `direction`: a solid when the profile is
/// closed, a surface when it is open.
Extrude { source: Handle, direction: [f64; 3], layer: Option<String>, delete_source: bool },
} }
/// Value of a host-managed drafting or document setting exposed to plugins. /// Value of a host-managed drafting or document setting exposed to plugins.
@@ -115,6 +115,8 @@ mod tests {
}, },
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true },
SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false },
SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true },
] { ] {
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(), assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
+1 -1
View File
@@ -136,4 +136,4 @@ is refused with a clear message instead of being rewritten.
Phase 1 alone would move Solid3D from blocked to complete-in-OCS. 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. Region creation from a closed profile followed (`RegionFromProfile`). Booleans and Surface remain. **Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Plane surfaces and extrusions (`SurfaceFromProfile`, `Extrude`) followed. Only booleans remain, plus lofted, revolved, swept and NURBS surfaces.
+4 -3
View File
@@ -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 **41 of 43** canvas kinds: 38 v7. The Python feature has explicit writes for **42 of 43** canvas kinds: 39
are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D and Region pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface 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 remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design) `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 canvas kind, SectionSymbol, has only `layer` and is recorded as blocked
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.
@@ -179,3 +179,4 @@ exact queue above and update statuses with evidence after each increment.
- 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. - Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved.
- Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`.
- Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind.
+2 -2
View File
@@ -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: **41 of 43 canvas kinds have explicit geometry writes** (38 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **28 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **42 of 43 canvas kinds have explicit geometry writes** (39 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and 1 remains layer-only (SectionSymbol, blocked). `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.
@@ -47,7 +47,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. |
| Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. |
| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: 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 | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. |
| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | | 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 After each increment, update the row with test names, fixture paths, exact
+2 -2
View File
@@ -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 41 geometry converters. Other writable properties canvas kinds outside its 42 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 41-kind generated schema it contains only handle, kind, and layer; outside the 42-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
+1 -1
View File
@@ -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 41 mapped kinds. the 42 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
+36 -1
View File
@@ -40,7 +40,8 @@
"Ole2Frame", "Ole2Frame",
"Solid3D", "Solid3D",
"Body", "Body",
"Region" "Region",
"Surface"
], ],
"manual_kinds": [ "manual_kinds": [
"Dimension" "Dimension"
@@ -891,6 +892,40 @@
"read_only": true "read_only": true
} }
} }
},
"Surface": {
"update_only": true,
"update_only_message": "Surface is built by the geometry kernel: use doc.solids.surface(profile) for a plane surface or doc.solids.extrude(open_profile, direction) for an extruded one; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; never rewritten by a script."
},
"surface_data": {
"exclude": true,
"_comment": "Kind-specific sweep/loft parameters and embedded profiles; preserved untouched."
},
"wires": {
"exclude": true
},
"silhouettes": {
"exclude": true
},
"kind": {
"read_only": true,
"python_name": "surface_kind",
"_comment": "Renamed: a bare `kind` key names the entity type in every Python dict."
},
"modeler_format_version": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
} }
} }
} }
@@ -146,6 +146,18 @@ class _Solids:
handle = profile.handle if isinstance(profile, _Entity) else int(profile) handle = profile.handle if isinstance(profile, _Entity) else int(profile)
return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))]
def surface(self, profile, layer=None, delete_source=False):
"""Make a plane surface from one closed planar profile."""
handle = profile.handle if isinstance(profile, _Entity) else int(profile)
return self._document.entities[ocs.solid_surface(handle, layer, bool(delete_source))]
def extrude(self, profile, direction, layer=None, delete_source=False):
"""Extrude a planar profile along `direction`: a Solid3D when the
profile is closed, a Surface when it is open."""
handle = profile.handle if isinstance(profile, _Entity) else int(profile)
vector = [float(v) for v in direction]
return self._document.entities[ocs.solid_extrude(handle, vector, layer, bool(delete_source))]
def transform(self, entity, matrix): def transform(self, entity, matrix):
"""Apply a column-major 4x4 rigid transform (16 numbers) in place.""" """Apply a column-major 4x4 rigid transform (16 numbers) in place."""
handle = entity.handle if isinstance(entity, _Entity) else int(entity) handle = entity.handle if isinstance(entity, _Entity) else int(entity)
+45 -1
View File
@@ -475,6 +475,50 @@ mod ocs {
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}")))
} }
/// Build a plane surface from a closed planar profile entity.
#[pyfunction]
fn solid_surface(source: u64, layer: Option<String>, delete_source: bool, vm: &VirtualMachine) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::SurfaceFromProfile {
source: Handle::new(source),
layer,
delete_source,
})
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_surface: {error}")))
}
/// Extrude a planar profile: a solid if closed, a surface if open.
#[pyfunction]
fn solid_extrude(
source: u64,
direction: Vec<f64>,
layer: Option<String>,
delete_source: bool,
vm: &VirtualMachine,
) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let direction: [f64; 3] = direction.try_into().map_err(|_| {
vm.new_value_error("ocs.solid_extrude: the direction needs 3 numbers".to_owned())
})?;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Extrude {
source: Handle::new(source),
direction,
layer,
delete_source,
})
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_extrude: {error}")))
}
/// Apply a column-major 4x4 rigid transform to a solid, body or region. /// Apply a column-major 4x4 rigid transform to a solid, body or region.
#[pyfunction] #[pyfunction]
fn solid_transform(handle: u64, matrix: Vec<f64>, vm: &VirtualMachine) -> PyResult<u64> { fn solid_transform(handle: u64, matrix: Vec<f64>, vm: &VirtualMachine) -> PyResult<u64> {
@@ -4905,7 +4949,7 @@ mod tests {
); );
} }
} }
assert_eq!(emitted.len(), 41); assert_eq!(emitted.len(), 42);
} }
#[cfg(feature = "experimental-host-model")] #[cfg(feature = "experimental-host-model")]
+279 -23
View File
@@ -621,22 +621,10 @@ impl<'a> HostSession<'a> {
Ok(handle) Ok(handle)
} }
SolidOperation::RegionFromProfile { source, layer, delete_source } => { SolidOperation::RegionFromProfile { source, layer, delete_source } => {
if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
return Err("layer name is empty".into());
}
let entity = self
.document()
.get_entity(source)
.cloned()
.ok_or_else(|| format!("entity {source:?} does not exist"))?;
if matches!(entity, EntityType::Region(_)) { if matches!(entity, EntityType::Region(_)) {
return Err("the source is already a region".into()); return Err("the source is already a region".into());
} }
if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) {
return Err(format!("entity {source:?} is on a locked layer"));
}
let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity)
.ok_or("the source is not a planar profile a region can be made from")?;
if !closed { if !closed {
return Err("the profile must be closed".into()); return Err("the profile must be closed".into());
} }
@@ -649,17 +637,60 @@ impl<'a> HostSession<'a> {
acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
region.wires = model::edge_wires(&body); region.wires = model::edge_wires(&body);
region.set_sat_document(&sat); region.set_sat_document(&sat);
region.common.layer = layer.unwrap_or_else(|| entity.common().layer.clone()); region.common.layer = layer;
self.push_undo("Create region"); self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region")
let handle = self.add_entity(EntityType::Region(region)); }
if handle.is_null() { SolidOperation::SurfaceFromProfile { source, layer, delete_source } => {
return Err("the region could not be added".into()); let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
if matches!(entity, EntityType::Region(_)) {
return Err("the source is already a region".into());
} }
if delete_source { if !closed {
self.app.tabs[self.tab].scene.erase_entities(&[source]); return Err("the profile must be closed".into());
self.publish_document_view();
} }
Ok(handle) let body = cadkernel::brep::planar_region(plane, &loops)
.ok_or("the geometry kernel could not build a surface from this profile")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the surface could not be exported losslessly")?;
let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane);
surface.point_of_reference =
acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
surface.wires = model::edge_wires(&body);
surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
surface.common.layer = layer;
self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface")
}
SolidOperation::Extrude { source, direction, layer, delete_source } => {
if direction.iter().any(|v| !v.is_finite()) {
return Err("extrusion direction must be finite".into());
}
if direction.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-9 {
return Err("extrusion direction must be nonzero".into());
}
let (entity, _plane, _loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
if matches!(entity, EntityType::Region(_)) {
return Err("extrude a curve profile; a region cannot be extruded here".into());
}
let body = crate::scene::model::presspull_model::extrusion_body(&entity, direction)
.ok_or("the geometry kernel could not extrude this profile in that direction")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the extrusion could not be exported losslessly")?;
let result = if closed {
let mut solid = acadrust::entities::Solid3D::new();
solid.wires = model::edge_wires(&body);
solid.set_sat_document(&sat);
solid.common.layer = layer;
EntityType::Solid3D(solid)
} else {
// An open profile sweeps to a surface. The generic kind
// carries only the payload, as OCS does after a boolean.
let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Generic);
surface.wires = model::edge_wires(&body);
surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
surface.common.layer = layer;
EntityType::Surface(surface)
};
self.commit_profile_result(result, source, delete_source, "Extrude profile")
} }
SolidOperation::Transform { handle, matrix } => { SolidOperation::Transform { handle, matrix } => {
if matrix.iter().any(|v| !v.is_finite()) { if matrix.iter().any(|v| !v.is_finite()) {
@@ -705,7 +736,25 @@ impl<'a> HostSession<'a> {
EntityType::Solid3D(value) => retarget!(value), EntityType::Solid3D(value) => retarget!(value),
EntityType::Body(value) => retarget!(value), EntityType::Body(value) => retarget!(value),
EntityType::Region(value) => retarget!(value), EntityType::Region(value) => retarget!(value),
_ => return Err("transform applies to Solid3D, Body and Region entities".into()), EntityType::Surface(value) => {
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&value.acis_data)
.ok_or("this surface'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 surface could not be exported losslessly")?;
value.wires = model::edge_wires(&moved);
value.silhouettes.clear();
value.history_handle = None;
value.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
// A moved plane is still a plane; a swept surface no longer
// matches its stored sweep parameters, so it becomes generic.
if value.kind != acadrust::entities::SurfaceKind::Plane {
value.kind = acadrust::entities::SurfaceKind::Generic;
value.surface_data = acadrust::entities::SurfaceData::Generic;
}
}
_ => return Err("transform applies to Solid3D, Body, Region and Surface entities".into()),
} }
self.push_undo("Transform solid"); self.push_undo("Transform solid");
if !self.update_entity(entity) { if !self.update_entity(entity) {
@@ -716,6 +765,56 @@ impl<'a> HostSession<'a> {
} }
} }
/// Look up a profile entity and its exact planar loops for the kernel.
/// Returns the entity, its plane, loops and closed flag, and the layer the
/// result should use (`layer`, or the profile's own).
#[allow(clippy::type_complexity)]
fn profile_source(
&self,
source: Handle,
layer: Option<String>,
delete_source: bool,
) -> Result<(EntityType, cadkernel::space::Plane, Vec<Vec<cadkernel::geom2d::Curve>>, bool, String), String> {
if layer.as_ref().is_some_and(|name| name.trim().is_empty()) {
return Err("layer name is empty".into());
}
let entity = self
.document()
.get_entity(source)
.cloned()
.ok_or_else(|| format!("entity {source:?} does not exist"))?;
if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) {
return Err(format!("entity {source:?} is on a locked layer"));
}
if matches!(entity, EntityType::Solid3D(_) | EntityType::Body(_) | EntityType::Surface(_)) {
return Err("the source must be a curve profile, not an existing solid or surface".into());
}
let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity)
.ok_or("the source is not a planar profile")?;
let layer = layer.unwrap_or_else(|| entity.common().layer.clone());
Ok((entity, plane, loops, closed, layer))
}
/// Record one undo step, add `result` and optionally erase the profile.
fn commit_profile_result(
&mut self,
result: EntityType,
source: Handle,
delete_source: bool,
label: &str,
) -> Result<Handle, String> {
self.push_undo(label);
let handle = self.add_entity(result);
if handle.is_null() {
return Err("the result could not be added".into());
}
if delete_source {
self.app.tabs[self.tab].scene.erase_entities(&[source]);
self.publish_document_view();
}
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);
} }
@@ -5071,6 +5170,163 @@ mod tests {
assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly"); assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly");
} }
#[test]
fn audit_python_surface_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_acis_body;
use crate::scene::model::solid_model as sm;
use acadrust::entities::SurfaceKind;
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 sorted = |mut list: Vec<Handle>| { list.sort_by_key(|h| h.value()); list };
let surfaces_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e {
EntityType::Surface(s) => Some(s.common.handle), _ => None }).collect());
let solids_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e {
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect());
let body_of = |document: &CadDocument, handle: Handle| match document.get_entity(handle) {
Some(EntityType::Surface(s)) => kernel_acis_body(&s.acis_data).expect("surface payload lifts losslessly"),
Some(EntityType::Solid3D(s)) => kernel_acis_body(&s.acis_data).expect("solid payload lifts losslessly"),
other => panic!("no surface or solid {handle:?}: {other:?}"),
};
let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_of(document, handle)).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
let kind_in = |document: &CadDocument, handle: Handle| match document.get_entity(handle) {
Some(EntityType::Surface(s)) => s.kind, other => panic!("no surface {handle:?}: {other:?}") };
// C: plane surfaces and extrusions from real profile entities.
let script = std::env::temp_dir().join(format!("ocs_surface_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"doc = ocs.active_document\n",
"s = doc.solids\n",
"circle = doc.create_entity('Circle', center=(0, 0, 0), radius=3)\n",
"rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n",
"open_path = doc.create_entity('LwPolyline', vertices=[{'location': {'x': 50.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 4.0}}])\n",
"temp = doc.create_entity('Circle', center=(70, 0, 0), radius=2)\n",
"s.surface(circle)\n",
"s.surface(rect, layer='SURFACES')\n",
"s.extrude(rect, (0, 0, 4))\n",
"s.extrude(circle, (0, 0, 5))\n",
"s.extrude(open_path, (0, 0, 3))\n",
"s.extrude(temp, (0, 0, 2), delete_source=True)\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let surfaces = surfaces_in(host.document());
let solids = solids_in(host.document());
assert_eq!((surfaces.len(), solids.len()), (3, 3), "{}", last(&host));
// surfaces[0] plane of the circle, [1] plane of the rectangle, [2] extruded open path.
assert_eq!((kind_in(host.document(), surfaces[0]), kind_in(host.document(), surfaces[1]), kind_in(host.document(), surfaces[2])),
(SurfaceKind::Plane, SurfaceKind::Plane, SurfaceKind::Generic));
let (low, high) = extent_in(host.document(), surfaces[0]);
assert!(close(low[0], -3.0) && close(high[1], 3.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}");
let (low, high) = extent_in(host.document(), surfaces[2]);
assert!(close(low[0], 50.0) && close(high[0], 54.0) && close(low[2], 0.0) && close(high[2], 3.0), "open path extrusion {low:?} {high:?}");
assert_eq!(host.document().get_entity(surfaces[1]).unwrap().common().layer, "SURFACES");
let pi = std::f64::consts::PI;
for (index, expected, tolerance) in [(0, 240.0, 1e-9), (1, pi * 9.0 * 5.0, 0.02), (2, pi * 4.0 * 2.0, 0.02)] {
let volume = sm::volume(&body_of(host.document(), solids[index]));
assert!((volume - expected).abs() <= tolerance * expected, "solid {index}: {volume} vs {expected}");
}
assert_eq!(host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(), 1,
"delete_source removed only the extruded temporary circle");
assert!(!host.app.tabs[0].scene.wire_models_for(&[surfaces[0]]).is_empty(), "no canvas geometry");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", surfaces[0].value()));
assert!(last(&host).contains("Surface"));
// V: refusals change nothing and leave no undo step.
let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default()));
let circle = host.document().entities().find_map(|e| match e { EntityType::Circle(c) => Some(c.common.handle), _ => None }).unwrap();
let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len());
for (command, message) in [
(format!("ocs.active_document.solids.extrude({}, (0, 0, 0))", circle.value()), "nonzero"),
(format!("ocs.active_document.solids.extrude({}, (float('nan'), 0, 1))", circle.value()), "must be finite"),
(format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", solids[0].value()), "curve profile"),
(format!("ocs.active_document.solids.surface({})", surfaces[0].value()), "curve profile"),
(format!("ocs.active_document.solids.surface({})", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.surface({}, layer='')", circle.value()), "layer name is empty"),
("ocs.active_document.solids.surface(999999)".to_string(), "does not exist"),
("ocs.solid_extrude(1, [1.0], None, False)".to_string(), "3 numbers"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts,
"refusals change nothing and record no undo step");
// E: isoline density is scriptable and validated; kind and payload are not.
let plane = surfaces[0];
let plane_before = host.document().get_entity(plane).unwrap().clone();
let script = std::env::temp_dir().join(format!("ocs_surface_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"e = doc.entities[{}]\n",
"with doc.transaction('Edit surface'):\n",
" e.u_isolines = 8\n",
" e.v_isolines = 6\n",
), plane.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let Some(EntityType::Surface(edited)) = host.document().get_entity(plane) else { unreachable!() };
assert_eq!((edited.u_isolines, edited.v_isolines, edited.kind), (8, 6, SurfaceKind::Plane), "{}", last(&host));
let edited_state = host.document().get_entity(plane).unwrap().clone();
for (patch, message) in [("'u_isolines':500", "between 0 and 200"), ("'surface_kind':'Extruded'", "read-only"),
("'surface_data':{}", "outside the editable schema"), ("'v_isolines':-1", "between 0 and 200")] {
dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Reject surface', [{{'handle':{}, {patch}}}])", plane.value()));
assert_eq!(host.document().get_entity(plane), Some(&edited_state), "{patch}");
assert!(last(&host).contains(message), "{patch}: {}", last(&host));
}
// Moves keep a plane a plane and turn a swept surface generic.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", plane.value()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (0, 0, 7)).handle", surfaces[2].value()));
assert_eq!(kind_in(host.document(), plane), SurfaceKind::Plane);
let (low, high) = extent_in(host.document(), plane);
assert!(close(low[0], 7.0) && close(high[0], 13.0) && close(low[1], 2.0) && close(high[1], 8.0), "{low:?} {high:?}");
let (low, _) = extent_in(host.document(), surfaces[2]);
assert!(close(low[2], 7.0), "{low:?}");
// W: both formats reopen surfaces that lift with the same extents, keep
// the isolines, and a reopened surface moves again.
let dwg = crate::io::load_bytes("surface.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("surface.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let moved = extent_in(host.document(), plane);
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
assert_eq!((surfaces_in(document).len(), solids_in(document).len()), (3, 3), "{format}");
let reopened = extent_in(document, plane);
for axis in 0..3 {
assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent");
}
let Some(EntityType::Surface(back)) = document.get_entity(plane) else { unreachable!() };
assert_eq!((back.u_isolines, back.v_isolines), (8, 6), "{format} isolines");
assert_eq!(back.kind, SurfaceKind::Plane, "{format} keeps the plane kind");
let again = host.add_entity(EntityType::Surface(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit");
}
// D and U.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", surfaces[1].value()));
assert!(host.document().get_entity(surfaces[1]).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(surfaces[1]).is_some(), "undo restores the deleted surface");
// Two re-edits of reopened copies, two moves, and the isoline edit come before.
app.undo_steps(2 + 2 + 1);
assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly");
}
#[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 {