diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ac5e54b0..27b5ba84 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -534,9 +534,9 @@ impl Node { .iter() .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), - NodeKind::Text(t) => t.bounds(), + NodeKind::Text(t) => self.projected(t.bounds()), NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), - NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))), + NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))), NodeKind::Blend { children, spec } => { let b = crate::live::nodes_bounds(children); vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds())) @@ -593,6 +593,8 @@ impl Node { if !self.is_layer() { self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); } + // Type and symbols in perspective keep looking the same, moved by `a`. + self.transform_projection(a); // Refitting an unplaced gradient only reproduces moves and uniform scales. if !keeps_gradient_fit(a) { self.pin_gradients(); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 74bae06a..4e1e24ef 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -5,6 +5,9 @@ //! stay attached and deleted objects leave nothing stale behind. use serde::{Deserialize, Serialize}; +use vectorcraft_geom::{Homography, Rect}; + +use crate::{Node, NodeKind}; /// The plane an object in perspective lies on. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -17,10 +20,50 @@ pub struct PerspectiveAttachment { /// grid plane in its original place. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub depth: f64, + /// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art + /// in perspective. They stay type and symbol instances; the renderer and exporters project + /// their outlines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection: Option<[f64; 9]>, + /// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not + /// saved). + #[serde(skip)] + pub editing: bool, } impl PerspectiveAttachment { pub fn new(plane: &str, depth: f64) -> Self { - Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 } } + Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() } + } + + /// The projection, when it is a valid one. + pub fn homography(&self) -> Option { + self.projection.and_then(Homography::from_array) + } +} + +impl Node { + /// The projective map type or a symbol instance in perspective is drawn through (not while + /// Edit Text shows it flat). + pub fn projection(&self) -> Option { + let a = self.perspective.as_deref().filter(|a| !a.editing)?; + matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten() + } + + /// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective. + pub(crate) fn projected(&self, b: Option) -> Option { + match (self.projection(), b) { + (Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)), + (_, b) => b, + } + } + + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { + if let Some(p) = self.perspective.as_deref_mut() + && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + { + p.projection = Some(h.to_array()); + } } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 9ebf55d8..893d2125 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -44,6 +44,7 @@ pub fn needs_bake(n: &Node) -> bool { || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) + || n.projection().is_some() || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) } @@ -92,7 +93,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } // Type, images, symbol instances and live objects: reshaped through their outlines. - if needs_outline(n) && has_geometry(&n.appearance.effects) { + if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ea0fbd2a..0efcb90e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -113,7 +113,7 @@ fn outline_nested_text(n: &mut Node) { /// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` /// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if geometry_effects(n).is_empty() || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) { return None; } as_art(n, symbol_art) @@ -146,6 +146,10 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { _ => outline_art(n, symbol_art)?, }; reshape_leaves(&mut art, &fx); + // Type and symbols in perspective: the art projected onto their plane. + if let Some(h) = n.projection() { + project_leaves(&mut art, &h); + } // The art is a group with `n`'s id (a clip group for an image). let mut out = art; out.name = n.name.clone(); @@ -159,6 +163,33 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(out) } +/// Map every path under `art` (and its gradients) through the projective map `h`, curves split +/// finely enough to follow it. +fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) { + let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p); + let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); + let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f); + for_each_leaf(art, &mut |leaf| { + leaf.pin_gradients(); + leaf.appearance.warp_gradients(&|p| h.affine_at(p).unwrap_or(vectorcraft_geom::Affine::IDENTITY)); + match &mut leaf.kind { + NodeKind::Path { path, live, .. } => { + project(path); + *live = None; + } + NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + if let NodeKind::Path { path, live, .. } = &mut Arc::make_mut(c).kind { + project(path); + *live = None; + } + } + } + _ => {} + } + }); +} + /// The visible geometry effects of `n`'s own effect list, in order. pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect() diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index e7ef87a4..6654df57 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile} use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect}; use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; -use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, stroke_owner, warp_from_rest, warp_node_with}; +use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, selected_roots}; use super::*; @@ -182,6 +182,15 @@ pub fn specs() -> Vec { has_doc, plane_match ), + cmd!( + "perspective.editText", + "Edit Text", + [], + None, + "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}", + has_doc, + edit_text + ), ] } @@ -717,7 +726,7 @@ fn grid_set(s: &mut Session, p: &Value) -> Result { let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; let m = h.then_after(&hi); - warp_checked(d, id, &|q| m.apply(q))?; + warp_checked(d, id, &m)?; } } store_grid(d, &g); @@ -757,20 +766,25 @@ fn roots(s: &Session, p: &Value) -> Result> { } /// Map `n` with `f`, failing if any point would leave the plane's visible side. -fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option) -> Result<()> { +/// 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. +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| f(*q).is_none()) { + if pts.iter().any(|q| h.apply(*q).is_none()) { return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); } - warp_node_with(n, &|q| f(q).unwrap_or(q)); + project_node(n, h); Ok(()) } -fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool) -> Result<()> { +/// 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 = g.attach_map_with(plane, b, snap).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".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); @@ -783,7 +797,7 @@ fn attach(s: &mut Session, p: &Value) -> Result { let plane = plane_param(&g, p, "perspective.attach")?; s.edit("Attach to Active Plane", |d, _| { for id in &ids { - attach_in(d, &mut g, *id, plane, false)?; + attach_in(d, &mut g, *id, plane, false, None)?; } store_grid(d, &g); Ok(()) @@ -798,7 +812,7 @@ fn release(s: &mut Session, p: &Value) -> Result { for id in &ids { g.attached.remove(&id.0.to_string()); if let Some(n) = d.node_mut(*id) { - n.perspective = None; + persp::release(n); } } store_grid(d, &g); @@ -842,13 +856,13 @@ fn persp_edit( 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)?; + 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, &|q| h.apply(q))?; + warp_checked(d, *id, &h)?; persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); } store_grid(d, &g); @@ -986,7 +1000,7 @@ fn plane_move(s: &mut Session, p: &Value) -> Result { ids = 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, &|q| m.apply(q))?; + warp_checked(d, *id, &m)?; persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); } } @@ -1040,11 +1054,16 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { let mut g = grid_of(&s.doc()?.doc); let plane = plane_param(&g, p, C)?; let snap = bool_or(p, "snap", g.snap); + // A click-to-size dialog's shape keeps its sizes in plane units from the click. + let map = match point_param(p, "at") { + Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?), + None => None, + }; 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)?; + attach_in(d, &mut g, id, plane, snap, map)?; store_grid(d, &g); Ok(()) }); @@ -1062,10 +1081,85 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { /// 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"]; + 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::from_doc(&s.active()?.doc)?; + 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`]). +fn edit_text(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = + id_param(p, "id").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), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; + let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); + t.transform(shift); + if let Some(rec) = n.perspective.as_deref_mut() { + rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).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). +pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { + let clear = |doc: &mut Arc| { + if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + { + p.editing = false; + } + }; + let saved = Arc::ptr_eq(&st.doc, &st.saved_doc); + clear(&mut st.doc); + if saved { + st.saved_doc = st.doc.clone(); + } + for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { + clear(&mut e.doc); + } + if let Some(it) = &mut st.interaction { + clear(&mut it.doc); + } + st.revision += 1; +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 655064f7..0b80ea93 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -665,6 +665,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { + super::distortcmds::finish_edit_text(st, i); st.selection.set([i]); } st.revision += 1; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aed7df46..7685dfbe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::EyedropperOptions; pub use cmd::clipboard::Clipboard; +pub use cmd::distortcmds::perspective_click; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use tooling::{UiRequest, ViewInfo}; @@ -1296,6 +1297,8 @@ mod tests_persp_planes; #[cfg(test)] mod tests_persp_select; #[cfg(test)] +mod tests_persp_text; +#[cfg(test)] mod tests_perspgrid; #[cfg(test)] mod tests_place; diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs new file mode 100644 index 00000000..96fe97c2 --- /dev/null +++ b/crates/engine/src/tests_persp_text.rs @@ -0,0 +1,271 @@ +//! Type and symbols in perspective (projected, still type and symbol instances, Edit Text), +//! attachment bookkeeping (copy, paste, duplicate, Selection-tool moves, release) and drawing in +//! perspective (flare, spiral, polar grid, click-to-size dialogs). + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::Mods; +use vectorcraft_tools::distort::perspective::{self as persp, Plane}; + +use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; +use super::*; + +/// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane. +fn persp_text(s: &mut Session) -> NodeId { + let r = s.execute("text.create", &json!({"x": 430, "y": 400, "text": "HELLO", "size": 48})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + id +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// The art the renderer and exporters draw for `id` (its outlines, projected). +fn drawn_bounds(s: &Session, id: NodeId) -> Rect { + let n = node(s, id); + vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap() +} + +#[test] +fn type_in_perspective_is_projected_and_stays_type() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + let h = n.projection().expect("drawn through a projection"); + // Really foreshortened: the right plane recedes to the right, so the letters' far end is + // shorter than their near end. + let out = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let mut pts = vec![]; + out.walk(&mut |c| { + if let Some(p) = c.path_data() { + pts.extend(p.anchors().map(|(_, _, a)| a.p)); + } + }); + let b = drawn_bounds(&s, id); + let near: Vec<&Point> = pts.iter().filter(|p| p.x < b.x0 + 0.2 * b.width()).collect(); + let far: Vec<&Point> = pts.iter().filter(|p| p.x > b.x1 - 0.2 * b.width()).collect(); + let span = |v: &[&Point]| v.iter().map(|p| p.y).fold(f64::MIN, f64::max) - v.iter().map(|p| p.y).fold(f64::MAX, f64::min); + assert!(span(&far) < span(&near) * 0.97, "far {} near {}", span(&far), span(&near)); + // Bounds, hit testing and the selection box follow the drawn type. + let gb = n.geometric_bounds().unwrap(); + assert!((gb.x0 - b.x0).abs() < 12.0 && (gb.x1 - b.x1).abs() < 12.0, "{gb:?} {b:?}"); + let hit = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, b.center(), Default::default()).unwrap(); + assert_eq!(hit.leaf, id); + let _ = h; +} + +#[test] +fn type_in_perspective_exports_projected_outlines() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + let doc = s.doc().unwrap().doc.clone(); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert!(!svg.contains("HELLO"), "no flat text in the SVG"); + let pdf = vectorcraft_render::effects::bake_document(&doc).unwrap(); + let baked = pdf.node(id).unwrap(); + assert!(!matches!(baked.kind, NodeKind::Text(_))); + let bb = baked.geometric_bounds().unwrap(); + assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}"); +} + +#[test] +fn moving_type_in_perspective_moves_its_projection() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") }; + let flat = t.xf; + s.execute("perspective.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 40, 0]})).unwrap(); + let n = node(&s, id); + let NodeKind::Text(t) = &n.kind else { panic!() }; + assert_eq!(t.xf, flat, "the type itself is untouched"); + // Its drawn corner moved 40 pt along the plane. + let g = grid(&s); + let hi = g.homography(Plane::Right).unwrap().inverse().unwrap(); + let b0 = { + s.execute("edit.undo", &json!({})).unwrap(); + drawn_bounds(&s, id) + }; + s.execute("edit.redo", &json!({})).unwrap(); + let b1 = drawn_bounds(&s, id); + let d = hi.apply(Point::new(b1.x0, b1.y1)).unwrap() - hi.apply(Point::new(b0.x0, b0.y1)).unwrap(); + assert!((d.x - 40.0).abs() < 1.0 && d.y.abs() < 1.0, "{d:?}"); + let _ = rect; +} + +#[test] +fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let drawn = node(&s, id).geometric_bounds().unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("perspective.editText", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.isolation, Some(id)); + let n = node(&s, id); + assert!(n.projection().is_none(), "flat while editing"); + // Shown where it is drawn in perspective. + assert!(n.geometric_bounds().unwrap().center().distance(drawn.center()) < 1e-6); + s.execute("text.setText", &json!({"id": id.0, "text": "HELLO WORLD"})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let n = node(&s, id); + assert!(n.projection().is_some(), "projected again"); + // Nothing left flat anywhere in the history. + for e in &s.doc().unwrap().history.undo { + if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) { + assert!(!p.editing); + } + } + // Not type in perspective: refused. + let (mut s2, rect) = persp_session(); + assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err()); +} + +#[test] +fn symbols_in_perspective_stay_instances() { + let (mut s, rect) = persp_session(); + s.execute("perspective.release", &json!({"ids": [rect.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [rect.0]})).unwrap(); + let sym = s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + let _ = sym; + let r = s.execute("symbol.place", &json!({"name": "Box", "x": 470, "y": 380})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::SymbolInstance { .. })); + assert!(n.projection().is_some()); + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + assert!(!matches!(baked.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "exports draw the projected art"); +} + +#[test] +fn copies_pastes_and_duplicates_stay_attached() { + let (mut s, id) = persp_session(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(pasted, id); + assert_eq!(attached(&s, pasted), Some((Plane::Right, 0.0))); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0], "copy": true})).unwrap(); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0))); + // Deleting leaves nothing stale behind. + s.execute("edit.clear", &json!({})).unwrap(); + assert!(grid(&s).attached_plane(copy).is_none()); +} + +#[test] +fn a_selection_tool_move_keeps_the_attachment_and_type_follows() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + let before = drawn_bounds(&s, id); + s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap(); + let c = node(&s, rect).geometric_bounds().unwrap().center(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + drag(&mut s, &[c, Point::new(c.x + 25.0, c.y + 10.0), Point::new(c.x + 25.0, c.y + 10.0)], Mods::default()); + assert_eq!(attached(&s, rect), Some((Plane::Right, 0.0))); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + let after = drawn_bounds(&s, id); + assert!((after.x0 - before.x0 - 25.0).abs() < 1e-6 && (after.y0 - before.y0 - 10.0).abs() < 1e-6, "{before:?} {after:?}"); +} + +#[test] +fn release_keeps_the_look_of_type_in_perspective() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(attached(&s, id), None); + assert_eq!(drawn_bounds(&s, id), b); + assert!(persp::attachment(&node(&s, id)).is_none()); +} + +#[test] +fn flare_spiral_and_polar_grid_draw_in_perspective() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + for (tool, pts) in [("spiral", [(300.0, 380.0), (320.0, 390.0), (330.0, 400.0)]), ("polarGrid", [(250.0, 350.0), (280.0, 380.0), (300.0, 410.0)])] + { + let n = undo_len(&s); + s.select_tool(tool, ViewInfo::default()).unwrap(); + drag(&mut s, &pts.map(|(x, y)| Point::new(x, y)), Mods::default()); + assert_eq!(undo_len(&s), n + 1, "{tool}"); + assert_eq!(s.journal.last().unwrap().0, "perspective.draw", "{tool}"); + let id = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_eq!(attached(&s, id).map(|a| a.0), Some(Plane::Left), "{tool}"); + } + let r = s.execute("perspective.draw", &json!({"command": "shape.flare", "params": {"cx": 300, "cy": 380, "diameter": 40}})).unwrap(); + assert_eq!(attached(&s, NodeId(r["id"].as_u64().unwrap())).map(|a| a.0), Some(Plane::Left)); +} + +#[test] +fn click_to_size_dialogs_draw_on_the_active_plane_in_plane_units() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap(); + let at = Point::new(470.0, 300.0); + let (cmd, p) = crate::perspective_click(&s, "shape.rectangle", &json!({"x": at.x, "y": at.y, "width": 50, "height": 30}), at).unwrap(); + assert_eq!(cmd, "perspective.draw"); + let r = s.execute(&cmd, &p).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let g = grid(&s); + let hi = g.homography(Plane::Right).unwrap().inverse().unwrap(); + let q: Vec = node(&s, id).path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect(); + let b = q.iter().skip(1).fold(Rect::from_points(q[0], q[0]), |r, p| r.union_pt(*p)); + assert!((b.width() - 50.0).abs() < 1e-6 && (b.height() - 30.0).abs() < 1e-6, "{b:?}"); + assert!(q.iter().any(|p| (g.homography(Plane::Right).unwrap().apply(*p).unwrap() - at).hypot() < 1e-6), "the click stays a corner"); + // Without a shown grid (or with no active plane) nothing changes. + s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap(); + assert!(crate::perspective_click(&s, "shape.rectangle", &json!({}), at).is_none()); +} + +#[test] +fn the_canvas_draws_type_in_perspective_foreshortened() { + let (mut s, rect) = persp_session(); + s.execute("edit.clear", &json!({"ids": [rect.0]})).ok(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, b, 2.0, true); + // The inked height of a strip of columns near each end of the line. + let height = |x0: f64, x1: f64| { + let (mut lo, mut hi) = (u32::MAX, 0); + for x in (x0 * img.width as f64) as u32..(x1 * img.width as f64) as u32 { + for y in 0..img.height { + if img.pixel(x, y)[0] < 128 { + lo = lo.min(y); + hi = hi.max(y); + } + } + } + hi.saturating_sub(lo) + }; + let (near, far) = (height(0.0, 0.25), height(0.75, 1.0)); + assert!(near > 10 && far * 100 < near * 97, "near {near} far {far}"); +} + +#[test] +fn double_clicking_type_in_perspective_edits_it_and_projections_round_trip() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + // Saved and opened again: still type, still projected. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.node(id).unwrap().projection(), node(&s, id).projection()); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + let c = node(&s, id).geometric_bounds().unwrap().center(); + let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, c.x, c.y), v).unwrap(); + assert_eq!(r, vec![crate::UiRequest::SwitchTool("type".into())]); + assert_eq!(s.doc().unwrap().isolation, Some(id)); + // Saved while editing: the file has it projected. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(doc.node(id).unwrap().projection().is_some()); + s.execute("object.exitIsolation", &json!({})).unwrap(); + assert!(node(&s, id).projection().is_some()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 3bdc3323..6505e416 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -236,6 +236,12 @@ fn structured_junk() { ("perspective.plane.matchObject", json!({"id": u64::MAX})), ("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})), ("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})), + ("perspective.editText", json!({"id": u64::MAX})), + ( + "perspective.draw", + json!({"command": "shape.rectangle", "params": {"x": 1, "y": 1, "width": 10, "height": 10}, "at": [1e308, -1e308], "plane": "left"}), + ), + ("perspective.draw", json!({"command": "shape.flare", "params": {"cx": 0, "cy": 0}, "plane": "ground"})), ("object.setProps", json!({"opacity": -1, "blend": 5})), ("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})), ("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})), diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index bd386b2e..349ddb80 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -102,6 +102,18 @@ impl Homography { Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse()))) } + /// The affine map that matches `self` to first order at `p` (its derivative there), `None` + /// beyond the horizon. + pub fn affine_at(&self, p: Point) -> Option { + let m = &self.0; + let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; + let q = self.apply(p)?; + // d(x/w) = (dx − (x/w)·dw) / w, and the same for y. + let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w; + let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]); + Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2())) + } + /// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when /// part of it crosses the horizon. pub fn map_rect_bbox(&self, r: Rect) -> Option { @@ -151,6 +163,18 @@ mod tests { assert!(h().conjugated(Affine::scale(0.0)).is_none()); } + #[test] + fn affine_at_is_the_derivative() { + let p = Point::new(12.0, -7.0); + let a = h().affine_at(p).unwrap(); + assert!(a * p == h().apply(p).unwrap() || (a * p).distance(h().apply(p).unwrap()) < 1e-9); + let e = 1e-4; + for d in [kurbo::Vec2::new(e, 0.0), kurbo::Vec2::new(0.0, e)] { + let exact = h().apply(p + d).unwrap(); + assert!((a * (p + d)).distance(exact) < 1e-7, "{d:?}"); + } + } + #[test] fn rect_bbox_is_the_corners_box() { let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 30fd7ce4..9b4f2517 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -52,7 +52,7 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group /// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { - (effects::needs_outline(n) && visible(&n.appearance.effects)) + (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0654d9ac..0437ffff 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -20,7 +20,7 @@ mod width; use std::sync::Arc; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::Point; +use vectorcraft_geom::{Homography, Point}; use crate::Tool; @@ -46,6 +46,24 @@ pub fn create(id: &str) -> Option> { /// editable points (text, images, symbols, meshes, live objects) get the approximation at their /// centre. Live shapes become plain paths. pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { + warp_node(n, f, None); +} + +/// [`warp_node_with`] for the projective map `h` (perspective): type and symbol instances keep +/// their flat art and are drawn through `h` (composed with the projection they had). +pub fn project_node(n: &mut Node, h: &Homography) { + warp_node(n, &|p| h.apply(p).unwrap_or(p), Some(h)); +} + +fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) { + if let Some(h) = h + && matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + { + let rec = n.perspective.get_or_insert_with(Default::default); + let had = rec.homography().unwrap_or(Homography::IDENTITY); + rec.projection = Some(h.then_after(&had).to_array()); + return; + } let bounds = n.geometric_bounds(); // Finite-difference step: 5% of the object (at least half a point). let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5)); @@ -66,7 +84,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } => { for c in children.iter_mut() { - warp_node_with(Arc::make_mut(c), f); + warp_node(Arc::make_mut(c), f, h); } } _ => { diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 144461f1..4e893ac7 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -480,11 +480,18 @@ impl PerspectiveGrid { /// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two /// corners the user dragged between). pub fn attach_map(&self, plane: Plane, b: Rect) -> Option Option + use<>> { - self.attach_map_with(plane, b, false) + let h = self.attach_homography(plane, b, false)?; + Some(move |p: Point| h.apply(p)) } /// [`Self::attach_map`]; with `snap` the corners land on the nearest gridlines (Snap to Grid). pub fn attach_map_with(&self, plane: Plane, b: Rect, snap: bool) -> Option Option + use<>> { + let h = self.attach_homography(plane, b, snap)?; + Some(move |p: Point| h.apply(p)) + } + + /// [`Self::attach_map_with`] as one projective map. + pub fn attach_homography(&self, plane: Plane, b: Rect, snap: bool) -> Option { let h = self.homography(plane)?; let hi = h.inverse()?; let snapped = |q: Point| if snap { self.snap_plane(q) } else { q }; @@ -493,8 +500,23 @@ impl PerspectiveGrid { let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 }; let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } }; let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() }; - let (x0, y0) = (b.x0, b.y0); - Some(move |p: Point| h.apply(Point::new(a.x + (p.x - x0) * sx, a.y + (p.y - y0) * sy))) + let m = Affine::new([sx, 0.0, 0.0, sy, a.x - b.x0 * sx, a.y - b.y0 * sy]); + Some(h.then_after(&Homography::from_affine(m))) + } + + /// The map that puts flat art drawn at `at` onto `plane` keeping its sizes in plane units (a + /// click-to-size shape dialog's shape): `at` stays where it is, and right and down on the page + /// go the ways the plane's axes run on the page there. + /// With `snap` the click lands on the nearest gridline intersection (Snap to Grid). + pub fn size_homography(&self, plane: Plane, at: Point, snap: bool) -> Option { + let h = self.homography(plane)?; + let q = h.inverse()?.apply(at)?; + let q = if snap { self.snap_plane(q) } else { q }; + let at = h.apply(q)?; + let sx = if h.apply(q + Vec2::new(1.0, 0.0))?.x < at.x { -1.0 } else { 1.0 }; + let sy = if h.apply(q + Vec2::new(0.0, 1.0))?.y < at.y { -1.0 } else { 1.0 }; + let m = Affine::new([sx, 0.0, 0.0, sy, q.x - sx * at.x, q.y - sy * at.y]); + Some(h.then_after(&Homography::from_affine(m))) } /// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`. @@ -667,9 +689,20 @@ pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { out } -/// Attach `n` to `plane` at `depth`. +/// Attach `n` to `plane` at `depth` (type and symbols keep their projection). pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { - n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth))); + let rec = n.perspective.get_or_insert_with(Default::default); + rec.plane = plane.id().into(); + rec.depth = if depth.is_finite() { depth } else { 0.0 }; +} + +/// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their +/// projection. +pub fn release(n: &mut Node) { + match n.perspective.as_deref_mut() { + Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() }, + _ => n.perspective = None, + } } /// The bounds of `n` mapped by `hi`: paths by their mapped curves, other objects by their boxes' @@ -1076,7 +1109,16 @@ impl Tool for PerspectiveSelectionTool { } (PointerKind::DoubleClick, d) => { self.drag = d; - PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect() + if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + return vec![w.options()]; + } + // Type in perspective: Edit Text, then type. + match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) { + Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => { + vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())] + } + _ => vec![], + } } (_, d) => { self.drag = d; diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 42e5c376..83dd3a62 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -36,5 +36,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); let params = form::params(d); app.ui.dialog = None; + // A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows. + let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?)); + if let Some((c, p)) = + at("cx", "cy").or_else(|| at("x", "y")).and_then(|pt| vectorcraft_engine::perspective_click(&app.session, &cmd, ¶ms, pt)) + { + return app.run(&c, p); + } app.run(&cmd, params) } diff --git a/crates/ui-egui/src/dialogs/perspective_plane.rs b/crates/ui-egui/src/dialogs/perspective_plane.rs index 80bca187..4ac217fe 100644 --- a/crates/ui-egui/src/dialogs/perspective_plane.rs +++ b/crates/ui-egui/src/dialogs/perspective_plane.rs @@ -127,4 +127,23 @@ mod tests { assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane"); assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err()); } + + #[test] + fn a_click_to_size_rectangle_goes_on_the_active_plane() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.run("perspective.grid.preset", json!({"kind": 2})).unwrap(); + app.run("perspective.plane.set", json!({"plane": "right"})).unwrap(); + app.select_tool("rectangle"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 470.0, 300.0), view); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("rectangle")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.journal.last().unwrap().0, "perspective.draw"); + let st = app.session.active().unwrap(); + let id = *st.selection.objects.first().unwrap(); + assert_eq!(vectorcraft_tools::distort::perspective::attachment(st.doc.node(id).unwrap()).map(|a| a.0), Some(Plane::Right)); + } } diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index c1ca0c7e..75a8a4f7 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -38,5 +38,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}), ), }; - run_and_close(app, id, params) + // On the active plane while the perspective grid shows, sized in plane units from the click. + match vectorcraft_engine::perspective_click(&app.session, id, ¶ms, vectorcraft_geom::Point::new(x, y)) { + Some((id, params)) => run_and_close(app, &id, params), + None => run_and_close(app, id, params), + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b7c318e..c9077f70 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1706,6 +1706,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Attach to Active Plane", "perspective.attach"), c("Release with Perspective", "perspective.release"), c("Move Plane to Match Object", "perspective.plane.matchObject"), + c("Edit Text", "perspective.editText"), ], ), sub( diff --git a/docs/mcp.md b/docs/mcp.md index 56f07e03..2acd4f11 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1907,6 +1907,21 @@ selected object onto it. {"name":"run_command","arguments":{"command":"ui.perspectivePlane","params":{"plane":"ground"}}} ``` +Type and symbol instances attached to a plane stay type and symbol instances: their `perspective` record keeps a +`projection` (a 3 × 3 matrix) the canvas and every export (SVG, PDF, EPS, EMF/WMF, DXF, PNG) draw their outlines +through, so they are really foreshortened and still editable. `perspective.editText` (Object › Perspective › Edit +Text, or double-clicking the type with the Perspective Selection tool) shows the type flat where it is drawn, in +isolation mode, for the Type tool and `text.*` commands; `object.exitIsolation` projects it again. Release with +Perspective keeps their look. Shape, spiral, polar grid and flare drags draw on the active plane while the grid +shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the click; sizes are plane units). + +```json +{"name":"run_command","arguments":{"command":"perspective.editText","params":{}}} +{"name":"run_command","arguments":{"command":"text.setText","params":{"text":"OPEN"}}} +{"name":"run_command","arguments":{"command":"object.exitIsolation","params":{}}} +{"name":"run_command","arguments":{"command":"perspective.draw","params":{"command":"shape.rectangle","params":{"x":470,"y":300,"width":50,"height":30},"at":[470,300]}}} +``` + ## Blends `object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to