From ecf245b7c9a8245630c597caff498115e4f0444e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:28:48 +0700 Subject: [PATCH 1/2] M8.8: Define Grid dialog prefilled from the grid, with viewing angle, units, scale, colours and opacity View > Perspective Grid > Define Grid... opens a dedicated `perspectiveGrid` dialog (ui.perspectiveGridDialog) prefilled from the current grid instead of a generic form with fixed defaults that forced kind 2. The grid model gains (append-only) name, viewing angle (the station point), units, scale, plane colours and opacity; grids with a viewing angle foreshorten each axis as a true camera. New commands: perspective.grid.get (read-only) and perspective.grid.define (one undo step, only changed fields apply). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/perspgrid.rs | 66 +++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_perspgrid.rs | 113 ++++ crates/tools/src/distort/perspective.rs | 55 +- .../tools/src/distort/perspective/define.rs | 511 ++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 5 + .../ui-egui/src/dialogs/perspective_grid.rs | 204 +++++++ .../ui-egui/src/dialogs/tests_perspective.rs | 81 +++ crates/ui-egui/src/menus.rs | 10 +- docs/control-protocol.md | 6 + docs/mcp.md | 18 + 12 files changed, 1061 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/perspgrid.rs create mode 100644 crates/engine/src/tests_perspgrid.rs create mode 100644 crates/tools/src/distort/perspective/define.rs create mode 100644 crates/ui-egui/src/dialogs/perspective_grid.rs create mode 100644 crates/ui-egui/src/dialogs/tests_perspective.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5622ba5c..d61365d6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -46,6 +46,7 @@ mod path; mod pathops; mod patterncmds; pub mod pdfcmds; +pub mod perspgrid; mod place; pub mod plugin; pub mod prefscmds; @@ -250,6 +251,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(plugin::specs()); v.extend(cut::specs()); v.extend(halftone::specs()); + v.extend(perspgrid::specs()); v }) } diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs new file mode 100644 index 00000000..f27e84d8 --- /dev/null +++ b/crates/engine/src/cmd/perspgrid.rs @@ -0,0 +1,66 @@ +//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the +//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those +//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`. + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid}; + +use super::distortcmds::{grid_of, store_grid}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "perspective.grid.get", + "Perspective Grid", + [], + None, + "{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}", + has_doc, + grid_get + ), + cmd!( + "perspective.grid.define", + "Define Perspective Grid", + [], + None, + "{name?: preset name (\"\" = custom), kind?: 1|2|3, units?: unit name (points, pixels, inches, centimeters, millimeters…), scale?: [artboard, real world], gridline?: gridline every, angle?: viewing angle (0–90°), distance?: viewing distance, horizonHeight?: above the ground level, thirdVp?: [x right, y up] from the centre of vision (3-point), leftColor?, rightColor?, groundColor?: \"#rrggbb\", opacity?: 0–100} lengths are real-world lengths in `units` at `scale`; missing fields keep the grid's (see perspective.grid.get define). Only what changes changes: a new type, angle or distance moves the vanishing points around the station point. One undo step → the new define fields", + has_doc, + grid_define + ), + ] +} + +/// What `perspective.grid.get` reports for `g`. +pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value { + let st = g.station(); + json!({ + "grid": g.definition_json(), + "define": g.definition(), + "station": {"x": st.x, "y": g.horizon, "distance": st.distance}, + "defined": PerspectiveGrid::from_doc(doc).is_some(), + }) +} + +fn grid_get(s: &mut Session, _: &Value) -> Result { + let doc = &s.doc()?.doc; + Ok(grid_info(doc, &grid_of(doc))) +} + +fn grid_define(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.define"; + let old = grid_of(&s.doc()?.doc); + let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?; + let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?; + // A changed grid is no longer the preset it came from (unless it was given a name). + if def.name == old.name && !def.same(&old.definition()) { + g.name.clear(); + } + if g != old { + s.edit("Define Perspective Grid", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + } + Ok(json!(g.definition())) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ce953a5a..78e9bf54 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1242,6 +1242,8 @@ mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; #[cfg(test)] +mod tests_perspgrid; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_plugins; diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs new file mode 100644 index 00000000..5aeb0b7a --- /dev/null +++ b/crates/engine/src/tests_perspgrid.rs @@ -0,0 +1,113 @@ +//! Perspective Grid definition, presets and view options (`perspective.grid.get` / `define`, +//! `perspective.presets.*`, View → Perspective Grid toggles, the grid widgets). + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn grid(s: &Session) -> PerspectiveGrid { + PerspectiveGrid::effective(&s.doc().unwrap().doc) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +// ---------- M8.8: Define Grid ---------- + +#[test] +fn grid_get_reports_the_model_the_define_fields_and_the_station_point() { + let mut s = session(); + let r = run(&mut s, "perspective.grid.get", json!({})); + assert_eq!(r["defined"], json!(false), "the default grid until one is set"); + assert_eq!(r["grid"]["kind"], json!(2)); + assert_eq!(r["define"]["name"], json!("[2P-Normal View]")); + assert_eq!((r["define"]["angle"].clone(), r["define"]["units"].clone()), (json!(45.0), json!("points"))); + let g = grid(&s); + assert!(near(r["station"]["x"].as_f64().unwrap(), g.origin[0]), "the normal view looks at the corner"); + assert!(near(r["station"]["distance"].as_f64().unwrap(), (g.vp_right - g.vp_left) / 2.0)); + // A query: nothing to undo. + assert_eq!(undo_len(&s), 0); + run(&mut s, "perspective.grid.preset", json!({"kind": 1})); + assert_eq!(run(&mut s, "perspective.grid.get", json!({}))["defined"], json!(true)); +} + +#[test] +fn define_sets_the_grid_from_the_dialog_fields_in_one_undo_step() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let before = grid(&s); + let n = undo_len(&s); + let r = run( + &mut s, + "perspective.grid.define", + json!({"units": "inches", "gridline": 0.5, "angle": 30, "distance": 4, "horizonHeight": 2, "leftColor": "#00ff00", "opacity": 75}), + ); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(r["units"], json!("inches")); + let g = grid(&s); + assert!(near(g.cell, 36.0) && near(g.origin[1] - g.horizon, 144.0)); + let st = g.station(); + assert!(near(st.distance, 288.0) && near(st.x, before.station().x), "the viewer stays put"); + assert!(near(g.viewing_angle(), 30.0)); + assert_eq!((g.left_color, g.opacity, g.name.as_str()), (Rgb([0, 255, 0]), 75.0, ""), "no longer the preset"); + // Unchanged fields leave the grid alone: no undo step. + let same = run(&mut s, "perspective.grid.get", json!({}))["define"].clone(); + run(&mut s, "perspective.grid.define", same); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(grid(&s), before); + // Bad values are refused and change nothing. + for bad in [json!({"angle": 0}), json!({"distance": -1}), json!({"units": "parsecs"}), json!({"kind": 5}), json!({"rightColor": "red"})] { + assert!(s.execute("perspective.grid.define", &bad).is_err(), "{bad}"); + } + assert_eq!(grid(&s), before); +} + +#[test] +fn define_switches_the_type_around_the_station_point() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let st = grid(&s).station(); + run(&mut s, "perspective.grid.define", json!({"kind": 1})); + let g = grid(&s); + assert_eq!(g.kind, 1); + assert!(near(g.vp_left, st.x) && near(g.distance, st.distance), "one-point looks straight at the station's centre of vision"); + run(&mut s, "perspective.grid.define", json!({"kind": 3, "thirdVp": [0, 500]})); + let g = grid(&s); + assert!(near(g.vp_vertical[0], g.station().x) && near(g.horizon - g.vp_vertical[1], 500.0)); +} + +#[test] +fn grid_definition_fields_survive_a_native_round_trip() { + let mut s = session(); + run(&mut s, "perspective.grid.define", json!({"units": "cm", "scale": [1, 4], "angle": 40, "groundColor": "#123456", "opacity": 20})); + let doc = s.doc().unwrap().doc.clone(); + vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(PerspectiveGrid::from_doc(&back), PerspectiveGrid::from_doc(&doc)); + let g = PerspectiveGrid::from_doc(&back).unwrap(); + assert_eq!((g.units.as_str(), g.scale, g.ground_color, g.opacity), ("centimeters", [1.0, 4.0], Rgb([0x12, 0x34, 0x56]), 20.0)); + // A grid saved before these fields loads with their defaults. + let mut old = serde_json::to_value(&g).unwrap(); + for k in ["name", "angle", "units", "scale", "leftColor", "rightColor", "groundColor", "opacity"] { + old.as_object_mut().unwrap().remove(k); + } + let g: PerspectiveGrid = serde_json::from_value(old).unwrap(); + assert_eq!((g.angle, g.units.as_str(), g.scale, g.opacity), (None, "points", [1.0, 1.0], 50.0)); +} diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 0ab111f3..a1ae9481 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -24,6 +24,9 @@ use vectorcraft_geom::{Point, Rect}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +pub mod define; +pub use define::{GridDefinition, Rgb, Station}; + /// Key under `Document.unknown`. pub const DOC_KEY: &str = "perspectiveGrid"; @@ -160,6 +163,30 @@ pub struct PerspectiveGrid { /// Objects attached to planes (node id → plane). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub attached: BTreeMap, + /// The preset the grid came from ("" = custom). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// Viewing angle in degrees (two/three-point): where the station point stands between the + /// vanishing points ([`Station`]). None for grids made before it: they foreshorten every axis + /// by `distance`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub angle: Option, + /// The unit Define Grid measures in (a unit name, e.g. `points`, `inches`). + #[serde(default = "define::points", skip_serializing_if = "define::is_points")] + pub units: String, + /// Scale: `[artboard, real world]` lengths (Define Grid's real-world lengths over this). + #[serde(default = "define::one_to_one", skip_serializing_if = "define::is_one_to_one")] + pub scale: [f64; 2], + /// Gridline colours of the left, right and horizontal (ground) planes. + #[serde(default = "define::left_rgb")] + pub left_color: Rgb, + #[serde(default = "define::right_rgb")] + pub right_color: Rgb, + #[serde(default = "define::ground_rgb")] + pub ground_color: Rgb, + /// Gridline opacity, 0–100 %. + #[serde(default = "define::half")] + pub opacity: f64, } impl PerspectiveGrid { @@ -185,6 +212,14 @@ impl PerspectiveGrid { visible: true, plane: Plane::Left, attached: BTreeMap::new(), + name: String::new(), + angle: None, + units: define::points(), + scale: define::one_to_one(), + left_color: define::left_rgb(), + right_color: define::right_rgb(), + ground_color: define::ground_rgb(), + opacity: define::half(), } } @@ -193,12 +228,9 @@ impl PerspectiveGrid { doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok()) } - /// The document's grid, or the default 2-point preset for the first artboard (hidden). + /// The document's grid, or the default two-point preset for the first artboard (hidden). pub fn effective(doc: &Document) -> Self { - Self::from_doc(doc).unwrap_or_else(|| { - let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - Self { visible: false, ..Self::preset(2, ab) } - }) + Self::from_doc(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) }) } /// Store into the document. @@ -230,7 +262,8 @@ impl PerspectiveGrid { o.insert(k.clone(), val.clone()); } } - let g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + let mut g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + g.reconcile(self, patch); g.validate()?; Ok(g) } @@ -261,7 +294,7 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } - Ok(()) + self.validate_definition() } pub fn planes(&self) -> [Plane; 3] { @@ -270,11 +303,11 @@ impl PerspectiveGrid { /// The homography of `plane` (plane coordinates in points → page). pub fn homography(&self, plane: Plane) -> Option { - let l = 1.0 / self.distance.max(1e-9); + let (ll, lr, lu) = self.foreshortening(); let o = [self.origin[0], self.origin[1], 1.0]; - let vl = [self.vp_left * l, self.horizon * l, l]; - let vr = [self.vp_right * l, self.horizon * l, l]; - let up = if self.kind == 3 { [self.vp_vertical[0] * l, self.vp_vertical[1] * l, l] } else { [0.0, -1.0, 0.0] }; + let vl = [self.vp_left * ll, self.horizon * ll, ll]; + let vr = [self.vp_right * lr, self.horizon * lr, lr]; + let up = if self.kind == 3 { [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu] } else { [0.0, -1.0, 0.0] }; let flat = [1.0, 0.0, 0.0]; let (c1, c2) = match (self.kind, plane) { (_, Plane::None) => return None, diff --git a/crates/tools/src/distort/perspective/define.rs b/crates/tools/src/distort/perspective/define.rs new file mode 100644 index 00000000..87594a57 --- /dev/null +++ b/crates/tools/src/distort/perspective/define.rs @@ -0,0 +1,511 @@ +//! Define Perspective Grid: the dialog's view of the grid ([`GridDefinition`]: type, units, scale, +//! gridline spacing, viewing angle and distance, horizon height, third vanishing point, gridline +//! colours and opacity), the camera behind it (the [`Station`] point) and the built-in presets. +//! +//! The station point is the viewer: `D` points in front of the picture plane, level with the +//! horizon at the centre of vision `cv`. In two- and three-point perspective the viewing angle θ +//! is the angle between the left plane and the picture plane, so the vanishing points sit at +//! `cv − D·cot θ` and `cv + D·tan θ` (the station point lies on the circle over them). A receding +//! axis foreshortens by the distance from the viewer to its vanishing point, which keeps the grid +//! cells square in the scene whatever the angle. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use vectorcraft_doc::{Document, Unit}; +use vectorcraft_geom::Rect; + +use super::{PerspectiveGrid, Plane}; + +/// A Letter page: where presets are fitted without a document. +pub const LETTER: Rect = Rect::new(0.0, 0.0, 612.0, 792.0); + +/// The viewing angle of grids made before it was stored, and of the presets. +pub const DEFAULT_ANGLE: f64 = 45.0; + +/// The largest length (points) a grid takes, as [`PerspectiveGrid::validate`] allows. +const MAX_LEN: f64 = 4.0e6; + +/// The built-in presets: (name, kind, horizon height as a fraction of the artboard height from +/// the top). The ground line sits at 78 % of the height in all of them. +pub const BUILTINS: [(&str, u8, f64); 8] = [ + ("[1P-Normal View]", 1, 0.42), + ("[1P-Low View]", 1, 0.66), + ("[1P-High View]", 1, 0.12), + ("[2P-Normal View]", 2, 0.42), + ("[2P-Low View]", 2, 0.66), + ("[2P-High View]", 2, 0.12), + ("[3P-Normal View]", 3, 0.42), + ("[3P-Low View]", 3, 0.66), +]; + +/// The first artboard of `doc` (a Letter page without one). +pub fn first_artboard(doc: &Document) -> Rect { + doc.artboards.first().map_or(LETTER, |a| a.rect) +} + +// Serde defaults of the grid's definition fields. +pub(super) fn points() -> String { + Unit::Points.key().into() +} +pub(super) fn is_points(u: &String) -> bool { + u == Unit::Points.key() +} +pub(super) fn one_to_one() -> [f64; 2] { + [1.0, 1.0] +} +pub(super) fn is_one_to_one(s: &[f64; 2]) -> bool { + *s == one_to_one() +} +pub(super) fn left_rgb() -> Rgb { + Rgb(Plane::Left.color()) +} +pub(super) fn right_rgb() -> Rgb { + Rgb(Plane::Right.color()) +} +pub(super) fn ground_rgb() -> Rgb { + Rgb(Plane::Ground.color()) +} +pub(super) fn half() -> f64 { + 50.0 +} + +/// A `#rrggbb` colour (gridline colours). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Rgb(pub [u8; 3]); + +impl Rgb { + /// `#rrggbb` or `rrggbb`. + pub fn parse(s: &str) -> Option { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); + Some(Self([byte(0)?, byte(2)?, byte(4)?])) + } + /// `#rrggbb`. + pub fn hex(self) -> String { + let [r, g, b] = self.0; + format!("#{r:02x}{g:02x}{b:02x}") + } +} + +impl TryFrom for Rgb { + type Error = String; + fn try_from(s: String) -> Result { + Self::parse(&s).ok_or_else(|| format!("`{s}` isn't a #rrggbb colour")) + } +} + +impl From for String { + fn from(c: Rgb) -> String { + c.hex() + } +} + +/// The viewer: the centre of vision `x` on the horizon and the distance to the picture plane. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Station { + pub x: f64, + pub distance: f64, +} + +/// Define Perspective Grid's fields: the grid as the dialog shows it. Lengths are real-world +/// lengths in `units`, drawn at `scale` (`[artboard, real world]`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GridDefinition { + /// The preset's name ("" = custom). + pub name: String, + /// 1, 2 or 3-point perspective. + pub kind: u8, + /// A unit name (`points`, `pixels`, `inches`, `centimeters`…). + pub units: String, + /// `[artboard, real world]`: 1:4 draws 4 real-world units as 1. + pub scale: [f64; 2], + /// Gridline every (real-world units). + pub gridline: f64, + /// Viewing angle (degrees, between 0 and 90; two/three-point). + pub angle: f64, + /// Viewing distance: the station point's distance from the picture plane. + pub distance: f64, + /// Horizon height above the ground level. + pub horizon_height: f64, + /// The third vanishing point (three-point) from the centre of vision: x to the right, y up. + pub third_vp: [f64; 2], + pub left_color: Rgb, + pub right_color: Rgb, + pub ground_color: Rgb, + /// Gridline opacity, 0–100 %. + pub opacity: f64, +} + +impl Default for GridDefinition { + /// [2P-Normal View] on a Letter page. + fn default() -> Self { + PerspectiveGrid::normal(2, LETTER).definition() + } +} + +impl GridDefinition { + /// The unit the lengths are in. + pub fn unit(&self) -> Unit { + Unit::named(&self.units).unwrap_or_default() + } + + /// Artboard points per real-world unit. + pub fn ratio(&self) -> f64 { + self.unit().points() * self.scale[0] / self.scale[1] + } + + /// Merge `patch` (any subset of the fields; other keys are ignored) into this definition. + pub fn merged(&self, patch: &Value) -> Result { + let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; + if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) { + for (k, val) in p { + if o.contains_key(k) { + o.insert(k.clone(), val.clone()); + } + } + } + let mut d: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + d.units = Unit::named(&d.units).ok_or_else(|| format!("unknown unit `{}`", d.units))?.key().into(); + d.validate()?; + Ok(d) + } + + pub fn validate(&self) -> Result<(), String> { + if !(1..=3).contains(&self.kind) { + return Err("kind must be 1, 2 or 3".into()); + } + if Unit::named(&self.units).is_none() { + return Err(format!("unknown unit `{}`", self.units)); + } + if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) { + return Err("scale must be two positive numbers [artboard, real world]".into()); + } + let r = self.ratio(); + let lens = [self.gridline, self.distance, self.horizon_height, self.third_vp[0], self.third_vp[1]]; + if lens.iter().any(|v| !(v * r).is_finite() || (v * r).abs() > MAX_LEN) { + return Err("grid lengths must be finite".into()); + } + if self.gridline * r <= 0.0 || self.distance * r <= 0.0 { + return Err("gridline spacing and viewing distance must be positive".into()); + } + if (self.horizon_height * r).abs() < 1e-6 { + return Err("the horizon height can't be 0".into()); + } + if !(self.angle > 0.0 && self.angle < 90.0) { + return Err("the viewing angle must be between 0 and 90°".into()); + } + if !(0.0..=100.0).contains(&self.opacity) { + return Err("opacity must be 0–100".into()); + } + Ok(()) + } + + /// The same grid (names aside, lengths compared in points)? + pub fn same(&self, o: &Self) -> bool { + let (r, s) = (self.ratio(), o.ratio()); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0); + self.kind == o.kind + && near(self.gridline * r, o.gridline * s) + && near(self.distance * r, o.distance * s) + && near(self.horizon_height * r, o.horizon_height * s) + && (self.kind != 3 || near(self.third_vp[0] * r, o.third_vp[0] * s) && near(self.third_vp[1] * r, o.third_vp[1] * s)) + && (self.kind == 1 || near(self.angle, o.angle)) + && (self.left_color, self.right_color, self.ground_color) == (o.left_color, o.right_color, o.ground_color) + && near(self.opacity, o.opacity) + } +} + +impl PerspectiveGrid { + /// The built-in preset `name` (any case) fitted to `ab`. + pub fn builtin(name: &str, ab: Rect) -> Option { + let &(name, kind, horizon) = BUILTINS.iter().find(|b| b.0.eq_ignore_ascii_case(name.trim()))?; + let mut g = Self::preset(kind, ab); + let ground = g.origin[1]; + g.horizon = ab.y0 + horizon * ab.height().max(1.0); + g.vp_vertical = [g.vp_vertical[0], g.horizon - 3.0 * (ground - g.horizon)]; + g.name = name.into(); + g.set_station(g.station(), DEFAULT_ANGLE); + Some(g) + } + + /// The normal view preset of `kind` (1–3) fitted to `ab`. + pub fn normal(kind: u8, ab: Rect) -> Self { + let name = BUILTINS.iter().find(|b| b.1 == kind.clamp(1, 3)).map_or("", |b| b.0); + Self::builtin(name, ab).unwrap_or_else(|| Self::preset(kind, ab)) + } + + /// The viewing angle in degrees. + pub fn viewing_angle(&self) -> f64 { + self.angle.unwrap_or(DEFAULT_ANGLE) + } + + /// The station point: one-point grids look straight at their vanishing point from + /// `distance`; two/three-point ones stand where the viewing angle puts them (grids without + /// one: halfway between the vanishing points). + pub fn station(&self) -> Station { + let w = self.vp_right - self.vp_left; + match self.angle { + _ if self.kind == 1 => Station { x: self.vp_left, distance: self.distance }, + Some(a) if w > 0.0 => { + let (s, c) = a.to_radians().sin_cos(); + Station { x: self.vp_left + w * c * c, distance: w * s * c } + } + _ => Station { x: (self.vp_left + self.vp_right) / 2.0, distance: if w.abs() > 1e-9 { w.abs() / 2.0 } else { self.distance } }, + } + } + + /// Place the vanishing points for a viewer at `st` looking at `angle` degrees (one-point: the + /// vanishing point at the centre of vision). + pub fn set_station(&mut self, st: Station, angle: f64) { + self.distance = st.distance; + self.angle = Some(angle); + if self.kind == 1 { + self.vp_left = st.x; + } else { + let t = angle.to_radians().tan(); + self.vp_left = st.x - st.distance / t; + self.vp_right = st.x + st.distance * t; + } + } + + /// 1 / the measuring distance of the left, right and vertical axes: the viewer's distance to + /// their vanishing points (grids without a viewing angle: `distance` for all). + pub(super) fn foreshortening(&self) -> (f64, f64, f64) { + let uniform = 1.0 / self.distance.max(1e-9); + if self.kind == 1 || self.angle.is_none() || self.vp_right <= self.vp_left { + return (uniform, uniform, uniform); + } + let st = self.station(); + let to = |x: f64, y: f64| 1.0 / (st.distance.powi(2) + (x - st.x).powi(2) + (y - self.horizon).powi(2)).sqrt().max(1e-9); + (to(self.vp_left, self.horizon), to(self.vp_right, self.horizon), to(self.vp_vertical[0], self.vp_vertical[1])) + } + + /// After merging `patch` over `old`: a new viewing angle, viewing distance or type moves the + /// vanishing points around the old station point (unless the patch places them), and + /// `distance` reads the station's. + pub(super) fn reconcile(&mut self, old: &Self, patch: &Value) { + let has = |k: &str| patch.get(k).is_some(); + let camera = has("angle") || has("distance") && self.kind != 1; + if !(has("vpLeft") || has("vpRight")) && (camera || self.kind != old.kind) { + let st = old.station(); + let st = Station { distance: if has("distance") { self.distance } else { st.distance }, ..st }; + self.set_station(st, self.viewing_angle()); + } + if self.kind != 1 && self.angle.is_some() && self.vp_right > self.vp_left { + self.distance = self.station().distance; + } + } + + /// The definition fields' part of [`PerspectiveGrid::validate`]. + pub(super) fn validate_definition(&self) -> Result<(), String> { + if self.angle.is_some_and(|a| !(a > 0.0 && a < 90.0)) { + return Err("the viewing angle must be between 0 and 90°".into()); + } + if self.kind != 1 && self.angle.is_some() && self.vp_right <= self.vp_left { + return Err("the right vanishing point must be right of the left one".into()); + } + if Unit::named(&self.units).is_none() { + return Err(format!("unknown unit `{}`", self.units)); + } + if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) { + return Err("scale must be two positive numbers [artboard, real world]".into()); + } + if !(0.0..=100.0).contains(&self.opacity) { + return Err("opacity must be 0–100".into()); + } + Ok(()) + } + + /// The grid as Define Grid shows it. + pub fn definition(&self) -> GridDefinition { + let mut d = GridDefinition { + name: self.name.clone(), + kind: self.kind, + units: self.units.clone(), + scale: self.scale, + gridline: 0.0, + angle: self.viewing_angle(), + distance: 0.0, + horizon_height: 0.0, + third_vp: [0.0; 2], + left_color: self.left_color, + right_color: self.right_color, + ground_color: self.ground_color, + opacity: self.opacity, + }; + let r = d.ratio(); + let st = self.station(); + d.gridline = self.cell / r; + d.distance = st.distance / r; + d.horizon_height = (self.origin[1] - self.horizon) / r; + d.third_vp = [(self.vp_vertical[0] - st.x) / r, (self.horizon - self.vp_vertical[1]) / r]; + d + } + + /// This grid redefined by `def`: only what differs from [`Self::definition`] changes, so the + /// unchanged fields leave the grid exactly as it was. A new type, viewing angle or distance + /// moves the vanishing points around the station point. + pub fn with_definition(&self, def: &GridDefinition) -> Result { + def.validate()?; + let cur = self.definition(); + let r = def.ratio(); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0); + let cr = cur.ratio(); + let mut g = self.clone(); + g.name = def.name.clone(); + g.units = def.unit().key().into(); + g.scale = def.scale; + (g.left_color, g.right_color, g.ground_color, g.opacity) = (def.left_color, def.right_color, def.ground_color, def.opacity); + g.kind = def.kind; + if !near(def.gridline * r, cur.gridline * cr) { + g.cell = def.gridline * r; + } + if !near(def.horizon_height * r, cur.horizon_height * cr) { + g.horizon = g.origin[1] - def.horizon_height * r; + } + if def.kind != self.kind || !near(def.distance * r, cur.distance * cr) || def.kind != 1 && !near(def.angle, cur.angle) { + let st = Station { distance: def.distance * r, ..self.station() }; + g.set_station(st, def.angle); + } + if !(near(def.third_vp[0] * r, cur.third_vp[0] * cr) && near(def.third_vp[1] * r, cur.third_vp[1] * cr)) || g.horizon != self.horizon { + let x = g.station().x; + g.vp_vertical = [x + def.third_vp[0] * r, g.horizon - def.third_vp[1] * r]; + } + g.validate()?; + Ok(g) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_geom::Point; + + const AB: Rect = Rect::new(0.0, 0.0, 800.0, 600.0); + + #[test] + fn the_station_point_places_the_vanishing_points() { + let mut g = PerspectiveGrid::normal(2, AB); + let st = g.station(); + assert!((st.x - g.origin[0]).abs() < 1e-9, "the normal view looks at the corner"); + // At 30° the left plane turns toward the picture plane: its vanishing point recedes. + g.set_station(st, 30.0); + assert!((st.x - g.vp_left - st.distance * 3f64.sqrt()).abs() < 1e-9); + assert!((g.vp_right - st.x - st.distance / 3f64.sqrt()).abs() < 1e-9); + let back = g.station(); + assert!((back.x - st.x).abs() < 1e-9 && (back.distance - st.distance).abs() < 1e-9); + // The station point sees the vanishing points at a right angle. + let (a, b) = (Point::new(g.vp_left - st.x, st.distance), Point::new(g.vp_right - st.x, st.distance)); + assert!((a.x * b.x + a.y * b.y).abs() < 1e-6 * st.distance * st.distance); + } + + #[test] + fn receding_axes_keep_true_lengths_at_the_picture_plane() { + // A true camera: at the picture plane a step along a receding axis spans its true width + // (cos θ of it on the left, sin θ on the right), whatever the viewing angle. + for angle in [20.0, 45.0, 70.0] { + let mut g = PerspectiveGrid::normal(2, AB); + g.set_station(g.station(), angle); + g.origin[0] = g.station().x; + for pl in [Plane::Left, Plane::Right] { + let h = g.homography(pl).unwrap(); + let (o, e) = (h.apply(Point::new(0.0, 0.0)).unwrap(), h.apply(Point::new(1e-3, 0.0)).unwrap()); + let s = angle.to_radians(); + let dx = if pl == Plane::Left { s.cos() } else { s.sin() }; + assert!(((e.x - o.x).abs() / 1e-3 - dx).abs() < 1e-3, "{angle} {pl:?}"); + } + } + } + + #[test] + fn definition_round_trips_and_unchanged_fields_change_nothing() { + for kind in [1u8, 2, 3] { + let g = PerspectiveGrid::normal(kind, AB); + let d = g.definition(); + assert_eq!(g.with_definition(&d).unwrap(), g); + // In inches at 1:4 the same grid reads differently but is the same. + let inches = GridDefinition { units: "inches".into(), scale: [1.0, 4.0], ..d.clone() }; + let r = inches.ratio() / d.ratio(); + let inches = GridDefinition { + gridline: d.gridline / r, + distance: d.distance / r, + horizon_height: d.horizon_height / r, + third_vp: d.third_vp.map(|v| v / r), + ..inches + }; + let g2 = g.with_definition(&inches).unwrap(); + assert_eq!((g2.origin, g2.horizon, g2.vp_left, g2.vp_right, g2.cell), (g.origin, g.horizon, g.vp_left, g.vp_right, g.cell)); + assert!(g2.definition().same(&d)); + } + // A grid from before the viewing angle keeps its foreshortening through an unchanged OK. + let legacy = PerspectiveGrid::preset(2, AB); + let d = legacy.definition(); + assert_eq!(legacy.with_definition(&GridDefinition { opacity: 80.0, ..d.clone() }).unwrap().angle, None); + assert!(legacy.with_definition(&GridDefinition { angle: 30.0, ..d }).unwrap().angle.is_some()); + } + + #[test] + fn definition_fields_map_to_the_grid() { + let g = PerspectiveGrid::normal(3, AB); + let d = GridDefinition { + gridline: 2.0, + angle: 30.0, + distance: 5.0, + horizon_height: 3.0, + third_vp: [1.0, 8.0], + units: "inches".into(), + ..g.definition() + }; + let n = g.with_definition(&d).unwrap(); + let st = n.station(); + assert!((n.cell - 144.0).abs() < 1e-9); + assert!((st.distance - 360.0).abs() < 1e-6 && (st.x - g.station().x).abs() < 1e-6, "the viewer stays put"); + assert!((n.viewing_angle() - 30.0).abs() < 1e-9); + assert!((n.origin[1] - n.horizon - 216.0).abs() < 1e-9); + assert!((n.vp_vertical[0] - st.x - 72.0).abs() < 1e-6 && (n.horizon - n.vp_vertical[1] - 576.0).abs() < 1e-6); + assert_eq!(n.units, "inches"); + // Bad values are refused. + for bad in [ + json!({"angle": 90}), + json!({"distance": 0}), + json!({"units": "furlongs"}), + json!({"scale": [1, 0]}), + json!({"opacity": 120}), + json!({"kind": 4}), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + assert_eq!(d.merged(&json!({"units": "mm", "leftColor": "#00ff00"})).unwrap().units, "millimeters"); + assert!(d.merged(&json!({"leftColor": "green"})).is_err()); + } + + #[test] + fn set_moves_the_vanishing_points_for_a_new_angle_or_distance() { + let g = PerspectiveGrid::normal(2, AB); + let st = g.station(); + let n = g.merged(&json!({"angle": 60})).unwrap(); + assert!((n.station().x - st.x).abs() < 1e-6 && (n.station().distance - st.distance).abs() < 1e-6); + let n = g.merged(&json!({"distance": 100})).unwrap(); + assert!((n.vp_right - n.vp_left - 200.0).abs() < 1e-6); + // Moving a vanishing point keeps the angle; `distance` follows. + let n = g.merged(&json!({"vpLeft": g.vp_left - 100.0})).unwrap(); + assert_eq!(n.angle, Some(45.0)); + assert!((n.distance - (n.vp_right - n.vp_left) / 2.0).abs() < 1e-6); + assert!(g.merged(&json!({"vpRight": g.vp_left - 10.0})).is_err()); + } + + #[test] + fn rgb_parses_hex() { + assert_eq!(Rgb::parse("#3366ff"), Some(Rgb([0x33, 0x66, 0xff]))); + assert_eq!(Rgb::parse("ff8C1A").map(Rgb::hex).as_deref(), Some("#ff8c1a")); + for bad in ["", "#12345", "#gg0000", "é12345", "#1234567", "+f+f+f"] { + assert_eq!(Rgb::parse(bad), None, "{bad}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 5abff41a..18d6a087 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -39,6 +39,7 @@ pub mod office_export; pub mod package; mod path_ops; pub mod pdf_presets; +pub mod perspective_grid; pub mod place; pub mod placement_options; pub mod plugin; @@ -249,6 +250,7 @@ registry! { PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, + PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -392,3 +394,6 @@ mod tests_print_advanced; #[cfg(test)] mod tests_scale; + +#[cfg(test)] +mod tests_perspective; diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs new file mode 100644 index 00000000..0337e786 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -0,0 +1,204 @@ +//! View → Perspective Grid → Define Grid…: the grid's type, units and scale, gridline spacing, +//! viewing angle and distance, horizon height and third vanishing point, and the planes' gridline +//! colours and opacity, prefilled from the grid (`perspective.grid.get`). OK runs +//! `perspective.grid.define`: one undo step, nothing when nothing changed. +//! +//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`). + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_tools::distort::perspective::Rgb; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Define Perspective Grid. +pub const KIND: &str = "perspectiveGrid"; + +/// The label column's width. +const LABEL: f32 = 140.0; +/// A value field's width. +const FIELD: f32 = 120.0; + +/// The unit the lengths were last shown in. +const SHOWN: &str = "__units"; + +/// The lengths among the fields: real-world lengths in `units`. +const LENGTHS: [&str; 3] = ["gridline", "distance", "horizonHeight"]; + +/// The scale choices: (label, [artboard, real world]). +const SCALES: [(&str, [f64; 2]); 9] = [ + ("1:1", [1.0, 1.0]), + ("1:2", [1.0, 2.0]), + ("1:4", [1.0, 4.0]), + ("1:8", [1.0, 8.0]), + ("1:12", [1.0, 12.0]), + ("1:24", [1.0, 24.0]), + ("1:48", [1.0, 48.0]), + ("1:96", [1.0, 96.0]), + ("1:100", [1.0, 100.0]), +]; + +const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Three Point Perspective"]; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Define Perspective Grid".into(), body, confirm, min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM }; + +/// Open Define Grid on the active document's grid. +pub fn open(app: &mut VectorcraftApp) -> Result { + let mut fields = app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take(); + fields[SHOWN] = fields["units"].clone(); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run("perspective.grid.define", form::params(d)); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +/// The unit the lengths are in. +fn unit(d: &Dialog) -> Unit { + Unit::named(&d.str("units")).unwrap_or_default() +} + +fn kind(d: &Dialog) -> u8 { + d.fields.get("kind").and_then(Value::as_u64).map_or(2, |k| k.clamp(1, 3) as u8) +} + +fn scale(d: &Dialog) -> [f64; 2] { + let v = d.fields.get("scale").and_then(Value::as_array); + let at = |i: usize| v.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(1.0); + [at(0), at(1)] +} + +/// A length field (`key`, in `unit`) `width` wide. Returns true when it changed. +fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool { + let pt = d.fields.get(key).and_then(Value::as_f64).map(|v| unit.to_pt(v)); + let Some(v) = widgets::num_field(ui, ("persp-len", key), pt, unit, width) else { return false }; + d.fields.insert(key.into(), json!(unit.from_pt(v))); + true +} + +/// One of the third vanishing point's coordinates (`i`: 0 = x, 1 = y). +fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bool) { + let mut v = d.fields.get("thirdVp").and_then(Value::as_array).cloned().unwrap_or_else(|| vec![json!(0), json!(0)]); + v.resize(2, json!(0)); + let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x)); + ui.label(if i == 0 { "X:" } else { "Y:" }); + if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner { + v[i] = json!(unit.from_pt(x)); + d.fields.insert("thirdVp".into(), Value::Array(v)); + } +} + +/// A plane's gridline colour: a chip and its hex. +fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let t = Tokens::get(ui.ctx()); + let cur = Rgb::parse(&d.str(key)); + widgets::label_row(ui, label, LABEL, |ui| { + let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + if let Some(Rgb([r, g, b])) = cur { + ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(r, g, b)); + } + ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + let hex = cur.map(|c| c.hex()).unwrap_or_default(); + if let Some(c) = widgets::hex_field(ui, ("persp-color", key), hex.trim_start_matches('#')).as_deref().and_then(Rgb::parse) { + d.fields.insert(key.into(), json!(c.hex())); + } + }); +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, "Perspective Grid Settings"); + ui.add_space(6.0); + let k = kind(d); + widgets::label_row(ui, "Type:", LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "persp-type", TYPES[usize::from(k - 1)], &TYPES, 200.0) { + d.fields.insert("kind".into(), json!(i + 1)); + } + }); + let units: Vec<(&str, &str)> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| (u.key(), u.label())).collect(); + form::choice(ui, d, "units", "Units:", (LABEL, 200.0), &units); + // A new unit (the menu or `ui.dialog.set`) keeps the lengths: they are converted. + let unit = unit(d); + let before = Unit::named(&d.str(SHOWN)).unwrap_or(unit); + d.fields.insert(SHOWN.into(), json!(unit.key())); + if unit != before { + let f = before.points() / unit.points(); + for key in LENGTHS { + if let Some(v) = d.fields.get(key).and_then(Value::as_f64) { + d.fields.insert(key.into(), json!(v * f)); + } + } + if let Some(v) = d.fields.get("thirdVp").and_then(Value::as_array) { + let v: Vec = v.iter().map(|x| x.as_f64().unwrap_or(0.0) * f).collect(); + d.fields.insert("thirdVp".into(), json!(v)); + } + } + let sc = scale(d); + let named = SCALES.iter().find(|(_, s)| *s == sc).map(|(l, _)| *l); + let mut labels: Vec<&str> = SCALES.iter().map(|(l, _)| *l).collect(); + labels.push("Custom"); + widgets::label_row(ui, "Scale:", LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "persp-scale", named.unwrap_or("Custom"), &labels, 200.0) { + // Custom starts from the scale shown. + let s = SCALES.get(i).map_or(sc, |(_, s)| *s); + d.fields.insert("scale".into(), json!(s)); + } + }); + if named.is_none() { + widgets::label_row(ui, "", LABEL, |ui| { + for (i, label) in ["Artboard:", "Real World:"].into_iter().enumerate() { + ui.label(egui::RichText::new(label).color(t.text_dim)); + if let Some(v) = widgets::plain_field(ui, ("persp-scale", i), sc[i], "", 3, 56.0).filter(|v| *v > 0.0 && v.is_finite()) { + let mut s = sc; + s[i] = v; + d.fields.insert("scale".into(), json!(s)); + } + } + }); + } + widgets::label_row(ui, "Gridline every:", LABEL, |ui| { + length(ui, d, "gridline", unit, FIELD); + }); + if sc[0] != sc[1] { + let real = unit.number(unit.to_pt(sc[1] / sc[0])); + let note = format!("1 {s} on the artboard stands for {real} {s} in the scene.", s = unit.suffix()); + widgets::label_row(ui, "", LABEL, |ui| { + ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim)); + }); + } + ui.add_space(4.0); + widgets::label_row(ui, "Viewing Angle:", LABEL, |ui| { + let a = d.f64("angle", 45.0); + if let Some(v) = ui.add_enabled_ui(k != 1, |ui| widgets::plain_field(ui, "persp-angle", a, "°", 2, FIELD)).inner { + d.fields.insert("angle".into(), json!(v)); + } + }); + widgets::label_row(ui, "Viewing Distance:", LABEL, |ui| { + length(ui, d, "distance", unit, FIELD); + }); + widgets::label_row(ui, "Horizon Height:", LABEL, |ui| { + length(ui, d, "horizonHeight", unit, FIELD); + }); + widgets::label_row(ui, "Third Vanishing Point:", LABEL, |ui| { + third_vp(ui, d, 0, unit, k == 3); + third_vp(ui, d, 1, unit, k == 3); + }); + ui.add_space(12.0); + widgets::subheader(ui, "Grid Color & Opacity"); + ui.add_space(6.0); + color(ui, d, "leftColor", "Left Grid:"); + color(ui, d, "rightColor", "Right Grid:"); + color(ui, d, "groundColor", "Horizontal Grid:"); + let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8); + form::slider_w(ui, d, ("opacity", "Opacity:", LABEL), 0.0..=100.0, "%", &track); + false +} diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs new file mode 100644 index 00000000..93a824cc --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -0,0 +1,81 @@ +//! Perspective Grid dialogs: Define Grid, the presets manager and the tool options. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_tools::distort::perspective::PerspectiveGrid; + +use crate::{VectorcraftApp, theme}; + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or_default() +} + +fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v); +} + +fn grid(app: &VectorcraftApp) -> PerspectiveGrid { + PerspectiveGrid::effective(&app.session.active().unwrap().doc) +} + +#[test] +fn define_grid_opens_on_the_grid_and_applies_its_fields() { + let mut app = app(); + app.run("perspective.grid.preset", json!({"kind": 3})).unwrap(); + let g = grid(&app); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); + assert_eq!(field(&app, "kind"), json!(3), "prefilled from the grid, not a fixed type"); + assert!((field(&app, "gridline").as_f64().unwrap() - g.cell).abs() < 1e-9); + frame(&mut app); + // A new unit converts the lengths shown. + set(&mut app, "units", json!("inches")); + frame(&mut app); + assert!((field(&app, "gridline").as_f64().unwrap() - g.cell / 72.0).abs() < 1e-9); + // OK with nothing changed leaves the grid as it was. + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + assert_eq!((grid(&app).cell, grid(&app).vp_left, grid(&app).kind), (g.cell, g.vp_left, 3)); + // A new viewing angle and colour apply in one undo step. + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "angle", json!(30)); + set(&mut app, "rightColor", json!("#112233")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + let n = grid(&app); + assert!((n.viewing_angle() - 30.0).abs() < 1e-9); + assert_eq!(n.right_color.hex(), "#112233"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + // A refused value keeps the dialog open. + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "distance", json!(-5)); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); +} + +#[test] +fn define_grid_is_a_view_menu_item() { + let app = app(); + let entries = crate::menus::menu_entries(&app); + let e = entries.iter().find(|e| e.command.as_deref() == Some("ui.perspectiveGridDialog")).expect("View › Perspective Grid › Define Grid…"); + assert!(e.path.iter().any(|p| p == "Perspective Grid"), "{:?}", e.path); + assert!(e.enabled); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 453cc25b..7fd65ab7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -547,6 +547,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)", ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -960,6 +966,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::print::open_preset(app, p), "plugin.dialog" => crate::dialogs::plugin::open(app, p), "ui.installPlugin" => io::install_plugin(app, s("path")), + "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), _ => return None, }; Some(r) @@ -1206,6 +1213,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "css.copy" | "css.exportFile" => app.session.active().is_some(), "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), "plugin.dialog" => app.session.active().is_some(), + "ui.perspectiveGridDialog" => app.session.active().is_some(), _ => true, } } @@ -1760,7 +1768,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { cp("One Point Perspective", "perspective.grid.preset", json!({"kind": 1})), cp("Two Point Perspective", "perspective.grid.preset", json!({"kind": 2})), cp("Three Point Perspective", "perspective.grid.preset", json!({"kind": 3})), - cp("Define Grid…", "perspective.grid.set", json!({"kind": 2, "cell": 20, "distance": 300})), + c("Define Grid…", "ui.perspectiveGridDialog"), ], ), c("Show Grid", "view.grid"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 114d6884..d2b52995 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -449,3 +449,9 @@ Tool options last: `tool.setOption {key, value}` or `{values: {…}}` (and `tool the options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, Symbolism, drawing tools) stay when you switch tools or documents and are saved with the preferences. The Liquify tools share their Global Brush Dimensions (`width`, `height`, `angle`, `intensity`). + +Perspective grid: View → Perspective Grid → Define Grid… (`ui.perspectiveGridDialog`) opens the `perspectiveGrid` +dialog prefilled from the grid. Its fields are those of `perspective.grid.define` (`kind`, `units`, `scale`, +`gridline`, `angle`, `distance`, `horizonHeight`, `thirdVp`, `leftColor`, `rightColor`, `groundColor`, `opacity`; +lengths in `units`, so setting `units` converts them). `ui.dialog.confirm` runs `perspective.grid.define`; a refused +value keeps the dialog open. diff --git a/docs/mcp.md b/docs/mcp.md index d8b56e43..07a887f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1746,3 +1746,21 @@ intensity), and the Symbolism tools one brush. Interaction state (pins, a refere ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}} ``` + +## Perspective grid + +The grid is document data (`perspective.grid.set` edits the model directly). `perspective.grid.get` reads it: +`grid` (the model), `define` (the Define Grid fields), `station` (the viewer: `x` the centre of vision on the +horizon `y`, `distance` from the picture plane, in points) and `defined` (false while the document uses the default +grid). `perspective.grid.define` sets the grid from the Define Grid fields: `kind`, `units`, `scale` +(`[artboard, real world]`), `gridline`, `angle` (the viewing angle, 0–90°), `distance` (the viewing distance), +`horizonHeight`, `thirdVp` (`[x right, y up]` from the centre of vision), `leftColor`, `rightColor`, `groundColor` +(`"#rrggbb"`) and `opacity` (0–100). Lengths are real-world lengths in `units` drawn at `scale`; missing fields keep the +grid's. Only what differs changes (an unchanged OK is no undo step): a new type, angle or distance moves the vanishing +points around the station point, which stays put. In two- and three-point grids the viewer looks at the left plane at +the viewing angle, so the vanishing points sit at `x − distance/tan(angle)` and `x + distance·tan(angle)`. + +```json +{"name":"run_command","arguments":{"command":"perspective.grid.get","params":{}}} +{"name":"run_command","arguments":{"command":"perspective.grid.define","params":{"units":"inches","gridline":0.5,"angle":30,"distance":6,"horizonHeight":3}}} +``` From f29e1391998eb72992b5d7151c6768d931f490bb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:08:07 +0700 Subject: [PATCH 2/2] ROADMAP: M8.8 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 7305a8e5..b14d0f83 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) | 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) | 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 |