diff --git a/docs/basics/marks.mdx b/docs/basics/marks.mdx index eb0a0148f..d07045a4c 100644 --- a/docs/basics/marks.mdx +++ b/docs/basics/marks.mdx @@ -151,4 +151,7 @@ line(..c) When `false` marks will not be stretched/affected by the current transformation, marks will be placed after the path is transformed. + Under `perspective`, `false` keeps marks flat in paper space while still + scaling them by depth. Mark placement and path shortening also happen in that + projected paper space. diff --git a/src/draw/projection.typ b/src/draw/projection.typ index 606742668..6b3a1d28f 100644 --- a/src/draw/projection.typ +++ b/src/draw/projection.typ @@ -41,6 +41,40 @@ (ref-depth * x / w, ref-depth * y / w, z) } +// Adapt mark placement for flat perspective marks by projecting the path first, +// then scaling mark-local geometry from the matching view-space tip depth. +#let _perspective-mark-placement-adapter(projection, scale-factor, transform, path) = { + let view-path = if transform != none { + drawable.apply-transform(transform, path).first() + } else { + path + } + let path = drawable.apply-transform(projection, view-path).first() + let path = drawable.apply-transform(matrix.transform-scale((1, 1, 0)), path).first() + + let adjust-style = (style, mark-tip-info) => { + let view-point = path-util.segment-point-at( + view-path.segments, + mark-tip-info.subpath-index, + mark-tip-info.segment-index, + mark-tip-info.t, + ) + let factor = scale-factor(view-point) + style = style + for key in ("length", "width", "inset") { + style.insert(key, style.at(key) * factor) + } + style + } + + ( + path: path, + transform: none, + projected: true, + adjust-style: adjust-style, + ) +} + #let _sort-by-distance(drawables) = { return drawables.sorted(key: d => { let z = none @@ -99,6 +133,9 @@ #let _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near) = { let probe-ctx = ctx probe-ctx.transform = view-rotation-matrix + // Probe the underlying scene only, otherwise mark placement can change + // the camera scaling when `transform-shape` toggles. + probe-ctx.ignore-marks = true let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many( probe-ctx, util.resolve-body(probe-ctx, body)) @@ -118,6 +155,9 @@ let probe-ctx = ctx probe-ctx.transform = view-rotation-matrix + // Probe the underlying scene only, otherwise mark placement can change + // the camera scaling when `transform-shape` toggles. + probe-ctx.ignore-marks = true let (ctx: _, bounds: _, drawables: probe-drawables, elements: _) = process.many( probe-ctx, util.resolve-body(probe-ctx, body)) @@ -163,13 +203,12 @@ // - 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. // - flatten (bool): Set $z=0$ for all geometry -#let _projection(body, view-matrix, projection-matrix, reset-transform: true, sorted: true, cull-face: "cw", flatten: false) = { +#let _projection(body, view-matrix, projection-matrix, mark-placement-adapter: none, reset-transform: true, sorted: true, cull-face: "cw", flatten: false) = { (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 + let previous-mark-placement-adapter = ctx.at("mark-placement-adapter", default: none) + if mark-placement-adapter != none { + ctx.mark-placement-adapter = mark-placement-adapter } let local-ctx = ctx @@ -193,10 +232,19 @@ drawables = _sort-by-distance(drawables) } + if projection-matrix != none { + drawables = drawables.map(d => { + if drawable.TAG.projected in d.at("tags", default: ()) { + d + } else { + drawable.apply-transform(projection-matrix, d).first() + } + }) + } + ctx.transform = transform - if perspective-mode { - drawable.apply-transform(projection-matrix, drawables) - ctx._perspective-projection = previous-perspective-mode + if mark-placement-adapter != none { + ctx.mark-placement-adapter = previous-mark-placement-adapter } if not reset-transform { drawables = drawable.apply-transform(ctx.transform, drawables) @@ -367,8 +415,14 @@ let ref-depth = _resolve-reference-depth(ctx, body, view-rotation-matrix, distance, near) let projection = pt => _perspective-project-point(pt, near, ref-depth) + let scale-factor = pt => ref-depth / calc.max(-pt.at(2, default: 0.0), near) + let mark-placement-adapter = _perspective-mark-placement-adapter.with( + projection, + scale-factor, + ) _projection(body, view-matrix, projection, + mark-placement-adapter: mark-placement-adapter, sorted: sorted, cull-face: cull-face, reset-transform: reset-transform) diff --git a/src/drawable.typ b/src/drawable.typ index 6dcc1cbe3..2fc38d30d 100644 --- a/src/drawable.typ +++ b/src/drawable.typ @@ -7,6 +7,8 @@ hidden: "hidden", no-bounds: "no-bounds", mark: "mark", + // Drawable geometry already lives in projected paper space. + projected: "projected", debug: "debug", ) diff --git a/src/mark.typ b/src/mark.typ index 867bae916..fc279cce4 100644 --- a/src/mark.typ +++ b/src/mark.typ @@ -246,7 +246,7 @@ /// - segments (drawable): The path to place the mark on. /// - is-end (bool): Start from the end of the path /// -> dictionary Dictionary with the following keys: pt, distance and drawable. -#let place-mark-on-path(ctx, styles, segments, is-end: false) = { +#let place-mark-on-path(ctx, styles, segments, is-end: false, adjust-style: none) = { if type(styles) != array { styles = (styles,) } @@ -283,22 +283,28 @@ style = merge-flag(style, "harpoon") style.mark = none - let mark = _eval-mark-shape-and-anchors(ctx, mark-fn(style), style) - let offset = style.at("offset", default: 0) - let inset = style.at("inset", default: 0) - let mark-tip-info = path-util.point-at( segments, distance, reverse: is-end) + + // Do not try to place this mark, if we failed to + // get a tip/base info. + if mark-tip-info == none { + continue + } + + if adjust-style != none { + style = adjust-style(style, mark-tip-info) + } + + let mark = _eval-mark-shape-and-anchors(ctx, mark-fn(style), style) + let inset = style.at("inset", default: 0) let mark-base-info = if mark.length != 0 { path-util.point-at( segments, distance + mark.length - inset, reverse: is-end) } else { mark-tip-info } - - // Do not try to place this mark, if we failed to - // get a tip/base info. - if mark-tip-info == none or mark-base-info == none { + if mark-base-info == none { continue } @@ -346,6 +352,36 @@ ) } +#let _adapt-mark-placement(ctx, style, transform, path) = { + let transform-shape = style.at("transform-shape", default: true) + let mark-placement-adapter = ctx.at("mark-placement-adapter", default: none) + + let projected = false + let adjust-style = none + + if not transform-shape and mark-placement-adapter != none { + let adapted = mark-placement-adapter( + transform, + path, + ) + path = adapted.path + transform = adapted.transform + projected = adapted.at("projected", default: false) + adjust-style = adapted.at("adjust-style", default: none) + } else if not transform-shape and transform != none { + path = drawable.apply-transform( + matrix.mul-mat(matrix.transform-scale((1,1,0)), transform), path).first() + transform = none + } + + ( + path: path, + transform: transform, + projected: projected, + adjust-style: adjust-style, + ) +} + /// Places marks along a path. Returns them as an {{array}} of {{drawable}}. /// /// - ctx (context): The context object. @@ -355,42 +391,44 @@ /// - add-path (bool): When `true` the shortened path will returned as the first {{drawable}} in the {{array}} /// -> array #let place-marks-along-path(ctx, style, transform, path, add-path: true) = { - let distance = (0, 0) - let snap-to = (none, none) - let drawables = () - let perspective-mode = ctx.at("_perspective-projection", default: false) + if ctx.at("ignore-marks", default: false) { + return if add-path { + drawable.apply-transform(transform, path) + } else { + () + } + } if style == none { style = (start: none, end: none, symbol: none) } - if perspective-mode { - style.transform-shape = true - } + let adapted = _adapt-mark-placement(ctx, style, transform, path) + path = adapted.path + transform = adapted.transform + let projected = adapted.projected + let adjust-style = adapted.adjust-style + + let distance = (0, 0) + let snap-to = (none, none) + let drawables = () + let both-symbol = style.at("symbol", default: none) - let start-symbol = style.at("start", - default: both-symbol) + let start-symbol = style.at("start", default: both-symbol) if start-symbol == none { start-symbol = both-symbol } - let end-symbol = style.at("end", - default: both-symbol) + let end-symbol = style.at("end", default: both-symbol) if end-symbol == none { end-symbol = both-symbol } - let (path, is-transformed) = if not style.at("transform-shape", default: true) and transform != none { - (drawable.apply-transform( - matrix.mul-mat(matrix.transform-scale((1,1,0)), transform), path).first(), true) - } else { - (path, false) - } - let segments = path.segments if start-symbol != none { let (drawables: start-drawables, distance: start-distance, pos: pt) = place-mark-on-path( ctx, process-style(ctx, style, "start", path-util.length(segments)), - segments + segments, + adjust-style: adjust-style, ) drawables += start-drawables distance.first() = start-distance @@ -401,7 +439,8 @@ ctx, process-style(ctx, style, "end", path-util.length(segments)), segments, - is-end: true + is-end: true, + adjust-style: adjust-style, ) drawables += end-drawables @@ -421,11 +460,12 @@ drawables.insert(0, path) } - // If not transformed pre mark placement, - // transform everything after mark placement. - if not is-transformed { + if transform != none { drawables = drawable.apply-transform(transform, drawables) } + if projected { + drawables = drawable.apply-tags(drawables, drawable.TAG.projected) + } return drawables } diff --git a/src/path-util.typ b/src/path-util.typ index dbb13f9b1..3ce981a4b 100644 --- a/src/path-util.typ +++ b/src/path-util.typ @@ -170,6 +170,7 @@ /// - point (vector) The point on the path /// - previous-point (vector) Point previous to point /// - direction (vector) Normalized direction vector +/// - t (float) Relative segment parameter in $[0, 1]$ /// - subpath-index (int) Index of the subpath /// - segment-index (int) Index of the segment /// None is returned, if the path is empty/of length zero. @@ -205,12 +206,14 @@ let pt = args.last() let length = vector.dist(origin, pt) if length != 0 { + let t = calc.min(1, distance / length) return ( - vector.lerp(origin, pt, calc.min(1, distance / length)), - vector.norm(vector.sub(pt, origin))) + vector.lerp(origin, pt, t), + vector.norm(vector.sub(pt, origin)), + t) } else { return ( - pt, (1, 0, 0)) + pt, (1, 0, 0), 0.0) } } else if kind == "c" { let (c1, c2, e) = args @@ -219,7 +222,8 @@ return ( bezier.cubic-point(origin, e, c1, c2, t), - bezier.cubic-derivative(origin, e, c1, c2, t)) + bezier.cubic-derivative(origin, e, c1, c2, t), + t) } } @@ -232,7 +236,7 @@ origin = segments.at(segment-index - 1).last() } - let (point, direction) = point-on-segment( + let (point, direction, t) = point-on-segment( origin, segment, distance - travelled) if reverse { direction = vector.scale(direction, -1) @@ -259,6 +263,7 @@ previous-point: origin, point: point, direction: direction, + t: t, subpath-index: subpath-index, segment-index: segment-index, distance: relative-distance, @@ -274,6 +279,28 @@ } } +/// Returns the point on a specific path segment at parameter `t`. +/// +/// - path (path): The path +/// - subpath-index (int): Subpath index +/// - segment-index (int): Segment index +/// - t (float): Relative segment parameter in $[0, 1]$ +/// -> vector +#let segment-point-at(path, subpath-index, segment-index, t) = { + let (origin, _, segments) = path.at(subpath-index) + let (kind, ..args) = segments.at(segment-index) + if segment-index > 0 { + origin = segments.at(segment-index - 1).last() + } + + return if kind == "l" { + vector.lerp(origin, args.last(), t) + } else if kind == "c" { + let (c1, c2, e) = args + bezier.cubic-point(origin, e, c1, c2, t) + } +} + /// Shorten a single line segment with a single point /// by a given distance. /// diff --git a/tests/projection/perspective/ref/1.png b/tests/projection/perspective/ref/1.png index f1ef625d2..4ea4c546f 100644 Binary files a/tests/projection/perspective/ref/1.png and b/tests/projection/perspective/ref/1.png differ diff --git a/tests/projection/perspective/test.typ b/tests/projection/perspective/test.typ index e352f6bf2..a3f572e47 100644 --- a/tests/projection/perspective/test.typ +++ b/tests/projection/perspective/test.typ @@ -38,6 +38,42 @@ }) }) +#test-case({ + import draw: * + perspective(x: 40deg, y: 25deg, distance: 7, { + line( + (0, 0, 0), + (2.8, 0, 0), + stroke: blue + 1pt, + mark: (start:"o", end: "stealth", transform-shape: false, scale: 1.5, fill: blue), + ) + line( + (0, 1.1, 3.8), + (2.8, 1.1, 3.8), + stroke: red + 1pt, + mark: (start:"o", end: "stealth", transform-shape: false, scale: 1.5, fill: red), + ) + }) +}) + +#test-case({ + import draw: * + perspective(x: 40deg, y: 25deg, distance: 7, { + line( + (0, 0, 0), + (2.8, 0, 0), + stroke: blue + 1pt, + mark: (start:"o", end: "stealth", transform-shape: true, scale: 1.5, fill: blue), + ) + line( + (0, 1.1, 3.8), + (2.8, 1.1, 3.8), + stroke: red + 1pt, + mark: (start:"o", end: "stealth", transform-shape: true, scale: 1.5, fill: red), + ) + }) +}) + #test-case({ import draw: * perspective({