diff --git a/doc/examples.typ b/doc/examples.typ index a182e59..a9c194c 100644 --- a/doc/examples.typ +++ b/doc/examples.typ @@ -56,7 +56,7 @@ for i in range(3) { ```) #let wires = example(``` -for i in range(4) { +for i in range(5) { draw.circle((i * 3, 0), radius: .1, name: "p" + str(i * 2)) draw.circle((i * 3 + 2, 1), radius: .1, name: "p" + str(i * 2 + 1)) draw.content((i * 3 + 1, -1), raw(wire.wire-styles.at(i))) @@ -68,9 +68,213 @@ wire.wire("w3", ("p4", "p5"), style: "dodge", wire.wire("w4", ("p6","p7"), style: "guided", guided-center:(20%, 40%), guided-margins: (90%,87%), guided-sides: ("north","south")) - +wire.wire("w5", ("p8", "p9"), style: "u-shape", + u-side: "east", u-depth: 0.5) ```, 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 u-shape-wire = example( + ``` + // east <-> east (u-side auto-detected) + element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA", + fill: util.colors.orange, + ports: (east: ((id: "out"),))) + element.block(x: 0.5, y: -2.5, w: 1.5, h: 1.5, id: "eB", + fill: util.colors.orange, + ports: (east: ((id: "out"),))) + wire.wire("uEE", ("eA-port-out", "eB-port-out"), + style: "u-shape", u-depth: 0.5) + + // west <-> west (auto-detected) + element.block(x: 6, y: 0, w: 1.5, h: 1.5, id: "wA", + fill: util.colors.yellow, + ports: (west: ((id: "in"),))) + element.block(x: 6.5, y: -2.5, w: 1.5, h: 1.5, id: "wB", + fill: util.colors.yellow, + ports: (west: ((id: "in"),))) + wire.wire("uWW", ("wA-port-in", "wB-port-in"), + style: "u-shape", u-depth: 0.5) + + // north <-> north (auto-detected) + element.block(x: 10, y: -3, w: 1.5, h: 1.5, id: "nA", + fill: util.colors.green, + ports: (north: ((id: "out"),))) + element.block(x: 12.5, y: -2.5, w: 1.5, h: 1.5, id: "nB", + fill: util.colors.green, + ports: (north: ((id: "out"),))) + wire.wire("uNN", ("nA-port-out", "nB-port-out"), + style: "u-shape", u-depth: 0.5) + + // south <-> south with explicit u-side override + element.block(x: 16, y: -3, w: 1.5, h: 1.5, id: "sA", + fill: util.colors.pink, + ports: (south: ((id: "out"),))) + element.block(x: 18.5, y: -3.5, w: 1.5, h: 1.5, id: "sB", + fill: util.colors.pink, + ports: (south: ((id: "out"),))) + wire.wire("uSS", ("sA-port-out", "sB-port-out"), + style: "u-shape", u-side: "south", u-depth: 0.5) + ```, + vertical: true, +) + +#let u-at = example( + ``` + // `u-at` places the U-bend crossbar on the axis + // of a referenced anchor, overriding `u-depth`. + element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA", + fill: util.colors.orange, + ports: (east: ((id: "o1"), (id: "o2")))) + element.block(x: 0, y: -3.5, w: 1.5, h: 1.5, id: "eB", + fill: util.colors.orange, + ports: (east: ((id: "i1"), (id: "i2")))) + element.block(x: 4, y: -2.25, w: 0.6, h: 0.6, id: "ref", + fill: util.colors.pink) + // Align one U crossbar with ref.east. + wire.wire("u1", ("eA-port-o1", "eB-port-i1"), + style: "u-shape", + u-at: "ref.east") + // Ergonomic scalar offset: 1 right of ref.east. + wire.wire("u2", ("eA-port-o2", "eB-port-i2"), + style: "u-shape", color: util.colors.blue, + u-at: (rel: 1, to: "ref.east")) + ```, + vertical: true, +) + +#let s-shape-wire = example( + ``` + // `s-shape` connects endpoints on opposite sides + // via 5 segments joined at 4 corners (u1-start, + // u1-end, u2-start, u2-end). Auto-detects axis + // from port anchors. + element.block(x: 0, y: 0, w: 1, h: 1, id: "A", + fill: util.colors.orange, + ports: (west: ((id: "out"),))) + element.block(x: 2.5, y: -1.75, w: 1, h: 1, id: "B", + fill: util.colors.orange, + ports: (east: ((id: "in"),))) + wire.wire("s1", ("A-port-out", "B-port-in"), + style: "s-shape") + + // Anchor-based override of the crossbar position. + element.block(x: 5.5, y: 0, w: 1, h: 1, id: "C", + fill: util.colors.yellow, + ports: (west: ((id: "out"),))) + element.block(x: 8.5, y: -1.75, w: 1, h: 1, id: "D", + fill: util.colors.yellow, + ports: (east: ((id: "in"),))) + element.block(x: 7, y: -0.7, w: 0.5, h: 0.5, id: "ref", + fill: util.colors.pink) + wire.wire("s2", ("C-port-out", "D-port-in"), + style: "s-shape", + s-at-mid: "ref.south") + + // C-shape degeneration: crossbar outside the + // endpoint span (s-ratio-mid > 100%). + element.block(x: 11, y: 0, w: 1, h: 1, id: "E", + fill: util.colors.green, + ports: (west: ((id: "out"),))) + element.block(x: 14, y: -0.75, w: 1, h: 1, id: "F", + fill: util.colors.green, + ports: (east: ((id: "in"),))) + wire.wire("s3", ("E-port-out", "F-port-in"), + style: "s-shape", + s-ratio-mid: 200%) + ```, + vertical: true, +) + +#let s-shape-wire-vertical = example( + ``` + // `s-shape` on the vertical axis (north <-> south): + // seg1 and seg5 are vertical, the crossbar is + // vertical, and the two horizontals connect them. + element.block(x: 0, y: 0, w: 1, h: 1, id: "A", + fill: util.colors.orange, + ports: (north: ((id: "out"),))) + element.block(x: 2, y: -3, w: 1, h: 1, id: "B", + fill: util.colors.orange, + ports: (south: ((id: "in"),))) + wire.wire("s1", ("A-port-out", "B-port-in"), + style: "s-shape") + + // Anchor-based override of the vertical crossbar. + element.block(x: 5, y: 0, w: 1, h: 1, id: "C", + fill: util.colors.yellow, + ports: (north: ((id: "out"),))) + element.block(x: 7, y: -3, w: 1, h: 1, id: "D", + fill: util.colors.yellow, + ports: (south: ((id: "in"),))) + element.block(x: 6.25, y: -1.5, w: 0.5, h: 0.5, id: "vref", + fill: util.colors.pink) + wire.wire("s2", ("C-port-out", "D-port-in"), + style: "s-shape", + s-at-mid: "vref.east") + ```, + vertical: true, +) + +#let s-shape-wire-loopback = example( + ``` + // `s-shape` looping back on a single block that + // has a west output and an east input. The wire + // exits west, wraps around below the block via a + // C-shape, and enters from the east. + // + // Since both endpoints share the same y, the + // crossbar's position is set by `s-ratio-mid` + // as an absolute distance below the endpoint y. + element.block(x: 2, y: 0, w: 1.5, h: 1.5, id: "A", + fill: util.colors.orange, + ports: ( + west: ((id: "out"),), + east: ((id: "in"),), + )) + wire.wire("s1", ("A-port-out", "A-port-in"), + style: "s-shape", + s-depth-1: 1.5, s-depth-2: 1.5, + s-ratio-mid: 150%) + + // Negative `s-ratio-mid` routes the crossbar the + // other way (above the block). + element.block(x: 7, y: 0, w: 1.5, h: 1.5, id: "B", + fill: util.colors.yellow, + ports: ( + west: ((id: "out"),), + east: ((id: "in"),), + )) + wire.wire("s2", ("B-port-out", "B-port-in"), + style: "s-shape", + s-depth-1: 1.5, s-depth-2: 1.5, + s-ratio-mid: -150%) + ```, + vertical: true, +) + #let stub = example(``` draw.circle((0, 0), radius: .1, name: "p") wire.stub("p", "north", name: "north", length: 1) diff --git a/gallery/platypus.png b/gallery/platypus.png index 7e0a912..cd954d8 100644 Binary files a/gallery/platypus.png and b/gallery/platypus.png differ diff --git a/gallery/test.png b/gallery/test.png index c4e9480..2c5d0c6 100644 Binary files a/gallery/test.png and b/gallery/test.png differ diff --git a/gallery/test2.png b/gallery/test2.png index c4e9480..2c5d0c6 100644 Binary files a/gallery/test2.png and b/gallery/test2.png differ diff --git a/gallery/test3.png b/gallery/test3.png index 8af0ba5..ac6032a 100644 Binary files a/gallery/test3.png and b/gallery/test3.png differ diff --git a/gallery/test4.png b/gallery/test4.png index 4f36ffc..57ae67b 100644 Binary files a/gallery/test4.png and b/gallery/test4.png differ diff --git a/gallery/test5.png b/gallery/test5.png index e23a31f..ecb2eec 100644 Binary files a/gallery/test5.png and b/gallery/test5.png differ diff --git a/gallery/test6.png b/gallery/test6.png index 409663e..eb67604 100644 Binary files a/gallery/test6.png and b/gallery/test6.png differ diff --git a/gallery/test7.png b/gallery/test7.png index cfe3ee4..99d3505 100644 Binary files a/gallery/test7.png and b/gallery/test7.png differ diff --git a/gallery/test_ushape.png b/gallery/test_ushape.png new file mode 100644 index 0000000..4f13e74 Binary files /dev/null and b/gallery/test_ushape.png differ diff --git a/gallery/test_ushape.typ b/gallery/test_ushape.typ new file mode 100644 index 0000000..0d4902e --- /dev/null +++ b/gallery/test_ushape.typ @@ -0,0 +1,53 @@ +// Demonstrates the u-shape wire style. +// The u-side is auto-detected from the port anchors when both endpoints +// belong to elements (deduced from the port position vs the element's +// center anchor). It can also be set explicitly with u-side. +#import "/src/lib.typ": circuit, element, util, wire + +#set page(width: auto, height: auto, margin: .5cm) + +#circuit({ + // ---- east <-> east (auto-detected) ---- + element.block(x: 0, y: 0, w: 1.5, h: 1.5, id: "eA", + fill: util.colors.orange, + ports: (east: ((id: "out"),))) + element.block(x: 0.5, y: -2.5, w: 1.5, h: 1.5, id: "eB", + fill: util.colors.orange, + ports: (east: ((id: "out"),))) + wire.wire("uEE", ("eA-port-out", "eB-port-out"), + style: "u-shape", u-depth: 0.6, + name: "E-E") + + // ---- west <-> west (auto-detected) ---- + element.block(x: 6, y: 0, w: 1.5, h: 1.5, id: "wA", + fill: util.colors.yellow, + ports: (west: ((id: "in"),))) + element.block(x: 6.5, y: -2.5, w: 1.5, h: 1.5, id: "wB", + fill: util.colors.yellow, + ports: (west: ((id: "in"),))) + wire.wire("uWW", ("wA-port-in", "wB-port-in"), + style: "u-shape", u-depth: 0.6, + name: "W-W") + + // ---- north <-> north (auto-detected) ---- + element.block(x: 10, y: -3, w: 1.5, h: 1.5, id: "nA", + fill: util.colors.green, + ports: (north: ((id: "out"),))) + element.block(x: 12.5, y: -2.5, w: 1.5, h: 1.5, id: "nB", + fill: util.colors.green, + ports: (north: ((id: "out"),))) + wire.wire("uNN", ("nA-port-out", "nB-port-out"), + style: "u-shape", u-depth: 0.6, + name: "N-N") + + // ---- south <-> south (explicit u-side override) ---- + element.block(x: 16, y: 0, w: 1.5, h: 1.5, id: "sA", + fill: util.colors.pink, + ports: (south: ((id: "out"),))) + element.block(x: 18.5, y: -0.5, w: 1.5, h: 1.5, id: "sB", + fill: util.colors.pink, + ports: (south: ((id: "out"),))) + wire.wire("uSS", ("sA-port-out", "sB-port-out"), + style: "u-shape", u-side: "south", u-depth: 0.6, + name: "S-S") +}) diff --git a/manual.pdf b/manual.pdf index f4e1052..5defea9 100644 Binary files a/manual.pdf and b/manual.pdf differ diff --git a/manual.typ b/manual.typ index ebe0c88..f1cfa11 100644 --- a/manual.typ +++ b/manual.typ @@ -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" diff --git a/src/cetz.typ b/src/cetz.typ index 980a619..adb796c 100644 --- a/src/cetz.typ +++ b/src/cetz.typ @@ -1 +1 @@ -#import "@preview/cetz:0.3.4": * \ No newline at end of file +#import "@preview/cetz:0.5.2": * \ No newline at end of file diff --git a/src/circuit.typ b/src/circuit.typ index f89389f..958fa6e 100644 --- a/src/circuit.typ +++ b/src/circuit.typ @@ -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 /// diff --git a/src/util.typ b/src/util.typ index a444434..56af6a6 100644 --- a/src/util.typ +++ b/src/util.typ @@ -73,4 +73,45 @@ #let valid-anchors = ( "center", "north", "east", "west", "south", "north-east", "north-west", "south-east", "south-west" -) \ No newline at end of file +) + +/// 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 +} \ No newline at end of file diff --git a/src/wire.typ b/src/wire.typ index cbf7c4b..fa2b93d 100644 --- a/src/wire.typ +++ b/src/wire.typ @@ -1,9 +1,27 @@ #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 -#let wire-styles = ("direct", "zigzag", "dodge", "guided") +/// +/// The `"u-shape"` style connects two ports that face the same cardinal side. +/// #examples.u-shape-wire +/// +/// The `"u-shape"` crossbar can also be aligned to an anchor via `u-at` +/// (analogous to `zigzag-at`). +/// #examples.u-at +/// +/// The `"s-shape"` style connects two ports that face opposite cardinal sides +/// (east↔west or north↔south) via 5 segments. +/// #examples.s-shape-wire +/// +/// The `"s-shape"` style also supports the vertical axis (north↔south): +/// #examples.s-shape-wire-vertical +/// +/// The `"s-shape"` style can also loop a single block's output back to its +/// own input by wrapping around above (or below) via a C-shape: +/// #examples.s-shape-wire-loopback +#let wire-styles = ("direct", "zigzag", "dodge", "guided", "u-shape", "s-shape") #let signal-width = 1pt #let bus-width = 1.5pt @@ -24,29 +42,95 @@ 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: , to: )` 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) +} + +#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 +152,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 +170,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 @@ -109,6 +193,315 @@ return (points, anchors) } +#let _u-valid-sides = ("east", "west", "north", "south") + +// 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 u-side + // 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-u-shape-wire(pts, u-depth, u-side, u-at, ctx) = { + let start = pts.first() + let end = pts.last() + + // Resolve the (auto|explicit) side + let side = u-side + if side == auto { + let side-start = detect-port-side(start, ctx) + let side-end = detect-port-side(end, ctx) + if side-start != none and side-end != none and side-start != side-end { + panic( + "u-shape: start port faces '" + side-start + + "' but end port faces '" + side-end + + "'; both must face the same side, or set u-side explicitly" + ) + } + if side-start != none { side = side-start } + else if side-end != none { side = side-end } + else { + panic( + "u-shape: could not auto-detect u-side (endpoints are not port anchors); " + + "please set u-side explicitly to one of " + repr(_u-valid-sides) + ) + } + } + if not side in _u-valid-sides { + panic("u-shape: invalid u-side '" + repr(side) + "'; must be one of " + repr(_u-valid-sides)) + } + + let (ctx, s) = coordinate.resolve(ctx, start) + let (ctx, e) = coordinate.resolve(ctx, end) + let (sx, sy, ..) = s + let (ex, ey, ..) = e + + // If `u-at` is set, resolve its coordinate and extract the relevant axis + // component; use it as the crossbar position instead of computing from + // `u-depth`. + let bar-at = none + if u-at != none { + let axis = if side == "east" or side == "west" { "x" } else { "y" } + let at-norm = normalize-scalar-offset(u-at, axis) + let (ctx2, r) = coordinate.resolve(ctx, at-norm) + bar-at = if axis == "x" { r.first() } else { r.at(1) } + } + + let p1 + let p2 + if side == "east" { + let bx = if bar-at != none { bar-at } else { calc.max(sx, ex) + u-depth } + p1 = (bx, sy, 0) + p2 = (bx, ey, 0) + } else if side == "west" { + let bx = if bar-at != none { bar-at } else { calc.min(sx, ex) - u-depth } + p1 = (bx, sy, 0) + p2 = (bx, ey, 0) + } else if side == "north" { + let by = if bar-at != none { bar-at } else { calc.max(sy, ey) + u-depth } + p1 = (sx, by, 0) + p2 = (ex, by, 0) + } else if side == "south" { + let by = if bar-at != none { bar-at } else { calc.min(sy, ey) - u-depth } + p1 = (sx, by, 0) + p2 = (ex, by, 0) + } + + let points = (start, p1, p2, end) + let anchors = ( + "start": start, + "u-start": p1, + "u-end": p2, + "end": end + ) + return (points, anchors) +} + +// Valid combinations of endpoint sides for an S-shape wire. +// Keyed by (sorted) pair; value is the wire axis. +#let _s-axis-from-sides(a, b) = { + if a == none or b == none { return none } + let pair = (a, b) + if ("east" in pair) and ("west" in pair) { return "horizontal" } + if ("north" in pair) and ("south" in pair) { return "vertical" } + return none +} + +// S-shape wire: 5 segments, 4 corners; connects endpoints on opposite sides. +// +// Horizontal axis (east ↔ west): sequence exits/enters through horizontal +// segments; two vertical segments join them via a horizontal crossbar. +// +// Corner layout (horizontal axis): +// start ── c1 +// │ +// c2 ─────────── c3 +// │ +// c4 ── end +// +// (or mirrored, depending on which endpoint faces east vs west, and on the +// relative y of the endpoints; when y_mid is outside the endpoint span the +// shape degenerates into a C-shape.) +#let get-s-shape-wire( + pts, + s-axis, + s-depth-1, + s-ratio-mid, + s-depth-2, + s-at-1, + s-at-mid, + s-at-2, + strict, + ctx, +) = { + let start = pts.first() + let end = pts.last() + + // Detect endpoint sides + let side-start = detect-port-side(start, ctx) + let side-end = detect-port-side(end, ctx) + + // Resolve (auto|explicit) axis + let axis = s-axis + if axis == auto { + let detected = _s-axis-from-sides(side-start, side-end) + if detected == none { + panic( + "s-shape: could not auto-detect s-axis; endpoints must be port anchors " + + "facing opposite sides on the same axis (east+west or north+south). " + + "Detected start='" + repr(side-start) + "', end='" + repr(side-end) + + "'. Set s-axis explicitly to \"horizontal\" or \"vertical\"." + ) + } + axis = detected + } + if axis != "horizontal" and axis != "vertical" { + panic("s-shape: invalid s-axis '" + repr(axis) + "'; must be \"horizontal\" or \"vertical\"") + } + + // Effective depth/ratio (auto → defaults) + let depth-1 = if s-depth-1 == auto { 0.5 } else { s-depth-1 } + let depth-2 = if s-depth-2 == auto { 0.5 } else { s-depth-2 } + let ratio-mid-raw = if s-ratio-mid == auto { 50% } else { s-ratio-mid } + let ratio-mid = ratio-mid-raw / 100% + + let (ctx, s) = coordinate.resolve(ctx, start) + let (ctx, e) = coordinate.resolve(ctx, end) + let (sx, sy, ..) = s + let (ex, ey, ..) = e + + // Determine outward directions from each port + let outward(side) = { + if side == "east" { return (1, 0) } + if side == "west" { return (-1, 0) } + if side == "north" { return (0, 1) } + if side == "south" { return (0, -1) } + return none + } + let out-s = outward(side-start) + let out-e = outward(side-end) + // Fallback when a port side isn't known (e.g. non-port endpoint): infer from + // the axis, assuming the wire flows toward the far endpoint. + if out-s == none { + if axis == "horizontal" { out-s = (if ex > sx { 1 } else { -1 }, 0) } + else { out-s = (0, if ey > sy { 1 } else { -1 }) } + } + if out-e == none { + if axis == "horizontal" { out-e = (if sx > ex { 1 } else { -1 }, 0) } + else { out-e = (0, if sy > ey { 1 } else { -1 }) } + } + + // Resolve the three placement overrides + let resolve-axial(at, ax) = { + if at == none { return none } + let at-norm = normalize-scalar-offset(at, ax) + let (_, r) = coordinate.resolve(ctx, at-norm) + return if ax == "x" { r.first() } else { r.at(1) } + } + + let c1x + let c1y + let c2x + let c2y + let c3x + let c3y + let c4x + let c4y + + if axis == "horizontal" { + // seg1 and seg5 are horizontal; the two verticals sit at x = x1 and x = x2. + let x1-at = resolve-axial(s-at-1, "x") + let x2-at = resolve-axial(s-at-2, "x") + let y-mid-at = resolve-axial(s-at-mid, "y") + + let x1 = if x1-at != none { x1-at } else { sx + out-s.at(0) * depth-1 } + let x2 = if x2-at != none { x2-at } else { ex + out-e.at(0) * depth-2 } + // When the endpoints share the same y (degenerate span), the ratio-based + // formula collapses to sy. Fall back to a unit offset scaled by ratio-mid + // so the crossbar can still be pushed away from the endpoint axis + // (e.g. for single-block loopbacks). Direction defaults to "down" (-y). + let y-mid = if y-mid-at != none { + y-mid-at + } else if ey == sy { + sy - ratio-mid + } else { + sy + ratio-mid * (ey - sy) + } + + c1x = x1; c1y = sy + c2x = x1; c2y = y-mid + c3x = x2; c3y = y-mid + c4x = x2; c4y = ey + + // Sanity checks (strict-only) + if strict { + // seg1 must extend outward: sign(x1 - sx) should match out-s.x + if (x1 - sx) * out-s.at(0) <= 0 { + panic("s-shape: seg1 does not extend outward from start port " + + "(x1=" + str(x1) + ", start.x=" + str(sx) + ", outward.x=" + + str(out-s.at(0)) + "). Adjust s-depth-1 or s-at-1.") + } + if (x2 - ex) * out-e.at(0) <= 0 { + panic("s-shape: seg5 does not extend outward from end port " + + "(x2=" + str(x2) + ", end.x=" + str(ex) + ", outward.x=" + + str(out-e.at(0)) + "). Adjust s-depth-2 or s-at-2.") + } + } + } else { + // vertical axis: seg1 and seg5 are vertical; the two horizontals sit at + // y = y1 and y = y2; the middle crossbar is vertical at x = x-mid. + let y1-at = resolve-axial(s-at-1, "y") + let y2-at = resolve-axial(s-at-2, "y") + let x-mid-at = resolve-axial(s-at-mid, "x") + + let y1 = if y1-at != none { y1-at } else { sy + out-s.at(1) * depth-1 } + let y2 = if y2-at != none { y2-at } else { ey + out-e.at(1) * depth-2 } + // Degenerate-span fallback: see comment in the horizontal branch. + let x-mid = if x-mid-at != none { + x-mid-at + } else if ex == sx { + sx + ratio-mid + } else { + sx + ratio-mid * (ex - sx) + } + + c1x = sx; c1y = y1 + c2x = x-mid; c2y = y1 + c3x = x-mid; c3y = y2 + c4x = ex; c4y = y2 + + if strict { + if (y1 - sy) * out-s.at(1) <= 0 { + panic("s-shape: seg1 does not extend outward from start port " + + "(y1=" + str(y1) + ", start.y=" + str(sy) + ", outward.y=" + + str(out-s.at(1)) + "). Adjust s-depth-1 or s-at-1.") + } + if (y2 - ey) * out-e.at(1) <= 0 { + panic("s-shape: seg5 does not extend outward from end port " + + "(y2=" + str(y2) + ", end.y=" + str(ey) + ", outward.y=" + + str(out-e.at(1)) + "). Adjust s-depth-2 or s-at-2.") + } + } + } + + let c1 = (c1x, c1y, 0) + let c2 = (c2x, c2y, 0) + let c3 = (c3x, c3y, 0) + let c4 = (c4x, c4y, 0) + + let points = (start, c1, c2, c3, c4, end) + let anchors = ( + "start": start, + "u1-start": c1, + "u1-end": c2, + "u2-start": c3, + "u2-end": c4, + "end": end, + ) + return (points, anchors) +} + #let get-guided-wire(pts, margins, sides, center-guides, ctx) = { let start = pts.first() let end = pts.last() @@ -147,18 +540,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,14 +597,26 @@ /// - 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 `-port-` 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: , to: )`, 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") /// - guided-center (array): the horizontal and vertical guides of the center guides (only with style "guided") /// - guided-margins (array): the start and end of guided margins of the wire (only with style "guided") /// - guided-sides (array): the side of start and end array (must be either "north", "south", "west", "east") (only work with style "guided") +/// - u-side (auto, str): The cardinal side both endpoints face (one of "east", "west", "north", "south"). If `auto`, it is deduced from the endpoints when they are port anchors of the form `-port-` by comparing each port's position with its element's `.center` anchor. Auto-detection panics if the two endpoints resolve to different sides, or if neither endpoint is a port anchor (only with style "u-shape") +/// - u-depth (auto, number): Distance between the outermost endpoint (in the `u-side` direction) and the U-bend line (only with style "u-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicitly set `u-at`. +/// - u-at (none, str, array, dictionary): Optional. Anchor-based placement of the U-bend crossbar (only with style "u-shape"). When set, overrides `u-depth` (which must be left at its default `auto`). The crossbar sits on the axis extracted from the given coordinate: `x` for `u-side` in `("east", "west")`, `y` for `("north", "south")`. Accepted forms mirror `zigzag-at`: raw CeTZ anchor name string, any native CeTZ coordinate, or the ergonomic scalar-offset form `(rel: , to: )`, which is rewritten to `(dx, 0)` for east/west and `(0, dy)` for north/south. +/// - s-axis (auto, str): The s-shape wire axis, one of "horizontal" or "vertical" (only with style "s-shape"). If `auto`, it is deduced from the endpoint port anchors: opposite east/west ports yield `"horizontal"`, opposite north/south ports yield `"vertical"`. Auto-detection panics on same-side, cross-axis, or non-port endpoints. +/// - s-depth-1 (auto, number): Distance seg1 (the first outward segment) extends from the start port (only with style "s-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicit `s-at-1`. +/// - s-ratio-mid (auto, ratio): Position of the s-shape crossbar between the endpoints along the axis perpendicular to `s-axis` (only with style "s-shape"). Defaults to `auto`, which resolves to `50%`. Values outside `0%..100%` are allowed and yield a C-shape variant. When the two endpoints share the same perpendicular coordinate (e.g. a single-block loopback where both ports are at the same y), the endpoint span is zero and `s-ratio-mid` is instead interpreted as an absolute offset from that shared coordinate: the crossbar sits `s-ratio-mid` units below the endpoints for horizontal S, or `s-ratio-mid` units right for vertical S (using `1` = ratio unit). Panics if combined with an explicit `s-at-mid`. +/// - s-depth-2 (auto, number): Distance seg5 (the last inward segment) extends from the end port (only with style "s-shape"). Defaults to `auto`, which resolves to `0.5`. Panics if combined with an explicit `s-at-2`. +/// - s-at-1 (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-depth-1` (only with style "s-shape"). Fixes the x (horizontal S) or y (vertical S) of the first vertical bend line. Accepted forms mirror `zigzag-at`. +/// - s-at-mid (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-ratio-mid` (only with style "s-shape"). Fixes the y (horizontal S) or x (vertical S) of the crossbar. Accepted forms mirror `zigzag-at`. +/// - s-at-2 (none, str, array, dictionary): Optional. Anchor-based placement overriding `s-depth-2` (only with style "s-shape"). Fixes the x (horizontal S) or y (vertical S) of the second vertical bend line. Accepted forms mirror `zigzag-at`. +/// - strict (bool): If true, s-shape performs sanity checks and panics when seg1/seg5 do not extend outward from their port (only with style "s-shape"). Defaults to `false`. #let wire( id, pts, bus: false, @@ -227,11 +632,23 @@ 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%) + dodge-margins: (5%, 5%), + u-side: auto, + u-depth: auto, + u-at: none, + s-axis: auto, + s-depth-1: auto, + s-ratio-mid: auto, + s-depth-2: auto, + s-at-1: none, + s-at-mid: none, + s-at-2: none, + strict: false, ) = draw.get-ctx(ctx => { if not style in wire-styles { panic("Invalid wire style '" + style + "'") @@ -240,6 +657,55 @@ 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 } + + // `u-at` validation. + if u-at != none { + if style != "u-shape" { + panic("wire '" + id + "': `u-at` requires style: \"u-shape\" (got \"" + + style + "\")") + } + if u-depth != auto { + panic("wire '" + id + + "': cannot combine `u-at` with an explicit `u-depth`") + } + } + let u-depth-effective = if u-depth == auto { 0.5 } else { u-depth } + + // `s-at-*` validation. + let s-params-set = ( + s-axis != auto or s-depth-1 != auto or s-ratio-mid != auto + or s-depth-2 != auto or s-at-1 != none or s-at-mid != none + or s-at-2 != none + ) + if s-params-set and style != "s-shape" { + panic("wire '" + id + "': `s-*` parameters require style: \"s-shape\" (got \"" + + style + "\")") + } + if s-at-1 != none and s-depth-1 != auto { + panic("wire '" + id + "': cannot combine `s-at-1` with an explicit `s-depth-1`") + } + if s-at-mid != none and s-ratio-mid != auto { + panic("wire '" + id + "': cannot combine `s-at-mid` with an explicit `s-ratio-mid`") + } + if s-at-2 != none and s-depth-2 != auto { + panic("wire '" + id + "': cannot combine `s-at-2` with an explicit `s-depth-2`") + } + + // Normalize user-provided coordinates: (x, y) -> (x, y, 0) + let pts = pts.map(normalize-coord) let stroke = ( paint: color, @@ -256,7 +722,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( @@ -274,9 +759,30 @@ guided-center, ctx ) - } + } else if style == "u-shape" { + (points, anchors) = get-u-shape-wire( + pts, + u-depth-effective, + u-side, + u-at, + ctx + ) + } else if style == "s-shape" { + (points, anchors) = get-s-shape-wire( + pts, + s-axis, + s-depth-1, + s-ratio-mid, + s-depth-2, + s-at-1, + s-at-mid, + s-at-2, + strict, + ctx, + ) + } - let mark = (fill: color) + let mark = (fill: color) if directed { mark = (end: ">", fill: color) } diff --git a/typst.toml b/typst.toml index b83700a..fb35880 100644 --- a/typst.toml +++ b/typst.toml @@ -1,7 +1,7 @@ [package] name = "circuiteria" version = "0.2.1" -compiler = "0.13.1" +compiler = "0.15.0" repository = "https://git.kb28.ch/HEL/circuiteria" entrypoint = "src/lib.typ" authors = [