Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3D Elements #657

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ package called `cetz-plot`.

## Draw
- Added `floating` function for drawing elements without affecting bounding boxes.
- The `ortho` function gained a `sorted` and `cull-face` argument to enable
depth ordering and face culling of drawables. Ordering is enabled by default.
- **BREAKING** Elements changed to counter clock-wise drawing order. This can
lead to changed path anchors, as the direction has changed!

## Marks
- Added support for mark `anchor` style key, to adjust mark placement and
Expand All @@ -45,6 +49,10 @@ package called `cetz-plot`.
## Anchors
- `copy-anchors` no longer requires copied anchors to have a default, allowing the copying of an element's anchors from a group as expected.

## 3D
- Added a `prism` function that allows extending a supplied front-face
along a vector.

# 0.2.2

## Anchors
Expand Down
Binary file modified manual.pdf
Binary file not shown.
4 changes: 4 additions & 0 deletions manual.typ
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ $ M_"world" = M_"world" dot M_"local" $

#doc-style.parse-show-module("/src/draw/projection.typ")

== Bodies

#doc-style.parse-show-module("/src/draw/bodies.typ")

= Coordinate Systems <coordinate-systems>
A _coordinate_ is a position on the canvas on which the picture is drawn. They take the form of dictionaries and the following sub-sections define the key value pairs for each system. Some systems have a more implicit form as an array of values and `CeTZ` attempts to infer the system based on the element types.

Expand Down
3 changes: 2 additions & 1 deletion src/draw.typ
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import "draw/grouping.typ": intersections, group, anchor, copy-anchors, place-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, place-marks, hide, floating
#import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, place-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, place-marks, hide, floating
#import "draw/transformations.typ": set-transform, rotate, translate, scale, set-origin, move-to, set-viewport
#import "draw/styling.typ": set-style, fill, stroke
#import "draw/shapes.typ": circle, circle-through, arc, arc-through, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path
#import "draw/bodies.typ": prism
#import "draw/projection.typ": ortho, on-xy, on-xz, on-yz
#import "draw/util.typ": assert-version
101 changes: 101 additions & 0 deletions src/draw/bodies.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#import "/src/coordinate.typ"
#import "/src/drawable.typ"
#import "/src/styles.typ"
#import "/src/path-util.typ"
#import "/src/util.typ"
#import "/src/vector.typ"
#import "/src/matrix.typ"
#import "/src/process.typ"
#import "/src/polygon.typ"

/// Draw a prism by extending a single element
/// into a direction.
///
/// Curved shapes get sampled into linear ones.
///
/// = parameters
///
/// = Styling
/// *Root:* `prism`
/// == Keys
/// #show-parameter-block("front-stroke", ("stroke", "none"), [Front-face stroke], default: auto)
/// #show-parameter-block("front-fill", ("fill", "none"), [Front-face fill], default: auto)
/// #show-parameter-block("back-stroke", ("stroke", "none"), [Back-face stroke], default: auto)
/// #show-parameter-block("back-fill", ("fill", "none"), [Back-face fill], default: auto)
/// #show-parameter-block("side-stroke", ("stroke", "none"), [Side stroke], default: auto)
/// #show-parameter-block("side-fill", ("fill", "none"), [Side fill], default: auto)
///
/// ```example
/// ortho({
/// // Draw a cube with and edge length of 2
/// prism({
/// rect((-1, -1), (rel: (2, 2)))
/// }, 2)
/// })
/// ```
///
/// - front-face (elements): A single element to use as front-face
/// - dir (number,vector): Z-distance or direction vector to extend
/// the front-face along
/// - samples (int): Number of samples to use for sampling curves
#let prism(front-face, dir, samples: 10, ..style) = {
assert.eq(style.pos(), (),
message: "Prism takes no positional arguments")

let style = style.named()
(ctx => {
let transform = ctx.transform
ctx.transform = matrix.ident()
let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, front-face))
ctx.transform = transform

assert.eq(drawables.len(), 1,
message: "Prism shape must be a single drawable.")

let points = polygon.from-segments(drawables.first().segments, samples: samples)

let style = styles.resolve(ctx.style, merge: style, root: "prism")

// Normal to extend the front face along
let n = if type(dir) == array {
dir.map(util.resolve-number.with(ctx))
} else {
(0, 0, util.resolve-number(ctx, dir))
}

let stroke = (:)
let fill = (:)
for face in ("front", "back", "side") {
stroke.insert(face, style.at("stroke-" + face, default: style.stroke))
fill.insert(face, style.at("fill-" + face, default: style.fill))
}

let drawables = ()
let back-points = util.apply-transform(matrix.transform-translate(..n), ..points)

// Back
let back = drawable.path(path-util.line-segment(back-points.rev()),
close: true, stroke: stroke.back, fill: fill.back)
drawables.push(back)

