diff --git a/CHANGES.md b/CHANGES.md index ca1cf1fb..f8fd6742 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,6 +41,8 @@ package called `cetz-plot`. depth ordering and face culling of drawables. Ordering is enabled by default. - Closed `line` and `merge-path` elements now have a `"centroid"` anchor that is the calculated centroid of the (non self-intersecting!) shape. +- Added `find-closest-point` for creating an anchor at the closest point between a + reference point and one or more elements. ## Marks - Added support for mark `anchor` style key, to adjust mark placement and diff --git a/src/bezier.typ b/src/bezier.typ index 36231a50..85237077 100644 --- a/src/bezier.typ +++ b/src/bezier.typ @@ -585,3 +585,36 @@ } return pts } + +/// Find the closest point on a bezier to a given point. +/// +/// - pt (vector): Reference point to find the closest point to +/// - s (vector): Bezier start +/// - e (vector): Bezier end +/// - c1 (vector): Bezier control point 1 +/// - c2 (vector): Bezier control point 2 +/// - max-recursion (int): Max recursion depth +#let cubic-closest-point(pt, s, e, c1, c2, max-recursion: 1) = { + let probe(low, high, depth) = { + let min = calc.inf + let min-t = 0 + + for t in range(0, 11) { + t = low + t / 10 * (high - low) + let d = vector.dist(pt, cubic-point(s, e, c1, c2, t)) + if d < min { + min = d + min-t = t + } + } + + if depth < max-recursion { + let step = (high - low) / 10 + return probe(calc.max(0, min-t - step), calc.min(min-t + step, 1), depth + 1) + } + + return cubic-point(s, e, c1, c2, min-t) + } + + return probe(0, 1, 0) +} diff --git a/src/draw.typ b/src/draw.typ index e46b87de..0fe7a97e 100644 --- a/src/draw.typ +++ b/src/draw.typ @@ -1,4 +1,4 @@ -#import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, hide, floating +#import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, hide, floating, find-closest-point #import "draw/transformations.typ": set-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 diff --git a/src/draw/grouping.typ b/src/draw/grouping.typ index d3dc791f..becb5892 100644 --- a/src/draw/grouping.typ +++ b/src/draw/grouping.typ @@ -190,6 +190,98 @@ },) } +/// Finds the closest point on one or more elements to a coordinate and +/// creates an anchor. Transformations insides the body are scoped and do +/// not get applied outsides. +/// +/// - name (str): Anchor name. +/// - reference-point (coordinate): Coordinate to find the closest point to. +/// - body (element,str): One or more elements to consider. A least one is required. A function that accepts `ctx` and returns elements is also accepted. If a string is passed, the existing named element is used. +#let find-closest-point(name, reference-point, body) = { + import "/src/bezier.typ": cubic-closest-point + + assert(type(name) == str, + message: "Anchor name must be of type string, got " + repr(name)) + assert(type(body) in (array, function, str), + message: "Expected body to be a list of elements, a callback or an elements name") + coordinate.resolve-system(reference-point) + + return (ctx => { + let (_, pt) = coordinate.resolve(ctx, reference-point) + pt = util.apply-transform(ctx.transform, pt) + + let (sub-ctx, drawables, output-drawables) = if type(body) == str { + let node = ctx.nodes.at(body) + (ctx, node.drawables, false) + } else { + let group-ctx = ctx + group-ctx.groups.push(()) + let node = process.many(group-ctx, util.resolve-body(ctx, body)) + (node.ctx, node.drawables, true) + } + + ctx.nodes += sub-ctx.nodes + + let min = calc.inf + let min-pt = none + + // Compute the closest point on line a-b to point pt + let line-closest-pt(pt, a, b) = { + let n = vector.sub(b, a) + let d = vector.dot(n, pt) + d -= vector.dot(a, n) + + let f = d / vector.dot(n, n) + return if f < 0 { + a + } else if f > 1 { + b + } else { + vector.add(a, vector.scale(n, f)) + } + } + + for d in drawables { + if not "segments" in d { continue } + + for ((kind, ..pts)) in d.segments { + if kind == "cubic" { + let tmp-pt = cubic-closest-point(pt, ..pts) + let tmp-min = vector.dist(tmp-pt, pt) + if tmp-min < min { + min-pt = tmp-pt + min = tmp-min + } + } else { + for i in range(1, pts.len()) { + let tmp-pt = line-closest-pt(pt, pts.at(i - 1), pts.at(i)) + let tmp-min = vector.dist(tmp-pt, pt) + if tmp-min < min { + min-pt = tmp-pt + min = tmp-min + } + } + } + } + } + + let (transform, anchors) = anchor_.setup( + anchor => min-pt, + ("default",), + default: "default", + name: name, + transform: none + ) + + return ( + ctx: ctx, + name: name, + anchors: anchors, + drawables: if output-drawables { drawables } else { () }, + ) + },) +} + /// Groups one or more elements together. This element acts as a scope, all state changes such as transformations and styling only affect the elements in the group. Elements after the group are not affected by the changes inside the group. /// /// ```typc example diff --git a/tests/closest-point/ref/1.png b/tests/closest-point/ref/1.png new file mode 100644 index 00000000..0cd7d7c6 Binary files /dev/null and b/tests/closest-point/ref/1.png differ diff --git a/tests/closest-point/test.typ b/tests/closest-point/test.typ new file mode 100644 index 00000000..5fc1ea02 --- /dev/null +++ b/tests/closest-point/test.typ @@ -0,0 +1,36 @@ +#set page(width: auto, height: auto) +#import "/tests/helper.typ": * + +#test-case({ + import cetz.draw: * + + group(name: "g", { + rotate(10deg) + rect((-1, -1), (1, 1), radius: .45) + }) + + for i in range(0, 360, step: 10) { + let pt = (i * 1deg, 2) + + find-closest-point("test", pt, { + rotate(10deg) + hide(rect((-1, -1), (1, 1), radius: .45)) + }) + + line(pt, "test") + circle(pt, radius: .1, fill: blue) + } +}) + +#test-case({ + import cetz.draw: * + + group(name: "g", { + rotate(10deg) + rect((-1, -1), (1, 1), radius: .45) + }) + + let pt = (2, 2) + find-closest-point("test", pt, "g") + line("test", pt) +})