From bd43ed7e91318755bb76685ec4232a0c82cac7a4 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:44:26 +0200 Subject: [PATCH] Audit the 17 pre-existing mapped kinds; add shared basic-geometry validation Runs every first-generation kind through the real-runner lifecycle driver. The audit found Ellipse unvalidated, zero-height Text/MText and non-finite Arc angles and Point/Line thickness accepted, and no creation-time checks for most kinds. validate_basic_entity now covers them and refuses only problems an edit introduces. Twelve kinds pass every gate; Polyline, Polyline2D, Polyline3D and Spline carry pinned persistence findings, and Insert is update-only. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/entity_coverage.rs | 205 ++++++++ docs/plugin-host-model-completion-plan.md | 3 +- docs/plugin-host-model-coverage-ledger.md | 36 +- src/app/plugin_host.rs | 516 ++++++++++++++++++- 4 files changed, 737 insertions(+), 23 deletions(-) diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 395008570..54f9d1430 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1020,6 +1020,209 @@ fn validate_light( Ok(()) } +/// Every geometry problem of a first-generation mapped kind. Messages name +/// the field, so an edit can be compared against the entity it replaces. +#[cfg(feature = "host")] +fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { + use crate::host::EntityType; + let mut errs: Vec = Vec::new(); + macro_rules! chk { + ($e:expr) => { + if let Err(error) = $e { + errs.push(error); + } + }; + } + let finite = |name: &str, value: f64| { + if value.is_finite() { + Ok(()) + } else { + Err(format!("{name} must be finite")) + } + }; + let positive = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + match new { + EntityType::Point(v) => { + chk!(finite_vector("Point.location", &v.location)); + chk!(finite("Point.thickness", v.thickness)); + chk!(finite("Point.x_axis_angle", v.x_axis_angle)); + chk!(solid_normal("Point.normal", &v.normal)); + } + EntityType::Line(v) => { + chk!(finite_vector("Line.start", &v.start)); + chk!(finite_vector("Line.end", &v.end)); + chk!(finite("Line.thickness", v.thickness)); + chk!(solid_normal("Line.normal", &v.normal)); + } + EntityType::Circle(v) => { + chk!(finite_vector("Circle.center", &v.center)); + chk!(positive("Circle.radius", v.radius)); + chk!(finite("Circle.thickness", v.thickness)); + chk!(solid_normal("Circle.normal", &v.normal)); + } + EntityType::Arc(v) => { + chk!(finite_vector("Arc.center", &v.center)); + chk!(positive("Arc.radius", v.radius)); + chk!(finite("Arc.start_angle", v.start_angle)); + chk!(finite("Arc.end_angle", v.end_angle)); + chk!(finite("Arc.thickness", v.thickness)); + chk!(solid_normal("Arc.normal", &v.normal)); + } + EntityType::Ellipse(v) => { + chk!(finite_vector("Ellipse.center", &v.center)); + chk!(solid_normal("Ellipse.major_axis", &v.major_axis)); + if !v.minor_axis_ratio.is_finite() || v.minor_axis_ratio <= 0.0 || v.minor_axis_ratio > 1.0 { + errs.push("Ellipse.minor_axis_ratio must be within (0, 1]".into()); + } + chk!(finite("Ellipse.start_parameter", v.start_parameter)); + chk!(finite("Ellipse.end_parameter", v.end_parameter)); + chk!(solid_normal("Ellipse.normal", &v.normal)); + } + EntityType::Text(v) => { + chk!(finite_vector("Text.insertion_point", &v.insertion_point)); + if let Some(alignment) = &v.alignment_point { + chk!(finite_vector("Text.alignment_point", alignment)); + } + chk!(positive("Text.height", v.height)); + chk!(finite("Text.rotation", v.rotation)); + chk!(finite("Text.oblique_angle", v.oblique_angle)); + if !v.width_factor.is_finite() || v.width_factor <= 0.0 { + errs.push("Text.width_factor must be finite and greater than zero".into()); + } + chk!(finite("Text.thickness", v.thickness)); + chk!(solid_normal("Text.normal", &v.normal)); + } + EntityType::MText(v) => { + chk!(finite_vector("MText.insertion_point", &v.insertion_point)); + chk!(positive("MText.height", v.height)); + chk!(finite("MText.rotation", v.rotation)); + if !v.rectangle_width.is_finite() || v.rectangle_width < 0.0 { + errs.push("MText.rectangle_width must be finite and non-negative".into()); + } + if let Some(height) = v.rectangle_height { + chk!(finite("MText.rectangle_height", height)); + } + chk!(positive("MText.line_spacing_factor", v.line_spacing_factor)); + chk!(solid_normal("MText.normal", &v.normal)); + } + EntityType::LwPolyline(v) => { + if v.vertices.len() < 2 { + errs.push("LwPolyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + if !vertex.location.x.is_finite() || !vertex.location.y.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}].location must be finite")); + } + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}] has a non-finite bulge or width")); + } + } + if !v.constant_width.is_finite() || v.constant_width < 0.0 { + errs.push("LwPolyline.constant_width must be finite and non-negative".into()); + } + chk!(finite("LwPolyline.elevation", v.elevation)); + chk!(finite("LwPolyline.thickness", v.thickness)); + chk!(solid_normal("LwPolyline.normal", &v.normal)); + } + EntityType::Polyline2D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline2D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline2D.vertices[{i}].location"), &vertex.location)); + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("Polyline2D.vertices[{i}] has a non-finite bulge or width")); + } + } + for (name, value) in [("start_width", v.start_width), ("end_width", v.end_width), ("thickness", v.thickness), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline2D.{name}"), value)); + } + chk!(solid_normal("Polyline2D.normal", &v.normal)); + } + EntityType::Polyline(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline.vertices[{i}].location"), &vertex.location)); + } + } + EntityType::Polyline3D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline3D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline3D.vertices[{i}].position"), &vertex.position)); + } + for (name, value) in [("default_start_width", v.default_start_width), ("default_end_width", v.default_end_width), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline3D.{name}"), value)); + } + chk!(solid_normal("Polyline3D.normal", &v.normal)); + } + EntityType::Spline(v) => { + if v.degree < 1 { + errs.push("Spline.degree must be at least 1".into()); + } + let degree = v.degree as usize; + if v.control_points.len() <= degree { + errs.push("Spline needs more control points than its degree".into()); + } + for (i, point) in v.control_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.control_points[{i}]"), point)); + } + for (i, point) in v.fit_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.fit_points[{i}]"), point)); + } + if v.knots.len() != v.control_points.len() + degree + 1 { + errs.push(format!( + "Spline.knots must hold {} values for {} control points of degree {degree}", + v.control_points.len() + degree + 1, + v.control_points.len() + )); + } + if v.knots.iter().any(|k| !k.is_finite()) || v.knots.windows(2).any(|w| w[1] < w[0]) { + errs.push("Spline.knots must be finite and nondecreasing".into()); + } + if !v.weights.is_empty() { + if v.weights.len() != v.control_points.len() { + errs.push("Spline.weights must match the control points".into()); + } + if v.weights.iter().any(|w| !w.is_finite() || *w <= 0.0) { + errs.push("Spline.weights must be finite and greater than zero".into()); + } + } + chk!(solid_normal("Spline.normal", &v.normal)); + } + _ => {} + } + errs +} + +/// Creation must be fully valid. An edit is refused only for problems it +/// introduces: a legacy value that was already invalid and is untouched +/// keeps the entity editable. +#[cfg(feature = "host")] +fn validate_basic_entity( + old: Option<&crate::host::EntityType>, + new: &crate::host::EntityType, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + let errors = basic_entity_errors(new); + let existing = old.map(basic_entity_errors).unwrap_or_default(); + match errors.into_iter().find(|error| !existing.contains(error)) { + Some(error) => Err(error), + None => 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")] @@ -1028,6 +1231,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } + validate_basic_entity(None, entity)?; match entity { EntityType::Ray(value) => { finite_vector("Ray.base_point", &value.base_point)?; @@ -1283,6 +1487,7 @@ pub fn validate_entity_mutation( Ok(()) } }; + validate_basic_entity(Some(before), after)?; match (before, after) { (EntityType::Point(old), EntityType::Point(new)) => { if !same3(&old.location, &new.location) { diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 9b3bf3ebe..b4f8fff04 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,7 +9,7 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 are creatable and `Insert` remains update-only. This is **not** 37 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; the audited Polyline, Polyline2D, Polyline3D, Spline and Insert pass every gate that their format or update-only status allows. 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 @@ -171,3 +171,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. - Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. - Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. +- Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a45e5e9e1..c98c5d441 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,28 +1,28 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **12 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **24 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. 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. | Kind | Baseline | Gate evidence / blocker | |---|---|---| -| Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | -| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle and nonfinite-coordinate rejection, full typed-entity preservation, Line object-pick routing, and edited DWG/DXF reopen pass. A second editable property outside defining endpoint geometry still needs integration coverage before this row is complete. | -| Circle | Mapped | Full gate pending. | -| Arc | Mapped | Full gate pending. | -| Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | -| Polyline | Mapped | Full gate pending. | -| Polyline2D | Mapped | Full gate pending. | -| Polyline3D | Mapped | Full gate pending. | -| LwPolyline | Mapped | Full gate pending. | -| Spline | Mapped | Full gate pending. | -| Text | Mapped | Full gate pending. | -| MText | Mapped | Full gate pending. | -| Ray | Mapped | Focused DWG round trip; full gate pending. | -| XLine | Mapped | Focused DWG round trip; full gate pending. | -| Solid | Mapped | Focused DWG round trip; full gate pending. | -| Face3D | Mapped | Focused DWG round trip; full gate pending. | -| Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | +| Point | **Complete** | Exercised: `location`, `thickness`. Rejects non-finite location, thickness and axis angle and a zero normal, which the audit found were previously accepted (thickness and normal) or unchecked at creation. `audit_python_point_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Line | **Complete** | Exercised: `end`, `thickness`. Existing endpoint, undo, selection and pick coverage plus the second-property edit the earlier row asked for. Rejects non-finite endpoints and thickness and a zero normal (thickness was previously accepted). `audit_python_line_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Circle | **Complete** | Exercised: `radius`, `center`. Rejects a non-positive or non-finite radius, non-finite center and thickness, and a zero normal. `audit_python_circle_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Arc | **Complete** | Exercised: `end_angle`, `radius`. Rejects a non-positive radius, non-finite angles (previously accepted), center and thickness, and a zero normal. `audit_python_arc_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Ellipse | **Complete** | Exercised: `minor_axis_ratio`, `end_parameter`. The audit found Ellipse had no validation at all; it now requires a nonzero major axis, a ratio within (0, 1], finite parameters and a nonzero normal. `audit_python_ellipse_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline | **Integration-tested / kind changes on save** | Exercised: `vertices`, `closed`. The legacy `Polyline` (3-D vertices) passes create, edit, reject, undo and canvas gates, and its vertices and closed state survive, but both writers save it as a 3-D polyline, so it reopens from DWG and DXF as `Polyline3D`. Pinned by per-format canary expectations. Decide whether to keep `Polyline` scriptable or steer scripts to `Polyline3D`. `audit_python_polyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Spline | **Integration-tested / blocked at W by acadrust (DXF weights)** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. Passes every gate in DWG; the DXF reader does not restore `weights` (a rational spline reopens non-rational; pinned by a canary). `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Text | **Complete** | Exercised: `text`, `rotation`, `height`. Rejects a non-positive height (previously accepted), a non-positive width factor, non-finite placement and angles, and a zero normal. Style and alignment fields are mapped but not individually exercised. `audit_python_text_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| MText | **Complete** | Exercised: `text`, `rectangle_width`, `rotation`. Rejects a non-positive height (previously accepted), a negative width, non-finite placement, a non-positive line-spacing factor and a zero normal. Attachment, columns and background fields are mapped but not individually exercised. `audit_python_mtext_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Ray | **Complete** | Exercised: `base_point`, `direction`. Unit-direction and finite-point validation already existed; the audit added the full DWG and DXF reopen and re-edit gate. `audit_python_ray_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| XLine | **Complete** | Exercised: `base_point`, `direction`. Same as Ray. `audit_python_xline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Solid | **Complete** | Exercised: `fourth_corner`, `thickness`. Finite corners, thickness and normal validation already existed; the audit added the full gate. `audit_python_solid_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Face3D | **Complete** | Exercised: `third_corner`, `invisible_edges`. Finite corners and known edge bits validation already existed; the audit added the full gate. `audit_python_face3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Insert | **Update-only (all gates pass)** | Exercised: `insert_point`, `x_scale`, `y_scale`, `rotation`, `column_count`, `column_spacing`. `audit_python_insert_lifecycle_over_real_ipc` passes E/D/U/I/W/V/P with a host-made block and insert: a six-property transaction edit, canvas wires, rejection of a zero scale, non-finite rotation and point, a zero array count, a zero normal and a `block_name` change (read-only), an atomic rollback, DWG and DXF reopen with re-edit, and undo/redo. Not `Complete` because Python cannot create an Insert (creation and block-reference semantics are an open decision: permanent update-only, or a create path that names an existing block). | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / 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. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 675c73a33..a23cd5b43 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3164,6 +3164,9 @@ mod tests { /// same as DWG). A canary that fails once the engine round-trips it. expect_edited_dxf: &'static str, expect_reedited_dxf: &'static str, + /// Same idea for DWG (empty: same as the in-session digest). + expect_edited_dwg: &'static str, + expect_reedited_dwg: &'static str, } fn run_mesh_lifecycle(case: &MeshCase) { @@ -3195,6 +3198,28 @@ mod tests { let tmp = std::env::temp_dir().to_string_lossy().into_owned(); let mut create_script = case.create.replace("TMPDIR", &tmp); + // Insert is update-only: the host makes a block and one insert, and the + // "create" script only has to exist. + let update_only = create_script.contains("MAKEINSERT"); + if update_only { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new("AUDITBLK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new("AUDITBLK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + host.add_entity(EntityType::Insert(insert)); + } let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); if create_script.contains("VIEWPORTHANDLE") || create_script.contains("SCALEHANDLE") { @@ -3262,6 +3287,8 @@ mod tests { for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { let expected_digest = if format == "DXF" && !case.expect_edited_dxf.is_empty() { case.expect_edited_dxf + } else if format == "DWG" && !case.expect_edited_dwg.is_empty() { + case.expect_edited_dwg } else { case.expect_edited }; @@ -3287,6 +3314,8 @@ mod tests { let reopened = crate::io::load_bytes(name, bytes).unwrap(); let expected_digest = if name.ends_with("dxf") && !case.expect_reedited_dxf.is_empty() { case.expect_reedited_dxf + } else if name.ends_with("dwg") && !case.expect_reedited_dwg.is_empty() { + case.expect_reedited_dwg } else { case.expect_reedited }; @@ -3304,14 +3333,16 @@ mod tests { drop(process); drop(host); app.finish_pending_history(0); - assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + assert_eq!(app.tabs[0].history.undo_stack.len(), if update_only { 2 } else { 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); + if !update_only { + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + app.redo_steps(if update_only { 2 } else { 3 }); assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } @@ -3350,6 +3381,8 @@ mod tests { expect_created: "3x3 v9 z0 d0/0 NoSmooth", expect_edited: "3x3 v9 z2.5 d6/6 Cubic", expect_reedited: "3x3 v9 z2.5 d6/6 Cubic", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3393,6 +3426,8 @@ mod tests { expect_created: "v4 f2 z0 last Some((1, 3, 4))", expect_edited: "v5 f3 z3 last Some((3, -4, 5))", expect_reedited: "v5 f3 z3 last Some((3, -4, 5))", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3435,6 +3470,8 @@ mod tests { expect_created: "v4 f1 e1 sub0 crease Some(1.5)", expect_edited: "v5 f2 e1 sub1 crease Some(1.5)", expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3474,6 +3511,8 @@ mod tests { expect_reedited: "r5 t6 h2 endz10.0 ccwfalse cptrue", // BLOCKER: the DXF reader parses boolean group 290 as an i16 and // never applies it, so handedness always reopens counter-clockwise. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "r5 t5 h2 endz10.0 ccwtrue cptrue", expect_reedited_dxf: "r5 t6 h2 endz10.0 ccwtrue cptrue", }); @@ -3523,6 +3562,8 @@ mod tests { expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue", expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue", expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3562,6 +3603,8 @@ mod tests { expect_created: "Rectangular Outside n2 at0.0,0.0 u10.0 v6.0", expect_edited: "Polygonal Inside n3 at2.0,3.0 u10.0 v6.0", expect_reedited: "Polygonal Inside n3 at2.0,3.0 u12.0 v6.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3607,6 +3650,8 @@ mod tests { expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2", // BLOCKER: DXF stores the rotation in degrees and the reader returns // it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2", expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2", }); @@ -3646,6 +3691,8 @@ mod tests { expect_created: "at100.0,100.0 80.0x60.0 vh50.0 tw0.00 frozen0", expect_edited: "at120.0,110.0 90.0x60.0 vh75.0 tw0.25 frozen1", expect_reedited: "at120.0,110.0 95.0x60.0 vh75.0 tw0.25 frozen1", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3686,6 +3733,8 @@ mod tests { expect_reedited: "max60.0,45.0 c30.0,22.5 s3.0 r0.3", // BLOCKER: a DXF DRAWINGVIEW reopens as a different (opaque) entity // kind, not as a ViewBorder; only DWG restores the typed record. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "wrong kind", expect_reedited_dxf: "wrong kind", }); @@ -3728,11 +3777,470 @@ mod tests { expect_reedited: "Key t3 i4.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", // BLOCKER: DXF does not restore `cast_shadows` (it reopens false), // like the other boolean groups the reader parses as integers. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "Key t3 i3.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", expect_reedited_dxf: "Key t3 i4.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", }); } + #[test] + fn audit_python_point_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Point', location=P(1.0, 2.0, 3.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.location = (4.0, 5.0, 6.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'location':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Point(_)), + digest: |entity| match entity { + EntityType::Point(v) => format!("{:.1},{:.1},{:.1} th{:.1}", v.location.x, v.location.y, v.location.z, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Point(v) = entity { v.location.z = 9.0; }, + expect_created: "1.0,2.0,3.0 th0.0", + expect_edited: "4.0,5.0,6.0 th2.0", + expect_reedited: "4.0,5.0,9.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_line_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Line', start=P(0.0, 0.0, 0.0), end=P(10.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end = (10.0, 5.0, 0.0)\n", " e.thickness = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'start':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Line(_)), + digest: |entity| match entity { + EntityType::Line(v) => format!("{:.1},{:.1} th{:.1}", v.end.x, v.end.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Line(v) = entity { v.start.x = 1.0; }, + expect_created: "10.0,0.0 th0.0", + expect_edited: "10.0,5.0 th1.5", + expect_reedited: "10.0,5.0 th1.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_circle_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Circle', center=P(0.0, 0.0, 0.0), radius=5.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.radius = 7.0\n", " e.center = (1.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'radius':-3.0", "radius"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Circle(_)), + digest: |entity| match entity { + EntityType::Circle(v) => format!("{:.1},{:.1} r{:.1}", v.center.x, v.center.y, v.radius), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Circle(v) = entity { v.radius = 8.0; }, + expect_created: "0.0,0.0 r5.0", + expect_edited: "1.0,1.0 r7.0", + expect_reedited: "1.0,1.0 r8.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_arc_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Arc', center=P(0.0, 0.0, 0.0), radius=5.0, start_angle=0.0, end_angle=1.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end_angle = 2.0\n", " e.radius = 6.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'end_angle':float('nan')", "finite"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Arc(_)), + digest: |entity| match entity { + EntityType::Arc(v) => format!("r{:.1} {:.1}..{:.1}", v.radius, v.start_angle, v.end_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Arc(v) = entity { v.start_angle = 0.5; }, + expect_created: "r5.0 0.0..1.5", + expect_edited: "r6.0 0.0..2.0", + expect_reedited: "r6.0 0.5..2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ellipse_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ellipse', center=P(0.0, 0.0, 0.0), major_axis=P(10.0, 0.0, 0.0), minor_axis_ratio=0.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.minor_axis_ratio = 0.25\n", " e.end_parameter = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'minor_axis_ratio':0.0", "ratio"), + ("'minor_axis_ratio':2.0", "ratio"), + ("'major_axis':{'x':0.0,'y':0.0,'z':0.0}", "axis"), + ], + is_kind: |entity| matches!(entity, EntityType::Ellipse(_)), + digest: |entity| match entity { + EntityType::Ellipse(v) => format!("q{:.2} ..{:.1}", v.minor_axis_ratio, v.end_parameter), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ellipse(v) = entity { v.minor_axis_ratio = 0.75; }, + expect_created: "q0.50 ..6.3", + expect_edited: "q0.25 ..3.0", + expect_reedited: "q0.75 ..3.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ray_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ray', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Ray(_)), + digest: |entity| match entity { + EntityType::Ray(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ray(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_xline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('XLine', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::XLine(_)), + digest: |entity| match entity { + EntityType::XLine(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::XLine(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_solid_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Solid', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(0.0, 4.0, 0.0), fourth_corner=P(4.0, 4.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.fourth_corner = (5.0, 5.0, 0.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Solid(_)), + digest: |entity| match entity { + EntityType::Solid(v) => format!("{:.1},{:.1} th{:.1}", v.fourth_corner.x, v.fourth_corner.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Solid(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "4.0,4.0 th0.0", + expect_edited: "5.0,5.0 th2.0", + expect_reedited: "5.0,5.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_face3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Face3D', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(4.0, 4.0, 1.0), fourth_corner=P(0.0, 4.0, 1.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.third_corner = (4.0, 4.0, 2.0)\n", " e.invisible_edges = 3\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'invisible_edges':64", "bits"), + ], + is_kind: |entity| matches!(entity, EntityType::Face3D(_)), + digest: |entity| match entity { + EntityType::Face3D(v) => format!("z{:.1} inv{}", v.third_corner.z, v.invisible_edges.bits()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Face3D(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "z1.0 inv0", + expect_edited: "z2.0 inv3", + expect_reedited: "z2.0 inv3", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_text_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Text', text='Hello', insertion=P(1.0, 2.0, 0.0), height=2.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'World'\n", " e.rotation = 0.5\n", " e.height = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'height':-1.0", "height"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Text(_)), + digest: |entity| match entity { + EntityType::Text(v) => format!("{} h{:.1} r{:.1}", v.value, v.height, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Text(v) = entity { v.height = 4.0; }, + expect_created: "Hello h2.5 r0.0", + expect_edited: "World h3.0 r0.5", + expect_reedited: "World h4.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_mtext_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('MText', text='Line one', insertion=P(1.0, 2.0, 0.0), height=2.5, rectangle_width=30.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'Line two'\n", " e.rectangle_width = 40.0\n", " e.rotation = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'rectangle_width':-1.0", "width"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::MText(_)), + digest: |entity| match entity { + EntityType::MText(v) => format!("{} w{:.1} r{:.1}", v.value, v.rectangle_width, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::MText(v) = entity { v.rectangle_width = 50.0; }, + expect_created: "Line one w30.0 r0.0", + expect_edited: "Line two w40.0 r0.5", + expect_reedited: "Line two w50.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_lwpolyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('LwPolyline', vertices=[{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 5.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 8.0}, 'bulge': 0.5}, {'location': {'x': 0.0, 'y': 8.0}}]\n", " e.is_closed = True\n", " e.constant_width = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0}}]", "at least 2"), + ("'constant_width':-1.0", "non-negative"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0}},{'location':{'x':1.0,'y':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::LwPolyline(_)), + digest: |entity| match entity { + EntityType::LwPolyline(v) => format!("n{} closed{} w{:.1} bulge{:.1}", v.vertices.len(), v.is_closed, v.constant_width, v.vertices.get(2).map_or(0.0, |x| x.bulge)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::LwPolyline(v) = entity { v.constant_width = 1.0; }, + expect_created: "n3 closedfalse w0.0 bulge0.0", + expect_edited: "n4 closedtrue w0.5 bulge0.5", + expect_reedited: "n4 closedtrue w1.0 bulge0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_polyline2d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline2D', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 0.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 0.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 0.0}}]\n", " e.closed = True\n", " e.thickness = 1.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'thickness':float('inf')", "finite"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline2D(_)), + digest: |entity| match entity { + EntityType::Polyline2D(v) => format!("n{} closed{} th{:.1}", v.vertices.len(), v.flags.is_closed(), v.thickness), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline2D(v) = entity { v.thickness = 2.0; }, + expect_created: "n3 closedfalse th0.0", + expect_edited: "n4 closedtrue th1.0", + expect_reedited: "n4 closedtrue th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "wrong kind LwPolyline", + expect_reedited_dxf: "wrong kind LwPolyline", + }); + } + + #[test] + fn audit_python_polyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline(_)), + digest: |entity| match entity { + EntityType::Polyline(v) => format!("n{} closed{} lastz{:.1}", v.vertices.len(), v.flags.is_closed(), v.vertices.last().map_or(0.0, |x| x.location.z)), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline(v) = entity { v.vertices[0].location.z = 1.0; }, + expect_created: "n3 closedfalse lastz4.0", + expect_edited: "n4 closedtrue lastz6.0", + expect_reedited: "n4 closedtrue lastz6.0", + expect_edited_dwg: "wrong kind Polyline3D", + expect_reedited_dwg: "wrong kind Polyline3D", + expect_edited_dxf: "wrong kind Polyline3D", + expect_reedited_dxf: "wrong kind Polyline3D", + }); + } + + #[test] + fn audit_python_polyline3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline3D', vertices=[{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'position': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.elevation = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'elevation':float('inf')", "finite"), + ("'vertices':[{'position':{'x':float('nan'),'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline3D(_)), + digest: |entity| match entity { + EntityType::Polyline3D(v) => format!("n{} el{:.1} lastz{:.1}", v.vertices.len(), v.elevation, v.vertices.last().map_or(0.0, |x| x.position.z)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Polyline3D(v) = entity { v.elevation = 2.5; }, + expect_created: "n3 el0.0 lastz4.0", + expect_edited: "n4 el1.5 lastz6.0", + expect_reedited: "n4 el2.5 lastz6.0", + expect_edited_dwg: "n4 el0.0 lastz6.0", + expect_reedited_dwg: "n4 el0.0 lastz6.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_spline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Spline', degree=2, knots=[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], control_points=[{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 5.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.control_points = [{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 8.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}]\n", " e.weights = [1.0, 2.0, 1.0]\n", "doc.selection = [e]\n"), + rejects: &[ + ("'degree':0", "at least 1"), + ("'knots':[0.0,1.0]", "must hold"), + ("'weights':[1.0,0.0,1.0]", "greater than zero"), + ("'weights':[1.0]", "match"), + ("'control_points':[{'x':float('nan'),'y':0.0,'z':0.0},{'x':1.0,'y':0.0,'z':0.0},{'x':2.0,'y':0.0,'z':0.0}]", "finite"), + ("'knots':[0.0,0.0,1.0,0.5,1.0,1.0]", "nondecreasing"), + ], + is_kind: |entity| matches!(entity, EntityType::Spline(_)), + digest: |entity| match entity { + EntityType::Spline(v) => format!("deg{} cp{} y{:.1} w{:?}", v.degree, v.control_points.len(), v.control_points[1].y, v.weights), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Spline(v) = entity { v.control_points[1].y = 9.0; }, + expect_created: "deg2 cp3 y5.0 w[]", + expect_edited: "deg2 cp3 y8.0 w[1.0, 2.0, 1.0]", + expect_reedited: "deg2 cp3 y9.0 w[1.0, 2.0, 1.0]", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "deg2 cp3 y8.0 w[]", + expect_reedited_dxf: "deg2 cp3 y9.0 w[]", + }); + } + + #[test] + fn audit_python_insert_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "# MAKEINSERT\n", + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.insert_point = (4.0, 5.0, 0.0)\n", + " e.x_scale = 2.0\n", + " e.y_scale = 3.0\n", + " e.rotation = 0.5\n", + " e.column_count = 2\n", + " e.column_spacing = 10.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'x_scale':0.0", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'column_count':0", "greater than zero"), + ("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'block_name':'OTHER'", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Insert(_)), + digest: |entity| match entity { + EntityType::Insert(v) => format!("{} at{:.1},{:.1} s{:.1},{:.1} r{:.1} cols{}x{:.1}", v.block_name, v.insert_point.x, + v.insert_point.y, v.x_scale(), v.y_scale(), v.rotation, v.column_count, v.column_spacing), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Insert(v) = entity { v.rotation = 1.0; }, + expect_created: "AUDITBLK at1.0,2.0 s1.0,1.0 r0.0 cols1x0.0", + expect_edited: "AUDITBLK at4.0,5.0 s2.0,3.0 r0.5 cols2x10.0", + expect_reedited: "AUDITBLK at4.0,5.0 s2.0,3.0 r1.0 cols2x10.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {