Skip to content

Commit 73cfdf5

Browse files
Add native perspective projection mode and tests (#1033)
This PR adds a native perspective projection mode to CeTZ and includes docs/tests for it. ```typst #cetz.canvas({ import cetz.draw: * perspective(x: 28deg, y: -35deg, distance: auto, { on-xz({ rect((-1, -1), (1, 1)) }) line((0, 0, 0), (1, 1, 1), stroke: red) }) }) ``` ### What’s included - Added `draw.perspective(...)` in `src/draw/projection.typ` - Exported `perspective` from `src/draw.typ` - Added API docs page: - `docs/api/draw-functions/projections/perspective.mdx` - `docs/api/sidebar.js` entry under Projections - Added projection regression test: - `tests/projection/perspective/test.typ` - `tests/projection/perspective/ref/1.png` ### Perspective behavior - Uses the same default camera angles as `ortho` (`x: 35.264deg`, `y: 45deg`, `z: 0deg`) - Applies perspective divide with internal near/depth handling - Computes and uses a **reference depth** (`d_ref`, corresponding to the nearest depth) to anchor projection scale: - projected coordinates use `x' = d_ref * x / w`, `y' = d_ref * y / w` - with `w = max(-z, near)` in view space - At the reference depth (the nearest visible depth in camera space), projected lengths are approximately preserved because scale is anchored to that plane; objects farther in depth are progressively smaller, which helps keep deep scenes from overflowing the canvas. - `distance` controls camera distance and therefore perspective strength: - smaller distance => stronger perspective (more foreshortening) - larger distance => flatter look (closer to orthographic) - `distance` is internally constrained to stay in front of the nearest geometry, preventing extreme camera placements that would cause distorted projections. - `distance: auto` derives a stable value from scene depth - Supports sorting and cull-face options like existing projection paths - Mark handling in perspective forces transformed-shape placement, avoiding oversized/distorted marks/arrows. ### Validation - `tt run projection/perspective projection/ortho mark/shape/transform` - All passed locally. <img width="642" height="2745" alt="1" src="https://github.com/user-attachments/assets/1a517925-b9cf-4f68-b59f-31f8a4acbc5c" />
2 parents d573cad + 9e8117e commit 73cfdf5

File tree

7 files changed

+321
-1
lines changed

7 files changed

+321
-1
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Perspective from "@site/cetz/docs/_generated/draw/projection/perspective.mdx";
2+
3+
# perspective
4+
5+
<Perspective />

docs/api/sidebar.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default [
9595
},
9696
items: [
9797
"api/draw-functions/projections/ortho",
98+
"api/draw-functions/projections/perspective",
9899
"api/draw-functions/projections/on-xy",
99100
"api/draw-functions/projections/on-xz",
100101
"api/draw-functions/projections/on-zy",

src/draw.typ

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
#import "draw/transformations.typ": set-transform, transform, rotate, translate, scale, set-origin, move-to, set-viewport
33
#import "draw/styling.typ": set-style, fill, stroke, register-mark
44
#import "draw/shapes.typ": circle, circle-through, arc, arc-through, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path, polygon, compound-path, n-star, rect-around, svg-path
5-
#import "draw/projection.typ": ortho, on-xy, on-xz, on-zy
5+
#import "draw/projection.typ": ortho, perspective, on-xy, on-xz, on-zy
66
#import "draw/util.typ": assert-version, register-coordinate-resolver

src/draw/projection.typ

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#import "/src/drawable.typ"
66
#import "/src/util.typ"
77
#import "/src/polygon.typ"
8+
#import "/src/path-util.typ"
9+
#import "/src/aabb.typ"
810

911
// Get an orthographic view matrix for 3 angles
1012
#let ortho-matrix(x, y, z) = matrix.mul-mat(
@@ -21,6 +23,25 @@
2123
(0, 0, 0, 1),
2224
)
2325

26+
// Build perspective view matrix from rotation and camera distance.
27+
#let _perspective-view-matrix(view-rotation-matrix, distance) = {
28+
matrix.mul-mat(
29+
matrix.ident(4),
30+
matrix.transform-translate(0, 0, -distance),
31+
view-rotation-matrix,
32+
)
33+
}
34+
35+
// Perspective divide for a single point.
36+
#let _perspective-project-point(pt, near, ref-depth) = {
37+
let x = pt.at(0)
38+
let y = pt.at(1)
39+
let z = pt.at(2, default: 0.0)
40+
let w = calc.max(-z, near)
41+
// Normalize by a reference depth so scale is anchored at that plane.
42+
(ref-depth * x / w, ref-depth * y / w, z)
43+
}
44+
2445
#let _sort-by-distance(drawables) = {
2546
return drawables.sorted(key: d => {
2647
let z = none
@@ -56,6 +77,82 @@
5677
})
5778
}
5879

80+
// Compute aggregate bounds from a list of drawables.
81+
#let _drawables-bounds(drawables) = {
82+
let bounds = none
83+
for d in drawable.filter-tagged(drawables, drawable.TAG.no-bounds) {
84+
let pts = if d.type == "path" {
85+
path-util.bounds(d.segments)
86+
} else if d.type == "content" {
87+
let (x, y, _, w, h,) = d.pos + (d.width, d.height)
88+
((x + w / 2, y - h / 2, 0.0), (x - w / 2, y + h / 2, 0.0))
89+
} else {
90+
()
91+
}
92+
if pts != () {
93+
bounds = aabb.aabb(pts, init: bounds)
94+
}
95+
}
96+
return bounds
97+
}
98+
99+
// Resolve reference depth as nearest drawable depth in camera space.
100+
#let _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near) = {
101+
let probe-ctx = ctx
102+
probe-ctx.transform = view-rotation-matrix
103+
let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many(
104+
probe-ctx,
105+
util.resolve-body(probe-ctx, body))
106+
let probe-bounds = _drawables-bounds(probe-drawables)
107+
if probe-bounds == none {
108+
return near
109+
}
110+
111+
// In camera space, depth is w = distance - z_rot. The nearest depth is at z_max.
112+
let z-max = probe-bounds.high.at(2)
113+
calc.max(distance - z-max, near)
114+
}
115+
116+
// Resolve distance/near values, including support for `auto`.
117+
#let _resolve-camera-distance-near(ctx, body, view-rotation-matrix, distance) = {
118+
let distance = if distance == auto { auto } else { util.resolve-number(ctx, distance) }
119+
120+
let probe-ctx = ctx
121+
probe-ctx.transform = view-rotation-matrix
122+
let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many(
123+
probe-ctx,
124+
util.resolve-body(probe-ctx, body))
125+
let probe-bounds = _drawables-bounds(probe-drawables)
126+
127+
let z-max = if probe-bounds == none { 0 } else { probe-bounds.high.at(2) }
128+
let z-min = if probe-bounds == none { 0 } else { probe-bounds.low.at(2) }
129+
let depth-span = calc.abs(z-max - z-min)
130+
let probe-near = calc.max(0.001, 0.01 * calc.max(depth-span, 1))
131+
132+
if distance == auto {
133+
// Use a larger margin to reduce perspective intensity for auto distance.
134+
let margin = calc.max(probe-near * 3, depth-span * 2)
135+
distance = calc.max(z-max + margin, probe-near * 2)
136+
}
137+
138+
// Keep the camera in front of the nearest geometry after rotation.
139+
let min-margin = calc.max(probe-near * 2, depth-span * 0.1)
140+
let min-distance = z-max + min-margin
141+
distance = calc.max(distance, min-distance)
142+
143+
let scene-scale = calc.max(depth-span, 1)
144+
let min-w = distance - z-max
145+
let near = if min-w > 0 {
146+
calc.max(0.001, calc.min(min-w * 0.5, scene-scale * 0.05))
147+
} else {
148+
calc.max(0.001, scene-scale * 0.01)
149+
}
150+
151+
assert(distance > 0, message: "distance must be > 0.")
152+
assert(near > 0, message: "near must be > 0.")
153+
(distance, near)
154+
}
155+
59156
// Sets up a view matrix to transform all `body` elements. The current context
60157
// transform is not modified.
61158
//
@@ -69,6 +166,11 @@
69166
#let _projection(body, view-matrix, projection-matrix, reset-transform: true, sorted: true, cull-face: "cw") = {
70167
(ctx => {
71168
let transform = ctx.transform
169+
let perspective-mode = type(projection-matrix) == function
170+
let previous-perspective-mode = ctx.at("_perspective-projection", default: false)
171+
if perspective-mode {
172+
ctx._perspective-projection = true
173+
}
72174
ctx.transform = view-matrix
73175

74176
let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, body))
@@ -87,6 +189,9 @@
87189
}
88190

89191
ctx.transform = transform
192+
if perspective-mode {
193+
ctx._perspective-projection = previous-perspective-mode
194+
}
90195
if not reset-transform {
91196
drawables = drawable.apply-transform(ctx.transform, drawables)
92197
}
@@ -207,3 +312,56 @@
207312
matrix.transform-translate(x, 0, 0)
208313
}, matrix.transform-rotate-y(90deg))
209314
})
315+
316+
/// Set-up a perspective projection environment.
317+
///
318+
/// Coordinates are transformed by a view matrix and then projected with
319+
/// perspective division:
320+
/// $x' = (d_"ref" * x) / w$ and $y' = (d_"ref" * y) / w$,
321+
/// where $w = max(-z, "near")$ in view space.
322+
///
323+
/// By default this uses the same isometric camera angles as `ortho`, but with
324+
/// perspective foreshortening.
325+
///
326+
/// ```example
327+
/// perspective({
328+
/// on-xz({
329+
/// rect((-1,-1), (1,1))
330+
/// })
331+
/// })
332+
/// ```
333+
///
334+
/// - x (angle): X-axis rotation angle
335+
/// - y (angle): Y-axis rotation angle
336+
/// - z (angle): Z-axis rotation angle
337+
/// - distance (number,auto): Distance from camera to scene origin. `auto`
338+
/// derives a stable value from scene depth.
339+
/// - sorted (bool): Sort drawables by depth (back to front)
340+
/// - cull-face (none,str): Enable back-face culling if set to `"cw"` for clockwise
341+
/// or `"ccw"` for counter-clockwise. Polygons of the specified order will not get drawn.
342+
/// - reset-transform (bool): Ignore the current transformation matrix
343+
/// - body (element): Elements to draw
344+
#let perspective(
345+
x: 35.264deg,
346+
y: 45deg,
347+
z: 0deg,
348+
distance: auto,
349+
sorted: true,
350+
cull-face: none,
351+
reset-transform: false,
352+
body,
353+
) = scope(ctx => {
354+
let view-rotation-matrix = ortho-matrix(x, y, z)
355+
356+
let (distance, near) = _resolve-camera-distance-near(ctx, body,
357+
view-rotation-matrix, distance)
358+
let view-matrix = _perspective-view-matrix(view-rotation-matrix, distance)
359+
360+
let ref-depth = _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near)
361+
let projection = pt => _perspective-project-point(pt, near, ref-depth)
362+
363+
_projection(body, view-matrix, projection,
364+
sorted: sorted,
365+
cull-face: cull-face,
366+
reset-transform: reset-transform)
367+
})

src/mark.typ

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,14 @@
358358
let distance = (0, 0)
359359
let snap-to = (none, none)
360360
let drawables = ()
361+
let perspective-mode = ctx.at("_perspective-projection", default: false)
361362

