diff --git a/doc/examples.typ b/doc/examples.typ index a182e59..02f5eef 100644 --- a/doc/examples.typ +++ b/doc/examples.typ @@ -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, @@ -186,9 +211,143 @@ 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)), style: "zigzag", zigzag-ratio: 80%) wire.intersection("w1.zig") ```) + +#let port-shape-attach = example( + ``` + // Pentagon ports expose `.east`, `.west`, `.north`, + // `.south` sub-anchors on the pentagon boundary, + // letting wires attach to any face. + element.block(x: 0, y: 0, w: 2, h: 2, id: "a", + port-shape: "pentagon", + ports: (west: ((id: "p", name: "P"),),)) + draw.circle((-2, 0.5), radius: .05, name: "L") + draw.circle(( 3, 1.5), radius: .05, name: "R") + draw.circle((-1, 2.5), radius: .05, name: "T") + // .west = outer flat base + wire.wire("w1", ("L", "a-port-p.west"), style: "direct") + // .east = tip inside the block + wire.wire("w2", ("R", "a-port-p.east"), + style: "direct", color: red) + // .north = top edge midpoint + wire.wire("w3", ("T", "a-port-p.north"), + style: "direct", color: blue) + ```, + vertical: true, +) 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_group_ports.png b/gallery/test_group_ports.png new file mode 100644 index 0000000..1335031 Binary files /dev/null and b/gallery/test_group_ports.png differ diff --git a/gallery/test_group_ports.typ b/gallery/test_group_ports.typ new file mode 100644 index 0000000..69d5d92 --- /dev/null +++ b/gallery/test_group_ports.typ @@ -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") +}) diff --git a/gallery/test_port_shape.png b/gallery/test_port_shape.png new file mode 100644 index 0000000..c39f90d Binary files /dev/null and b/gallery/test_port_shape.png differ diff --git a/gallery/test_port_shape.typ b/gallery/test_port_shape.typ new file mode 100644 index 0000000..c190f2c --- /dev/null +++ b/gallery/test_port_shape.typ @@ -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")) +}) diff --git a/manual.pdf b/manual.pdf index f4e1052..2428168 100644 Binary files a/manual.pdf and b/manual.pdf differ diff --git a/manual.typ b/manual.typ index ebe0c88..ace2877 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" @@ -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], +) + +#tidy.show-module(element-docs, sort-functions: false, show-module-name: false) #pagebreak() 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/elements/block.typ b/src/elements/block.typ index 5ff6928..276a0f2 100644 --- a/src/elements/block.typ +++ b/src/elements/block.typ @@ -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 ) diff --git a/src/elements/element.typ b/src/elements/element.typ index 09adfd3..64d04df 100644 --- a/src/elements/element.typ +++ b/src/elements/element.typ @@ -1,5 +1,5 @@ -#import "/src/cetz.typ": draw, coordinate -#import "ports.typ": add-ports, add-port +#import "/src/cetz.typ": coordinate, draw +#import "ports.typ": add-port, add-ports #import "../util.typ" #let find-port(ports, id) = { @@ -34,12 +34,26 @@ /// - `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: `""` (same-element port; axis must match, transitively resolved, cycles panic), `(element: "", port: "")` (cross-element; axis not validated; target element must be drawn first), or `""` (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). +/// - `stroke` (stroke): Optional. Per-port override of the pentagon stroke. Defaults to the element stroke. +/// - `fill` (none, color): Optional. Per-port override of the pentagon fill. Defaults to the element fill (or `gray` for clock ports with `shape: "pentagon"`). /// - 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 (``, ``) 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 the ports dictionary above); a per-port `shape: none` also removes the shape for just that port. 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: @@ -54,14 +68,21 @@ 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 - ) + ports: false, + ), ) = draw.get-ctx(ctx => { let width = w let height = h @@ -95,7 +116,7 @@ let used-pct = 100% - margins.at(0) - margins.at(1) let used-height = height * used-pct / 100% top-margin = height * margins.at(0) / 100% - + dy = used-height * (i + 1) / (ports.at(to-side).len() + 1) if not auto-ports { @@ -109,7 +130,7 @@ dy = height top-margin = 0 } - + let (ctx, from-pos) = coordinate.resolve(ctx, from) y = from-pos.at(1) + dy - height + top-margin } @@ -127,19 +148,37 @@ if (name != none) { draw.content( (name: id, anchor: name-anchor), - anchor: if name-anchor in util.valid-anchors {name-anchor} else {"center"}, + anchor: if name-anchor in util.valid-anchors { name-anchor } else { "center" }, padding: 0.5em, - align(center)[*#name*] + align(center)[*#name*], ) } if auto-ports { + let outside-name = if port-names-outside == auto { shell } else { port-names-outside } add-ports( id, - tl, tr, br, bl, + tl, + tr, + br, + bl, ports, ports-margins, - debug: debug.ports + width: width, + height: height, + 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, ) } -}) \ No newline at end of file + + if body != none { + body + } +}) diff --git a/src/elements/group.typ b/src/elements/group.typ index 994072c..64f9279 100644 --- a/src/elements/group.typ +++ b/src/elements/group.typ @@ -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 (``,), (``, ``) or (``, ``, ``, ``) /// - 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, diff --git a/src/elements/ports.typ b/src/elements/ports.typ index b24cdfc..e12d844 100644 --- a/src/elements/ports.typ +++ b/src/elements/ports.typ @@ -1,116 +1,446 @@ #import "/src/cetz.typ": draw -#import "../util.typ": rotate-anchor +#import "../util.typ": opposite-anchor, port-shape-vertices, rotate-anchor #let add-port( - elmt-id, side, port, pos, + 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 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 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, - angle: if name-rotate {90deg} else {0deg}, - name + 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( - pos, - name: id, + anchor-pos, radius: .1, stroke: none, - fill: red + fill: red, ) - - } else { - draw.hide(draw.circle( - pos, - radius: 0, - stroke: none, - name: id - )) } } +// Normalize an `align-with` value to either: +// - `(local: )` → reference to another port of the *same* element +// - `(external: )` → 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: , port: )` → external, anchor = `-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: )` → percentage along the side (local placement) +// - `(external: )` → 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, + tl, + tr, + br, + bl, ports, ports-margins, - debug: false + 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) + "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 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 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) + 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 - ) } + + 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, + ) } -} \ No newline at end of file +} diff --git a/src/util.typ b/src/util.typ index a444434..3fb0ec3 100644 --- a/src/util.typ +++ b/src/util.typ @@ -16,7 +16,7 @@ green: rgb(127, 200, 172), pink: rgb(236, 127, 178), purple: rgb(189, 151, 255), - blue: rgb(127, 203, 235) + blue: rgb(127, 203, 235), ) /// Pads a string on the left with 0s to the given length @@ -47,7 +47,7 @@ north-west: "south-east", north-east: "south-west", south-east: "north-west", - south-west: "north-east" + south-west: "north-east", ).at(anchor) } @@ -66,11 +66,140 @@ north-west: "north-east", north-east: "south-east", south-east: "south-west", - south-west: "north-west" + south-west: "north-west", ).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) } + + // World-cardinal midpoints of the pentagon's bounding box. These are + // named in *world* coordinates (not rotated with the shape), so `.east` + // always sits at the world +x extent of the pentagon, `.north` at +y, etc. + // Consequently the tip's cardinal name follows its world direction: + // - west side, kind "in" → tip world +x → tip = .east + // - west side, kind "out" → tip world -x → tip = .west + // - east side, kind "in" → tip world -x → tip = .west + // - east side, kind "out" → tip world +x → tip = .east + // - north side, kind "in" → tip world -y → tip = .south + // - north side, kind "out" → tip world +y → tip = .north + // - south side, kind "in" → tip world +y → tip = .north + // - south side, kind "out" → tip world -y → tip = .south + // + // The pentagon bounding box extents depend only on the side's orientation + // (horizontal for west/east, vertical for north/south), not on `kind`. + let (hx, hy) = if side == "west" or side == "east" { + (u, u / 2) + } else { + (u / 2, u) + } + let world-east = (hx, 0) + let world-west = (-hx, 0) + let world-north = (0, hy) + let world-south = (0, -hy) + + return ( + vertices: base-vertices.map(rot), + outer: rot(base-outer), + inner: rot(base-inner), + east: world-east, + west: world-west, + north: world-north, + south: world-south, + ) +} + #let valid-anchors = ( - "center", "north", "east", "west", "south", - "north-east", "north-west", "south-east", "south-west" -) \ No newline at end of file + "center", + "north", + "east", + "west", + "south", + "north-east", + "north-west", + "south-east", + "south-west", +) + +/// 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 +} diff --git a/src/wire.typ b/src/wire.typ index cbf7c4b..f6fd158 100644 --- a/src/wire.typ +++ b/src/wire.typ @@ -1,5 +1,5 @@ #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 @@ -68,8 +68,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 +86,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 @@ -147,18 +147,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 { @@ -240,6 +240,9 @@ if pts.len() != 2 { panic("Wrong number of points (got " + str(pts.len()) + " instead of 2)") } + + // Normalize user-provided coordinates: (x, y) -> (x, y, 0) + let pts = pts.map(normalize-coord) let stroke = ( paint: 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 = [