Compare commits

..
Author SHA1 Message Date
David Stadelmann 2e6bfab744 feat: Add s-shape wire style and u-at anchor placement
Introduces a new "s-shape" wire style connecting two ports on opposite
cardinal sides (east<->west or north<->south) via 5 segments joined at
4 corners (u1-start, u1-end, u2-start, u2-end). The axis is auto-detected
from port anchors; when both endpoints share the same perpendicular
coordinate (single-block loopback) `s-ratio-mid` is reinterpreted as an
absolute signed offset so the crossbar can still be placed above or
below the endpoints.

Also adds `u-at` to the "u-shape" style, an anchor-based crossbar
placement analogous to `zigzag-at`. `u-depth` now defaults to
`auto` to allow clean mutual-exclusion with `u-at`.

Factors the shared placement normalizer into
`normalize-scalar-offset(at, axis)`; `normalize-zigzag-at` remains as
a thin backwards-compatible wrapper.
2026-09-30 15:58:01 +02:00
David Stadelmann 018192db86 feat: Auto-detect zigzag-dir from port anchors
Analogous to u-shape's u-side auto-detection: when zigzag-dir is auto
(new default), deduce it from the endpoint port anchors by comparing
each port's position with its element's .center anchor. East/west ports
yield "vertical", north/south yield "horizontal". Panics on
conflicting endpoints or when neither endpoint is a port anchor.
2026-09-30 14:37:07 +02:00
Emanuel Schmid (fallback wsl) 26229d8fc4 fix: allow content as wire name 2026-09-22 14:40:52 +02:00
Emanuel Schmid (fallback wsl) 3baf799dca feat: Allow element alignment based on ports for x analogue to y 2026-09-18 16:55:37 +02:00
David Stadelmann a839dbb6d2 docs: document per-port shape overrides and rebuild manual
Document the per-port `shape`, `kind`, `size`, `stroke`, and `fill`
overrides in the ports dictionary of `element.elmt`, simplify the
`port-shape` parameter description, and update `port-shape-vertices`
return signature. Also reformat source and examples for consistency
and rebuild manual.pdf.
2026-09-16 10:07:13 +02:00
David Stadelmann 663614056e feat: Expose cardinal sub-anchors on pentagon-shaped ports
Wire up each port as a CeTZ group carrying, in addition to the
default attach anchor, four world-cardinal sub-anchors
`.east`, `.west`, `.north`, `.south` sitting on the pentagon's
bounding-box midpoints. This lets wires attach to any face of a
pentagon port instead of only the default outer/inner point:

  wire.wire("w", ("src", "blk-port-a.east"), ...)

Sub-anchor semantics are world-oriented (not rotated with the shape),
so the tip's cardinal name follows its actual world direction:

  - west  side, kind "in"  → tip = .east    (kind "out" → .west)
  - east  side, kind "in"  → tip = .west    (kind "out" → .east)
  - north side, kind "in"  → tip = .south   (kind "out" → .north)
  - south side, kind "in"  → tip = .north   (kind "out" → .south)

Bounding-box extents depend only on side orientation (horizontal for
east/west, vertical for north/south); `kind` mirrors the shape but
preserves the bbox, so cardinal names stay stable.

Non-pentagon ports degenerate all four sub-anchors to the default
attach point, so user code can address `.east`/`.west`/etc.
uniformly regardless of port shape. The bare port anchor name
(`<elmt-id>-port-<port-id>`) resolves to the same coordinate as
before via a custom "default" anchor, preserving backward
compatibility. Debug mode still marks only the default attach point.

Add `examples.port-shape-attach` demonstrating attaching wires to
the `.west`, `.east` and `.north` faces of a single pentagon port.
2026-09-14 18:22:31 +02:00
David Stadelmann 72eab1620c feat: Add anchor-based zigzag bend placement via zigzag-at
Introduce a new `zigzag-at` parameter on `wire.wire` as an alternative
to `zigzag-ratio` for positioning the middle bend segment of a
`style: "zigzag"` wire. Instead of a fraction of the endpoint span,
the bend is placed on the axis of a referenced anchor via CeTZ's
perpendicular operator (`|-`):

  - zigzag-dir "vertical":   segment sits at anchor.x
  - zigzag-dir "horizontal": segment sits at anchor.y

Accepted forms:
  - str                         — raw CeTZ anchor name (e.g. port anchor
                                  "elmt-port-id" or "elmt.center")
  - array / native CeTZ dict    — used as-is
  - (rel: <length>, to: <coord>) with scalar rel — ergonomic axial
    offset, rewritten to (rel: (dx, 0), to: ...) for vertical dir or
    (rel: (0, dy), to: ...) for horizontal dir

`zigzag-ratio` default flipped from `50%` to `auto` (still resolves
to 50%) to allow detecting an explicit user override. Panics on
misuse:
  - `zigzag-at` set together with an explicit `zigzag-ratio`
  - `zigzag-at` set with a non-zigzag `style`

Add `examples.zigzag-at` demonstrating both the string-anchor and the
ergonomic offset forms.
2026-09-14 17:47:09 +02:00
David Stadelmann fff5b2ecd5 feat: Support cross-element port alignment via align-with
Extend the port `align-with` field to accept references to ports of
other elements, in addition to the existing local-port form:

  - "<local-id>"                    → same-element port (unchanged)
  - (element: "<id>", port: "<id>") → cross-element reference
  - "<raw-anchor>"                  → arbitrary CeTZ anchor name

Cross-element alignment is implemented via CeTZ perpendicular
projection (`|-`) so the port sits on this element's side line while
sharing the remote port's y (east/west) or x (north/south) coordinate.
Local resolution still validates matching axis and detects cycles;
external refs skip axis validation (remote side unknown at eval time)
and require the target element to be drawn first.

