Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
157 changes: 157 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,10 @@
#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
if perspective-mode {
ctx.shared-state.insert("_perspective-projection", true)
}
ctx.transform = view-matrix

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

ctx.transform = transform
if perspective-mode {
ctx.shared-state.insert("_perspective-projection", false)
}
if not reset-transform {
drawables = drawable.apply-transform(ctx.transform, drawables)
}
Expand Down Expand Up @@ -207,3 +311,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.shared-state.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