From 0f0dbbf9d55beaaafa3c75ab7a47fb9e17cb0171 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:03:18 +0700 Subject: [PATCH 1/2] M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid keeps per-plane offsets (leftOffset, rightOffset, groundOffset). Each plane has a widget (a diamond in its colour) that the Perspective Grid and Perspective Selection tools drag: plain moves the plane, Shift moves the objects on it too, Alt copies them (perspective.plane.move). Double-clicking a widget, or ui.perspectivePlane, opens the plane's options dialog (location; Do Not Move / Move All Objects / Copy All Objects). Object › Perspective › Move Plane to Match Object (perspective.plane.matchObject). New objects attach where the plane is. Grid edits leave attached art in place as the reference app does; perspective.grid.set {reproject: true} moves it with the grid. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/distortcmds.rs | 99 +++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_persp_planes.rs | 184 ++++++++++++++++++ crates/engine/tests/command_sweep.rs | 6 + crates/tools/src/distort/perspective.rs | 181 +++++++++++++++-- crates/ui-egui/src/dialogs/mod.rs | 2 + .../ui-egui/src/dialogs/perspective_plane.rs | 130 +++++++++++++ crates/ui-egui/src/dialogs/tools.rs | 7 + crates/ui-egui/src/menus.rs | 17 +- docs/control-protocol.md | 4 + docs/mcp.md | 15 ++ 11 files changed, 631 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/tests_persp_planes.rs create mode 100644 crates/ui-egui/src/dialogs/perspective_plane.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 13abcef8..f80b6666 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Define Perspective Grid", [], None, - "{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid", + "{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)", has_doc, grid_set ), @@ -164,6 +164,24 @@ pub fn specs() -> Vec { has_selection, persp_nudge ), + cmd!( + "perspective.plane.move", + "Move Plane", + [], + None, + "{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}", + has_doc, + plane_move + ), + cmd!( + "perspective.plane.matchObject", + "Move Plane to Match Object", + [], + None, + "{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}", + has_doc, + plane_match + ), ] } @@ -528,8 +546,19 @@ fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result Result { - let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?; + let old = grid_of(&s.doc()?.doc); + let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?; + let reproject = bool_or(p, "reproject", false); 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 (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))?; + } + } store_grid(d, &g); Ok(()) })?; @@ -582,7 +611,7 @@ fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane 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(plane, b).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, 0.0); + 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(()) } @@ -653,7 +682,7 @@ fn persp_edit( 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)?; - (pl, 0.0) + (pl, g.offset(pl)) } }; first.get_or_insert((plane, depth)); @@ -754,6 +783,68 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { persp_transform(s, &p) } +fn plane_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.plane.move"; + 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", + }; + let ids = s.edit(label, |d, sel| { + old.adopt_stored_attachments(d); + let mut ids = vec![]; + if objects != "none" { + // The objects on the plane where it was (not those moved off it along its normal). + let on: Vec = persp::attached_roots(d) + .into_iter() + .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id)) + .map(|(id, ..)| id) + .collect(); + 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))?; + 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::>() })) +} + +fn plane_match(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 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))?; + s.edit("Move Plane to Match Object", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "plane": plane.id(), "offset": depth })) +} + fn persp_nudge(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.nudge"; let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6026cc1..fc7225e3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1284,6 +1284,8 @@ mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; #[cfg(test)] +mod tests_persp_planes; +#[cfg(test)] mod tests_persp_select; #[cfg(test)] mod tests_perspgrid; diff --git a/crates/engine/src/tests_persp_planes.rs b/crates/engine/src/tests_persp_planes.rs new file mode 100644 index 00000000..669fdf81 --- /dev/null +++ b/crates/engine/src/tests_persp_planes.rs @@ -0,0 +1,184 @@ +//! Perspective planes: offsets, plane widgets (alone, Shift with the objects, Alt with copies), +//! Move Plane to Match Object, and grid edits with attached art. + +use serde_json::json; +use vectorcraft_geom::{Point, Vec2}; +use vectorcraft_tools::Mods; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; + +use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; +use super::*; + +fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +#[test] +fn moving_a_plane_alone_leaves_its_objects() { + let (mut s, id) = persp_session(); + let before = path(&s, id); + let n = undo_len(&s); + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 50})).unwrap(); + assert_eq!((r["plane"].as_str(), r["offset"].as_f64()), (Some("right"), Some(50.0))); + assert_eq!(undo_len(&s), n + 1); + let g = grid(&s); + assert_eq!(g.right_offset, 50.0); + // The plane is drawn where it was moved: its origin corner lies 50 pt along its normal. + let o = g.homography(Plane::Right).unwrap().apply(Point::ZERO).unwrap(); + assert!((o - g.homography_at(Plane::Right, 50.0).unwrap().apply(Point::ZERO).unwrap()).hypot() < 1e-9); + assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() > 1.0); + assert_eq!(path(&s, id), before); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + // Relative moves; new objects attach where the plane is. + s.execute("perspective.plane.move", &json!({"plane": "right", "by": -20})).unwrap(); + assert_eq!(grid(&s).right_offset, 30.0); + let r = s.execute("shape.rectangle", &json!({"x": 470, "y": 300, "width": 30, "height": 30})).unwrap(); + let b = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [b.0], "plane": "right"})).unwrap(); + assert_eq!(attached(&s, b), Some((Plane::Right, 30.0))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(grid(&s).right_offset, 50.0); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right"})).is_err()); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 1, "objects": "all"})).is_err()); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": -1e9})).is_err(), "behind the viewer"); +} + +#[test] +fn moving_a_plane_with_its_objects_or_copies() { + let (mut s, id) = persp_session(); + // A second object moved off the plane along its normal stays put. + let r = s.execute("shape.rectangle", &json!({"x": 560, "y": 330, "width": 30, "height": 30})).unwrap(); + let off = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [off.0], "plane": "right"})).unwrap(); + s.execute("perspective.transform", &json!({"ids": [off.0], "matrix": [1, 0, 0, 1, 0, 0], "depth": 20})).unwrap(); + let off_path = path(&s, off); + let q = plane_pts(&s, id, Plane::Right, 0.0); + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 35, "objects": "move"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(attached(&s, id), Some((Plane::Right, 35.0))); + for (a, b) in q.iter().zip(plane_pts(&s, id, Plane::Right, 35.0)) { + assert!((*a - b).hypot() < 1e-6, "keeps its place on the plane"); + } + assert_eq!(path(&s, off), off_path); + // Copies: the originals stay, the copies go with the plane. + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 60, "objects": "copy"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert_ne!(copy, id); + assert_eq!(attached(&s, id), Some((Plane::Right, 35.0))); + assert_eq!(attached(&s, copy), Some((Plane::Right, 60.0))); + for (a, b) in q.iter().zip(plane_pts(&s, copy, Plane::Right, 60.0)) { + assert!((*a - b).hypot() < 1e-6); + } +} + +#[test] +fn plane_widgets_drag_with_shift_and_alt() { + let (mut s, id) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + for tool in ["perspectiveGrid", "perspectiveSelection"] { + s.select_tool(tool, ViewInfo::default()).unwrap(); + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let from = g.offset(Plane::Right); + // Where the widget lands 15 pt further along the plane's normal. + let to = g + .homography_at(Plane::Right, from + 15.0) + .unwrap() + .apply(g.homography(Plane::Right).unwrap().inverse().unwrap().apply(w).unwrap()) + .unwrap(); + let n = undo_len(&s); + drag(&mut s, &[w, to, to], Mods { shift: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1, "{tool}"); + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), p["objects"].as_str()), ("perspective.plane.move", Some("move"))); + assert!((grid(&s).right_offset - from - 15.0).abs() < 1e-6, "{tool}: {}", grid(&s).right_offset); + assert!((attached(&s, id).unwrap().1 - from - 15.0).abs() < 1e-6, "{tool}: the object went along"); + } + // Alt copies the objects; a plain drag moves the plane alone. + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len(); + let k = count(&s); + drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods { alt: true, ..Default::default() }); + assert_eq!(count(&s), k + 1); + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let depth = attached(&s, id).unwrap().1; + drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods::default()); + assert_eq!(attached(&s, id).unwrap().1, depth); + assert_eq!(count(&s), k + 1); +} + +#[test] +fn double_clicking_a_plane_widget_asks_for_its_options() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + let v = ViewInfo::default(); + let (_, w) = grid(&s).plane_widgets().into_iter().find(|(p, _)| *p == Plane::Ground).unwrap(); + for tool in ["perspectiveGrid", "perspectiveSelection"] { + s.select_tool(tool, v).unwrap(); + let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, w.x, w.y), v).unwrap(); + assert_eq!(r, vec![crate::UiRequest::Dialog("perspectivePlane".into(), json!({"plane": "ground"}))], "{tool}"); + } +} + +#[test] +fn move_plane_to_match_object() { + let (mut s, id) = persp_session(); + let q0 = plane_pts(&s, id, Plane::Right, 0.0); + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -25})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); + let r = s.execute("perspective.plane.matchObject", &json!({})).unwrap(); + assert_eq!(r, json!({"plane": "right", "offset": -25.0})); + let g = grid(&s); + assert_eq!((g.right_offset, g.plane), (-25.0, Plane::Right)); + // The object now lies on the plane where it is drawn. + let h = g.homography(Plane::Right).unwrap(); + for (q, a) in q0.iter().zip(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors()) { + assert!((h.apply(*q).unwrap() - a.2.p).hypot() < 1e-6); + } + s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("perspective.plane.matchObject", &json!({"id": id.0})).is_err()); +} + +#[test] +fn grid_edits_keep_attached_art_in_place_unless_reprojected() { + let (mut s, id) = persp_session(); + let before = path(&s, id); + let q = plane_pts(&s, id, Plane::Right, 0.0); + let vr = grid(&s).vp_right; + s.execute("perspective.grid.set", &json!({"vpRight": vr + 150.0})).unwrap(); + assert_eq!(path(&s, id), before, "as in the reference app, art stays where it is"); + s.execute("perspective.grid.set", &json!({"vpRight": vr - 100.0, "reproject": true})).unwrap(); + assert_ne!(path(&s, id), before); + // Re-projected from the previous grid: the same place on the plane of the new one. + let old = { + let mut g = grid(&s); + g.vp_right = vr + 150.0; + g + }; + let hi = old.homography(Plane::Right).unwrap().inverse().unwrap(); + let back: Vec = before.anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect(); + for (a, b) in back.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}"); + } + assert!(q.len() == back.len()); +} + +#[test] +fn plane_offsets_round_trip_and_old_grids_have_none() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.set", &json!({"leftOffset": 12, "groundOffset": -8})).unwrap(); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let g = PerspectiveGrid::from_doc(&doc).unwrap(); + assert_eq!((g.left_offset, g.right_offset, g.ground_offset), (12.0, 0.0, -8.0)); + let mut stored = doc.unknown["perspectiveGrid"].clone(); + assert!(stored.get("rightOffset").is_none(), "zero offsets aren't written"); + for k in ["leftOffset", "groundOffset"] { + stored.as_object_mut().unwrap().remove(k); + } + let old: PerspectiveGrid = serde_json::from_value(stored).unwrap(); + assert_eq!((old.left_offset, old.ground_offset), (0.0, 0.0)); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6ffc2722..3bdc3323 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -230,6 +230,12 @@ fn structured_junk() { ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})), ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})), ("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})), + ("perspective.plane.move", json!({"plane": "ground", "offset": 1e308, "objects": "copy"})), + ("perspective.plane.move", json!({"plane": "left", "by": -1e7, "objects": "move"})), + ("perspective.plane.move", json!({"plane": "none", "offset": 0})), + ("perspective.plane.matchObject", json!({"id": u64::MAX})), + ("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})), + ("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})), ("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/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 28789054..577d67b2 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -83,6 +83,9 @@ fn two() -> u8 { fn yes() -> bool { true } +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} /// The perspective grid definition (Define Grid) plus view state. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -141,6 +144,14 @@ pub struct PerspectiveGrid { /// Gridline opacity, 0–100 %. #[serde(default = "define::half")] pub opacity: f64, + /// Where the left, right and horizontal planes were moved along their normals (points; 0: their + /// place in the grid's definition): the plane widgets and `perspective.plane.move` set them. + #[serde(default, skip_serializing_if = "is_zero")] + pub left_offset: f64, + #[serde(default, skip_serializing_if = "is_zero")] + pub right_offset: f64, + #[serde(default, skip_serializing_if = "is_zero")] + pub ground_offset: f64, } impl PerspectiveGrid { @@ -174,6 +185,29 @@ impl PerspectiveGrid { right_color: define::right_rgb(), ground_color: define::ground_rgb(), opacity: define::half(), + left_offset: 0.0, + right_offset: 0.0, + ground_offset: 0.0, + } + } + + /// Where `plane` is along its normal (points; 0: its place in the grid's definition). + pub fn offset(&self, plane: Plane) -> f64 { + match plane { + Plane::Left => self.left_offset, + Plane::Right => self.right_offset, + Plane::Ground => self.ground_offset, + Plane::None => 0.0, + } + } + + /// Move `plane` to `offset` along its normal. + pub fn set_offset(&mut self, plane: Plane, offset: f64) { + match plane { + Plane::Left => self.left_offset = offset, + Plane::Right => self.right_offset = offset, + Plane::Ground => self.ground_offset = offset, + Plane::None => {} } } @@ -293,6 +327,11 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } + // A moved plane stays in front of the viewer. + let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9); + if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) { + return Err("a plane is moved too far (behind the viewer)".into()); + } self.validate_definition() } @@ -326,9 +365,9 @@ impl PerspectiveGrid { }) } - /// The homography of `plane` (plane coordinates in points → page). + /// The homography of `plane` where it is now (plane coordinates in points → page). pub fn homography(&self, plane: Plane) -> Option { - self.homography_at(plane, 0.0) + self.homography_at(plane, self.offset(plane)) } /// The homography of the plane parallel to `plane` that lies `depth` points along its normal @@ -500,6 +539,78 @@ impl PerspectiveGrid { } faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl) } + + /// The left, right and horizontal plane widgets: where each sits (on its plane: the middle of a + /// wall's ground edge, the middle of the horizontal plane). + pub fn plane_widgets(&self) -> Vec<(Plane, Point)> { + let e = self.extent / 2.0; + Plane::ALL + .into_iter() + .filter_map(|pl| { + let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) }; + Some((pl, self.homography(pl)?.apply(q)?)) + }) + .collect() + } +} + +/// Dragging a plane widget: the plane moves along its normal by as much as the pointer moved along +/// it (from the widget, wherever on it the press was). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlaneDrag { + pub plane: Plane, + anchor: Point, + press: Point, + offset: f64, +} + +impl PlaneDrag { + /// The plane widget within `tol` of `p` (drawn grids only). + pub fn hit(g: &PerspectiveGrid, tol: f64, p: Point) -> Option { + if !g.visible { + return None; + } + let (plane, anchor) = g.plane_widgets().into_iter().find(|(_, q)| q.distance(p) <= tol)?; + Some(Self { plane, anchor, press: p, offset: g.offset(plane) }) + } + + /// The preview for the pointer at `p`: Shift moves the objects on the plane with it, Alt copies + /// them. + pub fn preview(&self, g: &PerspectiveGrid, p: Point, m: Mods) -> Option { + let offset = g.depth_at(self.plane, self.offset, self.anchor, self.anchor + (p - self.press))?; + let objects = if m.alt { + "copy" + } else if m.shift { + "move" + } else { + "none" + }; + Some(Action::Preview("perspective.plane.move".into(), json!({"plane": self.plane.id(), "offset": offset, "objects": objects}))) + } + + /// The plane options dialog a double-click on the widget opens. + pub fn options(&self) -> Action { + Action::Dialog(PLANE_DIALOG.into(), json!({"plane": self.plane.id()})) + } +} + +/// The dialog kind of a plane's options (double-click its widget). +pub const PLANE_DIALOG: &str = "perspectivePlane"; + +/// The plane widgets as drawn: a diamond in each plane's colour. +fn plane_widget_overlays(g: &PerspectiveGrid, tol: f64) -> Vec { + let r = 5.0 * tol; + g.plane_widgets() + .into_iter() + .flat_map(|(pl, p)| { + let [cr, cg, cb] = pl.color(); + let quad = [Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)]; + [ + Overlay::Highlight { quad, color: [cr, cg, cb, 220] }, + Overlay::Path { path: super::diamond(p, r), color: [0x20, 0x20, 0x20], width: 1.0, dashed: false }, + ] + }) + .collect() } /// How far (points) along a plane's normal objects and planes may go. @@ -520,6 +631,21 @@ pub fn attachment(n: &Node) -> Option<(Plane, f64)> { Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) } +/// The objects in perspective that aren't inside another one: (id, plane, depth). +pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { + fn visit(n: &Node, out: &mut Vec<(NodeId, Plane, f64)>) { + match attachment(n) { + Some((pl, depth)) => out.push((n.id, pl, depth)), + None => n.children().into_iter().flatten().for_each(|c| visit(c, out)), + } + } + let mut out = vec![]; + for l in &doc.layers { + visit(l, &mut out); + } + out +} + /// Attach `n` to `plane` at `depth`. pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth))); @@ -611,6 +737,7 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec { width: 1.5, dashed: false, }); + out.extend(plane_widget_overlays(&g, tol)); out } @@ -683,6 +810,8 @@ fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid { #[derive(Default)] pub struct PerspectiveGridTool { drag: Option, + /// Dragging a plane widget. + plane: Option, } impl Tool for PerspectiveGridTool { @@ -702,6 +831,11 @@ impl Tool for PerspectiveGridTool { pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); return pre; } + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.plane = Some(d); + pre.push(Action::Begin("Move Plane".into())); + return pre; + } let tol = cx.tol(6.0); if let Some((h, _)) = handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) @@ -711,14 +845,16 @@ impl Tool for PerspectiveGridTool { } pre } + PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(), PointerKind::Drag => match self.drag { Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())], None => vec![], }, - PointerKind::Up => match self.drag.take() { - Some(_) => vec![Action::Commit], - None => vec![], + PointerKind::Up => match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], }, + PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(), _ => vec![], } } @@ -735,6 +871,9 @@ impl Tool for PerspectiveGridTool { fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { let g = PerspectiveGrid::current(cx.doc); let tol = cx.tol(6.0); + if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() { + return Cursor::Move; + } match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV, Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH, @@ -743,10 +882,13 @@ impl Tool for PerspectiveGridTool { } } fn busy(&self) -> bool { - self.drag.is_some() + self.drag.is_some() || self.plane.is_some() } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], + } } } @@ -761,6 +903,8 @@ enum SelDrag { Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, + /// Dragging a plane widget. + Plane(PlaneDrag), } /// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move @@ -805,7 +949,7 @@ impl PerspectiveSelectionTool { if g.plane != Plane::None { v["plane"] = json!(g.plane.id()); } - let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, 0.0))); + let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); self.measure = at.and_then(|(pl, depth)| { let text = if *perp { format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) @@ -834,6 +978,10 @@ impl Tool for PerspectiveSelectionTool { { return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))]; } + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.drag = SelDrag::Plane(d); + return vec![Action::Begin("Move Plane".into())]; + } if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g) && let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0)) { @@ -886,10 +1034,18 @@ impl Tool for PerspectiveSelectionTool { self.measure = None; if began { vec![Action::Commit] } else { vec![] } } - (PointerKind::Up, SelDrag::Scale { .. }) => { + (PointerKind::Drag, SelDrag::Plane(d)) => { + self.drag = SelDrag::Plane(d); + d.preview(&g, p, m).into_iter().collect() + } + (PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => { self.measure = None; vec![Action::Commit] } + (PointerKind::DoubleClick, d) => { + self.drag = d; + PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect() + } (_, d) => { self.drag = d; vec![] @@ -947,9 +1103,12 @@ impl Tool for PerspectiveSelectionTool { let g = PerspectiveGrid::current(cx.doc); let handle = match &self.drag { SelDrag::Scale { handle, .. } => Some(*handle), - SelDrag::Move { began: true, .. } => return Cursor::Move, + SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move, _ => None, }; + if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() { + return Cursor::Move; + } if let Some((_, _, rect, h)) = selection_persp_box(cx, &g) && let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0))) && let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center())) @@ -964,7 +1123,7 @@ impl Tool for PerspectiveSelectionTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.measure = None; match std::mem::take(&mut self.drag) { - SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } => vec![Action::Commit], + SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit], _ => vec![], } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 0d43061f..9946bcde 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -43,6 +43,7 @@ pub mod package; mod path_ops; pub mod pdf_presets; pub mod perspective_grid; +pub mod perspective_plane; pub mod perspective_presets; pub mod place; pub mod placement_options; @@ -259,6 +260,7 @@ registry! { LiquifyOptions: [liquify::KIND] => liquify::SPEC, PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC, BlendOptions: [blend_options::KIND] => blend_options::SPEC, + PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/perspective_plane.rs b/crates/ui-egui/src/dialogs/perspective_plane.rs new file mode 100644 index 00000000..80bca187 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_plane.rs @@ -0,0 +1,130 @@ +//! Perspective plane options: double-clicking a plane widget of the perspective grid (or +//! `ui.perspectivePlane {plane}`) moves that plane to an exact location, alone, with the objects on +//! it, or with copies of them. OK runs `perspective.plane.move`. +//! +//! Fields: `plane` (`left`, `right` or `ground`), `location` (points along the plane's normal; 0 +//! is its place in the grid's definition), `objects` (`none`, `move` or `copy`). + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the plane options. +pub const KIND: &str = "perspectivePlane"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +/// What becomes of the objects on the plane: (`objects` value, label). +const OBJECTS: [(&str, &str); 3] = [("none", "Do Not Move"), ("move", "Move All Objects"), ("copy", "Copy All Objects")]; + +fn heading(d: &Dialog) -> String { + match Plane::parse(&d.str("plane")) { + Some(Plane::Right) => "Right Plane", + Some(Plane::Ground) => "Horizontal Plane", + _ => "Left Plane", + } + .into() +} + +/// Open the options of `plane` (`{plane}`; default the active plane), filled in with its location. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let doc = &app.session.active().ok_or("no document")?.doc; + let g = PerspectiveGrid::current(doc); + let plane = match p.get("plane").and_then(Value::as_str) { + Some(s) => Plane::parse(s).ok_or("plane must be left, right or ground")?, + None => g.plane, + }; + if plane == Plane::None { + return Err("no active perspective plane".into()); + } + let fields = json!({"plane": plane.id(), "location": g.offset(plane), "objects": "none"}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + grid(ui, |ui| { + label(ui, "Location:"); + if let Some(v) = widgets::num_field(ui, "pp-location", Some(d.f64("location", 0.0)), unit, 120.0) { + d.fields.insert("location".into(), json!(v)); + } + ui.end_row(); + }); + ui.add_space(6.0); + let cur = d.str("objects"); + for (value, text) in OBJECTS { + if widgets::radio(ui, text, cur == value || (cur.is_empty() && value == "none"), true) { + d.fields.insert("objects".into(), json!(value)); + } + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let objects = Some(d.str("objects")).filter(|o| OBJECTS.iter().any(|(v, _)| v == o)).unwrap_or_else(|| "none".into()); + let p = json!({"plane": d.str("plane"), "offset": d.f64("location", 0.0), "objects": objects}); + run_and_close(app, "perspective.plane.move", p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + fn shown(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // A new window lays itself out in its first frame and paints in the next. + ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear(); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + #[test] + fn double_clicking_a_plane_widget_opens_its_options_and_ok_moves_it() { + 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(); + let id = app.run("shape.rectangle", json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("perspective.attach", json!({"ids": [id], "plane": "right"})).unwrap(); + let g = PerspectiveGrid::current(&app.session.active().unwrap().doc); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + app.select_tool("perspectiveGrid"); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, w.x, w.y), view); + let d = app.ui.dialog.clone().expect("the plane's options opened"); + assert_eq!((d.kind.as_str(), d.str("plane").as_str(), d.f64("location", 1.0)), (KIND, "right", 0.0)); + let texts = shown(&mut app); + for s in ["Right Plane", "Location:", "Do Not Move", "Move All Objects", "Copy All Objects"] { + assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("location".into(), json!(40)); + d.fields.insert("objects".into(), json!("copy")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.active().unwrap().doc; + assert_eq!(PerspectiveGrid::current(doc).right_offset, 40.0); + assert_eq!(doc.layers[0].children().unwrap().len(), 2, "copied"); + // The UI command opens it too; unknown planes are refused. + app.run("ui.perspectivePlane", json!({"plane": "ground"})).unwrap(); + assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane"); + assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 32ebfc19..80d88588 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -63,6 +63,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } return; } + // Double-clicking a plane widget of the perspective grid: that plane's options. + super::perspective_plane::KIND => { + if let Err(e) = super::perspective_plane::open(app, &p) { + app.status(e); + } + return; + } // Double-clicking a slice with the Slice Selection tool: Slice Options. super::slices::OPTIONS => { if let Err(e) = super::slices::open_options(app) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 573b08ad..7068fdaa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -661,6 +661,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -1088,6 +1094,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("no such perspective grid preset".into()), }, "ui.blendOptions" => crate::dialogs::blend_options::open(app), + "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), _ => return None, }; Some(r) @@ -1382,6 +1389,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() } + "ui.perspectivePlane" => app.session.active().is_some(), _ => true, } } @@ -1682,7 +1690,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Edit Contents", "object.envelope.editContents"), ], ), - sub("Perspective", vec![c("Attach to Active Plane", "perspective.attach"), c("Release with Perspective", "perspective.release")]), + sub( + "Perspective", + vec![ + c("Attach to Active Plane", "perspective.attach"), + c("Release with Perspective", "perspective.release"), + c("Move Plane to Match Object", "perspective.plane.matchObject"), + ], + ), sub( "Live Paint", vec![ diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ca5bfbd..6b3e1beb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -229,6 +229,10 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt `adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard", value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`. +Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`, +opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's +normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them. + Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`, Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a diff --git a/docs/mcp.md b/docs/mcp.md index 8e17a611..22ba39ce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1863,6 +1863,21 @@ copies). Object › Transform › Transform Again repeats the last perspective m {"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}} ``` +Planes move along their normals: `perspective.plane.move {plane, offset | by, objects}` (`leftOffset`, +`rightOffset`, `groundOffset` in the grid). The plane widgets (a diamond on each plane, drawn while the grid shows) +do it with the Perspective Grid and Perspective Selection tools: a plain drag moves the plane alone, Shift-drag moves +the objects on it too, Alt-drag copies them; double-clicking a widget (or `ui.perspectivePlane {plane}`) opens the +`perspectivePlane` dialog (`location` in points, `objects`: none, move or copy). Objects keep their place when the +grid's definition changes, as in the reference app; `perspective.grid.set` with `reproject: true` moves them with it +instead. `perspective.plane.matchObject` (Object › Perspective › Move Plane to Match Object) moves the plane of the +selected object onto it. + +```json +{"name":"run_command","arguments":{"command":"perspective.plane.move","params":{"plane":"right","offset":40,"objects":"move"}}} +{"name":"run_command","arguments":{"command":"perspective.plane.matchObject","params":{}}} +{"name":"run_command","arguments":{"command":"ui.perspectivePlane","params":{"plane":"ground"}}} +``` + ## Blends `object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to From e774f15537211c9de6f49f839d4464bb77be308a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:44:12 +0700 Subject: [PATCH 2/2] ROADMAP: M8.13 landed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 87010689..f5435d79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo) | 8–12 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object) | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |