From b629dc32de3916ca359d888aaebf79d170df7206 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 27 Jul 2026 21:50:50 +0300 Subject: [PATCH] feat(wipeout): add polygon creation modes Closes #528 --- src/app/command_driver.rs | 23 ++ src/app/commands/draw.rs | 8 +- src/command.rs | 2 + src/modules/draw/draw/wipeout.rs | 553 +++++++++++++++++++++++-------- src/scene/mod.rs | 16 + 5 files changed, 455 insertions(+), 147 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 23de79c9..10fed2b3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -587,6 +587,29 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } + CmdResult::WipeoutFromPolyline(handle) => { + let wipeout = { + let scene = &self.tabs[i].scene; + scene + .entity_belongs_to_active_space(handle) + .then(|| scene.document.get_entity(handle)) + .flatten() + .and_then( + crate::modules::draw::draw::wipeout::wipeout_from_polyline, + ) + }; + if let Some(wipeout) = wipeout { + return self.apply_cmd_result(CmdResult::CommitAndExit(wipeout)); + } + self.command_line.push_error( + "WIPEOUT Polyline: select a closed planar polyline with at least 3 vertices.", + ); + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } CmdResult::MviewSwitchLayout(layout) => { let task = self.on_layout_switch(layout); if let Some(prompt) = diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 272e9e80..9aff5f85 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -25,10 +25,10 @@ impl OpenCADStudio { .split_once(' ') .map(|(_, r)| r.trim().to_uppercase()) .unwrap_or_default(); - let wo_cmd = if args == "P" || args == "POLYGONAL" { - WipeoutCommand::new_polygonal() - } else { - WipeoutCommand::new_rectangular() + let wo_cmd = match args.as_str() { + "P" | "POLYLINE" => WipeoutCommand::new_polyline(), + "R" | "RECTANGULAR" => WipeoutCommand::new_rectangular(), + _ => WipeoutCommand::new_polygonal(), }; self.command_line.push_info(&wo_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(wo_cmd)); diff --git a/src/command.rs b/src/command.rs index 34017a53..d8433567 100644 --- a/src/command.rs +++ b/src/command.rs @@ -918,6 +918,8 @@ pub enum CmdResult { boundary: Option, boundary_handle: Handle, }, + /// Create a wipeout from an existing closed polyline in the active space. + WipeoutFromPolyline(Handle), /// Temporarily switch between paper and Model while MVIEW defines a new /// model-space window, keeping the command active. MviewSwitchLayout(String), diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index cd854ae1..958e3b08 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -1,15 +1,11 @@ -// WIPEOUT command — create a rectangular or polygonal masking area. -// -// Modes: -// WIPEOUT (default): two-corner rectangular wipeout -// WIPEOUT P: polygonal wipeout (pick corners, Enter to close) +// WIPEOUT command — draw a polygonal mask or derive one from a closed polyline. -use acadrust::entities::Wipeout; -use acadrust::types::Vector3; -use acadrust::EntityType; +use acadrust::entities::{Wipeout, WipeoutClipType}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -27,29 +23,53 @@ pub fn tool() -> ToolDef { pub struct WipeoutCommand { mode: WipeoutMode, first: Option, - poly_pts: Vec, + points: Vec, } +#[derive(Clone, Copy, PartialEq)] enum WipeoutMode { + Draw, + Polyline, Rectangular, - Polygonal, } impl WipeoutCommand { + pub fn new_polygonal() -> Self { + Self { + mode: WipeoutMode::Draw, + first: None, + points: Vec::new(), + } + } + + pub fn new_polyline() -> Self { + Self { + mode: WipeoutMode::Polyline, + first: None, + points: Vec::new(), + } + } + + /// Kept for the legacy `WIPEOUT RECTANGULAR` command-line form. pub fn new_rectangular() -> Self { Self { mode: WipeoutMode::Rectangular, first: None, - poly_pts: vec![], + points: Vec::new(), } } - pub fn new_polygonal() -> Self { - Self { - mode: WipeoutMode::Polygonal, - first: None, - poly_pts: vec![], + + fn finish_draw(&self) -> CmdResult { + match make_poly_wipeout(&self.points) { + Some(entity) => CmdResult::CommitAndExit(entity), + None => CmdResult::NeedPoint, } } + + fn undo_point(&mut self) -> CmdResult { + self.points.pop(); + CmdResult::NeedPoint + } } impl CadCommand for WipeoutCommand { @@ -58,161 +78,408 @@ impl CadCommand for WipeoutCommand { } fn prompt(&self) -> String { - match &self.mode { - WipeoutMode::Rectangular => { - if self.first.is_none() { - "WIPEOUT Specify first corner:".into() - } else { - "WIPEOUT Specify opposite corner:".into() - } + match self.mode { + WipeoutMode::Draw if self.points.is_empty() => { + "WIPEOUT Specify first point or [Polyline]:".into() } - WipeoutMode::Polygonal => { - if self.poly_pts.is_empty() { - "WIPEOUT (Polygon) Specify first point:".into() - } else { - format!( - "WIPEOUT (Polygon) Specify next point ({} pts, Enter to close):", - self.poly_pts.len() - ) - } + WipeoutMode::Draw => format!( + "WIPEOUT Specify next point or [Undo/Close] ({} points):", + self.points.len() + ), + WipeoutMode::Polyline => { + "WIPEOUT Polyline Select a closed planar polyline:".into() + } + WipeoutMode::Rectangular if self.first.is_none() => { + "WIPEOUT Rectangular Specify first corner:".into() + } + WipeoutMode::Rectangular => { + "WIPEOUT Rectangular Specify opposite corner:".into() } } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.mode { + fn options(&self) -> Vec { + match self.mode { + WipeoutMode::Draw if self.points.is_empty() => { + vec![CmdOption::new("Polyline", "P")] + } + WipeoutMode::Draw => vec![ + CmdOption::new("Undo", "U"), + CmdOption::new("Close", "C"), + ], + _ => Vec::new(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.mode { + WipeoutMode::Draw => { + if let Some(first) = self.points.first() { + let distance_squared = + (point.x - first.x).powi(2) + (point.y - first.y).powi(2); + if self.points.len() >= 3 && distance_squared < 1e-12 { + return self.finish_draw(); + } + } + self.points.push(point); + CmdResult::NeedPoint + } WipeoutMode::Rectangular => { - if let Some(p1) = self.first { - let entity = make_rect_wipeout(p1, pt); - CmdResult::CommitAndExit(entity) + if let Some(first) = self.first { + CmdResult::CommitAndExit(make_rect_wipeout(first, point)) } else { - self.first = Some(pt); + self.first = Some(point); CmdResult::NeedPoint } } - WipeoutMode::Polygonal => { - self.poly_pts.push(pt); - CmdResult::NeedPoint - } + WipeoutMode::Polyline => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - match &self.mode { - WipeoutMode::Polygonal if self.poly_pts.len() >= 3 => { - let entity = make_poly_wipeout(&self.poly_pts); - CmdResult::CommitAndExit(entity) - } + match self.mode { + WipeoutMode::Draw if self.points.len() >= 3 => self.finish_draw(), _ => CmdResult::Cancel, } } - fn window_corner_pick(&self) -> bool { - // Rectangular mode picks free window corners — exempt from Ortho/Polar - // (#363/#291 class). Polygon points keep the constraints (axis-aligned - // polygon edges are a legitimate use). - matches!(self.mode, WipeoutMode::Rectangular) + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); - match &self.mode { + fn needs_entity_pick(&self) -> bool { + self.mode == WipeoutMode::Polyline + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + if handle.is_null() { + CmdResult::NeedPoint + } else { + CmdResult::WipeoutFromPolyline(handle) + } + } + + fn wants_text_input(&self) -> bool { + self.mode == WipeoutMode::Draw + } + + fn point_step_accepts_keywords(&self) -> bool { + self.mode == WipeoutMode::Draw + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.mode != WipeoutMode::Draw { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "P" | "POLYLINE" if self.points.is_empty() => { + self.mode = WipeoutMode::Polyline; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if !self.points.is_empty() => { + Some(self.undo_point()) + } + "C" | "CLOSE" if self.points.len() >= 3 => { + Some(self.finish_draw()) + } + _ => None, + } + } + + fn on_undo_step(&mut self) -> Option { + if self.mode == WipeoutMode::Draw && !self.points.is_empty() { + Some(self.undo_point()) + } else { + None + } + } + + fn window_corner_pick(&self) -> bool { + self.mode == WipeoutMode::Rectangular + } + + fn window_first_corner(&self) -> Option { + (self.mode == WipeoutMode::Rectangular) + .then_some(self.first) + .flatten() + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.mode { + WipeoutMode::Draw => { + let first = *self.points.first()?; + let mut preview = self.points.clone(); + preview.push(point); + preview.push(first); + Some(WireModel::solid_f64( + "wipeout_preview".into(), + preview + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect(), + WireModel::CYAN, + false, + )) + } WipeoutMode::Rectangular => { - let p1 = self.first?.as_vec3(); - let min = p1.min(pt); - let max = p1.max(pt); - Some(WireModel { - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - fill_is_3d: false, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "wipeout_preview".into(), - points: vec![ - [min.x, min.y, min.z], - [max.x, min.y, min.z], - [max.x, max.y, max.z], - [min.x, max.y, max.z], - [min.x, min.y, min.z], + let first = self.first?; + Some(WireModel::solid_f64( + "wipeout_preview".into(), + vec![ + [first.x, first.y, first.z], + [point.x, first.y, first.z], + [point.x, point.y, first.z], + [first.x, point.y, first.z], + [first.x, first.y, first.z], ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) - } - WipeoutMode::Polygonal => { - if self.poly_pts.is_empty() { - return None; - } - let mut pts: Vec<[f32; 3]> = self - .poly_pts - .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) - .collect(); - pts.push([pt.x, pt.y, pt.z]); - pts.push([ - self.poly_pts[0].x as f32, - self.poly_pts[0].y as f32, - self.poly_pts[0].z as f32, - ]); - Some(WireModel { - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - fill_is_3d: false, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "wipeout_poly_preview".into(), - points: pts, - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + WireModel::CYAN, + false, + )) } + WipeoutMode::Polyline => None, } } } -fn make_rect_wipeout(p1: DVec3, p2: DVec3) -> EntityType { - // Drawing plane is world XY (z = elevation). - let c1 = Vector3::new(p1.x.min(p2.x), p1.y.min(p2.y), p1.z); - let c2 = Vector3::new(p1.x.max(p2.x), p1.y.max(p2.y), p1.z); - EntityType::Wipeout(Wipeout::from_corners(c1, c2)) +fn make_rect_wipeout(first: DVec3, second: DVec3) -> EntityType { + let corner1 = Vector3::new( + first.x.min(second.x), + first.y.min(second.y), + first.z, + ); + let corner2 = Vector3::new( + first.x.max(second.x), + first.y.max(second.y), + first.z, + ); + EntityType::Wipeout(Wipeout::from_corners(corner1, corner2)) } -fn make_poly_wipeout(pts: &[DVec3]) -> EntityType { - use acadrust::types::Vector2; - let z = pts[0].z; - let verts: Vec = pts.iter().map(|p| Vector2::new(p.x, p.y)).collect(); - EntityType::Wipeout(Wipeout::polygonal(&verts, z)) +fn same_point(first: DVec3, second: DVec3) -> bool { + (first - second).length_squared() < 1e-18 +} + +fn clean_boundary(points: &[DVec3]) -> Vec { + let mut clean = Vec::with_capacity(points.len()); + for point in points.iter().copied().filter(|point| point.is_finite()) { + if clean.last().is_none_or(|last| !same_point(*last, point)) { + clean.push(point); + } + } + if clean.len() > 1 && same_point(clean[0], *clean.last().unwrap()) { + clean.pop(); + } + clean +} + +fn make_poly_wipeout(points: &[DVec3]) -> Option { + let points = clean_boundary(points); + if points.len() < 3 { + return None; + } + let z = points[0].z; + if points.iter().any(|point| (point.z - z).abs() > 1e-7) { + return None; + } + + let mut min_x = points[0].x; + let mut min_y = points[0].y; + let mut max_x = points[0].x; + let mut max_y = points[0].y; + for point in points.iter().skip(1) { + min_x = min_x.min(point.x); + min_y = min_y.min(point.y); + max_x = max_x.max(point.x); + max_y = max_y.max(point.y); + } + let width = max_x - min_x; + let height = max_y - min_y; + if width < 1e-9 || height < 1e-9 { + return None; + } + + // Stable signed area around a local origin rejects collinear boundaries + // without losing precision on large world coordinates. + let origin = points[0]; + let mut twice_area = 0.0; + for index in 0..points.len() { + let a = points[index] - origin; + let b = points[(index + 1) % points.len()] - origin; + twice_area += a.x * b.y - b.x * a.y; + } + if twice_area.abs() <= width * height * 1e-10 { + return None; + } + + // Wipeout clip vertices use image-pixel coordinates centred on the image: + // X grows right, Y grows up while the stored V vector points down. + let clip_boundary_vertices = points + .iter() + .map(|point| { + let normalized_x = (point.x - min_x) / width; + let normalized_y = (point.y - min_y) / height; + Vector2::new(normalized_x - 0.5, 0.5 - normalized_y) + }) + .collect(); + let mut wipeout = Wipeout::new(); + wipeout.insertion_point = Vector3::new(min_x, min_y, z); + wipeout.u_vector = Vector3::new(width, 0.0, 0.0); + wipeout.v_vector = Vector3::new(0.0, height, 0.0); + wipeout.size = Vector2::new(1.0, 1.0); + wipeout.clip_type = WipeoutClipType::Polygonal; + wipeout.clip_boundary_vertices = clip_boundary_vertices; + wipeout.clipping_enabled = true; + Some(EntityType::Wipeout(wipeout)) +} + +fn explicitly_closed(points: &[DVec3]) -> bool { + points.len() >= 4 && same_point(points[0], *points.last().unwrap()) +} + +fn sample_2d_polyline( + vertices: &[([f64; 2], f64)], + elevation: f64, + normal: (f64, f64, f64), +) -> Vec { + if vertices.len() < 3 { + return Vec::new(); + } + let to_wcs = |point: [f64; 2]| { + let point = crate::scene::view::transform::ocs_point_to_wcs( + (point[0], point[1], elevation), + normal, + ); + DVec3::new(point.0, point.1, point.2) + }; + let mut points = vec![to_wcs(vertices[0].0)]; + for index in 0..vertices.len() { + let (start, bulge) = vertices[index]; + let end_index = (index + 1) % vertices.len(); + let end = vertices[end_index].0; + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge(start, end, bulge) + { + let steps = ((arc.sweep.abs() / std::f64::consts::TAU * 64.0).ceil() + as usize) + .clamp(4, 64); + for step in 1..=steps { + if end_index == 0 && step == steps { + break; + } + points.push(to_wcs(arc.sample(step as f64 / steps as f64))); + } + } else if end_index != 0 { + points.push(to_wcs(end)); + } + } + points +} + +/// Build a wipeout boundary from a picked closed polyline without consuming +/// the source entity. Bulged 2D segments are sampled into the polygonal mask. +pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { + let points = match entity { + EntityType::LwPolyline(polyline) => { + let raw: Vec = polyline + .vertices + .iter() + .map(|vertex| DVec3::new(vertex.location.x, vertex.location.y, polyline.elevation)) + .collect(); + if !polyline.is_closed && !explicitly_closed(&raw) { + return None; + } + let vertices: Vec<([f64; 2], f64)> = polyline + .vertices + .iter() + .map(|vertex| { + ( + [vertex.location.x, vertex.location.y], + vertex.bulge, + ) + }) + .collect(); + sample_2d_polyline( + &vertices, + polyline.elevation, + ( + polyline.normal.x, + polyline.normal.y, + polyline.normal.z, + ), + ) + } + EntityType::Polyline2D(polyline) => { + let raw: Vec = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::new( + vertex.location.x, + vertex.location.y, + polyline.elevation, + ) + }) + .collect(); + if !polyline.is_closed() && !explicitly_closed(&raw) { + return None; + } + let vertices: Vec<([f64; 2], f64)> = polyline + .vertices + .iter() + .map(|vertex| { + ( + [vertex.location.x, vertex.location.y], + vertex.bulge, + ) + }) + .collect(); + sample_2d_polyline( + &vertices, + polyline.elevation, + ( + polyline.normal.x, + polyline.normal.y, + polyline.normal.z, + ), + ) + } + EntityType::Polyline(polyline) => { + let points: Vec = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::new( + vertex.location.x, + vertex.location.y, + vertex.location.z, + ) + }) + .collect(); + if !polyline.is_closed() && !explicitly_closed(&points) { + return None; + } + points + } + EntityType::Polyline3D(polyline) => { + let points: Vec = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::new( + vertex.position.x, + vertex.position.y, + vertex.position.z, + ) + }) + .collect(); + if !polyline.flags.closed && !explicitly_closed(&points) { + return None; + } + points + } + _ => return None, + }; + make_poly_wipeout(&points) } // ── Autocomplete registry ───────────────────────────────── diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7053240a..06780fb1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2725,6 +2725,22 @@ impl Scene { ) } + /// True when an entity belongs to the space receiving newly drawn + /// entities: BEDIT's block, paper space, or model space (including MSPACE). + pub(crate) fn entity_belongs_to_active_space(&self, handle: Handle) -> bool { + let Some(entity) = self.document.get_entity(handle) else { + return false; + }; + let block = if let Some(block) = self.block_edit_block { + block + } else if self.current_layout != "Model" && self.active_viewport.is_none() { + self.current_layout_block_handle() + } else { + self.model_space_block_handle() + }; + self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + } + /// Returns the block-record handle for `current_layout`. /// /// Primary path: the Layout object's `block_record` field (set correctly