From ecf245b7c9a8245630c597caff498115e4f0444e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:28:48 +0700 Subject: [PATCH] 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 5622ba5c5..d61365d61 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 000000000..f27e84d8a --- /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 ce953a5af..78e9bf549 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 000000000..5aeb0b7ae --- /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 0ab111f35..a1ae94819 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 000000000..87594a577 --- /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 5abff41a6..18d6a0872 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 000000000..0337e7860 --- /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 000000000..93a824ccd --- /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 453cc25b0..7fd65ab75 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 114d6884e..d2b52995f 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 d8b56e43a..07a887f98 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}}} +```