mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
The Perspective Selection tool shows the selection's bounding box in perspective; its handles scale in plane space (perspective.transform; Shift proportional, Alt from the centre). Alt-drag moves copies that stay attached, pressing 5 while dragging moves along the plane's normal (ToolKey::Digit, delivered by the canvas and MCP press_key), and the arrow keys nudge in perspective (perspective.nudge). Transform Again repeats the last perspective move or scale in plane space. Attachments (plane and depth) now live on the objects (Node.perspective), so copies, duplicates and pastes keep them; grids from older files hand theirs over. Homography moves to vectorcraft-geom. Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7df7cf568a
commit
a0847e8a25
@@ -19,10 +19,11 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PerspectiveAttachment};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
use crate::bbox::{Handle as BoxHandle, scale_for_drag};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
pub mod define;
|
||||
pub use define::{GridDefinition, Rgb, Station};
|
||||
@@ -74,54 +75,7 @@ impl Plane {
|
||||
/// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius).
|
||||
pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64));
|
||||
|
||||
/// A 3×3 projective transform (row-major).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Homography(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Homography {
|
||||
/// From the images of (1,0,0), (0,1,0) and (0,0,1).
|
||||
pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self {
|
||||
Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]])
|
||||
}
|
||||
/// Map a point; None when it lands on or beyond the horizon (w ≤ 0).
|
||||
pub fn apply(&self, p: Point) -> Option<Point> {
|
||||
let m = &self.0;
|
||||
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
|
||||
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2];
|
||||
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
|
||||
if w <= 1e-9 || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(Point::new(x / w, y / w))
|
||||
}
|
||||
pub fn det(&self) -> f64 {
|
||||
let m = &self.0;
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
/// Inverse.
|
||||
pub fn inverse(&self) -> Option<Self> {
|
||||
let m = &self.0;
|
||||
let d = self.det();
|
||||
if d.abs() < 1e-15 {
|
||||
return None;
|
||||
}
|
||||
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
|
||||
let adj = [
|
||||
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
|
||||
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
|
||||
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
|
||||
];
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
for (r, row) in adj.iter().enumerate() {
|
||||
for (k, v) in row.iter().enumerate() {
|
||||
out[r][k] = v / d;
|
||||
}
|
||||
}
|
||||
// H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0.
|
||||
Some(Self(out))
|
||||
}
|
||||
}
|
||||
pub use vectorcraft_geom::Homography;
|
||||
|
||||
fn two() -> u8 {
|
||||
2
|
||||
@@ -223,25 +177,70 @@ impl PerspectiveGrid {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's grid, if one was defined.
|
||||
/// The document's grid, if one was defined, with [`Self::attached`] collected from the objects.
|
||||
pub fn from_doc(doc: &Document) -> Option<Self> {
|
||||
let mut g = Self::stored(doc)?;
|
||||
g.collect_attached(doc);
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// The document's grid, or the default two-point preset for the first artboard (hidden), with
|
||||
/// [`Self::attached`] collected from the objects.
|
||||
pub fn effective(doc: &Document) -> Self {
|
||||
let mut g = Self::current(doc);
|
||||
g.collect_attached(doc);
|
||||
g
|
||||
}
|
||||
|
||||
/// [`Self::effective`] without walking the document for attached objects (for drawing and
|
||||
/// tools: ask single objects with [`Self::attachment_of`]).
|
||||
pub fn current(doc: &Document) -> Self {
|
||||
Self::stored(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) })
|
||||
}
|
||||
|
||||
/// The grid as stored. Its `attached` holds what files from before attachments were kept on the
|
||||
/// objects recorded.
|
||||
fn stored(doc: &Document) -> Option<Self> {
|
||||
doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// The document's grid, or the default two-point preset for the first artboard (hidden).
|
||||
pub fn effective(doc: &Document) -> Self {
|
||||
Self::from_doc(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) })
|
||||
fn collect_attached(&mut self, doc: &Document) {
|
||||
doc.walk(|n| {
|
||||
if let Some((pl, _)) = attachment(n) {
|
||||
self.attached.insert(n.id.0.to_string(), pl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Store into the document.
|
||||
/// Store into the document. Attachments live on the objects: the stored grid keeps none.
|
||||
pub fn store(&self, doc: &mut Document) {
|
||||
self.adopt_stored_attachments(doc);
|
||||
let mut g = self.clone();
|
||||
g.attached.retain(|k, _| k.parse::<u64>().ok().is_some_and(|id| doc.node(NodeId(id)).is_some()));
|
||||
g.attached.clear();
|
||||
if let Ok(v) = serde_json::to_value(&g) {
|
||||
doc.unknown.insert(DOC_KEY.into(), v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the attachments a grid stored before they were kept on the objects onto the objects,
|
||||
/// unless this grid no longer has them (released since); ids of deleted objects are dropped.
|
||||
pub fn adopt_stored_attachments(&self, doc: &mut Document) {
|
||||
let Some(old) = Self::stored(doc) else { return };
|
||||
for (k, pl) in &old.attached {
|
||||
if self.attached.get(k) != Some(pl) {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = k.parse::<u64>().ok().and_then(|id| doc.node_mut(NodeId(id)))
|
||||
&& n.perspective.is_none()
|
||||
{
|
||||
n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0)));
|
||||
}
|
||||
}
|
||||
if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) {
|
||||
v.remove("attached");
|
||||
}
|
||||
}
|
||||
|
||||
/// Definition only (for tool previews and the Define Grid dialog).
|
||||
pub fn definition_json(&self) -> Value {
|
||||
let mut v = serde_json::to_value(self).unwrap_or(Value::Null);
|
||||
@@ -301,27 +300,108 @@ impl PerspectiveGrid {
|
||||
Plane::ALL
|
||||
}
|
||||
|
||||
/// The image of `axis`'s point at infinity: a vanishing point scaled by `1/distance` for a
|
||||
/// receding axis, a direction for an axis parallel to the picture plane.
|
||||
fn axis_col(&self, axis: Axis) -> [f64; 3] {
|
||||
// Each receding axis foreshortens by the viewer's distance to its vanishing point.
|
||||
let (ll, lr, lu) = self.foreshortening();
|
||||
match axis {
|
||||
Axis::X if self.kind == 1 => [1.0, 0.0, 0.0],
|
||||
Axis::X => [self.vp_right * lr, self.horizon * lr, lr],
|
||||
Axis::Y if self.kind == 3 => [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu],
|
||||
Axis::Y => [0.0, -1.0, 0.0],
|
||||
Axis::Z => [self.vp_left * ll, self.horizon * ll, ll],
|
||||
}
|
||||
}
|
||||
|
||||
/// A plane's axes: `[u, v, normal]`.
|
||||
pub fn axes(&self, plane: Plane) -> Option<[Axis; 3]> {
|
||||
Some(match (self.kind, plane) {
|
||||
(_, Plane::None) => return None,
|
||||
(1, Plane::Left) => [Axis::X, Axis::Y, Axis::Z],
|
||||
(1, Plane::Right) => [Axis::Z, Axis::Y, Axis::X],
|
||||
(_, Plane::Left) => [Axis::Z, Axis::Y, Axis::X],
|
||||
(_, Plane::Right) => [Axis::X, Axis::Y, Axis::Z],
|
||||
(_, Plane::Ground) => [Axis::X, Axis::Z, Axis::Y],
|
||||
})
|
||||
}
|
||||
|
||||
/// The homography of `plane` (plane coordinates in points → page).
|
||||
pub fn homography(&self, plane: Plane) -> Option<Homography> {
|
||||
let (ll, lr, lu) = self.foreshortening();
|
||||
let o = [self.origin[0], self.origin[1], 1.0];
|
||||
let vl = [self.vp_left * ll, self.horizon * ll, ll];
|
||||
let vr = [self.vp_right * lr, self.horizon * lr, lr];
|
||||
let up = if self.kind == 3 { [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu] } else { [0.0, -1.0, 0.0] };
|
||||
let flat = [1.0, 0.0, 0.0];
|
||||
let (c1, c2) = match (self.kind, plane) {
|
||||
(_, Plane::None) => return None,
|
||||
(1, Plane::Left) => (flat, [0.0, -1.0, 0.0]),
|
||||
(1, Plane::Right) => (vl, [0.0, -1.0, 0.0]),
|
||||
(1, Plane::Ground) => (flat, vl),
|
||||
(_, Plane::Left) => (vl, up),
|
||||
(_, Plane::Right) => (vr, up),
|
||||
(_, Plane::Ground) => (vr, vl),
|
||||
};
|
||||
let h = Homography::from_cols(c1, c2, o);
|
||||
self.homography_at(plane, 0.0)
|
||||
}
|
||||
|
||||
/// The homography of the plane parallel to `plane` that lies `depth` points along its normal
|
||||
/// (`None` behind the viewer).
|
||||
pub fn homography_at(&self, plane: Plane, depth: f64) -> Option<Homography> {
|
||||
let [u, v, n] = self.axes(plane)?;
|
||||
self.frame_homography(u, v, (n, depth))
|
||||
}
|
||||
|
||||
/// The plane spanned by axes `u` and `v` through the point `at.1` along axis `at.0`.
|
||||
fn frame_homography(&self, u: Axis, v: Axis, at: (Axis, f64)) -> Option<Homography> {
|
||||
if !at.1.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c = self.axis_col(at.0);
|
||||
let o = [self.origin[0] + at.1 * c[0], self.origin[1] + at.1 * c[1], 1.0 + at.1 * c[2]];
|
||||
if o[2] <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let h = Homography::from_cols(self.axis_col(u), self.axis_col(v), o);
|
||||
(h.det().abs() > 1e-15).then_some(h)
|
||||
}
|
||||
|
||||
/// The plane-space offset between page points `from` and `to` on `plane` at `depth`.
|
||||
pub fn plane_delta(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option<Vec2> {
|
||||
let hi = self.homography_at(plane, depth)?.inverse()?;
|
||||
Some(hi.apply(to)? - hi.apply(from)?)
|
||||
}
|
||||
|
||||
/// The depth an object on `plane` at `depth` dragged from `from` to `to` perpendicular to its
|
||||
/// plane reaches: the pointer is read on the plane through the drag start that holds the
|
||||
/// normal and the plane's v axis.
|
||||
pub fn depth_at(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option<f64> {
|
||||
let [u, v, n] = self.axes(plane)?;
|
||||
let q = self.homography_at(plane, depth)?.inverse()?.apply(from)?;
|
||||
let ci = self.frame_homography(n, v, (u, q.x))?.inverse()?;
|
||||
let d = depth + (ci.apply(to)?.x - ci.apply(from)?.x);
|
||||
(d.is_finite() && d.abs() <= MAX_DEPTH).then_some(d)
|
||||
}
|
||||
|
||||
/// The page map of plane-space affine map `m` for art on `plane` at `depth`, then moved `dz`
|
||||
/// along the plane's normal: `H(depth + dz) · m · H(depth)⁻¹`.
|
||||
pub fn transform_map(&self, plane: Plane, depth: f64, m: Affine, dz: f64) -> Option<Homography> {
|
||||
let h = self.homography_at(plane, depth)?;
|
||||
let to = self.homography_at(plane, depth + dz)?;
|
||||
Some(to.then_after(&Homography::from_affine(m)).then_after(&h.inverse()?))
|
||||
}
|
||||
|
||||
/// Where object `id` is attached (plane, depth): its own record, else the one a grid stored
|
||||
/// before attachments were kept on the objects.
|
||||
pub fn attachment_of(&self, doc: &Document, id: NodeId) -> Option<(Plane, f64)> {
|
||||
doc.node(id).and_then(attachment).or_else(|| self.attached_plane(id).map(|p| (p, 0.0)))
|
||||
}
|
||||
|
||||
/// The plane-space bounds of the attached objects among `ids` lying on the plane of the first
|
||||
/// one: (that plane, the first one's depth, bounds).
|
||||
pub fn plane_bounds(&self, doc: &Document, ids: &[NodeId]) -> Option<(Plane, f64, Rect)> {
|
||||
let mut out: Option<(Plane, f64, Rect)> = None;
|
||||
for id in ids {
|
||||
let Some((pl, depth)) = self.attachment_of(doc, *id) else { continue };
|
||||
if out.is_some_and(|o| o.0 != pl) {
|
||||
continue;
|
||||
}
|
||||
let hi = self.homography_at(pl, depth).and_then(|h| h.inverse());
|
||||
let Some(b) = hi.zip(doc.node(*id)).and_then(|(hi, n)| mapped_bounds(n, &hi)) else { continue };
|
||||
out = Some(match out {
|
||||
Some((p, d, r)) => (p, d, r.union(b)),
|
||||
None => (pl, depth, b),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Plane-coordinate extent of a plane.
|
||||
pub fn domain(&self, plane: Plane) -> Rect {
|
||||
match plane {
|
||||
@@ -363,6 +443,7 @@ impl PerspectiveGrid {
|
||||
Some(move |p: Point| h.apply(hi.apply(p)? + d))
|
||||
}
|
||||
|
||||
/// The plane object `id` is attached to, as collected by [`Self::from_doc`].
|
||||
pub fn attached_plane(&self, id: NodeId) -> Option<Plane> {
|
||||
self.attached.get(&id.0.to_string()).copied()
|
||||
}
|
||||
@@ -421,6 +502,57 @@ impl PerspectiveGrid {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (points) along a plane's normal objects and planes may go.
|
||||
pub const MAX_DEPTH: f64 = 1.0e6;
|
||||
|
||||
/// An axis of the grid's 3-D frame: Y is up, X and Z recede to the right and left vanishing points
|
||||
/// (in 1-point perspective X is the flat horizontal and Z recedes to the vanishing point).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
/// The plane and depth object `n` is attached to (its own record).
|
||||
pub fn attachment(n: &Node) -> Option<(Plane, f64)> {
|
||||
let a = n.perspective.as_deref()?;
|
||||
Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth))
|
||||
}
|
||||
|
||||
/// Attach `n` to `plane` at `depth`.
|
||||
pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) {
|
||||
n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth)));
|
||||
}
|
||||
|
||||
/// The bounds of `n` mapped by `hi`: paths by their mapped curves, other objects by their boxes'
|
||||
/// corners. `None` when part of it maps beyond the horizon.
|
||||
fn mapped_bounds(n: &Node, hi: &Homography) -> Option<Rect> {
|
||||
let mut acc: Option<Rect> = None;
|
||||
let mut ok = true;
|
||||
n.walk(&mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
let mut path = path.clone();
|
||||
for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) {
|
||||
for q in [&mut a.p, &mut a.h_in, &mut a.h_out] {
|
||||
match hi.apply(*q) {
|
||||
Some(v) => *q = v,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
acc = vectorcraft_geom::union_opt(acc, path.bounds());
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {}
|
||||
_ => match c.geometric_bounds().map(|b| hi.map_rect_bbox(b)) {
|
||||
Some(Some(b)) => acc = vectorcraft_geom::union_opt(acc, Some(b)),
|
||||
Some(None) => ok = false,
|
||||
None => {}
|
||||
},
|
||||
});
|
||||
if ok { acc } else { None }
|
||||
}
|
||||
|
||||
fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
let mut sign = 0.0;
|
||||
for i in 0..4 {
|
||||
@@ -441,7 +573,7 @@ fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or
|
||||
/// a perspective tool is active. `tol` = document units per screen pixel.
|
||||
pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::effective(doc);
|
||||
let g = PerspectiveGrid::current(doc);
|
||||
let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection");
|
||||
if !g.visible && !tool {
|
||||
return vec![];
|
||||
@@ -559,7 +691,7 @@ impl Tool for PerspectiveGridTool {
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let mut pre = vec![];
|
||||
@@ -591,7 +723,7 @@ impl Tool for PerspectiveGridTool {
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
handles(&g)
|
||||
.into_iter()
|
||||
.map(|(h, p)| match h {
|
||||
@@ -601,7 +733,7 @@ impl Tool for PerspectiveGridTool {
|
||||
.collect()
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
let tol = cx.tol(6.0);
|
||||
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
|
||||
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
|
||||
@@ -620,9 +752,71 @@ impl Tool for PerspectiveGridTool {
|
||||
|
||||
// ---------- Perspective Selection tool ----------
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum SelDrag {
|
||||
#[default]
|
||||
Idle,
|
||||
/// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along
|
||||
/// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event.
|
||||
Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool },
|
||||
/// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`).
|
||||
Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle },
|
||||
}
|
||||
|
||||
/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move
|
||||
/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the
|
||||
/// centre) objects within their perspective planes; the arrow keys nudge them in perspective.
|
||||
#[derive(Default)]
|
||||
pub struct PerspectiveSelectionTool {
|
||||
drag: Option<(Vec<NodeId>, Point)>,
|
||||
drag: SelDrag,
|
||||
measure: Option<(Point, String)>,
|
||||
}
|
||||
|
||||
/// The selection's perspective bounding box: plane, depth, plane-space rect and the homography
|
||||
/// that draws it.
|
||||
fn selection_persp_box(cx: &ToolContext, g: &PerspectiveGrid) -> Option<(Plane, f64, Rect, Homography)> {
|
||||
let (plane, depth, rect) = g.plane_bounds(cx.doc, &cx.selection.objects)?;
|
||||
Some((plane, depth, rect, g.homography_at(plane, depth)?))
|
||||
}
|
||||
|
||||
/// The handle of the perspective box (`rect` drawn by `h`) within `tol` of `p`.
|
||||
fn persp_handle_at(rect: Rect, h: &Homography, p: Point, tol: f64) -> Option<BoxHandle> {
|
||||
BoxHandle::ALL.into_iter().find(|k| h.apply(k.pos(rect)).is_some_and(|q| q.distance(p) <= tol))
|
||||
}
|
||||
|
||||
/// The resize cursor that fits a handle seen in direction `d` from the box's centre.
|
||||
fn resize_cursor(d: Vec2) -> Cursor {
|
||||
// Counter-clockwise degrees on screen (y down), folded to 0..180.
|
||||
let a = (-d.y).atan2(d.x).to_degrees().rem_euclid(180.0);
|
||||
match a {
|
||||
a if !(22.5..157.5).contains(&a) => Cursor::ResizeH,
|
||||
a if a < 67.5 => Cursor::ResizeNeSw,
|
||||
a if a < 112.5 => Cursor::ResizeV,
|
||||
_ => Cursor::ResizeNwSe,
|
||||
}
|
||||
}
|
||||
|
||||
impl PerspectiveSelectionTool {
|
||||
/// The `perspective.move` preview for the move drag (and its measurement label).
|
||||
fn move_preview(&mut self, cx: &ToolContext) -> Option<Action> {
|
||||
let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None };
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp});
|
||||
if g.plane != Plane::None {
|
||||
v["plane"] = json!(g.plane.id());
|
||||
}
|
||||
let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, 0.0)));
|
||||
self.measure = at.and_then(|(pl, depth)| {
|
||||
let text = if *perp {
|
||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth))
|
||||
} else {
|
||||
let d = g.plane_delta(pl, depth, *from, *last)?;
|
||||
cx.offset_label(d.x, d.y)
|
||||
};
|
||||
Some((*last, text))
|
||||
});
|
||||
Some(Action::Preview("perspective.move".into(), v))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for PerspectiveSelectionTool {
|
||||
@@ -630,19 +824,26 @@ impl Tool for PerspectiveSelectionTool {
|
||||
"perspectiveSelection"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let (p, m) = (ev.pos, ev.mods);
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
match (ev.kind, std::mem::take(&mut self.drag)) {
|
||||
(PointerKind::Down, _) => {
|
||||
self.measure = None;
|
||||
if g.visible
|
||||
&& let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p)
|
||||
{
|
||||
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
|
||||
}
|
||||
let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g)
|
||||
&& let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0))
|
||||
{
|
||||
self.drag = SelDrag::Scale { ids: cx.selection.objects.clone(), plane, depth, rect, handle };
|
||||
return vec![Action::Begin("Scale in Perspective".into())];
|
||||
}
|
||||
let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
return vec![Action::Exec("select.set".into(), json!({"ids": []}))];
|
||||
};
|
||||
let top = h.top_object(cx.isolation);
|
||||
let top = hit.top_object(cx.isolation);
|
||||
let mut acts = vec![];
|
||||
let ids = if cx.selection.objects.contains(&top) {
|
||||
cx.selection.objects.clone()
|
||||
@@ -650,53 +851,122 @@ impl Tool for PerspectiveSelectionTool {
|
||||
acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]})));
|
||||
vec![top]
|
||||
};
|
||||
acts.push(Action::Begin("Move in Perspective".into()));
|
||||
self.drag = Some((ids, p));
|
||||
// Selecting an object in perspective makes its plane the active one.
|
||||
if let Some((pl, _)) = g.attachment_of(cx.doc, top)
|
||||
&& pl != g.plane
|
||||
{
|
||||
acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
||||
}
|
||||
self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt };
|
||||
acts
|
||||
}
|
||||
PointerKind::Drag => match &self.drag {
|
||||
Some((ids, from)) => {
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [p.x, p.y]});
|
||||
if g.plane != Plane::None {
|
||||
v["plane"] = json!(g.plane.id());
|
||||
}
|
||||
vec![Action::Preview("perspective.move".into(), v)]
|
||||
(PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => {
|
||||
if !began && p.distance(from) < cx.tol(3.0) {
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt };
|
||||
return vec![];
|
||||
}
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(_) => vec![Action::Commit],
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into()));
|
||||
}
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt };
|
||||
out.extend(self.move_preview(cx));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, SelDrag::Scale { ids, plane, depth, rect, handle }) => {
|
||||
let q = g.homography_at(plane, depth).and_then(|h| h.inverse()).and_then(|hi| hi.apply(p));
|
||||
self.drag = SelDrag::Scale { ids: ids.clone(), plane, depth, rect, handle };
|
||||
let Some(q) = q else { return vec![] };
|
||||
let a = scale_for_drag(rect, handle, q, m.shift, m.alt);
|
||||
let nr = a.transform_rect_bbox(rect);
|
||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
||||
vec![Action::Preview("perspective.transform".into(), json!({"ids": crate::json_ids(&ids), "matrix": crate::select::matrix_json(a)}))]
|
||||
}
|
||||
(PointerKind::Up, SelDrag::Move { began, .. }) => {
|
||||
self.measure = None;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, SelDrag::Scale { .. }) => {
|
||||
self.measure = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(_, d) => {
|
||||
self.drag = d;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
// Attached objects in the selection get their plane's colour outline.
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let mut out = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id))
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
out.push(Overlay::Path {
|
||||
path: crate::xform::polygon(&crate::xform::rect_corners(b), true),
|
||||
color: pl.color(),
|
||||
width: 1.0,
|
||||
dashed: true,
|
||||
});
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) {
|
||||
*perp = !*perp;
|
||||
return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] };
|
||||
}
|
||||
let (dx, dy) = match key {
|
||||
ToolKey::Left => (-1, 0),
|
||||
ToolKey::Right => (1, 0),
|
||||
ToolKey::Up => (0, -1),
|
||||
ToolKey::Down => (0, 1),
|
||||
_ => return vec![],
|
||||
};
|
||||
if !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
vec![Action::Exec("perspective.nudge".into(), json!({"dx": dx, "dy": dy, "big": mods.shift, "copy": mods.alt}))]
|
||||
}
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
// The arrows nudge a selection in perspective.
|
||||
matches!(key, ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down)
|
||||
&& matches!(self.drag, SelDrag::Idle)
|
||||
&& cx.selection.anchors.is_empty()
|
||||
&& {
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
cx.selection.objects.iter().any(|id| g.attachment_of(cx.doc, *id).is_some())
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
// The selection's bounding box in perspective, in its plane's colour, with its handles.
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
let mut out = vec![];
|
||||
if let Some((plane, _, rect, h)) = selection_persp_box(cx, &g) {
|
||||
let corners: Vec<Point> = crate::xform::rect_corners(rect).iter().filter_map(|q| h.apply(*q)).collect();
|
||||
if corners.len() == 4 {
|
||||
out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color: plane.color(), width: 1.0, dashed: false });
|
||||
}
|
||||
for k in BoxHandle::ALL {
|
||||
if let Some(q) = h.apply(k.pos(rect)) {
|
||||
out.push(Overlay::Anchor { p: q, color: plane.color(), filled: false, size: 6.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((p, t)) = &self.measure {
|
||||
out.push(Overlay::Measure { p: *p, text: t.clone() });
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
let handle = match &self.drag {
|
||||
SelDrag::Scale { handle, .. } => Some(*handle),
|
||||
SelDrag::Move { began: true, .. } => return Cursor::Move,
|
||||
_ => None,
|
||||
};
|
||||
if let Some((_, _, rect, h)) = selection_persp_box(cx, &g)
|
||||
&& let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0)))
|
||||
&& let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center()))
|
||||
{
|
||||
return resize_cursor(a - c);
|
||||
}
|
||||
Cursor::Arrow
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
!matches!(self.drag, SelDrag::Idle)
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
self.measure = None;
|
||||
match std::mem::take(&mut self.drag) {
|
||||
SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } => vec![Action::Commit],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,4 +1090,101 @@ mod tests {
|
||||
assert_eq!(v["horizon"], json!(g.horizon + 10.0));
|
||||
assert!(!grid_overlays(&d, 1.0, "selection").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_moves_along_the_plane_normal() {
|
||||
// A grid with a viewing angle foreshortens each axis by its own distance.
|
||||
let angled = PerspectiveGrid { angle: Some(30.0), ..grid(3) };
|
||||
assert_ne!(angled.foreshortening().0, angled.foreshortening().1);
|
||||
for g in [grid(1), grid(2), grid(3), angled] {
|
||||
let kind = g.kind;
|
||||
for pl in Plane::ALL {
|
||||
let q = Point::new(40.0, 30.0);
|
||||
let from = g.homography_at(pl, 0.0).unwrap().apply(q).unwrap();
|
||||
let to = g.homography_at(pl, 25.0).unwrap().apply(q).unwrap();
|
||||
let d = g.depth_at(pl, 0.0, from, to).unwrap();
|
||||
assert!((d - 25.0).abs() < 1e-6, "{kind} {pl:?} {d}");
|
||||
// The map to the parallel plane keeps plane coordinates.
|
||||
let m = g.transform_map(pl, 0.0, Affine::IDENTITY, 25.0).unwrap();
|
||||
assert!((m.apply(from).unwrap() - to).hypot() < 1e-6);
|
||||
// In-plane: a plane-space translation.
|
||||
let m = g.transform_map(pl, 25.0, Affine::translate((10.0, -5.0)), 0.0).unwrap();
|
||||
let moved = g.homography_at(pl, 25.0).unwrap().inverse().unwrap().apply(m.apply(to).unwrap()).unwrap();
|
||||
assert!((moved - Point::new(50.0, 25.0)).hypot() < 1e-6, "{kind} {pl:?} {moved:?}");
|
||||
}
|
||||
}
|
||||
// Behind the viewer there is no plane.
|
||||
assert!(grid(2).homography_at(Plane::Left, -1.0e9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_live_on_objects_and_legacy_ones_are_adopted() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let mut g = grid(2);
|
||||
g.attached.insert(id.0.to_string(), Plane::Right);
|
||||
g.attached.insert("999999".into(), Plane::Left);
|
||||
// A grid as files from before stored it: the attachments in the grid.
|
||||
d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&g).unwrap());
|
||||
let read = PerspectiveGrid::from_doc(&d).unwrap();
|
||||
assert_eq!(read.attachment_of(&d, id), Some((Plane::Right, 0.0)));
|
||||
read.store(&mut d);
|
||||
assert!(d.unknown[DOC_KEY].get("attached").is_none(), "the stored grid keeps none");
|
||||
assert_eq!(attachment(d.node(id).unwrap()), Some((Plane::Right, 0.0)));
|
||||
assert_eq!(PerspectiveGrid::from_doc(&d).unwrap().attached_plane(id), Some(Plane::Right));
|
||||
// Released since: not adopted.
|
||||
let (mut d2, id2) = doc_with_rect();
|
||||
let mut old = grid(2);
|
||||
old.attached.insert(id2.0.to_string(), Plane::Left);
|
||||
d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap());
|
||||
grid(2).store(&mut d2);
|
||||
assert!(d2.node(id2).unwrap().perspective.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tool_scales_with_handles_and_5_moves_perpendicular() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
grid(2).store(&mut d);
|
||||
set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let g = PerspectiveGrid::current(&d);
|
||||
let (plane, depth, rect) = g.plane_bounds(&d, &[id]).unwrap();
|
||||
assert_eq!((plane, depth), (Plane::Right, 0.0));
|
||||
let h = g.homography(Plane::Right).unwrap();
|
||||
let mut t = PerspectiveSelectionTool::default();
|
||||
assert!(t.claims_key(&c, ToolKey::Left));
|
||||
// A corner handle scales.
|
||||
let corner = h.apply(Point::new(rect.x1, rect.y1)).unwrap();
|
||||
assert!(matches!(t.cursor(&c, corner, Mods::default()), Cursor::ResizeNwSe | Cursor::ResizeNeSw));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale in Perspective".into())]);
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, corner.x + 10.0, corner.y + 10.0));
|
||||
assert!(matches!(&acts[0], Action::Preview(cmd, _) if cmd == "perspective.transform"));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, corner.x + 10.0, corner.y + 10.0)), vec![Action::Commit]);
|
||||
// Moving: 5 toggles the perpendicular move.
|
||||
let mid = h.apply(rect.center()).unwrap();
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y));
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y));
|
||||
assert_eq!(acts[0], Action::Begin("Move in Perspective".into()));
|
||||
let Action::Preview(_, v) = &acts[1] else { panic!("{acts:?}") };
|
||||
assert_eq!(v["perpendicular"], json!(false));
|
||||
assert!(!t.claims_key(&c, ToolKey::Left), "busy");
|
||||
let acts = t.key(&c, ToolKey::Digit(5), Mods::default());
|
||||
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y])));
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ"))));
|
||||
assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty());
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_cursors_follow_the_handle_on_screen() {
|
||||
assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH);
|
||||
assert_eq!(resize_cursor(Vec2::new(0.1, -1.0)), Cursor::ResizeV);
|
||||
assert_eq!(resize_cursor(Vec2::new(1.0, -1.0)), Cursor::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(Vec2::new(1.0, 1.0)), Cursor::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(Vec2::new(-1.0, -1.0)), Cursor::ResizeNwSe);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,9 @@ pub enum ToolKey {
|
||||
Tab,
|
||||
Home,
|
||||
End,
|
||||
/// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular
|
||||
/// to the plane).
|
||||
Digit(u8),
|
||||
}
|
||||
|
||||
/// What a tool asks the engine to do.
|
||||
|
||||
Reference in New Issue
Block a user