clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

feat: add Squaring diagrams for squared rectangles and Smith diagrams

Dami committed 5ae47fd
Add an edge renderer unit that draws a squared rectangle beside its Smith
diagram. Bouwkamp codes are placed and converted into the circuit; planar
networks with a battery are solved exactly with Kirchhoff and Ohm and
arranged back into squares. The caption reports simple and perfect status
and compound blocks are outlined.

Register the engine across the catalog, editor, normalization profiles,
smoke scripts, docs, and the diagramming skill.
commit
5ae47fd59098b97a92099e95f99aec98be43cff7
parent
7eea6d2be8a8c770788227bb550290899188a9db
author
Dami <dami.etoile@gmail.com>
40 files changed, +1408 -19
Mapps/docs/data/diagram-types.mjs23++
Aapps/docs/docs/create/types/squaring.md68+++++
Aapps/docs/docs/style/types/squaring.md33++
Mapps/docs/static/diagram-types.json19+
Aapps/docs/static/examples/squaring.json5+
Mapps/docs/static/llms-full.txt101+++++++
Mapps/docs/static/llms.txt1+
Mapps/docs/static/skills/diagramming/SKILL.md2+
Mapps/editor/src/diagram-types.js1+
Mapps/editor/src/example-defaults.js1+
Mapps/editor/src/examples.js2+
Mapps/editor/src/format-icons.js3+-
Mapps/editor/src/launcher-catalog.js5+-
Mapps/editor/src/renderer-units.js1+
Mapps/editor/test/editor-languages.test.js2+-
Mapps/editor/test/launcher-catalog.test.js4+-
Mapps/editor/test/renderer-units.test.js4+-
Mapps/editor/test/svg-contract.test.js16+
Aexamples/diagrams/anti-wedge.squaring12+
Aexamples/diagrams/compound.squaring7+
Aexamples/diagrams/order-nine.squaring9+
Mrenderers/README.md8+-
Mrenderers/catalog/scripts/smoke.mjs6+-
Mrenderers/catalog/src/catalog.ts6+
Mrenderers/catalog/test/catalog.test.ts4+-
Mrenderers/catalog/test/gateway.test.ts2+-
Arenderers/edge/config/units/squaring.jsonc10+
Mrenderers/edge/src/adapters/index.ts2+
Arenderers/edge/src/adapters/squaring.ts30++
Mrenderers/edge/src/adapters/types.ts2+-
Arenderers/edge/src/languages/squaring-model.ts443++++++++++++++++++++++++++++++
Arenderers/edge/src/languages/squaring-renderer.ts216+++++++++++++++
Arenderers/edge/src/languages/squaring.ts201++++++++++++++
Arenderers/edge/src/units/squaring.ts9+
Mrenderers/edge/test/adapters.test.ts1+
Arenderers/edge/test/squaring.test.ts162+++++++++++
Mrenderers/scripts/smoke-units.mjs1+
Mrenderers/shared/engines.ts1+
Mshared/svg/index.js2+
Mskills/diagramming/SKILL.md2+
M apps/docs/data/diagram-types.mjs +23 -0
@@ -323,6 +323,29 @@ export const diagramTypes = [
323323 rendererOptions: [],324324 upstream: 'https://pikchr.org/home/doc/trunk/doc/userman.md',325325 },326+ {327+ id: 'squaring',328+ label: 'Squaring',329+ fullName: 'Squared rectangles and Smith diagrams',330+ category: 'Graphs and networks',331+ format: 'Squaring text',332+ summary: 'Squaring draws a squared rectangle, which is a rectangle tiled by squares, next to its Smith diagram. In that circuit every horizontal segment is a node and every square is a unit resistor whose current equals its side.',333+ use: 'Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. It can also build a squaring backwards from a planar network once you choose a battery edge.',334+ syntax: ['Optionally begin with `.view rectangle`, `.view circuit`, `.view overlay`, or `.view both`; the default is `both`.', 'Add `.labels none` to hide side lengths, voltages, and currents.', 'Add `title` followed by text to name the diagram.', 'For a known dissection, write `rectangle <width> x <height>` and then the square sides in Bouwkamp order; parentheses and commas are ignored.', 'Bouwkamp order places each square at the leftmost point of the highest unfilled segment. Squares that share a top edge are listed left to right.', 'For a network, write `battery <positive> <negative>` and then wires as `wire a b` or chains such as `a - b - c`.', 'Every wire has resistance one; the renderer solves Kirchhoff and Ohm exactly, scales the voltages so all sides are whole numbers, and arranges the squares.', 'The battery replaces one edge of the graph, so do not list that edge as a wire. A wire parallel to the battery becomes a full-height square.', 'The network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.', 'The caption reports the order, size, whether the squaring is simple (no smaller rectangle of squares) and perfect (no repeated side), and the battery.', 'Compound blocks are outlined; node color runs from blue at the negative pole to red at the positive pole.', 'Start comments with `#`.'],335+ language: 'text',336+ example: ['.view both', 'title Order 9 simple perfect squared rectangle', '', 'rectangle 33 x 32', 'squares (18 15) (7 8) (14 4) (10 1) (9)'],337+ styleSummary: 'Squaring tints each square by the voltage at its top edge, colors nodes from blue to red by voltage, and scales wire thickness by current. Compound blocks are outlined with a dashed line.',338+ sourceStyle: ['Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.', 'Use `.view overlay` to draw the circuit on top of the squares, which shows how each segment collapses into a node.', 'Use `.labels none` for large dissections where numbers would not fit.', 'Choose a different battery wire in a network to obtain a different squaring from the same graph.', 'Reorder the square list, not the geometry, to change a Bouwkamp dissection.'],339+ rendererOptions: [],340+ limitations: ['Networks are arranged by search, so very large or non-planar networks are rejected instead of drawn.', 'A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery.', 'Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.'],341+ origin: {342+ creator: 'Brooks, Smith, Stone, and Tutte',343+ site: 'The dissection of rectangles into squares (Duke Mathematical Journal, 1940)',344+ siteUrl: 'https://doi.org/10.1215/S0012-7094-40-00718-9',345+ note: 'Diagram.zip implements a text notation and SVG renderer for the squared-rectangle and Smith-diagram correspondence that the four Trinity students discovered.',346+ },347+ upstream: 'https://www.squaring.net/',348+ },326349 {327350 id: 'structurizr',328351 label: 'Structurizr',
A apps/docs/docs/create/types/squaring.md +68 -0
@@ -0,0 +1,68 @@
1+---2+id: create-squaring3+slug: /create/types/squaring4+title: Squaring5+description: Create a Squaring diagram in diagram.zip.6+sidebar_label: Squaring7+---8+9+import DiagramExample from '@site/src/components/DiagramExample';10+11+# Squaring12+13+Squaring draws a squared rectangle, which is a rectangle tiled by squares, next to its Smith diagram. In that circuit every horizontal segment is a node and every square is a unit resistor whose current equals its side.14+15+## Use this type16+17+Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. It can also build a squaring backwards from a planar network once you choose a battery edge.18+19+## Source format20+21+The source format is **Squaring text**. Enter the source in the diagram.zip editor.22+23+## Syntax essentials24+25+- Optionally begin with `.view rectangle`, `.view circuit`, `.view overlay`, or `.view both`; the default is `both`.26+- Add `.labels none` to hide side lengths, voltages, and currents.27+- Add `title` followed by text to name the diagram.28+- For a known dissection, write `rectangle <width> x <height>` and then the square sides in Bouwkamp order; parentheses and commas are ignored.29+- Bouwkamp order places each square at the leftmost point of the highest unfilled segment. Squares that share a top edge are listed left to right.30+- For a network, write `battery <positive> <negative>` and then wires as `wire a b` or chains such as `a - b - c`.31+- Every wire has resistance one; the renderer solves Kirchhoff and Ohm exactly, scales the voltages so all sides are whole numbers, and arranges the squares.32+- The battery replaces one edge of the graph, so do not list that edge as a wire. A wire parallel to the battery becomes a full-height square.33+- The network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.34+- The caption reports the order, size, whether the squaring is simple (no smaller rectangle of squares) and perfect (no repeated side), and the battery.35+- Compound blocks are outlined; node color runs from blue at the negative pole to red at the positive pole.36+- Start comments with `#`.37+38+### Example39+40+```text41+.view both42+title Order 9 simple perfect squared rectangle43+44+rectangle 33 x 3245+squares (18 15) (7 8) (14 4) (10 1) (9)46+```47+48+<DiagramExample engine="squaring" label="Squaring" sourceUrl="/examples/squaring.json" />49+50+## Origin51+52+Squaring originates with [Brooks, Smith, Stone, and Tutte](https://doi.org/10.1215/S0012-7094-40-00718-9) at [The dissection of rectangles into squares (Duke Mathematical Journal, 1940)](https://doi.org/10.1215/S0012-7094-40-00718-9). Diagram.zip implements a text notation and SVG renderer for the squared-rectangle and Smith-diagram correspondence that the four Trinity students discovered.53+54+## Limitations55+56+- Networks are arranged by search, so very large or non-planar networks are rejected instead of drawn.57+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery.58+- Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.59+60+## Related pages61+62+- [Style Squaring](/style/types/squaring)63+- [General presentation settings](/style/presentation)64+- [Open Squaring in the editor](https://diagram.zip/?type=squaring)65+66+## Upstream reference67+68+[Squaring documentation](https://www.squaring.net/)
A apps/docs/docs/style/types/squaring.md +33 -0
@@ -0,0 +1,33 @@
1+---2+id: style-squaring3+slug: /style/types/squaring4+title: Squaring5+description: Style a Squaring diagram in diagram.zip.6+sidebar_label: Squaring7+---8+9+# Style Squaring10+11+Squaring tints each square by the voltage at its top edge, colors nodes from blue to red by voltage, and scales wire thickness by current. Compound blocks are outlined with a dashed line.12+13+Start with the [general presentation settings](/style/presentation). Edit shared or raw presentation in Details, and keep renderer-specific styling in Source.14+15+## Source controls16+17+- Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.18+- Use `.view overlay` to draw the circuit on top of the squares, which shows how each segment collapses into a node.19+- Use `.labels none` for large dissections where numbers would not fit.20+- Choose a different battery wire in a network to obtain a different squaring from the same graph.21+- Reorder the square list, not the geometry, to change a Bouwkamp dissection.22+## Limitations23+24+- Networks are arranged by search, so very large or non-planar networks are rejected instead of drawn.25+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery.26+- Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.27+28+## Related pages29+30+- [Create a Squaring diagram](/create/types/squaring)31+- [General presentation settings](/style/presentation)32+- [SVG normalization and version contracts](/style/svg-normalization)33+- [Open Squaring in the editor](https://diagram.zip/?type=squaring)
M apps/docs/static/diagram-types.json +19 -0
A apps/docs/static/examples/squaring.json +5 -0
M apps/docs/static/llms-full.txt +101 -0
@@ -2482,6 +2482,107 @@ Start with the [general presentation settings](/style/presentation). Edit shared
24822482 24832483 ---24842484 2485+---2486+id: create-squaring2487+slug: /create/types/squaring2488+title: Squaring2489+description: Create a Squaring diagram in diagram.zip.2490+sidebar_label: Squaring2491+---2492+2493+# Squaring2494+2495+Squaring draws a squared rectangle, which is a rectangle tiled by squares, next to its Smith diagram. In that circuit every horizontal segment is a node and every square is a unit resistor whose current equals its side.2496+2497+## Use this type2498+2499+Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. It can also build a squaring backwards from a planar network once you choose a battery edge.2500+2501+## Source format2502+2503+The source format is **Squaring text**. Enter the source in the diagram.zip editor.2504+2505+## Syntax essentials2506+2507+- Optionally begin with `.view rectangle`, `.view circuit`, `.view overlay`, or `.view both`; the default is `both`.2508+- Add `.labels none` to hide side lengths, voltages, and currents.2509+- Add `title` followed by text to name the diagram.2510+- For a known dissection, write `rectangle <width> x <height>` and then the square sides in Bouwkamp order; parentheses and commas are ignored.2511+- Bouwkamp order places each square at the leftmost point of the highest unfilled segment. Squares that share a top edge are listed left to right.2512+- For a network, write `battery <positive> <negative>` and then wires as `wire a b` or chains such as `a - b - c`.2513+- Every wire has resistance one; the renderer solves Kirchhoff and Ohm exactly, scales the voltages so all sides are whole numbers, and arranges the squares.2514+- The battery replaces one edge of the graph, so do not list that edge as a wire. A wire parallel to the battery becomes a full-height square.2515+- The network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.2516+- The caption reports the order, size, whether the squaring is simple (no smaller rectangle of squares) and perfect (no repeated side), and the battery.2517+- Compound blocks are outlined; node color runs from blue at the negative pole to red at the positive pole.2518+- Start comments with `#`.2519+2520+### Example2521+2522+```text2523+.view both2524+title Order 9 simple perfect squared rectangle2525+2526+rectangle 33 x 322527+squares (18 15) (7 8) (14 4) (10 1) (9)2528+```2529+2530+## Origin2531+2532+Squaring originates with [Brooks, Smith, Stone, and Tutte](https://doi.org/10.1215/S0012-7094-40-00718-9) at [The dissection of rectangles into squares (Duke Mathematical Journal, 1940)](https://doi.org/10.1215/S0012-7094-40-00718-9). Diagram.zip implements a text notation and SVG renderer for the squared-rectangle and Smith-diagram correspondence that the four Trinity students discovered.2533+2534+## Limitations2535+2536+- Networks are arranged by search, so very large or non-planar networks are rejected instead of drawn.2537+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery.2538+- Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.2539+2540+## Related pages2541+2542+- [Style Squaring](/style/types/squaring)2543+- [General presentation settings](/style/presentation)2544+- [Open Squaring in the editor](https://diagram.zip/?type=squaring)2545+2546+## Upstream reference2547+2548+[Squaring documentation](https://www.squaring.net/)2549+2550+---2551+id: style-squaring2552+slug: /style/types/squaring2553+title: Squaring2554+description: Style a Squaring diagram in diagram.zip.2555+sidebar_label: Squaring2556+---2557+2558+# Style Squaring2559+2560+Squaring tints each square by the voltage at its top edge, colors nodes from blue to red by voltage, and scales wire thickness by current. Compound blocks are outlined with a dashed line.2561+2562+Start with the [general presentation settings](/style/presentation). Edit shared or raw presentation in Details, and keep renderer-specific styling in Source.2563+2564+## Source controls2565+2566+- Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.2567+- Use `.view overlay` to draw the circuit on top of the squares, which shows how each segment collapses into a node.2568+- Use `.labels none` for large dissections where numbers would not fit.2569+- Choose a different battery wire in a network to obtain a different squaring from the same graph.2570+- Reorder the square list, not the geometry, to change a Bouwkamp dissection.2571+## Limitations2572+2573+- Networks are arranged by search, so very large or non-planar networks are rejected instead of drawn.2574+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery.2575+- Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.2576+2577+## Related pages2578+2579+- [Create a Squaring diagram](/create/types/squaring)2580+- [General presentation settings](/style/presentation)2581+- [SVG normalization and version contracts](/style/svg-normalization)2582+- [Open Squaring in the editor](https://diagram.zip/?type=squaring)2583+2584+---2585+24852586 ---24862587 id: create-structurizr24872588 slug: /create/types/structurizr
M apps/docs/static/llms.txt +1 -0
M apps/docs/static/skills/diagramming/SKILL.md +2 -0
@@ -28,6 +28,8 @@ Common choices include Mermaid for compact general diagrams, PlantUML for UML-or
2828 2929 Use Tabular Recipe Notation (`trn`) for recipes, crafting trees, and assembly transformations where ingredient quantities feed short actions and meaningful outcomes. Put `.layout combined` or `.layout individual` in the TRN source. TRN originates with [Michael Chu at Cooking for Engineers](https://www.cookingforengineers.com/); diagram.zip provides its own text DSL and SVG renderer for that tabular recipe concept. For OCR transcription, preserve the printed quantities and wording, and put uncertain readings in `#` comments instead of guessing.3030 31+Use Squaring (`squaring`) for squared rectangles, squared squares, and their Smith diagrams: the electric-circuit view in which every horizontal segment is a node and every square is a unit resistor whose current is the square's side. Write a known dissection as `rectangle <width> x <height>` followed by the square sides in Bouwkamp order, or build one backwards from a planar network with `battery <positive> <negative>` and wires such as `a - b - c`. The caption reports whether the result is simple (no smaller rectangle of squares) and perfect (no repeated side). The correspondence comes from [Brooks, Smith, Stone, and Tutte](https://doi.org/10.1215/S0012-7094-40-00718-9); diagram.zip provides its own notation and SVG renderer for it.32+3133 The complete renderer set and syntax links are in [llms.txt](https://docs.diagram.zip/llms.txt) and [diagram-types.json](https://docs.diagram.zip/diagram-types.json). Semantic routing metadata is in [diagram-skills.json](https://docs.diagram.zip/diagram-skills.json).3234 3335 Do not infer standards support from a similarly named renderer mode. Confirm the renderer's output and interchange fidelity.
M apps/editor/src/diagram-types.js +1 -0
M apps/editor/src/example-defaults.js +1 -0
M apps/editor/src/examples.js +2 -0
M apps/editor/src/format-icons.js +2 -1
@@ -2,7 +2,7 @@ const ICON_KIND_BY_TYPE = Object.freeze({
22 plantuml: 'sequence', mermaid: 'flow', seqdiag: 'sequence', actdiag: 'flow', blockdiag: 'flow', bpmn: 'flow', trn: 'flow',33 c4plantuml: 'layers', structurizr: 'layers', d2: 'graph', graphviz: 'graph', nomnoml: 'classes', umlet: 'classes',44 dbml: 'database', erd: 'database', vega: 'chart', vegalite: 'chart',5- nwdiag: 'network', packetdiag: 'packet', rackdiag: 'rack', bytefield: 'packet', wavedrom: 'wave', symbolator: 'chip', wireviz: 'cable',5+ nwdiag: 'network', packetdiag: 'packet', rackdiag: 'rack', bytefield: 'packet', wavedrom: 'wave', symbolator: 'chip', wireviz: 'cable', squaring: 'squares',66 excalidraw: 'drawing', diagramsnet: 'canvas', ditaa: 'terminal', goat: 'terminal', svgbob: 'terminal', pikchr: 'drawing', tikz: 'typeset',77 })88
@@ -23,6 +23,7 @@ const ICON_PATHS = Object.freeze({
2323 drawing: '<path d="m11 45 4-14L40 6l10 10-25 25-14 4Z"/><path d="m16 30 10 10M36 10l10 10M11 45l9-3-6-6-3 9Z"/>',2424 canvas: '<rect x="7" y="10" width="46" height="39" rx="3"/><circle cx="19" cy="23" r="5"/><path d="m10 43 13-12 8 7 8-10 11 15M43 16h4M45 14v4"/>',2525 terminal: '<rect x="6" y="10" width="48" height="40" rx="4"/><path d="m14 22 7 6-7 6M27 36h14"/>',26+ squares: '<rect x="7" y="7" width="26" height="26"/><rect x="33" y="7" width="20" height="20"/><rect x="33" y="27" width="14" height="14"/><rect x="47" y="27" width="6" height="6"/><rect x="47" y="33" width="6" height="6"/><rect x="33" y="41" width="12" height="12"/><rect x="45" y="39" width="8" height="8"/><rect x="7" y="33" width="20" height="20"/><rect x="27" y="33" width="6" height="6"/><rect x="27" y="39" width="6" height="6"/><rect x="27" y="45" width="6" height="8"/><rect x="45" y="47" width="8" height="6"/>',2627 typeset: '<path d="M13 12h34M30 12v36M21 48h18M14 21h11M35 39h11"/><path d="m42 21 5 5-5 5"/>',2728 })2829
M apps/editor/src/launcher-catalog.js +3 -2
M apps/editor/src/renderer-units.js +1 -0
M apps/editor/test/editor-languages.test.js +1 -1
M apps/editor/test/launcher-catalog.test.js +2 -2
M apps/editor/test/renderer-units.test.js +2 -2
M apps/editor/test/svg-contract.test.js +16 -0
@@ -181,6 +181,22 @@ test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {
181181 }182182 })183183 184+test('normalizes squaring ink and canvas while preserving voltage colors', () => {185+ const squaring = canonicalizeSvg(186+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 40"><rect class="squaring-canvas" x="0" y="0" width="80" height="40" fill="#ffffff"></rect><g class="squaring-square" data-side="2"><rect x="4" y="4" width="20" height="20" fill="rgb(254,226,226)" stroke="#111827" stroke-width="1"></rect><text x="14" y="14" fill="#111827">2</text></g><path class="squaring-wire" d="M0 0L1 1" fill="none" stroke="#111827"></path><text class="squaring-summary" fill="#666666">Order 3</text></svg>',187+ metadata, 'squaring', 'diagramzip-squaring@1',188+ )189+190+ assert.match(squaring, /data-dz-profile="neutral-svg-semantic-2"/)191+ assert.match(squaring, /class="squaring-canvas"[^>]*data-dz-role="canvas"/)192+ assert.match(squaring, /fill="rgb\(254,226,226\)"[^>]*data-dz-stroke="line"/)193+ assert.doesNotMatch(squaring, /fill="rgb\(254,226,226\)"[^>]*data-dz-fill=/)194+ // The side label sits on a preserved tint, so it keeps its authored ink instead of a themed role.195+ assert.match(squaring, /<text x="14" y="14" fill="#111827">2<\/text>/)196+ assert.match(squaring, /class="squaring-summary"[^>]*data-dz-fill="ink-muted"/)197+ assert.match(materializeSvg(squaring, 'dark-transparent'), /data-dz-appearance="dark-transparent"/)198+})199+184200 test('normalizes TRN table structure and relationship zones', () => {185201 const trn = canonicalizeSvg(186202 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 40"><rect class="trn-canvas" width="80" height="40" fill="#fbf8dc"></rect><text class="trn-title" fill="#111827">Bread</text><rect class="trn-table-surface" y="10" width="80" height="30" fill="white"></rect><path class="trn-zone-shape trn-branch-4" fill="white" stroke="#65a268"></path><text class="trn-operation-label" fill="#111827">mix</text></svg>',
A examples/diagrams/anti-wedge.squaring +12 -0
A examples/diagrams/compound.squaring +7 -0
A examples/diagrams/order-nine.squaring +9 -0
M renderers/README.md +4 -4
@@ -11,7 +11,7 @@ This directory contains the complete Cloudflare renderer plane for diagram.zip.
1111 | `shared/` | Engine identities shared by the catalog and runtime units |1212 | `scripts/` | Cross-runtime deployment and smoke-test orchestration |1313 14-All 31 catalog engines have a final renderer path. No renderer uses the Fly.io application. No renderer can use an origin fallback.14+All 32 catalog engines have a final renderer path. No renderer uses the Fly.io application. No renderer can use an origin fallback.1515 1616 Each engine has a hostname:1717
@@ -37,7 +37,7 @@ npm --prefix edge run deploy:dry-run
3737 3838 Run `npm --prefix edge run smoke:renderers` after all units are deployed. This command checks one repository fixture or capability response for each catalog engine. It checks coverage and SVG structure. It does not compare pixels.3939 40-Run `npm --prefix edge run smoke:catalog` against a local catalog Worker. This command checks the health route, the 31-engine catalog, and the absence of the old render proxy.40+Run `npm --prefix edge run smoke:catalog` against a local catalog Worker. This command checks the health route, the 32-engine catalog, and the absence of the old render proxy.4141 4242 ## HTTP unit contract4343
@@ -79,12 +79,12 @@ Some units translate source into another engine. The pipeline header lists each
7979 8080 | Runtime | Count | Engines |8181 | --- | ---: | --- |82-| Worker JavaScript | 10 | BPMN, Bytefield, Excalidraw, Nomnoml, Structurizr, TRN, UMLet, Vega, Vega-Lite, WaveDrom |82+| Worker JavaScript | 11 | BPMN, Bytefield, Excalidraw, Nomnoml, Squaring, Structurizr, TRN, UMLet, Vega, Vega-Lite, WaveDrom |8383 | Worker Python | 8 | BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag, Symbolator, WireViz |8484 | Worker WebAssembly | 11 | PlantUML, C4 PlantUML, GraphViz, DBML, ERD, D2, Ditaa, GoAT, Pikchr, Svgbob, TikZ |8585 | Browser Run | 2 | Mermaid, diagrams.net |8686 87-The total is 31 of 31 engines. The Fly.io Java rendering image is not part of this renderer plane.87+The total is 32 of 32 engines. The Fly.io Java rendering image is not part of this renderer plane.8888 8989 ## Output policy9090
M renderers/catalog/scripts/smoke.mjs +3 -3
M renderers/catalog/src/catalog.ts +6 -0
M renderers/catalog/test/catalog.test.ts +2 -2
M renderers/catalog/test/gateway.test.ts +1 -1
A renderers/edge/config/units/squaring.jsonc +10 -0
M renderers/edge/src/adapters/index.ts +2 -0
A renderers/edge/src/adapters/squaring.ts +30 -0
M renderers/edge/src/adapters/types.ts +1 -1
A renderers/edge/src/languages/squaring-model.ts +443 -0
@@ -0,0 +1,443 @@
1+import { RenderError } from '../runtime/errors'2+import type { SquaringDocument, SquaringNetworkSource, SquaringRectangleSource } from './squaring'3+4+export interface SquaringSquare {5+ index: number6+ x: number7+ y: number8+ side: number9+ top: number10+ bottom: number11+}12+13+export interface SquaringNode {14+ id: string15+ index: number16+ y: number17+ x0: number18+ x1: number19+ voltage: number20+}21+22+export interface SquaringBlock {23+ x: number24+ y: number25+ width: number26+ height: number27+ squares: number28+}29+30+export interface SquaringModel {31+ form: 'rectangle' | 'network'32+ width: number33+ height: number34+ squares: SquaringSquare[]35+ nodes: SquaringNode[]36+ positive: number37+ negative: number38+ order: number39+ perfect: boolean40+ repeatedSides: number[]41+ simple: boolean42+ blocks: SquaringBlock[]43+}44+45+const SEARCH_BUDGET = 250_00046+const MAXIMUM_ORDER = 2_00047+48+function sourceError(line: number, message: string): never {49+ throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)50+}51+52+interface Segment {53+ x0: number54+ x1: number55+ depth: number56+ node: number57+}58+59+function mergeSkyline(segments: Segment[]): Segment[] {60+ const merged: Segment[] = []61+ for (const segment of segments) {62+ const previous = merged.at(-1)63+ if (previous !== undefined && previous.depth === segment.depth && previous.node === segment.node && previous.x1 === segment.x0) {64+ previous.x1 = segment.x165+ } else {66+ merged.push({ ...segment })67+ }68+ }69+ return merged70+}71+72+function lowestSegment(skyline: Segment[]): number {73+ let best = 074+ for (let index = 1; index < skyline.length; index += 1) {75+ if ((skyline[index]?.depth ?? Infinity) < (skyline[best]?.depth ?? Infinity)) best = index76+ }77+ return best78+}79+80+// Bouwkamp placement: every square goes to the leftmost point of the highest81+// unfilled horizontal segment, in source order.82+function placeBouwkamp(rectangle: SquaringRectangleSource): Array<{ x: number; y: number; side: number }> {83+ const { width, height, sides, line } = rectangle84+ if (sides.length > MAXIMUM_ORDER) sourceError(line, `At most ${MAXIMUM_ORDER} squares are supported.`)85+ const area = sides.reduce((sum, side) => sum + side * side, 0)86+ if (area !== width * height) {87+ sourceError(line, `The squares cover an area of ${area}, but a ${width} × ${height} rectangle has area ${width * height}.`)88+ }89+ let skyline: Segment[] = [{ x0: 0, x1: width, depth: 0, node: 0 }]90+ const placed: Array<{ x: number; y: number; side: number }> = []91+ sides.forEach((side, index) => {92+ const at = lowestSegment(skyline)93+ const segment = skyline[at]94+ if (segment === undefined) sourceError(line, 'The rectangle is already full.')95+ if (segment.x0 + side > segment.x1 || segment.depth + side > height) {96+ sourceError(line, `Square ${index + 1} (side ${side}) does not fit at column ${segment.x0}, row ${segment.depth}; only ${segment.x1 - segment.x0} × ${height - segment.depth} is free there.`)97+ }98+ placed.push({ x: segment.x0, y: segment.depth, side })99+ const replacement: Segment[] = [{ x0: segment.x0, x1: segment.x0 + side, depth: segment.depth + side, node: 0 }]100+ if (segment.x0 + side < segment.x1) replacement.push({ x0: segment.x0 + side, x1: segment.x1, depth: segment.depth, node: 0 })101+ skyline = mergeSkyline([...skyline.slice(0, at), ...replacement, ...skyline.slice(at + 1)])102+ })103+ return placed104+}105+106+// Every maximal horizontal segment of the dissection is a node of the Smith107+// diagram; its voltage is the height of the segment above the bottom edge.108+function nodesFromGeometry(squares: Array<{ x: number; y: number; side: number }>, width: number, height: number): {109+ nodes: SquaringNode[]110+ topOf: number[]111+ bottomOf: number[]112+} {113+ const intervals = new Map<number, Array<[number, number]>>()114+ const add = (y: number, x0: number, x1: number): void => {115+ const list = intervals.get(y) ?? []116+ list.push([x0, x1])117+ intervals.set(y, list)118+ }119+ add(0, 0, width)120+ add(height, 0, width)121+ for (const square of squares) {122+ add(square.y, square.x, square.x + square.side)123+ add(square.y + square.side, square.x, square.x + square.side)124+ }125+ const nodes: SquaringNode[] = []126+ for (const y of [...intervals.keys()].sort((a, b) => a - b)) {127+ const list = (intervals.get(y) ?? []).sort((a, b) => a[0] - b[0])128+ let current: [number, number] | null = null129+ const flush = (): void => {130+ if (current === null) return131+ nodes.push({ id: '', index: nodes.length, y, x0: current[0], x1: current[1], voltage: height - y })132+ current = null133+ }134+ for (const [x0, x1] of list) {135+ if (current !== null && x0 <= current[1]) current[1] = Math.max(current[1], x1)136+ else {137+ flush()138+ current = [x0, x1]139+ }140+ }141+ flush()142+ }143+ nodes.forEach((node, index) => {144+ node.id = index === 0 ? 'top' : index === nodes.length - 1 ? 'bottom' : `n${index}`145+ })146+ const find = (y: number, x: number): number => {147+ const node = nodes.find(candidate => candidate.y === y && candidate.x0 <= x && x < candidate.x1)148+ if (node === undefined) throw new RenderError(422, 'invalid_source', `No horizontal segment contains column ${x} at row ${y}.`)149+ return node.index150+ }151+ return {152+ nodes,153+ topOf: squares.map(square => find(square.y, square.x)),154+ bottomOf: squares.map(square => find(square.y + square.side, square.x)),155+ }156+}157+158+// Exact rational arithmetic keeps the Kirchhoff solution integral after scaling.159+type Fraction = { n: bigint; d: bigint }160+161+function gcd(a: bigint, b: bigint): bigint {162+ let x = a < 0n ? -a : a163+ let y = b < 0n ? -b : b164+ while (y !== 0n) [x, y] = [y, x % y]165+ return x166+}167+168+function fraction(n: bigint, d = 1n): Fraction {169+ if (d === 0n) throw new Error('Division by zero.')170+ const sign = d < 0n ? -1n : 1n171+ const divisor = gcd(n, d) || 1n172+ return { n: (sign * n) / divisor, d: (sign * d) / divisor }173+}174+175+const add = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d + b.n * a.d, a.d * b.d)176+const sub = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d - b.n * a.d, a.d * b.d)177+const mul = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.n, a.d * b.d)178+const div = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d, a.d * b.n)179+180+interface SolvedNetwork {181+ voltages: number[]182+ currents: number[]183+}184+185+function solveNetwork(network: SquaringNetworkSource): SolvedNetwork {186+ const { nodes, wires, positive, negative, line } = network187+ const indexOf = new Map(nodes.map((id, index) => [id, index]))188+ const positiveIndex = indexOf.get(positive) ?? 0189+ const negativeIndex = indexOf.get(negative) ?? 0190+ const interior = nodes.map((_, index) => index).filter(index => index !== positiveIndex && index !== negativeIndex)191+ const column = new Map(interior.map((node, position) => [node, position]))192+ const size = interior.length193+194+ const matrix: Fraction[][] = interior.map(() => interior.map(() => fraction(0n)))195+ const rhs: Fraction[] = interior.map(() => fraction(0n))196+ for (const wire of wires) {197+ const a = indexOf.get(wire.from) ?? 0198+ const b = indexOf.get(wire.to) ?? 0199+ for (const [self, other] of [[a, b], [b, a]] as const) {200+ const row = column.get(self)201+ if (row === undefined) continue202+ const rowValues = matrix[row]203+ if (rowValues === undefined) continue204+ rowValues[row] = add(rowValues[row] ?? fraction(0n), fraction(1n))205+ const otherColumn = column.get(other)206+ if (otherColumn !== undefined) rowValues[otherColumn] = sub(rowValues[otherColumn] ?? fraction(0n), fraction(1n))207+ else if (other === positiveIndex) rhs[row] = add(rhs[row] ?? fraction(0n), fraction(1n))208+ }209+ }210+211+ // Gaussian elimination with exact fractions.212+ for (let pivot = 0; pivot < size; pivot += 1) {213+ let pivotRow = pivot214+ while (pivotRow < size && (matrix[pivotRow]?.[pivot]?.n ?? 0n) === 0n) pivotRow += 1215+ if (pivotRow === size) {216+ sourceError(line, 'The network is not connected: every node must have a wire path to both battery nodes.')217+ }218+ if (pivotRow !== pivot) {219+ ;[matrix[pivot], matrix[pivotRow]] = [matrix[pivotRow] as Fraction[], matrix[pivot] as Fraction[]]220+ ;[rhs[pivot], rhs[pivotRow]] = [rhs[pivotRow] as Fraction, rhs[pivot] as Fraction]221+ }222+ const pivotValues = matrix[pivot] as Fraction[]223+ const pivotValue = pivotValues[pivot] as Fraction224+ for (let row = 0; row < size; row += 1) {225+ if (row === pivot) continue226+ const rowValues = matrix[row] as Fraction[]227+ const factor = div(rowValues[pivot] as Fraction, pivotValue)228+ if (factor.n === 0n) continue229+ for (let col = pivot; col < size; col += 1) {230+ rowValues[col] = sub(rowValues[col] as Fraction, mul(factor, pivotValues[col] as Fraction))231+ }232+ rhs[row] = sub(rhs[row] as Fraction, mul(factor, rhs[pivot] as Fraction))233+ }234+ }235+236+ const solution: Fraction[] = nodes.map(() => fraction(0n))237+ solution[positiveIndex] = fraction(1n)238+ interior.forEach((node, position) => {239+ solution[node] = div(rhs[position] as Fraction, (matrix[position] as Fraction[])[position] as Fraction)240+ })241+242+ let scale = 1n243+ for (const value of solution) scale = (scale * value.d) / gcd(scale, value.d)244+ const integral = solution.map(value => (value.n * scale) / value.d)245+ const currentValues = wires.map(wire => (integral[indexOf.get(wire.from) ?? 0] ?? 0n) - (integral[indexOf.get(wire.to) ?? 0] ?? 0n))246+ let reduction = 0n247+ for (const current of currentValues) reduction = gcd(reduction, current)248+ if (reduction === 0n) sourceError(line, 'No current flows through the network.')249+ const toNumber = (value: bigint): number => {250+ const reduced = value / reduction251+ if (reduced > BigInt(Number.MAX_SAFE_INTEGER) || reduced < -BigInt(Number.MAX_SAFE_INTEGER)) {252+ sourceError(line, 'The network produces square sides that are too large to draw.')253+ }254+ return Number(reduced)255+ }256+ return { voltages: integral.map(toNumber), currents: currentValues.map(toNumber) }257+}258+259+interface OrientedWire {260+ index: number261+ from: number262+ to: number263+ side: number264+}265+266+function distinctPermutations<T>(items: T[], key: (item: T) => string): T[][] {267+ const sorted = [...items].sort((a, b) => key(a).localeCompare(key(b)))268+ const results: T[][] = []269+ const used = sorted.map(() => false)270+ const current: T[] = []271+ const walk = (): void => {272+ if (current.length === sorted.length) {273+ results.push([...current])274+ return275+ }276+ let previous: string | null = null277+ sorted.forEach((item, index) => {278+ if (used[index]) return279+ const itemKey = key(item)280+ if (itemKey === previous) return281+ previous = itemKey282+ used[index] = true283+ current.push(item)284+ walk()285+ current.pop()286+ used[index] = false287+ })288+ }289+ walk()290+ return results291+}292+293+// Rebuild the dissection from the circuit: fill the rectangle top-down, and at294+// every horizontal segment try each left-to-right order of the squares that295+// hang from it. Polyhedral networks admit one arrangement up to mirroring.296+function arrangeNetwork(network: SquaringNetworkSource, solved: SolvedNetwork): { squares: SquaringSquare[]; nodes: SquaringNode[]; positive: number; negative: number } {297+ const { nodes, wires, positive, negative, line } = network298+ const indexOf = new Map(nodes.map((id, index) => [id, index]))299+ const positiveIndex = indexOf.get(positive) ?? 0300+ const negativeIndex = indexOf.get(negative) ?? 0301+ const oriented: OrientedWire[] = wires.map((wire, index) => {302+ const a = indexOf.get(wire.from) ?? 0303+ const b = indexOf.get(wire.to) ?? 0304+ const current = solved.currents[index] ?? 0305+ if (current === 0) {306+ sourceError(wire.line, `Wire ${wire.from} - ${wire.to} carries no current because both nodes settle at the same voltage, so it cannot become a square. Choose a different battery.`)307+ }308+ return current > 0 ? { index, from: a, to: b, side: current } : { index, from: b, to: a, side: -current }309+ })310+ const outgoing = nodes.map<OrientedWire[]>(() => [])311+ const inflow = nodes.map(() => 0)312+ for (const wire of oriented) {313+ outgoing[wire.from]?.push(wire)314+ inflow[wire.to] = (inflow[wire.to] ?? 0) + wire.side315+ }316+ const width = outgoing[positiveIndex]?.reduce((sum, wire) => sum + wire.side, 0) ?? 0317+ const height = solved.voltages[positiveIndex] ?? 0318+ const placement = new Map<number, { x: number; y: number }>()319+ let budget = SEARCH_BUDGET320+321+ const search = (skyline: Segment[]): boolean => {322+ budget -= 1323+ if (budget < 0) sourceError(line, 'The network has too many possible arrangements to explore. Choose a battery on the outer face of a polyhedral network.')324+ const at = lowestSegment(skyline)325+ const segment = skyline[at]326+ if (segment === undefined) return false327+ if (segment.depth >= height) return true328+ const node = segment.node329+ if (node === negativeIndex) return false330+ let start = at331+ while (start > 0 && skyline[start - 1]?.depth === segment.depth && skyline[start - 1]?.node === node) start -= 1332+ let end = at333+ while (end + 1 < skyline.length && skyline[end + 1]?.depth === segment.depth && skyline[end + 1]?.node === node) end += 1334+ const x0 = skyline[start]?.x0 ?? 0335+ const x1 = skyline[end]?.x1 ?? 0336+ const hanging = outgoing[node] ?? []337+ const outflow = hanging.reduce((sum, wire) => sum + wire.side, 0)338+ if (x1 - x0 !== outflow) return false339+ for (const order of distinctPermutations(hanging, wire => `${wire.side}:${wire.to}`)) {340+ const replacement: Segment[] = []341+ let x = x0342+ for (const wire of order) {343+ placement.set(wire.index, { x, y: segment.depth })344+ replacement.push({ x0: x, x1: x + wire.side, depth: segment.depth + wire.side, node: wire.to })345+ x += wire.side346+ }347+ if (search(mergeSkyline([...skyline.slice(0, start), ...replacement, ...skyline.slice(end + 1)]))) return true348+ for (const wire of order) placement.delete(wire.index)349+ }350+ return false351+ }352+353+ if (!search([{ x0: 0, x1: width, depth: 0, node: positiveIndex }])) {354+ sourceError(line, `The network cannot be arranged into a squared rectangle with the battery between ${positive} and ${negative}. Check that the wires form a planar network and try another battery.`)355+ }356+357+ const squares: SquaringSquare[] = oriented.map(wire => {358+ const at = placement.get(wire.index) ?? { x: 0, y: 0 }359+ return { index: wire.index, x: at.x, y: at.y, side: wire.side, top: wire.from, bottom: wire.to }360+ })361+ const x0 = nodes.map(() => Infinity)362+ for (const square of squares) x0[square.bottom] = Math.min(x0[square.bottom] ?? Infinity, square.x)363+ x0[positiveIndex] = 0364+ const modelNodes: SquaringNode[] = nodes.map((id, index) => {365+ const voltage = solved.voltages[index] ?? 0366+ const left = x0[index] === Infinity ? 0 : (x0[index] ?? 0)367+ const span = index === positiveIndex ? width : inflow[index] ?? 0368+ return { id, index, y: height - voltage, x0: left, x1: left + span, voltage }369+ })370+ return { squares, nodes: modelNodes, positive: positiveIndex, negative: negativeIndex }371+}372+373+// A compound block is a proper sub-rectangle tiled by at least two squares.374+function findBlocks(squares: SquaringSquare[], width: number, height: number): SquaringBlock[] {375+ const xs = [...new Set(squares.flatMap(square => [square.x, square.x + square.side]))].sort((a, b) => a - b)376+ const ys = [...new Set(squares.flatMap(square => [square.y, square.y + square.side]))].sort((a, b) => a - b)377+ const blocks: SquaringBlock[] = []378+ for (let left = 0; left < xs.length; left += 1) {379+ const x0 = xs[left] ?? 0380+ for (let right = left + 1; right < xs.length; right += 1) {381+ const x1 = xs[right] ?? 0382+ const overlapping = squares.filter(square => square.x < x1 && square.x + square.side > x0)383+ const straddlers = overlapping.filter(square => square.x < x0 || square.x + square.side > x1)384+ const inside = overlapping.filter(square => square.x >= x0 && square.x + square.side <= x1)385+ const clean = ys.filter(y => !overlapping.some(square => square.y < y && square.y + square.side > y))386+ for (const y0 of clean) {387+ let limit = height388+ for (const square of straddlers) {389+ if (square.y + square.side > y0) limit = Math.min(limit, square.y)390+ }391+ for (const y1 of clean) {392+ if (y1 <= y0) continue393+ if (y1 > limit) break394+ if (x0 === 0 && x1 === width && y0 === 0 && y1 === height) continue395+ const count = inside.filter(square => square.y >= y0 && square.y + square.side <= y1).length396+ if (count >= 2) blocks.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0, squares: count })397+ }398+ }399+ }400+ }401+ const contains = (outer: SquaringBlock, inner: SquaringBlock): boolean =>402+ outer !== inner && outer.x <= inner.x && outer.y <= inner.y && outer.x + outer.width >= inner.x + inner.width && outer.y + outer.height >= inner.y + inner.height403+ return blocks.filter(block => !blocks.some(other => contains(other, block)))404+}405+406+function analyse(form: 'rectangle' | 'network', width: number, height: number, squares: SquaringSquare[], nodes: SquaringNode[], positive: number, negative: number): SquaringModel {407+ const counts = new Map<number, number>()408+ for (const square of squares) counts.set(square.side, (counts.get(square.side) ?? 0) + 1)409+ const repeatedSides = [...counts.entries()].filter(([, count]) => count > 1).map(([side]) => side).sort((a, b) => a - b)410+ const blocks = findBlocks(squares, width, height)411+ return {412+ form,413+ width,414+ height,415+ squares,416+ nodes,417+ positive,418+ negative,419+ order: squares.length,420+ perfect: repeatedSides.length === 0,421+ repeatedSides,422+ simple: blocks.length === 0,423+ blocks,424+ }425+}426+427+export function buildSquaring(document: SquaringDocument): SquaringModel {428+ if (document.rectangle !== null) {429+ const rectangle = document.rectangle430+ const placed = placeBouwkamp(rectangle)431+ const { nodes, topOf, bottomOf } = nodesFromGeometry(placed, rectangle.width, rectangle.height)432+ const squares = placed.map((square, index) => ({ index, ...square, top: topOf[index] ?? 0, bottom: bottomOf[index] ?? 0 }))433+ return analyse('rectangle', rectangle.width, rectangle.height, squares, nodes, 0, nodes.length - 1)434+ }435+ if (document.network === null) throw new RenderError(422, 'invalid_source', 'The squaring source is empty.')436+ const network = document.network437+ if (network.wires.length > MAXIMUM_ORDER) sourceError(network.line, `At most ${MAXIMUM_ORDER} wires are supported.`)438+ const solved = solveNetwork(network)439+ const arranged = arrangeNetwork(network, solved)440+ const width = arranged.nodes[arranged.positive]?.x1 ?? 0441+ const height = arranged.nodes[arranged.positive]?.voltage ?? 0442+ return analyse('network', width, height, arranged.squares, arranged.nodes, arranged.positive, arranged.negative)443+}
A renderers/edge/src/languages/squaring-renderer.ts +216 -0
@@ -0,0 +1,216 @@
1+import type { SquaringDocument } from './squaring'2+import type { SquaringModel, SquaringNode, SquaringSquare } from './squaring-model'3+4+const MARGIN = 165+const PANEL_GAP = 446+const PANEL_TITLE_HEIGHT = 267+const CAPTION_HEIGHT = 448+const BATTERY_GUTTER = 409+const TARGET_SIZE = 48010+const FONT_FAMILY = 'Arial,Helvetica,sans-serif'11+const INK = '#111827'12+const MUTED = '#666666'13+const WIRE = '#111827'14+const OVERLAY_WIRE = '#1e293b'15+const BLOCK = '#db2777'16+17+interface Panel {18+ x: number19+ y: number20+ width: number21+ height: number22+}23+24+function escapeXml(value: string): string {25+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')26+}27+28+function round(value: number): string {29+ return String(Math.round(value * 100) / 100)30+}31+32+function mix(a: [number, number, number], b: [number, number, number], t: number): string {33+ const channel = (index: 0 | 1 | 2): number => Math.round(a[index] + (b[index] - a[index]) * t)34+ return `rgb(${channel(0)},${channel(1)},${channel(2)})`35+}36+37+// Voltage runs from the negative pole (blue) through neutral to the positive pole (red).38+function voltageColor(t: number, tint: boolean): string {39+ const clamped = Math.min(1, Math.max(0, t))40+ const blue: [number, number, number] = tint ? [219, 234, 254] : [37, 99, 235]41+ const neutral: [number, number, number] = tint ? [241, 245, 249] : [148, 163, 184]42+ const red: [number, number, number] = tint ? [254, 226, 226] : [220, 38, 38]43+ return clamped < 0.5 ? mix(blue, neutral, clamped * 2) : mix(neutral, red, (clamped - 0.5) * 2)44+}45+46+function squareSvg(square: SquaringSquare, model: SquaringModel, panel: Panel, unit: number, labels: boolean, overlay: boolean): string {47+ const x = panel.x + square.x * unit48+ const y = panel.y + square.y * unit49+ const size = square.side * unit50+ const topVoltage = model.nodes[square.top]?.voltage ?? model.height51+ const fill = voltageColor((topVoltage - square.side / 2) / model.height, true)52+ const fontSize = Math.min(16, size * 0.42)53+ // In the overlay the wire runs down the middle of the square, so the label moves beside it.54+ const labelX = overlay ? x + size / 2 + 6 : x + size / 255+ const label = labels && size >= 1056+ ? `<text x="${round(labelX)}" y="${round(y + size / 2)}" text-anchor="${overlay ? 'start' : 'middle'}" dominant-baseline="central" fill="${INK}" font-size="${round(fontSize)}" font-family="${FONT_FAMILY}">${square.side}</text>`57+ : ''58+ return `<g class="squaring-square" data-index="${square.index}" data-side="${square.side}" data-x="${square.x}" data-y="${square.y}"><title>Square ${square.side} at (${square.x}, ${square.y})</title><rect x="${round(x)}" y="${round(y)}" width="${round(size)}" height="${round(size)}" fill="${fill}" stroke="${INK}" stroke-width="1"/>${label}</g>`59+}60+61+function blocksSvg(model: SquaringModel, panel: Panel, unit: number): string {62+ return model.blocks.map(block => {63+ const inset = 264+ return `<rect class="squaring-block" data-squares="${block.squares}" x="${round(panel.x + block.x * unit + inset)}" y="${round(panel.y + block.y * unit + inset)}" width="${round(block.width * unit - inset * 2)}" height="${round(block.height * unit - inset * 2)}" fill="none" stroke="${BLOCK}" stroke-width="2.5" stroke-dasharray="7 4"><title>Compound block ${block.width} × ${block.height} of ${block.squares} squares</title></rect>`65+ }).join('')66+}67+68+function nodeCenter(node: SquaringNode, panel: Panel, unit: number): { x: number; y: number } {69+ return { x: panel.x + ((node.x0 + node.x1) / 2) * unit, y: panel.y + node.y * unit }70+}71+72+function wireSvg(square: SquaringSquare, model: SquaringModel, panel: Panel, unit: number, maxSide: number, showCurrent: boolean, overlay: boolean): string {73+ const stroke = overlay ? OVERLAY_WIRE : WIRE74+ const top = model.nodes[square.top]75+ const bottom = model.nodes[square.bottom]76+ if (top === undefined || bottom === undefined) return ''77+ const a = nodeCenter(top, panel, unit)78+ const b = nodeCenter(bottom, panel, unit)79+ const cx = panel.x + (square.x + square.side / 2) * unit80+ const size = square.side * unit81+ const radius = Math.max(0, Math.min(8, size / 3, Math.abs(cx - a.x) / 2, Math.abs(cx - b.x) / 2))82+ const width = 1 + 2.4 * (square.side / maxSide)83+ const towardsA = a.x < cx ? -1 : 184+ const towardsB = b.x < cx ? -1 : 185+ const path = [86+ `M${round(a.x)} ${round(a.y)}`,87+ `L${round(cx + towardsA * radius)} ${round(a.y)}`,88+ `Q${round(cx)} ${round(a.y)} ${round(cx)} ${round(a.y + radius)}`,89+ `L${round(cx)} ${round(b.y - radius)}`,90+ `Q${round(cx)} ${round(b.y)} ${round(cx + towardsB * radius)} ${round(b.y)}`,91+ `L${round(b.x)} ${round(b.y)}`,92+ ].join(' ')93+ const midY = (a.y + b.y) / 294+ const arrow = size >= 1495+ ? `<path d="M${round(cx - 3.5)} ${round(midY - 3)} L${round(cx + 3.5)} ${round(midY - 3)} L${round(cx)} ${round(midY + 3.5)} Z" fill="${stroke}"/>`96+ : ''97+ const label = showCurrent && size >= 1498+ ? `<text x="${round(cx + 6)}" y="${round(midY - 7)}" text-anchor="start" dominant-baseline="central" fill="${MUTED}" font-size="10" font-family="${FONT_FAMILY}">${square.side}</text>`99+ : ''100+ return `<g class="squaring-wire" data-from="${escapeXml(top.id)}" data-to="${escapeXml(bottom.id)}" data-current="${square.side}"><title>Wire ${escapeXml(top.id)} → ${escapeXml(bottom.id)} carries ${square.side}</title><path d="${path}" fill="none" stroke="${stroke}" stroke-width="${round(width)}" stroke-linecap="round" stroke-linejoin="round"/>${arrow}${label}</g>`101+}102+103+function nodeSvg(node: SquaringNode, model: SquaringModel, panel: Panel, unit: number, rung: boolean, showVoltage: boolean): string {104+ const center = nodeCenter(node, panel, unit)105+ const color = voltageColor(node.voltage / model.height, false)106+ const rungLine = rung107+ ? `<line x1="${round(panel.x + node.x0 * unit)}" y1="${round(center.y)}" x2="${round(panel.x + node.x1 * unit)}" y2="${round(center.y)}" stroke="${color}" stroke-width="3" stroke-linecap="round"/>`108+ : ''109+ const pole = node.index === model.positive ? '+' : node.index === model.negative ? '−' : ''110+ // Wires leave every node horizontally, so labels sit above or below the rung instead of on it.111+ const labelX = pole === '' ? center.x + 7 : center.x112+ const labelY = node.index === model.negative ? center.y + 13 : center.y - 10113+ const anchor = pole === '' ? 'start' : 'middle'114+ const label = showVoltage115+ ? `<text x="${round(labelX)}" y="${round(labelY)}" text-anchor="${anchor}" dominant-baseline="central" fill="${INK}" font-size="11" font-weight="700" font-family="${FONT_FAMILY}">${pole === '' ? '' : `${pole} `}${node.voltage}</text>`116+ : ''117+ return `<g class="squaring-node" data-node-id="${escapeXml(node.id)}" data-voltage="${node.voltage}"><title>Node ${escapeXml(node.id)} at ${node.voltage}</title>${rungLine}<circle cx="${round(center.x)}" cy="${round(center.y)}" r="5" fill="${color}" stroke="white" stroke-width="1.5"/>${label}</g>`118+}119+120+function batterySvg(model: SquaringModel, panel: Panel, unit: number): string {121+ const positive = model.nodes[model.positive]122+ const negative = model.nodes[model.negative]123+ if (positive === undefined || negative === undefined) return ''124+ const a = nodeCenter(positive, panel, unit)125+ const b = nodeCenter(negative, panel, unit)126+ const gx = panel.x - BATTERY_GUTTER / 2 - 4127+ const midY = (a.y + b.y) / 2128+ return `<g class="squaring-battery"><title>Battery: ${escapeXml(positive.id)} is the positive pole at ${positive.voltage}, ${escapeXml(negative.id)} is the negative pole at 0</title>` +129+ `<path d="M${round(a.x)} ${round(a.y)} L${round(gx)} ${round(a.y)} L${round(gx)} ${round(midY - 7)} M${round(gx)} ${round(midY + 7)} L${round(gx)} ${round(b.y)} L${round(b.x)} ${round(b.y)}" fill="none" stroke="${WIRE}" stroke-width="1.5" stroke-dasharray="4 3"/>` +130+ `<line x1="${round(gx - 9)}" y1="${round(midY - 4)}" x2="${round(gx + 9)}" y2="${round(midY - 4)}" stroke="${WIRE}" stroke-width="2.5"/>` +131+ `<line x1="${round(gx - 4)}" y1="${round(midY + 4)}" x2="${round(gx + 4)}" y2="${round(midY + 4)}" stroke="${WIRE}" stroke-width="2.5"/>` +132+ `<text x="${round(gx - 12)}" y="${round(midY - 8)}" text-anchor="end" dominant-baseline="central" fill="${INK}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">+</text>` +133+ `<text x="${round(gx - 12)}" y="${round(midY + 9)}" text-anchor="end" dominant-baseline="central" fill="${INK}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">−</text>` +134+ `</g>`135+}136+137+function panelTitle(text: string, panel: Panel): string {138+ return `<text class="squaring-panel-title" x="${round(panel.x)}" y="${round(panel.y - 9)}" fill="${MUTED}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(text)}</text>`139+}140+141+function frame(panel: Panel): string {142+ return `<rect x="${round(panel.x)}" y="${round(panel.y)}" width="${round(panel.width)}" height="${round(panel.height)}" fill="none" stroke="${INK}" stroke-width="1.5"/>`143+}144+145+function rectanglePanel(model: SquaringModel, panel: Panel, unit: number, labels: boolean, overlay = false): string {146+ const squares = model.squares.map(square => squareSvg(square, model, panel, unit, labels, overlay)).join('')147+ return `<g class="squaring-rectangle">${squares}${blocksSvg(model, panel, unit)}${frame(panel)}</g>`148+}149+150+function circuitPanel(model: SquaringModel, panel: Panel, unit: number, overlay: boolean, labels: boolean): string {151+ const maxSide = Math.max(...model.squares.map(square => square.side))152+ const wires = model.squares.map(square => wireSvg(square, model, panel, unit, maxSide, labels && !overlay, overlay)).join('')153+ const nodes = model.nodes.map(node => nodeSvg(node, model, panel, unit, overlay, labels && !overlay)).join('')154+ return `<g class="squaring-circuit" data-overlay="${overlay}">${batterySvg(model, panel, unit)}${wires}${nodes}</g>`155+}156+157+function defaultTitle(document: SquaringDocument, model: SquaringModel): string {158+ if (document.title !== '') return document.title159+ return model.width === model.height ? `Squared square ${model.width} × ${model.height}` : `Squared rectangle ${model.width} × ${model.height}`160+}161+162+export function describeSquaring(model: SquaringModel): string {163+ const parts = [`Order ${model.order}`, `${model.width} × ${model.height}`]164+ parts.push(model.simple ? 'simple' : `compound (${model.blocks.length} ${model.blocks.length === 1 ? 'block' : 'blocks'} outlined)`)165+ parts.push(model.perfect ? 'perfect' : `imperfect (${model.repeatedSides.length} repeated ${model.repeatedSides.length === 1 ? 'side' : 'sides'}: ${model.repeatedSides.join(', ')})`)166+ if (model.form === 'network') {167+ const positive = model.nodes[model.positive]?.id ?? ''168+ const negative = model.nodes[model.negative]?.id ?? ''169+ parts.push(`battery ${positive} → ${negative}`)170+ }171+ return parts.join(' · ')172+}173+174+export function renderSquaring(document: SquaringDocument, model: SquaringModel): string {175+ const unit = Math.min(60, Math.max(0.5, TARGET_SIZE / Math.max(model.width, model.height)))176+ const labels = document.labels === 'sides'177+ const view = document.view178+ const panelWidth = model.width * unit179+ const panelHeight = model.height * unit180+ const top = MARGIN + PANEL_TITLE_HEIGHT181+ const panels: string[] = []182+ let x = MARGIN183+184+ if (view === 'rectangle' || view === 'both') {185+ const panel = { x, y: top, width: panelWidth, height: panelHeight }186+ panels.push(panelTitle('Squared rectangle', panel), rectanglePanel(model, panel, unit, labels))187+ x += panelWidth + PANEL_GAP188+ }189+ if (view === 'circuit' || view === 'both') {190+ x += BATTERY_GUTTER191+ const panel = { x, y: top, width: panelWidth, height: panelHeight }192+ panels.push(panelTitle('Smith diagram', panel), circuitPanel(model, panel, unit, false, labels))193+ x += panelWidth194+ }195+ if (view === 'overlay') {196+ x += BATTERY_GUTTER197+ const panel = { x, y: top, width: panelWidth, height: panelHeight }198+ panels.push(panelTitle('Smith diagram over the squared rectangle', panel), rectanglePanel(model, panel, unit, labels, true), circuitPanel(model, panel, unit, true, labels))199+ x += panelWidth200+ }201+202+ const width = Math.max(x + MARGIN, 320)203+ const height = top + panelHeight + CAPTION_HEIGHT + MARGIN204+ const title = defaultTitle(document, model)205+ const summary = describeSquaring(model)206+ const captionY = top + panelHeight + 20207+208+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${round(width)} ${round(height)}" width="${round(width)}" height="${round(height)}" class="squaring-diagram" data-view="${view}" data-form="${model.form}" data-order="${model.order}" data-width="${model.width}" data-height="${model.height}" data-simple="${model.simple}" data-perfect="${model.perfect}">209+ <title>${escapeXml(title)}</title>210+ <desc>${escapeXml(summary)}. Each square is a wire carrying current equal to its side; each horizontal segment is a node whose voltage is its height above the bottom edge.</desc>211+ <rect class="squaring-canvas" x="0" y="0" width="${round(width)}" height="${round(height)}" fill="#ffffff"/>212+ ${panels.join('\n ')}213+ <text class="squaring-title" x="${MARGIN}" y="${round(captionY)}" fill="${INK}" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>214+ <text class="squaring-summary" x="${MARGIN}" y="${round(captionY + 17)}" fill="${MUTED}" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(summary)}</text>215+</svg>`216+}
A renderers/edge/src/languages/squaring.ts +201 -0
@@ -0,0 +1,201 @@
1+import { RenderError } from '../runtime/errors'2+3+export type SquaringView = 'rectangle' | 'circuit' | 'overlay' | 'both'4+export type SquaringLabels = 'sides' | 'none'5+6+export interface SquaringWire {7+ from: string8+ to: string9+ line: number10+}11+12+export interface SquaringRectangleSource {13+ width: number14+ height: number15+ sides: number[]16+ line: number17+}18+19+export interface SquaringNetworkSource {20+ positive: string21+ negative: string22+ nodes: string[]23+ wires: SquaringWire[]24+ line: number25+}26+27+export interface SquaringDocument {28+ view: SquaringView29+ labels: SquaringLabels30+ title: string31+ rectangle: SquaringRectangleSource | null32+ network: SquaringNetworkSource | null33+}34+35+const IDENTIFIER = /^[A-Za-z0-9_]+$/36+const CHAIN = /^[A-Za-z0-9_]+(?:\s*-+\s*[A-Za-z0-9_]+)+$/37+const RECTANGLE = /^rectangle\s+(\d+)\s*(?:x|×|by)\s*(\d+)$/i38+const NUMBER_LIST = /^[\d\s(),]+$/39+const VIEWS: readonly SquaringView[] = ['rectangle', 'circuit', 'overlay', 'both']40+const LABELS: readonly SquaringLabels[] = ['sides', 'none']41+42+function sourceError(line: number, message: string): never {43+ throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)44+}45+46+function stripComment(raw: string): string {47+ let quoted = false48+ for (let index = 0; index < raw.length; index += 1) {49+ const character = raw[index]50+ if (character === '"') quoted = !quoted51+ else if (character === '#' && !quoted) return raw.slice(0, index)52+ }53+ return raw54+}55+56+function identifier(value: string, line: number, role: string): string {57+ if (!IDENTIFIER.test(value)) sourceError(line, `${role} "${value}" must use letters, digits, or underscores.`)58+ return value59+}60+61+function positiveInteger(value: string, line: number, role: string): number {62+ const number = Number(value)63+ if (!Number.isSafeInteger(number) || number <= 0) sourceError(line, `${role} must be a positive whole number, not "${value}".`)64+ return number65+}66+67+function parseSides(text: string, line: number): number[] {68+ const tokens = text.replaceAll(/[(),]/g, ' ').trim().split(/\s+/).filter(token => token !== '')69+ return tokens.map(token => positiveInteger(token, line, 'Each square side'))70+}71+72+export function parseSquaring(source: string): SquaringDocument {73+ let view: SquaringView | null = null74+ let labels: SquaringLabels | null = null75+ let title = ''76+ let rectangle: SquaringRectangleSource | null = null77+ let battery: { positive: string; negative: string; line: number } | null = null78+ const wires: SquaringWire[] = []79+ const nodes: string[] = []80+ let firstNetworkLine = 081+82+ const rememberNode = (id: string): void => {83+ if (!nodes.includes(id)) nodes.push(id)84+ }85+ const addWire = (from: string, to: string, line: number): void => {86+ if (from === to) sourceError(line, `A wire cannot connect node "${from}" to itself.`)87+ if (rectangle !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')88+ if (firstNetworkLine === 0) firstNetworkLine = line89+ rememberNode(from)90+ rememberNode(to)91+ wires.push({ from, to, line })92+ }93+94+ const lines = source.split(/\r?\n/)95+ for (let index = 0; index < lines.length; index += 1) {96+ const line = index + 197+ const text = stripComment(lines[index] ?? '').trim()98+ if (text === '') continue99+100+ if (text.startsWith('.')) {101+ const [directive = '', value = '', ...rest] = text.slice(1).split(/\s+/)102+ if (rest.length > 0) sourceError(line, `The .${directive} directive takes one value.`)103+ if (directive === 'view') {104+ if (view !== null) sourceError(line, 'The .view directive may appear only once.')105+ if (!VIEWS.includes(value as SquaringView)) sourceError(line, `Unknown view "${value}". Use rectangle, circuit, overlay, or both.`)106+ view = value as SquaringView107+ } else if (directive === 'labels') {108+ if (labels !== null) sourceError(line, 'The .labels directive may appear only once.')109+ if (!LABELS.includes(value as SquaringLabels)) sourceError(line, `Unknown labels value "${value}". Use sides or none.`)110+ labels = value as SquaringLabels111+ } else {112+ sourceError(line, `Unknown directive ".${directive}". Use .view or .labels.`)113+ }114+ continue115+ }116+117+ const [keyword = '', ...restTokens] = text.split(/\s+/)118+ const rest = text.slice(keyword.length).trim()119+120+ if (keyword === 'title') {121+ if (rest === '') sourceError(line, 'A title needs text after the keyword.')122+ title = rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2 ? rest.slice(1, -1) : rest123+ continue124+ }125+126+ const rectangleMatch = RECTANGLE.exec(text)127+ if (rectangleMatch !== null) {128+ if (rectangle !== null) sourceError(line, 'Only one rectangle may be declared.')129+ if (wires.length > 0 || battery !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')130+ rectangle = {131+ width: positiveInteger(rectangleMatch[1] ?? '', line, 'The rectangle width'),132+ height: positiveInteger(rectangleMatch[2] ?? '', line, 'The rectangle height'),133+ sides: [],134+ line,135+ }136+ continue137+ }138+139+ if (keyword === 'squares' || NUMBER_LIST.test(text)) {140+ if (rectangle === null) sourceError(line, 'Declare the rectangle with "rectangle <width> x <height>" before listing squares.')141+ const sides = parseSides(keyword === 'squares' ? rest : text, line)142+ if (sides.length === 0) sourceError(line, 'List at least one square side.')143+ rectangle.sides.push(...sides)144+ continue145+ }146+147+ if (keyword === 'battery') {148+ if (battery !== null) sourceError(line, 'Only one battery may be declared.')149+ if (rectangle !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')150+ if (restTokens.length !== 2) sourceError(line, 'Write the battery as "battery <positive node> <negative node>".')151+ const positive = identifier(restTokens[0] ?? '', line, 'The positive node')152+ const negative = identifier(restTokens[1] ?? '', line, 'The negative node')153+ if (positive === negative) sourceError(line, 'The battery needs two different nodes.')154+ if (firstNetworkLine === 0) firstNetworkLine = line155+ rememberNode(positive)156+ rememberNode(negative)157+ battery = { positive, negative, line }158+ continue159+ }160+161+ if (keyword === 'wire') {162+ if (restTokens.length !== 2) sourceError(line, 'Write a wire as "wire <node> <node>" or "<node> - <node>".')163+ addWire(identifier(restTokens[0] ?? '', line, 'A node'), identifier(restTokens[1] ?? '', line, 'A node'), line)164+ continue165+ }166+167+ if (CHAIN.test(text)) {168+ const chain = text.split(/\s*-+\s*/).map(node => identifier(node, line, 'A node'))169+ for (let position = 1; position < chain.length; position += 1) {170+ addWire(chain[position - 1] ?? '', chain[position] ?? '', line)171+ }172+ continue173+ }174+175+ sourceError(line, `Unrecognized line "${text}". Expected a rectangle, squares, battery, wire, title, or directive.`)176+ }177+178+ if (rectangle === null && battery === null && wires.length === 0) {179+ sourceError(1, 'Declare a rectangle with its squares, or a network with a battery and wires.')180+ }181+182+ if (rectangle !== null) {183+ if (rectangle.sides.length < 2) sourceError(rectangle.line, 'A squared rectangle needs at least two squares.')184+ return { view: view ?? 'both', labels: labels ?? 'sides', title, rectangle, network: null }185+ }186+187+ if (battery === null) sourceError(firstNetworkLine, 'A network needs a battery: "battery <positive node> <negative node>".')188+ if (wires.length === 0) sourceError(battery.line, 'A network needs at least one wire.')189+ const connectedToWire = new Set(wires.flatMap(wire => [wire.from, wire.to]))190+ for (const pole of [battery.positive, battery.negative]) {191+ if (!connectedToWire.has(pole)) sourceError(battery.line, `Battery node "${pole}" must be connected to at least one wire.`)192+ }193+194+ return {195+ view: view ?? 'both',196+ labels: labels ?? 'sides',197+ title,198+ rectangle: null,199+ network: { positive: battery.positive, negative: battery.negative, nodes, wires, line: firstNetworkLine },200+ }201+}
A renderers/edge/src/units/squaring.ts +9 -0
M renderers/edge/test/adapters.test.ts +1 -0
A renderers/edge/test/squaring.test.ts +162 -0
@@ -0,0 +1,162 @@
1+import { describe, expect, it } from 'vitest'2+import orderNine from '../../../examples/diagrams/order-nine.squaring?raw'3+import antiWedge from '../../../examples/diagrams/anti-wedge.squaring?raw'4+import compound from '../../../examples/diagrams/compound.squaring?raw'5+import { squaringAdapter } from '../src/adapters/squaring'6+import { parseSquaring } from '../src/languages/squaring'7+import { buildSquaring } from '../src/languages/squaring-model'8+import type { RenderRequest } from '../src/runtime/types'9+10+function request(source: string, options: Record<string, string> = {}): RenderRequest {11+ return {12+ engine: 'squaring',13+ source,14+ format: 'svg',15+ options,16+ metadata: { title: '', description: '' },17+ presentation: { background: '', padding: 0, frame: false },18+ }19+}20+21+const render = (source: string, options: Record<string, string> = {}) => squaringAdapter.render(request(source, options), new AbortController().signal)22+23+describe('squaring language', () => {24+ it('parses a Bouwkamp rectangle with grouped, bare, and commented square lists', () => {25+ const document = parseSquaring(orderNine)26+27+ expect(document.view).toBe('both')28+ expect(document.labels).toBe('sides')29+ expect(document.title).toBe('Order 9 simple perfect squared rectangle')30+ expect(document.rectangle).toMatchObject({ width: 33, height: 32, sides: [18, 15, 7, 8, 14, 4, 10, 1, 9] })31+ expect(document.network).toBeNull()32+ expect(parseSquaring('rectangle 3x2\n2, 1, 1 # trailing comment').rectangle?.sides).toEqual([2, 1, 1])33+ })34+35+ it('parses a network with a battery, wire lines, and chains', () => {36+ const document = parseSquaring(antiWedge)37+38+ expect(document.rectangle).toBeNull()39+ expect(document.network?.positive).toBe('a')40+ expect(document.network?.negative).toBe('e')41+ expect(document.network?.nodes).toEqual(['a', 'e', 'b', 'c', 'd', 'f'])42+ expect(document.network?.wires).toHaveLength(9)43+ expect(parseSquaring('battery p n\nwire p n').network?.wires).toEqual([{ from: 'p', to: 'n', line: 2 }])44+ })45+46+ it('rejects mixed, incomplete, and malformed sources with line numbers', () => {47+ expect(() => parseSquaring('squares 1 2')).toThrow('Line 1: Declare the rectangle')48+ expect(() => parseSquaring('rectangle 3 x 2\nsquares 2 1 1\nbattery a b')).toThrow('Line 3: A squaring is either a rectangle')49+ expect(() => parseSquaring('a - b')).toThrow('Line 1: A network needs a battery')50+ expect(() => parseSquaring('battery a b\nc - d')).toThrow('Battery node "a" must be connected')51+ expect(() => parseSquaring('.view sideways\nrectangle 3 x 2\n2 1 1')).toThrow('Line 1: Unknown view "sideways"')52+ expect(() => parseSquaring('rectangle 3 x 2\n2 1 1\nbogus line')).toThrow('Line 3: Unrecognized line')53+ expect(() => parseSquaring('rectangle 3 x 2\n2')).toThrow('at least two squares')54+ })55+})56+57+describe('squaring model', () => {58+ it('places Bouwkamp squares and derives the Smith diagram from horizontal segments', () => {59+ const model = buildSquaring(parseSquaring(orderNine))60+61+ expect(model).toMatchObject({ form: 'rectangle', width: 33, height: 32, order: 9, simple: true, perfect: true, repeatedSides: [], blocks: [] })62+ expect(model.squares.map(square => [square.side, square.x, square.y])).toEqual([63+ [18, 0, 0], [15, 18, 0], [7, 18, 15], [8, 25, 15], [14, 0, 18], [4, 14, 18], [10, 14, 22], [1, 24, 22], [9, 24, 23],64+ ])65+ expect(model.nodes.map(node => [node.id, node.voltage, node.x0, node.x1])).toEqual([66+ ['top', 32, 0, 33], ['n1', 17, 18, 33], ['n2', 14, 0, 18], ['n3', 10, 14, 25], ['n4', 9, 24, 33], ['bottom', 0, 0, 33],67+ ])68+ // Kirchhoff: current into every interior node equals current out of it.69+ for (const node of model.nodes.slice(1, -1)) {70+ const inflow = model.squares.filter(square => square.bottom === node.index).reduce((sum, square) => sum + square.side, 0)71+ const outflow = model.squares.filter(square => square.top === node.index).reduce((sum, square) => sum + square.side, 0)72+ expect(inflow).toBe(outflow)73+ }74+ })75+76+ it('solves a polyhedral network into an integer squared rectangle', () => {77+ const model = buildSquaring(parseSquaring(antiWedge))78+79+ expect(model).toMatchObject({ form: 'network', width: 15, height: 11, order: 9, simple: true, perfect: false, repeatedSides: [1, 4, 5, 6] })80+ expect(model.nodes.find(node => node.id === 'a')?.voltage).toBe(11)81+ expect(model.nodes.find(node => node.id === 'e')?.voltage).toBe(0)82+ expect(model.squares.reduce((sum, square) => sum + square.side * square.side, 0)).toBe(15 * 11)83+ // Ohm with unit resistance: every square's side is the voltage drop across it.84+ for (const square of model.squares) {85+ expect((model.nodes[square.top]?.voltage ?? 0) - (model.nodes[square.bottom]?.voltage ?? 0)).toBe(square.side)86+ }87+ // The squares tile the rectangle without overlaps.88+ for (const first of model.squares) {89+ for (const second of model.squares) {90+ if (first === second) continue91+ const overlap = first.x < second.x + second.side && second.x < first.x + first.side && first.y < second.y + second.side && second.y < first.y + first.side92+ expect(overlap).toBe(false)93+ }94+ }95+ })96+97+ it('finds compound blocks and repeated sides', () => {98+ const model = buildSquaring(parseSquaring(compound))99+100+ expect(model).toMatchObject({ order: 10, simple: false, perfect: true })101+ expect(model.blocks).toEqual([{ x: 32, y: 0, width: 33, height: 32, squares: 9 }])102+103+ const quilt = buildSquaring(parseSquaring('rectangle 3 x 2\n2 1 1'))104+ expect(quilt.simple).toBe(false)105+ expect(quilt.blocks).toEqual([{ x: 2, y: 0, width: 1, height: 2, squares: 2 }])106+ expect(quilt.repeatedSides).toEqual([1])107+ })108+109+ it('explains why a rectangle or a network cannot become a squaring', () => {110+ expect(() => buildSquaring(parseSquaring('rectangle 33 x 32\n18 15 7 8 14 4 10 1 8'))).toThrow('area of 1039')111+ expect(() => buildSquaring(parseSquaring('rectangle 33 x 32\n15 18 7 8 14 4 10 1 9'))).toThrow('Square 7 (side 10) does not fit at column 0, row 22; only 7 × 10 is free there.')112+ expect(() => buildSquaring(parseSquaring('battery a b\na - b\nc - d'))).toThrow('not connected')113+ expect(() => buildSquaring(parseSquaring('battery a c\na - b - c\na - d - c\nb - d'))).toThrow('carries no current')114+ // A network containing K3,3 is not planar, so no arrangement exists.115+ expect(() => buildSquaring(parseSquaring('battery n1 n4\nn1 - n5\nn1 - n6\nn2 - n4\nn2 - n5\nn2 - n6\nn3 - n4\nn3 - n5\nn3 - n6\nn2 - n5'))).toThrow('cannot be arranged')116+ // A wire parallel to the battery becomes a full-height square beside a compound remainder.117+ const cube = buildSquaring(parseSquaring('battery a b\na - b - c - d - a\ne - f - g - h - e\na - e\nb - f\nc - g\nd - h'))118+ expect(cube).toMatchObject({ width: 24, height: 14, order: 12, simple: false })119+ expect(cube.squares[0]).toMatchObject({ side: 14, x: 0, y: 0 })120+ })121+})122+123+describe('squaring renderer', () => {124+ it('renders the rectangle and the Smith diagram side by side', async () => {125+ const result = await render(orderNine)126+127+ expect(result.engineVersion).toBe('diagramzip-squaring@1')128+ expect(result.body).toContain('<title>Order 9 simple perfect squared rectangle</title>')129+ expect(result.body).toContain('data-view="both" data-form="rectangle" data-order="9" data-width="33" data-height="32" data-simple="true" data-perfect="true"')130+ expect(result.body.match(/class="squaring-square"/g)).toHaveLength(9)131+ expect(result.body.match(/class="squaring-wire"/g)).toHaveLength(9)132+ expect(result.body.match(/class="squaring-node"/g)).toHaveLength(6)133+ expect(result.body).toContain('class="squaring-wire" data-from="top" data-to="n2" data-current="18"')134+ expect(result.body).toContain('class="squaring-node" data-node-id="n1" data-voltage="17"')135+ expect(result.body).toContain('class="squaring-battery"')136+ expect(result.body).toContain('Order 9 · 33 × 32 · simple · perfect')137+ expect(result.body).not.toContain('class="squaring-block"')138+ })139+140+ it('outlines compound blocks in the overlay view and honours label and view directives', async () => {141+ const overlay = await render(compound)142+ expect(overlay.body).toContain('data-view="overlay"')143+ expect(overlay.body).toContain('class="squaring-block" data-squares="9"')144+ expect(overlay.body).toContain('compound (1 block outlined) · perfect')145+ expect(overlay.body).toContain('data-overlay="true"')146+147+ const plain = await render('.view rectangle\n.labels none\nrectangle 3 x 2\n2 1 1')148+ expect(plain.body).toContain('data-view="rectangle"')149+ expect(plain.body).not.toContain('class="squaring-wire"')150+ expect(plain.body).not.toContain('<text x=')151+ expect(plain.body).toContain('<title>Squared rectangle 3 × 2</title>')152+153+ const circuit = await render(antiWedge.replace('.view both', '.view circuit'))154+ expect(circuit.body).toContain('data-view="circuit"')155+ expect(circuit.body).not.toContain('class="squaring-square"')156+ expect(circuit.body).toContain('battery a → e')157+ })158+159+ it('rejects renderer options', () => {160+ expect(() => render(orderNine, { scale: '2' })).toThrow('Unsupported squaring option: scale.')161+ })162+})
M renderers/scripts/smoke-units.mjs +1 -0
M renderers/shared/engines.ts +1 -0
M shared/svg/index.js +2 -0
M skills/diagramming/SKILL.md +2 -0
@@ -28,6 +28,8 @@ Common choices include Mermaid for compact general diagrams, PlantUML for UML-or
2828 2929 Use Tabular Recipe Notation (`trn`) for recipes, crafting trees, and assembly transformations where ingredient quantities feed short actions and meaningful outcomes. Put `.layout combined` or `.layout individual` in the TRN source. TRN originates with [Michael Chu at Cooking for Engineers](https://www.cookingforengineers.com/); diagram.zip provides its own text DSL and SVG renderer for that tabular recipe concept. For OCR transcription, preserve the printed quantities and wording, and put uncertain readings in `#` comments instead of guessing.3030 31+Use Squaring (`squaring`) for squared rectangles, squared squares, and their Smith diagrams: the electric-circuit view in which every horizontal segment is a node and every square is a unit resistor whose current is the square's side. Write a known dissection as `rectangle <width> x <height>` followed by the square sides in Bouwkamp order, or build one backwards from a planar network with `battery <positive> <negative>` and wires such as `a - b - c`. The caption reports whether the result is simple (no smaller rectangle of squares) and perfect (no repeated side). The correspondence comes from [Brooks, Smith, Stone, and Tutte](https://doi.org/10.1215/S0012-7094-40-00718-9); diagram.zip provides its own notation and SVG renderer for it.32+3133 The complete renderer set and syntax links are in [llms.txt](https://docs.diagram.zip/llms.txt) and [diagram-types.json](https://docs.diagram.zip/diagram-types.json). Semantic routing metadata is in [diagram-skills.json](https://docs.diagram.zip/diagram-skills.json).3234 3335 Do not infer standards support from a similarly named renderer mode. Confirm the renderer's output and interchange fidelity.