feat(select): offer the selection modes at "Select objects"

Only Previous and Last were on offer, so Window, Crossing, Fence, the two
polygons and All could not be reached at all -- and with no keyboard to
type them into, the buttons were the only way in.

Window and Crossing fix the sense of the next box. Dragging derives that
from the direction the corner travels and would overwrite it the moment
the corner moved, so the derivation stands down while a keyword holds it.
Add and Remove decide what a pick does for the rest of the round: Remove
subtracts the way Shift already does, rather than through PICKADD, which
decides whether a pick *replaces* the set and so would have wiped
everything gathered so far. Both are dropped when the next command
starts, since neither outlives the selection that asked for them.

Fence and the polygons are picked point by point, which TRIM and EXTEND
already ask for. That gesture -- the points, the rubber-banded dashed
preview, whether the path closes -- moves into one place both bind to,
and the two preview builders duplicated in trim.rs go with it.

A fence is not the polygon test with crossing set: that closes the point
list and counts whatever lies inside, so a line drawn past a row of
objects would take everything behind it too. It gets a hit test that only
counts a real intersection, and skips the area-based geometry entirely.

The lasso gathered its hits from wires, hatches, block hatches, meshes and
block meshes inline; the new modes need the same list, and a second copy
would drift. Both now come through `Scene::path_hit_handles`.

Closes #596

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-07 13:32:11 +03:00
1 parent 22890a9411
commit 5c5c7606a1
12 files changed
+613 -151

No files matched your search

+107
View File
@@ -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<Handle> = 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<iced::Point> = 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<Handle> = 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 {
+8
View File
@@ -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();
+7
View File
@@ -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,
+33 -97
View File
@@ -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<Handle> = 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;
}
}
+9
View File
@@ -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<Handle>),
/// End the command and dispatch the given command string. Used by an
/// interactive front-end that gathered its arguments step-by-step and
+116
View File
@@ -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
}
+1
View File
@@ -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;
+23 -47
View File
@@ -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<EntityType>,
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<WireModel> {
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<WireModel> {
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(_)
+102 -6
View File
@@ -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<FencePick>,
/// 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<CmdOption> {
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<CmdResult> {
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<WireModel> {
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;
}
+121
View File
@@ -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<Vec<WireModel>>,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
to_world: impl Fn(iced::Point) -> glam::DVec3,
) -> Vec<Handle> {
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<Handle> = 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],
+81 -1
View File
@@ -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,
+5
View File
@@ -14,6 +14,11 @@ pub struct SelectionState {
pub box_current: Option<Point>,
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 /