From 91900c4e7321e1aca2f24db84abfb58bc249eeeb Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 28 Sep 2026 08:05:19 +0100 Subject: [PATCH] bench(flatten): add FLATTEN metrics with extracted helpers Moves the flatten projection fns and command helpers into modules/draw/modify/flatten.rs (behavior unchanged) and adds flatten_plan / flatten_scene_apply to the COMMANDS suite. --- benches/README.md | 5 +- benches/command_benchmarks.rs | 173 ++++++++++- src/app/commands/inquiry.rs | 457 +-------------------------- src/modules/draw/modify/flatten.rs | 476 +++++++++++++++++++++++++++++ src/modules/draw/modify/mod.rs | 1 + 5 files changed, 660 insertions(+), 452 deletions(-) create mode 100644 src/modules/draw/modify/flatten.rs diff --git a/benches/README.md b/benches/README.md index f09c65a6f..f4d7e2d0c 100644 --- a/benches/README.md +++ b/benches/README.md @@ -68,8 +68,9 @@ cargo bench --bench performance_benchmarks -- --quick ## Command Benchmarks -First benchmarked command: **EXPLODE** (metrics `explode_geometry`, -`explode_scene_apply`). +First benchmarked commands: **EXPLODE** (metrics `explode_geometry`, +`explode_scene_apply`) and **FLATTEN** (metrics `flatten_plan`, +`flatten_scene_apply`). ```bash cargo bench --bench command_benchmarks -- --filter explode diff --git a/benches/command_benchmarks.rs b/benches/command_benchmarks.rs index df7cfabf3..3cd183a34 100644 --- a/benches/command_benchmarks.rs +++ b/benches/command_benchmarks.rs @@ -8,7 +8,8 @@ use std::hint::black_box; use std::time::Instant; use codec::entities::{ - Dimension, DimensionLinear, Insert, Line, LwPolyline, LwVertex, MLine, + Circle, Dimension, DimensionLinear, Insert, Line, LwPolyline, LwVertex, MLine, Point, + Polyline2D, Polyline3D, Ray, Solid, Text, Vertex2D, Vertex3DPolyline, }; use codec::tables::BlockRecord; use codec::types::{Vector2, Vector3}; @@ -18,6 +19,7 @@ use harness::BenchmarkRunner; use OpenCADStudio::modules::draw::modify::explode::{ apply_explode_replacements, explode_batch, }; +use OpenCADStudio::modules::draw::modify::flatten; use OpenCADStudio::scene::Scene; const BLOCK_NAME: &str = "BENCH_BLOCK"; @@ -238,6 +240,173 @@ fn bench_explode_scene_apply(runner: &mut BenchmarkRunner) { ); } +/// (lines, circles, polyline3d, polyline2d, texts, points, solids, rays, flat +/// lines) — full = 2,500 entities, 2,300 of which move. +fn flatten_counts(quick: bool) -> [usize; 9] { + if quick { + [120, 60, 80, 60, 60, 40, 40, 20, 20] // 500 total, 460 move + } else { + [600, 300, 400, 300, 300, 200, 200, 100, 100] // 2,500 total, 2,300 move + } +} + +fn flatten_fixture_entities(quick: bool) -> usize { + flatten_counts(quick).iter().sum() +} + +fn flatten_moved_expected(quick: bool) -> usize { + flatten_fixture_entities(quick) - if quick { 40 } else { 200 } +} + +/// Mixed-Z fixture: everything non-flat except the trailing ray + flat-line +/// groups, which FLATTEN must leave untouched (None). +fn push_flatten_fixture(doc: &mut CadDocument, quick: bool) -> Vec { + let [n_line, n_circle, n_pl3d, n_pl2d, n_text, n_point, n_solid, n_ray, n_flat] = + flatten_counts(quick); + let mut handles = Vec::with_capacity(flatten_fixture_entities(quick)); + + for i in 0..n_line { + let mut line = Line::new(); + let x = i as f64; + line.start = Vector3::new(x, 0.0, 1.0 + (i % 5) as f64); + line.end = Vector3::new(x + 1.0, 1.0, 2.0); + line.thickness = 1.0; + handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } + for i in 0..n_circle { + let mut c = Circle::from_center_radius(Vector3::new(i as f64, 0.0, 1.0 + (i % 3) as f64), 2.0); + if i % 2 == 1 { + c.normal = Vector3::new(0.0, 0.6, 0.8); // tilted → ellipse + } + handles.push(doc.add_entity(EntityType::Circle(c)).unwrap()); + } + for i in 0..n_pl3d { + let mut pl = Polyline3D::new(); + pl.elevation = (i % 4) as f64; + for k in 0..6 { + pl.vertices.push(Vertex3DPolyline::new(Vector3::new( + i as f64 + k as f64, + k as f64, + 1.0 + (k % 3) as f64, + ))); + } + handles.push(doc.add_entity(EntityType::Polyline3D(pl)).unwrap()); + } + for i in 0..n_pl2d { + let mut pl = Polyline2D::new(); + pl.elevation = 3.0 + (i % 5) as f64; + for k in 0..6 { + pl.vertices.push(Vertex2D::new(Vector3::new(k as f64, i as f64, 0.0))); + } + handles.push(doc.add_entity(EntityType::Polyline2D(pl)).unwrap()); + } + for i in 0..n_text { + let mut t = Text::new(); + t.insertion_point = Vector3::new(i as f64, 0.0, 1.5); + handles.push(doc.add_entity(EntityType::Text(t)).unwrap()); + } + for i in 0..n_point { + let mut p = Point::new(); + p.location = Vector3::new(i as f64, 1.0, 0.75); + handles.push(doc.add_entity(EntityType::Point(p)).unwrap()); + } + for i in 0..n_solid { + let x = i as f64; + let s = Solid::new( + Vector3::new(x, 0.0, 1.0), + Vector3::new(x + 1.0, 0.0, 2.0), + Vector3::new(x + 1.0, 1.0, 3.0), + Vector3::new(x, 1.0, 4.0), + ); + handles.push(doc.add_entity(EntityType::Solid(s)).unwrap()); + } + for i in 0..n_ray { + let r = Ray::new(Vector3::new(i as f64, 0.0, 5.0), Vector3::new(0.0, 0.0, 1.0)); + handles.push(doc.add_entity(EntityType::Ray(r)).unwrap()); + } + for i in 0..n_flat { + let mut line = Line::new(); + let x = i as f64; + line.start = Vector3::new(x, 0.0, 0.0); + line.end = Vector3::new(x + 1.0, 1.0, 0.0); + handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } + handles +} + +/// FLATTEN plan: collect candidates + compute replacements (fresh doc/sample). +fn bench_flatten_plan(runner: &mut BenchmarkRunner) { + let name = "flatten_plan"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = flatten_fixture_entities(quick); + let moved_expected = flatten_moved_expected(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut scene = Scene::new(); + push_flatten_fixture(&mut scene.document, quick); + + let t0 = Instant::now(); + let candidates = black_box(flatten::collect_flatten_handles(&scene)); + let updates = black_box(flatten::plan_flatten(&scene, &candidates)); + times.push(t0.elapsed().as_micros() as f64); + + assert_eq!(candidates.len(), entity_count); + assert_eq!(updates.len(), moved_expected); + } + + let med = median(×); + let throughput = entity_count as f64 / (med / 1_000_000.0); + runner.record( + name, + "FLATTEN plan: collect_flatten_handles + plan_flatten over the mixed-Z fixture", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + +/// FLATTEN apply: fresh Scene per sample, plan computed untimed, the publish +/// loop timed. +fn bench_flatten_scene_apply(runner: &mut BenchmarkRunner) { + let name = "flatten_scene_apply"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let moved_expected = flatten_moved_expected(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut scene = Scene::new(); + let handles = push_flatten_fixture(&mut scene.document, quick); + let updates = flatten::plan_flatten(&scene, &handles); + assert_eq!(updates.len(), moved_expected); + + let t0 = Instant::now(); + let moved = black_box(flatten::apply_flatten_updates(&mut scene, updates)); + times.push(t0.elapsed().as_micros() as f64); + assert_eq!(moved, moved_expected); + } + + let med = median(×); + let throughput = moved_expected as f64 / (med / 1_000_000.0); + runner.record( + name, + "FLATTEN apply: apply_flatten_updates (per-entity update_entity publish)", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + fn main() { println!("\nInitializing OpenCADStudio Command Benchmarks..."); let mut runner = BenchmarkRunner::new( @@ -252,6 +421,8 @@ fn main() { bench_explode_geometry(&mut runner); bench_explode_scene_apply(&mut runner); + bench_flatten_plan(&mut runner); + bench_flatten_scene_apply(&mut runner); runner.finish(); } diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 402210691..ab3e64ff4 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1131,38 +1131,17 @@ impl OpenCADStudio { // ── FLATTEN — move selected (or all) entities to Z=0 ───────────── "FLATTEN" => { - let handles: Vec = { - let scene = &self.tabs[i].scene; - let sel = self.tabs[i].scene.selected_entities(); - if sel.is_empty() { - scene - .document - .entities() - .map(|e| e.common().handle) - .filter(|handle| { - scene.entity_belongs_to_active_space(*handle) - && !scene.is_layer_locked(*handle) - }) - .collect() - } else { - sel.into_iter() - .map(|(h, _)| h) - .filter(|handle| { - scene.entity_belongs_to_active_space(*handle) - && !scene.is_layer_locked(*handle) - }) - .collect() - } - }; + let scene = &self.tabs[i].scene; + let handles = + crate::modules::draw::modify::flatten::collect_flatten_handles(scene); if handles.is_empty() { self.command_line.push_error(crate::t!("FLATTEN: no entities.").as_ref()); } else { let candidate_count = handles.len(); - let updates: Vec<_> = handles - .iter() - .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle)) - .filter_map(flatten_entity_z) - .collect(); + let updates = crate::modules::draw::modify::flatten::plan_flatten( + &self.tabs[i].scene, + &handles, + ); let moved = if updates.is_empty() { 0 } else { @@ -1738,283 +1717,6 @@ fn entity_list_details(entity: &codec::EntityType) -> String { } } -fn flatten_entity_z(entity: &codec::EntityType) -> Option { - use codec::types::Vector3; - - let flattened = match entity { - codec::EntityType::Circle(_) - | codec::EntityType::Arc(_) - | codec::EntityType::Ellipse(_) - | codec::EntityType::LwPolyline(_) - | codec::EntityType::Polyline2D(_) => flatten_curve_entity(entity)?, - codec::EntityType::Line(source) => { - let mut line = source.clone(); - line.start = flatten_point(line.start)?; - line.end = flatten_point(line.end)?; - line.thickness = 0.0; - line.normal = Vector3::UNIT_Z; - codec::EntityType::Line(line) - } - codec::EntityType::Polyline(source) => { - let mut polyline = source.clone(); - for vertex in &mut polyline.vertices { - vertex.location = flatten_point(vertex.location)?; - } - codec::EntityType::Polyline(polyline) - } - codec::EntityType::Polyline3D(source) => { - let mut polyline = source.clone(); - polyline.elevation = 0.0; - polyline.normal = Vector3::UNIT_Z; - for vertex in &mut polyline.vertices { - vertex.position = flatten_point(vertex.position)?; - } - codec::EntityType::Polyline3D(polyline) - } - codec::EntityType::Text(source) if positive_z_normal(source.normal) => { - let mut text = source.clone(); - text.insertion_point = flatten_point(text.insertion_point)?; - if let Some(alignment) = text.alignment_point { - text.alignment_point = Some(flatten_point(alignment)?); - } - text.thickness = 0.0; - text.normal = Vector3::UNIT_Z; - codec::EntityType::Text(text) - } - codec::EntityType::MText(source) if positive_z_normal(source.normal) => { - let mut text = source.clone(); - text.insertion_point = flatten_point(text.insertion_point)?; - text.normal = Vector3::UNIT_Z; - codec::EntityType::MText(text) - } - codec::EntityType::Point(source) => { - let mut point = source.clone(); - point.location = flatten_point(point.location)?; - point.thickness = 0.0; - point.normal = Vector3::UNIT_Z; - codec::EntityType::Point(point) - } - codec::EntityType::Spline(source) => { - let mut spline = source.clone(); - for point in &mut spline.control_points { - *point = flatten_point(*point)?; - } - for point in &mut spline.fit_points { - *point = flatten_point(*point)?; - } - spline.begin_tangent = flatten_vector(spline.begin_tangent)?; - spline.end_tangent = flatten_vector(spline.end_tangent)?; - spline.normal = Vector3::UNIT_Z; - spline.flags.planar = true; - codec::EntityType::Spline(spline) - } - codec::EntityType::Solid(source) => { - let corners = crate::entities::solid::wcs_corners(source); - let mut solid = source.clone(); - solid.first_corner = flatten_array(corners[0])?; - solid.second_corner = flatten_array(corners[1])?; - solid.third_corner = flatten_array(corners[2])?; - solid.fourth_corner = flatten_array(corners[3])?; - solid.normal = Vector3::UNIT_Z; - solid.thickness = 0.0; - codec::EntityType::Solid(solid) - } - codec::EntityType::Face3D(source) => { - let mut face = source.clone(); - face.first_corner = flatten_point(face.first_corner)?; - face.second_corner = flatten_point(face.second_corner)?; - face.third_corner = flatten_point(face.third_corner)?; - face.fourth_corner = flatten_point(face.fourth_corner)?; - codec::EntityType::Face3D(face) - } - _ => return None, - }; - - (flattened != *entity).then_some(flattened) -} - -fn flatten_curve_entity(entity: &codec::EntityType) -> Option { - use codec::types::{Vector2, Vector3}; - use kernel::geom2d::Curve; - - match entity { - codec::EntityType::Ellipse(source) - if source.center.z == 0.0 - && source.major_axis.z == 0.0 - && source.normal == Vector3::UNIT_Z => - { - return None; - } - codec::EntityType::LwPolyline(source) - if !z_axis_normal(source.normal) - && (source.constant_width != 0.0 - || source - .vertices - .iter() - .any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) => - { - return None; - } - codec::EntityType::Polyline2D(source) - if !z_axis_normal(source.normal) - && (source.start_width != 0.0 - || source.end_width != 0.0 - || source - .vertices - .iter() - .any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) => - { - return None; - } - codec::EntityType::LwPolyline(source) if source.vertices.len() < 2 => { - let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation); - let mut polyline = source.clone(); - for vertex in &mut polyline.vertices { - let point = flatten_array(plane.point_at([ - vertex.location.x, - vertex.location.y, - ]))?; - vertex.location = Vector2::new(point.x, point.y); - } - polyline.elevation = 0.0; - polyline.thickness = 0.0; - polyline.normal = Vector3::UNIT_Z; - return Some(codec::EntityType::LwPolyline(polyline)); - } - codec::EntityType::Polyline2D(source) if source.vertices.len() < 2 => { - let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation); - let mut polyline = source.clone(); - for vertex in &mut polyline.vertices { - let point = flatten_array(plane.point_at([ - vertex.location.x, - vertex.location.y, - ]))?; - vertex.location = point; - } - polyline.elevation = 0.0; - polyline.thickness = 0.0; - polyline.normal = Vector3::UNIT_Z; - return Some(codec::EntityType::Polyline2D(polyline)); - } - _ => {} - } - - let curve = crate::entities::curve::entity_curve_xy(entity)?; - match (entity, curve) { - (codec::EntityType::Circle(source), Curve::Circle(curve)) => { - let mut circle = source.clone(); - circle.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0); - circle.radius = curve.radius; - circle.thickness = 0.0; - circle.normal = Vector3::UNIT_Z; - Some(codec::EntityType::Circle(circle)) - } - (codec::EntityType::Arc(source), Curve::Arc(curve)) => { - let mut arc = source.clone(); - arc.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0); - arc.radius = curve.radius; - arc.start_angle = curve.start_angle; - arc.end_angle = curve.end_angle; - arc.thickness = 0.0; - arc.normal = Vector3::UNIT_Z; - Some(codec::EntityType::Arc(arc)) - } - (codec::EntityType::Circle(source), Curve::Ellipse(curve)) => { - flattened_ellipse(source.common.clone(), curve) - } - (codec::EntityType::Arc(source), Curve::Ellipse(curve)) => { - flattened_ellipse(source.common.clone(), curve) - } - (codec::EntityType::Ellipse(source), Curve::Ellipse(curve)) => { - flattened_ellipse(source.common.clone(), curve) - } - (codec::EntityType::LwPolyline(source), Curve::Polyline(curve)) => { - if curve.vertices.len() != source.vertices.len() { - return None; - } - let mut polyline = source.clone(); - for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) { - target.location = Vector2::new(projected.position[0], projected.position[1]); - target.bulge = projected.bulge; - } - polyline.elevation = 0.0; - polyline.thickness = 0.0; - polyline.normal = Vector3::UNIT_Z; - Some(codec::EntityType::LwPolyline(polyline)) - } - (codec::EntityType::Polyline2D(source), Curve::Polyline(curve)) => { - if curve.vertices.len() != source.vertices.len() { - return None; - } - let mut polyline = source.clone(); - for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) { - target.location = - Vector3::new(projected.position[0], projected.position[1], 0.0); - target.bulge = projected.bulge; - } - polyline.elevation = 0.0; - polyline.thickness = 0.0; - polyline.normal = Vector3::UNIT_Z; - Some(codec::EntityType::Polyline2D(polyline)) - } - _ => None, - } -} - -fn flattened_ellipse( - common: codec::entities::EntityCommon, - curve: kernel::geom2d::EllipseArc, -) -> Option { - use codec::types::Vector3; - - let geometry = curve.ellipse; - if !geometry.major_radius.is_finite() || geometry.major_radius <= 0.0 { - return None; - } - let mut ellipse = codec::entities::Ellipse::new(); - ellipse.common = common; - ellipse.center = Vector3::new(geometry.centre[0], geometry.centre[1], 0.0); - ellipse.major_axis = Vector3::new( - geometry.major_axis[0] * geometry.major_radius, - geometry.major_axis[1] * geometry.major_radius, - 0.0, - ); - ellipse.minor_axis_ratio = geometry.minor_radius / geometry.major_radius; - ellipse.start_parameter = curve.start_parameter; - ellipse.end_parameter = curve.end_parameter; - ellipse.normal = Vector3::UNIT_Z; - Some(codec::EntityType::Ellipse(ellipse)) -} - -fn flatten_point(point: codec::types::Vector3) -> Option { - flatten_array([point.x, point.y, point.z]) -} - -fn flatten_array(point: [f64; 3]) -> Option { - use kernel::space::Plane; - - let point = Plane::XY.point_at(Plane::XY.project(point)?); - Some(codec::types::Vector3::new(point[0], point[1], point[2])) -} - -fn flatten_vector(vector: codec::types::Vector3) -> Option { - use kernel::space::Plane; - - let vector = Plane::XY.vector_at(Plane::XY.project_vector([ - vector.x, vector.y, vector.z, - ])?); - Some(codec::types::Vector3::new( - vector[0], vector[1], vector[2], - )) -} - -fn positive_z_normal(normal: codec::types::Vector3) -> bool { - normal.x == 0.0 && normal.y == 0.0 && normal.z > 0.0 -} - -fn z_axis_normal(normal: codec::types::Vector3) -> bool { - normal.x == 0.0 && normal.y == 0.0 && normal.z != 0.0 -} // ── CAL — arithmetic expression evaluator ────────────────────────────────── // A small recursive-descent parser for `+ - * /`, parentheses, unary signs and @@ -2149,150 +1851,6 @@ impl ArithParser { } } -#[cfg(test)] -mod flatten_tests { - use super::flatten_entity_z; - use codec::types::Vector3; - use codec::EntityType; - - #[test] - fn flattens_3d_polyline_vertices() { - let mut pl = codec::entities::Polyline3D::new(); - for z in [5.0, -2.0, 7.5] { - pl.vertices - .push(codec::entities::Vertex3DPolyline::new(Vector3::new(1.0, 2.0, z))); - } - pl.elevation = 5.0; - - let entity = EntityType::Polyline3D(pl); - let entity = flatten_entity_z(&entity).expect("projection"); - - let EntityType::Polyline3D(pl) = entity else { - panic!("wrong variant") - }; - assert_eq!(pl.elevation, 0.0); - assert!(pl.vertices.iter().all(|v| v.position.z == 0.0)); - // X/Y must survive the projection. - assert!(pl.vertices.iter().all(|v| v.position.x == 1.0 && v.position.y == 2.0)); - } - - #[test] - fn flattens_2d_polyline_vertices_and_elevation() { - let mut pl = codec::entities::Polyline2D::new(); - pl.vertices - .push(codec::entities::Vertex2D::new(Vector3::new(3.0, 4.0, 9.0))); - pl.elevation = 9.0; - - let entity = EntityType::Polyline2D(pl); - let entity = flatten_entity_z(&entity).expect("projection"); - - let EntityType::Polyline2D(pl) = entity else { - panic!("wrong variant") - }; - assert_eq!(pl.elevation, 0.0); - assert_eq!(pl.vertices[0].location.z, 0.0); - assert_eq!(pl.vertices[0].location.x, 3.0); - } - - #[test] - fn flattens_solid_corners() { - let solid = codec::entities::Solid::new( - Vector3::new(0.0, 0.0, 1.0), - Vector3::new(1.0, 0.0, 2.0), - Vector3::new(1.0, 1.0, 3.0), - Vector3::new(0.0, 1.0, 4.0), - ); - - let entity = EntityType::Solid(solid); - let entity = flatten_entity_z(&entity).expect("projection"); - - let EntityType::Solid(s) = entity else { - panic!("wrong variant") - }; - assert_eq!( - [s.first_corner.z, s.second_corner.z, s.third_corner.z, s.fourth_corner.z], - [0.0; 4] - ); - } - - #[test] - fn reports_unsupported_entities_as_not_moved() { - let entity = EntityType::Ray(codec::entities::Ray::new( - Vector3::new(0.0, 0.0, 5.0), - Vector3::new(1.0, 0.0, 0.0), - )); - assert!(flatten_entity_z(&entity).is_none()); - } - - #[test] - fn still_flattens_a_line() { - let mut line = codec::entities::Line::new(); - line.start = Vector3::new(0.0, 0.0, 3.0); - line.end = Vector3::new(1.0, 1.0, 4.0); - let entity = EntityType::Line(line); - let entity = flatten_entity_z(&entity).expect("projection"); - let EntityType::Line(l) = entity else { panic!("wrong variant") }; - assert_eq!((l.start.z, l.end.z), (0.0, 0.0)); - } - - #[test] - fn already_flat_line_is_not_moved() { - let mut line = codec::entities::Line::new(); - line.start = Vector3::new(0.0, 0.0, 0.0); - line.end = Vector3::new(1.0, 1.0, 0.0); - let entity = EntityType::Line(line); - assert!(flatten_entity_z(&entity).is_none()); - } - - #[test] - fn tilted_circle_projects_to_ellipse() { - let mut circle = codec::entities::Circle::from_center_radius( - Vector3::new(2.0, 3.0, 4.0), - 5.0, - ); - circle.normal = Vector3::new(0.0, 0.6, 0.8); - circle.thickness = 2.0; - let entity = EntityType::Circle(circle); - - let projected = flatten_entity_z(&entity).expect("projection"); - let EntityType::Ellipse(ellipse) = projected else { - panic!("wrong variant") - }; - assert_eq!(ellipse.center.z, 0.0); - assert_eq!(ellipse.major_axis.z, 0.0); - assert_eq!(ellipse.normal, Vector3::UNIT_Z); - assert!(ellipse.minor_axis_ratio < 1.0); - } - - #[test] - fn tilted_solid_uses_world_projection() { - let mut solid = codec::entities::Solid::new( - Vector3::new(0.0, 0.0, 2.0), - Vector3::new(1.0, 0.0, 2.0), - Vector3::new(1.0, 1.0, 2.0), - Vector3::new(0.0, 1.0, 2.0), - ); - solid.normal = Vector3::new(0.0, 0.6, 0.8); - solid.thickness = 3.0; - let entity = EntityType::Solid(solid); - - let projected = flatten_entity_z(&entity).expect("projection"); - let EntityType::Solid(solid) = projected else { - panic!("wrong variant") - }; - assert_eq!(solid.normal, Vector3::UNIT_Z); - assert_eq!(solid.thickness, 0.0); - assert_eq!( - [ - solid.first_corner.z, - solid.second_corner.z, - solid.third_corner.z, - solid.fourth_corner.z, - ], - [0.0; 4] - ); - } -} #[cfg(test)] mod align_selected_bounds_tests { @@ -2439,3 +1997,4 @@ mod find_replace_command_tests { } } } + diff --git a/src/modules/draw/modify/flatten.rs b/src/modules/draw/modify/flatten.rs new file mode 100644 index 000000000..1c398e0ad --- /dev/null +++ b/src/modules/draw/modify/flatten.rs @@ -0,0 +1,476 @@ +//! FLATTEN: project entities onto the XY plane (Z=0). +//! +//! Extracted verbatim from `app/commands/inquiry.rs` so the command arm and +//! the COMMANDS benchmark share one implementation. + +use codec::{EntityType, Handle}; + +use crate::scene::Scene; +pub fn flatten_entity_z(entity: &codec::EntityType) -> Option { + use codec::types::Vector3; + + let flattened = match entity { + codec::EntityType::Circle(_) + | codec::EntityType::Arc(_) + | codec::EntityType::Ellipse(_) + | codec::EntityType::LwPolyline(_) + | codec::EntityType::Polyline2D(_) => flatten_curve_entity(entity)?, + codec::EntityType::Line(source) => { + let mut line = source.clone(); + line.start = flatten_point(line.start)?; + line.end = flatten_point(line.end)?; + line.thickness = 0.0; + line.normal = Vector3::UNIT_Z; + codec::EntityType::Line(line) + } + codec::EntityType::Polyline(source) => { + let mut polyline = source.clone(); + for vertex in &mut polyline.vertices { + vertex.location = flatten_point(vertex.location)?; + } + codec::EntityType::Polyline(polyline) + } + codec::EntityType::Polyline3D(source) => { + let mut polyline = source.clone(); + polyline.elevation = 0.0; + polyline.normal = Vector3::UNIT_Z; + for vertex in &mut polyline.vertices { + vertex.position = flatten_point(vertex.position)?; + } + codec::EntityType::Polyline3D(polyline) + } + codec::EntityType::Text(source) if positive_z_normal(source.normal) => { + let mut text = source.clone(); + text.insertion_point = flatten_point(text.insertion_point)?; + if let Some(alignment) = text.alignment_point { + text.alignment_point = Some(flatten_point(alignment)?); + } + text.thickness = 0.0; + text.normal = Vector3::UNIT_Z; + codec::EntityType::Text(text) + } + codec::EntityType::MText(source) if positive_z_normal(source.normal) => { + let mut text = source.clone(); + text.insertion_point = flatten_point(text.insertion_point)?; + text.normal = Vector3::UNIT_Z; + codec::EntityType::MText(text) + } + codec::EntityType::Point(source) => { + let mut point = source.clone(); + point.location = flatten_point(point.location)?; + point.thickness = 0.0; + point.normal = Vector3::UNIT_Z; + codec::EntityType::Point(point) + } + codec::EntityType::Spline(source) => { + let mut spline = source.clone(); + for point in &mut spline.control_points { + *point = flatten_point(*point)?; + } + for point in &mut spline.fit_points { + *point = flatten_point(*point)?; + } + spline.begin_tangent = flatten_vector(spline.begin_tangent)?; + spline.end_tangent = flatten_vector(spline.end_tangent)?; + spline.normal = Vector3::UNIT_Z; + spline.flags.planar = true; + codec::EntityType::Spline(spline) + } + codec::EntityType::Solid(source) => { + let corners = crate::entities::solid::wcs_corners(source); + let mut solid = source.clone(); + solid.first_corner = flatten_array(corners[0])?; + solid.second_corner = flatten_array(corners[1])?; + solid.third_corner = flatten_array(corners[2])?; + solid.fourth_corner = flatten_array(corners[3])?; + solid.normal = Vector3::UNIT_Z; + solid.thickness = 0.0; + codec::EntityType::Solid(solid) + } + codec::EntityType::Face3D(source) => { + let mut face = source.clone(); + face.first_corner = flatten_point(face.first_corner)?; + face.second_corner = flatten_point(face.second_corner)?; + face.third_corner = flatten_point(face.third_corner)?; + face.fourth_corner = flatten_point(face.fourth_corner)?; + codec::EntityType::Face3D(face) + } + _ => return None, + }; + + (flattened != *entity).then_some(flattened) +} + +fn flatten_curve_entity(entity: &codec::EntityType) -> Option { + use codec::types::{Vector2, Vector3}; + use kernel::geom2d::Curve; + + match entity { + codec::EntityType::Ellipse(source) + if source.center.z == 0.0 + && source.major_axis.z == 0.0 + && source.normal == Vector3::UNIT_Z => + { + return None; + } + codec::EntityType::LwPolyline(source) + if !z_axis_normal(source.normal) + && (source.constant_width != 0.0 + || source + .vertices + .iter() + .any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) => + { + return None; + } + codec::EntityType::Polyline2D(source) + if !z_axis_normal(source.normal) + && (source.start_width != 0.0 + || source.end_width != 0.0 + || source + .vertices + .iter() + .any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) => + { + return None; + } + codec::EntityType::LwPolyline(source) if source.vertices.len() < 2 => { + let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation); + let mut polyline = source.clone(); + for vertex in &mut polyline.vertices { + let point = flatten_array(plane.point_at([ + vertex.location.x, + vertex.location.y, + ]))?; + vertex.location = Vector2::new(point.x, point.y); + } + polyline.elevation = 0.0; + polyline.thickness = 0.0; + polyline.normal = Vector3::UNIT_Z; + return Some(codec::EntityType::LwPolyline(polyline)); + } + codec::EntityType::Polyline2D(source) if source.vertices.len() < 2 => { + let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation); + let mut polyline = source.clone(); + for vertex in &mut polyline.vertices { + let point = flatten_array(plane.point_at([ + vertex.location.x, + vertex.location.y, + ]))?; + vertex.location = point; + } + polyline.elevation = 0.0; + polyline.thickness = 0.0; + polyline.normal = Vector3::UNIT_Z; + return Some(codec::EntityType::Polyline2D(polyline)); + } + _ => {} + } + + let curve = crate::entities::curve::entity_curve_xy(entity)?; + match (entity, curve) { + (codec::EntityType::Circle(source), Curve::Circle(curve)) => { + let mut circle = source.clone(); + circle.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0); + circle.radius = curve.radius; + circle.thickness = 0.0; + circle.normal = Vector3::UNIT_Z; + Some(codec::EntityType::Circle(circle)) + } + (codec::EntityType::Arc(source), Curve::Arc(curve)) => { + let mut arc = source.clone(); + arc.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0); + arc.radius = curve.radius; + arc.start_angle = curve.start_angle; + arc.end_angle = curve.end_angle; + arc.thickness = 0.0; + arc.normal = Vector3::UNIT_Z; + Some(codec::EntityType::Arc(arc)) + } + (codec::EntityType::Circle(source), Curve::Ellipse(curve)) => { + flattened_ellipse(source.common.clone(), curve) + } + (codec::EntityType::Arc(source), Curve::Ellipse(curve)) => { + flattened_ellipse(source.common.clone(), curve) + } + (codec::EntityType::Ellipse(source), Curve::Ellipse(curve)) => { + flattened_ellipse(source.common.clone(), curve) + } + (codec::EntityType::LwPolyline(source), Curve::Polyline(curve)) => { + if curve.vertices.len() != source.vertices.len() { + return None; + } + let mut polyline = source.clone(); + for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) { + target.location = Vector2::new(projected.position[0], projected.position[1]); + target.bulge = projected.bulge; + } + polyline.elevation = 0.0; + polyline.thickness = 0.0; + polyline.normal = Vector3::UNIT_Z; + Some(codec::EntityType::LwPolyline(polyline)) + } + (codec::EntityType::Polyline2D(source), Curve::Polyline(curve)) => { + if curve.vertices.len() != source.vertices.len() { + return None; + } + let mut polyline = source.clone(); + for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) { + target.location = + Vector3::new(projected.position[0], projected.position[1], 0.0); + target.bulge = projected.bulge; + } + polyline.elevation = 0.0; + polyline.thickness = 0.0; + polyline.normal = Vector3::UNIT_Z; + Some(codec::EntityType::Polyline2D(polyline)) + } + _ => None, + } +} + +fn flattened_ellipse( + common: codec::entities::EntityCommon, + curve: kernel::geom2d::EllipseArc, +) -> Option { + use codec::types::Vector3; + + let geometry = curve.ellipse; + if !geometry.major_radius.is_finite() || geometry.major_radius <= 0.0 { + return None; + } + let mut ellipse = codec::entities::Ellipse::new(); + ellipse.common = common; + ellipse.center = Vector3::new(geometry.centre[0], geometry.centre[1], 0.0); + ellipse.major_axis = Vector3::new( + geometry.major_axis[0] * geometry.major_radius, + geometry.major_axis[1] * geometry.major_radius, + 0.0, + ); + ellipse.minor_axis_ratio = geometry.minor_radius / geometry.major_radius; + ellipse.start_parameter = curve.start_parameter; + ellipse.end_parameter = curve.end_parameter; + ellipse.normal = Vector3::UNIT_Z; + Some(codec::EntityType::Ellipse(ellipse)) +} + +fn flatten_point(point: codec::types::Vector3) -> Option { + flatten_array([point.x, point.y, point.z]) +} + +fn flatten_array(point: [f64; 3]) -> Option { + use kernel::space::Plane; + + let point = Plane::XY.point_at(Plane::XY.project(point)?); + Some(codec::types::Vector3::new(point[0], point[1], point[2])) +} + +fn flatten_vector(vector: codec::types::Vector3) -> Option { + use kernel::space::Plane; + + let vector = Plane::XY.vector_at(Plane::XY.project_vector([ + vector.x, vector.y, vector.z, + ])?); + Some(codec::types::Vector3::new( + vector[0], vector[1], vector[2], + )) +} + +fn positive_z_normal(normal: codec::types::Vector3) -> bool { + normal.x == 0.0 && normal.y == 0.0 && normal.z > 0.0 +} + +fn z_axis_normal(normal: codec::types::Vector3) -> bool { + normal.x == 0.0 && normal.y == 0.0 && normal.z != 0.0 +} + +/// Selection-or-all candidate handles: every entity in the active space that +/// sits on an unlocked layer (verbatim from the FLATTEN command arm). +pub fn collect_flatten_handles(scene: &Scene) -> Vec { + let sel = scene.selected_entities(); + if sel.is_empty() { + scene + .document + .entities() + .map(|e| e.common().handle) + .filter(|handle| { + scene.entity_belongs_to_active_space(*handle) + && !scene.is_layer_locked(*handle) + }) + .collect() + } else { + sel.into_iter() + .map(|(h, _)| h) + .filter(|handle| { + scene.entity_belongs_to_active_space(*handle) + && !scene.is_layer_locked(*handle) + }) + .collect() + } +} + +/// The replacement entities for candidates that actually move (None for +/// unsupported / already-flat entities, exactly as the arm filtered them). +pub fn plan_flatten(scene: &Scene, handles: &[Handle]) -> Vec { + handles + .iter() + .filter_map(|&handle| scene.document.get_entity(handle)) + .filter_map(flatten_entity_z) + .collect() +} + +/// Publish flattened replacements; returns how many entities moved. +pub fn apply_flatten_updates(scene: &mut Scene, updates: Vec) -> usize { + let mut moved = 0usize; + for entity in updates { + if scene.update_entity(entity) { + moved += 1; + } + } + moved +} + +#[cfg(test)] +mod flatten_tests { + use super::flatten_entity_z; + use codec::types::Vector3; + use codec::EntityType; + + #[test] + fn flattens_3d_polyline_vertices() { + let mut pl = codec::entities::Polyline3D::new(); + for z in [5.0, -2.0, 7.5] { + pl.vertices + .push(codec::entities::Vertex3DPolyline::new(Vector3::new(1.0, 2.0, z))); + } + pl.elevation = 5.0; + + let entity = EntityType::Polyline3D(pl); + let entity = flatten_entity_z(&entity).expect("projection"); + + let EntityType::Polyline3D(pl) = entity else { + panic!("wrong variant") + }; + assert_eq!(pl.elevation, 0.0); + assert!(pl.vertices.iter().all(|v| v.position.z == 0.0)); + // X/Y must survive the projection. + assert!(pl.vertices.iter().all(|v| v.position.x == 1.0 && v.position.y == 2.0)); + } + + #[test] + fn flattens_2d_polyline_vertices_and_elevation() { + let mut pl = codec::entities::Polyline2D::new(); + pl.vertices + .push(codec::entities::Vertex2D::new(Vector3::new(3.0, 4.0, 9.0))); + pl.elevation = 9.0; + + let entity = EntityType::Polyline2D(pl); + let entity = flatten_entity_z(&entity).expect("projection"); + + let EntityType::Polyline2D(pl) = entity else { + panic!("wrong variant") + }; + assert_eq!(pl.elevation, 0.0); + assert_eq!(pl.vertices[0].location.z, 0.0); + assert_eq!(pl.vertices[0].location.x, 3.0); + } + + #[test] + fn flattens_solid_corners() { + let solid = codec::entities::Solid::new( + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 2.0), + Vector3::new(1.0, 1.0, 3.0), + Vector3::new(0.0, 1.0, 4.0), + ); + + let entity = EntityType::Solid(solid); + let entity = flatten_entity_z(&entity).expect("projection"); + + let EntityType::Solid(s) = entity else { + panic!("wrong variant") + }; + assert_eq!( + [s.first_corner.z, s.second_corner.z, s.third_corner.z, s.fourth_corner.z], + [0.0; 4] + ); + } + + #[test] + fn reports_unsupported_entities_as_not_moved() { + let entity = EntityType::Ray(codec::entities::Ray::new( + Vector3::new(0.0, 0.0, 5.0), + Vector3::new(1.0, 0.0, 0.0), + )); + assert!(flatten_entity_z(&entity).is_none()); + } + + #[test] + fn still_flattens_a_line() { + let mut line = codec::entities::Line::new(); + line.start = Vector3::new(0.0, 0.0, 3.0); + line.end = Vector3::new(1.0, 1.0, 4.0); + let entity = EntityType::Line(line); + let entity = flatten_entity_z(&entity).expect("projection"); + let EntityType::Line(l) = entity else { panic!("wrong variant") }; + assert_eq!((l.start.z, l.end.z), (0.0, 0.0)); + } + + #[test] + fn already_flat_line_is_not_moved() { + let mut line = codec::entities::Line::new(); + line.start = Vector3::new(0.0, 0.0, 0.0); + line.end = Vector3::new(1.0, 1.0, 0.0); + let entity = EntityType::Line(line); + assert!(flatten_entity_z(&entity).is_none()); + } + + #[test] + fn tilted_circle_projects_to_ellipse() { + let mut circle = codec::entities::Circle::from_center_radius( + Vector3::new(2.0, 3.0, 4.0), + 5.0, + ); + circle.normal = Vector3::new(0.0, 0.6, 0.8); + circle.thickness = 2.0; + let entity = EntityType::Circle(circle); + + let projected = flatten_entity_z(&entity).expect("projection"); + let EntityType::Ellipse(ellipse) = projected else { + panic!("wrong variant") + }; + assert_eq!(ellipse.center.z, 0.0); + assert_eq!(ellipse.major_axis.z, 0.0); + assert_eq!(ellipse.normal, Vector3::UNIT_Z); + assert!(ellipse.minor_axis_ratio < 1.0); + } + + #[test] + fn tilted_solid_uses_world_projection() { + let mut solid = codec::entities::Solid::new( + Vector3::new(0.0, 0.0, 2.0), + Vector3::new(1.0, 0.0, 2.0), + Vector3::new(1.0, 1.0, 2.0), + Vector3::new(0.0, 1.0, 2.0), + ); + solid.normal = Vector3::new(0.0, 0.6, 0.8); + solid.thickness = 3.0; + let entity = EntityType::Solid(solid); + + let projected = flatten_entity_z(&entity).expect("projection"); + let EntityType::Solid(solid) = projected else { + panic!("wrong variant") + }; + assert_eq!(solid.normal, Vector3::UNIT_Z); + assert_eq!(solid.thickness, 0.0); + assert_eq!( + [ + solid.first_corner.z, + solid.second_corner.z, + solid.third_corner.z, + solid.fourth_corner.z, + ], + [0.0; 4] + ); + } +} diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index 93c2e6aac..210f72fd6 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -6,6 +6,7 @@ pub mod break_cmd; pub mod copy; pub mod delete; mod entity_index; +pub mod flatten; pub mod explode; pub mod fillet; pub mod geom;