mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Perspective: Automatic Plane Positioning checks a held plane's own place and finds skewed intersections
- perspective.grid.set rejects an offset that puts a plane behind the viewer even while Automatic Plane Positioning holds that plane, so the plane does not vanish when positioning ends. - The gridline intersection nearest the pointer is found on strongly skewed planes: each drawn line is searched at the point nearest the pointer on the page, instead of a few cells around the pointer's inverse. - Moves while an artboard name is pressed no longer reach the tool as hover, so Shift does not position the plane while the button is down.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user