diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index dadf2bdb6..26e9540ec 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -391,7 +391,50 @@ impl OpenCADStudio { return None; } let kw = text.trim().to_ascii_uppercase(); + + // Modes that arm the next gesture rather than selecting anything now. + // Dragging normally decides window-vs-crossing from the direction the + // corner travels; asking for one by name has to override that, and hold + // the override until the box is finished. (#596) + if let "W" | "WINDOW" | "C" | "CROSSING" = kw.as_str() { + let crossing = matches!(kw.as_str(), "C" | "CROSSING"); + { + let mut selection = self.tabs[i].scene.selection.borrow_mut(); + selection.box_crossing = crossing; + selection.box_crossing_locked = true; + } + let hint = if crossing { + crate::t!("Crossing: specify first corner.") + } else { + crate::t!("Window: specify first corner.") + }; + self.command_line.push_info(hint.as_ref()); + return Some(Task::none()); + } + + // Whether a pick adds to the set or takes away from it, for the rest of + // this selection. Mirrors PICKADD, which the pick paths already read. + if let "R" | "REMOVE" | "A" | "ADD" = kw.as_str() { + let adding = matches!(kw.as_str(), "A" | "ADD"); + self.select_remove_mode = !adding; + let hint = if adding { + crate::t!("Add mode: picks join the selection.") + } else { + crate::t!("Remove mode: picks leave the selection.") + }; + self.command_line.push_info(hint.as_ref()); + return Some(Task::none()); + } + let add: Vec = match kw.as_str() { + // Every selectable object of the current space. + "ALL" => self.tabs[i] + .scene + .entity_wires() + .iter() + .filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name)) + .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) + .collect(), "P" | "PREVIOUS" => self.tabs[i] .prev_selection .iter() @@ -418,6 +461,7 @@ impl OpenCADStudio { if add.is_empty() { self.command_line.push_info(match kw.as_str() { "P" | "PREVIOUS" => "No previous selection set.", + "ALL" => "Nothing to select.", _ => "No last object.", }); return Some(Task::none()); @@ -1476,6 +1520,69 @@ impl OpenCADStudio { "Command cancelled." }); } + CmdResult::SelectByPath { + path, + closed, + crossing, + } => { + // The command picked the path; the hit test lives here, where + // the camera and the drawing's geometry are. Everything after + // matches what a lasso does, Remove included. + let canvas = self.tabs[i].scene.selection.borrow().vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: canvas.0, + height: canvas.1, + }; + let edit_cam = self.tabs[i] + .scene + .viewport_edit_frame(canvas) + .map(|(cam, _)| cam); + let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); + let screen: Vec = path + .iter() + .map(|p| { + crate::scene::pick::hit_test::world_to_screen( + glam::DVec3::new(p[0], p[1], 0.0), + view_rot, + eye, + bounds, + ) + }) + .collect(); + let handles = self.tabs[i].scene.path_hit_handles( + &screen, + crossing, + !closed, + all_wires, + view_rot, + eye, + bounds, + |point| self.cursor_model_point(i, &edit_cam, point, bounds), + ); + if self.select_remove_mode { + for h in &handles { + self.tabs[i].scene.deselect_entity(*h); + } + } else { + for h in &handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].scene.expand_selection_for_groups(&handles); + } + self.refresh_properties(); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let count = handles.len(); + self.command_line + .push_info(crate::tf!("{count} object(s) found.").as_ref()); + return self.feed_command(StepInput::SelectionComplete(selected)); + } CmdResult::Relaunch(cmd, handles) => { self.tabs[i].scene.deselect_all(); for h in &handles { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 6077bf895..23be9559c 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -68,6 +68,14 @@ impl OpenCADStudio { // Starting a command closes any open ribbon dropdown (e.g. a style // combo left open) so it does not stay stuck behind the new tool. self.ribbon.close_dropdown(); + // Selection keywords last only for the round that asked for them: a + // Remove or a fixed Window sense must not quietly still be in force + // when the next command asks for objects. (#596) + self.select_remove_mode = false; + { + let mut selection = self.tabs[i].scene.selection.borrow_mut(); + selection.box_crossing_locked = false; + } // Cancel any running command before starting a new one. if self.tabs[i].active_cmd.is_some() { self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 848d665ef..d43628744 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -397,6 +397,12 @@ pub(super) struct OpenCADStudio { /// PICKADD (#226): true (default) = clicks accumulate; false = OS-style /// replace-on-click with Shift toggling. pick_add: bool, + /// The Remove selection keyword: picks take objects out of the set instead + /// of putting them in, until Add turns it off or the command ends. This is + /// what Shift already does per-click, not PICKADD — PICKADD decides whether + /// a pick *replaces* the set, so borrowing it here would wipe everything + /// gathered so far rather than subtract one thing. (#596) + select_remove_mode: bool, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = /// press-drag draws a rectangle marquee. pick_drag_rect: bool, @@ -2971,6 +2977,7 @@ impl OpenCADStudio { quick_properties_anchor: Point::new(12.0, 12.0), selection_cycling: false, pick_add: true, + select_remove_mode: false, pick_drag_rect: false, perf_hud: false, cycle_candidates: None, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1fefa36c7..a603859c7 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -700,7 +700,9 @@ impl OpenCADStudio { // overlay and completion) instead of the lasso. sel.box_anchor = Some(press); sel.box_current = Some(p); - sel.box_crossing = p.x < press.x; + if !sel.box_crossing_locked { + sel.box_crossing = p.x < press.x; + } } else { sel.poly_active = true; sel.poly_crossing = p.x < press.x; @@ -719,13 +721,17 @@ impl OpenCADStudio { } else if sel.left_dragging { if let Some(a) = sel.box_anchor { sel.box_current = Some(p); - sel.box_crossing = p.x < a.x; + if !sel.box_crossing_locked { + sel.box_crossing = p.x < a.x; + } } } } else if sel.box_anchor.is_some() { sel.box_current = Some(p); if let Some(a) = sel.box_anchor { - sel.box_crossing = p.x < a.x; + if !sel.box_crossing_locked { + sel.box_crossing = p.x < a.x; + } } } @@ -1913,6 +1919,7 @@ impl OpenCADStudio { sel.box_anchor_world = None; sel.box_current = None; sel.box_crossing = false; + sel.box_crossing_locked = false; sel.poly_active = false; sel.poly_points.clear(); sel.poly_crossing = false; @@ -2986,7 +2993,9 @@ impl OpenCADStudio { selection.box_anchor = Some(p_full); selection.box_current = Some(p_full); selection.box_anchor_world = Some(anchor_world); - selection.box_crossing = false; + if !selection.box_crossing_locked { + selection.box_crossing = false; + } None } else { self.command_line @@ -3199,6 +3208,7 @@ impl OpenCADStudio { selection.box_anchor_world = None; selection.box_current = None; selection.box_crossing = false; + selection.box_crossing_locked = false; drop(selection); return self.apply_cmd_result(result); } @@ -3300,7 +3310,7 @@ impl OpenCADStudio { // selection, Shift+box removes the boxed // entities. Esc / empty-space click still clears. // PICKADD 0 (#226): a plain box REPLACES. - if self.shift_down { + if self.shift_down || self.select_remove_mode { for h in &handles { self.tabs[i].scene.deselect_entity(*h); } @@ -3332,103 +3342,21 @@ impl OpenCADStudio { }; self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing; let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let world_aabb = poly_pts.iter().fold( - [ - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, - ], - |mut aabb, &point| { - let world = self.cursor_model_point(i, &edit_cam, point, bounds); - aabb[0] = aabb[0].min(world.x); - aabb[1] = aabb[1].min(world.y); - aabb[2] = aabb[2].max(world.x); - aabb[3] = aabb[3].max(world.y); - aabb - }, - ); - let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( + let handles = self.tabs[i].scene.path_hit_handles( + &poly_pts, + crossing, + false, all_wires, - world_aabb, - poly_pts.iter().fold( - [ - f32::INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - f32::NEG_INFINITY, - ], - |mut rect, point| { - rect[0] = rect[0].min(point.x); - rect[1] = rect[1].min(point.y); - rect[2] = rect[2].max(point.x); - rect[3] = rect[3].max(point.y); - rect - }, - ), view_rot, eye, bounds, + |point| self.cursor_model_point(i, &edit_cam, point, bounds), ); - let candidate_handles = self.tabs[i] - .scene - .interaction_candidate_handles(&area_candidates); - let mut handles: Vec = scene::pick::hit_test::poly_hit( - &poly_pts, - crossing, - &area_candidates, - view_rot, - eye, - bounds, - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - handles.extend(scene::pick::hit_test::poly_hit_hatch( - &poly_pts, - crossing, - &self.tabs[i] - .scene - .visible_hatches_for_click(candidate_handles.as_ref()), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(scene::pick::hit_test::poly_hit_insert_hatch( - &poly_pts, - crossing, - self.tabs[i].scene.insert_hatches_for_click().as_ref(), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(self.tabs[i].scene.mesh_poly_hit( - &poly_pts, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(self.tabs[i].scene.block_mesh_poly_hit( - &poly_pts, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - // Selection filter: keep only allowed types. - handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); // Accumulate like the box path (issue #83): plain // lasso adds, Shift+lasso removes. An empty lasso // leaves the current selection untouched so a stray // drag never discards hard-won picks. - if self.shift_down { + if self.shift_down || self.select_remove_mode { for h in &handles { self.tabs[i].scene.deselect_entity(*h); } @@ -3564,8 +3492,12 @@ impl OpenCADStudio { // PICKADD 0 (#226): a plain click // REPLACES the selection instead and // Shift+click toggles membership. - if self.shift_down { - if !selection_pick_add + if self.shift_down || self.select_remove_mode { + // Remove was asked for by name, so it only + // ever takes away — the toggle below is + // Shift's PICKADD-0 behaviour, not its. + if !self.select_remove_mode + && !selection_pick_add && !self.tabs[i].scene.selected.contains(&handle) { self.tabs[i].scene.select_entity(handle, false); @@ -3607,7 +3539,9 @@ impl OpenCADStudio { sel.box_anchor = Some(p_full); sel.box_current = Some(p_full); sel.box_anchor_world = Some(anchor_world); - sel.box_crossing = false; + if !sel.box_crossing_locked { + sel.box_crossing = false; + } } } } else { @@ -3656,6 +3590,7 @@ impl OpenCADStudio { selection.box_anchor_world = None; selection.box_current = None; selection.box_crossing = false; + selection.box_crossing_locked = false; drop(selection); return self.apply_cmd_result(result); } @@ -3752,7 +3687,7 @@ impl OpenCADStudio { // entities. An empty box leaves the selection alone // so an accidental empty drag never discards it. // PICKADD 0 (#226): a plain box REPLACES instead. - if self.shift_down { + if self.shift_down || self.select_remove_mode { for h in &handles { self.tabs[i].scene.deselect_entity(*h); } @@ -3773,6 +3708,7 @@ impl OpenCADStudio { sel.box_anchor_world = None; sel.box_current = None; sel.box_crossing = false; + sel.box_crossing_locked = false; selection_just_completed = true; } } diff --git a/src/command.rs b/src/command.rs index c00826877..9640eab10 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1034,6 +1034,15 @@ pub enum CmdResult { CancelForSpaceChange, /// End the selection-gather phase and re-dispatch the named command /// with the gathered handles installed as the active scene selection. + /// Select by a path the user picked point by point. `closed` polygons take + /// what they enclose, or merely touch when `crossing`; an open fence takes + /// only what it actually cuts. The host owns the hit test, so the command + /// hands over the geometry rather than the answer. (#596) + SelectByPath { + path: Vec<[f64; 2]>, + closed: bool, + crossing: bool, + }, Relaunch(String, Vec), /// End the command and dispatch the given command string. Used by an /// interactive front-end that gathered its arguments step-by-step and diff --git a/src/modules/draw/fence.rs b/src/modules/draw/fence.rs new file mode 100644 index 000000000..b57cf026c --- /dev/null +++ b/src/modules/draw/fence.rs @@ -0,0 +1,116 @@ +// A path picked point by point, and the way it is drawn while it is being +// picked. +// +// Two features ask the user for the same gesture. TRIM and EXTEND take a fence +// through the drawing and cut whatever it crosses; the Fence / WPolygon / +// CPolygon selection modes take one and select whatever it crosses or encloses. +// Only what happens to the result differs, so the gesture itself — the points, +// the rubber-banded dashed preview, and whether the path closes back on itself +// — lives here and both bind to it. + +use crate::scene::model::wire_model::WireModel; + +/// Dash rhythm shared by every in-progress selection gesture, so a fence being +/// picked reads the same wherever it is offered. +const DASH_LENGTH: f32 = 0.8; +const DASH_PATTERN: [f32; 8] = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// The crossing-box green, matching the selection marquee the option imitates. +const CROSSING_GREEN: [f32; 4] = [0.35, 0.85, 0.35, 0.9]; + +/// A path being picked one point at a time. +#[derive(Clone, Debug, Default)] +pub struct FencePick { + /// Points committed so far, in drawing coordinates. + pub points: Vec<[f64; 2]>, + /// Whether the path closes back to its first point. A polygon encloses an + /// area and can be asked what lies inside it; an open fence only ever + /// answers what it cuts. + pub closed: bool, +} + +impl FencePick { + /// An open fence: it selects what it crosses, and encloses nothing. + pub fn fence() -> Self { + Self { + points: Vec::new(), + closed: false, + } + } + + /// A closed polygon, which encloses an area as well as cutting through one. + pub fn polygon() -> Self { + Self { + points: Vec::new(), + closed: true, + } + } + + pub fn push(&mut self, point: [f64; 2]) { + self.points.push(point); + } + + pub fn len(&self) -> usize { + self.points.len() + } + + pub fn is_empty(&self) -> bool { + self.points.is_empty() + } + + /// Enough points to mean anything: two for a fence to cut with, three for a + /// polygon to enclose with. + pub fn is_usable(&self) -> bool { + self.points.len() >= if self.closed { 3 } else { 2 } + } + + /// The picked path, brought back to its first point when it is a polygon. + pub fn path(&self) -> Vec<[f64; 2]> { + let mut path = self.points.clone(); + if self.closed { + if let Some(first) = self.points.first().copied() { + path.push(first); + } + } + path + } + + /// The path so far, trailing the cursor, dashed. + pub fn preview(&self, cursor: [f64; 2], name: &str) -> WireModel { + let mut path = self.points.clone(); + path.push(cursor); + if self.closed { + if let Some(first) = self.points.first().copied() { + path.push(first); + } + } + dashed(path, name, WireModel::CYAN) + } +} + +/// Rubber-banded crossing rectangle, drawn like the selection marquee it stands +/// in for so the option reads as the selection it performs. (#336) +pub fn crossing_box_preview(corner: [f64; 2], cursor: [f64; 2], name: &str) -> WireModel { + dashed( + vec![ + corner, + [corner[0], cursor[1]], + cursor, + [cursor[0], corner[1]], + corner, + ], + name, + CROSSING_GREEN, + ) +} + +fn dashed(path: Vec<[f64; 2]>, name: &str, color: [f32; 4]) -> WireModel { + let points: Vec<[f32; 3]> = path + .into_iter() + .map(|p| [p[0] as f32, p[1] as f32, 0.0]) + .collect(); + let mut wire = WireModel::solid(name.into(), points, color, false); + wire.pattern_length = DASH_LENGTH; + wire.pattern = DASH_PATTERN; + wire +} diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 9bbbd8217..4f678d2b6 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -5,6 +5,7 @@ pub mod clipboard; pub mod defaults; mod donate; pub mod draw; +pub mod fence; pub mod groups; pub mod inquiry; pub mod layers; diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 3cd6ac5bc..fb60b2ff9 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -10,6 +10,8 @@ use std::f64::consts::TAU; +use crate::modules::draw::fence::{crossing_box_preview, FencePick}; + use acadrust::entities::{ Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, LwVertex, Ray as RayEnt, Spline as SplineEnt, XLine as XLineEnt, @@ -2100,7 +2102,7 @@ enum TrimMode { /// Collecting cutting/boundary edges; Enter returns to Pick. SelectEdges, /// Collecting fence points; Enter runs the fence pass. - Fence(Vec<[f64; 2]>), + Fence(FencePick), /// Crossing: waiting for the first rectangle corner. CrossFirst, /// Crossing: first corner picked, waiting for the second. @@ -2983,32 +2985,6 @@ fn extend_hover_wires( out } -/// Fence preview polyline (committed points + rubber point), dashed. -fn fence_preview_wire(pts: &[[f64; 2]], cursor: [f64; 2], name: &str) -> WireModel { - let mut wire: Vec<[f32; 3]> = pts.iter().map(|p| [p[0] as f32, p[1] as f32, 0.0]).collect(); - wire.push([cursor[0] as f32, cursor[1] as f32, 0.0]); - let mut w = WireModel::solid(name.into(), wire, WireModel::CYAN, false); - w.pattern_length = 0.8; - w.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; - w -} - -/// Crossing preview rectangle — the selection crossing-box look (dashed -/// green outline) so the option reads like a crossing selection (#336). -fn crossing_preview_wire(p1: [f64; 2], cursor: [f64; 2], name: &str) -> WireModel { - let pts = vec![ - [p1[0] as f32, p1[1] as f32, 0.0], - [p1[0] as f32, cursor[1] as f32, 0.0], - [cursor[0] as f32, cursor[1] as f32, 0.0], - [cursor[0] as f32, p1[1] as f32, 0.0], - [p1[0] as f32, p1[1] as f32, 0.0], - ]; - let mut w = WireModel::solid(name.into(), pts, [0.35, 0.85, 0.35, 0.9], false); - w.pattern_length = 0.8; - w.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; - w -} - pub struct TrimCommand { all_entities: Vec, entity_index: ModifyEntityIndex, @@ -3125,9 +3101,9 @@ impl CadCommand for TrimCommand { self.edge_set.len() ) .into_owned(), - TrimMode::Fence(pts) => crate::tf!( + TrimMode::Fence(pick) => crate::tf!( "TRIM{edge} Fence: pick points [{} placed, Enter = trim crossed]:", - pts.len() + pick.len() ) .into_owned(), TrimMode::CrossFirst => crate::tf!("TRIM{edge} Crossing: first corner:").into_owned(), @@ -3172,7 +3148,7 @@ impl CadCommand for TrimCommand { Some(CmdResult::NeedPoint) } "F" | "FENCE" => { - self.mode = TrimMode::Fence(Vec::new()); + self.mode = TrimMode::Fence(FencePick::fence()); Some(CmdResult::NeedPoint) } "C" | "CROSSING" => { @@ -3669,8 +3645,8 @@ impl CadCommand for TrimCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { match &mut self.mode { - TrimMode::Fence(pts) => { - pts.push([pt.x, pt.y]); + TrimMode::Fence(pick) => { + pick.push([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossFirst => { @@ -3698,9 +3674,9 @@ impl CadCommand for TrimCommand { fn on_preview_wires(&mut self, pt: DVec3) -> Vec { match &self.mode { - TrimMode::Fence(pts) if !pts.is_empty() => { - let mut out = vec![fence_preview_wire(pts, [pt.x, pt.y], "trim_fence")]; - let mut fpts = pts.clone(); + TrimMode::Fence(pick) if !pick.is_empty() => { + let mut out = vec![pick.preview([pt.x, pt.y], "trim_fence")]; + let mut fpts = pick.path(); fpts.push([pt.x, pt.y]); out.extend(fence_result_preview( &self.all_entities, @@ -3715,7 +3691,7 @@ impl CadCommand for TrimCommand { TrimMode::CrossSecond(p1) => { let p1 = *p1; let p2 = [pt.x, pt.y]; - let mut out = vec![crossing_preview_wire(p1, p2, "trim_cross")]; + let mut out = vec![crossing_box_preview(p1, p2, "trim_cross")]; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], @@ -3742,7 +3718,7 @@ impl CadCommand for TrimCommand { self.rebuild_geos(); CmdResult::NeedPoint } - TrimMode::Fence(pts) if pts.len() >= 2 => self.fence_run(&pts, None), + TrimMode::Fence(pick) if pick.is_usable() => self.fence_run(&pick.path(), None), TrimMode::Fence(_) | TrimMode::CrossFirst | TrimMode::CrossSecond(_) @@ -3845,9 +3821,9 @@ impl CadCommand for ExtendCommand { self.edge_set.len() ) .into_owned(), - TrimMode::Fence(pts) => crate::tf!( + TrimMode::Fence(pick) => crate::tf!( "EXTEND{edge} Fence: pick points [{} placed, Enter = extend crossed]:", - pts.len() + pick.len() ) .into_owned(), TrimMode::CrossFirst => { @@ -3889,7 +3865,7 @@ impl CadCommand for ExtendCommand { Some(CmdResult::NeedPoint) } "F" | "FENCE" => { - self.mode = TrimMode::Fence(Vec::new()); + self.mode = TrimMode::Fence(FencePick::fence()); Some(CmdResult::NeedPoint) } "C" | "CROSSING" => { @@ -4131,8 +4107,8 @@ impl CadCommand for ExtendCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { match &mut self.mode { - TrimMode::Fence(pts) => { - pts.push([pt.x, pt.y]); + TrimMode::Fence(pick) => { + pick.push([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossFirst => { @@ -4157,9 +4133,9 @@ impl CadCommand for ExtendCommand { fn on_preview_wires(&mut self, pt: DVec3) -> Vec { match &self.mode { - TrimMode::Fence(pts) if !pts.is_empty() => { - let mut out = vec![fence_preview_wire(pts, [pt.x, pt.y], "extend_fence")]; - let mut fpts = pts.clone(); + TrimMode::Fence(pick) if !pick.is_empty() => { + let mut out = vec![pick.preview([pt.x, pt.y], "extend_fence")]; + let mut fpts = pick.path(); fpts.push([pt.x, pt.y]); out.extend(fence_result_preview( &self.all_entities, @@ -4174,7 +4150,7 @@ impl CadCommand for ExtendCommand { TrimMode::CrossSecond(p1) => { let p1 = *p1; let p2 = [pt.x, pt.y]; - let mut out = vec![crossing_preview_wire(p1, p2, "extend_cross")]; + let mut out = vec![crossing_box_preview(p1, p2, "extend_cross")]; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], @@ -4201,7 +4177,7 @@ impl CadCommand for ExtendCommand { self.rebuild_geos(); CmdResult::NeedPoint } - TrimMode::Fence(pts) if pts.len() >= 2 => self.fence_run(&pts, None), + TrimMode::Fence(pick) if pick.is_usable() => self.fence_run(&pick.path(), None), TrimMode::Fence(_) | TrimMode::CrossFirst | TrimMode::CrossSecond(_) diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index c5b14a7e0..9ff9efe08 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -13,6 +13,7 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::modules::draw::fence::FencePick; use crate::scene::model::wire_model::WireModel; pub struct SelectObjectsCommand { @@ -23,6 +24,13 @@ pub struct SelectObjectsCommand { /// When true, gathering continues until Enter / right-click commits the set. /// When false, the first completed selection action fires immediately. commit_on_enter: bool, + /// A Fence / WPolygon / CPolygon path being picked point by point. While + /// one is under way the host must send clicks here as points rather than + /// treating them as picks, which is what `is_selection_gathering` reports. + pick: Option, + /// Whether the polygon being picked takes what it merely touches. Ignored + /// by a fence, which has no inside to speak of. + pick_crossing: bool, } impl SelectObjectsCommand { @@ -32,6 +40,8 @@ impl SelectObjectsCommand { pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: true, + pick: None, + pick_crossing: true, } } @@ -42,6 +52,8 @@ impl SelectObjectsCommand { pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: false, + pick: None, + pick_crossing: true, } } } @@ -52,6 +64,21 @@ impl CadCommand for SelectObjectsCommand { } fn prompt(&self) -> String { + if let Some(pick) = &self.pick { + let label = if !pick.closed { + t!("Fence") + } else if self.pick_crossing { + t!("Crossing polygon") + } else { + t!("Window polygon") + }; + return t!( + "%{mode}: pick points (%{count} placed, Enter to apply):", + mode = label, + count = pick.len() + ) + .into_owned(); + } if self.commit_on_enter && !self.handles.is_empty() { t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", @@ -67,16 +94,30 @@ impl CadCommand for SelectObjectsCommand { // Opt into the selection-gathering path; host routes clicks through // the normal selection system and calls on_selection_complete after each action. fn is_selection_gathering(&self) -> bool { - true + // A path being picked needs the clicks as points; handing them to the + // selection system instead would pick objects under each vertex. + self.pick.is_none() } - // Clickable selection keywords (#426): Previous re-selects the set the - // last command worked on, Last the most recently created object. The - // keywords are consumed centrally in `try_selection_keyword`, so they - // also work typed — these buttons just surface them. + // Clickable selection keywords (#426, #596). Window and Crossing fix the + // sense of the next box, which dragging would otherwise take from the + // direction the corner travels; All takes the whole space; Add and Remove + // decide what a pick does for the rest of the selection; Previous + // re-selects the set the last command worked on, Last the most recently + // created object. All are consumed centrally in `try_selection_keyword`, so + // they work typed as well — these buttons only surface them, which is what + // the on-screen keyboard-less case needs. fn options(&self) -> Vec { if self.commit_on_enter { vec![ + CmdOption::new(t!("Window").as_ref(), "W"), + CmdOption::new(t!("Crossing").as_ref(), "C"), + CmdOption::new(t!("Fence").as_ref(), "F"), + CmdOption::new(t!("WPolygon").as_ref(), "WP"), + CmdOption::new(t!("CPolygon").as_ref(), "CP"), + CmdOption::new(t!("All").as_ref(), "ALL"), + CmdOption::new(t!("Add").as_ref(), "A"), + CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Last").as_ref(), "L"), ] @@ -99,13 +140,68 @@ impl CadCommand for SelectObjectsCommand { CmdResult::Relaunch(std::mem::take(&mut self.pending_cmd), handles) } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let Some(pick) = self.pick.as_mut() { + pick.push([pt.x, pt.y]); + } CmdResult::NeedPoint } + fn wants_text_input(&self) -> bool { + true + } + + // Fence and the two polygons are picked here rather than centrally: they + // need somewhere to accumulate points, and the command is what outlives the + // individual clicks. The rest of the keywords act at once and are consumed + // in `try_selection_keyword`, which sees the token first. (#596) + fn on_text_input(&mut self, text: &str) -> Option { + if !self.commit_on_enter { + return None; + } + match text.trim().to_uppercase().as_str() { + "F" | "FENCE" => { + self.pick = Some(FencePick::fence()); + Some(CmdResult::NeedPoint) + } + "CP" | "CPOLYGON" => { + self.pick = Some(FencePick::polygon()); + self.pick_crossing = true; + Some(CmdResult::NeedPoint) + } + "WP" | "WPOLYGON" => { + self.pick = Some(FencePick::polygon()); + self.pick_crossing = false; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + match &self.pick { + Some(pick) if !pick.is_empty() => { + vec![pick.preview([pt.x, pt.y], "select_fence")] + } + _ => vec![], + } + } + // Enter / right-click ends the gather phase and fires the pending command // with the accumulated selection. Nothing selected → cancel. fn on_enter(&mut self) -> CmdResult { + // Finishing a path applies it and returns to picking objects; the set + // it produced joins whatever was already gathered. + if let Some(pick) = self.pick.take() { + if pick.is_usable() { + return CmdResult::SelectByPath { + path: pick.path(), + closed: pick.closed, + crossing: self.pick_crossing, + }; + } + return CmdResult::NeedPoint; + } if !self.commit_on_enter || self.handles.is_empty() { return CmdResult::Cancel; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7a63acb55..2b000c875 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7678,6 +7678,127 @@ impl Scene { } /// Top-level solid handles caught by a lasso polygon. + /// Everything a picked path takes, gathered from every kind of geometry the + /// selection can reach: wires, hatches, hatches inside blocks, meshes and + /// block meshes, narrowed first to what the path's own bounds can touch. + /// + /// `open_fence` distinguishes the two gestures that arrive here. A fence is + /// a line drawn through the drawing and takes only what it actually cuts; a + /// polygon closes back on itself and can also take what it encloses, or + /// merely touches when `crossing`. The lasso and the Fence / WPolygon / + /// CPolygon keywords both come through this, so they cannot drift apart on + /// which geometry they remember to look at. (#596) + #[allow(clippy::too_many_arguments)] + pub fn path_hit_handles( + &self, + poly_pts: &[iced::Point], + crossing: bool, + open_fence: bool, + all_wires: std::sync::Arc>, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + to_world: impl Fn(iced::Point) -> glam::DVec3, + ) -> Vec { + let world_aabb = poly_pts.iter().fold( + [ + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ], + |mut aabb, &point| { + let world = to_world(point); + aabb[0] = aabb[0].min(world.x); + aabb[1] = aabb[1].min(world.y); + aabb[2] = aabb[2].max(world.x); + aabb[3] = aabb[3].max(world.y); + aabb + }, + ); + let screen_rect = poly_pts.iter().fold( + [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ], + |mut rect, point| { + rect[0] = rect[0].min(point.x); + rect[1] = rect[1].min(point.y); + rect[2] = rect[2].max(point.x); + rect[3] = rect[3].max(point.y); + rect + }, + ); + let area_candidates = + self.interaction_candidates_in_aabb(all_wires, world_aabb, screen_rect, view_rot, eye, bounds); + let candidate_handles = self.interaction_candidate_handles(&area_candidates); + let wire_hits = if open_fence { + crate::scene::pick::hit_test::poly_fence_hit( + poly_pts, + &area_candidates, + view_rot, + eye, + bounds, + ) + } else { + crate::scene::pick::hit_test::poly_hit( + poly_pts, + crossing, + &area_candidates, + view_rot, + eye, + bounds, + ) + }; + let mut handles: Vec = wire_hits + .into_iter() + .filter_map(Self::handle_from_wire_name) + .collect(); + // An open fence has no interior, so the area-based geometry can only be + // reached by the polygon gestures. + if !open_fence { + handles.extend(crate::scene::pick::hit_test::poly_hit_hatch( + poly_pts, + crossing, + &self.visible_hatches_for_click(candidate_handles.as_ref()), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + handles.extend(crate::scene::pick::hit_test::poly_hit_insert_hatch( + poly_pts, + crossing, + self.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + handles.extend(self.mesh_poly_hit( + poly_pts, + crossing, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + handles.extend(self.block_mesh_poly_hit( + poly_pts, + crossing, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + } + handles.retain(|&h| self.passes_selection_filter(h)); + handles.retain(|&h| !self.is_layer_locked(h)); + handles + } + pub fn mesh_poly_hit( &self, poly: &[iced::Point], diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 79b5c4175..b7f5329e3 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -1038,6 +1038,86 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( out } +/// Wires the open polyline `fence` actually crosses. +/// +/// The Fence selection mode draws a line through a drawing and takes whatever +/// it cuts. That is not the polygon test with `crossing` set: the polygon one +/// closes the point list back to its start and counts anything lying inside the +/// area that closure encloses, so a fence drawn past a group of objects would +/// sweep up everything behind it as well. Here the chain stays open and only a +/// real intersection counts. (#596) +pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( + fence: &[Point], + wires: &'a W, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Vec<&'a str> { + if fence.len() < 2 { + return vec![]; + } + let cuts = |a: Point, b: Point| { + fence + .windows(2) + .any(|leg| segments_intersect(a, b, leg[0], leg[1])) + }; + let mut out = Vec::new(); + let mut seen: HashSet<&str> = HashSet::default(); + // Indexed segments when the source has them (the same fast path the + // polygon test uses), otherwise walk each wire's own points. + if let Some(segments) = wires.segments() { + for segment in segments { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + let start = segment.start as usize; + if start + 1 >= wire.points.len() || seen.contains(wire.name.as_str()) { + continue; + } + let a = world_to_screen( + wp64(wire.points[start], &wire.points_low, start), + view_rot, + eye, + bounds, + ); + let b = world_to_screen( + wp64(wire.points[start + 1], &wire.points_low, start + 1), + view_rot, + eye, + bounds, + ); + if cuts(a, b) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + return out; + } + for wire in wires.iter() { + if wire.points.len() < 2 { + continue; + } + let hit = (0..wire.points.len() - 1).any(|k| { + let a = world_to_screen( + wp64(wire.points[k], &wire.points_low, k), + view_rot, + eye, + bounds, + ); + let b = world_to_screen( + wp64(wire.points[k + 1], &wire.points_low, k + 1), + view_rot, + eye, + bounds, + ); + cuts(a, b) + }); + if hit && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + out +} + fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( wires: &'a W, poly: &[Point], @@ -1431,7 +1511,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( // ── Helpers ─────────────────────────────────────────────────────────────── -fn world_to_screen( +pub(crate) fn world_to_screen( world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index aee7562d9..677b98618 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -14,6 +14,11 @@ pub struct SelectionState { pub box_current: Option, pub box_last: Option<(Point, Point)>, pub box_crossing: bool, + /// Set when a Window / Crossing selection keyword fixed the sense of the + /// box being drawn. Dragging normally decides it from the direction the + /// corner travels, which would immediately overwrite what the user just + /// asked for, so that derivation stands down while this holds. + pub box_crossing_locked: bool, pub box_last_crossing: bool, /// A preview-only selection marquee `(anchor, current, crossing)` in canvas /// pixels, drawn identically to a real box-selection (green crossing fill /