From eb22de636b1346362ac32bf3ccef9730128f96f3 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 16:14:02 +0200 Subject: [PATCH] Decide Insert and Polyline: Insert creatable by block name, Polyline update-only Insert can be created by naming an existing ordinary block; the host refuses unknown blocks, model/paper space and containment cycles, and keeps legacy inserts of missing blocks editable. The legacy Polyline is update-only with a hint toward Polyline2D and Polyline3D because both writers convert it on save. The generator gains update_only_message. Co-Authored-By: Claude Sonnet 5 --- .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 70 +- docs/plugin-host-model-completion-plan.md | 5 +- docs/plugin-host-model-coverage-ledger.md | 6 +- plugins/opencad-python/build/generate.rs | 11 +- plugins/opencad-python/entity_manifest.json | 715 +++++++++++++----- plugins/opencad-python/src/ocs_module.rs | 40 +- src/app/plugin_host.rs | 84 +- 8 files changed, 734 insertions(+), 201 deletions(-) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 3169a9874..549f1d767 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -175,6 +175,7 @@ ], "Insert": [ "layer", + "block_name", "insert_point", "x_scale", "y_scale", @@ -488,9 +489,6 @@ ] }, "readable": { - "Insert": [ - "block_name" - ], "Tolerance": [ "dimension_style_handle" ], diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 54f9d1430..fe30e534a 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1330,6 +1330,24 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Viewport(value) => validate_viewport(None, value)?, EntityType::ViewBorder(value) => validate_view_border(None, value)?, EntityType::Light(value) => validate_light(None, value)?, + EntityType::Insert(value) => { + if value.block_name.trim().is_empty() { + return Err("Insert.block_name is empty".into()); + } + finite_vector("Insert.insert_point", &value.insert_point)?; + solid_normal("Insert.normal", &value.normal)?; + for (name, scale) in [("x_scale", value.x_scale()), ("y_scale", value.y_scale()), ("z_scale", value.z_scale())] { + insert_scale(&format!("Insert.{name}"), scale)?; + } + for (name, number) in [("rotation", value.rotation), ("column_spacing", value.column_spacing), ("row_spacing", value.row_spacing)] { + if !number.is_finite() { + return Err(format!("Insert.{name} must be finite")); + } + } + if value.column_count == 0 || value.row_count == 0 { + return Err("Insert array counts must be greater than zero".into()); + } + } EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1961,6 +1979,53 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + // An edit that keeps the block name is left alone so a legacy insert of a + // missing block stays editable; creation and renames must name a real, + // ordinary block that does not create a cycle. + let insert_is_unchanged_edit = |value: &crate::host::acadrust::entities::Insert| { + matches!(document.get_entity(value.common.handle), + Some(EntityType::Insert(existing)) if existing.block_name == value.block_name) + }; + if let Some(value) = match entity { + EntityType::Insert(value) if !insert_is_unchanged_edit(value) => Some(value), + _ => None, + } { + let name = value.block_name.trim(); + let record = document.block_records.get(name).ok_or_else(|| { + format!("Insert block {:?} does not exist", value.block_name) + })?; + if record.is_model_space() || record.is_paper_space() { + return Err("Insert cannot reference a model-space or paper-space block".into()); + } + // A block cannot (transitively) contain an insert of itself. + let owner_name = document + .block_records + .iter() + .find(|candidate| candidate.handle == value.common.owner_handle) + .map(|candidate| candidate.name.clone()); + if let Some(owner_name) = owner_name { + let mut pending = vec![record.name.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(current) = pending.pop() { + if current.eq_ignore_ascii_case(&owner_name) { + return Err(format!( + "inserting block {:?} into {owner_name:?} would make the block contain itself", + value.block_name + )); + } + if !seen.insert(current.to_ascii_lowercase()) { + continue; + } + if let Some(block) = document.block_records.get(¤t) { + for handle in &block.entity_handles { + if let Some(EntityType::Insert(nested)) = document.get_entity(*handle) { + pending.push(nested.block_name.clone()); + } + } + } + } + } + } if let EntityType::ViewBorder(value) = entity { if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); @@ -2984,8 +3049,9 @@ mod tests { .iter() .find(|property| property.name == "block_name") .unwrap(); - assert_eq!(block_name.model_access, ModelAccess::ReadOnly); - assert_eq!(block_name.validation, "none"); + // Named at creation; a geometry transaction can never change it (the + // mutation check rejects a different block). + assert_eq!(block_name.model_access, ModelAccess::ReadWrite); assert!( insert .properties diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 9362f43e5..fc74152dd 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -8,8 +8,8 @@ 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 **37 of 43** canvas kinds: 36 -are creatable and `Insert` remains update-only. This is **not** 37 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; the audited Polyline, Polyline2D, Polyline3D, Spline and Insert pass every gate that their format or update-only status allows. +are creatable (the legacy `Polyline` is update-only) and `Insert` remains update-only. This is **not** 37 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D, Polyline3D and Spline 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 @@ -173,3 +173,4 @@ exact queue above and update statuses with evidence after each increment. - Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. - Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. - Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. +- Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index be6a96235..931abf8b9 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -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: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **24 kinds are complete**, and 6 remain layer-only (one of them, 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`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **25 kinds are complete**, and 6 remain layer-only (one of them, 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. @@ -11,7 +11,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Circle | **Complete** | Exercised: `radius`, `center`. Rejects a non-positive or non-finite radius, non-finite center and thickness, and a zero normal. `audit_python_circle_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Arc | **Complete** | Exercised: `end_angle`, `radius`. Rejects a non-positive radius, non-finite angles (previously accepted), center and thickness, and a zero normal. `audit_python_arc_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Ellipse | **Complete** | Exercised: `minor_axis_ratio`, `end_parameter`. The audit found Ellipse had no validation at all; it now requires a nonzero major axis, a ratio within (0, 1], finite parameters and a nonzero normal. `audit_python_ellipse_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Polyline | **Integration-tested / kind changes on save** | Exercised: `vertices`, `closed`. The legacy `Polyline` (3-D vertices) passes create, edit, reject, undo and canvas gates, and its vertices and closed state survive, but both writers save it as a 3-D polyline, so it reopens from DWG and DXF as `Polyline3D`. Pinned by per-format canary expectations. Decide whether to keep `Polyline` scriptable or steer scripts to `Polyline3D`. `audit_python_polyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline | **Update-only (decision)** | Decision (2026-09-20): the legacy `Polyline` (3-D vertices) is not creatable from Python, because both file formats save it as `Polyline3D` and it would silently change kind on the first save. Creating one now fails with a message that names `Polyline2D` and `Polyline3D`. Existing `Polyline` entities remain editable: `audit_python_polyline_lifecycle_over_real_ipc` (host-made fixture) passes E/D/U/I/V with vertex and closed edits, canvas wires, rejection of short and non-finite vertex lists, an atomic rollback and three-step undo/redo, and pins the reopen behavior (both DWG and DXF return a `Polyline3D` with the vertices and closure intact). Scripts should create `Polyline2D` or `Polyline3D`. | | Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | @@ -22,7 +22,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | XLine | **Complete** | Exercised: `base_point`, `direction`. Same as Ray. `audit_python_xline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Solid | **Complete** | Exercised: `fourth_corner`, `thickness`. Finite corners, thickness and normal validation already existed; the audit added the full gate. `audit_python_solid_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Face3D | **Complete** | Exercised: `third_corner`, `invisible_edges`. Finite corners and known edge bits validation already existed; the audit added the full gate. `audit_python_face3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Insert | **Update-only (all gates pass)** | Exercised: `insert_point`, `x_scale`, `y_scale`, `rotation`, `column_count`, `column_spacing`. `audit_python_insert_lifecycle_over_real_ipc` passes E/D/U/I/W/V/P with a host-made block and insert: a six-property transaction edit, canvas wires, rejection of a zero scale, non-finite rotation and point, a zero array count, a zero normal and a `block_name` change (read-only), an atomic rollback, DWG and DXF reopen with re-edit, and undo/redo. Not `Complete` because Python cannot create an Insert (creation and block-reference semantics are an open decision: permanent update-only, or a create path that names an existing block). | +| Insert | **Complete** | Decision (2026-09-20): Insert is creatable. A script names an existing block with `block_name` and a placement; the host does not generate attribute records, because attributes stay with the `AttributeEntity` flow that already creates them from the block's ATTDEFs. Writable: `block_name` (fixed after creation; the mutation check refuses a different block), `insert_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `column_count`, `row_count`, `column_spacing`, `row_spacing`. Creation and rename rules: the block must exist, must not be model or paper space, and must not make a block contain itself directly or through nested inserts; scales must be finite and nonzero, array counts positive, and the name non-empty. An edit that keeps the block name is exempt from the block checks so a legacy insert of a missing block stays editable. `audit_python_insert_lifecycle_over_real_ipc` (shared driver, host-made block) passes C/R/E/D/U/I/W/V/P: creation by block name, a six-property transaction edit, canvas wires, rejection of zero scale, non-finite rotation and point, zero array count, zero normal and an unknown block, an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_insert_creation_rules_over_real_ipc` covers unknown block, model-space block, bad scale, zero count, empty name, direct self-insertion and indirect nesting cycles. Not covered: MINSERT (`dwg_minsert`) and attached-attribute creation, which stay excluded and preserved. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / blocked at W by acadrust (DXF ATTDEF reader)** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. Two staged tests cover it. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes with a real block record, Block/BlockEnd markers and a linked Insert. The 2026-09-20 audit `audit_python_attribute_definition_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, host-made block) compares **every writable field** through both formats: creation with non-default tag, prompt, value, placement, alignment point, height, rotation, width factor, oblique angle, alignments, all attribute flags, field length, generation flags and lock; a nine-property transaction edit; canvas wires; eight rejection cases (whitespace tag, non-positive height and width factor, missing style, owner change, zero normal, non-finite rotation, unknown alignment) plus an atomic rollback; DWG reopen with the **complete** edited state and re-edit; deletion; and three-step undo/redo. **W blocker, revalidated on acadrust `5b682ed`:** the DXF writer emits every ATTDEF group, but the reader handles only groups 1, 2, 3, 10, 40, 50, 280 and 101, so a DXF reopen keeps tag, prompt, value, insertion point, height and rotation and resets the alignment point, both alignments, width factor, oblique angle, all attribute flags (invisible, constant, verify, preset), field length, generation flags, lock and text style to defaults. This is now pinned by canary expectations for both the edit and the re-edit. Not `Complete` until the reader is extended; the fix belongs in cadcodec's `read_attdef`. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 219747fee..c07b9c39d 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -55,6 +55,9 @@ pub struct EntityOverride { /// create one without host-side reference setup. #[serde(default)] pub update_only: bool, + /// Message returned when a script tries to create an update-only kind. + #[serde(default)] + pub update_only_message: Option, #[serde(default)] pub fields: std::collections::BTreeMap, } @@ -874,7 +877,13 @@ pub(crate) fn entity_to_dict(vm: &VirtualMachine, entity: &acadrust::EntityType) fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { - if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) { + if let Some(override_def) = manifest.overrides.get(kind).filter(|o| o.update_only) { + let message = override_def.update_only_message.clone().unwrap_or_else(|| { + format!("{kind} cannot be created through the generic API; only existing {kind} entities can be edited") + }); + arms.push_str(&format!( + " \"{kind}\" => {{ return Err(vm.new_value_error({message:?}.to_owned())); }}\n" + )); continue; } if manifest.manual_kinds.contains(kind) { diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 6f0e0f87a..5126e4464 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -38,54 +38,90 @@ "ViewBorder", "Light" ], - "manual_kinds": ["Dimension"], - "base_fields": ["handle", "layer", "owner_handle"], + "manual_kinds": [ + "Dimension" + ], + "base_fields": [ + "handle", + "layer", + "owner_handle" + ], "overrides": { "Point": { "fields": { - "location": { "required": true } + "location": { + "required": true + } } }, "Line": { "fields": { - "start": { "required": true }, - "end": { "required": true } + "start": { + "required": true + }, + "end": { + "required": true + } } }, "Circle": { "fields": { - "center": { "required": true }, - "radius": { "required": true } + "center": { + "required": true + }, + "radius": { + "required": true + } } }, "Arc": { "fields": { - "center": { "required": true }, - "radius": { "required": true }, - "start_angle": { "required": true }, - "end_angle": { "required": true } + "center": { + "required": true + }, + "radius": { + "required": true + }, + "start_angle": { + "required": true + }, + "end_angle": { + "required": true + } } }, "Ellipse": { "fields": { - "center": { "required": true }, - "major_axis": { "required": true }, - "minor_axis_ratio": { "required": true } + "center": { + "required": true + }, + "major_axis": { + "required": true + }, + "minor_axis_ratio": { + "required": true + } } }, "Polyline": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "python_name": "closed", "rust_getter": "VAR.flags.is_closed()", "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" } - } + }, + "update_only": true, + "update_only_message": "Polyline is the legacy 3-D-vertex polyline and both file formats save it as Polyline3D; create a Polyline2D or Polyline3D instead (existing Polyline entities can still be edited)" }, "Polyline2D": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "python_name": "closed", "rust_getter": "VAR.flags.is_closed()", @@ -95,121 +131,200 @@ }, "Polyline3D": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "smooth_type": { "exclude": true, - "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface — and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." + "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface \u2014 and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." } } }, "LwPolyline": { "fields": { - "vertices": { "required": true } + "vertices": { + "required": true + } } }, "Spline": { "fields": { - "control_points": { "required": true } + "control_points": { + "required": true + } } }, "Text": { "fields": { - "value": { "python_name": "text", "required": true }, - "insertion_point": { "python_name": "insertion", "required": true } + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + } } }, "MText": { "fields": { - "value": { "python_name": "text", "required": true }, - "insertion_point": { "python_name": "insertion", "required": true }, + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + }, "background_color": { "exclude": true, - "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) — this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." + "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) \u2014 this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." } } }, "Ray": { "fields": { - "base_point": { "required": true }, - "direction": { "required": true } + "base_point": { + "required": true + }, + "direction": { + "required": true + } } }, "XLine": { "fields": { - "base_point": { "required": true }, - "direction": { "required": true } + "base_point": { + "required": true + }, + "direction": { + "required": true + } } }, "Solid": { "rust_constructor": "acadrust::entities::Solid::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", "fields": { - "first_corner": { "required": true }, - "second_corner": { "required": true }, - "third_corner": { "required": true }, - "fourth_corner": { "required": true } + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } } }, "Face3D": { "rust_constructor": "acadrust::entities::Face3D::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", "fields": { - "first_corner": { "required": true }, - "second_corner": { "required": true }, - "third_corner": { "required": true }, - "fourth_corner": { "required": true } + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } } }, "Insert": { - "update_only": true, "fields": { - "block_name": { "read_only": true }, + "block_name": { + "required": true + }, "x_scale": { "rust_getter": "VAR.x_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" }, "y_scale": { "rust_getter": "VAR.y_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" }, "z_scale": { "rust_getter": "VAR.z_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" }, - "dwg_minsert": { "exclude": true }, - "attributes": { "exclude": true }, - "view_rep_handle": { "exclude": true }, - "seqend_handle": { "exclude": true } - } + "dwg_minsert": { + "exclude": true + }, + "attributes": { + "exclude": true + }, + "view_rep_handle": { + "exclude": true + }, + "seqend_handle": { + "exclude": true + }, + "insert_point": { + "required": true + } + }, + "rust_constructor": "acadrust::entities::Insert::new(String::new(), acadrust::types::Vector3::ZERO)" }, "Tolerance": { "fields": { - "insertion_point": { "required": true }, - "text": { "required": true }, + "insertion_point": { + "required": true + }, + "text": { + "required": true + }, "dimension_style_name": { "rust_getter": "VAR.dimension_style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"dimension_style_name\", vm)? { VAR.dimension_style_name = v.try_into_value::(vm)?; VAR.dimension_style_handle = None; }" }, - "dimension_style_handle": { "read_only": true }, - "dwg_unknown_short": { "exclude": true } + "dimension_style_handle": { + "read_only": true + }, + "dwg_unknown_short": { + "exclude": true + } } }, "Shape": { "fields": { - "insertion_point": { "required": true }, - "size": { "required": true }, - "shape_name": { "required": true }, - "shape_number": { "required": true }, + "insertion_point": { + "required": true + }, + "size": { + "required": true + }, + "shape_name": { + "required": true + }, + "shape_number": { + "required": true + }, "style_name": { "rust_getter": "VAR.style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" }, - "style_handle": { "read_only": true } + "style_handle": { + "read_only": true + } } }, "AttributeDefinition": { "fields": { - "tag": { "required": true }, - "insertion_point": { "required": true }, - "height": { "required": true }, + "tag": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, "embedded_mtext": { "exclude": true, "_comment": "Embedded R2018+ MTEXT carries linked layout state. It remains snapshot-readable but is preserved untouched by Python edits until linked-payload mutation has dedicated validation." @@ -218,11 +333,21 @@ }, "AttributeEntity": { "fields": { - "tag": { "required": true }, - "value": { "required": true }, - "insertion_point": { "required": true }, - "height": { "required": true }, - "attdef_handle": { "read_only": true }, + "tag": { + "required": true + }, + "value": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, + "attdef_handle": { + "read_only": true + }, "embedded_mtext": { "exclude": true, "_comment": "The embedded R2018+ MTEXT payload remains snapshot-readable and is preserved by partial edits." @@ -231,7 +356,9 @@ }, "Hatch": { "fields": { - "paths": { "required": true }, + "paths": { + "required": true + }, "is_mpolygon": { "exclude": true, "_comment": "MPOLYGON is a distinct class-backed record and is preserved without exposing Hatch creation semantics." @@ -240,21 +367,35 @@ "exclude": true, "_comment": "Gradient payload mutation requires a separate validated model; partial Hatch edits preserve it." }, - "mpolygon_hatch_color": { "exclude": true }, - "mpolygon_x_direction": { "exclude": true }, - "mpolygon_boundary_handle_count": { "exclude": true } + "mpolygon_hatch_color": { + "exclude": true + }, + "mpolygon_x_direction": { + "exclude": true + }, + "mpolygon_boundary_handle_count": { + "exclude": true + } } }, "MLine": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "rust_getter": "(VAR.flags.bits() as i64)", "rust_setter": "if let Some(v) = dict.get_item_opt(\"flags\", vm)? { let bits = v.try_into_value::(vm)?; VAR.flags = i16::try_from(bits).ok().and_then(acadrust::entities::MLineFlags::from_bits).ok_or_else(|| vm.new_value_error(\"MLine.flags has unknown bits\".to_owned()))?; }" }, - "start_point": { "read_only": true }, - "style_handle": { "read_only": true }, - "style_element_count": { "read_only": true }, + "start_point": { + "read_only": true + }, + "style_handle": { + "read_only": true + }, + "style_element_count": { + "read_only": true + }, "style_name": { "rust_getter": "VAR.style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" @@ -263,28 +404,67 @@ }, "Leader": { "fields": { - "vertices": { "required": true }, - "origin": { "read_only": true }, - "dimension_gap": { "read_only": true }, - "arrowhead_type": { "read_only": true }, - "arrow_size": { "read_only": true }, - "byblock_color": { "read_only": true }, - "dwg_unknown_bit1": { "exclude": true }, - "dwg_unknown_bit2": { "exclude": true }, - "dwg_unknown_bit3": { "exclude": true }, - "dwg_unknown_bit4": { "exclude": true }, - "dwg_unknown_bit5": { "exclude": true }, - "dwg_unknown_short1": { "exclude": true } + "vertices": { + "required": true + }, + "origin": { + "read_only": true + }, + "dimension_gap": { + "read_only": true + }, + "arrowhead_type": { + "read_only": true + }, + "arrow_size": { + "read_only": true + }, + "byblock_color": { + "read_only": true + }, + "dwg_unknown_bit1": { + "exclude": true + }, + "dwg_unknown_bit2": { + "exclude": true + }, + "dwg_unknown_bit3": { + "exclude": true + }, + "dwg_unknown_bit4": { + "exclude": true + }, + "dwg_unknown_bit5": { + "exclude": true + }, + "dwg_unknown_short1": { + "exclude": true + } } }, "MultiLeader": { "fields": { - "arrowhead_overrides": { "exclude": true, "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." }, - "block_attributes": { "exclude": true, "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." }, - "dwg_version": { "read_only": true }, - "text_height": { "read_only": true, "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." }, - "property_override_flags": { "read_only": true }, - "text_align_in_ipe": { "read_only": true }, + "arrowhead_overrides": { + "exclude": true, + "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." + }, + "block_attributes": { + "exclude": true, + "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." + }, + "dwg_version": { + "read_only": true + }, + "text_height": { + "read_only": true, + "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." + }, + "property_override_flags": { + "read_only": true + }, + "text_align_in_ipe": { + "read_only": true + }, "context": { "keep_types": true, "rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?", @@ -294,132 +474,311 @@ }, "Table": { "fields": { - "insertion_point": { "required": true }, - "block_name": { "read_only": true }, - "block_record_handle": { "read_only": true }, - "data_version": { "read_only": true }, - "value_flags": { "read_only": true }, - "override_flag": { "read_only": true }, - "override_border_color": { "read_only": true }, - "override_border_line_weight": { "read_only": true }, - "override_border_visibility": { "read_only": true }, - "legacy_style_override": { "read_only": true }, - "name": { "read_only": true }, - "description": { "read_only": true }, - "field_handles": { "read_only": true }, - "base_style": { "read_only": true }, - "break_data": { "read_only": true }, - "break_ranges": { "read_only": true }, - "dwg_unknown_byte": { "exclude": true }, - "dwg_unknown_handle": { "exclude": true }, - "dwg_unknown_long1": { "exclude": true }, - "dwg_r2010_unknown_bit": { "exclude": true }, - "dwg_unknown_long2": { "exclude": true }, - "dwg_unknown_short": { "exclude": true }, - "legacy_border_colors": { "exclude": true, "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." }, - "legacy_border_line_weights": { "exclude": true }, - "legacy_border_visibility": { "exclude": true } + "insertion_point": { + "required": true + }, + "block_name": { + "read_only": true + }, + "block_record_handle": { + "read_only": true + }, + "data_version": { + "read_only": true + }, + "value_flags": { + "read_only": true + }, + "override_flag": { + "read_only": true + }, + "override_border_color": { + "read_only": true + }, + "override_border_line_weight": { + "read_only": true + }, + "override_border_visibility": { + "read_only": true + }, + "legacy_style_override": { + "read_only": true + }, + "name": { + "read_only": true + }, + "description": { + "read_only": true + }, + "field_handles": { + "read_only": true + }, + "base_style": { + "read_only": true + }, + "break_data": { + "read_only": true + }, + "break_ranges": { + "read_only": true + }, + "dwg_unknown_byte": { + "exclude": true + }, + "dwg_unknown_handle": { + "exclude": true + }, + "dwg_unknown_long1": { + "exclude": true + }, + "dwg_r2010_unknown_bit": { + "exclude": true + }, + "dwg_unknown_long2": { + "exclude": true + }, + "dwg_unknown_short": { + "exclude": true + }, + "legacy_border_colors": { + "exclude": true, + "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." + }, + "legacy_border_line_weights": { + "exclude": true + }, + "legacy_border_visibility": { + "exclude": true + } } }, "PolygonMesh": { "rust_constructor": "acadrust::entities::PolygonMeshEntity::default()", - "fields": { "vertices": { "required": true } } + "fields": { + "vertices": { + "required": true + } + } }, "PolyfaceMesh": { "fields": { - "vertices": { "required": true }, - "faces": { "required": true }, - "seqend_handle": { "read_only": true } + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "seqend_handle": { + "read_only": true + } } }, "Mesh": { "fields": { - "vertices": { "required": true }, - "faces": { "required": true }, - "version": { "read_only": true } + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "version": { + "read_only": true + } } }, "Helix": { "fields": { - "start_point": { "required": true }, - "spline": { "read_only": true, "_comment": "Derived by the host from the generating parameters." }, - "major_version": { "read_only": true }, - "maintenance_version": { "read_only": true } + "start_point": { + "required": true + }, + "spline": { + "read_only": true, + "_comment": "Derived by the host from the generating parameters." + }, + "major_version": { + "read_only": true + }, + "maintenance_version": { + "read_only": true + } } }, "RasterImage": { "fields": { - "insertion_point": { "required": true }, - "file_path": { "required": true }, - "class_version": { "read_only": true }, - "size": { "read_only": true, "_comment": "Pixel size of the image; the host takes it from the file." }, - "definition_handle": { "read_only": true }, - "definition_reactor_handle": { "read_only": true } + "insertion_point": { + "required": true + }, + "file_path": { + "required": true + }, + "class_version": { + "read_only": true + }, + "size": { + "read_only": true, + "_comment": "Pixel size of the image; the host takes it from the file." + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } } }, "Wipeout": { "fields": { - "insertion_point": { "required": true }, - "class_version": { "read_only": true }, - "definition_handle": { "read_only": true }, - "definition_reactor_handle": { "read_only": true } + "insertion_point": { + "required": true + }, + "class_version": { + "read_only": true + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } } }, "Underlay": { "fields": { - "definition_handle": { "required": true }, - "insertion_point": { "required": true } + "definition_handle": { + "required": true + }, + "insertion_point": { + "required": true + } } }, "Viewport": { "fields": { - "id": { "read_only": true }, - "snap_base": { "read_only": true }, - "snap_spacing": { "read_only": true }, - "grid_spacing": { "read_only": true }, - "snap_angle": { "read_only": true }, - "clip_boundary_handle": { "read_only": true }, - "style_sheet": { "read_only": true }, - "ucs_at_origin": { "read_only": true }, - "ucs_per_viewport": { "read_only": true }, - "ucs_origin": { "read_only": true }, - "ucs_x_axis": { "read_only": true }, - "ucs_y_axis": { "read_only": true }, - "ucs_handle": { "read_only": true }, - "base_ucs_handle": { "read_only": true }, - "ucs_ortho_type": { "read_only": true }, - "elevation": { "read_only": true }, - "shade_plot_mode": { "read_only": true }, - "grid_flags": { "read_only": true }, - "grid_major": { "read_only": true }, - "background_handle": { "read_only": true }, - "shade_plot_handle": { "read_only": true }, - "visual_style_handle": { "read_only": true }, - "sun_handle": { "read_only": true }, - "default_lighting": { "read_only": true }, - "default_lighting_type": { "read_only": true }, - "brightness": { "read_only": true }, - "contrast": { "read_only": true }, - "ambient_color": { "read_only": true }, - "center": { "required": true }, - "width": { "required": true }, - "height": { "required": true } + "id": { + "read_only": true + }, + "snap_base": { + "read_only": true + }, + "snap_spacing": { + "read_only": true + }, + "grid_spacing": { + "read_only": true + }, + "snap_angle": { + "read_only": true + }, + "clip_boundary_handle": { + "read_only": true + }, + "style_sheet": { + "read_only": true + }, + "ucs_at_origin": { + "read_only": true + }, + "ucs_per_viewport": { + "read_only": true + }, + "ucs_origin": { + "read_only": true + }, + "ucs_x_axis": { + "read_only": true + }, + "ucs_y_axis": { + "read_only": true + }, + "ucs_handle": { + "read_only": true + }, + "base_ucs_handle": { + "read_only": true + }, + "ucs_ortho_type": { + "read_only": true + }, + "elevation": { + "read_only": true + }, + "shade_plot_mode": { + "read_only": true + }, + "grid_flags": { + "read_only": true + }, + "grid_major": { + "read_only": true + }, + "background_handle": { + "read_only": true + }, + "shade_plot_handle": { + "read_only": true + }, + "visual_style_handle": { + "read_only": true + }, + "sun_handle": { + "read_only": true + }, + "default_lighting": { + "read_only": true + }, + "default_lighting_type": { + "read_only": true + }, + "brightness": { + "read_only": true + }, + "contrast": { + "read_only": true + }, + "ambient_color": { + "read_only": true + }, + "center": { + "required": true + }, + "width": { + "required": true + }, + "height": { + "required": true + } } }, "ViewBorder": { "fields": { - "version": { "read_only": true }, - "min": { "required": true }, - "max": { "required": true }, - "active_viewport": { "required": true }, - "scale_handle": { "required": true } + "version": { + "read_only": true + }, + "min": { + "required": true + }, + "max": { + "required": true + }, + "active_viewport": { + "required": true + }, + "scale_handle": { + "required": true + } } }, "Light": { "fields": { - "class_version": { "read_only": true }, - "position": { "required": true }, - "name": { "required": true } + "class_version": { + "read_only": true + }, + "position": { + "required": true + }, + "name": { + "required": true + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index adb89de84..f6b565f56 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3851,7 +3851,7 @@ mod ocs { #[cfg(feature = "experimental-host-model")] #[test] - fn insert_is_update_only_and_preserves_block_identity() { + fn insert_is_created_by_block_name_and_preserves_block_identity() { with_vm(|vm| { let mut insert = acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); @@ -3877,7 +3877,14 @@ mod ocs { .unwrap(), 2.0 ); - assert!(dict_to_entity(&rendered, vm).is_err()); + // A round-tripped dict names an existing block, so it creates one. + let recreated = dict_to_entity(&rendered, vm).unwrap(); + assert!(matches!(&recreated, EntityType::Insert(value) + if value.block_name == "DOOR" && value.x_scale() == 2.0)); + // Creation must name the block. + let unnamed = vm.ctx.new_dict(); + unnamed.set_item("kind", vm.new_pyobj("Insert"), vm).unwrap(); + assert!(dict_to_entity(&unnamed, vm).is_err()); let patch = vm.ctx.new_dict(); set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0); @@ -3896,7 +3903,10 @@ mod ocs { patch .set_item("block_name", vm.new_pyobj("OTHER"), vm) .unwrap(); - assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + // The adapter only converts; the host mutation check refuses a + // different block, so an edit cannot swap block identity. + let renamed = apply_dict_to_entity(&original, &patch, vm).unwrap(); + assert!(ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &renamed).is_err()); patch.del_item("block_name", vm).unwrap(); patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap(); assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); @@ -4387,7 +4397,7 @@ mod ocs { ); let dict = vm.ctx.new_dict(); - dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error"); let message = err.args().as_slice()[0] .clone() @@ -4401,7 +4411,7 @@ mod ocs { // An explicitly empty vertex list is exactly as invalid as an // absent one — both parse to the same empty Vec. let dict = vm.ctx.new_dict(); - dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm) .unwrap(); assert!(dict_to_entity(&dict, vm).is_err()); @@ -4409,10 +4419,24 @@ mod ocs { } #[test] - fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + fn legacy_polyline_creation_is_refused_with_a_hint() { with_vm(|vm| { let dict = vm.ctx.new_dict(); dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("legacy Polyline is update-only"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!(message.contains("Polyline2D or Polyline3D"), "{message}"); + }); + } + + #[test] + fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); dict.set_item("closed", vm.new_pyobj(true), vm).unwrap(); let vertex = vm.ctx.new_dict(); let location = vm.ctx.new_dict(); @@ -4424,8 +4448,8 @@ mod ocs { .unwrap(); let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); - let acadrust::EntityType::Polyline(polyline) = &entity else { - panic!("expected Polyline, got {entity:?}"); + let acadrust::EntityType::Polyline2D(polyline) = &entity else { + panic!("expected Polyline2D, got {entity:?}"); }; assert!(polyline.flags.is_closed()); assert_eq!(polyline.vertices.len(), 1); diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6ba318e8f..4bb0116ef 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3200,7 +3200,14 @@ mod tests { let mut create_script = case.create.replace("TMPDIR", &tmp); // Insert is update-only: the host makes a block and one insert, and the // "create" script only has to exist. - let update_only = create_script.contains("MAKEINSERT"); + let make_polyline = create_script.contains("MAKEPOLYLINE"); + if make_polyline { + // The legacy Polyline is update-only: the host makes one to edit. + let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 2.0), (10.0, 5.0, 4.0)] + .map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z)); + host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec()))); + } + let update_only = create_script.contains("MAKEINSERT") || make_polyline; if update_only || create_script.contains("MAKEBLOCK") { let next = host.document().next_handle(); let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); @@ -4127,7 +4134,8 @@ mod tests { #[test] fn audit_python_polyline_lifecycle_over_real_ipc() { run_mesh_lifecycle(&MeshCase { - create: concat!("doc.create_entity('Polyline', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + create: "# MAKEPOLYLINE +", edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"), rejects: &[ ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), @@ -4208,7 +4216,7 @@ mod tests { #[test] fn audit_python_insert_lifecycle_over_real_ipc() { run_mesh_lifecycle(&MeshCase { - create: "# MAKEINSERT\n", + create: concat!("# MAKEBLOCK\n", "doc.create_entity('Insert', block_name='AUDITBLK', insert_point=P(1.0, 2.0, 0.0))\n"), edit: concat!( "e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", @@ -4224,7 +4232,7 @@ mod tests { ("'rotation':float('nan')", "finite"), ("'column_count':0", "greater than zero"), ("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), - ("'block_name':'OTHER'", "read-only"), + ("'block_name':'OTHER'", "does not exist"), ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), ], is_kind: |entity| matches!(entity, EntityType::Insert(_)), @@ -4306,6 +4314,74 @@ mod tests { }); } + #[test] + fn audit_python_insert_creation_rules_over_real_ipc() { + 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 mut records = std::collections::BTreeMap::new(); + for name in ["OUTER", "INNER"] { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new(name); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new(name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + records.insert(name, record_handle); + } + 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 count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Insert(_))).count(); + let create = |name: &str, extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('Insert', block_name='{name}', insert_point={{'x':0.0,'y':0.0,'z':0.0}}{extra})"); + + // A valid insert of INNER into the drawing works. + dispatch(&mut host, &create("INNER", "")); + assert_eq!(count(&host), 1, "{}", last(&host)); + // Refused: unknown block, model space, bad scale, zero array count. + for (command, message) in [ + (create("MISSING", ""), "does not exist"), + (create("*Model_Space", ""), "model-space or paper-space"), + (create("INNER", ", x_scale=0.0"), "nonzero"), + (create("INNER", ", column_count=0"), "greater than zero"), + (create("", ""), "empty"), + ] { + dispatch(&mut host, &command); + assert_eq!(count(&host), 1, "{command}"); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + // A block cannot contain itself, directly or through another block. + let owner = |name: &str| format!(", owner_handle={}", records[name].value()); + dispatch(&mut host, &create("OUTER", &owner("OUTER"))); + assert_eq!(count(&host), 1); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + dispatch(&mut host, &create("INNER", &owner("OUTER"))); + assert_eq!(count(&host), 2, "{}", last(&host)); + dispatch(&mut host, &create("OUTER", &owner("INNER"))); + assert_eq!(count(&host), 2); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + // The legacy Polyline cannot be created and says what to use instead. + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Polyline', vertices=[])"); + assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host)); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {