From ec10bdbc48ecbae733ab318d3d4be59dc6a558ce Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 11:12:27 +0200 Subject: [PATCH] Map Leader in the Python host model; record acadrust W blockers Adds Leader validation and annotation/dimension-style reference checks to ocs_plugin_api, maps 15 writable and 5 read-only fields in the adapter, and adds staged_python_leader_lifecycle_over_real_ipc covering C/R/E/D/U/I/V/P. The W gate stays open: acadrust's DXF writer omits the annotation handle and DXF reads drop annotation_offset; DWG R2010+ does not store text size or the hookline flag. Each gap is pinned by a canary assertion. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 17 ++ .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 209 +++++++++++++++++ docs/plugin-host-model-completion-plan.md | 25 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 23 +- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 216 ++++++++++++++++++ 10 files changed, 486 insertions(+), 20 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index dd58dd74c..b8be77b79 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -756,6 +756,23 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "paths" | "seed_points" ) + | ( + "Leader", + "dimension_style" + | "arrow_enabled" + | "path_type" + | "creation_type" + | "hookline_direction" + | "hookline_enabled" + | "text_height" + | "text_width" + | "vertices" + | "annotation_handle" + | "normal" + | "horizontal_direction" + | "block_offset" + | "annotation_offset" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 931ae0806..a960cb236 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -23,12 +23,14 @@ "Shape": ["layer", "insertion_point", "size", "shape_name", "shape_number", "rotation", "relative_x_scale", "oblique_angle", "normal", "thickness", "style_name"], "AttributeDefinition": ["layer", "tag", "prompt", "default_value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], "AttributeEntity": ["layer", "tag", "value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], - "Hatch": ["layer", "elevation", "normal", "is_solid", "pattern", "pattern_angle", "pattern_scale", "pattern_type", "is_double", "style", "is_associative", "pixel_size", "paths", "seed_points"] + "Hatch": ["layer", "elevation", "normal", "is_solid", "pattern", "pattern_angle", "pattern_scale", "pattern_type", "is_double", "style", "is_associative", "pixel_size", "paths", "seed_points"], + "Leader": ["layer", "dimension_style", "arrow_enabled", "path_type", "creation_type", "hookline_direction", "hookline_enabled", "text_height", "text_width", "vertices", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"] }, "readable": { "Insert": ["block_name"], "Tolerance": ["dimension_style_handle"], "Shape": ["style_handle"], + "Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"], "AttributeEntity": ["attdef_handle"] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index da8c33bd3..52b808a13 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -231,6 +231,93 @@ fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Res Ok(()) } +/// Field checks shared by Leader creation and mutation. `old` limits the +/// checks to fields the edit touched so legacy DWG leaders stay editable. +#[cfg(feature = "host")] +fn validate_leader( + old: Option<&crate::host::acadrust::entities::Leader>, + new: &crate::host::acadrust::entities::Leader, +) -> Result<(), String> { + use crate::host::acadrust::entities::LeaderCreationType; + let vec_changed = |a: &crate::host::acadrust::types::Vector3, + b: &crate::host::acadrust::types::Vector3| { + old.is_none() + || a.x.to_bits() != b.x.to_bits() + || a.y.to_bits() != b.y.to_bits() + || a.z.to_bits() != b.z.to_bits() + }; + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("Leader.vertices requires at least 2 points".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("Leader.vertices[{index}]"), vertex)?; + } + if new.vertices.windows(2).all(|pair| pair[0] == pair[1]) { + return Err("Leader.vertices must not all coincide".into()); + } + } + if let Some(old_leader) = old { + if vec_changed(&old_leader.normal, &new.normal) { + solid_normal("Leader.normal", &new.normal)?; + } + } else { + solid_normal("Leader.normal", &new.normal)?; + } + if vec_changed( + old.map_or(&new.horizontal_direction, |o| &o.horizontal_direction), + &new.horizontal_direction, + ) { + solid_normal("Leader.horizontal_direction", &new.horizontal_direction)?; + } + for (name, before, after) in [ + ( + "block_offset", + old.map_or(&new.block_offset, |o| &o.block_offset), + &new.block_offset, + ), + ( + "annotation_offset", + old.map_or(&new.annotation_offset, |o| &o.annotation_offset), + &new.annotation_offset, + ), + ] { + if vec_changed(before, after) { + finite_vector(&format!("Leader.{name}"), after)?; + } + } + for (name, before, after) in [ + ( + "text_height", + old.map_or(new.text_height, |o| o.text_height), + new.text_height, + ), + ( + "text_width", + old.map_or(new.text_width, |o| o.text_width), + new.text_width, + ), + ] { + if (old.is_none() || before.to_bits() != after.to_bits()) + && (!after.is_finite() || after < 0.0 || (name == "text_height" && after == 0.0)) + { + return Err(format!( + "Leader.{name} must be finite and {}", + if name == "text_height" { "greater than zero" } else { "non-negative" } + )); + } + } + if old.is_none_or(|o| o.dimension_style != new.dimension_style) + && new.dimension_style.trim().is_empty() + { + return Err("Leader.dimension_style is empty".into()); + } + if new.creation_type == LeaderCreationType::NoAnnotation && !new.annotation_handle.is_null() { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + 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")] @@ -322,6 +409,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Shape.style_name is empty".into()); } } + EntityType::Leader(value) => validate_leader(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -676,6 +764,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { changed3( "AttributeDefinition.insertion_point", @@ -937,6 +1026,48 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Leader(value) = entity { + use crate::host::acadrust::entities::LeaderCreationType; + if !document + .dim_styles + .iter() + .any(|style| style.name.eq_ignore_ascii_case(value.dimension_style.trim())) + { + return Err(format!( + "Leader dimension style {:?} does not exist", + value.dimension_style + )); + } + if !value.annotation_handle.is_null() { + if value.creation_type == LeaderCreationType::NoAnnotation { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + let annotation = document + .get_entity(value.annotation_handle) + .filter(|_| value.annotation_handle != value.common.handle) + .ok_or_else(|| { + format!( + "Leader annotation handle {:?} does not exist", + value.annotation_handle + ) + })?; + let expected = match value.creation_type { + LeaderCreationType::WithText => matches!( + annotation, + EntityType::Text(_) | EntityType::MText(_) + ), + LeaderCreationType::WithTolerance => matches!(annotation, EntityType::Tolerance(_)), + LeaderCreationType::WithBlock => matches!(annotation, EntityType::Insert(_)), + LeaderCreationType::NoAnnotation => unreachable!(), + }; + if !expected { + return Err(format!( + "Leader annotation {:?} does not match creation_type {:?}", + value.annotation_handle, value.creation_type + )); + } + } + } if let EntityType::AttributeDefinition(value) = entity { let owner = entity.common().owner_handle; let block = document @@ -1202,6 +1333,7 @@ mod tests { | "AttributeDefinition" | "AttributeEntity" | "Hatch" + | "Leader" ) { assert!( @@ -1336,6 +1468,20 @@ mod tests { "Hatch.pixel_size".to_owned(), "Hatch.paths".to_owned(), "Hatch.seed_points".to_owned(), + "Leader.dimension_style".to_owned(), + "Leader.arrow_enabled".to_owned(), + "Leader.path_type".to_owned(), + "Leader.creation_type".to_owned(), + "Leader.hookline_direction".to_owned(), + "Leader.hookline_enabled".to_owned(), + "Leader.text_height".to_owned(), + "Leader.text_width".to_owned(), + "Leader.vertices".to_owned(), + "Leader.annotation_handle".to_owned(), + "Leader.normal".to_owned(), + "Leader.horizontal_direction".to_owned(), + "Leader.block_offset".to_owned(), + "Leader.annotation_offset".to_owned(), ]) ); } @@ -1368,6 +1514,69 @@ mod tests { .contains("does not exist")); } + #[cfg(feature = "host")] + #[test] + fn leader_geometry_and_annotation_references_are_validated() { + use crate::host::acadrust::{self, entities::LeaderCreationType, types::Vector3}; + let mut document = acadrust::CadDocument::new(); + let mut text = acadrust::entities::Text::new(); + text.common.handle = document.allocate_handle(); + let text_handle = text.common.handle; + document.add_entity(acadrust::EntityType::Text(text)).unwrap(); + let mut leader = acadrust::entities::Leader::two_point( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ); + leader.annotation_handle = text_handle; + let entity = acadrust::EntityType::Leader(leader.clone()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + + let mut wrong_kind = leader.clone(); + wrong_kind.creation_type = LeaderCreationType::WithTolerance; + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(wrong_kind) + ) + .unwrap_err() + .contains("does not match")); + let mut missing = leader.clone(); + missing.annotation_handle = acadrust::Handle::new(0xdead); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(missing) + ) + .unwrap_err() + .contains("does not exist")); + let mut none_with_handle = leader.clone(); + none_with_handle.creation_type = LeaderCreationType::NoAnnotation; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(none_with_handle)) + .unwrap_err() + .contains("no annotation")); + let mut one_point = leader.clone(); + one_point.vertices.truncate(1); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(one_point)) + .unwrap_err() + .contains("at least 2")); + let mut bad_style = leader.clone(); + bad_style.dimension_style = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(bad_style) + ) + .unwrap_err() + .contains("dimension style")); + + let mut edited = leader.clone(); + edited.vertices[1] = Vector3::new(f64::NAN, 0.0, 0.0); + assert!(validate_entity_mutation( + &acadrust::EntityType::Leader(leader.clone()), + &acadrust::EntityType::Leader(edited) + ) + .unwrap_err() + .contains("finite")); + } + #[cfg(feature = "host")] #[test] fn shape_geometry_and_shape_file_style_are_validated_and_bound() { diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 6e64de4d2..95f95931d 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -5,15 +5,15 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. As of 20 September 2026, the working branch is `plugin/host-model-api` at -`8648225a`, published as draft PR +`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 **22 of 43** canvas kinds: 21 -are creatable and `Insert` remains update-only. This is **not** 22 completed +v7. The Python feature has explicit writes for **23 of 43** canvas kinds: 22 +are creatable and `Insert` remains update-only. This is **not** 23 completed kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. 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`. The other 21 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 20 canvas kinds have only `layer` 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. @@ -123,14 +123,14 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Leader**. Build a valid vertex path and annotation-linked -fixture, map geometry separately from annotation/object handles, and verify -path rendering, selection, reference preservation, deletion, undo/redo and -both file formats through real IPC. AttributeDefinition remains mapped and -integration-tested but open at W. First rerun its DXF regression against -current acadrust `7ea4247`; do not mark it complete until the full optional -property state passes DXF save/reopen. Keep the exact queue above and update -statuses with evidence after each increment. +Continue with **MLine** (queue item 2). Leader is done except for its W +gate; when the engine writes DXF group 340, reads group 213 and the DWG +R2010+ text-size question is decided, flip the canary assertions in +`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. +AttributeDefinition remains mapped and integration-tested but open at W; its +real IPC lifecycle now passes against acadrust `7ea4247`, so audit the full +optional-property DXF round trip and close or retain the blocker. Keep the +exact queue above and update statuses with evidence after each increment. ## Phase 8 progress log @@ -157,3 +157,4 @@ statuses with evidence after each increment. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. +- Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 5d4626d07..9e8946b1c 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: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 remain layer-only. `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: **23 of 43 canvas kinds have explicit geometry writes** (22 creatable, plus update-only `Insert`), **4 kinds are complete**, and 20 remain layer-only. `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. @@ -28,7 +28,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | -| Leader | Layer-only | Requires annotation-linked fixture. | +| Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | | MLine | Layer-only | Requires valid style and vertex fixture. | | Dimension | Layer-only | Track individual dimension subtypes. | | MultiLeader | Layer-only | Requires nested context and style fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 03ece7f70..08f8bbf33 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -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 22 geometry converters. Other writable properties +canvas kinds outside its 23 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 @@ -91,7 +91,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 22-kind generated schema it contains only handle, kind, and layer; +outside the 23-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 diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 10da6d43c..0c772c760 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -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 22 mapped kinds. +the 23 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 diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 43b71be06..de33dc2ae 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -21,7 +21,8 @@ "Shape", "AttributeDefinition", "AttributeEntity", - "Hatch" + "Hatch", + "Leader" ], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { @@ -228,6 +229,26 @@ "mpolygon_x_direction": { "exclude": true }, "mpolygon_boundary_handle_count": { "exclude": true } } + }, + "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 }, + "override_color": { + "exclude": true, + "_comment": "Color is a multi-payload tagged enum the generator cannot express yet. It stays snapshot-readable and is preserved untouched by Python edits." + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 086afb826..d64cc0ab0 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4663,7 +4663,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 22); + assert_eq!(emitted.len(), 23); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e1a152d9f..2fa714ea9 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2152,6 +2152,222 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); } + #[test] + fn staged_python_leader_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + 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_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Text', text='NOTE', ", + "insertion={'x':20.0,'y':10.0,'z':0.0}, height=2.5).handle" + )); + let text_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Text(text) => Some(text.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Text: {}", last_output(&host))); + let script = std::env::temp_dir().join(format!("ocs_leader_create_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "doc.create_entity('Leader', vertices=[{{'x':0.0,'y':0.0,'z':0.0}},", + "{{'x':10.0,'y':10.0,'z':0.0}},{{'x':20.0,'y':10.0,'z':0.0}}], ", + "annotation_handle={}, hookline_enabled=True)\n" + ), text_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let leader_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Leader(leader) => Some(leader.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Leader: {}", last_output(&host))); + let created = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(created_leader) = &created else { unreachable!() }; + assert_eq!(created_leader.annotation_handle, text_handle); + assert_eq!(created_leader.vertices.len(), 3); + + let assert_geometry = |entity: &EntityType, document: &CadDocument| { + let EntityType::Leader(leader) = entity else { panic!("expected Leader") }; + let render = leader.to_render(document).expect("leader render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + panic!("expected leader linework"); + }; + assert!(points.len() >= leader.vertices.len()); + assert!(points.iter().flatten().all(|value| value.is_nan() || value.is_finite())); + }; + assert_geometry(&created, host.document()); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].annotation_handle", leader_handle.value())); + assert!(last_output(&host).contains(&text_handle.value().to_string())); + + // GUI picking groups a leader with its annotation, while scripted + // selection stays exact. + host.app.tabs[0].scene.select_entities(&[leader_handle]); + let mut grouped = host.selection(); + grouped.sort_by_key(|handle| handle.value()); + assert_eq!(grouped, vec![text_handle, leader_handle]); + host.app.tabs[0].scene.replace_selection_exact(&[]); + + let script = std::env::temp_dir().join(format!("ocs_leader_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "leader = doc.entities[{}]\n", + "with doc.transaction('Edit leader'):\n", + " leader.vertices = [{{'x':1.0,'y':1.0,'z':0.0}},{{'x':11.0,'y':12.0,'z':0.0}},", + "{{'x':22.0,'y':12.0,'z':0.0}},{{'x':30.0,'y':12.0,'z':0.0}}]\n", + " leader.text_height = 4.0\n", + " leader.arrow_enabled = False\n", + " leader.annotation_offset = (1.0, 2.0, 0.0)\n", + "doc.selection = [leader]\n" + ), leader_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 4, "edit failed: {}", last_output(&host)); + assert_eq!(edited.text_height, 4.0); + assert!(!edited.arrow_enabled); + assert_eq!(edited.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(edited.annotation_handle, text_handle); + assert_eq!(edited.common, created_leader.common); + assert_eq!(edited.dimension_style, created_leader.dimension_style); + assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled); + assert_eq!(edited.normal, created_leader.normal); + assert_eq!(edited.origin, created_leader.origin); + assert_eq!(edited.override_color, created_leader.override_color); + assert_eq!(host.selection(), vec![leader_handle]); + assert_geometry(&expected, host.document()); + + for (patch, message) in [ + ("'annotation_handle':999999", "does not exist"), + ("'creation_type':'WithTolerance'", "does not match creation_type"), + ("'creation_type':'NoAnnotation'", "no annotation"), + ("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"), + ("'text_height':0.0", "greater than zero"), + ("'dimension_style':'Missing'", "dimension style"), + ("'override_color':{}", "outside the editable schema"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])", + leader_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + // A valid edit paired with an invalid one is atomic across entities. + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text_width':5.0}},", + "{{'handle':{},'text_height':0.0}}])" + ), leader_handle.value(), text_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected)); + + // Both formats keep the path, annotation link, arrow flag and offset. + // DWG R2010+ derives text_height/text_width/hookline_enabled instead of + // storing them (acadrust writes them for R13-R2007/R13-R14 only), so + // only DXF must round-trip those three. + let assert_saved = |document: &CadDocument, is_dxf: bool| { + let Some(EntityType::Leader(leader)) = document.get_entity(leader_handle) else { + panic!("Leader missing after reopen"); + }; + assert_eq!(leader.vertices.len(), 4); + if is_dxf { + // BLOCKER: acadrust's DXF writer never emits group 340, so a + // DXF save unlinks the annotation. Flip this to + // `assert_eq!(.., text_handle)` once the engine writes it. + assert!(leader.annotation_handle.is_null(), "acadrust now writes DXF 340; drop the blocker"); + } else { + assert_eq!(leader.annotation_handle, text_handle); + } + assert!(!leader.arrow_enabled); + if is_dxf { + // BLOCKER: the DXF writer emits group 213 but the reader's + // coordinate mapping does not reassemble it. + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::ZERO, + "acadrust now reads DXF 213; drop the blocker"); + } else { + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + } + assert_eq!(leader.dimension_style, "Standard"); + if is_dxf { + assert!((leader.text_height - 4.0).abs() < 1e-12); + assert!(leader.hookline_enabled); + } else { + assert_eq!(leader.text_height, 2.5, "DWG R2010+ does not store text_height"); + assert!(!leader.hookline_enabled, "DWG R2010+ does not store hookline_enabled"); + } + assert!(matches!(document.get_entity(text_handle), Some(EntityType::Text(_)))); + }; + let reopened_dwg = crate::io::load_bytes("leader.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("leader.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg, false); + assert_saved(&reopened_dxf, true); + for (name, mut document) in [("leader-reedit.dwg", reopened_dwg), + ("leader-reedit.dxf", reopened_dxf)] { + assert_geometry(document.get_entity(leader_handle).unwrap(), &document); + let before = document.get_entity(leader_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Leader(leader) = &mut after else { unreachable!() }; + leader.vertices[3] = acadrust::types::Vector3::new(35.0, 12.0, 0.0); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(leader_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(leader_handle), + Some(EntityType::Leader(leader)) if (leader.vertices[3].x - 35.0).abs() < 1e-9)); + } + + // Erasing a leader also erases its annotation (scene leader grouping). + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", leader_handle.value())); + assert!(host.document().get_entity(leader_handle).is_none()); + assert!(host.document().get_entity(text_handle).is_none()); + for (name, bytes) in [ + ("leader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("leader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + let document = crate::io::load_bytes(name, bytes).unwrap(); + assert!(document.get_entity(leader_handle).is_none()); + assert!(document.get_entity(text_handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + // Text create, Leader create, edit transaction, Leader delete. + assert_eq!(app.tabs[0].history.undo_stack.len(), 4); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&expected)); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + app.redo_steps(4); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {