mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
3D and Materials: Perspective runs 0° to 160°, as documented
The public 3D and Materials panel options give Perspective as 0° to 160°
("how close or far the object appears"); it was a 0–100 % strength, for
Revolve, Extrude & Bevel, Inflate and Rotate alike.
- The eye stands where a sphere about the object's centre, √3 times its
half size (the box around it fits inside), fills that angle of the
view: 0° is none, wider brings the eye nearer, and at 160° it is just
outside the sphere, never inside the object. The documentation doesn't
say how the angle places the eye; docs/three-d.md says this is our
reading.
- `perspectiveAngle` (degrees) replaces `perspective` (%). A strength from
an earlier file or script is read as the angle that shows exactly the
same view: sin(angle / 2) = √3 × strength / 4, 100 % about 51.3°. An
angle given too wins. effect.setParams turns a stored strength into the
angle before applying new values. The parameter description names
`perspective` as the earlier form, so effect.apply and effect.setParams
still take it under the key check of #1592. Old and new code draw the four effects identically at 0, 25,
60 and 100 % (bounds equal to 1e-9).
- The dialogs' Perspective fields run 0–160°.
# Conflicts:
# docs/target-app-parity.md
This commit is contained in:
1 parent
a3357121fb
commit
39db6abfce
16 files changed
+231
-49
No files matched your search
@@ -53,7 +53,7 @@ pub struct Revolve {
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
/// Perspective, 0° (none) to 160°: see [`eye_distance`].
|
||||
pub perspective: f64,
|
||||
/// Radial subdivisions for a full turn, clamped to 8..128.
|
||||
pub segments: usize,
|
||||
@@ -84,6 +84,33 @@ impl Default for Revolve {
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest documented Perspective.
|
||||
pub const MAX_PERSPECTIVE: f64 = 160.0;
|
||||
|
||||
/// How far the eye is from the centre of an object of half size `size`, for a Perspective of
|
||||
/// `angle` degrees: from where a sphere of √3 × `size` about the centre (the box around the object
|
||||
/// fits inside it) fills that angle of the view. 0° is no perspective (`None`, the eye infinitely
|
||||
/// far); at 160° the eye is just outside the sphere, so never inside the object.
|
||||
fn eye_distance(angle: f64, size: f64) -> Option<f64> {
|
||||
let angle = finite(angle, 0.0, 0.0, MAX_PERSPECTIVE);
|
||||
(angle > 0.0).then(|| 3f64.sqrt() * size / (angle / 2.0).to_radians().sin())
|
||||
}
|
||||
|
||||
/// The scale of a view-space point `z` in front of the object's centre plane, for an eye at
|
||||
/// `eye`: 1 without perspective. Parts reaching past the eye's sphere (a wide outward bevel at a
|
||||
/// wide angle) are held just in front of the eye rather than turned inside out.
|
||||
fn perspective_scale(eye: Option<f64>, z: f64) -> f64 {
|
||||
eye.map_or(1.0, |e| e / (e - z).max(e * 1e-3))
|
||||
}
|
||||
|
||||
/// The Perspective angle that shows exactly what the earlier 0–1 perspective strength did (files
|
||||
/// from before the angle). The strength put the eye at 4 × the object's half size / strength, so
|
||||
/// sin(angle / 2) = √3 × strength / 4: full strength is about 51°.
|
||||
pub fn perspective_from_strength(strength: f64) -> f64 {
|
||||
let s = finite(strength, 0.0, 0.0, 1.0);
|
||||
2.0 * (3f64.sqrt() * s / 4.0).asin().to_degrees()
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
@@ -127,10 +154,10 @@ impl Revolve {
|
||||
};
|
||||
let center_y = bounds.center().y;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
|
||||
[bounds.y0, bounds.y1].map(|y| {
|
||||
let v = o.rotate(V3(0.0, y - center_y, 0.0));
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
}
|
||||
@@ -180,7 +207,7 @@ impl Revolve {
|
||||
rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0),
|
||||
rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0),
|
||||
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
|
||||
perspective: finite(self.perspective, 0.0, 0.0, 1.0),
|
||||
perspective: finite(self.perspective, 0.0, 0.0, MAX_PERSPECTIVE),
|
||||
segments: self.segments.clamp(8, 128),
|
||||
light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0),
|
||||
light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0),
|
||||
@@ -234,7 +261,7 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
};
|
||||
let center_y = (bounds.y0 + bounds.y1) * 0.5;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
|
||||
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
|
||||
let sweep = o.angle.to_radians();
|
||||
let [lx, ly, lz] = o.light_direction();
|
||||
@@ -293,7 +320,7 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
@@ -405,12 +432,44 @@ mod tests {
|
||||
for o in [
|
||||
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
|
||||
Revolve { angle: 120.0, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() },
|
||||
] {
|
||||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||||
}
|
||||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||||
}
|
||||
/// The angle that stands for an earlier perspective strength puts the eye where that strength
|
||||
/// did (4 × the half size / strength), so files from before the angle look as they did.
|
||||
#[test]
|
||||
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
|
||||
for strength in [0.05, 0.3, 0.6, 1.0] {
|
||||
for size in [1.0, 37.5, 900.0] {
|
||||
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
|
||||
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
|
||||
}
|
||||
}
|
||||
assert_eq!(perspective_from_strength(0.0), 0.0);
|
||||
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
|
||||
}
|
||||
|
||||
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
|
||||
/// the sphere the object's box fits in.
|
||||
#[test]
|
||||
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
|
||||
assert_eq!(eye_distance(0.0, 50.0), None);
|
||||
assert_eq!(eye_distance(-20.0, 50.0), None);
|
||||
assert_eq!(eye_distance(f64::NAN, 50.0), None);
|
||||
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
|
||||
let widest = eye_distance(160.0, 50.0).unwrap();
|
||||
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
|
||||
// Wider is closer.
|
||||
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
|
||||
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
|
||||
let (p, b) = cylinder();
|
||||
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
|
||||
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||||
let (p, b) = cylinder();
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
use crate::{MAX_FACES, Revolve, V3};
|
||||
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||||
|
||||
/// The view shared by every 3D effect: rotation, perspective and one directional light.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -19,7 +19,7 @@ pub struct View {
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
/// Perspective, 0° (none) to 160°, as Revolve's.
|
||||
pub perspective: f64,
|
||||
pub light_azimuth: f64,
|
||||
pub light_elevation: f64,
|
||||
@@ -389,7 +389,10 @@ fn inset(ring: &[Point], w: f64) -> Vec<Point> {
|
||||
struct Camera {
|
||||
view: Revolve,
|
||||
origin: Point,
|
||||
distance: f64,
|
||||
/// The eye's distance in front of the centre; `None` without perspective.
|
||||
eye: Option<f64>,
|
||||
/// Deeper than any part of the object: faces drawn first go this far behind it.
|
||||
far: f64,
|
||||
light: V3,
|
||||
}
|
||||
|
||||
@@ -398,7 +401,7 @@ impl Camera {
|
||||
let view = view.revolve();
|
||||
let [lx, ly, lz] = view.light_direction();
|
||||
let size = (bounds.width() / 2.0).max(bounds.height() / 2.0).max(depth / 2.0).max(1.0);
|
||||
Self { view, origin: bounds.center(), distance: 4.0 * size, light: V3(lx, ly, lz) }
|
||||
Self { view, origin: bounds.center(), eye: eye_distance(view.perspective, size), far: 16.0 * size, light: V3(lx, ly, lz) }
|
||||
}
|
||||
fn rotate(&self, v: V3) -> V3 {
|
||||
self.view.rotate(v)
|
||||
@@ -408,13 +411,13 @@ impl Camera {
|
||||
self.rotate(V3(p.x - self.origin.x, p.y - self.origin.y, z))
|
||||
}
|
||||
fn project(&self, v: V3) -> Point {
|
||||
let f = self.distance / (self.distance - v.2 * self.view.perspective);
|
||||
let f = perspective_scale(self.eye, v.2);
|
||||
Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f)
|
||||
}
|
||||
/// The cosine between a face's outward unit normal `n` and the direction from `at` (both in
|
||||
/// view space) to the eye: positive when the face is turned towards the viewer.
|
||||
fn facing(&self, at: V3, n: V3) -> f64 {
|
||||
let to_eye = if self.view.perspective > 0.0 { V3(0.0, 0.0, self.distance / self.view.perspective).sub(at).unit() } else { V3(0.0, 0.0, 1.0) };
|
||||
let to_eye = self.eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(0.0, 0.0, e).sub(at).unit());
|
||||
n.dot(to_eye)
|
||||
}
|
||||
fn brightness(&self, n: V3) -> f64 {
|
||||
@@ -461,7 +464,7 @@ impl Builder<'_> {
|
||||
if contours.iter().map(|c| signed_area(c).abs()).sum::<f64>() < 1e-10 {
|
||||
return Ok(());
|
||||
}
|
||||
let depth = if behind { centre.2 - 4.0 * self.camera.distance } else { centre.2 };
|
||||
let depth = if behind { centre.2 - self.camera.far } else { centre.2 };
|
||||
self.facets.push(Facet { contours, depth, brightness: self.camera.brightness(n) });
|
||||
if self.facets.len() > MAX_FACES {
|
||||
return Err("The 3D surface is too complex; simplify the shape".into());
|
||||
|
||||
@@ -216,7 +216,7 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
cap: true,
|
||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(
|
||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
||||
@@ -243,11 +243,30 @@ fn perspective_makes_the_near_face_larger() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 0.6, ..front() }).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
||||
assert!(deep.width() > flat.width() + 1.0);
|
||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||
}
|
||||
|
||||
/// The wider the Perspective, the nearer the eye: seen straight on, the front of a cube is drawn
|
||||
/// larger and larger, up to 160°, where the eye is still outside the cube.
|
||||
#[test]
|
||||
fn a_wider_perspective_brings_the_eye_nearer() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let mut last = 100.0;
|
||||
for angle in [20.0, 60.0, 110.0, 160.0] {
|
||||
// The front face alone shows: its width is the cube's on the page.
|
||||
let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
||||
assert!(width > last, "{angle}°: {width} after {last}");
|
||||
last = width;
|
||||
}
|
||||
// At 160° the eye is about 87.9 pt in front of the cube's centre (√3 × 50 / sin 80°), so the
|
||||
// front face, 50 pt nearer than the centre, is drawn about 2.3 times its size.
|
||||
let eye = 3f64.sqrt() * 50.0 / 80f64.to_radians().sin();
|
||||
assert!(close(last, 100.0 * eye / (eye - 50.0), 1e-6), "{last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inflate_membrane_matches_known_solutions() {
|
||||
// ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at
|
||||
|
||||
@@ -290,7 +290,7 @@ mod tests {
|
||||
rotation_x: -32.0,
|
||||
rotation_y: 24.0,
|
||||
rotation_z: 19.0,
|
||||
perspective: 0.7,
|
||||
perspective: crate::perspective_from_strength(0.7),
|
||||
..Default::default()
|
||||
};
|
||||
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
|
||||
|
||||
Reference in new issue
Block a user