diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 0f7e09cd0..dd58dd74c 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -740,6 +740,22 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "line_count" | "lock_position" ) + | ( + "Hatch", + "elevation" + | "normal" + | "is_solid" + | "pattern" + | "pattern_angle" + | "pattern_scale" + | "pattern_type" + | "is_double" + | "style" + | "is_associative" + | "pixel_size" + | "paths" + | "seed_points" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 341449ebc..931ae0806 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -22,7 +22,8 @@ "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"], "AttributeDefinition": ["layer", "tag", "prompt", "default_value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], - "AttributeEntity": ["layer", "tag", "value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"] + "AttributeEntity": ["layer", "tag", "value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], + "Hatch": ["layer", "elevation", "normal", "is_solid", "pattern", "pattern_angle", "pattern_scale", "pattern_type", "is_double", "style", "is_associative", "pixel_size", "paths", "seed_points"] }, "readable": { "Insert": ["block_name"], diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index cbfa829d0..da8c33bd3 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -61,6 +61,176 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { } } +#[cfg(feature = "host")] +fn validate_hatch_paths(paths: &[crate::host::acadrust::entities::BoundaryPath]) -> Result<(), String> { + use crate::host::acadrust::entities::BoundaryEdge; + use crate::host::acadrust::types::Vector2; + let close = |a: Vector2, b: Vector2| (a.x - b.x).hypot(a.y - b.y) <= 1e-8; + let ellipse_point = |center: Vector2, major: Vector2, ratio: f64, angle: f64| { + Vector2::new( + center.x + major.x * angle.cos() - major.y * ratio * angle.sin(), + center.y + major.y * angle.cos() + major.x * ratio * angle.sin(), + ) + }; + if paths.is_empty() { + return Err("Hatch requires at least one boundary path".into()); + } + for (pi, path) in paths.iter().enumerate() { + if path.edges.is_empty() { + return Err(format!("Hatch boundary path {pi} has no edges")); + } + if path.flags.is_not_closed() { + return Err(format!("Hatch boundary path {pi} is marked open")); + } + if path.flags.bits() & !0x1ff != 0 { + return Err(format!("Hatch boundary path {pi} has unknown flag bits")); + } + let mut endpoints = Vec::with_capacity(path.edges.len()); + for (ei, edge) in path.edges.iter().enumerate() { + let pair = match edge { + BoundaryEdge::Line(line) => { + if !line.start.x.is_finite() || !line.start.y.is_finite() || !line.end.x.is_finite() || !line.end.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} line has non-finite coordinates")); + } + if (line.start.x - line.end.x).hypot(line.start.y - line.end.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} line has zero length")); + } + (line.start, line.end) + } + BoundaryEdge::CircularArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc center has non-finite coordinates")); + } + if !arc.radius.is_finite() || arc.radius <= 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} arc radius must be finite and positive")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc angles must be finite")); + } + let point = |angle: f64| Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ); + let (start, end) = (point(arc.start_angle), point(arc.end_angle)); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::EllipticArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse center has non-finite coordinates")); + } + if !arc.major_axis_endpoint.x.is_finite() || !arc.major_axis_endpoint.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has non-finite coordinates")); + } + if arc.major_axis_endpoint.x.hypot(arc.major_axis_endpoint.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has zero length")); + } + if !arc.minor_axis_ratio.is_finite() || !(0.0..=1.0).contains(&arc.minor_axis_ratio) || arc.minor_axis_ratio == 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse ratio must be within (0, 1]")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse angles must be finite")); + } + let start = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.start_angle); + let end = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.end_angle); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::Polyline(poly) => { + if poly.vertices.len() < 3 { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline must have at least 3 vertices")); + } + for (vi, v) in poly.vertices.iter().enumerate() { + if !v.x.is_finite() || !v.y.is_finite() || !v.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} vertex {vi} has non-finite coordinates")); + } + } + if !poly.is_closed { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline is open")); + } + let first = poly.vertices.first().unwrap(); + (Vector2::new(first.x, first.y), Vector2::new(first.x, first.y)) + } + BoundaryEdge::Spline(spline) => { + if spline.degree < 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline degree must be at least 1")); + } + if spline.control_points.len() < spline.degree as usize + 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few control points for its degree")); + } + if spline.knots.iter().any(|knot| !knot.is_finite()) + || spline.knots.windows(2).any(|pair| pair[0] > pair[1]) + { + return Err(format!("Hatch boundary path {pi} edge {ei} spline knots must be finite and nondecreasing")); + } + for (ci, cp) in spline.control_points.iter().enumerate() { + if !cp.x.is_finite() || !cp.y.is_finite() || !cp.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} control point {ci} has non-finite coordinates")); + } + } + for (fi, fp) in spline.fit_points.iter().enumerate() { + if !fp.x.is_finite() || !fp.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} fit point {fi} has non-finite coordinates")); + } + } + for (name, tangent) in [("start", spline.start_tangent), ("end", spline.end_tangent)] { + if !tangent.x.is_finite() || !tangent.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} spline {name} tangent is non-finite")); + } + } + let points = if spline.fit_points.len() >= 2 { + spline.fit_points.clone() + } else { + spline.control_points.iter().map(|p| Vector2::new(p.x, p.y)).collect() + }; + if points.len() < 2 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few points")); + } + (*points.first().unwrap(), *points.last().unwrap()) + } + }; + endpoints.push(pair); + } + for ei in 0..endpoints.len() { + let next = (ei + 1) % endpoints.len(); + if !close(endpoints[ei].1, endpoints[next].0) { + return Err(format!("Hatch boundary path {pi} is not closed between edges {ei} and {next}")); + } + } + } + Ok(()) +} + +#[cfg(feature = "host")] +fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Result<(), String> { + if value.pattern.name.trim().is_empty() { + return Err("Hatch.pattern.name is empty".into()); + } + if value.is_solid { + if !value.pattern.name.eq_ignore_ascii_case("SOLID") || !value.pattern.lines.is_empty() { + return Err("solid Hatch requires the SOLID pattern without pattern lines".into()); + } + return Ok(()); + } + if !value.pattern_scale.is_finite() || value.pattern_scale <= 0.0 { + return Err("Hatch.pattern_scale must be finite and greater than zero".into()); + } + if value.pattern.lines.is_empty() { + return Err("patterned Hatch requires at least one pattern line".into()); + } + for (index, line) in value.pattern.lines.iter().enumerate() { + if !line.angle.is_finite() + || !line.base_point.x.is_finite() || !line.base_point.y.is_finite() + || !line.offset.x.is_finite() || !line.offset.y.is_finite() + || line.dash_lengths.iter().any(|dash| !dash.is_finite()) + { + return Err(format!("Hatch.pattern.lines[{index}] contains non-finite values")); + } + if line.offset.x.hypot(line.offset.y) < 1e-12 { + return Err(format!("Hatch.pattern.lines[{index}].offset must be nonzero")); + } + } + 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")] @@ -232,6 +402,25 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("AttributeEntity.line_count must be greater than zero".into()); } } + EntityType::Hatch(value) => { + solid_normal("Hatch.normal", &value.normal)?; + if !value.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + validate_hatch_pattern(value)?; + if !value.pattern_angle.is_finite() { + return Err("Hatch.pattern_angle must be finite".into()); + } + if !value.pixel_size.is_finite() || value.pixel_size < 0.0 { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + for (si, sp) in value.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + validate_hatch_paths(&value.paths)?; + } _ => {} } Ok(()) @@ -610,6 +799,50 @@ pub fn validate_entity_mutation( return Err("AttributeEntity.attdef_handle is read-only".into()); } } + (EntityType::Hatch(old), EntityType::Hatch(new)) => { + if !same3(&old.normal, &new.normal) { + solid_normal("Hatch.normal", &new.normal)?; + } + if old.elevation.to_bits() != new.elevation.to_bits() && !new.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + if old.pattern != new.pattern + || old.is_solid != new.is_solid + || old.pattern_scale.to_bits() != new.pattern_scale.to_bits() + { + validate_hatch_pattern(new)?; + } + if old.pattern_angle.to_bits() != new.pattern_angle.to_bits() + && !new.pattern_angle.is_finite() + { + return Err("Hatch.pattern_angle must be finite".into()); + } + if old.pixel_size.to_bits() != new.pixel_size.to_bits() + && (!new.pixel_size.is_finite() || new.pixel_size < 0.0) + { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + if old.paths != new.paths { + validate_hatch_paths(&new.paths)?; + } + if old.seed_points != new.seed_points { + for (si, sp) in new.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + } + if old.gradient_color != new.gradient_color { + return Err("Hatch.gradient_color is unmapped and read-only".into()); + } + if old.is_mpolygon != new.is_mpolygon + || old.mpolygon_hatch_color != new.mpolygon_hatch_color + || old.mpolygon_x_direction != new.mpolygon_x_direction + || old.mpolygon_boundary_handle_count != new.mpolygon_boundary_handle_count + { + return Err("Hatch MPOLYGON fields are unmapped and read-only".into()); + } + } _ => {} } Ok(()) @@ -635,6 +868,22 @@ pub fn validate_canvas_entity_references( return Err(format!("entity owner {owner:?} does not exist")); } } + if let EntityType::Hatch(value) = entity { + let handles = value.paths.iter().flat_map(|path| &path.boundary_handles); + let mut count = 0usize; + for handle in handles { + count += 1; + if handle.is_null() || document.get_entity(*handle).is_none() { + return Err(format!("Hatch boundary handle {handle:?} does not exist")); + } + } + if value.is_associative && count == 0 { + return Err("associative Hatch requires boundary handles".into()); + } + if !value.is_associative && count != 0 { + return Err("non-associative Hatch cannot carry boundary handles".into()); + } + } if let EntityType::Tolerance(value) = entity { let found = value .dimension_style_handle @@ -952,6 +1201,7 @@ mod tests { | "Shape" | "AttributeDefinition" | "AttributeEntity" + | "Hatch" ) { assert!( @@ -1073,6 +1323,19 @@ mod tests { "AttributeEntity.is_multiline".to_owned(), "AttributeEntity.line_count".to_owned(), "AttributeEntity.lock_position".to_owned(), + "Hatch.elevation".to_owned(), + "Hatch.normal".to_owned(), + "Hatch.is_solid".to_owned(), + "Hatch.pattern".to_owned(), + "Hatch.pattern_angle".to_owned(), + "Hatch.pattern_scale".to_owned(), + "Hatch.pattern_type".to_owned(), + "Hatch.is_double".to_owned(), + "Hatch.style".to_owned(), + "Hatch.is_associative".to_owned(), + "Hatch.pixel_size".to_owned(), + "Hatch.paths".to_owned(), + "Hatch.seed_points".to_owned(), ]) ); } @@ -1415,4 +1678,57 @@ mod tests { .contains("no whitespace") ); } + #[test] + fn hatch_geometry_and_boundaries_are_validated() { + use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; + let mut hatch = acadrust::entities::Hatch::new(); + let mut path = BoundaryPath::new(); + for (start, end) in [ + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (10.0, 10.0)), + ((10.0, 10.0), (0.0, 10.0)), + ((0.0, 10.0), (0.0, 0.0)), + ] { + path.edges.push(BoundaryEdge::Line(LineEdge { + start: acadrust::types::Vector2::new(start.0, start.1), + end: acadrust::types::Vector2::new(end.0, end.1), + })); + } + hatch.paths.push(path); + let entity = acadrust::EntityType::Hatch(hatch.clone()); + validate_new_canvas_entity(&entity).unwrap(); + + let mut invalid = hatch.clone(); + invalid.paths.clear(); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("at least one boundary path")); + + let mut invalid = hatch.clone(); + invalid.is_solid = false; + invalid.pattern_scale = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("greater than zero")); + + let mut document = acadrust::CadDocument::new(); + let boundary = document.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, + acadrust::types::Vector3::UNIT_X, + ), + )).unwrap(); + let mut associative = hatch.clone(); + associative.is_associative = true; + associative.paths[0].boundary_handles.push(boundary); + validate_canvas_entity_references( + &document, + &acadrust::EntityType::Hatch(associative.clone()), + ).unwrap(); + associative.paths[0].boundary_handles[0] = acadrust::Handle::new(u64::MAX); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Hatch(associative), + ).unwrap_err().contains("does not exist")); + } } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d60531410..e1a152d9f 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2009,6 +2009,149 @@ mod tests { ); } + #[test] + fn staged_python_hatch_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 mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + 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 script = std::env::temp_dir().join(format!("ocs_hatch_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'x': x, 'y': y}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{'kind':'Line','value':{'start':p(a,b),'end':p(c,d)}} for a,b,c,d in pairs]\n", + " return {'flags':1,'edges':edges,'boundary_handles':[]}\n", + "doc = ocs.active_document\n", + "hatch = doc.create_entity('Hatch', paths=[boundary(10.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let hatch_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Hatch: {:?}", + host.app.command_line.history.last().map(|entry| &entry.text))); + let created = host.document().get_entity(hatch_handle).unwrap().clone(); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle) + .is_some_and(|model| model.boundary.len() >= 4 && model.name == "SOLID")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].pattern['name']", hatch_handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("SOLID")); + + let script = std::env::temp_dir().join(format!("ocs_hatch_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'x': x, 'y': y}}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{{'kind':'Line','value':{{'start':p(a,b),'end':p(c,d)}}}} for a,b,c,d in pairs]\n", + " return {{'flags':1,'edges':edges,'boundary_handles':[]}}\n", + "doc = ocs.active_document\n", + "hatch = doc.entities[{}]\n", + "with doc.transaction('Edit hatch'):\n", + " hatch.paths = [boundary(12.0)]\n", + " hatch.is_solid = False\n", + " hatch.pattern = {{'name':'TEST','description':'test pattern','lines':[", + "{{'angle':0.0,'base_point':p(0.0,0.0),'offset':p(0.0,2.0),'dash_lengths':[1.0,-1.0]}}]}}\n", + " hatch.pattern_scale = 2.0\n", + " hatch.pattern_angle = 0.25\n", + "doc.selection = [hatch]\n" + ), hatch_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(hatch_handle).unwrap().clone(); + let EntityType::Hatch(edited) = &expected else { unreachable!() }; + assert!(!edited.is_solid); + assert_eq!(edited.pattern.name, "TEST"); + assert_eq!(edited.pattern.lines.len(), 1); + assert_eq!(edited.paths[0].edges.len(), 4); + assert!((edited.pattern_scale - 2.0).abs() < 1e-12); + assert_eq!(edited.gradient_color, match &created { EntityType::Hatch(value) => value.gradient_color.clone(), _ => unreachable!() }); + assert_eq!(host.selection(), vec![hatch_handle]); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle).is_some_and(|model| + model.boundary.len() >= 4 && model.name == "TEST" + && matches!(&model.pattern, + crate::scene::model::hatch_model::HatchPattern::Pattern(lines) + if !lines.is_empty()))); + + for (patch, message) in [ + ("'pattern_scale':0.0", "greater than zero"), + ("'is_associative':True", "requires boundary handles"), + ("'gradient_color':{}", "outside the editable schema"), + ("'paths':[{'flags':1,'edges':[{'kind':'Line','value':{'start':{'x':0.0,'y':0.0},'end':{'x':1.0,'y':0.0}}}],'boundary_handles':[]}]", "not closed"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject hatch', [{{'handle':{}, {patch}}}])", + hatch_handle.value())); + assert_eq!(host.document().get_entity(hatch_handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains(message)); + } + + let assert_saved = |document: &CadDocument| { + assert!(matches!(document.get_entity(hatch_handle), Some(EntityType::Hatch(hatch)) + if !hatch.is_solid && hatch.pattern.name == "TEST" + && hatch.pattern.lines.len() == 1 && hatch.paths[0].edges.len() == 4 + && (hatch.pattern_scale - 2.0).abs() < 1e-12)); + }; + let reopened_dwg = crate::io::load_bytes("hatch.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("hatch.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("hatch-reedit.dwg", reopened_dwg), + ("hatch-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(hatch_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Hatch(hatch) = &mut after else { unreachable!() }; + hatch.elevation = 3.0; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + *document.get_entity_mut(hatch_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle), + Some(EntityType::Hatch(hatch)) if (hatch.elevation - 3.0).abs() < 1e-12)); + } + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", hatch_handle.value())); + assert!(host.document().get_entity(hatch_handle).is_none()); + for (name, bytes) in [ + ("hatch-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("hatch-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle).is_none()); + } + drop(process); + drop(host); + 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(hatch_handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(hatch_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 { @@ -2380,7 +2523,7 @@ mod tests { let handle; { let mut host = HostSession::new(&mut app, 0); - handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default())); + handle = host.add_entity(EntityType::MLine(acadrust::entities::MLine::default())); let original = host.document().get_entity(handle).unwrap(); let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap();