Compare commits

..
Author SHA1 Message Date
David Stadelmann 6595387c5d 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-10-22 00:30:22 +02:00
5 changed files with 7867 additions and 7358 deletions

No files matched your search

+25
View File
@@ -23,6 +23,31 @@ 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,
+7551 -7313
View File
File diff suppressed because it is too large. Load diff
+28 -11
View File
@@ -166,8 +166,24 @@ If you have installed Circuiteria directly in your project, import #link("src/li
#pagebreak()
#let element-scope = (
element: element,
circuit: circuit,
draw: draw,
wire: wire,
tidy: tidy,
examples: examples,
doc-ref: doc-ref
)
#let elmt-docs = tidy.parse-module(
read("src/elements/element.typ"),
name: "element",
old-syntax: true,
scope: element-scope
)
#let element-docs = tidy.parse-module(
read("src/elements/element.typ") + "\n" +
read("src/elements/alu.typ") + "\n" +
read("src/elements/block.typ") + "\n" +
read("src/elements/extender.typ") + "\n" +
@@ -175,18 +191,19 @@ If you have installed Circuiteria directly in your project, import #link("src/li
read("src/elements/group.typ"),
name: "element",
old-syntax: true,
scope: (
element: element,
circuit: circuit,
draw: draw,
wire: wire,
tidy: tidy,
examples: examples,
doc-ref: doc-ref
)
scope: element-scope
)
#tidy.show-module(element-docs, sort-functions: false)
#tidy.show-module(elmt-docs, sort-functions: false, show-module-name: true)
#figure(
examples.align-with-ports,
caption: [Example of the `align-with` port field (see #doc-ref("element.elmt")).],
kind: "example",
supplement: [Example],
) <elmt-align-with-example>
#tidy.show-module(element-docs, sort-functions: false, show-module-name: false)
#pagebreak()
+5
View File
@@ -34,6 +34,9 @@
/// - `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 @elmt-align-with-example.
/// - `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).
@@ -161,6 +164,8 @@
bl,
ports,
ports-margins,
width: width,
height: height,
debug: debug.ports,
name-padding: port-name-padding,
outside-name: outside-name,
+258 -34
View File
@@ -142,6 +142,153 @@
}
}
// 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,
@@ -150,6 +297,8 @@
bl,
ports,
ports-margins,
width: 0,
height: 0,
debug: false,
outside-name: false,
name-padding: 2pt,
@@ -171,52 +320,127 @@
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 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)
}
}
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)
// ---- 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,
)
}
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,
)
}
}