Compare commits

...
6 Commits
Author SHA1 Message Date
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
David Stadelmann b8a8d38ed2 feat: Allow port names to be placed outside of a block 2026-09-14 11:42:37 +02:00
David Stadelmann 6070ce7fe3 feat: Add wire.breakout element for bus fan-out diagrams
Introduces wire.breakout: a 45° diagonal line of configurable length
(default 1em) that starts at a given (x, y) and exposes a port anchor
<id>-port-<id> at its end point so wires can attach to it as if it were
a block port. A synthesized <id>.center anchor is placed opposite the
configured port-side so wire.wire with style "u-shape" can auto-detect
the port's cardinal side.
- x/y accept number, (rel:, to:) and (from:) forms; (from:) sets the
  start point to the referenced anchor's coordinate.
- direction: one of north-east/north-west/south-east/south-west.
- Optional label positioned via name-pos (NE/NW/SE/SW); when name-pos is
  opposite to direction, the label falls back to the start point so it
  never covers the diagonal line. Label padding matches wire.wire (3pt).
Also adds three examples (breakout, breakout-label, breakout-wire) in
doc/examples.typ referenced from the docstring so tidy auto-renders
them in the manual, and a gallery entry (gallery/test_breakout.typ)
demonstrating orthogonal bus fan-out with breakouts in all four
directions.
2026-08-26 09:45:24 +02:00
David Stadelmann a51e8bb93b feature: Add port shape 2026-08-17 15:00:02 +02:00
David Stadelmann 563bb7b1d2 feat: Add port-name-padding parameter
Replaces the hard-coded 2pt padding used when placing port name labels.
Configurable per element via port-name-padding on elmt (forwarded by
block and group), defaulting to 2pt to preserve existing layout.
2026-08-17 14:13:06 +02:00
David Stadelmann 0d1478c765 feat: Add ports on group elements
Groups now accept ports/ports-margins like blocks. Port name labels
default to outside the group border (outside-port-names: true) so they
don't overlap child elements; set to false for block-style inside
labels.
2026-08-17 14:12:50 +02:00
14 changed files with 10552 additions and 6818 deletions

No files matched your search

+186
View File
@@ -81,6 +81,81 @@ wire.stub("p", "south", name: "south", length: 15pt)
wire.stub("p", "west", name: "west", length: 3em)
```)
#let breakout = example(```
// 16 breakouts: 4 directions × 4 label positions, laid out
// two per row so the labels have room to breathe.
let dirs = ("north-east", "north-west", "south-east", "south-west")
let poss = ("north-east", "north-west", "south-east", "south-west")
let short = (
"north-east": "NE", "north-west": "NW",
"south-east": "SE", "south-west": "SW",
)
let cols = 2
let dx = 5
let dy = 2
for (i, dir) in dirs.enumerate() {
for (j, pos) in poss.enumerate() {
let n = i * poss.len() + j
let r = calc.floor(n / cols)
let c = calc.rem(n, cols)
draw.circle((c * dx, -r * dy), radius: .05)
wire.breakout("b" + str(n),
x: c * dx, y: -r * dy,
direction: dir,
port-side: if dir.ends-with("east") { "west" } else { "east" },
name: short.at(dir) + "/" + short.at(pos),
name-pos: pos)
}
}
```)
#let breakout-label = example(```
// name-pos matches direction → label sits at the end point.
wire.breakout("b1", x: 0, y: 0, direction: "north-east",
port-side: "east",
name: "at end", name-pos: "north-east")
// name-pos opposite to direction → label would cover the line, so
// it falls back to the start point.
wire.breakout("b2", x: 4, y: 0, direction: "north-east",
port-side: "east",
name: "at start", name-pos: "south-west")
```, vertical: true)
#let breakout-wire = example(```
// Bus fan-out: a horizontal wire from the left block to the lower
// right block carries the full 10-bit bus. A breakout in the middle
// of that wire taps bits [9:5] and routes them to the upper right
// block via an L-shaped zigzag (up, then right).
element.block(x: 0, y: 0, w: 2, h: 2, id: "src",
fill: util.colors.blue,
ports: (east: ((id: "out"),)))
element.block(x: 6, y: 0, w: 2, h: 2, id: "dstL",
fill: util.colors.green,
ports: (west: ((id: "in"),)))
element.block(x: 6, y: 4, w: 2, h: 2, id: "dstU",
fill: util.colors.orange,
ports: (west: ((id: "in"),)))
// Full bus wire, labelled "[9:0]" on the left.
wire.wire("wbus", ("src-port-out", "dstL-port-in"),
name: "[9:0]", name-pos: "start")
// Breakout tapped from the midpoint of the bus.
wire.breakout("tap",
x: (rel: 2, to: "src-port-out"),
y: (from: "src-port-out"),
direction: "north-east",
port-side: "west",
name: "[9:5]", name-pos: "north-east")
// L-shaped wire from the breakout up and then right to the upper
// block's input.
wire.wire("wtap", ("tap-port-tap", "dstU-port-in"),
style: "zigzag", zigzag-dir: "horizontal",
zigzag-ratio: 100%)
```)
#let gate-and = example(```
gates.gate-and(x: 0, y: 0, w: 1.5, h: 1.5)
gates.gate-and(x: 3, y: 0, w: 1.5, h: 1.5, inverted: "all")
@@ -188,6 +263,117 @@ 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)
// 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)
#let port-names-outside = example(```
element.block(
x: 0, y: 0, w: 2, h: 2, id: "block",
port-names-outside: true,
port-name-padding: 4pt,
ports: (
north: ((id: "clk", name: "CLK", clock: true),),
west: ((id: "in1", name: "A"),
(id: "in2", name: "B")),
east: ((id: "out", name: "Y"),)
)
)
```)
#let port-shape = example(```
// Block with pentagon-shaped ports.
// - port-shape / port-kind / port-size are element-level defaults.
// - Any port field (shape, kind, size, stroke, fill) overrides per port.
// - kind: "in" → tip points into the block (default)
// kind: "out" → tip points away from the block
// - clock ports with shape: "pentagon" drop their triangle and default
// to a gray fill.
element.block(
id: "b", w: 3, h: 3, x: 0, y: 0,
fill: util.colors.yellow.lighten(60%),
port-shape: "pentagon",
port-size: 1em,
ports: (
// west: default input
west: ((id: "in", name: "D"),),
// east: output — tip points away
east: ((id: "out", name: "Q", kind: "out"),),
// north: clock port — pentagon replaces the triangle, gray fill
north: ((id: "clk", name: "CLK", clock: true),),
// south: styled output with per-port fill and stroke
south: ((id: "rdy", name: "RDY", kind: "out",
fill: util.colors.blue, stroke: red + 1.5pt),),
),
)
// Wires connect at the outer end of each pentagon.
wire.stub("b-port-in", "west")
wire.stub("b-port-out", "east")
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("w2", ((0, 0), (1, -.5)),
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

After

Width:  |  Height:  |  Size: 23 KiB

+86
View File
@@ -0,0 +1,86 @@
// Demonstrates ports on a group element.
// outside-port-names: true (default) — labels outside the group border
// outside-port-names: false — labels inside (block-style)
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// --- Group with port labels INSIDE (outside-port-names: false) ---
element.group(
id: "cpu",
name: "CPU (labels outside)",
name-anchor: "north",
stroke: (dash: "dashed"),
fill: util.colors.yellow.lighten(60%),
padding: 1em,
port-name-padding: .5em,
outside-port-names: true,
ports: (
west: (
(id: "in1", name: "D1"),
(id: "in2", name: "D2"),
),
east: ((id: "out", name: "Q"),),
south: (
(id: "rst", name: "RST", vertical: true),
(id: "done", name: "DONE", vertical: true),
),
),
{
element.block(
id: "reg2",
w: 2.5,
h: 3,
x: 0,
y: 0,
fill: util.colors.green,
ports: (
west: ((id: "d1"), (id: "d2")),
east: ((id: "q"),),
north: ((id: "clk", clock: true),),
),
)
},
)
// --- Group with port labels INSIDE (outside-port-names: false) ---
element.group(
id: "cpu2",
name: "CPU (labels inside)",
name-anchor: "north",
stroke: (dash: "dashed"),
fill: util.colors.yellow.lighten(60%),
padding: 4em,
outside-port-names: false,
port-name-padding: 0.5em,
ports: (
west: (
(id: "in1", name: "D1"),
(id: "in2", name: "D2"),
),
east: ((id: "out", name: "Q"),),
south: (
(id: "rst", name: "RST", vertical: true),
(id: "done", name: "DONE", vertical: true),
),
),
{
element.block(
id: "reg2",
w: 2.5,
h: 3,
x: 10,
y: 0,
fill: util.colors.green,
ports: (
west: ((id: "d1"), (id: "d2")),
east: ((id: "q"),),
north: ((id: "clk", clock: true),),
),
)
},
)
wire.stub("cpu2-port-in1", "west", name: "A")
wire.stub("cpu2-port-in2", "west", name: "B")
wire.stub("cpu2-port-out", "east", name: "Q")
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+117
View File
@@ -0,0 +1,117 @@
// Demonstrates pentagon-shaped ports.
//
// - Element-level defaults: `port-shape`, `port-kind`, `port-size`.
// - Per-port overrides: `shape`, `kind`, `size`, `stroke`, `fill`.
// - Wire endpoints connect at the outer end of each pentagon.
// - Clock ports with pentagon shape drop the clock triangle and default
// to a gray fill.
#import "/src/lib.typ": circuit, element, util, wire
#set page(width: auto, height: auto, margin: .5cm)
#circuit({
// -----------------------------------------------------------------
// 1) All four sides × input/output
// -----------------------------------------------------------------
element.block(
id: "grid", w: 4, h: 4, x: 0, y: 0,
name: "sides × in/out", name-anchor: "center",
fill: util.colors.yellow.lighten(60%),
port-shape: "pentagon",
ports: (
west: (
(id: "wi", name: "W-in"),
(id: "wo", name: "W-out", kind: "out"),
),
east: (
(id: "ei", name: "E-in"),
(id: "eo", name: "E-out", kind: "out"),
),
north: (
(id: "ni", name: "N-in", vertical: true),
(id: "no", name: "N-out", kind: "out", vertical: true),
),
south: (
(id: "si", name: "S-in", vertical: true),
(id: "so", name: "S-out", kind: "out", vertical: true),
),
),
)
// -----------------------------------------------------------------
// 2) Per-port styling: fill and stroke overrides + size override
// -----------------------------------------------------------------
element.block(
id: "styled", w: 3, h: 3, x: 10, y: 0,
name: "per-port styling", name-anchor: "center",
fill: util.colors.green.lighten(40%),
port-shape: "pentagon",
ports: (
west: ((id: "a", name: "A", fill: util.colors.blue),),
east: ((id: "b", name: "B", kind: "out",
stroke: red + 1.5pt, fill: util.colors.pink),),
north: ((id: "c", name: "C", size: 1.5em),),
south: ((id: "d", name: "D", kind: "out",
fill: util.colors.orange),),
),
)
// -----------------------------------------------------------------
// 3) Clock port with pentagon → gray fill, no triangle
// -----------------------------------------------------------------
element.block(
id: "clk", w: 3, h: 2.5, x: 16, y: 0,
name: "clock + pentagon", name-anchor: "center",
fill: util.colors.orange.lighten(60%),
port-shape: "pentagon",
ports: (
west: ((id: "d", name: "D"),),
east: ((id: "q", name: "Q", kind: "out"),),
north: ((id: "clk", name: "CLK", clock: true),),
),
)
// -----------------------------------------------------------------
// 4) Two pentagon-ported blocks connected by a wire
// (wire terminates at outer edge of each pentagon)
// -----------------------------------------------------------------
element.block(
id: "src", w: 2, h: 1.5, x: 0, y: -5,
name: "src", name-anchor: "center",
fill: util.colors.pink,
port-shape: "pentagon",
ports: (east: ((id: "q", name: "Q", kind: "out"),)),
)
element.block(
id: "dst", w: 2, h: 1.5, x: 6, y: -5,
name: "dst", name-anchor: "center",
fill: util.colors.blue,
port-shape: "pentagon",
ports: (west: ((id: "d", name: "D"),)),
)
wire.wire("w-sd", ("src-port-q", "dst-port-d"))
// -----------------------------------------------------------------
// 5) Group with pentagon ports enclosing a child block
// -----------------------------------------------------------------
element.group(
id: "g", name: "group w/ pentagon ports", name-anchor: "north",
stroke: (dash: "dashed"),
fill: util.colors.purple.lighten(70%),
padding: 2em,
port-shape: "pentagon",
ports: (
west: ((id: "in", name: "IN"),),
east: ((id: "out", name: "OUT", kind: "out"),),
),
{
element.block(
id: "inner", w: 2, h: 1.5, x: 12, y: -5,
fill: util.colors.green,
ports: (west: ((id: "d"),), east: ((id: "q"),)),
)
},
)
wire.wire("wgi", ("g-port-in", "inner-port-d"))
wire.wire("wgo", ("inner-port-q", "g-port-out"))
})
+9670 -6798
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -16,9 +16,22 @@
/// Draws a block element
///
/// #examples.block
///
/// Ports can be given a pentagon shape (with configurable direction, size,
/// stroke and fill) via `port-shape` and per-port overrides:
/// #examples.port-shape
///
/// Port name labels can be placed outside the block via `port-names-outside`:
/// #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,
@@ -27,15 +40,22 @@
name-anchor: "center",
ports: (),
ports-margins: (),
port-name-padding: 2pt,
port-names-outside: auto,
port-shape: none,
port-kind: "in",
port-size: 1em,
fill: none,
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,
@@ -44,8 +64,14 @@
name-anchor: name-anchor,
ports: ports,
ports-margins: ports-margins,
port-name-padding: port-name-padding,
port-names-outside: port-names-outside,
port-shape: port-shape,
port-kind: port-kind,
port-size: port-size,
fill: fill,
stroke: stroke,
id: id,
shell: shell,
debug: debug
)
+27 -1
View File
@@ -37,11 +37,17 @@
/// - `clock` (`bool`): Whether it is a clock port (triangle symbol)
/// - `vertical` (`bool`): Whether the name should be drawn vertically
/// - 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, 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 #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-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:
@@ -56,11 +62,18 @@
name-anchor: "center",
ports: (:),
ports-margins: (:),
port-name-padding: 2pt,
port-names-outside: auto,
port-shape: none,
port-kind: "in",
port-size: 1em,
fill: none,
stroke: black + 1pt,
id: "",
auto-ports: true,
ports-y: (:),
shell: false,
body: none,
debug: (
ports: false
)
@@ -135,12 +148,25 @@
}
if auto-ports {
let outside-name = if port-names-outside == auto { shell } else { port-names-outside }
add-ports(
id,
tl, tr, br, bl,
ports,
ports-margins,
debug: debug.ports
debug: debug.ports,
name-padding: port-name-padding,
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
}
})
+44 -1
View File
@@ -1,9 +1,15 @@
#import "/src/cetz.typ": draw, coordinate
#import "../util.typ"
#import "ports.typ": add-ports
/// Draws a group of elements
///
/// #examples.group
///
/// Ports can also be defined on a group, just like on a block. By default
/// port labels are drawn *outside* the group border so they do not overlap
/// child elements. Set `outside-port-names: false` to draw them inside.
/// #examples.group-ports
/// - body (elements, function): Elements to group
/// - id (str): see #doc-ref("element.elmt")
/// - name (str): The group's name
@@ -16,6 +22,14 @@
/// - array: either (`<all>`,), (`<vertical>`, `<horizontal>`) or (`<top>`, `<right>`, `<bottom>`, `<left>`)
/// - dictionary: valid keys are "top", "right", "bottom" and "left"
/// - radius (number): The corner radius
/// - ports (dictionary): see #doc-ref("element.elmt")
/// - ports-margins (dictionary): see #doc-ref("element.elmt")
/// - outside-port-names (bool): If `true` (default), port name labels are drawn outside the group border so they do not overlap child elements. Set to `false` to draw labels inside, matching the block behaviour
/// - port-name-padding (number, length): Padding between the port name label and the group border (default `2pt`). Accepts anything CeTZ accepts as `padding` (number or length)
/// - port-shape (none, str): see #doc-ref("element.elmt")
/// - port-kind (str): see #doc-ref("element.elmt")
/// - port-size (length): see #doc-ref("element.elmt")
/// - debug (dictionary): see #doc-ref("element.elmt")
#let group(
body,
id: "",
@@ -24,7 +38,17 @@
fill: none,
stroke: black + 1pt,
padding: 0.5em,
radius: 0.5em
radius: 0.5em,
ports: (:),
ports-margins: (:),
outside-port-names: true,
port-name-padding: 2pt,
port-shape: none,
port-kind: "in",
port-size: 1em,
debug: (
ports: false
)
) = {
let min-x = none
let max-x = none
@@ -68,6 +92,25 @@
stroke: stroke,
fill: fill
)
add-ports(
id,
id + ".north-west",
id + ".north-east",
id + ".south-east",
id + ".south-west",
ports,
ports-margins,
debug: debug.ports,
outside-name: outside-port-names,
name-padding: port-name-padding,
port-shape: port-shape,
port-kind: port-kind,
port-size: port-size,
elmt-stroke: stroke,
elmt-fill: fill,
)
if name != none {
draw.content(
id + "." + name-anchor,
+91 -17
View File
@@ -1,36 +1,94 @@
#import "/src/cetz.typ": draw
#import "../util.typ": rotate-anchor
#import "../util.typ": rotate-anchor, opposite-anchor, port-shape-vertices
#let add-port(
elmt-id, side, port, pos,
prev: none,
next: none,
debug: false
debug: false,
outside-name: false,
name-padding: 2pt,
port-shape: none,
port-kind: "in",
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)
let is-clock = port.at("clock", default: false)
if (port.at("clock", default: false)) {
// 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 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 })
// When shape is enabled, wire-connection anchor moves outside the block, and
// inside-name labels reference the inner tip/base; otherwise both collapse to
// the boundary point (unchanged behaviour).
let outer-pos = pos
let inner-pos = pos
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),
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 {
if prev == none or next == none {
panic("Clock port must have previous and next positions")
}
let size = 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, -size) }
else if (side == "east") { offset = (-size, 0) }
else if (side == "south") { offset = ( 0, size) }
else if (side == "west") { offset = ( size, 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)
// TODO: use context or vectors to have the height relative to the width
draw.line(prev, pos1, next)
}
// When outside-name is true (e.g. for groups) the label is placed on the
// exterior of the element border so it does not overlap child elements.
// 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) }
} else {
if name-rotate { rotate-anchor(side) } else { side }
}
// Label sits at the outer edge of the shape for outside-name, inner edge for
// inside-name. `name-padding` is thus naturally measured from the shape edge
// (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(
pos,
anchor: if name-rotate {rotate-anchor(side)} else {side},
padding: 2pt,
label-pos,
anchor: name-anchor,
padding: name-padding,
angle: if name-rotate {90deg} else {0deg},
name
)
@@ -38,7 +96,7 @@
if debug {
draw.circle(
pos,
anchor-pos,
name: id,
radius: .1,
stroke: none,
@@ -47,7 +105,7 @@
} else {
draw.hide(draw.circle(
pos,
anchor-pos,
radius: 0,
stroke: none,
name: id
@@ -60,7 +118,15 @@
tl, tr, br, bl,
ports,
ports-margins,
debug: false
debug: false,
outside-name: false,
name-padding: 2pt,
port-shape: none,
port-kind: "in",
port-size: 1em,
elmt-stroke: black + 1pt,
elmt-fill: none,
wire-inside: false,
) = {
let sides = (
"north": (tl, tr),
@@ -109,8 +175,16 @@
pos,
prev: pos-prev,
next: pos-next,
debug: debug
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,
)
}
}
}
}
+49
View File
@@ -90,6 +90,55 @@
).at(anchor)
}
/// Returns the pentagon port shape vertices and the outer/inner anchor
/// offsets for the given side and direction.
///
/// The pentagon straddles the block boundary: 1 unit outside, 1 unit inside;
/// height ±0.5 units. For `kind: "in"` the tip points into the block; for
/// `kind: "out"` it points away.
///
/// - side (str): One of `"north"`, `"east"`, `"south"`, `"west"`
/// - kind (str): `"in"` or `"out"`
/// - u (length,number): The pentagon unit size
/// -> dictionary: `(vertices: (..5 pairs..), outer: (dx, dy), inner: (dx, dy))`
#let port-shape-vertices(side, kind, u) = {
// 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),
)
// For output, mirror horizontally so the tip points west (outside).
if kind == "out" {
base-vertices = base-vertices.map(p => (-p.at(0), p.at(1)))
}
// Outer (wire-connection) and inner (inside-label) anchors for a west port.
// Outer is always 1u outside the boundary along the port normal; inner is 1u
// inside. Independent of `kind` (only the pentagon fill/tip direction change).
let base-outer = (-u, 0)
let base-inner = (u, 0)
// Rotation from west's frame to target side:
// west: identity (x, y)
// 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) }
return (
vertices: base-vertices.map(rot),
outer: rot(base-outer),
inner: rot(base-inner),
)
}
#let valid-anchors = (
"center", "north", "east", "west", "south",
"north-east", "north-west", "south-east", "south-west"
+139 -1
View File
@@ -1,5 +1,5 @@
#import "/src/cetz.typ": draw, coordinate
#import "util.typ": opposite-anchor, normalize-coord
#import "util.typ": opposite-anchor, normalize-coord, resolve-rel-coord
/// List of valid wire styles
/// #examples.wires
@@ -511,3 +511,141 @@
)
}
}
/// Draws a wire breakout: a 45° diagonal line starting at (x, y) and
/// ending 1 unit away in one of the four diagonal directions. The end
/// point exposes a port anchor named `<id>-port-<id>` so wires can attach
/// to it as if it were a block port. A synthesized `<id>.center` anchor
/// is placed opposite `port-side` so #doc-ref("wire.wire") with
/// `style: "u-shape"` can auto-detect the port side.
///
/// #examples.breakout
///
/// Label positions are anchored so that the label sits neatly next to the
/// end point of the breakout. When the label direction matches the
/// breakout direction (which would overlap the line) the label is
/// automatically placed at the start point instead so the text does not
/// cover the line.
///
/// #examples.breakout-label
///
/// The port anchor makes it easy to fan out a bus:
///
/// #examples.breakout-wire
///
/// - id (str): The breakout id (also used as the port id)
/// - x (number, dictionary): The starting x position (same forms as `element.block`)
/// - y (number, dictionary): The starting y position (same forms as `element.block`)
/// - direction (str): One of `"north-east"`, `"north-west"`, `"south-east"`, `"south-west"`
/// - length (length, number): The diagonal length (default `1em`, matches port size)
/// - port-side (str): Cardinal side used for wire-attach behavior (`"north"`, `"east"`, `"south"`, `"west"`)
/// - stroke (stroke): Line stroke (paint + thickness)
/// - name (none, str): Optional label displayed near the breakout
/// - name-pos (none, str): Label position, one of `"north-west"`, `"north-east"`, `"south-west"`, `"south-east"`
/// - debug (bool): If true, shows a red dot at the port anchor
#let breakout(
id,
x: none, y: none,
direction: "north-east",
length: 1em,
port-side: "east",
stroke: black + 1pt,
name: none,
name-pos: none,
debug: false,
) = draw.get-ctx(ctx => {
if x == none { panic("breakout: parameter x must be set") }
if y == none { panic("breakout: parameter y must be set") }
let valid-directions = ("north-east", "north-west", "south-east", "south-west")
if not direction in valid-directions {
panic("breakout: invalid direction '" + repr(direction)
+ "'; must be one of " + repr(valid-directions))
}
let valid-sides = ("north", "east", "south", "west")
if not port-side in valid-sides {
panic("breakout: invalid port-side '" + repr(port-side)
+ "'; must be one of " + repr(valid-sides))
}
// Diagonal component: length / sqrt(2). Resolve to a canvas-space
// number so we can do arithmetic with anchor coordinates.
let d-raw = length / calc.sqrt(2)
let (_, d-vec) = coordinate.resolve(ctx, (d-raw, 0, 0))
let d = d-vec.at(0)
// Direction sign vector for (dx, dy).
let sx = if direction.ends-with("east") { 1 } else { -1 }
let sy = if direction.starts-with("north") { 1 } else { -1 }
// Resolve x.
let sx0 = x
if type(x) == dictionary {
if "rel" in x {
sx0 = resolve-rel-coord(ctx, x, 0)
} else if "from" in x {
let (_, from-pos) = coordinate.resolve(ctx, x.from)
sx0 = from-pos.at(0)
} else {
panic("breakout: unsupported x dictionary: " + repr(x))
}
}
// Resolve y.
let sy0 = y
if type(y) == dictionary {
if "rel" in y {
sy0 = resolve-rel-coord(ctx, y, 1)
} else if "from" in y {
let (_, from-pos) = coordinate.resolve(ctx, y.from)
sy0 = from-pos.at(1)
} else {
panic("breakout: unsupported y dictionary: " + repr(y))
}
}
let start = (sx0, sy0)
let end = (rel: (sx * d, sy * d), to: start)
// Offset used for the synthesized center anchor: opposite of port-side.
let center-offset = (
"north": (0, -1em),
"east": (-1em, 0),
"south": (0, 1em),
"west": (1em, 0),
).at(port-side)
let port-anchor-name = id + "-port-" + id
draw.group(name: id, {
draw.line(start, end, stroke: stroke)
draw.anchor("start", start)
draw.anchor("end", end)
draw.anchor("center", (rel: center-offset, to: end))
if name != none and name-pos != none {
let label-anchor = (
"north-west": "south-east",
"north-east": "south-west",
"south-west": "north-east",
"south-east": "north-west",
).at(name-pos)
// Overlap case: label direction is opposite to breakout direction,
// so the label at the end point would extend back over the line.
// Anchor it at the start point instead.
let label-pos = if name-pos == opposite-anchor(direction) { start } else { end }
draw.content(label-pos, anchor: label-anchor, padding: 3pt, name)
}
})
// Port anchor emitted at top level so it can be referenced by its
// bare name (mirrors add-port's approach in ports.typ).
if debug {
draw.circle(end, name: port-anchor-name,
radius: .1, stroke: none, fill: red)
} else {
draw.hide(draw.circle(end, name: port-anchor-name,
radius: 0, stroke: none))
}
})