Sides can be named unknowns or expressions; groups say what they hang under and the sketch is solved exactly. Networks accept faces, polyhedron presets, and battery any, which tries every wire and lists the candidates. Incomplete dissections render with hatched gaps, and ? sides fill the gap they land in.
- commit
- 6b7e26ec2daa0377f1164862f54d47b159576819
- parent
- 2a0487f4ca500a855d3897e57836256df0d433f6
- author
- Dami <dami.etoile@gmail.com>
@@ -330,14 +330,14 @@ export const diagramTypes = [
330330 category: 'Graphs and networks',331331 format: 'Squaring text',332332 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 `#`.'],333+ use: 'Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. Sketch one with unknown sides and let the solver close it. Or build one backwards from a planar network by choosing or searching the 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.', 'Write `rectangle ? x ?` to infer the size; the first group is then the top row.', 'A `?` side is a square as wide as the gap it lands in. Squares that do not fit stop placement, and the rest of the rectangle is hatched.', 'For a sketch, name sides with letters or expressions such as `a`, `2a`, or `a+b`. Every later group needs an `under` clause such as `(g h) under f c`, listing the squares it rests on from left to right.', 'The sketch solver scales the unique solution to whole numbers, or reports free lengths, contradictions, or a fractional side.', 'For a network, write `battery <positive> <negative>` and then wires as `wire a b`, chains such as `a - b - c`, `face a b c d` polygons, or `polyhedron cube`.', 'Write `battery any` to try every wire as the battery; the best result is drawn and every candidate is listed.', '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.', '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 network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.', 'Symmetric solids often fail for every battery because mirror-image nodes settle at the same voltage; asymmetric polyhedra work best.', '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 `#`.'],335335 language: 'text',336336 example: ['.view both', 'title Order 9 simple perfect squared rectangle', '', 'rectangle 33 x 32', 'squares (18 15) (7 8) (14 4) (10 1) (9)'],337337 styleSummary: 'Squaring colors each square and node by voltage from blue to red and scales wire thickness by current. Light and dark appearances use their own square, node, and wire palettes, and 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.'],338+ sourceStyle: ['Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.', 'Incomplete dissections always render as the rectangle view with hatched gaps.', '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.'],339339 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.'],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 or use `battery any`.', '`battery any` is limited to networks with at most 60 wires.', 'Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.'],341341 origin: {342342 creator: 'Brooks, Smith, Stone, and Tutte',343343 site: 'The dissection of rectangles into squares (Duke Mathematical Journal, 1940)',@@ -14,7 +14,7 @@ Squaring draws a squared rectangle, which is a rectangle tiled by squares, next
1414 1515 ## Use this type1616 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.17+Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. Sketch one with unknown sides and let the solver close it. Or build one backwards from a planar network by choosing or searching the battery edge.1818 1919 ## Source format2020 @@ -27,10 +27,16 @@ The source format is **Squaring text**. Enter the source in the diagram.zip edit
2727 - Add `title` followed by text to name the diagram.2828 - For a known dissection, write `rectangle <width> x <height>` and then the square sides in Bouwkamp order; parentheses and commas are ignored.2929 - 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.30+- Write `rectangle ? x ?` to infer the size; the first group is then the top row.31+- A `?` side is a square as wide as the gap it lands in. Squares that do not fit stop placement, and the rest of the rectangle is hatched.32+- For a sketch, name sides with letters or expressions such as `a`, `2a`, or `a+b`. Every later group needs an `under` clause such as `(g h) under f c`, listing the squares it rests on from left to right.33+- The sketch solver scales the unique solution to whole numbers, or reports free lengths, contradictions, or a fractional side.34+- For a network, write `battery <positive> <negative>` and then wires as `wire a b`, chains such as `a - b - c`, `face a b c d` polygons, or `polyhedron cube`.35+- Write `battery any` to try every wire as the battery; the best result is drawn and every candidate is listed.3236 - 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.37+- 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.3338 - The network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.39+- Symmetric solids often fail for every battery because mirror-image nodes settle at the same voltage; asymmetric polyhedra work best.3440 - The caption reports the order, size, whether the squaring is simple (no smaller rectangle of squares) and perfect (no repeated side), and the battery.3541 - Compound blocks are outlined; node color runs from blue at the negative pole to red at the positive pole.3642 - Start comments with `#`.@@ -54,7 +60,8 @@ Squaring originates with [Brooks, Smith, Stone, and Tutte](https://doi.org/10.12
5460 ## Limitations5561 5662 - 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.63+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery or use `battery any`.64+- `battery any` is limited to networks with at most 60 wires.5865 - Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.5966 6067 ## Related pages@@ -15,6 +15,7 @@ Start with the [general presentation settings](/style/presentation). Edit shared
1515 ## Source controls1616 1717 - Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.18+- Incomplete dissections always render as the rectangle view with hatched gaps.1819 - Use `.view overlay` to draw the circuit on top of the squares, which shows how each segment collapses into a node.1920 - Use `.labels none` for large dissections where numbers would not fit.2021 - Choose a different battery wire in a network to obtain a different squaring from the same graph.@@ -22,7 +23,8 @@ Start with the [general presentation settings](/style/presentation). Edit shared
2223 ## Limitations2324 2425 - 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+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery or use `battery any`.27+- `battery any` is limited to networks with at most 60 wires.2628 - Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.2729 2830 ## Related pages@@ -2496,7 +2496,7 @@ Squaring draws a squared rectangle, which is a rectangle tiled by squares, next
24962496 24972497 ## Use this type24982498 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.2499+Use Squaring to explore squared rectangles and squared squares, or to check whether a dissection is simple and perfect. Sketch one with unknown sides and let the solver close it. Or build one backwards from a planar network by choosing or searching the battery edge.25002500 25012501 ## Source format25022502 @@ -2509,10 +2509,16 @@ The source format is **Squaring text**. Enter the source in the diagram.zip edit
25092509 - Add `title` followed by text to name the diagram.25102510 - For a known dissection, write `rectangle <width> x <height>` and then the square sides in Bouwkamp order; parentheses and commas are ignored.25112511 - 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.2512+- Write `rectangle ? x ?` to infer the size; the first group is then the top row.2513+- A `?` side is a square as wide as the gap it lands in. Squares that do not fit stop placement, and the rest of the rectangle is hatched.2514+- For a sketch, name sides with letters or expressions such as `a`, `2a`, or `a+b`. Every later group needs an `under` clause such as `(g h) under f c`, listing the squares it rests on from left to right.2515+- The sketch solver scales the unique solution to whole numbers, or reports free lengths, contradictions, or a fractional side.2516+- For a network, write `battery <positive> <negative>` and then wires as `wire a b`, chains such as `a - b - c`, `face a b c d` polygons, or `polyhedron cube`.2517+- Write `battery any` to try every wire as the battery; the best result is drawn and every candidate is listed.25142518 - 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.2519+- 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.25152520 - The network form needs a planar network in which the battery closes a face; polyhedral (3-connected planar) networks give a simple squaring.2521+- Symmetric solids often fail for every battery because mirror-image nodes settle at the same voltage; asymmetric polyhedra work best.25162522 - The caption reports the order, size, whether the squaring is simple (no smaller rectangle of squares) and perfect (no repeated side), and the battery.25172523 - Compound blocks are outlined; node color runs from blue at the negative pole to red at the positive pole.25182524 - Start comments with `#`.@@ -2534,7 +2540,8 @@ Squaring originates with [Brooks, Smith, Stone, and Tutte](https://doi.org/10.12
25342540 ## Limitations25352541 25362542 - 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.2543+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery or use `battery any`.2544+- `battery any` is limited to networks with at most 60 wires.25382545 - Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.25392546 25402547 ## Related pages@@ -2564,6 +2571,7 @@ Start with the [general presentation settings](/style/presentation). Edit shared
25642571 ## Source controls25652572 25662573 - Use `.view rectangle` for the dissection alone and `.view circuit` for the Smith diagram alone.2574+- Incomplete dissections always render as the rectangle view with hatched gaps.25672575 - Use `.view overlay` to draw the circuit on top of the squares, which shows how each segment collapses into a node.25682576 - Use `.labels none` for large dissections where numbers would not fit.25692577 - Choose a different battery wire in a network to obtain a different squaring from the same graph.@@ -2571,7 +2579,8 @@ Start with the [general presentation settings](/style/presentation). Edit shared
25712579 ## Limitations25722580 25732581 - 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.2582+- A wire whose two nodes settle at the same voltage carries no current and is rejected; choose another battery or use `battery any`.2583+- `battery any` is limited to networks with at most 60 wires.25752584 - Node positions in the Smith diagram follow the midpoints of the horizontal segments rather than a spring layout.25762585 25772586 ## Related pages@@ -28,7 +28,7 @@ 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.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; sketch one with named sides and `under` clauses and let the solver close it; or build one backwards from a planar network (`wire`, `face`, or `polyhedron cube`) with `battery <positive> <negative>` or `battery any`. Incomplete dissections render with hatched gaps instead of failing. 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.3232 3333 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).3434 @@ -184,7 +184,7 @@ test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {
184184 test('normalizes squaring voltage bands, wires, and labels with dedicated palettes', () => {185185 const squaring = canonicalizeSvg(186186 '<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 class="squaring-square-fill" data-band="7" x="4" y="4" width="20" height="20" fill="#f87171" stroke="#1e293b"></rect><text class="squaring-square-label" x="14" y="14" fill="#0f172a">2</text></g><path class="squaring-wire-path" d="M0 0L1 1" fill="none" stroke="#1e293b"></path><circle class="squaring-node-dot" data-band="0" cx="1" cy="1" r="5" fill="#2563eb"></circle><rect class="squaring-block" x="1" y="1" width="2" height="2" fill="none" stroke="#db2777"></rect><text class="squaring-summary" fill="#666666">Order 3</text></svg>',187- metadata, 'squaring', 'diagramzip-squaring@2',187+ metadata, 'squaring', 'diagramzip-squaring@3',188188 )189189 190190 assert.match(squaring, /data-dz-profile="squaring-semantic-1"/)@@ -0,0 +1,8 @@
1+# Incomplete input still renders: the squares that fit are drawn, the rest of2+# the rectangle is hatched, and the caption explains where placement stopped.3+# A "?" square is as wide as the gap it lands in.4+.view both5+title Sketch in progress6+7+rectangle 33 x 328+(18 15) (7 8) (14 ?)@@ -0,0 +1,13 @@
1+# Stone's method: sketch the dissection with unknown sides and let the2+# geometry solve them. Each group names the squares it hangs under, left to3+# right, so the sketch carries its own topology. The solver scales the4+# one-dimensional solution to whole numbers.5+.view both6+title Order 9 solved from a sketch7+8+rectangle ? x ?9+(a b)10+(c d) under b11+(e f) under a12+(g h) under f c13+(i) under h d@@ -98,7 +98,7 @@ const versions: Record<EngineId, string> = {
9898 nwdiag: 'nwdiag@3.0.0/python-worker-1',9999 packetdiag: 'packetdiag@3.0.0/python-worker-1',100100 rackdiag: 'rackdiag@3.0.0/python-worker-1',101- squaring: 'diagramzip-squaring@2',101+ squaring: 'diagramzip-squaring@3',102102 graphviz: 'graphviz@15.1.1/edge-wasm-2',103103 dbml: 'dbml@1.0.31+graphviz@15.1.1/edge-wasm-2',104104 pikchr: 'pikchr@85e65b9686/edge-wasm-1',@@ -5,6 +5,6 @@
55 "compatibility_date": "2026-08-20",66 "compatibility_flags": ["nodejs_compat"],77 "observability": { "enabled": true, "logs": { "head_sampling_rate": 1 } },8- "vars": { "RENDERER_BUILD": "diagramzip-squaring-2" },8+ "vars": { "RENDERER_BUILD": "diagramzip-squaring-3" },99 "routes": [{ "pattern": "squaring.render.diagram.zip", "custom_domain": true }]1010 }@@ -5,7 +5,7 @@ import { buildSquaring } from '../languages/squaring-model'
55 import { renderSquaring } from '../languages/squaring-renderer'66 import { edgeFailure, edgeResult } from './types'77 8-const VERSION = 'diagramzip-squaring@2'8+const VERSION = 'diagramzip-squaring@3'99 1010 function validateOptions(options: Record<string, string>): void {1111 for (const name of Object.keys(options)) {@@ -1,5 +1,5 @@
11 import { RenderError } from '../runtime/errors'2-import type { SquaringDocument, SquaringNetworkSource, SquaringRectangleSource } from './squaring'2+import type { SideExpression, SquareGroup, SquaringDocument, SquaringNetworkSource, SquaringRectangleSource, SquaringWire } from './squaring'33 44 export interface SquaringSquare {55 index: number@@ -8,6 +8,7 @@ export interface SquaringSquare {
88 side: number99 top: number1010 bottom: number11+ label: string1112 }1213 1314 export interface SquaringNode {@@ -27,6 +28,17 @@ export interface SquaringBlock {
2728 squares: number2829 }2930 31+export interface BatteryCandidate {32+ positive: string33+ negative: string34+ order: number35+ width: number36+ height: number37+ simple: boolean38+ perfect: boolean39+ error: string | null40+}41+3042 export interface SquaringModel {3143 form: 'rectangle' | 'network'3244 width: number@@ -36,19 +48,94 @@ export interface SquaringModel {
3648 positive: number3749 negative: number3850 order: number51+ complete: boolean52+ problem: string | null3953 perfect: boolean4054 repeatedSides: number[]4155 simple: boolean4256 blocks: SquaringBlock[]57+ search: BatteryCandidate[] | null58+ searchIndex: number4359 }4460 4561 const SEARCH_BUDGET = 250_0004662 const MAXIMUM_ORDER = 2_00063+const MAXIMUM_BATTERY_SEARCH = 604764 4865 function sourceError(line: number, message: string): never {4966 throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)5067 }5168 69+// ---------------------------------------------------------------------------70+// Exact rational arithmetic keeps Kirchhoff and Stone solutions integral.71+72+type Fraction = { n: bigint; d: bigint }73+74+function gcd(a: bigint, b: bigint): bigint {75+ let x = a < 0n ? -a : a76+ let y = b < 0n ? -b : b77+ while (y !== 0n) [x, y] = [y, x % y]78+ return x79+}80+81+function fraction(n: bigint, d = 1n): Fraction {82+ if (d === 0n) throw new Error('Division by zero.')83+ const sign = d < 0n ? -1n : 1n84+ const divisor = gcd(n, d) || 1n85+ return { n: (sign * n) / divisor, d: (sign * d) / divisor }86+}87+88+const ZERO = fraction(0n)89+const add = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d + b.n * a.d, a.d * b.d)90+const sub = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d - b.n * a.d, a.d * b.d)91+const mul = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.n, a.d * b.d)92+const div = (a: Fraction, b: Fraction): Fraction => fraction(a.n * b.d, a.d * b.n)93+const isZero = (a: Fraction): boolean => a.n === 0n94+95+/** Row-reduce an augmented matrix in place; returns pivot columns. */96+function reduce(matrix: Fraction[][], columns: number): number[] {97+ const pivots: number[] = []98+ let row = 099+ for (let column = 0; column < columns && row < matrix.length; column += 1) {100+ let pivotRow = row101+ while (pivotRow < matrix.length && isZero(matrix[pivotRow]?.[column] ?? ZERO)) pivotRow += 1102+ if (pivotRow === matrix.length) continue103+ ;[matrix[row], matrix[pivotRow]] = [matrix[pivotRow] as Fraction[], matrix[row] as Fraction[]]104+ const pivotValues = matrix[row] as Fraction[]105+ const pivotValue = pivotValues[column] as Fraction106+ for (let index = 0; index < pivotValues.length; index += 1) pivotValues[index] = div(pivotValues[index] as Fraction, pivotValue)107+ for (let other = 0; other < matrix.length; other += 1) {108+ if (other === row) continue109+ const values = matrix[other] as Fraction[]110+ const factor = values[column] as Fraction111+ if (isZero(factor)) continue112+ for (let index = 0; index < values.length; index += 1) values[index] = sub(values[index] as Fraction, mul(factor, pivotValues[index] as Fraction))113+ }114+ pivots.push(column)115+ row += 1116+ }117+ return pivots118+}119+120+function integerScale(values: Fraction[]): bigint[] {121+ let scale = 1n122+ for (const value of values) scale = (scale * value.d) / gcd(scale, value.d)123+ const integral = values.map(value => (value.n * scale) / value.d)124+ let reduction = 0n125+ for (const value of integral) reduction = gcd(reduction, value)126+ return reduction === 0n ? integral : integral.map(value => value / reduction)127+}128+129+function toSafeNumber(value: bigint, line: number): number {130+ if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < -BigInt(Number.MAX_SAFE_INTEGER)) {131+ sourceError(line, 'The solution produces side lengths that are too large to draw.')132+ }133+ return Number(value)134+}135+136+// ---------------------------------------------------------------------------137+// Skyline placement shared by Bouwkamp order and network arrangement.138+52139 interface Segment {53140 x0: number54141 x1: number@@ -77,50 +164,282 @@ function lowestSegment(skyline: Segment[]): number {
77164 return best78165 }79166 167+interface Placed {168+ x: number169+ y: number170+ side: number171+ label: string172+}173+174+interface Placement {175+ width: number176+ height: number177+ squares: Placed[]178+ problem: string | null179+}180+80181 // 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 } = rectangle182+// unfilled segment, in source order. A "?" square is as wide as that segment.183+// Placement stops at the first square that does not fit and reports why.184+function placeBouwkamp(rectangle: SquaringRectangleSource): Placement {185+ const { line } = rectangle186+ const sides = rectangle.groups.flatMap(group => group.sides)84187 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}.`)188+ let width = rectangle.width189+ if (width === null) {190+ const first = rectangle.groups[0]191+ if (first === undefined || first.sides.some(side => side.auto)) sourceError(line, 'With an unknown width, the first group must list the top row of squares with numbers, for example "(18 15)".')192+ width = first.sides.reduce((sum, side) => sum + side.constant, 0)88193 }194+ const height = rectangle.height ?? Infinity89195 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) => {196+ const squares: Placed[] = []197+ let problem: string | null = null198+ for (const [index, expression] of sides.entries()) {92199 const at = lowestSegment(skyline)93200 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.`)201+ if (segment === undefined || segment.depth >= height) {202+ problem = `square ${index + 1} (${expression.text}) has nowhere to go; the rectangle is already full`203+ break204+ }205+ const room = segment.x1 - segment.x0206+ const side = expression.auto ? Math.min(room, height - segment.depth) : expression.constant207+ if (side > room || segment.depth + side > height) {208+ problem = `square ${index + 1} (side ${side}) does not fit at column ${segment.x0}, row ${segment.depth}; only ${room} × ${height === Infinity ? '∞' : height - segment.depth} is free there`209+ break97210 }98- placed.push({ x: segment.x0, y: segment.depth, side })211+ squares.push({ x: segment.x0, y: segment.depth, side, label: String(side) })99212 const replacement: Segment[] = [{ x0: segment.x0, x1: segment.x0 + side, depth: segment.depth + side, node: 0 }]100213 if (segment.x0 + side < segment.x1) replacement.push({ x0: segment.x0 + side, x1: segment.x1, depth: segment.depth, node: 0 })101214 skyline = mergeSkyline([...skyline.slice(0, at), ...replacement, ...skyline.slice(at + 1)])215+ }216+ const deepest = Math.max(...skyline.map(segment => segment.depth))217+ const finalHeight = height === Infinity ? deepest : height218+ if (problem === null && skyline.some(segment => segment.depth !== finalHeight)) {219+ const gap = skyline[lowestSegment(skyline)]220+ problem = `the squares do not fill the rectangle; the first gap is at column ${gap?.x0 ?? 0}, row ${gap?.depth ?? 0}`221+ }222+ return { width, height: finalHeight, squares, problem }223+}224+225+// ---------------------------------------------------------------------------226+// Stone sketches: sides are linear expressions and each group names the227+// squares it hangs under. The geometry gives a linear system whose one-228+// dimensional solution is the squaring.229+230+type Form = Map<string, Fraction>231+232+const CONSTANT = '$1'233+const WIDTH = '$W'234+const HEIGHT = '$H'235+236+function formOf(expression: SideExpression): Form {237+ const form: Form = new Map()238+ if (expression.constant !== 0) form.set(CONSTANT, fraction(BigInt(expression.constant)))239+ for (const [symbol, coefficient] of Object.entries(expression.terms)) form.set(symbol, fraction(BigInt(coefficient)))240+ return form241+}242+243+function addForms(a: Form, b: Form, sign = 1n): Form {244+ const result: Form = new Map(a)245+ for (const [key, value] of b) {246+ const next = add(result.get(key) ?? ZERO, mul(value, fraction(sign)))247+ if (isZero(next)) result.delete(key)248+ else result.set(key, next)249+ }250+ return result251+}252+253+function solveSketch(rectangle: SquaringRectangleSource): Placement {254+ const { groups, line } = rectangle255+ const sides = groups.flatMap(group => group.sides)256+ if (sides.length > MAXIMUM_ORDER) sourceError(line, `At most ${MAXIMUM_ORDER} squares are supported.`)257+ const groupOf = groups.flatMap((group, index) => group.sides.map(() => index))258+ const sideForms = sides.map(formOf)259+ const xForms: Form[] = []260+ const yForms: Form[] = []261+ const equations: Form[] = []262+ const usedAsBase = new Map<number, number>()263+264+ const resolve = (ref: string, group: SquareGroup, limit: number): number => {265+ let matches: number[]266+ if (ref.startsWith('#')) {267+ const index = Number(ref.slice(1)) - 1268+ matches = index >= 0 && index < sides.length ? [index] : []269+ } else if (/^\d+$/.test(ref)) {270+ matches = sides.flatMap((side, index) => Object.keys(side.terms).length === 0 && side.constant === Number(ref) ? [index] : [])271+ } else {272+ matches = sides.flatMap((side, index) => side.constant === 0 && Object.keys(side.terms).length === 1 && side.terms[ref] === 1 ? [index] : [])273+ }274+ if (matches.length === 0) sourceError(group.line, `"under ${ref}" does not name any square listed so far.`)275+ if (matches.length > 1) sourceError(group.line, `"under ${ref}" is ambiguous; use #n to pick the n-th square.`)276+ const index = matches[0] ?? 0277+ if (index >= limit) sourceError(group.line, `"under ${ref}" refers to a square that is listed later; list the squares above first.`)278+ return index279+ }280+281+ let cursor = 0282+ groups.forEach((group, groupIndex) => {283+ const first = cursor284+ let x: Form285+ let y: Form286+ if (groupIndex === 0) {287+ x = new Map()288+ y = new Map()289+ } else {290+ const refs = (group.under ?? []).map(ref => resolve(ref, group, first))291+ for (const [position, ref] of refs.entries()) {292+ const previousUse = usedAsBase.get(ref)293+ if (previousUse !== undefined) sourceError(group.line, `Square ${sides[ref]?.text ?? ref} already has a group under it (line ${previousUse}).`)294+ usedAsBase.set(ref, group.line)295+ if (position > 0) {296+ const left = refs[position - 1] ?? ref297+ // Consecutive base squares share a bottom edge and touch side by side.298+ equations.push(addForms(addForms(yForms[left] ?? new Map(), sideForms[left] ?? new Map()), addForms(yForms[ref] ?? new Map(), sideForms[ref] ?? new Map()), -1n))299+ equations.push(addForms(addForms(xForms[left] ?? new Map(), sideForms[left] ?? new Map()), xForms[ref] ?? new Map(), -1n))300+ }301+ }302+ const base = refs[0] ?? 0303+ x = xForms[base] ?? new Map()304+ y = addForms(yForms[base] ?? new Map(), sideForms[base] ?? new Map())305+ // The group is exactly as wide as the run of base squares.306+ let runWidth: Form = new Map()307+ for (const ref of refs) runWidth = addForms(runWidth, sideForms[ref] ?? new Map())308+ let groupWidth: Form = new Map()309+ for (const side of group.sides) groupWidth = addForms(groupWidth, formOf(side))310+ equations.push(addForms(groupWidth, runWidth, -1n))311+ }312+ for (const side of group.sides) {313+ xForms.push(x)314+ yForms.push(y)315+ x = addForms(x, formOf(side))316+ cursor += 1317+ }318+ })319+320+ // The top row spans the width; every square without anything under it rests on the bottom edge.321+ const widthForm: Form = rectangle.width === null ? new Map([[WIDTH, fraction(1n)]]) : new Map([[CONSTANT, fraction(BigInt(rectangle.width))]])322+ const heightForm: Form = rectangle.height === null ? new Map([[HEIGHT, fraction(1n)]]) : new Map([[CONSTANT, fraction(BigInt(rectangle.height))]])323+ let topRow: Form = new Map()324+ for (const side of groups[0]?.sides ?? []) topRow = addForms(topRow, formOf(side))325+ equations.push(addForms(topRow, widthForm, -1n))326+ sides.forEach((_, index) => {327+ if (usedAsBase.has(index)) return328+ equations.push(addForms(addForms(yForms[index] ?? new Map(), sideForms[index] ?? new Map()), heightForm, -1n))329+ })330+331+ const unknowns = [...new Set([...equations.flatMap(form => [...form.keys()]), ...sideForms.flatMap(form => [...form.keys()])])].filter(key => key !== CONSTANT).sort()332+ const column = new Map(unknowns.map((name, index) => [name, index]))333+ const homogeneous = equations.every(form => !form.has(CONSTANT))334+ const matrix = equations.map(form => {335+ const row = unknowns.map(() => ZERO)336+ for (const [key, value] of form) {337+ const index = column.get(key)338+ if (index !== undefined) row[index] = value339+ }340+ row.push(mul(form.get(CONSTANT) ?? ZERO, fraction(-1n)))341+ return row102342 })103- return placed343+ const pivots = reduce(matrix, unknowns.length)344+ const free = unknowns.filter((_, index) => !pivots.includes(index))345+ const inconsistent = matrix.some(row => row.slice(0, unknowns.length).every(isZero) && !isZero(row[unknowns.length] ?? ZERO))346+ if (inconsistent) sourceError(line, 'The sketch cannot close: its equations contradict each other. Check the "under" clauses and any given numbers.')347+348+ const values = new Map<string, Fraction>()349+ if (homogeneous) {350+ if (free.length !== 1) {351+ sourceError(line, free.length === 0352+ ? 'The sketch only closes when every side is zero. Check the "under" clauses.'353+ : `The sketch has ${free.length} free lengths (${free.join(', ')}); give a number for ${free.length - 1} of them or add a group that ties them together.`)354+ }355+ const freeName = free[0] ?? ''356+ values.set(freeName, fraction(1n))357+ pivots.forEach((columnIndex, rowIndex) => {358+ const row = matrix[rowIndex] as Fraction[]359+ const freeIndex = column.get(freeName) ?? 0360+ values.set(unknowns[columnIndex] ?? '', mul(row[freeIndex] ?? ZERO, fraction(-1n)))361+ })362+ } else {363+ if (free.length > 0) sourceError(line, `The sketch has ${free.length} free ${free.length === 1 ? 'length' : 'lengths'} (${free.join(', ')}); give a number for ${free.length === 1 ? 'it' : 'all but one'} or add a group that ties them together.`)364+ pivots.forEach((columnIndex, rowIndex) => {365+ values.set(unknowns[columnIndex] ?? '', (matrix[rowIndex] as Fraction[])[unknowns.length] ?? ZERO)366+ })367+ }368+369+ const evaluate = (form: Form): Fraction => {370+ let total = form.get(CONSTANT) ?? ZERO371+ for (const [key, value] of form) {372+ if (key === CONSTANT) continue373+ total = add(total, mul(value, values.get(key) ?? ZERO))374+ }375+ return total376+ }377+378+ let width = evaluate(widthForm)379+ let height = evaluate(heightForm)380+ let sideValues = sideForms.map(evaluate)381+ let xValues = xForms.map(evaluate)382+ let yValues = yForms.map(evaluate)383+ if (homogeneous) {384+ // Scale the one-dimensional solution to the smallest positive integers.385+ const scaled = integerScale([width, height, ...sideValues, ...xValues, ...yValues])386+ const sign = (scaled[0] ?? 0n) < 0n ? -1n : 1n387+ const [w = 0n, h = 0n, ...rest] = scaled.map(value => value * sign)388+ width = fraction(w)389+ height = fraction(h)390+ sideValues = rest.slice(0, sides.length).map(value => fraction(value))391+ xValues = rest.slice(sides.length, sides.length * 2).map(value => fraction(value))392+ yValues = rest.slice(sides.length * 2).map(value => fraction(value))393+ } else {394+ const fractional = [width, height, ...sideValues].find(value => value.d !== 1n)395+ if (fractional !== undefined) sourceError(line, `The given numbers force a side of ${fractional.n}/${fractional.d}; adjust them so every side is a whole number.`)396+ }397+ const bad = sideValues.findIndex(value => value.n <= 0n)398+ if (bad >= 0) sourceError(line, `The sketch closes only if square ${bad + 1} (${sides[bad]?.text ?? ''}) has side ${sideValues[bad]?.n ?? 0n}; check the "under" clauses.`)399+400+ const squares: Placed[] = sides.map((side, index) => ({401+ x: toSafeNumber(xValues[index]?.n ?? 0n, line),402+ y: toSafeNumber(yValues[index]?.n ?? 0n, line),403+ side: toSafeNumber(sideValues[index]?.n ?? 0n, line),404+ label: Object.keys(side.terms).length === 0 ? String(sideValues[index]?.n ?? 0n) : `${side.text}=${sideValues[index]?.n ?? 0n}`,405+ }))406+ const widthNumber = toSafeNumber(width.n, line)407+ const heightNumber = toSafeNumber(height.n, line)408+ return { width: widthNumber, height: heightNumber, squares, problem: tilingProblem(squares, widthNumber, heightNumber) }104409 }105410 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-} {411+function tilingProblem(squares: Placed[], width: number, height: number): string | null {412+ for (const [index, square] of squares.entries()) {413+ if (square.x < 0 || square.y < 0 || square.x + square.side > width || square.y + square.side > height) {414+ return `square ${index + 1} (${square.label}) sticks out of the ${width} × ${height} rectangle`415+ }416+ for (const [otherIndex, other] of squares.entries()) {417+ if (otherIndex <= index) continue418+ if (square.x < other.x + other.side && other.x < square.x + square.side && square.y < other.y + other.side && other.y < square.y + square.side) {419+ return `squares ${index + 1} (${square.label}) and ${otherIndex + 1} (${other.label}) overlap`420+ }421+ }422+ }423+ const area = squares.reduce((sum, square) => sum + square.side * square.side, 0)424+ if (area !== width * height) return `the squares cover ${area} of the ${width * height} area; the sketch leaves a gap`425+ return null426+}427+428+// ---------------------------------------------------------------------------429+// Smith diagram from geometry: every maximal horizontal segment is a node.430+431+function nodesFromGeometry(squares: Placed[], width: number, height: number): { nodes: SquaringNode[]; topOf: number[]; bottomOf: number[] } {113432 const intervals = new Map<number, Array<[number, number]>>()114- const add = (y: number, x0: number, x1: number): void => {433+ const addInterval = (y: number, x0: number, x1: number): void => {115434 const list = intervals.get(y) ?? []116435 list.push([x0, x1])117436 intervals.set(y, list)118437 }119- add(0, 0, width)120- add(height, 0, width)438+ addInterval(0, 0, width)439+ addInterval(height, 0, width)121440 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)441+ addInterval(square.y, square.x, square.x + square.side)442+ addInterval(square.y + square.side, square.x, square.x + square.side)124443 }125444 const nodes: SquaringNode[] = []126445 for (const y of [...intervals.keys()].sort((a, b) => a - b)) {@@ -155,105 +474,53 @@ function nodesFromGeometry(squares: Array<{ x: number; y: number; side: number }
155474 }156475 }157476 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)477+// ---------------------------------------------------------------------------478+// Networks: solve Kirchhoff and Ohm exactly, then arrange the squares.179479 180480 interface SolvedNetwork {181481 voltages: number[]182482 currents: number[]183483 }184484 185-function solveNetwork(network: SquaringNetworkSource): SolvedNetwork {186- const { nodes, wires, positive, negative, line } = network485+interface Battery {486+ positive: string487+ negative: string488+}489+490+function solveNetwork(nodes: string[], wires: SquaringWire[], battery: Battery, line: number): SolvedNetwork {187491 const indexOf = new Map(nodes.map((id, index) => [id, index]))188- const positiveIndex = indexOf.get(positive) ?? 0189- const negativeIndex = indexOf.get(negative) ?? 0492+ const positiveIndex = indexOf.get(battery.positive) ?? 0493+ const negativeIndex = indexOf.get(battery.negative) ?? 0190494 const interior = nodes.map((_, index) => index).filter(index => index !== positiveIndex && index !== negativeIndex)191495 const column = new Map(interior.map((node, position) => [node, position]))192496 const size = interior.length193497 194- const matrix: Fraction[][] = interior.map(() => interior.map(() => fraction(0n)))195- const rhs: Fraction[] = interior.map(() => fraction(0n))498+ const matrix: Fraction[][] = interior.map(() => [...interior.map(() => ZERO), ZERO])196499 for (const wire of wires) {197500 const a = indexOf.get(wire.from) ?? 0198501 const b = indexOf.get(wire.to) ?? 0199502 for (const [self, other] of [[a, b], [b, a]] as const) {200503 const row = column.get(self)201504 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) continue226505 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))506+ rowValues[row] = add(rowValues[row] ?? ZERO, fraction(1n))507+ const otherColumn = column.get(other)508+ if (otherColumn !== undefined) rowValues[otherColumn] = sub(rowValues[otherColumn] ?? ZERO, fraction(1n))509+ else if (other === positiveIndex) rowValues[size] = add(rowValues[size] ?? ZERO, fraction(1n))233510 }234511 }512+ const pivots = reduce(matrix, size)513+ if (pivots.length !== size) sourceError(line, 'The network is not connected: every node must have a wire path to both battery nodes.')235514 236- const solution: Fraction[] = nodes.map(() => fraction(0n))515+ const solution: Fraction[] = nodes.map(() => ZERO)237516 solution[positiveIndex] = fraction(1n)238517 interior.forEach((node, position) => {239- solution[node] = div(rhs[position] as Fraction, (matrix[position] as Fraction[])[position] as Fraction)518+ solution[node] = (matrix[position] as Fraction[])[size] ?? ZERO240519 })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)520+ const integral = integerScale(solution)245521 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) }522+ if (currentValues.every(value => value === 0n)) sourceError(line, 'No current flows through the network.')523+ return { voltages: integral.map(value => toSafeNumber(value, line)), currents: currentValues.map(value => toSafeNumber(value, line)) }257524 }258525 259526 interface OrientedWire {@@ -290,14 +557,22 @@ function distinctPermutations<T>(items: T[], key: (item: T) => string): T[][] {
290557 return results291558 }292559 560+interface Arranged {561+ width: number562+ height: number563+ squares: SquaringSquare[]564+ nodes: SquaringNode[]565+ positive: number566+ negative: number567+}568+293569 // Rebuild the dissection from the circuit: fill the rectangle top-down, and at294570 // every horizontal segment try each left-to-right order of the squares that295571 // 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 } = network572+function arrangeNetwork(nodes: string[], wires: SquaringWire[], battery: Battery, solved: SolvedNetwork, line: number): Arranged {298573 const indexOf = new Map(nodes.map((id, index) => [id, index]))299- const positiveIndex = indexOf.get(positive) ?? 0300- const negativeIndex = indexOf.get(negative) ?? 0574+ const positiveIndex = indexOf.get(battery.positive) ?? 0575+ const negativeIndex = indexOf.get(battery.negative) ?? 0301576 const oriented: OrientedWire[] = wires.map((wire, index) => {302577 const a = indexOf.get(wire.from) ?? 0303578 const b = indexOf.get(wire.to) ?? 0@@ -351,12 +626,12 @@ function arrangeNetwork(network: SquaringNetworkSource, solved: SolvedNetwork):
351626 }352627 353628 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.`)629+ sourceError(line, `The network cannot be arranged into a squared rectangle with the battery between ${battery.positive} and ${battery.negative}. Check that the wires form a planar network and try another battery.`)355630 }356631 357632 const squares: SquaringSquare[] = oriented.map(wire => {358633 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 }634+ return { index: wire.index, x: at.x, y: at.y, side: wire.side, top: wire.from, bottom: wire.to, label: String(wire.side) }360635 })361636 const x0 = nodes.map(() => Infinity)362637 for (const square of squares) x0[square.bottom] = Math.min(x0[square.bottom] ?? Infinity, square.x)@@ -367,10 +642,12 @@ function arrangeNetwork(network: SquaringNetworkSource, solved: SolvedNetwork):
367642 const span = index === positiveIndex ? width : inflow[index] ?? 0368643 return { id, index, y: height - voltage, x0: left, x1: left + span, voltage }369644 })370- return { squares, nodes: modelNodes, positive: positiveIndex, negative: negativeIndex }645+ return { width, height, squares, nodes: modelNodes, positive: positiveIndex, negative: negativeIndex }371646 }372647 373-// A compound block is a proper sub-rectangle tiled by at least two squares.648+// ---------------------------------------------------------------------------649+// Analysis: perfect (no repeated side) and simple (no compound block).650+374651 function findBlocks(squares: SquaringSquare[], width: number, height: number): SquaringBlock[] {375652 const xs = [...new Set(squares.flatMap(square => [square.x, square.x + square.side]))].sort((a, b) => a - b)376653 const ys = [...new Set(squares.flatMap(square => [square.y, square.y + square.side]))].sort((a, b) => a - b)@@ -403,41 +680,104 @@ function findBlocks(squares: SquaringSquare[], width: number, height: number): S
403680 return blocks.filter(block => !blocks.some(other => contains(other, block)))404681 }405682 406-function analyse(form: 'rectangle' | 'network', width: number, height: number, squares: SquaringSquare[], nodes: SquaringNode[], positive: number, negative: number): SquaringModel {683+interface AnalysisInput {684+ form: 'rectangle' | 'network'685+ width: number686+ height: number687+ squares: SquaringSquare[]688+ nodes: SquaringNode[]689+ positive: number690+ negative: number691+ problem: string | null692+ search?: BatteryCandidate[] | null693+ searchIndex?: number694+}695+696+function analyse(input: AnalysisInput): SquaringModel {697+ const complete = input.problem === null407698 const counts = new Map<number, number>()408- for (const square of squares) counts.set(square.side, (counts.get(square.side) ?? 0) + 1)699+ for (const square of input.squares) counts.set(square.side, (counts.get(square.side) ?? 0) + 1)409700 const repeatedSides = [...counts.entries()].filter(([, count]) => count > 1).map(([side]) => side).sort((a, b) => a - b)410- const blocks = findBlocks(squares, width, height)701+ const blocks = complete ? findBlocks(input.squares, input.width, input.height) : []411702 return {412- form,413- width,414- height,415- squares,416- nodes,417- positive,418- negative,419- order: squares.length,703+ form: input.form,704+ width: input.width,705+ height: input.height,706+ squares: input.squares,707+ nodes: input.nodes,708+ positive: input.positive,709+ negative: input.negative,710+ order: input.squares.length,711+ complete,712+ problem: input.problem,420713 perfect: repeatedSides.length === 0,421714 repeatedSides,422- simple: blocks.length === 0,715+ simple: complete && blocks.length === 0,423716 blocks,717+ search: input.search ?? null,718+ searchIndex: input.searchIndex ?? -1,719+ }720+}721+722+function fromPlacement(placement: Placement): SquaringModel {723+ const { width, height, problem } = placement724+ if (problem !== null) {725+ const squares = placement.squares.map((square, index) => ({ index, ...square, top: -1, bottom: -1 }))726+ return analyse({ form: 'rectangle', width, height, squares, nodes: [], positive: -1, negative: -1, problem })727+ }728+ const { nodes, topOf, bottomOf } = nodesFromGeometry(placement.squares, width, height)729+ const squares = placement.squares.map((square, index) => ({ index, ...square, top: topOf[index] ?? 0, bottom: bottomOf[index] ?? 0 }))730+ return analyse({ form: 'rectangle', width, height, squares, nodes, positive: 0, negative: nodes.length - 1, problem: null })731+}732+733+function solveBattery(network: SquaringNetworkSource, wires: SquaringWire[], battery: Battery): SquaringModel {734+ const solved = solveNetwork(network.nodes, wires, battery, network.line)735+ const arranged = arrangeNetwork(network.nodes, wires, battery, solved, network.line)736+ return analyse({ form: 'network', ...arranged, problem: null })737+}738+739+function rank(model: SquaringModel): number {740+ return (model.simple ? 2 : 0) + (model.perfect ? 1 : 0)741+}742+743+function searchBatteries(network: SquaringNetworkSource): SquaringModel {744+ if (network.wires.length > MAXIMUM_BATTERY_SEARCH) sourceError(network.line, `"battery any" tries every wire, so the network can have at most ${MAXIMUM_BATTERY_SEARCH} wires.`)745+ const candidates: BatteryCandidate[] = []746+ let best: SquaringModel | null = null747+ let bestIndex = -1748+ network.wires.forEach((wire, index) => {749+ const rest = network.wires.filter((_, other) => other !== index)750+ const battery = { positive: wire.from, negative: wire.to }751+ try {752+ const model = solveBattery(network, rest, battery)753+ candidates.push({ ...battery, order: model.order, width: model.width, height: model.height, simple: model.simple, perfect: model.perfect, error: null })754+ const better = best === null755+ || rank(model) > rank(best)756+ || (rank(model) === rank(best) && model.width * model.height < best.width * best.height)757+ if (better) {758+ best = model759+ bestIndex = candidates.length - 1760+ }761+ } catch (error) {762+ const message = error instanceof Error ? error.message.replace(/^Line \d+: /, '') : String(error)763+ candidates.push({ ...battery, order: rest.length, width: 0, height: 0, simple: false, perfect: false, error: message })764+ }765+ })766+ if (best === null) {767+ sourceError(network.line, `No wire works as the battery. ${candidates[0]?.error ?? ''}`.trim())424768 }769+ const chosen: SquaringModel = best770+ return { ...chosen, search: candidates, searchIndex: bestIndex }425771 }426772 427773 export function buildSquaring(document: SquaringDocument): SquaringModel {428774 if (document.rectangle !== null) {429775 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)776+ return fromPlacement(rectangle.symbolic ? solveSketch(rectangle) : placeBouwkamp(rectangle))434777 }435778 if (document.network === null) throw new RenderError(422, 'invalid_source', 'The squaring source is empty.')436779 const network = document.network437780 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)781+ if (network.searchBattery) return searchBatteries(network)782+ return solveBattery(network, network.wires, { positive: network.positive ?? '', negative: network.negative ?? '' })443783 }@@ -0,0 +1,95 @@
1+// Skeletons of common convex polyhedra as edge lists. Every one is a2+// polyhedral (3-connected planar) graph, so any edge can serve as the battery.3+4+export interface PolyhedronPreset {5+ vertices: string[]6+ edges: Array<[string, string]>7+}8+9+function cycleEdges(names: string[]): Array<[string, string]> {10+ return names.map((name, index) => [name, names[(index + 1) % names.length] ?? name])11+}12+13+function fromFaces(faces: string[][]): PolyhedronPreset {14+ const vertices: string[] = []15+ const seen = new Set<string>()16+ const edges: Array<[string, string]> = []17+ for (const face of faces) {18+ for (const vertex of face) {19+ if (!vertices.includes(vertex)) vertices.push(vertex)20+ }21+ for (const [a, b] of cycleEdges(face)) {22+ const key = a < b ? `${a}|${b}` : `${b}|${a}`23+ if (seen.has(key)) continue24+ seen.add(key)25+ edges.push([a, b])26+ }27+ }28+ return { vertices, edges }29+}30+31+function ring(prefix: string, count: number): string[] {32+ return Array.from({ length: count }, (_, index) => `${prefix}${index + 1}`)33+}34+35+function prism(count: number): PolyhedronPreset {36+ const top = ring('a', count)37+ const bottom = ring('b', count)38+ return {39+ vertices: [...top, ...bottom],40+ edges: [...cycleEdges(top), ...cycleEdges(bottom), ...top.map((name, index) => [name, bottom[index] ?? name] as [string, string])],41+ }42+}43+44+function antiprism(count: number): PolyhedronPreset {45+ const top = ring('a', count)46+ const bottom = ring('b', count)47+ const band = top.flatMap((name, index) => [[name, bottom[index] ?? name], [name, bottom[(index + 1) % count] ?? name]] as Array<[string, string]>)48+ return { vertices: [...top, ...bottom], edges: [...cycleEdges(top), ...cycleEdges(bottom), ...band] }49+}50+51+function pyramid(count: number): PolyhedronPreset {52+ const base = ring('b', count)53+ return { vertices: ['apex', ...base], edges: [...cycleEdges(base), ...base.map(name => ['apex', name] as [string, string])] }54+}55+56+const DODECAHEDRON_FACES = [57+ [0, 1, 2, 3, 4], [0, 5, 10, 6, 1], [1, 6, 11, 7, 2], [2, 7, 12, 8, 3], [3, 8, 13, 9, 4], [4, 9, 14, 5, 0],58+ [15, 10, 5, 14, 19], [16, 11, 6, 10, 15], [17, 12, 7, 11, 16], [18, 13, 8, 12, 17], [19, 14, 9, 13, 18], [15, 16, 17, 18, 19],59+].map(face => face.map(index => `v${index}`))60+61+function icosahedron(): PolyhedronPreset {62+ const upper = ring('u', 5)63+ const lower = ring('l', 5)64+ const band = upper.flatMap((name, index) => [[name, lower[index] ?? name], [name, lower[(index + 1) % 5] ?? name]] as Array<[string, string]>)65+ return {66+ vertices: ['top', ...upper, ...lower, 'bottom'],67+ edges: [68+ ...upper.map(name => ['top', name] as [string, string]),69+ ...cycleEdges(upper),70+ ...band,71+ ...cycleEdges(lower),72+ ...lower.map(name => ['bottom', name] as [string, string]),73+ ],74+ }75+}76+77+export const POLYHEDRA: Record<string, () => PolyhedronPreset> = {78+ tetrahedron: () => fromFaces([['a', 'b', 'c'], ['a', 'c', 'd'], ['a', 'd', 'b'], ['b', 'd', 'c']]),79+ cube: () => prism(4),80+ octahedron: () => fromFaces([81+ ['top', 'a', 'b'], ['top', 'b', 'c'], ['top', 'c', 'd'], ['top', 'd', 'a'],82+ ['bottom', 'b', 'a'], ['bottom', 'c', 'b'], ['bottom', 'd', 'c'], ['bottom', 'a', 'd'],83+ ]),84+ dodecahedron: () => fromFaces(DODECAHEDRON_FACES),85+ icosahedron,86+ 'triangular-prism': () => prism(3),87+ 'pentagonal-prism': () => prism(5),88+ 'hexagonal-prism': () => prism(6),89+ 'square-pyramid': () => pyramid(4),90+ 'pentagonal-pyramid': () => pyramid(5),91+ 'square-antiprism': () => antiprism(4),92+ 'tetragonal-antiwedge': () => fromFaces([['a', 'b', 'c', 'd'], ['a', 'd', 'e', 'f'], ['a', 'f', 'b'], ['b', 'f', 'e'], ['b', 'e', 'c'], ['c', 'e', 'd']]),93+}94+95+export const POLYHEDRON_NAMES = Object.keys(POLYHEDRA)@@ -42,14 +42,14 @@ function squareSvg(square: SquaringSquare, model: SquaringModel, panel: Panel, u
4242 const x = panel.x + square.x * unit4343 const y = panel.y + square.y * unit4444 const size = square.side * unit45- const topVoltage = model.nodes[square.top]?.voltage ?? model.height45+ const topVoltage = model.nodes[square.top]?.voltage ?? model.height - square.y4646 const level = band((topVoltage - square.side / 2) / model.height)4747 const fill = LIGHT_SQUARE_BANDS[level] ?? LIGHT_SQUARE_BANDS[0]48- const fontSize = Math.min(16, size * 0.42)48+ const fontSize = Math.min(16, size * 0.42, (size * 1.6) / Math.max(1, square.label.length))4949 // In the overlay the wire runs down the middle of the square, so the label moves beside it.5050 const labelX = overlay ? x + size / 2 + 6 : x + size / 25151 const label = labels && size >= 1052- ? `<text x="${round(labelX)}" y="${round(y + size / 2)}" text-anchor="${overlay ? 'start' : 'middle'}" dominant-baseline="central" class="squaring-square-label" fill="${INK}" font-size="${round(fontSize)}" font-family="${FONT_FAMILY}">${square.side}</text>`52+ ? `<text x="${round(labelX)}" y="${round(y + size / 2)}" text-anchor="${overlay ? 'start' : 'middle'}" dominant-baseline="central" class="squaring-square-label" fill="${INK}" font-size="${round(fontSize)}" font-family="${FONT_FAMILY}">${escapeXml(square.label)}</text>`5353 : ''5454 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 class="squaring-square-fill" data-band="${level}" x="${round(x)}" y="${round(y)}" width="${round(size)}" height="${round(size)}" fill="${fill}" stroke="${INK}" stroke-width="1"/>${label}</g>`5555 }@@ -141,7 +141,33 @@ function frame(panel: Panel): string {
141141 142142 function rectanglePanel(model: SquaringModel, panel: Panel, unit: number, labels: boolean, overlay = false): string {143143 const squares = model.squares.map(square => squareSvg(square, model, panel, unit, labels, overlay)).join('')144- return `<g class="squaring-rectangle">${squares}${blocksSvg(model, panel, unit)}${frame(panel)}</g>`144+ const gaps = model.complete145+ ? ''146+ : `<rect class="squaring-gap" x="${round(panel.x)}" y="${round(panel.y)}" width="${round(panel.width)}" height="${round(panel.height)}" fill="url(#squaring-hatch)"/>`147+ return `<g class="squaring-rectangle">${gaps}${squares}${blocksSvg(model, panel, unit)}${frame(panel)}</g>`148+}149+150+function searchTableSvg(model: SquaringModel, x: number, y: number, width: number): { svg: string; height: number } {151+ if (model.search === null) return { svg: '', height: 0 }152+ const rowHeight = 15153+ const maxRows = 24154+ const rows = model.search.slice(0, maxRows)155+ const columns = [0, 110, 200, 270, 340]156+ const cell = (text: string, column: number, rowY: number, bold = false, muted = false): string =>157+ `<text x="${round(x + (columns[column] ?? 0))}" y="${round(rowY)}" class="${muted ? 'squaring-summary' : 'squaring-node-label'}" fill="${muted ? MUTED : INK}" font-size="11"${bold ? ' font-weight="700"' : ''} font-family="${FONT_FAMILY}">${escapeXml(text)}</text>`158+ const header = ['Battery', 'Size', 'Order', 'Simple', 'Perfect'].map((text, column) => cell(text, column, y + 12, true, true)).join('')159+ const body = rows.map((candidate, index) => {160+ const rowY = y + 12 + rowHeight * (index + 1)161+ const chosen = index === model.searchIndex162+ const name = `${candidate.positive} → ${candidate.negative}${chosen ? ' ◀' : ''}`163+ if (candidate.error !== null) {164+ return cell(name, 0, rowY, chosen) + `<text x="${round(x + 110)}" y="${round(rowY)}" class="squaring-summary" fill="${MUTED}" font-size="11" font-family="${FONT_FAMILY}">${escapeXml(candidate.error.length > 70 ? `${candidate.error.slice(0, 67)}…` : candidate.error)}</text>`165+ }166+ return cell(name, 0, rowY, chosen) + cell(`${candidate.width} × ${candidate.height}`, 1, rowY, chosen) + cell(String(candidate.order), 2, rowY, chosen) + cell(candidate.simple ? 'yes' : 'no', 3, rowY, chosen) + cell(candidate.perfect ? 'yes' : 'no', 4, rowY, chosen)167+ }).join('')168+ const more = model.search.length > maxRows ? cell(`+${model.search.length - maxRows} more`, 0, y + 12 + rowHeight * (rows.length + 1), false, true) : ''169+ const height = 12 + rowHeight * (rows.length + 1 + (more === '' ? 0 : 1)) + 6170+ return { svg: `<g class="squaring-search" data-candidates="${model.search.length}"><line x1="${round(x)}" y1="${round(y + 17)}" x2="${round(x + Math.min(width, 420))}" y2="${round(y + 17)}" stroke="${MUTED}" stroke-width="0.5" class="squaring-frame"/>${header}${body}${more}</g>`, height }145171 }146172 147173 function circuitPanel(model: SquaringModel, panel: Panel, unit: number, overlay: boolean, labels: boolean): string {@@ -158,12 +184,16 @@ function defaultTitle(document: SquaringDocument, model: SquaringModel): string
158184 159185 export function describeSquaring(model: SquaringModel): string {160186 const parts = [`Order ${model.order}`, `${model.width} × ${model.height}`]187+ if (!model.complete) {188+ parts.push(`incomplete: ${model.problem ?? ''}`)189+ return parts.join(' | ')190+ }161191 parts.push(model.simple ? 'simple' : `compound (${model.blocks.length} ${model.blocks.length === 1 ? 'block' : 'blocks'} outlined)`)162192 parts.push(model.perfect ? 'perfect' : `imperfect (${model.repeatedSides.length} repeated ${model.repeatedSides.length === 1 ? 'side' : 'sides'}: ${model.repeatedSides.join(', ')})`)163193 if (model.form === 'network') {164194 const positive = model.nodes[model.positive]?.id ?? ''165195 const negative = model.nodes[model.negative]?.id ?? ''166- parts.push(`battery ${positive} → ${negative}`)196+ parts.push(model.search === null ? `battery ${positive} → ${negative}` : `battery ${positive} → ${negative} (best of ${model.search.length} choices)`)167197 }168198 return parts.join(' | ')169199 }@@ -171,7 +201,7 @@ export function describeSquaring(model: SquaringModel): string {
171201 export function renderSquaring(document: SquaringDocument, model: SquaringModel): string {172202 const unit = Math.min(60, Math.max(0.5, TARGET_SIZE / Math.max(model.width, model.height)))173203 const labels = document.labels === 'sides'174- const view = document.view204+ const view = model.complete ? document.view : 'rectangle'175205 const panelWidth = model.width * unit176206 const panelHeight = model.height * unit177207 const top = MARGIN + PANEL_TITLE_HEIGHT@@ -196,18 +226,21 @@ export function renderSquaring(document: SquaringDocument, model: SquaringModel)
196226 x += panelWidth197227 }198228 199- const width = Math.max(x + MARGIN, 320)200- const height = top + panelHeight + CAPTION_HEIGHT + MARGIN201229 const title = defaultTitle(document, model)202230 const summary = describeSquaring(model)203231 const captionY = top + panelHeight + 20232+ const table = searchTableSvg(model, MARGIN, captionY + 28, Math.max(x - MARGIN, 320))233+ const width = Math.max(x + MARGIN, 320, model.search === null ? 0 : 420 + MARGIN * 2, MARGIN * 2 + summary.length * 6.6)234+ const height = top + panelHeight + CAPTION_HEIGHT + table.height + MARGIN235+ const hatch = model.complete ? '' : `<defs><pattern id="squaring-hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line class="squaring-frame" x1="0" y1="0" x2="0" y2="8" stroke="${MUTED}" stroke-width="1.5"/></pattern></defs>`204236 205- 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}">237+ 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-complete="${model.complete}" data-simple="${model.simple}" data-perfect="${model.perfect}">206238 <title>${escapeXml(title)}</title>207239 <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>208- <rect class="squaring-canvas" x="0" y="0" width="${round(width)}" height="${round(height)}" fill="#ffffff"/>240+ ${hatch}<rect class="squaring-canvas" x="0" y="0" width="${round(width)}" height="${round(height)}" fill="#ffffff"/>209241 ${panels.join('\n ')}210242 <text class="squaring-title" x="${MARGIN}" y="${round(captionY)}" fill="${INK}" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>211243 <text class="squaring-summary" x="${MARGIN}" y="${round(captionY + 17)}" fill="${MUTED}" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(summary)}</text>244+ ${table.svg}212245 </svg>`213246 }@@ -1,8 +1,23 @@
11 import { RenderError } from '../runtime/errors'2+import { POLYHEDRA, POLYHEDRON_NAMES } from './squaring-polyhedra'23 34 export type SquaringView = 'rectangle' | 'circuit' | 'overlay' | 'both'45 export type SquaringLabels = 'sides' | 'none'56 7+/** A side length: a constant, a linear expression in named unknowns, or `?` for "as wide as the gap". */8+export interface SideExpression {9+ text: string10+ constant: number11+ terms: Record<string, number>12+ auto: boolean13+}14+15+export interface SquareGroup {16+ sides: SideExpression[]17+ under: string[] | null18+ line: number19+}20+621 export interface SquaringWire {722 from: string823 to: string@@ -10,15 +25,17 @@ export interface SquaringWire {
1025 }1126 1227 export interface SquaringRectangleSource {13- width: number14- height: number15- sides: number[]28+ width: number | null29+ height: number | null30+ groups: SquareGroup[]31+ symbolic: boolean1632 line: number1733 }1834 1935 export interface SquaringNetworkSource {20- positive: string21- negative: string36+ positive: string | null37+ negative: string | null38+ searchBattery: boolean2239 nodes: string[]2340 wires: SquaringWire[]2441 line: number@@ -33,11 +50,13 @@ export interface SquaringDocument {
3350 }3451 3552 const IDENTIFIER = /^[A-Za-z0-9_]+$/53+const SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*$/3654 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(),]+$/55+const RECTANGLE = /^rectangle\s+(\d+|\?)\s*(?:x|×|by)\s*(\d+|\?)$/i56+const TERM = /^([+-]?)(\d*)([A-Za-z_][A-Za-z0-9_]*)?$/3957 const VIEWS: readonly SquaringView[] = ['rectangle', 'circuit', 'overlay', 'both']4058 const LABELS: readonly SquaringLabels[] = ['sides', 'none']59+const RESERVED = new Set(['under', 'squares', 'rectangle', 'battery', 'wire', 'face', 'polyhedron', 'title', 'any'])4160 4261 function sourceError(line: number, message: string): never {4362 throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)@@ -48,7 +67,11 @@ function stripComment(raw: string): string {
4867 for (let index = 0; index < raw.length; index += 1) {4968 const character = raw[index]5069 if (character === '"') quoted = !quoted51- else if (character === '#' && !quoted) return raw.slice(0, index)70+ else if (character === '#' && !quoted) {71+ // "#3" refers to the third square; a comment needs a space (or line start) before the # and no digit after it.72+ const atBoundary = index === 0 || /\s/.test(raw[index - 1] ?? '')73+ if (atBoundary && !/^\d/.test(raw[index + 1] ?? '')) return raw.slice(0, index)74+ }5275 }5376 return raw5477 }@@ -64,9 +87,83 @@ function positiveInteger(value: string, line: number, role: string): number {
6487 return number6588 }6689 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'))90+export function parseSideExpression(token: string, line: number): SideExpression {91+ if (token === '?') return { text: '?', constant: 0, terms: {}, auto: true }92+ const terms: Record<string, number> = {}93+ let constant = 094+ const pieces = token.match(/[+-]?[^+-]+/g)95+ if (pieces === null || pieces.join('') !== token) sourceError(line, `"${token}" is not a side length. Use a number, a name such as a, or an expression such as 2a+b.`)96+ for (const piece of pieces) {97+ const match = TERM.exec(piece)98+ if (match === null) sourceError(line, `"${token}" is not a side length. Use a number, a name such as a, or an expression such as 2a+b.`)99+ const sign = match[1] === '-' ? -1 : 1100+ const digits = match[2] ?? ''101+ const symbol = match[3]102+ if (symbol === undefined) {103+ if (digits === '') sourceError(line, `"${token}" is not a side length.`)104+ constant += sign * Number(digits)105+ } else {106+ if (RESERVED.has(symbol)) sourceError(line, `"${symbol}" is a keyword and cannot name a side.`)107+ terms[symbol] = (terms[symbol] ?? 0) + sign * (digits === '' ? 1 : Number(digits))108+ }109+ }110+ for (const [symbol, coefficient] of Object.entries(terms)) if (coefficient === 0) delete terms[symbol]111+ if (Object.keys(terms).length === 0 && constant <= 0) sourceError(line, `A side length must be positive, not "${token}".`)112+ return { text: token, constant, terms, auto: false }113+}114+115+function parseSquareLine(text: string, line: number): SquareGroup[] {116+ const [body, ...underParts] = text.split(/\bunder\b/)117+ if (underParts.length > 1) sourceError(line, 'Write at most one "under" clause per line.')118+ const spaced = (body ?? '').replaceAll(',', ' ').replaceAll('(', ' ( ').replaceAll(')', ' ) ')119+ const tokens = spaced.trim().split(/\s+/).filter(token => token !== '')120+ const groups: SquareGroup[] = []121+ let open: SideExpression[] | null = null122+ let loose: SideExpression[] = []123+ for (const token of tokens) {124+ if (token === '(') {125+ if (open !== null) sourceError(line, 'Groups cannot be nested.')126+ if (loose.length > 0) {127+ groups.push({ sides: loose, under: null, line })128+ loose = []129+ }130+ open = []131+ } else if (token === ')') {132+ if (open === null) sourceError(line, 'Unexpected ")".')133+ if (open.length === 0) sourceError(line, 'Empty group "()".')134+ groups.push({ sides: open, under: null, line })135+ open = null136+ } else {137+ const expression = parseSideExpression(token, line)138+ if (open !== null) open.push(expression)139+ else loose.push(expression)140+ }141+ }142+ if (open !== null) sourceError(line, 'Missing ")".')143+ if (loose.length > 0) groups.push({ sides: loose, under: null, line })144+ if (groups.length === 0) sourceError(line, 'List at least one square side.')145+ const underText = underParts[0]?.trim() ?? ''146+ if (underParts.length === 1) {147+ if (groups.length !== 1) sourceError(line, 'A line with "under" holds exactly one group of squares.')148+ const refs = underText.split(/[\s,]+/).filter(ref => ref !== '')149+ if (refs.length === 0) sourceError(line, 'Name the squares this group hangs under, for example "under a b".')150+ for (const ref of refs) {151+ if (!(SYMBOL.test(ref) || /^\d+$/.test(ref) || /^#\d+$/.test(ref))) sourceError(line, `"${ref}" is not a square reference. Use a side name, a number, or #n for the n-th square.`)152+ }153+ const group = groups[0]154+ if (group !== undefined) group.under = refs155+ }156+ return groups157+}158+159+// Bare numbers are always squares. Named sides need a group "(a b)" or an160+// "under" clause so that a stray line of words is reported, not solved.161+function looksLikeSquareLine(text: string): boolean {162+ const body = text.split(/\bunder\b/)[0] ?? ''163+ const tokens = body.replaceAll(',', ' ').replaceAll('(', ' ').replaceAll(')', ' ').trim().split(/\s+/).filter(token => token !== '')164+ if (tokens.length === 0 || !tokens.every(token => token === '?' || /^[+-]?[\dA-Za-z_+-]+$/.test(token))) return false165+ const symbolic = tokens.some(token => /[A-Za-z_]/.test(token))166+ return !symbolic || text.includes('(') || /\bunder\b/.test(text)70167 }71168 72169 export function parseSquaring(source: string): SquaringDocument {@@ -74,20 +171,27 @@ export function parseSquaring(source: string): SquaringDocument {
74171 let labels: SquaringLabels | null = null75172 let title = ''76173 let rectangle: SquaringRectangleSource | null = null77- let battery: { positive: string; negative: string; line: number } | null = null174+ let battery: { positive: string | null; negative: string | null; search: boolean; line: number } | null = null78175 const wires: SquaringWire[] = []176+ const wireKeys = new Set<string>()79177 const nodes: string[] = []80178 let firstNetworkLine = 081179 82180 const rememberNode = (id: string): void => {83181 if (!nodes.includes(id)) nodes.push(id)84182 }85- const addWire = (from: string, to: string, line: number): void => {86- if (from === to) sourceError(line, `A wire cannot connect node "${from}" to itself.`)183+ const networkOnly = (line: number): void => {87184 if (rectangle !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')88185 if (firstNetworkLine === 0) firstNetworkLine = line186+ }187+ const addWire = (from: string, to: string, line: number): void => {188+ if (from === to) sourceError(line, `A wire cannot connect node "${from}" to itself.`)189+ networkOnly(line)89190 rememberNode(from)90191 rememberNode(to)192+ const key = from < to ? `${from}|${to}` : `${to}|${from}`193+ if (wireKeys.has(key)) return194+ wireKeys.add(key)91195 wires.push({ from, to, line })92196 }93197 @@ -127,34 +231,32 @@ export function parseSquaring(source: string): SquaringDocument {
127231 if (rectangleMatch !== null) {128232 if (rectangle !== null) sourceError(line, 'Only one rectangle may be declared.')129233 if (wires.length > 0 || battery !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')234+ const width = rectangleMatch[1] ?? '?'235+ const height = rectangleMatch[2] ?? '?'130236 rectangle = {131- width: positiveInteger(rectangleMatch[1] ?? '', line, 'The rectangle width'),132- height: positiveInteger(rectangleMatch[2] ?? '', line, 'The rectangle height'),133- sides: [],237+ width: width === '?' ? null : positiveInteger(width, line, 'The rectangle width'),238+ height: height === '?' ? null : positiveInteger(height, line, 'The rectangle height'),239+ groups: [],240+ symbolic: false,134241 line,135242 }136243 continue137244 }138245 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-147246 if (keyword === 'battery') {148247 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>".')248+ networkOnly(line)249+ if (restTokens.length === 1 && (restTokens[0] === 'any' || restTokens[0] === '?')) {250+ battery = { positive: null, negative: null, search: true, line }251+ continue252+ }253+ if (restTokens.length !== 2) sourceError(line, 'Write the battery as "battery <positive node> <negative node>" or "battery any".')151254 const positive = identifier(restTokens[0] ?? '', line, 'The positive node')152255 const negative = identifier(restTokens[1] ?? '', line, 'The negative node')153256 if (positive === negative) sourceError(line, 'The battery needs two different nodes.')154- if (firstNetworkLine === 0) firstNetworkLine = line155257 rememberNode(positive)156258 rememberNode(negative)157- battery = { positive, negative, line }259+ battery = { positive, negative, search: false, line }158260 continue159261 }160262 @@ -164,6 +266,33 @@ export function parseSquaring(source: string): SquaringDocument {
164266 continue165267 }166268 269+ if (keyword === 'face') {270+ if (restTokens.length < 3) sourceError(line, 'A face needs at least three nodes.')271+ const face = restTokens.map(node => identifier(node, line, 'A node'))272+ if (new Set(face).size !== face.length) sourceError(line, 'A face cannot repeat a node.')273+ face.forEach((node, position) => addWire(node, face[(position + 1) % face.length] ?? node, line))274+ continue275+ }276+277+ if (keyword === 'polyhedron') {278+ const name = restTokens.join(' ').trim().toLowerCase()279+ const preset = POLYHEDRA[name]280+ if (preset === undefined) sourceError(line, `Unknown polyhedron "${name}". Choose one of: ${POLYHEDRON_NAMES.join(', ')}.`)281+ const { edges } = preset()282+ for (const [from, to] of edges) addWire(from, to, line)283+ continue284+ }285+286+ if (keyword === 'squares' || (rectangle !== null && looksLikeSquareLine(text))) {287+ if (rectangle === null) sourceError(line, 'Declare the rectangle with "rectangle <width> x <height>" before listing squares.')288+ const groups = parseSquareLine(keyword === 'squares' ? rest : text, line)289+ for (const group of groups) {290+ if (group.sides.some(side => Object.keys(side.terms).length > 0)) rectangle.symbolic = true291+ rectangle.groups.push(group)292+ }293+ continue294+ }295+167296 if (CHAIN.test(text)) {168297 const chain = text.split(/\s*-+\s*/).map(node => identifier(node, line, 'A node'))169298 for (let position = 1; position < chain.length; position += 1) {@@ -172,7 +301,7 @@ export function parseSquaring(source: string): SquaringDocument {
172301 continue173302 }174303 175- sourceError(line, `Unrecognized line "${text}". Expected a rectangle, squares, battery, wire, title, or directive.`)304+ sourceError(line, `Unrecognized line "${text}". Expected a rectangle, squares, battery, wire, face, polyhedron, title, or directive.`)176305 }177306 178307 if (rectangle === null && battery === null && wires.length === 0) {@@ -180,15 +309,28 @@ export function parseSquaring(source: string): SquaringDocument {
180309 }181310 182311 if (rectangle !== null) {183- if (rectangle.sides.length < 2) sourceError(rectangle.line, 'A squared rectangle needs at least two squares.')312+ const count = rectangle.groups.reduce((sum, group) => sum + group.sides.length, 0)313+ if (count === 0) sourceError(rectangle.line, 'List the squares after the rectangle.')314+ if (rectangle.symbolic) {315+ rectangle.groups.forEach((group, index) => {316+ if (index > 0 && group.under === null) sourceError(group.line, 'In a sketch with named sides, every group after the first needs "under" to say which squares it hangs from.')317+ if (index === 0 && group.under !== null) sourceError(group.line, 'The first group is the top row and cannot hang under anything.')318+ })319+ } else if (rectangle.groups.some(group => group.under !== null) && rectangle.groups.some(group => group.sides.some(side => side.auto))) {320+ sourceError(rectangle.line, 'Use "?" sides or "under" clauses, not both.')321+ }184322 return { view: view ?? 'both', labels: labels ?? 'sides', title, rectangle, network: null }185323 }186324 187- if (battery === null) sourceError(firstNetworkLine, 'A network needs a battery: "battery <positive node> <negative node>".')325+ if (battery === null) sourceError(firstNetworkLine, 'A network needs a battery: "battery <positive node> <negative node>" or "battery any".')188326 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.`)327+ if (!battery.search) {328+ const connectedToWire = new Set(wires.flatMap(wire => [wire.from, wire.to]))329+ for (const pole of [battery.positive, battery.negative]) {330+ if (pole !== null && !connectedToWire.has(pole)) sourceError(battery.line, `Battery node "${pole}" must be connected to at least one wire.`)331+ }332+ } else if (wires.length < 2) {333+ sourceError(battery.line, '"battery any" needs at least two wires so one can become the battery.')192334 }193335 194336 return {@@ -196,6 +338,6 @@ export function parseSquaring(source: string): SquaringDocument {
196338 labels: labels ?? 'sides',197339 title,198340 rectangle: null,199- network: { positive: battery.positive, negative: battery.negative, nodes, wires, line: firstNetworkLine },341+ network: { positive: battery.positive, negative: battery.negative, searchBattery: battery.search, nodes, wires, line: firstNetworkLine },200342 }201343 }@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
22 import orderNine from '../../../examples/diagrams/order-nine.squaring?raw'33 import antiWedge from '../../../examples/diagrams/anti-wedge.squaring?raw'44 import compound from '../../../examples/diagrams/compound.squaring?raw'5+import stoneSketch from '../../../examples/diagrams/stone-sketch.squaring?raw'6+import cubeSearch from '../../../examples/diagrams/cube-search.squaring?raw'7+import gap from '../../../examples/diagrams/gap.squaring?raw'58 import { squaringAdapter } from '../src/adapters/squaring'69 import { parseSquaring } from '../src/languages/squaring'710 import { buildSquaring } from '../src/languages/squaring-model'11+import { POLYHEDRA } from '../src/languages/squaring-polyhedra'812 import type { RenderRequest } from '../src/runtime/types'913 1014 function request(source: string, options: Record<string, string> = {}): RenderRequest {@@ -19,6 +23,18 @@ function request(source: string, options: Record<string, string> = {}): RenderRe
1923 }2024 2125 const render = (source: string, options: Record<string, string> = {}) => squaringAdapter.render(request(source, options), new AbortController().signal)26+const build = (source: string) => buildSquaring(parseSquaring(source))27+28+function expectTiling(model: ReturnType<typeof build>): void {29+ expect(model.squares.reduce((sum, square) => sum + square.side * square.side, 0)).toBe(model.width * model.height)30+ for (const first of model.squares) {31+ for (const second of model.squares) {32+ if (first === second) continue33+ 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.side34+ expect(overlap).toBe(false)35+ }36+ }37+}2238 2339 describe('squaring language', () => {2440 it('parses a Bouwkamp rectangle with grouped, bare, and commented square lists', () => {@@ -27,20 +43,37 @@ describe('squaring language', () => {
2743 expect(document.view).toBe('both')2844 expect(document.labels).toBe('sides')2945 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] })46+ expect(document.rectangle).toMatchObject({ width: 33, height: 32, symbolic: false })47+ expect(document.rectangle?.groups.map(group => group.sides.map(side => side.constant))).toEqual([[18, 15], [7, 8], [14, 4], [10, 1], [9]])3148 expect(document.network).toBeNull()32- expect(parseSquaring('rectangle 3x2\n2, 1, 1 # trailing comment').rectangle?.sides).toEqual([2, 1, 1])49+ expect(parseSquaring('rectangle 3x2\n2, 1, 1 # trailing comment').rectangle?.groups[0]?.sides.map(side => side.text)).toEqual(['2', '1', '1'])3350 })3451 35- it('parses a network with a battery, wire lines, and chains', () => {36- const document = parseSquaring(antiWedge)52+ it('parses sketches with expressions, unknown dimensions, and under clauses', () => {53+ const document = parseSquaring('rectangle ? x 10\n(a 2a+b)\n(b) under a\n(3) under #2 b')54+ expect(document.rectangle).toMatchObject({ width: null, height: 10, symbolic: true })55+ const groups = document.rectangle?.groups ?? []56+ expect(groups[0]?.sides[1]).toMatchObject({ text: '2a+b', terms: { a: 2, b: 1 }, constant: 0 })57+ expect(groups[1]?.under).toEqual(['a'])58+ expect(groups[2]?.under).toEqual(['#2', 'b'])59+ expect(parseSquaring('rectangle 5 x 5\n(3 ?)').rectangle?.groups[0]?.sides[1]?.auto).toBe(true)60+ })3761 62+ it('parses a network with a battery, wire lines, chains, faces, and polyhedra', () => {63+ const document = parseSquaring(antiWedge)3864 expect(document.rectangle).toBeNull()3965 expect(document.network?.positive).toBe('a')4066 expect(document.network?.negative).toBe('e')4167 expect(document.network?.nodes).toEqual(['a', 'e', 'b', 'c', 'd', 'f'])4268 expect(document.network?.wires).toHaveLength(9)43- expect(parseSquaring('battery p n\nwire p n').network?.wires).toEqual([{ from: 'p', to: 'n', line: 2 }])69+70+ const faces = parseSquaring('battery any\nface a b c\nface a c d\nface a d b\nface b d c')71+ expect(faces.network?.searchBattery).toBe(true)72+ expect(faces.network?.wires).toHaveLength(6)73+74+ const cube = parseSquaring(cubeSearch)75+ expect(cube.network?.wires).toHaveLength(12)76+ expect(cube.network?.nodes).toHaveLength(8)4477 })4578 4679 it('rejects mixed, incomplete, and malformed sources with line numbers', () => {@@ -50,22 +83,37 @@ describe('squaring language', () => {
5083 expect(() => parseSquaring('battery a b\nc - d')).toThrow('Battery node "a" must be connected')5184 expect(() => parseSquaring('.view sideways\nrectangle 3 x 2\n2 1 1')).toThrow('Line 1: Unknown view "sideways"')5285 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')86+ expect(() => parseSquaring('rectangle ? x ?\n(a b)\n(c d)')).toThrow('Line 3: In a sketch with named sides, every group after the first needs "under"')87+ expect(() => parseSquaring('battery a b\npolyhedron teapot')).toThrow('Unknown polyhedron "teapot"')88+ expect(() => parseSquaring('rectangle 3 x 2\n(a wire)')).toThrow('is a keyword')89+ })90+})91+92+describe('polyhedron presets', () => {93+ it('define connected graphs with the expected edge counts', () => {94+ const expected: Record<string, [number, number]> = {95+ tetrahedron: [4, 6], cube: [8, 12], octahedron: [6, 12], dodecahedron: [20, 30], icosahedron: [12, 30],96+ 'triangular-prism': [6, 9], 'square-pyramid': [5, 8], 'square-antiprism': [8, 16], 'tetragonal-antiwedge': [6, 10],97+ }98+ for (const [name, [vertices, edges]] of Object.entries(expected)) {99+ const preset = POLYHEDRA[name]?.()100+ expect(preset?.vertices, name).toHaveLength(vertices)101+ expect(preset?.edges, name).toHaveLength(edges)102+ }54103 })55104 })56105 57106 describe('squaring model', () => {58107 it('places Bouwkamp squares and derives the Smith diagram from horizontal segments', () => {59- const model = buildSquaring(parseSquaring(orderNine))108+ const model = build(orderNine)60109 61- expect(model).toMatchObject({ form: 'rectangle', width: 33, height: 32, order: 9, simple: true, perfect: true, repeatedSides: [], blocks: [] })110+ expect(model).toMatchObject({ form: 'rectangle', width: 33, height: 32, order: 9, complete: true, simple: true, perfect: true, repeatedSides: [], blocks: [] })62111 expect(model.squares.map(square => [square.side, square.x, square.y])).toEqual([63112 [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],64113 ])65114 expect(model.nodes.map(node => [node.id, node.voltage, node.x0, node.x1])).toEqual([66115 ['top', 32, 0, 33], ['n1', 17, 18, 33], ['n2', 14, 0, 18], ['n3', 10, 14, 25], ['n4', 9, 24, 33], ['bottom', 0, 0, 33],67116 ])68- // Kirchhoff: current into every interior node equals current out of it.69117 for (const node of model.nodes.slice(1, -1)) {70118 const inflow = model.squares.filter(square => square.bottom === node.index).reduce((sum, square) => sum + square.side, 0)71119 const outflow = model.squares.filter(square => square.top === node.index).reduce((sum, square) => sum + square.side, 0)@@ -73,48 +121,84 @@ describe('squaring model', () => {
73121 }74122 })75123 124+ it('renders partial dissections instead of rejecting them', () => {125+ const stopped = build('rectangle 33 x 32\n15 18 7 8 14 4 10 1 9')126+ expect(stopped.complete).toBe(false)127+ expect(stopped.problem).toContain('square 7 (side 10) does not fit at column 0, row 22')128+ expect(stopped.squares).toHaveLength(6)129+ expect(stopped.nodes).toEqual([])130+131+ const unfilled = build('rectangle 33 x 32\n18 15 7 8')132+ expect(unfilled.complete).toBe(false)133+ expect(unfilled.problem).toContain('do not fill the rectangle')134+135+ const auto = build(gap)136+ expect(auto.squares.map(square => square.side)).toEqual([18, 15, 7, 8, 14, 4])137+ expect(auto.complete).toBe(false)138+139+ const inferred = build('rectangle ? x ?\n(18 15) (7 8) (14 4) (10 1) (9)')140+ expect(inferred).toMatchObject({ width: 33, height: 32, complete: true, simple: true, perfect: true })141+ })142+143+ it('solves a Stone sketch with unknown sides into the order-9 squaring', () => {144+ const model = build(stoneSketch)145+ expect(model).toMatchObject({ width: 33, height: 32, order: 9, complete: true, simple: true, perfect: true })146+ expect(model.squares.map(square => square.side)).toEqual([18, 15, 7, 8, 14, 4, 10, 1, 9])147+ expect(model.squares[0]?.label).toBe('a=18')148+ expectTiling(model)149+150+ const pinned = build('rectangle ? x ?\n(a 15)\n(c d) under 15\n(e f) under a\n(g h) under f c\n(i) under h d')151+ expect(pinned.width).toBe(33)152+153+ // Four equal squares: the sketch closes as a 2 × 2 block.154+ expect(build('rectangle ? x ?\n(a b)\n(c) under a\n(d) under b')).toMatchObject({ width: 2, height: 2, complete: true, perfect: false })155+ expect(() => build('rectangle ? x ?\n(a 16)\n(c d) under 16\n(e f) under a\n(g h) under f c\n(i) under h d')).toThrow(/whole number|cannot close|closes only/)156+ expect(() => build('rectangle ? x ?\n(a b)\n(c) under b\n(d) under c a')).toThrow(/every side is zero|closes only|cannot close/)157+ expect(() => build('rectangle ? x ?\n(a 1 1)\n(c) under 1')).toThrow('ambiguous')158+ expect(() => build('rectangle ? x ?\n(a b)\n(c) under a\n(d) under a')).toThrow('already has a group under it')159+ })160+76161 it('solves a polyhedral network into an integer squared rectangle', () => {77- const model = buildSquaring(parseSquaring(antiWedge))162+ const model = build(antiWedge)78163 79164 expect(model).toMatchObject({ form: 'network', width: 15, height: 11, order: 9, simple: true, perfect: false, repeatedSides: [1, 4, 5, 6] })80165 expect(model.nodes.find(node => node.id === 'a')?.voltage).toBe(11)81166 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.84167 for (const square of model.squares) {85168 expect((model.nodes[square.top]?.voltage ?? 0) - (model.nodes[square.bottom]?.voltage ?? 0)).toBe(square.side)86169 }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- }170+ expectTiling(model)95171 })96172 97- it('finds compound blocks and repeated sides', () => {98- const model = buildSquaring(parseSquaring(compound))173+ it('tries every wire as the battery and keeps the best candidate', () => {174+ const model = build(cubeSearch)175+ expect(model.search).toHaveLength(12)176+ expect(model.searchIndex).toBeGreaterThanOrEqual(0)177+ expect(model.search?.every(candidate => candidate.error === null)).toBe(true)178+ expect(model).toMatchObject({ order: 11, complete: true })179+ expectTiling(model)180+181+ const wedge = build('polyhedron tetragonal-antiwedge\nbattery any')182+ expect(wedge.search).toHaveLength(10)183+ expect(wedge.simple).toBe(true)184+ })99185 186+ it('finds compound blocks and repeated sides', () => {187+ const model = build(compound)100188 expect(model).toMatchObject({ order: 10, simple: false, perfect: true })101189 expect(model.blocks).toEqual([{ x: 32, y: 0, width: 33, height: 32, squares: 9 }])102190 103- const quilt = buildSquaring(parseSquaring('rectangle 3 x 2\n2 1 1'))191+ const quilt = build('rectangle 3 x 2\n2 1 1')104192 expect(quilt.simple).toBe(false)105193 expect(quilt.blocks).toEqual([{ x: 2, y: 0, width: 1, height: 2, squares: 2 }])106194 expect(quilt.repeatedSides).toEqual([1])107195 })108196 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'))197+ it('explains why a network cannot become a squaring', () => {198+ expect(() => build('battery a b\na - b\nc - d')).toThrow('not connected')199+ expect(() => build('battery a c\na - b - c\na - d - c\nb - d')).toThrow('carries no current')200+ expect(() => build('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')201+ const cube = build('battery a b\na - b - c - d - a\ne - f - g - h - e\na - e\nb - f\nc - g\nd - h')118202 expect(cube).toMatchObject({ width: 24, height: 14, order: 12, simple: false })119203 expect(cube.squares[0]).toMatchObject({ side: 14, x: 0, y: 0 })120204 })@@ -124,9 +208,9 @@ describe('squaring renderer', () => {
124208 it('renders the rectangle and the Smith diagram side by side', async () => {125209 const result = await render(orderNine)126210 127- expect(result.engineVersion).toBe('diagramzip-squaring@2')211+ expect(result.engineVersion).toBe('diagramzip-squaring@3')128212 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"')213+ expect(result.body).toContain('data-view="both" data-form="rectangle" data-order="9" data-width="33" data-height="32" data-complete="true" data-simple="true" data-perfect="true"')130214 expect(result.body.match(/class="squaring-square"/g)).toHaveLength(9)131215 expect(result.body.match(/class="squaring-wire"/g)).toHaveLength(9)132216 expect(result.body.match(/class="squaring-node"/g)).toHaveLength(6)@@ -135,6 +219,24 @@ describe('squaring renderer', () => {
135219 expect(result.body).toContain('class="squaring-battery"')136220 expect(result.body).toContain('Order 9 | 33 × 32 | simple | perfect')137221 expect(result.body).not.toContain('class="squaring-block"')222+ expect(result.body).not.toContain('squaring-hatch')223+ })224+225+ it('draws incomplete dissections with hatched gaps and no circuit', async () => {226+ const result = await render(gap)227+ expect(result.body).toContain('data-view="rectangle"')228+ expect(result.body).toContain('data-complete="false"')229+ expect(result.body).toContain('class="squaring-gap"')230+ expect(result.body).toContain('id="squaring-hatch"')231+ expect(result.body).not.toContain('class="squaring-wire"')232+ expect(result.body).toContain('incomplete: the squares do not fill the rectangle')233+ })234+235+ it('lists every battery candidate under a searched network', async () => {236+ const result = await render(cubeSearch)237+ expect(result.body).toContain('class="squaring-search" data-candidates="12"')238+ expect(result.body).toContain('best of 12 choices')239+ expect(result.body).toContain('◀')138240 })139241 140242 it('outlines compound blocks in the overlay view and honours label and view directives', async () => {@@ -147,7 +249,7 @@ describe('squaring renderer', () => {
147249 const plain = await render('.view rectangle\n.labels none\nrectangle 3 x 2\n2 1 1')148250 expect(plain.body).toContain('data-view="rectangle"')149251 expect(plain.body).not.toContain('class="squaring-wire"')150- expect(plain.body).not.toContain('<text x=')252+ expect(plain.body).not.toContain('class="squaring-square-label"')151253 expect(plain.body).toContain('<title>Squared rectangle 3 × 2</title>')152254 153255 const circuit = await render(antiWedge.replace('.view both', '.view circuit'))@@ -151,7 +151,7 @@ const VERSION_PATTERNS = Object.freeze({
151151 nwdiag: /(?:nwdiag@)?3\.0\.0/,152152 packetdiag: /(?:packetdiag@)?3\.0\.0/,153153 rackdiag: /(?:rackdiag@)?3\.0\.0/,154- squaring: /diagramzip-squaring@2/,154+ squaring: /diagramzip-squaring@3/,155155 bytefield: /bytefield-svg@1\.11\.0/,156156 mermaid: /mermaid@11\.17\.0/,157157 bpmn: /(?:bpmn-js@18\.25\.1|diagramzip-bpmn-svg@1)/,@@ -28,7 +28,7 @@ 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.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; sketch one with named sides and `under` clauses and let the solver close it; or build one backwards from a planar network (`wire`, `face`, or `polyhedron cube`) with `battery <positive> <negative>` or `battery any`. Incomplete dissections render with hatched gaps instead of failing. 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.3232 3333 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).3434