Skip to content

Commit

Permalink
Distance and Angle Path Anchors (#381)
Browse files Browse the repository at this point in the history
This PR adds path anchors for fixed and relative distances to most
elements.
Closed elements (circle, rect, arc) can also handle angle anchors.

This means:
`(name: "element", anchor: <number, ratio, angle, string>)` 
can be used to get dynamic anchors on element paths.

- Arc PIE mode line strip has been fixed and is now the last element,
after the arc so that all arc paths start at `arc-start`.
- All paths got `start`, `mid` and `end` anchors that point to 0%, 50%
and 100% of the path.
  • Loading branch information
johannes-wolf authored Dec 21, 2023
1 parent b31d6af commit 6aeb792
Show file tree
Hide file tree
Showing 17 changed files with 563 additions and 319 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ CeTZ 0.2.0 requires Typst 0.10.0
- Added Hobby curves (`hobby`) in addition to catmull (thanks to @Enivex)
- Added `radius` style to `rect` for drawing rounded rects
- Added `hide` function for hiding elements
- Added distance, ratio and angle anchors to elements

### Plot
- Added `plot.add-contour(..)` for plotting contour plots
Expand Down
22 changes: 19 additions & 3 deletions manual.typ
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Many CeTZ functions expect data in certain formats which we will call types. Not
/ `<style>`: Named arguments (or a dictionary if used for a single argument) of style key-values

== Anchors <anchors>
Anchors are named positions relative to named elements. To use an anchor of an element, you must give the element a name using the `name` argument. All elements with the `name` argument allow anchors.
Anchors positions relative to named elements. To use an anchor of an element, you must give the element a name using the `name` argument. All elements with the `name` argument allow anchors.
```example
// Name the circle
circle((0,0), name: "circle")
Expand Down Expand Up @@ -319,16 +319,32 @@ for (c, s, f, cont) in (
Defines a point relative to a named element using anchors, see @anchors.

#def-arg("name", `<string>`, [The name of the element that you wish to use to specify a coordinate.])
#def-arg("anchor", `<string>`, [An anchor of the element. If one is not given a default anchor will be used. On most elements this is `center` but it can be different.])
#def-arg("anchor", `<number, angle, string>`, [An anchor of the element. If one is not given a default anchor will be used. On most elements this is `center` but it can be different.])

You can also use implicit syntax of a dot separated string in the form `"name.anchor"`.
You can also use implicit syntax of a dot separated string in the form `"name.anchor"`
for named anchors.

```example
line((0,0), (3,2), name: "line")
circle("line.end", name: "circle")
rect("line.start", "circle.east")
```

Using the dictionary anchor syntax, you can not only use named anchors, but also
query the element for distance or angle anchors on it's path:

```example
circle((0,0), name: "circle")
// Anchor at 30 degree
content((name: "circle", anchor: 30deg), box(fill: white, $ 30 degree $))
// Anchor at 30% of the path length
content((name: "circle", anchor: 30%), box(fill: white, $ 30 % $))
// Anchor at 3.14 of the path
content((name: "circle", anchor: 3.14), box(fill: white, $ p = 3.14 $))
```

Note, that not all elements provide angle or distance based anchors!

== Tangent
This system allows you to compute the point that lies tangent to a shape. In detail, consider an element and a point. Now draw a straight line from the point so that it "touches" the element (more formally, so that it is _tangent_ to this element). The point where the line touches the shape is the point referred to by this coordinate system.

Expand Down
219 changes: 175 additions & 44 deletions src/anchor.typ
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,125 @@
south: 270deg,
south-east: 315deg,
)
#let compass-directions = compass-angle.keys()
#let compass-directions-with-center = compass-directions + ("center",)

// Path distance anchors
#let path-distances = (
start: 0%,
mid: 50%,
end: 100%,
)
#let path-distance-names = path-distances.keys()

// All default anchor names of closed shapes
#let closed-shape-names = compass-directions-with-center + path-distance-names


/// Calculates a border anchor at the given angle by testing for an intersection between a line and the given drawables.
///
/// This function is not ready to be used widely in its current state. It is only to be used to calculate the cardinal anchors of the arc element until properly updated. It will panic if no intersections have been found.
///
/// - 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.
/// - angle (angle): The angle to check for a border anchor at.
/// -> vector
#let border(center, x-dist, y-dist, drawables, angle) = {
x-dist += util.float-epsilon
y-dist += util.float-epsilon

if type(drawables) == dictionary {
drawables = (drawables,)
}

let test-line = (
center,
(
center.at(0) + x-dist * calc.cos(angle),
center.at(1) + y-dist * calc.sin(angle),
center.at(2),
)
)

let pts = ()
for drawable in drawables {
if drawable.type != "path" {
continue
}
pts += intersection.line-path(..test-line, drawable)
}

if pts.len() == 1 {
return pts.first()
}

// Find the furthest intersection point from center
return util.sort-points-by-distance(center, pts).last()
}

/// Handle path distance anchor
#let resolve-distance(anchor, drawable) = {
if type(anchor) in (int, float, ratio) {
return path-util.point-on-path(drawable.segments, anchor)
}
}

/// Handle border angle anchor
#let resolve-border-angle(anchor, center, rx, ry, drawable) = {
return border(center, rx, ry, drawable, anchor)
}

/// Handle named compass direction
#let resolve-compass-dir(anchor, center, rx, ry, drawable, with-center: true) = {
if type(anchor) == str {
return if anchor in compass-directions {
border(center, rx, ry, drawable, compass-angle.at(anchor))
} else if with-center and anchor == "center" {
center
}
}
}

// Handle anchor for a line shape
//
// Path anchors are:
// - Distance anchors
// - Ratio anchors
#let calculate-path-anchor(anchor, drawable) = {
if type(drawable) == array {
assert(drawable.len() == 1,
message: "Expected a single path, got " + repr(drawable))
drawable = drawable.first()
}

if type(anchor) == str and anchor in path-distance-names {
anchor = path-distances.at(anchor)
}

return resolve-distance(anchor, drawable)
}

// Handle anchor for a closed shape
//
// Border anchors are:
// - Compass direction anchors
// - Angle anchors
#let calculate-border-anchor(anchor, center, rx, ry, drawable) = {
if type(drawable) == array {
assert(drawable.len() == 1,
message: "Expected a single path, got " + repr(drawable))
drawable = drawable.first()
}

if type(anchor) == str {
return resolve-compass-dir(anchor, center, rx, ry, drawable)
} else if type(anchor) == angle {
return resolve-border-angle(anchor, center, rx, ry, drawable)
}
}


/// Setup an anchor calculation and handling function for an element. Unifies anchor error checking and calculation of the offset transform.
///
Expand All @@ -32,8 +151,57 @@
/// - transform (matrix): The current transformation matrix to apply to an anchor's position before returning it. If `offset-anchor` and `default` is set, it will be first translated by the distance between them.
/// - name (str): The name of the element, this is only used in the error message in the event an anchor is invalid.
/// - offset-anchor: The name of an anchor to offset the transform by.
/// - border-anchors (bool): If true, add border anchors (compass and angle anchors)
/// - path-anchors (bool): If true, add path anchors (distance anchors)
/// - center (none,vector): Center of the path `path`, used for border anchor calculation
/// - radii (none,tuple): Radius tuple used for border anchor calculation
/// - path (none,drawable): Path used for path and border anchor calculation
/// -> (matrix, function)
#let setup(callback, anchor-names, default: none, transform: none, name: none, offset-anchor: none) = {
#let setup(callback,
anchor-names,
default: none,
transform: none,
name: none,
offset-anchor: none,
border-anchors: false,
path-anchors: false,
center: none,
radii: none,
path: none) = {
// Passing no callback is valid!
if callback == auto {
callback = (anchor) => {}
}

// Add enabled anchor names
if border-anchors {
assert(center != none and radii != none and path != none,
message: "Border anchors need center point, radii and the path set!")
anchor-names += compass-directions-with-center
}
if path-anchors {
assert(path != none,
message: "Path anchors need the path set!")
anchor-names += path-distance-names
}

// Populate callback with auto added
// anchor functions
if border-anchors or path-anchors {
callback = (anchor) => {
let pt = callback(anchor)
if pt == none and border-anchors {
pt = calculate-border-anchor(
anchor, center, ..radii, path)
}
if pt == none and path-anchors {
pt = calculate-path-anchor(
anchor, path)
}
return pt
}
}

if default != none and offset-anchor != none {
assert(
offset-anchor in anchor-names,
Expand All @@ -52,6 +220,7 @@
}
}

// Anchor callback
let calculate-anchor(anchor) = {
if anchor == () {
return anchor-names
Expand All @@ -60,13 +229,14 @@
assert.ne(default, none, message: strfmt("Element '{}' does not have a default anchor!", name))
anchor = default
}

let out = callback(anchor)
assert(
anchor in anchor-names,
message: strfmt("Anchor '{}' not in anchors {} for element '{}'", anchor, repr(anchor-names), name)
// message: strfmt("Anchor '{}' not in anchors {}", anchor, repr(anchor-names)) + if name != none { strfmt(" for element '{}'", name) }
out != none,
message: strfmt("Anchor '{}' not in anchors {} for element '{}'",
anchor, repr(anchor-names), name)
)

let out = callback(anchor)
return if transform != none {
util.apply-transform(
transform,
Expand All @@ -80,42 +250,3 @@
}


/// Calculates a border anchor at the given angle by testing for an intersection between a line and the given drawables.
///
/// This function is not ready to be used widely in its current state. It is only to be used to calculate the cardinal anchors of the arc element until properly updated. It will panic if no intersections have been found.
///
/// - 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.
/// - angle (angle): The angle to check for a border anchor at.
/// -> vector
#let border(center, x-dist, y-dist, drawables, angle) = {
if type(drawables) == dictionary {
drawables = (drawables,)
}

let test-line = (
center,
(
center.at(0) + x-dist * calc.cos(angle),
center.at(1) + y-dist * calc.sin(angle),
center.at(2),
)
)

let pts = ()
for drawable in drawables {
if drawable.type != "path" {
continue
}
pts += intersection.line-path(..test-line, drawable)
}

if pts.len() == 1 {
return pts.first()
}

// Find the furthest intersection point from center
return util.sort-points-by-distance(center, pts).last()
}
27 changes: 18 additions & 9 deletions src/coordinate.typ
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,9 @@


#let resolve-anchor(ctx, c) = {
// (name: <string>, anchor: <string> or <none>)
// (name: <string>, anchor: <number, angle, string> or <none>)
// "name.anchor"
// "name"

let (name, anchor) = if type(c) == str {
let parts = c.split(".")
if parts.len() == 1 {
Expand All @@ -70,15 +69,25 @@
}

// Check if node is known
assert(
name in ctx.nodes,
message: strfmt("Unknown element '{}' in elements {}", name, repr(ctx.nodes.keys()))
)
assert(name in ctx.nodes,
message: "Unknown element '" + name + "' in elements " + repr(ctx.nodes.keys()))

// Resolve length anchors
if type(anchor) == length {
anchor = util.resolve-number(ctx, anchor)
}

return util.revert-transform(
// Check if anchor is known
let node = ctx.nodes.at(name)
let pos = (node.anchors)(anchor)
assert(pos != none,
message: "Unknown anchor '" + repr(anchor) + "' for element '" + name + "'")

let pos = util.revert-transform(
ctx.transform,
(ctx.nodes.at(name).anchors)(anchor)
)
pos)

return pos
}

#let resolve-barycentric(ctx, c) = {
Expand Down
Loading

0 comments on commit 6aeb792

Please sign in to comment.