mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
fe7da442ca
commit
ecf245b7c9
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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}}}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user