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,