Compare commits

..
Author SHA1 Message Date
David Stadelmann 7f69c9cf49 feat: add wire.breakout element for bus fan-out diagrams
Introduces wire.breakout: a 45° diagonal line of configurable length
(default 1em) that starts at a given (x, y) and exposes a port anchor
<id>-port-<id> at its end point so wires can attach to it as if it were
a block port. A synthesized <id>.center anchor is placed opposite the
configured port-side so the side the port faces can be deduced from
the anchor.
- x/y accept number, (rel:, to:) and (from:) forms; (from:) sets the
  start point to the referenced anchor's coordinate.
- direction: one of north-east/north-west/south-east/south-west.
- Optional label positioned via name-pos (NE/NW/SE/SW); when name-pos is
  opposite to direction, the label falls back to the start point so it
  never covers the diagonal line. Label padding matches wire.wire (3pt).
Also adds three examples (breakout, breakout-label, breakout-wire) in
doc/examples.typ referenced from the docstring so tidy auto-renders
them in the manual, and a gallery entry (gallery/test_breakout.typ)
demonstrating orthogonal bus fan-out with breakouts in all four
directions.
2026-10-22 00:29:44 +02:00
David Stadelmann add0e2929b feat: add relative y coordinates 2026-10-22 00:28:15 +02:00
13 changed files with 7868 additions and 8613 deletions

No files matched your search

