diff --git a/CHANGES.md b/CHANGES.md index bc2d2cc5..29c53590 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,6 +17,8 @@ - The `merge-path` element now support `mark:`; by default, marks of the source elements get removed (#922, #948) - The `intersections` element ignores mark shapes by default (see `ignore-marks:`) (#948) +- Added a new `(project: , onto: (, ))` + coordinate for projecting a point onto a line (short form: `(pt, "_|_", a, b)`) # 0.4.1 - Added a `n-star` shape for drawing n-pointed stars diff --git a/docs/basics/coordinate-systems.mdx b/docs/basics/coordinate-systems.mdx index ce259b02..83f2444d 100644 --- a/docs/basics/coordinate-systems.mdx +++ b/docs/basics/coordinate-systems.mdx @@ -376,6 +376,22 @@ circle(a, radius: .1, fill: black) line((a, .7, b), (a: (), b: a, number: .5, angle: 90deg), stroke: red) ``` +## Projection + +To project a point `pt` onto a line from `a` to `b`, you can use the +`(project: pt, onto: (a, b))` or short `(pt, "_|_", a, b)` coordinate. + +```typc exapmle +set-style(fill: black, radius: 0.1) + +circle(name: "A", (0, 0)) +circle(name: "B", (3, 1)) +circle(name: "P", (1.9, -1.6)) + +line("A", "B") +line("P", (project: "P", onto: ("A", "B"))) +``` + ## Function An array where the first element is a function and the rest are coordinates will cause the function to be called with the resolved coordinates. The resolved coordinates will be given as a vector that represents an xyz point in space. diff --git a/src/canvas.typ b/src/canvas.typ index 9a25b9b6..df88880e 100644 --- a/src/canvas.typ +++ b/src/canvas.typ @@ -8,6 +8,7 @@ #import "styles.typ" #import "process.typ" #import "coordinate.typ" +#import "path-modifier.typ" /// Sets up a canvas for drawing on. /// @@ -63,8 +64,10 @@ mnemonics: (:), marks: (:), ), - // coordinate resolver + // Coordinate resolver resolve-coordinate: (), + // Path modifiers + path-modifiers: path-modifier.builtin, // Shared state that is not scoped by group/scope elements. // CeTZ itself does not use this dictionary for data. shared-state: (:), diff --git a/src/coordinate.typ b/src/coordinate.typ index a5c5c124..42495dd0 100644 --- a/src/coordinate.typ +++ b/src/coordinate.typ @@ -251,6 +251,30 @@ return vector.add(a, vector.scale(ab, distance)) } +// Resolve a projection coordinate. +// +// (project: p, onto: (a, b)) +// (p, "_|_", a, b) +// (p, "⟂", a, b) +#let resolve-project-point-on-line(resolve, ctx, c) = { + let (ctx, a, b, p) = if type(c) == dictionary { + let (project: p, onto: (a, b)) = c + (_, a, b) = resolve(ctx, a, b) + (ctx, p) = resolve(ctx, p) + (ctx, a, b, p) + } else { + let (p, _, a, b) = c + (_, a, b) = resolve(ctx, a, b) + (ctx, p) = resolve(ctx, p) + (ctx, a, b, p) + } + + let ap = vector.sub(p, a) + let ab = vector.sub(b, a) + + return vector.add(a, vector.scale(ab, vector.dot(ap, ab) / vector.dot(ab, ab))) +} + #let resolve-function(resolve, ctx, c) = { let (func, ..c) = c (ctx, ..c) = resolve(ctx, ..c) @@ -290,6 +314,8 @@ "relative" } else if len in (3, 4) and keys.all(k => k in ("a", "number", "angle", "abs", "b")) { "lerp" + } else if len == 2 and keys.all(k => k in ("project", "onto")) { + "project" } } else if type(c) == array { let len = c.len() @@ -304,6 +330,8 @@ "perpendicular" } else if len in (3, 4) and types.at(1) in (int, float, length, ratio) and (len == 3 or (len == 4 and types.at(2) == angle)) { "lerp" + } else if len == 4 and c.at(1) in ("_|_", "⟂") { + "project" } else if len >= 2 and types.first() == function { "function" } @@ -370,6 +398,8 @@ c } else if t == "lerp" { resolve-lerp(resolve, ctx, c) + } else if t == "project" { + resolve-project-point-on-line(resolve, ctx, c) } else if t == "function" { resolve-function(resolve, ctx, c) } else { diff --git a/src/draw/shapes.typ b/src/draw/shapes.typ index bff5ab12..17331c8c 100644 --- a/src/draw/shapes.typ +++ b/src/draw/shapes.typ @@ -16,6 +16,7 @@ #import "/src/mark-shapes.typ" as mark-shapes_ #import "/src/polygon.typ" as polygon_ #import "/src/aabb.typ" +#import "/src/path-modifier.typ": apply-modifiers #import "transformations.typ": * #import "styling.typ": * @@ -81,6 +82,9 @@ stroke: style.stroke ) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = anchor_.setup( (_) => center, ("center",), @@ -149,6 +153,9 @@ stroke: style.stroke ) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = anchor_.setup( (anchor) => ( center: center, @@ -259,6 +266,9 @@ mode: style.mode ) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let sector-center = ( x - rx * calc.cos(start-angle), y - ry * calc.sin(start-angle), @@ -579,6 +589,9 @@ stroke: style.stroke, ) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + // Get bounds let (transform, anchors) = anchor_.setup( name => { @@ -650,6 +663,9 @@ fill: style.fill, stroke: style.stroke) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let edge-anchors = range(0, sides).map(i => "edge-" + str(i)) let corner-anchors = range(0, sides).map(i => "corner-" + str(i)) @@ -768,6 +784,9 @@ } } + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let edge-anchors = range(0, sides * 2).map(i => "edge-" + str(i)) let corner-anchors = range(0, sides * 2).map(i => "corner-" + str(i)) @@ -955,6 +974,9 @@ drawables += outer-strips.map(s => drawable.line-strip( s, stroke: style.stroke, close: at-border.all(v => v))) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let center = vector.lerp(from, to, .5) let (transform, anchors) = anchor_.setup( _ => center, @@ -1434,6 +1456,9 @@ fill: style.fill, stroke: style.stroke) } + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + // Calculate border anchors let center = vector.lerp(a, b, .5) let (width, height, ..) = size @@ -1514,6 +1539,9 @@ stroke: style.stroke, ) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = anchor_.setup( anchor => ( ctrl-0: ctrl.at(0), @@ -1613,6 +1641,9 @@ fill-rule: style.fill-rule, stroke: style.stroke) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = { let a = for (i, pt) in pts.enumerate() { (("pt-" + str(i)): pt) @@ -1687,6 +1718,9 @@ fill-rule: style.fill-rule, stroke: style.stroke) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = { let a = for (i, pt) in pts.enumerate() { (("pt-" + str(i)): pt) @@ -1767,6 +1801,9 @@ fill: style.fill, fill-rule: style.fill-rule, stroke: style.stroke, subpaths) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = anchor_.setup( name => { if name == "centroid" { @@ -1868,6 +1905,9 @@ let style = styles.resolve(ctx.style, merge: style) let drawables = drawable.path(fill: style.fill, fill-rule: style.fill-rule, stroke: style.stroke, subpaths) + // Apply modifiers + drawables = apply-modifiers(ctx, style, drawables) + let (transform, anchors) = anchor_.setup( name => { if name == "centroid" { diff --git a/src/path-modifier.typ b/src/path-modifier.typ new file mode 100644 index 00000000..adf296d2 --- /dev/null +++ b/src/path-modifier.typ @@ -0,0 +1,54 @@ +// Shorten or extend a path. +#let _shorten-path(ctx, style, path) = { + import "path-util.typ": shorten-to + + let shorten = style.at("shorten", default: (0, 0)) + if type(shorten) != array { + shorten = (shorten, shorten) + } + + // Early exit on zero lengths + if shorten.all(v => v in (0, 0%, 0pt)) { + return none + } + + // Do not attempt to shorten/extend closed paths + let (origin, closed, segments) = path.first() + if closed or segments == () { + return none + } + + return shorten-to(path, shorten, ignore-subpaths: true) +} + + +#let builtin = ( + shorten: _shorten-path, +) + +/// Apply all enabled modifiers onto a path. +/// +/// - ctx (context): +/// - style (style): +/// - path (path, array): A list of paths or a single path +/// -> path|array +#let apply-modifiers(ctx, style, path) = { + if type(path) == array { + return path.map(p => apply-modifiers(ctx, style, p)) + } + + let all-modifiers = ctx.at("path-modifiers", default: ()) + let enabled-modifiers = style.at("modifiers", default: ()) + + for name in enabled-modifiers { + assert(name in all-modifiers, + message: "No modifier named '" + name + "' registered.") + + let new-path = (all-modifiers.at(name))(ctx, style, path.segments) + if new-path != none { + path.segments = new-path + } + } + + return path +} diff --git a/src/path-util.typ b/src/path-util.typ index 6b50772e..2e5f9814 100644 --- a/src/path-util.typ +++ b/src/path-util.typ @@ -76,7 +76,11 @@ let (origin, _, segments) = path.first() let (kind, ..args) = segments.first() if kind == "l" { - return vector.dir(origin, args.last()) + let v = vector.sub(origin, args.last()) + if vector.len(v) != 0 { + return vector.norm(v) + } + return v } else if kind == "c" { let (c1, c2, e) = args return bezier.cubic-derivative(origin, e, c1, c2, 0) @@ -96,7 +100,11 @@ let (kind, ..args) = segments.last() if kind == "l" { - return vector.dir(origin, args.last()) + let v = vector.sub(origin, args.last()) + if vector.len(v) != 0 { + return vector.norm(v) + } + return v } else if kind == "c" { let (c1, c2, e) = args return bezier.cubic-derivative(e, origin, c2, c1, 0) @@ -193,6 +201,7 @@ /// - path (path): The path /// - distance (ratio, number): Distance along the path /// - reverse (bool): Travel from end to start +/// - clamp (bool): Clamp distance between 0%-100% /// - ignore-subpaths (bool): If false consider the whole path, including sub-paths /// /// -> none,dictionary Dictionary with the following keys: @@ -202,7 +211,7 @@ /// - 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. -#let point-at(path, distance, reverse: false, samples: auto, ignore-subpaths: true) = { +#let point-at(path, distance, reverse: false, clamp: true, samples: auto, ignore-subpaths: true) = { if samples == auto { samples = number-of-samples(samples) } @@ -309,7 +318,7 @@ #let _shorten-line(origin, previous, args, distance, reverse: false) = { let pt = args.last() let length = vector.dist(previous, pt) - if length > 0 { + if length != 0 { let t = if reverse { 1 - distance / length } else { @@ -355,24 +364,87 @@ } } -/// Shorten a path on one or both sides +// Extend a path by `distance` by placing/extending straight +// lines at the start/end +// +// - path (path): Input path +// - distance (float): Distance to extend the path by +// - reverse (bool): If true, extend the beginning of the path instead +// of the end +// - samples (auto, int): Number of samples to use for sampling curves +// -> path +#let _extend-path(path, distance, reverse: false, samples: auto) = { + if reverse { + let (origin, closed, segments) = path.first() + if type(segments.first()) != array { panic(path) } + let (kind, ..args) = segments.first() + if kind == "l" { + let dir = vector.sub(args.last(), origin) + if vector.len(dir) != 0 { dir = vector.norm(dir) } + origin = vector.add(origin, vector.scale(dir, distance)) + return ((origin, false, segments),) + path.slice(1) + } else { + // We extend cubic beziers by just appending straight lines. + let (c1, c2, e) = args + let dir = bezier.cubic-derivative(origin, e, c1, c2, 0) + let old-origin = origin + origin = vector.add(origin, vector.scale(vector.norm(dir), distance)) + return ((origin, false, (("l", old-origin),) + segments),) + path.slice(1) + } + } else { + let (origin, closed, segments) = path.last() + let (kind, ..args) = segments.last() + let prev = if segments.len() > 1 { + segments.at(-2).last() + } else { + origin + } + + let last-segment = if kind == "l" { + let dir = vector.sub(prev, args.last()) + if vector.len(dir) != 0 { dir = vector.norm(dir) } + let end = vector.add(args.last(), vector.scale(dir, distance)) + + // Prepend a straight line + (("l", end),) + } else { + // We extend cubic beziers by just appending straight lines. + let (c1, c2, e) = args + let dir = bezier.cubic-derivative(prev, e, c1, c2, 1) + let end = vector.add(e, vector.scale(vector.norm(dir), -distance)) + + // We re-add the curve + some straight tail + (("c", c1, c2, e), ("l", end),) + } + + return path.slice(0, -1) + ((origin, false, segments.slice(0, -1) + last-segment),) + } +} + +/// Shorten or extend a path on one or both sides /// /// - path (Path): Path /// - distance (number,ratio,array): Distance to shorten the path by -/// - reverse (boolean): If true, start from the end +/// - reverse (bool): If true, start from the end +/// - ignore-subpaths (bool): Only shorten/extend the first sub-path /// - mode ('CURVED','LINEAR'): Shortening mode for cubic segments /// - samples (auto,int): Samples to take for measuring cubic segments /// - snap-to (none,array): Optional array of points to try to move the shortened segment to -#let shorten-to(path, distance, reverse: false, +#let shorten-to(path, distance, reverse: false, ignore-subpaths: true, mode: "CURVED", samples: auto, snap-to: none) = { let snap-to-threshold = 1e-4 + // Shortcut zero length + if distance == 0 or distance == 0% or distance == (0, 0) { + return path + } + // Shorten from both sides if type(distance) == array { let original-length = length(path) let (start, end) = distance.map(v => { if type(v) == ratio { - v * original-length + original-length * v / 100% } else { v } @@ -381,8 +453,23 @@ path = shorten-to(path, start, reverse: reverse, mode: mode, samples: samples, snap-to: if snap-to != none { snap-to.first() } else { none }) path = shorten-to(path, end, reverse: not reverse, mode: mode, samples: samples, snap-to: if snap-to != none { snap-to.last() } else { none }) return path + } else if type(distance) == ratio { + let length = length(path) + distance = length * distance / 100% + } + + // Extend the path + if distance < 0 { + if ignore-subpaths { + return _extend-path(path.slice(0, 1), distance, + reverse: reverse, samples: samples) + path.slice(1) + } else { + return _extend-path(path, distance, + reverse: reverse, samples: samples) + } } + // Shorten the path let point = point-at(path, distance, reverse: reverse) if point != none { // Find the subpath to modify diff --git a/src/styles.typ b/src/styles.typ index 9c29d6b8..eeaacc14 100644 --- a/src/styles.typ +++ b/src/styles.typ @@ -5,10 +5,7 @@ fill-rule: "non-zero", stroke: black + 1pt, radius: 1, - /// Bezier shortening mode: - /// - "LINEAR" Moving the affected point and it's next control point (like TikZ "quick" key) - /// - "CURVED" Preserving the bezier curve by calculating new control points - shorten: "LINEAR", + modifiers: ("shorten",), // Allowed values: // - none @@ -57,42 +54,44 @@ circle: ( radius: auto, stroke: auto, - fill: auto + fill: auto, + modifiers: auto, ), group: ( padding: auto, fill: auto, - stroke: auto + stroke: auto, ), line: ( mark: auto, fill: auto, fill-rule: auto, stroke: auto, + modifiers: auto, ), bezier: ( stroke: auto, fill: auto, fill-rule: auto, mark: auto, - shorten: auto, + modifiers: auto, ), catmull: ( tension: .5, mark: auto, - shorten: auto, stroke: auto, fill: auto, fill-rule: auto, + modifiers: auto, ), hobby: ( /// Curve start and end omega (curlyness) omega: (0,0), mark: auto, - shorten: auto, stroke: auto, fill: auto, fill-rule: auto, + modifiers: auto, ), rect: ( /// Rect corner radius that supports the following types: @@ -108,6 +107,7 @@ radius: 0, stroke: auto, fill: auto, + modifiers: auto, ), arc: ( // Supported values: @@ -119,13 +119,15 @@ mark: auto, stroke: auto, fill: auto, - radius: auto + radius: auto, + modifiers: auto, ), polygon: ( radius: auto, stroke: auto, fill: auto, fill-rule: auto, + modifiers: auto, ), n-star: ( radius: auto, @@ -133,6 +135,7 @@ fill: auto, // Connect inner points of the star show-inner: false, + modifiers: auto, ), content: ( padding: auto, diff --git a/tests/coordinate-lerp/ref/1.png b/tests/coordinate/lerp/ref/1.png similarity index 100% rename from tests/coordinate-lerp/ref/1.png rename to tests/coordinate/lerp/ref/1.png diff --git a/tests/coordinate-lerp/test.typ b/tests/coordinate/lerp/test.typ similarity index 100% rename from tests/coordinate-lerp/test.typ rename to tests/coordinate/lerp/test.typ diff --git a/tests/coordinate/project/ref/1.png b/tests/coordinate/project/ref/1.png new file mode 100644 index 00000000..027a7199 Binary files /dev/null and b/tests/coordinate/project/ref/1.png differ diff --git a/tests/coordinate/project/test.typ b/tests/coordinate/project/test.typ new file mode 100644 index 00000000..858053e6 --- /dev/null +++ b/tests/coordinate/project/test.typ @@ -0,0 +1,21 @@ +#set page(width: auto, height: auto) +#import "/src/lib.typ": * +#import "/tests/helper.typ": * + +#test-case({ + import draw: * + + let orig = (0, 0) + let (o, a, b) = ((0, 0), (45deg, 2), (5deg, 2.25)) + line(o, a, mark: (end: ">"), name: "v1") + line(o, b, mark: (end: ">"), name: "v2") + + line("v1.80%", (project: (), onto: ("v2.start", "v2.end")), + stroke: red) + + line("v1.60%", ((), "_|_", "v2.start", "v2.end"), + stroke: blue) + + line("v1.40%", ((), "⟂", "v2.start", "v2.end"), + stroke: green) +}) diff --git a/tests/line-element-element-intersection/ref/1.png b/tests/line-element-element-intersection/ref/1.png index 2e27fb8c..e562a855 100644 Binary files a/tests/line-element-element-intersection/ref/1.png and b/tests/line-element-element-intersection/ref/1.png differ diff --git a/tests/line-element-element-intersection/test.typ b/tests/line-element-element-intersection/test.typ index c590b0ad..50e68e6d 100644 --- a/tests/line-element-element-intersection/test.typ +++ b/tests/line-element-element-intersection/test.typ @@ -83,19 +83,12 @@ return coord }) - scale(4) - arc((), start: 15deg, stop: 35deg, radius: 5mm, mode: "PIE", fill: color.mix((green, 20%), white), anchor: "origin") let orig = (0, 0) - let (o, a, b) = ((0, 0), (35deg, 1cm), (15deg, 1.25cm)) + let (o, a, b) = ((0, 0), (35deg, 2), (15deg, 2.25)) line(o, a, mark: (end: ">"), name: "v1") line(o, b, mark: (end: ">"), name: "v2") - // Because of a bug with `line`, curstom coordinates do not work properly, - // so we create a named anchor. - anchor("pt", ("v1.end", "perpendicular", "v2.start", "v2.end")) - //line("v1.end", "pt", stroke: red) - // #944: ERROR resolving coordinate: - line("v1.end", ("v1.end", "perpendicular", "v2.start", "v2.end"), + line("v1.80%", ((), "perpendicular", "v2.start", "v2.end"), stroke: red) })