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:
Ayberk A. authored and bflatastic committed 2026-10-14 05:10:09 +07:00
1 parent a3357121fb
commit 39db6abfce
16 files changed
+231 -49

No files matched your search

+66 -7
View File
@@ -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();
+10 -7
View File
@@ -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());
+21 -2
View File
@@ -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
+1 -1
View File
@@ -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();