Files
vectorcraft/crates/engine/src/cmd/distortcmds/perspective.rs
T
dotCooCoo 6be88a24b4 Fix #785: commands refuse an id that isn't an object id instead of using the selection
Commands whose `id` or `ids` the shared check in Session::execute does not
read still parsed them with id_param/ids_param, which drop anything that is
not a non-negative integer, and then acted on the selection or the current
layer. `object.blend.make {"ids": 3}` blended the selection,
`object.envelope.makeWithWarp {"ids": [2, "x"]}` warped the selection,
`graph.setData {"id": "2"}` changed the selected graph and
`layer.newSublayer {"parent": "2"}` added a sublayer to the current layer.

- cmd::named_ids and cmd::named_id read `ids`/`id` as targets does: a value
  that is not an object id, or an unresolved step reference, is an error.
- The symbol and brush, Live Paint, Shape Builder and Image Trace, blend,
  envelope and mesh, perspective, Liquify, Puppet Warp and graph helpers use
  them in place of the lenient readers.
- layer.clippingMask.toggle checks `id`, and layer.newSublayer checks
  `parent`, with checked_id before falling back to the highlighted row or the
  current layer.
2026-11-02 14:32:49 +07:00

644 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
Affine, Arc, Document, EngineError, Homography, NodeId, NodeKind, PerspectiveGrid, Plane, Point, Result, Session, Value, bad, check_numbers,
check_switches, collect_points, duplicate_in, finite_numbers, json, matrix_param, named_id, named_ids, number_or, ok, outermost, persp,
point_param, project_node, selected_roots, str_param, switch_or,
};
// ---------- perspective grid ----------
/// The document's perspective grid (the default preset when none was defined).
pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid {
PerspectiveGrid::effective(d)
}
/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`).
pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
g.store(d);
}
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
/// leaves `d` as it is.
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
let mut g = g.clone();
g.attached.clear();
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
}
/// Change view state of the grid without an undo step.
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
let mut g = grid_of(&st.doc);
f(&mut g);
store_grid(Arc::make_mut(&mut st.doc), &g);
if let Some(it) = &mut st.interaction {
store_grid(Arc::make_mut(&mut it.doc), &g);
}
st.revision += 1;
Ok(g)
}
pub(super) fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.set";
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad(C, e))?;
let reproject = switch_or(p, "reproject", false, C)?;
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
return Ok(g.definition_json());
}
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
for (id, plane, depth) in persp::attached_roots(d) {
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
let m = h.and_then(|(hi, h)| h.checked_then_after(&hi));
let m = m.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
warp_checked(d, id, &m)?;
}
}
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.show";
// A missing or null `visible` toggles the grid.
let want = match p.get("visible") {
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))?;
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)?;
Ok(json!({ "plane": plane.id() }))
}
fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
let plane = match str_param(p, "plane") {
Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?,
None => g.plane,
};
if plane == Plane::None {
return Err(EngineError::Other("no active perspective plane".into()));
}
Ok(plane)
}
pub(super) fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match named_ids(p)? {
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
None => selected_roots(s),
}
}
/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it),
/// failing if any point would leave the plane's visible side or a projection would not be finite.
fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut pts = vec![];
collect_points(n, &mut pts);
if pts.iter().any(|q| h.apply(*q).is_none()) || !project_node(n, h) {
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
}
Ok(())
}
/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners
/// (on the nearest gridlines with `snap`).
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option<Homography>) -> Result<()> {
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m =
map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane));
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
pub(super) fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, "perspective.attach")?;
// With no object to attach, when the document holds the grid already, the document and its undo
// history stay as they are.
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
return Ok(json!({ "ids": [], "plane": plane.id() }));
}
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane, false, None)?;
}
store_grid(d, &g);
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "plane": plane.id() }))
}
pub(super) fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
// When releasing leaves every object as it is (none is in perspective) and the document holds
// the grid already, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
let unchanged = |id: &NodeId| {
d.node(*id).is_none_or(|n| {
let mut released = n.clone();
persp::release(&mut released);
released == *n
})
};
if ids.iter().all(unchanged) && grid_kept(d, &g) {
return ok();
}
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
if let Some(n) = d.node_mut(*id) {
persp::release(n);
}
}
store_grid(d, &g);
Ok(())
})?;
ok()
}
/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and
/// the depth it ends at.
type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>;
/// Where an object is in perspective: its plane and depth.
type Attached = (Plane, f64);
fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
fn stores_attachments(d: &Document) -> bool {
!PerspectiveGrid::current(d).attached.is_empty()
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
/// own ([`stores_attachments`]).
fn persp_edit(
s: &mut Session,
label: &str,
ids: &[NodeId],
fallback: Option<Plane>,
copy: bool,
step: PerspStep,
) -> Result<(Vec<NodeId>, Option<Attached>)> {
if ids.is_empty() {
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None };
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
// below maps only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
None => ids.to_vec(),
};
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
Some(a) => a,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl, false, None)?;
(pl, g.offset(pl))
}
};
first.get_or_insert((plane, depth));
let (h, depth) = step(&g, plane, depth)?;
warp_checked(d, *id, &h)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
}
store_grid(d, &g);
Ok((targets, first))
})
}
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
/// holds the grid already; None otherwise.
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
let d = &s.doc()?.doc;
let g = grid_of(d);
if ids.is_empty() || !grid_kept(d, &g) {
return Ok(None);
}
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
Ok(at.and_then(|at| at.first().copied()))
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
let st = s.doc_mut()?;
match &mut st.interaction {
Some(it) => it.perspective_again = Some(again),
None => st.last_perspective = Some(again),
}
Ok(())
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// A plane-space affine map as `perspective.transform`'s `matrix` param.
fn matrix_json(m: Affine) -> Value {
json!(m.as_coeffs())
}
pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.move";
let ids = roots(s, p)?;
let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?;
let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?;
if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
let constrain = switch_or(p, "constrain", false, C)?;
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let snap_to = if constrain && !perp {
let doc = &s.doc()?.doc;
let g = grid_of(doc);
g.plane_bounds(doc, &ids)
.and_then(|(plane, depth, _)| {
let h = g.homography_at(plane, depth)?;
let start = h.inverse()?.apply(from)?;
let delta = g.plane_delta(plane, depth, from, to)?;
h.apply(start + vectorcraft_tools::distort::perspective::constrain_move_delta(delta).1)
})
.unwrap_or(to)
} else {
to
};
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) };
let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| {
let (direction, dv) =
if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) };
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
if !constrain {
return f.2;
}
if direction.x.abs() < 1e-12 {
return vectorcraft_geom::Vec2::new(0.0, f.2.y);
}
if direction.y.abs() < 1e-12 {
return vectorcraft_geom::Vec2::new(f.2.x, 0.0);
}
let (correction, axis) =
if f.2.x == 0.0 || (f.2.y != 0.0 && f.2.y.abs() < f.2.x.abs()) { (f.2.y, direction.y) } else { (f.2.x, direction.x) };
direction * (correction / axis)
})
};
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
if perp {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
// included) and there is no copy, the document and its undo history stay as they are.
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
if perp {
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
} else {
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
};
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
let (targets, first) = match first_still {
Some(at) => (ids, Some(at)),
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
};
// Transform Again repeats the first object's move, and the identity when no object moved.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
};
if let Some(a) = again {
record_again(s, a)?;
}
}
Ok(ids_json(&targets))
}
pub(super) fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = number_or(p, "depth", 0.0, C)?;
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = switch_or(p, "copy", false, C)?;
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
return Err(EngineError::Other("the objects would leave the grid".into()));
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
// An identity without a copy moves no object that is in perspective already: then the document
// and its undo history stay as they are, and Transform Again repeats the identity.
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
ids
} else {
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
};
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space
/// transform on the selection.
pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
let mut p = again.clone();
if let Some(o) = p.as_object_mut() {
o.remove("ids");
}
persp_transform(s, &p)
}
pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
check_numbers(p, &["offset", "by"], C)?;
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
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,
(None, Some(b)) => from + b,
(None, None) => return Err(bad(C, "give offset or by")),
};
let objects = str_param(p, "objects").unwrap_or("none");
if !matches!(objects, "none" | "move" | "copy") {
return Err(bad(C, "objects must be none, move or copy"));
}
let mut g = old.clone();
g.set_offset(plane, to);
g.validate().map_err(|e| bad(C, e))?;
let label = match objects {
"copy" => "Move Plane and Copy Objects",
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
// The objects on the plane where it was (not those moved off it along its normal).
let on_plane = |d: &Document| -> Vec<NodeId> {
let editable = d.editable();
persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id))
.map(|(id, ..)| id)
.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) {
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<_>>() }));
}
// In an interaction whose document is still its snapshot, the copies of the objects on the
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
// lists attachments of its own (`stores_attachments`).
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
let on = on_plane(&s.doc()?.doc);
crate::cmd::object::preview_copies(s, &on, false)?
} else {
None
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
ids = match &kept {
// The document becomes the snapshot with the kept copies, not yet moved, and the
// loop below moves only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None => {
let on = on_plane(d);
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
}
};
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &m)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
}
}
store_grid(d, &g);
Ok(ids)
})?;
Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
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()))?;
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) {
return Ok(json!({ "plane": plane.id(), "offset": depth }));
}
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(json!({ "plane": plane.id(), "offset": depth }))
}
pub(super) fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 };
let copy = switch_or(p, "copy", false, C)?;
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
let g = grid_of(&st.doc);
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy}))
}
pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.draw";
let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string();
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = switch_or(p, "snap", g.snap, C)?;
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => {
let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?)
}
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
let res = s.edit("Draw in Perspective", |d, _| {
attach_in(d, &mut g, id, plane, snap, map)?;
store_grid(d, &g);
Ok(())
});
// One undo step for the whole gesture.
let st = s.doc_mut()?;
if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 {
st.history.undo.truncate(undo_before + 1);
if let Some(e) = st.history.undo.last_mut() {
e.label = "Draw in Perspective".into();
}
st.history.recost_last(&st.doc);
}
res?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
}
/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane.
pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
const SHAPES: &[&str] = &[
"shape.rectangle",
"shape.ellipse",
"shape.polygon",
"shape.star",
"shape.line",
"shape.rectangularGrid",
"shape.arc",
"shape.flare",
"shape.spiral",
"shape.polarGrid",
];
if !SHAPES.contains(&cmd) {
return None;
}
let g = PerspectiveGrid::current(&s.active()?.doc);
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
}
/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the
/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the
/// click), else `None` (run it as it is).
pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> {
let (c, mut p) = perspective_rewrite(s, cmd, params)?;
p["at"] = json!([at.x, at.y]);
Some((c, p))
}
/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
pub(super) fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
return Err(EngineError::Other("select type in perspective".into()));
}
s.edit("Edit Text", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) };
// Shown flat where it is drawn: the type moves there and the projection moves the other
// way, so the picture stays.
let Some(flat) = t.bounds() else { return Ok(()) };
let cross = || EngineError::Other("the object would cross the horizon of the perspective plane".into());
let drawn = h.map_rect_bbox(flat).ok_or_else(cross)?;
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
let moved = h.checked_then_after(&Homography::from_affine(shift.inverse())).ok_or_else(cross)?;
t.transform(shift);
if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) {
rec.projection = Some(moved.to_array());
rec.editing = true;
}
Ok(())
})?;
let st = s.doc_mut()?;
st.isolation = Some(id);
st.selection.set([id]);
st.revision += 1;
Ok(json!({ "id": id.0 }))
}
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
/// state stays saved, [`crate::DocState::change_unsaved`]).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
// builds (the document now keeps its own).
if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()))
{
p.editing = false;
}
};
st.change_unsaved(clear);
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
clear(&mut e.doc);
e.doc.drop_lookup_cache();
if saved {
st.saved_doc = e.doc.clone();
}
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
}
st.revision += 1;
}