diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 72cb02b6db..e6a15b5242 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -378,9 +378,11 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } - // A moved plane stays in front of the viewer. - let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9); - if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) { + // A moved plane stays in front of the viewer, both where Automatic Plane Positioning holds + // it and in its own place, which it goes back to. + let behind = |p: Plane, d: f64| self.axes(p).is_some_and(|[.., n]| 1.0 + d * self.axis_col(n)[2] <= 1e-9); + let bad = |p: Plane, d: f64| !d.is_finite() || d.abs() > MAX_DEPTH || behind(p, d); + if Plane::ALL.iter().any(|p| bad(*p, self.offset(*p)) || bad(*p, self.placed_offset(*p))) { return Err("a plane is moved too far (behind the viewer)".into()); } self.validate_definition() diff --git a/crates/tools/src/distort/perspective/infer.rs b/crates/tools/src/distort/perspective/infer.rs index 26faa97747..e5f9fd1a8d 100644 --- a/crates/tools/src/distort/perspective/infer.rs +++ b/crates/tools/src/distort/perspective/infer.rs @@ -131,17 +131,32 @@ impl PerspectiveGrid { for plane in self.shown_planes() { let depth = self.offset(plane); let Some(h) = self.homography_at(plane, depth) else { continue }; - let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue }; + let Some(hi) = h.inverse() else { continue }; let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue }; - // The drawn lines nearest the pointer along each axis, and their neighbors: the - // projection skews and shrinks the cells, so the intersection nearest on the page can be - // one over from the nearest in the plane. - let near = |v: f64, step: f64, last: usize| { - let i = (v / step).round().clamp(0.0, last as f64) as i64; - (i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step) + // On each drawn line, the intersections either side of the point of the line nearest + // the pointer on the page. The projection keeps the order of points along a line, so + // the nearest intersection on that line is one of those two, however skewed the cells. + let mut candidates = Vec::with_capacity(2 * (nu + nv + 2)); + let mut along = |line: &dyn Fn(f64) -> Point, step: f64, last: usize, coord: fn(Point) -> f64| { + let (Some(a), Some(b)) = (h.apply(line(0.0)), h.apply(line(last as f64 * step))) else { return }; + let ab = b - a; + let len2 = ab.x * ab.x + ab.y * ab.y; + let t = if len2 > 0.0 { (((p - a).x * ab.x + (p - a).y * ab.y) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let Some(at) = hi.apply(a + ab * t).map(coord).filter(|v| v.is_finite()) else { return }; + let k = (at / step).floor().clamp(0.0, last as f64); + for k in [k, (k + 1.0).min(last as f64)] { + candidates.push(line(k * step)); + } }; - for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) { - let q = Point::new(u, v); + for i in 0..=nu { + let u = i as f64 * su; + along(&|v| Point::new(u, v), sv, nv, |q| q.y); + } + for i in 0..=nv { + let v = i as f64 * sv; + along(&|u| Point::new(u, v), su, nu, |q| q.x); + } + for q in candidates { let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue }; if d <= tol && best.is_none_or(|b| d < b.0) @@ -268,6 +283,33 @@ mod tests { assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none()); } + #[test] + fn a_held_plane_still_checks_its_own_place() { + // Held at x = 0, the left wall's own place must stay in front of the viewer too: it goes + // back there once positioning ends. + let g = PerspectiveGrid { plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + assert!(g.merged(&json!({"leftOffset": -800})).is_err()); + assert!(PerspectiveGrid { inferred: None, ..g.clone() }.merged(&json!({"leftOffset": -800})).is_err()); + let moved = g.merged(&json!({"leftOffset": 40})).unwrap(); + assert_eq!((moved.placed_offset(Plane::Left), moved.offset(Plane::Left), moved.inferred), (40.0, 0.0, Some([0.0; 3]))); + } + + #[test] + fn the_nearest_intersection_on_the_page_is_found_on_a_skewed_plane() { + // The left vanishing point next to the origin skews the left wall's cells hard: the + // intersection nearest on the page lies many cells from the pointer's inverse. + let g = PerspectiveGrid { vp_left: 399.0, plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + let d = Document::new(800.0, 600.0); + let gridlines = PlaneTargets { anchors: false, gridlines: true }; + let p = Point::new(400.0, 219.761194); + let want = Point::new(10.0 * g.cell, 10.0 * g.cell); + assert!((g.to_page(Plane::Left, want).unwrap() - p).hypot() < 0.5); + let (_, at) = g.infer_at(&d, p, 6.0, gridlines).unwrap(); + for (got, want) in at.iter().zip([0.0, want.y, want.x]) { + assert!((got - want).abs() < 1e-6, "{at:?}"); + } + } + #[test] fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() { let g = PerspectiveGrid { plane: Plane::Right, ..grid() }; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 88bb262844..886eb81c94 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -133,6 +133,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo drag_released(app, ui, &f, d, release_pos(ui, button).unwrap_or(p)); } } else if let Some(p) = hover_tool + // A press on an artboard's name starts no drag: the moves while it is held are not hover. + && !(pointer.primary_down() && ui.data(|d| d.get_temp::<(usize, bool)>(name_press_id()).is_some())) && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) { let ev = PointerEvent { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6e48b0a357..2b423217d6 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1027,6 +1027,49 @@ fn shift_clicking_canvas_artboard_label_toggles_multi_selection() { assert_eq!(app.session.tool_options()["selected"], json!([2]), "Plain click selects that artboard alone"); } +/// A press on an artboard's name starts no drag. Moving with the button still held is not hover, +/// so Shift over a gridline intersection does not position the Perspective Selection tool's plane +/// until the button is up. +#[test] +fn moves_while_an_artboard_name_is_pressed_do_not_hover_the_tool() { + use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300, "artboards": 2})).unwrap(); + app.select_tool("perspectiveSelection"); + app.session.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + if let Some(v) = app.view_mut() { + v.center = Point::new(200.0, 150.0); + v.zoom = 1.0; + v.fitted = true; + } + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drawn = shapes(&mut app, &ctx); + let label = drawn + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text() == "01 - Artboard 1" => Some(t.pos + vec2(10.0, 5.0)), + _ => None, + }) + .unwrap(); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone(); + let g = PerspectiveGrid::current(&doc(&app)); + let target = xf.to_screen(g.to_page(Plane::Left, Point::new(2.0 * g.cell, 3.0 * g.cell)).unwrap()); + assert!(rect.contains(label) && rect.contains(target), "{label:?} {target:?} {rect:?}"); + + let shift = egui::Modifiers::SHIFT; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: shift }; + frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(label), button(label, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target + vec2(0.5, 0.0))]); + assert!(!PerspectiveGrid::positioning(&doc(&app)), "the button is still down"); + frame(&mut app, &ctx, vec![button(target, false)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target)]); + assert!(PerspectiveGrid::positioning(&doc(&app)), "with the button up, Shift over the intersection positions the plane"); +} + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. #[test]