From 942222c3811107d9c03fc2fb54fcf243919c4a7d Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 18:05:38 +0200 Subject: [PATCH] Map SectionSymbol, the last unmapped kind points is canonical; the host derives the point counts and refreshes the end/tick/label projections with sync_display_fields when it changes, and validates that style and view-representation references are existing class objects. The lifecycle test passes every gate in DWG; DXF reopens the symbol as an unknown entity because cadcodec dispatches SECTIONLINE only inside blocks (pinned by a canary and already in the cadcodec write-up). Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 20 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 56 +++++++++ docs/cadcodec-reader-gaps.md | 3 +- docs/plugin-host-model-completion-plan.md | 5 +- 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 | 44 ++++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 111 ++++++++++++++++++ 11 files changed, 242 insertions(+), 10 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 8da98a6f8..87387e1c5 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("SectionSymbol", _) | ("Surface", "u_isolines" | "v_isolines") | ("Ole2Frame", _) | ("Underlay", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 2d7bfc98b..435d37089 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -507,6 +507,13 @@ "layer", "u_isolines", "v_isolines" + ], + "SectionSymbol": [ + "layer", + "symbol_scale", + "points", + "style_handle", + "view_rep_handle" ] }, "readable": { @@ -638,6 +645,19 @@ "modeler_format_version", "point_of_reference", "history_handle" + ], + "SectionSymbol": [ + "view_symbol_version", + "raw_view_symbol_70", + "version", + "raw_point_count_90", + "raw_flags_90", + "raw_point_record_count", + "end_a", + "end_b", + "tick_a", + "tick_b", + "label" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index e36ded41d..d09bca4cb 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1263,6 +1263,33 @@ fn validate_surface( Ok(()) } +/// SectionSymbol: the cut mark drawn on a Model-Documentation view. `points` +/// is the canonical geometry; the counts and the end/tick/label projections are +/// derived by the host, so only the inputs are validated. +#[cfg(feature = "host")] +fn validate_section_symbol( + old: Option<&crate::host::acadrust::entities::SectionSymbol>, + new: &crate::host::acadrust::entities::SectionSymbol, +) -> Result<(), String> { + if old.is_some_and(|old| old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits()) { + return Ok(()); + } + if !new.symbol_scale.is_finite() || new.symbol_scale <= 0.0 { + return Err("SectionSymbol.symbol_scale must be finite and greater than zero".into()); + } + if new.points.len() < 2 { + return Err("SectionSymbol.points requires at least 2 points".into()); + } + for (index, point) in new.points.iter().enumerate() { + finite_vector(&format!("SectionSymbol.points[{index}].point"), &point.point)?; + finite_vector(&format!("SectionSymbol.points[{index}].label_offset"), &point.label_offset)?; + if !point.bulge.is_finite() { + return Err(format!("SectionSymbol.points[{index}].bulge must be finite")); + } + } + 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")] @@ -1390,6 +1417,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, EntityType::Surface(value) => validate_surface(None, value)?, + EntityType::SectionSymbol(value) => validate_section_symbol(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1765,6 +1793,7 @@ pub fn validate_entity_mutation( } } (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, + (EntityType::SectionSymbol(old), EntityType::SectionSymbol(new)) => validate_section_symbol(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)) => { @@ -2075,6 +2104,28 @@ pub fn validate_canvas_entity_references( } } } + if let EntityType::SectionSymbol(value) = entity { + use crate::host::acadrust::objects::{ClassObjectData, ObjectType}; + for (name, handle, wanted) in [ + ("style", value.style_handle, "SectionViewStyle"), + ("view representation", value.view_rep_handle, "ViewRep"), + ] { + if handle.is_null() { + continue; + } + let matches = match document.objects.get(&handle) { + Some(ObjectType::ClassObject(object)) => match (&object.data, wanted) { + (ClassObjectData::SectionViewStyle(_), "SectionViewStyle") => true, + (ClassObjectData::ViewRep(_), "ViewRep") => true, + _ => false, + }, + _ => false, + }; + if !matches { + return Err(format!("SectionSymbol {name} {handle:?} is not an existing {wanted} object")); + } + } + } 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)); @@ -2570,6 +2621,7 @@ mod tests { | "Body" | "Region" | "Surface" + | "SectionSymbol" ) { assert!( @@ -2861,6 +2913,10 @@ mod tests { "Ole2Frame.is_paper_space".to_owned(), "Surface.u_isolines".to_owned(), "Surface.v_isolines".to_owned(), + "SectionSymbol.symbol_scale".to_owned(), + "SectionSymbol.points".to_owned(), + "SectionSymbol.style_handle".to_owned(), + "SectionSymbol.view_rep_handle".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index e8f5cc277..9dbe422c4 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -151,7 +151,8 @@ reopens as a different, opaque entity kind instead of `ViewBorder`. DWG restores the typed record. The same applies to `SectionSymbol`. **Evidence:** `audit_python_view_border_lifecycle_over_real_ipc` (canary -`expect_edited_dxf: "wrong kind"`). +`expect_edited_dxf: "wrong kind"`) and `audit_python_section_symbol_lifecycle_over_real_ipc` +(canary `wrong kind Unknown`). **Suggested fix:** add the two arms to the ENTITIES dispatch, calling `read_view_border_dxf` and `read_section_symbol_dxf`. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 6008240c6..4ea89d902 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 **42 of 43** canvas kinds: 39 +v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 40 are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format 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 -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light 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 remaining canvas kind, SectionSymbol, has only `layer` and is recorded as blocked +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder, Light and SectionSymbol 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. Every canvas kind now has explicit writes; SectionSymbol is mapped and integration-tested but blocked at DXF persistence by cadcodec 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. @@ -182,3 +182,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. - Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. +- Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 3d3444c37..efa7ebdc1 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: **42 of 43 canvas kinds have explicit geometry writes** (39 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and 1 remains layer-only (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: **43 of 43 canvas kinds have explicit geometry writes** (40 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and none remains 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. @@ -42,7 +42,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | 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 | **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 | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | +| SectionSymbol | **Integration-tested / blocked at W by cadcodec (DXF SECTIONLINE)** | The earlier blocker was overstated, and reading cadcodec and OCS showed why. cadcodec documents `points` as the canonical geometry, says the point-count fields equal the point count in every verified ODA export, and ships `sync_display_fields()` to refresh the `end_a`/`end_b`/`tick_a`/`tick_b`/`label` projections; OCS's own grip and transform code already calls it. OCS also renders a symbol with no style and no view link. So the kind is mapped: writable `points` (each `{point, bulge, label, label_offset, raw_flag_280}`), `symbol_scale`, `style_handle` and `view_rep_handle`; read-only `view_symbol_version`, `raw_view_symbol_70`, `version`, `raw_flags_90`, the two derived counts and the five projections. The host sets `raw_point_count_90` and `raw_point_record_count` to the point count and calls `sync_display_fields()` whenever `points` is created or changed, and leaves a symbol whose points did not change exactly as read. Host validation requires at least two finite points with finite bulges and label offsets, a positive scale, and that each non-null reference is an existing `SectionViewStyle` (style) or `ViewRep` (view representation) class object; the wrong object kind is refused. Creating one without references gives a standalone symbol. `audit_python_section_symbol_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/V/P and DWG W: a two-point symbol, a three-point edit with a new label, bulge and scale in one transaction (counts and projections follow), canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, three-step undo/redo. `audit_python_section_symbol_references_over_real_ipc` covers valid references, a standalone symbol and four wrong-object refusals with host-made class objects. **W blocker (cadcodec, pinned by a canary; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatches `SECTIONLINE` only inside blocks, so a symbol in the drawing's entity list reopens as an `Unknown` entity. Open items that cannot be checked here: `raw_flags_90` has unknown semantics (0 for a new symbol, preserved on read), and whether AutoCAD accepts a symbol with no parent view. Scripts cannot create the style or view-representation objects. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 8bb6e0ca6..39a91be8c 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 42 geometry converters. Other writable properties +canvas kinds outside its 43 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 @@ -92,7 +92,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 42-kind generated schema it contains only handle, kind, and layer; +outside the 43-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 7f594836e..94a7796f5 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 42 mapped kinds. +the 43 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 014a750b6..05d48d57b 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -41,7 +41,8 @@ "Solid3D", "Body", "Region", - "Surface" + "Surface", + "SectionSymbol" ], "manual_kinds": [ "Dimension" @@ -926,6 +927,47 @@ "read_only": true } } + }, + "SectionSymbol": { + "fields": { + "points": { + "required": true + }, + "view_symbol_version": { + "read_only": true + }, + "raw_view_symbol_70": { + "read_only": true + }, + "version": { + "read_only": true + }, + "raw_point_count_90": { + "read_only": true + }, + "raw_flags_90": { + "read_only": true, + "_comment": "Semantics unknown in cadcodec; preserved as read, 0 for a new symbol." + }, + "raw_point_record_count": { + "read_only": true + }, + "end_a": { + "read_only": true + }, + "end_b": { + "read_only": true + }, + "tick_a": { + "read_only": true + }, + "tick_b": { + "read_only": true + }, + "label": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 2ff6cfc59..2f860727d 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4983,7 +4983,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 42); + assert_eq!(emitted.len(), 43); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 80c76ace3..dd3d531db 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,19 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::SectionSymbol(symbol) = entity { + // `points` is the canonical geometry: the point counts follow it + // (they equal the point count in every verified export) and the + // end, tick and label projections are refreshed from it. A symbol + // whose points did not change is left exactly as it was read. + let unchanged = matches!(old, Some(EntityType::SectionSymbol(previous)) if previous.points == symbol.points); + if !unchanged { + let count = i32::try_from(symbol.points.len()).unwrap_or(i32::MAX); + symbol.raw_point_count_90 = count; + symbol.raw_point_record_count = count; + symbol.sync_display_fields(); + } + } if let EntityType::Spline(spline) = entity { // The DXF writer emits weights only for a rational spline, so the // flag must follow the weights a script sets. @@ -5561,6 +5574,104 @@ mod tests { assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}"); } + #[test] + fn audit_python_section_symbol_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('SectionSymbol', symbol_scale=1.0, points=[\n", + " {'point': P(0.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)},\n", + " {'point': P(40.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)}])\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.points = [{'point': P(0.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, 3.0, 0.0)},\n", + " {'point': P(20.0, 10.0, 0.0), 'bulge': 0.0}, {'point': P(40.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, -3.0, 0.0)}]\n", + " e.symbol_scale = 2.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'points':[{'point':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2 points"), + ("'points':[{'point':{'x':float('nan'),'y':0.0,'z':0.0}},{'point':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'symbol_scale':0.0", "greater than zero"), + ("'style_handle':999999", "not an existing SectionViewStyle"), + ("'view_rep_handle':999999", "not an existing ViewRep"), + ("'raw_point_count_90':9", "read-only"), + ("'end_a':[1.0, 1.0]", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::SectionSymbol(_)), + digest: |entity| match entity { + EntityType::SectionSymbol(v) => format!("n{} ends {:.1},{:.1}->{:.1},{:.1} label{} tick{:.1}/{:.1} scale{:.1} counts{}/{}", + v.points.len(), v.end_a[0], v.end_a[1], v.end_b[0], v.end_b[1], v.label, v.tick_a, v.tick_b, + v.symbol_scale, v.raw_point_count_90, v.raw_point_record_count), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::SectionSymbol(v) = entity { v.symbol_scale = 3.0; }, + expect_created: "n2 ends 0.0,0.0->40.0,0.0 labelA tick2.0/2.0 scale1.0 counts2/2", + expect_edited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale2.0 counts3/3", + expect_reedited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale3.0 counts3/3", + // BLOCKER (cadcodec, see docs/cadcodec-reader-gaps.md issue 5): the + // DXF reader dispatches SECTIONLINE only inside blocks, so a symbol + // in the entity list reopens as an unknown entity. + expect_edited_dxf: "wrong kind Unknown", + expect_reedited_dxf: "wrong kind Unknown", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + #[test] + fn audit_python_section_symbol_references_over_real_ipc() { + use acadrust::objects::{ClassObject, ClassObjectData, ObjectType}; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let mut object = |host: &mut HostSession<'_>, data: ClassObjectData| { + let handle = host.document_mut().allocate_handle(); + let mut class_object = ClassObject::new(data); + class_object.handle = handle; + host.document_mut().objects.insert(handle, ObjectType::ClassObject(class_object)); + handle + }; + let style = object(&mut host, ClassObjectData::SectionViewStyle(Default::default())); + let view_rep = object(&mut host, ClassObjectData::ViewRep(Default::default())); + let scale = host.document_mut().allocate_handle(); + let mut scale_object = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale_object.handle = scale; + host.document_mut().objects.insert(scale, ObjectType::Scale(scale_object)); + 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 = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::SectionSymbol(_))).count(); + let create = |extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('SectionSymbol', points=[{{'point': {{'x': 0.0, 'y': 0.0, 'z': 0.0}}}}, {{'point': {{'x': 10.0, 'y': 0.0, 'z': 0.0}}}}]{extra}).handle"); + // A symbol may reference the drawing's own style and view representation. + dispatch(&mut host, &create(&format!(", style_handle={}, view_rep_handle={}", style.value(), view_rep.value()))); + assert_eq!(count(&host), 1, "{}", last(&host)); + let Some(EntityType::SectionSymbol(symbol)) = host.document().entities().find(|e| matches!(e, EntityType::SectionSymbol(_))) else { unreachable!() }; + assert_eq!((symbol.style_handle, symbol.view_rep_handle), (style, view_rep)); + // A standalone symbol needs neither. + dispatch(&mut host, &create("")); + assert_eq!(count(&host), 2, "{}", last(&host)); + // Wrong object kinds are refused: a style slot cannot hold a view rep, a + // scale object is neither, and a swapped pair fails. + for (extra, message) in [ + (format!(", style_handle={}", view_rep.value()), "not an existing SectionViewStyle"), + (format!(", style_handle={}", scale.value()), "not an existing SectionViewStyle"), + (format!(", view_rep_handle={}", style.value()), "not an existing ViewRep"), + (", view_rep_handle=424242".to_string(), "not an existing ViewRep"), + ] { + dispatch(&mut host, &create(&extra)); + assert_eq!(count(&host), 2, "{extra}"); + assert!(last(&host).contains(message), "{extra}: {}", last(&host)); + } + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {