mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #438 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
608aa225a7
commit
25c9658908
6 files changed
+208
-9
No files matched your search
@@ -12,8 +12,13 @@ pub struct SelectPhase {
|
||||
pub done: bool,
|
||||
pub single: bool,
|
||||
pub removing: bool,
|
||||
/// What each selection so far changed, for Undo: (handles, whether they were removed).
|
||||
pub history: Vec<(Vec<Handle>, bool)>,
|
||||
}
|
||||
|
||||
/// Most selection steps Undo remembers.
|
||||
const MAX_SELECT_HISTORY: usize = 1000;
|
||||
|
||||
pub enum SelOutcome {
|
||||
/// Keep prompting.
|
||||
More,
|
||||
@@ -29,7 +34,7 @@ impl SelectPhase {
|
||||
let pre = s.selection();
|
||||
if !pre.is_empty() && s.settings.pickfirst {
|
||||
s.remember_selection(&pre);
|
||||
SelectPhase { picked: pre, done: true, single: false, removing: false }
|
||||
SelectPhase { picked: pre, done: true, ..Default::default() }
|
||||
} else {
|
||||
SelectPhase::default()
|
||||
}
|
||||
@@ -50,18 +55,27 @@ impl SelectPhase {
|
||||
pub fn feed(&mut self, s: &mut Session, i: &Input) -> Result<SelOutcome> {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
let before = self.picked.len();
|
||||
if self.removing {
|
||||
let changed: Vec<Handle> = if self.removing {
|
||||
let gone: Vec<Handle> = self.picked.iter().copied().filter(|h| hs.contains(h)).collect();
|
||||
self.picked.retain(|h| !hs.contains(h));
|
||||
gone
|
||||
} else {
|
||||
let mut added = Vec::new();
|
||||
for h in hs {
|
||||
if !self.picked.contains(h) {
|
||||
self.picked.push(*h);
|
||||
added.push(*h);
|
||||
}
|
||||
}
|
||||
added
|
||||
};
|
||||
if !changed.is_empty() {
|
||||
if self.history.len() >= MAX_SELECT_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history.push((changed, self.removing));
|
||||
}
|
||||
let n = hs.len();
|
||||
let _ = before;
|
||||
if n > 0 {
|
||||
s.echo(format!("{n} found, {} total", self.picked.len()));
|
||||
} else {
|
||||
@@ -77,6 +91,16 @@ impl SelectPhase {
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"r" | "remove" => self.removing = true,
|
||||
"a" | "add" => self.removing = false,
|
||||
// Undo: take back the last selection that changed the set.
|
||||
"u" | "undo" => {
|
||||
match self.history.pop() {
|
||||
Some((hs, true)) => self.picked.extend(hs),
|
||||
Some((hs, false)) => self.picked.retain(|h| !hs.contains(h)),
|
||||
None => s.echo("Nothing to undo."),
|
||||
}
|
||||
s.echo(format!("{} total", self.picked.len()));
|
||||
s.set_selection(self.picked.clone());
|
||||
}
|
||||
_ => s.echo("*Invalid selection*"),
|
||||
}
|
||||
Ok(SelOutcome::More)
|
||||
|
||||
@@ -500,6 +500,8 @@ pub struct Session {
|
||||
pub pending_window: Option<PendingWindow>,
|
||||
/// A selection mode typed at "Select objects" (W, C, F, WP, CP) collecting its points.
|
||||
pub select_mode: Option<select::SelectMode>,
|
||||
/// SIngle typed at "Select objects": the next selection ends the prompt.
|
||||
pub select_single: bool,
|
||||
pub untitled_counter: u32,
|
||||
/// The last dimension created (DIMCONTINUE / DIMBASELINE).
|
||||
pub last_dim: Option<Handle>,
|
||||
@@ -541,6 +543,7 @@ impl Session {
|
||||
clipboard_base: Vec2::ZERO,
|
||||
pending_window: None,
|
||||
select_mode: None,
|
||||
select_single: false,
|
||||
untitled_counter: 0,
|
||||
last_dim: None,
|
||||
last_used: LastUsed::default(),
|
||||
@@ -789,7 +792,18 @@ impl Session {
|
||||
// Generic selection handling during "Select objects:" prompts.
|
||||
let input = self.preprocess_selection(input)?;
|
||||
match input {
|
||||
Some(i) => self.feed(Some(i)),
|
||||
Some(i) => {
|
||||
// SIngle: the first selection that finds objects also ends the prompt.
|
||||
let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty());
|
||||
self.feed(Some(i))?;
|
||||
if single {
|
||||
self.select_single = false;
|
||||
if self.current_prompt().is_some_and(|p| p.accept.select) {
|
||||
self.feed(Some(Input::Enter))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -810,6 +824,20 @@ impl Session {
|
||||
self.select_mode = Some(m);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
|
||||
{
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"si" | "single" => {
|
||||
self.select_single = true;
|
||||
return Ok(None);
|
||||
}
|
||||
// AUto (a pick, or a window where nothing is picked) is how points always select;
|
||||
// Multiple picks the same way until Enter.
|
||||
"au" | "auto" | "m" | "multiple" => return Ok(None),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let space = self.space();
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
@@ -854,6 +882,25 @@ impl Session {
|
||||
/// objects go to the command as a pick once the mode is complete.
|
||||
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
|
||||
if mode.kind == select::SelectKind::Group {
|
||||
return match input {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => {
|
||||
self.echo(format!("No group named \"{}\".", name.trim()));
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
// Enter or Esc leaves the group name prompt.
|
||||
Input::Cancel => Ok(Some(input)),
|
||||
Input::Enter => Ok(None),
|
||||
_ => {
|
||||
self.echo("Invalid group name.");
|
||||
self.select_mode = Some(mode);
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
}
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
if mode.push(p) {
|
||||
@@ -933,6 +980,9 @@ impl Session {
|
||||
}
|
||||
|
||||
fn finish(&mut self, run: Running, cancelled: bool) {
|
||||
// A typed selection mode or SIngle never outlives its command.
|
||||
self.select_mode = None;
|
||||
self.select_single = false;
|
||||
if find_command(&run.id).is_some_and(|c| c.undoable)
|
||||
&& let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id)
|
||||
{
|
||||
|
||||
@@ -284,6 +284,14 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
|
||||
out
|
||||
}
|
||||
|
||||
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
|
||||
/// drawing has no such group.
|
||||
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
|
||||
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
|
||||
let store = d.space(space)?;
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
|
||||
}
|
||||
|
||||
/// A selection mode typed at "Select objects", collecting its points before it selects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SelectKind {
|
||||
@@ -297,6 +305,10 @@ pub enum SelectKind {
|
||||
WPolygon,
|
||||
/// `CP`: objects inside or crossing a polygon; Enter closes it.
|
||||
CPolygon,
|
||||
/// `BOX`: two corners; left to right is a window, right to left a crossing.
|
||||
Box,
|
||||
/// `G`: the objects of a group, by name.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// Most points a fence or polygon collects (typed input is hostile).
|
||||
@@ -318,18 +330,21 @@ impl SelectMode {
|
||||
"f" | "fence" => SelectKind::Fence,
|
||||
"wp" | "wpolygon" => SelectKind::WPolygon,
|
||||
"cp" | "cpolygon" => SelectKind::CPolygon,
|
||||
"box" => SelectKind::Box,
|
||||
"g" | "group" => SelectKind::Group,
|
||||
_ => return None,
|
||||
};
|
||||
Some(SelectMode { kind, pts: Vec::new() })
|
||||
}
|
||||
fn is_box(&self) -> bool {
|
||||
matches!(self.kind, SelectKind::Window | SelectKind::Crossing)
|
||||
matches!(self.kind, SelectKind::Window | SelectKind::Crossing | SelectKind::Box)
|
||||
}
|
||||
/// The prompt shown while the mode collects points.
|
||||
pub fn prompt(&self) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
let (first, next) = match self.kind {
|
||||
SelectKind::Window | SelectKind::Crossing => ("Specify first corner", "Specify opposite corner"),
|
||||
SelectKind::Group => return Prompt::new("Enter group name", Accept::TEXT),
|
||||
SelectKind::Window | SelectKind::Crossing | SelectKind::Box => ("Specify first corner", "Specify opposite corner"),
|
||||
SelectKind::Fence => ("Specify first fence point", "Specify next fence point"),
|
||||
SelectKind::WPolygon | SelectKind::CPolygon => ("Specify first polygon point", "Specify endpoint of line"),
|
||||
};
|
||||
@@ -352,6 +367,7 @@ impl SelectMode {
|
||||
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
|
||||
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
}
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
|
||||
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Keywords typed at "Select objects" (#428): BOX, SIngle, AUto, Multiple, Group, Add, Remove and
|
||||
//! Undo.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// Three lines on the X axis: A 0–10, B 12–20, C 30–40.
|
||||
fn drawing() -> Session {
|
||||
let mut s = Session::new();
|
||||
for l in ["LINE 0,0 10,0", "LINE 12,0 20,0", "LINE 30,0 40,0"] {
|
||||
s.cmdline(l).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The start X of the lines left in the drawing.
|
||||
fn left(s: &Session) -> Vec<f64> {
|
||||
let mut v: Vec<f64> = s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.a.x) } else { None }).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
/// ERASE with `input` typed at "Select objects", then Enter.
|
||||
fn erase(input: &str) -> Vec<f64> {
|
||||
let mut s = drawing();
|
||||
s.cmdline(input).unwrap();
|
||||
assert!(s.running.is_some(), "{input}: still selecting");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{input}: Enter ends the selection");
|
||||
left(&s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_follows_the_direction_of_its_corners() {
|
||||
// Left to right: a window, only B.
|
||||
assert_eq!(erase("ERASE BOX 5,-1 25,1"), vec![0.0, 30.0]);
|
||||
// Right to left: a crossing, A and B.
|
||||
assert_eq!(erase("ERASE box 25,1 5,-1"), vec![30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_ends_the_prompt_with_the_first_selection() {
|
||||
let mut s = drawing();
|
||||
s.cmdline("ERASE SI").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
// An empty spot starts a window; its other corner selects B and ends ERASE at once.
|
||||
s.cmdline("5,-1").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
s.cmdline("25,1").unwrap();
|
||||
assert!(s.running.is_none(), "SIngle needs no Enter");
|
||||
assert_eq!(left(&s), vec![0.0, 30.0]);
|
||||
// A pick, with the full word.
|
||||
let mut s = drawing();
|
||||
s.cmdline("ERASE single 35,0").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(left(&s), vec![0.0, 12.0]);
|
||||
// SIngle is per prompt: the next ERASE waits for Enter again.
|
||||
s.cmdline("ERASE 5,0").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_and_multiple_keep_picking_until_enter() {
|
||||
assert_eq!(erase("ERASE AU 15,0 35,0"), vec![0.0]);
|
||||
assert_eq!(erase("ERASE M 15,0 35,0"), vec![0.0]);
|
||||
assert_eq!(erase("ERASE multiple auto 5,0"), vec![12.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selects_a_group_by_name() {
|
||||
let mut s = drawing();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("group", &json!({ "name": "Ends", "handles": [hs[0], hs[2]] })).unwrap();
|
||||
s.cmdline("ERASE G").unwrap();
|
||||
let p = s.current_prompt().unwrap().display();
|
||||
assert!(p.contains("Enter group name"), "{p}");
|
||||
s.cmdline("ends").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(left(&s), vec![12.0]);
|
||||
// An unknown name says so and goes back to "Select objects".
|
||||
let mut s = drawing();
|
||||
s.cmdline("ERASE group").unwrap();
|
||||
s.cmdline("Nope").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("No group named \"Nope\"")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(left(&s), vec![0.0, 12.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_add_and_undo() {
|
||||
// Remove takes B out of ALL; Add picks again.
|
||||
assert_eq!(erase("ERASE ALL R 15,0 A 15,0"), Vec::<f64>::new());
|
||||
assert_eq!(erase("ERASE ALL R 15,0"), vec![12.0]);
|
||||
// Undo takes back the last pick…
|
||||
assert_eq!(erase("ERASE 5,0 15,0 U"), vec![12.0, 30.0]);
|
||||
// …or the last removal, and steps back further on each Undo.
|
||||
assert_eq!(erase("ERASE ALL R 15,0 U"), Vec::<f64>::new());
|
||||
assert_eq!(erase("ERASE 5,0 15,0 U U 35,0"), vec![0.0, 12.0]);
|
||||
// Undo with nothing selected says so and keeps prompting.
|
||||
let mut s = drawing();
|
||||
s.cmdline("ERASE U").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == "Nothing to undo."), "{:?}", s.log);
|
||||
assert!(s.running.is_some());
|
||||
}
|
||||
+1
-1
@@ -65,7 +65,7 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
|
||||
| U3 | **[alpha blocker]** TK (temporary tracking, comes with object snap tracking, U1). FROM, M2P/MTP, `.x/.y/.xy` point filters, the `*` WCS prefix and the angle override `<a` landed at every point prompt (#392) | High | 1–2 |
|
||||
| U4 | Tab snap cycling offers the closest snap of each mode, not every candidate; the snap menu has no Temporary track point (comes with U1). Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393) | Low | 1–2 |
|
||||
| U5 | Hot-grip options (Base point, Copy, Undo, Reference) are shown but ignored; multiple hot grips; multifunctional grip menus (Add/Remove Vertex, Convert to Arc) | Medium-high | 6–10 |
|
||||
| U6 | Selection keywords W, C, WP, CP, F, BOX, AU, SI, M, G; lasso (press-drag windows landed in #63); selection cycling; SELECTSIMILAR; ISOLATEOBJECTS/HIDEOBJECTS | Medium-high | 6–10 |
|
||||
| U6 | Selection: lasso; selection cycling (a list of overlapping objects, SELECTIONCYCLING); the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, and ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) landed | Medium | 3–5 |
|
||||
| U7 | Right-click context menus (canvas, selection, command, command line, palettes, tabs) | Medium-high | 6–10 |
|
||||
| U8 | Snaps missing on some objects: MID on circles/ellipses/splines; NOD on dimension points; INS on ATTDEF/MLEADER; TAN on ellipses/splines; GCEN on splines | Medium | 4–6 |
|
||||
| U9 | Polar: additional angles, relative to last segment, PolarSnap; isometric snap/grid and ISODRAFT (dead toggle) | Medium | 4–6 |
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Feature | AutoCAD | CADCraft |
|
||||
|---|---|---|
|
||||
| Pick, implied window (left→right) and crossing (right→left) | yes | yes, coloured and dashed rectangles (`lib.rs:576-589`, `canvas.rs:823-836`) |
|
||||
| Keywords W, C, WP, CP, F, BOX, AU, SI, M, G (group), CL (class) | yes | **All, Last, Previous, Add, Remove only** (`lib.rs:595-599`, `machines.rs:77-80`); fence only as a JSON parameter |
|
||||
| Keywords W, C, WP, CP, F, BOX, AU, SI, M, G (group), CL (class) | yes | yes except CLass: W, C, F, WP, CP (#226); BOX, AUto, SIngle, Multiple, Group, Add, Remove, Undo (#428); ALL, Last, Previous |
|
||||
| Press-drag selection windows (PICKDRAG) | yes | yes (#63, PICKDRAG = 2) |
|
||||
| Lasso | yes | **no** |
|
||||
| Selection cycling (SELECTIONCYCLING) | yes | toggle nothing reads |
|
||||
|
||||
Reference in new issue
Block a user