// Sides
for i in range(0, points.len()) {
let k = calc.rem(i + 1, points.len())

let quad = (points.at(i), back-points.at(i), back-points.at(k), points.at(k))
let side = drawable.path(path-util.line-segment(quad),
close: true, stroke: stroke.side, fill: fill.side)
drawables.push(side)
}

// Front
let front = drawable.path(path-util.line-segment(points),
close: true, stroke: stroke.front, fill: fill.front)
drawables.push(front)

return (
ctx: ctx,
drawables: drawable.apply-transform(ctx.transform, drawables),
)
},)
}
86 changes: 67 additions & 19 deletions src/draw/projection.typ
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
#import "/src/matrix.typ"
#import "/src/drawable.typ"
#import "/src/util.typ"

#let ortho-projection-matrix = ((1, 0, 0, 0),
(0, 1, 0, 0),
(0, 0, 0, 0),
(0, 0, 0, 1))
#import "/src/polygon.typ"

// Get an orthographic view matrix for 3 angles
#let ortho-matrix(x, y, z) = matrix.mul-mat(
Expand All @@ -18,25 +14,72 @@
matrix.transform-rotate-z(z),
)

// Pushes a view- and projection-matrix to transform
// all `body` elements. The current context transform is
// not modified.
#let ortho-projection-matrix = (
(1, 0, 0, 0),
(0, 1, 0, 0),
(0, 0, 0, 0),
(0, 0, 0, 1),
)

// Sort drawables by median or max segment z-value
#let _sort-by-distance(drawables) = {
return drawables.sorted(key: d => {
let z = none
for ((kind, ..pts)) in d.segments {
pts = pts.map(p => p.at(2))
z = if z == none {
calc.max(..pts)
} else {
calc.max(z, ..pts)
}
}
return z
})
}

// Filter out all clock-wise polygons, or if `invert` is true,
// all counter clock-wise ones.
#let _filter-cw-faces(drawables, mode: "cw") = {
return drawables.filter(d => {
let poly = polygon.from-segments(d.segments)
poly.first() != poly.last() or polygon.winding-order(poly) == mode
})
}

// Sets up a view matrix to transform all `body` elements. The current context
// transform is not modified.
//
// - body (element): Elements
// - view-matrix (matrix): View matrix
// - projection-matrix (matrix): Projection matrix
// - reset-transform (bool): If true, override (and thus ignore)
// the current transformation with the new matrices instead
// of multiplying them.
#let _projection(body, view-matrix, projection-matrix, reset-transform: false) = {
// - reset-transform (bool): Ignore the current transformation matrix
// - sorted (bool): Sort drawables by maximum distance (front to back)
// - cull-face (none,string): Enable back-face culling if set to `"cw"` for clockwise
// or `"ccw"` for counter-clockwise. Polygons of the specified order will not get drawn.
#let _projection(body, view-matrix, projection-matrix, reset-transform: true, sorted: true, cull-face: "cw") = {
(ctx => {
let transform = ctx.transform
ctx.transform = matrix.mul-mat(projection-matrix, view-matrix)
if not reset-transform {
ctx.transform = matrix.mul-mat(transform, ctx.transform)
}
ctx.transform = view-matrix

let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, body))

if cull-face != none {
assert(cull-face in ("cw", "ccw"),
message: "cull-face must be none, cw or ccw.")
drawables = _filter-cw-faces(drawables, mode: cull-face)
}
if sorted {
drawables = _sort-by-distance(drawables)
}

if projection-matrix != none {
drawables = drawable.apply-transform(projection-matrix, drawables)
}

ctx.transform = transform
if not reset-transform {
drawables = drawable.apply-transform(ctx.transform, drawables)
}

return (
ctx: ctx,
Expand Down Expand Up @@ -79,11 +122,16 @@
/// - x (angle): X-axis rotation angle
/// - y (angle): Y-axis rotation angle
/// - z (angle): Z-axis rotation angle
/// - sorted (bool): Sort drawables by maximum distance (front to back)
/// - cull-face (none,string): 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 ortho(x: 35.264deg, y: 45deg, z: 0deg, reset-transform: false, body, name: none) = group(name: name, ctx => {
_projection(body, ortho-matrix(x, y, z),
ortho-projection-matrix, reset-transform: reset-transform)
#let ortho(x: 35.264deg, y: 45deg, z: 0deg, sorted: true, cull-face: none, reset-transform: false, body, name: none) = group(name: name, ctx => {
_projection(body, ortho-matrix(x, y, z), ortho-projection-matrix,
sorted: sorted,
cull-face: cull-face,
reset-transform: reset-transform)
})

