diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 435d37089..79b1fe3ec 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -448,7 +448,6 @@ "back_clip_z", "view_height", "twist_angle", - "custom_scale", "frozen_layers", "render_mode", "circle_sides", @@ -611,7 +610,8 @@ "default_lighting_type", "brightness", "contrast", - "ambient_color" + "ambient_color", + "custom_scale" ], "ViewBorder": [ "version" diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index d09bca4cb..a58dba1da 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -2942,7 +2942,6 @@ mod tests { "Viewport.back_clip_z".to_owned(), "Viewport.view_height".to_owned(), "Viewport.twist_angle".to_owned(), - "Viewport.custom_scale".to_owned(), "Viewport.frozen_layers".to_owned(), "Viewport.render_mode".to_owned(), "Viewport.circle_sides".to_owned(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d764823ee..953593033 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,11 +9,11 @@ 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 **43 of 43** canvas kinds: 41 are creatable (the legacy `Polyline` 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, Surface and Ole2Frame 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. +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, Surface, Ole2Frame and Viewport 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, 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 +`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 is held short of `Complete` by unverifiable image-dictionary linkage (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. @@ -184,3 +184,4 @@ exact queue above and update statuses with evidence after each increment. - 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. - Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. +- Completed `Viewport` by closing its missing canvas oracle and its derived state. The shared driver now switches the scene to the layout that owns a viewport and requires a drawn frame. The host assigns a unique id (at least 2) and derives `custom_scale = height / view_height` on every scripted create or edit, as MVIEW does, so `custom_scale` became read-only. A control showed my earlier suspicion was wrong: the default id 0 still draws a frame in a layout with no sheet viewport, so the id matters for uniqueness, not visibility. 31 kinds are complete. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index d072b7119..6bac2a9b6 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: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **30 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`. +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** (41 creatable, plus update-only legacy `Polyline` and `Body`), **31 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. @@ -40,7 +40,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | | 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. | +| Viewport | **Complete (OCS round trip)** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, `custom_scale`, snap and 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 and every frozen-layer handle an existing layer. **Derived by the host (new 2026-09-20), as the MVIEW command does:** a scripted viewport gets an `id` unique in its layout and at least 2 (max existing plus one), and `custom_scale` is `height / view_height`, recomputed whenever either changes, so `custom_scale` is now read-only (setting the scale directly would have let it drift from the geometry). 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. **Canvas oracle (new):** the shared driver now activates the layout that owns the viewport (`Scene::set_current_layout`) and requires the viewport to draw a frame both after creation and after the edit. `staged_python_viewport_lifecycle_over_real_ipc` passes C/R/E/D/U/I/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. `audit_python_viewport_ids_and_scale_over_real_ipc` checks unique ids (2, 3, 4), the derived scale on create and after a zoom and a resize, an id kept across edits, a rejected direct `custom_scale` write, and that every viewport draws a frame. A control showed that the engine default id 0 is **not** invisible in a layout with no sheet viewport (it draws a frame), which corrects an earlier suspicion; the derived id matters for uniqueness. Not covered: clip boundary entities (`clip_boundary_handle` is read-only and preserved), non-rectangular viewports, and AutoCAD acceptance of the written viewports, which cannot be tested here. | | 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 | **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. | diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 766aa6cf1..298087fed 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -752,6 +752,10 @@ }, "height": { "required": true + }, + "custom_scale": { + "read_only": true, + "_comment": "Derived by the host as height / view_height, like MVIEW." } } }, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e6daa7ef4..3c1628a9c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,26 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::Viewport(viewport) = entity { + // A viewport needs an id unique in its layout and at least 2 (id 1 + // is the never-drawn sheet viewport, and a lone id 0 can be taken for + // it), and its scale follows its paper height and model view height; + // both are what the MVIEW command sets. + if old.is_none() { + let owner = viewport.common.owner_handle; + let highest = self + .document() + .entities() + .filter_map(|e| match e { + EntityType::Viewport(other) if other.common.owner_handle == owner => Some(other.id), + _ => None, + }) + .max() + .unwrap_or(1); + viewport.id = (highest + 1).max(2); + } + viewport.custom_scale = viewport.height / viewport.view_height; + } 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 @@ -3682,12 +3702,18 @@ mod tests { .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); let created = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&created), case.expect_created); - // Viewports draw only on their paper-space layout tab, which this - // driver does not activate, so they have no model-tab wire oracle. - let has_wires = !matches!(created, EntityType::Viewport(_)); - if has_wires { - assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + // A paper-space viewport draws only on its layout tab, so the canvas + // oracle first activates the layout that owns it. + if matches!(created, EntityType::Viewport(_)) { + let owner = created.common().owner_handle; + let layout = host.document().objects.values().find_map(|object| match object { + acadrust::objects::ObjectType::Layout(l) if l.block_record == owner => Some(l.name.clone()), + _ => None, + }).expect("the viewport's owner is a layout block"); + host.app.tabs[0].scene.set_current_layout(layout); } + let has_wires = true; + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp).replace("LAYER0", &layer0.unwrap_or_default().to_string())); let expected = host.document().get_entity(handle).unwrap().clone(); @@ -5777,6 +5803,70 @@ mod tests { } } + #[test] + fn audit_python_viewport_ids_and_scale_over_real_ipc() { + 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 paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let layout = host.document().objects.values().find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.block_record == paper => Some(l.name.clone()), _ => None }).unwrap(); + host.app.tabs[0].scene.set_current_layout(layout); + 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 viewports = |host: &HostSession<'_>| { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Viewport(v) => Some((v.common.handle, v.id)), _ => None }).collect(); + list.sort_by_key(|(h, _)| h.value()); + list + }; + let create = |width: f64, height: f64, view_height: f64| format!( + "PY_EVAL ocs.active_document.create_entity('Viewport', owner_handle={}, center=(50, 50, 0), width={width}, height={height}, view_height={view_height}).handle", paper.value()); + dispatch(&mut host, &create(80.0, 60.0, 50.0)); + dispatch(&mut host, &create(40.0, 30.0, 10.0)); + dispatch(&mut host, &create(20.0, 20.0, 20.0)); + let list = viewports(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let ids: Vec = list.iter().map(|(_, id)| *id).collect(); + assert_eq!(ids, vec![2, 3, 4], "scripted viewports get unique ids of at least 2: {ids:?}"); + for (handle, expected) in [(list[0].0, 60.0 / 50.0), (list[1].0, 30.0 / 10.0), (list[2].0, 1.0)] { + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { unreachable!() }; + assert!((v.custom_scale - expected).abs() < 1e-12, "custom_scale {} vs {expected}", v.custom_scale); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "viewport {handle:?} draws a frame"); + } + // The scale follows the geometry; it cannot be set directly. + let second = list[0].0; + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Zoom', [{{'handle':{}, 'view_height':25.0}}])", second.value())); + let Some(EntityType::Viewport(zoomed)) = host.document().get_entity(second) else { unreachable!() }; + assert!((zoomed.custom_scale - 60.0 / 25.0).abs() < 1e-12, "{}", last(&host)); + assert_eq!(zoomed.id, 2, "an edit keeps the id"); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Resize', [{{'handle':{}, 'height':90.0}}])", second.value())); + let Some(EntityType::Viewport(resized)) = host.document().get_entity(second) else { unreachable!() }; + assert!((resized.custom_scale - 90.0 / 25.0).abs() < 1e-12); + let before = host.document().get_entity(second).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Scale', [{{'handle':{}, 'custom_scale':3.0}}])", second.value())); + assert!(last(&host).contains("read-only"), "{}", last(&host)); + assert_eq!(host.document().get_entity(second), Some(&before)); + // Control: the engine's default id 0 is not invisible here (there is no + // sheet viewport to confuse it with), so the derived id matters for + // uniqueness, not for visibility. + let mut bare = acadrust::entities::Viewport::new(); + bare.common.owner_handle = paper; + bare.width = 40.0; + bare.height = 30.0; + let bare = host.document_mut().add_entity(EntityType::Viewport(bare)).unwrap(); + assert_eq!(host.document().get_entity(bare).map(|e| match e { EntityType::Viewport(v) => v.id, _ => -1 }), Some(0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[bare]).is_empty()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {