Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/api/draw-functions/projections/perspective.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Perspective from "@site/cetz/docs/_generated/draw/projection/perspective.mdx";

# perspective

<Perspective />
1 change: 1 addition & 0 deletions docs/api/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default [
},
items: [
"api/draw-functions/projections/ortho",
"api/draw-functions/projections/perspective",
"api/draw-functions/projections/on-xy",
"api/draw-functions/projections/on-xz",
"api/draw-functions/projections/on-zy",
Expand Down
2 changes: 1 addition & 1 deletion src/draw.typ
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
#import "draw/transformations.typ": set-transform, transform, rotate, translate, scale, set-origin, move-to, set-viewport
#import "draw/styling.typ": set-style, fill, stroke, register-mark
#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
#import "draw/projection.typ": ortho, on-xy, on-xz, on-zy
#import "draw/projection.typ": ortho, perspective, on-xy, on-xz, on-zy
#import "draw/util.typ": assert-version, register-coordinate-resolver
158 changes: 158 additions & 0 deletions src/draw/projection.typ
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#import "/src/drawable.typ"
#import "/src/util.typ"
#import "/src/polygon.typ"
#import "/src/path-util.typ"
#import "/src/aabb.typ"

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

// Build perspective view matrix from rotation and camera distance.
#let _perspective-view-matrix(view-rotation-matrix, distance) = {
matrix.mul-mat(
matrix.ident(4),
matrix.transform-translate(0, 0, -distance),
view-rotation-matrix,
)
}

// Perspective divide for a single point.
#let _perspective-project-point(pt, near, ref-depth) = {
let x = pt.at(0)
let y = pt.at(1)
let z = pt.at(2, default: 0.0)
let w = calc.max(-z, near)
// Normalize by a reference depth so scale is anchored at that plane.
(ref-depth * x / w, ref-depth * y / w, z)
}

#let _sort-by-distance(drawables) = {
return drawables.sorted(key: d => {
let z = none
Expand Down Expand Up @@ -56,6 +77,82 @@
})
}

// Compute aggregate bounds from a list of drawables.
#let _drawables-bounds(drawables) = {
let bounds = none
for d in drawable.filter-tagged(drawables, drawable.TAG.no-bounds) {
let pts = if d.type == "path" {
path-util.bounds(d.segments)
} else if d.type == "content" {
let (x, y, _, w, h,) = d.pos + (d.width, d.height)
((x + w / 2, y - h / 2, 0.0), (x - w / 2, y + h / 2, 0.0))
} else {
()
}
if pts != () {
bounds = aabb.aabb(pts, init: bounds)
}
}
return bounds
}

// Resolve reference depth as nearest drawable depth in camera space.
#let _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near) = {
let probe-ctx = ctx
probe-ctx.transform = view-rotation-matrix
let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many(
probe-ctx,
util.resolve-body(probe-ctx, body))
let probe-bounds = _drawables-bounds(probe-drawables)
if probe-bounds == none {
return near
}

// In camera space, depth is w = distance - z_rot. The nearest depth is at z_max.
let z-max = probe-bounds.high.at(2)
calc.max(distance - z-max, near)
}

// Resolve distance/near values, including support for `auto`.
#let _resolve-camera-distance-near(ctx, body, view-rotation-matrix, distance) = {
let distance = if distance == auto { auto } else { util.resolve-number(ctx, distance) }

let probe-ctx = ctx
probe-ctx.transform = view-rotation-matrix
let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many(
probe-ctx,
util.resolve-body(probe-ctx, body))
let probe-bounds = _drawables-bounds(probe-drawables)

let z-max = if probe-bounds == none { 0 } else { probe-bounds.high.at(2) }
let z-min = if probe-bounds == none { 0 } else { probe-bounds.low.at(2) }
let depth-span = calc.abs(z-max - z-min)
let probe-near = calc.max(0.001, 0.01 * calc.max(depth-span, 1))

if distance == auto {
// Use a larger margin to reduce perspective intensity for auto distance.
let margin = calc.max(probe-near * 3, depth-span * 2)
distance = calc.max(z-max + margin, probe-near * 2)
}

// Keep the camera in front of the nearest geometry after rotation.
let min-margin = calc.max(probe-near * 2, depth-span * 0.1)
let min-distance = z-max + min-margin
distance = calc.max(distance, min-distance)

let scene-scale = calc.max(depth-span, 1)
let min-w = distance - z-max
let near = if min-w > 0 {
calc.max(0.001, calc.min(min-w * 0.5, scene-scale * 0.05))
} else {
calc.max(0.001, scene-scale * 0.01)
}

assert(distance > 0, message: "distance must be > 0.")
assert(near > 0, message: "near must be > 0.")
(distance, near)
}

// Sets up a view matrix to transform all `body` elements. The current context
// transform is not modified.
//
Expand All @@ -69,6 +166,11 @@
#let _projection(body, view-matrix, projection-matrix, reset-transform: true, sorted: true, cull-face: "cw") = {
(ctx => {
let transform = ctx.transform
let perspective-mode = type(projection-matrix) == function
let previous-perspective-mode = ctx.at("_perspective-projection", default: false)
if perspective-mode {
ctx._perspective-projection = true
}
ctx.transform = view-matrix

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

ctx.transform = transform
if perspective-mode {
ctx._perspective-projection = previous-perspective-mode
}
if not reset-transform {
drawables = drawable.apply-transform(ctx.transform, drawables)
}
Expand Down Expand Up @@ -207,3 +312,56 @@
matrix.transform-translate(x, 0, 0)
}, matrix.transform-rotate-y(90deg))
})

/// Set-up a perspective projection environment.
///
/// Coordinates are transformed by a view matrix and then projected with
/// perspective division:
/// $x' = (d_"ref" * x) / w$ and $y' = (d_"ref" * y) / w$,
/// where $w = max(-z, "near")$ in view space.
///
/// By default this uses the same isometric camera angles as `ortho`, but with
/// perspective foreshortening.
///
/// ```example
/// perspective({
/// on-xz({
/// rect((-1,-1), (1,1))
/// })
/// })
/// ```
///
/// - x (angle): X-axis rotation angle
/// - y (angle): Y-axis rotation angle
/// - z (angle): Z-axis rotation angle
/// - distance (number,auto): Distance from camera to scene origin. `auto`
/// derives a stable value from scene depth.
/// - sorted (bool): Sort drawables by depth (back to front)
/// - cull-face (none,str): Enable back-face culling if set to `"cw"` for clockwise
/// or `"ccw"` for counter-clockwise. Polygons of the specified order will not get drawn.
/// - reset-transform (bool): Ignore the current transformation matrix
/// - body (element): Elements to draw
#let perspective(
x: 35.264deg,
y: 45deg,
z: 0deg,
distance: auto,
sorted: true,
cull-face: none,
reset-transform: false,
body,
) = scope(ctx => {
let view-rotation-matrix = ortho-matrix(x, y, z)

let (distance, near) = _resolve-camera-distance-near(ctx, body,
view-rotation-matrix, distance)
let view-matrix = _perspective-view-matrix(view-rotation-matrix, distance)

let ref-depth = _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near)
let projection = pt => _perspective-project-point(pt, near, ref-depth)

_projection(body, view-matrix, projection,
sorted: sorted,
cull-face: cull-face,
reset-transform: reset-transform)
})
4 changes: 4 additions & 0 deletions src/mark.typ
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,14 @@
let distance = (0, 0)
let snap-to = (none, none)
let drawables = ()
let perspective-mode = ctx.at("_perspective-projection", default: false)

if style == none {
style = (start: none, end: none, symbol: none)
}
if perspective-mode {
style.transform-shape = true
}
let both-symbol = style.at("symbol", default: none)
let start-symbol = style.at("start",
default: both-symbol)
Expand Down
Binary file added tests/projection/perspective/ref/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions tests/projection/perspective/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#import "/src/lib.typ": *
#import "/tests/helper.typ": *
#set page(width: auto, height: auto)

#let axes(l) = {
import draw: *

set-style(mark: (end: ">", transform-shape: false))

on-layer(-1, {
line((-l,0), (l,0), stroke: red, name: "x")
content((rel: ((name: "x", anchor: 50%), .5, "x.end"), to: "x.end"), text(red, $x$))

line((0,-l), (0,l), stroke: blue, name: "y")
content((rel: ((name: "y", anchor: 50%), .5, "y.end"), to: "y.end"), text(blue, $y$))

line((0,0,-l), (0,0,l), stroke: green, name: "z", mark: (z-up: (1,0,0)))
content((rel: ((name: "z", anchor: 50%), .5, "z.end"), to: "z.end"), text(green, $z$))
})
}

#let checkerboard() = {
import draw: *
for x in range(0, 3) {
for y in range(0, 3) {
rect((x,y),(rel: (1,1)),
fill: if calc.rem(x + y, 2) != 0 { black } else { white })
}
}
}

#test-case({
import draw: *
perspective(reset-transform: false, {
line((-1, 0), (1, 0), mark: (end: ">"))
})
})

#test-case({
import draw: *
perspective({
axes(4)
on-xy({
checkerboard()
})
})
})

#test-case({
import draw: *
perspective({
axes(4)
on-xz({
checkerboard()
})
})
})

#test-case({
import draw: *
perspective({
axes(4)
on-zy({
checkerboard()
})
})
})

#test-case({
import draw: *
perspective(sorted: true, {
axes(4)
on-zy(x: -1, {
checkerboard()
})
on-xy(z: -1, {
checkerboard()
})
on-xz(y: -1, {
checkerboard()
})
})
})

// Ordering
#test-case({
import draw: *
perspective(sorted: true, {
scope({ translate((0, 0, +1)); rect((-1, -1), (1, 1), fill: blue) })
scope({ translate((0, 0, 0)); rect((-1, -1), (1, 1), fill: red) })
scope({ translate((0, 0, -1)); rect((-1, -1), (1, 1), fill: green) })
})
})

// Fully visible
#test-case({
import draw: *
perspective(x: 0deg, y: 0deg, cull-face: "cw", {
rect((-1, -1), (1, 1))
circle((0,0))
})
})

// Nothing visible
#test-case({
import draw: *
perspective(x: 0deg, y: 0deg, cull-face: "cw", {
line((-1, -1), (1, -1), (1, 1), (-1, 1), close: true)
rotate(y: 120deg)
line((-1,-1), (1,-1), (0,1), close: true)
})
})

// Face order of library shapes
#test-case({
import draw: *
perspective(cull-face: "cw", {
rect((-1, -1), (1, 1), radius: .5)
})
})

#test-case({
import draw: *
perspective(cull-face: "cw", {
circle((0,0))
})
})

#test-case({
import draw: *
perspective(cull-face: "cw", {
arc((0,0), start: 0deg, stop: 270deg, mode: "PIE")
})
})

#test-case({
import draw: *
perspective(cull-face: "cw", {
content((0,0), [Text])
})
})

// #1004 - Leak objects to the outside
#test-case({
import draw: *
perspective({
on-xz({ circle((0, 2, 2), name: "a") })
on-zy({ circle((2, 0, 0), name: "b") })
})

line("a", "b")
})
Loading