Compare commits

..
Author SHA1 Message Date
David Stadelmann beda61b9f4 feat: auto-detect zigzag-dir from port anchors
When `zigzag-dir` is `auto` (the new default), deduce it from the
endpoint port anchors using `detect-port-side`. East/west ports yield
"vertical" and north/south ports yield "horizontal". If the direction
cannot be deduced (no port anchor on either endpoint, or conflicting
directions) it falls back to "vertical", the previous default.
2026-10-22 00:32:01 +02:00
David Stadelmann 2c6e5f44df feat: add anchor-based zigzag bend placement via zigzag-at
Add the `zigzag-at` parameter to place the middle bend of a zigzag wire
on the axis of a referenced anchor. It overrides `zigzag-ratio`, which
now defaults to `auto` (resolving to 50%) so the two are mutually
exclusive. Accepted forms are an anchor name, any CeTZ coordinate, or
the scalar offset `(rel: <length>, to: <coord>)`.

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

Add the `zigzag-at` example.
2026-10-22 00:31:52 +02:00
David Stadelmann df4c956a0b chore: update minimum compiler version to 0.15.0 2026-10-21 23:39:59 +02:00
David Stadelmann ac087087c5 deps: update tidy to 0.4.3 2026-10-21 23:39:59 +02:00
David Stadelmann 77eb4fa946 deps: update CeTZ 0.3.4 to 0.5.2 with coordinate fixes
CeTZ 0.5.2 requires 3D coordinate vectors (x, y, z) for raw
coordinate tuples passed to coordinate.resolve(). Update internal
wire computations (dodge, guided) to use 3-element tuples, fix
.last() to .at(1) for y-component access on resolved coordinates,
and add a normalize-coord utility that automatically pads user-
provided 2D points (x, y) to (x, y, 0) so users do not need to
specify the z dimension.
2026-10-21 23:39:59 +02:00
HEL c9aa81af2e Merge pull request 'Release v0.2.1' (#25) from dev into main
Reviewed-on: HEL/circuiteria#25
2026-07-03 23:37:38 +00:00
HEL 6871053810 Merge pull request 'Release v0.2.1' (#24) from release/0.2.1 into dev
Reviewed-on: HEL/circuiteria#24
2026-07-03 23:36:46 +00:00
HEL 73b7348eed chore: remove duplicate alt text 2026-07-04 01:35:35 +02:00
HEL b71e822bb8 chore: use preview import paths in examples 2026-06-30 21:40:09 +02:00
HEL 4c1025b1eb chore: remove "Typst" from description 2026-06-30 21:31:55 +02:00
HEL b35c04a7d9 chore: add alt text on README images 2026-06-30 21:29:01 +02:00
HEL 0612e9b6ec chore: bump to 0.2.1 2026-06-30 21:13:41 +02:00
26 changed files with 255 additions and 59 deletions

No files matched your search

+7 -7
View File
@@ -3,7 +3,7 @@
Circuiteria is a [Typst](https://typst.app) package for drawing block circuit diagrams using the [CeTZ](https://typst.app/universe/package/cetz) package.
<p align="center">
<img src="./gallery/platypus.png" alt="Perry the platypus">
<img src="./gallery/platypus.png" alt="A platypus / Perry the Platypus meme made with colored rounded rectangles">
</p>
## Examples
@@ -11,17 +11,17 @@ Circuiteria is a [Typst](https://typst.app) package for drawing block circuit di
<tr>
<td colspan="2">
<a href="./gallery/test.typ">
<img src="./gallery/test.png" width="500px">
<img src="./gallery/test.png" width="500px" alt="Block diagram of a multi-cycle RISC-V FPGA processor">
</a>
</td>
</tr>
<tr>
<td colspan="2">A bit of eveything</td>
<td colspan="2">A bit of everything</td>
</tr>
<tr>
<td colspan="2">
<a href="./gallery/test5.typ">
<img src="./gallery/test5.png" width="500px">
<img src="./gallery/test5.png" width="500px" alt="Block diagram of a single-cycle RISC-V processor">
</a>
</td>
</tr>
@@ -31,12 +31,12 @@ Circuiteria is a [Typst](https://typst.app) package for drawing block circuit di
<tr>
<td>
<a href="./gallery/test4.typ">
<img src="./gallery/test4.png" width="250px">
<img src="./gallery/test4.png" width="250px" alt="Top-level block diagram of a single-cycle RISC-V processor">
</a>
</td>
<td>
<a href="./gallery/test6.typ">
<img src="./gallery/test6.png" width="250px">
<img src="./gallery/test6.png" width="250px" alt="Block diagram of a simple single-cycle RISC-V ALU">
</a>
</td>
</tr>
@@ -56,7 +56,7 @@ For more information, see the [manual](manual.pdf)
To use this package, simply import [circuiteria](https://typst.app/universe/package/circuiteria) and call the `circuit` function:
```typ
#import "@preview/circuiteria:0.2.0"
#import "@preview/circuiteria:0.2.1"
#circuiteria.circuit({
import circuiteria: *
...
+24
View File
@@ -71,6 +71,30 @@ wire.wire("w4", ("p6","p7"), style: "guided",
```, vertical: true)
#let zigzag-at = example(
```
// `zigzag-at` places the middle bend of a zigzag wire
// on the axis of a referenced anchor, overriding
// `zigzag-ratio`.
element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "src",
ports: (east: ((id: "o", name: "O"),),))
element.block(x: 3, y: -1.2, w: 1, h: 1, id: "ref",
fill: util.colors.pink,
ports: (west: ((id: "p", name: "R"),),))
element.block(x: 5.5, y: -2, w: 1.5, h: 1.5, id: "dst",
ports: (west: ((id: "i", name: "I"),),))
// String form: anchor name of a port.
wire.wire("w1", ("src-port-o", "dst-port-i"),
style: "zigzag",
zigzag-at: "ref-port-p")
// Ergonomic offset: shift 8pt right of ref.east.
wire.wire("w2", ("src-port-o", "dst-port-i"),
style: "zigzag",
zigzag-at: (rel: 8pt, to: "ref.east"))
```,
vertical: true,
)
#let stub = example(```
draw.circle((0, 0), radius: .1, name: "p")
wire.stub("p", "north", name: "north", length: 1)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
#import "../src/lib.typ": *
#import "@preview/circuiteria:0.2.1": *
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
#import "../src/lib.typ": circuit, element, util, wire
#import "@preview/circuiteria:0.2.1": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
#import "../src/lib.typ": circuit, element, util, wire
#import "@preview/circuiteria:0.2.1": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
#import "../src/lib.typ": circuit, element, util, wire
#import "@preview/circuiteria:0.2.1": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw
#import "../src/lib.typ": *
#import "@preview/circuiteria:0.2.1": *
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 93 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw
#import "../src/lib.typ": *
#import "@preview/circuiteria:0.2.1": *
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw
#import "../src/lib.typ": *
#import "@preview/circuiteria:0.2.1": *
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
#import "../src/lib.typ": circuit, element, util, wire
#import "@preview/circuiteria:0.2.1": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1,4 +1,4 @@
#import "@preview/tidy:0.4.1"
#import "@preview/tidy:0.4.3"
#import "/src/cetz.typ": draw, canvas
#import "src/lib.typ"
#import "doc/examples.typ"
@@ -105,7 +105,7 @@ This package provides a way to make beautiful block circuit diagrams using the C
Simply import Circuiteria and call the `circuit` function:
#pad(left: 1em)[```typ
#import "@preview/circuiteria:0.2.0"
#import "@preview/circuiteria:0.2.1"
#circuiteria.circuit({
import circuiteria: *
...
+1 -1
View File
@@ -1 +1 @@
#import "@preview/cetz:0.3.4": *
#import "@preview/cetz:0.5.2": *
+1 -1
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": canvas
#import "@preview/tidy:0.3.0"
#import "@preview/tidy:0.4.3"
/// Draws a block circuit diagram
///
+1 -1
View File
@@ -1,4 +1,4 @@
#let version = version(0, 2, 0)
#let version = version(0, 2, 1)
#let name = "circuiteria"
#import "circuit.typ": circuit
+42 -1
View File
@@ -73,4 +73,45 @@
#let valid-anchors = (
"center", "north", "east", "west", "south",
"north-east", "north-west", "south-east", "south-west"
)
)
/// Normalizes a coordinate so that 2D points (x, y) are padded
/// to (x, y, 0) as required by CeTZ's coordinate resolver.
/// Also handles function-based and dictionary-based coordinates
/// recursively.
///
/// - coord (any): A CeTZ-compatible coordinate
/// -> any
#let normalize-coord(coord) = {
if type(coord) == array {
if coord.len() == 2 {
let (a, b) = coord
// Raw 2D point: (x, y) -> (x, y, 0)
if type(a) in (int, float) and type(b) in (int, float) {
return (a, b, 0)
}
// 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 result
}, b)
}
}
}
if type(coord) == dictionary {
let result = (:)
for (key, value) in coord {
result.insert(key, normalize-coord(value))
}
return result
}
return coord
}
+166 -35
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw, coordinate
#import "util.typ": opposite-anchor
#import "util.typ": opposite-anchor, normalize-coord
/// List of valid wire styles
/// #examples.wires
@@ -24,29 +24,123 @@
return (pts, anchors)
}
#let get-zigzag-wire(pts, ratio, dir) = {
// Normalize an anchor-based placement value used by `zigzag-at`, `u-at`, and
// the `s-at-*` family.
//
// `axis` selects which cartesian component the offset applies to:
// - `"x"` → offset in x (used when the placement fixes an x coordinate, e.g.
// a vertical bend line for horizontal wire flow).
// - `"y"` → offset in y (used when the placement fixes a y coordinate).
//
// Accepted forms:
// - `none` → unset; caller falls back to
// depth/ratio-based placement.
// - `str` / tuple / native CeTZ dict → passed through as a CeTZ coordinate.
// - `(rel: <length>, to: <coord>)` with a *scalar* `rel`
// → ergonomic axial offset, rewritten
// to `(rel: (rel, 0), to: to)` for
// `axis == "x"` or
// `(rel: (0, rel), to: to)` for
// `axis == "y"`. If `rel` is already
// an array/tuple, the value is
// passed through unchanged.
#let normalize-scalar-offset(at, axis) = {
if at == none { return none }
if type(at) == dictionary and "rel" in at and "to" in at and type(at.rel) != array {
let r = at.rel
if axis == "x" {
return (rel: (r, 0), to: at.to)
} else {
return (rel: (0, r), to: at.to)
}
}
return at
}
// Backwards-compatible wrapper: translates the old `dir` vocabulary
// (`"vertical"` / `"horizontal"`) to the `axis` vocabulary used by
// `normalize-scalar-offset`.
#let normalize-zigzag-at(at, dir) = {
let axis = if dir == "vertical" { "x" } else { "y" }
return normalize-scalar-offset(at, axis)
}
// Try to deduce the cardinal side a port anchor faces by comparing its
// position to the center of the element it belongs to. Returns none if the
// anchor is not a port-style anchor (i.e. does not contain "-port-") or if
// resolution fails.
#let detect-port-side(anchor, ctx) = {
if type(anchor) != str { return none }
let sep = "-port-"
if not anchor.contains(sep) { return none }
let elmt-id = anchor.split(sep).first()
let port-pos = none
let center-pos = none
// coordinate.resolve panics on unknown anchors; there is no try/catch in
// Typst, so we assume port anchors created by add-port always come with a
// matching group of the same name. If not, the user must set the relevant side parameter
// explicitly.
let (_, p1) = coordinate.resolve(ctx, anchor)
let (_, p2) = coordinate.resolve(ctx, elmt-id + ".center")
port-pos = p1
center-pos = p2
let dx = port-pos.at(0) - center-pos.at(0)
let dy = port-pos.at(1) - center-pos.at(1)
if calc.abs(dx) >= calc.abs(dy) {
return if dx >= 0 { "east" } else { "west" }
} else {
return if dy >= 0 { "north" } else { "south" }
}
}
#let get-zigzag-wire(pts, ratio, dir, at: none) = {
let start = pts.first()
let end = pts.last()
let mid = if dir == "vertical" {
(start, ratio, (horizontal: end, vertical: ()))
} else {
(start, ratio, (horizontal: (), vertical: end))
}
let points = if dir == "vertical" {
(
start,
(horizontal: mid, vertical: ()),
(horizontal: (), vertical: end),
end
)
let points
if at == none {
// Ratio-based placement (default).
let mid = if dir == "vertical" {
(start, ratio, (horizontal: end, vertical: ()))
} else {
(start, ratio, (horizontal: (), vertical: end))
}
points = if dir == "vertical" {
(
start,
(horizontal: mid, vertical: ()),
(horizontal: (), vertical: end),
end
)
} else {
(
start,
(horizontal: (), vertical: mid),
(horizontal: end, vertical: ()),
end
)
}
} else {
(
start,
(horizontal: (), vertical: mid),
(horizontal: end, vertical: ()),
end
)
// Anchor-based placement: the middle bend segment sits on the axis
// extracted from `at` via CeTZ's perpendicular operator `|-`:
// - vertical: middle segment at x = at.x
// - horizontal: middle segment at y = at.y
points = if dir == "vertical" {
(
start,
(at, "|-", start),
(at, "|-", end),
end
)
} else {
(
start,
(start, "|-", at),
(end, "|-", at),
end
)
}
}
let anchors = (
"start": start,
@@ -68,8 +162,8 @@
let (ctx, p0) = coordinate.resolve(ctx, start)
let (ctx, p3) = coordinate.resolve(ctx, end)
p0 = (x: p0.first(), y: p0.last())
p3 = (x: p3.first(), y: p3.last())
p0 = (x: p0.first(), y: p0.at(1))
p3 = (x: p3.first(), y: p3.at(1))
let dx1 = margin-start
let dx2 = margin-end
@@ -86,13 +180,13 @@
if side-end == "east" {
dx2 *= -1
}
p1 = (p0.x + dx1, p0.y)
p2 = (p3.x - dx2, p0.y)
p1 = (p0.x + dx1, p0.y, 0)
p2 = (p3.x - dx2, p0.y, 0)
let points = (
start,
(horizontal: p1, vertical: ()),
(horizontal: (), vertical: (0, dodge-y)),
(horizontal: (), vertical: (0, dodge-y, 0)),
(horizontal: p2, vertical: ()),
(horizontal: (), vertical: end),
end
@@ -147,18 +241,18 @@
// points that are closest to the edge points
let p1 = (p0.x + dx1, p0.y + dy1)
let p5 = (p6.x + dx2, p6.y + dy2)
let p1 = (p0.x + dx1, p0.y + dy1, 0)
let p5 = (p6.x + dx2, p6.y + dy2, 0)
// middle point
let center_x = p0.x + box_width * center_horizontal / 100%
let center_y = p0.y + box_height * center_vertical / 100%
let p3 = (center_x, center_y)
let p3 = (center_x, center_y, 0)
// setting up the points for that touch the guides
let p2 = (0,0)
let p4 = (0,0)
let p2 = (0, 0, 0)
let p4 = (0, 0, 0)
if side-start in ("north", "south") {
p2 = (horizontal: p3, vertical: p1)
} else {
@@ -204,8 +298,9 @@
/// - reverse (bool): If true, the start and end points will be swapped (useful in cases where the start point depends on the end point, for example with perpendiculars)
/// - directed (bool): If true, the wire will be directed, meaning an arrow will be drawn at the endpoint
/// - rotate-name (bool): If true, the name will be rotated according to the wire's slope
/// - zigzag-ratio (ratio): Position of the zigzag vertical relative to the horizontal span (only with style "zigzag")
/// - zigzag-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"`. 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
/// - dodge-y (number): Y position to dodge the wire to (only with style "dodge")
/// - dodge-sides (array): The start and end sides (going out of the connected element) of the wire (only with style "dodge")
/// - dodge-margins (array): The start and end margins (i.e. space before dodging) of the wire (only with style "dodge")
@@ -227,8 +322,9 @@
guided-sides: ("east", "west"),
directed: false,
rotate-name: true,
zigzag-ratio: 50%,
zigzag-dir: "vertical",
zigzag-ratio: auto,
zigzag-dir: auto,
zigzag-at: none,
dodge-y: 0,
dodge-sides: ("east", "west"),
dodge-margins: (5%, 5%)
@@ -240,6 +336,22 @@
if pts.len() != 2 {
panic("Wrong number of points (got " + str(pts.len()) + " instead of 2)")
}
// `zigzag-at` validation.
if zigzag-at != none {
if style != "zigzag" {
panic("wire '" + id + "': `zigzag-at` requires style: \"zigzag\" (got \""
+ style + "\")")
}
if zigzag-ratio != auto {
panic("wire '" + id
+ "': cannot combine `zigzag-at` with an explicit `zigzag-ratio`")
}
}
let zigzag-ratio-effective = if zigzag-ratio == auto { 50% } else { zigzag-ratio }
// Normalize user-provided coordinates: (x, y) -> (x, y, 0)
let pts = pts.map(normalize-coord)
let stroke = (
paint: color,
@@ -256,7 +368,26 @@
(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 = 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),
)
} else if style == "dodge" {
(points, anchors) = get-dodge-wire(
+3 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "circuiteria"
version = "0.2.0"
compiler = "0.13.1"
version = "0.2.1"
compiler = "0.15.0"
repository = "https://git.kb28.ch/HEL/circuiteria"
entrypoint = "src/lib.typ"
authors = [
@@ -9,6 +9,6 @@ authors = [
]
categories = [ "visualization" ]
license = "Apache-2.0"
description = "Drawing block circuits with Typst made easy, using CeTZ"
description = "Drawing block circuits made easy, using CeTZ"
keywords = [ "circuit", "block", "draw" ]
exclude = [ "gallery", "justfile", "doc" ]