+77 -206
View File
@@ -56,7 +56,7 @@ for i in range(3) {
```)
#let wires = example(```
for i in range(5) {
for i in range(4) {
draw.circle((i * 3, 0), radius: .1, name: "p" + str(i * 2))
draw.circle((i * 3 + 2, 1), radius: .1, name: "p" + str(i * 2 + 1))
draw.content((i * 3 + 1, -1), raw(wire.wire-styles.at(i)))
@@ -68,213 +68,9 @@ wire.wire("w3", ("p4", "p5"), style: "dodge",
wire.wire("w4", ("p6","p7"), style: "guided",
guided-center:(20%, 40%), guided-margins: (90%,87%),
guided-sides: ("north","south"))
wire.wire("w5", ("p8", "p9"), style: "u-shape",
u-side: "east", u-depth: 0.5)
```, 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 u-shape-wire = example(
```
// east <-> east (u-side auto-detected)
element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA",
fill: util.colors.orange,
ports: (east: ((id: "out"),)))
element.block(x: 0.5, y: -2.5, w: 1.5, h: 1.5, id: "eB",
fill: util.colors.orange,
ports: (east: ((id: "out"),)))
wire.wire("uEE", ("eA-port-out", "eB-port-out"),
style: "u-shape", u-depth: 0.5)
// west <-> west (auto-detected)
element.block(x: 6, y: 0, w: 1.5, h: 1.5, id: "wA",
fill: util.colors.yellow,
ports: (west: ((id: "in"),)))
element.block(x: 6.5, y: -2.5, w: 1.5, h: 1.5, id: "wB",
fill: util.colors.yellow,
ports: (west: ((id: "in"),)))
wire.wire("uWW", ("wA-port-in", "wB-port-in"),
style: "u-shape", u-depth: 0.5)
// north <-> north (auto-detected)
element.block(x: 10, y: -3, w: 1.5, h: 1.5, id: "nA",
fill: util.colors.green,
ports: (north: ((id: "out"),)))
element.block(x: 12.5, y: -2.5, w: 1.5, h: 1.5, id: "nB",
fill: util.colors.green,
ports: (north: ((id: "out"),)))
wire.wire("uNN", ("nA-port-out", "nB-port-out"),
style: "u-shape", u-depth: 0.5)
// south <-> south with explicit u-side override
element.block(x: 16, y: -3, w: 1.5, h: 1.5, id: "sA",
fill: util.colors.pink,
ports: (south: ((id: "out"),)))
element.block(x: 18.5, y: -3.5, w: 1.5, h: 1.5, id: "sB",
fill: util.colors.pink,
ports: (south: ((id: "out"),)))
wire.wire("uSS", ("sA-port-out", "sB-port-out"),
style: "u-shape", u-side: "south", u-depth: 0.5)
```,
vertical: true,
)
#let u-at = example(
```
// `u-at` places the U-bend crossbar on the axis
// of a referenced anchor, overriding `u-depth`.
element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA",
fill: util.colors.orange,
ports: (east: ((id: "o1"), (id: "o2"))))
element.block(x: 0, y: -3.5, w: 1.5, h: 1.5, id: "eB",
fill: util.colors.orange,
ports: (east: ((id: "i1"), (id: "i2"))))
element.block(x: 4, y: -2.25, w: 0.6, h: 0.6, id: "ref",
fill: util.colors.pink)
// Align one U crossbar with ref.east.
wire.wire("u1", ("eA-port-o1", "eB-port-i1"),
style: "u-shape",
u-at: "ref.east")
// Ergonomic scalar offset: 1 right of ref.east.
wire.wire("u2", ("eA-port-o2", "eB-port-i2"),
style: "u-shape", color: util.colors.blue,
u-at: (rel: 1, to: "ref.east"))
```,
vertical: true,
)
#let s-shape-wire = example(
```
// `s-shape` connects endpoints on opposite sides
// via 5 segments joined at 4 corners (u1-start,
// u1-end, u2-start, u2-end). Auto-detects axis
// from port anchors.
element.block(x: 0, y: 0, w: 1, h: 1, id: "A",
fill: util.colors.orange,
ports: (west: ((id: "out"),)))
element.block(x: 2.5, y: -1.75, w: 1, h: 1, id: "B",
fill: util.colors.orange,
ports: (east: ((id: "in"),)))
wire.wire("s1", ("A-port-out", "B-port-in"),
style: "s-shape")
// Anchor-based override of the crossbar position.
element.block(x: 5.5, y: 0, w: 1, h: 1, id: "C",
fill: util.colors.yellow,
ports: (west: ((id: "out"),)))
element.block(x: 8.5, y: -1.75, w: 1, h: 1, id: "D",
fill: util.colors.yellow,
ports: (east: ((id: "in"),)))
element.block(x: 7, y: -0.7, w: 0.5, h: 0.5, id: "ref",
fill: util.colors.pink)
wire.wire("s2", ("C-port-out", "D-port-in"),
style: "s-shape",
s-at-mid: "ref.south")
// C-shape degeneration: crossbar outside the
// endpoint span (s-ratio-mid > 100%).
element.block(x: 11, y: 0, w: 1, h: 1, id: "E",
fill: util.colors.green,
ports: (west: ((id: "out"),)))
element.block(x: 14, y: -0.75, w: 1, h: 1, id: "F",
fill: util.colors.green,
ports: (east: ((id: "in"),)))
wire.wire("s3", ("E-port-out", "F-port-in"),
style: "s-shape",
s-ratio-mid: 200%)
```,
vertical: true,
)
#let s-shape-wire-vertical = example(
```
// `s-shape` on the vertical axis (north <-> south):
// seg1 and seg5 are vertical, the crossbar is
// vertical, and the two horizontals connect them.
element.block(x: 0, y: 0, w: 1, h: 1, id: "A",
fill: util.colors.orange,
ports: (north: ((id: "out"),)))
element.block(x: 2, y: -3, w: 1, h: 1, id: "B",
fill: util.colors.orange,
ports: (south: ((id: "in"),)))
wire.wire("s1", ("A-port-out", "B-port-in"),
style: "s-shape")
// Anchor-based override of the vertical crossbar.
element.block(x: 5, y: 0, w: 1, h: 1, id: "C",
fill: util.colors.yellow,
ports: (north: ((id: "out"),)))
element.block(x: 7, y: -3, w: 1, h: 1, id: "D",
fill: util.colors.yellow,
ports: (south: ((id: "in"),)))
element.block(x: 6.25, y: -1.5, w: 0.5, h: 0.5, id: "vref",
fill: util.colors.pink)
wire.wire("s2", ("C-port-out", "D-port-in"),
style: "s-shape",
s-at-mid: "vref.east")
```,
vertical: true,
)
#let s-shape-wire-loopback = example(
```
// `s-shape` looping back on a single block that
// has a west output and an east input. The wire
// exits west, wraps around below the block via a
// C-shape, and enters from the east.
//
// Since both endpoints share the same y, the
// crossbar's position is set by `s-ratio-mid`
// as an absolute distance below the endpoint y.
element.block(x: 2, y: 0, w: 1.5, h: 1.5, id: "A",
fill: util.colors.orange,
ports: (
west: ((id: "out"),),
east: ((id: "in"),),
))
wire.wire("s1", ("A-port-out", "A-port-in"),
style: "s-shape",
s-depth-1: 1.5, s-depth-2: 1.5,
s-ratio-mid: 150%)
// Negative `s-ratio-mid` routes the crossbar the
// other way (above the block).
element.block(x: 7, y: 0, w: 1.5, h: 1.5, id: "B",
fill: util.colors.yellow,
ports: (
west: ((id: "out"),),
east: ((id: "in"),),
))
wire.wire("s2", ("B-port-out", "B-port-in"),
style: "s-shape",
s-depth-1: 1.5, s-depth-2: 1.5,
s-ratio-mid: -150%)
```,
vertical: true,
)
#let stub = example(```
draw.circle((0, 0), radius: .1, name: "p")
wire.stub("p", "north", name: "north", length: 1)
@@ -283,6 +79,81 @@ wire.stub("p", "south", name: "south", length: 15pt)
wire.stub("p", "west", name: "west", length: 3em)
```)
#let breakout = example(```
// 16 breakouts: 4 directions × 4 label positions, laid out
// two per row so the labels have room to breathe.
let dirs = ("north-east", "north-west", "south-east", "south-west")
let poss = ("north-east", "north-west", "south-east", "south-west")
let short = (
"north-east": "NE", "north-west": "NW",
"south-east": "SE", "south-west": "SW",
)
let cols = 2
let dx = 5
let dy = 2
for (i, dir) in dirs.enumerate() {
for (j, pos) in poss.enumerate() {
let n = i * poss.len() + j
let r = calc.floor(n / cols)
let c = calc.rem(n, cols)
draw.circle((c * dx, -r * dy), radius: .05)
wire.breakout("b" + str(n),
x: c * dx, y: -r * dy,
direction: dir,
port-side: if dir.ends-with("east") { "west" } else { "east" },
name: short.at(dir) + "/" + short.at(pos),
name-pos: pos)
}
}
```)
#let breakout-label = example(```
// name-pos matches direction → label sits at the end point.
wire.breakout("b1", x: 0, y: 0, direction: "north-east",
port-side: "east",
name: "at end", name-pos: "north-east")
// name-pos opposite to direction → label would cover the line, so
// it falls back to the start point.
wire.breakout("b2", x: 4, y: 0, direction: "north-east",
port-side: "east",
name: "at start", name-pos: "south-west")
```, vertical: true)
#let breakout-wire = example(```
// Bus fan-out: a horizontal wire from the left block to the lower
// right block carries the full 10-bit bus. A breakout in the middle
// of that wire taps bits [9:5] and routes them to the upper right
// block via an L-shaped zigzag (up, then right).
element.block(x: 0, y: 0, w: 2, h: 2, id: "src",
fill: util.colors.blue,
ports: (east: ((id: "out"),)))
element.block(x: 6, y: 0, w: 2, h: 2, id: "dstL",
fill: util.colors.green,
ports: (west: ((id: "in"),)))
element.block(x: 6, y: 4, w: 2, h: 2, id: "dstU",
fill: util.colors.orange,
ports: (west: ((id: "in"),)))
// Full bus wire, labelled "[9:0]" on the left.
wire.wire("wbus", ("src-port-out", "dstL-port-in"),
name: "[9:0]", name-pos: "start")
// Breakout tapped from the midpoint of the bus.
wire.breakout("tap",
x: (rel: 2, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "north-east",
port-side: "west",
name: "[9:5]", name-pos: "north-east")
// L-shaped wire from the breakout up and then right to the upper
// block's input.
wire.wire("wtap", ("tap-port-tap", "dstU-port-in"),
style: "zigzag", zigzag-dir: "horizontal",
zigzag-ratio: 100%)
```)
#let gate-and = example(```
gates.gate-and(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-and(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+117
View File
@@ -0,0 +1,117 @@
// Demonstrates the wire.breakout element used to split off slices of a
// bus that connects blocks. The main bus wires are always laid out
// orthogonally; breakouts diagonally tap intermediate points on those
// wires and route the sub-bus to another block via an L-shaped zigzag.
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// ---- Source block on the left, main sink on the right ----
element.block(x: 0, y: 0, w: 2, h: 2, id: "src", fill: util.colors.blue, ports: (
east: ((id: "out"),),
))
element.block(x: 15, y: 0, w: 2, h: 2, id: "dstMain", fill: util.colors.green, ports: (
west: ((id: "in"),),
))
// Full bus from src to dstMain, labelled [15:0] at the start.
wire.wire("wbus", ("src-port-out", "dstMain-port-in"), name: "[15:0]", name-pos: "start")
// ---- Upper-left sink: taps bits [11:8] via NW breakout ----
element.block(x: 0, y: 4, w: 2, h: 2, id: "dstNW", fill: util.colors.yellow, ports: (
east: ((id: "in"),),
))
wire.breakout(
"tapNE",
x: (rel: 3, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "north-east",
port-side: "west",
name: "[15:12]",
name-pos: "north-east",
)
// L-shape: up first, then left into dstNW.
wire.wire(
"wNW",
("tapNE-port-tapNE", "dstNW-port-in"),
style: "zigzag",
zigzag-dir: "horizontal",
zigzag-ratio: 100%,
)
// ---- Upper-right sink: taps bits [15:12] via NE breakout ----
element.block(x: 15, y: 4, w: 2, h: 2, id: "dstNE", fill: util.colors.orange, ports: (
west: ((id: "in"),),
))
wire.breakout(
"tapNW",
x: (rel: 7, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "north-west",
port-side: "east",
name: "[11:8]",
name-pos: "north-east",
)
// L-shape: up first, then right into dstNE.
wire.wire(
"wNE",
("tapNW-port-tapNW", "dstNE-port-in"),
style: "zigzag",
zigzag-dir: "horizontal",
zigzag-ratio: 100%,
)
// ---- Lower-left sink: taps bits [3:0] via SW breakout ----
element.block(x: 0, y: -4, w: 2, h: 2, id: "dstSW", fill: util.colors.purple, ports: (
east: ((id: "in"),),
))
wire.breakout(
"tapSE",
x: (rel: 5, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "south-east",
port-side: "west",
name: "[7:4]",
name-pos: "south-east",
)
// L-shape: down first, then left into dstSW.
wire.wire(
"wSW",
("tapSE-port-tapSE", "dstSW-port-in"),
style: "zigzag",
zigzag-dir: "horizontal",
zigzag-ratio: 100%,
)
// ---- Lower-right sink: taps bits [7:4] via SE breakout ----
element.block(x: 15, y: -4, w: 2, h: 2, id: "dstSE", fill: util.colors.pink, ports: (
west: ((id: "in"),),
))
wire.breakout(
"tapSW",
x: (rel: 11, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "south-west",
port-side: "east",
name: "[3:0]",
name-pos: "south-west",
)
// L-shape: down first, then right into dstSE.
wire.wire(
"wSE",
("tapSW-port-tapSW", "dstSE-port-in"),
style: "zigzag",
zigzag-dir: "horizontal",
zigzag-ratio: 100%,
)
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

-53
View File
@@ -1,53 +0,0 @@
// Demonstrates the u-shape wire style.
// The u-side is auto-detected from the port anchors when both endpoints
// belong to elements (deduced from the port position vs the element's
// center anchor). It can also be set explicitly with u-side.
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// ---- east <-> east (auto-detected) ----
element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA",
fill: util.colors.orange,
ports: (east: ((id: "out"),)))
element.block(x: 0.5, y: -2.5, w: 1.5, h: 1.5, id: "eB",
fill: util.colors.orange,
ports: (east: ((id: "out"),)))
wire.wire("uEE", ("eA-port-out", "eB-port-out"),
style: "u-shape", u-depth: 0.6,
name: "E-E")
// ---- west <-> west (auto-detected) ----
element.block(x: 6, y: 0, w: 1.5, h: 1.5, id: "wA",
fill: util.colors.yellow,
ports: (west: ((id: "in"),)))
element.block(x: 6.5, y: -2.5, w: 1.5, h: 1.5, id: "wB",
fill: util.colors.yellow,
ports: (west: ((id: "in"),)))
wire.wire("uWW", ("wA-port-in", "wB-port-in"),
style: "u-shape", u-depth: 0.6,
name: "W-W")
// ---- north <-> north (auto-detected) ----
element.block(x: 10, y: -3, w: 1.5, h: 1.5, id: "nA",
fill: util.colors.green,
ports: (north: ((id: "out"),)))
element.block(x: 12.5, y: -2.5, w: 1.5, h: 1.5, id: "nB",
fill: util.colors.green,
ports: (north: ((id: "out"),)))
wire.wire("uNN", ("nA-port-out", "nB-port-out"),
style: "u-shape", u-depth: 0.6,
name: "N-N")
// ---- south <-> south (explicit u-side override) ----
element.block(x: 16, y: 0, w: 1.5, h: 1.5, id: "sA",
fill: util.colors.pink,
ports: (south: ((id: "out"),)))
element.block(x: 18.5, y: -0.5, w: 1.5, h: 1.5, id: "sB",
fill: util.colors.pink,
ports: (south: ((id: "out"),)))
wire.wire("uSS", ("sA-port-out", "sB-port-out"),
style: "u-shape", u-side: "south", u-depth: 0.6,
name: "S-S")
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+46
View File
@@ -0,0 +1,46 @@
// Demonstrates the `(rel: <number>, to: <anchor>)` shorthand for the `y`
// parameter (analogous to the existing shorthand for `x`).
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// Anchor block: absolute placement.
element.block(
x: 0, y: 0, w: 2, h: 1.5,
id: "A",
fill: util.colors.orange,
ports: (east: ((id: "out"),))
)
// X relative, Y absolute (existing feature).
element.block(
x: (rel: 1, to: "A.east"), y: 0, w: 2, h: 1.5,
id: "B",
fill: util.colors.yellow,
ports: (west: ((id: "in"),), east: ((id: "out"),))
)
// Both X and Y relative to another element's anchor (new feature).
element.block(
x: (rel: 1, to: "B.east"),
y: (rel: 2, to: "B.south-west"),
w: 2, h: 1.5,
id: "C",
fill: util.colors.green,
ports: (west: ((id: "in"),))
)
// Y relative only, X absolute.
element.block(
x: 0,
y: (rel: -2.5, to: "A.south-west"),
w: 2, h: 1.5,
id: "D",
fill: util.colors.blue,
ports: (east: ((id: "out"),))
)
wire.wire("wAB", ("A-port-out", "B-port-in"))
wire.wire("wBC", ("B-port-out", "C-port-in"), style: "zigzag")
})
+7423 -7805
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -24,7 +24,9 @@
/// If it is a dictionary, it should be in the format `(rel: number, to: str)`, where `rel` is the offset and `to` the base anchor
/// - y (number, dictionary): The y position (bottom-left corner).
///
/// If it is a dictionary, it should be in the format `(from: str, to: str)`, where `from` is the base anchor and `to` is the id of the port to align with the anchor
/// If it is a dictionary, it can take one of two forms:
/// - `(rel: number, to: str)`: `rel` is the y offset and `to` the base anchor (analogous to `x`)
/// - `(from: str, to: str)`: `from` is the base anchor and `to` is the id of the port to align with the anchor
/// - w (number): Width of the element
/// - h (number): Height of the element
/// - name (none, str): Optional name of the block
@@ -74,13 +76,12 @@
if h == none { panic("Parameter h must be set") }
if (type(x) == dictionary) {
let offset = x.rel
let to = x.to
let (ctx, to-pos) = coordinate.resolve(ctx, (rel: (offset, 0), to: to))
x = to-pos.at(0)
x = util.resolve-rel-coord(ctx, x, 0)
}
if (type(y) == dictionary) {
if (type(y) == dictionary and "rel" in y) {
y = util.resolve-rel-coord(ctx, y, 1)
} else if (type(y) == dictionary) {
let from = y.from
let to = y.to
let (to-side, i) = find-port(ports, to)
+5 -5
View File
@@ -1,4 +1,5 @@
#import "/src/cetz.typ": draw, coordinate
#import "/src/util.typ"
#import "../ports.typ": add-ports, add-port
#import "../element.typ"
@@ -49,13 +50,12 @@
if h == none { panic("Parameter h must be set") }
if (type(x) == dictionary) {
let offset = x.rel
let to = x.to
let (ctx, to-pos) = coordinate.resolve(ctx, (rel: (offset, 0), to: to))
x = to-pos.at(0)
x = util.resolve-rel-coord(ctx, x, 0)
}
if (type(y) == dictionary) {
if (type(y) == dictionary and "rel" in y) {
y = util.resolve-rel-coord(ctx, y, 1)
} else if (type(y) == dictionary) {
let from = y.from
let to = y.to
+5 -5
View File
@@ -1,4 +1,5 @@
#import "/src/cetz.typ": draw, coordinate
#import "/src/util.typ"
#import "../ports.typ": add-ports, add-port
#import "../element.typ"
@@ -60,13 +61,12 @@
if h == none { panic("Parameter h must be set") }
if (type(x) == dictionary) {
let offset = x.rel
let to = x.to
let (ctx, to-pos) = coordinate.resolve(ctx, (rel: (offset, 0), to: to))
x = to-pos.at(0)
x = util.resolve-rel-coord(ctx, x, 0)
}
if (type(y) == dictionary) {
if (type(y) == dictionary and "rel" in y) {
y = util.resolve-rel-coord(ctx, y, 1)
} else if (type(y) == dictionary) {
let from = y.from
let to = y.to
+20
View File
@@ -1,3 +1,23 @@
#import "cetz.typ": coordinate
/// Resolves a relative coordinate shorthand of the form `(rel: <number>, to: <anchor>)`
/// to a single scalar along the requested axis.
///
/// Used to implement the shared `(rel:, to:)` shorthand accepted by the `x` and
/// `y` parameters of `elmt`, `gate` and `iec-gate`.
///
/// - ctx (dictionary): The current CeTZ context
/// - val (dictionary): A dictionary with fields `rel` (number) and `to` (anchor)
/// - axis (int): `0` for x, `1` for y
/// -> number
#let resolve-rel-coord(ctx, val, axis) = {
let offset = val.rel
let to = val.to
let delta = if axis == 0 { (offset, 0) } else { (0, offset) }
let (_, to-pos) = coordinate.resolve(ctx, (rel: delta, to: to))
return to-pos.at(axis)
}
/// Predefined color palette
/// #let w = 4
/// #box(width: 100%, align(center)[
+168 -533
View File
@@ -1,27 +1,9 @@
#import "/src/cetz.typ": draw, coordinate
#import "util.typ": opposite-anchor, normalize-coord
#import "util.typ": opposite-anchor, normalize-coord, resolve-rel-coord
/// List of valid wire styles
/// #examples.wires
///
/// The `"u-shape"` style connects two ports that face the same cardinal side.
/// #examples.u-shape-wire
///
/// The `"u-shape"` crossbar can also be aligned to an anchor via `u-at`
/// (analogous to `zigzag-at`).
/// #examples.u-at
///
/// The `"s-shape"` style connects two ports that face opposite cardinal sides
/// (east↔west or north↔south) via 5 segments.
/// #examples.s-shape-wire
///
/// The `"s-shape"` style also supports the vertical axis (north↔south):
/// #examples.s-shape-wire-vertical
///
/// The `"s-shape"` style can also loop a single block's output back to its
/// own input by wrapping around above (or below) via a C-shape:
/// #examples.s-shape-wire-loopback
#let wire-styles = ("direct", "zigzag", "dodge", "guided", "u-shape", "s-shape")
#let wire-styles = ("direct", "zigzag", "dodge", "guided")
#let signal-width = 1pt
#let bus-width = 1.5pt
@@ -42,95 +24,29 @@
return (pts, anchors)
}
// 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)
}
#let get-zigzag-wire(pts, ratio, dir, at: none) = {
#let get-zigzag-wire(pts, ratio, dir) = {
let start = pts.first()
let end = pts.last()
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
)
}
let mid = if dir == "vertical" {
(start, ratio, (horizontal: end, vertical: ()))
} else {
// 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
)
}
(start, ratio, (horizontal: (), vertical: end))
}
let points = if dir == "vertical" {
(
start,
(horizontal: mid, vertical: ()),
(horizontal: (), vertical: end),
end
)
} else {
(
start,
(horizontal: (), vertical: mid),
(horizontal: end, vertical: ()),
end
)
}
let anchors = (
"start": start,
@@ -193,315 +109,6 @@
return (points, anchors)
}
#let _u-valid-sides = ("east", "west", "north", "south")
// 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 u-side
// 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-u-shape-wire(pts, u-depth, u-side, u-at, ctx) = {
let start = pts.first()
let end = pts.last()
// Resolve the (auto|explicit) side
let side = u-side
if side == auto {
let side-start = detect-port-side(start, ctx)
let side-end = detect-port-side(end, ctx)
if side-start != none and side-end != none and side-start != side-end {
panic(
"u-shape: start port faces '" + side-start
+ "' but end port faces '" + side-end
+ "'; both must face the same side, or set u-side explicitly"
)
}
if side-start != none { side = side-start }
else if side-end != none { side = side-end }
else {
panic(
"u-shape: could not auto-detect u-side (endpoints are not port anchors); "
+ "please set u-side explicitly to one of " + repr(_u-valid-sides)
)
}
}
if not side in _u-valid-sides {
panic("u-shape: invalid u-side '" + repr(side) + "'; must be one of " + repr(_u-valid-sides))
}
let (ctx, s) = coordinate.resolve(ctx, start)
let (ctx, e) = coordinate.resolve(ctx, end)
let (sx, sy, ..) = s
let (ex, ey, ..) = e
// If `u-at` is set, resolve its coordinate and extract the relevant axis
// component; use it as the crossbar position instead of computing from
// `u-depth`.
let bar-at = none
if u-at != none {
let axis = if side == "east" or side == "west" { "x" } else { "y" }
let at-norm = normalize-scalar-offset(u-at, axis)
let (ctx2, r) = coordinate.resolve(ctx, at-norm)
bar-at = if axis == "x" { r.first() } else { r.at(1) }
}
let p1
let p2
if side == "east" {
let bx = if bar-at != none { bar-at } else { calc.max(sx, ex) + u-depth }
p1 = (bx, sy, 0)
p2 = (bx, ey, 0)
} else if side == "west" {
let bx = if bar-at != none { bar-at } else { calc.min(sx, ex) - u-depth }
p1 = (bx, sy, 0)
p2 = (bx, ey, 0)
} else if side == "north" {
let by = if bar-at != none { bar-at } else { calc.max(sy, ey) + u-depth }
p1 = (sx, by, 0)
p2 = (ex, by, 0)
} else if side == "south" {
let by = if bar-at != none { bar-at } else { calc.min(sy, ey) - u-depth }
p1 = (sx, by, 0)
p2 = (ex, by, 0)
}
let points = (start, p1, p2, end)
let anchors = (
"start": start,
"u-start": p1,
"u-end": p2,
"end": end
)
return (points, anchors)
}
// Valid combinations of endpoint sides for an S-shape wire.
// Keyed by (sorted) pair; value is the wire axis.
#let _s-axis-from-sides(a, b) = {
if a == none or b == none { return none }
let pair = (a, b)
if ("east" in pair) and ("west" in pair) { return "horizontal" }
if ("north" in pair) and ("south" in pair) { return "vertical" }
return none
}
// S-shape wire: 5 segments, 4 corners; connects endpoints on opposite sides.
//
// Horizontal axis (east ↔ west): sequence exits/enters through horizontal
// segments; two vertical segments join them via a horizontal crossbar.
//
// Corner layout (horizontal axis):
// start ── c1
// │
// c2 ─────────── c3
// │
// c4 ── end
//
// (or mirrored, depending on which endpoint faces east vs west, and on the
// relative y of the endpoints; when y_mid is outside the endpoint span the
// shape degenerates into a C-shape.)
#let get-s-shape-wire(
pts,
s-axis,
s-depth-1,
s-ratio-mid,
s-depth-2,
s-at-1,
s-at-mid,
s-at-2,
strict,
ctx,
) = {
let start = pts.first()
let end = pts.last()
// Detect endpoint sides
let side-start = detect-port-side(start, ctx)
let side-end = detect-port-side(end, ctx)
// Resolve (auto|explicit) axis
let axis = s-axis
if axis == auto {
let detected = _s-axis-from-sides(side-start, side-end)
if detected == none {
panic(
"s-shape: could not auto-detect s-axis; endpoints must be port anchors "
+ "facing opposite sides on the same axis (east+west or north+south). "
+ "Detected start='" + repr(side-start) + "', end='" + repr(side-end)
+ "'. Set s-axis explicitly to \"horizontal\" or \"vertical\"."
)
}
axis = detected
}
if axis != "horizontal" and axis != "vertical" {
panic("s-shape: invalid s-axis '" + repr(axis) + "'; must be \"horizontal\" or \"vertical\"")
}
// Effective depth/ratio (auto → defaults)
let depth-1 = if s-depth-1 == auto { 0.5 } else { s-depth-1 }
let depth-2 = if s-depth-2 == auto { 0.5 } else { s-depth-2 }
let ratio-mid-raw = if s-ratio-mid == auto { 50% } else { s-ratio-mid }
let ratio-mid = ratio-mid-raw / 100%
let (ctx, s) = coordinate.resolve(ctx, start)
let (ctx, e) = coordinate.resolve(ctx, end)
let (sx, sy, ..) = s
let (ex, ey, ..) = e
// Determine outward directions from each port
let outward(side) = {
if side == "east" { return (1, 0) }
if side == "west" { return (-1, 0) }
if side == "north" { return (0, 1) }
if side == "south" { return (0, -1) }
return none
}
let out-s = outward(side-start)
let out-e = outward(side-end)
// Fallback when a port side isn't known (e.g. non-port endpoint): infer from
// the axis, assuming the wire flows toward the far endpoint.
if out-s == none {
if axis == "horizontal" { out-s = (if ex > sx { 1 } else { -1 }, 0) }
else { out-s = (0, if ey > sy { 1 } else { -1 }) }
}
if out-e == none {
if axis == "horizontal" { out-e = (if sx > ex { 1 } else { -1 }, 0) }
else { out-e = (0, if sy > ey { 1 } else { -1 }) }
}
// Resolve the three placement overrides
let resolve-axial(at, ax) = {
if at == none { return none }
let at-norm = normalize-scalar-offset(at, ax)
let (_, r) = coordinate.resolve(ctx, at-norm)
return if ax == "x" { r.first() } else { r.at(1) }
}
let c1x
let c1y
let c2x
let c2y
let c3x
let c3y
let c4x
let c4y
if axis == "horizontal" {
// seg1 and seg5 are horizontal; the two verticals sit at x = x1 and x = x2.
let x1-at = resolve-axial(s-at-1, "x")
let x2-at = resolve-axial(s-at-2, "x")
let y-mid-at = resolve-axial(s-at-mid, "y")
let x1 = if x1-at != none { x1-at } else { sx + out-s.at(0) * depth-1 }
let x2 = if x2-at != none { x2-at } else { ex + out-e.at(0) * depth-2 }
// When the endpoints share the same y (degenerate span), the ratio-based
// formula collapses to sy. Fall back to a unit offset scaled by ratio-mid
// so the crossbar can still be pushed away from the endpoint axis
// (e.g. for single-block loopbacks). Direction defaults to "down" (-y).
let y-mid = if y-mid-at != none {
y-mid-at
} else if ey == sy {
sy - ratio-mid
} else {
sy + ratio-mid * (ey - sy)
}
c1x = x1; c1y = sy
c2x = x1; c2y = y-mid
c3x = x2; c3y = y-mid
c4x = x2; c4y = ey
// Sanity checks (strict-only)
if strict {
// seg1 must extend outward: sign(x1 - sx) should match out-s.x
if (x1 - sx) * out-s.at(0) <= 0 {
panic("s-shape: seg1 does not extend outward from start port "
+ "(x1=" + str(x1) + ", start.x=" + str(sx) + ", outward.x="
+ str(out-s.at(0)) + "). Adjust s-depth-1 or s-at-1.")
}
if (x2 - ex) * out-e.at(0) <= 0 {
panic("s-shape: seg5 does not extend outward from end port "
+ "(x2=" + str(x2) + ", end.x=" + str(ex) + ", outward.x="
+ str(out-e.at(0)) + "). Adjust s-depth-2 or s-at-2.")
}
}
} else {
// vertical axis: seg1 and seg5 are vertical; the two horizontals sit at
// y = y1 and y = y2; the middle crossbar is vertical at x = x-mid.
let y1-at = resolve-axial(s-at-1, "y")
let y2-at = resolve-axial(s-at-2, "y")
let x-mid-at = resolve-axial(s-at-mid, "x")
let y1 = if y1-at != none { y1-at } else { sy + out-s.at(1) * depth-1 }
let y2 = if y2-at != none { y2-at } else { ey + out-e.at(1) * depth-2 }
// Degenerate-span fallback: see comment in the horizontal branch.
let x-mid = if x-mid-at != none {
x-mid-at
} else if ex == sx {
sx + ratio-mid
} else {
sx + ratio-mid * (ex - sx)
}
c1x = sx; c1y = y1
c2x = x-mid; c2y = y1
c3x = x-mid; c3y = y2
c4x = ex; c4y = y2
if strict {
if (y1 - sy) * out-s.at(1) <= 0 {
panic("s-shape: seg1 does not extend outward from start port "
+ "(y1=" + str(y1) + ", start.y=" + str(sy) + ", outward.y="
+ str(out-s.at(1)) + "). Adjust s-depth-1 or s-at-1.")
}
if (y2 - ey) * out-e.at(1) <= 0 {
panic("s-shape: seg5 does not extend outward from end port "
+ "(y2=" + str(y2) + ", end.y=" + str(ey) + ", outward.y="
+ str(out-e.at(1)) + "). Adjust s-depth-2 or s-at-2.")
}
}
}
let c1 = (c1x, c1y, 0)
let c2 = (c2x, c2y, 0)
let c3 = (c3x, c3y, 0)
let c4 = (c4x, c4y, 0)
let points = (start, c1, c2, c3, c4, end)
let anchors = (
"start": start,
"u1-start": c1,
"u1-end": c2,
"u2-start": c3,
"u2-end": c4,
"end": end,
)
return (points, anchors)
}
#let get-guided-wire(pts, margins, sides, center-guides, ctx) = {
let start = pts.first()
let end = pts.last()
@@ -597,26 +204,14 @@
/// - 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 (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 (auto, str): The zigzag's direction, as either "vertical" or "horizontal" (only with style "zigzag"). If `auto`, it is deduced from the endpoints when they are port anchors of the form `<elmt-id>-port-<port-id>` by comparing each port's position with its element's `.center` anchor: east/west-facing ports yield `"vertical"` (wire exits horizontally) and north/south-facing ports yield `"horizontal"`. If the direction cannot be deduced (no port anchor on either endpoint, or conflicting directions), it falls back to `"vertical"`.
/// - 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
/// - zigzag-ratio (ratio): Position of the zigzag vertical relative to the horizontal span (only with style "zigzag")
/// - zigzag-dir (str): The zigzag's direction. As either "vertical" or "horizontal" (only with dstyle "zigzag")
/// - 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")
/// - guided-center (array): the horizontal and vertical guides of the center guides (only with style "guided")
/// - guided-margins (array): the start and end of guided margins of the wire (only with style "guided")
/// - guided-sides (array): the side of start and end array (must be either "north", "south", "west", "east") (only work with style "guided")
/// - u-side (auto, str): The cardinal side both endpoints face (one of "east", "west", "north", "south"). If `auto`, it is deduced from the endpoints when they are port anchors of the form `<elmt-id>-port-<port-id>` by comparing each port's position with its element's `.center` anchor. Auto-detection panics if the two endpoints resolve to different sides, or if neither endpoint is a port anchor (only with style "u-shape")
/// - u-depth (auto, number): Distance between the outermost endpoint (in the `u-side` direction) and the U-bend line (only with style "u-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicitly set `u-at`.
/// - u-at (none, str, array, dictionary): Optional. Anchor-based placement of the U-bend crossbar (only with style "u-shape"). When set, overrides `u-depth` (which must be left at its default `auto`). The crossbar sits on the axis extracted from the given coordinate: `x` for `u-side` in `("east", "west")`, `y` for `("north", "south")`. Accepted forms mirror `zigzag-at`: raw CeTZ anchor name string, any native CeTZ coordinate, or the ergonomic scalar-offset form `(rel: <length>, to: <coord>)`, which is rewritten to `(dx, 0)` for east/west and `(0, dy)` for north/south.
/// - s-axis (auto, str): The s-shape wire axis, one of "horizontal" or "vertical" (only with style "s-shape"). If `auto`, it is deduced from the endpoint port anchors: opposite east/west ports yield `"horizontal"`, opposite north/south ports yield `"vertical"`. Auto-detection panics on same-side, cross-axis, or non-port endpoints.
/// - s-depth-1 (auto, number): Distance seg1 (the first outward segment) extends from the start port (only with style "s-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicit `s-at-1`.
/// - s-ratio-mid (auto, ratio): Position of the s-shape crossbar between the endpoints along the axis perpendicular to `s-axis` (only with style "s-shape"). Defaults to `auto`, which resolves to `50%`. Values outside `0%..100%` are allowed and yield a C-shape variant. When the two endpoints share the same perpendicular coordinate (e.g. a single-block loopback where both ports are at the same y), the endpoint span is zero and `s-ratio-mid` is instead interpreted as an absolute offset from that shared coordinate: the crossbar sits `s-ratio-mid` units below the endpoints for horizontal S, or `s-ratio-mid` units right for vertical S (using `1` = ratio unit). Panics if combined with an explicit `s-at-mid`.
/// - s-depth-2 (auto, number): Distance seg5 (the last inward segment) extends from the end port (only with style "s-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicit `s-at-2`.
/// - s-at-1 (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-depth-1` (only with style "s-shape"). Fixes the x (horizontal S) or y (vertical S) of the first vertical bend line. Accepted forms mirror `zigzag-at`.
/// - s-at-mid (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-ratio-mid` (only with style "s-shape"). Fixes the y (horizontal S) or x (vertical S) of the crossbar. Accepted forms mirror `zigzag-at`.
/// - s-at-2 (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-depth-2` (only with style "s-shape"). Fixes the x (horizontal S) or y (vertical S) of the second vertical bend line. Accepted forms mirror `zigzag-at`.
/// - strict (bool): If true, s-shape performs sanity checks and panics when seg1/seg5 do not extend outward from their port (only with style "s-shape"). Defaults to `false`.
#let wire(
id, pts,
bus: false,
@@ -632,23 +227,11 @@
guided-sides: ("east", "west"),
directed: false,
rotate-name: true,
zigzag-ratio: auto,
zigzag-dir: auto,
zigzag-at: none,
zigzag-ratio: 50%,
zigzag-dir: "vertical",
dodge-y: 0,
dodge-sides: ("east", "west"),
dodge-margins: (5%, 5%),
u-side: auto,
u-depth: auto,
u-at: none,
s-axis: auto,
s-depth-1: auto,
s-ratio-mid: auto,
s-depth-2: auto,
s-at-1: none,
s-at-mid: none,
s-at-2: none,
strict: false,
dodge-margins: (5%, 5%)
) = draw.get-ctx(ctx => {
if not style in wire-styles {
panic("Invalid wire style '" + style + "'")
@@ -658,52 +241,6 @@
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 }
// `u-at` validation.
if u-at != none {
if style != "u-shape" {
panic("wire '" + id + "': `u-at` requires style: \"u-shape\" (got \""
+ style + "\")")
}
if u-depth != auto {
panic("wire '" + id
+ "': cannot combine `u-at` with an explicit `u-depth`")
}
}
let u-depth-effective = if u-depth == auto { 0.5 } else { u-depth }
// `s-at-*` validation.
let s-params-set = (
s-axis != auto or s-depth-1 != auto or s-ratio-mid != auto
or s-depth-2 != auto or s-at-1 != none or s-at-mid != none
or s-at-2 != none
)
if s-params-set and style != "s-shape" {
panic("wire '" + id + "': `s-*` parameters require style: \"s-shape\" (got \""
+ style + "\")")
}
if s-at-1 != none and s-depth-1 != auto {
panic("wire '" + id + "': cannot combine `s-at-1` with an explicit `s-depth-1`")
}
if s-at-mid != none and s-ratio-mid != auto {
panic("wire '" + id + "': cannot combine `s-at-mid` with an explicit `s-ratio-mid`")
}
if s-at-2 != none and s-depth-2 != auto {
panic("wire '" + id + "': cannot combine `s-at-2` with an explicit `s-depth-2`")
}
// Normalize user-provided coordinates: (x, y) -> (x, y, 0)
let pts = pts.map(normalize-coord)
@@ -722,26 +259,7 @@
(points, anchors) = get-direct-wire(pts)
} else if style == "zigzag" {
let zz-dir = zigzag-dir
if zz-dir == auto {
let side-start = detect-port-side(pts.first(), ctx)
let side-end = detect-port-side(pts.last(), ctx)
let dir-from-side(s) = if s == none { none } else if s == "east" or s == "west" { "vertical" } else { "horizontal" }
let d-start = dir-from-side(side-start)
let d-end = dir-from-side(side-end)
// Fall back to "vertical" (the previous default) when the direction
// cannot be deduced (no port anchors, or conflicting endpoints).
if d-start != none and d-end != none and d-start != d-end { zz-dir = "vertical" }
else if d-start != none { zz-dir = d-start }
else if d-end != none { zz-dir = d-end }
else { zz-dir = "vertical" }
}
(points, anchors) = get-zigzag-wire(
pts,
zigzag-ratio-effective,
zz-dir,
at: normalize-zigzag-at(zigzag-at, zz-dir),
)
(points, anchors) = get-zigzag-wire(pts, zigzag-ratio, zigzag-dir)
} else if style == "dodge" {
(points, anchors) = get-dodge-wire(
@@ -759,30 +277,9 @@
guided-center,
ctx
)
} else if style == "u-shape" {
(points, anchors) = get-u-shape-wire(
pts,
u-depth-effective,
u-side,
u-at,
ctx
)
} else if style == "s-shape" {
(points, anchors) = get-s-shape-wire(
pts,
s-axis,
s-depth-1,
s-ratio-mid,
s-depth-2,
s-at-1,
s-at-mid,
s-at-2,
strict,
ctx,
)
}
}
let mark = (fill: color)
let mark = (fill: color)
if directed {
mark = (end: ">", fill: color)
}
@@ -906,3 +403,141 @@
)
}
}
/// Draws a wire breakout: a 45° diagonal line starting at (x, y) and
/// ending 1 unit away in one of the four diagonal directions. The end
/// point exposes a port anchor named `<id>-port-<id>` so wires can attach
/// to it as if it were a block port. A synthesized `<id>.center` anchor
/// is placed opposite `port-side`, so the cardinal side the port faces
/// can be deduced by comparing the port with the `.center` anchor.
///
/// #examples.breakout
///
/// Label positions are anchored so that the label sits neatly next to the
/// end point of the breakout. When the label direction matches the
/// breakout direction (which would overlap the line) the label is
/// automatically placed at the start point instead so the text does not
/// cover the line.
///
/// #examples.breakout-label
///
/// The port anchor makes it easy to fan out a bus:
///
/// #examples.breakout-wire
///
/// - id (str): The breakout id (also used as the port id)
/// - x (number, dictionary): The starting x position (same forms as `element.block`)
/// - y (number, dictionary): The starting y position (same forms as `element.block`)
/// - direction (str): One of `"north-east"`, `"north-west"`, `"south-east"`, `"south-west"`
/// - length (length, number): The diagonal length (default `1em`, matches port size)
/// - port-side (str): Cardinal side used for wire-attach behavior (`"north"`, `"east"`, `"south"`, `"west"`)
/// - stroke (stroke): Line stroke (paint + thickness)
/// - name (none, str): Optional label displayed near the breakout
/// - name-pos (none, str): Label position, one of `"north-west"`, `"north-east"`, `"south-west"`, `"south-east"`
/// - debug (bool): If true, shows a red dot at the port anchor
#let breakout(
id,
x: none, y: none,
direction: "north-east",
length: 1em,
port-side: "east",
stroke: black + 1pt,
name: none,
name-pos: none,
debug: false,
) = draw.get-ctx(ctx => {
if x == none { panic("breakout: parameter x must be set") }
if y == none { panic("breakout: parameter y must be set") }
let valid-directions = ("north-east", "north-west", "south-east", "south-west")
if not direction in valid-directions {
panic("breakout: invalid direction '" + repr(direction)
+ "'; must be one of " + repr(valid-directions))
}
let valid-sides = ("north", "east", "south", "west")
if not port-side in valid-sides {
panic("breakout: invalid port-side '" + repr(port-side)
+ "'; must be one of " + repr(valid-sides))
}
// Diagonal component: length / sqrt(2). Resolve to a canvas-space
// number so we can do arithmetic with anchor coordinates.
let d-raw = length / calc.sqrt(2)
let (_, d-vec) = coordinate.resolve(ctx, (d-raw, 0, 0))
let d = d-vec.at(0)
// Direction sign vector for (dx, dy).
let sx = if direction.ends-with("east") { 1 } else { -1 }
let sy = if direction.starts-with("north") { 1 } else { -1 }
// Resolve x.
let sx0 = x
if type(x) == dictionary {
if "rel" in x {
sx0 = resolve-rel-coord(ctx, x, 0)
} else if "from" in x {
let (_, from-pos) = coordinate.resolve(ctx, x.from)
sx0 = from-pos.at(0)
} else {
panic("breakout: unsupported x dictionary: " + repr(x))
}
}
// Resolve y.
let sy0 = y
if type(y) == dictionary {
if "rel" in y {
sy0 = resolve-rel-coord(ctx, y, 1)
} else if "from" in y {
let (_, from-pos) = coordinate.resolve(ctx, y.from)
sy0 = from-pos.at(1)
} else {
panic("breakout: unsupported y dictionary: " + repr(y))
}
}
let start = (sx0, sy0)
let end = (rel: (sx * d, sy * d), to: start)
// Offset used for the synthesized center anchor: opposite of port-side.
let center-offset = (
"north": (0, -1em),
"east": (-1em, 0),
"south": (0, 1em),
"west": (1em, 0),
).at(port-side)
let port-anchor-name = id + "-port-" + id
draw.group(name: id, {
draw.line(start, end, stroke: stroke)
draw.anchor("start", start)
draw.anchor("end", end)
draw.anchor("center", (rel: center-offset, to: end))
if name != none and name-pos != none {
let label-anchor = (
"north-west": "south-east",
"north-east": "south-west",
"south-west": "north-east",
"south-east": "north-west",
).at(name-pos)
// Overlap case: label direction is opposite to breakout direction,
// so the label at the end point would extend back over the line.
// Anchor it at the start point instead.
let label-pos = if name-pos == opposite-anchor(direction) { start } else { end }
draw.content(label-pos, anchor: label-anchor, padding: 3pt, name)
}
})
// Port anchor emitted at top level so it can be referenced by its
// bare name (mirrors add-port's approach in ports.typ).
if debug {
draw.circle(end, name: port-anchor-name,
radius: .1, stroke: none, fill: red)
} else {
draw.hide(draw.circle(end, name: port-anchor-name,
radius: 0, stroke: none))
}
})