Also enrich port-related panic messages with the element id and port
context to make offending figures easier to locate, and add a manual
example (`examples.align-with-ports`) demonstrating both local and
cross-element alignment.
2026-09-14 17:12:57 +02:00
David Stadelmann f7cb457202 fix: Make ports always appear on the highest layer 2026-09-14 15:18:23 +02:00
David Stadelmann fd761487ff feat: Add parameter shell to block (alternative to group)
Defining a block and placing elements into that block has the benefit
that you can use the ports to align the elements within the block to the
ports of that outer ("shell") block
2026-09-14 11:45:55 +02:00
8 changed files with 11632 additions and 8284 deletions

No files matched your search

+439 -170
View File
@@ -23,6 +23,34 @@ wire.stub("block-port-in2", "west")
wire.stub("block-port-out", "east")
```)
#let align-with-ports = example(
```
// `align-with` reuses another port's coord
// (E/W: y, N/S: x). Forms:
// 1. "id" — local
// 2. (element:"e", port:"p") — cross-elmt
// 3. "raw-anchor" — CeTZ anchor
element.block(x: 0, y: 0, w: 2, h: 2, id: "b1",
ports: (
west: ((id: "a", name: "A", y: 30%),),
east: ((id: "oa", name: "A'",
"align-with": "a"),),
))
element.block(x: 4, y: -.3, w: 2, h: 2.6, id: "b2",
ports: (
west: ((id: "ia", name: "A",
"align-with":
(element: "b1", port: "oa")),),
east: ((id: "o", name: "C"),),
))
wire.stub("b1-port-a", "west")
wire.wire("w", ("b1-port-oa", "b2-port-ia"),
style: "direct")
wire.stub("b2-port-o", "east")
```,
vertical: true,
)
#let extender = example(```
element.extender(
x: 0, y: 0, w: 3, h: 1,
@@ -55,23 +83,26 @@ for i in range(3) {
}
```)
#let wires = example(```
for i in range(5) {
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)))
}
wire.wire("w1", ("p0", "p1"), style: "direct")
wire.wire("w2", ("p2", "p3"), style: "zigzag")
wire.wire("w3", ("p4", "p5"), style: "dodge",
dodge-y: -0.5, dodge-margins: (0.5, 0.5))
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)
#let wires = example(
```
for i in range(5) {
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)))
}
wire.wire("w1", ("p0", "p1"), style: "direct")
wire.wire("w2", ("p2", "p3"), style: "zigzag", zigzag-dir: "vertical")
wire.wire("w3", ("p4", "p5"), style: "dodge",
dodge-y: -0.5, dodge-margins: (0.5, 0.5))
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)
```,
vertical: true,
)
#let stub = example(```
draw.circle((0, 0), radius: .1, name: "p")
@@ -109,18 +140,21 @@ for (i, dir) in dirs.enumerate() {
}
```)
#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")
#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)
// 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
@@ -156,85 +190,133 @@ wire.wire("wtap", ("tap-port-tap", "dstU-port-in"),
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")
```, vertical: true)
#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")
```,
vertical: true,
)
#let gate-nand = example(```
gates.gate-nand(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-nand(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-nand = example(
```
gates.gate-nand(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-nand(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-buf = example(```
gates.gate-buf(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-buf(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-buf = example(
```
gates.gate-buf(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-buf(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-not = example(```
gates.gate-not(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-not(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-not = example(
```
gates.gate-not(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-not(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-or = example(```
gates.gate-or(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-or(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-or = example(
```
gates.gate-or(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-or(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-nor = example(```
gates.gate-nor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-nor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-nor = example(
```
gates.gate-nor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-nor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-xor = example(```
gates.gate-xor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-xor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-xor = example(
```
gates.gate-xor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-xor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let gate-xnor = example(```
gates.gate-xnor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-xnor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let gate-xnor = example(
```
gates.gate-xnor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-xnor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-and = example(```
gates.iec-gate-and(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-and(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-and = example(
```
gates.iec-gate-and(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-and(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-nand = example(```
gates.iec-gate-nand(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-nand(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-nand = example(
```
gates.iec-gate-nand(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-nand(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-or = example(```
gates.iec-gate-or(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-or(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-or = example(
```
gates.iec-gate-or(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-or(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-nor = example(```
gates.iec-gate-nor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-nor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-nor = example(
```
gates.iec-gate-nor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-nor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-xor = example(```
gates.iec-gate-xor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-xor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-xor = example(
```
gates.iec-gate-xor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-xor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-xnor = example(```
gates.iec-gate-xnor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-xnor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-xnor = example(
```
gates.iec-gate-xnor(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-xnor(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-buf = example(```
gates.iec-gate-buf(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-buf(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-buf = example(
```
gates.iec-gate-buf(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-buf(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let iec-gate-not = example(```
gates.iec-gate-not(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-not(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```, vertical: true)
#let iec-gate-not = example(
```
gates.iec-gate-not(x: 0, y: 0, w: 1.5, h: 1.5)
gates.iec-gate-not(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
```,
vertical: true,
)
#let group = example(```
element.group(
@@ -263,47 +345,50 @@ wire.wire("w2", ("b2-port-out", "b3-port-in2"),
style: "zigzag")
```)
#let group-ports = example(```
// outside-port-names: true (default) — labels outside the border
element.group(
id: "g1", name: "outside (default)",
stroke: (dash: "dashed"),
fill: util.colors.blue.lighten(60%),
padding: 2em,
outside-port-names: true,
port-name-padding: 4pt,
ports: (
west: ((id: "in", name: "D"),),
east: ((id: "out", name: "Q"),),
),
{ element.block(id: "b1", w: 2, h: 1.5, x: 0, y: 0,
fill: util.colors.green,
ports: (west: ((id: "d", name: "d"),),
east: ((id: "q", name: "q"),))) }
)
wire.wire("wG1in", ("g1-port-in", "b1-port-d"), directed: true)
wire.wire("wG1out", ("b1-port-q", "g1-port-out"), directed: true)
#let group-ports = example(
```
// outside-port-names: true (default) — labels outside the border
element.group(
id: "g1", name: "outside (default)",
stroke: (dash: "dashed"),
fill: util.colors.blue.lighten(60%),
padding: 2em,
outside-port-names: true,
port-name-padding: 4pt,
ports: (
west: ((id: "in", name: "D"),),
east: ((id: "out", name: "Q"),),
),
{ element.block(id: "b1", w: 2, h: 1.5, x: 0, y: 0,
fill: util.colors.green,
ports: (west: ((id: "d", name: "d"),),
east: ((id: "q", name: "q"),))) }
)
wire.wire("wG1in", ("g1-port-in", "b1-port-d"), directed: true)
wire.wire("wG1out", ("b1-port-q", "g1-port-out"), directed: true)
// outside-port-names: false — labels inside the border (block style)
// with extra padding via port-name-padding
element.group(
id: "g2", name: "inside",
stroke: (dash: "dashed"),
fill: util.colors.orange.lighten(60%),
padding: 2em,
outside-port-names: false,
port-name-padding: 0.4em,
ports: (
west: ((id: "in", name: "D"),),
east: ((id: "out", name: "Q"),),
),
{ element.block(id: "b2", w: 2, h: 1.5, x: 6, y: 0,
fill: util.colors.green,
ports: (west: ((id: "d"),), east: ((id: "q"),))) }
// outside-port-names: false — labels inside the border (block style)
// with extra padding via port-name-padding
element.group(
id: "g2", name: "inside",
stroke: (dash: "dashed"),
fill: util.colors.orange.lighten(60%),
padding: 2em,
outside-port-names: false,
port-name-padding: 0.4em,
ports: (
west: ((id: "in", name: "D"),),
east: ((id: "out", name: "Q"),),
),
{ element.block(id: "b2", w: 2, h: 1.5, x: 6, y: 0,
fill: util.colors.green,
ports: (west: ((id: "d"),), east: ((id: "q"),))) }
)
wire.stub("g2-port-in", "west")
wire.stub("g2-port-out", "east")
```,
vertical: true,
)
wire.stub("g2-port-in", "west")
wire.stub("g2-port-out", "east")
```, vertical: true)
#let port-names-outside = example(```
element.block(
@@ -351,51 +436,235 @@ wire.stub("b-port-clk", "north")
wire.stub("b-port-rdy", "south")
```)
#let shell = example(```
// A shell block acts as a container: port name labels are drawn
// outside by default, and wires attach at the *inside* tip of
// pentagon-shaped ports so child elements can sit within.
element.block(
id: "sh", w: 5, h: 3, x: 0, y: 0,
shell: true,
port-shape: "pentagon",
fill: util.colors.blue.lighten(80%),
ports: (
west: ((id: "in", name: "IN"),),
east: ((id: "out", name: "OUT", kind: "out"),),
),
{
element.block(id: "inner", w: 2, h: 1.5, x: 1.5, y: 0.75,
fill: util.colors.yellow,
ports: (west: ((id: "a"),), east: ((id: "b"),)))
}
)
wire.wire("w-in", ("sh-port-in", "inner-port-a"))
wire.wire("w-out", ("inner-port-b", "sh-port-out"))
```)
#let intersection = example(```
wire.wire("w1", ((0, 0), (1, 1)), style: "zigzag")
wire.wire("w1", ((0, 0), (1, 1)), style: "zigzag", zigzag-dir: "vertical")
wire.wire("w2", ((0, 0), (1, -.5)),
style: "zigzag", zigzag-ratio: 80%)
style: "zigzag", zigzag-dir: "vertical", zigzag-ratio: 80%)
wire.intersection("w1.zig")
```)
#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)
#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)
// 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)
// 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)
// 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 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-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,
)
+30
View File
@@ -0,0 +1,30 @@
// Demonstrates the `(from: <anchor>, to: <port-id>)` port-alignment shorthand
// for the `x` parameter (analogous to the existing shorthand for `y`).
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// Anchor block: absolute placement, with a single "addr" port on its
// north side (auto-centred horizontally by circuiteria).
element.block(
x: 0, y: 0, w: 2, h: 1.5,
id: "vcemem",
fill: util.colors.orange,
ports: (north: ((id: "addr"),), east: ((id: "out"),))
)
// This block's "addr" port (south side) is x-aligned with vcemem's "addr"
// port (north side), even though B is wider than vcemem and not
// horizontally aligned by default.
element.block(
x: (from: "vcemem-port-addr", to: "addr"),
y: (rel: 2, to: "vcemem.north"),
w: 4.5, h: 1,
id: "fsm",
fill: util.colors.yellow,
ports: (south: ((id: "addr"),), east: ((id: "out"),))
)
wire.wire("wAddr", ("vcemem-port-addr", "fsm-port-addr"), style: "zigzag")
})
+10254 -7971
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -25,7 +25,13 @@
/// #examples.port-names-outside
/// For other parameters description, see #doc-ref("element.elmt")
/// - radius (number, length, ratio, dictionary): The corner radius of the block. See CeTZ documentation for more information
/// - shell (bool): If true, the block behaves like a shell/container (analogous to a group): port name labels default to being drawn outside the block, and wires attach to the inside tip of pentagon-shaped ports. See #doc-ref("element.elmt")
///
/// Use `shell: true` to wrap child elements inside a bordered container without
/// having ports overlap the interior:
/// #examples.shell
#let block(
..args,
x: none,
y: none,
w: none,
@@ -35,7 +41,7 @@
ports: (),
ports-margins: (),
port-name-padding: 2pt,
port-names-outside: false,
port-names-outside: auto,
port-shape: none,
port-kind: "in",
port-size: 1em,
@@ -43,11 +49,13 @@
radius: 0.5em,
stroke: black + 1pt,
id: "",
shell: false,
debug: (
ports: false
)
) = element.elmt(
draw-shape: draw-shape.with(radius: radius),
body: if args.pos().len() > 0 { args.pos().first() } else { none },
x: x,
y: y,
w: w,
@@ -64,5 +72,6 @@
fill: fill,
stroke: stroke,
id: id,
shell: shell,
debug: debug
)
+75 -17
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw, coordinate
#import "ports.typ": add-ports, add-port
#import "/src/cetz.typ": coordinate, draw
#import "ports.typ": add-port, add-ports
#import "../util.typ"
#let find-port(ports, id) = {
@@ -21,7 +21,9 @@
/// - draw-shape (function): Draw function
/// - x (number, dictionary): The x position (bottom-left corner).
///
/// 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
/// If it is a dictionary, it can take one of two forms:
/// - `(rel: number, to: str)`: `rel` is the x offset and `to` the base anchor
/// - `(from: str, to: str)`: `from` is the base anchor and `to` is the id of the port to align with the anchor (analogous to `y`)
/// - y (number, dictionary): The y position (bottom-left corner).
///
/// If it is a dictionary, it can take one of two forms:
@@ -36,17 +38,27 @@
/// - `name` (`str`): Optional name displayed *in* the block
/// - `clock` (`bool`): Whether it is a clock port (triangle symbol)
/// - `vertical` (`bool`): Whether the name should be drawn vertically
/// - `align-with` (`str`, `dictionary`): Optional. Reuse another port's placement coordinate (east/west: y, north/south: x). Accepted forms: `"<local-id>"` (same-element port; axis must match, transitively resolved, cycles panic), `(element: "<id>", port: "<id>")` (cross-element; axis not validated; target element must be drawn first), or `"<raw-anchor>"` (any unrecognised string is used as a CeTZ anchor name). See #examples.align-with-ports
/// - `x` (length, ratio, function): Optional. North/south ports only. Explicit x-offset override measured from the left corner of the side. A `ratio` is used as a percentage along the side; a length/number is used as an absolute offset; a function is called with the element width and must return a length/number. Panics if used together with `align-with` or on an east/west port.
/// - `y` (length, ratio, function): Optional. East/west ports only. Analogous to `x`, measured from the top corner of the side and called with the element height when a function is passed.
/// - `shape` (`none`, `str`): Optional. Per-port override of the element-level `port-shape` (e.g. `"pentagon"`). Lets a single port on a block or group opt into (or out of) a shape independently of the element default. See the `port-shape` parameter below.
/// - `kind` (`str`): Optional. Per-port override of the element-level `port-kind` (`"in"` / `"out"`). Affects pentagon orientation.
/// - `size` (length): Optional. Per-port override of the element-level `port-size` (unit size of pentagon-shaped ports).
/// - `stroke` (stroke): Optional. Per-port override of the pentagon stroke. Defaults to the element stroke.
/// - `fill` (none, color): Optional. Per-port override of the pentagon fill. Defaults to the element fill (or `gray` for clock ports with `shape: "pentagon"`).
/// - ports-margins (dictionary): Dictionary of ports margins (used with automatic port placement). They keys are cardinal directions ("north", "east", "south", "west"). The values are tuples of (`<start>`, `<end>`) margins (numbers)
/// - port-name-padding (number, length): Padding between the port name label and the port anchor point (default `2pt`)
/// - port-names-outside (bool): If true, port name labels are drawn on the outside of the block instead of the inside (default `false`)
/// - port-shape (none, str): Default port shape for all ports of this element. Set to `"pentagon"` to draw pentagon-shaped ports. Overridable per port via the port's `shape` field. See #examples.port-shape
/// - port-names-outside (bool, auto): If true, port name labels are drawn on the outside of the block instead of the inside. When `auto` (default), follows the `shell` setting (outside if `shell: true`, inside otherwise)
/// - port-shape (none, str): Default port shape for all ports of this element. Set to `"pentagon"` to draw pentagon-shaped ports. Overridable per port via the port's `shape` field (see the ports dictionary above); a per-port `shape: none` also removes the shape for just that port. See #examples.port-shape
/// - port-kind (str): Default direction of ports (`"in"` or `"out"`). Overridable per port via the port's `kind` field
/// - port-size (length): Default unit size of pentagon-shaped ports (default `1em`). Overridable per port via the port's `size` field
/// - fill (none, color): Fill color
/// - stroke (stroke): Border stroke
/// - id (str): The block id (for future reference)
/// - auto-ports (bool): Whether to use auto port placements or not. If false, `draw-shape` is responsible for adding the appropiate ports
/// - ports-x (dictionary): Dictionary of the ports x offsets (used with `auto-ports: false`)
/// - ports-y (dictionary): Dictionary of the ports y offsets (used with `auto-ports: false`)
/// - shell (bool): If true, the block behaves like a shell/container (analogous to a group): port name labels default to being drawn *outside* the block border, and wires attach to the *inside* tip of pentagon-shaped ports instead of the outside base. Useful for wrapping child elements without having ports overlap them (default `false`)
/// - debug (dictionary): Dictionary of debug options.
///
/// Supported fields include:
@@ -62,7 +74,7 @@
ports: (:),
ports-margins: (:),
port-name-padding: 2pt,
port-names-outside: false,
port-names-outside: auto,
port-shape: none,
port-kind: "in",
port-size: 1em,
@@ -70,10 +82,13 @@
stroke: black + 1pt,
id: "",
auto-ports: true,
ports-x: (:),
ports-y: (:),
shell: false,
body: none,
debug: (
ports: false
)
ports: false,
),
) = draw.get-ctx(ctx => {
let width = w
let height = h
@@ -85,10 +100,42 @@
if w == none { panic("Parameter w must be set") }
if h == none { panic("Parameter h must be set") }
if (type(x) == dictionary) {
if (type(x) == dictionary and "rel" in x) {
x = util.resolve-rel-coord(ctx, x, 0)
} else if (type(x) == dictionary) {
let from = x.from
let to = x.to
let (to-side, i) = find-port(ports, to)
let margins = (0%, 0%)
if to-side in ports-margins {
margins = ports-margins.at(to-side)
}
let dx
let left-margin
if to-side in ("north", "south") {
let used-pct = 100% - margins.at(0) - margins.at(1)
let used-width = width * used-pct / 100%
left-margin = width * margins.at(0) / 100%
dx = used-width * (i + 1) / (ports.at(to-side).len() + 1)
if not auto-ports {
left-margin = 0
dx = ports-x.at(to)(width)
}
} else if to-side == "west" {
dx = 0
left-margin = 0
} else if to-side == "east" {
dx = width
left-margin = 0
}
let (ctx, from-pos) = coordinate.resolve(ctx, from)
x = from-pos.at(0) + dx - width + left-margin
}
if (type(y) == dictionary and "rel" in y) {
y = util.resolve-rel-coord(ctx, y, 1)
} else if (type(y) == dictionary) {
@@ -106,7 +153,7 @@
let used-pct = 100% - margins.at(0) - margins.at(1)
let used-height = height * used-pct / 100%
top-margin = height * margins.at(0) / 100%
dy = used-height * (i + 1) / (ports.at(to-side).len() + 1)
if not auto-ports {
@@ -120,7 +167,7 @@
dy = height
top-margin = 0
}
let (ctx, from-pos) = coordinate.resolve(ctx, from)
y = from-pos.at(1) + dy - height + top-margin
}
@@ -138,26 +185,37 @@
if (name != none) {
draw.content(
(name: id, anchor: name-anchor),
anchor: if name-anchor in util.valid-anchors {name-anchor} else {"center"},
anchor: if name-anchor in util.valid-anchors { name-anchor } else { "center" },
padding: 0.5em,
align(center)[*#name*]
align(center)[*#name*],
)
}
if auto-ports {
let outside-name = if port-names-outside == auto { shell } else { port-names-outside }
add-ports(
id,
tl, tr, br, bl,
tl,
tr,
br,
bl,
ports,
ports-margins,
width: width,
height: height,
debug: debug.ports,
name-padding: port-name-padding,
outside-name: port-names-outside,
outside-name: outside-name,
port-shape: port-shape,
port-kind: port-kind,
port-size: port-size,
elmt-stroke: stroke,
elmt-fill: fill,
wire-inside: shell,
)
}
})
if body != none {
body
}
})
+328 -65
View File
@@ -1,8 +1,11 @@
#import "/src/cetz.typ": draw
#import "../util.typ": rotate-anchor, opposite-anchor, port-shape-vertices
#import "../util.typ": opposite-anchor, port-shape-vertices, rotate-anchor
#let add-port(
elmt-id, side, port, pos,
elmt-id,
side,
port,
pos,
prev: none,
next: none,
debug: false,
@@ -13,6 +16,7 @@
port-size: 1em,
elmt-stroke: black + 1pt,
elmt-fill: none,
wire-inside: false,
) = {
let name = port.at("name", default: "")
let name-rotate = port.at("vertical", default: false)
@@ -20,14 +24,15 @@
// Per-port overrides fall back to element-level defaults.
let shape = port.at("shape", default: port-shape)
let kind = port.at("kind", default: port-kind)
let size = port.at("size", default: port-size)
let kind = port.at("kind", default: port-kind)
let size = port.at("size", default: port-size)
let shape-stroke = port.at("stroke", default: elmt-stroke)
// Pentagon fill defaults to the element's fill (so the pentagon covers the
// block-border segment it crosses). Clock ports default to gray. Per-port
// `fill` overrides both.
let shape-fill = port.at("fill",
default: if is-clock and shape == "pentagon" { gray } else { elmt-fill })
let shape-fill = port.at("fill", default: if is-clock and shape == "pentagon" { gray } else {
elmt-fill
})
// When shape is enabled, wire-connection anchor moves outside the block, and
// inside-name labels reference the inner tip/base; otherwise both collapse to
@@ -38,12 +43,18 @@
if shape == "pentagon" {
let sh = port-shape-vertices(side, kind, size)
let v = sh.vertices.map(off => (rel: off, to: pos))
draw.line(
v.at(0), v.at(1), v.at(2), v.at(3), v.at(4),
// Draw pentagon on a higher layer so it always renders on top of wires
// and neighbouring elements.
draw.on-layer(1, draw.line(
v.at(0),
v.at(1),
v.at(2),
v.at(3),
v.at(4),
close: true,
stroke: shape-stroke,
fill: shape-fill,
)
))
outer-pos = (rel: sh.outer, to: pos)
inner-pos = (rel: sh.inner, to: pos)
} else if is-clock {
@@ -51,12 +62,13 @@
panic("Clock port must have previous and next positions")
}
let csize = if port.at("small", default: false) {8pt} else {1em}
let csize = if port.at("small", default: false) { 8pt } else { 1em }
let offset
if (side == "north") { offset = ( 0, -csize) }
else if (side == "east") { offset = (-csize, 0) }
else if (side == "south") { offset = ( 0, csize) }
else if (side == "west") { offset = ( csize, 0) }
if (side == "north") { offset = (0, -csize) } else if (side == "east") {
offset = (-csize, 0)
} else if (side == "south") { offset = (0, csize) } else if (side == "west") {
offset = (csize, 0)
}
let pos1 = (rel: offset, to: pos)
@@ -69,8 +81,7 @@
// Horizontal labels: anchor = opposite of the side (e.g. east port → west-anchored text, outside).
// Vertical labels: same opposite side but rotated 90°, keeping text readable.
let name-anchor = if outside-name {
if name-rotate { rotate-anchor(opposite-anchor(side)) }
else { opposite-anchor(side) }
if name-rotate { rotate-anchor(opposite-anchor(side)) } else { opposite-anchor(side) }
} else {
if name-rotate { rotate-anchor(side) } else { side }
}
@@ -80,39 +91,214 @@
// (or from the boundary when shape is disabled).
let label-pos = if outside-name { outer-pos } else { inner-pos }
// For "shell" containers, wires must attach at the inside of the pentagon
// (the tip that points inward), not at the exterior base.
let anchor-pos = if wire-inside and shape == "pentagon" { inner-pos } else { outer-pos }
draw.content(
label-pos,
anchor: name-anchor,
padding: name-padding,
angle: if name-rotate {90deg} else {0deg},
name
angle: if name-rotate { 90deg } else { 0deg },
name,
)
let id = elmt-id + "-port-" + port.at("id")
// Cardinal sub-anchor positions.
// - Pentagon ports: the four world-cardinal midpoints of the pentagon's
// bounding box (all lie on the pentagon boundary).
// - Non-pentagon ports: all four collapse to the default attach point.
let sub-east = anchor-pos
let sub-west = anchor-pos
let sub-north = anchor-pos
let sub-south = anchor-pos
if shape == "pentagon" {
let sh = port-shape-vertices(side, kind, size)
sub-east = (rel: sh.east, to: pos)
sub-west = (rel: sh.west, to: pos)
sub-north = (rel: sh.north, to: pos)
sub-south = (rel: sh.south, to: pos)
}
// Expose the port as a CeTZ group carrying the default attach point plus
// the four cardinal sub-anchors (`.east`, `.west`, `.north`, `.south`).
// The custom anchors override CeTZ's auto-generated bbox anchors of the
// same name.
draw.group(name: id, {
draw.anchor("default", anchor-pos)
draw.anchor("east", sub-east)
draw.anchor("west", sub-west)
draw.anchor("north", sub-north)
draw.anchor("south", sub-south)
})
if debug {
draw.circle(
outer-pos,
name: id,
anchor-pos,
radius: .1,
stroke: none,
fill: red
fill: red,
)
} else {
draw.hide(draw.circle(
outer-pos,
radius: 0,
stroke: none,
name: id
))
}
}
// Normalize an `align-with` value to either:
// - `(local: <id>)` → reference to another port of the *same* element
// - `(external: <anchor-name>)` → CeTZ anchor name of a port in another element
// Accepted input forms:
// - `str` matching a local port id → local
// - `str` not matching any local port id → external, used as-is as CeTZ anchor
// - `(element: <str>, port: <str>)` → external, anchor = `<element>-port-<port>`
#let classify-align-with(raw-val, placements) = {
if type(raw-val) == dictionary {
if "element" in raw-val and "port" in raw-val {
return (external: raw-val.element + "-port-" + raw-val.port)
}
panic("`align-with` dictionary must contain `element` and `port` keys, got: " + repr(raw-val))
}
if type(raw-val) != str {
panic("`align-with` must be a string or `(element:, port:)` dictionary, got: " + repr(raw-val))
}
if raw-val in placements {
return (local: raw-val)
}
return (external: raw-val)
}
// Resolve a single port's placement, following `align-with` references
// transitively and detecting cycles.
//
// Returns `(placements, result)` where `result` is either:
// - `(pct: <ratio>)` → percentage along the side (local placement)
// - `(external: <anchor-name>)` → align with the given remote CeTZ anchor
#let resolve-placement(placements, id, visiting, elmt-id: "") = {
let where = if elmt-id != "" { " (element '" + elmt-id + "')" } else { "" }
let p = placements.at(id)
if p.at("resolved", default: false) {
if "external-ref" in p {
return (placements, (external: p.external-ref))
}
return (placements, (pct: p.pct))
}
if id in visiting {
panic(
"align-with cycle involving port '"
+ id
+ "'"
+ where
+ " (path: "
+ visiting.join(" -> ")
+ " -> "
+ id
+ ")",
)
}
let raw = p.raw
let side = p.side
let is-ew = side in ("east", "west")
let has-align = "align-with" in raw
let has-x = "x" in raw
let has-y = "y" in raw
let has-num = has-x or has-y
if has-align and has-num {
panic("Port '" + id + "'" + where + ": cannot combine `align-with` with `x`/`y` override")
}
if is-ew and has-x {
panic(
"Port '"
+ id
+ "'"
+ where
+ " on side '"
+ side
+ "': use `y` for placement override on east/west sides, not `x`",
)
}
if (not is-ew) and has-y {
panic(
"Port '"
+ id
+ "'"
+ where
+ " on side '"
+ side
+ "': use `x` for placement override on north/south sides, not `y`",
)
}
let result = none
if has-num {
let val = if is-ew { raw.y } else { raw.x }
let dim = if is-ew { p.height } else { p.width }
if type(val) == function {
val = val(dim)
}
let pct
if type(val) == ratio {
pct = val
} else {
// Numeric length: measured from `pt0` along the side.
pct = (val / dim) * 100%
}
result = (pct: pct)
} else if has-align {
let classified = classify-align-with(raw.at("align-with"), placements)
if "external" in classified {
// Cross-element / raw anchor reference: axis cannot be validated here
// (remote side unknown at eval time). Propagate as external.
result = (external: classified.external)
} else {
let ref-id = classified.local
let ref-side = placements.at(ref-id).side
let ref-ew = ref-side in ("east", "west")
if is-ew != ref-ew {
panic(
"Port '"
+ id
+ "'"
+ where
+ " on '"
+ side
+ "' cannot `align-with` port '"
+ ref-id
+ "' on '"
+ ref-side
+ "': incompatible axis",
)
}
let new-visiting = visiting + (id,)
let res = resolve-placement(placements, ref-id, new-visiting, elmt-id: elmt-id)
placements = res.at(0)
result = res.at(1)
}
} else {
result = (pct: p.pct-auto)
}
if "external" in result {
p.insert("external-ref", result.external)
p.insert("pct", none)
} else {
p.insert("pct", result.pct)
}
p.insert("resolved", true)
placements.insert(id, p)
return (placements, result)
}
#let add-ports(
elmt-id,
tl, tr, br, bl,
tl,
tr,
br,
bl,
ports,
ports-margins,
width: 0,
height: 0,
debug: false,
outside-name: false,
name-padding: 2pt,
@@ -121,63 +307,140 @@
port-size: 1em,
elmt-stroke: black + 1pt,
elmt-fill: none,
wire-inside: false,
) = {
let sides = (
"north": (tl, tr),
"east": (tr, br),
"south": (bl, br),
"west": (tl, bl)
"west": (tl, bl),
)
if type(ports) != dictionary {
return
}
// ---- Phase 1: build placement table with per-port baseline pct. ----
let placements = (:)
// Preserve iteration order for phase 3.
let order = ()
// Per-side (pt0, pt1) after margin adjustment, and per-side spacing.
let side-info = (:)
for (side, props) in sides {
let side-ports = ports.at(side, default: ())
let pt0 = props.at(0)
let pt1 = props.at(1)
if side in ports-margins {
let margins = ports-margins.at(side)
let a = (pt0, margins.at(0), pt1)
let b = (pt0, 100% - margins.at(1), pt1)
pt0 = a
pt1 = b
}
let space = 100% / (side-ports.len() + 1)
side-info.insert(side, (pt0: pt0, pt1: pt1, space: space))
for (i, port) in side-ports.enumerate() {
let pct = (i + 1) * space
let pt0 = props.at(0)
let pt1 = props.at(1)
if side in ports-margins {
let (a, b) = (pt0, pt1)
let margins = ports-margins.at(side)
a = (pt0, margins.at(0), pt1)
b = (pt0, 100% - margins.at(1), pt1)
pt0 = a
pt1 = b
let pid = port.at("id")
if pid in placements {
panic(
"Duplicate port id '"
+ pid
+ "' in element '"
+ elmt-id
+ "' on side '"
+ side
+ "' (existing side: '"
+ placements.at(pid).side
+ "', new port: "
+ repr(port)
+ ")",
)
}
let pos = (pt0, pct, pt1)
let pct-prev = (i + 0.5) * space
let pct-next = (i + 1.5) * space
let pos-prev = (pt0, pct-prev, pt1)
let pos-next = (pt0, pct-next, pt1)
let pct-auto = (i + 1) * space
placements.insert(pid, (
side: side,
raw: port,
index: i,
pct-auto: pct-auto,
width: width,
height: height,
resolved: false,
))
order.push(pid)
}
}
// ---- Phase 2: resolve overrides & align-with references. ----
for pid in order {
let res = resolve-placement(placements, pid, (), elmt-id: elmt-id)
placements = res.at(0)
}
// ---- Phase 3: draw. ----
for pid in order {
let p = placements.at(pid)
let side = p.side
let info = side-info.at(side)
let pt0 = info.pt0
let pt1 = info.pt1
let space = info.space
let port = p.raw
let is-ew = side in ("east", "west")
let pos
let pos-prev
let pos-next
if "external-ref" in p {
// Cross-element alignment: build pos via CeTZ perpendicular so this
// port shares one coordinate with the remote anchor and stays on this
// element's side line for the other coordinate.
// east/west: same y as remote, x from this side (pt0.x == pt1.x)
// north/south: same x as remote, y from this side (pt0.y == pt1.y)
let ref = p.external-ref
if is-ew {
pos = (pt0, "|-", ref)
// Fallback prev/next: just the pos itself; clock ports do not
// combine meaningfully with cross-element align.
pos-prev = pos
pos-next = pos
} else {
pos = (ref, "|-", pt0)
pos-prev = pos
pos-next = pos
}
} else {
let pct = p.pct
pos = (pt0, pct, pt1)
pos-prev = (pt0, pct - 0.5 * space, pt1)
pos-next = (pt0, pct + 0.5 * space, pt1)
if port.at("small", default: false) {
pos-prev = (pos, 4pt, pt0)
pos-next = (pos, 4pt, pt1)
}
add-port(
elmt-id,
side,
port,
pos,
prev: pos-prev,
next: pos-next,
debug: debug,
outside-name: outside-name,
name-padding: name-padding,
port-shape: port-shape,
port-kind: port-kind,
port-size: port-size,
elmt-stroke: elmt-stroke,
elmt-fill: elmt-fill,
)
}
add-port(
elmt-id,
side,
port,
pos,
prev: pos-prev,
next: pos-next,
debug: debug,
outside-name: outside-name,
name-padding: name-padding,
port-shape: port-shape,
port-kind: port-kind,
port-size: port-size,
elmt-stroke: elmt-stroke,
elmt-fill: elmt-fill,
wire-inside: wire-inside,
)
}
}
+64 -25
View File
@@ -36,7 +36,7 @@
green: rgb(127, 200, 172),
pink: rgb(236, 127, 178),
purple: rgb(189, 151, 255),
blue: rgb(127, 203, 235)
blue: rgb(127, 203, 235),
)
/// Pads a string on the left with 0s to the given length
@@ -67,7 +67,7 @@
north-west: "south-east",
north-east: "south-west",
south-east: "north-west",
south-west: "north-east"
south-west: "north-east",
).at(anchor)
}
@@ -86,7 +86,7 @@
north-west: "north-east",
north-east: "south-east",
south-east: "south-west",
south-west: "north-west"
south-west: "north-west",
).at(anchor)
}
@@ -105,11 +105,11 @@
// Base case: west port, input (tip east / into block).
// Vertices as (dx, dy) offsets relative to the boundary anchor.
let base-vertices = (
(-u, -u/2),
(-u, +u/2),
(u/2, +u/2),
(u, 0), // tip east (inner side)
(u/2, -u/2),
(-u, -u / 2),
(-u, +u / 2),
(u / 2, +u / 2),
(u, 0), // tip east (inner side)
(u / 2, -u / 2),
)
// For output, mirror horizontally so the tip points west (outside).
if kind == "out" {
@@ -126,22 +126,58 @@
// north: (x, y) → (y, -x)
// east: (x, y) → (-x, -y)
// south: (x, y) → (-y, x)
let rot = if side == "west" { p => p }
else if side == "north" { p => (p.at(1), -p.at(0)) }
else if side == "east" { p => (-p.at(0), -p.at(1)) }
else if side == "south" { p => (-p.at(1), p.at(0)) }
else { panic("Unknown side: " + side) }
let rot = if side == "west" { p => p } else if side == "north" {
p => (p.at(1), -p.at(0))
} else if side == "east" { p => (-p.at(0), -p.at(1)) } else if side == "south" {
p => (-p.at(1), p.at(0))
} else { panic("Unknown side: " + side) }
// World-cardinal midpoints of the pentagon's bounding box. These are
// named in *world* coordinates (not rotated with the shape), so `.east`
// always sits at the world +x extent of the pentagon, `.north` at +y, etc.
// Consequently the tip's cardinal name follows its world direction:
// - west side, kind "in" → tip world +x → tip = .east
// - west side, kind "out" → tip world -x → tip = .west
// - east side, kind "in" → tip world -x → tip = .west
// - east side, kind "out" → tip world +x → tip = .east
// - north side, kind "in" → tip world -y → tip = .south
// - north side, kind "out" → tip world +y → tip = .north
// - south side, kind "in" → tip world +y → tip = .north
// - south side, kind "out" → tip world -y → tip = .south
//
// The pentagon bounding box extents depend only on the side's orientation
// (horizontal for west/east, vertical for north/south), not on `kind`.
let (hx, hy) = if side == "west" or side == "east" {
(u, u / 2)
} else {
(u / 2, u)
}
let world-east = (hx, 0)
let world-west = (-hx, 0)
let world-north = (0, hy)
let world-south = (0, -hy)
return (
vertices: base-vertices.map(rot),
outer: rot(base-outer),
inner: rot(base-inner),
east: world-east,
west: world-west,
north: world-north,
south: world-south,
)
}
#let valid-anchors = (
"center", "north", "east", "west", "south",
"north-east", "north-west", "south-east", "south-west"
"center",
"north",
"east",
"west",
"south",
"north-east",
"north-west",
"south-east",
"south-west",
)
/// Normalizes a coordinate so that 2D points (x, y) are padded
@@ -162,16 +198,19 @@
// Function-based coordinate: (fn, point)
if type(a) == function {
let orig = a
return (v => {
let result = orig(v)
if type(result) == array and result.len() == 2 {
let (rx, ry) = result
if type(rx) in (int, float) and type(ry) in (int, float) {
return (rx, ry, 0)
return (
v => {
let result = orig(v)
if type(result) == array and result.len() == 2 {
let (rx, ry) = result
if type(rx) in (int, float) and type(ry) in (int, float) {
return (rx, ry, 0)
}
}
}
return result
}, b)
return result
},
b,
)
}
}
}
@@ -183,4 +222,4 @@
return result
}
return coord
}
}
+432 -35
View File
@@ -6,7 +6,22 @@
///
/// The `"u-shape"` style connects two ports that face the same cardinal side.
/// #examples.u-shape-wire
#let wire-styles = ("direct", "zigzag", "dodge", "guided", "u-shape")
///
/// 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 signal-width = 1pt
#let bus-width = 1.5pt
@@ -27,29 +42,95 @@
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)
}
#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,
@@ -142,7 +223,7 @@
}
}
#let get-u-shape-wire(pts, u-depth, u-side, ctx) = {
#let get-u-shape-wire(pts, u-depth, u-side, u-at, ctx) = {
let start = pts.first()
let end = pts.last()
@@ -176,22 +257,33 @@
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 = calc.max(sx, ex) + u-depth
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 = calc.min(sx, ex) - u-depth
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 = calc.max(sy, ey) + u-depth
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 = calc.min(sy, ey) - u-depth
let by = if bar-at != none { bar-at } else { calc.min(sy, ey) - u-depth }
p1 = (sx, by, 0)
p2 = (ex, by, 0)
}
@@ -206,6 +298,210 @@
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 = _u-detect-side(start, ctx)
let side-end = _u-detect-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()
@@ -301,8 +597,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-dir (str): The zigzag's direction. As either "vertical" or "horizontal" (only with dstyle "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 (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"`. Auto-detection panics if the two endpoints resolve to conflicting directions, or if neither endpoint is a port anchor.
/// - 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")
@@ -310,7 +607,16 @@
/// - 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 (number): Distance between the outermost endpoint (in the `u-side` direction) and the U-bend line (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,
@@ -326,13 +632,23 @@
guided-sides: ("east", "west"),
directed: false,
rotate-name: true,
zigzag-ratio: 50%,
zigzag-dir: "vertical",
zigzag-ratio: auto,
zigzag-dir: auto,
zigzag-at: none,
dodge-y: 0,
dodge-sides: ("east", "west"),
dodge-margins: (5%, 5%),
u-side: auto,
u-depth: 0.5,
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,
) = draw.get-ctx(ctx => {
if not style in wire-styles {
panic("Invalid wire style '" + style + "'")
@@ -342,6 +658,52 @@
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)
@@ -360,7 +722,28 @@
(points, anchors) = get-direct-wire(pts)
} else if style == "zigzag" {
(points, anchors) = get-zigzag-wire(pts, zigzag-ratio, zigzag-dir)
let zz-dir = zigzag-dir
if zz-dir == auto {
let side-start = _u-detect-side(pts.first(), ctx)
let side-end = _u-detect-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)
if d-start != none and d-end != none and d-start != d-end {
panic("zigzag: start port faces '" + side-start
+ "' but end port faces '" + side-end
+ "'; cannot auto-detect `zigzag-dir`. Set it explicitly.")
}
if d-start != none { zz-dir = d-start }
else if d-end != none { zz-dir = d-end }
else { panic("zigzag: could not auto-detect `zigzag-dir` (no port anchor on either endpoint). Set it explicitly.") }
}
(points, anchors) = get-zigzag-wire(
pts,
zigzag-ratio-effective,
zz-dir,
at: normalize-zigzag-at(zigzag-at, zz-dir),
)
} else if style == "dodge" {
(points, anchors) = get-dodge-wire(
@@ -381,10 +764,24 @@
} else if style == "u-shape" {
(points, anchors) = get-u-shape-wire(
pts,
u-depth,
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)
@@ -422,7 +819,7 @@
if name != none {
let names = ()
if type(name) == str {
if type(name) == str or type(name) == content {
names = ((name, name-pos),)
} else if type(name) == array {
@@ -517,7 +914,7 @@
/// 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 #doc-ref("wire.wire") with
/// `style: "u-shape"` can auto-detect the port side.
/// `style: "u-shape"` (or `"zigzag"`) can auto-detect the port side.
///
/// #examples.breakout
///