From a86d5bc7272572e558e9a18c36fbd9cac8005d23 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:02:40 +0200 Subject: [PATCH] Complete MLine host model coverage Maps vertices, flags, justification, normal, scale and style with host validation and MLineStyle binding. The host recomputes vertex direction, miter and element parameters on scripted create/edit, so scripts only supply positions. staged_python_mline_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 9 + .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 148 ++++++++++++ docs/plugin-host-model-completion-plan.md | 11 +- 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 | 19 +- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 210 +++++++++++++++++- src/entities/mline.rs | 48 ++++ 11 files changed, 445 insertions(+), 16 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index b8be77b79..277d79ea6 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -773,6 +773,15 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "block_offset" | "annotation_offset" ) + | ( + "MLine", + "flags" + | "justification" + | "normal" + | "scale_factor" + | "style_name" + | "vertices" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index a960cb236..4230aeee2 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -24,12 +24,14 @@ "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"], - "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"] + "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"], + "MLine": ["layer", "flags", "justification", "normal", "scale_factor", "style_name", "vertices"] }, "readable": { "Insert": ["block_name"], "Tolerance": ["dimension_style_handle"], "Shape": ["style_handle"], + "MLine": ["start_point", "style_handle", "style_element_count"], "Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"], "AttributeEntity": ["attdef_handle"] }, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 52b808a13..37ef77f55 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -318,6 +318,65 @@ fn validate_leader( Ok(()) } +/// Field checks shared by MLine creation and mutation. Vertex `direction`, +/// `miter` and segment cut parameters are derived by the host, so only their +/// finiteness is checked here. +#[cfg(feature = "host")] +fn validate_mline( + old: Option<&crate::host::acadrust::entities::MLine>, + new: &crate::host::acadrust::entities::MLine, +) -> Result<(), String> { + if old.is_none_or(|old| old.flags != new.flags) && new.flags.bits() & !0x0f != 0 { + return Err("MLine.flags has unknown bits".into()); + } + if old.is_none_or(|old| old.scale_factor.to_bits() != new.scale_factor.to_bits()) + && (!new.scale_factor.is_finite() || new.scale_factor == 0.0) + { + return Err("MLine.scale_factor must be finite and nonzero".into()); + } + if old.is_none_or(|old| old.normal != new.normal) { + solid_normal("MLine.normal", &new.normal)?; + } + if old.is_none_or(|old| old.style_name != new.style_name) && new.style_name.trim().is_empty() { + return Err("MLine.style_name is empty".into()); + } + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("MLine.vertices requires at least 2 points".into()); + } + for (vi, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("MLine.vertices[{vi}].position"), &vertex.position)?; + finite_vector(&format!("MLine.vertices[{vi}].direction"), &vertex.direction)?; + finite_vector(&format!("MLine.vertices[{vi}].miter"), &vertex.miter)?; + for (si, segment) in vertex.segments.iter().enumerate() { + if segment + .parameters + .iter() + .chain(&segment.area_fill_parameters) + .any(|value| !value.is_finite()) + { + return Err(format!( + "MLine.vertices[{vi}].segments[{si}] contains non-finite parameters" + )); + } + } + } + if new + .vertices + .windows(2) + .all(|pair| pair[0].position == pair[1].position) + { + return Err("MLine.vertices must not all coincide".into()); + } + } + if new.flags.contains(crate::host::acadrust::entities::MLineFlags::CLOSED) + && new.vertices.len() < 3 + { + return Err("closed MLine requires at least 3 vertices".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")] @@ -410,6 +469,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } } EntityType::Leader(value) => validate_leader(None, value)?, + EntityType::MLine(value) => validate_mline(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -764,6 +824,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { changed3( @@ -1026,6 +1087,24 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::MLine(value) = entity { + let style = resolve_mline_style(document, value).ok_or_else(|| { + format!("MLine style {:?} does not exist", value.style_name) + })?; + if style.elements.is_empty() { + return Err(format!("MLine style {:?} has no elements", style.name)); + } + for (index, vertex) in value.vertices.iter().enumerate() { + if !vertex.segments.is_empty() && vertex.segments.len() != style.elements.len() { + return Err(format!( + "MLine.vertices[{index}] has {} segments but style {:?} has {} elements", + vertex.segments.len(), + style.name, + style.elements.len() + )); + } + } + } if let EntityType::Leader(value) = entity { use crate::host::acadrust::entities::LeaderCreationType; if !document @@ -1147,6 +1226,32 @@ pub fn validate_canvas_entity_references( Ok(()) } +/// Stable handle wins; the name is the fallback for legacy entities. +#[cfg(feature = "host")] +fn resolve_mline_style<'a>( + document: &'a crate::host::CadDocument, + mline: &crate::host::acadrust::entities::MLine, +) -> Option<&'a crate::host::acadrust::objects::MLineStyle> { + use crate::host::acadrust::objects::ObjectType; + mline + .style_handle + .filter(|handle| !handle.is_null()) + .and_then(|handle| match document.objects.get(&handle) { + Some(ObjectType::MLineStyle(style)) => Some(style), + _ => None, + }) + .or_else(|| { + document.objects.values().find_map(|object| match object { + ObjectType::MLineStyle(style) + if style.name.eq_ignore_ascii_case(mline.style_name.trim()) => + { + Some(style) + } + _ => None, + }) + }) +} + /// Fill name-based table references with stable handles before a new entity or /// replacement is committed. Existing non-null handles remain authoritative /// for legacy DWG entities whose fallback name can be stale. @@ -1173,6 +1278,9 @@ pub fn bind_canvas_entity_references( }) .map(|style| style.handle); } + EntityType::MLine(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = resolve_mline_style(document, value).map(|style| style.handle); + } EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { value.style_handle = document .text_styles @@ -1334,6 +1442,7 @@ mod tests { | "AttributeEntity" | "Hatch" | "Leader" + | "MLine" ) { assert!( @@ -1482,6 +1591,12 @@ mod tests { "Leader.horizontal_direction".to_owned(), "Leader.block_offset".to_owned(), "Leader.annotation_offset".to_owned(), + "MLine.flags".to_owned(), + "MLine.justification".to_owned(), + "MLine.normal".to_owned(), + "MLine.scale_factor".to_owned(), + "MLine.style_name".to_owned(), + "MLine.vertices".to_owned(), ]) ); } @@ -1577,6 +1692,39 @@ mod tests { .contains("finite")); } + #[cfg(feature = "host")] + #[test] + fn mline_geometry_and_style_references_are_validated_and_bound() { + use crate::host::acadrust::{self, entities::MLineVertex, types::Vector3}; + let document = acadrust::CadDocument::new(); + let mut mline = acadrust::entities::MLine::new(); + for x in [0.0, 5.0] { + mline.vertices.push(MLineVertex::new(Vector3::new(x, 0.0, 0.0))); + } + let mut entity = acadrust::EntityType::MLine(mline.clone()); + validate_new_canvas_entity(&entity).unwrap(); + bind_canvas_entity_references(&document, &mut entity).unwrap(); + assert!(matches!(&entity, acadrust::EntityType::MLine(value) if value.style_handle.is_some())); + + let mut missing = mline.clone(); + missing.style_name = "Missing".into(); + assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(missing)) + .unwrap_err().contains("does not exist")); + let mut wrong_segments = mline.clone(); + wrong_segments.vertices[0].init_segments(3); + assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(wrong_segments)) + .unwrap_err().contains("segments but style")); + let mut one = mline.clone(); + one.vertices.truncate(1); + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(one)).unwrap_err().contains("at least 2")); + let mut closed = mline.clone(); + closed.flags |= acadrust::entities::MLineFlags::CLOSED; + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(closed)).unwrap_err().contains("at least 3")); + let mut scale = mline.clone(); + scale.scale_factor = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(scale)).unwrap_err().contains("nonzero")); + } + #[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 95f95931d..950f6fa49 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **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. +v7. The Python feature has explicit writes for **24 of 43** canvas kinds: 23 +are creatable and `Insert` remains update-only. This is **not** 24 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch and MLine 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`. 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` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 19 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,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **MLine** (queue item 2). Leader is done except for its W +Continue with **Dimension** (queue item 3; track each subtype separately). 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`. @@ -158,3 +158,4 @@ exact queue above and update statuses with evidence after each increment. - 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`. +- Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 9e8946b1c..93d8b2189 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: **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`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **24 of 43 canvas kinds have explicit geometry writes** (23 creatable, plus update-only `Insert`), **5 kinds are complete**, and 19 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. @@ -29,7 +29,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | 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 | **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. | +| MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | | Dimension | Layer-only | Track individual dimension subtypes. | | MultiLeader | Layer-only | Requires nested context and style fixture. | | Table | Layer-only | Requires table style and cell fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 08f8bbf33..3dad4fbe7 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 23 geometry converters. Other writable properties +canvas kinds outside its 24 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 23-kind generated schema it contains only handle, kind, and layer; +outside the 24-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 0c772c760..edd6e405b 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 23 mapped kinds. +the 24 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 de33dc2ae..93da7a608 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -22,7 +22,8 @@ "AttributeDefinition", "AttributeEntity", "Hatch", - "Leader" + "Leader", + "MLine" ], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { @@ -230,6 +231,22 @@ "mpolygon_boundary_handle_count": { "exclude": true } } }, + "MLine": { + "fields": { + "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 }, + "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; }" + } + } + }, "Leader": { "fields": { "vertices": { "required": true }, diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index d64cc0ab0..a1e3c1448 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(), 23); + assert_eq!(emitted.len(), 24); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2fa714ea9..67c7fedce 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -149,7 +149,26 @@ impl<'a> HostSession<'a> { } } - pub fn add_entity(&mut self, entity: EntityType) -> Handle { + /// Recompute host-derived state for entities a script created or edited. + fn normalize_scripted_entity( + &self, + old: Option<&EntityType>, + entity: &mut EntityType, + ) -> Result<(), String> { + if let EntityType::MLine(new) = entity { + let old = match old { + Some(EntityType::MLine(old)) => Some(old), + _ => None, + }; + crate::entities::mline::normalize_scripted_mline(old, new, self.document())?; + } + Ok(()) + } + + pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + if self.normalize_scripted_entity(None, &mut entity).is_err() { + return Handle::NULL; + } if let EntityType::AttributeEntity(mut attribute) = entity { let owner = attribute.common.owner_handle; let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() @@ -182,7 +201,12 @@ impl<'a> HostSession<'a> { handle } - pub fn add_entities(&mut self, entities: Vec) -> Vec { + pub fn add_entities(&mut self, mut entities: Vec) -> Vec { + for entity in &mut entities { + // Creation input was validated by the caller; a degenerate MLine + // simply keeps its supplied (unnormalized) vertices. + let _ = self.normalize_scripted_entity(None, entity); + } let handles = self.app.tabs[self.tab].scene.add_entities(entities); self.publish_document_view(); handles @@ -194,7 +218,11 @@ impl<'a> HostSession<'a> { /// Replace the entity carrying `entity`'s handle in place, refreshing the /// scene's derived caches. Returns `false` when no entity has that handle. - pub fn update_entity(&mut self, entity: EntityType) -> bool { + pub fn update_entity(&mut self, mut entity: EntityType) -> bool { + let old = self.document().get_entity(entity.common().handle).cloned(); + if self.normalize_scripted_entity(old.as_ref(), &mut entity).is_err() { + return false; + } let ok = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), entity => self.app.tabs[self.tab].scene.update_entity(entity), @@ -236,6 +264,8 @@ impl<'a> HostSession<'a> { .map_err(|error| format!("entity {handle:?}: {error}"))?; ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; + self.normalize_scripted_entity(Some(&existing), entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; let lock_handle = if matches!(entity, EntityType::AttributeEntity(_)) { entity.common().owner_handle } else { @@ -2368,6 +2398,180 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); } + #[test] + fn staged_python_mline_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(); + let style_handle = host.document().objects.iter().find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::MLineStyle(style) if style.name == "Standard" => Some(*handle), + _ => None, + }).expect("document carries the Standard MLineStyle"); + + let script = std::env::temp_dir().join(format!("ocs_mline_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'position': {'x': x, 'y': y, 'z': 0.0}}\n", + "doc = ocs.active_document\n", + "doc.create_entity('MLine', vertices=[p(0.0, 0.0), p(10.0, 0.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::MLine(mline) => Some(mline.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create MLine: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(created_mline) = &created else { unreachable!() }; + assert_eq!(created_mline.style_handle, Some(style_handle)); + assert_eq!(created_mline.style_element_count, 2); + assert_eq!(created_mline.start_point, acadrust::types::Vector3::ZERO); + let first_parameters = |mline: &acadrust::entities::MLine, vertex: usize| -> Vec { + mline.vertices[vertex].segments.iter().map(|segment| segment.parameters[0]).collect() + }; + assert_eq!(first_parameters(created_mline, 0), vec![0.5, -0.5]); + assert!(created_mline.vertices.iter().all(|vertex| { + (vertex.direction.x - 1.0).abs() < 1e-9 && (vertex.miter.y.abs() - 1.0).abs() < 1e-9 + })); + let assert_lines = |entity: &EntityType, document: &CadDocument, expected_vertices: usize| { + let EntityType::MLine(mline) = entity else { panic!("expected MLine") }; + assert_eq!(mline.vertices.len(), expected_vertices); + let lines = crate::entities::mline::mline_lines(mline, document); + assert!(lines.len() >= 2, "expected styled element lines"); + assert!(lines.iter().flat_map(|line| &line.points).flatten().all(|v| v.is_finite())); + }; + assert_lines(&created, host.document(), 2); + + let script = std::env::temp_dir().join(format!("ocs_mline_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'position': {{'x': x, 'y': y, 'z': 0.0}}}}\n", + "doc = ocs.active_document\n", + "mline = doc.entities[{}]\n", + "with doc.transaction('Edit mline'):\n", + " mline.vertices = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)]\n", + " mline.scale_factor = 2.0\n", + " mline.justification = 'Top'\n", + "doc.selection = [mline]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 3, "edit failed: {}", last_output(&host)); + assert_eq!(edited.scale_factor, 2.0); + assert_eq!(edited.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(edited.style_handle, created_mline.style_handle); + assert_eq!(edited.common, created_mline.common); + assert_eq!(edited.normal, created_mline.normal); + assert_eq!(edited.flags.bits() & !1, 0); + // Top justification with scale 2: first element sits on the path, + // the second 2.0 to the right of travel; the corner miter widens it. + let end = first_parameters(edited, 0); + assert!(end[0].abs() < 1e-9 && (end[1] + 2.0).abs() < 1e-9, "{end:?}"); + let corner = first_parameters(edited, 1); + assert!(corner[1].abs() > 2.0 && corner[1].is_finite(), "miter must widen at the corner: {corner:?}"); + assert!((edited.vertices[1].direction.y - edited.vertices[1].direction.x).abs() > 1e-6); + assert_lines(&expected, host.document(), 3); + assert_eq!(host.selection(), vec![handle]); + + for (patch, message) in [ + ("'style_name':'Missing'", "does not exist"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'scale_factor':0.0", "nonzero"), + ("'flags':64", "unknown bits"), + ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0},'segments':[{}]},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "segments but style"), + ("'start_point':{'x':5.0,'y':5.0,'z':0.0}", "read-only"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject mline', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'scale_factor':3.0}},", + "{{'handle':{},'scale_factor':0.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let assert_saved = |document: &CadDocument| { + let Some(EntityType::MLine(mline)) = document.get_entity(handle) else { + panic!("MLine missing after reopen"); + }; + assert_eq!(mline.vertices.len(), 3); + assert_eq!(mline.scale_factor, 2.0); + assert_eq!(mline.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(mline.style_name, "Standard"); + assert_eq!(mline.vertices[2].position, acadrust::types::Vector3::new(10.0, 10.0, 0.0)); + let (a, b) = (first_parameters(mline, 1), first_parameters(edited, 1)); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9), "{a:?} vs {b:?}"); + assert_lines(&document.get_entity(handle).unwrap().clone(), document, 3); + }; + let reopened_dwg = crate::io::load_bytes("mline.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("mline.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("mline-reedit.dwg", reopened_dwg), ("mline-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + mline.vertices[2].position = acadrust::types::Vector3::new(10.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(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + let old = match &before { EntityType::MLine(old) => old, _ => unreachable!() }; + crate::entities::mline::normalize_scripted_mline(Some(old), mline, &document).unwrap(); + *document.get_entity_mut(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(handle), + Some(EntityType::MLine(mline)) if (mline.vertices[2].position.y - 12.0).abs() < 1e-9)); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mline-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mline-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(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 { diff --git a/src/entities/mline.rs b/src/entities/mline.rs index f9d10c7ce..e2ead2af9 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -418,6 +418,54 @@ fn restore_shifted_mline_segment( } } +/// Recompute the derived MLine state after a scripted create or edit: +/// vertex direction/miter, element segment layout and the per-element miter +/// offsets. Dash/cut parameters survive an edit unless a vertex moved. +pub(crate) fn normalize_scripted_mline( + old: Option<&MLine>, + new: &mut MLine, + document: &acadrust::CadDocument, +) -> Result<(), String> { + let style = resolved_mline_style(new, document) + .ok_or_else(|| format!("MLine style {:?} does not exist", new.style_name))?; + let unchanged = old.is_some_and(|old| { + old.vertices == new.vertices + && old.normal == new.normal + && old.flags == new.flags + && old.justification == new.justification + && old.scale_factor.to_bits() == new.scale_factor.to_bits() + && old.style_name == new.style_name + && old.style_handle == new.style_handle + }); + if unchanged { + return Ok(()); + } + let moved = old.is_none_or(|old| { + old.vertices.len() != new.vertices.len() + || old + .vertices + .iter() + .zip(&new.vertices) + .any(|(a, b)| a.position != b.position) + }); + let elements = style.elements.len().max(1); + for vertex in &mut new.vertices { + if vertex.segments.is_empty() { + vertex.init_segments(elements); + } else if moved { + for segment in &mut vertex.segments { + segment.parameters.truncate(1); + segment.area_fill_parameters.clear(); + } + } + } + if !rebuild_mline_geometry(new) { + return Err("MLine vertices are degenerate".into()); + } + crate::modules::draw::draw::mline::sync_mline_element_parameters(new, style); + Ok(()) +} + /// Resolve a multiline into styled parallel lines in WCS. pub fn resolved_mline_style<'a>( m: &MLine,