Complete Region: create from a profile, plus tuple and int friendliness

RegionFromProfile reuses the REGION command's kernel calls; doc.solids.region
exposes it with layer and delete_source options. The lifecycle test builds
regions from real circle and polyline entities and covers refusals, a move,
both formats and undo. create_entity now accepts coordinate tuples for point
properties and every generated float setter accepts Python ints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 17:37:10 +02:00
1 parent 04cd9e462d
commit c731d3a9a4
9 files changed
+233 -7

No files matched your search

+5
View File
@@ -85,6 +85,11 @@ pub enum SolidOperation {
/// Apply a rigid transform, given as a column-major 4x4 matrix, to an /// Apply a rigid transform, given as a column-major 4x4 matrix, to an
/// existing solid. Scaling and shear are refused. /// existing solid. Scaling and shear are refused.
Transform { handle: Handle, matrix: [f64; 16] }, Transform { handle: Handle, matrix: [f64; 16] },
/// Build a planar region from one closed planar profile entity (a circle,
/// ellipse, closed polyline or spline, ...). The new region goes on
/// `layer`, or on the source's layer when `None`; `delete_source` erases
/// the profile afterwards, as the REGION command does.
RegionFromProfile { source: Handle, 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.
@@ -114,6 +114,7 @@ mod tests {
layer: Some("SOLIDS".into()), layer: Some("SOLIDS".into()),
}, },
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 },
] { ] {
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. Booleans, Region creation 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`). Booleans and Surface remain.
+4 -3
View File
@@ -7,9 +7,9 @@ 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: 37 v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 38
are creatable (the legacy `Polyline`, `Ole2Frame`, `Body` and `Region` 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 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. 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.
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
@@ -178,3 +178,4 @@ exact queue above and update statuses with evidence after each increment.
- 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. - 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`.
+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** (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`. 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`.
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,7 +44,7 @@ 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 | **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. | | 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 | **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). |
+4
View File
@@ -406,6 +406,10 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String
/// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult<T>` expression. /// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult<T>` expression.
fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String {
if type_id == "f64" {
// RustPython's f64 conversion rejects ints; scripts write `radius=5`.
return format!("py_number_to_f64({value_expr}, vm)");
}
if type_id == "u64" { if type_id == "u64" {
// serde reports Rust `usize` as `u64`; the inferred cast serves both. // serde reports Rust `usize` as `u64`; the inferred cast serves both.
return format!("{value_expr}.try_into_value::<u64>(vm).map(|n| n as _)"); return format!("{value_expr}.try_into_value::<u64>(vm).map(|n| n as _)");
+30 -1
View File
@@ -1,5 +1,27 @@
"""Thin Python view over OCS host API v7 entity transactions and coverage.""" """Thin Python view over OCS host API v7 entity transactions and coverage."""
def _coordinates(value, width):
return dict(zip("xyz"[:width], (float(axis) for axis in value)))
def _coerce_points(kind, properties):
"""Let create_entity take (x, y, z) tuples for point properties, as
transaction edits already do, by using the property types in the schema."""
schema = ocs.entity_coverage(kind)
types = {row["name"]: row for row in schema["properties"]} if schema else {}
result = {}
for name, value in properties.items():
row = types.get(name)
if row is not None and row["type"] in ("Vector2", "Vector3"):
width = 2 if row["type"] == "Vector2" else 3
if row["sequence"]:
value = [_coordinates(item, width) if isinstance(item, (tuple, list)) else item for item in value]
elif isinstance(value, (tuple, list)):
value = _coordinates(value, width)
result[name] = value
return result
class _Entity: class _Entity:
def __init__(self, document, handle): def __init__(self, document, handle):
object.__setattr__(self, "_document", document) object.__setattr__(self, "_document", document)
@@ -117,6 +139,13 @@ class _Solids:
def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None): 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) return self._create("pyramid", tuple(center) + (radius, height, sides), layer)
def region(self, profile, layer=None, delete_source=False):
"""Make a planar region from one closed planar profile (a circle,
ellipse, closed polyline or spline). Keeps the profile unless
`delete_source` is true, and uses the profile's layer by default."""
handle = profile.handle if isinstance(profile, _Entity) else int(profile)
return self._document.entities[ocs.solid_region(handle, 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)
@@ -151,7 +180,7 @@ class _Document:
"""Create one mapped entity and return its live document descriptor.""" """Create one mapped entity and return its live document descriptor."""
if "kind" in properties or "handle" in properties: if "kind" in properties or "handle" in properties:
raise ValueError("kind and handle are managed by create_entity") raise ValueError("kind and handle are managed by create_entity")
handle = ocs.add(dict(kind=kind, **properties)) handle = ocs.add(dict(kind=kind, **_coerce_points(kind, properties)))
return self.entities[handle] return self.entities[handle]
def delete_entity(self, entity): def delete_entity(self, entity):
+17
View File
@@ -458,6 +458,23 @@ mod ocs {
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}"))) .map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}")))
} }
/// Build a planar region from a closed planar profile entity.
#[pyfunction]
fn solid_region(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::RegionFromProfile {
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_region: {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> {
+169
View File
@@ -620,6 +620,47 @@ impl<'a> HostSession<'a> {
} }
Ok(handle) Ok(handle)
} }
SolidOperation::RegionFromProfile { source, layer, delete_source } => {
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 matches!(entity, EntityType::Region(_)) {
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 {
return Err("the profile must be closed".into());
}
let body = cadkernel::brep::planar_region(plane, &loops)
.ok_or("the geometry kernel could not build a region from this profile")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the region could not be exported losslessly")?;
let mut region = acadrust::entities::Region::new();
region.point_of_reference =
acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
region.wires = model::edge_wires(&body);
region.set_sat_document(&sat);
region.common.layer = layer.unwrap_or_else(|| entity.common().layer.clone());
self.push_undo("Create region");
let handle = self.add_entity(EntityType::Region(region));
if handle.is_null() {
return Err("the region could not be added".into());
}
if delete_source {
self.app.tabs[self.tab].scene.erase_entities(&[source]);
self.publish_document_view();
}
Ok(handle)
}
SolidOperation::Transform { handle, matrix } => { SolidOperation::Transform { handle, matrix } => {
if matrix.iter().any(|v| !v.is_finite()) { if matrix.iter().any(|v| !v.is_finite()) {
return Err("transform matrix must be finite".into()); return Err("transform matrix must be finite".into());
@@ -4902,6 +4943,134 @@ mod tests {
assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text); assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text);
} }
#[test]
fn audit_python_region_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_acis_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 regions = |host: &HostSession<'_>| -> Vec<Handle> {
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Region(r) => Some(r.common.handle), _ => None }).collect();
list.sort_by_key(|h| h.value());
list
};
let extent_in = |document: &CadDocument, handle: Handle| {
let Some(EntityType::Region(region)) = document.get_entity(handle) else { panic!("no region {handle:?}") };
let body = kernel_acis_body(&region.acis_data).expect("region payload lifts losslessly");
sm::extent(&body).unwrap()
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
// C: regions from a circle (source kept), a closed rectangle (source
// deleted) and a second circle on its own layer.
let script = std::env::temp_dir().join(format!("ocs_region_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"doc = ocs.active_document\n",
"circle = doc.create_entity('Circle', center=(0, 0, 0), radius=5)\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",
"doc.solids.region(circle)\n",
"doc.solids.region(rect, delete_source=True)\n",
"other = doc.create_entity('Circle', center=(50, 0, 0), radius=2, layer='PROFILES')\n",
"doc.solids.region(other, layer='REGIONS')\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let list = regions(&host);
assert_eq!(list.len(), 3, "{}", last(&host));
let (low, high) = extent_in(host.document(), list[0]);
assert!(close(low[0], -5.0) && close(high[0], 5.0) && close(low[1], -5.0) && close(high[1], 5.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}");
let (low, high) = extent_in(host.document(), list[1]);
assert!(close(low[0], 20.0) && close(high[0], 30.0) && close(high[1], 6.0), "{low:?} {high:?}");
let layer_of = |host: &HostSession<'_>, handle: Handle| host.document().get_entity(handle).unwrap().common().layer.clone();
assert_eq!(layer_of(&host, list[0]), "0", "defaults to the profile's layer");
assert_eq!(layer_of(&host, list[2]), "REGIONS");
let circles = host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count();
let polylines = host.document().entities().filter(|e| matches!(e, EntityType::LwPolyline(_))).count();
assert_eq!((circles, polylines), (2, 0), "delete_source removed only the rectangle");
let Some(EntityType::Region(first)) = host.document().get_entity(list[0]) else { unreachable!() };
assert_eq!((first.wires.is_empty(), first.point_of_reference.z), (false, 0.0));
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[0].value()));
assert!(last(&host).contains("Region"));
// V: refusals change nothing and leave no undo step.
let open_line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(5.0, 0.0, 0.0))));
let mut open_poly = acadrust::entities::LwPolyline::new();
open_poly.vertices = vec![
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 4.0)),
];
let open_poly = host.add_entity(EntityType::LwPolyline(open_poly));
let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default()));
let entity_count = host.document().entities().count();
let undo_now = host.app.tabs[0].history.undo_stack.len();
for (command, message) in [
(format!("ocs.active_document.solids.region({})", open_line.value()), "must be closed"),
(format!("ocs.active_document.solids.region({})", open_poly.value()), "must be closed"),
(format!("ocs.active_document.solids.region({})", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.region({})", list[0].value()), "already a region"),
("ocs.active_document.solids.region(999999)".to_string(), "does not exist"),
(format!("ocs.active_document.solids.region({}, layer='')", open_line.value()), "layer name is empty"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!(host.document().entities().count(), entity_count);
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
// E: a region moves through the same kernel channel.
let before = host.document().get_entity(list[0]).unwrap().clone();
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", list[0].value()));
let (low, high) = extent_in(host.document(), list[0]);
assert!(close(low[0], 5.0) && close(high[0], 15.0) && close(low[1], 0.0) && close(high[1], 10.0), "{low:?} {high:?}");
// W: both formats reopen a region that lifts with the same extent, and
// a reopened region moves again.
let dwg = crate::io::load_bytes("region.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("region.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let moved = extent_in(host.document(), list[0]);
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
assert_eq!(regions_in(document).len(), 3, "{format}");
let reopened = extent_in(document, list[0]);
for axis in 0..3 {
assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent {reopened:?} vs {moved:?}");
}
let Some(EntityType::Region(back)) = document.get_entity(list[0]) else { unreachable!() };
let again = host.add_entity(EntityType::Region(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");
}
fn regions_in(document: &CadDocument) -> Vec<Handle> {
document.entities().filter_map(|e| match e { EntityType::Region(r) => Some(r.common.handle), _ => None }).collect()
}
// D and U: delete, undo, and undo the creation steps exactly.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", list[2].value()));
assert!(host.document().get_entity(list[2]).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(list[2]).is_some(), "undo restores the deleted region");
// Two re-edits of reopened copies, then the move of the first region.
app.undo_steps(3);
assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region 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 {