mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Merge pull request #7663 from dotCooCoo/feat/perspective-auto-plane-positioning
Perspective: Perspective Grid Options has Automatic Plane Positioning
This commit is contained in:
@@ -70,13 +70,26 @@ pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(switch_or(p, "visible", false, C)?),
|
||||
};
|
||||
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
|
||||
// A hidden grid holds no plane for Automatic Plane Positioning.
|
||||
let g = silent(s, |g| {
|
||||
g.visible = want.unwrap_or(!g.visible);
|
||||
if !g.visible {
|
||||
g.inferred = None;
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "visible": g.visible }))
|
||||
}
|
||||
|
||||
pub(super) fn plane_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?;
|
||||
silent(s, |g| g.plane = plane)?;
|
||||
// The new active plane passes through the point Automatic Plane Positioning holds, unless no
|
||||
// plane is active or that would put the plane behind the viewer.
|
||||
silent(s, |g| {
|
||||
g.plane = plane;
|
||||
if g.inferred.is_some() && (plane == Plane::None || g.validate().is_err()) {
|
||||
g.inferred = None;
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "plane": plane.id() }))
|
||||
}
|
||||
|
||||
@@ -138,6 +151,9 @@ pub(super) fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
if !ids.is_empty() {
|
||||
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
|
||||
}
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "plane": plane.id() }))
|
||||
}
|
||||
|
||||
@@ -205,8 +221,13 @@ fn persp_edit(
|
||||
return Err(EngineError::Other("select the objects to transform in perspective".into()));
|
||||
}
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
// An object not in perspective yet attaches to `fallback` where that plane is now.
|
||||
let attaches = {
|
||||
let d = &s.doc()?.doc;
|
||||
fallback.filter(|_| ids.iter().any(|id| g.attachment_of(d, *id).is_none()))
|
||||
};
|
||||
let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None };
|
||||
s.edit(label, |d, sel| {
|
||||
let edited = s.edit(label, |d, sel| {
|
||||
g.adopt_stored_attachments(d);
|
||||
let targets = match &kept {
|
||||
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
|
||||
@@ -236,7 +257,11 @@ fn persp_edit(
|
||||
}
|
||||
store_grid(d, &g);
|
||||
Ok((targets, first))
|
||||
})
|
||||
})?;
|
||||
if let Some(plane) = attaches {
|
||||
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
|
||||
}
|
||||
Ok(edited)
|
||||
}
|
||||
|
||||
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
|
||||
@@ -430,6 +455,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["offset", "by"], C)?;
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&old, p, C)?;
|
||||
// A plane that Automatic Plane Positioning holds moves from where it is held, and the move
|
||||
// places it there for good: the moved grid holds no plane (undoing the move holds it again).
|
||||
let held = old.inferred;
|
||||
let from = old.offset(plane);
|
||||
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
|
||||
(Some(o), _) => o,
|
||||
@@ -440,7 +468,7 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(objects, "none" | "move" | "copy") {
|
||||
return Err(bad(C, "objects must be none, move or copy"));
|
||||
}
|
||||
let mut g = old.clone();
|
||||
let mut g = PerspectiveGrid { inferred: None, ..old.clone() };
|
||||
g.set_offset(plane, to);
|
||||
g.validate().map_err(|e| bad(C, e))?;
|
||||
let label = match objects {
|
||||
@@ -458,8 +486,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect()
|
||||
};
|
||||
// A plane moved to where it is, with its objects or without them, moves nothing: when the
|
||||
// document holds the grid already, the document and its undo history stay as they are.
|
||||
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
|
||||
// document holds the grid already, the document and its undo history stay as they are (a
|
||||
// plane held where it was placed stays held).
|
||||
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) {
|
||||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
@@ -507,12 +536,19 @@ pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
|
||||
let mut g = grid_of(&st.doc);
|
||||
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
|
||||
// The moved grid holds no plane for Automatic Plane Positioning (undoing the move holds it
|
||||
// again).
|
||||
let held = g.inferred.take();
|
||||
g.set_offset(plane, depth);
|
||||
g.plane = plane;
|
||||
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
|
||||
// When the plane is at the object and active already, and the document holds that grid, the
|
||||
// document and its undo history stay as they are.
|
||||
if grid_kept(&st.doc, &g) {
|
||||
// document and its undo history stay as they are; a plane held elsewhere goes back to that
|
||||
// place as view state.
|
||||
if grid_kept(&st.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) {
|
||||
if held.is_some() {
|
||||
silent(s, |g| g.inferred = None)?;
|
||||
}
|
||||
return Ok(json!({ "plane": plane.id(), "offset": depth }));
|
||||
}
|
||||
s.edit("Move Plane to Match Object", |d, _| {
|
||||
@@ -573,6 +609,7 @@ pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.history.recost_last(&st.doc);
|
||||
}
|
||||
res?;
|
||||
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
|
||||
Ok(json!({ "id": id.0, "plane": plane.id() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
|
||||
//!
|
||||
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
|
||||
//! the grid: they toggle without an undo step, as Show Grid does.
|
||||
//! the grid: they toggle without an undo step, as Show Grid does. So is the point Automatic Plane
|
||||
//! Positioning holds the active plane through (`perspective.plane.infer`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -15,12 +16,15 @@ use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_tools::distort::perspective::Plane;
|
||||
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
|
||||
use vectorcraft_tools::distort::perspective::infer::{HIT_PX, InferSource};
|
||||
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
|
||||
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
|
||||
use vectorcraft_tools::distort::perspective::{GridDefinition, MAX_DEPTH, PerspectiveGrid};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::distortcmds::{grid_kept, grid_of, silent, store_grid};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, number_or, str_param, switch_or, unique_name,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -92,10 +96,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Perspective Grid Options",
|
||||
[],
|
||||
None,
|
||||
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}",
|
||||
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight, anchorPoint?: bool, gridlineIntersection?: bool} the Plane Switching Widget: shown or not, and the corner of the document window it stays in; Automatic Plane Positioning: whether it takes a point from an anchor point of art in perspective and from a gridline intersection (both on by default; see perspective.plane.infer). Kept with the preferences; no params reads them → {show, position, anchorPoint, gridlineIntersection}",
|
||||
always,
|
||||
widget_options
|
||||
),
|
||||
cmd!(
|
||||
"perspective.plane.infer",
|
||||
"Automatic Plane Positioning",
|
||||
[],
|
||||
None,
|
||||
"{at?: [x, y], tolerance?: pt around `at` (default 6), clear?: bool} with `at` on an anchor point of shown art in perspective, or on a gridline intersection of a plane that shows, the active plane moves through that point for now and the other planes hide (the Perspective Selection tool: Shift held over one with no button down). An anchor point within reach wins over an intersection, and perspective.widget.options turns either off. Making another plane active (perspective.plane.set) brings that plane through the same point. The plane goes back to its place when an object is drawn on it or attached to it (perspective.draw, perspective.attach, an object brought into perspective by perspective.move or perspective.transform), when a plane is moved (perspective.plane.move, perspective.plane.matchObject), when the grid hides or no plane is active, when the file is opened again, and with `clear` (Escape with the Perspective Selection tool). View state: not an undo step; no params reads it → {active, plane, offset: where the active plane is, point?: [x, y, z] in the grid's frame, source?: anchor|gridline (what `at` found; absent when nothing was within reach)}",
|
||||
has_doc,
|
||||
plane_infer
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.lock",
|
||||
"Lock Grid",
|
||||
@@ -259,21 +272,32 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
|
||||
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
|
||||
// The objects stay attached and the view options stay as they were.
|
||||
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
|
||||
// The objects stay attached and the view options stay as they were, and so does a plane held
|
||||
// by Automatic Plane Positioning while the new grid can hold it.
|
||||
let mut g = PerspectiveGrid {
|
||||
attached: old.attached,
|
||||
locked: old.locked,
|
||||
lock_station: old.lock_station,
|
||||
snap: old.snap,
|
||||
rulers: old.rulers,
|
||||
inferred: old.inferred,
|
||||
..g
|
||||
};
|
||||
g.keep_positioning_valid();
|
||||
// When the document holds this grid already, apart from whether it shows and its active plane,
|
||||
// the preset only sets those two, without an undo step (as perspective.grid.show and
|
||||
// perspective.plane.set do).
|
||||
let view = PerspectiveGrid { visible: old.visible, plane: old.plane, ..g.clone() };
|
||||
let view = PerspectiveGrid { visible: old.visible, plane: old.plane, inferred: old.inferred, ..g.clone() };
|
||||
if !grid_kept(&s.doc()?.doc, &view) {
|
||||
s.edit("Perspective Grid Preset", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
} else if (old.visible, old.plane) != (g.visible, g.plane) {
|
||||
} else if (old.visible, old.plane, old.inferred) != (g.visible, g.plane, g.inferred) {
|
||||
silent(s, |cur| {
|
||||
cur.visible = g.visible;
|
||||
cur.plane = g.plane;
|
||||
cur.inferred = g.inferred;
|
||||
})?;
|
||||
}
|
||||
Ok(g.definition_json())
|
||||
@@ -460,10 +484,100 @@ fn widget_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.position =
|
||||
v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?;
|
||||
}
|
||||
if let Some(v) = p.get("anchorPoint") {
|
||||
o.anchor_point = v.as_bool().ok_or_else(|| bad(C, "anchorPoint must be true or false"))?;
|
||||
}
|
||||
if let Some(v) = p.get("gridlineIntersection") {
|
||||
o.gridline_intersection = v.as_bool().ok_or_else(|| bad(C, "gridlineIntersection must be true or false"))?;
|
||||
}
|
||||
s.prefs.perspective_widget = o;
|
||||
Ok(json!(o))
|
||||
}
|
||||
|
||||
/// What `perspective.plane.infer` reports for `g`.
|
||||
fn infer_info(g: &PerspectiveGrid, source: Option<InferSource>) -> Value {
|
||||
let active = g.inferred.is_some() && g.plane != Plane::None;
|
||||
let mut v = json!({ "active": active, "plane": g.plane.id(), "offset": g.offset(g.plane) });
|
||||
if let Some(at) = g.inferred.filter(|_| active) {
|
||||
v["point"] = json!(at);
|
||||
}
|
||||
if let Some(source) = source {
|
||||
v["source"] = json!(source.id());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn plane_infer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.plane.infer";
|
||||
let clear = switch_or(p, "clear", false, C)?;
|
||||
let at = match p.get("at") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?),
|
||||
};
|
||||
let tol = number_or(p, "tolerance", HIT_PX, C)?;
|
||||
if !(tol.is_finite() && (0.0..=MAX_DEPTH).contains(&tol)) {
|
||||
return Err(bad(C, "tolerance must be 0 or more"));
|
||||
}
|
||||
if clear && at.is_some() {
|
||||
return Err(bad(C, "give `at` or `clear`, not both"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let g = PerspectiveGrid::current(doc);
|
||||
let mut source = None;
|
||||
let next = match at {
|
||||
_ if clear => None,
|
||||
None => g.inferred,
|
||||
Some(at) => {
|
||||
if !g.visible {
|
||||
return Err(EngineError::Other("the perspective grid is hidden".into()));
|
||||
}
|
||||
if g.plane == Plane::None {
|
||||
return Err(EngineError::Other("no active perspective plane".into()));
|
||||
}
|
||||
let targets = s.prefs.perspective_widget.targets();
|
||||
if !targets.any() {
|
||||
return Err(EngineError::Other(
|
||||
"Automatic Plane Positioning is off: turn on anchorPoint or gridlineIntersection (perspective.widget.options)".into(),
|
||||
));
|
||||
}
|
||||
match g.infer_at(doc, at, tol, targets) {
|
||||
Some((found, point)) => {
|
||||
source = Some(found);
|
||||
Some(point)
|
||||
}
|
||||
None => g.inferred,
|
||||
}
|
||||
}
|
||||
};
|
||||
if next == g.inferred {
|
||||
return Ok(infer_info(&g, source));
|
||||
}
|
||||
// A plane through the point stays in front of the viewer.
|
||||
if next.is_some() {
|
||||
PerspectiveGrid { inferred: next, ..g }.validate().map_err(|e| bad(C, e))?;
|
||||
}
|
||||
let g = silent(s, |g| g.inferred = next)?;
|
||||
Ok(infer_info(&g, source))
|
||||
}
|
||||
|
||||
/// An object was drawn on, or attached to, `plane`: when Automatic Plane Positioning holds that
|
||||
/// plane, it goes back to its place as view state, not an undo step (a plane it doesn't hold
|
||||
/// leaves it as it is). In a drag that happens once the drag is committed
|
||||
/// ([`Session::commit_interaction`]), so the plane stays put while the object is drawn.
|
||||
pub(crate) fn plane_positioning_used(s: &mut Session, plane: Plane) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
let g = grid_of(&st.doc);
|
||||
if g.inferred.is_none() || g.plane != plane {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
it.plane_placed = true;
|
||||
return Ok(());
|
||||
}
|
||||
silent(s, |g| g.inferred = None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its
|
||||
/// drag and release go nowhere); None when the event isn't the widget's.
|
||||
|
||||
@@ -474,6 +474,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"perspective.grid.snap",
|
||||
"perspective.move",
|
||||
"perspective.nudge",
|
||||
"perspective.plane.infer",
|
||||
"perspective.plane.matchObject",
|
||||
"perspective.plane.move",
|
||||
"perspective.plane.set",
|
||||
|
||||
@@ -24,6 +24,9 @@ pub struct Interaction {
|
||||
/// The perspective transform the previews make (`perspective.transform` params): Transform
|
||||
/// Again repeats it once the drag is committed.
|
||||
pub perspective_again: Option<Value>,
|
||||
/// A preview drew an object on, or attached one to, a plane held by Automatic Plane
|
||||
/// Positioning: the plane goes back to its place once the drag is committed.
|
||||
pub(crate) plane_placed: bool,
|
||||
/// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here
|
||||
/// ([`PreviewCopies`]).
|
||||
pub(crate) copies: Option<Arc<PreviewCopies>>,
|
||||
|
||||
@@ -329,6 +329,8 @@ impl Session {
|
||||
// Ruler guides a reader left in the saved list become nodes on their layers.
|
||||
doc.guides_into_nodes();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
// A plane held by Automatic Plane Positioning when the file was saved is back in its place.
|
||||
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc);
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
@@ -357,6 +359,7 @@ impl Session {
|
||||
doc.resolve_inline_art();
|
||||
doc.guides_into_nodes();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc);
|
||||
// In the back, type set in a font named like a composite font is measured again below, in
|
||||
// the document's own composite fonts, not in the front document's.
|
||||
let back = self.active != Some(index);
|
||||
|
||||
@@ -289,6 +289,7 @@ impl Session {
|
||||
variables_highlight: st.variables_highlight.clone(),
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
plane_placed: false,
|
||||
copies: None,
|
||||
move_box: std::sync::OnceLock::new(),
|
||||
});
|
||||
@@ -319,6 +320,7 @@ impl Session {
|
||||
it.preview_paint = None;
|
||||
it.color_mode_paints = None;
|
||||
it.deferred_color_mode = None;
|
||||
it.plane_placed = false;
|
||||
if selection_changed {
|
||||
st.selection.clone_from(&it.selection);
|
||||
}
|
||||
@@ -378,6 +380,7 @@ impl Session {
|
||||
after.clone_from(&paint);
|
||||
}
|
||||
let perspective_again = it.perspective_again.take();
|
||||
let plane_placed = it.plane_placed;
|
||||
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Alt pressed or released during a move drag turns it into a copy or back: name it for
|
||||
// what it did in the end.
|
||||
@@ -395,6 +398,12 @@ impl Session {
|
||||
preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0),
|
||||
);
|
||||
st.keep_interaction(it);
|
||||
// The drag drew an object on, or attached one to, the plane Automatic Plane Positioning
|
||||
// held: the plane is back in its place now, as view state (undoing the drag holds it again).
|
||||
if plane_placed && changed && vectorcraft_tools::distort::perspective::PerspectiveGrid::positioning(&st.doc) {
|
||||
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(Arc::make_mut(&mut st.doc));
|
||||
st.revision += 1;
|
||||
}
|
||||
if preview.0 == "object.transform" && changed {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
if let Some(m) = m {
|
||||
|
||||
@@ -128,6 +128,7 @@ mod pdffidelity;
|
||||
mod pdfmarks;
|
||||
mod pdfpresets;
|
||||
mod pdfraster;
|
||||
mod persp_infer;
|
||||
mod persp_planes;
|
||||
pub(super) mod persp_select;
|
||||
mod persp_text;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Automatic Plane Positioning: Shift held over an anchor point of art in perspective or over a
|
||||
//! gridline intersection moves the active plane through it for now (`perspective.plane.infer`),
|
||||
//! the two Perspective Grid Options that turn the targets on and off, and what ends it.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::tests_persp_select::{attached, bounds_of, drag, grid, persp_session, plane_pts, undo_len};
|
||||
use crate::*;
|
||||
|
||||
fn shift() -> Mods {
|
||||
Mods { shift: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The top anchor of path `id` (on the right wall at depth 0): its page point and its height.
|
||||
fn top_anchor(s: &Session, id: NodeId) -> (Point, f64) {
|
||||
let page: Vec<Point> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
|
||||
let plane = plane_pts(s, id, Plane::Right, 0.0);
|
||||
page.into_iter().zip(plane).map(|(p, q)| (p, q.y)).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap()
|
||||
}
|
||||
|
||||
fn hover(s: &mut Session, p: Point, mods: Mods) {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, p.x, p.y).with_mods(mods), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
fn revision(s: &Session) -> u64 {
|
||||
s.doc().unwrap().revision
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_over_an_anchor_holds_the_active_plane_at_its_height_until_an_object_is_drawn() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
let (anchor, height) = top_anchor(&s, id);
|
||||
assert!(height > 10.0, "{height}");
|
||||
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let steps = undo_len(&s);
|
||||
// Without Shift the pointer only hovers.
|
||||
hover(&mut s, anchor, Mods::default());
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
hover(&mut s, anchor, shift());
|
||||
let g = grid(&s);
|
||||
assert!(g.inferred.is_some());
|
||||
assert!((g.offset(Plane::Ground) - height).abs() < 1e-6, "{} {height}", g.offset(Plane::Ground));
|
||||
assert_eq!((g.ground_offset, g.shown_planes(), undo_len(&s)), (0.0, vec![Plane::Ground], steps));
|
||||
// Held there already, another hover over the anchor changes nothing.
|
||||
let rev = revision(&s);
|
||||
hover(&mut s, anchor, shift());
|
||||
assert_eq!(revision(&s), rev);
|
||||
// The Rectangle tool draws on the plane where it is held, and the plane stays put for the drag.
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 400.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 470.0, 430.0), v).unwrap();
|
||||
assert!(grid(&s).inferred.is_some(), "held while the object is drawn");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 470.0, 430.0), v).unwrap();
|
||||
let drawn = s.doc().unwrap().selection.objects[0];
|
||||
assert_ne!(drawn, id);
|
||||
let (plane, depth) = attached(&s, drawn).unwrap();
|
||||
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
|
||||
// Drawn, the plane is back in its place and the other planes show: one undo step for the drag.
|
||||
let g = grid(&s);
|
||||
assert_eq!((g.inferred, g.offset(Plane::Ground), g.shown_planes().len(), undo_len(&s)), (None, 0.0, 3, steps + 1));
|
||||
// Undoing the drag holds the plane again; Escape with the Perspective Selection tool ends that.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_during_a_drag_still_constrains_the_move() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
// From the middle of the object, along the wall and a little up.
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
drag(&mut s, &[from, to, to], shift());
|
||||
assert_eq!(grid(&s).inferred, None, "with a button down Shift constrains the move");
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "{a:?} → {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gridline_intersection_brings_each_plane_made_active_through_it() {
|
||||
let (mut s, _) = persp_session();
|
||||
let (g, steps) = (grid(&s), undo_len(&s));
|
||||
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
|
||||
let at = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
|
||||
let r = s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y], "tolerance": 1})).unwrap();
|
||||
assert_eq!((r["active"].as_bool(), r["source"].as_str(), r["plane"].as_str()), (Some(true), Some("gridline"), Some("left")));
|
||||
for (got, want) in r["point"].as_array().unwrap().iter().zip([0.0, 2.0 * g.cell, 3.0 * g.cell]) {
|
||||
assert!((got.as_f64().unwrap() - want).abs() < 1e-6, "{r}");
|
||||
}
|
||||
for (plane, want) in [("ground", 2.0 * g.cell), ("right", 3.0 * g.cell), ("left", 0.0)] {
|
||||
s.execute("perspective.plane.set", &json!({"plane": plane})).unwrap();
|
||||
let r = s.execute("perspective.plane.infer", &json!({})).unwrap();
|
||||
assert!((r["offset"].as_f64().unwrap() - want).abs() < 1e-6 && r["active"] == json!(true) && r.get("source").is_none(), "{plane}: {r}");
|
||||
}
|
||||
let held = grid(&s);
|
||||
assert_eq!((held.left_offset, held.right_offset, held.ground_offset, undo_len(&s)), (0.0, 0.0, 0.0, steps));
|
||||
// Nothing within reach leaves the plane where it is held.
|
||||
let rev = revision(&s);
|
||||
let mid = held.to_page(Plane::Left, Point::new(3.5 * g.cell, 2.5 * g.cell)).unwrap();
|
||||
let r = s.execute("perspective.plane.infer", &json!({"at": [mid.x, mid.y], "tolerance": 0.5})).unwrap();
|
||||
assert!(r["active"] == json!(true) && r.get("source").is_none() && revision(&s) == rev, "{r}");
|
||||
// With no active plane nothing is held.
|
||||
s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap();
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
assert!(s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y]})).is_err(), "no active plane");
|
||||
assert_eq!(s.execute("perspective.plane.infer", &json!({})).unwrap()["active"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_options_turn_the_targets_on_and_off_and_are_kept() {
|
||||
let (mut s, id) = persp_session();
|
||||
assert!(s.prefs.perspective_widget.anchor_point && s.prefs.perspective_widget.gridline_intersection, "both on by default");
|
||||
let (anchor, _) = top_anchor(&s, id);
|
||||
let infer = |s: &mut Session| s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01}));
|
||||
assert_eq!(infer(&mut s).unwrap()["source"], json!("anchor"));
|
||||
s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap();
|
||||
let r = s.execute("perspective.widget.options", &json!({"anchorPoint": false})).unwrap();
|
||||
assert_eq!((r["anchorPoint"].as_bool(), r["gridlineIntersection"].as_bool(), r["show"].as_bool()), (Some(false), Some(true), Some(true)));
|
||||
assert!(infer(&mut s).unwrap().get("source").is_none(), "the anchor is no target now");
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(!back.perspective_widget.anchor_point && back.perspective_widget.gridline_intersection);
|
||||
s.execute("perspective.widget.options", &json!({"gridlineIntersection": false})).unwrap();
|
||||
assert!(infer(&mut s).is_err(), "both off");
|
||||
for bad in [json!({"anchorPoint": "yes"}), json!({"gridlineIntersection": 1})] {
|
||||
assert!(s.execute("perspective.widget.options", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(!s.prefs.perspective_widget.anchor_point, "a refused value changes nothing");
|
||||
s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"anchorPoint": true}})).unwrap();
|
||||
assert!(s.prefs.perspective_widget.anchor_point && !s.prefs.perspective_widget.gridline_intersection);
|
||||
// Preferences saved before the options existed read as both on.
|
||||
let old: vectorcraft_tools::distort::perspective::widget::WidgetOptions = serde_json::from_value(json!({"show": false})).unwrap();
|
||||
assert!(old.anchor_point && old.gridline_intersection && !old.show);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_leave_the_grid_as_it_is() {
|
||||
let (mut s, id) = persp_session();
|
||||
let (anchor, _) = top_anchor(&s, id);
|
||||
let rev = revision(&s);
|
||||
for bad in [
|
||||
json!({"at": "here"}),
|
||||
json!({"at": [1]}),
|
||||
json!({"at": [anchor.x, anchor.y], "tolerance": -1}),
|
||||
json!({"at": [anchor.x, anchor.y], "tolerance": "wide"}),
|
||||
json!({"at": [anchor.x, anchor.y], "clear": true}),
|
||||
json!({"clear": "yes"}),
|
||||
] {
|
||||
assert!(s.execute("perspective.plane.infer", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((grid(&s).inferred, revision(&s)), (None, rev));
|
||||
// Clearing when nothing is held changes nothing.
|
||||
assert_eq!(s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap()["active"], json!(false));
|
||||
assert_eq!(revision(&s), rev);
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
assert!(s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y]})).is_err(), "hidden grid");
|
||||
}
|
||||
|
||||
/// A session whose ground plane is held at the height of the attached rectangle's top anchor.
|
||||
fn held_session() -> (Session, NodeId, f64) {
|
||||
let (mut s, id) = persp_session();
|
||||
let (anchor, height) = top_anchor(&s, id);
|
||||
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
|
||||
let r = s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01})).unwrap();
|
||||
assert_eq!(r["source"], json!("anchor"));
|
||||
(s, id, height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_an_object_ends_it_and_the_object_sits_where_the_plane_was_held() {
|
||||
let (mut s, _, height) = held_session();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
|
||||
let flat = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("perspective.attach", &json!({"ids": [flat.0]})).unwrap();
|
||||
let (plane, depth) = attached(&s, flat).unwrap();
|
||||
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
|
||||
assert_eq!((grid(&s).inferred, grid(&s).ground_offset), (None, 0.0));
|
||||
// An attach of nothing, or to a plane it doesn't hold, leaves it held.
|
||||
let (mut s, ..) = held_session();
|
||||
s.execute("perspective.attach", &json!({"ids": []})).unwrap();
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
|
||||
let other = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("perspective.attach", &json!({"ids": [other.0], "plane": "right"})).unwrap();
|
||||
assert_eq!(attached(&s, other).map(|a| a.0), Some(Plane::Right));
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
// A move of an object already in perspective leaves it held; one brought into perspective ends it.
|
||||
let (mut s, id, height) = held_session();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [460, 400], "to": [470, 400]})).unwrap();
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
|
||||
let flat = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("perspective.move", &json!({"ids": [flat.0], "from": [430, 410], "to": [440, 410]})).unwrap();
|
||||
let (plane, depth) = attached(&s, flat).unwrap();
|
||||
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_plane_hiding_the_grid_and_reopening_the_file_end_it() {
|
||||
// A moved plane moves from where it is held and is placed there for good, in one undo step;
|
||||
// undoing the move holds it again.
|
||||
let (mut s, _, height) = held_session();
|
||||
let steps = undo_len(&s);
|
||||
s.execute("perspective.plane.move", &json!({"plane": "ground", "by": 10})).unwrap();
|
||||
let g = grid(&s);
|
||||
assert_eq!((g.inferred, undo_len(&s)), (None, steps + 1));
|
||||
assert!((g.ground_offset - (height + 10.0)).abs() < 1e-6, "{}", g.ground_offset);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((grid(&s).inferred.is_some(), grid(&s).ground_offset), (true, 0.0));
|
||||
// A refused move leaves it held.
|
||||
let (mut s, ..) = held_session();
|
||||
assert!(s.execute("perspective.plane.move", &json!({"plane": "ground", "offset": 1, "objects": "all"})).is_err());
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
// A plane dragged while it is held, then the drag canceled, is held again.
|
||||
let (mut s, ..) = held_session();
|
||||
s.begin_interaction("Move Plane").unwrap();
|
||||
s.preview("perspective.plane.move", &json!({"plane": "ground", "by": 20})).unwrap();
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
s.cancel_interaction().unwrap();
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
// Move Plane to Match Object.
|
||||
let (mut s, id, _) = held_session();
|
||||
s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!((grid(&s).inferred, grid(&s).plane), (None, Plane::Right));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((grid(&s).inferred.is_some(), grid(&s).plane), (true, Plane::Ground), "undo holds it again");
|
||||
// With the plane placed at the object already, matching only puts the held plane back there.
|
||||
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
|
||||
assert!(grid(&s).inferred.is_some());
|
||||
let steps = undo_len(&s);
|
||||
s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!((grid(&s).inferred, grid(&s).offset(Plane::Right), undo_len(&s)), (None, 0.0, steps));
|
||||
// Hide Grid.
|
||||
let (mut s, ..) = held_session();
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
// The grid's definition changing leaves it held, and it is not `perspective.grid.set`'s to set.
|
||||
let (mut s, ..) = held_session();
|
||||
let held = grid(&s).inferred;
|
||||
s.execute("perspective.grid.set", &json!({"cell": 31, "inferred": [1, 2, 3]})).unwrap();
|
||||
assert_eq!(grid(&s).inferred, held);
|
||||
// `perspective.grid.set` hiding the grid or choosing no plane ends it.
|
||||
for patch in [json!({"visible": false}), json!({"plane": "none"})] {
|
||||
let (mut s, ..) = held_session();
|
||||
s.execute("perspective.grid.set", &patch).unwrap();
|
||||
assert_eq!(grid(&s).inferred, None, "{patch}");
|
||||
}
|
||||
// Another preset leaves it held, as other changes to the grid's definition do.
|
||||
let (mut preset, ..) = held_session();
|
||||
let before = grid(&preset).inferred;
|
||||
preset.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap();
|
||||
assert_eq!((grid(&preset).kind, grid(&preset).inferred), (3, before));
|
||||
// A file saved while a plane is held opens with the plane in its place.
|
||||
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
let back = vectorcraft_format::load(&saved).unwrap();
|
||||
assert!(PerspectiveGrid::positioning(&back), "the saved file still holds the point");
|
||||
s.add_document(back, None);
|
||||
assert_eq!(grid(&s).inferred, None);
|
||||
assert_eq!(grid(&s).cell, 31.0);
|
||||
}
|
||||
@@ -520,7 +520,10 @@ fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
|
||||
click(&mut s, q, view);
|
||||
assert_eq!(grid(&s).plane, Plane::Ground);
|
||||
// Perspective Grid Options move it to another corner or hide it.
|
||||
assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"}));
|
||||
assert_eq!(
|
||||
run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})),
|
||||
json!({"show": true, "position": "bottomRight", "anchorPoint": true, "gridlineIntersection": true})
|
||||
);
|
||||
let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left);
|
||||
assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}");
|
||||
click(&mut s, r, view);
|
||||
|
||||
@@ -429,6 +429,7 @@ impl Session {
|
||||
},
|
||||
screen: view.screen,
|
||||
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
|
||||
plane_targets: self.prefs.perspective_widget.targets(),
|
||||
precise_cursors: self.precise_cursors(),
|
||||
global_edit: st.global_edit.as_ref().map_or(&[], |g| g.members.as_slice()),
|
||||
};
|
||||
@@ -1543,7 +1544,7 @@ impl Session {
|
||||
if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) {
|
||||
return None;
|
||||
}
|
||||
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone());
|
||||
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone(), it.plane_placed);
|
||||
(st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields)
|
||||
};
|
||||
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
|
||||
@@ -1565,6 +1566,7 @@ impl Session {
|
||||
it.preview = interaction_fields.0;
|
||||
it.copies = interaction_fields.1;
|
||||
it.perspective_again = interaction_fields.2;
|
||||
it.plane_placed = interaction_fields.3;
|
||||
}
|
||||
}
|
||||
self.pending_paint = paint;
|
||||
|
||||
@@ -329,6 +329,8 @@ pub struct ToolContext<'a> {
|
||||
pub screen: Option<ScreenFrame>,
|
||||
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
|
||||
pub plane_widget: Option<distort::perspective::widget::WidgetCorner>,
|
||||
/// What Automatic Plane Positioning takes a point from (Perspective Grid Options).
|
||||
pub plane_targets: distort::perspective::widget::PlaneTargets,
|
||||
/// General › Use Precise Cursors, or Caps Lock while it is on: the drawing tools' pointers are
|
||||
/// crosshairs ([`crate::Cursor::precise`]), and the Blob Brush and the Eraser show no tip outline.
|
||||
pub precise_cursors: bool,
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 infer;
|
||||
pub mod view;
|
||||
pub mod widget;
|
||||
pub use define::{GridDefinition, Rgb, Station};
|
||||
@@ -183,6 +184,11 @@ pub struct PerspectiveGrid {
|
||||
/// 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>,
|
||||
/// Automatic Plane Positioning: the point (x, y and z in the grid's frame, points, as
|
||||
/// [`Axis`] names them) the active plane passes through for now, while the other planes hide
|
||||
/// ([`infer`]). View state.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inferred: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl PerspectiveGrid {
|
||||
@@ -224,11 +230,18 @@ impl PerspectiveGrid {
|
||||
snap: true,
|
||||
rulers: false,
|
||||
extent_right: None,
|
||||
inferred: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `plane` is along its normal (points; 0: its place in the grid's definition).
|
||||
/// Where `plane` is along its normal (points; 0: its place in the grid's definition): for the
|
||||
/// active plane held by Automatic Plane Positioning, where that holds it.
|
||||
pub fn offset(&self, plane: Plane) -> f64 {
|
||||
self.inferred_offset(plane).unwrap_or_else(|| self.placed_offset(plane))
|
||||
}
|
||||
|
||||
/// Where `plane` was moved along its normal, Automatic Plane Positioning aside.
|
||||
pub fn placed_offset(&self, plane: Plane) -> f64 {
|
||||
match plane {
|
||||
Plane::Left => self.left_offset,
|
||||
Plane::Right => self.right_offset,
|
||||
@@ -325,7 +338,8 @@ impl PerspectiveGrid {
|
||||
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" {
|
||||
// Automatic Plane Positioning's point is `perspective.plane.infer`'s to set.
|
||||
if k == "ids" || k == "attached" || k == "inferred" {
|
||||
continue;
|
||||
}
|
||||
o.insert(k.clone(), val.clone());
|
||||
@@ -333,6 +347,7 @@ impl PerspectiveGrid {
|
||||
}
|
||||
let mut g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||||
g.reconcile(self, patch);
|
||||
g.keep_positioning_valid();
|
||||
g.validate()?;
|
||||
Ok(g)
|
||||
}
|
||||
@@ -354,7 +369,7 @@ impl PerspectiveGrid {
|
||||
self.extent,
|
||||
self.height,
|
||||
];
|
||||
if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) {
|
||||
if nums.iter().chain(self.inferred.iter().flatten()).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 {
|
||||
@@ -563,10 +578,31 @@ impl PerspectiveGrid {
|
||||
let Some(h) = self.homography(plane) else { return vec![] };
|
||||
let d = self.domain(plane);
|
||||
let mut out = vec![];
|
||||
let (w, h_len) = (d.width(), d.height());
|
||||
let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { return out };
|
||||
for i in 0..=nu {
|
||||
let u = i as f64 * su;
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, h_len))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
for i in 0..=nv {
|
||||
let v = i as f64 * sv;
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(w, v))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How the gridlines of `plane` are spaced as drawn: (step, the number of the last line) along
|
||||
/// its u and its v axis. `None` for a grid without lines.
|
||||
pub(crate) fn line_steps(&self, plane: Plane) -> Option<((f64, usize), (f64, usize))> {
|
||||
let d = self.domain(plane);
|
||||
// A stored grid is not checked: no spacing, or an extent that isn't a number, has no lines.
|
||||
let (w, h_len) = (d.width(), d.height());
|
||||
if !(self.cell.is_finite() && self.cell > 0.0 && w.is_finite() && h_len.is_finite()) {
|
||||
return out;
|
||||
return None;
|
||||
}
|
||||
let step = |len: f64| {
|
||||
let mut s = self.cell;
|
||||
@@ -580,19 +616,7 @@ impl PerspectiveGrid {
|
||||
// tolerance can inflate the count when the whole domain is far smaller than it, so the
|
||||
// bound is capped on its own, before any loop runs.
|
||||
let count = |len: f64, s: f64| ((len + 1e-9) / s).floor().clamp(0.0, 120.0) as usize;
|
||||
for i in 0..=count(w, su) {
|
||||
let u = i as f64 * su;
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, h_len))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
for i in 0..=count(h_len, sv) {
|
||||
let v = i as f64 * sv;
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(w, v))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(((su, count(w, su)), (sv, count(h_len, sv))))
|
||||
}
|
||||
|
||||
/// Plane Switching widget geometry in the first artboard's top-left corner (headless): (plane,
|
||||
@@ -611,10 +635,10 @@ impl PerspectiveGrid {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// wall's ground edge, the middle of the horizontal plane). A hidden plane has none.
|
||||
pub fn plane_widgets(&self) -> Vec<(Plane, Point)> {
|
||||
let e = self.extent / 2.0;
|
||||
Plane::ALL
|
||||
self.shown_planes()
|
||||
.into_iter()
|
||||
.filter_map(|pl| {
|
||||
let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) };
|
||||
@@ -793,7 +817,7 @@ pub fn grid_overlays_in(doc: &Document, tol: f64, place: Option<widget::WidgetPl
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
for pl in g.planes() {
|
||||
for pl in g.shown_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 });
|
||||
|
||||
@@ -385,6 +385,7 @@ impl PerspectiveGrid {
|
||||
let x = g.station().x;
|
||||
g.vp_vertical = [x + def.third_vp[0] * r, g.horizon - def.third_vp[1] * r];
|
||||
}
|
||||
g.keep_positioning_valid();
|
||||
g.validate()?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Automatic Plane Positioning (Perspective Grid Options): the active plane takes its height or
|
||||
//! depth from an anchor point of art in perspective, or from a gridline intersection. With the
|
||||
//! Perspective Selection tool, Shift held over one, with no button down, moves the active plane
|
||||
//! through that point for now and hides the other planes ([`PerspectiveGrid::inferred`]). Making
|
||||
//! another plane the active one brings that plane through the same point. The plane goes back to
|
||||
//! its place once an object is drawn on it or attached to it.
|
||||
//!
|
||||
//! The point is kept in the grid's frame: x, y and z in points along [`Axis::X`], [`Axis::Y`] and
|
||||
//! [`Axis::Z`] from the origin, so a plane through it lies at the point's coordinate along the
|
||||
//! plane's normal.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::widget::PlaneTargets;
|
||||
use super::{Axis, DOC_KEY, PerspectiveGrid, Plane, attachment};
|
||||
use crate::{Action, ToolContext};
|
||||
|
||||
/// The pointer is over an anchor point or a gridline intersection within this many screen pixels.
|
||||
pub const HIT_PX: f64 = 6.0;
|
||||
|
||||
/// What Automatic Plane Positioning took its point from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InferSource {
|
||||
Anchor,
|
||||
Gridline,
|
||||
}
|
||||
|
||||
impl InferSource {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Anchor => "anchor",
|
||||
Self::Gridline => "gridline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// Where the axis' coordinate is in a point of the grid's frame.
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::X => 0,
|
||||
Self::Y => 1,
|
||||
Self::Z => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` with each shown object in perspective that isn't inside another one, its plane and
|
||||
/// its depth: its own record, else the one grid `g` stored before attachments were kept on the
|
||||
/// objects ([`PerspectiveGrid::attachment_of`]).
|
||||
fn each_attached<'a>(g: &PerspectiveGrid, n: &'a Node, f: &mut dyn FnMut(&'a Node, Plane, f64)) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
match attachment(n).or_else(|| g.attached_plane(n.id).map(|p| (p, 0.0))) {
|
||||
Some((pl, depth)) => f(n, pl, depth),
|
||||
None => n.children().into_iter().flatten().for_each(|c| each_attached(g, c, f)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` with `n` and each node inside it, leaving out hidden nodes and what they hold.
|
||||
fn each_shown<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
f(n);
|
||||
n.children().into_iter().flatten().for_each(|c| each_shown(c, f));
|
||||
}
|
||||
|
||||
impl PerspectiveGrid {
|
||||
/// Where Automatic Plane Positioning holds `plane`: the active plane only.
|
||||
pub(super) fn inferred_offset(&self, plane: Plane) -> Option<f64> {
|
||||
let p = self.inferred?;
|
||||
if plane != self.plane {
|
||||
return None;
|
||||
}
|
||||
let [.., n] = self.axes(plane)?;
|
||||
Some(p[n.index()])
|
||||
}
|
||||
|
||||
/// The planes that show: all three, or only the active one while Automatic Plane Positioning
|
||||
/// holds it.
|
||||
pub fn shown_planes(&self) -> Vec<Plane> {
|
||||
match self.inferred {
|
||||
Some(_) if self.plane != Plane::None => vec![self.plane],
|
||||
_ => Plane::ALL.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the grid's frame at plane coordinates `q` on `plane` at `depth`.
|
||||
fn frame_point(&self, plane: Plane, depth: f64, q: Point) -> Option<[f64; 3]> {
|
||||
let [u, v, n] = self.axes(plane)?;
|
||||
let mut out = [0.0; 3];
|
||||
out[u.index()] = q.x;
|
||||
out[v.index()] = q.y;
|
||||
out[n.index()] = depth;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The anchor point of shown art in perspective nearest `p` within `tol`: (distance, frame point).
|
||||
fn anchor_near(&self, doc: &Document, p: Point, tol: f64) -> Option<(f64, [f64; 3])> {
|
||||
let mut best: Option<(f64, [f64; 3])> = None;
|
||||
let mut visit = |n: &Node, plane: Plane, depth: f64| {
|
||||
// The plane's inverse map, worked out only once an anchor is within reach.
|
||||
let mut hi = None;
|
||||
each_shown(n, &mut |c| {
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
for a in path.subpaths.iter().flat_map(|sp| sp.anchors.iter()) {
|
||||
let d = a.p.distance(p);
|
||||
if !(d <= tol && best.is_none_or(|b| d < b.0)) {
|
||||
continue;
|
||||
}
|
||||
let inv = hi.get_or_insert_with(|| self.homography_at(plane, depth).and_then(|h| h.inverse()));
|
||||
if let Some(at) = inv.as_ref().and_then(|m| m.apply(a.p)).and_then(|q| self.frame_point(plane, depth, q)) {
|
||||
best = Some((d, at));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
for l in &doc.layers {
|
||||
each_attached(self, l, &mut visit);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The gridline intersection of a shown plane nearest `p` within `tol`: (distance, frame point).
|
||||
fn intersection_near(&self, p: Point, tol: f64) -> Option<(f64, [f64; 3])> {
|
||||
let mut best: Option<(f64, [f64; 3])> = None;
|
||||
for plane in self.shown_planes() {
|
||||
let depth = self.offset(plane);
|
||||
let Some(h) = self.homography_at(plane, depth) else { continue };
|
||||
let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue };
|
||||
let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue };
|
||||
// The drawn lines nearest the pointer along each axis, and their neighbors: the
|
||||
// projection skews and shrinks the cells, so the intersection nearest on the page can be
|
||||
// one over from the nearest in the plane.
|
||||
let near = |v: f64, step: f64, last: usize| {
|
||||
let i = (v / step).round().clamp(0.0, last as f64) as i64;
|
||||
(i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step)
|
||||
};
|
||||
for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) {
|
||||
let q = Point::new(u, v);
|
||||
let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue };
|
||||
if d <= tol
|
||||
&& best.is_none_or(|b| d < b.0)
|
||||
&& let Some(at) = self.frame_point(plane, depth, q)
|
||||
{
|
||||
best = Some((d, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// What Automatic Plane Positioning finds within `tol` of page point `p`: the nearest anchor
|
||||
/// point of shown art in perspective, else the nearest gridline intersection of a shown plane,
|
||||
/// as a point of the grid's frame. `None` while the grid is hidden or has no active plane.
|
||||
pub fn infer_at(&self, doc: &Document, p: Point, tol: f64, targets: PlaneTargets) -> Option<(InferSource, [f64; 3])> {
|
||||
if !self.visible || self.plane == Plane::None || !(tol.is_finite() && p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let anchor = if targets.anchors { self.anchor_near(doc, p, tol) } else { None };
|
||||
let found = match anchor {
|
||||
Some((_, at)) => Some((InferSource::Anchor, at)),
|
||||
None if targets.gridlines => self.intersection_near(p, tol).map(|(_, at)| (InferSource::Gridline, at)),
|
||||
None => None,
|
||||
};
|
||||
found.filter(|(_, at)| at.iter().all(|v| v.is_finite()))
|
||||
}
|
||||
|
||||
/// Put a plane that Automatic Plane Positioning holds in the grid stored in `doc` back in its
|
||||
/// place. Returns whether one was held.
|
||||
pub fn end_positioning(doc: &mut Document) -> bool {
|
||||
if !Self::positioning(doc) {
|
||||
return false;
|
||||
}
|
||||
doc.unknown.get_mut(DOC_KEY).and_then(|v| v.as_object_mut()).is_some_and(|o| o.remove("inferred").is_some())
|
||||
}
|
||||
|
||||
/// Drop the point Automatic Plane Positioning holds when the grid no longer shows, has no
|
||||
/// active plane, or would put the active plane behind the viewer through it.
|
||||
pub fn keep_positioning_valid(&mut self) {
|
||||
if self.inferred.is_some() && (!self.visible || self.plane == Plane::None || self.validate().is_err()) {
|
||||
self.inferred = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Automatic Plane Positioning holds a plane of the grid stored in `doc`.
|
||||
pub fn positioning(doc: &Document) -> bool {
|
||||
doc.unknown.get(DOC_KEY).and_then(|v| v.get("inferred")).is_some_and(|v| !v.is_null())
|
||||
}
|
||||
}
|
||||
|
||||
/// The Perspective Selection tool's step for Shift held over `p` with no button down: the command
|
||||
/// that moves the active plane through the anchor point or gridline intersection there, unless
|
||||
/// the plane is held through that point already.
|
||||
pub(super) fn hover_action(cx: &ToolContext, g: &PerspectiveGrid, p: Point) -> Option<Action> {
|
||||
let tol = cx.tol(HIT_PX);
|
||||
let (_, at) = g.infer_at(cx.doc, p, tol, cx.plane_targets)?;
|
||||
if g.inferred.is_some_and(|held| held.iter().zip(&at).all(|(a, b)| (a - b).abs() <= 1e-9)) {
|
||||
return None;
|
||||
}
|
||||
Some(Action::Exec("perspective.plane.infer".into(), json!({"at": [p.x, p.y], "tolerance": tol})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{Mods, PointerEvent, PointerKind, Tool, ToolKey};
|
||||
use vectorcraft_doc::Selection;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
fn grid() -> PerspectiveGrid {
|
||||
PerspectiveGrid::preset(2, Rect::new(0.0, 0.0, 800.0, 600.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gridline_intersection_gives_the_frame_point_the_planes_pass_through() {
|
||||
let mut g = grid();
|
||||
let (d, cell) = (Document::new(800.0, 600.0), g.cell);
|
||||
// Three cells along and two up the left wall (two-point: u recedes along Z, v is Y).
|
||||
let p = g.to_page(Plane::Left, Point::new(3.0 * cell, 2.0 * cell)).unwrap();
|
||||
let (source, at) = g.infer_at(&d, p, 1.0, PlaneTargets::ALL).unwrap();
|
||||
assert_eq!(source, InferSource::Gridline);
|
||||
for (got, want) in at.iter().zip([0.0, 2.0 * cell, 3.0 * cell]) {
|
||||
assert!((got - want).abs() < 1e-6, "{at:?}");
|
||||
}
|
||||
// Held, the active plane lies at the point's coordinate along its normal, the other planes
|
||||
// hide, and their own places stay.
|
||||
g.inferred = Some(at);
|
||||
g.plane = Plane::Ground;
|
||||
assert!((g.offset(Plane::Ground) - 2.0 * cell).abs() < 1e-6);
|
||||
assert_eq!((g.placed_offset(Plane::Ground), g.offset(Plane::Left)), (0.0, 0.0));
|
||||
assert_eq!(g.shown_planes(), vec![Plane::Ground]);
|
||||
assert_eq!(g.plane_widgets().len(), 1);
|
||||
// The plane drawn there passes through the point the pointer was over.
|
||||
let back = g.to_page(Plane::Ground, Point::new(0.0, 3.0 * cell)).unwrap();
|
||||
assert!((back - p).hypot() < 1e-6, "{back:?} {p:?}");
|
||||
// Another plane made active passes through it too.
|
||||
g.plane = Plane::Right;
|
||||
assert!((g.offset(Plane::Right) - 3.0 * cell).abs() < 1e-6);
|
||||
assert_eq!(g.offset(Plane::Ground), 0.0);
|
||||
// With no active plane nothing is held.
|
||||
g.plane = Plane::None;
|
||||
assert_eq!(g.shown_planes(), Plane::ALL.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pointer_must_be_within_reach_of_a_target_that_is_on() {
|
||||
// Only the right wall shows, in its own place: the other planes' lines are out of the way.
|
||||
let g = PerspectiveGrid { plane: Plane::Right, inferred: Some([0.0; 3]), ..grid() };
|
||||
let (d, cell) = (Document::new(800.0, 600.0), g.cell);
|
||||
let on = g.to_page(Plane::Right, Point::new(2.0 * cell, cell)).unwrap();
|
||||
let mid = g.to_page(Plane::Right, Point::new(2.5 * cell, 1.5 * cell)).unwrap();
|
||||
assert!(g.infer_at(&d, on + vectorcraft_geom::Vec2::new(2.0, 0.0), 3.0, PlaneTargets::ALL).is_some());
|
||||
assert!(g.infer_at(&d, mid, 3.0, PlaneTargets::ALL).is_none(), "the middle of a cell is no intersection");
|
||||
assert!(g.infer_at(&d, on, 3.0, PlaneTargets { anchors: true, gridlines: false }).is_none());
|
||||
assert!(g.infer_at(&d, on, f64::NAN, PlaneTargets::ALL).is_none());
|
||||
assert!(g.infer_at(&d, Point::new(f64::INFINITY, 0.0), 3.0, PlaneTargets::ALL).is_none());
|
||||
// Hidden, or without an active plane, the grid gives no point.
|
||||
assert!(PerspectiveGrid { visible: false, ..g.clone() }.infer_at(&d, on, 3.0, PlaneTargets::ALL).is_none());
|
||||
assert!(PerspectiveGrid { plane: Plane::None, ..g.clone() }.infer_at(&d, on, 3.0, PlaneTargets::ALL).is_none());
|
||||
// Beyond the plane's last line there is no intersection.
|
||||
let past = g.to_page(Plane::Right, Point::new(g.right_extent() + 5.0 * cell, cell)).unwrap();
|
||||
assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() {
|
||||
let g = PerspectiveGrid { plane: Plane::Right, ..grid() };
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
g.store(&mut d);
|
||||
// A group on the right wall holding one small square, a corner in the middle of a cell.
|
||||
let at = g.to_page(Plane::Right, Point::new(2.5 * g.cell, 1.5 * g.cell)).unwrap();
|
||||
let (layer, group, square) = (d.layers[0].id, d.alloc_id(), d.alloc_id());
|
||||
let mut path = Node::path(square, vectorcraft_geom::shapes::rectangle(Rect::new(at.x, at.y, at.x + 2.0, at.y + 2.0)), Default::default());
|
||||
path.visible = false;
|
||||
let mut art = Node::group(group, vec![std::sync::Arc::new(path)]);
|
||||
super::super::set_attachment(&mut art, Plane::Right, 0.0);
|
||||
d.insert(Some(layer), 0, art).unwrap();
|
||||
let anchors = PlaneTargets { anchors: true, gridlines: false };
|
||||
assert!(g.infer_at(&d, at, 0.5, anchors).is_none(), "the square is hidden");
|
||||
d.node_mut(square).unwrap().visible = true;
|
||||
let (source, point) = g.infer_at(&d, at, 0.5, anchors).unwrap();
|
||||
assert_eq!(source, InferSource::Anchor);
|
||||
for (got, want) in point.iter().zip([2.5 * g.cell, 1.5 * g.cell, 0.0]) {
|
||||
assert!((got - want).abs() < 1e-6, "{point:?}");
|
||||
}
|
||||
// A grid stored before attachments were kept on the objects lists them itself.
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let (layer, square) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(
|
||||
Some(layer),
|
||||
0,
|
||||
Node::path(square, vectorcraft_geom::shapes::rectangle(Rect::new(at.x, at.y, at.x + 2.0, at.y + 2.0)), Default::default()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(g.infer_at(&d, at, 0.5, anchors).is_none(), "not in perspective");
|
||||
let mut legacy = g.clone();
|
||||
legacy.attached.insert(square.0.to_string(), Plane::Right);
|
||||
assert_eq!(legacy.infer_at(&d, at, 0.5, anchors).map(|f| f.0), Some(InferSource::Anchor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_point_ends_with_end_positioning() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let mut g = grid();
|
||||
g.store(&mut d);
|
||||
assert!(!PerspectiveGrid::positioning(&d));
|
||||
assert!(!PerspectiveGrid::end_positioning(&mut d));
|
||||
g.inferred = Some([1.0, 2.0, 3.0]);
|
||||
g.store(&mut d);
|
||||
assert!(PerspectiveGrid::positioning(&d));
|
||||
assert_eq!(PerspectiveGrid::current(&d).inferred, Some([1.0, 2.0, 3.0]));
|
||||
// `perspective.grid.set` leaves the point as it is.
|
||||
assert_eq!(g.merged(&json!({"inferred": [9, 9, 9], "cell": 25})).unwrap().inferred, Some([1.0, 2.0, 3.0]));
|
||||
assert!(PerspectiveGrid { inferred: Some([f64::NAN, 0.0, 0.0]), ..g.clone() }.validate().is_err());
|
||||
assert!(PerspectiveGrid::end_positioning(&mut d));
|
||||
assert_eq!(PerspectiveGrid::current(&d), PerspectiveGrid { inferred: None, ..g });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_over_an_intersection_asks_for_the_plane_once() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let mut g = grid();
|
||||
g.store(&mut d);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let at = g.to_page(Plane::Left, Point::new(2.0 * g.cell, 3.0 * g.cell)).unwrap();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let hover = |d: &Document, mods: Mods| {
|
||||
let c = cx(d, &s, &p);
|
||||
let mut t = super::super::PerspectiveSelectionTool::default();
|
||||
t.pointer(&c, &PointerEvent { mods, ..PointerEvent::new(PointerKind::Move, at.x, at.y) })
|
||||
};
|
||||
assert!(hover(&d, Mods::default()).is_empty(), "without Shift the pointer only hovers");
|
||||
let acts = hover(&d, shift);
|
||||
let [Action::Exec(cmd, v)] = acts.as_slice() else { panic!("{acts:?}") };
|
||||
assert_eq!((cmd.as_str(), &v["at"]), ("perspective.plane.infer", &json!([at.x, at.y])));
|
||||
// Held through that point already, the hover asks for nothing more.
|
||||
g.inferred = g.infer_at(&d, at, 1.0, PlaneTargets::ALL).map(|f| f.1);
|
||||
g.store(&mut d);
|
||||
assert!(hover(&d, shift).is_empty());
|
||||
// Escape puts the plane back.
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = super::super::PerspectiveSelectionTool::default();
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Exec("perspective.plane.infer".into(), json!({"clear": true}))]);
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,9 @@ impl Tool for PerspectiveSelectionTool {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
// Automatic Plane Positioning: Shift held over an anchor point of art in perspective or
|
||||
// over a gridline intersection, with no button down. During a drag Shift constrains.
|
||||
(PointerKind::Move, SelDrag::Idle) if m.shift => super::infer::hover_action(cx, &g, p).into_iter().collect(),
|
||||
(_, d) => {
|
||||
self.drag = d;
|
||||
vec![]
|
||||
@@ -296,7 +299,8 @@ impl Tool for PerspectiveSelectionTool {
|
||||
}
|
||||
}
|
||||
/// Escape during a move, a resize or a plane widget drag cancels it: the objects and the plane
|
||||
/// go back to the start of the drag.
|
||||
/// go back to the start of the drag. With no drag, Escape puts a plane that Automatic Plane
|
||||
/// Positioning holds back in its place.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.busy() {
|
||||
self.measure = None;
|
||||
@@ -305,6 +309,9 @@ impl Tool for PerspectiveSelectionTool {
|
||||
_ => vec![Action::Cancel],
|
||||
};
|
||||
}
|
||||
if key == ToolKey::Escape && PerspectiveGrid::current(cx.doc).inferred.is_some() {
|
||||
return vec![Action::Exec("perspective.plane.infer".into(), json!({"clear": true}))];
|
||||
}
|
||||
if let (ToolKey::Digit(5), SelDrag::Move { perp, began, shift, .. }) = (key, &mut self.drag) {
|
||||
*perp = !*perp;
|
||||
*shift = mods.shift;
|
||||
|
||||
@@ -50,17 +50,44 @@ impl WidgetCorner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perspective Grid Options: whether the Plane Switching Widget shows, and where.
|
||||
/// Perspective Grid Options: whether the Plane Switching Widget shows, and where, and what
|
||||
/// Automatic Plane Positioning takes a point from ([`super::infer`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct WidgetOptions {
|
||||
pub show: bool,
|
||||
pub position: WidgetCorner,
|
||||
/// Automatic Plane Positioning › Anchor Point of Perspective Artwork.
|
||||
pub anchor_point: bool,
|
||||
/// Automatic Plane Positioning › Intersection of Gridlines.
|
||||
pub gridline_intersection: bool,
|
||||
}
|
||||
|
||||
impl Default for WidgetOptions {
|
||||
fn default() -> Self {
|
||||
Self { show: true, position: WidgetCorner::TopLeft }
|
||||
Self { show: true, position: WidgetCorner::TopLeft, anchor_point: true, gridline_intersection: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetOptions {
|
||||
/// What Automatic Plane Positioning takes a point from.
|
||||
pub fn targets(self) -> PlaneTargets {
|
||||
PlaneTargets { anchors: self.anchor_point, gridlines: self.gridline_intersection }
|
||||
}
|
||||
}
|
||||
|
||||
/// What Automatic Plane Positioning takes a point from: the anchor points of art in perspective,
|
||||
/// the gridline intersections of the planes that show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PlaneTargets {
|
||||
pub anchors: bool,
|
||||
pub gridlines: bool,
|
||||
}
|
||||
|
||||
impl PlaneTargets {
|
||||
pub const ALL: Self = Self { anchors: true, gridlines: true };
|
||||
pub fn any(self) -> bool {
|
||||
self.anchors || self.gridlines
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ pub(crate) mod testutil {
|
||||
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
|
||||
screen: None,
|
||||
plane_widget: Some(Default::default()),
|
||||
plane_targets: distort::perspective::widget::PlaneTargets::ALL,
|
||||
precise_cursors: false,
|
||||
global_edit: &[],
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane Switching
|
||||
//! Widget shows and which corner of the window it sits in. OK runs `perspective.widget.options`
|
||||
//! (the options live in the preferences).
|
||||
//! Widget shows and which corner of the window it sits in, and what Automatic Plane Positioning
|
||||
//! takes a point from. OK runs `perspective.widget.options` (the options live in the preferences).
|
||||
//!
|
||||
//! Fields: `show` (bool) and `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`).
|
||||
//! Fields: `show` (bool), `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`),
|
||||
//! `anchorPoint` (bool) and `gridlineIntersection` (bool).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_tools::distort::perspective::widget::WidgetCorner;
|
||||
@@ -38,5 +39,13 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.add_space(8.0);
|
||||
let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect();
|
||||
ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", tl!("Widget Position:"), (110.0, 160.0), &corners));
|
||||
ui.add_space(8.0);
|
||||
widgets::section_header(ui, tl!("Automatic Plane Positioning"));
|
||||
for (key, label) in [("anchorPoint", tl!("Anchor Point of Perspective Artwork")), ("gridlineIntersection", tl!("Intersection of Gridlines"))] {
|
||||
let on = d.bool(key);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
d.fields.insert(key.into(), json!(!on));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -250,6 +250,26 @@ fn double_clicking_the_tool_opens_perspective_grid_options() {
|
||||
assert!(!app.session.prefs.perspective_widget.show);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_grid_options_hold_automatic_plane_positioning() {
|
||||
let mut app = app();
|
||||
app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap();
|
||||
assert_eq!((field(&app, "anchorPoint"), field(&app, "gridlineIntersection")), (json!(true), json!(true)), "both on by default");
|
||||
frame(&mut app);
|
||||
set(&mut app, "gridlineIntersection", json!(false));
|
||||
frame(&mut app);
|
||||
super::confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), None);
|
||||
let o = app.session.prefs.perspective_widget;
|
||||
assert!(o.anchor_point && !o.gridline_intersection && o.show);
|
||||
// The dialog opens on what was set, and a value that is not true or false is refused.
|
||||
app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap();
|
||||
assert_eq!(field(&app, "gridlineIntersection"), json!(false));
|
||||
set(&mut app, "anchorPoint", json!("off"));
|
||||
assert!(super::confirm(&mut app).is_err());
|
||||
assert!(app.session.prefs.perspective_widget.anchor_point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digit_keys_switch_planes_and_the_widget_follows_the_window() {
|
||||
let mut app = app();
|
||||
|
||||
+24
-3
@@ -10346,9 +10346,30 @@ or of a plane widget, cancels it and puts the grid back. The Plane Switching Wid
|
||||
of the document window (headless: the first artboard's top-left corner); a press on it picks the plane with any tool
|
||||
while the grid shows (choosing a perspective tool shows it, and `perspective.grid.show` hides it again with the tool
|
||||
still chosen), and the keys 1–4 (`key` with `"1"`…`"4"`) pick the left, horizontal, right and no plane.
|
||||
`perspective.widget.options {show?, position?}` (Perspective Grid Options, double-click the tool) hides it or moves
|
||||
it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, kept with the preferences (`prefs.get`/`prefs.set` key
|
||||
`perspectiveWidget`).
|
||||
`perspective.widget.options {show?, position?, anchorPoint?, gridlineIntersection?}` (Perspective Grid Options,
|
||||
double-click the tool) hides it or moves it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, and turns the
|
||||
two targets of Automatic Plane Positioning on or off (`anchorPoint`: Anchor Point of Perspective Artwork;
|
||||
`gridlineIntersection`: Intersection of Gridlines; both on by default). The options are kept with the preferences
|
||||
(`prefs.get`/`prefs.set` key `perspectiveWidget`), and a value that is not `true` or `false` is an error.
|
||||
|
||||
Automatic Plane Positioning: `perspective.plane.infer {at?: [x, y], tolerance?, clear?}` moves the active plane
|
||||
through the anchor point of shown art in perspective, or the gridline intersection of a plane that shows, within
|
||||
`tolerance` points of `at` (6 by default), for now, and hides the other planes. An anchor point within reach wins
|
||||
over an intersection. With the Perspective Selection tool, `pointer` `move` with Shift over one does the same,
|
||||
within 6 screen pixels; with a button down, Shift constrains the move as before. `perspective.plane.set` then brings
|
||||
the plane it makes active through the same point. The plane goes back to its place, and the other planes show
|
||||
again, when an object is drawn on it or attached to it (`perspective.draw`, `perspective.attach`, or an object
|
||||
brought into perspective by `perspective.move` or `perspective.transform`; a drag keeps the plane held until it is
|
||||
committed), when `perspective.plane.matchObject` moves a plane, when the grid hides, when no plane is active, when
|
||||
the file is opened again, and with `clear` (Escape with the Perspective Selection tool). `perspective.plane.move`
|
||||
moves the held plane from where it is held and leaves it at its new place, and the other planes show again.
|
||||
The held point is view state, not an undo step: `perspective.grid.get` reports it as `grid.inferred` (x, y and z
|
||||
in the grid's frame). `perspective.grid.set`, `perspective.grid.define` and `perspective.grid.preset` keep it
|
||||
unless the grid they make hides, has no active plane, or would put the plane through the point behind the viewer.
|
||||
Without params the command reads the state:
|
||||
`{active, plane, offset, point?, source?: anchor|gridline}`. With `at`, `source` is absent when nothing was within
|
||||
reach, and the grid stays as it was. It is an error when the grid is hidden, when no plane is active, when both
|
||||
targets are off, and when `at`, `tolerance` or `clear` is not what the params take.
|
||||
|
||||
## Envelopes
|
||||
|
||||
|
||||
@@ -1939,6 +1939,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so.
|
||||
With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth.
|
||||
With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, an edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on those axes is that close, the move does not snap. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`.
|
||||
Perspective Grid Options has Automatic Plane Positioning with its two checkboxes, Anchor Point of Perspective Artwork and Intersection of Gridlines. With the Perspective Selection tool, Shift held over an anchor point of art in perspective, or over a gridline intersection, moves the active plane to that height or depth and hides the other planes; a plane made active then passes through the same point, and the planes are back in place once an object is drawn or placed there (`perspective.plane.infer`). VectorCraft's own rules: both options are on by default and kept with the preferences, the pointer counts as over a target within 6 screen pixels, an anchor point wins over an intersection, and Shift positions the plane only while no button is down. Tests: `crates/engine/src/tests/persp_infer.rs`.
|
||||
When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns.
|
||||
Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change.
|
||||
Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change.
|
||||
|
||||
Reference in New Issue
Block a user