Files
circuiteria/src/elements/ports.typ
T
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

447 lines
13 KiB
Typst

#import "/src/cetz.typ": draw
#import "../util.typ": opposite-anchor, port-shape-vertices, rotate-anchor
#let add-port(
elmt-id,
side,
port,
pos,
prev: none,
next: none,
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)
// 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 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 {
if prev == none or next == none {
panic("Clock port must have previous and next positions")
}
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)
}
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(
label-pos,
anchor: name-anchor,
padding: name-padding,
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(
anchor-pos,
radius: .1,
stroke: none,
fill: red,
)
}
}
// 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,
ports,
ports-margins,
width: 0,
height: 0,
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),
"east": (tr, br),
"south": (bl, br),
"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 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 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,
wire-inside: wire-inside,
)
}
}