diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 958375596..e931d3ae8 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -791,6 +791,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("PolyfaceMesh", _) | ("Mesh", _) | ("Helix", _) + | ("RasterImage", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 5c5153824..3fe468967 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -388,6 +388,19 @@ "turn_height", "handedness", "constraint" + ], + "RasterImage": [ + "layer", + "insertion_point", + "u_vector", + "v_vector", + "flags", + "clipping_enabled", + "brightness", + "contrast", + "fade", + "clip_boundary", + "file_path" ] }, "readable": { @@ -448,6 +461,12 @@ "spline", "major_version", "maintenance_version" + ], + "RasterImage": [ + "class_version", + "definition_handle", + "definition_reactor_handle", + "size" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 4ea721692..05dcd28b6 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -781,6 +781,54 @@ fn validate_helix( Ok(()) } +/// RasterImage display parameters. The image definition is created and owned +/// by the host; a script supplies the file and the placement. +#[cfg(feature = "host")] +fn validate_raster_image( + old: Option<&crate::host::acadrust::entities::RasterImage>, + new: &crate::host::acadrust::entities::RasterImage, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if let Some(old) = old { + if old.file_path != new.file_path { + return Err("RasterImage.file_path is fixed at creation; the image definition owns it".into()); + } + } else if new.file_path.trim().is_empty() { + return Err("RasterImage.file_path is empty".into()); + } else if new.definition_handle.is_none_or(|handle| handle.is_null()) + && !std::path::Path::new(&new.file_path).is_file() + { + return Err(format!("RasterImage.file_path {:?} is not a readable file", new.file_path)); + } + finite_vector("RasterImage.insertion_point", &new.insertion_point)?; + finite_vector("RasterImage.u_vector", &new.u_vector)?; + finite_vector("RasterImage.v_vector", &new.v_vector)?; + let cross = new.u_vector.cross(&new.v_vector); + if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { + return Err("RasterImage.u_vector and v_vector must be nonzero and not parallel".into()); + } + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x < 0.0 || new.size.y < 0.0 { + return Err("RasterImage.size must be finite and non-negative".into()); + } + if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { + return Err("RasterImage brightness, contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("RasterImage.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary.vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("RasterImage.clip_boundary.vertices[{index}] must be finite")); + } + } + if new.clipping_enabled && new.clip_boundary.vertices.len() < 2 { + return Err("RasterImage clipping requires at least 2 clip boundary vertices".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")] @@ -881,6 +929,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?, EntityType::Mesh(value) => validate_mesh(None, value)?, EntityType::Helix(value) => validate_helix(None, value)?, + EntityType::RasterImage(value) => validate_raster_image(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1242,6 +1291,7 @@ pub fn validate_entity_mutation( (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, + (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(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)) => { @@ -1505,6 +1555,16 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::RasterImage(value) = entity { + if let Some(handle) = value.definition_handle.filter(|handle| !handle.is_null()) { + if !matches!( + document.objects.get(&handle), + Some(crate::host::acadrust::objects::ObjectType::ImageDefinition(_)) + ) { + return Err(format!("RasterImage image definition {handle:?} does not exist")); + } + } + } if let EntityType::Table(value) = entity { if let Some(handle) = value.table_style_handle.filter(|handle| !handle.is_null()) { if !matches!( @@ -1920,6 +1980,7 @@ mod tests { | "PolyfaceMesh" | "Mesh" | "Helix" + | "RasterImage" ) { assert!( @@ -2183,6 +2244,16 @@ mod tests { "Helix.turn_height".to_owned(), "Helix.handedness".to_owned(), "Helix.constraint".to_owned(), + "RasterImage.insertion_point".to_owned(), + "RasterImage.u_vector".to_owned(), + "RasterImage.v_vector".to_owned(), + "RasterImage.flags".to_owned(), + "RasterImage.clipping_enabled".to_owned(), + "RasterImage.brightness".to_owned(), + "RasterImage.contrast".to_owned(), + "RasterImage.fade".to_owned(), + "RasterImage.clip_boundary".to_owned(), + "RasterImage.file_path".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 128f70c8f..67118543f 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 **31 of 43** canvas kinds: 30 +v7. The Python feature has explicit writes for **32 of 43** canvas kinds: 31 are creatable and `Insert` remains update-only. This is **not** 30 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh 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 and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). The other 12 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 and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 11 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 **RasterImage** (queue item 10). Leader is done except for its W +Continue with **Wipeout** (queue item 11). 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`. @@ -164,3 +164,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. - Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. - Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. +- Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 326839968..26e17df25 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: **31 of 43 canvas kinds have explicit geometry writes** (30 creatable, plus update-only `Insert`), **11 kinds are complete**, and 12 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: **32 of 43 canvas kinds have explicit geometry writes** (31 creatable, plus update-only `Insert`), **11 kinds are complete**, and 11 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. @@ -37,7 +37,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | -| RasterImage | Layer-only | Requires image definition and file fixture. | +| 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 | Layer-only | Requires clip and image-definition fixture. | | Underlay | Layer-only | Requires external definition/file fixture. | | Viewport | Layer-only | Requires layout and view-state fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index d1f6a1a02..b96b5f6da 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 31 geometry converters. Other writable properties +canvas kinds outside its 32 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 31-kind generated schema it contains only handle, kind, and layer; +outside the 32-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 b46227440..7c8c22ac6 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 31 mapped kinds. +the 32 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 a6f2e60dd..478dad1e4 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -30,7 +30,8 @@ "PolygonMesh", "PolyfaceMesh", "Mesh", - "Helix" + "Helix", + "RasterImage" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -340,6 +341,16 @@ "major_version": { "read_only": true }, "maintenance_version": { "read_only": true } } + }, + "RasterImage": { + "fields": { + "insertion_point": { "required": true }, + "file_path": { "required": true }, + "class_version": { "read_only": true }, + "size": { "read_only": true, "_comment": "Pixel size of the image; the host takes it from the file." }, + "definition_handle": { "read_only": true }, + "definition_reactor_handle": { "read_only": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index bb6537a92..39375f109 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(), 31); + assert_eq!(emitted.len(), 32); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 949b57a1b..51eaf2ad7 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -182,7 +182,47 @@ impl<'a> HostSession<'a> { Ok(()) } + /// A scripted RasterImage names a file; the host creates the image + /// definition object it needs (pixel size read from the file) and links it. + fn prepare_scripted_raster_image(&mut self, entity: &mut EntityType) -> Result<(), String> { + let EntityType::RasterImage(image) = entity else { + return Ok(()); + }; + if image.definition_handle.is_some_and(|handle| !handle.is_null()) { + return Ok(()); + } + let (width, height) = image::image_dimensions(&image.file_path) + .map_err(|error| format!("cannot read image {:?}: {error}", image.file_path))?; + let handle = self.document_mut().allocate_handle(); + let mut definition = acadrust::objects::ImageDefinition::with_dimensions( + image.file_path.clone(), + width, + height, + ); + definition.handle = handle; + definition.is_loaded = true; + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition)); + image.definition_handle = Some(handle); + // `size` is the image's pixel size, and an untouched clip boundary + // covers the whole image, so both follow the file. + let untouched_clip = image.clip_boundary + == acadrust::entities::ClipBoundary::full_image(image.size.x, image.size.y); + image.size = acadrust::types::Vector2::new(f64::from(width), f64::from(height)); + if untouched_clip { + image.clip_boundary = acadrust::entities::ClipBoundary::full_image( + f64::from(width), + f64::from(height), + ); + } + Ok(()) + } + pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + if self.prepare_scripted_raster_image(&mut entity).is_err() { + return Handle::NULL; + } if self.normalize_scripted_entity(None, &mut entity).is_err() { return Handle::NULL; } @@ -220,6 +260,7 @@ impl<'a> HostSession<'a> { pub fn add_entities(&mut self, mut entities: Vec) -> Vec { for entity in &mut entities { + let _ = self.prepare_scripted_raster_image(entity); // Creation input was validated by the caller; a degenerate MLine // simply keeps its supplied (unnormalized) vertices. let _ = self.normalize_scripted_entity(None, entity); @@ -2515,7 +2556,7 @@ mod tests { ("'style_name':'Missing'", "does not exist"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), ("'scale_factor':0.0", "nonzero"), - ("'flags':64", "unknown bits"), + ("'flags':64", "unknown or out-of-range bits"), ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), @@ -3152,7 +3193,8 @@ mod tests { let _ = std::fs::remove_file(&path); }; - run_script(&mut host, "create", case.create); + let tmp = std::env::temp_dir().to_string_lossy().into_owned(); + run_script(&mut host, "create", &case.create.replace("TMPDIR", &tmp)); let handle = host.document().entities().find(|entity| (case.is_kind)(entity)) .map(|entity| entity.common().handle) .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); @@ -3160,7 +3202,7 @@ mod tests { assert_eq!((case.digest)(&created), case.expect_created); 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())); + run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp)); let expected = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host)); assert_eq!(expected.common(), created.common()); @@ -3402,6 +3444,55 @@ mod tests { }); } + #[test] + fn staged_python_raster_image_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(8, 4, image::Rgba([200, 30, 30, 255])) + .save(std::env::temp_dir().join("ocs_raster_test.png")) + .unwrap(); + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('RasterImage', file_path='TMPDIR/ocs_raster_test.png',\n", + " insertion_point=P(10.0, 20.0, 0.0), u_vector=P(0.5, 0.0, 0.0), v_vector=P(0.0, 0.5, 0.0))\n"), + edit: concat!( + "img = doc.entities[HANDLE]\n", + "cb = img.clip_boundary\n", + "cb['vertices'] = [{'x': 1.0, 'y': 1.0}, {'x': 7.0, 'y': 3.0}]\n", + "with doc.transaction('Edit image'):\n", + " img.insertion_point = (12.0, 22.0, 0.0)\n", + " img.u_vector = (1.0, 0.0, 0.0)\n", + " img.brightness = 70\n", + " img.contrast = 40\n", + " img.fade = 10\n", + " img.clip_boundary = cb\n", + " img.clipping_enabled = True\n", + "doc.selection = [img]\n"), + rejects: &[ + ("'file_path':'other.png'", "fixed at creation"), + ("'brightness':101", "within 0..=100"), + ("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"), + ("'v_vector':{'x':2.0,'y':0.0,'z':0.0}", "not parallel"), + ("'size':{'x':5.0,'y':5.0}", "read-only"), + ("'definition_handle':5", "read-only"), + ("'flags':64", "unknown or out-of-range bits"), + ("'clip_boundary':{'vertices':[]}", "at least 2"), + ], + is_kind: |entity| matches!(entity, EntityType::RasterImage(_)), + digest: |entity| match entity { + EntityType::RasterImage(i) => format!("{} {:.1},{:.1} u{:.1} b{} c{} f{} clip{} n{} {}x{} def{}", + std::path::Path::new(&i.file_path).file_name().unwrap().to_string_lossy(), + i.insertion_point.x, i.insertion_point.y, i.u_vector.x, i.brightness, i.contrast, i.fade, + i.clipping_enabled, i.clip_boundary.vertices.len(), i.size.x, i.size.y, i.definition_handle.is_some()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::RasterImage(i) = entity { i.brightness = 80; }, + expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue", + expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue", + expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {