From 21ce4b0e2b388ffeea272c452c7d857096d88c9c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:13:23 +0200 Subject: [PATCH] Map ViewBorder in the Python host model; record DXF kind blocker Bounds, scale and rotation are writable with viewport and scale reference rules. staged_python_view_border_lifecycle_over_real_ipc passes every gate in DWG; a DXF DRAWINGVIEW reopens as an opaque entity, pinned by a canary. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 13 +++++ crates/ocs_plugin_api/src/entity_coverage.rs | 47 ++++++++++++++++ docs/plugin-host-model-completion-plan.md | 9 ++-- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 12 ++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 54 +++++++++++++++++++ 10 files changed, 137 insertions(+), 11 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 15b84f9f2..030115d49 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -795,6 +795,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Wipeout", _) | ("Underlay", _) | ("Viewport", _) + | ("ViewBorder", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 978082943..533e50723 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -452,6 +452,16 @@ "render_mode", "circle_sides", "ucs_icon_visible" + ], + "ViewBorder": [ + "layer", + "min", + "max", + "center", + "scale", + "rotation_angle", + "active_viewport", + "scale_handle" ] }, "readable": { @@ -553,6 +563,9 @@ "brightness", "contrast", "ambient_color" + ], + "ViewBorder": [ + "version" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 6d44d4385..e20e03490 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -949,6 +949,32 @@ fn validate_viewport( Ok(()) } +/// ViewBorder: the frame of a drawing view, tied to a viewport and a scale. +#[cfg(feature = "host")] +fn validate_view_border( + old: Option<&crate::host::acadrust::entities::ViewBorder>, + new: &crate::host::acadrust::entities::ViewBorder, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + for (name, pair) in [("min", new.min), ("max", new.max), ("center", new.center)] { + if !pair[0].is_finite() || !pair[1].is_finite() { + return Err(format!("ViewBorder.{name} must be finite")); + } + } + if new.min[0] >= new.max[0] || new.min[1] >= new.max[1] { + return Err("ViewBorder.min must be below max on both axes".into()); + } + if !new.scale.is_finite() || new.scale <= 0.0 { + return Err("ViewBorder.scale must be finite and greater than zero".into()); + } + if !new.rotation_angle.is_finite() { + return Err("ViewBorder.rotation_angle must be finite".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")] @@ -1053,6 +1079,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Wipeout(value) => validate_wipeout(None, value)?, EntityType::Underlay(value) => validate_underlay(None, value)?, EntityType::Viewport(value) => validate_viewport(None, value)?, + EntityType::ViewBorder(value) => validate_view_border(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1418,6 +1445,7 @@ pub fn validate_entity_mutation( (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, + (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, (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)) => { @@ -1681,6 +1709,17 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::ViewBorder(value) = entity { + if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { + return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); + } + if !matches!( + document.objects.get(&value.scale_handle), + Some(crate::host::acadrust::objects::ObjectType::Scale(_)) + ) { + return Err(format!("ViewBorder scale {:?} does not exist", value.scale_handle)); + } + } if let EntityType::Viewport(value) = entity { let owner = value.common.owner_handle; let in_paper_space = !owner.is_null() @@ -2144,6 +2183,7 @@ mod tests { | "Wipeout" | "Underlay" | "Viewport" + | "ViewBorder" ) { assert!( @@ -2459,6 +2499,13 @@ mod tests { "Viewport.render_mode".to_owned(), "Viewport.circle_sides".to_owned(), "Viewport.ucs_icon_visible".to_owned(), + "ViewBorder.min".to_owned(), + "ViewBorder.max".to_owned(), + "ViewBorder.center".to_owned(), + "ViewBorder.scale".to_owned(), + "ViewBorder.rotation_angle".to_owned(), + "ViewBorder.active_viewport".to_owned(), + "ViewBorder.scale_handle".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 752441150..fd7834de7 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 **35 of 43** canvas kinds: 34 -are creatable and `Insert` remains update-only. This is **not** 35 completed +v7. The Python feature has explicit writes for **36 of 43** canvas kinds: 35 +are creatable and `Insert` remains update-only. This is **not** 36 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout 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). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 8 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). Leader, Helix, Underlay and ViewBorder are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 7 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 **ViewBorder** (queue item 14). Leader is done except for its W +Continue with **SectionSymbol** (queue item 15). 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`. @@ -168,3 +168,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. - Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. - Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. +- 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`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index cd29a97ba..805a8a05c 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: **35 of 43 canvas kinds have explicit geometry writes** (34 creatable, plus update-only `Insert`), **12 kinds are complete**, and 8 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: **36 of 43 canvas kinds have explicit geometry writes** (35 creatable, plus update-only `Insert`), **12 kinds are complete**, and 7 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. @@ -41,7 +41,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | -| ViewBorder | Layer-only | Requires linked view records. | +| ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | Layer-only | Requires section-view style and point records. | | Light | Layer-only | Requires position/target and photometric-state fixture. | | Region | Layer-only | Requires valid ACIS region payload. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index dad2dc77b..50ef30ea2 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 35 geometry converters. Other writable properties +canvas kinds outside its 36 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 35-kind generated schema it contains only handle, kind, and layer; +outside the 36-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 9bba529fc..6ba899e49 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 35 mapped kinds. +the 36 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 592f5f9e0..2851e1076 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -34,7 +34,8 @@ "RasterImage", "Wipeout", "Underlay", - "Viewport" + "Viewport", + "ViewBorder" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -403,6 +404,15 @@ "width": { "required": true }, "height": { "required": true } } + }, + "ViewBorder": { + "fields": { + "version": { "read_only": true }, + "min": { "required": true }, + "max": { "required": true }, + "active_viewport": { "required": true }, + "scale_handle": { "required": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 48bb52533..a6e8589ec 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 35); + assert_eq!(emitted.len(), 36); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6d7057d6a..277ff0c00 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3197,6 +3197,20 @@ mod tests { let mut create_script = case.create.replace("TMPDIR", &tmp); 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") { + // A view border ties to an existing viewport and scale. + let owner = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.common.owner_handle = owner; + let viewport_handle = host.add_entity(EntityType::Viewport(viewport)); + let scale_handle = host.document_mut().allocate_handle(); + let mut scale = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale.handle = scale_handle; + host.document_mut().objects.insert(scale_handle, acadrust::objects::ObjectType::Scale(scale)); + create_script = create_script + .replace("VIEWPORTHANDLE", &viewport_handle.value().to_string()) + .replace("SCALEHANDLE", &scale_handle.value().to_string()); + } create_script = create_script .replace("PAPERSPACE", &paper.unwrap_or_default().to_string()) .replace("LAYER0", &layer0.unwrap_or_default().to_string()); @@ -3637,6 +3651,46 @@ mod tests { }); } + #[test] + fn staged_python_view_border_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('ViewBorder', min=[0.0, 0.0], max=[40.0, 30.0], center=[20.0, 15.0], scale=1.0,\n", + " active_viewport=VIEWPORTHANDLE, scale_handle=SCALEHANDLE)\n"), + edit: concat!( + "vb = doc.entities[HANDLE]\n", + "with doc.transaction('Edit view border'):\n", + " vb.max = [60.0, 45.0]\n", + " vb.center = [30.0, 22.5]\n", + " vb.scale = 2.0\n", + " vb.rotation_angle = 0.3\n", + "doc.selection = [vb]\n"), + rejects: &[ + ("'max':[-1.0, 5.0]", "below max"), + ("'scale':0.0", "greater than zero"), + ("'rotation_angle':float('nan')", "finite"), + ("'center':[float('inf'), 0.0]", "finite"), + ("'active_viewport':999999", "does not exist"), + ("'scale_handle':999999", "does not exist"), + ("'version':3", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::ViewBorder(_)), + digest: |entity| match entity { + EntityType::ViewBorder(v) => format!("max{:.1},{:.1} c{:.1},{:.1} s{:.1} r{:.1}", v.max[0], v.max[1], + v.center[0], v.center[1], v.scale, v.rotation_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::ViewBorder(v) = entity { v.scale = 3.0; }, + expect_created: "max40.0,30.0 c20.0,15.0 s1.0 r0.0", + expect_edited: "max60.0,45.0 c30.0,22.5 s2.0 r0.3", + 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_dxf: "wrong kind", + expect_reedited_dxf: "wrong kind", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {