diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 71f39498f..1d60b1952 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", _) + | ("Ole2Frame", _) | ("Underlay", _) | ("Viewport", _) | ("ViewBorder", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 549f1d767..62bcdae23 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -486,6 +486,13 @@ "shadow_map_softness", "photometric_mode", "photometric_data" + ], + "Ole2Frame": [ + "layer", + "upper_left_corner", + "lower_right_corner", + "lock_aspect", + "is_paper_space" ] }, "readable": { @@ -590,6 +597,12 @@ ], "Light": [ "class_version" + ], + "Ole2Frame": [ + "version", + "source_application", + "ole_object_type", + "dwg_mode" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index fe30e534a..74c1056e1 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1223,6 +1223,28 @@ fn validate_basic_entity( } } +/// Ole2Frame: only the frame placement is scriptable; the embedded object +/// storage is never rewritten. +#[cfg(feature = "host")] +fn validate_ole_frame( + old: Option<&crate::host::acadrust::entities::Ole2Frame>, + new: &crate::host::acadrust::entities::Ole2Frame, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Ole2Frame.upper_left_corner", &new.upper_left_corner)?; + finite_vector("Ole2Frame.lower_right_corner", &new.lower_right_corner)?; + let (a, b) = (new.upper_left_corner, new.lower_right_corner); + if (a.x - b.x).abs() <= 1.0e-12 || (a.y - b.y).abs() <= 1.0e-12 { + return Err("Ole2Frame corners must span a nonzero width and height".into()); + } + if new.lock_aspect > 1 { + return Err("Ole2Frame.lock_aspect must be 0 or 1".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")] @@ -1348,6 +1370,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Insert array counts must be greater than zero".into()); } } + EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1716,6 +1739,12 @@ pub fn validate_entity_mutation( (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, (EntityType::Light(old), EntityType::Light(new)) => validate_light(Some(old), new)?, + (EntityType::Ole2Frame(old), EntityType::Ole2Frame(new)) => { + validate_ole_frame(Some(old), new)?; + if old.storage != new.storage || old.envelope != new.envelope { + return Err("Ole2Frame embedded storage cannot change".into()); + } + } (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2502,6 +2531,7 @@ mod tests { | "Viewport" | "ViewBorder" | "Light" + | "Ole2Frame" ) { assert!( @@ -2787,6 +2817,10 @@ mod tests { "Wipeout.clip_mode".to_owned(), "Wipeout.clip_type".to_owned(), "Wipeout.clip_boundary_vertices".to_owned(), + "Ole2Frame.upper_left_corner".to_owned(), + "Ole2Frame.lower_right_corner".to_owned(), + "Ole2Frame.lock_aspect".to_owned(), + "Ole2Frame.is_paper_space".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 64d11d655..d8f370b87 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 **37 of 43** canvas kinds: 36 -are creatable (the legacy `Polyline` is update-only). This is **not** 37 completed +v7. The Python feature has explicit writes for **38 of 43** canvas kinds: 36 +are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 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 and Face3D 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 other 6 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface, Ole2Frame) have only `layer` and are 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 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 other 5 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface) have only `layer` and are recorded as blocked 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. @@ -175,3 +175,4 @@ exact queue above and update statuses with evidence after each increment. - Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. +- Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 13a1f79e5..2389c4171 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: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **26 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: **38 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline` and `Ole2Frame`), **26 kinds are complete**, and 5 remain layer-only and blocked (SectionSymbol, Region, Body, Solid3D, Surface). `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. @@ -48,7 +48,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Body | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | | Solid3D | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | | Surface | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Ole2Frame | **Blocked** | Not mapped. A valid OLE frame carries a `StructuredStoragePayload` (a compound-file image of the embedded object) that a script cannot supply, so creation is impossible, and frame-geometry edits (`upper_left_corner`, `lower_right_corner`, `lock_aspect`) would be an update-only path that has no real embedded-object fixture to prove storage and aspect persistence through DWG and DXF. Adding it needs a fixture builder for a minimal valid OLE storage plus a proven round-trip of the payload; until then the kind keeps raw snapshot access and layer edits. | +| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | After each increment, update the row with test names, fixture paths, exact passing commands, the OCS commit hash, and any manual GUI result. Host and diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 3fbae22ad..375e3bb3c 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 37 geometry converters. Other writable properties +canvas kinds outside its 38 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 37-kind generated schema it contains only handle, kind, and layer; +outside the 38-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 aac19fde3..bfe545089 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 37 mapped kinds. +the 38 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 5126e4464..5bca21be9 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -36,7 +36,8 @@ "Underlay", "Viewport", "ViewBorder", - "Light" + "Light", + "Ole2Frame" ], "manual_kinds": [ "Dimension" @@ -780,6 +781,32 @@ "required": true } } + }, + "Ole2Frame": { + "update_only": true, + "update_only_message": "Ole2Frame cannot be created from a script: its embedded object storage is opaque; existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", + "fields": { + "storage": { + "exclude": true, + "_comment": "Opaque compound-file payload; snapshot-readable and preserved untouched." + }, + "envelope": { + "exclude": true, + "_comment": "Opaque frame envelope; preserved untouched." + }, + "version": { + "read_only": true + }, + "source_application": { + "read_only": true + }, + "ole_object_type": { + "read_only": true + }, + "dwg_mode": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index f6b565f56..1d348c8b9 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4780,7 +4780,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 37); + assert_eq!(emitted.len(), 38); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e9fc9d03c..a1f7d6859 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3212,7 +3212,28 @@ mod tests { .map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z)); host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec()))); } - let update_only = create_script.contains("MAKEINSERT") || make_polyline; + let make_ole = create_script.contains("MAKEOLE"); + if make_ole { + // An Ole2Frame is update-only: the host embeds a real picture. + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let picture = crate::io::ole_embed::EmbeddedImage { + bytes: png.into_inner(), + pixel_width: 4, + pixel_height: 3, + name: "audit.png".into(), + }; + crate::io::ole_embed::add_embedded_image( + host.document_mut(), + &picture, + acadrust::types::Vector3::new(10.0, 10.0, 0.0), + 20.0, + ) + .unwrap(); + } + let update_only = create_script.contains("MAKEINSERT") || make_polyline || make_ole; if update_only || create_script.contains("MAKEBLOCK") { let next = host.document().next_handle(); let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); @@ -4387,6 +4408,47 @@ mod tests { assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host)); } + #[test] + fn audit_python_ole2frame_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "# MAKEOLE\n", + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.upper_left_corner = (12.0, 40.0, 0.0)\n", + " e.lower_right_corner = (42.0, 10.0, 0.0)\n", + " e.lock_aspect = 1\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'upper_left_corner':{'x':42.0,'y':40.0,'z':0.0}", "nonzero width and height"), + ("'lock_aspect':2", "0 or 1"), + ("'lower_right_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'version':3", "read-only"), + ("'storage':{}", "outside the editable schema"), + ], + is_kind: |entity| matches!(entity, EntityType::Ole2Frame(_)), + digest: |entity| match entity { + EntityType::Ole2Frame(v) => { + let picture = match acadrust::entities::extract_presentation(&v.encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes.len(), + _ => 0, + }; + format!("ul{:.1},{:.1} lr{:.1},{:.1} lock{} picture{}", v.upper_left_corner.x, v.upper_left_corner.y, + v.lower_right_corner.x, v.lower_right_corner.y, v.lock_aspect, picture) + } + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ole2Frame(v) = entity { v.lower_right_corner.x = 50.0; }, + expect_created: "ul10.0,25.0 lr30.0,10.0 lock0 picture119", + expect_edited: "ul12.0,40.0 lr42.0,10.0 lock1 picture119", + expect_reedited: "ul12.0,40.0 lr50.0,10.0 lock1 picture119", + 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 {