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 { 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 { 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 { 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 { 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 { 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> { 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) -> 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 { 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::>(), "plane": plane.id() })) } pub(super) fn release(s: &mut Session, p: &Value) -> Result { 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, copy: bool, step: PerspStep, ) -> Result<(Vec, Option)> { 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> { let d = &s.doc()?.doc; let g = grid_of(d); if ids.is_empty() || !grid_kept(d, &g) { return Ok(None); } let at: Option> = 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::>() }) } /// 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 { 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 { 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 { 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 { 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 { 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::>() })); } // 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::>() })) } pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result { 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 { 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::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) } pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result { 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, ¶ms)?; 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 { 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| { // 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; }