/// Draw elements on the xy-plane with optional z offset.
Expand Down
24 changes: 12 additions & 12 deletions src/draw/shapes.typ
Original file line number Diff line number Diff line change
Expand Up @@ -1069,10 +1069,10 @@
let (rx, ry) = radius
if rx > 0 or ry > 0 {
let m = 0.551784
let p0 = (p0.at(0) * m * radius.at(0),
p0.at(1) * m * radius.at(1))
let p1 = (p1.at(0) * m * radius.at(0),
p1.at(1) * m * radius.at(1))
let p0 = (p0.at(0) * m * rx,
p0.at(1) * m * ry)
let p1 = (p1.at(0) * m * rx,
p1.at(1) * m * ry)
(path-util.cubic-segment(s, e,
vector.add(s, p0),
vector.add(e, p1)),)
Expand All @@ -1098,14 +1098,14 @@
let (p6, p7) = get-corner-pts(sw, (x1, y1, z), ( 1, 0), ( 0, 1))

let segments = ()
segments += corner-arc(nw, p0, p1, (0, 1), (-1, 0))
if p1 != p2 { segments += (path-util.line-segment((p1, p2)),) }
segments += corner-arc(ne, p2, p3, (1, 0), (0, 1))
if p3 != p4 { segments += (path-util.line-segment((p3, p4)),) }
segments += corner-arc(se, p4, p5, (0, -1), (1, 0))
if p5 != p6 { segments += (path-util.line-segment((p5, p6)),) }
segments += corner-arc(sw, p6, p7, (-1, 0), (0,-1))
if p7 != p0 { segments += (path-util.line-segment((p7, p0)),) }
segments += corner-arc(nw, p1, p0, (-1,0), (0, 1))
if p0 != p7 { segments += (path-util.line-segment((p0, p7)),) }
segments += corner-arc(sw, p7, p6, (0,-1), (-1,0))
if p6 != p5 { segments += (path-util.line-segment((p6, p5)),) }
segments += corner-arc(se, p5, p4, (1, 0), (0,-1))
if p4 != p3 { segments += (path-util.line-segment((p4, p3)),) }
segments += corner-arc(ne, p3, p2, (0, 1), (1, 0))
if p2 != p1 { segments += (path-util.line-segment((p2, p1)),) }

drawable.path(segments, fill: style.fill, stroke: style.stroke, close: true)
}
Expand Down
25 changes: 15 additions & 10 deletions src/drawable.typ
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,28 @@
(
path-util.cubic-segment(
(x, top, z),
(right, y, z),
(x + m * rx, top, z),
(right, y + m * ry, z),
(left, y, z),
(x - m * rx, top, z),
(left, y + m * ry, z),
),
path-util.cubic-segment(
(right, y, z),
(left, y, z),
(x, bottom, z),
(right, y - m * ry, z),
(x + m * rx, bottom, z),
(left, y - m * ry, z),
(x - m * rx, bottom, z),
),
path-util.cubic-segment(
(x, bottom, z),
(left, y, z),
(x - m * rx, bottom, z),
(left, y - m * ry, z),
(right, y, z),
(x + m * rx, bottom, z),
(right, y - m * ry, z),
),
path-util.cubic-segment(
(right, y, z),
(x, top, z),
(right, y + m * ry, z),
(x + m * rx, top, z)
),
path-util.cubic-segment((left, y, z), (x, top, z), (left, y + m * ry, z), (x - m * rx, top, z)),
),
stroke: stroke,
fill: fill,
Expand Down
59 changes: 59 additions & 0 deletions src/polygon.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/// Returns a list of polygon points from
/// a list of segments.
///
/// Cubic segments get linearized by sampling.
///
/// - segment (list): List of segments
/// - samples (int): Number of samples
/// -> List of vectors
#let from-segments(segments, samples: 10) = {
import "/src/bezier.typ": cubic-point
let poly = ()
for ((kind, ..pts)) in segments {
if kind == "cubic" {
poly += range(0, samples).map(t => {
cubic-point(..pts, t / (samples - 1))
})
} else {
poly += pts
}
}
return poly
}

/// Computes the signed area of a 2D polygon.
///
/// The formula used is the following:
/// $ 1/2 sum_i=0^n-1 x_i*y_i+1 - x_i+1*y_i $
///
/// - points (list): List of Vectors of dimension >= 2
/// -> Signed area
#let signed-area(points) = {
let a = 0
let n = points.len()
let (cx, cy) = (0, 0)
for i in range(0, n) {
let (x0, y0, ..) = points.at(i)
let (x1, y1, ..) = points.at(calc.rem(i + 1, n))
cx += (x0 + x1) * (x0 * y1 - x1 * y0)
cy += (y0 + y1) * (x0 * y1 - x1 * y0)
a += x0 * y1 - x1 * y0
}
return .5 * a
}

/// Returns the winding order of a 2D polygon
/// by using it's signed area.
///
/// - point (list): List of polygon points
/// -> "ccw" (counter clock-wise) or "cw" (clock-wise) or none
#let winding-order(points) = {
let area = signed-area(points)
if area > 0 {
"cw"
} else if area < 0 {
"ccw"
} else {
none
}
}
Loading
Loading