diff --git a/CHANGES.md b/CHANGES.md index 206b537cf..96c43145b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,9 @@ - `rect-around` now uses the shape instead of all anchors to compute an elements bounding box (#1022) - Fixed transformation of content frame paths along with the position in `drawable.apply-transform` - `intersections` is now using a `scope` internally to not leak body transformations +- Added special border & path anchor calculation for rects and ellipses +- Added commented out code that prepares for tikz-like border anchors + (north-east ≠ 45°) - this will be a breaking change # 0.5.0 - **BREAKING** The default matrix changed to id (#967) diff --git a/src/anchor-helper.typ b/src/anchor-helper.typ new file mode 100644 index 000000000..47ab6d6f1 --- /dev/null +++ b/src/anchor-helper.typ @@ -0,0 +1,265 @@ +#import "vector.typ" +#import "util.typ": float-epsilon + +#let _sampled-quarter-samples = 16 +#let _sampled-quarter = range(0, _sampled-quarter-samples + 1).map(t => { + t = t / _sampled-quarter-samples * 90deg + (calc.cos(t), calc.sin(t)) +}) + +// Get the circumference of a sampled quarter. +// +// -> float +#let _sampled-quarter-circumference(x-radius, y-radius) = { + let len = _sampled-quarter-samples + let u = 0 + for i in range(1, len + 1) { + let (p0x, p0y) = _sampled-quarter.at(i - 1) + p0x *= x-radius + p0y *= y-radius + + let (p1x, p1y) = _sampled-quarter.at(i) + p1x *= x-radius + p1y *= y-radius + + u += vector.dist((p0x, p0y), (p1x, p1y)) + } + return u +} + +// Lookup a sampled point on a quarter for distance s. +// +// - s (float): Distance on the quarter +// -> vector +#let _lookup-sampled-quarter-point(s, x-radius, y-radius) = { + let len = _sampled-quarter-samples + let t = 0 + for i in range(1, len + 1) { + let (p0x, p0y) = _sampled-quarter.at(i - 1) + p0x *= x-radius + p0y *= y-radius + + let (p1x, p1y) = _sampled-quarter.at(i) + p1x *= x-radius + p1y *= y-radius + let d = vector.dist((p0x, p0y), (p1x, p1y)) + + // We found our segement, lets interpolate + if t <= s and s <= t + d { + return vector.lerp((p0x, p0y), (p1x, p1y), (s - t) / d) + } + + t += d + } + + return (0, y-radius) +} + +// Lookup a point on the sampled ellipse for distance s +// +// - s (ration, float, length): Distance on the ellipses border, must be normalized +// - x-radius (float): X radius +// - y-radius (float): Y radius +// - unit-length (length): Unit length +// -> vector +// -> none +#let _lookup-ellipse-point(s, x-radius, y-radius, unit-length) = { + let qcirc = _sampled-quarter-circumference(x-radius, y-radius) + let circ = 4 * qcirc + + if type(s) == ratio { + s = s / 100% * circ + } + if type(s) == length { + s = s / unit-length + } + + // Normalize the distance to [0, circ] + s = s - calc.floor(s / circ) * circ + + // Find the quadrant we are in + let quadrant = calc.floor(s / qcirc) + let local = s - quadrant * qcirc + + return if quadrant == 0 { + _lookup-sampled-quarter-point(local, x-radius, y-radius) + } else if quadrant == 1 { + let (x, y) = _lookup-sampled-quarter-point(qcirc - local, x-radius, y-radius) + (-x, y) + } else if quadrant == 2 { + let (x, y) = _lookup-sampled-quarter-point(local, x-radius, y-radius) + (-x, -y) + } else { + let (x, y) = _lookup-sampled-quarter-point(qcirc - local, x-radius, y-radius) + (x, -y) + } +} + +// Compute a point on a circle for distance s +// +// - s (float): Distance on the circle border +// -> vector +#let _circle-point(s, radius, unit-length) = { + let circ = 2 * calc.pi * radius + + if type(s) == ratio { + s = s / 100% * circ + } + if type(s) == length { + s = s / unit-length + } + + // Normalize the distance to [0, u] + s = s - calc.floor(s / circ) * circ + + let theta = s / circ * 360deg + return (calc.cos(theta) * radius, + calc.sin(theta) * radius) +} + +/// Compute the border-anchor of a rect. +/// +/// - center (vector): Rect center point +/// - angle (angle): Angle +/// - width (float): Width of the rect +/// - height (float): Height of the rect +/// -> vector +#let compute-rect-border(center, angle, width: 1, height: 1) = { + let eps = float-epsilon + let (cx, cy, cz) = center + + // Normalize angle + angle = angle - calc.floor(angle / 360deg) * 360deg + + // Special cases for degenerate rects + if width < eps { + return (cx, cy + calc.sin(angle) * height / 2, cz) + } else if height < eps { + return (cx + calc.cos(angle) * width / 2, cy, cz) + } + + // Fast path for square rects + /* if calc.abs(width - height) < eps { */ + let sx = calc.cos(angle) + let sy = calc.sin(angle) + + if 45deg <= angle and angle <= 135deg { sy = 1 } + else if 225deg <= angle and angle <= 315deg { sy = -1 } + + if 315deg <= angle or angle <= 45deg { sx = 1 } + else if 135deg <= angle and angle <= 225deg { sx = -1 } + + return (cx + sx * width / 2, + cy + sy * height / 2, + cz) + /* } */ + +/* This is the correct code for tikz like border anchors on a circle + let radius = width * width + height * height + + let p0 = (cx - width / 2, cy + height / 2, cz) + let p1 = (cx + width / 2, cy + height / 2, cz) + let p2 = (cx + width / 2, cy - height / 2, cz) + let p3 = (cx - width / 2, cy - height / 2, cz) + + let scanline = (cx + calc.cos(angle) * radius, + cy + calc.sin(angle) * radius, + cz) + + let pt + pt = intersection.line-line(p0, p1, center, scanline) + if (pt != none) { return pt } + pt = intersection.line-line(p1, p2, center, scanline) + if (pt != none) { return pt } + pt = intersection.line-line(p2, p3, center, scanline) + if (pt != none) { return pt } + pt = intersection.line-line(p3, p0, center, scanline) + if (pt != none) { return pt } + + panic("Unreachable: rect-border", angle, center, scanline, width, height) +*/ +} + +/// Compute the path-anchor of a rect. +/// +/// - center (vector): Rect center point +/// - anchor (float, ratio): Distance +/// - width (float): Width of the rect +/// - height (float): Height of the rect +/// - unit-length (length): Canvas unit length +/// -> vector +#let compute-rect-path(center, anchor, width: 1, height: 1, unit-length: 1cm) = { + let u = width * 2 + height * 2 + if type(anchor) == ratio { + anchor = anchor / 100% * u + } + if type(anchor) == length { + anchor /= unit-length + } + + // Normalize the distance to [0, u] + anchor = anchor - calc.floor(anchor / u) * u + let (cx, cy, cz) = center + + // We start at east and go counter clockwise + let h2 = height / 2 + let w2 = width / 2 + if anchor <= h2 { return (cx + w2, cy + anchor, cz) } + anchor -= h2 + if anchor <= width { return (cx + w2 - anchor, cy + h2, cz) } + anchor -= width + if anchor <= height { return (cx - w2, cy + h2 - anchor, cz) } + anchor -= height + if anchor <= width { return (cx - w2 + anchor, cy - h2, cz) } + anchor -= width + return (cx + w2, cy - h2 + anchor, cz) +} + +/// Compute the border-anchor of an ellipse. +/// +/// - center (vector): Rect center point +/// - angle (angle): Angle +/// - x-radius (float): X radius +/// - y-radius (float): Y radius +/// -> vector +#let compute-ellipse-border(center, angle, x-radius: 1, y-radius: 1) = { + let eps = float-epsilon + + let (cx, cy, cz) = center + + // TODO: See compute-rect-border + /* if calc.abs(x-radius - y-radius) < eps { */ + return (cx + calc.cos(angle) * x-radius, + cy + calc.sin(angle) * y-radius, + cz) + /* } */ + +/* + let d = calc.sqrt(calc.pow(calc.cos(angle), 2)/(x-radius * x-radius) + calc.pow(calc.sin(angle), 2)/(y-radius * y-radius)) + let bx = calc.cos(angle) / d + let by = calc.sin(angle) / d + + return (cx + bx, cy + by, cz) +*/ +} + +/// Compute the path-anchor of an ellipse +/// +/// - center (vector): Rect center point +/// - anchor (float, ratio, length): Distance +/// - x-radius (float): X radius +/// - y-radius (float): Y radius +/// - unit-length (length): Canvas unit length +/// -> vector +#let compute-ellipse-path(center, anchor, x-radius: 1, y-radius: 1, unit-length: 1cm) = { + let eps = 1e-6 + + let (ox, oy) = if calc.abs(x-radius - y-radius) < eps { + _circle-point(anchor, x-radius, unit-length) + } else { + _lookup-ellipse-point(anchor, x-radius, y-radius, unit-length) + } + + let (cx, cy, cz) = center + return (cx + ox, cy + oy, cz) +} diff --git a/src/anchor.typ b/src/anchor.typ index 86650f17d..01a43a72d 100644 --- a/src/anchor.typ +++ b/src/anchor.typ @@ -7,6 +7,7 @@ #import "path-util.typ" #import "matrix.typ" #import "vector.typ" +#import "anchor-helper.typ": compute-rect-border, compute-rect-path, compute-ellipse-border, compute-ellipse-path // Compass direction to angle #let named-border-anchors = ( @@ -27,37 +28,37 @@ end: 100%, ) -/// Calculates a border anchor at the given angle by testing for an intersection between a line and the given drawables. Returns `none` if no intersection is found for better error reporting. +/// Calculates a border anchor at the given angle by testing for an +/// intersection between a line and the given drawables. The scan-line +/// goes from `center` to the border of an ellipse with radius `x-radius`, `y-radiu` +/// at angle `angle`. If multiple intersection points are found, the +/// one with the largest distance from `center` is returned. +/// +/// Returns `none` if no intersection is found for better error reporting. /// /// - center (vector): The position from which to start the test line. -/// - x-dist (number): The furthest distance the test line should go in the x direction. -/// - y-dist (number): The furthest distance the test line should go in the y direction. -/// - drawables (drawables): Drawables to test for an intersection against. Ideally should be of type path but all others are ignored. +/// - x-radius (number): The furthest distance the test line should go in the x direction. +/// - y-radius (number): The furthest distance the test line should go in the y direction. /// - angle (angle): The angle to check for a border anchor at. +/// - drawables (drawables): Drawables to test for an intersection against. Ideally should be of type path but all others are ignored. /// -> none /// -> vector -#let shape-border(center, x-dist, y-dist, drawables, angle) = { - x-dist += util.float-epsilon - y-dist += util.float-epsilon - +#let _shape-border(center, x-dist, y-dist, angle, drawables) = { + let eps = util.float-epsilon if type(drawables) == dictionary { drawables = (drawables,) } + let (cx, cy, cz) = center let test-line = ( center, - ( - center.at(0) + calc.abs(x-dist) * calc.cos(angle), - center.at(1) + calc.abs(y-dist) * calc.sin(angle), - center.at(2), - ) + (cx + calc.abs(x-dist + eps) * calc.cos(angle), + cy + calc.abs(y-dist + eps) * calc.sin(angle), + cz) ) let pts = () for drawable in drawables { - if drawable.type != "path" { - continue - } pts += intersection.line-path(..test-line, drawable) } @@ -73,7 +74,6 @@ } } - /// Setup an anchor calculation and handling function for an element. Unifies anchor error checking and calculation of the offset transform. /// /// A tuple of a transformation matrix and function will be returned. @@ -113,13 +113,17 @@ callback = (anchor) => {} } + if type(radii) != array { + radii = (radii, radii) + } + // Add enabled anchor names if name != none or offset-anchor != none { - if border-anchors { + if border-anchors and border-anchor-callback == none { assert("center" in anchor-names and radii != none and path != none, message: "Border anchors need a center anchor, radii and the path set!") } - if path-anchors { + if path-anchors and path-anchor-callback == none { assert.ne(path, none, message: "Path anchors need the path set!") } @@ -193,7 +197,7 @@ out = if border-anchor-callback != none { border-anchor-callback(callback("center"), anchor) } else { - shape-border(callback("center"), ..radii, path, anchor) + _shape-border(callback("center"), ..radii, anchor, path) } if out == none { panic(strfmt("Element '{}' does not have a border for anchor '{}'.", name, anchor)) diff --git a/src/coordinate.typ b/src/coordinate.typ index 03914ae0f..7ae75b005 100644 --- a/src/coordinate.typ +++ b/src/coordinate.typ @@ -120,7 +120,7 @@ return c } -#let resolve-tangent(resolve, inverse, ctx, c) = { +#let resolve-tangent(resolve, inverse, ctx, c, eps: 1e-6) = { // (element: , point: , solution: ) // 1) center + query point @@ -137,27 +137,32 @@ // 4) move into unit-circle coords let Dscaled = (D.at(0)/a, D.at(1)/b) let rho = vector.len(Dscaled) - if rho < 1 { + if rho < 1 - eps { panic("No tangent solution for element " + c.element + " and point " + repr(c.point)) } // 5) normalize & compute tangent parameters let ux = Dscaled.at(0) / rho let uy = Dscaled.at(1) / rho - let t = 1 / rho - let h = calc.sqrt(1 - t*t) + + // 5.1) Special case: Point is on the ellipse border + // + // We return a point at rho = 1 + eps + // to get a usable tangent solution. + if rho <= 1 + eps { + rho = 1 + eps + } + + let t = 1 / rho + let h = calc.sqrt((rho - 1) * (rho + 1)) / rho // 6) pick one of the two solutions on the unit circle let (sx, sy) = if c.solution == 1 { - ( - ux*t - uy*h, - uy*t + ux*h - ) + (ux*t - uy*h, + uy*t + ux*h) } else { - ( - ux*t + uy*h, - uy*t - ux*h - ) + (ux*t + uy*h, + uy*t - ux*h) } // 7) map back through the ellipse‐stretch and return diff --git a/src/draw/grouping.typ b/src/draw/grouping.typ index 716079efc..521885b17 100644 --- a/src/draw/grouping.typ +++ b/src/draw/grouping.typ @@ -291,17 +291,14 @@ aabb.padded(bounds, padding) } - // Calculate a bounding box path used for border anchor calculation. + // Calculate a bounding box used for border anchor calculation. let center = none let width = none let height = none - let path = none if bounds != none { (bounds.low.at(1), bounds.high.at(1)) = (bounds.high.at(1), bounds.low.at(1)) center = aabb.mid(bounds) (width, height, ..) = aabb.size(bounds) - - path = drawable.line-strip(aabb.corner-points(bounds), close: true) } // Children can be none if the groups array is empty @@ -313,8 +310,6 @@ } } - let is-degenerate = (width != none and util.float-eq(width, 0)) or (height != none and util.float-eq(height, 0)) - let all-anchors = if bounds != none { (default: center, center: center) } + anchors @@ -337,20 +332,11 @@ name: name, default: if bounds != none or "default" in anchors { "default" }, offset-anchor: anchor, + nested-anchors: true, path-anchors: bounds != none, border-anchors: bounds != none, - radii: (width, height), - path: path, - nested-anchors: true, - border-anchor-callback: if is-degenerate { - (center, angle) => { - let x = if util.float-eq(width, 0) { 0 } else { calc.cos(angle) } - let y = if util.float-eq(height, 0) { 0 } else { calc.sin(angle) } - return vector.add(center, (x * width / 2, y * height / 2)) - } - } else { - none - } + border-anchor-callback: anchor_.compute-rect-border.with(width: width, height: height), + path-anchor-callback: anchor_.compute-rect-path.with(width: width, height: height, unit-length: ctx.length), ) return ( diff --git a/src/draw/shapes.typ b/src/draw/shapes.typ index ad75690c4..93cce3bff 100644 --- a/src/draw/shapes.typ +++ b/src/draw/shapes.typ @@ -42,7 +42,6 @@ /// content(a, [A]); content(b, [B]) /// ``` /// -/// /// - ..points-style (coordinate, style): The position to place the circle on. /// If given two coordinates, the distance between them is used as radius. /// If given a single coordinate, the radius can be set via the `radius` (style) @@ -57,6 +56,7 @@ /// /// === Anchors /// Supports border and path anchors. The `"center"` anchor is the default. +/// Path anchors start at 0deg (east) and go counter-clockwise. /// #let circle(..points-style, name: none, anchor: none) = { let style = points-style.named() @@ -102,8 +102,8 @@ transform: ctx.transform, border-anchors: true, path-anchors: true, - radii: (rx*2, ry*2), - path: drawables, + border-anchor-callback: anchor_.compute-ellipse-border.with(x-radius: rx, y-radius: ry), + path-anchor-callback: anchor_.compute-ellipse-path.with(x-radius: rx, y-radius: ry, unit-length: ctx.length), ) return ( @@ -1020,16 +1020,16 @@ s, stroke: style.stroke, close: at-border.all(v => v))) let center = vector.lerp(from, to, .5) + let width = calc.abs(to-x - from-x) + let height = calc.abs(to-y - from-y) + let (transform, anchors) = anchor_.setup( _ => center, ("center",), name: name, transform: ctx.transform, border-anchors: true, - radii: (vector.dist(center, from) * 2,) * 2, - path: drawable.line-strip( - (from, (from.first(), to.at(1), 0), to, (to.first(), from.at(1), 0)), - close: true) + border-anchor-callback: anchor_.compute-rect-border.with(width: width, height: height) ) return ( @@ -1381,6 +1381,7 @@ /// /// == Anchors /// Supports border and path anchors. It's default is the `"center"` anchor. +/// Path anchors start at 0deg (east) and go counter-clockwise. /// #let rect(a, b, name: none, anchor: none, ..style) = { // No extra positional arguments from the style sink @@ -1419,7 +1420,7 @@ let (north-west: nw, north-east: ne, south-west: sw, south-east: se) = util.as-corner-radius-dict(ctx, style.radius, size) - let no-radius = style.radius == none or style.radius == 0 + let no-radius = style.radius == none or style.radius == 0 or (nw == (0,0) and ne == (0,0) and sw == (0,0) and se == (0,0)) let drawables = if no-radius { let segments = () // Four corner points for a non-rounded rectangle: @@ -1536,6 +1537,8 @@ path-anchors: true, radii: (width * 2, height * 2), path: drawables, + border-anchor-callback: if no-radius { anchor_.compute-rect-border.with(width: width, height: height) }, + path-anchor-callback: if no-radius { anchor_.compute-rect-path.with(width: width, height: height, unit-length: ctx.length) }, ) return ( diff --git a/tests/bounds/test.typ b/tests/bounds/test.typ index 30d13cfff..f1c13a66c 100644 --- a/tests/bounds/test.typ +++ b/tests/bounds/test.typ @@ -1,7 +1,8 @@ #set page(width: auto, height: auto) #import "/src/lib.typ": * +#import "/tests/helper.typ": * -#box(stroke: 2pt + red, canvas({ +#test-case({ import draw: * group(name: "g", { @@ -12,9 +13,9 @@ on-layer(-1, { rect("g.south-west", "g.north-east", stroke: .5pt + green) }) -})) +}) -#box(stroke: 2pt + red, canvas({ +#test-case({ import draw: * group(name: "g", { @@ -23,9 +24,9 @@ on-layer(-1, { rect("g.south-west", "g.north-east", stroke: .5pt + green) }) -})) +}) -#box(stroke: 2pt + red, canvas({ +#test-case({ import draw: * let pts = ((-1.414213562373095, 0), @@ -39,4 +40,4 @@ on-layer(-1, { rect("g.south-west", "g.north-east", stroke: .5pt + green) }) -})) +}) diff --git a/tests/coordinate/tangent/ref/1.png b/tests/coordinate/tangent/ref/1.png new file mode 100644 index 000000000..504852838 Binary files /dev/null and b/tests/coordinate/tangent/ref/1.png differ diff --git a/tests/coordinate/tangent/test.typ b/tests/coordinate/tangent/test.typ new file mode 100644 index 000000000..0c8238937 --- /dev/null +++ b/tests/coordinate/tangent/test.typ @@ -0,0 +1,42 @@ +#set page(width: auto, height: auto) +#import "/src/lib.typ": * +#import "/tests/helper.typ": * + +#test-case(solution => { + import draw: * + + let p = (2, 1) + circle((0, 0), name: "c") + cross(p) + line(p, (element: "c", point: p, solution: solution)) +}, args: (0, 1)) + +#test-case(solution => { + import draw: * + + let p = (1, 2) + circle((0, 0), name: "c", radius: (2, 1)) + cross(p) + line(p, (element: "c", point: p, solution: solution)) +}, args: (0, 1)) + +// Special case: Point on the ellipse border +#test-case(p => { + import draw: * + + circle((0, 0), name: "c") + cross(p) + + line((p, -1, (element: "c", point: p, solution: 1)), + (p, +1, (element: "c", point: p, solution: 1))) +}, args: ("c.0deg", "c.30deg", "c.185deg")) + +#test-case(p => { + import draw: * + + circle((0, 0), name: "c", radius: (2, 1)) + cross(p) + + line((p, -1, (element: "c", point: p, solution: 1)), + (p, +1, (element: "c", point: p, solution: 1))) +}, args: ("c.0deg", "c.30deg", "c.185deg")) diff --git a/tests/element/anchors/ref/1.png b/tests/element/anchors/ref/1.png index 6fe4c61b2..399c65ba7 100644 Binary files a/tests/element/anchors/ref/1.png and b/tests/element/anchors/ref/1.png differ diff --git a/tests/floating/ref/1.png b/tests/floating/ref/1.png index b800833d4..4643430ad 100644 Binary files a/tests/floating/ref/1.png and b/tests/floating/ref/1.png differ diff --git a/tests/group/anchors/ref/1.png b/tests/group/anchors/ref/1.png index cd0d3b1cc..037b857e6 100644 Binary files a/tests/group/anchors/ref/1.png and b/tests/group/anchors/ref/1.png differ diff --git a/tests/group/anchors/test.typ b/tests/group/anchors/test.typ index aecb27b4e..5aa75a7d2 100644 --- a/tests/group/anchors/test.typ +++ b/tests/group/anchors/test.typ @@ -2,6 +2,12 @@ #import "/src/lib.typ": * #import "/tests/helper.typ": * +#test-case({ + import cetz.draw: * + + rect((-1,-1),(1,1), name:"r") + cross("r.0deg") +}) #test-case({ import cetz.draw: * diff --git a/tests/group/transform/ref/1.png b/tests/group/transform/ref/1.png index d3bac5674..6127a9c6e 100644 Binary files a/tests/group/transform/ref/1.png and b/tests/group/transform/ref/1.png differ diff --git a/tests/path/anchors/ref/1.png b/tests/path/anchors/ref/1.png index faa462195..93299bfaa 100644 Binary files a/tests/path/anchors/ref/1.png and b/tests/path/anchors/ref/1.png differ diff --git a/tests/rect/rounded/ref/1.png b/tests/rect/rounded/ref/1.png index ca338b3c7..8f364fe58 100644 Binary files a/tests/rect/rounded/ref/1.png and b/tests/rect/rounded/ref/1.png differ