362363
if style == none {
363364
style = (start: none, end: none, symbol: none)
364365
}
366+
if perspective-mode {
367+
style.transform-shape = true
368+
}
365369
let both-symbol = style.at("symbol", default: none)
366370
let start-symbol = style.at("start",
367371
default: both-symbol)
238 KB
Loading
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#import "/src/lib.typ": *
2+
#import "/tests/helper.typ": *
3+
#set page(width: auto, height: auto)
4+
5+
#let axes(l) = {
6+
import draw: *
7+
8+
set-style(mark: (end: ">", transform-shape: false))
9+
10+
on-layer(-1, {
11+
line((-l,0), (l,0), stroke: red, name: "x")
12+
content((rel: ((name: "x", anchor: 50%), .5, "x.end"), to: "x.end"), text(red, $x$))
13+
14+
line((0,-l), (0,l), stroke: blue, name: "y")
15+
content((rel: ((name: "y", anchor: 50%), .5, "y.end"), to: "y.end"), text(blue, $y$))
16+
17+
line((0,0,-l), (0,0,l), stroke: green, name: "z", mark: (z-up: (1,0,0)))
18+
content((rel: ((name: "z", anchor: 50%), .5, "z.end"), to: "z.end"), text(green, $z$))
19+
})
20+
}
21+
22+
#let checkerboard() = {
23+
import draw: *
24+
for x in range(0, 3) {
25+
for y in range(0, 3) {
26+
rect((x,y),(rel: (1,1)),
27+
fill: if calc.rem(x + y, 2) != 0 { black } else { white })
28+
}
29+
}
30+
}
31+
32+
#test-case({
33+
import draw: *
34+
perspective(reset-transform: false, {
35+
line((-1, 0), (1, 0), mark: (end: ">"))
36+
})
37+
})
38+
39+
#test-case({
40+
import draw: *
41+
perspective({
42+
axes(4)
43+
on-xy({
44+
checkerboard()
45+
})
46+
})
47+
})
48+
49+
#test-case({
50+
import draw: *
51+
perspective({
52+
axes(4)
53+
on-xz({
54+
checkerboard()
55+
})
56+
})
57+
})
58+
59+
#test-case({
60+
import draw: *
61+
perspective({
62+
axes(4)
63+
on-zy({
64+
checkerboard()
65+
})
66+
})
67+
})
68+
69+
#test-case({
70+
import draw: *
71+
perspective(sorted: true, {
72+
axes(4)
73+
on-zy(x: -1, {
74+
checkerboard()
75+
})
76+
on-xy(z: -1, {
77+
checkerboard()
78+
})
79+
on-xz(y: -1, {
80+
checkerboard()
81+
})
82+
})
83+
})
84+
85+
// Ordering
86+
#test-case({
87+
import draw: *
88+
perspective(sorted: true, {
89+
scope({ translate((0, 0, +1)); rect((-1, -1), (1, 1), fill: blue) })
90+
scope({ translate((0, 0, 0)); rect((-1, -1), (1, 1), fill: red) })
91+
scope({ translate((0, 0, -1)); rect((-1, -1), (1, 1), fill: green) })
92+
})
93+
})
94+
95+
// Fully visible
96+
#test-case({
97+
import draw: *
98+
perspective(x: 0deg, y: 0deg, cull-face: "cw", {
99+
rect((-1, -1), (1, 1))
100+
circle((0,0))
101+
})
102+
})
103+
104+
// Nothing visible
105+
#test-case({
106+
import draw: *
107+
perspective(x: 0deg, y: 0deg, cull-face: "cw", {
108+
line((-1, -1), (1, -1), (1, 1), (-1, 1), close: true)
109+
rotate(y: 120deg)
110+
line((-1,-1), (1,-1), (0,1), close: true)
111+
})
112+
})
113+
114+
// Face order of library shapes
115+
#test-case({
116+
import draw: *
117+
perspective(cull-face: "cw", {
118+
rect((-1, -1), (1, 1), radius: .5)
119+
})
120+
})
121+
122+
#test-case({
123+
import draw: *
124+
perspective(cull-face: "cw", {
125+
circle((0,0))
126+
})
127+
})
128+
129+
#test-case({
130+
import draw: *
131+
perspective(cull-face: "cw", {
132+
arc((0,0), start: 0deg, stop: 270deg, mode: "PIE")
133+
})
134+
})
135+
136+
#test-case({
137+
import draw: *
138+
perspective(cull-face: "cw", {
139+
content((0,0), [Text])
140+
})
141+
})
142+
143+
// #1004 - Leak objects to the outside
144+
#test-case({
145+
import draw: *
146+
perspective({
147+
on-xz({ circle((0, 2, 2), name: "a") })
148+
on-zy({ circle((2, 0, 0), name: "b") })
149+
})
150+
151+
line("a", "b")
152+
})

0 commit comments

Comments
 (0)