From 54322e00e3bde29b1f34872a6fe747257f3efb13 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 07:43:30 +0200 Subject: [PATCH] Complete Shape host model coverage --- crates/ocs_plugin_api/build.rs | 5 +- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 111 +++++++++++- docs/plugin-host-model-completion-plan.md | 2 + docs/plugin-host-model-coverage-ledger.md | 4 +- src/app/plugin_host.rs | 164 ++++++++++++++++-- src/entities/shape.rs | 2 +- 7 files changed, 271 insertions(+), 23 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 82df52245..388c31471 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -585,7 +585,10 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("Insert", "insert_point" | "x_scale" | "y_scale" | "z_scale" | "rotation" | "normal" | "column_count" | "row_count" | "column_spacing" | "row_spacing") | ("Tolerance", "insertion_point" | "direction" | "normal" | "text" | - "dimension_style_name" | "text_height" | "dimension_gap")) { + "dimension_style_name" | "text_height" | "dimension_gap") | + ("Shape", "insertion_point" | "size" | "shape_name" | "shape_number" | + "rotation" | "relative_x_scale" | "oblique_angle" | "normal" | + "thickness" | "style_name")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 33cdec8a2..f0d4fb537 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -19,11 +19,13 @@ "Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"], "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"], - "Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"] + "Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"], + "Shape": ["layer", "insertion_point", "size", "shape_name", "shape_number", "rotation", "relative_x_scale", "oblique_angle", "normal", "thickness", "style_name"] }, "readable": { "Insert": ["block_name"], - "Tolerance": ["dimension_style_handle"] + "Tolerance": ["dimension_style_handle"], + "Shape": ["style_handle"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index b76843345..fc591e7dd 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -90,6 +90,22 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); } } + EntityType::Shape(value) => { + finite_vector("Shape.insertion_point", &value.insertion_point)?; + solid_normal("Shape.normal", &value.normal)?; + if !value.size.is_finite() || value.size <= 0.0 { return Err("Shape.size must be finite and greater than zero".into()); } + if value.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } + if !(1..=i16::MAX as i32).contains(&value.shape_number) { return Err("Shape.shape_number must be between 1 and 32767".into()); } + if !value.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if !value.relative_x_scale.is_finite() || value.relative_x_scale.abs() < 1e-12 { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if !value.thickness.is_finite() { return Err("Shape.thickness must be finite".into()); } + if value.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + } _ => {} } Ok(()) @@ -228,10 +244,38 @@ pub fn validate_entity_mutation( return Err("Tolerance.dimension_gap must be finite".into()); } if old.dimension_style_handle != new.dimension_style_handle - && !(old.dimension_style_name != new.dimension_style_name && new.dimension_style_handle.is_none()) { + && old.dimension_style_name == new.dimension_style_name { return Err("Tolerance.dimension_style_handle is read-only".into()); } } + (EntityType::Shape(old), EntityType::Shape(new)) => { + changed3("Shape.insertion_point", &old.insertion_point, &new.insertion_point)?; + if !same3(&old.normal, &new.normal) { solid_normal("Shape.normal", &new.normal)?; } + if old.size.to_bits() != new.size.to_bits() && (!new.size.is_finite() || new.size <= 0.0) { + return Err("Shape.size must be finite and greater than zero".into()); + } + if old.shape_name != new.shape_name && new.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } + if old.shape_number != new.shape_number && !(1..=i16::MAX as i32).contains(&new.shape_number) { + return Err("Shape.shape_number must be between 1 and 32767".into()); + } + if old.rotation.to_bits() != new.rotation.to_bits() && !new.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if old.relative_x_scale.to_bits() != new.relative_x_scale.to_bits() + && (!new.relative_x_scale.is_finite() || new.relative_x_scale.abs() < 1e-12) { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && (!new.oblique_angle.is_finite() || new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2) { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { + return Err("Shape.thickness must be finite".into()); + } + if old.style_name != new.style_name && new.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + if old.style_handle != new.style_handle + && !(old.style_name != new.style_name && new.style_handle.is_some()) { + return Err("Shape.style_handle is read-only".into()); + } + } _ => {} } Ok(()) @@ -252,9 +296,42 @@ pub fn validate_canvas_entity_references( ); if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); } } + if let EntityType::Shape(value) = entity { + let style = value.style_handle.filter(|handle| !handle.is_null()) + .and_then(|handle| document.text_styles.iter().find(|style| style.handle == handle)) + .or_else(|| document.text_styles.iter().find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim()))) + .ok_or_else(|| format!("Shape text style {:?} does not exist", value.style_name))?; + if !style.is_shape_file { return Err(format!("Shape text style {:?} is not a shape-file style", style.name)); } + if style.font_file.trim().is_empty() { return Err(format!("Shape text style {:?} has no SHX file", style.name)); } + } Ok(()) } +/// Fill name-based table references with stable handles before a new entity or +/// replacement is committed. Existing non-null handles remain authoritative +/// for legacy DWG entities whose fallback name can be stale. +#[cfg(feature = "host")] +pub fn bind_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &mut crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + match entity { + EntityType::Tolerance(value) if value.dimension_style_handle.filter(|h| !h.is_null()).is_none() => { + value.dimension_style_handle = document.dim_styles.iter() + .find(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())) + .map(|style| style.handle); + } + EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = document.text_styles.iter() + .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) + .map(|style| style.handle); + } + _ => {} + } + validate_canvas_entity_references(document, entity) +} + /// Clone a canvas entity with a new layer, preserving its other snapshot /// fields. Internal records and opaque fallbacks are excluded. #[cfg(feature = "host")] @@ -305,7 +382,7 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance" | "Shape") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -341,6 +418,11 @@ mod tests { "Tolerance.normal".to_owned(), "Tolerance.text".to_owned(), "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), "Tolerance.dimension_gap".to_owned(), + "Shape.insertion_point".to_owned(), "Shape.size".to_owned(), + "Shape.shape_name".to_owned(), "Shape.shape_number".to_owned(), + "Shape.rotation".to_owned(), "Shape.relative_x_scale".to_owned(), + "Shape.oblique_angle".to_owned(), "Shape.normal".to_owned(), + "Shape.thickness".to_owned(), "Shape.style_name".to_owned(), ])); } @@ -364,6 +446,31 @@ mod tests { .unwrap_err().contains("does not exist")); } + #[cfg(feature = "host")] + #[test] + fn shape_geometry_and_shape_file_style_are_validated_and_bound() { + use crate::host::acadrust::{self, tables::TableEntry, types::Vector3}; + let mut document = acadrust::CadDocument::new(); + let mut style = acadrust::tables::TextStyle::new("Symbols"); + style.set_handle(document.allocate_handle()); + style.is_shape_file = true; + style.font_file = "symbols.shx".into(); + let style_handle = style.handle; + document.text_styles.add(style).unwrap(); + let mut shape = acadrust::entities::Shape::with_style( + Vector3::new(1.0, 2.0, 0.0), "ARROW", "Symbols", 2.0, 0.5); + shape.shape_number = 1; + let mut entity = acadrust::EntityType::Shape(shape); + validate_new_canvas_entity(&entity).unwrap(); + bind_canvas_entity_references(&document, &mut entity).unwrap(); + assert!(matches!(entity, acadrust::EntityType::Shape(ref value) + if value.style_handle == Some(style_handle))); + let acadrust::EntityType::Shape(mut bad) = entity else { unreachable!() }; + bad.size = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) + .unwrap_err().contains("greater than zero")); + } + #[test] fn layer_write_coverage_matches_canvas_scope() { let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index b2baffcf2..310050382 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -107,3 +107,5 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. - Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes. - `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. +- Closed `Shape` with a generated SHX fixture and a real shape-file `TextStyle`. Host-owned binding resolves the Python style name to the stable table handle required by DWG; validation rejects missing/non-shape styles and invalid geometry. The adapter maps all stable shape fields and keeps the bound style handle read-only. +- `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the actual runner, checks the real SHX linework before and after DWG/DXF reopen, and proves that invalid size/style edits remain atomic. The DXF renderer fallback now searches named `is_shape_file` styles as well as legacy unnamed styles. Shape is **Complete**; the queue now continues with `AttributeDefinition`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a770d088d..fd99623aa 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 19 September 2026: **18 of 43 canvas kinds have explicit geometry writes** (17 creatable, plus update-only `Insert`), **1 kind is complete**, and 25 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 19 September 2026: **19 of 43 canvas kinds have explicit geometry writes** (18 creatable, plus update-only `Insert`), **2 kinds are complete**, and 24 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** = portable H7 v5 build. 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. @@ -24,7 +24,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Face3D | Mapped | Focused DWG round trip; full gate pending. | | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | -| Shape | Layer-only | Requires valid shape/style fixture. | +| Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | Layer-only | Requires block-definition fixture. | | AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | | Hatch | Layer-only | Requires valid boundary and pattern fixtures. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3a73ce3ba..3b481586c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -132,13 +132,13 @@ impl<'a> HostSession<'a> { pub fn update_entities_transaction( &mut self, label: &str, - entities: Vec, + mut entities: Vec, ) -> Result<(), String> { use std::collections::HashSet; if label.trim().is_empty() { return Err("transaction label is empty".into()); } if entities.is_empty() { return Ok(()); } let mut seen = HashSet::new(); - for entity in &entities { + for entity in &mut entities { let handle = entity.common().handle; if handle.is_null() || !seen.insert(handle) { return Err(format!("null or duplicate entity handle: {handle:?}")); @@ -152,11 +152,11 @@ impl<'a> HostSession<'a> { if existing.common().owner_handle != entity.common().owner_handle { return Err(format!("entity {handle:?} changes owner")); } - ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) - .map_err(|error| format!("entity {handle:?}: {error}"))?; - ocs_plugin_api::entity_coverage::validate_canvas_entity_references( + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( self.document(), entity, ).map_err(|error| format!("entity {handle:?}: {error}"))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return Err(format!("entity {handle:?} is on a locked layer")); } @@ -644,6 +644,7 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm mod tests { use super::*; use crate::app::OpenCADStudio; + use crate::entities::traits::RenderConvertible; use acadrust::entities::{Line, Point}; use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; @@ -891,6 +892,133 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_shape_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let shx_path = std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id())); + let mut shx = b"AutoCAD-86 shapes 1.0\r\n\x1A".to_vec(); + for value in [1_u16, 1, 1, 1, 9] { shx.extend_from_slice(&value.to_le_bytes()); } + shx.extend_from_slice(b"ARROW\0\x10\x14\0"); + std::fs::write(&shx_path, shx).unwrap(); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let style_handle = host.document_mut().allocate_handle(); + let mut style = acadrust::tables::TextStyle::new("TestShapes"); + style.handle = style_handle; + style.is_shape_file = true; + style.font_file = shx_path.to_string_lossy().into_owned(); + host.document_mut().text_styles.add(style).unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Shape',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},size=2.0,", + "shape_name='ARROW',shape_number=1,style_name='TestShapes').handle" + )); + let handle = host.document().entities().next().expect("Python created a Shape") + .common().handle; + let Some(EntityType::Shape(created)) = host.document().get_entity(handle) else { + panic!("expected Shape"); + }; + assert_eq!(created.style_handle, Some(style_handle)); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].shape_name", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("ARROW")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_shape_document_model_{}.py", std::process::id())); + std::fs::write(&script_path, format!(concat!( + "doc = ocs.active_document\n", + "shape = doc.entities[{}]\n", + "with doc.transaction('Edit shape'):\n", + " shape.insertion_point = (4.0, 5.0, 0.0)\n", + " shape.size = 3.0\n", + " shape.rotation = 0.5\n", + " shape.relative_x_scale = 1.5\n", + "doc.selection = [shape]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Shape(edited) = &expected else { unreachable!() }; + assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(edited.size, 3.0); + assert_eq!(edited.rotation, 0.5); + assert_eq!(edited.relative_x_scale, 1.5); + assert_eq!(edited.common, created.common); + assert_eq!(edited.shape_name, created.shape_name); + assert_eq!(edited.shape_number, created.shape_number); + assert_eq!(edited.style_name, created.style_name); + assert_eq!(edited.style_handle, created.style_handle); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.thickness, created.thickness); + assert_eq!(host.selection(), vec![handle]); + + let assert_real_glyph = |entity: &EntityType, document: &CadDocument| { + let EntityType::Shape(shape) = entity else { panic!("expected Shape"); }; + let render = shape.to_render(document).expect("shape render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + panic!("expected shape linework"); + }; + assert_eq!(points.len(), 3, "expected SHX glyph rather than diamond placeholder"); + assert!(points.iter().flatten().all(|value| value.is_finite())); + }; + assert_real_glyph(&expected, host.document()); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = crate::io::load_bytes("shape.dwg", dwg_bytes).unwrap(); + let dwg_entity = dwg_doc.get_entity(handle).expect("Shape survives DWG"); + assert!(matches!(dwg_entity, EntityType::Shape(value) + if value.shape_number == 1 && value.size == 3.0 && value.rotation == 0.5)); + assert_real_glyph(dwg_entity, &dwg_doc); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_doc = crate::io::load_bytes("shape.dxf", dxf_bytes).unwrap(); + let dxf_entity = dxf_doc.get_entity(handle).expect("Shape survives DXF"); + assert!(matches!(dxf_entity, EntityType::Shape(value) + if value.shape_name == "ARROW" && value.size == 3.0 && (value.rotation - 0.5).abs() < 1e-12)); + assert_real_glyph(dxf_entity, &dxf_doc); + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + let _ = std::fs::remove_file(&shx_path); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value)) + if value.insertion_point.x == 1.0 && value.size == 2.0)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -1019,17 +1147,23 @@ mod tests { // Notifications are best-effort and consumed by the runner's reader // thread before its next Dispatch. Give that thread a bounded handoff. std::thread::sleep(std::time::Duration::from_millis(50)); - { - let mut host = HostSession::new(&mut app, 1); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); - } - let other_events = &app.command_line.history.last().unwrap().text; + let poll_events_until = |app: &mut OpenCADStudio, tab: usize, epoch: &str| { + let mut output = String::new(); + for _ in 0..10 { + { + let mut host = HostSession::new(app, tab); + assert!(process.dispatch(&mut host, + "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + output = app.command_line.history.last().unwrap().text.clone(); + if output.contains(epoch) { break; } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + output + }; + let other_events = poll_events_until(&mut app, 1, "202"); assert!(other_events.contains("202") && !other_events.contains("101"), "{other_events}"); - { - let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); - } - let first_events = &app.command_line.history.last().unwrap().text; + let first_events = poll_events_until(&mut app, 0, "101"); assert!(first_events.contains("101") && !first_events.contains("202"), "{first_events}"); for epoch in 0..257 { diff --git a/src/entities/shape.rs b/src/entities/shape.rs index 7b4b1b8f1..12cb0f4cc 100644 --- a/src/entities/shape.rs +++ b/src/entities/shape.rs @@ -119,7 +119,7 @@ fn shx_polylines( // glyph named like this shape. if !name.is_empty() { for style in document.text_styles.iter() { - if style.name.is_empty() && !style.font_file.trim().is_empty() { + if style.is_shape_file && !style.font_file.trim().is_empty() { if let Some(p) = try_style(style) { return Some(p); }