Temporary tracking point TT and tracking TK at every point prompt (part of #441) (#445)

Squash of PR #445 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:23:45 -04:00
1 parent 77de6af455
commit 9427b77209
8 files changed
+218 -18

No files matched your search

+9 -4
View File
@@ -926,6 +926,8 @@ impl Session {
Some(i) => {
if let Input::Point(p) = &i {
self.last_point = *p;
// A temporary tracking point (TT) serves one point.
self.tracking.release_temporary();
}
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.machine.input(self, i)));
match r {
@@ -1172,14 +1174,17 @@ impl Session {
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
return self.input(Input::Point(p));
}
// Direct distance entry along the tracking path the cursor is on, from the point it
// comes from (an acquired or temporary tracking point needs no base point).
if let Some(dist) = units::parse_distance(tt).filter(|_| !prompt.accept.number)
&& let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along())
{
return self.input(Input::Point(from + dir * dist));
}
// Direct distance entry along the rubber band.
if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt))
&& !prompt.accept.number
{
// Along the tracking path the cursor is on, from the point it comes from.
if let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along()) {
return self.input(Input::Point(from + dir * dist));
}
let mut c = self.cursor;
if self.settings.orthomode {
c = snap::ortho(base, c);
+51 -7
View File
@@ -1,6 +1,7 @@
//! Point modifiers typed at a point prompt, as in AutoCAD: `FROM` (a base point and an offset),
//! `M2P`/`MTP` (the midpoint between two points), the point filters `.x`, `.y`, `.z`, `.xy`,
//! `.xz`, `.yz`, the angle override `<a` and the object snap overrides (`END`, `MID` … `NON`).
//! `.xz`, `.yz`, the angle override `<a`, the object snap overrides (`END`, `MID` … `NON`), the
//! temporary tracking point `TT` and tracking `TK`.
//!
//! A modifier pushes a [`Frame`] onto [`Session::point_mods`]. While frames are pending the
//! session shows their prompt ("Base point:", "<Offset>:", "First point of mid:", ".X of",
@@ -76,6 +77,12 @@ pub enum Frame {
/// An object snap override for the next point only: one [`mode`] bit, or 0 for `NON` (no
/// object snap).
Snap { mode: u32 },
/// `TT`: the point given is acquired for object snap tracking (it sends alignment paths) and
/// the prompt below asks again.
TempTrack,
/// `TK` (tracking): a run of points, each straight across or up from the one before; Enter
/// ends it and hands down the last.
Track { last: Option<Vec2> },
}
/// The object snap overrides and the words they are typed as: any start of a word of three
@@ -152,6 +159,9 @@ impl Session {
Frame::Mid { first: Some(f) } => Prompt::new("Second point of mid", point).base(f),
Frame::Filter { keep, first: None } => Prompt::new(format!("{} of", keep.name()), point).base_opt(p.base),
Frame::Filter { keep, first: Some(_) } => Prompt::new(format!("(need {})", keep.need()), point).base_opt(p.base),
Frame::TempTrack => Prompt::new("Specify temporary OTRACK point", point),
Frame::Track { last: None } => Prompt::new("First tracking point", point),
Frame::Track { last: Some(l) } => Prompt::new("Next point (Press ENTER to end tracking)", point).base(l),
Frame::Angle { .. } | Frame::Snap { mode: 0 } => p,
// "Endpoint of:", keeping the base and deferral PER/TAN need.
Frame::Snap { mode } => Prompt { message: format!("{} of", snap_name(mode)), keywords: Vec::new(), default: None, ..p },
@@ -189,9 +199,14 @@ impl Session {
if let Some(mode) = parse_snap(b) {
return Some(Frame::Snap { mode });
}
match b.to_ascii_lowercase().as_str() {
let b = b.to_ascii_lowercase();
match b.as_str() {
"from" | "fro" => Some(Frame::From { base: None }),
"m2p" | "mtp" => Some(Frame::Mid { first: None }),
"tt" => Some(Frame::TempTrack),
// TK, or TRACKING from three letters (TRA, TRACK).
"tk" => Some(Frame::Track { last: None }),
_ if b.len() >= 3 && "tracking".starts_with(&b) => Some(Frame::Track { last: None }),
_ => None,
}
}
@@ -218,6 +233,16 @@ impl Session {
self.point_mods.push(f);
return Some(Ok(()));
}
// A distance at "Next point" of TK: straight across or up from the last tracking point,
// the way the cursor lies from it.
if let Some(Frame::Track { last: Some(l) }) = self.point_mods.last().copied()
&& crate::prompt::parse_point_with(t, self.last_point, &self.angle_settings()).is_none()
&& let Some(d) = crate::units::parse_distance(t)
{
let dir = (crate::snap::ortho(l, self.cursor) - l).normalized();
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir };
return Some(self.modifier_point(l + dir * d));
}
if let Some(Frame::Angle { base, dir }) = self.point_mods.last().copied() {
// Typed coordinates are explicit: they win over the override.
if crate::prompt::parse_point_with(t, self.last_point, &self.angle_settings()).is_some() {
@@ -249,11 +274,18 @@ impl Session {
self.input(Input::Deferred(d))
}
Input::Deferred(d) => self.modifier_point(d.at),
// Enter drops the modifiers and returns to the command's prompt.
Input::Enter => {
self.point_mods.clear();
Ok(())
}
// Enter ends tracking with its last point; otherwise it drops the modifiers and
// returns to the command's prompt.
Input::Enter => match self.point_mods.last().copied() {
Some(Frame::Track { last: Some(l) }) => {
self.point_mods.pop();
self.modifier_point(l)
}
_ => {
self.point_mods.clear();
Ok(())
}
},
Input::Cancel => {
self.point_mods.clear();
self.input(Input::Cancel)
@@ -289,6 +321,18 @@ impl Session {
self.point_mods.push(Frame::Filter { keep, first: Some(p) });
return Ok(());
}
Frame::TempTrack => {
self.tracking.acquire_temporary(p);
self.last_point = p;
return Ok(());
}
Frame::Track { last } => {
// Each point after the first goes straight across or up from the last one.
let q = last.map_or(p, |l| crate::snap::ortho(l, p));
self.point_mods.push(Frame::Track { last: Some(q) });
self.last_point = q;
return Ok(());
}
// The offset point is already measured from the base (`@` reads the last point).
Frame::From { base: Some(_) } => {}
Frame::Mid { first: Some(f) } => p = (f + p) * 0.5,
+25 -1
View File
@@ -152,13 +152,16 @@ pub enum Acquired {
Parallel { at: Vec2, dir: Vec2 },
/// An object rested on with Apparent Intersection running, and its whole extension.
Apparent { at: Vec2, shape: PathShape },
/// A temporary tracking point (`TT`): it sends alignment paths even with OTRACK off, and is
/// released once the command takes a point.
Temporary { at: Vec2 },
}
impl Acquired {
/// Where the acquisition marker goes.
pub fn at(&self) -> Vec2 {
match *self {
Acquired::Point { at, .. } | Acquired::Parallel { at, .. } | Acquired::Apparent { at, .. } => at,
Acquired::Point { at, .. } | Acquired::Parallel { at, .. } | Acquired::Apparent { at, .. } | Acquired::Temporary { at } => at,
}
}
@@ -171,6 +174,7 @@ impl Acquired {
a.cross(*b).abs() < 1e-9 && (*p - *q).cross(*a).abs() <= 1e-9 * (1.0 + p.len())
}
(Acquired::Apparent { shape: a, .. }, Acquired::Apparent { shape: b, .. }) => same_shape(a, b),
(Acquired::Temporary { at: a }, Acquired::Temporary { at: b }) => near(*a, *b),
_ => false,
}
}
@@ -313,6 +317,22 @@ impl Tracker {
}
}
/// Acquire a temporary tracking point (`TT`), unless it is already acquired.
pub fn acquire_temporary(&mut self, at: Vec2) {
if !at.is_finite() {
return;
}
let cand = Acquired::Temporary { at };
if !self.acquired.iter().any(|a| a.same(&cand)) {
self.toggle(cand);
}
}
/// Release the temporary tracking points (the command took a point).
pub fn release_temporary(&mut self) {
self.acquired.retain(|a| !matches!(a, Acquired::Temporary { .. }));
}
/// The cursor is over `cand` (or nothing) at `now` seconds: acquires or releases it once it
/// has rested there for [`DWELL`] seconds, or at once when `shift` is held in Shift-to-acquire
/// mode. Returns true while an acquisition is pending (the UI should redraw again soon).
@@ -383,6 +403,10 @@ impl Tracker {
out.push(Path { shape, kind: PathKind::Apparent, from: at, name: "Apparent Intersection" });
}
}
Acquired::Temporary { at } => {
let name = "Temporary track point";
out.extend(cfg.track_angles.iter().map(|a| Path { shape: ray(at, *a), kind: PathKind::Alignment, from: at, name }));
}
}
}
out.retain(|p| p.shape.is_finite() && p.from.is_finite());
+122
View File
@@ -0,0 +1,122 @@
//! Temporary tracking (#441): the `TT` temporary tracking point and the `TK` tracking modifier,
//! typed at any point prompt and driven without the UI.
use cadcraft_doc::EntityKind;
use cadcraft_engine::Session;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::snap::tracking::{Acquired, CursorQuery, PathKind};
use serde_json::json;
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// The last line drawn as (start, end).
fn last_line(s: &Session) -> (Vec2, Vec2) {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a.xy(), l.b.xy())) } else { None }).last().unwrap()
}
fn near(p: Vec2, x: f64, y: f64) -> bool {
p.near(v(x, y), 1e-9)
}
/// Move the cursor to `raw` as the canvas does: snap it and remember where it ended up.
fn hover(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
let r = s.snap_cursor(raw, &q);
s.cursor = r.point;
r.point
}
#[test]
fn tt_acquires_a_point_that_tracks_until_the_next_point() {
let mut s = Session::new();
// Even with object snap tracking off, a temporary tracking point sends its paths.
s.execute("otrack", &json!({ "on": false })).unwrap();
s.cmdline("line tt").unwrap();
assert!(s.prompt_text().ends_with("Specify temporary OTRACK point:"), "{}", s.prompt_text());
s.cmdline("5,5").unwrap();
assert!(s.prompt_text().ends_with("Specify first point:"), "{}", s.prompt_text());
assert_eq!(s.tracking.acquired, vec![Acquired::Temporary { at: v(5.0, 5.0) }]);
// The cursor slides along the horizontal path from 5,5 …
let p = hover(&mut s, v(8.0, 5.04), None);
assert!(near(p, 8.0, 5.0), "{p:?}");
let t = s.tracking.last.clone().unwrap();
assert_eq!((t.paths[0].kind, t.paths[0].name), (PathKind::Alignment, "Temporary track point"));
// … and a distance typed there is measured from the tracking point, with no base point.
s.cmdline("4").unwrap();
assert!(near(s.last_point, 9.0, 5.0), "{:?}", s.last_point);
assert!(s.tracking.acquired.is_empty(), "released once the command took a point");
s.cmdline("@0,3").unwrap();
s.cmdline("").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 9.0, 5.0) && near(b, 9.0, 8.0), "{a:?} {b:?}");
}
#[test]
fn tt_point_is_the_last_point_and_takes_snap_overrides() {
let mut s = Session::new();
s.script("LINE 0,0 10,0\n\n").unwrap();
s.cmdline("line 0,5 _tt end 9.97,0.04").unwrap();
assert_eq!(s.tracking.acquired, vec![Acquired::Temporary { at: v(10.0, 0.0) }]);
// `@` is measured from the temporary point.
s.cmdline("@0,2").unwrap();
s.cmdline("").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 0.0, 5.0) && near(b, 10.0, 2.0), "{a:?} {b:?}");
}
#[test]
fn tk_runs_orthogonal_tracking_points_until_enter() {
let mut s = Session::new();
s.cmdline("line 0,0 tk").unwrap();
assert!(s.prompt_text().ends_with("First tracking point:"), "{}", s.prompt_text());
s.cmdline("10,0").unwrap();
assert!(s.prompt_text().ends_with("Next point (Press ENTER to end tracking):"), "{}", s.prompt_text());
assert_eq!(s.current_prompt().and_then(|p| p.base), Some(v(10.0, 0.0)));
// Picked off-axis, the point goes straight up from the last tracking point.
s.cmdline("12,3").unwrap();
assert!(near(s.last_point, 10.0, 3.0), "{:?}", s.last_point);
s.cmdline("@5,0").unwrap();
// A distance follows the cursor's axis from the last tracking point.
s.cursor = v(15.2, 9.0);
s.cmdline("2").unwrap();
assert!(near(s.last_point, 15.0, 5.0), "{:?}", s.last_point);
// Enter ends tracking: the line runs to the last tracking point.
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Specify next point or [Undo]:"), "{}", s.prompt_text());
let (a, b) = last_line(&s);
assert!(near(a, 0.0, 0.0) && near(b, 15.0, 5.0), "{a:?} {b:?}");
}
#[test]
fn tk_takes_snaps_and_nests_under_from() {
let mut s = Session::new();
s.script("LINE 0,0 10,0\n\n").unwrap();
// FROM, base point by tracking from the snapped endpoint, then the offset.
s.cmdline("line from tracking endp 9.96,0.03 @0,4").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("<Offset>:"), "{}", s.prompt_text());
s.cmdline("@1,1").unwrap();
s.cmdline("@1,0").unwrap();
s.cmdline("").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 11.0, 5.0) && near(b, 12.0, 5.0), "{a:?} {b:?}");
}
#[test]
fn enter_at_the_first_tracking_point_and_hostile_input_are_harmless() {
let mut s = Session::new();
s.cmdline("line tk").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Specify first point:"), "{}", s.prompt_text());
for _ in 0..40 {
s.cmdline("tk").unwrap();
s.cmdline("tt").unwrap();
}
s.cmdline("nan,1").unwrap();
s.cmdline("1e308").unwrap();
s.cancel();
assert!(s.running.is_none());
assert!(s.tracking.acquired.len() <= cadcraft_engine::snap::tracking::MAX_ACQUIRED);
}
+4 -3
View File
@@ -67,14 +67,15 @@ const FILTERS: &[&str] = &[".X", ".Y", ".Z", ".XY", ".XZ", ".YZ"];
/// The id of the Osnap Settings item (the Drafting Settings dialog).
const OSNAP_SETTINGS: &str = "dsettings";
/// The object snap menu (Shift/Ctrl+right-click): From, Mid Between 2 Points, Point Filters, the
/// snap modes, None and Osnap Settings. An item's id is the text it types; the modifiers are
/// only available at a point prompt.
/// The object snap menu (Shift/Ctrl+right-click): Temporary track point, From, Mid Between 2
/// Points, Point Filters, the snap modes, None and Osnap Settings. An item's id is the text it
/// types; the modifiers are only available at a point prompt.
pub fn snap_entries(app: &CadApp) -> Vec<Vec<Entry>> {
let on = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p));
let typed = |label: &str, id: &str| Entry::Item { label: label.into(), id: id.into(), shortcut: None, enabled: on };
let name = |m: u32| mode::ALL.iter().find(|(b, _)| *b == m).map_or("", |(_, n)| *n);
let mut groups = vec![vec![
typed("Temporary track point", "_tt"),
typed("From", "_from"),
typed("Mid Between 2 Points", "_m2p"),
Entry::Sub { label: "Point Filters".into(), children: FILTERS.iter().map(|f| typed(f, &f.to_ascii_lowercase())).collect() },
+1
View File
@@ -908,6 +908,7 @@
Tan, Tan, Radius Дотична, дотична, радіус
Tan, Tan, Tan Дотична, дотична, дотична
Tangent Дотичність
Temporary track point Тимчасова точка відстеження
Text Текст
Text Editor Текстовий редактор
Text Height Висота тексту
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
908
909
910
911
912
913
914
+4
View File
@@ -122,6 +122,7 @@ fn snap_menu_types_point_modifiers_and_snap_overrides() {
let items = snap_items(&app);
let labels: Vec<&str> = items.iter().map(|(l, _, _)| l.as_str()).collect();
for l in [
"Temporary track point",
"From",
"Mid Between 2 Points",
"Point Filters/.X",
@@ -151,6 +152,9 @@ fn snap_menu_types_point_modifiers_and_snap_overrides() {
assert_eq!(app.session.snap_override(), Some(0));
context_menu::choose_snap(&mut app, "_from");
assert!(app.session.prompt_text().ends_with("Base point:"), "{}", app.session.prompt_text());
app.cmdline("");
context_menu::choose_snap(&mut app, "_tt");
assert!(app.session.prompt_text().ends_with("Specify temporary OTRACK point:"), "{}", app.session.prompt_text());
}
/// The text painted in one frame.
+2 -3
View File
@@ -60,10 +60,9 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
| # | Gap | User impact | Est. |
|---|---|---|---|
| U1 | Tracking leftovers: polar angles relative to the last segment (POLARMODE 1), PolarSnap, temporary tracking point (TT), Extension from ellipse and spline ends, the tracking options in Drafting Settings (object snap tracking, Extension, Parallel and Apparent Intersection landed in #379) | Medium | 3–5 |
| U1 | Tracking leftovers: polar angles relative to the last segment (POLARMODE 1), PolarSnap, Extension from ellipse and spline ends, the tracking options in Drafting Settings (object snap tracking, Extension, Parallel and Apparent Intersection landed in #379; the temporary tracking point TT in #441) | Medium | 2–4 |
| U2 | Dynamic input: grip dimensional input, lock icons, DYNPROMPT options menu (pointer boxes with Tab and relative entry landed in #60) | Medium | 3–5 |
| 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 |
| U4 | Tab snap cycling offers the closest snap of each mode, not every candidate. Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393), the menu's Temporary track point in #441 | Low | 1 |
| 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: 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 |