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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 09:40:26 +07:00
co-authored by Claude Opus 5.5
parent fe7da442ca
commit ecf245b7c9
12 changed files with 1061 additions and 12 deletions
+2
View File
@@ -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
})
}
+66
View File
@@ -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<CommandSpec> {
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<Value> {
let doc = &s.doc()?.doc;
Ok(grid_info(doc, &grid_of(doc)))
}
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
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()))
}
+2
View File
@@ -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;
+113
View File
@@ -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));
}
+44 -11
View File
@@ -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<String, Plane>,
/// 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<f64>,
/// 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<Homography> {
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,
@@ -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<Self> {
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<String> for Rgb {
type Error = String;
fn try_from(s: String) -> Result<Self, String> {
Self::parse(&s).ok_or_else(|| format!("`{s}` isn't a #rrggbb colour"))
}
}
impl From<Rgb> 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<Self, String> {
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<Self> {
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<Self, String> {
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}");
}
}
}
+5
View File
@@ -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;
@@ -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<Value, String> {
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<Value, String> {
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<f64> = 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
}
@@ -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);
}
+9 -1
View File
@@ -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<R
"ui.printPresetDialog" => 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<Item>)> {
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"),
+6
View File
@@ -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.
+18
View File
@@ -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}}}
```