feat: add anchor-based zigzag bend placement via zigzag-at

Add the `zigzag-at` parameter to place the middle bend of a zigzag wire
on the axis of a referenced anchor. It overrides `zigzag-ratio`, which
now defaults to `auto` (resolving to 50%) so the two are mutually
exclusive. Accepted forms are an anchor name, any CeTZ coordinate, or
the scalar offset `(rel: <length>, to: <coord>)`.

Add the generic helpers this relies on and that other wire styles reuse:
- `normalize-scalar-offset(at, axis)` turns a scalar offset into an x or
  y offset; `normalize-zigzag-at` is a wrapper around it.
- `detect-port-side(anchor, ctx)` deduces the cardinal side a port faces
  by comparing it with its element's `.center` anchor.

Add the `zigzag-at` example.
This commit is contained in:
David Stadelmann committed 2026-10-22 00:31:52 +02:00
1 parent df4c956a0b
commit 2c6e5f44df
3 files changed
+6709 -6355

No files matched your search

+24
View File
@@ -71,6 +71,30 @@ wire.wire("w4", ("p6","p7"), style: "guided",
```, vertical: true)
#let zigzag-at = example(
```
// `zigzag-at` places the middle bend of a zigzag wire
// on the axis of a referenced anchor, overriding
// `zigzag-ratio`.
element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "src",
ports: (east: ((id: "o", name: "O"),),))
element.block(x: 3, y: -1.2, w: 1, h: 1, id: "ref",
fill: util.colors.pink,
ports: (west: ((id: "p", name: "R"),),))
element.block(x: 5.5, y: -2, w: 1.5, h: 1.5, id: "dst",
ports: (west: ((id: "i", name: "I"),),))
// String form: anchor name of a port.
wire.wire("w1", ("src-port-o", "dst-port-i"),
style: "zigzag",
zigzag-at: "ref-port-p")
// Ergonomic offset: shift 8pt right of ref.east.
wire.wire("w2", ("src-port-o", "dst-port-i"),
style: "zigzag",
zigzag-at: (rel: 8pt, to: "ref.east"))
```,
vertical: true,
)
#let stub = example(```
draw.circle((0, 0), radius: .1, name: "p")
wire.stub("p", "north", name: "north", length: 1)
+6549 -6333
View File
File diff suppressed because it is too large. Load diff
+136 -22
View File
@@ -24,29 +24,123 @@
return (pts, anchors)
}
#let get-zigzag-wire(pts, ratio, dir) = {
// Normalize an anchor-based placement value used by `zigzag-at`, `u-at`, and
// the `s-at-*` family.
//
// `axis` selects which cartesian component the offset applies to:
// - `"x"` → offset in x (used when the placement fixes an x coordinate, e.g.
// a vertical bend line for horizontal wire flow).
// - `"y"` → offset in y (used when the placement fixes a y coordinate).
//
// Accepted forms:
// - `none` → unset; caller falls back to
// depth/ratio-based placement.
// - `str` / tuple / native CeTZ dict → passed through as a CeTZ coordinate.
// - `(rel: <length>, to: <coord>)` with a *scalar* `rel`
// → ergonomic axial offset, rewritten
// to `(rel: (rel, 0), to: to)` for
// `axis == "x"` or
// `(rel: (0, rel), to: to)` for
// `axis == "y"`. If `rel` is already
// an array/tuple, the value is
// passed through unchanged.
#let normalize-scalar-offset(at, axis) = {
if at == none { return none }
if type(at) == dictionary and "rel" in at and "to" in at and type(at.rel) != array {
let r = at.rel
if axis == "x" {
return (rel: (r, 0), to: at.to)
} else {
return (rel: (0, r), to: at.to)
}
}
return at
}
// Backwards-compatible wrapper: translates the old `dir` vocabulary
// (`"vertical"` / `"horizontal"`) to the `axis` vocabulary used by
// `normalize-scalar-offset`.
#let normalize-zigzag-at(at, dir) = {
let axis = if dir == "vertical" { "x" } else { "y" }
return normalize-scalar-offset(at, axis)
}
// Try to deduce the cardinal side a port anchor faces by comparing its
// position to the center of the element it belongs to. Returns none if the
// anchor is not a port-style anchor (i.e. does not contain "-port-") or if
// resolution fails.
#let detect-port-side(anchor, ctx) = {
if type(anchor) != str { return none }
let sep = "-port-"
if not anchor.contains(sep) { return none }
let elmt-id = anchor.split(sep).first()
let port-pos = none
let center-pos = none
// coordinate.resolve panics on unknown anchors; there is no try/catch in
// Typst, so we assume port anchors created by add-port always come with a
// matching group of the same name. If not, the user must set the relevant side parameter
// explicitly.
let (_, p1) = coordinate.resolve(ctx, anchor)
let (_, p2) = coordinate.resolve(ctx, elmt-id + ".center")
port-pos = p1
center-pos = p2
let dx = port-pos.at(0) - center-pos.at(0)
let dy = port-pos.at(1) - center-pos.at(1)
if calc.abs(dx) >= calc.abs(dy) {
return if dx >= 0 { "east" } else { "west" }
} else {
return if dy >= 0 { "north" } else { "south" }
}
}
#let get-zigzag-wire(pts, ratio, dir, at: none) = {
let start = pts.first()
let end = pts.last()
let mid = if dir == "vertical" {
(start, ratio, (horizontal: end, vertical: ()))
} else {
(start, ratio, (horizontal: (), vertical: end))
}
let points = if dir == "vertical" {
(
start,
(horizontal: mid, vertical: ()),
(horizontal: (), vertical: end),
end
)
let points
if at == none {
// Ratio-based placement (default).
let mid = if dir == "vertical" {
(start, ratio, (horizontal: end, vertical: ()))
} else {
(start, ratio, (horizontal: (), vertical: end))
}
points = if dir == "vertical" {
(
start,
(horizontal: mid, vertical: ()),
(horizontal: (), vertical: end),
end
)
} else {
(
start,
(horizontal: (), vertical: mid),
(horizontal: end, vertical: ()),
end
)
}
} else {
(
start,
(horizontal: (), vertical: mid),
(horizontal: end, vertical: ()),
end
)
// Anchor-based placement: the middle bend segment sits on the axis
// extracted from `at` via CeTZ's perpendicular operator `|-`:
// - vertical: middle segment at x = at.x
// - horizontal: middle segment at y = at.y
points = if dir == "vertical" {
(
start,
(at, "|-", start),
(at, "|-", end),
end
)
} else {
(
start,
(start, "|-", at),
(end, "|-", at),
end
)
}
}
let anchors = (
"start": start,
@@ -204,8 +298,9 @@
/// - reverse (bool): If true, the start and end points will be swapped (useful in cases where the start point depends on the end point, for example with perpendiculars)
/// - directed (bool): If true, the wire will be directed, meaning an arrow will be drawn at the endpoint
/// - rotate-name (bool): If true, the name will be rotated according to the wire's slope
/// - zigzag-ratio (ratio): Position of the zigzag vertical relative to the horizontal span (only with style "zigzag")
/// - zigzag-ratio (auto, ratio): Position of the zigzag middle bend segment relative to the horizontal (or vertical) span, as a fraction between the two endpoints (only with style "zigzag"). Defaults to `auto`, which resolves to `50%`. Panics if combined with an explicitly set `zigzag-at`.
/// - zigzag-dir (str): The zigzag's direction. As either "vertical" or "horizontal" (only with dstyle "zigzag")
/// - zigzag-at (none, str, array, dictionary): Optional. Anchor-based placement of the zigzag middle bend segment (only with style "zigzag"). When set, overrides `zigzag-ratio` (which must be left at its default `auto`). The middle segment sits on the axis extracted from the given coordinate: `x` for `zigzag-dir: "vertical"`, `y` for `"horizontal"`. Accepted forms: a raw CeTZ anchor name string (e.g. `"elmt.center"` or `"elmt-port-a"`); any native CeTZ coordinate (tuple, dict such as `(rel: (dx, dy), to: anchor)`); or the ergonomic scalar-offset form `(rel: <length>, to: <coord>)`, which is rewritten to a `(dx, 0)` offset for `"vertical"` and `(0, dy)` for `"horizontal"`. See #examples.zigzag-at
/// - dodge-y (number): Y position to dodge the wire to (only with style "dodge")
/// - dodge-sides (array): The start and end sides (going out of the connected element) of the wire (only with style "dodge")
/// - dodge-margins (array): The start and end margins (i.e. space before dodging) of the wire (only with style "dodge")
@@ -227,8 +322,9 @@
guided-sides: ("east", "west"),
directed: false,
rotate-name: true,
zigzag-ratio: 50%,
zigzag-ratio: auto,
zigzag-dir: "vertical",
zigzag-at: none,
dodge-y: 0,
dodge-sides: ("east", "west"),
dodge-margins: (5%, 5%)
@@ -241,6 +337,19 @@
panic("Wrong number of points (got " + str(pts.len()) + " instead of 2)")
}
// `zigzag-at` validation.
if zigzag-at != none {
if style != "zigzag" {
panic("wire '" + id + "': `zigzag-at` requires style: \"zigzag\" (got \""
+ style + "\")")
}
if zigzag-ratio != auto {
panic("wire '" + id
+ "': cannot combine `zigzag-at` with an explicit `zigzag-ratio`")
}
}
let zigzag-ratio-effective = if zigzag-ratio == auto { 50% } else { zigzag-ratio }
// Normalize user-provided coordinates: (x, y) -> (x, y, 0)
let pts = pts.map(normalize-coord)
@@ -259,7 +368,12 @@
(points, anchors) = get-direct-wire(pts)
} else if style == "zigzag" {
(points, anchors) = get-zigzag-wire(pts, zigzag-ratio, zigzag-dir)
(points, anchors) = get-zigzag-wire(
pts,
zigzag-ratio-effective,
zigzag-dir,
at: normalize-zigzag-at(zigzag-at, zigzag-dir),
)
} else if style == "dodge" {
(points, anchors) = get-dodge-wire(