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.
447 lines
13 KiB
Typst
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,
|
|
)
|
|
}
|
|
}
|