forked from 1b-agent/opencadstudio-upstream
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:
1 parent
918351f264
commit
9cd6653a58
14 files changed
+441
-34
No files matched your search
@@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
| ("Helix", _)
|
||||
| ("RasterImage", _)
|
||||
| ("Wipeout", _)
|
||||
| ("Surface", "u_isolines" | "v_isolines")
|
||||
| ("Ole2Frame", _)
|
||||
| ("Underlay", _)
|
||||
| ("Viewport", _)
|
||||
|
||||
@@ -502,6 +502,11 @@
|
||||
],
|
||||
"Region": [
|
||||
"layer"
|
||||
],
|
||||
"Surface": [
|
||||
"layer",
|
||||
"u_isolines",
|
||||
"v_isolines"
|
||||
]
|
||||
},
|
||||
"readable": {
|
||||
@@ -627,6 +632,12 @@
|
||||
"uid",
|
||||
"point_of_reference",
|
||||
"history_handle"
|
||||
],
|
||||
"Surface": [
|
||||
"surface_kind",
|
||||
"modeler_format_version",
|
||||
"point_of_reference",
|
||||
"history_handle"
|
||||
]
|
||||
},
|
||||
"aliases": {
|
||||
@@ -643,6 +654,9 @@
|
||||
"MText": {
|
||||
"text": "value",
|
||||
"insertion": "insertion_point"
|
||||
},
|
||||
"Surface": {
|
||||
"surface_kind": "kind"
|
||||
}
|
||||
},
|
||||
"synthetic": {
|
||||
|
||||
@@ -1245,6 +1245,24 @@ fn validate_ole_frame(
|
||||
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
|
||||
/// host checks. Called by the Python add path before an entity enters the document.
|
||||
#[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::Surface(value) => validate_surface(None, value)?,
|
||||
EntityType::AttributeDefinition(value) => {
|
||||
finite_vector(
|
||||
"AttributeDefinition.insertion_point",
|
||||
@@ -1745,6 +1764,7 @@ pub fn validate_entity_mutation(
|
||||
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::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), 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]
|
||||
fn unsupported_kinds_have_no_editable_properties() {
|
||||
let catalog: EntityCoverageCatalog =
|
||||
@@ -2535,6 +2569,7 @@ mod tests {
|
||||
| "Solid3D"
|
||||
| "Body"
|
||||
| "Region"
|
||||
| "Surface"
|
||||
)
|
||||
{
|
||||
assert!(
|
||||
@@ -2824,6 +2859,8 @@ mod tests {
|
||||
"Ole2Frame.lower_right_corner".to_owned(),
|
||||
"Ole2Frame.lock_aspect".to_owned(),
|
||||
"Ole2Frame.is_paper_space".to_owned(),
|
||||
"Surface.u_isolines".to_owned(),
|
||||
"Surface.v_isolines".to_owned(),
|
||||
"Underlay.underlay_type".to_owned(),
|
||||
"Underlay.definition_handle".to_owned(),
|
||||
"Underlay.insertion_point".to_owned(),
|
||||
|
||||
@@ -90,6 +90,11 @@ pub enum SolidOperation {
|
||||
/// `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 },
|
||||
/// 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.
|
||||
|
||||
@@ -115,6 +115,8 @@ mod tests {
|
||||
},
|
||||
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
|
||||
SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true },
|
||||
SolidOperation::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();
|
||||
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
@@ -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
|
||||
`a8dd3640` plus the Leader increment, published as draft PR
|
||||
[#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
|
||||
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
|
||||
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
|
||||
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
|
||||
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.
|
||||
- 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 `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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
After each increment, update the row with test names, fixture paths, exact
|
||||
|
||||
@@ -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
|
||||
the same undoable transaction; internal and opaque variants remain read-only
|
||||
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
|
||||
checks remain outstanding.
|
||||
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.
|
||||
|
||||
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
|
||||
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
|
||||
covered kinds, the coverage object lists actual readable and editable
|
||||
|
||||
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
|
||||
traced field, including fields marked `unmapped`. Query it with
|
||||
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
|
||||
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
|
||||
CAD fields, including `snapshot["common"]` and kind-specific nested values.
|
||||
It can inspect an unmapped kind; editing the dictionary does
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"Ole2Frame",
|
||||
"Solid3D",
|
||||
"Body",
|
||||
"Region"
|
||||
"Region",
|
||||
"Surface"
|
||||
],
|
||||
"manual_kinds": [
|
||||
"Dimension"
|
||||
@@ -891,6 +892,40 @@
|
||||
"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)
|
||||
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):
|
||||
"""Apply a column-major 4x4 rigid transform (16 numbers) in place."""
|
||||
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
|
||||
|
||||
@@ -475,6 +475,50 @@ mod ocs {
|
||||
.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.
|
||||
#[pyfunction]
|
||||
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")]
|
||||
|
||||
+279
-23
@@ -621,22 +621,10 @@ impl<'a> HostSession<'a> {
|
||||
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"))?;
|
||||
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 && 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());
|
||||
}
|
||||
@@ -649,17 +637,60 @@ impl<'a> HostSession<'a> {
|
||||
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());
|
||||
region.common.layer = layer;
|
||||
self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region")
|
||||
}
|
||||
SolidOperation::SurfaceFromProfile { source, layer, delete_source } => {
|
||||
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 {
|
||||
self.app.tabs[self.tab].scene.erase_entities(&[source]);
|
||||
self.publish_document_view();
|
||||
if !closed {
|
||||
return Err("the profile must be closed".into());
|
||||
}
|
||||
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 } => {
|
||||
if matrix.iter().any(|v| !v.is_finite()) {
|
||||
@@ -705,7 +736,25 @@ impl<'a> HostSession<'a> {
|
||||
EntityType::Solid3D(value) => retarget!(value),
|
||||
EntityType::Body(value) => retarget!(value),
|
||||
EntityType::Region(value) => retarget!(value),
|
||||
_ => return Err("transform applies to Solid3D, Body and Region entities".into()),
|
||||
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");
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn staged_python_point_pick_and_cancel_over_real_ipc() {
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
|
||||
Reference in new issue
Block a user