mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
Tools that showed distance or angle overlays during drags now respect the Units preference, consistent with shape and selection tools. Co-authored-by: Cursor <cursoragent@cursor.com>
1532 lines
66 KiB
Rust
1532 lines
66 KiB
Rust
//! Perspective Grid: the grid model, plane homographies, overlays, and the Perspective Grid
|
||
//! (Shift+P) and Perspective Selection (Shift+V) tools.
|
||
//!
|
||
//! The grid is stored in the document (`Document.unknown["perspectiveGrid"]`) as a
|
||
//! [`PerspectiveGrid`]. Every plane is a homography `H` from plane coordinates `(u, v)` in points
|
||
//! to the page, built from homogeneous columns: `H = [c1 c2 O]` where `O` is the ground-level
|
||
//! origin (the corner where the planes meet) and `c1`/`c2` are the images of the plane axes'
|
||
//! points at infinity — a vanishing point scaled by `1/distance` for receding axes, a direction
|
||
//! for axes parallel to the picture plane. Along a receding axis `u ↦ O + (VP − O)·u/(u + d)`,
|
||
//! the projective foreshortening of a camera `d` points from the origin.
|
||
//!
|
||
//! - 1-point: Left = the flat front plane, Right = the side wall receding to `vpLeft`, Ground
|
||
//! recedes to `vpLeft` too.
|
||
//! - 2-point: Left recedes to `vpLeft`, Right to `vpRight`, verticals stay vertical; Ground spans
|
||
//! both vanishing points.
|
||
//! - 3-point: as 2-point, but verticals converge to `vpVertical`.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PerspectiveAttachment};
|
||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||
|
||
use crate::bbox::{Handle as BoxHandle, scale_for_drag};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||
|
||
pub mod define;
|
||
pub mod view;
|
||
pub mod widget;
|
||
pub use define::{GridDefinition, Rgb, Station};
|
||
|
||
/// Key under `Document.unknown`.
|
||
pub const DOC_KEY: &str = "perspectiveGrid";
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum Plane {
|
||
#[default]
|
||
Left,
|
||
Right,
|
||
Ground,
|
||
/// No active plane (widget key 4).
|
||
None,
|
||
}
|
||
|
||
impl Plane {
|
||
pub fn parse(s: &str) -> Option<Self> {
|
||
Some(match s {
|
||
"left" => Self::Left,
|
||
"right" => Self::Right,
|
||
"ground" | "horizontal" => Self::Ground,
|
||
"none" => Self::None,
|
||
_ => return None,
|
||
})
|
||
}
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Self::Left => "left",
|
||
Self::Right => "right",
|
||
Self::Ground => "ground",
|
||
Self::None => "none",
|
||
}
|
||
}
|
||
/// Illustrator's plane colours: left blue, right orange, ground green.
|
||
pub fn color(self) -> [u8; 3] {
|
||
match self {
|
||
Self::Left => [0x33, 0x66, 0xff],
|
||
Self::Right => [0xff, 0x8c, 0x1a],
|
||
Self::Ground => [0x2e, 0xb8, 0x4a],
|
||
Self::None => [0x99, 0x99, 0x99],
|
||
}
|
||
}
|
||
pub const ALL: [Plane; 3] = [Plane::Left, Plane::Right, Plane::Ground];
|
||
}
|
||
|
||
/// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius).
|
||
pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64));
|
||
|
||
pub use vectorcraft_geom::Homography;
|
||
|
||
fn two() -> u8 {
|
||
2
|
||
}
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
fn is_zero(v: &f64) -> bool {
|
||
*v == 0.0
|
||
}
|
||
|
||
/// The perspective grid definition (Define Grid) plus view state.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PerspectiveGrid {
|
||
/// 1, 2 or 3-point perspective.
|
||
#[serde(default = "two")]
|
||
pub kind: u8,
|
||
/// Ground-level origin (where the planes meet), page coordinates.
|
||
pub origin: [f64; 2],
|
||
/// Horizon height (page y).
|
||
pub horizon: f64,
|
||
/// Left vanishing point x (the single vanishing point in 1-point perspective).
|
||
pub vp_left: f64,
|
||
/// Right vanishing point x.
|
||
pub vp_right: f64,
|
||
/// Third (vertical) vanishing point, 3-point only.
|
||
pub vp_vertical: [f64; 2],
|
||
/// Viewing distance (points): how fast receding axes foreshorten.
|
||
pub distance: f64,
|
||
/// Gridline every (points, in plane units).
|
||
pub cell: f64,
|
||
/// Horizontal extent of the planes (points, plane units).
|
||
pub extent: f64,
|
||
/// Vertical extent of the wall planes (points, plane units).
|
||
pub height: f64,
|
||
#[serde(default = "yes")]
|
||
pub visible: bool,
|
||
/// Active plane (Plane Switching widget).
|
||
#[serde(default)]
|
||
pub plane: Plane,
|
||
/// Objects attached to planes (node id → plane).
|
||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||
pub attached: BTreeMap<String, Plane>,
|
||
/// The preset the grid came from ("" = custom).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub name: String,
|
||
/// Viewing angle in degrees (two/three-point): where the station point stands between the
|
||
/// vanishing points ([`Station`]). None for grids made before it: they foreshorten every axis
|
||
/// by `distance`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub angle: Option<f64>,
|
||
/// The unit Define Grid measures in (a unit name, e.g. `points`, `inches`).
|
||
#[serde(default = "define::points", skip_serializing_if = "define::is_points")]
|
||
pub units: String,
|
||
/// Scale: `[artboard, real world]` lengths (Define Grid's real-world lengths over this).
|
||
#[serde(default = "define::one_to_one", skip_serializing_if = "define::is_one_to_one")]
|
||
pub scale: [f64; 2],
|
||
/// Gridline colours of the left, right and horizontal (ground) planes.
|
||
#[serde(default = "define::left_rgb")]
|
||
pub left_color: Rgb,
|
||
#[serde(default = "define::right_rgb")]
|
||
pub right_color: Rgb,
|
||
#[serde(default = "define::ground_rgb")]
|
||
pub ground_color: Rgb,
|
||
/// Gridline opacity, 0–100 %.
|
||
#[serde(default = "define::half")]
|
||
pub opacity: f64,
|
||
/// Where the left, right and horizontal planes were moved along their normals (points; 0: their
|
||
/// place in the grid's definition): the plane widgets and `perspective.plane.move` set them.
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub left_offset: f64,
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub right_offset: f64,
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub ground_offset: f64,
|
||
/// View → Perspective Grid → Lock Grid: the grid's widgets can't be dragged.
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub locked: bool,
|
||
/// Lock Station Point: dragging one vanishing point moves the other around the station point.
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub lock_station: bool,
|
||
/// Snap to Grid: art drawn or moved in perspective lands on gridlines within a quarter cell.
|
||
#[serde(default = "yes")]
|
||
pub snap: bool,
|
||
/// Show Rulers: a ruler up the line where the planes meet.
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub rulers: bool,
|
||
/// Horizontal extent of the right plane (points, plane units); None: `extent`, as the left.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub extent_right: Option<f64>,
|
||
}
|
||
|
||
impl PerspectiveGrid {
|
||
/// Illustrator-like preset for `kind` fitted to an artboard.
|
||
pub fn preset(kind: u8, ab: Rect) -> Self {
|
||
let (w, h) = (ab.width().max(1.0), ab.height().max(1.0));
|
||
let kind = kind.clamp(1, 3);
|
||
let horizon = ab.y0 + 0.42 * h;
|
||
let ground = ab.y0 + 0.78 * h;
|
||
let ox = if kind == 1 { ab.x0 + 0.3 * w } else { ab.x0 + 0.5 * w };
|
||
let (vl, vr) = if kind == 1 { (ab.x0 + 0.55 * w, ab.x1) } else { (ab.x0 + 0.02 * w, ab.x1 - 0.02 * w) };
|
||
Self {
|
||
kind,
|
||
origin: [ox, ground],
|
||
horizon,
|
||
vp_left: vl,
|
||
vp_right: vr,
|
||
vp_vertical: [ox, horizon - 3.0 * (ground - horizon)],
|
||
distance: 0.5 * w,
|
||
cell: (w / 30.0).max(1.0).round(),
|
||
extent: 0.6 * w,
|
||
height: (ground - ab.y0) * 0.8,
|
||
visible: true,
|
||
plane: Plane::Left,
|
||
attached: BTreeMap::new(),
|
||
name: String::new(),
|
||
angle: None,
|
||
units: define::points(),
|
||
scale: define::one_to_one(),
|
||
left_color: define::left_rgb(),
|
||
right_color: define::right_rgb(),
|
||
ground_color: define::ground_rgb(),
|
||
opacity: define::half(),
|
||
left_offset: 0.0,
|
||
right_offset: 0.0,
|
||
ground_offset: 0.0,
|
||
locked: false,
|
||
lock_station: false,
|
||
snap: true,
|
||
rulers: false,
|
||
extent_right: None,
|
||
}
|
||
}
|
||
|
||
/// Where `plane` is along its normal (points; 0: its place in the grid's definition).
|
||
pub fn offset(&self, plane: Plane) -> f64 {
|
||
match plane {
|
||
Plane::Left => self.left_offset,
|
||
Plane::Right => self.right_offset,
|
||
Plane::Ground => self.ground_offset,
|
||
Plane::None => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Move `plane` to `offset` along its normal.
|
||
pub fn set_offset(&mut self, plane: Plane, offset: f64) {
|
||
match plane {
|
||
Plane::Left => self.left_offset = offset,
|
||
Plane::Right => self.right_offset = offset,
|
||
Plane::Ground => self.ground_offset = offset,
|
||
Plane::None => {}
|
||
}
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
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. 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.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.extras().perspective.is_none()
|
||
{
|
||
n.extras_mut().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);
|
||
if let Some(o) = v.as_object_mut() {
|
||
o.remove("attached");
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Merge `patch` (any subset of the fields) into this grid.
|
||
pub fn merged(&self, patch: &Value) -> Result<Self, String> {
|
||
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||
if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) {
|
||
for (k, val) in p {
|
||
if k == "ids" || k == "attached" {
|
||
continue;
|
||
}
|
||
o.insert(k.clone(), val.clone());
|
||
}
|
||
}
|
||
let mut g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||
g.reconcile(self, patch);
|
||
g.validate()?;
|
||
Ok(g)
|
||
}
|
||
|
||
pub fn validate(&self) -> Result<(), String> {
|
||
if !(1..=3).contains(&self.kind) {
|
||
return Err("kind must be 1, 2 or 3".into());
|
||
}
|
||
let nums = [
|
||
self.origin[0],
|
||
self.origin[1],
|
||
self.horizon,
|
||
self.vp_left,
|
||
self.vp_right,
|
||
self.vp_vertical[0],
|
||
self.vp_vertical[1],
|
||
self.distance,
|
||
self.cell,
|
||
self.extent,
|
||
self.height,
|
||
];
|
||
if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) {
|
||
return Err("grid values must be finite".into());
|
||
}
|
||
if self.distance <= 0.0 || self.cell <= 0.0 || self.extent <= 0.0 || self.height <= 0.0 {
|
||
return Err("distance, cell, extent and height must be positive".into());
|
||
}
|
||
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)) {
|
||
return Err("a plane is moved too far (behind the viewer)".into());
|
||
}
|
||
self.validate_definition()
|
||
}
|
||
|
||
pub fn planes(&self) -> [Plane; 3] {
|
||
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` where it is now (plane coordinates in points → page).
|
||
pub fn homography(&self, plane: Plane) -> Option<Homography> {
|
||
self.homography_at(plane, self.offset(plane))
|
||
}
|
||
|
||
/// 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: the left plane is `extent` wide, the right one
|
||
/// [`Self::right_extent`]; the ground spans both (one-point: the front plane's width by the
|
||
/// side wall's depth).
|
||
pub fn domain(&self, plane: Plane) -> Rect {
|
||
match (plane, self.kind) {
|
||
(Plane::Ground, 1) => Rect::new(0.0, 0.0, self.extent, self.right_extent()),
|
||
(Plane::Ground, _) => Rect::new(0.0, 0.0, self.right_extent(), self.extent),
|
||
(Plane::Right, _) => Rect::new(0.0, 0.0, self.right_extent(), self.height),
|
||
_ => Rect::new(0.0, 0.0, self.extent, self.height),
|
||
}
|
||
}
|
||
|
||
/// The right plane's horizontal extent.
|
||
pub fn right_extent(&self) -> f64 {
|
||
self.extent_right.unwrap_or(self.extent)
|
||
}
|
||
|
||
/// Page → plane coordinates.
|
||
pub fn to_plane(&self, plane: Plane, p: Point) -> Option<Point> {
|
||
self.homography(plane)?.inverse()?.apply(p)
|
||
}
|
||
/// Plane → page coordinates.
|
||
pub fn to_page(&self, plane: Plane, q: Point) -> Option<Point> {
|
||
self.homography(plane)?.apply(q)
|
||
}
|
||
|
||
/// The map that puts flat art with bounds `b` onto `plane`: an axis-aligned affine map sends
|
||
/// `b` to the plane rectangle whose opposite corners project to `b`'s top-left and
|
||
/// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two
|
||
/// corners the user dragged between).
|
||
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||
let h = self.attach_homography(plane, b, false)?;
|
||
Some(move |p: Point| h.apply(p))
|
||
}
|
||
|
||
/// [`Self::attach_map`]; with `snap` the corners land on the nearest gridlines (Snap to Grid).
|
||
pub fn attach_map_with(&self, plane: Plane, b: Rect, snap: bool) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||
let h = self.attach_homography(plane, b, snap)?;
|
||
Some(move |p: Point| h.apply(p))
|
||
}
|
||
|
||
/// [`Self::attach_map_with`] as one projective map.
|
||
pub fn attach_homography(&self, plane: Plane, b: Rect, snap: bool) -> Option<Homography> {
|
||
let h = self.homography(plane)?;
|
||
let hi = h.inverse()?;
|
||
let snapped = |q: Point| if snap { self.snap_plane(q) } else { q };
|
||
let a = snapped(hi.apply(Point::new(b.x0, b.y0))?);
|
||
let c = snapped(hi.apply(Point::new(b.x1, b.y1))?);
|
||
let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 };
|
||
let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } };
|
||
let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() };
|
||
let m = Affine::new([sx, 0.0, 0.0, sy, a.x - b.x0 * sx, a.y - b.y0 * sy]);
|
||
Some(h.then_after(&Homography::from_affine(m)))
|
||
}
|
||
|
||
/// The map that puts flat art drawn at `at` onto `plane` keeping its sizes in plane units (a
|
||
/// click-to-size shape dialog's shape): `at` stays where it is, and right and down on the page
|
||
/// go the ways the plane's axes run on the page there.
|
||
/// With `snap` the click lands on the nearest gridline intersection (Snap to Grid).
|
||
pub fn size_homography(&self, plane: Plane, at: Point, snap: bool) -> Option<Homography> {
|
||
let h = self.homography(plane)?;
|
||
let q = h.inverse()?.apply(at)?;
|
||
let q = if snap { self.snap_plane(q) } else { q };
|
||
let at = h.apply(q)?;
|
||
let sx = if h.apply(q + Vec2::new(1.0, 0.0))?.x < at.x { -1.0 } else { 1.0 };
|
||
let sy = if h.apply(q + Vec2::new(0.0, 1.0))?.y < at.y { -1.0 } else { 1.0 };
|
||
let m = Affine::new([sx, 0.0, 0.0, sy, q.x - sx * at.x, q.y - sy * at.y]);
|
||
Some(h.then_after(&Homography::from_affine(m)))
|
||
}
|
||
|
||
/// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`.
|
||
pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||
let hi = self.homography(plane)?.inverse()?;
|
||
self.move_map_by(plane, hi.apply(to)? - hi.apply(from)?)
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// Grid lines of `plane` (page space).
|
||
pub fn lines(&self, plane: Plane) -> Vec<(Point, Point)> {
|
||
let Some(h) = self.homography(plane) else { return vec![] };
|
||
let d = self.domain(plane);
|
||
let mut out = vec![];
|
||
let step = |len: f64| {
|
||
let mut s = self.cell;
|
||
while len / s > 120.0 {
|
||
s *= 2.0;
|
||
}
|
||
s
|
||
};
|
||
let (su, sv) = (step(d.width()), step(d.height()));
|
||
let mut u = 0.0;
|
||
while u <= d.width() + 1e-9 {
|
||
if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, d.height()))) {
|
||
out.push((a, b));
|
||
}
|
||
u += su;
|
||
}
|
||
let mut v = 0.0;
|
||
while v <= d.height() + 1e-9 {
|
||
if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(d.width(), v))) {
|
||
out.push((a, b));
|
||
}
|
||
v += sv;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Plane Switching widget geometry in the first artboard's top-left corner (headless): (plane,
|
||
/// quad) faces and the "no plane" circle (centre, r). See [`Self::widget_at`].
|
||
pub fn widget(&self, doc: &Document, tol: f64) -> WidgetGeom {
|
||
self.widget_at(doc, tol, widget::WidgetPlace::default())
|
||
}
|
||
|
||
/// Which widget control is at `p`.
|
||
pub fn widget_hit(&self, doc: &Document, tol: f64, p: Point) -> Option<Plane> {
|
||
let (faces, (c, r)) = self.widget(doc, tol);
|
||
if p.distance(c) <= r * 1.5 {
|
||
return Some(Plane::None);
|
||
}
|
||
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
|
||
}
|
||
|
||
/// The left, right and horizontal plane widgets: where each sits (on its plane: the middle of a
|
||
/// wall's ground edge, the middle of the horizontal plane).
|
||
pub fn plane_widgets(&self) -> Vec<(Plane, Point)> {
|
||
let e = self.extent / 2.0;
|
||
Plane::ALL
|
||
.into_iter()
|
||
.filter_map(|pl| {
|
||
let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) };
|
||
Some((pl, self.homography(pl)?.apply(q)?))
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Dragging a plane widget: the plane moves along its normal by as much as the pointer moved along
|
||
/// it (from the widget, wherever on it the press was).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct PlaneDrag {
|
||
pub plane: Plane,
|
||
anchor: Point,
|
||
press: Point,
|
||
offset: f64,
|
||
}
|
||
|
||
impl PlaneDrag {
|
||
/// The plane widget within `tol` of `p` (drawn grids only).
|
||
pub fn hit(g: &PerspectiveGrid, tol: f64, p: Point) -> Option<Self> {
|
||
if !g.visible {
|
||
return None;
|
||
}
|
||
let (plane, anchor) = g.plane_widgets().into_iter().find(|(_, q)| q.distance(p) <= tol)?;
|
||
Some(Self { plane, anchor, press: p, offset: g.offset(plane) })
|
||
}
|
||
|
||
/// The preview for the pointer at `p`: Shift moves the objects on the plane with it, Alt copies
|
||
/// them.
|
||
pub fn preview(&self, g: &PerspectiveGrid, p: Point, m: Mods) -> Option<Action> {
|
||
let offset = g.depth_at(self.plane, self.offset, self.anchor, self.anchor + (p - self.press))?;
|
||
let objects = if m.alt {
|
||
"copy"
|
||
} else if m.shift {
|
||
"move"
|
||
} else {
|
||
"none"
|
||
};
|
||
Some(Action::Preview("perspective.plane.move".into(), json!({"plane": self.plane.id(), "offset": offset, "objects": objects})))
|
||
}
|
||
|
||
/// The plane options dialog a double-click on the widget opens.
|
||
pub fn options(&self) -> Action {
|
||
Action::Dialog(PLANE_DIALOG.into(), json!({"plane": self.plane.id()}))
|
||
}
|
||
}
|
||
|
||
/// The dialog kind of a plane's options (double-click its widget).
|
||
pub const PLANE_DIALOG: &str = "perspectivePlane";
|
||
|
||
/// The plane widgets as drawn: a diamond in each plane's colour.
|
||
fn plane_widget_overlays(g: &PerspectiveGrid, tol: f64) -> Vec<Overlay> {
|
||
let r = 5.0 * tol;
|
||
g.plane_widgets()
|
||
.into_iter()
|
||
.flat_map(|(pl, p)| {
|
||
let [cr, cg, cb] = pl.color();
|
||
let quad = [Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)];
|
||
[
|
||
Overlay::Highlight { quad, color: [cr, cg, cb, 220] },
|
||
Overlay::Path { path: super::diamond(p, r), color: [0x20, 0x20, 0x20], width: 1.0, dashed: false },
|
||
]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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.extras().perspective.as_deref()?;
|
||
Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth))
|
||
}
|
||
|
||
/// The objects in perspective that aren't inside another one: (id, plane, depth).
|
||
pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> {
|
||
fn visit(n: &Node, out: &mut Vec<(NodeId, Plane, f64)>) {
|
||
match attachment(n) {
|
||
Some((pl, depth)) => out.push((n.id, pl, depth)),
|
||
None => n.children().into_iter().flatten().for_each(|c| visit(c, out)),
|
||
}
|
||
}
|
||
let mut out = vec![];
|
||
for l in &doc.layers {
|
||
visit(l, &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Attach `n` to `plane` at `depth` (type and symbols keep their projection).
|
||
pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) {
|
||
let mut extras = n.extras_mut();
|
||
let rec = extras.perspective.get_or_insert_with(Default::default);
|
||
rec.plane = plane.id().into();
|
||
rec.depth = if depth.is_finite() { depth } else { 0.0 };
|
||
}
|
||
|
||
/// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their
|
||
/// projection.
|
||
pub fn release(n: &mut Node) {
|
||
let mut extras = n.extras_mut();
|
||
match extras.perspective.as_deref_mut() {
|
||
Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() },
|
||
_ => extras.perspective = None,
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
|
||
/// Is `p` inside the convex quad `q` (either winding; a point on an edge is inside)?
|
||
pub(crate) fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||
let mut sign = 0.0;
|
||
for i in 0..4 {
|
||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||
let c = (b - a).cross(p - a);
|
||
if c.abs() < 1e-12 {
|
||
continue;
|
||
}
|
||
if sign == 0.0 {
|
||
sign = c.signum();
|
||
} else if c.signum() != sign {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible
|
||
/// (selecting a perspective tool shows it). `tol` = document units per screen pixel.
|
||
pub fn grid_overlays(doc: &Document, tol: f64) -> Vec<Overlay> {
|
||
grid_overlays_in(doc, tol, Some(widget::WidgetPlace::default()))
|
||
}
|
||
|
||
/// [`grid_overlays`] with the Plane Switching Widget at `place` (None: hidden).
|
||
pub fn grid_overlays_in(doc: &Document, tol: f64, place: Option<widget::WidgetPlace>) -> Vec<Overlay> {
|
||
let g = PerspectiveGrid::current(doc);
|
||
if !g.visible {
|
||
return vec![];
|
||
}
|
||
let mut out = vec![];
|
||
for pl in g.planes() {
|
||
let (line, color) = (g.line_color(pl), g.plane_color(pl));
|
||
for (a, b) in g.lines(pl) {
|
||
out.push(Overlay::GridLine { a, b, color: line });
|
||
}
|
||
if pl == g.plane
|
||
&& let Some(h) = g.homography(pl)
|
||
{
|
||
let d = g.domain(pl);
|
||
let corners: Vec<Point> =
|
||
[(d.x0, d.y0), (d.x1, d.y0), (d.x1, d.y1), (d.x0, d.y1)].iter().filter_map(|&(x, y)| h.apply(Point::new(x, y))).collect();
|
||
if corners.len() == 4 {
|
||
out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color, width: 2.0, dashed: false });
|
||
}
|
||
}
|
||
}
|
||
let xs = [g.vp_left, g.vp_right, g.origin[0]];
|
||
let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min) - 50.0 * tol, xs.iter().cloned().fold(f64::MIN, f64::max) + 50.0 * tol);
|
||
out.push(Overlay::Line { a: Point::new(x0, g.horizon), b: Point::new(x1, g.horizon), color: [0x60, 0x60, 0x60], dashed: true });
|
||
if g.rulers {
|
||
out.extend(g.ruler_overlays(tol));
|
||
}
|
||
// Widget.
|
||
let Some(place) = place else { return out };
|
||
let (faces, (c, r)) = g.widget_at(doc, tol, place);
|
||
for (pl, q) in faces {
|
||
let [cr, cg, cb] = pl.color();
|
||
out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] });
|
||
out.push(Overlay::Path { path: crate::xform::polygon(&q, true), color: [0x40, 0x40, 0x40], width: 1.0, dashed: false });
|
||
}
|
||
out.push(Overlay::Path {
|
||
path: super::ellipse_path(c, r, r, 0.0),
|
||
color: if g.plane == Plane::None { [0x20, 0x20, 0x20] } else { [0x99, 0x99, 0x99] },
|
||
width: 1.5,
|
||
dashed: false,
|
||
});
|
||
out.extend(plane_widget_overlays(&g, tol));
|
||
out
|
||
}
|
||
|
||
// ---------- Perspective Grid tool ----------
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Handle {
|
||
VpLeft,
|
||
VpRight,
|
||
VpVertical,
|
||
Horizon,
|
||
Origin,
|
||
ExtentLeft,
|
||
ExtentRight,
|
||
Height,
|
||
/// The left and right ground-level points: they move the whole grid.
|
||
GroundLeft,
|
||
GroundRight,
|
||
/// The grid cell size widget, `n` cells up the line where the planes meet.
|
||
Cell,
|
||
}
|
||
|
||
/// The ground-level points and the cell widget sit at least this far (screen pixels) from the
|
||
/// origin.
|
||
const NEAR_ORIGIN_PX: f64 = 24.0;
|
||
|
||
/// How many cells up the cell widget sits so it stays clear of the origin (`tol` = document units
|
||
/// per screen pixel).
|
||
fn cell_steps(g: &PerspectiveGrid, tol: f64) -> f64 {
|
||
(NEAR_ORIGIN_PX * tol / g.cell.max(1e-9)).ceil().clamp(1.0, 1.0e6)
|
||
}
|
||
|
||
fn handles(g: &PerspectiveGrid, tol: f64) -> Vec<(Handle, Point)> {
|
||
let o = Point::new(g.origin[0], g.origin[1]);
|
||
let mut v = vec![(Handle::VpLeft, Point::new(g.vp_left, g.horizon))];
|
||
if g.kind != 1 {
|
||
v.push((Handle::VpRight, Point::new(g.vp_right, g.horizon)));
|
||
}
|
||
if g.kind == 3 {
|
||
v.push((Handle::VpVertical, Point::new(g.vp_vertical[0], g.vp_vertical[1])));
|
||
}
|
||
v.push((Handle::Origin, o));
|
||
let mut at = |h: Handle, pl: Plane, q: Point| {
|
||
if let Some(p) = g.to_page(pl, q) {
|
||
v.push((h, p));
|
||
}
|
||
};
|
||
at(Handle::ExtentLeft, Plane::Left, Point::new(g.extent, 0.0));
|
||
at(Handle::ExtentRight, Plane::Right, Point::new(g.right_extent(), 0.0));
|
||
at(Handle::Height, Plane::Left, Point::new(0.0, g.height));
|
||
at(Handle::Cell, Plane::Left, Point::new(0.0, g.cell * cell_steps(g, tol)));
|
||
// The ground-level points: a little way from the origin along each wall's ground line.
|
||
for (h, pl) in [(Handle::GroundLeft, Plane::Left), (Handle::GroundRight, Plane::Right)] {
|
||
if let Some(p) = g.to_page(pl, Point::new(g.cell, 0.0)).filter(|p| p.distance(o) > 1e-9) {
|
||
v.push((h, o + (p - o).normalize() * (NEAR_ORIGIN_PX * tol)));
|
||
}
|
||
}
|
||
// The horizon handle sits on the horizon above the origin.
|
||
v.push((Handle::Horizon, Point::new(g.origin[0], g.horizon)));
|
||
v
|
||
}
|
||
|
||
/// A widget drag: the widget, the grid and the pointer when it was pressed, and the cell widget's
|
||
/// steps then.
|
||
struct Press {
|
||
handle: Handle,
|
||
grid: PerspectiveGrid,
|
||
at: Point,
|
||
steps: f64,
|
||
}
|
||
|
||
/// `g` moved by `d`: the whole grid, vanishing points and horizon too.
|
||
fn translated(g: &PerspectiveGrid, d: Vec2) -> PerspectiveGrid {
|
||
let mut n = g.clone();
|
||
n.origin = [g.origin[0] + d.x, g.origin[1] + d.y];
|
||
n.horizon += d.y;
|
||
n.vp_left += d.x;
|
||
n.vp_right += d.x;
|
||
n.vp_vertical = [g.vp_vertical[0] + d.x, g.vp_vertical[1] + d.y];
|
||
n
|
||
}
|
||
|
||
/// The grid with a widget dragged to `p`. Shift constrains: a vanishing point keeps the horizon,
|
||
/// the ground-level points and the origin move along one axis, extents and the height go by whole
|
||
/// cells. Alt drags both extents together.
|
||
fn drag_handle(press: &Press, p: Point, mods: Mods) -> PerspectiveGrid {
|
||
let g = &press.grid;
|
||
let mut n = g.clone();
|
||
let along_axis = |p: Point| {
|
||
let d = p - press.at;
|
||
if !mods.shift {
|
||
d
|
||
} else if d.x.abs() >= d.y.abs() {
|
||
Vec2::new(d.x, 0.0)
|
||
} else {
|
||
Vec2::new(0.0, d.y)
|
||
}
|
||
};
|
||
let cells = |v: f64| if mods.shift { (v / g.cell).round().max(1.0) * g.cell } else { v.max(g.cell) };
|
||
let h = press.handle;
|
||
match h {
|
||
Handle::VpLeft | Handle::VpRight => {
|
||
if !mods.shift {
|
||
n.horizon = p.y;
|
||
}
|
||
match (h == Handle::VpLeft, g.lock_station && g.kind != 1) {
|
||
(left, true) => n.swing(left, p.x),
|
||
(true, false) => n.vp_left = p.x,
|
||
(false, false) => n.vp_right = p.x,
|
||
}
|
||
}
|
||
Handle::VpVertical => n.vp_vertical = [p.x, p.y],
|
||
Handle::Horizon => n.horizon = p.y,
|
||
Handle::Origin => {
|
||
let d = along_axis(p);
|
||
n.origin = [g.origin[0] + d.x, g.origin[1] + d.y];
|
||
}
|
||
Handle::GroundLeft | Handle::GroundRight => n = translated(g, along_axis(p)),
|
||
Handle::ExtentLeft | Handle::ExtentRight => {
|
||
let pl = if h == Handle::ExtentRight { Plane::Right } else { Plane::Left };
|
||
if let Some(q) = g.to_plane(pl, p) {
|
||
let e = cells(q.x);
|
||
if h == Handle::ExtentLeft || mods.alt {
|
||
n.extent = e;
|
||
}
|
||
if h == Handle::ExtentRight || mods.alt {
|
||
n.extent_right = Some(e);
|
||
}
|
||
}
|
||
}
|
||
Handle::Height => {
|
||
if let Some(q) = g.to_plane(Plane::Left, p) {
|
||
n.height = cells(q.y);
|
||
}
|
||
}
|
||
Handle::Cell => {
|
||
if let Some(q) = g.to_plane(Plane::Left, p) {
|
||
n.cell = (q.y / press.steps).max(0.5);
|
||
}
|
||
}
|
||
}
|
||
if n.validate().is_ok() { n } else { g.clone() }
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct PerspectiveGridTool {
|
||
drag: Option<Press>,
|
||
/// Dragging a plane widget.
|
||
plane: Option<PlaneDrag>,
|
||
}
|
||
|
||
impl Tool for PerspectiveGridTool {
|
||
fn id(&self) -> &'static str {
|
||
"perspectiveGrid"
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
let p = ev.pos;
|
||
let g = PerspectiveGrid::current(cx.doc);
|
||
match ev.kind {
|
||
PointerKind::Down => {
|
||
let mut pre = vec![];
|
||
if !g.visible {
|
||
pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true})));
|
||
}
|
||
if let Some(pl) = g.widget_hit_cx(cx, p) {
|
||
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
||
return pre;
|
||
}
|
||
if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||
self.plane = Some(d);
|
||
pre.push(Action::Begin("Move Plane".into()));
|
||
return pre;
|
||
}
|
||
let tol = cx.tol(6.0);
|
||
if !g.locked
|
||
&& let Some((h, _)) = handles(&g, cx.tol(1.0))
|
||
.into_iter()
|
||
.filter(|(_, q)| q.distance(p) <= tol)
|
||
.min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
|
||
{
|
||
let steps = cell_steps(&g, cx.tol(1.0));
|
||
self.drag = Some(Press { handle: h, grid: g, at: p, steps });
|
||
pre.push(Action::Begin("Edit Perspective Grid".into()));
|
||
}
|
||
pre
|
||
}
|
||
PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(),
|
||
PointerKind::Drag => match &self.drag {
|
||
Some(press) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(press, p, ev.mods).definition_json())],
|
||
None => vec![],
|
||
},
|
||
PointerKind::Up => match (self.drag.take(), self.plane.take()) {
|
||
(None, None) => vec![],
|
||
_ => vec![Action::Commit],
|
||
},
|
||
PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let g = PerspectiveGrid::current(cx.doc);
|
||
// The grid's handles hide with it.
|
||
if !g.visible {
|
||
return vec![];
|
||
}
|
||
let ink = [0x20, 0x20, 0x20];
|
||
handles(&g, cx.tol(1.0))
|
||
.into_iter()
|
||
.map(|(h, p)| match h {
|
||
Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: ink, filled: true, size: 7.0 },
|
||
Handle::GroundLeft | Handle::GroundRight => Overlay::Anchor { p, color: ink, filled: true, size: 5.0 },
|
||
Handle::Cell => Overlay::Handle { p, color: ink },
|
||
_ => Overlay::Anchor { p, color: ink, filled: false, size: 6.0 },
|
||
})
|
||
.collect()
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||
let g = PerspectiveGrid::current(cx.doc);
|
||
let tol = cx.tol(6.0);
|
||
if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() {
|
||
return Cursor::Move;
|
||
}
|
||
if g.locked {
|
||
return Cursor::Arrow;
|
||
}
|
||
match handles(&g, cx.tol(1.0)).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
|
||
Some(Handle::Horizon | Handle::Height | Handle::Cell) => Cursor::ResizeV,
|
||
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
|
||
Some(_) => Cursor::Move,
|
||
None => Cursor::Arrow,
|
||
}
|
||
.turned(cx.view_angle())
|
||
}
|
||
fn busy(&self) -> bool {
|
||
self.drag.is_some() || self.plane.is_some()
|
||
}
|
||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||
match (self.drag.take(), self.plane.take()) {
|
||
(None, None) => vec![],
|
||
_ => vec![Action::Commit],
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 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 },
|
||
/// Dragging a plane widget.
|
||
Plane(PlaneDrag),
|
||
}
|
||
|
||
/// 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: 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, g.offset(g.plane))));
|
||
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 {
|
||
fn id(&self) -> &'static str {
|
||
"perspectiveSelection"
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
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 let Some(pl) = g.widget_hit_cx(cx, p) {
|
||
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
|
||
}
|
||
if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||
self.drag = SelDrag::Plane(d);
|
||
return vec![Action::Begin("Move Plane".into())];
|
||
}
|
||
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 = hit.top_object(cx.isolation);
|
||
let mut acts = vec![];
|
||
let ids = if cx.selection.objects.contains(&top) {
|
||
cx.selection.objects.clone()
|
||
} else {
|
||
acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]})));
|
||
vec![top]
|
||
};
|
||
// 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, 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![];
|
||
}
|
||
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::Drag, SelDrag::Plane(d)) => {
|
||
self.drag = SelDrag::Plane(d);
|
||
d.preview(&g, p, m).into_iter().collect()
|
||
}
|
||
(PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => {
|
||
self.measure = None;
|
||
vec![Action::Commit]
|
||
}
|
||
(PointerKind::DoubleClick, d) => {
|
||
self.drag = d;
|
||
if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||
return vec![w.options()];
|
||
}
|
||
// Type in perspective: Edit Text, then type.
|
||
match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) {
|
||
Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => {
|
||
vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())]
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
(_, d) => {
|
||
self.drag = d;
|
||
vec![]
|
||
}
|
||
}
|
||
}
|
||
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 cx.measurement_labels
|
||
&& 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 {
|
||
let g = PerspectiveGrid::current(cx.doc);
|
||
let handle = match &self.drag {
|
||
SelDrag::Scale { handle, .. } => Some(*handle),
|
||
SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move,
|
||
_ => None,
|
||
};
|
||
if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() {
|
||
return Cursor::Move;
|
||
}
|
||
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()))
|
||
{
|
||
// The handle's direction on screen: the view turns it clockwise.
|
||
return resize_cursor((Affine::rotate(cx.view_angle().to_radians()) * (a - c).to_point()).to_vec2());
|
||
}
|
||
Cursor::Arrow
|
||
}
|
||
fn busy(&self) -> bool {
|
||
!matches!(self.drag, SelDrag::Idle)
|
||
}
|
||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||
self.measure = None;
|
||
match std::mem::take(&mut self.drag) {
|
||
SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit],
|
||
_ => vec![],
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::testutil::*;
|
||
use vectorcraft_doc::Selection;
|
||
|
||
fn grid(kind: u8) -> PerspectiveGrid {
|
||
PerspectiveGrid::preset(kind, Rect::new(0.0, 0.0, 800.0, 600.0))
|
||
}
|
||
|
||
#[test]
|
||
fn homography_maps_plane_corners_to_the_vanishing_points() {
|
||
for kind in [1u8, 2, 3] {
|
||
let g = grid(kind);
|
||
for pl in Plane::ALL {
|
||
let h = g.homography(pl).unwrap();
|
||
// The plane origin is the ground-level origin.
|
||
let o = h.apply(Point::new(0.0, 0.0)).unwrap();
|
||
assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() < 1e-9, "{kind} {pl:?}");
|
||
// Far along a receding axis, points approach the vanishing point on the horizon.
|
||
let far = h.apply(Point::new(1e9, 0.0)).unwrap();
|
||
let expect = match (kind, pl) {
|
||
(1, Plane::Left | Plane::Ground) => None,
|
||
(1, Plane::Right) => Some(Point::new(g.vp_left, g.horizon)),
|
||
(_, Plane::Left) => Some(Point::new(g.vp_left, g.horizon)),
|
||
_ => Some(Point::new(g.vp_right, g.horizon)),
|
||
};
|
||
if let Some(e) = expect {
|
||
assert!((far - e).hypot() < 1e-3, "{kind} {pl:?} {far:?}");
|
||
}
|
||
}
|
||
}
|
||
// 2-point walls: verticals stay vertical and unforeshortened at the origin edge.
|
||
let g = grid(2);
|
||
let top = g.to_page(Plane::Left, Point::new(0.0, 100.0)).unwrap();
|
||
assert!((top - Point::new(g.origin[0], g.origin[1] - 100.0)).hypot() < 1e-9);
|
||
// Foreshortening: u = distance lands halfway to the vanishing point.
|
||
let mid = g.to_page(Plane::Right, Point::new(g.distance, 0.0)).unwrap();
|
||
let want = Point::new(g.origin[0], g.origin[1]).lerp(Point::new(g.vp_right, g.horizon), 0.5);
|
||
assert!((mid - want).hypot() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn homography_round_trips() {
|
||
for kind in [1u8, 2, 3] {
|
||
let g = grid(kind);
|
||
for pl in Plane::ALL {
|
||
for q in [Point::new(10.0, 20.0), Point::new(300.0, 5.0), Point::new(0.0, 0.0), Point::new(123.0, 77.0)] {
|
||
let p = g.to_page(pl, q).unwrap();
|
||
let back = g.to_plane(pl, p).unwrap();
|
||
assert!((back - q).hypot() < 1e-6, "{kind} {pl:?} {q:?} → {back:?}");
|
||
}
|
||
}
|
||
}
|
||
assert!(grid(2).homography(Plane::None).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn attach_map_keeps_the_dragged_corners() {
|
||
let g = grid(2);
|
||
let b = Rect::new(450.0, 300.0, 550.0, 420.0);
|
||
let m = g.attach_map(Plane::Right, b).unwrap();
|
||
assert!((m(Point::new(b.x0, b.y0)).unwrap() - Point::new(b.x0, b.y0)).hypot() < 1e-6);
|
||
assert!((m(Point::new(b.x1, b.y1)).unwrap() - Point::new(b.x1, b.y1)).hypot() < 1e-6);
|
||
// The other corners lie on grid lines through the plane: the top-right corner is on the
|
||
// line from the top-left corner to the right vanishing point.
|
||
let tr = m(Point::new(b.x1, b.y0)).unwrap();
|
||
let vp = Point::new(g.vp_right, g.horizon);
|
||
assert!((tr - Point::new(b.x0, b.y0)).cross(vp - Point::new(b.x0, b.y0)).abs() < 1e-6);
|
||
assert!(tr.y != b.y0);
|
||
}
|
||
|
||
#[test]
|
||
fn move_map_slides_within_the_plane() {
|
||
let g = grid(2);
|
||
let from = g.to_page(Plane::Left, Point::new(50.0, 50.0)).unwrap();
|
||
let to = g.to_page(Plane::Left, Point::new(80.0, 60.0)).unwrap();
|
||
let m = g.move_map(Plane::Left, from, to).unwrap();
|
||
let p = g.to_page(Plane::Left, Point::new(10.0, 10.0)).unwrap();
|
||
let q = g.to_plane(Plane::Left, m(p).unwrap()).unwrap();
|
||
assert!((q - Point::new(40.0, 20.0)).hypot() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn grid_serde_and_merge() {
|
||
let g = grid(3);
|
||
let mut d = Document::new(800.0, 600.0);
|
||
g.store(&mut d);
|
||
assert_eq!(PerspectiveGrid::from_doc(&d), Some(g.clone()));
|
||
let m = g.merged(&json!({"kind": 1, "cell": 25})).unwrap();
|
||
assert_eq!((m.kind, m.cell), (1, 25.0));
|
||
assert!(g.merged(&json!({"distance": -1})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn grid_tool_widget_and_handles() {
|
||
let mut d = Document::new(800.0, 600.0);
|
||
grid(2).store(&mut d);
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let g = PerspectiveGrid::effective(&d);
|
||
let mut t = PerspectiveGridTool::default();
|
||
// The horizon handle drags up and down; in a view turned 90° it drags left and right on
|
||
// screen.
|
||
let horizon = handles(&g, c.tol(1.0)).into_iter().find(|h| h.0 == Handle::Horizon).map(|h| h.1).unwrap();
|
||
assert_eq!(t.cursor(&c, horizon, Mods::default()), Cursor::ResizeV);
|
||
let mut turned = cx(&d, &s, &p);
|
||
turned.screen = turned_view(90.0);
|
||
assert_eq!(t.cursor(&turned, horizon, Mods::default()), Cursor::ResizeH);
|
||
// Click the right face of the widget.
|
||
let (faces, _) = g.widget(&d, 1.0);
|
||
let q = faces[1].1;
|
||
let centre = Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0);
|
||
assert_eq!(
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)),
|
||
vec![Action::Exec("perspective.plane.set".into(), json!({"plane": "right"}))]
|
||
);
|
||
// Drag the left vanishing point.
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, g.vp_left, g.horizon));
|
||
assert_eq!(acts, vec![Action::Begin("Edit Perspective Grid".into())]);
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, g.vp_left - 40.0, g.horizon + 10.0));
|
||
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
|
||
assert_eq!(cmd, "perspective.grid.set");
|
||
assert_eq!(v["vpLeft"], json!(g.vp_left - 40.0));
|
||
assert_eq!(v["horizon"], json!(g.horizon + 10.0));
|
||
assert!(!grid_overlays(&d, 1.0).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().extras().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));
|
||
// In a view turned 90° its cursor turns with the view.
|
||
let mut turned = cx(&d, &s, &p);
|
||
turned.screen = turned_view(90.0);
|
||
assert_eq!(t.cursor(&turned, corner, Mods::default()), t.cursor(&c, corner, Mods::default()).turned(90.0));
|
||
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);
|
||
}
|
||
}
|