diff --git a/.gitignore b/.gitignore index 7540c4e..5136a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ /playwright-report/ # next.js -/.next/ +.next/ /out/ # production diff --git a/DESIGN.md b/DESIGN.md index 38f3cce..c35cfb7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -50,6 +50,17 @@ Two tokens per mode. No accent color. Ever. In `globals.css` the tokens carry the `--color-` prefix (`--color-paper` etc.) that Tailwind v4's `@theme` requires to generate `bg-paper` / `text-ink` utilities. Same tokens, two forms. +### Scoped experiment hues + +An experiment may bind a constellation hue to a named role through a scoped token. No new hue enters the language. The Penrose explorer does this: + +| Token | Reuses | Role | +| --- | --- | --- | +| `--color-penrose-thick` | `--color-moment-1` (gold) | thick rhombus fill | +| `--color-penrose-thin` | `--color-moment-4` (teal) | thin rhombus fill | + +Grout reuses `--color-paper`; the pin ring reuses `--color-ink`. The only new value is a dark teal (`#4f7d92`) that `--color-penrose-thin` takes in dark mode, nudged lighter for contrast on the dark paper. `thick` is identical in both modes. + ## Dither The visual signature. Three roles, strict territories, strict density. @@ -121,6 +132,14 @@ The visual signature. Three roles, strict territories, strict density. - **Active.** Fill at ink `0.1`. - **Disabled.** Opacity `0.4`, `cursor: not-allowed`. +## Teaching animation (scoped) + +Animation is otherwise not in the language. Teaching experiments are the one exception, under a hard contract. + +- **User-initiated only.** Motion happens on an explicit play, step, or slider drag. Never on load, never ambient, never on scroll. +- **Reduced-motion is honored.** Under `prefers-reduced-motion`, play is disabled and the sketch renders its representative end state, stationary. The query is read live, so a mid-session change takes effect. +- **Confined to teaching sketches.** This applies to the framed sketch primitive inside a teaching experiment (the Penrose spine's `Sketch` harness). It does not relax the no-animation rule for site chrome, forms, or data viz. + ## Light/dark toggle - **State.** Cookie `theme` = `"light"` | `"dark"`. Absent = `"dark"`. @@ -138,12 +157,12 @@ The visual signature. Three roles, strict territories, strict density. - MDX for experiment writeups - Open Graph / social cards - Tags, categories, search -- A second accent color +- A second accent color (relaxed: an experiment may bind an existing constellation hue to a named role through a scoped token, as the Penrose explorer does; no new hue) - Custom icon set beyond toggle and row-delete `×` - Photography or non-dithered illustration -- Animation beyond focus and hover +- Animation beyond focus and hover (relaxed: user-initiated motion inside a teaching sketch, under the "Teaching animation" contract above) - A second typeface family -- Per-experiment custom styling +- Per-experiment custom styling (relaxed: scoped tokens that reuse existing constellation hues are allowed) - Multi-column layouts, dashboards, dense tables ## Adding a new experiment diff --git a/docs/superpowers/plans/2026-06-24-penrose-unbounded-generator.md b/docs/superpowers/plans/2026-06-24-penrose-unbounded-generator.md new file mode 100644 index 0000000..49e9499 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-penrose-unbounded-generator.md @@ -0,0 +1,638 @@ +# Penrose Unbounded Viewport Generator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the explorer's bounded one-patch generation with an edgeless plane: for the visible rectangle, enumerate only the tiles in view (a fixed generic Penrose tiling), generated lazily as the camera pans, with exact `[n; j,k]` addresses computed locally. + +**Architecture:** A pure pentagrid enumerator (`pentagrid.ts`) turns a viewport rect into the visible `RenderFace`s by enumerating de Bruijn line-crossings and filtering by physical tile position. A physical-space chunk cache (`chunks.ts`) generates and memoizes cells on demand. The explorer component swaps its mount-time `buildPatch` for the chunk cache; everything else (camera, hit-test, codec, pin, share, theme) is unchanged. + +**Tech Stack:** Next.js App Router, React, TypeScript, native 2D canvas, Bun (`bun test`), Playwright. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-06-24-penrose-unbounded-generator-design.md`. The enumerator algorithm in this plan is verified: it matches the tested `generate()` cut-and-project oracle key-for-key, and an adversarial pass confirmed far-from-origin viewports (to radius 400) drop zero visible tiles. + +## Global Constraints + +- **Tooling:** `bun` / `bunx` only. Never `npm`/`npx`/`yarn`/`pnpm`. +- **Client-only:** no `useSearchParams`, no Suspense, no `export const runtime/revalidate/dynamic`. URL state stays read-once + debounced `history.replaceState`. +- **Frame:** render and hit-test in the `cap.physical` (ζ^l) frame. No rotation. Never mix `physical(K)` with the old substitution `pos` frame. +- **Filter by the tile body, never by the crossing point.** `physical(K) = (5/2)·z + physical(γ) + bounded`; filtering by `z` drops far tiles. Own tiles by their `physical(K)` centroid. +- **Two projections of γ:** `internal(γ) = Σ γ_l ζ^{2l} = (vx,vy)` is the window center; `physical(γ) = Σ γ_l ζ^l` is the inverse-map shift. Do not mix. +- **Tests:** colocated `bun:test`, table-driven where it fits. The existing engine suite (`bun test ./src/app/x/penrose/` → 68 pass) must stay green. E2E in `e2e/x/penrose/`. +- **Prose:** no emdashes in authored code/comments or copy. +- **Address gauge:** because `Σγ_l = 0`, the de Bruijn index lands in band `{1,2,3,4}`, matching the engine; no `[1,1,1,1,1]` shift. Keys are byte-identical to `faces.ts`/`patch.ts`. +- **Pinned generic window center:** `(vx, vy) = (0.137, -0.081)` (the generic offset the engine's own `cap.test.ts` uses). Validated non-degenerate by the genericity test. +- **Do not modify** `cap.ts`, `deflate.ts`, `bridge.ts`, `fold.ts`, `faces.ts` (the tested engine), or `codec.ts`. `patch.ts` is read-only except its `RenderFace`/`Pt` exports are imported. + +--- + +## File Structure + +- **Create:** `src/app/x/penrose/explore/lib/pentagrid.ts` (+ `pentagrid.test.ts`) — the enumerator. +- **Create:** `src/app/x/penrose/explore/lib/chunks.ts` (+ `chunks.test.ts`) — the physical-space chunk cache. +- **Modify:** `src/app/x/penrose/explore/PenroseExplorer.tsx` — swap mount-build for per-viewport generation. +- **Modify:** `e2e/x/penrose/explore.spec.ts` — add a pan-far test and the share round-trip in the new gauge. +- **Modify (copy):** `src/app/x/penrose/page.tsx`, `src/app/x/penrose/explore/page.tsx`, `src/app/x/page.tsx` — the bounded story becomes the edgeless story. +- **Unchanged:** `cap.ts`, `deflate.ts`, `bridge.ts`, `fold.ts`, `faces.ts`, `patch.ts`, `hitTest.ts`, `codec.ts`. `patch.ts`/`buildPatch` stay (tested foundation, future teaching sketches), just no longer the explorer's render source. + +The reference prototype (verified, in scratchpad) is at `…/scratchpad/pentagrid.ts`; Task 1 ports it and extends it to return full `RenderFace`s. + +--- + +## Task 1: The pentagrid enumerator (`pentagrid.ts`) + +**Files:** +- Create: `src/app/x/penrose/explore/lib/pentagrid.ts` +- Test: `src/app/x/penrose/explore/lib/pentagrid.test.ts` + +**Interfaces:** +- Consumes: `PCOS, PSIN, ICOS, ISIN, physical` from `./cap`; `Pt, RenderFace` from `./patch`; (test only) `generate` from `./cap`, `extractFaces` from `./faces`. +- Produces: + - `type Rect = { minX: number; minY: number; maxX: number; maxY: number }` + - `function gammaFromWindowCenter(vx: number, vy: number): number[]` + - `function facesInViewport(view: Rect, gamma: readonly number[], physicalMargin?: number): RenderFace[]` + - `const WINDOW_CENTER: readonly [number, number]` = `[0.137, -0.081]` + - `const GAMMA: readonly number[]` = `gammaFromWindowCenter(...WINDOW_CENTER)` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/app/x/penrose/explore/lib/pentagrid.test.ts +import { describe, expect, test } from "bun:test"; + +import { generate, physical, type Vec5 } from "./cap"; +import { extractFaces } from "./faces"; +import { facesInViewport, gammaFromWindowCenter, GAMMA, WINDOW_CENTER, type Rect } from "./pentagrid"; + +const PHI = (1 + Math.sqrt(5)) / 2; + +// Oracle: the tested cut-and-project generate(), as faces, for the same tiling. +// generate() yields Vertex{n,p}; extractFaces wants LiftedVertex{pos,coord}. +function oracleFaces(radius: number, vx: number, vy: number) { + const verts = generate(radius, vx, vy).map((v) => ({ pos: physical(v.n), coord: v.n })); + return extractFaces(verts); +} +const inDisk = (cx: number, cy: number, r: number) => Math.hypot(cx, cy) <= r; + +describe("facesInViewport matches the generate() oracle key-for-key", () => { + const [vx, vy] = WINDOW_CENTER; + // A few origin-ish and off-origin regions. Compare only faces whose centroid is + // inside an inner disk where the disk-clipped oracle is complete. + const cases = [ + { R: 16, cx: 0, cy: 0 }, + { R: 16, cx: 6, cy: 4 }, + ]; + for (const { R, cx, cy } of cases) { + test(`region r=${R} at (${cx},${cy})`, () => { + const inner = R - 5; + const oracle = new Map( + oracleFaces(R, vx, vy) + .map((f) => [f.key, f] as const), + ); + // restrict oracle to faces with centroid in the inner disk + const oracleKeys = new Set( + [...oracle.keys()].filter((key) => { + const f = faceCentroidFromKey(key); + return inDisk(f[0], f[1], inner); + }), + ); + const view: Rect = { minX: cx - R, minY: cy - R, maxX: cx + R, maxY: cy + R }; + const enumFaces = facesInViewport(view, GAMMA); + const enumKeys = new Set( + enumFaces + .filter((f) => inDisk(f.centroid[0], f.centroid[1], inner)) + .map((f) => f.key), + ); + const missing = [...oracleKeys].filter((k) => !enumKeys.has(k)); + const extra = [...enumKeys].filter((k) => !oracleKeys.has(k)); + expect(oracleKeys.size).toBeGreaterThan(100); + expect(missing).toEqual([]); + expect(extra).toEqual([]); + }); + } +}); + +// helper: physical centroid of a face from its "n0,n1,n2,n3,n4|jk" key +function faceCentroidFromKey(key: string): [number, number] { + const [coordStr, jk] = key.split("|"); + const n = coordStr.split(",").map(Number); + const j = Number(jk[0]), k = Number(jk[1]); + const c1 = [...n]; c1[j]++; + const c2 = [...c1]; c2[k]++; + const c3 = [...n]; c3[k]++; + const ps = [n, c1, c2, c3].map((c) => physical(c as Vec5)); + return [(ps[0][0] + ps[1][0] + ps[2][0] + ps[3][0]) / 4, (ps[0][1] + ps[1][1] + ps[2][1] + ps[3][1]) / 4]; +} + +describe("far-from-origin viewports drop nothing", () => { + test("a small viewport far out still returns its tiles, all with finite corners", () => { + const view: Rect = { minX: 45, minY: 45, maxX: 50, maxY: 50 }; + const faces = facesInViewport(view, GAMMA); + expect(faces.length).toBeGreaterThan(5); + for (const f of faces) { + expect(f.coord.length).toBe(5); + expect(f.corners.length).toBe(4); + for (const [x, y] of f.corners) { + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + } + // every returned tile's centroid is within one tile of the view + expect(f.centroid[0]).toBeGreaterThan(view.minX - 2); + expect(f.centroid[0]).toBeLessThan(view.maxX + 2); + } + }); +}); + +describe("tiling validity", () => { + const faces = facesInViewport({ minX: -12, minY: -12, maxX: 12, maxY: 12 }, GAMMA); + test("corners are unit-edge rhombi", () => { + for (const f of faces) { + for (let i = 0; i < 4; i++) { + const a = f.corners[i], b = f.corners[(i + 1) % 4]; + expect(Math.abs(Math.hypot(b[0] - a[0], b[1] - a[1]) - 1)).toBeLessThan(0.02); + } + } + }); + test("base corner is the componentwise min on axes j,k", () => { + for (const f of faces) { + const { coord: n, j, k } = f; + // n must be <= n+e_j and n+e_k on those axes, i.e. it is the min corner + expect(n[j]).toBe(Math.min(n[j], n[j] + 1)); + expect(n[k]).toBe(Math.min(n[k], n[k] + 1)); + } + }); + test("thick:thin ratio approaches phi", () => { + const thick = faces.filter((f) => f.type === "thick").length; + const thin = faces.filter((f) => f.type === "thin").length; + expect(thick / thin).toBeGreaterThan(1.55); + expect(thick / thin).toBeLessThan(1.7); + }); + test("keys are unique", () => { + expect(new Set(faces.map((f) => f.key)).size).toBe(faces.length); + }); +}); + +describe("genericity: the pinned window center has no on-boundary ties", () => { + test("gammaFromWindowCenter reproduces the window center via internal projection", () => { + const g = gammaFromWindowCenter(0.137, -0.081); + // internal(g) = Σ g_l ζ^{2l} = (vx,vy) + const ICOS = [0, 1, 2, 3, 4].map((l) => Math.cos((4 * Math.PI * l) / 5)); + const ISIN = [0, 1, 2, 3, 4].map((l) => Math.sin((4 * Math.PI * l) / 5)); + let vx = 0, vy = 0; + for (let l = 0; l < 5; l++) { vx += g[l] * ICOS[l]; vy += g[l] * ISIN[l]; } + expect(vx).toBeCloseTo(0.137, 9); + expect(vy).toBeCloseTo(-0.081, 9); + expect(g.reduce((s, x) => s + x, 0)).toBeCloseTo(0, 9); // sum 0 -> index band {1,2,3,4} + }); +}); +``` + +- [ ] **Step 2: Run the tests, confirm they fail** + +Run: `bun test ./src/app/x/penrose/explore/lib/pentagrid.test.ts` +Expected: FAIL with module not found / `facesInViewport is not a function`. + +- [ ] **Step 3: Implement `pentagrid.ts`** + +Port the verified scratchpad prototype, extended to return full `RenderFace`s (corners + centroid + coord + j + k). The algorithm is unchanged from the prototype; only the output shape grows from `{key,type}` to `RenderFace`. + +```ts +// src/app/x/penrose/explore/lib/pentagrid.ts +// Fast pentagrid viewport enumerator for a fixed generic Penrose tiling. +// +// A tile is a de Bruijn line-crossing of families j= (2/5)*phi ~= 0.65 covers the bounded term + +const fl = (x: number, y: number, l: number) => x * PCOS[l] + y * PSIN[l]; + +function physicalGamma(gamma: readonly number[]): [number, number] { + let x = 0, y = 0; + for (let l = 0; l < 5; l++) { x += gamma[l] * PCOS[l]; y += gamma[l] * PSIN[l]; } + return [x, y]; +} + +function lineRange(rect: Rect, l: number, gamma: readonly number[]): [number, number] { + const c = [ + fl(rect.minX, rect.minY, l), fl(rect.maxX, rect.minY, l), + fl(rect.minX, rect.maxY, l), fl(rect.maxX, rect.maxY, l), + ]; + return [Math.ceil(Math.min(...c) + gamma[l]), Math.floor(Math.max(...c) + gamma[l])]; +} + +function solveCrossing(j: number, k: number, aj: number, ak: number): [number, number] { + const a = PCOS[j], b = PSIN[j], c = PCOS[k], d = PSIN[k]; + const det = a * d - b * c; + return [(aj * d - b * ak) / det, (a * ak - aj * c) / det]; +} + +export function facesInViewport(view: Rect, gamma: readonly number[], physicalMargin = 1.5): RenderFace[] { + const out: RenderFace[] = []; + const seen = new Set(); + + // Step 1: physical viewport -> grid-space z region. + const [pgx, pgy] = physicalGamma(gamma); + const zx0 = (2 / 5) * (view.minX - pgx), zx1 = (2 / 5) * (view.maxX - pgx); + const zy0 = (2 / 5) * (view.minY - pgy), zy1 = (2 / 5) * (view.maxY - pgy); + const zRegion: Rect = { + minX: Math.min(zx0, zx1) - GRID_MARGIN, maxX: Math.max(zx0, zx1) + GRID_MARGIN, + minY: Math.min(zy0, zy1) - GRID_MARGIN, maxY: Math.max(zy0, zy1) + GRID_MARGIN, + }; + + // Step 2: per-family line ranges over the z region. + const ranges: [number, number][] = []; + for (let l = 0; l < 5; l++) ranges.push(lineRange(zRegion, l, gamma)); + + const keepMinX = view.minX - physicalMargin, keepMaxX = view.maxX + physicalMargin; + const keepMinY = view.minY - physicalMargin, keepMaxY = view.maxY + physicalMargin; + + for (let j = 0; j < 5; j++) { + for (let k = j + 1; k < 5; k++) { + const [mjLo, mjHi] = ranges[j]; + const [mkLo, mkHi] = ranges[k]; + for (let mj = mjLo; mj <= mjHi; mj++) { + for (let mk = mkLo; mk <= mkHi; mk++) { + const [x, y] = solveCrossing(j, k, mj - gamma[j], mk - gamma[k]); + if (x < zRegion.minX || x > zRegion.maxX || y < zRegion.minY || y > zRegion.maxY) continue; + + // Step 3: local address. Nudge +eps along both family normals so ceil resolves + // into the cell whose min-on-(j,k) corner is the (mj,mk) crossing corner. + const eps = 1e-7; + const nx = x + eps * PCOS[j] + eps * PCOS[k]; + const ny = y + eps * PSIN[j] + eps * PSIN[k]; + const K = new Array(5) as number[]; + for (let l = 0; l < 5; l++) K[l] = Math.ceil(fl(nx, ny, l) + gamma[l]); + K[j] = mj; K[k] = mk; + + // corners n, n+e_j, n+e_j+e_k, n+e_k (cyclic), positions via physical. + const c0 = K as Vec5; + const c1 = [...K] as number[]; c1[j]++; + const c2 = [...c1] as number[]; c2[k]++; + const c3 = [...K] as number[]; c3[k]++; + const p0 = physical(c0), p1 = physical(c1 as Vec5), p2 = physical(c2 as Vec5), p3 = physical(c3 as Vec5); + const centroid: Pt = [(p0[0] + p1[0] + p2[0] + p3[0]) / 4, (p0[1] + p1[1] + p2[1] + p3[1]) / 4]; + + // Step 4: filter by physical centroid. + if (centroid[0] < keepMinX || centroid[0] > keepMaxX || centroid[1] < keepMinY || centroid[1] > keepMaxY) continue; + + const key = `${K.join(",")}|${j}${k}`; + if (seen.has(key)) continue; + seen.add(key); + const d = k - j; + out.push({ + key, coord: K, j, k, + type: d === 1 || d === 4 ? "thick" : "thin", + corners: [p0, p1, p2, p3], centroid, + }); + } + } + } + } + return out; +} +``` + +- [ ] **Step 4: Run the tests, confirm they pass** + +Run: `bun test ./src/app/x/penrose/explore/lib/pentagrid.test.ts` +Expected: PASS (oracle key-for-key, far viewport, validity, genericity). Then run the full engine suite `bun test ./src/app/x/penrose/` and confirm it stays at 68 + the new tests, 0 fail. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/x/penrose/explore/lib/pentagrid.ts src/app/x/penrose/explore/lib/pentagrid.test.ts +git commit -m "feat(penrose): pentagrid viewport enumerator (generic edgeless tiling)" +``` + +--- + +## Task 2: Physical-space chunk cache (`chunks.ts`) + +**Files:** +- Create: `src/app/x/penrose/explore/lib/chunks.ts` +- Test: `src/app/x/penrose/explore/lib/chunks.test.ts` + +**Interfaces:** +- Consumes: `facesInViewport`, `GAMMA`, `Rect` from `./pentagrid`; `RenderFace` from `./patch`. +- Produces: + - `const CELL = 8` (cell side in physical unit edges) + - `class ChunkCache { facesInView(view: Rect): RenderFace[]; size: number }` — generates and memoizes the cells covering `view` (plus a one-cell margin ring), evicting cells far outside it, and returns the de-duped visible faces. Each cell owns tiles whose `physical(K)` centroid is in its half-open `[min,max)` bounds, so the union is seam-free. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/app/x/penrose/explore/lib/chunks.test.ts +import { describe, expect, test } from "bun:test"; + +import { facesInViewport, GAMMA, type Rect } from "./pentagrid"; +import { ChunkCache, CELL } from "./chunks"; + +const keys = (faces: { key: string }[]) => new Set(faces.map((f) => f.key)); + +describe("chunk cache reconstructs a region seam-free", () => { + // The cache, queried over a region, must return exactly the tiles whose centroid is + // in that region (matching a single facesInViewport call restricted by centroid). + for (const at of [{ x: 0, y: 0 }, { x: 40, y: 40 }, { x: -45, y: 12 }]) { + test(`region at (${at.x},${at.y}) near and far from origin`, () => { + const view: Rect = { minX: at.x - 12, minY: at.y - 12, maxX: at.x + 12, maxY: at.y + 12 }; + const cache = new ChunkCache(GAMMA); + const fromCache = cache.facesInView(view); + // ground truth: one enumeration, restricted to centroids strictly inside the view + const inView = (c: readonly [number, number]) => + c[0] >= view.minX && c[0] < view.maxX && c[1] >= view.minY && c[1] < view.maxY; + const truth = facesInViewport(view, GAMMA).filter((f) => inView(f.centroid)); + const cacheInView = fromCache.filter((f) => inView(f.centroid)); + // every tile whose centroid is in the view is present exactly once, no extras + expect(keys(cacheInView)).toEqual(keys(truth)); + expect(cacheInView.length).toBe(truth.length); // no duplicates + }); + } +}); + +describe("determinism and eviction", () => { + test("two caches over the same view return identical key sets", () => { + const view: Rect = { minX: 20, minY: -5, maxX: 32, maxY: 7 }; + const a = new ChunkCache(GAMMA).facesInView(view); + const b = new ChunkCache(GAMMA).facesInView(view); + expect(keys(a)).toEqual(keys(b)); + }); + test("panning away then back yields the same faces (eviction is lossless)", () => { + const cache = new ChunkCache(GAMMA); + const here: Rect = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + const far: Rect = { minX: 200, minY: 200, maxX: 210, maxY: 210 }; + const first = keys(cache.facesInView(here)); + cache.facesInView(far); // forces eviction of the first region + const again = keys(cache.facesInView(here)); + expect(again).toEqual(first); + }); +}); +``` + +- [ ] **Step 2: Run the tests, confirm they fail** + +Run: `bun test ./src/app/x/penrose/explore/lib/chunks.test.ts` +Expected: FAIL with `ChunkCache is not a constructor`. + +- [ ] **Step 3: Implement `chunks.ts`** + +```ts +// src/app/x/penrose/explore/lib/chunks.ts +// Physical-space chunk cache over the pentagrid enumerator. Cells are squares of side +// CELL in the physical (render) frame. A cell owns tiles whose physical(K) centroid is +// in its half-open [min,max) bounds, so the union over cells is seam-free (each tile in +// exactly one cell). Cells are generated on demand and LRU-evicted when far from the view. + +import { facesInViewport, type Rect } from "./pentagrid"; +import type { RenderFace } from "./patch"; + +export const CELL = 8; +const KEEP_RING = 1; // generate one ring of cells beyond the viewport +const MAX_CELLS = 4096; // evict beyond this many cached cells + +const cellKey = (cx: number, cy: number) => `${cx},${cy}`; + +export class ChunkCache { + private cells = new Map(); + private order: string[] = []; // simple LRU queue of cell keys + + constructor(private gamma: readonly number[]) {} + + get size(): number { + return this.cells.size; + } + + private cellFaces(cx: number, cy: number): RenderFace[] { + const key = cellKey(cx, cy); + const hit = this.cells.get(key); + if (hit) return hit; + // Generate the cell: enumerate over the cell's physical bounds, then keep tiles whose + // centroid is in this cell's half-open bounds. facesInViewport already grows the search + // region by its grid + physical margins, so every tile touching the cell is enumerated. + const minX = cx * CELL, minY = cy * CELL, maxX = minX + CELL, maxY = minY + CELL; + const faces = facesInViewport({ minX, minY, maxX, maxY }, this.gamma).filter( + (f) => f.centroid[0] >= minX && f.centroid[0] < maxX && f.centroid[1] >= minY && f.centroid[1] < maxY, + ); + this.cells.set(key, faces); + this.order.push(key); + if (this.cells.size > MAX_CELLS) { + const evict = this.order.shift(); + if (evict && evict !== key) this.cells.delete(evict); + } + return faces; + } + + facesInView(view: Rect): RenderFace[] { + const cx0 = Math.floor(view.minX / CELL) - KEEP_RING; + const cx1 = Math.floor(view.maxX / CELL) + KEEP_RING; + const cy0 = Math.floor(view.minY / CELL) - KEEP_RING; + const cy1 = Math.floor(view.maxY / CELL) + KEEP_RING; + const out: RenderFace[] = []; + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) out.push(...this.cellFaces(cx, cy)); + } + return out; + } +} +``` + +- [ ] **Step 4: Run the tests, confirm they pass** + +Run: `bun test ./src/app/x/penrose/explore/lib/chunks.test.ts` then `bun test ./src/app/x/penrose/` +Expected: PASS; full suite green. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/x/penrose/explore/lib/chunks.ts src/app/x/penrose/explore/lib/chunks.test.ts +git commit -m "feat(penrose): physical-space chunk cache over the pentagrid enumerator" +``` + +--- + +## Task 3: Wire the explorer to per-viewport generation + +**Files:** +- Modify: `src/app/x/penrose/explore/PenroseExplorer.tsx` +- Modify: `e2e/x/penrose/explore.spec.ts` (add pan-far) + +**Interfaces:** +- Consumes: `ChunkCache` from `./lib/chunks`; `GAMMA`, `type Rect`, `WINDOW_CENTER` from `./lib/pentagrid`; existing `buildHitIndex`/`hitFace` from `./lib/hitTest`; `findFaceByTile` from `./lib/patch`. + +The component keeps its camera, pointer/wheel/pinch, theme, HUD, pin, and share machinery. Three changes: drop the mount-time `buildPatch`; on each render compute the viewport rect and pull visible faces from the chunk cache; rebuild the hit index over the visible set when it changes. + +- [ ] **Step 1: Replace the patch model with the chunk cache** + +In `PenroseExplorer.tsx`: + +- Remove the imports of `buildPatch`/`Patch` and the `PATCH_LEVEL` constant and the `seedToCenter`-by-face helper. Add: + +```tsx +import { ChunkCache } from "./lib/chunks"; +import { GAMMA, WINDOW_CENTER } from "./lib/pentagrid"; +``` + +- Replace `patchRef`/the mount build effect. The cache lives in a ref; there is no whole-plane build, so `ready` can be set immediately: + +```tsx + const cacheRef = useRef(null); + // ... + useEffect(() => { + cacheRef.current = new ChunkCache(GAMMA); + // default camera center: a fixed generic point off the origin (no sun center exists). + const c = seedToCenter(seed); + offsetRef.current = [c[0], c[1]]; + // apply a shared-URL pin/zoom if present (unchanged decode logic), centering on the tile. + // ... existing decodeTile/parseZoom read-once, but resolve the pin against a freshly + // generated viewport around the decoded tile's physical position ... + setReady(true); + }, [seed]); +``` + +- `seedToCenter` becomes a pure hash to a world position (no patch needed): + +```tsx +function seedToCenter(seed: string): readonly [number, number] { + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); } + // spread seeds across a wide area; any point is a valid generic location. + const r = 30 + ((h >>> 0) % 400); + const a = ((h >>> 8) % 360) * (Math.PI / 180); + return [r * Math.cos(a), r * Math.sin(a)]; +} +``` + +- [ ] **Step 2: Generate per viewport in the render loop** + +In `render()`, after computing the world view rect `x0,x1,y0,y1` (already computed for culling), pull faces from the cache and rebuild the hit index when the visible set changes: + +```tsx + const view = { minX: x0, minY: y0, maxX: x1, maxY: y1 }; + const faces = cacheRef.current!.facesInView(view); + // rebuild the hit index for hover/pin against exactly the visible faces + hitRef.current = buildHitIndex(faces); + for (const f of faces) { + // draw f.corners (same fill-by-type + grout stroke as today) + } +``` + +`updateHover` and the click-to-pin handler keep using `hitRef.current` via `hitFace`; they now test against the per-frame visible set. The pin still stores `{coord, j, k}` and writes the URL via the unchanged codec. (Rebuilding the index each frame is cheap: a viewport holds a few hundred to low-thousands of faces. If profiling shows it matters, gate the rebuild on the view rect changing.) + +- [ ] **Step 3: Resolve a shared pin against a generated viewport** + +The read-once URL decode (unchanged `decodeTile`/`parseZoom`) yields a tile address `{coord, j, k}`. Center the camera on that tile's physical position, then on the first render `findFaceByTile` resolves it against the freshly generated visible faces: + +```tsx + const tile = decodeTile(params.get("t") ?? undefined); + if (tile) { + // center on the tile's physical position (physical(coord) shifted to its centroid) + const faces = new ChunkCache(GAMMA).facesInView({ + minX: -999, minY: -999, maxX: 999, maxY: 999, // replaced below by a small box around the tile + }); + // simpler: compute the tile centroid directly and center there + } +``` + +Concretely: import `physical` is not needed in the component; instead compute the centroid by generating a tiny viewport around `physical(coord)`. To keep the component free of engine math, add a small helper to `pentagrid.ts`, `tileCentroid(coord, j, k): Pt`, and use it to center. (Add that export in Task 1 if not present, or here as a one-line addition with its own test.) + +- [ ] **Step 4: Update the E2E to pan far** + +```ts +// add to e2e/x/penrose/explore.spec.ts +import { expect, test } from "@playwright/test"; + +test("the plane has no edge: panning far keeps showing tiles", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + // drag many times in one direction, then confirm a tile address still reads under the cursor + const box = await canvas.boundingBox(); + if (!box) throw new Error("no canvas box"); + const midX = box.x + box.width / 2, midY = box.y + box.height / 2; + for (let i = 0; i < 20; i++) { + await page.mouse.move(midX, midY); + await page.mouse.down(); + await page.mouse.move(midX - 300, midY, { steps: 5 }); + await page.mouse.up(); + } + await page.mouse.move(midX, midY); + await expect(page.getByText(/address/i)).toBeVisible(); +}); +``` + +- [ ] **Step 5: Verify and commit** + +Run: `bun test ./src/app/x/penrose/` (green), `bun run build` (green), `bunx playwright test e2e/x/penrose/explore.spec.ts` (canvas mounts, seed shows, pan-far still reads an address). +Manual: open the explorer, pan a long way in every direction, confirm tiles keep appearing with no blank grout edge and the address HUD keeps updating. + +```bash +git add -A +git commit -m "feat(penrose): edgeless explorer via per-viewport pentagrid generation" +``` + +--- + +## Task 4: Share round-trip in the new gauge, and copy + +**Files:** +- Modify: `e2e/x/penrose/explore.spec.ts` (share round-trip) +- Modify: `src/app/x/penrose/page.tsx`, `src/app/x/penrose/explore/page.tsx`, `src/app/x/page.tsx` (copy) + +- [ ] **Step 1: Share round-trip E2E (self-contained, new gauge)** + +The existing round-trip test (pin a tile, capture the URL, reload, assert the same pinned address) is gauge-agnostic, so it should still pass against the generic tiling. Confirm it does; if it hardcoded any sun-gauge address, replace with the self-contained pin-capture-reload form. + +Run: `bunx playwright test e2e/x/penrose/explore.spec.ts` +Expected: PASS (pin survives reload in the new gauge; pan-far reads an address). + +- [ ] **Step 2: Update the copy to the edgeless story** + +Rewrite the bounded-plane phrasing to the edgeless one, in all three surfaces: +- `src/app/x/page.tsx` `labs[]` Penrose blurb. +- `src/app/x/penrose/page.tsx` prose/metadata. +- `src/app/x/penrose/explore/page.tsx` metadata description. + +Keep it accurate: a generic Penrose tiling, generated per viewport, edgeless, every tile addressed by its de Bruijn coordinate. No emdashes. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat(penrose): edgeless-plane share round-trip and copy" +``` + +--- + +## Self-Review notes (for the executor) + +- **Frame:** everything renders in `cap.physical` (ζ^l). Do not bring in the substitution `pos` frame or its 18° rotation; this is a different tiling. +- **Filter by centroid, never by `z`.** If far tiles vanish on pan, you reintroduced crossing-point filtering. The `physical(K)` centroid is the only valid owner. +- **The oracle test is the gate.** `facesInViewport` must match `extractFaces(generate(R, vx, vy))` key-for-key on the inner disk; that is the proof the fast path equals the proven slow path. +- **Two γ projections.** `internal(γ)=(vx,vy)` (window center, what γ is built from) vs `physical(γ)` (the inverse-map shift). Mixing them mis-maps the search region. +- **Keys are byte-identical to the sun gauge's format but the addresses differ** (different tiling), so old preview share links will not resolve. Intended. +- **Deferred:** the `buildPatch` substitution path stays for teaching sketches; the bounded explorer's `PATCH_LEVEL` is gone. BigInt deep-zoom remains unbuilt and unneeded (float64 safe to ~1e14). diff --git a/docs/superpowers/plans/2026-06-24-penrose-v1-explorer.md b/docs/superpowers/plans/2026-06-24-penrose-v1-explorer.md new file mode 100644 index 0000000..21444f4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-penrose-v1-explorer.md @@ -0,0 +1,1076 @@ +# Penrose v1 Explorer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a working, shareable bounded Penrose explorer at `/x/penrose/explore`, built on the tested cut-and-project / substitution engine, where every tile under the cursor shows its exact ℤ⁵ address and any view is a shareable URL. + +**Architecture:** Port the tested engine (`research/penrose/cap/`) into the app. Add three thin pure modules on top: a patch builder (engine faces → render model with corner positions, one frame), hit-testing (point → tile), and an address codec (ℤ⁵ tile + camera ↔ URL). Rewrite the explorer component to build one large patch at mount and pan/zoom over it as camera math, salvaging the existing canvas/interaction layer. Client-side only, no server runtime. + +**Tech Stack:** Next.js App Router, React, TypeScript, native 2D canvas, Bun (`bun test`), Playwright (`bunx playwright test`). No new dependencies. + +**Scope:** This plan is the explorer (spec slices 1–4). The teaching spine (palette tokens, landing writeup, Sketch harness, sketches) is a separate follow-up plan. The B1 Mosaic palette is deferred; this plan renders with the existing `--color-moment-*` tokens as an interim, swapped out in the palette slice. + +**Spec:** `docs/superpowers/specs/2026-06-24-penrose-v1-design.md`. + +## Global Constraints + +- **Tooling:** `bun` and `bunx` only. Never `npm`/`npx`/`yarn`/`pnpm`. +- **Client-only:** No server data flow, no `useSearchParams`, no Suspense for the explorer, no `export const runtime/revalidate/dynamic`. URL state is read once from `window.location.search` and written with debounced `history.replaceState`. +- **No architectural drift:** No caching primitives, no module-level singletons, no `'use cache'`. The patch is a per-mount client object. +- **Frame discipline:** Render and hit-test exclusively in the `LiftedVertex.pos` frame. Never reconstruct corner positions with `physical(coord)`; look them up by coord key in the lifted vertex set. `pos` and `physical(coord)` differ by a fixed rotation. +- **Tests:** Colocated `*.test.ts` with `bun:test`, table-driven where the shape fits. Engine tests must stay green after the port (`bun test ./src/app/x/penrose/` → 34 pass). E2E lives in `e2e/x/penrose/`. +- **Prose:** No emdashes in any user-facing copy. Use periods and commas. +- **Routing:** `/x/penrose/explore` is confirmed safe against `src/lib/tripwire/patterns.ts` (the `/x/` prefix short-circuits the matcher). Do not add a route matching a tripwire pattern. +- **Engine is classical and tested:** Do not modify the ported engine's math. It reproduces de Bruijn / D'Andrea results; see `research/penrose/STATUS.md`. + +--- + +## File Structure + +- **Move (git mv), no edits needed (relative imports preserved):** + - `research/penrose/cap/cap.ts` → `src/app/x/penrose/explore/lib/cap.ts` + - `research/penrose/cap/deflate.ts` → `src/app/x/penrose/explore/lib/deflate.ts` + - `research/penrose/cap/bridge.ts` → `src/app/x/penrose/explore/lib/bridge.ts` + - `research/penrose/cap/fold.ts` → `src/app/x/penrose/explore/lib/fold.ts` + - `research/penrose/cap/faces.ts` → `src/app/x/penrose/explore/lib/faces.ts` + - the matching `*.test.ts` for each +- **Create:** + - `src/app/x/penrose/explore/lib/patch.ts` — engine faces → render model (corners, centroid, bounds), one frame. + - `src/app/x/penrose/explore/lib/patch.test.ts` + - `src/app/x/penrose/explore/lib/hitTest.ts` — spatial grid + point-in-rhombus. + - `src/app/x/penrose/explore/lib/hitTest.test.ts` + - `src/app/x/penrose/explore/lib/codec.ts` — address/seed/zoom URL codec. + - `src/app/x/penrose/explore/lib/codec.test.ts` + - `e2e/x/penrose/explore.spec.ts` — canvas-mount smoke + share round-trip. +- **Modify:** + - `src/app/x/penrose/explore/PenroseExplorer.tsx` — rewrite onto the new modules. + - `src/app/x/penrose/explore/page.tsx` — `h-screen` → `h-dvh`. +- **Delete:** + - `src/app/x/penrose/explore/lib/pentagrid.ts` — the old buggy engine (after the explorer stops importing it, in Task 4). + +Each file has one responsibility: `patch` builds geometry, `hitTest` answers point queries, `codec` is pure URL serialization, the component is camera + interaction + paint. + +--- + +## Task 1: Port the tested engine into the app + +**Files:** +- Move: `research/penrose/cap/{cap,deflate,bridge,fold,faces}.ts` and their `.test.ts` → `src/app/x/penrose/explore/lib/` + +**Interfaces:** +- Produces: the engine API in its new home. `substitutionFaces(level): { faces: Face[]; verts: LiftedVertex[] }`, `Face = { key: string; type: "thick" | "thin" }`, `LiftedVertex = { pos: readonly [number, number]; coord: readonly number[] }`, plus `lift`, `deflate`, `extractFaces`, `thickThinRatio`, `physical`, `internal`, `index`, `A`, `nextCoordCanonical`, `PHI`, `TAU`. All unchanged. + +- [ ] **Step 1: Move the engine files with git, preserving the tests** + +```bash +cd /Users/n2p5/src/github.com/funcimp/func.lol +git mv research/penrose/cap/cap.ts src/app/x/penrose/explore/lib/cap.ts +git mv research/penrose/cap/cap.test.ts src/app/x/penrose/explore/lib/cap.test.ts +git mv research/penrose/cap/deflate.ts src/app/x/penrose/explore/lib/deflate.ts +git mv research/penrose/cap/deflate.test.ts src/app/x/penrose/explore/lib/deflate.test.ts +git mv research/penrose/cap/bridge.ts src/app/x/penrose/explore/lib/bridge.ts +git mv research/penrose/cap/bridge.test.ts src/app/x/penrose/explore/lib/bridge.test.ts +git mv research/penrose/cap/fold.ts src/app/x/penrose/explore/lib/fold.ts +git mv research/penrose/cap/fold.test.ts src/app/x/penrose/explore/lib/fold.test.ts +git mv research/penrose/cap/faces.ts src/app/x/penrose/explore/lib/faces.ts +git mv research/penrose/cap/faces.test.ts src/app/x/penrose/explore/lib/faces.test.ts +``` + +The five engine modules import each other only by relative path (`./deflate`, `./bridge`, `./cap`), so moving them together needs no edits. + +- [ ] **Step 2: Run the ported engine tests in their new location** + +Run: `bun test ./src/app/x/penrose/explore/lib/` +Expected: `34 pass, 0 fail` (24 `test()` blocks, several table-driven). + +- [ ] **Step 3: Confirm nothing else imported from the old research path** + +Run: `grep -rn "research/penrose/cap" src/ ; grep -rn "research/penrose/cap" docs/ || true` +Expected: no `src/` import references the old path. (`research/penrose/STATUS.md` may reference `cap/` as the research home; that is documentation, leave it. Note in the commit that the engine now lives in the app.) + +- [ ] **Step 4: Verify the old engine is still present and untouched (deleted later)** + +The old `src/app/x/penrose/explore/lib/pentagrid.ts` is still imported by `PenroseExplorer.tsx`. Do NOT delete it yet; the explorer is rewritten in Task 4. The build stays green. +Run: `bunx tsc --noEmit` (or `bun run build` if that is the project's typecheck path) +Expected: no new type errors. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(penrose): port tested cut-and-project engine into the explorer app" +``` + +--- + +## Task 2: Patch builder (engine faces → render model) + +**Files:** +- Create: `src/app/x/penrose/explore/lib/patch.ts` +- Test: `src/app/x/penrose/explore/lib/patch.test.ts` + +**Interfaces:** +- Consumes: `substitutionFaces(level)`, `LiftedVertex`, `Face` from `./faces` and `./bridge`. +- Produces: + - `type Pt = readonly [number, number]` + - `type RenderFace = { key: string; coord: readonly number[]; type: "thick" | "thin"; corners: readonly [Pt, Pt, Pt, Pt]; centroid: Pt }` + - `type Patch = { level: number; faces: RenderFace[]; bounds: { minX: number; minY: number; maxX: number; maxY: number } }` + - `function buildPatch(level: number): Patch` + +The `corners` are in **cyclic polygon order** `n, n+eⱼ, n+eⱼ+eₖ, n+eₖ`, all looked up by coord key in the lifted vertex `pos` frame. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/app/x/penrose/explore/lib/patch.test.ts +import { describe, expect, test } from "bun:test"; + +import { buildPatch } from "./patch"; + +describe("buildPatch produces a render-ready patch in the pos frame", () => { + const patch = buildPatch(6); + + test("returns faces with the level recorded", () => { + expect(patch.level).toBe(6); + expect(patch.faces.length).toBeGreaterThan(100); + }); + + test("every face has a 5-component address, a type, and four finite corners", () => { + for (const f of patch.faces) { + expect(f.coord.length).toBe(5); + expect(f.type === "thick" || f.type === "thin").toBe(true); + expect(f.corners.length).toBe(4); + for (const [x, y] of f.corners) { + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + } + } + }); + + test("corners form a rhombus: all four edges are ~unit length", () => { + for (const f of patch.faces) { + const c = f.corners; + for (let i = 0; i < 4; i++) { + const a = c[i], b = c[(i + 1) % 4]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + expect(Math.abs(len - 1)).toBeLessThan(0.02); + } + } + }); + + test("centroid is the corner average and lies inside the bounds", () => { + for (const f of patch.faces) { + const mx = (f.corners[0][0] + f.corners[1][0] + f.corners[2][0] + f.corners[3][0]) / 4; + const my = (f.corners[0][1] + f.corners[1][1] + f.corners[2][1] + f.corners[3][1]) / 4; + expect(Math.abs(f.centroid[0] - mx)).toBeLessThan(1e-9); + expect(Math.abs(f.centroid[1] - my)).toBeLessThan(1e-9); + expect(f.centroid[0]).toBeGreaterThanOrEqual(patch.bounds.minX); + expect(f.centroid[0]).toBeLessThanOrEqual(patch.bounds.maxX); + } + }); + + test("thick:thin ratio approaches phi on a real patch", () => { + const thick = patch.faces.filter((f) => f.type === "thick").length; + const thin = patch.faces.filter((f) => f.type === "thin").length; + expect(thick / thin).toBeGreaterThan(1.5); + expect(thick / thin).toBeLessThan(1.75); + }); + + test("face keys are unique", () => { + const keys = new Set(patch.faces.map((f) => f.key)); + expect(keys.size).toBe(patch.faces.length); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `bun test ./src/app/x/penrose/explore/lib/patch.test.ts` +Expected: FAIL with module-not-found / `buildPatch is not a function`. + +- [ ] **Step 3: Implement `patch.ts`** + +```ts +// src/app/x/penrose/explore/lib/patch.ts +// Turn the tested engine's faces into a render model: each rhombus carries its +// four corner positions (cyclic order) and centroid, all in the LiftedVertex.pos +// frame. Corner positions are looked up by coord key, never recomputed via +// physical() — pos and physical(coord) differ by a fixed rotation. + +import { substitutionFaces } from "./faces"; + +export type Pt = readonly [number, number]; + +export type RenderFace = { + key: string; // the engine Face.key, "n0,n1,n2,n3,n4|jk" — the ℤ⁵ address + coord: readonly number[]; // base corner n (length 5), the address anchor + type: "thick" | "thin"; + corners: readonly [Pt, Pt, Pt, Pt]; // cyclic: n, n+e_j, n+e_j+e_k, n+e_k + centroid: Pt; +}; + +export type Patch = { + level: number; + faces: RenderFace[]; + bounds: { minX: number; minY: number; maxX: number; maxY: number }; +}; + +const bump = (n: readonly number[], l: number): number[] => { + const c = [...n]; + c[l]++; + return c; +}; + +export function buildPatch(level: number): Patch { + const { faces, verts } = substitutionFaces(level); + + const posByCoord = new Map(); + for (const v of verts) posByCoord.set(v.coord.join(","), v.pos); + + const out: RenderFace[] = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + for (const f of faces) { + const [coordStr, jk] = f.key.split("|"); + const n = coordStr.split(",").map(Number); + const j = Number(jk[0]); + const k = Number(jk[1]); + + // Cyclic corner coords around the rhombus. + const cn = n; + const cj = bump(n, j); + const cjk = bump(cj, k); + const ck = bump(n, k); + + const p0 = posByCoord.get(cn.join(",")); + const p1 = posByCoord.get(cj.join(",")); + const p2 = posByCoord.get(cjk.join(",")); + const p3 = posByCoord.get(ck.join(",")); + if (!p0 || !p1 || !p2 || !p3) continue; // corner-acceptance guarantees presence + + const corners: readonly [Pt, Pt, Pt, Pt] = [p0, p1, p2, p3]; + const centroid: Pt = [ + (p0[0] + p1[0] + p2[0] + p3[0]) / 4, + (p0[1] + p1[1] + p2[1] + p3[1]) / 4, + ]; + for (const [x, y] of corners) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + out.push({ key: f.key, coord: n, type: f.type, corners, centroid }); + } + + return { level, faces: out, bounds: { minX, minY, maxX, maxY } }; +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `bun test ./src/app/x/penrose/explore/lib/patch.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/x/penrose/explore/lib/patch.ts src/app/x/penrose/explore/lib/patch.test.ts +git commit -m "feat(penrose): patch builder turns engine faces into a render model" +``` + +--- + +## Task 3: Hit-testing (point → tile) + +**Files:** +- Create: `src/app/x/penrose/explore/lib/hitTest.ts` +- Test: `src/app/x/penrose/explore/lib/hitTest.test.ts` + +**Interfaces:** +- Consumes: `RenderFace`, `Pt` from `./patch`. +- Produces: + - `type HitIndex = { cell: number; grid: Map }` + - `function buildHitIndex(faces: readonly RenderFace[], cell?: number): HitIndex` + - `function hitFace(index: HitIndex, x: number, y: number): RenderFace | null` + +A uniform grid buckets each face into every cell its corner bounding box overlaps. `hitFace` tests only the faces in the query point's cell, with a convex point-in-quad test. Faces do not overlap, so the first hit wins. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/app/x/penrose/explore/lib/hitTest.test.ts +import { describe, expect, test } from "bun:test"; + +import { buildPatch } from "./patch"; +import { buildHitIndex, hitFace } from "./hitTest"; + +describe("hit-testing returns the tile under a point", () => { + const patch = buildPatch(6); + const index = buildHitIndex(patch.faces); + + test("a face centroid hits its own face", () => { + // Sample across the patch to keep the test fast but representative. + const step = Math.max(1, Math.floor(patch.faces.length / 200)); + for (let i = 0; i < patch.faces.length; i += step) { + const f = patch.faces[i]; + const hit = hitFace(index, f.centroid[0], f.centroid[1]); + expect(hit?.key).toBe(f.key); + } + }); + + test("a point well outside the patch hits nothing", () => { + const far = patch.bounds.maxX + 1000; + expect(hitFace(index, far, far)).toBeNull(); + }); + + test("a hit's corners actually contain the query point", () => { + const f = patch.faces[Math.floor(patch.faces.length / 2)]; + const hit = hitFace(index, f.centroid[0], f.centroid[1]); + expect(hit).not.toBeNull(); + // centroid is strictly interior to a convex rhombus + expect(hit!.key).toBe(f.key); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `bun test ./src/app/x/penrose/explore/lib/hitTest.test.ts` +Expected: FAIL with `buildHitIndex is not a function`. + +- [ ] **Step 3: Implement `hitTest.ts`** + +```ts +// src/app/x/penrose/explore/lib/hitTest.ts +// Point → tile, accelerated by a uniform spatial grid. Tiles are ~unit sized in +// the pos frame, so a cell near 1.5 units keeps buckets small. Each face is +// bucketed into every cell its bounding box overlaps; a query tests only its +// own cell. Faces are non-overlapping, so the first containing face wins. + +import type { Pt, RenderFace } from "./patch"; + +export type HitIndex = { cell: number; grid: Map }; + +const cellKey = (cx: number, cy: number) => `${cx},${cy}`; + +export function buildHitIndex(faces: readonly RenderFace[], cell = 1.5): HitIndex { + const grid = new Map(); + for (const f of faces) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const [x, y] of f.corners) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + const cx0 = Math.floor(minX / cell), cx1 = Math.floor(maxX / cell); + const cy0 = Math.floor(minY / cell), cy1 = Math.floor(maxY / cell); + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const key = cellKey(cx, cy); + const bucket = grid.get(key); + if (bucket) bucket.push(f); + else grid.set(key, [f]); + } + } + } + return { cell, grid }; +} + +function pointInQuad(px: number, py: number, q: readonly [Pt, Pt, Pt, Pt]): boolean { + let sign = 0; + for (let i = 0; i < 4; i++) { + const a = q[i], b = q[(i + 1) % 4]; + const cross = (b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0]); + if (cross !== 0) { + const s = cross > 0 ? 1 : -1; + if (sign === 0) sign = s; + else if (s !== sign) return false; + } + } + return true; +} + +export function hitFace(index: HitIndex, x: number, y: number): RenderFace | null { + const cx = Math.floor(x / index.cell), cy = Math.floor(y / index.cell); + const bucket = index.grid.get(cellKey(cx, cy)); + if (!bucket) return null; + for (const f of bucket) { + if (pointInQuad(x, y, f.corners)) return f; + } + return null; +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `bun test ./src/app/x/penrose/explore/lib/hitTest.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/x/penrose/explore/lib/hitTest.ts src/app/x/penrose/explore/lib/hitTest.test.ts +git commit -m "feat(penrose): spatial-grid hit-testing maps a point to its tile" +``` + +--- + +## Task 4: Rewrite the explorer onto the new engine + +**Files:** +- Modify: `src/app/x/penrose/explore/PenroseExplorer.tsx` (full rewrite, salvaging the canvas/interaction layer) +- Modify: `src/app/x/penrose/explore/page.tsx` (`h-screen` → `h-dvh`) +- Delete: `src/app/x/penrose/explore/lib/pentagrid.ts` +- Test: `e2e/x/penrose/explore.spec.ts` (canvas-mount smoke) + +**Interfaces:** +- Consumes: `buildPatch`, `Patch`, `RenderFace` from `./lib/patch`; `buildHitIndex`, `hitFace`, `HitIndex` from `./lib/hitTest`. +- Produces: a working `` that builds one patch at mount, pans/zooms over it, and shows the address under the cursor. + +Colors are interim (`--color-moment-1` thick, `--color-moment-4` thin, `--color-paper` grout). The B1 palette is a later slice. + +- [ ] **Step 1: Replace `PenroseExplorer.tsx` in full** + +```tsx +// src/app/x/penrose/explore/PenroseExplorer.tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { buildPatch, type Patch } from "./lib/patch"; +import { buildHitIndex, hitFace, type HitIndex } from "./lib/hitTest"; + +const PATCH_LEVEL = 10; // ~55k faces, world radius ~123 unit edges, ~350ms one-time build +const DEFAULT_ZOOM = 40; // px per unit edge + +function readCssVar(name: string): string { + if (typeof document === "undefined") return "#000"; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +// Deterministic seed → camera center: hash the seed, pick a face centroid in a +// mid-radius band so the default view is generic — off the singular sun center +// at the origin, in from the patch boundary. +function seedToCenter(seed: string, patch: Patch): readonly [number, number] { + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + const { minX, minY, maxX, maxY } = patch.bounds; + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + const rMax = Math.min(maxX - cx, maxY - cy); + const band = patch.faces.filter((f) => { + const r = Math.hypot(f.centroid[0] - cx, f.centroid[1] - cy); + return r > rMax * 0.25 && r < rMax * 0.6; + }); + const pool = band.length > 0 ? band : patch.faces; + return pool[(h >>> 0) % pool.length].centroid; +} + +export default function PenroseExplorer({ seed = "funclol" }: { seed?: string }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const patchRef = useRef(null); + const hitRef = useRef(null); + const offsetRef = useRef<[number, number]>([0, 0]); + const zoomRef = useRef(DEFAULT_ZOOM); + const dprRef = useRef(1); + const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + const dirtyRef = useRef(true); + const rafRef = useRef(null); + + const [hoverAddress, setHoverAddress] = useState(null); + const [ready, setReady] = useState(false); + + // Build the patch once for this seed (synchronous; the overlay paints first). + useEffect(() => { + const patch = buildPatch(PATCH_LEVEL); + patchRef.current = patch; + hitRef.current = buildHitIndex(patch.faces); + const c = seedToCenter(seed, patch); + offsetRef.current = [c[0], c[1]]; + setReady(true); + }, [seed]); + + useEffect(() => { + if (!ready) return; + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const requestRender = () => { + dirtyRef.current = true; + if (rafRef.current === null) rafRef.current = requestAnimationFrame(render); + }; + + const resize = () => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + dprRef.current = dpr; + sizeRef.current = { w: rect.width, h: rect.height }; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + requestRender(); + }; + + const ro = new ResizeObserver(resize); + ro.observe(container); + resize(); + + const themeObserver = new MutationObserver(requestRender); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + const pointers = new Map(); + let gesture: { midX: number; midY: number; dist: number } | null = null; + + const updateHover = (clientX: number, clientY: number) => { + const rect = canvas.getBoundingClientRect(); + const cx = clientX - rect.left - sizeRef.current.w / 2; + const cy = clientY - rect.top - sizeRef.current.h / 2; + const wx = cx / zoomRef.current + offsetRef.current[0]; + const wy = cy / zoomRef.current + offsetRef.current[1]; + const f = hitRef.current ? hitFace(hitRef.current, wx, wy) : null; + setHoverAddress(f ? f.coord : null); + }; + + const refreshGesture = () => { + if (pointers.size < 2) { gesture = null; return; } + const pts = [...pointers.values()]; + const midX = (pts[0][0] + pts[1][0]) / 2, midY = (pts[0][1] + pts[1][1]) / 2; + const dist = Math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); + gesture = { midX, midY, dist }; + }; + + const onPointerDown = (e: PointerEvent) => { + canvas.setPointerCapture(e.pointerId); + pointers.set(e.pointerId, [e.clientX, e.clientY]); + refreshGesture(); + }; + + const onPointerMove = (e: PointerEvent) => { + const prev = pointers.get(e.pointerId); + if (prev) { + const dx = e.clientX - prev[0], dy = e.clientY - prev[1]; + pointers.set(e.pointerId, [e.clientX, e.clientY]); + if (pointers.size === 1) { + offsetRef.current[0] -= dx / zoomRef.current; + offsetRef.current[1] -= dy / zoomRef.current; + requestRender(); + } else if (pointers.size >= 2 && gesture !== null) { + const pts = [...pointers.values()]; + const midX = (pts[0][0] + pts[1][0]) / 2, midY = (pts[0][1] + pts[1][1]) / 2; + const dist = Math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); + if (dist > 0 && gesture.dist > 0) { + const rect = canvas.getBoundingClientRect(); + const px = midX - rect.left - sizeRef.current.w / 2; + const py = midY - rect.top - sizeRef.current.h / 2; + const worldX = px / zoomRef.current + offsetRef.current[0]; + const worldY = py / zoomRef.current + offsetRef.current[1]; + const newZoom = clamp(zoomRef.current * (dist / gesture.dist), 4, 800); + zoomRef.current = newZoom; + offsetRef.current[0] = worldX - px / newZoom; + offsetRef.current[1] = worldY - py / newZoom; + offsetRef.current[0] -= (midX - gesture.midX) / newZoom; + offsetRef.current[1] -= (midY - gesture.midY) / newZoom; + requestRender(); + } + gesture = { midX, midY, dist }; + } + } + updateHover(e.clientX, e.clientY); + }; + + const onPointerUp = (e: PointerEvent) => { + pointers.delete(e.pointerId); + try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ } + refreshGesture(); + }; + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left - sizeRef.current.w / 2; + const cy = e.clientY - rect.top - sizeRef.current.h / 2; + const worldX = cx / zoomRef.current + offsetRef.current[0]; + const worldY = cy / zoomRef.current + offsetRef.current[1]; + const newZoom = clamp(zoomRef.current * Math.exp(-e.deltaY * 0.001), 4, 800); + zoomRef.current = newZoom; + offsetRef.current[0] = worldX - cx / newZoom; + offsetRef.current[1] = worldY - cy / newZoom; + requestRender(); + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); + + function render() { + rafRef.current = null; + if (!dirtyRef.current) return; + dirtyRef.current = false; + const patch = patchRef.current; + if (!patch) return; + const { w, h } = sizeRef.current; + const dpr = dprRef.current; + const thick = readCssVar("--color-moment-1") || "#C89B3C"; + const thin = readCssVar("--color-moment-4") || "#3E6B7C"; + const grout = readCssVar("--color-paper") || "#0f0e0c"; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx!.fillStyle = grout; + ctx!.fillRect(0, 0, w, h); + + const zoom = zoomRef.current; + const [ox, oy] = offsetRef.current; + const cx = w / 2, cy = h / 2; + const halfW = w / 2 / zoom + 2, halfH = h / 2 / zoom + 2; + const x0 = ox - halfW, x1 = ox + halfW, y0 = oy - halfH, y1 = oy + halfH; + + ctx!.lineJoin = "round"; + ctx!.lineWidth = 1; + ctx!.strokeStyle = grout; + for (const f of patch.faces) { + if (f.centroid[0] < x0 || f.centroid[0] > x1 || f.centroid[1] < y0 || f.centroid[1] > y1) continue; + const [a, b, c, d] = f.corners; + ctx!.beginPath(); + ctx!.moveTo((a[0] - ox) * zoom + cx, (a[1] - oy) * zoom + cy); + ctx!.lineTo((b[0] - ox) * zoom + cx, (b[1] - oy) * zoom + cy); + ctx!.lineTo((c[0] - ox) * zoom + cx, (c[1] - oy) * zoom + cy); + ctx!.lineTo((d[0] - ox) * zoom + cx, (d[1] - oy) * zoom + cy); + ctx!.closePath(); + ctx!.fillStyle = f.type === "thick" ? thick : thin; + ctx!.fill(); + ctx!.stroke(); + } + } + + requestRender(); + + return () => { + ro.disconnect(); + themeObserver.disconnect(); + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerUp); + canvas.removeEventListener("wheel", onWheel); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [ready]); + + return ( +
+ +
+
seed  {seed}
+ {hoverAddress &&
address [{hoverAddress.join(",")}]
} +
+ {!ready && ( +
+ building tiling +
+ )} +
+ ); +} + +function clamp(x: number, lo: number, hi: number): number { + return Math.min(Math.max(x, lo), hi); +} +``` + +- [ ] **Step 2: Delete the old engine and fix the shell** + +```bash +git rm src/app/x/penrose/explore/lib/pentagrid.ts +``` + +In `src/app/x/penrose/explore/page.tsx`, change the explorer shell from `h-screen` to `h-dvh`: + +```tsx +// before:
+// after: +
+``` + +- [ ] **Step 3: Typecheck and confirm no stale imports** + +Run: `grep -rn "pentagrid" src/` then `bunx tsc --noEmit` +Expected: no `pentagrid` references remain; no type errors. + +- [ ] **Step 4: Write the canvas-mount E2E smoke** + +```ts +// e2e/x/penrose/explore.spec.ts +import { expect, test } from "@playwright/test"; + +test("explorer mounts a canvas", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); +}); + +test("explorer shows the seed in the HUD", async ({ page }) => { + await page.goto("/x/penrose/explore"); + await expect(page.getByText(/seed/i)).toBeVisible(); +}); +``` + +- [ ] **Step 5: Run the E2E smoke** + +Run: `bunx playwright test e2e/x/penrose/explore.spec.ts` +Expected: PASS (canvas visible, seed shown). If the dev server is not auto-started by the Playwright config, start it per the repo's E2E setup first. + +- [ ] **Step 6: Manual check (one line in the PR notes)** + +Run the dev server, open `/x/penrose/explore`, confirm: tiling paints after a brief "building tiling" flash, pan/zoom/pinch work, and hovering a tile shows `address [..]` that stays stable as you pan back and forth over the same tile. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(penrose): rewrite explorer onto the tested engine, bounded patch with exact addresses" +``` + +--- + +## Task 5: Address codec (tile + camera ↔ URL) + +**Files:** +- Create: `src/app/x/penrose/explore/lib/codec.ts` +- Test: `src/app/x/penrose/explore/lib/codec.test.ts` + +**Interfaces:** +- Produces: + - `function encodeAddress(coord: readonly number[]): string` — five integers joined by `.`, e.g. `"3.-1.0.2.-4"`. + - `function decodeAddress(raw: string | string[] | undefined): number[] | null` — exactly five integers, else null. + - `function parseSeed(raw: string | string[] | undefined): string | null` — `^[A-Za-z0-9_-]{1,32}$`, else null. + - `function parseZoom(raw: string | string[] | undefined): number | null` — finite number clamped to `[4, 800]`, else null. + +Decimal (not base62): the components are tiny signed integers, so decimal is clearer and round-trips signs without extra encoding. This is the v2 seam; when addresses widen to BigInt, widen the codec here. The strict "return null on bad input" contract mirrors `prime-moments/lib/share.ts`. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/app/x/penrose/explore/lib/codec.test.ts +import { describe, expect, test } from "bun:test"; + +import { encodeAddress, decodeAddress, parseSeed, parseZoom } from "./codec"; + +describe("address codec round-trips ℤ⁵ coordinates", () => { + const cases: number[][] = [ + [0, 0, 0, 0, 0], + [3, -1, 0, 2, -4], + [10, 11, -12, 13, -14], + [-1, -1, -1, -1, -1], + ]; + for (const coord of cases) { + test(`round-trips [${coord}]`, () => { + expect(decodeAddress(encodeAddress(coord))).toEqual(coord); + }); + } +}); + +describe("decodeAddress rejects bad input", () => { + const bad: (string | string[] | undefined)[] = [ + undefined, + ["3.0.0.0.0"], + "", + "1.2.3", // too few + "1.2.3.4.5.6", // too many + "1.2.x.4.5", // non-integer + "1.2.3.4.5.5", // too many + "1.2.3.4.999999", // component out of range (|n| > 100000) + ]; + for (const raw of bad) { + test(`rejects ${JSON.stringify(raw)}`, () => { + expect(decodeAddress(raw)).toBeNull(); + }); + } +}); + +describe("parseSeed", () => { + test("accepts a short alnum seed", () => { + expect(parseSeed("funclol")).toBe("funclol"); + expect(parseSeed("a_b-9")).toBe("a_b-9"); + }); + test("rejects empty, array, too long, or illegal chars", () => { + expect(parseSeed("")).toBeNull(); + expect(parseSeed(["x"])).toBeNull(); + expect(parseSeed("a".repeat(33))).toBeNull(); + expect(parseSeed("has space")).toBeNull(); + }); +}); + +describe("parseZoom", () => { + test("accepts and clamps", () => { + expect(parseZoom("40")).toBe(40); + expect(parseZoom("1")).toBe(4); // clamped up + expect(parseZoom("9999")).toBe(800); // clamped down + }); + test("rejects non-numbers", () => { + expect(parseZoom("abc")).toBeNull(); + expect(parseZoom(undefined)).toBeNull(); + expect(parseZoom(["40"])).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `bun test ./src/app/x/penrose/explore/lib/codec.test.ts` +Expected: FAIL with `encodeAddress is not a function`. + +- [ ] **Step 3: Implement `codec.ts`** + +```ts +// src/app/x/penrose/explore/lib/codec.ts +// URL serialization for the explorer's share link. The tile address is the ℤ⁵ +// coordinate (five small signed integers); the camera adds seed and zoom. Every +// parser returns null on bad input — the caller treats null as "ignore this +// param, use the default," never an error. Decimal encoding keeps signs trivial +// and is the v2 seam (widen here when addresses become BigInt). + +export function encodeAddress(coord: readonly number[]): string { + return coord.join("."); +} + +export function decodeAddress(raw: string | string[] | undefined): number[] | null { + if (typeof raw !== "string") return null; + if (raw.trim() === "") return null; + const parts = raw.split("."); + if (parts.length !== 5) return null; + const coord = parts.map((s) => Number(s)); + if (coord.some((n) => !Number.isInteger(n) || Math.abs(n) > 100000)) return null; + return coord; +} + +export function parseSeed(raw: string | string[] | undefined): string | null { + if (typeof raw !== "string") return null; + return /^[A-Za-z0-9_-]{1,32}$/.test(raw) ? raw : null; +} + +export function parseZoom(raw: string | string[] | undefined): number | null { + if (typeof raw !== "string") return null; + const z = Number(raw); + if (!Number.isFinite(z)) return null; + return Math.min(Math.max(z, 4), 800); +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `bun test ./src/app/x/penrose/explore/lib/codec.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/x/penrose/explore/lib/codec.ts src/app/x/penrose/explore/lib/codec.test.ts +git commit -m "feat(penrose): URL codec for tile address, seed, and zoom" +``` + +--- + +## Task 6: Pin, origin, share (the one operation) + share E2E + +**Files:** +- Modify: `src/app/x/penrose/explore/PenroseExplorer.tsx` (add pin, URL read-once, debounced write) +- Modify: `e2e/x/penrose/explore.spec.ts` (share round-trip) + +**Interfaces:** +- Consumes: `encodeAddress`, `decodeAddress`, `parseSeed`, `parseZoom` from `./lib/codec`; `hitFace` from `./lib/hitTest`. +- Produces: click-to-pin that recenters the camera on the tile and writes `?s=&t=&z=`; a load that reads those back and centers on the pinned tile. + +A pinned tile is found in the patch by matching `RenderFace.coord` to the decoded address. Pin = camera origin = share are the same action. + +- [ ] **Step 1: Write the failing share round-trip E2E** + +```ts +// add to e2e/x/penrose/explore.spec.ts +import { expect, test } from "@playwright/test"; + +test("a shared address URL loads and reports that tile", async ({ page }) => { + // A concrete tile address present in the default patch. If this address is not + // found (engine change), the test fails loudly — update it from a hover readout. + await page.goto("/x/penrose/explore?s=funclol&t=0.0.0.0.0&z=40"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + // The pinned address is echoed in the HUD. + await expect(page.getByText(/pinned/i)).toBeVisible(); +}); + +test("the URL gains s, t, z after a click", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + await canvas.click({ position: { x: 200, y: 200 } }); + await expect(page).toHaveURL(/[?&]t=/); + await expect(page).toHaveURL(/[?&]s=/); + await expect(page).toHaveURL(/[?&]z=/); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `bunx playwright test e2e/x/penrose/explore.spec.ts` +Expected: FAIL (no "pinned" HUD line, URL unchanged after click). + +- [ ] **Step 3: Add pin state and the click handler to the component** + +In `PenroseExplorer.tsx`, add a pinned ref and a pinned-address state next to the existing refs: + +```tsx + const pinnedRef = useRef(null); + const [pinnedAddress, setPinnedAddress] = useState(null); +``` + +Add a helper that centers the camera on a face and pins it (place beside `seedToCenter`, or inline in the effect): + +```tsx + const findFaceByCoord = (patch: Patch, coord: readonly number[]) => + patch.faces.find((f) => f.coord.length === coord.length && f.coord.every((v, i) => v === coord[i])) ?? null; +``` + +In the canvas effect, add a click handler that pins the tile under the pointer (distinguish click from drag by a small movement threshold). Register it with the other listeners and clean it up in the return: + +```tsx + let downAt: { x: number; y: number } | null = null; + const onClickDown = (e: PointerEvent) => { downAt = { x: e.clientX, y: e.clientY }; }; + const onClickUp = (e: PointerEvent) => { + if (!downAt) return; + const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y); + downAt = null; + if (moved > 6) return; // a drag, not a click + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left - sizeRef.current.w / 2; + const cy = e.clientY - rect.top - sizeRef.current.h / 2; + const wx = cx / zoomRef.current + offsetRef.current[0]; + const wy = cy / zoomRef.current + offsetRef.current[1]; + const f = hitRef.current ? hitFace(hitRef.current, wx, wy) : null; + if (!f) return; + pinnedRef.current = f.coord; + setPinnedAddress(f.coord); + offsetRef.current = [f.centroid[0], f.centroid[1]]; + requestRender(); + writeUrl(); + }; + canvas.addEventListener("pointerdown", onClickDown); + canvas.addEventListener("pointerup", onClickUp); +``` + +Add the cleanup lines in the effect's return: + +```tsx + canvas.removeEventListener("pointerdown", onClickDown); + canvas.removeEventListener("pointerup", onClickUp); +``` + +Add the pinned line to the HUD JSX, under the hover line: + +```tsx + {pinnedAddress &&
pinned [{pinnedAddress.join(",")}]
} +``` + +- [ ] **Step 4: Add URL read-once-on-mount and debounced write** + +Import the codec at the top: + +```tsx +import { encodeAddress, decodeAddress, parseSeed, parseZoom } from "./lib/codec"; +``` + +In the patch-build effect, after building the patch, read the URL once and apply a pinned tile / zoom if present (overriding the seed-derived center): + +```tsx + useEffect(() => { + const patch = buildPatch(PATCH_LEVEL); + patchRef.current = patch; + hitRef.current = buildHitIndex(patch.faces); + + const params = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams(); + const tAddr = decodeAddress(params.get("t") ?? undefined); + const z = parseZoom(params.get("z") ?? undefined); + if (z !== null) zoomRef.current = z; + + const pinned = tAddr ? findFaceByCoord(patch, tAddr) : null; + if (pinned) { + pinnedRef.current = pinned.coord; + setPinnedAddress(pinned.coord); + offsetRef.current = [pinned.centroid[0], pinned.centroid[1]]; + } else { + const c = seedToCenter(seed, patch); + offsetRef.current = [c[0], c[1]]; + } + setReady(true); + }, [seed]); +``` + +Add a debounced `writeUrl` inside the canvas effect (it reads the live refs and the current `seed` prop): + +```tsx + let writeTimer: ReturnType | null = null; + const writeUrl = () => { + if (writeTimer) clearTimeout(writeTimer); + writeTimer = setTimeout(() => { + const params = new URLSearchParams(); + const s = parseSeed(seed); + if (s) params.set("s", s); + if (pinnedRef.current) params.set("t", encodeAddress(pinnedRef.current)); + params.set("z", String(Math.round(zoomRef.current))); + window.history.replaceState(null, "", `?${params.toString()}`); + }, 250); + }; +``` + +Call `writeUrl()` from the pan, wheel, and pinch handlers (after `requestRender()`), and clear the timer in the cleanup: + +```tsx + if (writeTimer) clearTimeout(writeTimer); +``` + +Note: `writeUrl` and `findFaceByCoord` reference `seed`, so the canvas effect's dependency array stays `[ready]` (the patch is rebuilt on `seed` change in the other effect, which flips `ready`). If `seed` can change without unmount in practice, add `seed` to the canvas-effect deps; for v1 the seed is a stable prop. + +- [ ] **Step 5: Update the share E2E with a real address, then run it** + +Run the dev server, hover a tile near the center, read its `address [..]` from the HUD, and put that exact value in the `t=` of the first E2E test (replacing `0.0.0.0.0` if that address is not in the patch). + +Run: `bunx playwright test e2e/x/penrose/explore.spec.ts` +Expected: PASS (pinned HUD shows on the shared URL; URL gains `s`, `t`, `z` after a click). + +- [ ] **Step 6: Full test sweep** + +Run: `bun test ./src/app/x/penrose/ && bunx playwright test e2e/x/penrose/` +Expected: engine + unit tests `34 + patch + hitTest + codec` all pass; both E2E specs pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(penrose): click-to-pin doubles as camera origin and share URL" +``` + +--- + +## Self-Review notes (for the executor) + +- **Frame discipline.** If hover/pin ever reports the wrong tile, the cause is almost certainly mixing `pos` and `physical(coord)`. Everything stays in `pos`; corners come from `posByCoord`. Do not call `physical()` in the explorer. +- **Patch level.** `PATCH_LEVEL = 10` is the starting point (≈55k faces, ≈350ms build, world radius ≈123 edges). If first paint feels slow on a laptop, drop to 9 (≈21k faces) and note the smaller roam radius. This is a tuning constant, not an architectural choice. +- **No palette yet.** Interim colors are `--color-moment-1/4`. The B1 Mosaic tokens, the pin's `--ink` ring, and the DESIGN.md amendments are the next plan (gated on maintainer sign-off). +- **Deferred to the teaching-spine plan:** landing/explore copy rewrite off the new addressing story (the `labs[]` blurb and both `page.tsx` files still say "de Bruijn pentagrid"), the Sketch harness, and sketches 1–5. diff --git a/docs/superpowers/specs/2026-06-23-penrose-v1-design.md b/docs/superpowers/specs/2026-06-23-penrose-v1-design.md new file mode 100644 index 0000000..e6e1072 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-penrose-v1-design.md @@ -0,0 +1,193 @@ +# Penrose — v1 Design + +> **SUPERSEDED (2026-06-24).** This spec was written against the old de Bruijn pentagrid +> viewport-anchor engine, which had a real re-anchor bug and was replaced by the tested +> cut-and-project / substitution engine. The current spec is +> `2026-06-24-penrose-v1-design.md`. Kept for history. + +**Status:** superseded +**Date:** 2026-06-23 +**Source:** brainstorming session (terminal + visual companion) + +## Context + +The Penrose experiment is the most algorithmically ambitious thing on the site and the most unfinished. A WIP on `claude/penrose-tiling-exploration-EuACV` holds a research corpus (`research/penrose/`), a de Bruijn pentagrid engine (`src/app/x/penrose/explore/lib/pentagrid.ts`), a working canvas explorer, and a thin landing page. A six-agent review found the math geometrically correct, the viewport-anchor architecture sound and oracle-validated, and the explorer good to use. It also found what separates a WIP from a shipped experiment: zero tests, no share codec, an off-DESIGN palette, inaccurate copy, and one real bug. + +The bug is the headline. The exported `Tile.coord` is many-to-one (the review measured 240 tiles collapsing to ~118 coords), so the landing's promise that "the coord under the cursor is still the right one" is false. The proven-unique identity, the pentagrid vertex `(j, k, kj, kk)`, is computed and then discarded. + +Brainstorming sharpened the purpose. This is not an explorer with an essay attached. It is a guided introduction to Penrose tilings for people who have never met one: an explorable explanation where small playable figures each teach one idea, ending in a full explorer. + +Three deep properties drive the teaching, and they are one structure seen three ways. **Non-locality:** local matching rules are necessary but not sufficient, so laying tiles by them alone hits dead ends. Penrose's anecdote (Ball, *Prospect*): he spotted a university tiling whose edge tile broke the rules, so it "would go wrong somewhere in the middle of the lawn." **Local indistinguishability:** any two tilings share every finite patch yet differ globally, faulting along "veins." **φ-inflation:** each tile subdivides into φ-smaller tiles and composes into φ-larger supertiles indefinitely, with a unique grouping. That unique hierarchy is the hidden skeleton the three expose, and the exact address under the cursor is it made explicit: which two of the five grids cross here and at which lines, the tile's place in the 5D lattice that local rules cannot see. + +This is safe at any size because we never lay tiles locally. The de Bruijn pentagrid is global: each tile is a deterministic projection from a 5D lattice. No growth, no backtracking, no invalid configuration reachable. The line indices grow without bound as you pan (a 51-digit integer at 10^50 units out), which is why the address is BigInt and the explorer keeps a BigInt anchor with a small Float64 render offset. Regularity (`Σγ ∉ ℤ` vs the singular symmetric case) is a separate matter, not the address fix; an empirical probe demoted it from this spec's first draft (see centerpiece 2). + +## Goal + +Ship `/x/penrose` as a teaching experiment: a guided, explorable introduction whose centerpiece is an infinite explorer that always knows the exact tile under your cursor, taught by small playable sketches on the hierarchy spine. + +1. **Correct address.** The tile's address is the crossing `(j, k, kj, kk)` (`kj`/`kk` BigInt), proven unique per rhombus and test-gated. The old `cell_UR` 5-tuple collided and is dropped. +2. **Explorer, focused.** Keep the built interaction; add a seed input, click-to-pin, the B1 palette, and the small UX fixes. No overlays bolted on. +3. **Share by tile-address.** seed + pinned tile + zoom in the URL; the pin doubles as the camera origin. Client-side state, no `useSearchParams`. +4. **A Sketch harness** and the figures it powers. +5. **Tests** for the pure math, plus a Playwright smoke and a share round-trip. +6. **A teaching writeup** on the spine, with the experiment badge and footer. +7. **DESIGN.md amendments**, conscious: a Penrose palette and a scoped teaching-animation rule. + +Ship in reviewable slices, the way Prime Moments and Tripwire shipped. + +## Non-goals + +- **No overlays on the explorer.** Inflation and veins are bounded teaching sketches, never explorer modes. +- **Vein overlay is v2.** It slots into the overlay-sketch pattern built for inflation. +- **No seed gallery.** One seed input plus randomize. +- **No full keyboard pan/zoom.** v1 makes the canvas focusable and throttles the aria-live HUD; full keyboard nav is v2. +- **No style toggles, and no tracery.** The midline decoration is dropped. +- **No V1 emblem, no OG cards, no MDX.** Tracked in IDEAS.md. +- **No change to the Go module.** Honest research history, ships nothing. +- **No server-side anything.** No persistence, no precompute, no runtime config change. All client. + +## The teaching spine + +Single-column scroll, prose and sketches alternating, ending in the explorer. + +1. **Meet the two tiles** (static + hover). Thick and thin rhombus, φ in their angles, the edge marks. +2. **The dead-end** (play). Lay tiles by local rules until the patch paints into a corner. Penrose's lawn, playable. Non-locality. +3. **Five grids, one tiling** (play). Draw the five line families, morph each crossing into its dual rhombus. How de Bruijn builds it. +4. **Regular vs singular** (slider on `Σγ`). Watch the symmetric pinwheel (`Σγ ∈ ℤ`, lines concurrent) relax into a generic tiling. What regularity means in the pentagrid. +5. **The golden ratio appears** (play). thick:thin count converging to φ. +6. **Zoom the hierarchy** (inflation overlay, gated by the spike). A bounded patch with its φ-supertiling overlaid; step between depths. +7. **The explorer** (hero). Go anywhere; the exact address always under your cursor. + +## Design centerpieces + +### 1. One engine, many thin consumers + +`lib/pentagrid.ts` is the single source the explorer, all six sketches, the tests, and the v2 demos draw from. Invest in one correct, well-tested engine; keep every consumer small. Move the lib up to `src/app/x/penrose/lib/` (out of `explore/`), since the teaching page shares it too. + +### 2. The correctness fix: the crossing identity, test-gated + +The exported `cell_UR` 5-tuple is the wrong *kind* of label. A 5-tuple names a region of the plane; a tile is a *crossing* of two grid lines. An empirical probe settled it: across five seeds, `cell_UR` collapses ~50% (324 tiles → 135 distinct), and making the pentagrid regular does not help, it collides regardless. The reason is structural: the 5-tuple records which gap you sit in for each family but forgets which two families actually crossed, and that is exactly what tells two adjacent tiles apart. + +So the address is the crossing the enumerator already computes and discards: `(j, k, kj, kk)`. `j, k ∈ {0..4}` are which two grids meet (small ints); `kj, kk` are which line in each, unbounded, so BigInt. The absolute address is `(j, k, anchor.nProj[j] + kj, anchor.nProj[k] + kk)`. This is unique 324/324 in the probe, already computed, and legible ("grids 0 and 1, lines 7000000000000 and -4"). The invariant, enforced by a test: **every tile's `(j, k, kj, kk)` is distinct over any viewport, for any seed.** + +Regularity is decoupled and demoted to a teaching topic (sketch 4). The current singular seed renders fine and addresses uniquely, so v1 leaves `gammaFromSeed` as is. + +### 3. The explorer, focused + +Keep the built interaction (pan, cursor-zoom, pinch, two-finger pan, hover readout, theme reactivity). Add: + +- **Seed input** plus randomize. Requires fixing the empty-deps `useEffect` so a new seed rebuilds γ and the anchor. +- **Click-to-pin.** The pin is the shared/selected tile and the camera origin; pinning re-anchors on the tile. +- **B1 palette.** +- **UX fixes:** an affordance hint, `h-screen → h-dvh`, move the HUD off the breadcrumb overlap, stop the per-frame `setTileCount` re-render. +- **Cleanup:** wire `pointToCoordAnchored` for exact hover (tested near the 1e8 boundary) or delete it and `pointToCoordExact`. + +### 4. Share by tile-address + +A tile's address doubles as the camera position, so a link is one address plus zoom plus seed, shorter and more meaningful than raw coordinates. "Make this my origin" and "share this view" become the same operation. + +``` +/x/penrose/explore?s=&t=&z= +``` + +- `s` = seed (absent = default). +- `t` = pinned tile's address `(j, k, kj, kk)`: the two small family indices plus `kj`/`kk` as base62 BigInt (optional; present = pin and center on it). +- `z` = zoom, short fixed-precision float (absent = default). + +Two modules, mirroring Prime Moments' `encoding.ts` + `share.ts`: + +- `lib/encoding.ts`: base62 BigInt codec, `encode/decodeTileAddress`, `encode/decodeState`. Pure, round-trip tested. +- `lib/share.ts`: URL assembly and debounced `history.replaceState`. + +**Mechanism (the AGENTS.md data-flow call, made here):** the explorer is a client canvas. Read URL state once on mount from `window.location.search`; write it with debounced `replaceState` on change. No `useSearchParams`, no Suspense, no data flow into the server components. Within the existing "all routes dynamic, client canvas" model; not an architectural change. + +### 5. The Sketch harness + +`Sketch.tsx`: a client primitive wrapping a render area (canvas or SVG) and a control bar (play/pause, step, reset, optional slider). It owns the `requestAnimationFrame` loop and the reduced-motion contract; each sketch supplies a `step`/`render(t)`. + +**Reduced motion is a hard contract.** Nothing autoplays; motion happens only on play or slider drag. Under `prefers-reduced-motion`, sketches render the end state and never move on load. This is what lets us amend DESIGN.md without breaking its restraint. + +### 6. The inflation spike, contained + +The inflation overlay is the one piece existing research did not de-risk. A naive φ pixel-scale is a lie; it will not align with the true supertile grouping. The correct version needs the exact γ→γ′ transform plus the φ scaling. De Bruijn derived this in the pentagrid framework, so it exists, but it will not be vibed. Scope it as a research exercise, like the rest of that folder: + +- `research/penrose/05-inflation.ts` + `.md`: derive the transform, validate against the exact oracle that inflated vertices coincide with the expected supertile vertices. +- Only after it validates do we build the overlay sketch and `inflation.test.ts`. +- The spike is isolated. If the math is gnarly, the overlay slips to v2 and the rest of v1 ships. + +### 7. Palette B1 and the DESIGN.md amendments + +The chosen look (validated in the companion against the real tiling) is **B1 Mosaic**: solid fills, the dark `--paper` as grout, gold thick, teal thin, an `--ink` ring on the pin. Gold and teal are the Prime Moments constellation hues, so the site keeps one color language; the teal is nudged lighter on dark. + +Two conscious amendments, scoped as the constellation ring is scoped: + +**Color.** Add `--color-penrose-*` tokens, both modes: + +| Role | Light | Dark | +| --- | --- | --- | +| thick | `#C89B3C` | `#C89B3C` | +| thin | `#3E6B7C` | `#4f7d92` | +| grout | `--paper` | `--paper` | +| pin ring | `--ink` | `--ink` | + +**Animation.** DESIGN.md lists animation under "Not in the language (yet)." Amend to permit user-initiated teaching animation only: a figure may move on play or slider drag, must respect `prefers-reduced-motion`, and is confined to teaching experiments. No autoplay, no ambient motion. + +## Tests + +Table-driven `bun:test` where the shape fits. + +- `lib/pentagrid.test.ts`: address uniqueness, every tile's `(j, k, kj, kk)` distinct over a sampled viewport across seeds (the headline guard); coverage / no-overlap on sampled points; thick:thin → φ; anchored vs exact agreement at anchor 0 and near the 1e8 boundary; enumerate → `tileContains` round-trip. +- `lib/encoding.test.ts`: round-trip seed / tile-address / zoom, including large BigInt addresses. +- `lib/inflation.test.ts` (after the spike): inflated vertices coincide with supertile vertices. +- Playwright: `/x/penrose` loads and sketches mount; `/x/penrose/explore` mounts the canvas; a `?s=&t=&z=` URL loads and centers on the pin. + +## The writeup + +Teaching, on the spine, accurate. Prose interleaved with the sketches, ending in the explorer. Add the numbered badge (Penrose is the third experiment by publish date, `experiment 03`) and the "an experiment by nathan toups" footer, matching Prime Moments. Depth over volume. + +Housekeeping in `research/penrose/`: fix the README question-count contradiction (three / four / five in three places), mark exercise 02 superseded (it benchmarked int32, not the BigInt address), add `05-inflation`. The copy is accurate about share, which now ships. + +## Files + +``` +src/app/x/penrose/ + page.tsx teaching page: prose + sketches, hero link + lib/ shared engine (moved up out of explore/) + pentagrid.ts engine + address fix; inflation transform after spike + encoding.ts base62 + state codec + share.ts URL assembly + replaceState + pentagrid.test.ts encoding.test.ts inflation.test.ts + components/ + Sketch.tsx harness + MeetTheTiles.tsx DeadEnd.tsx FiveGrids.tsx + RegularVsSingular.tsx GoldenRatio.tsx InflationOverlay.tsx + explore/ + page.tsx full-bleed explorer route + PenroseExplorer.tsx seed, pin, palette, UX fixes; imports ../lib + +research/penrose/05-inflation.ts / .md the spike +research/penrose/README.md fix count; mark 02 superseded +src/app/globals.css + --color-penrose-* tokens +DESIGN.md + palette; + teaching-animation rule +src/app/x/page.tsx refresh the Penrose blurb +``` + +## Build order (slices) + +Each slice is independently reviewable and lands with its tests. + +1. **Engine + address fix + tests.** Move lib up; expose the crossing `(j, k, kj, kk)` as the `Tile` address (`kj`/`kk` BigInt), drop `cell_UR`; prove uniqueness over a sampled viewport. Nothing proceeds until that test is green. +2. **Encoding + share + explorer wiring.** The codec and its tests; seed input (with the effect fix), click-to-pin, URL read-on-mount and debounced write; share round-trip E2E. +3. **Explorer polish + palette.** B1, the `--color-penrose-*` tokens, the color amendment, the UX fixes, dead-export cleanup. +4. **Sketch harness + core sketches.** `Sketch.tsx` with the reduced-motion contract, the animation amendment, then the intro and four sketches. +5. **Inflation spike → overlay sketch.** Validate against the oracle, then build the sketch and its test. Slips to v2 if it does not validate. +6. **Teaching page + writeup.** Assemble the page, the prose, badge and footer, README fixes, index blurb. + +## Open risks + +- **The inflation transform** (slice 5) is the one unproven piece, test-gated against the exact oracle; it slips to v2 if it does not validate. (The address question is resolved: a probe across five seeds confirmed `cell_UR` collides ~50% regardless of regularity, while `(j, k, kj, kk)` is unique 324/324.) +- **The dead-end sketch** (slice 4) is a bounded, deterministic scripted sequence, not a live solver, so it always reaches the same teachable conflict. + +## v2 + +Vein overlay sketch (two seeds); full keyboard navigation; style toggles, V1 emblem, OG cards. The architecture stays ready. diff --git a/docs/superpowers/specs/2026-06-24-penrose-unbounded-generator-design.md b/docs/superpowers/specs/2026-06-24-penrose-unbounded-generator-design.md new file mode 100644 index 0000000..e3b2aae --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-penrose-unbounded-generator-design.md @@ -0,0 +1,202 @@ +# Penrose — Unbounded Viewport Generator (edgeless plane) + +**Status:** spec +**Date:** 2026-06-24 +**Builds on:** `2026-06-24-penrose-v1-design.md` (the shipped bounded explorer). This +replaces the explorer's generation path; the rest of the explorer (camera, pin, share, +hit-test, render loop) stays. + +## Why + +The shipped explorer builds one large patch at mount and pans over it, so it has an edge +you reach by panning out. We want an **edgeless** plane: as the camera moves, generate +only the tiles in view. The earlier worry (BigInt, exact golden-field arithmetic, three +coupled subsystems) turned out to be unnecessary. Two experiment results decide the +design: + +- **Float64 is exact out to radius ~10¹⁴ unit edges.** A camera at zoom 4 to 800 px/edge + cannot be panned anywhere near that by hand. So float64 is safe with ~11 orders of + magnitude to spare, and no BigInt is needed for the explorer. (A future jump-to-a-far- + coordinate feature is the only thing that would need exact arithmetic; out of scope.) +- **The shipped "sun" tiling is the singular pentagrid (offset 0), which is the hardest + case to generate this way** (lines concurrent at the center, tiles sitting exactly on + the acceptance boundary). A **generic** (non-singular) tiling has none of that, is far + more robust, and is locally indistinguishable from the sun. The maintainer chose the + generic tiling. + +## The decision (settled) + +Show a fixed **generic** Penrose tiling, generated per viewport. Every finite patch of it +is identical to a patch of the shipped sun tiling (local indistinguishability), so nothing +is lost in what the user sees or learns. Costs, both acceptable for a WIP preview: + +- The default landing view changes: no perfectly-symmetric 5-fold sun star at the center. +- Share links minted by the current bounded preview stop resolving (their addresses are in + the sun's gauge; the generic tiling has its own gauge). No real links exist yet. + +## Architecture + +### The generator: fixed-gamma pentagrid enumeration + +A de Bruijn pentagrid is five families of parallel lines; family `l` is the level sets of +`f_l(z) = Re(z·conj(ζ^l)) = x·PCOS[l] + y·PSIN[l]` (the engine's `cap.PCOS/PSIN`). A fixed +shift vector `γ` (one offset per family) places the lines. Each crossing of a line from +family `j` with a line from family `k` is one rhombus. Enumerating only the crossings +inside the viewport is O(visible), not the O(N⁵) box scan of the existing `generate()`. + +`γ` is **generic**: `Σγ_l = 0`, irrational, chosen so no five lines are concurrent and no +crossing lands on a window boundary. The exact value is a planning detail, pinned as a +constant and validated by a test (below). Genericity is what removes every degeneracy the +singular sun has. + +**The crucial subtlety (verified by prototype):** a tile's render position is +`physical(K)`, not the line-crossing point `z`. They differ by a fixed scaling, +`physical(K) = (5/2)·z + physical(γ) + b`, where `b = Σ t_l ζ^l` is bounded by `τ` (the +golden ratio). So the crossing point sits ~2/5 of the way toward the origin from the tile +body, and that gap grows without bound with distance. **Filter by the tile body, never by +`z`.** (The first prototype filtered by `z` and silently dropped every off-center tile; the +oracle missed it because it only tested origin-centered regions.) + +**Enumerate a physical viewport rectangle `V`:** + +1. **Map `V` into grid space.** Invert the scaling: `z ≈ (2/5)(physical − physical(γ))`. + Map `V`'s corners to a grid-space region `Z`, expanded by a constant grid margin + `GRID_MARGIN ≥ (2/5)·τ ≈ 0.65` (use 1.0) that covers the bounded `b`. Because `b` is + bounded, this margin is constant at any distance, which is what makes panning far work. +2. **Line-index ranges over `Z`.** For family `l`, indices `m ∈ [ceil(min f_l + γ_l), + floor(max f_l + γ_l)]` over `Z`'s corners. +3. **Solve crossings.** For each pair `j`, +LRU-evict cells outside the viewport plus a margin ring. Adjacent cells tile with **no seam +and no overlap** because each tile's centroid lies in exactly one half-open cell; the union +of per-cell results reconstructs the whole, de-duped by key. (Verified: grid seams near and +far from origin reconstruct key-for-key with zero lost, zero double-owned.) Note the +`facesInViewport` enumerator returns a one-tile border halo beyond `V`; a cell doing exact +partitioning re-applies half-open centroid ownership rather than trusting the raw set. + +Per camera change: compute the visible cell range, generate any newly exposed cells, +collect their faces, refresh the hit index over the visible set, render. Generation is +cheap (a few hundred to low-thousands of crossings per viewport, each a 2×2 solve plus a +projection), so it runs on the main thread on camera change with no worker. Mount is +instant (no whole-plane build, no "building tiling" freeze). + +### Precision + +Float64 throughout. Safe to radius ~10¹⁴ unit edges, unreachable by hand. The decimal tile +codec is already the documented seam for a future BigInt/exact-arithmetic jump-to-coordinate +feature; not built here. + +## Integration with the explorer + +- Replace the mount-time `buildPatch(PATCH_LEVEL)` + one-fixed-patch model with the chunk + cache fed by the pentagrid generator. The render loop computes the viewport rect (it + already does, for culling) and draws the visible cells' faces. +- `seedToCenter` / default view: the generic tiling has no `[0,0,0,0,0]` sun center. Pick a + deterministic default camera position (a fixed point in the ζ^l frame), and keep the + seed → camera-center mapping (a seed hashes to a starting position/address). +- Pin, hover, share, camera, theme, accessibility: unchanged. They consume `RenderFace`s + and a hit index; only the source of those changes. + +## Reuse vs new + +**Reused unchanged:** `cap.PCOS/PSIN/physical/index`, the `RenderFace` type, `hitTest.ts` +(`buildHitIndex`/`hitFace`, rebuilt over the current visible face set on each +regeneration), `codec.ts` (the 7-integer wire format), the entire render/pointer/zoom/pin/ +share machinery in `PenroseExplorer.tsx`. The substitution engine (`deflate/lift/faces/patch`) stays as the +tested foundation and as the source for later teaching sketches; it is no longer the +explorer's render path. + +**New:** one module (`pentagrid.ts`): line-range, crossing solve, local address, and +`facesInViewport(rect, γ)` returning `RenderFace[]`. A chunk cache with LRU eviction. The +fixed `γ` (and optional orientation) constant. Wiring in the component to generate per +viewport instead of once at mount. + +## Correctness and testing + +The existing slow but tested `generate(radius, vx, vy)` (cut-and-project, O(N⁵)) is the +**oracle**: for the chosen `γ`'s equivalent window offset, the fast pentagrid enumerator +must produce exactly the same faces as `extractFaces` over `generate()` on a bounded +region. This is the headline test (fast path == proven slow path), key-for-key. + +Other tests: + +- **Genericity guard:** the chosen `γ` yields no concurrent lines and no crossing within + epsilon of a window boundary over a sampled region (so there are no ties to break). +- **Tiling validity:** every face is a unit-edge rhombus; thick:thin ratio over a region + → φ; no two faces overlap and the region is fully covered (corner-acceptance holds). +- **Seam test:** a region generated as one block equals the union of its chunk cells, + key-for-key, with no missing or duplicated tiles at cell boundaries. +- **Address locality:** a tile's `K_l` computed at its crossing equals the address derived + from its corners (internal consistency), and is stable when the same tile is reached from + a different viewport. +- **Share round-trip:** encode a generated tile's address, decode, `findFaceByTile` in a + freshly generated viewport returns the same tile (the unbounded analogue of the bounded + round-trip test). +- **E2E:** pan far in one direction and confirm tiles keep appearing (no edge, no blank + grout) and the address HUD keeps reading. + +## Non-goals + +- No BigInt / exact-arithmetic. Float64 only; the far-radius wall is unreachable. +- No reproduction of the sun tiling or its addresses; no continuity with current preview + links. +- No Web Worker (generation is cheap and incremental). +- No jump-to-arbitrary-coordinate feature. +- No change to the substitution engine or the teaching-spine plans. + +## Open questions for planning + +- **The `γ` constant.** Pick a vetted generic vector (`Σ = 0`, irrational, no degeneracy) + and pin it; the genericity guard test validates it. Determine its equivalent + `(vx, vy)` window offset so the `generate()` oracle test can use the matching window. +- **Cell size** (the chunk grid step) and **eviction margin**: tune for smooth panning vs + cache memory; start ~8 unit edges and one margin ring, measure. +- **Default view / seed mapping.** A fixed default camera position in the generic tiling, + and how a seed string maps to a starting position/address (the bounded explorer hashed + the seed to a face centroid; the analogue here hashes to a world position). +- **Orientation.** Whether to apply a fixed global rotation for aesthetics or render raw + ζ^l. A visual call, decided during the build. + +## Build slices (each lands with its tests) + +1. **The enumerator core.** `pentagrid.ts`: line-range, crossing solve, local address, + `facesInViewport(rect, γ)`. Test against the `generate()` oracle (key-for-key on a + region) and the genericity + tiling-validity guards. +2. **Chunk cache + seams.** The cell cache, eviction, and the seam test. +3. **Explorer integration.** Replace the mount-time patch with per-viewport generation; + default view / seed mapping; hover and pin against the generated set. E2E: pan-forever, + address keeps reading. +4. **Share round-trip** in the new gauge, plus the landing/explore copy updated off the + edgeless-plane story. diff --git a/docs/superpowers/specs/2026-06-24-penrose-v1-design.md b/docs/superpowers/specs/2026-06-24-penrose-v1-design.md new file mode 100644 index 0000000..31ef11f --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-penrose-v1-design.md @@ -0,0 +1,373 @@ +# Penrose — v1 Design (rewrite) + +**Status:** spec +**Date:** 2026-06-24 +**Supersedes:** `2026-06-23-penrose-v1-design.md` (written against the old de Bruijn +pentagrid viewport-anchor engine, since replaced) + +## Why this is a rewrite + +The prior spec was built on the explorer's old engine: a de Bruijn pentagrid with a +BigInt viewport anchor and a Float64 render offset. That engine has a real bug. It does +not preserve the tiling across a nonzero re-anchor (`research/penrose/STATUS.md`, +Corrections). Its exported address `(j, k, kj, kk)` and the whole "cell_UR collides" +framing belong to that engine. + +The engine was replaced, not patched. A tested cut-and-project / substitution engine now +lives in `research/penrose/cap/` with a passing test suite (`bun test +./research/penrose/cap/` → 34 pass; 24 `test()` blocks, several table-driven). It +reproduces classical de Bruijn +theory (de Bruijn 1981/1990; D'Andrea 2023, *A Guide to Penrose Tilings*) exactly. The +address is now the de Bruijn ℤ⁵ coordinate `n = (n₀..n₄)`, and a rhombus is `[n; j, k]`. +See `research/penrose/STATUS.md` for provenance. + +Everything in the prior spec that was about the *product* carries over unchanged. The +guided explorable-explanation framing, the teaching spine, the B1 Mosaic palette, +share-by-address, the reduced-motion contract and Sketch harness, the experiment chrome. +This rewrite keeps all of that and re-grounds the engine-dependent parts on `cap/`. + +## Goal + +Ship `/x/penrose` as a teaching experiment: a guided, explorable introduction to Penrose +tilings whose centerpiece is an explorer that always knows the exact tile under your +cursor, taught by small playable sketches on the φ-inflation spine. + +Build order is explorer first, then the learning tool. The explorer proves the engine +works for real users; the sketches then teach what it is doing. + +## The three teaching properties (engine-independent, carried over) + +One structure seen three ways. + +- **Non-locality.** Local matching rules are necessary but not sufficient, so laying + tiles by them alone hits dead ends. Penrose's lawn anecdote (Ball, *Prospect*): an edge + tile broke the rules, so the tiling "would go wrong somewhere in the middle of the + lawn." +- **Local indistinguishability.** Any two Penrose tilings share every finite patch yet + differ globally. You can never tell which tiling you are in from any finite view. +- **φ-inflation.** Each tile subdivides into φ-smaller tiles and composes into φ-larger + supertiles indefinitely, with a unique grouping. This hierarchy is the hidden skeleton, + and the exact ℤ⁵ address under the cursor is that skeleton made explicit. + +The engine swap strengthens this. φ-inflation was a "math might not validate" risk in the +prior spec. It is now solved and tested: it is the integer operator `A` (eigenvalues −φ, +1/φ, 2) and the closed-form fold `coord' = −A·coord + m·ones`, which is D'Andrea Theorem +5.16. + +## Scope decisions (this rewrite) + +### 1. Bounded world, staged seams + +v1 ships a **bounded explorer**: one large patch generated from the origin (deflation +level ~8–10, tens of thousands of tiles), with pan and cursor-zoom as camera math over +that fixed tile set. Every tile carries its exact ℤ⁵ address from the tested lift. +Hit-testing under the cursor reads that address out. + +The level is bounded by render cost and reach (how far you can roam), not by precision. +The integer ℤ⁵ address stays exact in Float64 far past any renderable level: components +grow like O(φ^N), about 49 at level 10, against the 2⁵³ ≈ 9e15 exact-integer limit; level +10 is roughly 55k faces and a ~350ms one-time build. What forces v2 is far-from-origin +physical-position precision under unbounded pan, not the address. + +True infinite pan is **v2**. It needs three coupled, currently-unbuilt subsystems +(viewport-clipped pruned-deflation generation, BigInt + golden-field exact arithmetic for +the window membership test, and a self-contained canonical frame for far-from-origin +anchoring). Each is real engineering with its own correctness oracle. v1 is built with +clean seams (an address/codec layer and a camera layer that do not assume float) so v2 +slots in without a rewrite. + +### 2. One canonical tiling; a seed is a coordinate into it + +There is essentially one Penrose tiling up to local isomorphism (the property the sketches +teach). v1 models exactly that. The engine generates one canonical tiling, the +sun-centered deflation. A **seed is a starting coordinate / region** in that tiling, and +**randomize teleports to a new region**. Variety is real because the tiling is +non-periodic: every region looks locally different. The seed is itself an address, which +is the thesis of the whole experiment. + +This replaces the old "seed = different `gamma` = different tiling" model. It is more +truthful to the math, efficient (lift once, roam), and avoids the O(radius⁵) brute-force +`generate()` path. The default view is a generic off-center region, reached as a camera +translation over the sun-centered substitution patch: the engine center stays the singular +5-fold sun, the camera starts off it. A seed coordinate maps to a camera center; the sun +is reachable but is not the landing view. (Seed → camera-center mapping is an open +planning question below.) + +### 3. Explorer first, learning tool second + +v1 sequences the explorer (engine port, renderer, addressing, pin/share) ahead of the +teaching spine (landing writeup, Sketch harness, sketches). The full learning tool is in +scope; it is built after the explorer is live. + +## Architecture + +### Engine packaging (needs maintainer sign-off at review) + +Port `research/penrose/cap/{cap,deflate,bridge,fold,faces}.ts` and their `.test.ts` files +into `src/app/x/penrose/explore/lib/` verbatim, making them app code. The research notes +(`research/penrose/05`–`09`, `STATUS.md`) stay where they are as the writeup source. +Delete the old `src/app/x/penrose/explore/lib/pentagrid.ts`. + +Rationale: AGENTS.md requires experiments to be self-contained and ship with func.lol. The +tested engine is the experiment's core; it belongs in the experiment. Importing across the +`research/` boundary is a gray area and couples app builds to research scratch. The engine +is small and already test-covered, so the port is a move plus a path fixup, and the tests +move with it. + +This is an architectural change. It is presented here for sign-off; no code moves until +the spec is approved. + +### Coordinate frames (from the engine) + +Three origin-centered frames, all in the tested engine: + +- **ℤ⁵ coord** `n = (n₀..n₄)`, the base-corner vertex coordinate. `index = Σ nₗ ∈ {1,2,3,4}` + for valid vertices. Integer, exact, deep-zoom-safe. A vertex coord is not a tile: a tile is + the rhombus `[n; j, k]` (the `Face.key`), and many rhombi share an `n`. The full `[n; j, k]` + is the tile identity for hit-testing and the URL, and is what the HUD shows. +- **Physical** `physical(n) = Σ nₗ·(cos 2πl/5, sin 2πl/5)`, edge length 1. The explorer + renders in the closely related `LiftedVertex.pos` frame, a fixed rotation of this (see + "One frame, pinned" below). Do not mix the two. +- **Internal** `internal(n)`, not drawn. The bounded membership space that makes this a + quasicrystal rather than a grid. + +`scale = φ^level` is the single conversion constant between raw deflation geometry and +unit-edge physical geometry. The explorer works in the lifted (scaled) `pos` frame where +edges are ≈ 1 and coords are attached. + +### The data the explorer renders + +The substitution path is the rich one. `substitutionFaces(level)` returns +`{ faces: Face[], verts: LiftedVertex[] }` in one call: + +- `LiftedVertex = { pos: Pt, coord: readonly number[] }` (length 5 by construction) — + physical position plus exact ℤ⁵ address. +- `Face = { key: string, type: "thick" | "thin" }` where `key = "n.join(',')|jk"`. The four + corners are the coords `n, n+eⱼ, n+eₖ, n+eⱼ₊ₖ`. Proven exact vs the substitution (no + phantoms, none missing). + +**One frame, pinned.** Render and hit-test exclusively in the `LiftedVertex.pos` frame. +`pos` and `physical(coord)` are NOT the same frame: `lift()` integrates coords in a frame +rotated from `pos` by a fixed offset, so they differ by a pure rotation (measured up to ~2 +units apart at level 4). The patch builder looks up each corner's position by its coord key +in the `verts` set, never via `physical()`. All geometry (corners, centroid, hit-test) +lives in the `pos` frame. + +The explorer needs three thin layers the engine does not yet provide. They are v1 work +items, all built on the tested core and individually testable: + +- **Patch builder.** Wrap `substitutionFaces(level)` into a render model: a `Map` from + coord key to `LiftedVertex.pos`, and per face its four corner positions (looked up in + that map) plus centroid, keyed by `Face.key`, all in the `pos` frame. A static set; built + once per session (and once per seed/randomize jump). +- **Hit-testing.** Map a physical (x, y) under the cursor to a face. Point-in-rhombus + against precomputed corners, accelerated by a uniform spatial grid bucket (the tiling is + near-uniform density, so a grid keyed on physical position is enough). Returns the + `Face` and its `coord`. +- **Address codec.** Encode/decode the ℤ⁵ address (and the camera) for the URL. Pure, + validated, with a base62 form for the integer components. Built to the + prime-moments codec precedent (strict parse, return null on bad input). + +## The explorer (bounded) + +Salvage the rendering and interaction layer of the current `PenroseExplorer.tsx`, which is +good: native 2D canvas with `devicePixelRatio` + `ResizeObserver`, refs-not-state for all +camera values, a RAF dirty-flag render loop, unified Pointer Events with pinch when two +pointers are down, wheel zoom pivoting on the cursor, theme colors read live from CSS vars +with a `MutationObserver` on `data-theme`. Keep this nearly verbatim. + +Replace the state model that was coupled to the buggy Float64 viewport anchor +(`anchorRef`/`offsetRef`/`maybeReAnchor`/`makeAnchor`/`enumerateTiles`). The new model: + +- One patch built from the seed region at mount. Camera is pan + zoom over the fixed set. +- `drawFaces` fills each rhombus solid (B1 Mosaic), strokes the grout. No per-frame React + state; the HUD reads through refs and a throttled `useState` for the address line. +- Hover reads the face under the cursor and shows its address. Click pins it. + +Interaction commitments (carried over): pan, cursor-zoom, pinch, two-finger pan, hover +readout, theme reactivity, seed input + randomize, click-to-pin. + +UX fixes carried over: an affordance hint on first load, `h-dvh` not `h-screen`, the HUD +clear of the breadcrumb, no per-frame `setState`. + +Accessibility carried over: focusable canvas, throttled `aria-live` address HUD. Full +keyboard pan/zoom is v2. + +### Pin, origin, share are one operation + +Click-to-pin selects a tile. The pin is the shared tile and the camera origin at once. +"Make this my origin" and "share this view" are the same action. Re-rooting is cheap and +consistent in the bounded model (the pinned tile's `coord` is exact; the camera recenters +on its physical position). No re-anchor, so no re-anchor bug. + +## Share by address + +URL scheme on the explore route: + +``` +/x/penrose/explore?s=&t=&z= +``` + +- `s` absent → default seed. `t` present → pin and center on that tile. `z` absent → + default zoom. +- Client-side only: read `window.location.search` once on mount, write with debounced + `history.replaceState`. No `useSearchParams`, no Suspense, no server data flow. The root + layout reads `cookies()` for the theme so pages already render per-request (DESIGN.md, + light/dark toggle); this experiment adds no runtime export and no architectural change, + so the URL approach needs no separate sign-off. +- `t` encodes the ℤ⁵ address (5 integers) compactly via the base62 codec. `s` encodes the + seed coordinate the same way. The codec is the engine-independent seam for v2 (when + addresses become BigInt, only the codec widens). + +## The teaching spine (built after the explorer) + +Single-column scroll, prose and sketches alternating, ending in the explorer hero. Depth +over volume. Experiment badge and "an experiment by nathan toups" footer matching Prime +Moments. Verify the experiment number against the current `labs[]` publish order before +hard-coding it. + +### Sketch harness + +`Sketch.tsx`, a client primitive: a render area (canvas or SVG) plus a control bar +(play/pause, step, reset, optional slider). It owns the RAF loop and the reduced-motion +contract. Each sketch supplies `step` and `render(t)`. + +**Reduced-motion hard contract.** Nothing autoplays. Motion only on play or slider drag. +Under `prefers-reduced-motion`, render the end state and never move on load. + +### Sketches in v1 + +The set and order, and how each grounds on the new engine: + +1. **Meet the two tiles** (static + hover). Thick and thin rhombus, φ in the angles, the + edge marks. Authored geometry. +2. **The dead-end** (play). Lay tiles by local matching rules until the patch paints into a + corner. Penrose's lawn anecdote, playable. A bounded, deterministic *scripted* sequence, + authored content, deliberately not from the engine (it demonstrates local-rule failure, + which the global engine never does). +3. **The golden ratio appears** (play). thick:thin rhombus count converging to φ, from + `thickThinRatio` over `substitutionFaces(level)` across levels (the rhombus picture the + sketch shows; `colorCounts` counts triangles, a different object). +4. **Zoom the hierarchy** (inflation overlay). A bounded patch with its φ-supertiling + overlaid; step between depths. Math is solved (operator `A` / the fold); this is overlay + rendering on a tested transform, no longer a research risk. +5. **The explorer** (hero). Go anywhere in the patch; the exact address always under the + cursor. + +Deferred to v2 (they need the most rework against cut-and-project, decided in this +rewrite): **Five grids, one tiling** (the de Bruijn dual picture, now one of two +equivalent views rather than how the explorer computes) and **Regular vs singular** (its +old slider rode `Σγ`; in cut-and-project the analogue is the internal-window offset and +five-line concurrency, a new mechanism to design). + +## Palette: B1 Mosaic (needs maintainer sign-off at review) + +Solid fills, dark `--paper` as grout, gold thick, teal thin, an `--ink` ring on the pin. +Gold and teal reuse the Prime Moments constellation hues so the site keeps one color +language; teal is nudged lighter on dark. + +Proposed `--color-penrose-*` tokens. They reuse the existing constellation hues, so the +palette adds no genuinely new color: thick is gold `#C89B3C` (= `--color-moment-1`), thin +is teal `#3E6B7C` (= `--color-moment-4`). One departure: thin is nudged lighter on dark +(`#4f7d92`) for contrast against the dark `--paper`, which needs a `[data-theme="dark"]` +override (like `--paper`/`--ink`/`--subtle` have, unlike the mode-invariant constellation +tokens). So the tokens live in `@theme` like the constellation ring, with a single dark +override for thin. + +| role | light | dark | +| -------- | ------------------------ | ------------------------ | +| thick | `#C89B3C` (gold = moment-1) | `#C89B3C` | +| thin | `#3E6B7C` (teal = moment-4) | `#4f7d92` (lighter) | +| grout | `--paper` | `--paper` | +| pin ring | `--ink` | `--ink` | + +Consumed through one thin helper (`explore/lib/colors.ts`) that maps roles onto the +tokens, mirroring `prime-moments/lib/colors.ts`. Canvas reads them live via +`getComputedStyle` so they invert with the theme. + +**DESIGN.md amendments**, conscious and minimal: + +1. Add the `--color-penrose-*` tokens to the color section. Note they reuse the + constellation hues (gold = moment-1, teal = moment-4), so no new hue enters the + language; the only new value is the dark teal shade. +2. Qualify the "Not in the language (yet)" list: "a second accent color" / + "per-experiment custom styling" are relaxed for experiments that reuse existing + constellation hues through a scoped helper. Update the `globals.css` comment + "the only color on the site" to acknowledge the penrose roles reuse those same hues. +3. Add a scoped teaching-animation rule: user-initiated motion only, respect + `prefers-reduced-motion`, confined to teaching experiments, no autoplay or ambient + motion. + +Adding named color and amending DESIGN.md are architectural per CLAUDE.md. Presented for +sign-off; no tokens land until approved. This lives in a later polish slice, so it does +not block the explorer start. + +## Testing + +Engine: the `cap/` suite (34 pass, 24 `test()` blocks with table-driven cases) moves with +the port and must pass in its new home (`bun test ./src/app/x/penrose/`). + +New v1 unit tests (colocated, table-driven where the shape fits): + +- **Patch builder.** Face count grows ~φ² per level; every face has four finite corners; + centroids distinct. +- **Hit-testing.** For a sampled set of points strictly inside known faces, the hit returns + that face; points in the grout return none; round-trip face → centroid → hit is the same + face. +- **Address codec.** Round-trip `s` / `t` / `z`, including negative and multi-digit ℤ⁵ + components; bad input returns null (strict parse). +- **Multi-scale consistency** (the property the old anchor violated), a v2-seam guard: a + tile's address from `nextCoordCanonical` (the self-contained fold, valid in the canonical + {1,2,3,4} band) matches the lift's address for the same tile. The fully self-contained + absolute frame is an open item in STATUS.md (`nextCoord` still reads its band-min from a + lift), so the test uses the canonical fold to stay lift-independent. v1 builds one + fixed-level patch and never refines at runtime, so this only guards the v2 seam. + +E2E (Playwright): `/x/penrose` loads and the page mounts; `/x/penrose/explore` mounts the +canvas; a `?s=&t=&z=` URL loads and centers on the pin (header-level assertions, the repo's +E2E style). + +## Build slices (ordered, each lands with its tests) + +1. **Engine port.** Move `cap/` into `explore/lib/`, fix paths, delete `pentagrid.ts`, all + tests green in the new location (`bun test ./src/app/x/penrose/` → 34 pass; this adds the + `*.test.ts` files to the app test run, a test-surface change, not a runtime one). No + behavior change. +2. **Patch builder + hit-testing.** The two render-model layers, with their unit tests. No + UI yet. +3. **Explorer rewrite.** Salvage the canvas/interaction layer; wire it to the patch builder + and hit-testing; hover address HUD; seed + randomize (seed = region). Drop the old + anchor model. +4. **Pin + share.** Address codec; pin = origin = share; the `s/t/z` URL; client-side + read-once / debounced write. Share round-trip E2E. +5. **Palette + polish.** B1 Mosaic tokens + the DESIGN.md amendments (sign-off gated); + `explore/lib/colors.ts`; the UX fixes; rewrite the superseded "infinite / de Bruijn + pentagrid" copy off the new addressing story in all three surfaces: the `labs[]` blurb + in `src/app/x/page.tsx`, and the metadata/prose in `x/penrose/page.tsx` and + `x/penrose/explore/page.tsx`. +6. **Teaching spine.** Landing writeup, Sketch harness, sketches 1–5 in order. Reduced- + motion contract. + +## Non-goals (v1) + +- No infinite pan, no BigInt deep-zoom. v2, with its own test suite. +- No overlays bolted onto the explorer. Inflation is a bounded sketch, never an explorer + mode. Vein overlay is v2. +- No seed gallery. One seed input plus randomize. +- No full keyboard pan/zoom. v2. +- No style toggles, no tracery (the midline decoration is dropped). +- No server-side anything, no persistence, no precompute. Pure client compute. +- No emblem, no OG cards, no MDX. + +## Open questions for planning + +- **Patch level.** Pick the deflation level that balances tile count (render cost) against + reach (how far you can roam) while keeping Float64 coords exact. Likely 8–10; confirm by + measuring coord magnitude and frame time. +- **Randomize range.** In the bounded patch, randomize jumps within the generated region. + Confirm the region is large enough that randomize feels like "somewhere new" and define + how a seed coordinate maps to a camera center. +- **Sun center.** Decide whether the famous 5-fold sun center is surfaced as a named + landmark or simply reachable. Default view is generic, off-center. +- **Experiment number.** Re-confirm publish order against the current `labs[]` before + hard-coding the badge. diff --git a/docs/superpowers/specs/2026-06-25-penrose-teaching-spine.md b/docs/superpowers/specs/2026-06-25-penrose-teaching-spine.md new file mode 100644 index 0000000..4fabb92 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-penrose-teaching-spine.md @@ -0,0 +1,92 @@ +# Penrose Teaching Spine — editorial arc + +**Status:** design (the narrative the `/x/penrose` page tells) +**Date:** 2026-06-25 +**Builds on:** `2026-06-24-penrose-v1-design.md` (the v1 design; this refines the teaching +spine into a guided story) and the shipped explorer + Sketch harness. + +The `/x/penrose` page is a guided explorable explanation: a single-column scroll that +walks the reader through one question and its consequences, prose alternating with small +playable sketches, ending at the explorer. The goal is to show people how cool this is, in +order, each idea earned by the one before it. + +## The arc (sections, in order) + +Each section is a short prose beat and, where marked, a sketch (rendered through the +`_components/Sketch.tsx` harness: static, or animated with the reduced-motion contract). + +1. **The question.** Can a set of tiles cover the whole infinite plane but only ever + aperiodically, never settling into a repeating pattern? (Prose.) +2. **The history.** Wang asked whether any set that tiles the plane can do so periodically. + In 1966 Berger showed no: an aperiodic set exists, his first one used 20,426 Wang tiles. + It was whittled down over years (Robinson got to 6), until Penrose in 1974 reached just + **two** tiles plus matching rules. (Prose. Keep it light and accurate; cite names/years, + no need for a bibliography.) +3. **How the two tiles work.** The fat (72/108) and thin (36/144) rhombi, their matching + marks, where φ hides. SKETCH: "Meet the two tiles" (built, static + hover). +4. **A local dead-end.** Lay tiles by the local matching rules and you can paint into a + corner: a spot where the arrows conflict and nothing fits. SKETCH: "The dead-end" (built, + animated; currently the accessible single-vertex version). +5. **But the problem is deeper.** Here is the real subtlety: you can tile a whole region + perfectly by local rules and still be globally doomed, and the contradiction is forced + far from any visible mistake (Penrose's lawn: the bad tile is at the edge, it "goes wrong + in the middle"). SKETCH (to build, the hard one): grow a valid patch outward ring by ring + under local rules, center provably fine, until a forced unfillable gap appears in an + OUTER ring, distant from any choice. The teaching beat: local correctness does not + guarantee global success, and the failure is non-local. +6. **So you solve it globally.** If local trial-and-error can dead-end, do not tile locally + at all. Project from a 5-dimensional integer lattice: every tile is a deterministic + shadow of a 5D point, so the plane is computed, never backtracked, and can never dead-end. + This is the cut-and-project method, and it is what the explorer runs. SKETCH (to build): + the cut-and-project view, two linked panels, the physical tiling and the internal + "shadow" space with the acceptance window; a tile exists iff its 5D shadow lands in the + window, decided locally from the coordinate, no walk from any origin. +7. **The overlay.** Penrose noticed that overlaying two of these tilings reveals structure: + slide one over the other and large regions snap into agreement, separated by shifting + "veins" of interference, all organized by the 5-fold symmetry. SKETCH (to build, the + projector demo): two tilings, one filled and one as contrasting edges, a slider slides / + rotates one over the other; agreement islands and the veins between them. Teaching beat: + any two Penrose tilings share every finite patch yet never globally match. +8. **A coordinate system.** Because every tile is a 5D lattice point, every tile has a + unique, exact address. That is what lets the explorer tell you where you are anywhere on + the edgeless plane. SKETCH or prose tied to #6 (the ℤ⁵ address read off the local + crossing). May merge with #6 if one two-panel sketch carries both the "it exists" and + the "here is its address" beats. +9. **More magic: scaling.** Any valid Penrose tiling can be inflated or deflated into + another valid Penrose tiling, scaled by φ, indefinitely; the count of fat to thin tiles + tends to φ. SKETCHES (to build): "The golden ratio appears" (thick:thin -> φ) and "Zoom + the hierarchy" (a patch under its φ-supertiling, stepping depths). +10. **The explorer.** A link/hero into `/x/penrose/explore`: walk the addressed, edgeless + plane, every tile naming itself under your cursor. + +## Sketch inventory and status + +- Meet the two tiles, static + hover. BUILT. +- The dead-end, animated. BUILT (accessible single-vertex version; section 5 adds the + faithful far-contradiction beat, either by reworking this or as a second sketch). +- The deeper problem (grow-out to a distant forced gap), animated. TO BUILD. +- Cut-and-project / ℤ⁵ solver (physical + internal window, hover; the address read + locally), interactive. TO BUILD. (Carries sections 6 and 8.) +- The interference overlay (two tilings, slider, agreement islands + veins), animated. TO + BUILD. +- The golden ratio appears (thick:thin -> φ), animated. TO BUILD. +- Zoom the hierarchy (φ-supertiling overlay, step depths), animated/slider. TO BUILD. + +## Constraints (carried) + +- House visual language (DESIGN.md): dark default, paper/ink + `--color-penrose-*` tokens, + mono labels, no rounded corners on chrome, restrained aesthetic. No emdashes. +- Reduced-motion hard contract: nothing autoplays; under `prefers-reduced-motion` the + harness renders the representative end state; motion only on user play / slider. +- The sketches that need a tiling use the substitution engine (`deflate`, `subdivide`, + `colorCounts`) or the pentagrid enumerator; the dead-end and deeper-problem sketches are + hand-authored (they show local-rule FAILURE, which the global engine never produces). +- Accuracy: the history beat must be factually correct (Berger 1966 / 20,426 tiles; + Robinson 6; Penrose 1974 / two tiles). Keep it light, not a paper. + +## Build order + +The page scaffold (the full prose arc with section structure, the two built sketches +slotted, and placeholders for the rest) lands first so the story reads end to end. Then the +sketches fill their slots in arc order, each pushed to the preview for review. The deeper- +problem sketch (section 5) is the hardest and gets its own careful slice. diff --git a/e2e/x/penrose/explore.spec.ts b/e2e/x/penrose/explore.spec.ts new file mode 100644 index 0000000..d656a1e --- /dev/null +++ b/e2e/x/penrose/explore.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; + +test("explorer mounts a canvas", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); +}); + +test("explorer shows the seed in the HUD", async ({ page }) => { + await page.goto("/x/penrose/explore"); + await expect(page.getByText(/seed/i)).toBeVisible(); +}); + +test("the URL gains s, t, z after a click", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + await canvas.click({ position: { x: 200, y: 200 } }); + await expect(page).toHaveURL(/[?&]t=/); + await expect(page).toHaveURL(/[?&]s=/); + await expect(page).toHaveURL(/[?&]z=/); +}); + +// Self-contained round-trip: pin a real tile, capture the URL it produces, reload +// that URL, and assert the same address reappears. No hardcoded coordinates, so +// the test cannot drift out of sync with the engine. +test("a pinned address round-trips through the share URL", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + + // Click the center to pin whatever tile is under the cursor. + await canvas.click({ position: { x: 200, y: 200 } }); + + const pinned = page.getByText(/pinned/i); + await expect(pinned).toBeVisible(); + const pinnedText = (await pinned.textContent())?.trim(); + expect(pinnedText).toBeTruthy(); + + // The debounced write lands within 250ms; wait for the URL to carry t. + await expect(page).toHaveURL(/[?&]t=/); + const sharedUrl = page.url(); + + // Reload the captured URL in a fresh context and confirm the same pin shows. + await page.goto(sharedUrl); + await expect(canvas).toBeVisible(); + await expect(page.getByText(/pinned/i)).toHaveText(pinnedText!); +}); + +// The plane is edgeless: tiles are generated per viewport, so dragging far in one +// direction never reaches a boundary. Drag many times, then confirm a tile address +// still reads under the cursor. A bounded one-patch model would run out of tiles +// and the address HUD would stop updating. +test("the plane has no edge: panning far keeps showing tiles", async ({ page }) => { + await page.goto("/x/penrose/explore"); + const canvas = page.locator("canvas[aria-label='Penrose tiling explorer canvas']"); + await expect(canvas).toBeVisible(); + + const box = await canvas.boundingBox(); + if (!box) throw new Error("no canvas box"); + const midX = box.x + box.width / 2, + midY = box.y + box.height / 2; + for (let i = 0; i < 20; i++) { + await page.mouse.move(midX, midY); + await page.mouse.down(); + await page.mouse.move(midX - 300, midY, { steps: 5 }); + await page.mouse.up(); + } + await page.mouse.move(midX, midY); + await expect(page.getByText(/address/i)).toBeVisible(); +}); diff --git a/e2e/x/penrose/page.spec.ts b/e2e/x/penrose/page.spec.ts new file mode 100644 index 0000000..54f413c --- /dev/null +++ b/e2e/x/penrose/page.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from "@playwright/test"; + +test("the landing page renders the two-tiles sketch", async ({ page }) => { + await page.goto("/x/penrose"); + const figure = page.getByRole("img", { + name: /two Penrose rhombi side by side/i, + }); + await expect(figure).toBeVisible(); +}); + +test("the badge derives the experiment number from publication order", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // Penrose is the third experiment by publishedAt, so the badge must read 03, + // computed from the labs data rather than hand-set. + await expect(page.getByText("experiment 03")).toBeVisible(); +}); + +test("hovering a tile surfaces its golden-ratio detail", async ({ page }) => { + await page.goto("/x/penrose"); + // The thick rhombus is the first of the two polygons; hovering it surfaces its + // angle and golden-ratio detail in the live caption. + const thick = page.locator("svg polygon").first(); + await thick.scrollIntoViewIfNeeded(); + await thick.hover(); + await expect(page.getByText(/long diagonal is exactly/i)).toBeVisible(); +}); + +// Each animated sketch carries its own control bar, so button locators must be +// scoped to the sketch's figure (the page has more than one animated sketch). +const solverFigure = (page: import("@playwright/test").Page) => + page + .locator("figure") + .filter({ + has: page.getByRole("img", { + name: /small six-edge hole carved from a real Penrose patch/i, + }), + }); + +const unsolvableFigure = (page: import("@playwright/test").Page) => + page + .locator("figure") + .filter({ + has: page.getByRole("img", { + name: /single closed sixteen-edge hole with exactly one surviving completion/i, + }), + }); + +test("the geometry-only dead-end sketch mounts and honours the reduced-motion contract", async ({ + page, +}) => { + await page.goto("/x/penrose"); + const figure = solverFigure(page); + await expect( + figure.getByRole("img", { + name: /small six-edge hole carved from a real Penrose patch/i, + }), + ).toBeVisible(); + // The harness mounts at the stationary end state, so reset is enabled and step + // is disabled until the viewer rewinds. This is the reduced-motion contract + // observed from outside. + const reset = figure.getByRole("button", { name: "reset" }); + const step = figure.getByRole("button", { name: "step" }); + await reset.scrollIntoViewIfNeeded(); + await expect(reset).toBeEnabled(); + await expect(step).toBeDisabled(); + await reset.click(); + await expect(step).toBeEnabled(); + await expect(reset).toBeDisabled(); +}); + +test("the unsolvable-future sketch mounts its animated canvas and controls", async ({ + page, +}) => { + await page.goto("/x/penrose"); + const figure = unsolvableFigure(page); + await expect( + figure.getByRole("img", { + name: /single closed sixteen-edge hole with exactly one surviving completion/i, + }), + ).toBeVisible(); + await expect( + figure.getByRole("button", { name: "play", exact: true }), + ).toBeVisible(); + await expect(figure.getByRole("button", { name: "step" })).toBeVisible(); + await expect(figure.getByRole("button", { name: "reset" })).toBeVisible(); +}); + +test("the unsolvable-future sketch loads at its stationary end state", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // Same harness contract: mounts at t = 1 (end state), so reset is enabled and + // step is disabled until the viewer rewinds. + const figure = unsolvableFigure(page); + const reset = figure.getByRole("button", { name: "reset" }); + const step = figure.getByRole("button", { name: "step" }); + await reset.scrollIntoViewIfNeeded(); + await expect(reset).toBeEnabled(); + await expect(step).toBeDisabled(); + await reset.click(); + await expect(step).toBeEnabled(); + await expect(reset).toBeDisabled(); +}); + +const overlayFigure = (page: import("@playwright/test").Page) => + page.locator("figure").filter({ + has: page.getByRole("img", { + name: /Two real Penrose tilings overlaid/i, + }), + }); + +test("the interference-overlay sketch mounts its animated canvas and controls", async ({ + page, +}) => { + await page.goto("/x/penrose"); + const figure = overlayFigure(page); + await expect( + figure.getByRole("img", { name: /Two real Penrose tilings overlaid/i }), + ).toBeVisible(); + await expect( + figure.getByRole("button", { name: "play", exact: true }), + ).toBeVisible(); + await expect(figure.getByRole("button", { name: "step" })).toBeVisible(); + await expect(figure.getByRole("button", { name: "reset" })).toBeVisible(); + // The projector motion is the slider: it scrubs the turn of the top layer. + await expect(figure.getByRole("slider", { name: "turn" })).toBeVisible(); +}); + +test("the interference-overlay sketch loads at its stationary end state", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // Same harness contract: mounts at t = 1 (the representative islands-and-veins + // frame), so reset is enabled and step is disabled until the viewer rewinds. + const figure = overlayFigure(page); + const reset = figure.getByRole("button", { name: "reset" }); + const step = figure.getByRole("button", { name: "step" }); + await reset.scrollIntoViewIfNeeded(); + await expect(reset).toBeEnabled(); + await expect(step).toBeDisabled(); + await reset.click(); + await expect(step).toBeEnabled(); + await expect(reset).toBeDisabled(); +}); + +test("the cut-and-project sketch renders and links its two panels on hover", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // The two-panel cut-and-project figure: a real patch in physical space and the + // bounded shadow window in internal space. It is static (no clock), so it has no + // control bar; the teaching link is hover. + const figure = page + .locator("figure") + .filter({ has: page.getByRole("img", { name: /two linked panels/i }) }); + const svg = figure.getByRole("img", { name: /two linked panels/i }); + await svg.scrollIntoViewIfNeeded(); + await expect(svg).toBeVisible(); + + // The static frame already names a seed tile's address. Hovering a different + // tile updates the live caption to its ℤ⁵ coordinate. + await expect( + figure.getByText(/the shadow of lattice point/i), + ).toBeVisible(); + const tile = svg.locator("polygon").first(); + await tile.hover(); + await expect( + figure.getByText(/four corners.*shadows all land inside the window/i), + ).toBeVisible(); +}); + +const goldenFigure = (page: import("@playwright/test").Page) => + page.locator("figure").filter({ + has: page.getByRole("img", { + name: /running count of thick to thin tiles/i, + }), + }); + +test("the golden-ratio sketch mounts its animated canvas, level slider, and count readout", async ({ + page, +}) => { + await page.goto("/x/penrose"); + const figure = goldenFigure(page); + await expect( + figure.getByRole("img", { name: /running count of thick to thin tiles/i }), + ).toBeVisible(); + await expect( + figure.getByRole("button", { name: "play", exact: true }), + ).toBeVisible(); + await expect(figure.getByRole("button", { name: "step" })).toBeVisible(); + await expect(figure.getByRole("button", { name: "reset" })).toBeVisible(); + // The level is the slider; the readout shows the running thick/thin ratio. + await expect(figure.getByRole("slider", { name: "level" })).toBeVisible(); + await expect(figure.getByText(/thick ÷ thin/i)).toBeVisible(); +}); + +test("the golden-ratio sketch loads at its stationary deepest level", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // Same harness contract: mounts at t = 1 (the deepest level, ratio nearest phi), + // so reset is enabled and step is disabled until the viewer rewinds. + const figure = goldenFigure(page); + const reset = figure.getByRole("button", { name: "reset" }); + const step = figure.getByRole("button", { name: "step" }); + await reset.scrollIntoViewIfNeeded(); + await expect(reset).toBeEnabled(); + await expect(step).toBeDisabled(); + await reset.click(); + await expect(step).toBeEnabled(); + await expect(reset).toBeDisabled(); +}); + +const hierarchyFigure = (page: import("@playwright/test").Page) => + page.locator("figure").filter({ + has: page.getByRole("img", { + name: /supertiles the small rhombi compose into/i, + }), + }); + +test("the zoom-hierarchy sketch mounts its animated canvas and depth slider", async ({ + page, +}) => { + await page.goto("/x/penrose"); + const figure = hierarchyFigure(page); + await expect( + figure.getByRole("img", { name: /supertiles the small rhombi compose into/i }), + ).toBeVisible(); + await expect( + figure.getByRole("button", { name: "play", exact: true }), + ).toBeVisible(); + await expect(figure.getByRole("button", { name: "step" })).toBeVisible(); + await expect(figure.getByRole("button", { name: "reset" })).toBeVisible(); + // The depth is the slider; the readout names the supertiles. + await expect(figure.getByRole("slider", { name: "depth" })).toBeVisible(); + await expect(figure.getByText(/supertiles/i).first()).toBeVisible(); +}); + +test("the zoom-hierarchy sketch loads at its stationary deepest depth", async ({ + page, +}) => { + await page.goto("/x/penrose"); + // Same harness contract: mounts at t = 1 (the deepest depth, where the self- + // similarity reads hardest), so reset is enabled and step is disabled. + const figure = hierarchyFigure(page); + const reset = figure.getByRole("button", { name: "reset" }); + const step = figure.getByRole("button", { name: "step" }); + await reset.scrollIntoViewIfNeeded(); + await expect(reset).toBeEnabled(); + await expect(step).toBeDisabled(); + await reset.click(); + await expect(step).toBeEnabled(); + await expect(reset).toBeDisabled(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..e1bda27 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Research scripts have their own lifecycle. Run them with bun, not lint. + "research/**", ]), ]); diff --git a/research/penrose/01-coord-representation.md b/research/penrose/01-coord-representation.md new file mode 100644 index 0000000..80248d4 --- /dev/null +++ b/research/penrose/01-coord-representation.md @@ -0,0 +1,70 @@ +# 01 — Coord representation + +**Question.** Where does Float64 disagree with a high-precision oracle for pentagrid `pointToCoord`, and what does the oracle cost? + +**Decision.** Ship `Coord = readonly [bigint, bigint, bigint, bigint, bigint]` with BigInt-exact math for hover and URL. The address layer stays exact at any size. Per-frame rendering may fall back to a Float64 viewport-anchor pattern if `03` confirms the budget forces it; that's a render-only optimization, not an addressing compromise. + +## Method + +Two implementations of `pointToCoord`, called on the same input: + +- **Exact.** BigInt arithmetic. Constants computed algebraically inside the script: + - `√5` via `bigintSqrt(5 · SCALE²)` + - `cos(2πj/5) = (±√5 ± 1) / 4` + - `sin(2πj/5) = √(10 ± 2√5) / 4` (via `bigintSqrt` again) + - `SCALE = 10⁵⁰`. The floor of the projection is provably correct for magnitudes well below the SCALE ceiling. +- **Float64.** Straight `Math.floor(px · cos + py · sin + γ)`. + +For each magnitude R, sample 1000 random points. The point is generated as a BigInt (the canonical "intended" position); both implementations receive equivalent inputs — Float64 gets the cast version. Compare the resulting 5-tuples coord-by-coord. Any element-wise mismatch counts as disagreement. + +Seed `funclol`. Script: [`01-coord-representation.ts`](./01-coord-representation.ts). + +## Numbers + +``` +|p| agree disagree +0 100.0% 0.0% +1e+3 100.0% 0.0% +1e+6 100.0% 0.0% +1e+9 100.0% 0.0% +1e+12 99.8% 0.2% <- first disagreement +1e+13 99.0% 1.0% +1e+14 86.4% 13.6% +1e+15 55.9% 44.1% +1e+18 0.0% 100.0% +1e+24 0.0% 100.0% +1e+30 0.0% 100.0% +1e+40 0.0% 100.0% +``` + +Throughput, sampled at |p|=10⁶, 50,000 calls: + +``` +exact: 2.58 µs/call +float64: 0.13 µs/call (~20× faster) +``` + +## Interpretation + +- Float64 first disagrees at |p|=10¹² (0.2%). Float64 ULP at 10¹² is ≈ 2.2e-4; tile widths are O(1); a 2e-4 absolute error puts ≈0.04% of points within ε of a tile boundary, which roughly matches the observed rate. +- The rate climbs steeply: by 10¹⁴ (ULP ≈ 2.2e-2) it's 14%; by 10¹⁵ (ULP ≈ 0.22) it's 44%; by 10¹⁸ it's complete. +- Exact stays correct across the full tested range. +- Exact is ~20× slower per call. Absolute cost is 2.58 µs, invisible in human-paced contexts (hover, URL share). + +The earlier script's "no flicker up to 10¹²" result was a methodology artifact: an ε=1e-9 displacement vanishes into Float64 quantization at high R, so identical inputs trivially produced identical outputs. The new comparison against an oracle exposes the real failure boundary. + +## Implications for the shipped explorer + +- `Coord = readonly [bigint, bigint, bigint, bigint, bigint]`. No Float64 in the address layer. +- `pointToCoord` uses the exact implementation for the hover readout and URL state. Per-event cost is in the µs range — not a concern. +- Per-frame `enumerateTilesInRect`: not yet tested in BigInt. The Float64 version runs in 1.59 ms mean at 1315 tiles ([`03-enumeration-cost.md`](./03-enumeration-cost.md)); a naive BigInt version is ~20× slower (≈30 ms), outside the 16 ms frame budget. A viewport-anchor pattern (BigInt anchor + Float64 offsets, periodic re-anchor) preserves rendering correctness at any anchor magnitude while keeping the hot path on Float64. Q3 needs a follow-up benchmark before committing. +- URL coord codec must accept BigInt. Q2 needs a follow-up with BigInt base62. + +## Caveats + +- The "exact" oracle here has a precision ceiling near magnitude 10⁴⁹ (where the BigInt projection error reaches the integer floor threshold given `SCALE = 10⁵⁰`). Beyond that the oracle itself drifts. Adaptive `SCALE` (grow with the magnitude under test) would push the ceiling arbitrarily high. Not implemented; not needed for any reach a user can pan to in a lifetime. +- The cyclotomic ring ℤ[ζ₅] is the genuinely-unbounded substrate (no `SCALE` at all — elements are 4-tuples of BigInts, addition / multiplication are exact, comparison reduces to sign-determination on `a + b√5`). Worth revisiting if "jump to an explicit coord of magnitude 10¹⁰⁰" becomes a real feature, rather than a theoretical bound. + +## Citation + +`lib/pentagrid.ts` declares `Coord = readonly [bigint, bigint, bigint, bigint, bigint]`. The exact `pointToCoord` uses the algebraic constants computed in this script (or a refined cyclotomic-ring version). Any Float64 fast path is render-only and lives behind a viewport-anchor abstraction, citing this writeup as the justification for the anchor scheme. diff --git a/research/penrose/01-coord-representation.ts b/research/penrose/01-coord-representation.ts new file mode 100644 index 0000000..03d54a6 --- /dev/null +++ b/research/penrose/01-coord-representation.ts @@ -0,0 +1,215 @@ +// research/penrose/01-coord-representation.ts +// +// Q: Where does Float64 disagree with a high-precision oracle for the +// pentagrid pointToCoord operation, and what does the oracle cost? +// +// Method: implement pointToCoord twice. +// +// Exact — BigInt arithmetic. Constants computed algebraically from +// √5 via BigInt sqrt, scaled to ~50 decimal digits. The +// scale is large enough that floor() of the projection is +// provably correct for any magnitude tested below. +// +// Float64 — straight Float64. The candidate cheap implementation. +// +// At each magnitude R, sample N random points (generated as BigInt so +// both implementations see the same intended position; Float64 receives +// the Float64-cast version). Compare the two 5-tuples coord-by-coord. +// +// First disagreement = the precision-drift boundary. Throughput numbers +// at the bottom price the difference. +// +// Decision input: ship `Coord = readonly [bigint, ...]` with exact math +// for correctness at any size. Use Float64 only as a per-frame optimization +// if the perf budget forces a viewport-anchor compromise. +// +// Run: bun run research/penrose/01-coord-representation.ts + +const SCALE = 10n ** 50n; +const SCALE_F = Number(SCALE); + +function bigintSqrt(n: bigint): bigint { + if (n < 0n) throw new Error("negative"); + if (n < 2n) return n; + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} + +function bigintFloorDiv(n: bigint, d: bigint): bigint { + if (d <= 0n) throw new Error("d must be positive"); + if (n >= 0n) return n / d; + const q = n / d; + return n % d === 0n ? q : q - 1n; +} + +// Algebraic constants at scale SCALE. +// cos(2π/5) = (√5 - 1) / 4 +// cos(4π/5) = -(√5 + 1) / 4 +// sin(2π/5) = √(10 + 2√5) / 4 +// sin(4π/5) = √(10 - 2√5) / 4 +const SQRT5 = bigintSqrt(5n * SCALE * SCALE); +const T_PLUS = 10n * SCALE + 2n * SQRT5; +const T_MINUS = 10n * SCALE - 2n * SQRT5; +const SQRT_T_PLUS = bigintSqrt(T_PLUS * SCALE); +const SQRT_T_MINUS = bigintSqrt(T_MINUS * SCALE); + +const COS_HI: readonly bigint[] = [ + SCALE, + bigintFloorDiv(SQRT5 - SCALE, 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(SQRT5 - SCALE, 4n), +]; + +const SIN_HI: readonly bigint[] = [ + 0n, + bigintFloorDiv(SQRT_T_PLUS, 4n), + bigintFloorDiv(SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_PLUS, 4n), +]; + +const COS_F: readonly number[] = COS_HI.map((c) => Number(c) / SCALE_F); +const SIN_F: readonly number[] = SIN_HI.map((s) => Number(s) / SCALE_F); + +// pointToCoord — exact. pxBig, pyBig at scale SCALE (i.e., the real +// coordinate × SCALE). gammaBig at scale SCALE. +function pointToCoordExact(pxBig: bigint, pyBig: bigint, gammaBig: readonly bigint[]): readonly bigint[] { + const SCALE2 = SCALE * SCALE; + const out: bigint[] = new Array(5); + for (let j = 0; j < 5; j++) { + const proj = pxBig * COS_HI[j] + pyBig * SIN_HI[j] + gammaBig[j] * SCALE; + out[j] = bigintFloorDiv(proj, SCALE2); + } + return out; +} + +// pointToCoord — Float64. +function pointToCoordFloat(px: number, py: number, gamma: readonly number[]): readonly number[] { + const out: number[] = new Array(5); + for (let j = 0; j < 5; j++) { + out[j] = Math.floor(px * COS_F[j] + py * SIN_F[j] + gamma[j]); + } + return out; +} + +// gamma: derive BigInt natively from a deterministic hash, then cast to +// Float64. Both implementations get equivalent inputs; only Float64's +// computation has precision loss. +function gammaFromSeed(seed: string): { exact: readonly bigint[]; float: readonly number[] } { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + const raw: bigint[] = []; + for (let i = 0; i < 5; i++) { + h = Math.imul(h ^ (i + 1), 16777619) >>> 0; + // h is in [0, 2^32). Map to [-SCALE/2, SCALE/2). + raw.push((BigInt(h) * SCALE) / (1n << 32n) - SCALE / 2n); + } + const sum = raw.reduce((a, b) => a + b, 0n); + const exact = raw.map((g) => g - sum / 5n); + const float = exact.map((g) => Number(g) / SCALE_F); + return { exact, float }; +} + +// Random point at magnitude approximately magBig. +function randomPoint(magBig: bigint): { pxBig: bigint; pyBig: bigint; pxF: number; pyF: number } { + const theta = Math.random() * 2 * Math.PI; + const DIR_SCALE = 10n ** 15n; + const dirCos = BigInt(Math.round(Math.cos(theta) * 1e15)); + const dirSin = BigInt(Math.round(Math.sin(theta) * 1e15)); + // px = magBig · dirCos · SCALE / DIR_SCALE. + const pxBig = (magBig * dirCos * SCALE) / DIR_SCALE; + const pyBig = (magBig * dirSin * SCALE) / DIR_SCALE; + const pxF = Number(pxBig) / SCALE_F; + const pyF = Number(pyBig) / SCALE_F; + return { pxBig, pyBig, pxF, pyF }; +} + +const seed = "funclol"; +const { exact: gammaE, float: gammaF } = gammaFromSeed(seed); + +const MAGNITUDES: { label: string; mag: bigint }[] = [ + { label: "0", mag: 0n }, + { label: "1e+3", mag: 10n ** 3n }, + { label: "1e+6", mag: 10n ** 6n }, + { label: "1e+9", mag: 10n ** 9n }, + { label: "1e+12", mag: 10n ** 12n }, + { label: "1e+13", mag: 10n ** 13n }, + { label: "1e+14", mag: 10n ** 14n }, + { label: "1e+15", mag: 10n ** 15n }, + { label: "1e+18", mag: 10n ** 18n }, + { label: "1e+24", mag: 10n ** 24n }, + { label: "1e+30", mag: 10n ** 30n }, + { label: "1e+40", mag: 10n ** 40n }, +]; + +const SAMPLES = 1000; + +console.log(`seed=${seed} samples=${SAMPLES} oracle scale=10^50\n`); +console.log("|p| agree disagree first disagreement"); +console.log("-------- -------- -------- ------------------"); + +const disagreements: { mag: string; rate: number }[] = []; +for (const { label, mag } of MAGNITUDES) { + let agree = 0; + let disagree = 0; + let example = ""; + for (let i = 0; i < SAMPLES; i++) { + const { pxBig, pyBig, pxF, pyF } = randomPoint(mag); + const coordE = pointToCoordExact(pxBig, pyBig, gammaE); + const coordF = pointToCoordFloat(pxF, pyF, gammaF); + let same = true; + for (let j = 0; j < 5; j++) { + const f = coordF[j]; + if (!Number.isFinite(f) || Math.abs(f) > Number.MAX_SAFE_INTEGER || BigInt(f) !== coordE[j]) { + same = false; + break; + } + } + if (same) agree++; + else { + disagree++; + if (example === "") { + example = `exact=[${coordE.join(",")}] float=[${coordF.join(",")}]`; + } + } + } + const rate = disagree / SAMPLES; + disagreements.push({ mag: label, rate }); + const ag = `${((agree / SAMPLES) * 100).toFixed(1)}%`.padEnd(8); + const dis = `${(rate * 100).toFixed(1)}%`.padEnd(8); + console.log(`${label.padEnd(8)} ${ag} ${dis} ${example.slice(0, 70)}`); +} + +const firstBad = disagreements.find((d) => d.rate > 0); +console.log(""); +console.log( + firstBad + ? `first disagreement at |p|=${firstBad.mag} (rate ${(firstBad.rate * 100).toFixed(1)}%)` + : "no disagreement across tested range", +); + +// Throughput. +console.log(""); +const BENCH_N = 50_000; +const bp = randomPoint(10n ** 6n); +{ + const t0 = performance.now(); + for (let i = 0; i < BENCH_N; i++) pointToCoordExact(bp.pxBig, bp.pyBig, gammaE); + const dt = performance.now() - t0; + console.log(`exact: ${((dt / BENCH_N) * 1000).toFixed(2)} µs/call (${BENCH_N} calls in ${dt.toFixed(0)} ms)`); +} +{ + const t0 = performance.now(); + for (let i = 0; i < BENCH_N; i++) pointToCoordFloat(bp.pxF, bp.pyF, gammaF); + const dt = performance.now() - t0; + console.log(`float64: ${((dt / BENCH_N) * 1000).toFixed(2)} µs/call (${BENCH_N} calls in ${dt.toFixed(0)} ms)`); +} diff --git a/research/penrose/02-url-encoding.md b/research/penrose/02-url-encoding.md new file mode 100644 index 0000000..bb4f30b --- /dev/null +++ b/research/penrose/02-url-encoding.md @@ -0,0 +1,54 @@ +# 02 — URL coord encoding + +**Question.** Compare base62 (prime-moments precedent) against alternatives for the share-link state codec. + +**Status.** Needs rerun. [`01-coord-representation.md`](./01-coord-representation.md) settled on `Coord = readonly [bigint, ...]`, so the URL must encode arbitrary BigInts, not 32-bit fixed-point. The numbers below test the int32 case and are kept as a baseline for the bigint-aware version. + +**Provisional decision.** Keep base62 in spirit; switch from fixed-width int32 to variable-length BigInt-base62. The base62 alphabet still gives ~5.95 bits/char, which is within a few percent of base64url's 6 bits/char, and base62 stays URL-safe without padding. + +## Method + +1000 random viewport states, sampled from the explorer's plausible reach: + +- `cx, cy ~ uniform in [-1e6, 1e6]` +- `zoom ~ log-uniform in [1, 1000]` +- `level ~ uniform int in [-2, 3]` + +State packs four nonneg int32s: cx, cy at 1e-3 precision (sub-pixel at every plausible zoom; fits int32 via `+2^31` offset); zoom at 1e-2; level offset `+10`. Four encodings tested: + +- `base62` — each int base62-encoded, dot-joined. +- `b64url` — packed little-endian into a 16-byte buffer, base64url-encoded. +- `hex` — each int hex-encoded, dot-joined. +- `json` — `encodeURIComponent(JSON.stringify({cx, cy, z, l}))`. + +[`02-url-encoding.ts`](./02-url-encoding.ts). + +## Numbers + +``` +encoding min mean p95 max example +base62 18 18.5 19 19 2lkFOa.2lhjl2.16y.b +b64url 22 22.0 22 22 OjAAgOz183-aEAAACwAAAA +hex 22 23.4 25 25 8000303a.7ff3f5ec.109a.b +json 77 83.7 86 87 %7B%22cx%22%3A12.346… +``` + +Example state: `cx=12.346, cy=-789.012, zoom=42.5, level=1`. + +## Interpretation + +- base62 wins by 3-4 chars over b64url at p95. The gap widens at the small-state end of the distribution because base62 is variable-width. +- b64url's 16-byte fixed buffer pays alignment overhead on small ints. +- hex is uniformly longer; json is 4× longer once urlencoded. + +## Citation + +The shipped `lib/encoding.ts` mirrors `src/app/x/prime-moments/lib/encoding.ts` — four nonneg int32s (cx, cy via `+2^31` offset; level via `+10` offset), base62-encoded, dot-joined. + +Final URL form: + +``` +/x/penrose/explore?s=&v=..&l= +``` + +Decode failures fall back to defaults silently. diff --git a/research/penrose/02-url-encoding.ts b/research/penrose/02-url-encoding.ts new file mode 100644 index 0000000..9b50cac --- /dev/null +++ b/research/penrose/02-url-encoding.ts @@ -0,0 +1,131 @@ +// research/penrose/02-url-encoding.ts +// +// Q: For typical (cx, cy, zoom, level) viewport states, how long is the +// URL share-link under different encodings? +// +// Precision choices (justified separately): cx, cy in world units to +// 1e-3 (sub-pixel at every plausible zoom); zoom to 1e-2; level int. +// At 1e-3 precision, cx, cy as ints fit in int32 across |p| ≤ 2e6. +// +// Encodings: +// base62 prime-moments pattern: 4 nonneg ints (signed via +2^31 +// offset), each base62-encoded, joined with '.'. +// b64url same 4 ints packed little-endian into a 16-byte buffer, +// base64url-encoded. +// hex same 4 ints in hex, joined with '.'. +// json encodeURIComponent(JSON.stringify({...})). +// +// 1000 random states sampled from the explorer's plausible reach: +// cx, cy ~ uniform in [-1e6, 1e6] +// zoom ~ log-uniform in [1, 1000] +// level ~ uniform int in [-2, 3] +// +// Run: bun run research/penrose/02-url-encoding.ts + +const B62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function toBase62(n: number): string { + if (n === 0) return "0"; + const neg = n < 0; + let x = Math.abs(Math.trunc(n)); + let s = ""; + while (x > 0) { + s = B62[x % 62] + s; + x = Math.floor(x / 62); + } + return neg ? "-" + s : s; +} + +const OFFSET = 2 ** 31; + +function asFixedInts(cx: number, cy: number, zoom: number, level: number) { + return { + cxI: Math.round(cx * 1e3) + OFFSET, + cyI: Math.round(cy * 1e3) + OFFSET, + zI: Math.round(zoom * 1e2), + lI: level + 10, + }; +} + +function encodeBase62(cx: number, cy: number, zoom: number, level: number): string { + const { cxI, cyI, zI, lI } = asFixedInts(cx, cy, zoom, level); + return `${toBase62(cxI)}.${toBase62(cyI)}.${toBase62(zI)}.${toBase62(lI)}`; +} + +function encodeBase64url(cx: number, cy: number, zoom: number, level: number): string { + const { cxI, cyI, zI, lI } = asFixedInts(cx, cy, zoom, level); + const buf = Buffer.alloc(16); + buf.writeUInt32LE(cxI, 0); + buf.writeUInt32LE(cyI, 4); + buf.writeUInt32LE(zI, 8); + buf.writeUInt32LE(lI, 12); + return buf.toString("base64url"); +} + +function encodeHex(cx: number, cy: number, zoom: number, level: number): string { + const { cxI, cyI, zI, lI } = asFixedInts(cx, cy, zoom, level); + return `${cxI.toString(16)}.${cyI.toString(16)}.${zI.toString(16)}.${lI.toString(16)}`; +} + +function encodeJson(cx: number, cy: number, zoom: number, level: number): string { + return encodeURIComponent( + JSON.stringify({ + cx: +cx.toFixed(3), + cy: +cy.toFixed(3), + z: +zoom.toFixed(2), + l: level, + }), + ); +} + +const ENCODINGS: Record string> = { + base62: encodeBase62, + b64url: encodeBase64url, + hex: encodeHex, + json: encodeJson, +}; + +const N = 1000; +const lens: Record = { base62: [], b64url: [], hex: [], json: [] }; + +for (let i = 0; i < N; i++) { + const cx = (Math.random() * 2 - 1) * 1e6; + const cy = (Math.random() * 2 - 1) * 1e6; + const zoom = Math.exp(Math.random() * Math.log(1000)); + const level = Math.floor(Math.random() * 6) - 2; + for (const [k, fn] of Object.entries(ENCODINGS)) { + lens[k].push(fn(cx, cy, zoom, level).length); + } +} + +function summarize(xs: number[]) { + const sorted = [...xs].sort((a, b) => a - b); + const mean = xs.reduce((a, b) => a + b, 0) / xs.length; + const p95 = sorted[Math.floor(0.95 * sorted.length)]; + const max = sorted[sorted.length - 1]; + const min = sorted[0]; + return { min, mean, p95, max }; +} + +console.log(`samples=${N} cx,cy∈[-1e6,1e6] zoom∈[1,1000] level∈[-2,3]\n`); +console.log("encoding min mean p95 max example"); +console.log("-------- ---- ---- ---- ---- --------"); +const example = { cx: 12.3456, cy: -789.0123, zoom: 42.5, level: 1 }; +for (const k of Object.keys(ENCODINGS)) { + const s = summarize(lens[k]); + const ex = ENCODINGS[k](example.cx, example.cy, example.zoom, example.level); + console.log( + `${k.padEnd(8)} ${String(s.min).padEnd(4)} ${s.mean.toFixed(1).padEnd(5)} ${String(s.p95).padEnd(4)} ${String(s.max).padEnd(4)} ${ex}`, + ); +} + +// The base62 entry is the prime-moments precedent. Decision criterion: +// keep base62 unless something is >10% shorter at p95. +const b62 = summarize(lens.base62); +const winners = Object.entries(lens) + .map(([k, xs]) => ({ k, s: summarize(xs) })) + .filter((e) => e.k !== "base62" && e.s.p95 < b62.p95 * 0.9); +const summary = winners.length === 0 + ? `base62 is within 10% of every alternative at p95 — keep the prime-moments codec` + : `${winners.map((w) => w.k).join(", ")} beats base62 by >10% at p95`; +console.log(`\nsummary: ${summary}`); diff --git a/research/penrose/03-enumeration-cost.md b/research/penrose/03-enumeration-cost.md new file mode 100644 index 0000000..690b626 --- /dev/null +++ b/research/penrose/03-enumeration-cost.md @@ -0,0 +1,44 @@ +# 03 — Enumeration cost + +**Question.** How fast is `enumerateTilesInRect` in Float64 vs in BigInt-exact, and what does the gap imply for the explorer's per-frame budget? + +## Method + +A self-contained pentagrid enumerator, twice. Both implementations iterate the same 10 direction-pairs, compute the same line-index bounds, solve for the same intersection vertices, and produce the same 5-tuples. The only difference is the arithmetic backend: one in Float64, one in BigInt with the algebraic constants from [`01-coord-representation.ts`](./01-coord-representation.ts) at `SCALE = 10⁵⁰`. + +Bun on the maintainer's machine. 3 warmup iterations per implementation, 50 timed iterations per row. Seed `funclol`. Script: [`03-enumeration-cost.ts`](./03-enumeration-cost.ts). + +## Numbers + +``` +size rect tiles float64 mean float64 p95 exact mean exact p95 ratio +small 12×8 381 0.84ms 4.73ms 9.97ms 13.36ms 11.9× +medium 24×14 1315 1.89ms 3.11ms 29.05ms 35.64ms 15.4× +large 36×22 3092 2.65ms 3.78ms 56.88ms 69.50ms 21.4× +x-large 48×30 5583 5.04ms 6.90ms 83.57ms 96.58ms 16.6× +``` + +Per-tile cost at the budget target (1315 tiles): Float64 1.44 µs/tile, exact 22.1 µs/tile. The 20.7 µs/tile gap is the perf tax for BigInt arithmetic in the hot path. + +## Interpretation + +- Float64 fits the 16 ms frame budget at any rect size tested. Mean stays well under 6 ms even at 5500 tiles. +- BigInt-exact misses the 16 ms budget starting at the medium rect (~1300 tiles → 29 ms mean, ~35 ms p95). At typical zoom levels for the explorer, exact-throughout panning runs at ~33 fps, not 60. +- BigInt-exact stays under a 33 ms / 30 fps budget through ~1500 tiles, then drops below 30 fps for denser viewports. +- Hover and URL paths are unaffected — they're called once per event in absolute µs, not per-frame. + +## Implications for the shipped explorer + +Three viable shapes for the addressing-and-render pipeline: + +1. **Exact throughout.** Simplest. Address and render both in BigInt. Panning runs at ~33 fps for medium viewports, drops below 30 fps for dense ones. Acceptable for a contemplative explorer; not great for a tactile pan / zoom feel. +2. **Viewport-anchor hybrid.** Address layer in BigInt (correct at any size). Render in Float64 relative to a BigInt anchor, re-anchored when offsets grow past a precision threshold (e.g., 1e8). Hot path stays at Float64 speed (60 fps), addressing stays exact. Extra ~150 lines of anchor management. +3. **Bounded-precision BigInt.** Same shape as exact-throughout, but with `SCALE = 10²⁰` instead of `10⁵⁰`. BigInt mul becomes ~4× cheaper (smaller limb count). Projected ~7 ms at 1500 tiles, inside the 16 ms budget. Correctness ceiling drops from 10⁴⁹ to ~10¹⁹ — still vastly past any reachable position. Not literally infinite though. + +The viewport-anchor pattern (2) is the canonical infinite-canvas approach and keeps both correctness and frame-rate. The bounded-precision option (3) is a smaller change and worth measuring before committing if simpler-throughout is preferred over literally-infinite. + +## Citation + +`lib/pentagrid.ts` `enumerateTilesInRect` uses pattern (2) or (3) depending on the maintainer's call. Either way, `Coord = readonly [bigint, ...]` (per [`01-coord-representation.md`](./01-coord-representation.md)) so the addressing layer is exact regardless of the render path. + +If profiling later shows the dedup hash dominating in either backend, swap `Set` → packed-key `Set` (encode the 5-tuple into 53 bits when coords are small). diff --git a/research/penrose/03-enumeration-cost.ts b/research/penrose/03-enumeration-cost.ts new file mode 100644 index 0000000..954c673 --- /dev/null +++ b/research/penrose/03-enumeration-cost.ts @@ -0,0 +1,253 @@ +// research/penrose/03-enumeration-cost.ts +// +// Q: How fast is enumerateTilesInRect for the de Bruijn pentagrid +// construction, in Float64 vs in BigInt-exact? +// +// Float64 sets the lower bound on per-frame cost. BigInt-exact (matching +// 01-coord-representation's high-precision oracle) sets the ceiling. +// The gap between them is the perf tax for "100% correctness at any size" +// in the addressing layer, which decides whether the explorer can stay +// fully exact or needs a viewport-anchor pattern in the render path. +// +// Method: a self-contained pentagrid enumerator, twice. For each pair +// (j, k) of grid directions, enumerate integer line indices (kj, kk) +// whose intersection vertex falls in the rect. Each vertex corresponds +// to a P3 rhombus; the tile's pentagrid coord is the 5-tuple of floors +// at the vertex. Both implementations use the same seed, same gamma, +// same rect; only the arithmetic backend differs. +// +// Run multiple rect sizes targeting ~500, ~1500, ~3000 tiles. Report +// mean and p95 ms over 50 timed runs for each implementation. +// +// Run: bun run research/penrose/03-enumeration-cost.ts + +const SCALE = 10n ** 50n; +const SCALE_F = Number(SCALE); +const SCALE2 = SCALE * SCALE; + +function bigintSqrt(n: bigint): bigint { + if (n < 0n) throw new Error("negative"); + if (n < 2n) return n; + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} + +function bigintFloorDiv(n: bigint, d: bigint): bigint { + if (d === 0n) throw new Error("div by zero"); + if (d < 0n) { + n = -n; + d = -d; + } + if (n >= 0n) return n / d; + return n % d === 0n ? n / d : n / d - 1n; +} + +const SQRT5 = bigintSqrt(5n * SCALE * SCALE); +const T_PLUS = 10n * SCALE + 2n * SQRT5; +const T_MINUS = 10n * SCALE - 2n * SQRT5; +const SQRT_T_PLUS = bigintSqrt(T_PLUS * SCALE); +const SQRT_T_MINUS = bigintSqrt(T_MINUS * SCALE); + +const COS_HI: readonly bigint[] = [ + SCALE, + bigintFloorDiv(SQRT5 - SCALE, 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(SQRT5 - SCALE, 4n), +]; +const SIN_HI: readonly bigint[] = [ + 0n, + bigintFloorDiv(SQRT_T_PLUS, 4n), + bigintFloorDiv(SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_PLUS, 4n), +]; + +const COS_F: readonly number[] = COS_HI.map((c) => Number(c) / SCALE_F); +const SIN_F: readonly number[] = SIN_HI.map((s) => Number(s) / SCALE_F); + +function gammaFromSeed(seed: string): { exact: readonly bigint[]; float: readonly number[] } { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + const raw: bigint[] = []; + for (let i = 0; i < 5; i++) { + h = Math.imul(h ^ (i + 1), 16777619) >>> 0; + raw.push((BigInt(h) * SCALE) / (1n << 32n) - SCALE / 2n); + } + const sum = raw.reduce((a, b) => a + b, 0n); + const exact = raw.map((g) => g - sum / 5n); + const float = exact.map((g) => Number(g) / SCALE_F); + return { exact, float }; +} + +type Rect = { x0: number; y0: number; x1: number; y1: number }; + +function enumerateTilesFloat(gamma: readonly number[], rect: Rect): number { + const seen = new Set(); + for (let j = 0; j < 4; j++) { + for (let k = j + 1; k < 5; k++) { + const ejx = COS_F[j], ejy = SIN_F[j]; + const ekx = COS_F[k], eky = SIN_F[k]; + const det = ejx * eky - ejy * ekx; + if (Math.abs(det) < 1e-12) continue; + const invDet = 1 / det; + const pj0 = rect.x0 * ejx + rect.y0 * ejy; + const pj1 = rect.x1 * ejx + rect.y0 * ejy; + const pj2 = rect.x0 * ejx + rect.y1 * ejy; + const pj3 = rect.x1 * ejx + rect.y1 * ejy; + const pk0 = rect.x0 * ekx + rect.y0 * eky; + const pk1 = rect.x1 * ekx + rect.y0 * eky; + const pk2 = rect.x0 * ekx + rect.y1 * eky; + const pk3 = rect.x1 * ekx + rect.y1 * eky; + const kjMin = Math.floor(Math.min(pj0, pj1, pj2, pj3) + gamma[j]) - 1; + const kjMax = Math.ceil(Math.max(pj0, pj1, pj2, pj3) + gamma[j]) + 1; + const kkMin = Math.floor(Math.min(pk0, pk1, pk2, pk3) + gamma[k]) - 1; + const kkMax = Math.ceil(Math.max(pk0, pk1, pk2, pk3) + gamma[k]) + 1; + for (let kj = kjMin; kj <= kjMax; kj++) { + const aj = kj - gamma[j]; + for (let kk = kkMin; kk <= kkMax; kk++) { + const ak = kk - gamma[k]; + const px = (eky * aj - ejy * ak) * invDet; + const py = (-ekx * aj + ejx * ak) * invDet; + if (px < rect.x0 || px > rect.x1 || py < rect.y0 || py > rect.y1) continue; + const t0 = j === 0 ? kj : k === 0 ? kk : Math.floor(px * COS_F[0] + py * SIN_F[0] + gamma[0]); + const t1 = j === 1 ? kj : k === 1 ? kk : Math.floor(px * COS_F[1] + py * SIN_F[1] + gamma[1]); + const t2 = j === 2 ? kj : k === 2 ? kk : Math.floor(px * COS_F[2] + py * SIN_F[2] + gamma[2]); + const t3 = j === 3 ? kj : k === 3 ? kk : Math.floor(px * COS_F[3] + py * SIN_F[3] + gamma[3]); + const t4 = j === 4 ? kj : k === 4 ? kk : Math.floor(px * COS_F[4] + py * SIN_F[4] + gamma[4]); + seen.add(`${t0},${t1},${t2},${t3},${t4}`); + } + } + } + } + return seen.size; +} + +function enumerateTilesExact(gammaBig: readonly bigint[], rect: Rect): number { + const seen = new Set(); + // Use Float64 to compute integer line-index bounds; the bounds only + // need to over-cover the rect by 1, so approximate ranging is fine. + // The actual vertex math runs in BigInt. + const gammaF = gammaBig.map((g) => Number(g) / SCALE_F); + for (let j = 0; j < 4; j++) { + for (let k = j + 1; k < 5; k++) { + const ejx = COS_HI[j], ejy = SIN_HI[j]; + const ekx = COS_HI[k], eky = SIN_HI[k]; + const det = ejx * eky - ejy * ekx; + if (det === 0n) continue; + const ejxF = COS_F[j], ejyF = SIN_F[j], ekxF = COS_F[k], ekyF = SIN_F[k]; + const pj0 = rect.x0 * ejxF + rect.y0 * ejyF; + const pj1 = rect.x1 * ejxF + rect.y0 * ejyF; + const pj2 = rect.x0 * ejxF + rect.y1 * ejyF; + const pj3 = rect.x1 * ejxF + rect.y1 * ejyF; + const pk0 = rect.x0 * ekxF + rect.y0 * ekyF; + const pk1 = rect.x1 * ekxF + rect.y0 * ekyF; + const pk2 = rect.x0 * ekxF + rect.y1 * ekyF; + const pk3 = rect.x1 * ekxF + rect.y1 * ekyF; + const kjMin = Math.floor(Math.min(pj0, pj1, pj2, pj3) + gammaF[j]) - 1; + const kjMax = Math.ceil(Math.max(pj0, pj1, pj2, pj3) + gammaF[j]) + 1; + const kkMin = Math.floor(Math.min(pk0, pk1, pk2, pk3) + gammaF[k]) - 1; + const kkMax = Math.ceil(Math.max(pk0, pk1, pk2, pk3) + gammaF[k]) + 1; + const rectX0Big = BigInt(rect.x0) * SCALE; + const rectY0Big = BigInt(rect.y0) * SCALE; + const rectX1Big = BigInt(rect.x1) * SCALE; + const rectY1Big = BigInt(rect.y1) * SCALE; + for (let kjN = kjMin; kjN <= kjMax; kjN++) { + const kj = BigInt(kjN); + const aj = kj * SCALE - gammaBig[j]; + for (let kkN = kkMin; kkN <= kkMax; kkN++) { + const kk = BigInt(kkN); + const ak = kk * SCALE - gammaBig[k]; + // Solve [e_j; e_k] · p = (aj, ak). p is at scale SCALE. + // px = (eky·aj - ejy·ak) · SCALE / det + // py = (ejx·ak - ekx·aj) · SCALE / det + const pxNum = (eky * aj - ejy * ak) * SCALE; + const pyNum = (ejx * ak - ekx * aj) * SCALE; + const px = bigintFloorDiv(pxNum, det); + const py = bigintFloorDiv(pyNum, det); + if (px < rectX0Big || px > rectX1Big || py < rectY0Big || py > rectY1Big) continue; + const tup: bigint[] = new Array(5); + for (let l = 0; l < 5; l++) { + if (l === j) tup[l] = kj; + else if (l === k) tup[l] = kk; + else tup[l] = bigintFloorDiv(px * COS_HI[l] + py * SIN_HI[l] + gammaBig[l] * SCALE, SCALE2); + } + seen.add(`${tup[0]},${tup[1]},${tup[2]},${tup[3]},${tup[4]}`); + } + } + } + } + return seen.size; +} + +const { exact: gammaE, float: gammaF } = gammaFromSeed("funclol"); + +const TRY_SIZES = [ + { name: "small", rect: { x0: -6, y0: -4, x1: 6, y1: 4 } }, + { name: "medium", rect: { x0: -12, y0: -7, x1: 12, y1: 7 } }, + { name: "large", rect: { x0: -18, y0: -11, x1: 18, y1: 11 } }, + { name: "x-large", rect: { x0: -24, y0: -15, x1: 24, y1: 15 } }, +]; + +// Sanity: both implementations produce the same tile count for each rect. +for (const { name, rect } of TRY_SIZES) { + const fc = enumerateTilesFloat(gammaF, rect); + const ec = enumerateTilesExact(gammaE, rect); + if (fc !== ec) { + console.error(`${name}: float=${fc} exact=${ec} — disagreement (rect near origin should match)`); + } +} + +// Warm up +for (let i = 0; i < 3; i++) { + enumerateTilesFloat(gammaF, TRY_SIZES[1].rect); + enumerateTilesExact(gammaE, TRY_SIZES[1].rect); +} + +console.log(`seed=funclol oracle scale=10^50\n`); +console.log("size rect tiles float64 mean float64 p95 exact mean exact p95 ratio"); +console.log("------- -------- ------ ------------- ----------- ---------- --------- -----"); + +for (const { name, rect } of TRY_SIZES) { + const N = 50; + const w = rect.x1 - rect.x0; + const h = rect.y1 - rect.y0; + const fTimes: number[] = []; + let count = 0; + for (let i = 0; i < N; i++) { + const t0 = performance.now(); + count = enumerateTilesFloat(gammaF, rect); + fTimes.push(performance.now() - t0); + } + fTimes.sort((a, b) => a - b); + const fMean = fTimes.reduce((a, b) => a + b, 0) / N; + const fP95 = fTimes[Math.floor(0.95 * N)]; + + const eTimes: number[] = []; + for (let i = 0; i < N; i++) { + const t0 = performance.now(); + enumerateTilesExact(gammaE, rect); + eTimes.push(performance.now() - t0); + } + eTimes.sort((a, b) => a - b); + const eMean = eTimes.reduce((a, b) => a + b, 0) / N; + const eP95 = eTimes[Math.floor(0.95 * N)]; + + const ratio = eMean / fMean; + console.log( + `${name.padEnd(7)} ${`${w}×${h}`.padEnd(8)} ${String(count).padEnd(6)} ${`${fMean.toFixed(2)}ms`.padEnd(13)} ${`${fP95.toFixed(2)}ms`.padEnd(11)} ${`${eMean.toFixed(2)}ms`.padEnd(10)} ${`${eP95.toFixed(2)}ms`.padEnd(9)} ${ratio.toFixed(1)}×`, + ); +} + +console.log(""); +console.log("verdict (vs 16ms/frame budget at 1500 tiles):"); +console.log(" float64 — see table"); +console.log(" exact — see table"); diff --git a/research/penrose/04-viewport-anchor.md b/research/penrose/04-viewport-anchor.md new file mode 100644 index 0000000..cf8186b --- /dev/null +++ b/research/penrose/04-viewport-anchor.md @@ -0,0 +1,77 @@ +# 04 — Viewport anchor + +**Question.** Does the BigInt-truth / Float64-view pattern preserve both 60fps enumeration and exact addressing at any anchor magnitude? + +**Decision.** Yes. The pattern is viable. Ship it. + +## Pattern + +State holds two pieces: + +``` +anchor: { x: bigint, y: bigint } // exact world position, unbounded +offset: { x: number, y: number } // small Float64 delta, |offset| < threshold +``` + +For each direction j, precompute the anchor's projection once per re-anchor: + +``` +nProj_j = floor(anchor · e_j + γ_j) // BigInt, exact integer part +fProj_j = {anchor · e_j + γ_j} ∈ [0,1) // Float64, fractional part +``` + +Per-frame enumeration runs Float64 in offset space with `γ_eff = fProj`. Each tile found gets its absolute pentagrid coord by adding `nProj_j` to each tuple element. Render math (canvas transforms, vertex positions) never touches the anchor — only the address-layer translation does. + +Re-anchor when `|offset|` crosses ~1e8 (Float64 precision is comfortable up to ~10¹⁵; pick the threshold an order of magnitude below the danger zone). + +## Method + +[`04-viewport-anchor.ts`](./04-viewport-anchor.ts). Two checks: + +- **Correctness.** At anchor=(0,0), the anchored enumerator must produce the same set of absolute coords as 03's BigInt-exact enumerator on the same rect. Same 1315 tiles, same 5-tuples, set equality. +- **Throughput.** Time the anchored enumerator at anchor magnitudes 0, 1e5, 1e10, 1e20, 1e30, 1e40. The Float64 inner loop is identical regardless of magnitude; only the per-tile absolute-coord conversion (BigInt-add) grows with anchor size. + +Seed `funclol`. 50 timed iterations per row, 5 warmup. + +## Numbers + +``` +correctness (anchor=0): anchored=1315 exact=1315 equal=true + +anchor_mag tiles mean_ms p95_ms +0 1315 3.04ms 6.04ms +1e5 1302 2.90ms 3.79ms +1e10 1316 2.87ms 3.57ms +1e20 1306 5.02ms 7.66ms +1e30 1319 6.30ms 7.24ms +1e40 1322 7.15ms 8.81ms + +makeAnchor at |a|=1e20: 4.57 µs/call +``` + +## Interpretation + +- Correctness verified: 1315/1315 tile coords match the BigInt oracle at anchor=0. The anchored algorithm is just `γ_eff = fProj` Float64 enumeration plus a constant `nProj` shift, which is algebraically identical to absolute-frame enumeration. +- Throughput stays under 16 ms / 60 fps at every anchor magnitude tested, up to 10⁴⁰. The per-frame budget is preserved. +- The slight growth with magnitude (3 ms → 7 ms across 10⁰ → 10⁴⁰) comes from per-tile BigInt-add to convert offset coords to absolute. At 1315 tiles, 5 BigInt-adds per tile × growing BigInt size: ~3 ms overhead total. Easy to defer this conversion to display time if we ever need to (the dedup set could key on offset coords instead of absolute). +- Re-anchoring costs 4.57 µs. At one re-anchor per second of heavy panning, that's 0.0005% of CPU. Free. + +## Implications for the shipped explorer + +`lib/pentagrid.ts`: + +- `Coord = readonly [bigint, bigint, bigint, bigint, bigint]`. +- `pointToCoord(p, γ)` is BigInt-exact (used by hover, URL). +- `enumerateTilesInRect(anchor, rect, γ)` takes a precomputed anchor and a rect in offset space. Returns tiles whose coords are `anchor.nProj + offsetCoord`. Float64 inner loop, BigInt result. + +`lib/transform.ts`: + +- World-to-screen transform is `screen = (world - anchor) * zoom + canvas_center`. +- World-to-anchor reduction (the only place BigInt and Float64 cross) happens once per re-anchor. +- Re-anchor when `|cameraOffset|` exceeds a threshold (default 1e8 world units). + +The cursor's hover readout is computed via `pointToCoord(anchor + cursor_offset_as_bigint, γ)`, taking ~2.6 µs per pointermove. Invisible. + +## Citation + +`lib/pentagrid.ts` cites this writeup for the dual-layer design. The bound on `|offset|` and the re-anchor threshold are tuned here, not in the shipped code; if a future change needs different bounds (e.g., higher zoom limits), rerun this script to validate. diff --git a/research/penrose/04-viewport-anchor.ts b/research/penrose/04-viewport-anchor.ts new file mode 100644 index 0000000..33cba39 --- /dev/null +++ b/research/penrose/04-viewport-anchor.ts @@ -0,0 +1,282 @@ +// research/penrose/04-viewport-anchor.ts +// +// Q: Does the "BigInt truth, Float64 view" pattern preserve both 60fps +// enumeration and exact addressing at any anchor magnitude? +// +// Pattern: state = (anchor: bigint × 2, offset: float × 2). For each +// direction j, precompute the anchor's projection once: +// +// nProj_j = floor(anchor · e_j + γ_j) // BigInt, exact +// fProj_j = {anchor · e_j + γ_j} ∈ [0,1) // Float64, fractional +// +// Per-frame enumeration runs in anchor-relative offset space with +// γ_eff = fProj. Found tiles get their absolute coord by adding nProj +// to each tuple element. The render path never touches the anchor. +// +// Two things to confirm: +// +// 1. **Correctness.** At anchor = (0,0), the anchored enumeration must +// produce the same set of absolute coords as 03's BigInt-exact +// enumeration on the same rect. +// +// 2. **Throughput.** Per-frame cost is independent of anchor magnitude +// — the inner loop is identical Float64 math with substituted +// constants. Time at anchors 0, 1e10, 1e20, 1e30, 1e40 and confirm +// the times stay flat. +// +// Run: bun run research/penrose/04-viewport-anchor.ts + +const SCALE = 10n ** 50n; +const SCALE_F = Number(SCALE); +const SCALE2 = SCALE * SCALE; + +function bigintSqrt(n: bigint): bigint { + if (n < 2n) return n; + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} + +function bigintFloorDiv(n: bigint, d: bigint): bigint { + if (d === 0n) throw new Error("div by zero"); + if (d < 0n) { + n = -n; + d = -d; + } + if (n >= 0n) return n / d; + return n % d === 0n ? n / d : n / d - 1n; +} + +const SQRT5 = bigintSqrt(5n * SCALE * SCALE); +const T_PLUS = 10n * SCALE + 2n * SQRT5; +const T_MINUS = 10n * SCALE - 2n * SQRT5; +const SQRT_T_PLUS = bigintSqrt(T_PLUS * SCALE); +const SQRT_T_MINUS = bigintSqrt(T_MINUS * SCALE); + +const COS_HI: readonly bigint[] = [ + SCALE, + bigintFloorDiv(SQRT5 - SCALE, 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(-(SQRT5 + SCALE), 4n), + bigintFloorDiv(SQRT5 - SCALE, 4n), +]; +const SIN_HI: readonly bigint[] = [ + 0n, + bigintFloorDiv(SQRT_T_PLUS, 4n), + bigintFloorDiv(SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_MINUS, 4n), + bigintFloorDiv(-SQRT_T_PLUS, 4n), +]; +const COS_F: readonly number[] = COS_HI.map((c) => Number(c) / SCALE_F); +const SIN_F: readonly number[] = SIN_HI.map((s) => Number(s) / SCALE_F); + +function gammaFromSeed(seed: string): { exact: readonly bigint[]; float: readonly number[] } { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + const raw: bigint[] = []; + for (let i = 0; i < 5; i++) { + h = Math.imul(h ^ (i + 1), 16777619) >>> 0; + raw.push((BigInt(h) * SCALE) / (1n << 32n) - SCALE / 2n); + } + const sum = raw.reduce((a, b) => a + b, 0n); + const exact = raw.map((g) => g - sum / 5n); + const float = exact.map((g) => Number(g) / SCALE_F); + return { exact, float }; +} + +type Rect = { x0: number; y0: number; x1: number; y1: number }; + +type Anchor = { + x: bigint; + y: bigint; + nProj: readonly bigint[]; + fProj: readonly number[]; +}; + +function makeAnchor(x: bigint, y: bigint, gammaBig: readonly bigint[]): Anchor { + const nProj: bigint[] = new Array(5); + const fProj: number[] = new Array(5); + for (let j = 0; j < 5; j++) { + // proj at scale SCALE²: x·COS_HI + y·SIN_HI + γ·SCALE + const proj = x * COS_HI[j] + y * SIN_HI[j] + gammaBig[j] * SCALE; + const n = bigintFloorDiv(proj, SCALE2); + nProj[j] = n; + // remainder ∈ [0, SCALE²) + const remainder = proj - n * SCALE2; + fProj[j] = Number(remainder) / Number(SCALE2); + } + return { x, y, nProj, fProj }; +} + +// Enumerate tiles in offset-relative rect. Returns the absolute coord set +// as strings "c0,c1,c2,c3,c4" (mix of bigint). +function enumerateAnchored(anchor: Anchor, rect: Rect): Set { + const seen = new Set(); + const gamma = anchor.fProj; + for (let j = 0; j < 4; j++) { + for (let k = j + 1; k < 5; k++) { + const ejx = COS_F[j], ejy = SIN_F[j]; + const ekx = COS_F[k], eky = SIN_F[k]; + const det = ejx * eky - ejy * ekx; + if (Math.abs(det) < 1e-12) continue; + const invDet = 1 / det; + const pj0 = rect.x0 * ejx + rect.y0 * ejy; + const pj1 = rect.x1 * ejx + rect.y0 * ejy; + const pj2 = rect.x0 * ejx + rect.y1 * ejy; + const pj3 = rect.x1 * ejx + rect.y1 * ejy; + const pk0 = rect.x0 * ekx + rect.y0 * eky; + const pk1 = rect.x1 * ekx + rect.y0 * eky; + const pk2 = rect.x0 * ekx + rect.y1 * eky; + const pk3 = rect.x1 * ekx + rect.y1 * eky; + const kjMin = Math.floor(Math.min(pj0, pj1, pj2, pj3) + gamma[j]) - 1; + const kjMax = Math.ceil(Math.max(pj0, pj1, pj2, pj3) + gamma[j]) + 1; + const kkMin = Math.floor(Math.min(pk0, pk1, pk2, pk3) + gamma[k]) - 1; + const kkMax = Math.ceil(Math.max(pk0, pk1, pk2, pk3) + gamma[k]) + 1; + for (let kj = kjMin; kj <= kjMax; kj++) { + const aj = kj - gamma[j]; + for (let kk = kkMin; kk <= kkMax; kk++) { + const ak = kk - gamma[k]; + const px = (eky * aj - ejy * ak) * invDet; + const py = (-ekx * aj + ejx * ak) * invDet; + if (px < rect.x0 || px > rect.x1 || py < rect.y0 || py > rect.y1) continue; + // Local offset coords (small numbers from Float64). + const o0 = j === 0 ? kj : k === 0 ? kk : Math.floor(px * COS_F[0] + py * SIN_F[0] + gamma[0]); + const o1 = j === 1 ? kj : k === 1 ? kk : Math.floor(px * COS_F[1] + py * SIN_F[1] + gamma[1]); + const o2 = j === 2 ? kj : k === 2 ? kk : Math.floor(px * COS_F[2] + py * SIN_F[2] + gamma[2]); + const o3 = j === 3 ? kj : k === 3 ? kk : Math.floor(px * COS_F[3] + py * SIN_F[3] + gamma[3]); + const o4 = j === 4 ? kj : k === 4 ? kk : Math.floor(px * COS_F[4] + py * SIN_F[4] + gamma[4]); + // Absolute coord = anchor.nProj + offset coord. + const c0 = anchor.nProj[0] + BigInt(o0); + const c1 = anchor.nProj[1] + BigInt(o1); + const c2 = anchor.nProj[2] + BigInt(o2); + const c3 = anchor.nProj[3] + BigInt(o3); + const c4 = anchor.nProj[4] + BigInt(o4); + seen.add(`${c0},${c1},${c2},${c3},${c4}`); + } + } + } + } + return seen; +} + +// Script 3's BigInt-exact enumerator, inlined for the correctness check. +function enumerateExact(gammaBig: readonly bigint[], rect: Rect): Set { + const seen = new Set(); + const gammaF = gammaBig.map((g) => Number(g) / SCALE_F); + for (let j = 0; j < 4; j++) { + for (let k = j + 1; k < 5; k++) { + const ejx = COS_HI[j], ejy = SIN_HI[j]; + const ekx = COS_HI[k], eky = SIN_HI[k]; + const det = ejx * eky - ejy * ekx; + if (det === 0n) continue; + const ejxF = COS_F[j], ejyF = SIN_F[j], ekxF = COS_F[k], ekyF = SIN_F[k]; + const pj0 = rect.x0 * ejxF + rect.y0 * ejyF; + const pj1 = rect.x1 * ejxF + rect.y0 * ejyF; + const pj2 = rect.x0 * ejxF + rect.y1 * ejyF; + const pj3 = rect.x1 * ejxF + rect.y1 * ejyF; + const pk0 = rect.x0 * ekxF + rect.y0 * ekyF; + const pk1 = rect.x1 * ekxF + rect.y0 * ekyF; + const pk2 = rect.x0 * ekxF + rect.y1 * ekyF; + const pk3 = rect.x1 * ekxF + rect.y1 * ekyF; + const kjMin = Math.floor(Math.min(pj0, pj1, pj2, pj3) + gammaF[j]) - 1; + const kjMax = Math.ceil(Math.max(pj0, pj1, pj2, pj3) + gammaF[j]) + 1; + const kkMin = Math.floor(Math.min(pk0, pk1, pk2, pk3) + gammaF[k]) - 1; + const kkMax = Math.ceil(Math.max(pk0, pk1, pk2, pk3) + gammaF[k]) + 1; + const rectX0Big = BigInt(rect.x0) * SCALE; + const rectY0Big = BigInt(rect.y0) * SCALE; + const rectX1Big = BigInt(rect.x1) * SCALE; + const rectY1Big = BigInt(rect.y1) * SCALE; + for (let kjN = kjMin; kjN <= kjMax; kjN++) { + const kj = BigInt(kjN); + const aj = kj * SCALE - gammaBig[j]; + for (let kkN = kkMin; kkN <= kkMax; kkN++) { + const kk = BigInt(kkN); + const ak = kk * SCALE - gammaBig[k]; + const pxNum = (eky * aj - ejy * ak) * SCALE; + const pyNum = (ejx * ak - ekx * aj) * SCALE; + const px = bigintFloorDiv(pxNum, det); + const py = bigintFloorDiv(pyNum, det); + if (px < rectX0Big || px > rectX1Big || py < rectY0Big || py > rectY1Big) continue; + const tup: bigint[] = new Array(5); + for (let l = 0; l < 5; l++) { + if (l === j) tup[l] = kj; + else if (l === k) tup[l] = kk; + else tup[l] = bigintFloorDiv(px * COS_HI[l] + py * SIN_HI[l] + gammaBig[l] * SCALE, SCALE2); + } + seen.add(`${tup[0]},${tup[1]},${tup[2]},${tup[3]},${tup[4]}`); + } + } + } + } + return seen; +} + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +const { exact: gammaE } = gammaFromSeed("funclol"); +const rect: Rect = { x0: -12, y0: -7, x1: 12, y1: 7 }; + +// Correctness: anchor = (0, 0) should match script 3's exact enumeration. +const anchor0 = makeAnchor(0n, 0n, gammaE); +const anchoredSet = enumerateAnchored(anchor0, rect); +const exactSet = enumerateExact(gammaE, rect); +const correct = setsEqual(anchoredSet, exactSet); + +console.log(`seed=funclol rect=24×14 (1300+ tiles)\n`); +console.log(`correctness (anchor=0): anchored=${anchoredSet.size} exact=${exactSet.size} equal=${correct}`); +if (!correct) { + console.log(` anchored extras: ${[...anchoredSet].filter((c) => !exactSet.has(c)).slice(0, 3).join(" | ")}`); + console.log(` exact extras: ${[...exactSet].filter((c) => !anchoredSet.has(c)).slice(0, 3).join(" | ")}`); +} +console.log(""); + +// Throughput at different anchor magnitudes. +console.log("throughput vs anchor magnitude:"); +console.log("anchor_mag tiles mean_ms p95_ms"); +console.log("------------ ------ -------- --------"); + +const ANCHOR_EXPS = [0, 5, 10, 20, 30, 40]; +for (const exp of ANCHOR_EXPS) { + const ax = exp === 0 ? 0n : (10n ** BigInt(exp)) * SCALE; + const ay = exp === 0 ? 0n : (10n ** BigInt(exp)) * SCALE; + const anchor = makeAnchor(ax, ay, gammaE); + // Warmup + for (let i = 0; i < 5; i++) enumerateAnchored(anchor, rect); + const N = 50; + const times: number[] = []; + let count = 0; + for (let i = 0; i < N; i++) { + const t0 = performance.now(); + const tiles = enumerateAnchored(anchor, rect); + count = tiles.size; + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + const mean = times.reduce((a, b) => a + b, 0) / N; + const p95 = times[Math.floor(0.95 * N)]; + const label = exp === 0 ? "0" : `1e${exp}`; + console.log( + `${label.padEnd(12)} ${String(count).padEnd(6)} ${`${mean.toFixed(2)}ms`.padEnd(8)} ${p95.toFixed(2)}ms`, + ); +} + +// makeAnchor cost (per anchor change, not per frame). +console.log(""); +const N_ANCHOR = 10_000; +{ + const t0 = performance.now(); + for (let i = 0; i < N_ANCHOR; i++) makeAnchor((10n ** 20n) * SCALE, (10n ** 20n) * SCALE, gammaE); + const dt = performance.now() - t0; + console.log(`makeAnchor at |a|=1e20: ${((dt / N_ANCHOR) * 1000).toFixed(2)} µs/call`); +} diff --git a/research/penrose/05-substitution-and-z5.md b/research/penrose/05-substitution-and-z5.md new file mode 100644 index 0000000..e1bb5f2 --- /dev/null +++ b/research/penrose/05-substitution-and-z5.md @@ -0,0 +1,194 @@ +# Penrose — Substitution, Z⁵ coordinates, and the engine pivot + +> **Correction (2026-06-23).** The novelty assessment below is wrong. After reading +> de Bruijn 1990 ("Updown generation") and D'Andrea 2023 (*A Guide to Penrose +> Tilings*), the results are classical and standard — not a publishable contribution. +> The fold is D'Andrea **Theorem 5.16**; the window is **Prop 5.15**. See `STATUS.md` +> → "Provenance" for the line-by-line correspondence. The reasoning here is kept as a +> record of how the work proceeded. + +Research note. Captures the investigation that moved the explorer's engine from +"de Bruijn pentagrid + BigInt viewport anchor" to "substitution / deflation with +exact arithmetic, addressed by de Bruijn Z⁵ coordinates." Includes the literature +survey, what is established versus what we must derive, the novelty assessment, and +the open leads. + +Status: design research, not yet implemented. The shipped engine still uses the +older approach; this note is the basis for the rewrite. + +## How we got here + +1. **The re-anchor bug.** The shipped explorer uses a BigInt anchor with a Float64 + render offset, re-anchoring when the offset passes `1e8`. Probing showed the + re-anchor does **not preserve the tiling**: enumerating the same world region + from two different anchors yields different tiles, at *any* nonzero shift (not a + precision effect). The de Bruijn *addressing* (`pointToCoordExact` / + `pointToCoordAnchored`) is correct and anchor-independent; only the tile + *generation* geometry in `enumerateTiles` drifts. So the coordinate system was + never the broken part; the renderer was. + +2. **Substitution is correct and traverses cheaply.** A Robinson-triangle + deflation (Preshing's formulation) produces a correct P3 tiling (thick:thin → φ, + exact edge ratios). With viewport pruning, reaching a far viewport costs work + that grows only with the *number of inflation levels* (`~log_φ(distance)`), a + few hundred tiles per level, versus exponential blow-up without pruning + (`1.6e10` triangles → `~880` of real work at radius `1e4`). + +3. **BigInt is fast enough; Float64 is not precise enough.** Benchmarking pruned + deflation in Float64 vs fixed-point BigInt: + + | distance D | Float64 | clean? | BigInt | clean? | slowdown | + | --- | --- | --- | --- | --- | --- | + | 1e12 | 0.02 ms | yes | 0.20 ms | yes | ~10× | + | 1e15 | 0.03 ms | **no** | 0.32 ms | yes | ~9× | + | 1e50 | — | — | 0.62 ms | yes | — | + + Float64 dies near `1e13` (relative precision erodes with magnitude). Fixed-point + BigInt has *constant absolute* precision, so it stays clean at any distance, at + sub-millisecond cost (well under a 16 ms frame). The research-03 "BigInt is 29 ms, + too slow" result measured full per-tile lattice enumeration; pruning cuts the + BigInt work ~50×. + +4. **The two constructions are the same tiling family.** The substitution "sun" and + de Bruijn tilings share radial vertex signatures exactly (same local-isomorphism + class). Radial matching cannot pin the *exact* γ (the whole LI class shares it), + which sent us to the literature. + +## Literature survey + +Primary sources and what they establish. Where a claim is our synthesis rather than +a verbatim source, it is marked. + +### The pentagrid and its coordinate + +- de Bruijn (1981) maps `x ∈ ℝ²` to a 5-tuple `K_j(x) = ⌈x·v_j + γ_j⌉`, with + `v_j = (cos 2πj/5, sin 2πj/5)` and `γ_j` real **offsets** (perpendicular line + displacements, not spacings). The dual is a Penrose P3 tiling iff `Σγ_j ∈ ℤ` + (canonically `Σγ_j = 0`). +- The vertex index `Index(z) = Σ_j K_j(z)` takes only the values `{1,2,3,4}` + (Au-Yang & Perk, attributed to de Bruijn). Even `{2,4}` / odd `{1,3}` sublattices. +- Vertex position map: `f(z) = Σ_j K_j(z) · ζ^j`, `ζ = e^{2πi/5}`. **The 5-tuple is + the Z⁵ cut-and-project coordinate, and the physical position is its projection.** +- **Multigrid = projection** (Gähler & Rhyner 1986, proven): the grid method and the + projection-from-Z⁵ method are equivalent with an explicit offset↔window map. + +### The symmetric center (sun / star) + +- The unique `D₅`-symmetric pentagrid is `γ = (0,0,0,0,0)`, and it is **singular**: + five lines meet at the origin, whose dual is a central regular **decagon**. +- The **sun** and **star** are the two `D₅`-symmetric desingularizations of that + decagon, *not* two distinct nonzero offset vectors. (Sourced synthesis from the + singular-pentagrid result; de Bruijn's own "sun/star" text is a scanned image we + could not extract, and D'Andrea 2023 §3.1.2 is paywalled.) +- **Watch out:** Greg Egan's applet uses a different convention where the offset is + the *window center*; there `a=(1/5,…,1/5)` gives 5-fold symmetry. That `1/5` is + **not** de Bruijn's `γ`. Do not transplant it into `⌈x·v_j + γ_j⌉`. +- LI class is fixed by `Σγ_j mod 1`. `Σγ_j ∈ ℤ` is Penrose; `Σγ_j = 1/2` is the + distinct "anti-Penrose" ten-fold class. +- **Decision:** the explorer should render a **generic regular** tiling + (`Σγ_j ∈ ℤ`, offset off the singular center) to avoid the decagon and keep + coordinates clean everywhere. + +### Inflation as an integer map on Z⁵ + +- φ-inflation is the single integer circulant `A` on Z⁵ with first row + `(0,0,1,1,0)`. Eigenvalues: `−φ` on the physical plane (`ζ^j`), `1/φ` on the + internal plane (`ζ^{2j}`, the Galois conjugate `ζ → ζ²`), and `2` on the all-ones + diagonal (the index direction). Integer, maps Z⁵ → Z⁵. (Cotfas + math-ph/0403062; eigenstructure independently reproduced in the survey.) +- The offset transforms as the single internal-space phase + `Σ_j γ_j ζ^{2j}`, contracted by `1/φ` per inflation step. `Σγ_j = 0` (zero + diagonal component) is preserved because `A` is `C₅`-equivariant. +- The exact per-offset `γ_j → γ'_j` with its additive constant lives in de Bruijn + 1990 "Updown generation" (paywalled). We have the verified linear-algebra form and + would derive the offset form ourselves. + +### Pruned deflation and exact arithmetic (prior art) + +- Simon Tatham's quasiblog does pruned deflation to a target region with exact + `Z[ζ₁₀]` / `a + b√5` vertex arithmetic. This is the closest prior art for the + renderer pillar; our renderer extends it to BigInt for unbounded deep zoom. +- Tatham (arXiv:2512.16595) formalizes the substitution-hierarchy address as + finite-state transducers, explicitly **non-geometric** ("position not required"). + +## The architecture: work in Z⁵ + +The literature collapses the "two engines" idea into one representation. + +- **Every tile/vertex is a Z⁵ lattice point** `n = (n_0,…,n_4)`. +- **Geometry** = projection `Σ n_j ζ^j` (for rendering), exact in `Z[ζ]`/BigInt. +- **Address** = the Z⁵ point itself (the de Bruijn coordinate), with index + `Σ n_j ∈ {1,2,3,4}`. The coordinate is not something bolted on; it *is* the + representation. +- **Inflation/deflation** = the integer matrix `A` (and the substitution rule for + the fine direction). Exact, unbounded, no float drift. +- **Cross-validation** = an independent pentagrid enumeration (a genuinely different + algorithm) as a mutual oracle. The regression framework survives. + +This keeps the coordinate system, gives exact geometry at any zoom, uses one exact +integer substrate, and unifies with the inflation teaching demo. + +## Novelty (is there a paper?) + +Honest read: each pillar has prior art; the specific combination appears unpublished. + +- **Prior art:** pruned deflation + exact arithmetic (Tatham); de Bruijn coordinate + addressing (classical); "two constructions as oracle" (gglouser, but only + projection-vs-multigrid, same family, no deep zoom). +- **What would be new:** + 1. **Substitution vs pentagrid as a *cross-family* mutual oracle** (existing tools + only cross-check within the projection family). + 2. **An explicit address↔de-Bruijn-coordinate bridge for the substitution case**, + which Pardo-Guerra, Washburn & Allahyarov (arXiv:2603.13553, 2026) prove for + height-functions/strip-indices but leave as an **open conjecture** for + substitution. An explicit, validated construction lands on that frontier. + 3. **BigInt arbitrary-precision deflation for unbounded deep zoom** — no public + implementation found (existing renderers are float, or exact-field but not + deep-zoom). + +The strongest publishable claim is not "we render Penrose tilings" (well-trodden) but +"an explicit, validated address↔de-Bruijn-coordinate correspondence realized as a +mutual-oracle deep-zoom renderer." A systems contribution that touches an open math +conjecture, not a pure-math breakthrough. Caveat: negative result over ~10 searches; a +niche source could exist. + +## Open leads / what we must derive + +- **Get D'Andrea 2023 §5.3 "Composition and pentagrids" and §3.4 "Index sequences"** + (Springer; arXiv preview is front-matter only). The single most important source + to obtain before claiming the address↔coordinate map is unpublished; it may already + contain a partial version. +- **The Z⁵ substitution (deflation) rule.** `A` gives inflation (fine → coarse); + deflation adds child Z⁵ points by specific displacement vectors we must derive + from the substitution geometry. This is the constructive core. +- **The exact `γ_j → γ'_j` offset inflation constant** (de Bruijn 1990, paywalled). + We have the linear-algebra form; derive the additive constant in grid coordinates. +- **Per-tile window resolution:** which of the four pentagonal windows a tile at a + given hierarchy address lands in. Established at the LI-class/index level only. +- **A BigInt deep-zoom renderer** for unbounded distance: unproven engineering we + build ourselves. + +## Citations + +- N. G. de Bruijn, "Algebraic theory of Penrose's non-periodic tilings of the plane, + I and II", Indag. Math. 43 (1981) 39-66. + +- N. G. de Bruijn, "Updown generation of Penrose patterns", Indag. Math. N.S. 1 + (1990) 201-220. +- F. Gähler & J. Rhyner, "Equivalence of the generalised grid and projection + methods…", J. Phys. A 19 (1986) 267-277. +- H. Au-Yang & J. H. H. Perk, "Quasicrystals — The impact of N. G. de Bruijn", + arXiv:1306.6698 (2013). +- L. Effinger-Dean, "The Empire Problem in Penrose Tilings" (Williams thesis, 2006), + Ch. 4. +- N. Cotfas, "On the self-similarities of the Penrose tiling", arXiv:math-ph/0403062 + (2004). +- S. Pardo-Guerra, J. Washburn & E. Allahyarov, "Matching Rules as Cocycle + Conditions…", arXiv:2603.13553 (2026). — leaves + the substitution address↔coordinate map as an open conjecture. +- S. Tatham, "Two algorithms for randomly generating aperiodic tilings". + +- C. Goodman-Strauss, "Matching rules and substitution tilings", Ann. Math. 147 + (1998) 181-223. +- F. D'Andrea, "A Guide to Penrose Tilings", Springer 2023 (arXiv:2310.18950 + front-matter). diff --git a/research/penrose/06-addressing-and-applications.md b/research/penrose/06-addressing-and-applications.md new file mode 100644 index 0000000..d06de87 --- /dev/null +++ b/research/penrose/06-addressing-and-applications.md @@ -0,0 +1,104 @@ +# Penrose — Path addressing, the base-A number system, and applications + +Research note. Records the addressing model that fell out of the Z⁵ pivot (the path +↔ coordinate equivalence, the inflation matrix as a number-system base, recursive +multi-scale navigation) and the application ideas it opens up, including procedural +infinite-plane level design. Forward-looking: a model and a set of leads, not yet +built. + +## The addressing model + +### Path and coordinate are the same thing (event sourcing) + +A tile can be named two ways, and they are equivalent: + +- **Path** (event log): the sequence of inflate / move / deflate operations that + reaches the tile from a fixed origin. +- **Coordinate** (materialized view): the absolute de Bruijn Z⁵ 5-tuple. + +`coordinate = fold(path)`. Replaying the path accumulates the Z⁵ operations and +lands on the absolute 5-tuple. The path is the source of truth (cheap, frame-free, +relative); the coordinate is derived (you pay a replay to materialize it). The +canonical root-to-tile hierarchy path is in bijection with the coordinate; arbitrary +navigation paths fold many-to-one onto the same coordinate, exactly as many event +logs fold to one state. + +### The path is the coordinate written in base A + +φ-inflation is the integer circulant matrix `A` on Z⁵ (validated: it acts as exactly +−φ on the physical plane, machine precision). A tile's coordinate expands as + +``` +n = offset₀ + A·offset₁ + A²·offset₂ + … + Aᵏ·offsetₖ +``` + +where each `offsetᵢ` is the small Z⁵ child-displacement chosen at level `i` (a digit +from the finite substitution alphabet). This is a **positional number system on the +lattice with base A** (a matrix numeration system). The inflation path is the digit +string; the coordinate is its value; computing one from the other is Horner +evaluation. This is why addressing is logarithmic, not linear. + +### Logarithmic addressing + +A tile at physical distance `D` has a Z⁵ coordinate of magnitude ~`D`, but you reach +it in `k ≈ log_φ(D)` steps, not `D`: inflate `k` levels so your viewport sits in one +small-coordinate supertile, then deflate down following the digits. `O(log distance)` +exact-integer matrix operations. For `D = 10⁵⁰`, ~240 steps, not 10⁵⁰ tiles. + +### The recursion, and its one asymmetry + +Inflate / move / deflate is recursive to any depth, with the *same* matrix `A` at +every level (self-similarity), and exact at any depth because Z⁵ arithmetic never +drifts. The reach scales: inflate `k` levels and each coarse move strides `φᵏ` +layer-0 tiles. + +The asymmetry: **inflation is deterministic, deflation is a choice.** Every tile has +one supertile (`A` is a function, a "carry up"). A supertile has several children, so +coming down you must supply the digit at each level. Inflate-then-deflate returns a +tile *and its siblings*, not the single tile; you pick yours out by the path. + +### Multi-scale consistency (the correctness condition) + +Moving at a coarse level and then refining yields the *identical* tiling as moving at +the fine level, because it is one self-similar object with a unique supertile grouping +at every scale. This is precisely the property the old de Bruijn viewport-anchor +violated (re-anchoring there produced a different tiling). The Z⁵ / inflation engine +satisfies it by construction. + +### What is still unbuilt + +The recursion structure is proven; the missing piece is the **exact Z⁵ substitution +alphabet**: the specific child-offset vectors a supertile deflates into, verified to +reproduce a real Penrose tiling tile-for-tile. That is the constructive core, and the +map it realizes (substitution address → de Bruijn coordinate) is the one the 2026 +cocycle paper leaves open for the substitution case (see +[05-substitution-and-z5.md](./05-substitution-and-z5.md)). + +## Applications (maintainer's thinking) + +The addressing model is not specific to a tiling explorer. Its properties map well +onto **procedurally generated, infinite-plane level design** for games: + +- **Quick traversal + zoom.** Travel across a vast map cheaply (logarithmic + addressing, giant φ-strides at coarse levels), then zoom into a specific point and + generate only the local detail (pruned deflation, bounded per-frame work). No need + to generate or store the whole world. +- **Layers as portals.** The inflation levels are literal layers. Moving between + layer N and layer N−1 is a portal, and the path guarantees you know exactly which + set of fine tiles lives under a given coarse tile. A coarse cell maps to a unique + set of children across the layer boundary; you can hand a player a portal between + layers and know the destination set is fixed and consistent. +- **Infinite non-repeating content for free.** The tiling is aperiodic: no two + regions are identical, so every location is unique level content, deterministically + fixed by the seed. Variety without authoring, and without repeats. +- **Deterministic, storage-free, networkable.** Same coordinate → same content, + always. Worlds regenerate from a coordinate rather than being stored, and an exact + Z⁵ coordinate is a precise shareable / networkable address for a location (good for + multiplayer consistency and "meet me here" links). +- **Natural level-of-detail.** The hierarchy is the LOD structure: render coarse far + away, fine up close, guaranteed consistent across the seam because it is one + self-similar object. + +These are leads, not commitments. The func.lol Penrose experiment is the proving +ground for the engine; a game application would be a separate project built on the +same addressing core. diff --git a/research/penrose/07-cut-and-project-window.md b/research/penrose/07-cut-and-project-window.md new file mode 100644 index 0000000..535b951 --- /dev/null +++ b/research/penrose/07-cut-and-project-window.md @@ -0,0 +1,105 @@ +# Penrose — The exact cut-and-project window, and the validated engine + +> **Correction (2026-06-23).** "Genuinely open (our contribution)" below is wrong. +> The window is D'Andrea 2023 **Prop 5.15**; the address↔coordinate map is **Theorem +> 5.16**. Both are classical de Bruijn theory, reproduced under test, not new results. +> See `STATUS.md` → "Provenance." Kept as a record of the reasoning. + +Research note. Records the exact acceptance window (from the literature), the +inflation/offset rule, a validated cut-and-project engine built from them, and the +two pieces that were thought open at the time (corrected above). This is the +technical foundation for the Z⁵ substitution alphabet and the address↔coordinate +bridge. + +## The exact window (found, two independent formalisms agree) + +The Penrose vertex set is `V = { n ∈ ℤ⁵ : π'(n) ∈ K_{σ(n)} }`, where: + +- `σ(n) = Σ n_l` is the **index**, and only `σ ∈ {1,2,3,4}` carry vertices. +- `π(n) = Σ n_l ζ^l` is the **physical** projection (`ζ = e^{2πi/5}`), the vertex + position in the plane. +- `π'(n) = Σ n_l ζ^{2l}` is the **internal** projection (the star map `ζ → ζ²`). +- The four windows, with `P` = the unit regular pentagon (convex hull of the five + 5th-roots of unity) and `τ = φ`: + + | index `n` | window `K_n` | shape | + | --- | --- | --- | + | 1 | `v + P` | small, upright | + | 2 | `v − τP` | large, reversed | + | 3 | `v + τP` | large, upright | + | 4 | `v − P` | small, reversed | + + Circumradius ratio `1 : τ : τ : 1`; scaling signs `(+1, −τ, +τ, −1)`; `K₁,K₃` + share orientation, `K₂,K₄` are point-reversed. `v ∈ E'` is the offset (de Bruijn's + γ data; `v = 0` is the singular symmetric center, so use a generic `v`). + +Sources: Cotfas math-ph/0403062 (before eq.21) and 0710.3845 (after eq.9); +cyclotomic form Haynes-Lutsko arXiv:2512.21444 Ex.7.11 / Baake-Grimm. The two agree +on shapes, scalings, orientations, and index assignment. + +### Validated + +Filtering ℤ⁵ through these four pentagons (generic `v`) produces a correct Penrose +rhombus tiling: in a disk of radius 6, 136 vertices, 245 unit-distance edges, **every +edge on one of the five `ζ^l` directions** (zero off-direction), internal projections +bounded, vertex-by-index counts `{1:23, 2:44, 3:49, 4:20}` matching the `1:τ:τ:1` +size ratio. So we have a clean, exact, validated cut-and-project engine. + +## Inflation and the offset (γ) transform + +- The φ-inflation is the integer circulant `A` (first row `0,0,1,1,0`), which **is** + Cotfas's operator `S` for `λ = −τ`. Eigenvalues, verified numerically: `−φ` + (physical `E`), `1/φ` (internal `E'`), `2` (diagonal/index `E''`). +- Internal contraction about a center `y`: `π'[S(x−y)+y] = (1/φ)·(π'x − π'y) + π'y`. +- **Offset (γ) transform, affine form (usable now):** the offset flows as + `offset → v + (1/φ)·(offset − v)`, contracting toward the fixed internal center + `v` by `1/φ`. `Σγ_l ∈ ℤ` is invariant; the index multiplies `n → 2n`. Window + nesting holds because `|1/φ| < 1` keeps the contracted pentagon inside. +- We work in ℤ⁵ + window directly, so the affine offset rule is all the engine + needs. The *literal closed-form pentagrid recurrence* `γ_l → γ_l'` is not published + openly (it would be in D'Andrea 2023 §5.3, paywalled, or de Bruijn 1990); we would + derive it only if we want to emit de-Bruijn-γ coordinates rather than ℤ⁵ points. + +de Bruijn 5-tuple emission (the projection side, fully explicit): +`K_j(z) = ⌈Re(z·ζ^{-j}) + γ_j⌉`, `Σγ_j = 0`, index `Σ K_j ∈ {1,2,3,4}`, vertex +`f(z) = Σ K_j ζ^j` (Au-Yang & Perk eqs. 24-26). + +## What remains open (our contribution) + +The literature is explicit that two pieces are unsettled, and they are exactly what +we need: + +1. **The Z⁵ substitution alphabet = the window IFS displacement set.** Each pentagon + deflates as `Ω_i = ⋃_j ⋃_{t ∈ T_ij} (λ*·Ω_j + t*)` with `λ* = −1/φ` (Baake-Grimm + arXiv:2004.03256 eq.7; the Fibonacci template `W_a = σW_a ∪ σW_b`, + `W_b = σW_a + σ`, `σ = −1/φ`). The displacement shifts `t` for Penrose are **not + given in closed form** in any source found. Deriving them (and lifting to ℤ⁵) is + the substitution alphabet. + +2. **The address → de Bruijn 5-tuple map.** Pardo-Guerra, Washburn & Allahyarov + (arXiv:2603.13553) prove the strip-index/height side and state the substitution + converse (Conjecture 5.6) as **"the main open problem of this paper."** The map + must be assembled as: hierarchy address → displacement (inflation matrix `T`) → + ℤ⁵ lift → `K` via the ⌈·⌉ formula. No citable closed form exists. + +Both are now tractable on the validated cut-and-project engine: the window IFS shifts +can be fit geometrically against the exact pentagons, and the address map composed +through `A` and the window test. + +## Citations + +- N. Cotfas, "On the self-similarities of the Penrose tiling", arXiv:math-ph/0403062 + (J. Phys. A 37 (2004) 3125). Window, projectors, inflation integrality. +- N. Cotfas, "Symmetry properties of Penrose type tilings", arXiv:0710.3845. Window + `W2=−τW1, W3=τW1, W4=−W1`; offset transform. +- A. Haynes & C. Lutsko, "The Gauss circle problem for Penrose tilings", + arXiv:2512.21444. Cyclotomic window `P, −τP−2, τP−3, −P−4`. +- M. Baake & U. Grimm, "Inflation versus projection sets… the role of the window", + arXiv:2004.03256. Window IFS, `σ = −1/φ`. +- Au-Yang & Perk, arXiv:1306.6698. de Bruijn 5-tuple `K_j = ⌈Re(z ζ^{-j}) + γ_j⌉`. +- Pardo-Guerra, Washburn & Allahyarov, arXiv:2603.13553. The substitution + address↔coordinate map as the stated open problem (Conjecture 5.6). +- F. D'Andrea, "A Guide to Penrose Tilings", Springer 2023, §5.3 (paywalled): the + likely home of the explicit γ recurrence. +- de Bruijn, "Updown generation of Penrose patterns", Indag. Math. N.S. 1 (1990) + 201-220 (not openly retrievable): the explicit inflation rule. diff --git a/research/penrose/08-the-bridge.md b/research/penrose/08-the-bridge.md new file mode 100644 index 0000000..aa22769 --- /dev/null +++ b/research/penrose/08-the-bridge.md @@ -0,0 +1,72 @@ +# Penrose — The bridge: substitution → de Bruijn coordinates + +> **Correction (2026-06-23).** This is not the "open-conjecture case." The bridge is +> D'Andrea 2023 **§5.3** (Prop 5.14 cut-and-project characterization, Theorem 5.16 +> composition), i.e. classical de Bruijn. We reproduce it under test; we do not solve +> arXiv:2603.13553's broader conjecture. See `STATUS.md` → "Provenance." Kept as a +> record of the reasoning. + +Research note. The result that closes the address↔coordinate gap: the substitution +tiling, lifted to ℤ⁵ by edge-integration, is a cut-and-project tiling, so every +substitution-rendered tile gets its exact de Bruijn coordinate. Built and validated +in `research/penrose/cap/bridge.ts` (+ tests). + +## The construction + +1. Build the tiling by **deflation** (Robinson-triangle substitution from the central + wheel). This gives vertices and unit edges, reliably, at any depth. +2. Rotate so the edge directions align with the five `ζ^l`. Substitution edges sit at + `18° + k·36°`; a `−18°` rotation puts them on the `ζ^l` directions. +3. **Integrate the edges.** Each unit edge points along some `ζ^l`, so walking it adds + `±e_l` to the ℤ⁵ coordinate. BFS from one vertex assigns a ℤ⁵ point to every vertex. + +That ℤ⁵ point is the de Bruijn coordinate. Physical position is its projection +`Σ n_l ζ^l`; internal coordinate is `Σ n_l ζ^{2l}`; index is `Σ n_l`. + +## The validation (tested) + +`bun test ./research/penrose/cap/` — the bridge suite asserts, on a 1211-vertex patch: + +- **Every unit edge lies on a `ζ^l` direction** (`badEdges = 0`). +- **The lift is path-independent**: every rhombus closes, zero loop inconsistencies, + every vertex assigned. (This is the non-trivial part — it means the edge-integration + is consistent, i.e. the tiling genuinely embeds in ℤ⁵.) +- **Indices obey the de Bruijn index theorem**: exactly four consecutive values + (here `−2,−1,0,1`, a constant shift of the canonical `{1,2,3,4}`). +- **Internal projections are bounded** (`max |π'| < φ`), the cut-and-project fingerprint. +- **The window is four pentagons** with the `1 : φ : φ : 1` size ratio (outer indices + small, inner indices large). + +So the substitution tiling and the cut-and-project tiling are the **same object**, and +the lift is the explicit substitution-address → de-Bruijn-coordinate map. + +## Why this matters + +- **The mutual oracle is real.** Two completely independent constructions (substitution + and cut-and-project) agree, validated by the index theorem and the window. That is the + cross-family cross-check no prior tool does. +- **It is the open-conjecture case.** Pardo-Guerra, Washburn & Allahyarov + (arXiv:2603.13553) prove the projection/strip-index side and leave the substitution + case (Conjecture 5.6 converse) as "the main open problem of this paper." This is an + explicit, validated construction of exactly that map — the engineering contribution + the novelty survey flagged. +- **The engine is complete and addressable.** Deflate to render (fast, reliable, both + directions), lift to address (exact de Bruijn coordinate), all under test. + +## Remaining + +- **T6: correct face extraction** so the thick:thin = φ ratio test passes from a bare + vertex set (needed for rendering faces, not for the coordinate map). +- **The base-`A` fold.** The recursive/event-sourced address (digits = per-level child + offsets, base = the inflation matrix `A`) is the compact form of the same coordinate; + worth implementing as the navigation representation (small numbers while panning, + materialize the ℤ⁵ coordinate on demand). The lift here validates the destination; the + fold would give the cheap route to it. +- **Canonical index normalization.** The lift's indices are shifted by a constant (the + reference vertex's true index); pin the offset that lands them on `{1,2,3,4}`. + +## Files + +- `research/penrose/cap/cap.ts` / `cap.test.ts` — cut-and-project engine + inflation `A`. +- `research/penrose/cap/deflate.ts` / `deflate.test.ts` — reliable deflation. +- `research/penrose/cap/bridge.ts` / `bridge.test.ts` — the lift / coordinate map. diff --git a/research/penrose/09-the-fold.md b/research/penrose/09-the-fold.md new file mode 100644 index 0000000..51ad50e --- /dev/null +++ b/research/penrose/09-the-fold.md @@ -0,0 +1,80 @@ +# Penrose — The fold: the closed-form address↔coordinate recursion + +> **Correction (2026-06-23).** The fold is **not** novel and not the cocycle paper's +> open problem. It is D'Andrea 2023 **Theorem 5.16** exactly: `coord' = −A·coord + +> m·ones` equals the composition map Φ via the cyclotomic identity `C = J − A` and +> `m = index − c`, and the carry `c ∈ {0,1,1,2}` we re-derived is Theorem 5.16's `c`. +> Equivalently de Bruijn 1990 §3.12. See `STATUS.md` → "Provenance." Kept as a record +> of the reasoning (the skeptical carry re-derivation still stands as good practice). + +Research note. The local, O(log) form of the substitution → de-Bruijn-coordinate +map, and the resolution of the additive index correction that was the open piece. +Built and tested in `research/penrose/cap/fold.ts`. + +## The result + +A persistent vertex's de Bruijn coordinate transforms between consecutive deflation +levels by a single deterministic recursion: + +``` +coord' = −A·coord + m·[1,1,1,1,1] +m = ⌈(1 + 2·index)/5⌉ (canonical frame, index ∈ {1,2,3,4} ⇒ m ∈ {1,2}) +``` + +(Frame-relative form, for a band starting at `bandMin`: `m = ⌈(bandMin + 2·index)/5⌉`.) + +Nothing here is fitted — every term is forced: + +- **`−A`** is the inflation operator (eigenvalues `−φ, 1/φ, 2`). +- **`[1,1,1,1,1]` is forced, not chosen.** It is `A`'s eigenvector for eigenvalue 2 + (`A·𝟙 = 2𝟙`), and it is the **kernel of both projections**: `π(𝟙) = π'(𝟙) = 0` + (the five 5th-roots of unity sum to zero). So adding it shifts the de Bruijn index + by exactly 5 and moves the tile not at all. It is the *unique* index-gauge + direction, and `det A = 2` is why a single such digit suffices. +- **`m` is the forced carry.** `index' = −2·index + 5m`, and `m` is the only integer + putting `index'` back in `{1,2,3,4}`. Since `−2` is invertible mod 5, + `index' = (−2·index) mod 5` permutes `{1,2,3,4}` bijectively (`1→3→4→2→1`). + +This is a base-`(−A)` numeration on ℤ⁵; the digit is the de Bruijn index carry — +the additive index correction the literature leaves unstated. + +(History: a first version keyed the carry off the *source* band and held at only two +of four level pairs — a frame coincidence. Keying it off the target/canonical index +makes it universal; it now holds at every level pair under test.) + +## Why it matters + +- **Closed-form and local.** No global edge-integration; one matrix-multiply plus a + conditional add per level. Iterating from a coarse seed reaches any depth in + O(levels) = O(log distance) exact-integer steps. This is the efficient address↔ + coordinate transform the navigation model needs (small numbers while zooming, + materialize the full coordinate on demand). +- **Resolves the open piece.** The substitution-address → coordinate map was the + cocycle paper's stated open problem; its hard part is precisely the additive index + correction, now pinned to one bit. +- **Validated against an independent oracle.** The recursion reproduces every + persistent vertex of the edge-integration lift exactly (100%), and stays exact at + any depth. Two independent routes to the coordinate agree. + +## How it was found + +Lift two consecutive deflation levels (the edge-integration bridge), match persistent +vertices by wheel position, and fit `coord⁽ᴺ⁺¹⁾ = M·coord⁽ᴺ⁾ + shift`. `M = −A` fits +exactly half the vertices; the residual takes exactly two values, `0` and +`[1,1,1,1,1]`, and the choice is fully determined by `index(coord⁽ᴺ⁾)` (lower band → +0, upper band → carry). That is the recursion. + +## Remaining to complete the full map + +- **The golden-point (new-vertex) rule.** The recursion above transforms a *persistent* + vertex between scales. Deflation also creates new vertices (the golden-section points + on each edge); their ℤ⁵ offset is one finer-lattice edge (`±e_l`), sketched but not + yet formalized/tested. The recursion + this rule give the complete tile-enumeration + with coordinates. +- **T6: face extraction** (for rendering rhombi and the thick:thin = φ proof). + +## The full tested chain + +`research/penrose/cap/`: `cap` (cut-and-project engine + inflation `A`), `deflate` +(reliable deflation), `bridge` (substitution → ℤ⁵ via edge-integration), `fold` (the +closed-form recursion). `bun test ./research/penrose/cap/` — 24 pass, 1 todo. diff --git a/research/penrose/README.md b/research/penrose/README.md new file mode 100644 index 0000000..7377085 --- /dev/null +++ b/research/penrose/README.md @@ -0,0 +1,57 @@ +# Penrose — Research + +The pre-implementation exercises behind the [Penrose experiment](../../src/app/x/penrose/). Three substrate-level questions answered with small Bun scripts before any explorer code lands. Each answers one decision the shipped `lib/` would otherwise have to guess. + +The pattern mirrors [`research/prime-moments`](../prime-moments/) and [`research/tripwire`](../tripwire/): public, sitemap-omitted, README + scripts + short findings. + +## Origin + +Penrose's P3 (thick + thin rhombi) tiles the plane aperiodically. The de Bruijn pentagrid construction lets us address any tile by an integer 5-tuple `(k0, k1, k2, k3, k4)` and answer `point → tile` in O(1). The shipped experiment is an infinite-canvas explorer of that tiling. + +The design constraint is `100% correctness at any size` — the explorer's pentagrid coords must be exact at any magnitude a user can pan to. Float64 is therefore the candidate that needs justification, not the default. Five substrate questions, all framed against an exact oracle: + +1. **Precision drift.** Where does Float64 `pointToCoord` disagree with a high-precision BigInt-algebraic oracle, and what does the oracle cost per call? Sets the addressing-layer requirement (Coord type) and prices any render-only Float64 fast path. +2. **URL share-link codec.** Given BigInt coord elements, which encoding produces the shortest share-link? +3. **Enumeration cost.** How fast is the de Bruijn pentagrid enumerator, Float64 vs BigInt-exact? Decides whether exact-throughout fits the per-frame budget. +4. **Viewport anchor.** Does a BigInt-truth / Float64-view pattern (BigInt anchor + Float64 offset, re-anchored when offset grows) preserve both 60 fps enumeration and exact addressing at any anchor magnitude? +5. **Go runtime.** Does Go's `math/big` give meaningfully better BigInt throughput than JS BigInt for the enumeration hot path? Informs whether Go-WASM or server-side precomputation are worth pursuing. + +Each script writes its numbers to stdout. The sibling `.md` writeups summarize. + +## The three questions + +| # | Question | Script | Findings | +| - | ---------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| 1 | Precision drift: where does Float64 disagree with exact? | [`01-coord-representation.ts`](./01-coord-representation.ts) | [`01-coord-representation.md`](./01-coord-representation.md) | +| 2 | URL coord encoding: base62 vs others (needs BigInt rerun) | [`02-url-encoding.ts`](./02-url-encoding.ts) | [`02-url-encoding.md`](./02-url-encoding.md) | +| 3 | Enumeration cost: Float64 vs BigInt-exact | [`03-enumeration-cost.ts`](./03-enumeration-cost.ts) | [`03-enumeration-cost.md`](./03-enumeration-cost.md) | +| 4 | Viewport anchor: BigInt truth, Float64 view | [`04-viewport-anchor.ts`](./04-viewport-anchor.ts) | [`04-viewport-anchor.md`](./04-viewport-anchor.md) | +| 5 | Go runtime: is `math/big` worth it? | [`go/cmd/bench`](./go/cmd/bench/main.go) | [`go/README.md`](./go/README.md) | + +## Pentagrid in one paragraph + +Five unit vectors `e_j = (cos(2πj/5), sin(2πj/5))` for `j ∈ {0..4}`. A seed-derived phase `γ_j` per direction, normalized so `Σ γ_j = 0`. For any world point `p`, the pentagrid coord is `(floor(p · e_j + γ_j))` for `j ∈ {0..4}`. Two tiles share an edge iff their coords differ by one in a single index. The de Bruijn construction maps each pentagrid coord to a P3 rhombus tile in the Penrose tiling. That's the whole address space. + +## Running + +```sh +bun run research/penrose/01-coord-representation.ts +bun run research/penrose/02-url-encoding.ts +bun run research/penrose/03-enumeration-cost.ts +bun run research/penrose/04-viewport-anchor.ts + +# Go experiment, separate toolchain: +cd research/penrose/go && go run ./cmd/bench +``` + +Each script is self-contained. Numbers in the `.md` writeups are captured from a single run on the maintainer's machine; rerun to refresh. + +## What this folder is not + +No implementation code. No shared helpers with `src/app/x/penrose/lib/`. A little duplication keeps each script honest. The shipped `lib/` cites these writeups in one-line comments where the decision matters. + +## References + +- N. G. de Bruijn, *Algebraic theory of Penrose's non-periodic tilings of the plane* (1981). +- Roger Penrose's original P3 (kites & darts → thick & thin rhombi). +- Tony Smith's notes on the pentagrid → rhombus mapping. diff --git a/research/penrose/STATUS.md b/research/penrose/STATUS.md new file mode 100644 index 0000000..b1c94b1 --- /dev/null +++ b/research/penrose/STATUS.md @@ -0,0 +1,140 @@ +# Penrose research — status: verified vs open + +A living summary of where the cut-and-project / substitution engine and the +address↔coordinate work stand. Separates what is **proven (by runnable test)** from +what is **still open**. The numbered notes (`05`–`09`) carry the detail; the tested +code is in `research/penrose/cap/`. + +Run the proofs: `bun test ./research/penrose/cap/` → **34 pass, 0 fail**. + +## Verified (tested) + +- **The exact acceptance window** is the four pentagons `K₁=P, K₂=−τP, K₃=τP, K₄=−P` + by index `Σn ∈ {1,2,3,4}` (Cotfas; two formalisms agree). Filtering ℤ⁵ through it + generates a correct Penrose tiling — every unit edge on a `ζˡ` direction, internal + projections bounded. (`cap.ts`, `07`.) +- **Inflation `A` is exactly the φ-inflation** — physical `×(−φ)`, internal `×(1/φ)`, + index `×2`, to 9 decimals. `A` is Cotfas's operator. (`cap.ts`.) +- **Deflation is reliable at every level** — the substitution gives a valid Penrose + tiling at each depth: color ratio → φ, tiles isoceles, exact `×1/φ` contraction, + count `×φ²`, deterministic. (`deflate.ts`, `05`.) So both directions hold. +- **The bridge**: the substitution tiling lifts to ℤ⁵ by edge-integration; the lift + is path-independent (rhombi close), the indices obey the de Bruijn index theorem + (4 consecutive values), and the internal projections fill the four-pentagon window. + The two independent constructions are the same object, and every substitution tile + gets its de Bruijn coordinate. (`bridge.ts`, `08`.) +- **The closed-form coordinate recursion** `coord' = −A·coord + m·[1,1,1,1,1]`, with + `m = ⌈(1+2·index)/5⌉` forced by the index landing in `{1,2,3,4}`. `[1,1,1,1,1]` is + forced: `A`'s eigenvalue-2 eigenvector and the kernel of both projections (the + unique index gauge). Holds at every level pair (3→4 … 6→7), not a fit. (`fold.ts`, + `09`.) +- **Face extraction is exact.** A 2-face `[n;j,k]` is a tile iff all four corners are + accepted vertices — validated tile-for-tile against the substitution (no phantoms, + none missing, types agree, thick:thin → φ). (`faces.ts`.) +- **The golden-point rule completes coordinate-space deflation.** A deflation-created + vertex on edge `(A,B)` in direction `l` is exactly `goldenPoint(A,l) = fold(A) + eₗ`, + proven against the lift. So deflation runs entirely in ℤ⁵: existing vertices by the + fold, new vertices by `fold(A)+eₗ`, faces by corner-acceptance. (`fold.ts`.) + +What this amounts to: a **tested reimplementation of classical de Bruijn theory**. The +engine reproduces, by runnable test, results that are standard in the literature — +de Bruijn 1981 (pentagrid + window), de Bruijn 1990 (updown / composition recurrence), +and D'Andrea 2023 (the modern textbook proofs). It is local, O(log)-per-tile, exact +integer. It is **not novel mathematics.** See "Provenance" below for the line-by-line +correspondence. The value is engineering and verification: a correct, test-backed +substitution-address → de-Bruijn-coordinate map we can build the explorer on. + +## Open / unsolved (still working on) + +### Math / the map +- **Self-contained canonical frame.** The fold currently reads the target band's min + from the lift. A fully self-contained absolute frame (solve the reference vertex's + de Bruijn coordinate once, so the band is always `{1,2,3,4}` without a lift) would + remove the dependency. The rule itself is forced and universal; this is bookkeeping. +- **Address-as-path → coordinate, composed.** The digit *sequence* (hierarchy path) is + D'Andrea's **index sequence** `ι(T,z₀) ∈ {0,1}^ℕ` (§3.4, Def 3.24): 0 = large + triangle, 1 = gnomon, at each composition level. Known and classified (Prop 3.27: + tilings/isometry → sequences/tail-equivalence is 2-to-1; Lemma 3.26: no two + consecutive 1s = the golden-mean shift, which is *why* color-ratio → φ). It pins the + tiling only up to isometry — it forgets the absolute frame. The one thing we add is + anchoring that path to an absolute ℤ⁵ coordinate via the fold. That is wiring two + known constructions together, not new math. Worth formalizing for O(log) addressing. + +### Literature: retrieved, verdict in +- **de Bruijn 1990 "Updown generation"** and **D'Andrea 2023 §5.3 "Composition and + Pentagrids"** are now read. Verdict: our closed form does **not** extend theirs — it + *is* theirs. D'Andrea **Theorem 5.16** is our fold exactly (`coord' = −A·coord + + m·ones` = Φ via the cyclotomic identity `C = J − A` and `m = index − c`; the carry + `{0,1,1,2}` we re-derived is Theorem 5.16's carry). Details in Provenance below. + +### Rendering / engineering +- **BigInt deep-zoom path.** The recursion is exact integer; wiring it to BigInt for + unbounded distance (and the pruned-deflation viewport generation) is unbuilt. + +### Downstream (not math-open) +- **Explorer integration**: wire this engine into `/x/penrose` (the v1 we started + from), replacing the buggy viewport-anchor. Spec rewritten around the cut-and-project / + substitution engine: `docs/superpowers/specs/2026-06-24-penrose-v1-design.md` (bounded + explorer first, then the teaching spine; infinite pan is v2). The old + `2026-06-23-penrose-v1-design.md` is superseded. +- **Writeup**: a teaching note, not a paper. The math is classical (de Bruijn, + D'Andrea); the writeup's job is to explain it well and to document that our engine + reproduces it under test. No novelty claim. + +## Provenance / citations + +Every result below is classical. The right-hand column is the primary source; the +left is where we reproduce it under test. + +- **The four-pentagon window by index** (`cap.ts`, `07`) — de Bruijn 1981 §8; Cotfas; + D'Andrea 2023 **Prop 5.15** (`P₂=−φP₁, P₃=φP₁, P₄=−P₁`, = our `SCALE_BY_INDEX`). +- **Inflation `A`, eigenvalues −φ / 1/φ / 2** (`cap.ts`) — Cotfas's operator; the + composition matrix `C = I+S+S⁻¹` of D'Andrea **Thm 5.16** is `J − A`. +- **The closed-form fold** `coord' = −A·coord + m·ones`, `m = ⌈(1+2·index)/5⌉` + (`fold.ts`, `09`) — D'Andrea 2023 **Theorem 5.16** (composition map Φ), equivalently + de Bruijn 1990 §3.12. The carry `m = index − c`, `c ∈ {0,1,1,2}`, is Thm 5.16's `c`. +- **The bridge** (substitution lifts to ℤ⁵; index theorem; bounded internal) + (`bridge.ts`, `08`) — de Bruijn 1981/1990; D'Andrea 2023 **§5.3** (Prop 5.14, the + cut-and-project characterization `f_γ(n̄) ∈ Im(g)`). +- **Deflation / color ratio → φ** (`deflate.ts`, `05`) — Robinson substitution; + D'Andrea 2023 **§3.4** Lemma 3.26 (golden-mean shift) + **§3.5** (density). +- **Index sequence = the hierarchy path** — D'Andrea 2023 **§3.4** Def 3.24, + Prop 3.27 (2-to-1 onto tail-equivalence classes). The doorway to Ch 6 (Connes' + noncommutative space), which we did **not** touch. +- **Terminology**: two distinct "index" objects. de Bruijn pentagrid index + `σ = Σn_l ∈ {1,2,3,4}` (Ch 5, our `index()`) vs Robinson index *sequence* + `ι(T,z₀)` (Ch 3, the path). Do not conflate. + +Full citations: +- N.G. de Bruijn, "Algebraic theory of Penrose's non-periodic tilings of the plane, + I & II," Indag. Math. 43 (1981), 39–66. +- N.G. de Bruijn, "Updown generation of Penrose patterns," Indag. Math. 1 (1990), + 201–219. +- F. D'Andrea, *A Guide to Penrose Tilings*, Springer (2023). Ch 3 (Robinson + triangles, index sequences), Ch 5 (pentagrids, composition), Ch 6 (noncommutative + space). + +On arXiv:2603.13553 (Pardo-Guerra/Washburn/Allahyarov): we do **not** claim to settle +their open problem. Their conjecture is broader (cohomological/general). We have a +tested map for the substitution case, which is exactly the classical de Bruijn theory +above — not a proof of their general statement. + +## Corrections on the record (for honesty) +- The de Bruijn viewport-anchor in the original explorer does **not** preserve the + tiling across a re-anchor (any nonzero shift) — a real bug; the engine was replaced, + not patched. (`05`.) +- The first version of the fold's carry keyed off the *source* band and held at only + two of four level pairs — a frame coincidence caught by a skeptical re-check. The + forced target/canonical rule is universal. (`09`.) +- Earlier drafts framed the fold and bridge as a "narrow, publishable contribution on + an open frontier." That was wrong. Reading de Bruijn 1990 and D'Andrea 2023 (esp. + Thm 5.16) showed the results are classical and standard. The framing is corrected + throughout: a tested reimplementation, not new mathematics. + +## Map of the work +- Notes: `05`-substitution-and-z5, `06`-addressing-and-applications, `07`-cut-and- + project-window, `08`-the-bridge, `09`-the-fold. +- Code: `cap/{cap,deflate,bridge,fold,faces}.ts` (+ `.test.ts`). 34 tests pass. +- Tasks #6 (face extraction) and #7 (golden-point rule) are now done; the math/engine + is complete and tested. What remains is bookkeeping (canonical frame, path + composition) and engineering (BigInt deep-zoom, explorer integration, the writeup). diff --git a/research/penrose/go/README.md b/research/penrose/go/README.md new file mode 100644 index 0000000..0227ca9 --- /dev/null +++ b/research/penrose/go/README.md @@ -0,0 +1,91 @@ +# Pentagrid — Go experiment + +A Go port of two TypeScript benchmarks, built to answer two questions: + +1. Does Go's `math/big` give meaningfully better BigInt throughput than JS BigInt for full exact enumeration? (Mirrors [`../03-enumeration-cost.ts`](../03-enumeration-cost.ts).) +2. Does Go beat JS on the viewport-anchor pattern, where the hot path is mostly Float64 and only the per-tile BigInt-add scales with anchor magnitude? (Mirrors [`../04-viewport-anchor.ts`](../04-viewport-anchor.ts).) + +The TypeScript code in `../` is the explorer's actual runtime. This Go module is a research artifact, kept around because the numbers are informative for two downstream questions: (a) is Go-WASM worth the bundle weight on the client, (b) is server-side tile precomputation feasible as a Go endpoint. + +## Layout + +``` +go.mod +pentagrid.go math, init constants, EnumerateExact, MakeAnchor, EnumerateAnchored +cmd/bench/main.go mirrors 03-enumeration-cost.ts (exact enumeration, varying rects) +cmd/benchanchor/main.go mirrors 04-viewport-anchor.ts (anchored, varying anchor magnitudes) +``` + +`pentagrid.go` mirrors the TS oracle in structure: SCALE = 10⁵⁰, `√5` via `big.Int.Sqrt`, cosine and sine derived algebraically, gamma via the same FNV-1a hash. The seed and constants are bit-identical across languages. + +## Running + +```sh +cd research/penrose/go +go run ./cmd/bench +go run ./cmd/benchanchor +``` + +## Numbers — exact enumeration (`go run ./cmd/bench`) + +``` +size rect tiles Go mean Go p95 +small 12×8 381 3.61ms 4.90ms +medium 24×14 1315 10.91ms 11.63ms +large 36×22 3092 24.09ms 25.23ms +x-large 48×30 5583 44.42ms 51.10ms +``` + +Side-by-side at 1315 tiles: + +| backend | mean | p95 | per-tile | +| ---------------------------------- | ------- | ------- | --------- | +| JS Float64 | 1.89 ms | 3.11 ms | 1.44 µs | +| JS viewport-anchor (anchor=0) | 3.04 ms | 6.04 ms | 2.31 µs | +| Go viewport-anchor (anchor=0) | 2.78 ms | 3.43 ms | 2.12 µs | +| Go math/big (exact) | 10.91 ms | 11.63 ms | 8.30 µs | +| JS BigInt (exact) | 29.05 ms | 35.64 ms | 22.1 µs | + +## Numbers — viewport anchor (`go run ./cmd/benchanchor`) + +``` +correctness (anchor=0): anchored=1315 exact=1315 equal=true + +anchor_mag tiles mean_ms p95_ms +0 1315 2.78ms 3.43ms +1e5 1302 3.17ms 4.38ms +1e10 1316 3.02ms 3.49ms +1e20 1306 3.90ms 4.39ms +1e30 1319 4.25ms 4.57ms +1e40 1322 4.89ms 5.23ms + +MakeAnchor at |a|=1e20: 3.40 µs/call +``` + +Side-by-side, viewport-anchor pattern, same workload: + +| anchor mag | JS mean | Go mean | Go advantage | +| ---------- | ------- | ------- | ------------ | +| 0 | 3.04 ms | 2.78 ms | 1.09× | +| 1e10 | 2.87 ms | 3.02 ms | (JS faster) | +| 1e20 | 5.02 ms | 3.90 ms | 1.29× | +| 1e30 | 6.30 ms | 4.25 ms | 1.48× | +| 1e40 | 7.15 ms | 4.89 ms | 1.46× | + +## Interpretation + +**Exact enumeration.** Go `math/big` is ~2.7× faster than JS BigInt for the all-BigInt hot path. Real speedup but smaller than folklore suggests — the inner loop is BigInt-bound rather than allocation-bound, and JS BigInt does well at allocation reuse via inline caching. Still ~5.8× slower than JS Float64. + +**Viewport anchor.** JS and Go are essentially tied at small anchor magnitudes (Float64 inner loop dominates, both JITs/AOT compile it well). Go pulls ahead by 1.3-1.5× at large anchor magnitudes (≥1e20) because the per-tile BigInt-add for offset→absolute coord conversion is faster in Go. Both stay comfortably under the 16 ms / 60 fps budget at every magnitude tested. + +## Implications + +**Go-WASM in the client: not worth it.** The anchored pattern already gives 60 fps in pure JS, and the Go advantage at large anchors (1.5×) is irrelevant when JS is already at 7 ms. Add WASM's typical 1.5-2× slowdown vs native Go, ~500 KB tinygo bundle, and the cross-language toolchain cost, and the math doesn't add up. + +**Go for server-side precomputation: still the right tool if we ever ship it.** A 3000-tile region renders in ~24 ms native Go (exact, no anchor). A Vercel function returning precomputed tile geometry for landing-page illustrations is a clean win: zero client-side math, CDN-cacheable JSON, exact addressing baked in. The interactive explorer still wants client-side enumeration for tactile pan/zoom, but static illustrations don't need the same architecture. + +**The viewport-anchor pattern wins regardless of language.** Whatever runtime we pick, the architecture (`BigInt truth, Float64 view`) is what makes 60 fps + exact-at-any-size possible. The 1.5× language gap at large anchors doesn't change the design. + +## Citation + +If the maintainer ever decides to add a server-side precompute endpoint for landing illustrations, the Go pentagrid in `pentagrid.go` is the seed of that work — port `EnumerateExact` to a tile-geometry exporter, wrap in a Vercel function, ship a static `tiles.json` artifact. diff --git a/research/penrose/go/cmd/bench/main.go b/research/penrose/go/cmd/bench/main.go new file mode 100644 index 0000000..5795435 --- /dev/null +++ b/research/penrose/go/cmd/bench/main.go @@ -0,0 +1,66 @@ +// Command bench mirrors the structure of research/penrose/03-enumeration-cost.ts +// in Go. Same seed, same rect sizes, same 50-iteration loop. The point is to +// see whether Go's math/big is meaningfully faster than JS BigInt for the +// pentagrid enumeration hot path. +// +// Run from research/penrose/go: +// +// go run ./cmd/bench +package main + +import ( + "fmt" + "sort" + "time" + + "github.com/funcimp/func.lol/research/penrose" +) + +type row struct { + name string + rect pentagrid.Rect +} + +func main() { + rows := []row{ + {"small", pentagrid.Rect{X0: -6, Y0: -4, X1: 6, Y1: 4}}, + {"medium", pentagrid.Rect{X0: -12, Y0: -7, X1: 12, Y1: 7}}, + {"large", pentagrid.Rect{X0: -18, Y0: -11, X1: 18, Y1: 11}}, + {"x-large", pentagrid.Rect{X0: -24, Y0: -15, X1: 24, Y1: 15}}, + } + + gamma, _ := pentagrid.GammaFromSeed("funclol") + + // Warm up. + for i := 0; i < 3; i++ { + pentagrid.EnumerateExact(gamma, rows[1].rect) + } + + fmt.Println("seed=funclol scale=10^50 (Go math/big)") + fmt.Println() + fmt.Println("size rect tiles mean_ms p95_ms") + fmt.Println("------- -------- ------ -------- --------") + + const N = 50 + const p95Idx = N * 95 / 100 + for _, r := range rows { + times := make([]float64, 0, N) + var count int + for i := 0; i < N; i++ { + t0 := time.Now() + count = pentagrid.EnumerateExact(gamma, r.rect) + times = append(times, float64(time.Since(t0).Nanoseconds())/1e6) + } + sort.Float64s(times) + var sum float64 + for _, t := range times { + sum += t + } + mean := sum / float64(N) + p95 := times[p95Idx] + w := r.rect.X1 - r.rect.X0 + h := r.rect.Y1 - r.rect.Y0 + fmt.Printf("%-7s %-8s %-6d %5.2fms %5.2fms\n", + r.name, fmt.Sprintf("%dx%d", w, h), count, mean, p95) + } +} diff --git a/research/penrose/go/cmd/benchanchor/main.go b/research/penrose/go/cmd/benchanchor/main.go new file mode 100644 index 0000000..b693ecf --- /dev/null +++ b/research/penrose/go/cmd/benchanchor/main.go @@ -0,0 +1,91 @@ +// Command benchanchor mirrors research/penrose/04-viewport-anchor.ts in +// Go. Tests the BigInt-truth / Float64-view pattern at anchor magnitudes +// 0, 1e5, 1e10, 1e20, 1e30, 1e40 and reports throughput per row. +// +// Run from research/penrose/go: +// +// go run ./cmd/benchanchor +package main + +import ( + "fmt" + "math/big" + "sort" + "time" + + "github.com/funcimp/func.lol/research/penrose" +) + +func main() { + gamma, _ := pentagrid.GammaFromSeed("funclol") + rect := pentagrid.Rect{X0: -12, Y0: -7, X1: 12, Y1: 7} + + // Correctness sanity: anchor=(0,0) anchored count should equal + // the BigInt-exact enumerator's count on the same rect. + zero := big.NewInt(0) + anchor0 := pentagrid.MakeAnchor(zero, zero, gamma) + anchoredCount := pentagrid.EnumerateAnchored(anchor0, rect) + exactCount := pentagrid.EnumerateExact(gamma, rect) + fmt.Printf("seed=funclol rect=24×14\n\n") + fmt.Printf("correctness (anchor=0): anchored=%d exact=%d equal=%t\n\n", + anchoredCount, exactCount, anchoredCount == exactCount) + + // Throughput vs anchor magnitude. + fmt.Println("anchor_mag tiles mean_ms p95_ms") + fmt.Println("------------ ------ -------- --------") + + mags := []int{0, 5, 10, 20, 30, 40} + for _, exp := range mags { + var ax, ay *big.Int + if exp == 0 { + ax = big.NewInt(0) + ay = big.NewInt(0) + } else { + ten := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(exp)), nil) + ax = new(big.Int).Mul(ten, pentagrid.Scale) + ay = new(big.Int).Mul(ten, pentagrid.Scale) + } + anchor := pentagrid.MakeAnchor(ax, ay, gamma) + + // Warmup. + for i := 0; i < 5; i++ { + pentagrid.EnumerateAnchored(anchor, rect) + } + + const N = 50 + const p95Idx = N * 95 / 100 + times := make([]float64, 0, N) + var count int + for i := 0; i < N; i++ { + t0 := time.Now() + count = pentagrid.EnumerateAnchored(anchor, rect) + times = append(times, float64(time.Since(t0).Nanoseconds())/1e6) + } + sort.Float64s(times) + var sum float64 + for _, t := range times { + sum += t + } + mean := sum / float64(N) + p95 := times[p95Idx] + var label string + if exp == 0 { + label = "0" + } else { + label = fmt.Sprintf("1e%d", exp) + } + fmt.Printf("%-12s %-6d %5.2fms %5.2fms\n", label, count, mean, p95) + } + + // MakeAnchor cost at large magnitude (mirrors JS Q4). + const NA = 10_000 + tenE20 := new(big.Int).Exp(big.NewInt(10), big.NewInt(20), nil) + ax := new(big.Int).Mul(tenE20, pentagrid.Scale) + ay := new(big.Int).Mul(tenE20, pentagrid.Scale) + t0 := time.Now() + for i := 0; i < NA; i++ { + pentagrid.MakeAnchor(ax, ay, gamma) + } + dt := float64(time.Since(t0).Nanoseconds()) / 1e3 / float64(NA) + fmt.Printf("\nMakeAnchor at |a|=1e20: %.2f µs/call\n", dt) +} diff --git a/research/penrose/go/go.mod b/research/penrose/go/go.mod new file mode 100644 index 0000000..9edd276 --- /dev/null +++ b/research/penrose/go/go.mod @@ -0,0 +1,3 @@ +module github.com/funcimp/func.lol/research/penrose + +go 1.24 diff --git a/research/penrose/go/pentagrid.go b/research/penrose/go/pentagrid.go new file mode 100644 index 0000000..f47efe5 --- /dev/null +++ b/research/penrose/go/pentagrid.go @@ -0,0 +1,372 @@ +// Package pentagrid implements the de Bruijn pentagrid construction for +// Penrose P3 tilings using math/big for arbitrary-precision arithmetic. +// +// This is a research exploration of whether Go's math/big package gives +// meaningfully better BigInt throughput than JS BigInt for the +// pentagrid enumeration hot path. See ../03-enumeration-cost.md for the +// JS numbers this is compared against. +package pentagrid + +import ( + "fmt" + "math" + "math/big" +) + +const ScaleExp = 50 + +var ( + Scale *big.Int + Scale2 *big.Int + scaleF float64 + sqrt5 *big.Int + sqrtTPlus *big.Int + sqrtTMinus *big.Int + + CosHi [5]*big.Int + SinHi [5]*big.Int + CosF [5]float64 + SinF [5]float64 +) + +func init() { + Scale = new(big.Int).Exp(big.NewInt(10), big.NewInt(ScaleExp), nil) + Scale2 = new(big.Int).Mul(Scale, Scale) + scaleF, _ = new(big.Float).SetInt(Scale).Float64() + + // √5 · Scale + sqrt5 = new(big.Int).Sqrt(new(big.Int).Mul(big.NewInt(5), Scale2)) + + // √(10 + 2√5) · Scale and √(10 - 2√5) · Scale + tenScale := new(big.Int).Mul(big.NewInt(10), Scale) + twoSqrt5 := new(big.Int).Mul(big.NewInt(2), sqrt5) + tPlus := new(big.Int).Add(tenScale, twoSqrt5) + tMinus := new(big.Int).Sub(tenScale, twoSqrt5) + sqrtTPlus = new(big.Int).Sqrt(new(big.Int).Mul(tPlus, Scale)) + sqrtTMinus = new(big.Int).Sqrt(new(big.Int).Mul(tMinus, Scale)) + + // cos(2πj/5) values, scaled. + four := big.NewInt(4) + a := new(big.Int).Sub(sqrt5, Scale) // (√5 - 1) + a = floorDiv(a, four) // (√5 - 1)/4 + b := new(big.Int).Neg(new(big.Int).Add(sqrt5, Scale)) // -(√5 + 1) + b = floorDiv(b, four) // -(√5 + 1)/4 + CosHi[0] = new(big.Int).Set(Scale) + CosHi[1] = new(big.Int).Set(a) + CosHi[2] = new(big.Int).Set(b) + CosHi[3] = new(big.Int).Set(b) + CosHi[4] = new(big.Int).Set(a) + + // sin(2πj/5) values, scaled. + sp := floorDiv(sqrtTPlus, four) + sm := floorDiv(sqrtTMinus, four) + SinHi[0] = big.NewInt(0) + SinHi[1] = new(big.Int).Set(sp) + SinHi[2] = new(big.Int).Set(sm) + SinHi[3] = new(big.Int).Neg(sm) + SinHi[4] = new(big.Int).Neg(sp) + + // Float64 versions. + for j := 0; j < 5; j++ { + f, _ := new(big.Float).SetInt(CosHi[j]).Float64() + CosF[j] = f / scaleF + f2, _ := new(big.Float).SetInt(SinHi[j]).Float64() + SinF[j] = f2 / scaleF + } +} + +// floorDiv returns floor(n / d) for d != 0. math/big's Quo truncates +// toward zero; we want floor. +func floorDiv(n, d *big.Int) *big.Int { + q, r := new(big.Int).QuoRem(n, d, new(big.Int)) + if r.Sign() != 0 && (n.Sign() < 0) != (d.Sign() < 0) { + q.Sub(q, big.NewInt(1)) + } + return q +} + +// GammaFromSeed mirrors the JS FNV-1a / mulberry-ish derivation in +// research/penrose/03-enumeration-cost.ts so direct comparisons remain +// apples-to-apples. +func GammaFromSeed(seed string) ([5]*big.Int, [5]float64) { + var h uint32 = 2166136261 + for _, c := range seed { + h ^= uint32(c) + h *= 16777619 + } + var raw [5]*big.Int + for i := 0; i < 5; i++ { + h ^= uint32(i + 1) + h *= 16777619 + // raw[i] = (h * Scale) / 2^32 - Scale/2 + hb := new(big.Int).SetUint64(uint64(h)) + num := new(big.Int).Mul(hb, Scale) + denom := new(big.Int).Lsh(big.NewInt(1), 32) + raw[i] = new(big.Int).Quo(num, denom) + raw[i].Sub(raw[i], new(big.Int).Quo(Scale, big.NewInt(2))) + } + sum := big.NewInt(0) + for i := 0; i < 5; i++ { + sum.Add(sum, raw[i]) + } + shift := new(big.Int).Quo(sum, big.NewInt(5)) + var exact [5]*big.Int + var float [5]float64 + for i := 0; i < 5; i++ { + exact[i] = new(big.Int).Sub(raw[i], shift) + f, _ := new(big.Float).SetInt(exact[i]).Float64() + float[i] = f / scaleF + } + return exact, float +} + +// Rect is in unscaled world units (small ints near origin for the +// benchmark). Internally it is rescaled to Scale for BigInt math. +type Rect struct { + X0, Y0, X1, Y1 int64 +} + +// EnumerateExact returns the count of unique tiles with vertices in the +// rect, computed in BigInt-exact math. +func EnumerateExact(gamma [5]*big.Int, rect Rect) int { + seen := make(map[string]struct{}, 2048) + + gammaF := [5]float64{} + for j := 0; j < 5; j++ { + f, _ := new(big.Float).SetInt(gamma[j]).Float64() + gammaF[j] = f / scaleF + } + + rectX0Big := new(big.Int).Mul(big.NewInt(rect.X0), Scale) + rectY0Big := new(big.Int).Mul(big.NewInt(rect.Y0), Scale) + rectX1Big := new(big.Int).Mul(big.NewInt(rect.X1), Scale) + rectY1Big := new(big.Int).Mul(big.NewInt(rect.Y1), Scale) + + tmp := new(big.Int) + prod1 := new(big.Int) + prod2 := new(big.Int) + num := new(big.Int) + aj := new(big.Int) + ak := new(big.Int) + pxNum := new(big.Int) + pyNum := new(big.Int) + px := new(big.Int) + py := new(big.Int) + det := new(big.Int) + + tup := [5]*big.Int{} + for i := range tup { + tup[i] = new(big.Int) + } + + for j := 0; j < 4; j++ { + for k := j + 1; k < 5; k++ { + ejx, ejy := CosHi[j], SinHi[j] + ekx, eky := CosHi[k], SinHi[k] + ejxF, ejyF := CosF[j], SinF[j] + ekxF, ekyF := CosF[k], SinF[k] + + det.Mul(ejx, eky) + tmp.Mul(ejy, ekx) + det.Sub(det, tmp) + if det.Sign() == 0 { + continue + } + + rx0, rx1 := float64(rect.X0), float64(rect.X1) + ry0, ry1 := float64(rect.Y0), float64(rect.Y1) + pj0 := rx0*ejxF + ry0*ejyF + pj1 := rx1*ejxF + ry0*ejyF + pj2 := rx0*ejxF + ry1*ejyF + pj3 := rx1*ejxF + ry1*ejyF + pk0 := rx0*ekxF + ry0*ekyF + pk1 := rx1*ekxF + ry0*ekyF + pk2 := rx0*ekxF + ry1*ekyF + pk3 := rx1*ekxF + ry1*ekyF + + kjMin := int64(math.Floor(minF(pj0, pj1, pj2, pj3)+gammaF[j])) - 1 + kjMax := int64(math.Ceil(maxF(pj0, pj1, pj2, pj3)+gammaF[j])) + 1 + kkMin := int64(math.Floor(minF(pk0, pk1, pk2, pk3)+gammaF[k])) - 1 + kkMax := int64(math.Ceil(maxF(pk0, pk1, pk2, pk3)+gammaF[k])) + 1 + + for kjN := kjMin; kjN <= kjMax; kjN++ { + kj := big.NewInt(kjN) + aj.Mul(kj, Scale) + aj.Sub(aj, gamma[j]) + for kkN := kkMin; kkN <= kkMax; kkN++ { + kk := big.NewInt(kkN) + ak.Mul(kk, Scale) + ak.Sub(ak, gamma[k]) + + // pxNum = (eky*aj - ejy*ak) * Scale + prod1.Mul(eky, aj) + prod2.Mul(ejy, ak) + pxNum.Sub(prod1, prod2) + pxNum.Mul(pxNum, Scale) + + // pyNum = (ejx*ak - ekx*aj) * Scale + prod1.Mul(ejx, ak) + prod2.Mul(ekx, aj) + pyNum.Sub(prod1, prod2) + pyNum.Mul(pyNum, Scale) + + px.Set(floorDivInto(px, pxNum, det)) + py.Set(floorDivInto(py, pyNum, det)) + + if px.Cmp(rectX0Big) < 0 || px.Cmp(rectX1Big) > 0 || + py.Cmp(rectY0Big) < 0 || py.Cmp(rectY1Big) > 0 { + continue + } + + for l := 0; l < 5; l++ { + if l == j { + tup[l].Set(kj) + } else if l == k { + tup[l].Set(kk) + } else { + // floor((px*COS_HI[l] + py*SIN_HI[l] + gamma[l]*Scale) / Scale²) + prod1.Mul(px, CosHi[l]) + prod2.Mul(py, SinHi[l]) + num.Add(prod1, prod2) + prod1.Mul(gamma[l], Scale) + num.Add(num, prod1) + tup[l].Set(floorDivInto(tup[l], num, Scale2)) + } + } + key := fmt.Sprintf("%s,%s,%s,%s,%s", tup[0], tup[1], tup[2], tup[3], tup[4]) + seen[key] = struct{}{} + } + } + } + } + return len(seen) +} + +// floorDivInto writes floor(n / d) into dst and returns it. +func floorDivInto(dst, n, d *big.Int) *big.Int { + dst.QuoRem(n, d, scratch) + if scratch.Sign() != 0 && (n.Sign() < 0) != (d.Sign() < 0) { + dst.Sub(dst, oneBig) + } + return dst +} + +var ( + scratch = new(big.Int) + oneBig = big.NewInt(1) +) + +func minF(a, b, c, d float64) float64 { + return math.Min(math.Min(a, b), math.Min(c, d)) +} +func maxF(a, b, c, d float64) float64 { + return math.Max(math.Max(a, b), math.Max(c, d)) +} + +// Anchor holds an exact world position plus precomputed per-direction +// projections. The anchor's integer projection (NProj) is BigInt; +// the fractional remainder (FProj) is Float64 in [0, 1) and feeds the +// per-frame enumeration loop as γ_eff. Per-tile absolute coords are +// built by adding NProj[j] to the offset-frame floor result. +type Anchor struct { + X, Y *big.Int + NProj [5]*big.Int + FProj [5]float64 +} + +// MakeAnchor computes the projections for a given world position. +// Call once per re-anchor; cheap to throw away when the offset grows +// past the precision threshold and a new anchor is picked. +func MakeAnchor(x, y *big.Int, gamma [5]*big.Int) *Anchor { + a := &Anchor{X: new(big.Int).Set(x), Y: new(big.Int).Set(y)} + scale2F, _ := new(big.Float).SetInt(Scale2).Float64() + for j := 0; j < 5; j++ { + proj := new(big.Int).Mul(x, CosHi[j]) + t := new(big.Int).Mul(y, SinHi[j]) + proj.Add(proj, t) + t.Mul(gamma[j], Scale) + proj.Add(proj, t) + + n := floorDiv(proj, Scale2) + remainder := new(big.Int).Mul(n, Scale2) + remainder.Sub(proj, remainder) + // remainder is in [0, Scale²); convert to Float64 in [0, 1). + rf, _ := new(big.Float).SetInt(remainder).Float64() + a.NProj[j] = n + a.FProj[j] = rf / scale2F + } + return a +} + +// EnumerateAnchored counts unique tiles in the offset-relative rect. +// The inner loop is pure Float64 with γ_eff = anchor.FProj; for each +// found tile, the absolute pentagrid coord is anchor.NProj[j] + the +// Float64-derived offset coord. Dedup key is the absolute 5-tuple, to +// match the JS Q4 benchmark for fair comparison. +func EnumerateAnchored(anchor *Anchor, rect Rect) int { + seen := make(map[string]struct{}, 2048) + gamma := anchor.FProj + + rx0, rx1 := float64(rect.X0), float64(rect.X1) + ry0, ry1 := float64(rect.Y0), float64(rect.Y1) + + tmpAbs := [5]*big.Int{} + for i := range tmpAbs { + tmpAbs[i] = new(big.Int) + } + + for j := 0; j < 4; j++ { + for k := j + 1; k < 5; k++ { + ejx, ejy := CosF[j], SinF[j] + ekx, eky := CosF[k], SinF[k] + det := ejx*eky - ejy*ekx + if math.Abs(det) < 1e-12 { + continue + } + invDet := 1 / det + + pj0 := rx0*ejx + ry0*ejy + pj1 := rx1*ejx + ry0*ejy + pj2 := rx0*ejx + ry1*ejy + pj3 := rx1*ejx + ry1*ejy + pk0 := rx0*ekx + ry0*eky + pk1 := rx1*ekx + ry0*eky + pk2 := rx0*ekx + ry1*eky + pk3 := rx1*ekx + ry1*eky + + kjMin := int64(math.Floor(minF(pj0, pj1, pj2, pj3)+gamma[j])) - 1 + kjMax := int64(math.Ceil(maxF(pj0, pj1, pj2, pj3)+gamma[j])) + 1 + kkMin := int64(math.Floor(minF(pk0, pk1, pk2, pk3)+gamma[k])) - 1 + kkMax := int64(math.Ceil(maxF(pk0, pk1, pk2, pk3)+gamma[k])) + 1 + + for kj := kjMin; kj <= kjMax; kj++ { + aj := float64(kj) - gamma[j] + for kk := kkMin; kk <= kkMax; kk++ { + ak := float64(kk) - gamma[k] + px := (eky*aj - ejy*ak) * invDet + py := (-ekx*aj + ejx*ak) * invDet + if px < rx0 || px > rx1 || py < ry0 || py > ry1 { + continue + } + var o [5]int64 + for l := 0; l < 5; l++ { + switch l { + case j: + o[l] = kj + case k: + o[l] = kk + default: + o[l] = int64(math.Floor(px*CosF[l] + py*SinF[l] + gamma[l])) + } + } + for l := 0; l < 5; l++ { + tmpAbs[l].Add(anchor.NProj[l], big.NewInt(o[l])) + } + key := fmt.Sprintf("%s,%s,%s,%s,%s", tmpAbs[0], tmpAbs[1], tmpAbs[2], tmpAbs[3], tmpAbs[4]) + seen[key] = struct{}{} + } + } + } + } + return len(seen) +} diff --git a/src/app/globals.css b/src/app/globals.css index 65b6d98..c779c8d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -16,11 +16,19 @@ --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: var(--font-jetbrains-mono), ui-monospace, "SF Mono", Menlo, monospace; - /* Constellation ring — the only color on the site */ + /* Constellation ring. The site's only hues. Experiments may reuse + them through scoped tokens (e.g. --color-penrose-* below); no new + hue enters the language. */ --color-moment-1: #C89B3C; --color-moment-2: #C64F3C; --color-moment-3: #8B4670; --color-moment-4: #3E6B7C; + + /* Penrose explorer roles. Reuse constellation hues. Thick rhombus = + gold (moment-1), thin rhombus = teal (moment-4). Grout reuses + --color-paper; the pin ring reuses --color-ink. */ + --color-penrose-thick: #C89B3C; + --color-penrose-thin: #3E6B7C; } /* Dark-mode override. The server-rendered layout sets @@ -30,6 +38,10 @@ --color-paper: #0f0e0c; --color-ink: #ede9d8; --color-subtle: #1c1a16; + + /* Teal nudged lighter for contrast on the dark paper. Thick (gold) is + identical in both modes. */ + --color-penrose-thin: #4f7d92; } /* ============================================================ diff --git a/src/app/x/page.tsx b/src/app/x/page.tsx index 596c519..cd7e663 100644 --- a/src/app/x/page.tsx +++ b/src/app/x/page.tsx @@ -18,6 +18,17 @@ type Lab = { }; const labs: Lab[] = [ + { + slug: "penrose", + title: "Penrose", + blurb: + "A Penrose tiling you can pan forever, generated on the fly for whatever you are looking at, every tile carrying its exact coordinate.", + publishedAt: "2026-05-11", + links: { + github: + "https://github.com/funcimp/func.lol/tree/main/research/penrose", + }, + }, { slug: "tripwire", title: "Tripwire", @@ -42,6 +53,18 @@ const labs: Lab[] = [ }, ]; +// The experiment's number is its position in publication order: oldest is 01. It is +// derived from the labs data, never hand-numbered, so adding or reordering an +// experiment renumbers the rest automatically. Returns the 1-based index of `slug`, +// or 0 if the slug is unknown (no experiment should render a zero, so a caller that +// sees one has a bad slug). +export function experimentNumber(slug: string): number { + const ordered = [...labs].sort((a, b) => + a.publishedAt.localeCompare(b.publishedAt), + ); + return ordered.findIndex((lab) => lab.slug === slug) + 1; +} + export default function LabsIndexPage() { return (
diff --git a/src/app/x/penrose/_components/AddressWalk.tsx b/src/app/x/penrose/_components/AddressWalk.tsx new file mode 100644 index 0000000..1a6747e --- /dev/null +++ b/src/app/x/penrose/_components/AddressWalk.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Sketch from "./Sketch"; +import { buildPatch, type SketchTile } from "./lib/cutProject"; +import { buildEdgeWalk, DIRS, type EdgeWalk } from "./lib/address"; + +// "Every tile knows its address": the spine's section-8 sketch. The address is the +// tile's ℤ⁵ coordinate, five integers. Every edge of the tiling is a unit step in one +// of five fixed directions, so you can WALK to any tile along its edges. The reveal: +// first the blank route, tracing edge by edge from the origin tile to the target, +// with the tiles hidden. Then the tiling fades in around it, and because the route ran +// along real edges, it lines up exactly with the grid. +// +// Bound to address.ts (and address.test.ts): the route is a breadth-first path on the +// real edge graph of the cut-and-project patch; every segment is a genuine unit-edge +// in one of the five directions, and it ends on the target tile's vertex. +// +// Canvas: the harness drives render(t); t traces the route then fades in the tiling. + +const VB_W = 620; +const VB_H = 540; +const PAD = 28; + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; paper: string; ink: string }; +type Pt = readonly [number, number]; + +function makeFit(walk: EdgeWalk, patch: SketchTile[]) { + let minX = 0; + let minY = 0; + let maxX = 0; + let maxY = 0; + const note = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + for (const t of patch) for (const [x, y] of t.physical) note(x, y); + for (const [x, y] of walk.path) note(x, y); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const half = Math.max(maxX - minX, maxY - minY) / 2 + 0.4; + const s = Math.min((VB_W - 2 * PAD) / (2 * half), (VB_H - 2 * PAD - 40) / (2 * half)); + return (p: Pt): [number, number] => [ + VB_W / 2 + (p[0] - cx) * s, + VB_H / 2 + 18 - (p[1] - cy) * s, // room for the address row up top + ]; +} + +function caption( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = "11px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +function arrowHead(ctx: CanvasRenderingContext2D, from: [number, number], to: [number, number], ink: string) { + const ang = Math.atan2(to[1] - from[1], to[0] - from[0]); + const len = 9; + ctx.save(); + ctx.fillStyle = ink; + ctx.beginPath(); + ctx.moveTo(to[0], to[1]); + ctx.lineTo(to[0] - len * Math.cos(ang - 0.4), to[1] - len * Math.sin(ang - 0.4)); + ctx.lineTo(to[0] - len * Math.cos(ang + 0.4), to[1] - len * Math.sin(ang + 0.4)); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +function paint( + ctx: CanvasRenderingContext2D, + t: number, + walk: EdgeWalk, + patch: SketchTile[], + colors: Colors, +) { + const { thick, thin, paper, ink } = colors; + const fit = makeFit(walk, patch); + const E = walk.edgeDirs.length; + + // Phase 1: trace the route along edges. Phase 2: the tiling fades in around it. + const walkP = Math.max(0, Math.min(1, t / 0.58)); + const shown = Math.max(0, Math.min(E, Math.round(walkP * E))); + const fade = Math.max(0, Math.min(1, (t - 0.62) / 0.38)); + + ctx.clearRect(0, 0, VB_W, VB_H); + ctx.fillStyle = paper; + ctx.fillRect(0, 0, VB_W, VB_H); + + // The surrounding tiling, faded in. + if (fade > 0) { + for (const tl of patch) { + ctx.beginPath(); + tl.physical.forEach((c, i) => { + const [x, y] = fit(c); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.save(); + ctx.globalAlpha = fade * 0.8; + ctx.fillStyle = tl.type === "thick" ? thick : thin; + ctx.fill(); + ctx.globalAlpha = fade * 0.5; + ctx.lineWidth = 0.7; + ctx.strokeStyle = ink; + ctx.stroke(); + ctx.restore(); + } + // The target tile, highlighted where the route lands. + ctx.beginPath(); + walk.targetCorners.forEach((c, i) => { + const [x, y] = fit(c); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.save(); + ctx.globalAlpha = fade; + ctx.fillStyle = walk.targetType === "thick" ? thick : thin; + ctx.fill(); + ctx.lineWidth = 2.2; + ctx.strokeStyle = ink; + ctx.stroke(); + ctx.restore(); + } + + // The five direction rays from the start vertex: the edge directions the route uses. + const o = fit(walk.start.p); + const curDir = shown > 0 ? walk.edgeDirs[shown - 1] : -1; + for (let l = 0; l < 5; l++) { + const tipData: Pt = [walk.start.p[0] + DIRS[l][0], walk.start.p[1] + DIRS[l][1]]; + const tip = fit(tipData); + const lit = l === curDir && fade < 0.5; + ctx.save(); + ctx.globalAlpha = (lit ? 0.7 : 0.28) * (1 - fade * 0.7); + ctx.strokeStyle = ink; + ctx.lineWidth = lit ? 1.8 : 1; + ctx.setLineDash(lit ? [] : [3, 3]); + ctx.beginPath(); + ctx.moveTo(o[0], o[1]); + ctx.lineTo(tip[0], tip[1]); + ctx.stroke(); + ctx.restore(); + caption(ctx, String(l), tip[0] + (tip[0] - o[0]) * 0.18, tip[1] + (tip[1] - o[1]) * 0.18, ink, 0.45 * (1 - fade * 0.6)); + } + + // The route: a bold ink polyline with a paper casing so it reads over the tiles. + if (shown > 0) { + for (const [w, col] of [[5.5, paper], [2.6, ink]] as const) { + ctx.save(); + ctx.beginPath(); + const p0 = fit(walk.path[0]); + ctx.moveTo(p0[0], p0[1]); + for (let i = 1; i <= shown; i++) { + const [x, y] = fit(walk.path[i]); + ctx.lineTo(x, y); + } + ctx.lineWidth = w; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.strokeStyle = col; + ctx.stroke(); + ctx.restore(); + } + arrowHead(ctx, fit(walk.path[shown - 1]), fit(walk.path[shown]), ink); + for (let i = 1; i <= shown; i++) { + const [x, y] = fit(walk.path[i]); + ctx.beginPath(); + ctx.arc(x, y, 2.6, 0, Math.PI * 2); + ctx.fillStyle = ink; + ctx.fill(); + } + } + + // Start marker. + ctx.beginPath(); + ctx.arc(o[0], o[1], 3.4, 0, Math.PI * 2); + ctx.fillStyle = ink; + ctx.fill(); + ctx.beginPath(); + ctx.arc(o[0], o[1], 3.4, 0, Math.PI * 2); + ctx.lineWidth = 1.3; + ctx.strokeStyle = paper; + ctx.stroke(); + caption(ctx, "start", o[0], o[1] + 15, ink, 0.5 * (1 - fade * 0.4)); + + // The address row, up top: the target tile's coordinate, lit once the route lands. + const addrAlpha = shown >= E ? 0.95 : 0.4; + ctx.save(); + ctx.font = "15px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + caption(ctx, "ADDRESS", VB_W / 2, 14, ink, 0.5); + const digitGap = 30; + const startX = VB_W / 2 - (digitGap * 4) / 2; + for (let l = 0; l < 5; l++) { + ctx.globalAlpha = addrAlpha; + ctx.fillStyle = ink; + ctx.fillText(String(walk.targetCoord[l]), startX + l * digitGap, 34); + } + ctx.restore(); + ctx.globalAlpha = 1; + + // Bottom caption. + if (fade > 0.2) { + caption(ctx, "the route ran along real edges, so it lines up with the grid", VB_W / 2, VB_H - 14, ink, fade * 0.8); + } else { + caption(ctx, "walk along the edges to the tile, each edge one of five directions", VB_W / 2, VB_H - 14, ink, 0.72); + } +} + +export default function AddressWalk() { + const walk = useMemo(() => buildEdgeWalk(), []); + const patch = useMemo(() => buildPatch(), []); + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + paper: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + paper: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB_W * dpr; + canvas.height = VB_H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + paint(ctx, t, walk, patch, colorsRef.current); + }, + [refreshColors, walk, patch], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(1); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + const addr = walk.targetCoord.join(", "); + + return ( + + +
+

+ Address [{addr}]. Every edge of the + tiling runs in one of five fixed directions, so you can walk to any tile + along its edges. Trace the route from the start tile and you arrive here, + on the boundaries of the real tiles. +

+

+ Every tile gets five integers like this, an exact name on a floor with no + edges. That is the coordinate the explorer reads under your cursor, and a + shared link is just these five numbers. +

+
+
+ ); +} diff --git a/src/app/x/penrose/_components/CutAndProject.tsx b/src/app/x/penrose/_components/CutAndProject.tsx new file mode 100644 index 0000000..240441c --- /dev/null +++ b/src/app/x/penrose/_components/CutAndProject.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import Sketch from "./Sketch"; +import { + buildPatch, + rejectedPoints, + windowPolygon, + INDICES, + WINDOW, + type Pt, + type SketchTile, +} from "./lib/cutProject"; +import { index } from "../explore/lib/cap"; + +// "So you solve it globally": the spine's section-6 sketch, two linked panels. +// +// LEFT, physical space: a real Penrose patch, the same tiles the explorer paints, +// straight from the pentagrid enumerator at the pinned window center. RIGHT, +// internal "shadow" space: the acceptance window (four nested pentagons by index), +// the bounded region the whole tiling is decided against. +// +// The teaching move is the link. Hover a tile on the left; on the right its four +// corner ℤ⁵ points cast their shadows (internal projection) and every one lands +// INSIDE the window. That is the whole rule: a lattice point is a tiling vertex +// iff its shadow is in the window, a test local to the point. Faint dots outside +// the window are real ℤ⁵ points the plane discards, shadow outside, gone. Walk the +// plane forever (left is unbounded) and the shadow never leaves this little region +// (right is bounded). No walk, no backtrack, no strand. +// +// Static SVG with hover: theme colors are CSS var() references so the panels invert +// with the toggle for free, and a static sketch honors reduced-motion by +// construction (the harness adds no clock). The address under the cursor is the +// tile's own ℤ⁵ coordinate. + +const VB_W = 720; +const VB_H = 380; +const GUTTER = 28; +const PAD = 26; +const PANEL_W = (VB_W - GUTTER) / 2; + +const pointsAttr = (pts: readonly Pt[]) => pts.map(([x, y]) => `${x},${y}`).join(" "); + +// A fit: map a data-space box into a panel box, y flipped (SVG y grows down), +// preserving aspect so circles stay circles. +type Fit = (p: Pt) => [number, number]; +function makeFit( + data: { cx: number; cy: number; half: number }, + panel: { x: number; y: number; w: number; h: number }, +): Fit { + const s = Math.min(panel.w, panel.h) / (2 * data.half); + const px0 = panel.x + panel.w / 2; + const py0 = panel.y + panel.h / 2; + return ([x, y]) => [px0 + (x - data.cx) * s, py0 - (y - data.cy) * s]; +} + +// Left panel: the physical patch. Fit its corner extent into the left box. +function physicalView(patch: SketchTile[]): { fit: Fit } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const t of patch) + for (const [x, y] of t.physical) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const half = Math.max(maxX - minX, maxY - minY) / 2 + 0.3; + const fit = makeFit( + { cx, cy, half }, + { x: PAD, y: PAD, w: PANEL_W - 2 * PAD, h: VB_H - 2 * PAD }, + ); + return { fit }; +} + +// Right panel: internal space. The window spans ~±τ around its center; fit that. +function internalView(): { fit: Fit } { + const TAU = (1 + Math.sqrt(5)) / 2; + const fit = makeFit( + { cx: WINDOW.vx, cy: WINDOW.vy, half: TAU + 0.25 }, + { x: PANEL_W + GUTTER + PAD, y: PAD, w: PANEL_W - 2 * PAD, h: VB_H - 2 * PAD }, + ); + return { fit }; +} + +export default function CutAndProject() { + const patch = useMemo(() => buildPatch(), []); + const rejected = useMemo(() => rejectedPoints(), []); + const { fit: fitL } = useMemo(() => physicalView(patch), [patch]); + const { fit: fitR } = useMemo(() => internalView(), []); + + // A representative tile, highlighted by default so the static (and reduced-motion) + // frame already tells the story: one tile lit, its four shadows inside the window. + const seed = useMemo(() => { + let best = patch[0]; + let bestD = Infinity; + for (const t of patch) { + const [cx, cy] = t.physical[0]; + const d = Math.hypot(cx, cy); + if (d < bestD) { + bestD = d; + best = t; + } + } + return best?.key ?? null; + }, [patch]); + + const [hover, setHover] = useState(null); + const active = patch.find((t) => t.key === (hover ?? seed)) ?? null; + + // Window pentagons, faint, largest first so smaller ones read on top. + const windowPolys = INDICES.map((idx) => ({ + idx, + pts: windowPolygon(idx).map(fitR), + })).sort((a, b) => b.idx - a.idx); + + const fillFor = (t: SketchTile) => + t.type === "thick" ? "var(--color-penrose-thick)" : "var(--color-penrose-thin)"; + + const addr = active + ? `${active.coord.join(", ")} · ${active.type}` + : "hover a tile"; + + return ( + + + {/* hairline divider between the panels */} + + + {/* panel titles */} + + PHYSICAL · THE TILING + + + INTERNAL · THE SHADOW WINDOW + + + {/* LEFT: the real patch. Every tile drawn at physical() of its corners. */} + + {patch.map((t) => { + const lit = active?.key === t.key; + return ( + setHover(t.key)} + onMouseLeave={() => setHover(null)} + style={{ cursor: "pointer", transition: "opacity 0.12s ease" }} + /> + ); + })} + {/* the lit tile's four corners, marked, to tie them to the right panel */} + {active && + active.physical.map(fitL).map(([x, y], i) => ( + + ))} + + + {/* RIGHT: the acceptance window and the shadows. */} + + {/* the four index windows, faint, drawn as ink outlines */} + {windowPolys.map(({ idx, pts }) => ( + + ))} + + {/* rejected lattice points: shadow outside the window, discarded */} + {rejected.map((r, i) => { + const [x, y] = fitR(r.internal); + return ( + + + + + ); + })} + + {/* the lit tile's four shadows, landing inside the window */} + {active && + active.internal.map(fitR).map(([x, y], i) => { + const idx = index(active.cornerCoords[i]); + return ( + + {`corner shadow, index ${idx}, inside the window`} + + ); + })} + + + +
+

+ {active ? ( + <> + This tile is the shadow of lattice point{" "} + [{addr}]. Its four corners' + shadows all land inside the window, so the plane keeps it. The + crossed points are lattice points whose shadow lands outside, so the + plane discards them. + + ) : ( + "Hover a tile. Its four 5D corners cast shadows into the window on the right; all inside means accepted." + )} +

+

+ Walk anywhere on the left and the plane never ends. The shadows on the + right never leave this little window. That boundedness is the whole + tiling, and the test is local to each point, so the build never strands. + The address is just the ℤ⁵ coordinate. +

+
+
+ ); +} diff --git a/src/app/x/penrose/_components/FibonacciStrip.tsx b/src/app/x/penrose/_components/FibonacciStrip.tsx new file mode 100644 index 0000000..5451fa6 --- /dev/null +++ b/src/app/x/penrose/_components/FibonacciStrip.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Sketch from "./Sketch"; +import { + D, + DPERP, + latticePoints, + LONG, + physical, + SHORT, + WINDOW_W, +} from "./lib/fibonacci"; +import { facesInViewport, GAMMA } from "../explore/lib/pentagrid"; +import type { RenderFace } from "../explore/lib/patch"; + +// "Cut and project, where you can see it": the spine's section-6 lead-in. The same +// construction one dimension lower, fully visible. A square integer lattice, a line at +// the golden slope, and a strip (the window) around it. A scan sweeps ALONG the line: +// the points it crosses inside the strip drop onto the line and grow the Fibonacci +// chain, long and short intervals, ratio phi, never repeating. Below, the real 2D +// Penrose tiling, the same cut and project one stage up (5D -> 2D), builds in the same +// sweep, and a pointer tracks from the scan's point on the line to the tile filling in +// at that moment. Cut is the strip, project is the drop, and the same method one stage +// up gives the tiles on the grid. +// +// Bound to fibonacci.ts (and fibonacci.test.ts): every accepted point and every +// long/short gap is computed, not drawn by hand. The Penrose patch is real enumerator +// output (facesInViewport). The pointer is an analogy in step (both quasicrystals have +// two prototiles, built by the same window test), not a tile-by-tile map across the +// dimensions. +// +// Canvas: the harness drives render(t); t is the scan position. Theme colours are read +// live. Reduced motion mounts at t = 1, the finished frame (scan and pointer gone). + +const VB_W = 720; +const VB_H = 712; +const PAD = 30; + +const TOP = { x: PAD, y: 26, w: VB_W - 2 * PAD, h: 214 }; +const CHAIN_Y = 288; +const CHAIN_H = 16; +const PEN = { x: PAD, y: 350, w: VB_W - 2 * PAD, h: 330 }; + +// How much of the lattice to show. The line of slope 1/phi runs across this box. +const VIEW_M = 7; +const VIEW_N = 5; +const S_EXT = 16; // half-length of the drawn line/strip in data units (overshoots view) +const OFFSET0 = 0.05; // the fixed window offset (centres the strip on the line) + +// The Penrose patch: the same cut and project, one stage up. +const PEN_PX = 8.5; // physical half-width shown +const PEN_PY = (PEN_PX * PEN.h) / PEN.w; // matched to the panel aspect +const PEN_SCALE = Math.min(PEN.w / (2 * PEN_PX), PEN.h / (2 * PEN_PY)); +const PEN_VIEW = { + minX: -PEN_PX - 0.8, + maxX: PEN_PX + 0.8, + minY: -PEN_PY - 0.8, + maxY: PEN_PY + 0.8, +}; + +type V2 = readonly [number, number]; +const add = (a: V2, b: V2): V2 => [a[0] + b[0], a[1] + b[1]]; +const scale = (k: number, v: V2): V2 => [k * v[0], k * v[1]]; +const clamp01 = (x: number) => Math.max(0, Math.min(1, x)); + +// A tile plus its physical radius, so the plane can be computed outward from the +// centre (each tile from its own coordinate) as the scan runs above it. +type Cell2D = { f: RenderFace; r: number }; + +const penToPx = ([x, y]: V2): [number, number] => [ + PEN.x + PEN.w / 2 + x * PEN_SCALE, + PEN.y + PEN.h / 2 - y * PEN_SCALE, +]; + +// Data (m,n) plane -> pixels, equal scale so the lattice stays square, y flipped. +const SCALE = Math.min(TOP.w / (2 * VIEW_M), TOP.h / (2 * VIEW_N)); +const fitD = ([x, y]: V2): [number, number] => [ + TOP.x + TOP.w / 2 + x * SCALE, + TOP.y + TOP.h / 2 - y * SCALE, +]; + +// A point on the line internal = k, at parameter s along the line direction D. +const onLine = (k: number, s: number): V2 => add(scale(k, DPERP), scale(s, D)); + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; paper: string; ink: string }; + +function caption( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, + align: CanvasTextAlign = "center", +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = + "11px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textAlign = align; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +function arrowHead( + ctx: CanvasRenderingContext2D, + from: [number, number], + to: [number, number], + color: string, + alpha: number, +) { + const ang = Math.atan2(to[1] - from[1], to[0] - from[0]); + const len = 9; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(to[0], to[1]); + ctx.lineTo(to[0] - len * Math.cos(ang - 0.4), to[1] - len * Math.sin(ang - 0.4)); + ctx.lineTo(to[0] - len * Math.cos(ang + 0.4), to[1] - len * Math.sin(ang + 0.4)); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +function paint( + ctx: CanvasRenderingContext2D, + t: number, + colors: Colors, + cells: Cell2D[], +) { + const { thick, thin, paper, ink } = colors; + const gamma = OFFSET0 - WINDOW_W / 2; // centre the window on the line + + ctx.clearRect(0, 0, VB_W, VB_H); + ctx.fillStyle = paper; + ctx.fillRect(0, 0, VB_W, VB_H); + + caption(ctx, "THE LATTICE, A LINE, A STRIP", TOP.x, 16, ink, 0.55, "left"); + caption(ctx, "scan the strip across the grid ▸", VB_W - PAD, 16, ink, 0.5, "right"); + + const all = latticePoints(VIEW_M + 1, gamma).filter( + (p) => Math.abs(p.m) <= VIEW_M && Math.abs(p.n) <= VIEW_N, + ); + const accepted = all + .filter((p) => p.accepted) + .sort((a, b) => a.phys - b.phys); + const physMin = accepted.length ? accepted[0].phys : -1; + const physMax = accepted.length ? accepted[accepted.length - 1].phys : 1; + + // The scan: a position that sweeps ALONG the line, from one end of the view to the + // other, as t goes 0 -> 1. Points are revealed as the scan crosses them (directional, + // not from the centre), so the strip genuinely traverses the grid. + const SW0 = physMin - 1; + const SW1 = physMax + 1; + const sweep = SW0 + t * (SW1 - SW0); + const BAND = 0.7; + const reveal1 = (phys: number) => clamp01((sweep - phys) / BAND); + // The scan head and the pointer fade out at the very end for a clean finished frame. + const scanFade = 1 - clamp01((t - 0.92) / 0.08); + + // The plane is computed OUTWARD from the origin, each tile from its own coordinate, + // so where the tiles come from is clear: the centre, growing out. sqrt(t) so the + // count (area ~ radius squared) grows evenly across the slider. + let maxR = 0; + for (const c of cells) if (c.r > maxR) maxR = c.r; + const revealR = Math.sqrt(Math.max(0, t)) * (maxR + 1); + const reveal2 = (r: number) => clamp01((revealR - r) / 1.2); + + // --- top lattice panel, clipped -------------------------------------------- + ctx.save(); + ctx.beginPath(); + ctx.rect(TOP.x, TOP.y, TOP.w, TOP.h); + ctx.clip(); + + // The strip (the window) and the line, the fixed apparatus the scan runs along. + const strip: V2[] = [ + onLine(gamma, -S_EXT), + onLine(gamma, S_EXT), + onLine(gamma + WINDOW_W, S_EXT), + onLine(gamma + WINDOW_W, -S_EXT), + ]; + ctx.beginPath(); + strip.forEach((p, i) => { + const [px, py] = fitD(p); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.closePath(); + ctx.fillStyle = ink; + ctx.globalAlpha = 0.08; + ctx.fill(); + ctx.globalAlpha = 0.3; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.strokeStyle = ink; + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + + const [l0x, l0y] = fitD(onLine(0, -S_EXT)); + const [l1x, l1y] = fitD(onLine(0, S_EXT)); + ctx.beginPath(); + ctx.moveTo(l0x, l0y); + ctx.lineTo(l1x, l1y); + ctx.lineWidth = 1.6; + ctx.strokeStyle = ink; + ctx.globalAlpha = 0.7; + ctx.stroke(); + ctx.globalAlpha = 1; + + // Lattice points the scan has crossed: rejected faint, accepted dropped onto the line. + for (const p of all) { + if (p.accepted) continue; + const ap = reveal1(p.phys); + if (ap <= 0.01) continue; + const [px, py] = fitD([p.m, p.n]); + ctx.beginPath(); + ctx.arc(px, py, 2, 0, Math.PI * 2); + ctx.fillStyle = ink; + ctx.globalAlpha = 0.22 * ap; + ctx.fill(); + } + ctx.globalAlpha = 1; + for (const p of accepted) { + const ap = reveal1(p.phys); + if (ap <= 0.01) continue; + const foot = scale(physical(p.m, p.n), D); + const [pxx, pyy] = fitD([p.m, p.n]); + const [fxx, fyy] = fitD(foot); + ctx.beginPath(); + ctx.moveTo(pxx, pyy); + ctx.lineTo(fxx, fyy); + ctx.lineWidth = 1; + ctx.strokeStyle = ink; + ctx.globalAlpha = 0.4 * ap; + ctx.stroke(); + } + ctx.globalAlpha = 1; + for (const p of accepted) { + const ap = reveal1(p.phys); + if (ap <= 0.01) continue; + const [px, py] = fitD([p.m, p.n]); + ctx.globalAlpha = ap; + ctx.beginPath(); + ctx.arc(px, py, 3.4, 0, Math.PI * 2); + ctx.fillStyle = ink; + ctx.fill(); + ctx.beginPath(); + ctx.arc(px, py, 3.4, 0, Math.PI * 2); + ctx.lineWidth = 1.4; + ctx.strokeStyle = paper; + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // The scan head: a bright line perpendicular to the strip at the sweep position, + // travelling along the diagonal. + if (scanFade > 0.01 && sweep > SW0 && sweep < SW1) { + const a = add(scale(sweep, D), scale(gamma - 0.5, DPERP)); + const b = add(scale(sweep, D), scale(gamma + WINDOW_W + 0.5, DPERP)); + const [ax, ay] = fitD(a); + const [bx, by] = fitD(b); + ctx.save(); + ctx.globalAlpha = scanFade; + ctx.strokeStyle = ink; + ctx.lineWidth = 2.2; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.restore(); + } + + ctx.restore(); // end clip + + // --- the 1D chain bar ------------------------------------------------------ + const x0 = PAD + 8; + const x1 = VB_W - PAD - 8; + const barX = (phys: number) => + x0 + ((phys - physMin) / (physMax - physMin || 1)) * (x1 - x0); + if (accepted.length >= 2) { + const mid = (LONG + SHORT) / 2; + for (const p of accepted) { + const ap = reveal1(p.phys); + if (ap <= 0.01) continue; + const foot = scale(physical(p.m, p.n), D); + const [, fy] = fitD(foot); + const bx = barX(p.phys); + ctx.beginPath(); + ctx.moveTo(fitD(foot)[0], fy); + ctx.lineTo(bx, CHAIN_Y - CHAIN_H / 2 - 2); + ctx.lineWidth = 1; + ctx.strokeStyle = ink; + ctx.globalAlpha = 0.12 * ap; + ctx.stroke(); + } + ctx.globalAlpha = 1; + for (let i = 1; i < accepted.length; i++) { + if (accepted[i].phys > sweep) continue; // a gap shows once the scan has passed it + const gap = accepted[i].phys - accepted[i - 1].phys; + const isLong = gap > mid; + const xa = barX(accepted[i - 1].phys); + const xb = barX(accepted[i].phys); + ctx.fillStyle = isLong ? thick : thin; + ctx.fillRect(xa, CHAIN_Y - CHAIN_H / 2, xb - xa - 1.5, CHAIN_H); + } + for (const p of accepted) { + const ap = reveal1(p.phys); + if (ap <= 0.01) continue; + const bx = barX(p.phys); + ctx.beginPath(); + ctx.moveTo(bx, CHAIN_Y - CHAIN_H / 2 - 3); + ctx.lineTo(bx, CHAIN_Y + CHAIN_H / 2 + 3); + ctx.lineWidth = 1; + ctx.strokeStyle = ink; + ctx.globalAlpha = 0.5 * ap; + ctx.stroke(); + } + ctx.globalAlpha = 1; + caption( + ctx, + "the chain on the line: long and short, ratio φ, never repeating", + VB_W / 2, + CHAIN_Y + CHAIN_H / 2 + 22, + ink, + 0.78, + ); + } + + caption( + ctx, + "points inside the strip drop onto the line as the scan passes", + VB_W / 2, + TOP.y + TOP.h + 16, + ink, + 0.7, + ); + + // --- the real Penrose tiling, the same method one stage up, swept in step -------- + caption( + ctx, + "THE SAME METHOD, ONE STAGE UP · 5D → 2D · REAL PENROSE TILES", + PEN.x, + PEN.y - 14, + ink, + 0.55, + "left", + ); + ctx.save(); + ctx.beginPath(); + ctx.rect(PEN.x, PEN.y, PEN.w, PEN.h); + ctx.clip(); + ctx.lineJoin = "round"; + for (const { f, r } of cells) { + const appear = reveal2(r); + if (appear <= 0.01) continue; + ctx.beginPath(); + f.corners.forEach((c, i) => { + const [px, py] = penToPx([c[0], c[1]]); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.closePath(); + ctx.globalAlpha = appear * 0.9; + ctx.fillStyle = f.type === "thick" ? thick : thin; + ctx.fill(); + ctx.globalAlpha = appear * 0.5; + ctx.lineWidth = 0.8; + ctx.strokeStyle = ink; + ctx.stroke(); + } + // The build wavefront: a faint ring at the current reach, so the tiles read as + // appearing from the centre outward. + if (scanFade > 0.01 && revealR < maxR + 0.5) { + const [ox, oy] = penToPx([0, 0]); + ctx.globalAlpha = 0.3 * scanFade; + ctx.setLineDash([4, 5]); + ctx.lineWidth = 1.2; + ctx.strokeStyle = ink; + ctx.beginPath(); + ctx.arc(ox, oy, revealR * PEN_SCALE, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + ctx.globalAlpha = 1; + ctx.restore(); + + // --- the pointer: this interval on the line points to a tile of its kind ---------- + // The two lengths correspond to the two tiles: a long interval to a fat (gold) tile, + // a short interval to a thin (blue) tile. The pointer leaves the interval the scan + // just crossed, coloured to match, and points to a central tile of that kind. This is + // the prototile correspondence, not a coordinate map (1D and 2D are different lattices). + if (scanFade > 0.02 && accepted.length >= 2) { + let fatC: V2 | null = null; + let thinC: V2 | null = null; + let fatR = Infinity; + let thinR = Infinity; + for (const { f, r } of cells) { + if (r > revealR) continue; + if (f.type === "thick") { + if (r < fatR) { fatR = r; fatC = [f.centroid[0], f.centroid[1]]; } + } else if (r < thinR) { thinR = r; thinC = [f.centroid[0], f.centroid[1]]; } + } + const mid = (LONG + SHORT) / 2; + let segMid: number | null = null; + let segLong = false; + for (let i = 1; i < accepted.length; i++) { + if (accepted[i].phys > sweep) break; // the most recent interval the scan crossed + segMid = (barX(accepted[i - 1].phys) + barX(accepted[i].phys)) / 2; + segLong = accepted[i].phys - accepted[i - 1].phys > mid; + } + const target = segLong ? fatC : thinC; + if (segMid != null && target) { + const color = segLong ? thick : thin; + const fromY = CHAIN_Y + CHAIN_H / 2 + 4; + const to = penToPx(target); + ctx.save(); + ctx.globalAlpha = scanFade * 0.85; + ctx.strokeStyle = color; + ctx.lineWidth = 1.8; + ctx.beginPath(); + ctx.moveTo(segMid, fromY); + ctx.lineTo(to[0], to[1]); + ctx.stroke(); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(segMid, fromY, 2.8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + arrowHead(ctx, [segMid, fromY], to, color, scanFade * 0.9); + caption( + ctx, + segLong ? "long → fat" : "short → thin", + (segMid + to[0]) / 2 + 16, + (fromY + to[1]) / 2, + color, + scanFade * 0.85, + "left", + ); + } + } + + caption( + ctx, + "the line scans the grid; the plane is computed outward, the same method one stage up", + VB_W / 2, + PEN.y + PEN.h + 18, + ink, + 0.72, + ); +} + +export default function FibonacciStrip() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + paper: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + + // Precompute the fixed Penrose patch once, each tile tagged with its physical radius + // so the plane can be computed outward from the centre. + const cells = useMemo( + () => + facesInViewport(PEN_VIEW, GAMMA).map((f) => ({ + f, + r: Math.hypot(f.centroid[0], f.centroid[1]), + })), + [], + ); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + paper: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB_W * dpr; + canvas.height = VB_H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + paint(ctx, t, colorsRef.current, cells); + }, + [refreshColors, cells], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(1); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + const accepted = latticePoints(VIEW_M + 1, OFFSET0 - WINDOW_W / 2).filter( + (p) => p.accepted && Math.abs(p.m) <= VIEW_M && Math.abs(p.n) <= VIEW_N, + ).length; + + return ( + + + + ); +} diff --git a/src/app/x/penrose/_components/GoldenRatio.tsx b/src/app/x/penrose/_components/GoldenRatio.tsx new file mode 100644 index 0000000..0882fcc --- /dev/null +++ b/src/app/x/penrose/_components/GoldenRatio.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import Sketch from "./Sketch"; +import { + countSeries, + halfExtent, + PHI, + rhombiAt, + type Counts, + type Pt, + type Rhombus, +} from "./lib/scaling"; + +// "The golden ratio appears": the spine's section-9 sketch one. Count the fat tiles +// and the thin tiles in a Penrose patch and lay them out as two stacks, gold for +// thick and blue for thin. Deflate deeper and the gold stack grows out to exactly φ +// times the blue stack: the ratio of the counts is the golden ratio. The patch above +// is the real tiling being counted; the stacks below settle on the φ mark as the +// level climbs. +// +// HONEST BY CONSTRUCTION. Counts and geometry are deflate() output (lib/scaling.ts +// and its test): the thick:thin numbers equal faces.ts substitutionFaces at every +// level, the stack lengths are in exact thick:thin proportion, and the gap to φ +// shrinks as the level climbs. +// +// Canvas: the harness drives render(t); the slider scrubs the level (levels crossfade +// gradually). Theme colours are read live. Reduced motion mounts at t = 1, the +// deepest level, the gold stack on the φ mark. + +const VB = 460; +const VB_H = 448; +const MARGIN = 12; + +const MIN_LEVEL = 1; +const MAX_LEVEL = 8; + +// The patch, centred in the top region. +const PATCH_CY = 138; +const PATCH_H = 268; + +// The two stacks. Blue is the unit; gold runs blue * (thick/thin), reaching the φ +// mark at blue * φ. As the ratio climbs to φ the gold stack grows out to that mark. +const BAR_X0 = 80; +const BLUE_LEN = 188; +const GOLD_Y = 332; +const BLUE_Y = 374; +const BAR_H = 26; + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; paper: string; ink: string }; + +const smooth = (e0: number, e1: number, x: number): number => { + const u = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); + return u * u * (3 - 2 * u); +}; + +// Draw one level's rhombi at a shared scale and a given opacity, centred in the patch +// region. No clear, so two adjacent levels can be composited into a crossfade frame. +function drawPatch( + ctx: CanvasRenderingContext2D, + rhombi: readonly Rhombus[], + half: number, + colors: Colors, + alpha: number, +) { + if (alpha <= 0.01 || rhombi.length === 0) return; + const { thick, thin, ink } = colors; + const s = (PATCH_H - 2 * MARGIN) / (2 * half); + const toPx = (p: Pt): [number, number] => [VB / 2 + p[0] * s, PATCH_CY - p[1] * s]; + const edge = Math.max(0.3, Math.min(1, 18 / Math.sqrt(rhombi.length))); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineJoin = "round"; + for (const r of rhombi) { + ctx.beginPath(); + const [x0, y0] = toPx(r.corners[0]); + ctx.moveTo(x0, y0); + for (let i = 1; i < 4; i++) { + const [x, y] = toPx(r.corners[i]); + ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fillStyle = r.kind === "thick" ? thick : thin; + ctx.fill(); + ctx.strokeStyle = ink; + ctx.lineWidth = edge; + ctx.stroke(); + } + ctx.restore(); +} + +// A stack: a solid colour bar from x0 to x1, the count drawn as length. +function drawStack( + ctx: CanvasRenderingContext2D, + x0: number, + x1: number, + y: number, + color: string, + ink: string, +) { + ctx.save(); + ctx.fillStyle = color; + ctx.fillRect(x0, y - BAR_H / 2, x1 - x0, BAR_H); + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1; + ctx.strokeStyle = ink; + ctx.strokeRect(x0, y - BAR_H / 2, x1 - x0, BAR_H); + ctx.restore(); +} + +function label( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, + align: CanvasTextAlign, + size = 11, +) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = `${size}px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace`; + ctx.textAlign = align; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +function drawStacks(ctx: CanvasRenderingContext2D, counts: Counts, colors: Colors) { + const { thick, thin, ink } = colors; + const ratio = counts.ratio; + const eqX = BAR_X0 + BLUE_LEN; // where blue ends, and gold ends if the ratio were 1 + const phiX = BAR_X0 + BLUE_LEN * PHI; // the golden-ratio mark + const goldX1 = BAR_X0 + BLUE_LEN * ratio; + const yTop = GOLD_Y - BAR_H / 2 - 14; + const yBot = BLUE_Y + BAR_H / 2 + 6; + + // the "x1" reference (ratio of one) and the golden mark + ctx.save(); + ctx.globalAlpha = 0.25; + ctx.strokeStyle = ink; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(eqX, yTop); + ctx.lineTo(eqX, yBot); + ctx.stroke(); + ctx.restore(); + label(ctx, "×1", eqX, yBot + 12, ink, 0.45, "center"); + + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.strokeStyle = thick; + ctx.lineWidth = 2; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.moveTo(phiX, yTop - 6); + ctx.lineTo(phiX, yBot); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + label(ctx, "× φ = 1.618", phiX, yTop - 14, thick, 0.95, "center"); + + // the two stacks + drawStack(ctx, BAR_X0, goldX1, GOLD_Y, thick, ink); + drawStack(ctx, BAR_X0, eqX, BLUE_Y, thin, ink); + + // row labels and counts + label(ctx, "thick", BAR_X0 - 10, GOLD_Y, ink, 0.7, "right"); + label(ctx, "thin", BAR_X0 - 10, BLUE_Y, ink, 0.7, "right"); + label(ctx, counts.thick.toLocaleString(), goldX1 + 8, GOLD_Y, ink, 0.85, "left"); + label(ctx, counts.thin.toLocaleString(), eqX + 8, BLUE_Y, ink, 0.85, "left"); +} + +export default function GoldenRatio() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + paper: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + + const series = useMemo(() => countSeries(MAX_LEVEL), []); + const patches = useMemo( + () => Array.from({ length: MAX_LEVEL + 1 }, (_, l) => (l >= MIN_LEVEL ? rhombiAt(l) : [])), + [], + ); + const halves = useMemo(() => patches.map((rh) => (rh.length ? halfExtent(rh) : 1)), [patches]); + + const [counts, setCounts] = useState(series[MAX_LEVEL - 1]); + const levelRef = useRef(MAX_LEVEL); + const lastTRef = useRef(1); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + paper: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + lastTRef.current = t; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB * dpr; + canvas.height = VB_H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + + const f = t * (MAX_LEVEL - MIN_LEVEL); + const lo = MIN_LEVEL + Math.floor(f); + const hi = Math.min(MAX_LEVEL, lo + 1); + const frac = f - Math.floor(f); + const fade = lo === hi ? 0 : smooth(0.05, 0.95, frac); + const commonHalf = Math.max(halves[lo], halves[hi]); + const shown = frac < 0.5 ? lo : hi; + + const colors = colorsRef.current; + ctx.clearRect(0, 0, VB, VB_H); + ctx.fillStyle = colors.paper; + ctx.fillRect(0, 0, VB, VB_H); + drawPatch(ctx, patches[lo], commonHalf, colors, 1 - fade); + drawPatch(ctx, patches[hi], commonHalf, colors, fade); + drawStacks(ctx, series[shown - 1], colors); + + if (shown !== levelRef.current) { + levelRef.current = shown; + setCounts(series[shown - 1]); + } + }, + [patches, halves, series, refreshColors], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(lastTRef.current); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + const gap = Math.abs(counts.ratio - PHI); + + return ( + + +
+
+ + level{" "} + {counts.level} + + + thick{" "} + {counts.thick.toLocaleString()} + + + thin{" "} + {counts.thin.toLocaleString()} + + + thick ÷ thin{" "} + {counts.ratio.toFixed(4)} + +
+

+ Count the fat tiles and the thin ones and stack them. The gold stack runs φ ≈{" "} + {PHI.toFixed(4)} times the blue, off by{" "} + {gap.toFixed(4)} here. Deflate deeper and + it lands on the golden ratio, the same φ that set the tile angles. +

+
+
+ ); +} diff --git a/src/app/x/penrose/_components/InterferenceOverlay.tsx b/src/app/x/penrose/_components/InterferenceOverlay.tsx new file mode 100644 index 0000000..6e07f5d --- /dev/null +++ b/src/app/x/penrose/_components/InterferenceOverlay.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Sketch from "./Sketch"; +import { buildOverlay, type Overlay, type Pt } from "./lib/overlay"; + +// "Slide one over another": the spine's section-7 sketch, Penrose's overhead- +// projector demo, rebuilt to push and spin. Two real Penrose tilings are drawn as +// line work over a large plane that runs well off screen, the bottom in ink and the +// top in a translucent accent. Spin the top layer a full turn, or drag it, and the +// places where the two disagree organize into five-fold rosettes that bloom and +// drift. Zoomed out, those rosettes read at scale; the off-screen plane means there +// is always tiling under the frame to move into view. +// +// HONEST BY CONSTRUCTION. Both layers are the SAME real enumerator patch +// (lib/overlay.ts and its test). The interference is emergent: nothing is tinted, +// the moiré is just two real tilings overlapping. Only the visible tiles are drawn +// each frame (culled by centroid), so a large plane stays smooth to spin and drag. +// +// The harness drives render(t) for the spin (full 360, looping); pointer drag slides +// the top layer. Theme colours are read live so it inverts with the toggle. + +const VB = 560; +const MARGIN = 10; +// Zoomed far out over a large generated plane, so the five-fold interference rosettes +// read at scale and dragging never runs out of tiling. +const VIEW_HALF = 42; +const GEN_HALF = 75; +const CULL_R = VIEW_HALF + 2; // draw only tiles whose centroid is within the frame +// The tilings carry five-fold symmetry, so a full spin just repeats; one fifth of a +// turn is the whole story. The slider turns the top layer across [0, 72 deg]. +const TURN_MAX = (2 * Math.PI) / 5; +const OFFSET_MAX = 12; // how far the top layer may be dragged, in tile-edge units + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; paper: string; ink: string }; + +const SCALE = (VB - 2 * MARGIN) / (2 * VIEW_HALF); +const toPx = (p: Pt): [number, number] => [ + VB / 2 + p[0] * SCALE, + VB / 2 - p[1] * SCALE, // canvas y grows downward +]; + +const clamp = (v: number, m: number) => Math.max(-m, Math.min(m, v)); + +// Stroke a list of faces (optionally transformed) in one path. Caller culls first. +function strokeFaces( + ctx: CanvasRenderingContext2D, + faces: Overlay["a"], + xf: ((p: Pt) => Pt) | null, + color: string, + width: number, + alpha: number, +) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineJoin = "round"; + ctx.beginPath(); + for (const f of faces) { + const c = f.corners; + const p0 = xf ? xf(c[0]) : c[0]; + const [x0, y0] = toPx(p0); + ctx.moveTo(x0, y0); + for (let i = 1; i < c.length; i++) { + const p = xf ? xf(c[i]) : c[i]; + const [x, y] = toPx(p); + ctx.lineTo(x, y); + } + ctx.closePath(); + } + ctx.stroke(); + ctx.restore(); +} + +function caption( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, +) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = + "11px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +export default function InterferenceOverlay() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + paper: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + const overlay = useMemo(() => buildOverlay(GEN_HALF), []); + // The bottom layer is fixed; its visible tiles never change, so cull once. + const bottomVisible = useMemo( + () => + overlay.a.filter( + (f) => Math.abs(f.centroid[0]) <= CULL_R && Math.abs(f.centroid[1]) <= CULL_R, + ), + [overlay], + ); + + const twistRef = useRef(TURN_MAX); // mount at a fifth-turn (t = 1), full interference + const offsetRef = useRef([0, 0]); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + paper: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const repaint = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB * dpr; + canvas.height = VB * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + const { thick, paper, ink } = colorsRef.current; + const twist = twistRef.current; + const [ox, oy] = offsetRef.current; + const cos = Math.cos(twist); + const sin = Math.sin(twist); + const xf = (p: Pt): Pt => [p[0] * cos - p[1] * sin + ox, p[0] * sin + p[1] * cos + oy]; + + ctx.clearRect(0, 0, VB, VB); + ctx.fillStyle = paper; + ctx.fillRect(0, 0, VB, VB); + + // BOTTOM layer: the fixed tiling, faint thin ink, so the top reads crisply over it. + strokeFaces(ctx, bottomVisible, null, ink, 0.5, 0.3); + + // TOP layer: the same tiling, spun and slid, in translucent accent. Cull by the + // transformed centroid so only what lands in the frame is drawn. + const topVisible = overlay.b.filter((f) => { + const cx = f.centroid[0] * cos - f.centroid[1] * sin + ox; + const cy = f.centroid[0] * sin + f.centroid[1] * cos + oy; + return Math.abs(cx) <= CULL_R && Math.abs(cy) <= CULL_R; + }); + strokeFaces(ctx, topVisible, xf, thick, 0.7, 0.85); + + caption(ctx, "drag to slide the top layer · turn it up to a fifth", VB / 2, VB - 14, ink, 0.7); + }, [overlay, bottomVisible, refreshColors]); + + const render = useCallback( + (t: number) => { + twistRef.current = t * TURN_MAX; + repaint(); + }, + [repaint], + ); + + // Pointer drag translates the top layer. Pixel deltas convert to data units. + const dragging = useRef(false); + const last = useRef<[number, number]>([0, 0]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + dragging.current = true; + last.current = [e.clientX, e.clientY]; + e.currentTarget.setPointerCapture(e.pointerId); + }, []); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return; + const rect = e.currentTarget.getBoundingClientRect(); + const k = (rect.width / VB) * SCALE; // CSS px per data unit + const dx = (e.clientX - last.current[0]) / k; + const dy = -(e.clientY - last.current[1]) / k; + last.current = [e.clientX, e.clientY]; + offsetRef.current = [ + clamp(offsetRef.current[0] + dx, OFFSET_MAX), + clamp(offsetRef.current[1] + dy, OFFSET_MAX), + ]; + repaint(); + }, + [repaint], + ); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + dragging.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }, []); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + repaint(); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, repaint]); + + return ( + + + + ); +} diff --git a/src/app/x/penrose/_components/MeetTheTiles.tsx b/src/app/x/penrose/_components/MeetTheTiles.tsx new file mode 100644 index 0000000..f9dbe4e --- /dev/null +++ b/src/app/x/penrose/_components/MeetTheTiles.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; + +import Sketch from "./Sketch"; +import { PHI, THICK, THIN, type Pt, type Rhombus } from "./lib/tiles"; + +// "Meet the two tiles": the first spine sketch. Two unit-edge Penrose rhombi side +// by side, filled in the B1 palette on the paper grout, edges in ink. The static +// labelled state stands alone (enough for touch); hover surfaces the angle detail +// and the golden-ratio fact for the rhombus under the cursor. +// +// SVG, not canvas: this is a static labelled figure, so the markup is the simplest +// way to place text at geometric points and to get theme reactivity for free (the +// fills are CSS var() references that invert with data-theme, no repaint loop). + +const VB_W = 520; +const VB_H = 300; +const SCALE = 88; // px per unit edge inside the viewBox + +// Lay each rhombus in its own half of the viewBox. The geometry from lib/tiles is +// origin-centred and unit-edge; place() scales it and drops it at a center point. +function place(tile: Rhombus, cx: number, cy: number): Pt[] { + return tile.corners.map(([x, y]) => [cx + x * SCALE, cy - y * SCALE]); +} + +const pointsAttr = (pts: Pt[]) => pts.map(([x, y]) => `${x},${y}`).join(" "); + +type TileFigureProps = { + tile: Rhombus; + cx: number; + cy: number; + fill: string; + active: boolean; + dimmed: boolean; + onEnter: () => void; + onLeave: () => void; +}; + +function TileFigure({ + tile, + cx, + cy, + fill, + active, + dimmed, + onEnter, + onLeave, +}: TileFigureProps) { + const pts = place(tile, cx, cy); + const [right, top, left, bottom] = pts; + + // The acute angle sits at the left/right (x-axis) corners; the obtuse at + // top/bottom. Label one of each. + return ( + + + + {/* Long diagonal, dashed in ink, revealed on hover. This is the line whose + length carries phi (thick) or 1/phi (thin). */} + {active && ( + + )} + + {/* Acute angle label at the right corner, obtuse at the top corner. */} + + {tile.acute}° + + + {tile.obtuse}° + + + {/* Tile name beneath. */} + + {tile.kind === "thick" ? "THICK" : "THIN"} + + + ); +} + +export default function MeetTheTiles() { + const [hover, setHover] = useState<"thick" | "thin" | null>(null); + + const detail = + hover === "thick" + ? `Thick rhombus. Angles ${THICK.acute}° and ${THICK.obtuse}°. With a unit edge its long diagonal is exactly φ ≈ ${PHI.toFixed(3)}.` + : hover === "thin" + ? `Thin rhombus. Angles ${THIN.acute}° and ${THIN.obtuse}°. With a unit edge its short diagonal is exactly 1/φ ≈ ${(1 / PHI).toFixed(3)}.` + : "Hover a tile to reveal its diagonal. The angle family 36 / 72 / 108 / 144 is the golden ratio in disguise."; + + return ( + + + setHover("thick")} + onLeave={() => setHover(null)} + /> + setHover("thin")} + onLeave={() => setHover(null)} + /> + +

+ {detail} +

+
+ ); +} diff --git a/src/app/x/penrose/_components/Sketch.tsx b/src/app/x/penrose/_components/Sketch.tsx new file mode 100644 index 0000000..b8ccde5 --- /dev/null +++ b/src/app/x/penrose/_components/Sketch.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; + +// Sketch is the framed teaching primitive for the Penrose spine. It gives every +// sketch the same chrome (a mono label, a hairline border, no rounded corners per +// DESIGN.md) and, for animated sketches, the same controls and the same motion +// contract. The render area is the sketch's own canvas or SVG; the harness never +// paints, it only frames and (for animated sketches) drives a clock. +// +// Two kinds: +// +// STATIC {svgOrCanvas} +// No clock, no controls. May still be interactive via hover. Used for +// "Meet the two tiles". +// +// ANIMATED +// The harness owns the requestAnimationFrame loop and renders a control +// bar (play/pause, step, reset, optional slider). The sketch supplies +// render(t): a paint function called with normalised time t in [0,1]. +// +// Reduced-motion hard contract: nothing autoplays. On mount the harness paints the +// representative end state (t = 1) and never moves on its own. Under +// prefers-reduced-motion, play is disabled and motion happens only on an explicit +// slider drag or step. The contract is re-evaluated live if the media query flips. + +export type SketchAnimation = { + // Wall-clock length of one pass, in milliseconds. Larger is slower. + duration: number; + // Paint one frame at normalised time t in [0, 1]. Called by the harness on every + // animation frame, on a step, on a reset, and on a slider drag. The sketch reads + // theme colors itself (live, via getComputedStyle) so it stays theme-reactive. + render: (t: number) => void; + // Loop back to t = 0 at the end instead of stopping at t = 1. Default false. + loop?: boolean; + // Optional labelled slider. When present the control bar shows it and a drag + // scrubs render(t) directly. Label is a short mono caption (e.g. "level"). + slider?: { label: string }; +}; + +type SketchProps = { + // Mono label above the frame: UPPERCASE, the house metadata style. + label: string; + // The render area and any sketch-specific content (a caption, a readout). The + // sketch's own / must carry its own aria-label; the harness frames + // it but does not own its accessible name. + children: ReactNode; + // Present => animated. Absent => static. + animation?: SketchAnimation; + className?: string; +}; + +function usePrefersReducedMotion(): boolean { + // Default to "reduced" so the very first paint never moves, even before the + // effect runs. The effect corrects it and subscribes to live changes. + const [reduced, setReduced] = useState(true); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const sync = () => setReduced(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + return reduced; +} + +export default function Sketch({ + label, + children, + animation, + className, +}: SketchProps) { + return ( +
+
+ {label} +
+
+
{children}
+ {animation && } +
+ + ); +} + +// The animated control bar plus the RAF clock. Split out so the static path +// carries no animation machinery at all. +function Controls({ animation }: { animation: SketchAnimation }) { + const { duration, render, loop = false, slider } = animation; + const reduced = usePrefersReducedMotion(); + + const [playing, setPlaying] = useState(false); + const [t, setT] = useState(1); // start at the representative end state + const rafRef = useRef(null); + const startRef = useRef(0); // wall-clock anchor for the current run + const tAtStartRef = useRef(1); + + // Keep a stable reference to render so the loop effect does not restart when a + // parent re-creates the closure each render. + const renderRef = useRef(render); + renderRef.current = render; + + // Paint the end state once on mount and whenever the render fn identity is + // refreshed while paused. This satisfies "render the end state, never move on + // load": the first frame the user sees is t = 1, stationary. + useEffect(() => { + if (!playing) renderRef.current(t); + }, [playing, t]); + + // The clock. Only runs while playing. Translates wall-clock to normalised t, + // loops or halts at the end, and paints each frame through the live render ref. + useEffect(() => { + if (!playing) return; + startRef.current = performance.now(); + tAtStartRef.current = t >= 1 && !loop ? 0 : t; // replay from 0 if at the end + const tick = (now: number) => { + const elapsed = (now - startRef.current) / duration; + let next = tAtStartRef.current + elapsed; + if (next >= 1) { + if (loop) { + next = next % 1; + } else { + next = 1; + renderRef.current(1); + setT(1); + setPlaying(false); + return; + } + } + renderRef.current(next); + setT(next); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + // t is intentionally read once at play time (the resume point), not tracked. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playing, duration, loop]); + + const onStep = useCallback(() => { + setPlaying(false); + setT((cur) => { + const next = Math.min(1, cur + 0.05); + return next; + }); + }, []); + + const onReset = useCallback(() => { + setPlaying(false); + setT(0); + }, []); + + const onScrub = useCallback((value: number) => { + setPlaying(false); + setT(value); + }, []); + + const btn = + "font-mono text-[11px] lowercase tracking-[0.06em] border border-ink px-3 py-1.5 hover:bg-ink hover:text-paper transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-ink"; + + return ( +
+ + + + {slider && ( + + )} +
+ ); +} diff --git a/src/app/x/penrose/_components/StopTilingByHand.tsx b/src/app/x/penrose/_components/StopTilingByHand.tsx new file mode 100644 index 0000000..bca87f5 --- /dev/null +++ b/src/app/x/penrose/_components/StopTilingByHand.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Sketch from "./Sketch"; +import type { SceneA, Tile } from "./lib/geomWall"; +import walls from "./lib/geomWalls.json"; +import type { Pt } from "./lib/overlap"; + +// "A piece fits, and still strands you": the spine's section-4 sketch, PURE GEOMETRY. +// The rigid hexagon scene from geomWalls.json (computed by lib/geomWall.ts, bound to +// the proof by geomWall.test.ts) has exactly ONE geometry-only filling, two rhombi. +// The constrained edge admits two rhombi by bare geometry; one completes, the other +// (the tempting move) seats cleanly and then STRANDS. +// +// HOW WE SHOW THE STRAND. Place the tempting rhombus. It covers one rhombus of the +// two-rhombus hole, cleanly. What is left is the other rhombus of AREA but the WRONG +// shape: it is not a rhombus, so no tile can fill it. We make that visible by +// painting the whole hole red and drawing the placed tile opaque on top: the red that +// still shows is the uncovered gap, and it is triangles, which no rhombus fits. Then +// the one correct filling replaces it, clean. The red is geometry (hole minus the +// tiles drawn), not a label; the dead-end itself is the proof in geomWall.test.ts. +// +// Canvas: the harness drives render(t); theme colours are read live so the patch +// inverts with the toggle. + +const scene = walls.sceneA_rigidHexagon as unknown as SceneA; + +const VB_W = 520; +const VB_H = 460; +const MARGIN = 44; +const WALL_RING = 1.9; // draw wall tiles whose centroid is within this of the hole +const RED = "#d24a3d"; // the "cannot be filled" gap colour, readable on either theme + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; grout: string; ink: string }; + +const smooth = (e0: number, e1: number, x: number) => { + const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); + return t * t * (3 - 2 * t); +}; + +// --------------------------------------------------------------------------- +// Viewport: fit the hole, its completion, the wrong move, and a tight ring of wall +// tiles for context. Computed once; the scene is static data. +// --------------------------------------------------------------------------- + +type View = { + toPx: (p: Pt) => [number, number]; + wall: Tile[]; +}; + +function centroid(v: readonly Pt[]): Pt { + let x = 0; + let y = 0; + for (const p of v) { + x += p[0]; + y += p[1]; + } + return [x / v.length, y / v.length]; +} + +function buildView(): View { + const c = scene.holeCenter; + const wall = scene.wall.filter((t) => { + const [cx, cy] = centroid(t.v); + return Math.hypot(cx - c[0], cy - c[1]) <= WALL_RING; + }); + + const pts: Pt[] = [...scene.holePolygon]; + for (const t of scene.uniqueCompletion) for (const p of t.v) pts.push(p); + for (const p of scene.wrongMove.v) pts.push(p); + for (const t of wall) for (const p of t.v) pts.push(p); + + let minx = Infinity; + let maxx = -Infinity; + let miny = Infinity; + let maxy = -Infinity; + for (const [x, y] of pts) { + minx = Math.min(minx, x); + maxx = Math.max(maxx, x); + miny = Math.min(miny, y); + maxy = Math.max(maxy, y); + } + const w = maxx - minx; + const h = maxy - miny; + const scale = Math.min((VB_W - 2 * MARGIN) / w, (VB_H - 2 * MARGIN) / h); + const cx = (minx + maxx) / 2; + const cy = (miny + maxy) / 2; + const toPx = (p: Pt): [number, number] => [ + VB_W / 2 + (p[0] - cx) * scale, + VB_H / 2 - (p[1] - cy) * scale, // canvas y grows downward + ]; + return { toPx, wall }; +} + +// --------------------------------------------------------------------------- +// Drawing primitives. +// --------------------------------------------------------------------------- + +function pathPoly( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], +) { + ctx.beginPath(); + const [x0, y0] = toPx(v[0]); + ctx.moveTo(x0, y0); + for (let i = 1; i < v.length; i++) { + const [x, y] = toPx(v[i]); + ctx.lineTo(x, y); + } + ctx.closePath(); +} + +function fillTile( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], + fill: string, + ink: string, + alpha: number, + lineWidth = 1.1, +) { + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, v, toPx); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = lineWidth; + ctx.lineJoin = "round"; + ctx.strokeStyle = ink; + ctx.stroke(); + ctx.restore(); +} + +function fillPoly( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], + color: string, + alpha: number, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, v, toPx); + ctx.fillStyle = color; + ctx.fill(); + ctx.restore(); +} + +function strokeLoop( + ctx: CanvasRenderingContext2D, + loop: readonly Pt[], + toPx: (p: Pt) => [number, number], + color: string, + width: number, + alpha: number, + dash: number[] = [], +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, loop, toPx); + ctx.setLineDash(dash); + ctx.lineWidth = width; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +} + +function caption( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = + "11px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +// --------------------------------------------------------------------------- +// Timeline. Outline the hole, seat the tempting move (it fits), reveal the gap it +// leaves as RED triangles no tile can fill, then clear it and grow the one correct +// filling. t = 1 is the clean resolved patch. +// --------------------------------------------------------------------------- + +const HOLE_IN = 0.1; +const SEAT_FROM = 0.16; +const SEAT_TO = 0.34; +const GAP_FROM = 0.42; // the red, unfillable triangles appear +const GAP_TO = 0.58; +const CLEAR_FROM = 0.7; // the wrong move and red gap clear +const COMP_FROM = 0.76; // the one correct filling grows +const COMP_TO = 0.94; + +function paint( + ctx: CanvasRenderingContext2D, + t: number, + view: View, + colors: Colors, +) { + const { thick, thin, grout, ink } = colors; + const { toPx, wall } = view; + + ctx.clearRect(0, 0, VB_W, VB_H); + ctx.fillStyle = grout; + ctx.fillRect(0, 0, VB_W, VB_H); + + const wallIn = smooth(0, HOLE_IN, t); + const seat = smooth(SEAT_FROM, SEAT_TO, t); + const gap = smooth(GAP_FROM, GAP_TO, t); + const comp = smooth(COMP_FROM, COMP_TO, t); + const clear = 1 - smooth(CLEAR_FROM, CLEAR_FROM + 0.06, t); // wrong move + red fade + + // 1. The committed wall ring, muted while the hole is the subject, brightening to + // a finished patch as the correct filling completes. + if (wallIn > 0) { + const wallAlpha = wallIn * (0.34 + 0.5 * comp); + for (const tile of wall) { + fillTile(ctx, tile.v, toPx, tile.type === "fat" ? thick : thin, ink, wallAlpha, 0.8); + } + strokeLoop(ctx, scene.holePolygon, toPx, ink, 2, wallIn * (1 - comp) * (1 - gap * 0.6), [5, 4]); + } + + // 2. The gap the wrong move leaves: paint the whole hole red UNDER the tiles, so + // the red that still shows after the tile is drawn is the uncovered, unfillable + // triangle(s). Frame it in red while it holds. + const redA = gap * clear; + if (redA > 0) { + fillPoly(ctx, scene.holePolygon, toPx, RED, redA * 0.66); + strokeLoop(ctx, scene.holePolygon, toPx, RED, 1.5, redA * 0.8); + } + + // 3. The tempting wrong move, opaque so it covers the red beneath it; what red is + // left is the gap it cannot help. + const wrongA = seat * clear; + if (wrongA > 0) { + fillTile( + ctx, + scene.wrongMove.v, + toPx, + scene.wrongMove.type === "fat" ? thick : thin, + ink, + wrongA, + 1.1, + ); + } + + // 4. The one correct filling grows on top and settles the patch clean. + if (comp > 0) { + const per = 1 / scene.uniqueCompletion.length; + scene.uniqueCompletion.forEach((tile, k) => { + const appear = smooth(k * per, (k + 1) * per, comp); + if (appear <= 0) return; + fillTile(ctx, tile.v, toPx, tile.type === "fat" ? thick : thin, ink, appear, 1.1); + }); + } + + // Captions, one beat at a time. + if (t < GAP_FROM) { + caption(ctx, "one small hole, exactly one filling", VB_W / 2, 22, ink, wallIn * 0.8); + if (seat > 0) caption(ctx, "this piece fits cleanly", VB_W / 2, VB_H - 22, ink, seat * 0.85); + } else if (t < CLEAR_FROM) { + caption(ctx, "but no tile can fill the red it leaves", VB_W / 2, VB_H - 22, ink, gap * clear * 0.9); + } else if (comp > 0.35) { + const lead = (comp - 0.35) / 0.65; + caption(ctx, "only this filling works", VB_W / 2, VB_H - 30, ink, lead * 0.85); + caption(ctx, "no rule invoked, the shapes alone decide", VB_W / 2, VB_H - 14, ink, lead * 0.62); + } +} + +export default function StopTilingByHand() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + grout: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + const view = useMemo(() => buildView(), []); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + grout: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB_W * dpr; + canvas.height = VB_H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + paint(ctx, t, view, colorsRef.current); + }, + [refreshColors, view], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(1); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + return ( + + + + ); +} diff --git a/src/app/x/penrose/_components/UnsolvableFuture.tsx b/src/app/x/penrose/_components/UnsolvableFuture.tsx new file mode 100644 index 0000000..87efe49 --- /dev/null +++ b/src/app/x/penrose/_components/UnsolvableFuture.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Sketch from "./Sketch"; +import type { SceneB, Tile } from "./lib/geomWall"; +import walls from "./lib/geomWalls.json"; +import type { Pt } from "./lib/overlap"; + +// "The thin fits, place it, now nothing fits": the spine's section-5 sketch, PURE +// GEOMETRY. The expert's exact objection, refuted. A rich sixteen-edge hole carved +// from a real patch (geomWalls.json, computed by lib/geomWall.ts, bound by +// geomWall.test.ts). A few locally legal tiles build, then on the doomed edge a THIN +// rhombus seats with zero overlap, the move the expert pointed at. +// +// HOW WE SHOW THE STRAND. Place the thin, then fill the rest of the hole as far as +// the geometry allows (strandFill, the maximal legal partial fill). Tiles still +// cannot cover everything: a gap survives that no rhombus fits. We make it visible by +// painting the hole red and drawing the placed tiles opaque on top, so the red that +// remains is the uncovered gap. Then the one surviving completion replaces the wrong +// path, leaving no red. The red is geometry (hole minus the tiles drawn); the dead-end +// is the proof in geomWall.test.ts. +// +// Canvas: the harness drives render(t); theme colours are read live. + +const scene = walls.sceneB_thinRefuted as unknown as SceneB; + +const VB_W = 560; +const VB_H = 540; +const MARGIN = 30; +const WALL_RING = 3.4; // draw wall tiles whose centroid is within this of the hole +const RED = "#d24a3d"; // the "cannot be filled" gap colour, readable on either theme + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; grout: string; ink: string }; + +const smooth = (e0: number, e1: number, x: number) => { + const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); + return t * t * (3 - 2 * t); +}; + +type View = { + toPx: (p: Pt) => [number, number]; + wall: Tile[]; +}; + +function centroid(v: readonly Pt[]): Pt { + let x = 0; + let y = 0; + for (const p of v) { + x += p[0]; + y += p[1]; + } + return [x / v.length, y / v.length]; +} + +function buildView(): View { + const c = scene.holeCenter; + const wall = scene.wall.filter((t) => { + const [cx, cy] = centroid(t.v); + return Math.hypot(cx - c[0], cy - c[1]) <= WALL_RING; + }); + + const pts: Pt[] = [...scene.holePolygon]; + for (const t of scene.completion) for (const p of t.v) pts.push(p); + for (const t of scene.forcedPrefix) for (const p of t.v) pts.push(p); + for (const t of scene.strandFill) for (const p of t.v) pts.push(p); + for (const p of scene.temptingThin.v) pts.push(p); + for (const t of wall) for (const p of t.v) pts.push(p); + + let minx = Infinity; + let maxx = -Infinity; + let miny = Infinity; + let maxy = -Infinity; + for (const [x, y] of pts) { + minx = Math.min(minx, x); + maxx = Math.max(maxx, x); + miny = Math.min(miny, y); + maxy = Math.max(maxy, y); + } + const w = maxx - minx; + const h = maxy - miny; + const scale = Math.min((VB_W - 2 * MARGIN) / w, (VB_H - 2 * MARGIN) / h); + const cx = (minx + maxx) / 2; + const cy = (miny + maxy) / 2; + const toPx = (p: Pt): [number, number] => [ + VB_W / 2 + (p[0] - cx) * scale, + VB_H / 2 - (p[1] - cy) * scale, // canvas y grows downward + ]; + return { toPx, wall }; +} + +// --------------------------------------------------------------------------- +// Drawing primitives. +// --------------------------------------------------------------------------- + +function pathPoly( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], +) { + ctx.beginPath(); + const [x0, y0] = toPx(v[0]); + ctx.moveTo(x0, y0); + for (let i = 1; i < v.length; i++) { + const [x, y] = toPx(v[i]); + ctx.lineTo(x, y); + } + ctx.closePath(); +} + +function fillTile( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], + fill: string, + ink: string, + alpha: number, + lineWidth = 1.1, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, v, toPx); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = lineWidth; + ctx.lineJoin = "round"; + ctx.strokeStyle = ink; + ctx.stroke(); + ctx.restore(); +} + +function fillPoly( + ctx: CanvasRenderingContext2D, + v: readonly Pt[], + toPx: (p: Pt) => [number, number], + color: string, + alpha: number, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, v, toPx); + ctx.fillStyle = color; + ctx.fill(); + ctx.restore(); +} + +function strokeLoop( + ctx: CanvasRenderingContext2D, + loop: readonly Pt[], + toPx: (p: Pt) => [number, number], + color: string, + width: number, + alpha: number, + dash: number[] = [], +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + pathPoly(ctx, loop, toPx); + ctx.setLineDash(dash); + ctx.lineWidth = width; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +} + +function caption( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + ink: string, + alpha: number, +) { + if (alpha <= 0.001) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = ink; + ctx.font = + "11px ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); + ctx.restore(); +} + +// Draw a list of tiles with a per-tile staggered reveal, opaque so they cover the +// red beneath. progress in [0,1]; mul scales the final opacity (for clearing). +function drawTiles( + ctx: CanvasRenderingContext2D, + tiles: readonly Tile[], + toPx: (p: Pt) => [number, number], + thick: string, + thin: string, + ink: string, + progress: number, + mul: number, +) { + if (mul <= 0.001 || tiles.length === 0) return; + const per = 1 / tiles.length; + tiles.forEach((tile, k) => { + const appear = smooth(k * per, (k + 1) * per, progress); + if (appear <= 0) return; + fillTile(ctx, tile.v, toPx, tile.type === "fat" ? thick : thin, ink, appear * mul, 1.1); + }); +} + +// --------------------------------------------------------------------------- +// Timeline. Build the legal prefix, seat the tempting thin, fill the rest as far as +// the geometry allows, and reveal the RED gap nothing can fill. Then clear the wrong +// path and grow the one surviving completion. t = 1 is the clean resolved patch. +// --------------------------------------------------------------------------- + +const WALL_IN = 0.08; +const PREFIX_FROM = 0.1; +const PREFIX_TO = 0.3; +const THIN_FROM = 0.32; +const THIN_TO = 0.42; +const STRAND_FROM = 0.46; // fill the rest; the red gap appears +const STRAND_TO = 0.66; +const CLEAR_FROM = 0.72; // the wrong path and red gap clear +const COMP_FROM = 0.76; // the surviving completion grows +const COMP_TO = 0.96; + +function paint( + ctx: CanvasRenderingContext2D, + t: number, + view: View, + colors: Colors, +) { + const { thick, thin, grout, ink } = colors; + const { toPx, wall } = view; + + ctx.clearRect(0, 0, VB_W, VB_H); + ctx.fillStyle = grout; + ctx.fillRect(0, 0, VB_W, VB_H); + + const wallIn = smooth(0, WALL_IN, t); + const prefix = smooth(PREFIX_FROM, PREFIX_TO, t); + const thinR = smooth(THIN_FROM, THIN_TO, t); + const strand = smooth(STRAND_FROM, STRAND_TO, t); + const comp = smooth(COMP_FROM, COMP_TO, t); + const clear = 1 - smooth(CLEAR_FROM, CLEAR_FROM + 0.06, t); // wrong path + red fade + + // 1. Wall ring, muted then brightening to a finished patch. + if (wallIn > 0) { + const wallAlpha = wallIn * (0.32 + 0.5 * comp); + for (const tile of wall) { + fillTile(ctx, tile.v, toPx, tile.type === "fat" ? thick : thin, ink, wallAlpha, 0.8); + } + strokeLoop(ctx, scene.holePolygon, toPx, ink, 2, wallIn * (1 - comp) * (1 - strand * 0.6), [5, 4]); + } + + // 2. The red gap: paint the whole hole red UNDER the wrong-path tiles, so the red + // still showing once they are drawn is the uncovered gap nothing can fill. + const redA = strand * clear; + if (redA > 0) { + fillPoly(ctx, scene.holePolygon, toPx, RED, redA * 0.66); + strokeLoop(ctx, scene.holePolygon, toPx, RED, 1.5, redA * 0.7); + } + + // 3. The wrong path, opaque so it covers the red: the legal prefix, the tempting + // thin, and the maximal fill of the rest. What red is left is the gap. + drawTiles(ctx, scene.forcedPrefix, toPx, thick, thin, ink, prefix, clear); + if (thinR > 0 && clear > 0) { + fillTile(ctx, scene.temptingThin.v, toPx, scene.temptingThin.type === "fat" ? thick : thin, ink, thinR * clear, 1.1); + } + drawTiles(ctx, scene.strandFill, toPx, thick, thin, ink, strand, clear); + + // 4. The one surviving completion grows on top and settles the patch clean. + drawTiles(ctx, scene.completion, toPx, thick, thin, ink, comp, 1); + + // Captions, one beat at a time. + if (t < THIN_FROM) { + caption(ctx, "a few locally legal tiles, all fine so far", VB_W / 2, VB_H - 20, ink, prefix * 0.85); + } else if (t < STRAND_FROM) { + caption(ctx, "the thin the expert pointed at fits here, zero overlap", VB_W / 2, VB_H - 20, ink, thinR * 0.85); + } else if (t < CLEAR_FROM) { + caption(ctx, "fill in the rest, and the red gap can take no tile", VB_W / 2, VB_H - 20, ink, strand * clear * 0.9); + } else if (comp > 0.35) { + const lead = (comp - 0.35) / 0.65; + caption(ctx, "only this completion survives", VB_W / 2, VB_H - 30, ink, lead * 0.85); + caption(ctx, "no rule invoked, the shapes alone decide", VB_W / 2, VB_H - 14, ink, lead * 0.62); + } +} + +export default function UnsolvableFuture() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + grout: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + const view = useMemo(() => buildView(), []); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + grout: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB_W * dpr; + canvas.height = VB_H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + paint(ctx, t, view, colorsRef.current); + }, + [refreshColors, view], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(1); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + return ( + + + + ); +} diff --git a/src/app/x/penrose/_components/ZoomHierarchy.tsx b/src/app/x/penrose/_components/ZoomHierarchy.tsx new file mode 100644 index 0000000..69e84af --- /dev/null +++ b/src/app/x/penrose/_components/ZoomHierarchy.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import Sketch from "./Sketch"; +import { PHI, rhombiAt, type Pt, type Rhombus } from "./lib/scaling"; + +// "Zoom the hierarchy": the spine's section-9 sketch two, a deflation zoom-in drawn +// as NESTED LINE GRIDS in alternating colours. Each deflation level is the tiling's +// edges drawn in one colour; consecutive levels alternate gold and blue, so as the +// camera dives the nesting stays legible: a gold grid with a finer blue grid inside +// it, then blue prominent with a finer gold grid inside, level after level. The +// current level is brightest, its coarser and finer neighbours fainter, with a gentle +// crossfade as the camera passes each phi-step (no heavy fade to lose the nesting in). +// +// HONEST BY CONSTRUCTION. deflate(L) is subdivide(deflate(L-1)); every level is real +// engine output (lib/scaling.ts and its test), so the finer grid inside a tile is +// exactly that tile's subdivision. The zoom is a true camera scale; the camera stays +// inside the wheel's rim, and tiles are culled by centroid so only the visible patch +// draws. (The geometry is finite: level 10 is already ~55k tiles, so the dive spans +// five real levels.) +// +// Canvas: the harness drives render(t); t = 1 is the deepest zoom on the finest level; +// lowering t zooms back out. + +const VB = 480; + +// Levels drawn. The current level walks MIN_C..MIN_C+STEPS as the camera zooms in. +const MIN_C = 5; +const STEPS = 4; +const LO_LEVEL = MIN_C; // 5 (coarsest base) +const HI_LEVEL = MIN_C + STEPS; // 9 (finest layer drawn out) +// Each level gets a four-phase beat over its share of the timeline. The camera holds +// still through phases 1-3 and only zooms in phase 4. +// [0, P1) the layer alone (a breath) +// [P1, P2) the finer layer draws out across the plane, no zoom +// [P2, P3) both layers held, to let the nesting sink in +// [P3, 1] zoom in so the finer layer reaches the base size, the base fading out +const P1 = 0.2; +const P2 = 0.5; +const P3 = 0.68; + +// The camera dives toward VIEW_C, off the central five-fold star, staying inside the +// unit-radius wheel. RHO_START is the view radius at the coarsest level; it shrinks by +// phi per step (zoom in). +const RHO_START = 0.32; +const VIEW_C: Pt = [0.32, 0.13]; + +function readVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +type Colors = { thick: string; thin: string; paper: string; ink: string }; +type Cell = { corners: readonly Pt[]; cx: number; cy: number }; + +const smooth = (e0: number, e1: number, x: number): number => { + const u = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); + return u * u * (3 - 2 * u); +}; + +function cellsAt(level: number): Cell[] { + return rhombiAt(level).map((r: Rhombus) => { + let cx = 0; + let cy = 0; + for (const [x, y] of r.corners) { + cx += x; + cy += y; + } + return { corners: r.corners, cx: cx / 4, cy: cy / 4 }; + }); +} + +type ToPx = (p: Pt) => [number, number]; + +// Even levels gold, odd levels blue, so consecutive (nested) levels always contrast. +const colorForLevel = (level: number, colors: Colors) => + level % 2 === 0 ? colors.thick : colors.thin; + +// Stroke a level's edges. revealR + band let a layer "draw out" from the centre: a +// tile fades in as the wavefront radius passes its centroid. Pass a revealR past the +// cull radius (band 0) to draw the whole layer at once. +function strokeLevel( + ctx: CanvasRenderingContext2D, + cells: Cell[], + toPx: ToPx, + cullR: number, + color: string, + width: number, + alpha: number, + revealR: number, + band: number, +) { + if (alpha <= 0.01) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineJoin = "round"; + for (const r of cells) { + const rad = Math.hypot(r.cx - VIEW_C[0], r.cy - VIEW_C[1]); + if (rad > cullR) continue; + let a = alpha; + if (band > 0) a *= Math.max(0, Math.min(1, (revealR - rad) / band)); + if (a <= 0.01) continue; + ctx.globalAlpha = a; + ctx.beginPath(); + const [x0, y0] = toPx(r.corners[0]); + ctx.moveTo(x0, y0); + for (let i = 1; i < 4; i++) { + const [x, y] = toPx(r.corners[i]); + ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.stroke(); + } + ctx.restore(); +} + +export default function ZoomHierarchy() { + const canvasRef = useRef(null); + const colorsRef = useRef({ + thick: "#C89B3C", + thin: "#3E6B7C", + paper: "#0f0e0c", + ink: "#ede9d8", + }); + const dprRef = useRef(0); + + const byLevel = useMemo>(() => { + const out: Record = {}; + for (let L = LO_LEVEL; L <= HI_LEVEL; L++) out[L] = cellsAt(L); + return out; + }, []); + + const [level, setLevel] = useState(MIN_C + STEPS); + const levelRef = useRef(MIN_C + STEPS); + const lastTRef = useRef(1); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thick: readVar("--color-penrose-thick", "#C89B3C"), + thin: readVar("--color-penrose-thin", "#3E6B7C"), + paper: readVar("--color-paper", "#0f0e0c"), + ink: readVar("--color-ink", "#ede9d8"), + }; + }, []); + + const render = useCallback( + (t: number) => { + lastTRef.current = t; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + if (dpr !== dprRef.current) { + dprRef.current = dpr; + canvas.width = VB * dpr; + canvas.height = VB * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + refreshColors(); + } + + // t = 1: deepest zoom on the finest level. Each level cycle: hold it alone, draw + // out the finer layer, beat, then zoom so the finer layer becomes the new base. + const seg = t * STEPS; + const k = Math.min(STEPS - 1, Math.floor(seg)); + const g = seg - k; // progress through this level's beat + const baseL = MIN_C + k; + const finerL = baseL + 1; + + const drawOut = smooth(P1, P2, g); // the finer layer draws out in phase 2 + const zoomFrac = smooth(P3, 1, g); // the camera zooms only in phase 4 + const uZoom = k + zoomFrac; // holds through phases 1-3, then dives one level + const rho = RHO_START * Math.pow(PHI, -uZoom); + const c = VB / 2 / rho; + const toPx: ToPx = (p) => [ + VB / 2 + (p[0] - VIEW_C[0]) * c, + VB / 2 - (p[1] - VIEW_C[1]) * c, + ]; + const cullR = rho * 1.6; + const colors = colorsRef.current; + + ctx.clearRect(0, 0, VB, VB); + ctx.fillStyle = colors.paper; + ctx.fillRect(0, 0, VB, VB); + + // Base layer: full through the beats and the draw-out, fading as the zoom hands + // the plane over to the finer layer. + const baseAlpha = 0.92 * (1 - zoomFrac); + if (byLevel[baseL]) { + strokeLevel(ctx, byLevel[baseL], toPx, cullR, colorForLevel(baseL, colors), 1.7, baseAlpha, cullR + 1, 0); + } + // Finer layer: absent in phase 1, drawing out across the plane in phase 2, full + // from then on (and growing to the base size through the zoom). + if (byLevel[finerL] && drawOut > 0.001) { + const full = g >= P2; + const revealR = full ? cullR + 1 : drawOut * cullR; + const band = full ? 0 : cullR * 0.28; + strokeLevel(ctx, byLevel[finerL], toPx, cullR, colorForLevel(finerL, colors), 1.7, 0.92, revealR, band); + } + + const shown = zoomFrac < 0.5 ? baseL : finerL; + if (shown !== levelRef.current) { + levelRef.current = shown; + setLevel(shown); + } + }, + [byLevel, refreshColors], + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + refreshColors(); + render(lastTRef.current); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [refreshColors, render]); + + return ( + + +
+
+ + level{" "} + {level} + + + each level{" "} + alternates gold / blue + + + tiles per supertile{" "} + ≈ {(PHI * PHI).toFixed(3)} + +
+

+ Each grid is one deflation level, the next 1/φ ≈ {(1 / PHI).toFixed(3)} the + size nested inside it, drawn in the opposite colour so the layers stay + distinct. Every supertile holds φ² ≈ {(PHI * PHI).toFixed(3)} of the tiles a + level down. Inflate or deflate forever and you stay on a valid Penrose + tiling, a copy of itself at every scale. +

+
+
+ ); +} diff --git a/src/app/x/penrose/_components/lib/address.test.ts b/src/app/x/penrose/_components/lib/address.test.ts new file mode 100644 index 0000000..e6b3205 --- /dev/null +++ b/src/app/x/penrose/_components/lib/address.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; + +import { physical, type Vec5 } from "../../explore/lib/cap"; +import { buildEdgeWalk, DIRS } from "./address"; + +// This BINDS the address sketch to the engine. The sketch claims you can walk to any +// tile along the tiling's own edges, each edge one of five fixed directions, and that +// the route lines up with the grid. Every claim is a test: the directions are unit +// vectors 72 degrees apart, and every segment of the route is a genuine unit-length +// tile edge in one of those directions, ending on the target tile's vertex. If the +// route ever leaves the edges, it would not line up, and this fails. + +describe("the five directions are the pentagon edge directions", () => { + test("each is a unit vector", () => { + for (const [x, y] of DIRS) expect(Math.hypot(x, y)).toBeCloseTo(1, 12); + }); + test("consecutive directions are 72 degrees apart", () => { + for (let l = 0; l < 5; l++) { + const a = DIRS[l]; + const b = DIRS[(l + 1) % 5]; + const dot = a[0] * b[0] + a[1] * b[1]; + expect(dot).toBeCloseTo(Math.cos((2 * Math.PI) / 5), 12); + } + }); +}); + +describe("the edge walk lies on real tile edges (it lines up with the grid)", () => { + const w = buildEdgeWalk(); + + test("the route is non-trivial and ends at the target vertex", () => { + expect(w.path.length).toBeGreaterThan(6); + expect(w.edgeDirs.length).toBe(w.path.length - 1); + const end = w.path[w.path.length - 1]; + const [px, py] = physical(w.targetCoord as unknown as Vec5); + expect(end[0]).toBeCloseTo(px, 9); + expect(end[1]).toBeCloseTo(py, 9); + }); + + test("every segment is a unit-length tile edge in one of the five directions", () => { + for (let i = 1; i < w.path.length; i++) { + const a = w.path[i - 1]; + const b = w.path[i]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + expect(len).toBeCloseTo(1, 6); // a real unit edge, so it lines up with the tiles + const dir = w.edgeDirs[i - 1]; + expect(dir).toBeGreaterThanOrEqual(0); + expect(dir).toBeLessThanOrEqual(4); + // the segment runs parallel to its named edge direction + const dx = (b[0] - a[0]) / len; + const dy = (b[1] - a[1]) / len; + const dot = Math.abs(dx * DIRS[dir][0] + dy * DIRS[dir][1]); + expect(dot).toBeCloseTo(1, 6); + } + }); +}); diff --git a/src/app/x/penrose/_components/lib/address.ts b/src/app/x/penrose/_components/lib/address.ts new file mode 100644 index 0000000..c6badd6 --- /dev/null +++ b/src/app/x/penrose/_components/lib/address.ts @@ -0,0 +1,127 @@ +// Data for the "every tile knows its address" sketch (spine section 8). The address +// is the tile's ℤ⁵ coordinate. Every edge of the tiling is a unit step in one of five +// fixed directions (the tile edge directions), so you can WALK to any tile along its +// edges. This module builds the patch's edge graph and a breadth-first route from the +// origin tile to a target, so the route lies on REAL edges and lines up with the grid +// when the tiles are drawn. Bound to the engine (cap.ts, buildPatch) by address.test.ts. + +import { PCOS, PSIN, type Pt } from "../../explore/lib/cap"; +import { buildPatch } from "./cutProject"; + +// The five physical edge directions, d_l at angle 72*l degrees. +export const DIRS: Pt[] = PCOS.map((c, l) => [c, PSIN[l]] as Pt); + +// --------------------------------------------------------------------------- +// The edge walk: a route to a tile along REAL tile edges, so it lines up with the +// grid when the tiles are revealed. Every tiling edge is a unit step in one of the +// five directions, so the walk is still "steps along the five directions," now along +// edges the tiles actually share. Bound by address.test.ts. +// --------------------------------------------------------------------------- + +export type EdgeWalk = { + start: { coord: number[]; p: Pt }; // the vertex nearest the origin + targetCoord: number[]; // the tile we walk to (its anchor vertex's coordinate) + targetType: "thick" | "thin"; + targetCorners: Pt[]; // the target tile, to highlight + path: Pt[]; // physical points along the route, start..target vertex + edgeDirs: number[]; // direction index 0..4 of each edge (length path.length - 1) +}; + +// Build the patch's edge graph and shortest-route to a clear, mid-distance target. +export function buildEdgeWalk(): EdgeWalk { + const patch = buildPatch(); + const vert = new Map(); + const adj = new Map(); + const link = (ak: string, bk: string, dir: number) => { + const a = adj.get(ak) ?? adj.set(ak, []).get(ak)!; + if (!a.some((e) => e.k === bk)) a.push({ k: bk, dir }); + }; + for (const t of patch) { + const cc = t.cornerCoords as unknown as number[][]; + const pp = t.physical; + for (let i = 0; i < 4; i++) { + const a = cc[i]; + const b = cc[(i + 1) % 4]; + const ak = a.join(","); + const bk = b.join(","); + vert.set(ak, { coord: [...a], p: [pp[i][0], pp[i][1]] }); + vert.set(bk, { coord: [...b], p: [pp[(i + 1) % 4][0], pp[(i + 1) % 4][1]] }); + let dir = 0; + for (let l = 0; l < 5; l++) if (b[l] !== a[l]) { dir = l; break; } + link(ak, bk, dir); + link(bk, ak, dir); + } + } + + // Start: the accepted vertex nearest the physical origin. + let startK = ""; + let bestD = Infinity; + for (const [k, v] of vert) { + const d = Math.hypot(v.p[0], v.p[1]); + if (d < bestD) { + bestD = d; + startK = k; + } + } + + // Breadth-first distances and parents from the start vertex. + const parent = new Map(); + const dist = new Map(); + parent.set(startK, null); + dist.set(startK, 0); + const queue = [startK]; + for (let qi = 0; qi < queue.length; qi++) { + const cur = queue[qi]; + const d = dist.get(cur)!; + for (const e of adj.get(cur) ?? []) { + if (!dist.has(e.k)) { + dist.set(e.k, d + 1); + parent.set(e.k, { k: cur, dir: e.dir }); + queue.push(e.k); + } + } + } + + // Target: a tile sitting well inside the patch (so it is not cut by the patch + // edge), reaching to the side so the route spans the view. Deterministic: among + // reachable tiles with anchor radius in [3.8, 4.8], the rightmost, tie-broken by + // coordinate. The breadth-first route to it is a real edge path (~8-11 edges). + let targetTile = patch[0]; + let best = -Infinity; + for (const t of patch) { + const k = (t.cornerCoords[0] as unknown as number[]).join(","); + if (!dist.has(k)) continue; + const v = vert.get(k)!; + const r = Math.hypot(v.p[0], v.p[1]); + if (r < 3.8 || r > 4.8) continue; + const score = v.p[0] * 1000 + v.p[1]; + if (score > best) { + best = score; + targetTile = t; + } + } + const targetK = (targetTile.cornerCoords[0] as unknown as number[]).join(","); + + // Reconstruct the route start..target. + const keys: string[] = []; + const dirs: number[] = []; + let cur: string | undefined = targetK; + while (cur) { + keys.push(cur); + const par = parent.get(cur); + if (!par) break; + dirs.push(par.dir); + cur = par.k; + } + keys.reverse(); + dirs.reverse(); + + return { + start: vert.get(startK)!, + targetCoord: [...(targetTile.coord as number[])], + targetType: targetTile.type, + targetCorners: targetTile.physical.map(([x, y]) => [x, y] as Pt), + path: keys.map((k) => vert.get(k)!.p), + edgeDirs: dirs, + }; +} diff --git a/src/app/x/penrose/_components/lib/cutProject.test.ts b/src/app/x/penrose/_components/lib/cutProject.test.ts new file mode 100644 index 0000000..7462b3c --- /dev/null +++ b/src/app/x/penrose/_components/lib/cutProject.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; + +import { internal, inWindow, index, physical } from "../../explore/lib/cap"; +import { WINDOW_CENTER } from "../../explore/lib/pentagrid"; +import { + buildPatch, + cornerCoords, + rejectedPoints, + windowPolygon, + WINDOW, +} from "./cutProject"; + +// This test BINDS the cut-and-project sketch to the engine math. The sketch draws +// physical(coord) on the left and internal(coord) on the right for the SAME corner +// coords; a tile exists iff all four corners pass inWindow. If anyone redraws the +// picture so the dots stop being internal() of the drawn coords, or shows a +// "rejected" point that actually lands in the window, this fails. + +const [VX, VY] = WINDOW_CENTER; +const patch = buildPatch(); + +describe("the window center the sketch uses is the explorer's", () => { + test("WINDOW matches WINDOW_CENTER", () => { + expect(WINDOW.vx).toBe(VX); + expect(WINDOW.vy).toBe(VY); + }); +}); + +describe("every drawn tile is a real accepted tile", () => { + test("the patch is a genuine, non-trivial Penrose patch", () => { + expect(patch.length).toBeGreaterThan(40); + }); + + test("every corner of every tile is accepted (inWindow true)", () => { + for (const t of patch) { + for (const c of t.cornerCoords) { + expect(inWindow(c, VX, VY)).toBe(true); + } + } + }); + + test("every accepted corner has index in {1,2,3,4}", () => { + for (const t of patch) { + for (const c of t.cornerCoords) { + const i = index(c); + expect(i).toBeGreaterThanOrEqual(1); + expect(i).toBeLessThanOrEqual(4); + } + } + }); +}); + +describe("the picture IS the math: drawn points are the cap.ts projections", () => { + test("left-panel corners are exactly physical(coord)", () => { + for (const t of patch) { + t.cornerCoords.forEach((c, i) => { + const [px, py] = physical(c); + expect(t.physical[i][0]).toBe(px); + expect(t.physical[i][1]).toBe(py); + }); + } + }); + + test("right-panel shadows are exactly internal(coord), same coords", () => { + for (const t of patch) { + t.cornerCoords.forEach((c, i) => { + const [ix, iy] = internal(c); + expect(t.internal[i][0]).toBe(ix); + expect(t.internal[i][1]).toBe(iy); + }); + } + }); + + test("cornerCoords are the rhombus corners n, n+e_j, n+e_j+e_k, n+e_k", () => { + for (const t of patch.slice(0, 5)) { + const expected = cornerCoords(t.coord, t.j, t.k); + expect(t.cornerCoords).toEqual(expected); + } + }); +}); + +describe("every accepted shadow lands inside its index window", () => { + // The right panel draws windowPolygon(idx) and plots internal(corner) inside it. + // inWindow is the engine's own test; the polygon must agree with it. We assert + // the shadow is inside the polygon for the corner's index, by ray casting. + function inPoly(p: readonly [number, number], poly: readonly [number, number][]) { + let inside = false; + for (let i = 0, jj = poly.length - 1; i < poly.length; jj = i++) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[jj]; + const hit = + yi > p[1] !== yj > p[1] && + p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi; + if (hit) inside = !inside; + } + return inside; + } + + test("internal(corner) sits inside windowPolygon(index(corner))", () => { + for (const t of patch) { + for (const c of t.cornerCoords) { + const poly = windowPolygon(index(c)); + expect(inPoly(internal(c), poly)).toBe(true); + } + } + }); +}); + +describe("rejected points the sketch shows are genuinely discarded", () => { + const rejected = rejectedPoints(); + + test("there are rejected points to show", () => { + expect(rejected.length).toBeGreaterThan(2); + }); + + test("each rejected point fails inWindow (shadow outside the window)", () => { + for (const r of rejected) { + expect(inWindow(r.coord, VX, VY)).toBe(false); + } + }); + + test("each rejected point is a candidate vertex, index in {1,2,3,4}", () => { + for (const r of rejected) { + const i = index(r.coord); + expect(i).toBeGreaterThanOrEqual(1); + expect(i).toBeLessThanOrEqual(4); + } + }); + + test("the rejected dot the sketch plots is exactly internal(coord)", () => { + for (const r of rejected) { + const [ix, iy] = internal(r.coord); + expect(r.internal[0]).toBe(ix); + expect(r.internal[1]).toBe(iy); + } + }); +}); + +describe("the shadow space is bounded (this is the whole point)", () => { + test("every accepted shadow stays within the largest window of the center", () => { + // Walk anywhere in the unbounded plane, the shadow never escapes ~τ of center. + const TAU = (1 + Math.sqrt(5)) / 2; + for (const t of patch) { + for (const c of t.cornerCoords) { + const [ix, iy] = internal(c); + expect(Math.hypot(ix - VX, iy - VY)).toBeLessThan(TAU + 1e-6); + } + } + }); +}); diff --git a/src/app/x/penrose/_components/lib/cutProject.ts b/src/app/x/penrose/_components/lib/cutProject.ts new file mode 100644 index 0000000..0417d0c --- /dev/null +++ b/src/app/x/penrose/_components/lib/cutProject.ts @@ -0,0 +1,149 @@ +// Data for the cut-and-project sketch (spine section 6, "So you solve it globally"). +// +// The teaching claim: stop tiling locally. Every tile is the shadow of a point in +// the 5D integer lattice ℤ⁵, and a point becomes a tiling vertex iff its INTERNAL +// shadow lands inside a small acceptance window. The test is local to each point, +// so the plane is computed and can never strand. This module produces exactly the +// numbers the sketch draws, all through the real engine (cap.ts, pentagrid.ts), so +// the picture is the math and the colocated test can bind the two. +// +// Two frames, both from cap.ts: +// physical(n) places a vertex in the plane (left panel, the tiling). +// internal(n) is the "shadow" (right panel, the bounded acceptance space). +// A tile [coord; j,k] is the rhombus with corners coord, +e_j, +e_j+e_k, +e_k. It +// is a real tile iff all four corners pass inWindow at this tiling's window center. + +import { + internal, + physical, + index, + inWindow, + TAU, + type Pt, + type Vec5, +} from "../../explore/lib/cap"; +import { facesInViewport, GAMMA, WINDOW_CENTER } from "../../explore/lib/pentagrid"; +import type { RenderFace } from "../../explore/lib/patch"; + +export type { Pt, Vec5 } from "../../explore/lib/cap"; + +const [VX, VY] = WINDOW_CENTER; + +// The four corner ℤ⁵ coords of the rhombus [coord; j,k], cyclic: +// n, n+e_j, n+e_j+e_k, n+e_k. The same order patch.ts and pentagrid use, so the +// physical corners line up tile for tile with what the enumerator drew. +export function cornerCoords( + coord: readonly number[], + j: number, + k: number, +): [Vec5, Vec5, Vec5, Vec5] { + const c0 = [...coord]; + const c1 = [...c0]; + c1[j]++; + const c2 = [...c1]; + c2[k]++; + const c3 = [...c0]; + c3[k]++; + return [c0, c1, c2, c3] as unknown as [Vec5, Vec5, Vec5, Vec5]; +} + +// One accepted tile: its address, the four corner coords, and both projections of +// each corner. physical[] feeds the left panel, internal[] the right. +export type SketchTile = { + key: string; + coord: readonly number[]; + j: number; + k: number; + type: "thick" | "thin"; + // Corner data, index-aligned (length 4, cyclic). + cornerCoords: [Vec5, Vec5, Vec5, Vec5]; + physical: [Pt, Pt, Pt, Pt]; + internal: [Pt, Pt, Pt, Pt]; +}; + +function toSketchTile(f: RenderFace): SketchTile { + const cc = cornerCoords(f.coord, f.j, f.k); + return { + key: f.key, + coord: f.coord, + j: f.j, + k: f.k, + type: f.type, + cornerCoords: cc, + physical: cc.map(physical) as [Pt, Pt, Pt, Pt], + internal: cc.map(internal) as [Pt, Pt, Pt, Pt], + }; +} + +// A real patch of the fixed tiling, the same enumerator the explorer runs, at the +// pinned window center. A modest viewport: enough tiles to read as a tiling, small +// enough to draw cleanly. +const VIEW = { minX: -5.5, minY: -5.5, maxX: 5.5, maxY: 5.5 }; + +export function buildPatch(): SketchTile[] { + return facesInViewport(VIEW, GAMMA) + .map(toSketchTile) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +// A rejected lattice point: index in {1..4} (so it is a candidate vertex) but its +// internal shadow falls OUTSIDE the acceptance window, so the plane discards it. +export type Rejected = { coord: Vec5; internal: Pt }; + +// Sample ℤ⁵ points whose shadow lands outside the window, for the "discarded" +// dots on the right. We scan a small lattice box, keep points with index in +// {1..4} that fail inWindow, and whose shadow sits near (but outside) the window +// so the dots read as "just missed" rather than scattered to infinity. Deterministic. +export function rejectedPoints(limit = 7): Rejected[] { + const out: Rejected[] = []; + const n = [0, 0, 0, 0, 0]; + const R = 2; + for (n[0] = -R; n[0] <= R; n[0]++) + for (n[1] = -R; n[1] <= R; n[1]++) + for (n[2] = -R; n[2] <= R; n[2]++) + for (n[3] = -R; n[3] <= R; n[3]++) + for (n[4] = -R; n[4] <= R; n[4]++) { + const v = [n[0], n[1], n[2], n[3], n[4]] as unknown as Vec5; + const idx = index(v); + if (idx < 1 || idx > 4) continue; + if (inWindow(v, VX, VY)) continue; + const [ix, iy] = internal(v); + const r = Math.hypot(ix - VX, iy - VY); + // Just outside the largest window (circumradius τ), not way out. + if (r > TAU && r < TAU + 1.1) out.push({ coord: v, internal: [ix, iy] }); + } + // Spread the kept dots around the window so they don't pile on one side. + out.sort((a, b) => Math.atan2(a.internal[1] - VY, a.internal[0] - VX) - Math.atan2(b.internal[1] - VY, b.internal[0] - VX)); + const step = Math.max(1, Math.floor(out.length / limit)); + const spread: Rejected[] = []; + for (let i = 0; i < out.length && spread.length < limit; i += step) spread.push(out[i]); + return spread; +} + +// The acceptance window for an index, centered at the tiling's window center. +// inWindow tests internal(n) against v + s·P, where P is the unit pentagon and +// s = SCALE_BY_INDEX[idx]. The four nested pentagons, by index, in INTERNAL space. +// We expose center, scale, and a flag for whether the pentagon is reflected +// (negative scale) so the sketch can draw the same outline inWindow checks against. +export const WINDOW = { vx: VX, vy: VY } as const; +const SCALE_BY_INDEX = [0, 1, -TAU, TAU, -1]; + +// Pentagon outline (circumradius 1, a vertex at angle 0), the unit window P that +// inPentagon tests. Scaled and translated, this is the index window's boundary. +export function unitPentagon(): Pt[] { + const pts: Pt[] = []; + for (let i = 0; i < 5; i++) { + const a = (2 * Math.PI * i) / 5; + pts.push([Math.cos(a), Math.sin(a)]); + } + return pts; +} + +// The boundary polygon of the index window in internal space: vertices of +// v + s·P. Negative s reflects through v, which is the genuine window for that index. +export function windowPolygon(idx: number): Pt[] { + const s = SCALE_BY_INDEX[idx]; + return unitPentagon().map(([x, y]) => [VX + s * x, VY + s * y] as Pt); +} + +export const INDICES = [1, 2, 3, 4] as const; diff --git a/src/app/x/penrose/_components/lib/fibonacci.test.ts b/src/app/x/penrose/_components/lib/fibonacci.test.ts new file mode 100644 index 0000000..691aa01 --- /dev/null +++ b/src/app/x/penrose/_components/lib/fibonacci.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; + +import { + chain, + internal, + latticePoints, + LONG, + PHI, + physical, + SHORT, + WINDOW_W, +} from "./fibonacci"; + +// This BINDS the Fibonacci-strip sketch to the cut-and-project theorem it claims to +// show. The sketch says: lattice points inside a strip of one-cell width project to +// a chain of exactly two lengths, in ratio phi, ordered like the Fibonacci word and +// counted toward phi. Every one of those claims is a test here. If the sketch ever +// drifts into a hand-drawn "aperiodic-looking" picture, one of these fails. + +// Interior helpers: trim the outer points of a chain so the square (m,n) box edge +// can't masquerade as a tiling defect. The theorem is about the bulk. +function interior(xs: T[], drop: number): T[] { + return xs.slice(drop, xs.length - drop); +} + +describe("the two lengths are phi apart, by construction", () => { + const cases: Array<[string, number, number]> = [ + ["LONG / SHORT", LONG / SHORT, PHI], + ["window width is phi^2 / norm", WINDOW_W, PHI * PHI / Math.sqrt(PHI * PHI + 1)], + ["LONG is the (1,0) step", LONG, physical(1, 0)], + ["SHORT is the (0,1) step", SHORT, physical(0, 1)], + ]; + for (const [name, got, want] of cases) { + test(name, () => expect(got).toBeCloseTo(want, 12)); + } + + test("the window is exactly one unit cell wide on the internal axis", () => { + // The unit square's four corners projected onto the internal axis span WINDOW_W. + const corners = [internal(0, 0), internal(1, 0), internal(0, 1), internal(1, 1)]; + const span = Math.max(...corners) - Math.min(...corners); + expect(span).toBeCloseTo(WINDOW_W, 12); + }); +}); + +describe("a one-cell strip projects to a two-length chain", () => { + // A handful of offsets (phases). The construction must hold for every phase, not + // one lucky alignment. + const offsets = [-0.7, -0.2, 0, 0.31, 0.6]; + + for (const gamma of offsets) { + test(`gamma ${gamma}: every interior gap is LONG or SHORT`, () => { + const { segs } = chain(22, gamma); + const inner = interior(segs, 3); + expect(inner.length).toBeGreaterThan(8); + for (const s of inner) { + const gap = s.to.phys - s.from.phys; + const isLong = Math.abs(gap - LONG) < 1e-9; + const isShort = Math.abs(gap - SHORT) < 1e-9; + expect(isLong || isShort).toBe(true); + } + }); + + test(`gamma ${gamma}: consecutive accepted points differ by one unit step`, () => { + // The heart of cut-and-project: with a one-cell window, the only steps that + // keep you in the strip are (1,0) and (0,1). This is WHY there are two lengths. + const { segs } = chain(22, gamma); + for (const s of interior(segs, 3)) { + const dm = s.to.m - s.from.m; + const dn = s.to.n - s.from.n; + const unit = (dm === 1 && dn === 0) || (dm === 0 && dn === 1); + expect(unit).toBe(true); + } + }); + + test(`gamma ${gamma}: no two short intervals are adjacent`, () => { + // The Fibonacci word has no "SS": every short is flanked by longs. + const { segs } = chain(22, gamma); + const word = interior(segs, 3).map((s) => s.kind); + for (let i = 1; i < word.length; i++) { + expect(word[i] === "S" && word[i - 1] === "S").toBe(false); + } + }); + } +}); + +describe("the thick:thin count tends to phi", () => { + // Same beat as the golden-ratio sketch, here from the chain itself: long:short + // approaches phi as the sampled run grows. + test("long:short over a long run is within 0.05 of phi", () => { + const { segs } = chain(60, 0.123); + const inner = interior(segs, 6); + const longs = inner.filter((s) => s.kind === "L").length; + const shorts = inner.filter((s) => s.kind === "S").length; + expect(longs).toBeGreaterThan(0); + expect(shorts).toBeGreaterThan(0); + expect(longs / shorts).toBeCloseTo(PHI, 1); + expect(Math.abs(longs / shorts - PHI)).toBeLessThan(0.05); + }); +}); + +describe("sliding the strip changes the chain but not its alphabet", () => { + // The overlay beat in miniature: two phases give locally-identical tilings (same + // two lengths) that are not the same sequence. + test("two offsets yield the same lengths, different order", () => { + const a = chain(30, 0).segs.map((s) => s.kind).join(""); + const b = chain(30, 0.5).segs.map((s) => s.kind).join(""); + expect(a).not.toBe(b); + // both drawn from the same two-letter alphabet + expect(/^[LS]+$/.test(a)).toBe(true); + expect(/^[LS]+$/.test(b)).toBe(true); + }); +}); + +describe("acceptance is the local window test, nothing more", () => { + test("a point is accepted iff its internal shadow is in the window", () => { + for (const p of latticePoints(8, 0)) { + expect(p.accepted).toBe(p.internal >= 0 && p.internal < WINDOW_W); + } + }); +}); diff --git a/src/app/x/penrose/_components/lib/fibonacci.ts b/src/app/x/penrose/_components/lib/fibonacci.ts new file mode 100644 index 0000000..6fd7b80 --- /dev/null +++ b/src/app/x/penrose/_components/lib/fibonacci.ts @@ -0,0 +1,95 @@ +// The 2D -> 1D cut-and-project: the visible, honest miniature of the 5D -> 2D +// construction the explorer runs. A line through the integer lattice Z^2 at the +// golden slope, and a strip (the "window") around it. Lattice points that fall in +// the strip project onto the line and produce the Fibonacci chain: long and short +// intervals whose lengths are in ratio phi and whose order never repeats. +// +// physical(p) = p . D position along the line (the kept shadow) +// internal(p) = p . DPERP distance off the line (the shadow that must land +// in the window) +// accept(p) iff internal(p) in [gamma, gamma + WINDOW_W) +// +// The window width is exactly the unit cell projected onto the internal axis, the +// 2D image of "the projection of the unit hypercube" that defines the Penrose +// window. With that width, consecutive accepted points along the line differ by a +// single unit lattice step, (1,0) or (0,1), which project to the two physical +// lengths LONG = phi/NORM and SHORT = 1/NORM, ratio phi. fibonacci.test.ts binds +// the code to that claim: two gap lengths, ratio phi, unit steps only, no SS, and +// a thick:thin count tending to phi. + +export const PHI = (1 + Math.sqrt(5)) / 2; +const NORM = Math.sqrt(PHI * PHI + 1); + +// Physical direction: the line of slope 1/phi. Internal direction: its perpendicular. +export const D: readonly [number, number] = [PHI / NORM, 1 / NORM]; +export const DPERP: readonly [number, number] = [-1 / NORM, PHI / NORM]; + +// The window is the unit cell projected onto the internal axis. Width phi^2 / NORM +// (phi + 1 = phi^2). +export const WINDOW_W = (PHI + 1) / NORM; + +export const LONG = PHI / NORM; // physical length of a (1,0) step +export const SHORT = 1 / NORM; // physical length of a (0,1) step + +export type LatPt = { + m: number; + n: number; + phys: number; // along the line + internal: number; // off the line (the shadow tested against the window) + accepted: boolean; +}; + +export function physical(m: number, n: number): number { + return (PHI * m + n) / NORM; +} + +export function internal(m: number, n: number): number { + return (-m + PHI * n) / NORM; +} + +// Every lattice point with |m|,|n| <= range, each tagged accepted against the +// strip [gamma, gamma + WINDOW_W). +export function latticePoints(range: number, gamma: number): LatPt[] { + const out: LatPt[] = []; + for (let m = -range; m <= range; m++) { + for (let n = -range; n <= range; n++) { + const off = internal(m, n); + out.push({ + m, + n, + phys: physical(m, n), + internal: off, + accepted: off >= gamma && off < gamma + WINDOW_W, + }); + } + } + return out; +} + +export type ChainSeg = { + from: LatPt; + to: LatPt; + kind: "L" | "S"; +}; + +// The 1D chain: accepted points sorted along the line, each gap classified long or +// short by which unit step bridges it. +export function chain( + range: number, + gamma: number, +): { points: LatPt[]; segs: ChainSeg[] } { + const points = latticePoints(range, gamma) + .filter((p) => p.accepted) + .sort((a, b) => a.phys - b.phys); + const mid = (LONG + SHORT) / 2; + const segs: ChainSeg[] = []; + for (let i = 1; i < points.length; i++) { + const gap = points[i].phys - points[i - 1].phys; + segs.push({ + from: points[i - 1], + to: points[i], + kind: gap > mid ? "L" : "S", + }); + } + return { points, segs }; +} diff --git a/src/app/x/penrose/_components/lib/genGeomWalls.ts b/src/app/x/penrose/_components/lib/genGeomWalls.ts new file mode 100644 index 0000000..09b5b0e --- /dev/null +++ b/src/app/x/penrose/_components/lib/genGeomWalls.ts @@ -0,0 +1,32 @@ +// Generate the committed geomWalls.json the geometry-only sketches render. +// +// bun src/app/x/penrose/_components/lib/genGeomWalls.ts +// +// The sketches import this JSON so they do not recompute two large boards in the +// browser. The data is not trusted on its own: geomWall.test.ts re-runs +// computeGeomWalls() and asserts the committed JSON matches it byte-for-byte AND +// that every dead-end is a real geometric wall (the tempting move fits with zero +// overlap; after it every candidate on the next gap overlaps by real area). +// Regenerate this whenever the geometry parameters change, then run the test. +// +// Uses node:fs (not the Bun global) so it typechecks under the project's Node +// typings while still running under bun. + +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { computeGeomWalls } from "./geomWall"; + +const walls = computeGeomWalls(); +const path = fileURLToPath(new URL("./geomWalls.json", import.meta.url)); +writeFileSync(path, JSON.stringify(walls, null, 2) + "\n"); + +const a = walls.sceneA_rigidHexagon; +const b = walls.sceneB_thinRefuted; +console.log( + `geomWalls.json written:\n` + + ` A rigid hexagon: wall=${a.wall.length} completion=${a.uniqueCompletion.length} ` + + `wrong=${a.wrongMove.type} c${a.wrongMove.corner} gaps=${a.unfillableGaps.length}\n` + + ` B thin refuted: wall=${b.wall.length} prefix=${b.forcedPrefix.length} ` + + `tempting=${b.temptingThin.type} c${b.temptingThin.corner} gaps=${b.unfillableGaps.length}`, +); diff --git a/src/app/x/penrose/_components/lib/genScene.ts b/src/app/x/penrose/_components/lib/genScene.ts new file mode 100644 index 0000000..ed3c36f --- /dev/null +++ b/src/app/x/penrose/_components/lib/genScene.ts @@ -0,0 +1,25 @@ +// Generate the committed scene.json the section-5 sketch renders. +// +// bun src/app/x/penrose/_components/lib/genScene.ts +// +// The sketch imports this JSON so it does not recompute a 410-tile board in the +// browser. The data is not trusted on its own: unsolvableFuture.test.ts re-runs +// computeScene() and asserts the committed JSON matches it byte-for-byte AND that +// every dead-end is genuinely doomed. Regenerate this whenever the proof +// parameters change, then run the test. +// +// Uses node:fs (not the Bun global) so it typechecks under the project's Node +// typings while still running under bun. + +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { computeScene } from "./unsolvableFuture"; + +const scene = computeScene(); +const path = fileURLToPath(new URL("./scene.json", import.meta.url)); +writeFileSync(path, JSON.stringify(scene, null, 2) + "\n"); +console.log( + `scene.json written: wall=${scene.meta.wallTiles} hole=${scene.meta.holeEdges} ` + + `completion=${scene.meta.completionTiles} deadEnds=${scene.meta.deadEnds}`, +); diff --git a/src/app/x/penrose/_components/lib/geomWall.test.ts b/src/app/x/penrose/_components/lib/geomWall.test.ts new file mode 100644 index 0000000..4f6f302 --- /dev/null +++ b/src/app/x/penrose/_components/lib/geomWall.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; + +import committed from "./geomWalls.json"; +import { + computeGeomWalls, + overlapsReal, + penetration, + TOUCH_EPS, + type Gap, + type GeomWalls, +} from "./geomWall"; + +// This test BINDS both geometry-only sketches to the proof. The sketches render +// geomWalls.json; this test re-runs the overlap-only search that produced it and +// asserts the geometric-wall invariants. The whole point is to answer a Penrose +// expert's objection: the earlier honest sketches rejected a move by the matching +// rule (a tile fits the gap, but the rule forbids it), which a viewer can dispute. +// Here we never invoke the rule. We place the tempting tile, keep building, and +// reach a gap where every candidate OVERLAPS committed material by real area. +// +// If anyone weakens a wall back into a rule-only rejection (a "dead-end" whose +// gap actually had a non-overlapping candidate), or fakes the fit of the tempting +// move, these tests fail. + +const walls: GeomWalls = computeGeomWalls(); + +// An overlap is real, not rounding noise, if its penetration clears 0.1. The two +// verified scenes clear it by a wide margin (min penetration 0.59 and 0.22). +const REAL_OVERLAP = 0.1; + +// Every candidate on a gap overlaps committed material by a real margin: positive +// penetration above the real-overlap floor, and positive shared area. This is the +// geometric wall, asserted from the scene's own overlap evidence. +function assertGeometricWall(gaps: Gap[]) { + expect(gaps.length).toBeGreaterThan(0); + for (const gap of gaps) { + expect(gap.candidates.length).toBeGreaterThan(0); + // ZERO non-overlapping candidates: nothing fits this gap by pure geometry. + const nonOverlapping = gap.candidates.filter( + (c) => c.maxPenetration <= TOUCH_EPS, + ); + expect(nonOverlapping.length).toBe(0); + for (const c of gap.candidates) { + // Every candidate truly overlaps, by a real margin, with real shared area. + expect(c.maxPenetration).toBeGreaterThan(REAL_OVERLAP); + expect(c.overlapArea).toBeGreaterThan(0); + } + } +} + +describe("the overlap engine measures real geometry, not a rule", () => { + test("two disjoint squares do not overlap; two coincident squares do", () => { + const sq = (dx: number) => + [ + [dx, 0], + [dx + 1, 0], + [dx + 1, 1], + [dx, 1], + ] as [number, number][]; + // Edge-touching (shared side) is not overlap: that is how rhombi legitimately + // meet. A real interior overlap is. + expect(overlapsReal(sq(0), sq(1))).toBe(false); // touch along x=1 + expect(overlapsReal(sq(0), sq(0))).toBe(true); // coincident + expect(penetration(sq(0), sq(0.5))).toBeCloseTo(0.5, 6); + }); +}); + +describe("scene A: the rigid hexagon is geometrically rigid", () => { + const a = walls.sceneA_rigidHexagon; + + test("a six-edge hole carved from a real tiling, area ~1.54", () => { + expect(a.holePolygon.length).toBe(6); + expect(a.holeArea).toBeCloseTo(1.539, 2); + expect(a.wall.length).toBeGreaterThan(100); + }); + + test("(a) the correct tile completes the hole: exactly ONE geometry-only filling", () => { + // Rigid: the hole has a single geometry-only completion, two rhombi. The + // correct first move is uniqueCompletion[0]; the wrong move is a DIFFERENT + // tile on the same constrained edge. + expect(a.uniqueCompletion.length).toBe(2); + expect(a.geomCompletionsAfterWrong).toBe(0); + }); + + test("(b) the tempting wrong move fits the constrained edge with ZERO overlap", () => { + // The constrained edge admits two rhombi by pure geometry; the wrong move is + // one of them, so it genuinely fits. We reconstruct the board before the wrong + // move and confirm the wrong tile overlaps nothing on it. + expect(a.geomMovesOnEdge).toBe(2); + const before = a.wall.map((t) => t.v); + let worst = -Infinity; + for (const v of before) { + worst = Math.max(worst, penetration(a.wrongMove.v, v)); + } + expect(worst).toBeLessThanOrEqual(TOUCH_EPS); // it FITS, no overlap + }); + + test("(c) after following the wrong move through, every candidate overlaps", () => { + // The geometric wall: no candidate on any remaining gap fits. Each overlaps a + // committed tile by a real margin. No rule is invoked anywhere. + assertGeometricWall(a.unfillableGaps); + }); +}); + +describe("scene B: the expert's 'a thin fits there' case, refuted", () => { + const b = walls.sceneB_thinRefuted; + + test("a rich 16-edge hole with a locally legal forced prefix", () => { + expect(b.holePolygon.length).toBe(16); + expect(b.forcedPrefix.length).toBe(7); + expect(b.wall.length).toBeGreaterThan(100); + }); + + test("(b) the tempting THIN fits the doomed edge with ZERO overlap", () => { + // This is the exact move the expert pointed at: a thin rhombus that visibly + // fits the edge. It must fit by geometry (penetration below the touch epsilon + // against the whole board), or the refutation is hollow. + expect(b.temptingThin.type).toBe("thin"); + const board = [...b.wall, ...b.forcedPrefix].map((t) => t.v); + let worst = -Infinity; + for (const v of board) { + worst = Math.max(worst, penetration(b.temptingThin.v, v)); + } + expect(worst).toBeLessThanOrEqual(TOUCH_EPS); // it FITS, no overlap + }); + + test("(c) after placing the thin, every candidate on the next gap overlaps", () => { + // The payoff: placing the tile the expert said fits leads, by geometry alone, + // to a gap where nothing fits without real overlap. No rule invoked. + expect(b.geomCompletionsAfterThin).toBe(0); + assertGeometricWall(b.unfillableGaps); + }); +}); + +describe("the strand fill is a real legal partial fill, leaving a true gap", () => { + // The sketches paint the hole red, then draw the committed tiles (the wrong move, + // plus for scene B the prefix and the strand) opaque on top; the red that remains + // is the uncovered gap nothing fits. For that to be honest the strand must be a + // genuine overlap-free partial fill. We re-check every strand tile against the + // whole board it was placed onto. + function strandIsLegal(board: Tile[], strand: Tile[]): boolean { + const placed = [...board]; + for (const t of strand) { + for (const u of placed) if (overlapsReal(t.v, u.v)) return false; + placed.push(t); + } + return true; + } + + test("scene A strands on the wrong move alone (rigid hole, empty strand)", () => { + const a = walls.sceneA_rigidHexagon; + // The two-rhombus hole: the wrong rhombus alone leaves a gap, no strand needed. + expect(a.strandFill.length).toBe(0); + }); + + test("scene B fills most of the hole, then a real gap survives", () => { + const b = walls.sceneB_thinRefuted; + // The thin the expert pointed at: fill the rest as far as possible, and tiles + // still cannot cover everything. A non-trivial legal strand, then a gap. + expect(b.strandFill.length).toBeGreaterThan(0); + const board = [...b.wall, ...b.forcedPrefix, b.temptingThin as unknown as typeof b.wall[number]]; + expect(strandIsLegal(board, b.strandFill)).toBe(true); + // Even after the strand, the hole is not complete: the gap is real. + expect(b.geomCompletionsAfterThin).toBe(0); + }); +}); + +describe("the committed geomWalls.json matches the live computation", () => { + // The sketches render geomWalls.json. This asserts the committed snapshot is + // exactly what the search produces now, so the shipped data cannot drift from + // the proof. Serialise both through JSON so number formatting is identical. + test("geomWalls.json equals computeGeomWalls() byte-for-byte", () => { + const live = JSON.parse(JSON.stringify(walls)); + expect(committed).toEqual(live); + }); +}); diff --git a/src/app/x/penrose/_components/lib/geomWall.ts b/src/app/x/penrose/_components/lib/geomWall.ts new file mode 100644 index 0000000..96c6035 --- /dev/null +++ b/src/app/x/penrose/_components/lib/geomWall.ts @@ -0,0 +1,510 @@ +// The PURE-GEOMETRY dead-end, ported faithfully from a verified spike. This module +// answers the Penrose expert's objection head on. The earlier honest sketches +// rejected a tempting move by the MATCHING RULE: a tile fits the gap, but seating +// it would close a vertex no Penrose tiling allows. A viewer can dispute that. The +// shape fits; you have only asserted a rule. So here we never invoke the rule. We +// place the tempting tile, keep building, and reach a gap where every candidate +// rhombus OVERLAPS committed material by real area. The wall is geometry, not a +// label. No one can dispute a tile sitting on top of another tile. +// +// THE CLAIM, made precise. A candidate is rejected ONLY by real polygon overlap: +// SAT penetration above a touch epsilon, measured against every committed/placed +// tile. The seven-star atlas is never consulted. The hole-frontier selection +// (which open edges still face the hole) is kept only to decide what still needs +// filling; it never rejects a tile. A tile that fits with zero overlap is always +// accepted, even if it is matching-illegal. +// +// WHY EXHAUSTION IS A PROOF. The hole is bounded with finite area A. Every fill +// tile shares an edge with the hole frontier and may not overlap the wall, so all +// fill tiles lie inside the hole (a tile crossing the wall would overlap a wall +// tile and be rejected). Each rhombus has area >= sin(36 deg) > 0, so at most +// ceil(A / sin36) tiles fit. The search tree is finite. We guard that bound +// explicitly: if a branch exceeds it the run is INVALID (capHit). It never does. +// +// TWO SCENES. +// A) A geometrically RIGID hexagon hole: exactly ONE geometry-only filling (two +// rhombi). The constrained edge admits two rhombi by pure geometry; one +// completes, the other (a fat-108 move) strands. After the wrong move every +// candidate on the next gap overlaps. A piece fits and still strands you. +// B) The expert's exact case, refuted. A rich 16-edge hole. On a doomed frontier +// edge a THIN rhombus does fit with zero overlap (the move the expert pointed +// at). Place it. The geometry-only exhaustive search then finds ZERO +// completion: the very next gap admits no rhombus without real overlap. +// +// DETERMINISM. Fixed level 5, fixed anchors, fixed RHOLE per scene, fixed +// candidate try-order, fixed frontier ordering. The scene is a pure function of +// nothing. The committed geomWalls.json is its snapshot; geomWall.test.ts re-runs +// this and asserts both the snapshot match and the geometric-wall invariants, so +// the sketches can never drift into a fake or back into a rule-only rejection. + +import { + Board, + candidates, + keyPt, + type Pt, + type Tile, +} from "./naiveSolver"; +import { + carve, + centroid, + computeScene, + holePolygon, + inPoly, + polyArea, + unitTiling, +} from "./unsolvableFuture"; +import { + overlapArea, + overlapsReal, + penetration, + TOUCH_EPS, +} from "./overlap"; + +export type { Pt, Tile }; +// Re-export the overlap engine so callers (and the proof test) get one entry +// point. The engine itself lives in overlap.ts, dependency-free, so the sketches +// can import it alone without pulling the deflation/search code into the browser. +export { overlapArea, overlapPolygon, overlapsReal, penetration, TOUCH_EPS } from "./overlap"; + +const SIN36 = Math.sin((36 * Math.PI) / 180); + +// Deterministic anchors. Scene A's hexagon center is the tile centroid nearest +// this point; scene B reuses the section-5 hole exactly (same anchor, RHOLE 2.3). +const LEVELS = 5; +const SCENE_A_ANCHOR: Pt = [-0.769, 1.868]; +const SCENE_A_RHOLE = 0.85; + +// --------------------------------------------------------------------------- +// Board and frontier helpers. The overlap engine (penetration / overlapsReal / +// overlapArea) lives in overlap.ts and is imported above: one auditable test, +// independent of the board's private SAT. +// --------------------------------------------------------------------------- + +function boardFrom(tiles: Tile[]): Board { + const b = new Board(); + for (const t of tiles) b.place(t); + return b; +} + +function tileId(t: Tile): string { + return t.v.map(keyPt).sort().join("#") + ":" + t.type; +} + +// The sorted-first endpoint key of a frontier edge, the canonical reference vertex +// for naming a tile's corner there. The edge key is keyPt(a)|keyPt(b) sorted, so +// this is its first half. Naming the corner from a fixed endpoint keeps the label +// stable regardless of which way the frontier walk hands us the edge. +function edgeKeyHead(a: Pt, bb: Pt): string { + const ka = keyPt(a); + const kb = keyPt(bb); + return ka < kb ? ka : kb; +} + +// The corner angle of tile t at the lattice vertex with key vKey, in degrees. +function cornerAt(t: Tile, vKey: string): number | null { + for (let i = 0; i < 4; i++) { + if (keyPt(t.v[i]) !== vKey) continue; + const cur = t.v[i]; + const prev = t.v[(i + 3) % 4]; + const next = t.v[(i + 1) % 4]; + const u: Pt = [prev[0] - cur[0], prev[1] - cur[1]]; + const w: Pt = [next[0] - cur[0], next[1] - cur[1]]; + const c = + (u[0] * w[0] + u[1] * w[1]) / + (Math.hypot(u[0], u[1]) * Math.hypot(w[0], w[1])); + return Math.round((Math.acos(Math.max(-1, Math.min(1, c))) * 180) / Math.PI); + } + return null; +} + +// Frontier: open edges whose inward midpoint lies in the hole. Used ONLY to pick +// which edges still need filling, never to reject a candidate tile. Sorted for +// determinism. +function holeFrontier( + b: Board, + poly: Pt[], +): { key: string; a: Pt; bb: Pt }[] { + const out: { key: string; a: Pt; bb: Pt }[] = []; + const seen = new Set(); + for (const e of b.openEdges()) { + const mid: Pt = [(e.a[0] + e.b[0]) / 2, (e.a[1] + e.b[1]) / 2]; + if (inPoly(mid, poly) && !seen.has(e.key)) { + seen.add(e.key); + out.push({ key: e.key, a: e.a, bb: e.b }); + } + } + out.sort((x, y) => (x.key < y.key ? -1 : 1)); + return out; +} + +// Geometry-only legal fills on an edge: reject ONLY by real overlap with the full +// board. No matching-rule test. De-duplicated, in the fixed candidate order. +function geomFills(allTiles: Tile[], a: Pt, bb: Pt): Tile[] { + const seen = new Set(); + const out: Tile[] = []; + for (const c of candidates(a, bb)) { + let bad = false; + for (const u of allTiles) { + if (overlapsReal(c.v, u.v)) { + bad = true; + break; + } + } + if (bad) continue; + const id = tileId(c); + if (seen.has(id)) continue; + seen.add(id); + out.push(c); + } + return out; +} + +// --------------------------------------------------------------------------- +// The geometry-only exhaustive search. Returns the number of completions and the +// first one found. countAll enumerates every completion; else it stops at first. +// --------------------------------------------------------------------------- + +type GeomResult = { + completions: number; + firstCompletion: Tile[] | null; + capHit: boolean; +}; + +function geomSearch(fixed: Tile[], poly: Pt[], countAll = false): GeomResult { + const res: GeomResult = { + completions: 0, + firstCompletion: null, + capHit: false, + }; + const maxFill = Math.ceil(polyArea(poly) / SIN36) + 6; + const extra: Tile[] = []; + + function rec(): boolean { + if (extra.length > maxFill) { + res.capHit = true; + return false; + } + const all = [...fixed, ...extra]; + const b = boardFrom(all); + const front = holeFrontier(b, poly); + if (front.length === 0) { + res.completions++; + if (!res.firstCompletion) res.firstCompletion = [...extra]; + return !countAll; + } + // Drive the most-constrained edge: fewest geometric fills first. + let chosen = front[0]; + let fills = geomFills(all, front[0].a, front[0].bb); + for (let i = 1; i < front.length; i++) { + const f = geomFills(all, front[i].a, front[i].bb); + if (f.length < fills.length) { + chosen = front[i]; + fills = f; + } + if (fills.length === 0) break; + } + if (fills.length === 0) return false; // geometric dead-end: no fill here + void chosen; + for (const cand of fills) { + extra.push(cand); + if (rec()) return true; + extra.pop(); + } + return false; + } + rec(); + return res; +} + +// --------------------------------------------------------------------------- +// A serialisable candidate on an unfillable gap, with its real overlap evidence. +// --------------------------------------------------------------------------- + +export type GapCandidate = { + type: "fat" | "thin"; + corner: number | null; + v: [Pt, Pt, Pt, Pt]; + // The worst penetration (smallest separating distance) against the board. Above + // TOUCH_EPS means this candidate truly overlaps committed material. + maxPenetration: number; + // The real shared area against the board. Zero would mean it fits; here every + // candidate's value is positive, which is the whole point. + overlapArea: number; +}; + +export type Gap = { + edge: [Pt, Pt]; + candidates: GapCandidate[]; +}; + +// Enumerate every distinct candidate on a gap edge and, for each, the worst real +// overlap against the board. This is the adversarial "no piece fits" evidence: +// every candidate carries a positive penetration and a positive shared area. +function enumGap(all: Tile[], a: Pt, bb: Pt): Gap { + // Name each candidate's corner from the edge endpoint `a` handed to us by the + // frontier walk. (The frontier orders edges deterministically, so this is + // stable.) It is a descriptive label on the overlap evidence, not load-bearing. + const ka = keyPt(a); + const seen = new Set(); + const cands: GapCandidate[] = []; + for (const c of candidates(a, bb)) { + const id = tileId(c); + if (seen.has(id)) continue; + seen.add(id); + let maxPen = -Infinity; + let maxArea = 0; + for (const u of all) { + const pen = penetration(c.v, u.v); + const ar = pen > TOUCH_EPS ? overlapArea(c.v, u.v) : 0; + if (pen > maxPen) maxPen = pen; + if (ar > maxArea) maxArea = ar; + } + cands.push({ + type: c.type, + corner: cornerAt(c, ka), + v: c.v, + maxPenetration: maxPen, + overlapArea: maxArea, + }); + } + return { edge: [a, bb], candidates: cands }; +} + +// The gaps that admit no rhombus by pure geometry, given the current board. +function unfillableGaps(all: Tile[], poly: Pt[]): Gap[] { + return holeFrontier(boardFrom(all), poly) + .filter((e) => geomFills(all, e.a, e.bb).length === 0) + .map((e) => enumGap(all, e.a, e.bb)); +} + +// "Fill the rest as far as the geometry allows." A bounded backtracking search for +// the LARGEST legal partial fill: drive the most-constrained frontier edge that +// still admits a tile, try each of its geometry-legal fills, and recurse, keeping +// the deepest fill found anywhere. Doomed (zero-fill) edges are simply left, so the +// search keeps filling around them; when no edge admits a tile the branch is stuck. +// Every placed tile is overlap-free against the whole board, so the result is a real +// partial fill, and what it cannot cover is the smallest gap the geometry forces, the +// triangle no rhombus fits. Deterministic (fixed frontier and candidate order); the +// finiteness bound guards the recursion. +function maximalGeomFill(fixed: Tile[], poly: Pt[]): Tile[] { + const cap = Math.ceil(polyArea(poly) / SIN36) + 6; + let best: Tile[] = []; + const extra: Tile[] = []; + + function rec(): void { + if (extra.length > best.length) best = [...extra]; + if (extra.length >= cap) return; + const all = [...fixed, ...extra]; + const front = holeFrontier(boardFrom(all), poly); + let edge: { a: Pt; bb: Pt } | null = null; + let fills: Tile[] = []; + for (const e of front) { + const f = geomFills(all, e.a, e.bb); + if (f.length === 0) continue; // doomed edge: leave it, fill around it + if (edge === null || f.length < fills.length) { + edge = e; + fills = f; + } + } + if (edge === null) return; // nothing fillable remains: this branch is stuck + for (const cand of fills) { + extra.push(cand); + rec(); + extra.pop(); + } + } + rec(); + return best; +} + +// --------------------------------------------------------------------------- +// Scene A: the rigid hexagon. +// --------------------------------------------------------------------------- + +export type SceneA = { + title: string; + summary: string; + holeCenter: Pt; + holeArea: number; + holePolygon: Pt[]; + wall: Tile[]; + // The unique geometry-only filling (two rhombi), in placement order. + uniqueCompletion: Tile[]; + // The constrained frontier edge that admits two rhombi by pure geometry. + constrainedEdge: [Pt, Pt]; + geomMovesOnEdge: number; + // The tempting wrong move: it fits the constrained edge with zero overlap, then + // strands. (The correct move is uniqueCompletion[0].) + wrongMove: { type: "fat" | "thin"; corner: number | null; v: [Pt, Pt, Pt, Pt] }; + // The maximal legal partial fill placed after the wrong move before nothing fits. + // What the hole keeps uncovered after the wrong move and this strand is the gap no + // tile can fill (for the rigid hexagon this is empty: the wrong move alone strands). + strandFill: Tile[]; + // Every gap left unfillable after the wrong move, with full overlap evidence. + unfillableGaps: Gap[]; + // Always 0: after the wrong move the hole has no geometry-only completion. + geomCompletionsAfterWrong: number; +}; + +function computeSceneA(): SceneA { + const tiles = unitTiling(LEVELS); + let center: Pt = SCENE_A_ANCHOR; + let best = Infinity; + for (const t of tiles) { + const c = centroid(t); + const d = Math.hypot(c[0] - SCENE_A_ANCHOR[0], c[1] - SCENE_A_ANCHOR[1]); + if (d < best) { + best = d; + center = c; + } + } + const { removed, kept } = carve(tiles, center, SCENE_A_RHOLE); + const poly = holePolygon(kept, removed); + if (!poly) throw new Error("geomWall sceneA: hole is not a single closed loop"); + + const g = geomSearch(kept, poly, true); + if (!g.firstCompletion) { + throw new Error("geomWall sceneA: no geometry-only completion"); + } + const right = g.firstCompletion; + + // The constrained frontier edge: fewest geometry-only moves. + const front = holeFrontier(boardFrom(kept), poly); + let edge = front[0]; + let moves = geomFills(kept, front[0].a, front[0].bb); + for (let i = 1; i < front.length; i++) { + const m = geomFills(kept, front[i].a, front[i].bb); + if (m.length < moves.length) { + edge = front[i]; + moves = m; + } + } + // The wrong move: the geometry-only fill on that edge that strands (no + // completion follows it). There is exactly one such move on a rigid hole. + const wrong = moves.find( + (m) => geomSearch([...kept, m], poly, false).completions === 0, + ); + if (!wrong) throw new Error("geomWall sceneA: no stranding wrong move found"); + + const after = [...kept, wrong]; + const afterSearch = geomSearch(after, poly, true); + const gaps = unfillableGaps(after, poly); + + return { + title: "Geometry-only dead-end: rigid hexagon hole", + summary: + "A six-edge hole with exactly one geometry-only filling, two rhombi. The constrained edge admits two rhombi by pure geometry; one completes, the other strands. After the wrong rhombus every candidate on the next gap overlaps committed tiles by real area.", + holeCenter: center, + holeArea: polyArea(poly), + holePolygon: poly, + wall: kept, + uniqueCompletion: right, + constrainedEdge: [edge.a, edge.bb], + geomMovesOnEdge: moves.length, + wrongMove: { + type: wrong.type, + corner: cornerAt(wrong, edgeKeyHead(edge.a, edge.bb)), + v: wrong.v, + }, + strandFill: maximalGeomFill(after, poly), + unfillableGaps: gaps, + geomCompletionsAfterWrong: afterSearch.completions, + }; +} + +// --------------------------------------------------------------------------- +// Scene B: the expert's "a thin fits there" case, refuted. +// --------------------------------------------------------------------------- + +export type SceneB = { + title: string; + summary: string; + holeCenter: Pt; + holeArea: number; + holePolygon: Pt[]; + wall: Tile[]; + // The hole's one surviving completion (the only filling that finishes it). The + // payoff: every wrong branch strands, this one does not. + completion: Tile[]; + // The locally legal fill placed before the doomed edge is reached. + forcedPrefix: Tile[]; + // The frontier edge the expert pointed at: a thin rhombus fits it with zero + // overlap. + doomedEdge: [Pt, Pt]; + // That tempting thin tile. It fits (penetration < TOUCH_EPS), the move the + // objection is about. Place it anyway. + temptingThin: { type: "fat" | "thin"; corner: number | null; v: [Pt, Pt, Pt, Pt] }; + // The maximal legal partial fill placed after the thin before nothing fits: "fill + // the rest as far as you can." What stays uncovered after the prefix, the thin, and + // this strand is the triangular gap no rhombus fits. + strandFill: Tile[]; + // Every gap left unfillable after placing the thin, with full overlap evidence. + unfillableGaps: Gap[]; + // Always 0: after the thin the hole has no geometry-only completion. + geomCompletionsAfterThin: number; +}; + +function computeSceneB(): SceneB { + // Reuse the section-5 hole exactly: same anchor, same RHOLE, same wall and hole + // loop, same matching-rule dead-ends. We take its depth-7 dead-end (a locally + // legal fill that the rule says dooms an edge) and prove the doom is GEOMETRIC. + const scene = computeScene(); + const d = scene.deadEnds.find((x) => x.depth === 7); + if (!d) throw new Error("geomWall sceneB: depth-7 dead-end not found"); + + const poly = scene.hole; + const base = [...scene.wall, ...d.fill]; + const [a, bb] = d.doomedEdge; + + // On the doomed edge a thin rhombus fits with zero overlap: the expert's move. + const fits = geomFills(base, a, bb); + if (fits.length === 0) { + throw new Error("geomWall sceneB: doomed edge admits no geometry fill"); + } + const thin = fits[0]; + if (thin.type !== "thin") { + throw new Error(`geomWall sceneB: tempting fill is ${thin.type}, not thin`); + } + + const after = [...base, thin]; + const afterSearch = geomSearch(after, poly, true); + const completions = afterSearch.completions; + const gaps = unfillableGaps(after, poly); + + return { + title: "Geometry-only dead-end: the 'a thin fits there' case, refuted", + summary: + "On this doomed edge a thin rhombus does fit with zero overlap, the expert's objection. Place it. The geometry-only exhaustive search then finds no completion: the very next gap admits no rhombus without real overlap.", + holeCenter: scene.meta.holeCenter, + holeArea: polyArea(poly), + holePolygon: poly, + wall: scene.wall, + completion: scene.completion, + forcedPrefix: d.fill, + doomedEdge: [a, bb], + temptingThin: { + type: thin.type, + corner: cornerAt(thin, edgeKeyHead(a, bb)), + v: thin.v, + }, + strandFill: maximalGeomFill(after, poly), + unfillableGaps: gaps, + geomCompletionsAfterThin: completions, + }; +} + +// --------------------------------------------------------------------------- +// The one public entry point: compute both scenes deterministically. +// --------------------------------------------------------------------------- + +export type GeomWalls = { + sceneA_rigidHexagon: SceneA; + sceneB_thinRefuted: SceneB; +}; + +export function computeGeomWalls(): GeomWalls { + return { + sceneA_rigidHexagon: computeSceneA(), + sceneB_thinRefuted: computeSceneB(), + }; +} diff --git a/src/app/x/penrose/_components/lib/geomWalls.json b/src/app/x/penrose/_components/lib/geomWalls.json new file mode 100644 index 0000000..2f2b8d5 --- /dev/null +++ b/src/app/x/penrose/_components/lib/geomWalls.json @@ -0,0 +1,19437 @@ +{ + "sceneA_rigidHexagon": { + "title": "Geometry-only dead-end: rigid hexagon hole", + "summary": "A six-edge hole with exactly one geometry-only filling, two rhombi. The constrained edge admits two rhombi by pure geometry; one completes, the other strands. After the wrong rhombus every candidate on the next gap overlaps committed tiles by real area.", + "holeCenter": [ + -0.7694208842938131, + 1.868033988749895 + ], + "holeArea": 1.5388417685876263, + "holePolygon": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ] + ], + "wall": [ + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 0 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 8.057480106940814, + 0 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 10.547378391823594, + 2.4270509831248424 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 7.469694854648341, + 2.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 8.057480106940814, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 10.547378391823594, + -1.809016994374947 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 10.547378391823594, + 1.8090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.61652530576288, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 0 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + -0.5 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 1.5388417685876266, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 1.5388417685876266, + 0.5 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 10.547378391823594, + -3.427050983124842 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + -2.9270509831248424 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -1.5 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.5186383383531865, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + 0.5 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 4.97979656976556, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 6.5186383383531865, + 0.5 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.95959313953112, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 9.59632187552844, + 4.73606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.61652530576288, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0, + 0 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 6.123233995736766e-17, + 1 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.538841768587627, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 6.518638338353187, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 5.567581822058035, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 0.9510565162951543, + 10.781152949374528 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 9.47213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ], + [ + 1.5388417685876274, + 10.590169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.123233995736766e-17, + 1 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 4.028740053470408, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.077683537175254, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.951056516295153, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ], + [ + -0.9510565162951526, + 10.781152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.616525305762879, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.9797965697655595, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + -1.5388417685876257, + 10.590169943749475 + ], + [ + -2.126627020880099, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ], + [ + -4.028740053470406, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.126627020880099, + 9.781152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + 0, + 0 + ], + [ + 6.123233995736766e-17, + 1 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -6.5186383383531865, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.077683537175253, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -9.008536623235967, + 3.927050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 3.4270509831248432 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.95959313953112, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.59632187552844, + 3.118033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -8.057480106940814, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -7.46969485464834, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -9.59632187552844, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.97979656976556, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.48989828488278, + 0.19098300562505333 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.97979656976556, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 2.427050983124844 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -10.547378391823594, + 3.4270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + -1.8090169943749426 + ], + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + 1.0000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -10.547378391823594, + 1.8090169943749497 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -0.809016994374943 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -10.547378391823596, + 0.19098300562505632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + 0.19098300562505632 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.959593139531123, + 1.0000000000000027 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -4.028740053470407, + 0.30901699437494856 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -1.3090169943749452 + ], + [ + -3.4409548011779343, + -0.49999999999999856 + ], + [ + -4.028740053470407, + 0.30901699437494856 + ], + [ + -4.616525305762881, + -0.4999999999999981 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -4.616525305762881, + -0.4999999999999981 + ], + [ + -4.028740053470407, + 0.30901699437494856 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -0.809016994374946 + ], + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -2.48989828488278, + 0.19098300562505333 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + 0, + 0 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 0.30901699437494856 + ], + [ + -3.0776835371752536, + 1.1102230246251565e-15 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.0776835371752536, + 1.1102230246251565e-15 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ], + [ + -2.48989828488278, + 0.19098300562505333 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779343, + -0.49999999999999856 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ], + [ + -3.0776835371752536, + 1.1102230246251565e-15 + ], + [ + -4.028740053470407, + 0.30901699437494856 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -1.3090169943749452 + ], + [ + -3.077683537175254, + -1.6180339887498936 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ], + [ + -3.4409548011779343, + -0.49999999999999856 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -9.008536623235969, + -1.309016994374944 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -3.4270509831248366 + ], + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -1.618033988749892 + ], + [ + -4.028740053470408, + -1.3090169943749452 + ], + [ + -4.616525305762881, + -0.4999999999999981 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -4.979796569765561, + -1.618033988749892 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.518638338353188, + -1.499999999999997 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.106423590645662, + -5.545084971874735 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.979796569765561, + -5.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -6.518638338353188, + -4.736067977499787 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -7.106423590645662, + -8.16311896062463 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -9.008536623235969, + -4.545084971874733 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.469694854648342, + -4.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -4.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -4.7360679774997845 + ], + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -7.6631189606246295 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.6631189606246295 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + -6.354101966249681 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -9.008536623235969, + -5.545084971874733 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.6631189606246295 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.420751370943496, + -6.354101966249681 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.077683537175254, + -2.618033988749894 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -3.077683537175254, + -2.618033988749894 + ], + [ + -3.077683537175254, + -1.6180339887498936 + ], + [ + -4.028740053470408, + -1.3090169943749452 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.077683537175254, + -2.618033988749894 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -4.028740053470408, + -4.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.616525305762881, + -3.736067977499788 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903075, + -1.6180339887498942 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ], + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951539, + -1.3090169943749475 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + -1.5388417685876268, + -0.4999999999999991 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ], + [ + -2.4898982848827806, + -1.8090169943749466 + ], + [ + -3.077683537175254, + -2.618033988749894 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903075, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ], + [ + -1.9021130325903075, + -1.6180339887498942 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827806, + -1.8090169943749466 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ], + [ + -1.9021130325903075, + -1.6180339887498942 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -2.618033988749894 + ], + [ + -2.4898982848827806, + -1.8090169943749466 + ], + [ + -2.4898982848827806, + -0.809016994374946 + ], + [ + -3.077683537175254, + -1.6180339887498936 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.645265359233289, + -3.427050983124838 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.028740053470408, + -1.3090169943749452 + ], + [ + -4.979796569765561, + -1.618033988749892 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.979796569765561, + -4.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -5.567581822058035, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.567581822058035, + -9.281152949374524 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876281, + -10.590169943749475 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -9.472135954999578 + ], + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.077683537175254, + -4.236067977499789 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 0, + 0 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.0372311685419464e-15, + -11.090169943749475 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.126627020880101, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 2.489898284882778, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -9.281152949374528 + ], + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.567581822058031, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470404, + -7.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -10.590169943749475 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.979796569765558, + -9.472135954999581 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 4.028740053470404, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.077683537175251, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.077683537175252, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 0.951056516295153, + -1.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.077683537175252, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.930853086060713, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.567581822058033, + -4.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.663118960624634 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 7.106423590645658, + -8.163118960624635 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.930853086060711, + -8.163118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 8.057480106940814, + -4.236067977499792 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.596321875528439, + -4.7360679774997925 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 7.469694854648338, + -7.663118960624635 + ], + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.106423590645659, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235967, + -2.9270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 5.567581822058033, + -1.8090169943749488 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.9797965697655595, + -1.618033988749896 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ] + ] + } + ], + "uniqueCompletion": [ + { + "type": "fat", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 2.220446049250313e-16, + 1.618033988749895 + ], + [ + -0.5877852522924727, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.5877852522924727, + 2.4270509831248424 + ], + [ + 4.440892098500626e-16, + 3.23606797749979 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ] + ] + } + ], + "constrainedEdge": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ] + ], + "geomMovesOnEdge": 2, + "wrongMove": { + "type": "fat", + "corner": 108, + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ] + ] + }, + "strandFill": [], + "unfillableGaps": [ + { + "edge": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ] + ], + "candidates": [ + { + "type": "fat", + "corner": 72, + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ] + ], + "maxPenetration": 0.9510565162951532, + "overlapArea": 0.9510565162951531 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -1.3143277802978337, + 2.4270509831248424 + ], + [ + -1.9021130325903068, + 1.618033988749895 + ] + ], + "maxPenetration": 0.9510565162951532, + "overlapArea": 0.6571638901489169 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.3632712640026801, + 3.118033988749895 + ], + [ + -0.9510565162951532, + 2.3090169943749475 + ] + ], + "maxPenetration": 0.5877852522924729, + "overlapArea": 0.40614962029113294 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -1.314327780297834, + 1.8090169943749477 + ], + [ + -1.9021130325903068, + 1.0000000000000002 + ] + ], + "maxPenetration": 0.5877852522924734, + "overlapArea": 0.2938926261462367 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.36327126400268017, + 0.5 + ], + [ + 0.22451398828979297, + 1.3090169943749475 + ] + ], + "maxPenetration": 0.5877852522924734, + "overlapArea": 0.3632712640026806 + }, + { + "type": "fat", + "corner": 72, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 2.220446049250313e-16, + 1 + ], + [ + 0.5877852522924734, + 1.8090169943749475 + ] + ], + "maxPenetration": 0.5877852522924732, + "overlapArea": 0.406149620291133 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -0.9510565162951532, + 0.30901699437494745 + ], + [ + -0.3632712640026801, + 1.118033988749895 + ] + ], + "maxPenetration": 0.5877852522924731, + "overlapArea": 0.2938926261462366 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 2.220446049250313e-16, + 1.618033988749895 + ], + [ + 0.5877852522924734, + 2.4270509831248424 + ] + ], + "maxPenetration": 0.587785252292473, + "overlapArea": 0.2938926261462369 + } + ] + }, + { + "edge": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ] + ], + "candidates": [ + { + "type": "fat", + "corner": 72, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -1.9021130325903068, + 2.618033988749895 + ], + [ + -1.314327780297834, + 1.8090169943749477 + ] + ], + "maxPenetration": 0.9510565162951532, + "overlapArea": 0.6571638901489164 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951534, + 1.3090169943749475 + ] + ], + "maxPenetration": 0.9510565162951532, + "overlapArea": 0.9510565162951535 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -1.9021130325903066, + 3.23606797749979 + ], + [ + -1.3143277802978337, + 2.4270509831248424 + ] + ], + "maxPenetration": 0.5877852522924731, + "overlapArea": 0.29389262614623646 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -0.9510565162951533, + 1.9270509831248424 + ], + [ + -0.36327126400268034, + 1.118033988749895 + ] + ], + "maxPenetration": 0.5877852522924731, + "overlapArea": 0.4061496202911329 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + 0.5877852522924734, + 2.4270509831248424 + ], + [ + 5.551115123125783e-16, + 3.2360679774997894 + ] + ], + "maxPenetration": 0.9510565162951535, + "overlapArea": 0.6571638901489172 + }, + { + "type": "fat", + "corner": 72, + "v": [ + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + 0.22451398828979297, + 2.9270509831248424 + ], + [ + -0.36327126400267973, + 3.73606797749979 + ] + ], + "maxPenetration": 0.5877852522924727, + "overlapArea": 0.36327126400268084 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + 0.5877852522924734, + 1.8090169943749475 + ], + [ + 4.440892098500626e-16, + 2.618033988749895 + ] + ], + "maxPenetration": 0.5877852522924731, + "overlapArea": 0.43264990185912444 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -0.9510565162951531, + 2.9270509831248424 + ], + [ + -0.36327126400268017, + 2.118033988749895 + ], + [ + -0.3632712640026801, + 3.118033988749895 + ], + [ + -0.9510565162951528, + 3.9270509831248424 + ] + ], + "maxPenetration": 0.5877852522924729, + "overlapArea": 0.2938926261462361 + } + ] + } + ], + "geomCompletionsAfterWrong": 0 + }, + "sceneB_thinRefuted": { + "title": "Geometry-only dead-end: the 'a thin fits there' case, refuted", + "summary": "On this doomed edge a thin rhombus does fit with zero overlap, the expert's objection. Place it. The geometry-only exhaustive search then finds no completion: the very next gap admits no rhombus without real overlap.", + "holeCenter": [ + -3.259319169176594, + -1.059016994374946 + ], + "holeArea": 16.478231477884307, + "holePolygon": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ] + ], + "wall": [ + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 0 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 8.057480106940814, + 0 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 10.547378391823594, + 2.4270509831248424 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 7.469694854648341, + 2.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 8.057480106940814, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 10.547378391823594, + -1.809016994374947 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 10.547378391823594, + 1.8090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.61652530576288, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 0 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + -0.5 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 1.5388417685876266, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 1.5388417685876266, + 0.5 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 10.547378391823594, + -3.427050983124842 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + -2.9270509831248424 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -1.5 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.5186383383531865, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + 0.5 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 4.97979656976556, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 6.5186383383531865, + 0.5 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.95959313953112, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 9.59632187552844, + 4.73606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.61652530576288, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0, + 0 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 6.123233995736766e-17, + 1 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.538841768587627, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 6.518638338353187, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 5.567581822058035, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 0.9510565162951543, + 10.781152949374528 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 9.47213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ], + [ + 1.5388417685876274, + 10.590169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.123233995736766e-17, + 1 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 4.028740053470408, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.077683537175254, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.951056516295153, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ], + [ + -0.9510565162951526, + 10.781152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.616525305762879, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.9797965697655595, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + -1.5388417685876257, + 10.590169943749475 + ], + [ + -2.126627020880099, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ], + [ + -4.028740053470406, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.126627020880099, + 9.781152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + 0, + 0 + ], + [ + 6.123233995736766e-17, + 1 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -6.5186383383531865, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.077683537175253, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -9.008536623235967, + 3.927050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 3.4270509831248432 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.95959313953112, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.59632187552844, + 3.118033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -8.057480106940814, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -7.46969485464834, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -9.59632187552844, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.97979656976556, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.97979656976556, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 2.427050983124844 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -10.547378391823594, + 3.4270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + -1.8090169943749426 + ], + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + 1.0000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -10.547378391823594, + 1.8090169943749497 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -0.809016994374943 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -10.547378391823596, + 0.19098300562505632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + 0.19098300562505632 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.959593139531123, + 1.0000000000000027 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + 0, + 0 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -9.008536623235969, + -1.309016994374944 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -3.4270509831248366 + ], + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.518638338353188, + -1.499999999999997 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.106423590645662, + -5.545084971874735 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.979796569765561, + -5.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -6.518638338353188, + -4.736067977499787 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -7.106423590645662, + -8.16311896062463 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -9.008536623235969, + -4.545084971874733 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.469694854648342, + -4.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -4.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -4.7360679774997845 + ], + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -7.6631189606246295 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.6631189606246295 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + -6.354101966249681 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -9.008536623235969, + -5.545084971874733 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.6631189606246295 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.420751370943496, + -6.354101966249681 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -4.028740053470408, + -4.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.616525305762881, + -3.736067977499788 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951539, + -1.3090169943749475 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + -1.5388417685876268, + -0.4999999999999991 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.645265359233289, + -3.427050983124838 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.979796569765561, + -4.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -5.567581822058035, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.567581822058035, + -9.281152949374524 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876281, + -10.590169943749475 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -9.472135954999578 + ], + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.077683537175254, + -4.236067977499789 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 0, + 0 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.0372311685419464e-15, + -11.090169943749475 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.126627020880101, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 2.489898284882778, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -9.281152949374528 + ], + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.567581822058031, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470404, + -7.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -10.590169943749475 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.979796569765558, + -9.472135954999581 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 4.028740053470404, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.077683537175251, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.077683537175252, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 0.951056516295153, + -1.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.077683537175252, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.930853086060713, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.567581822058033, + -4.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.663118960624634 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 7.106423590645658, + -8.163118960624635 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.930853086060711, + -8.163118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 8.057480106940814, + -4.236067977499792 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.596321875528439, + -4.7360679774997925 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 7.469694854648338, + -7.663118960624635 + ], + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.106423590645659, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235967, + -2.9270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 5.567581822058033, + -1.8090169943749488 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.9797965697655595, + -1.618033988749896 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ] + ] + } + ], + "completion": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951545, + -2.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.4898982848827806, + -0.8090169943749477 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -2.4898982848827815, + -3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.979796569765561, + -2.618033988749893 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -2.4898982848827806, + -0.8090169943749472 + ], + [ + -3.0776835371752536, + -1.6180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.0776835371752536, + -1.6180339887498945 + ], + [ + -2.4898982848827806, + -0.8090169943749472 + ], + [ + -3.4409548011779343, + -0.4999999999999997 + ], + [ + -4.028740053470407, + -1.309016994374947 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -3.0776835371752536, + -1.6180339887498945 + ], + [ + -4.028740053470407, + -1.309016994374947 + ], + [ + -4.028740053470408, + -2.3090169943749466 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.979796569765561, + -2.618033988749893 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ], + [ + -5.567581822058035, + -0.8090169943749457 + ], + [ + -5.567581822058035, + -1.8090169943749457 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -0.8090169943749457 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -4.616525305762881, + -0.49999999999999817 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.616525305762881, + -0.49999999999999817 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -3.4409548011779343, + -0.4999999999999982 + ], + [ + -4.028740053470408, + 0.3090169943749493 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -3.077683537175253, + 1 + ], + [ + -3.077683537175253, + 2.220446049250313e-16 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -3.077683537175253, + 2.220446049250313e-16 + ], + [ + -4.028740053470407, + 0.30901699437494773 + ], + [ + -3.4409548011779343, + -0.4999999999999995 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175253, + 2.220446049250313e-16 + ], + [ + -3.077683537175253, + 1 + ], + [ + -4.028740053470407, + 1.3090169943749475 + ], + [ + -4.028740053470407, + 0.30901699437494773 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.616525305762881, + -0.49999999999999817 + ], + [ + -4.028740053470408, + 0.3090169943749493 + ], + [ + -4.979796569765561, + 1.9984014443252818e-15 + ], + [ + -5.567581822058035, + -0.8090169943749455 + ] + ] + } + ], + "forcedPrefix": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951545, + -2.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.8531695488854614, + -1.3090169943749477 + ], + [ + -2.853169548885462, + -2.309016994374947 + ] + ] + } + ], + "doomedEdge": [ + [ + -1.902113, + -1.618034 + ], + [ + -2.4898982999999997, + -0.809017 + ] + ], + "temptingThin": { + "type": "thin", + "corner": 36, + "v": [ + [ + -1.902113, + -1.618034 + ], + [ + -2.4898982999999997, + -0.809017 + ], + [ + -3.440954827200324, + -0.5000000391877191 + ], + [ + -2.853169527200324, + -1.3090170391877192 + ] + ] + }, + "strandFill": [ + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -3.440954801177934, + 0.5 + ], + [ + -3.4409548011779343, + -0.4999999999999995 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.4409548011779334, + 0.5000000000000004 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 0.5000000000000004 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.028740053470407, + 0.3090169943749477 + ], + [ + -3.4409548011779334, + -0.49999999999999956 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -2.8531695488854614, + -1.3090169943749477 + ], + [ + -3.4409548011779343, + -0.5000000000000002 + ], + [ + -3.4409548011779343, + -1.4999999999999993 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -3.4409548011779343, + -1.4999999999999993 + ], + [ + -4.392011317473088, + -1.8090169943749466 + ], + [ + -3.8042260651806155, + -2.618033988749894 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.8042260651806155, + -2.618033988749894 + ], + [ + -4.392011317473088, + -1.8090169943749466 + ], + [ + -5.343067833768242, + -1.4999999999999991 + ], + [ + -4.755282581475769, + -2.309016994374946 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -4.979796569765561, + -0.9999999999999969 + ], + [ + -4.979796569765561, + 2.220446049250313e-15 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + 0.3090169943749477 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.979796569765562, + -0.9999999999999991 + ], + [ + -4.0287400534704085, + -0.6909830056250523 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + -0.49999999999999956 + ], + [ + -4.028740053470407, + 0.3090169943749477 + ], + [ + -4.028740053470407, + -0.6909830056250523 + ], + [ + -3.4409548011779334, + -1.4999999999999996 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + -1.4999999999999996 + ], + [ + -4.028740053470407, + -0.6909830056250523 + ], + [ + -4.97979656976556, + -0.9999999999999996 + ], + [ + -4.392011317473087, + -1.8090169943749472 + ] + ] + } + ], + "unfillableGaps": [ + { + "edge": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ] + ], + "candidates": [ + { + "type": "fat", + "corner": 72, + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.3143277802978348, + -1.809016994374948 + ], + [ + -2.2653842965929885, + -1.5 + ] + ], + "maxPenetration": 0.9510565162951536, + "overlapArea": 0.6571638901489172 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.618033988749895 + ], + [ + -2.8531695488854614, + -1.309016994374947 + ] + ], + "maxPenetration": 0.9510565162951536, + "overlapArea": 0.9510565162951536 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951544, + -2.309016994374948 + ], + [ + -1.9021130325903082, + -2 + ] + ], + "maxPenetration": 0.5877852522924731, + "overlapArea": 0.29389262614623646 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -3.4409548011779343, + -1.4999999999999993 + ] + ], + "maxPenetration": 0.5877852522924734, + "overlapArea": 0.40614962029113366 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -3.440954801177935, + -3.1180339887498945 + ], + [ + -2.4898982848827815, + -3.4270509831248424 + ] + ], + "maxPenetration": 0.5877852522924742, + "overlapArea": 0.2938926261462398 + }, + { + "type": "fat", + "corner": 72, + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -2.853169548885462, + -3.309016994374947 + ], + [ + -1.9021130325903084, + -3.618033988749895 + ] + ], + "maxPenetration": 0.5877852522924722, + "overlapArea": 0.2938926261462358 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -3.8042260651806155, + -2.6180339887498945 + ], + [ + -2.853169548885462, + -2.927050983124842 + ] + ], + "maxPenetration": 0.22451398828979396, + "overlapArea": 0.06937863785644405 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.853169548885462, + -2.309016994374947 + ], + [ + -2.265384296592989, + -3.1180339887498945 + ], + [ + -1.3143277802978353, + -3.4270509831248424 + ] + ], + "maxPenetration": 0.5877852522924729, + "overlapArea": 0.2938926261462358 + } + ] + }, + { + "edge": [ + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ], + "candidates": [ + { + "type": "fat", + "corner": 72, + "v": [ + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ] + ], + "maxPenetration": 0.9510565162951543, + "overlapArea": 0.9510565162951541 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -3.6654687894677274, + -3.4270509831248406 + ], + [ + -3.077683537175254, + -4.236067977499788 + ] + ], + "maxPenetration": 0.9510565162951541, + "overlapArea": 0.6571638901489165 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -3.4409548011779343, + -3.1180339887498936 + ] + ], + "maxPenetration": 0.5877852522924734, + "overlapArea": 0.40614962029113366 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -3.077683537175254, + -3.618033988749893 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ] + ], + "maxPenetration": 0.5877852522924734, + "overlapArea": 0.2938926261462371 + }, + { + "type": "fat", + "corner": 108, + "v": [ + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -1.538841768587627, + -3.1180339887498936 + ], + [ + -2.1266270208801004, + -2.3090169943749457 + ] + ], + "maxPenetration": 0.587785252292472, + "overlapArea": 0.2938926261462358 + }, + { + "type": "fat", + "corner": 72, + "v": [ + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -1.9021130325903075, + -2.6180339887498936 + ], + [ + -2.4898982848827806, + -1.8090169943749457 + ] + ], + "maxPenetration": 0.5877852522924742, + "overlapArea": 0.29389262614623735 + }, + { + "type": "thin", + "corner": 144, + "v": [ + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -1.538841768587627, + -3.7360679774997885 + ], + [ + -2.1266270208801004, + -2.9270509831248406 + ] + ], + "maxPenetration": 0.5877852522924738, + "overlapArea": 0.2938926261462358 + }, + { + "type": "thin", + "corner": 36, + "v": [ + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -2.4898982848827806, + -2.427050983124841 + ], + [ + -3.077683537175254, + -1.6180339887498931 + ] + ], + "maxPenetration": 0.22451398828979396, + "overlapArea": 0.06937863785644449 + } + ] + } + ], + "geomCompletionsAfterThin": 0 + } +} diff --git a/src/app/x/penrose/_components/lib/naiveSolver.test.ts b/src/app/x/penrose/_components/lib/naiveSolver.test.ts new file mode 100644 index 0000000..5fb0906 --- /dev/null +++ b/src/app/x/penrose/_components/lib/naiveSolver.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test"; + +import { + arcLegal, + candidates, + deflatedRhombi, + isCompleteStar, + PHI, + solveToDeadEnd, + STARS, + type Pt, +} from "./naiveSolver"; + +// This test is the honesty guarantee for the "stop tiling by hand" sketch. It +// ports two proofs from the verified spike: +// +// (a) the legality oracle accepts a REAL deflated Penrose patch with ZERO +// violations, and the fat:thin count ratio approaches phi. If the seven +// stars were wrong, a real tiling would trip them. +// +// (b) the greedy solver strands DETERMINISTICALLY at a computed dead-end, and +// at the stranded wedge the only non-overlapping candidate is an illegal +// thin fill whose vertex is not one of the seven stars; every other +// candidate overlaps. So a tile fits the gap and is still forbidden. That +// is the claim the sketch makes, proven here against the same code that +// draws it. + +const EPS = 1e-7; +const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]]; +const keyPt = (p: Pt) => `${Math.round(p[0] / EPS)},${Math.round(p[1] / EPS)}`; + +function interiorAngle(prev: Pt, cur: Pt, next: Pt): number { + const u = sub(prev, cur); + const w = sub(next, cur); + const c = (u[0] * w[0] + u[1] * w[1]) / (Math.hypot(...u) * Math.hypot(...w)); + return (Math.acos(Math.max(-1, Math.min(1, c))) * 180) / Math.PI; +} +const snap = (d: number) => + [36, 72, 108, 144].reduce((b, a) => (Math.abs(a - d) < Math.abs(b - d) ? a : b), 36); + +// Reconstruct each vertex's angular fan from a set of rhombi, so we can audit it +// against the oracle exactly as the spike's verifier did. +function vertexFans(rhombi: { v: [Pt, Pt, Pt, Pt] }[]) { + const at = new Map(); + for (const r of rhombi) { + for (let i = 0; i < 4; i++) { + const cur = r.v[i]; + const prev = r.v[(i + 3) % 4]; + const next = r.v[(i + 1) % 4]; + const angle = snap(interiorAngle(prev, cur, next)); + const aPrev = Math.atan2(prev[1] - cur[1], prev[0] - cur[0]); + const aNext = Math.atan2(next[1] - cur[1], next[0] - cur[0]); + const norm = (x: number) => ((x % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + const rad = (angle * Math.PI) / 180; + const ccwStart = norm(norm(aPrev + rad - aNext) < 1e-6 ? aPrev : aNext); + const arr = at.get(keyPt(cur)) ?? []; + arr.push({ angle, ccwStart }); + at.set(keyPt(cur), arr); + } + } + return at; +} + +function isContig(sorted: { angle: number; ccwStart: number }[]): boolean { + for (let i = 0; i < sorted.length - 1; i++) { + const end = sorted[i].ccwStart + (sorted[i].angle * Math.PI) / 180; + if ( + Math.abs(((end - sorted[i + 1].ccwStart + Math.PI) % (2 * Math.PI)) - Math.PI) > + 1e-4 + ) { + return false; + } + } + return true; +} + +describe("the seven-star atlas", () => { + test("there are exactly seven stars, each closing to 360", () => { + expect(STARS.length).toBe(7); + for (const star of STARS) { + expect(star.reduce((s, a) => s + a, 0)).toBe(360); + } + }); + + test("every star is itself a complete star but a truncated arc is not", () => { + for (const star of STARS) { + expect(isCompleteStar(star)).toBe(true); + // drop the last corner: no longer 360, must not read as complete + expect(isCompleteStar(star.slice(0, -1))).toBe(false); + } + }); + + test("[108,108,108,36] is NOT one of the seven stars", () => { + // The dead-end's tempting fill closes to this. It must be inadmissible, or + // the whole sketch is a lie. + expect(isCompleteStar([108, 108, 108, 36])).toBe(false); + expect(STARS.some((s) => s.includes(108) && s.filter((a) => a === 108).length >= 3)).toBe( + false, + ); + }); +}); + +describe("(a) the oracle accepts a real deflated tiling with zero violations", () => { + for (const levels of [3, 4, 5]) { + test(`depth ${levels}: every vertex of a real patch is legal`, () => { + const rhombi = deflatedRhombi(levels, 1); + expect(rhombi.length).toBeGreaterThan(40); + const fans = vertexFans(rhombi); + + let violComplete = 0; + let violPartial = 0; + for (const [, corners] of fans) { + const sum = corners.reduce((s, c) => s + c.angle, 0); + const sorted = [...corners].sort((a, b) => a.ccwStart - b.ccwStart); + const angles = sorted.map((c) => c.angle); + if (Math.abs(sum - 360) < 1) { + if (!isCompleteStar(angles)) violComplete++; + } else if (isContig(sorted)) { + // contiguous boundary fan: must be a legal arc + if (!arcLegal(angles)) violPartial++; + } + // non-contiguous boundary fans (a vertex with a gap on the patch rim) + // are skipped: they are not a single arc. + } + expect(violComplete).toBe(0); + expect(violPartial).toBe(0); + }); + } + + test("fat:thin count ratio approaches phi as the patch deepens", () => { + const ratio = (levels: number) => { + const r = deflatedRhombi(levels, 1); + const fat = r.filter((x) => x.type === "fat").length; + const thin = r.filter((x) => x.type === "thin").length; + return fat / thin; + }; + const shallow = ratio(4); + const deep = ratio(6); + // both bracket phi, and deeper is closer to it than shallower. + expect(Math.abs(deep - PHI)).toBeLessThan(Math.abs(shallow - PHI)); + expect(deep).toBeCloseTo(PHI, 1); + }); +}); + +describe("(b) the greedy solver strands at a computed dead-end", () => { + const solution = solveToDeadEnd(); + + test("it strands deterministically after about ten tiles, near the seed", () => { + expect(solution.steps.length).toBe(10); + const v = solution.deadEnd.vertex; + const dist = Math.hypot(v[0], v[1]); + // about two and a half tile-widths from the seed, not at the rim of a giant + // patch: the dead-end comes quickly. + expect(dist).toBeGreaterThan(1.5); + expect(dist).toBeLessThan(3.5); + }); + + test("the stranded wedge is three fat corners and a 36-degree gap", () => { + expect(solution.deadEnd.committedAngles).toEqual([108, 108, 108]); + expect(solution.deadEnd.gapAngle).toBe(36); + }); + + test("the only non-overlapping candidate is the illegal thin fill", () => { + const { vertex, ghost } = solution.deadEnd; + const key = keyPt(vertex); + + // Re-derive the board state by replaying the solution, so we can enumerate + // every candidate at the dead vertex's exposed edges, exactly as the spike's + // scrutiny did. (We reuse candidates() and rebuild a fresh board.) + const tiles = solution.steps.map((s) => s.tile); + + // A minimal overlap test mirroring the module's SAT (touching allowed). + const overlap = (A: readonly Pt[], B: readonly Pt[]): boolean => { + const norms = (P: readonly Pt[]) => + P.map((_, i) => { + const e = sub(P[(i + 1) % P.length], P[i]); + return [-e[1], e[0]] as Pt; + }); + const proj = (P: readonly Pt[], ax: Pt): [number, number] => { + let lo = Infinity; + let hi = -Infinity; + for (const p of P) { + const d = p[0] * ax[0] + p[1] * ax[1]; + if (d < lo) lo = d; + if (d > hi) hi = d; + } + return [lo, hi]; + }; + for (const ax of [...norms(A), ...norms(B)]) { + const [a0, a1] = proj(A, ax); + const [b0, b1] = proj(B, ax); + if (a1 - b0 < 1e-6 || b1 - a0 < 1e-6) return false; + } + return true; + }; + + // Exposed edges incident to the dead vertex: owned by exactly one placed tile. + const owners = new Map(); + for (const t of tiles) { + for (let i = 0; i < 4; i++) { + const a = t.v[i]; + const b = t.v[(i + 1) % 4]; + const kp = keyPt(a); + const kq = keyPt(b); + const ek = kp < kq ? `${kp}|${kq}` : `${kq}|${kp}`; + const cur = owners.get(ek) ?? { count: 0, a, b }; + cur.count++; + owners.set(ek, cur); + } + } + const incident = [...owners.values()].filter( + (o) => o.count === 1 && (keyPt(o.a) === key || keyPt(o.b) === key), + ); + expect(incident.length).toBeGreaterThan(0); + + let nonOverlapping = 0; + let illegalThinFills = 0; + for (const e of incident) { + for (const c of candidates(e.a, e.b)) { + if (tiles.some((u) => overlap(c.v, u.v))) continue; + nonOverlapping++; + // Non-overlapping means it fits the gap geometrically. It must be the + // illegal thin fill: a tile that closes the vertex to a non-star. + expect(c.type).toBe("thin"); + illegalThinFills++; + } + } + // A tile fits the gap, so the claim is never "no tile fits". + expect(nonOverlapping).toBeGreaterThan(0); + expect(illegalThinFills).toBe(nonOverlapping); + expect(ghost.type).toBe("thin"); + }); + + test("the tempting fill would close the vertex to a forbidden star", () => { + const { closesTo } = solution.deadEnd; + expect(closesTo.slice().sort((a, b) => a - b)).toEqual([36, 108, 108, 108]); + // The reason the rule forbids it: this arrangement is not in the atlas. + expect(isCompleteStar(closesTo)).toBe(false); + }); +}); diff --git a/src/app/x/penrose/_components/lib/naiveSolver.ts b/src/app/x/penrose/_components/lib/naiveSolver.ts new file mode 100644 index 0000000..f1e4d31 --- /dev/null +++ b/src/app/x/penrose/_components/lib/naiveSolver.ts @@ -0,0 +1,584 @@ +// A naive greedy Penrose P3 solver and its legality oracle, ported verbatim in +// behaviour from a verified spike. This module is the honest source for the +// "stop tiling by hand" sketch: it COMPUTES the dead-end rather than authoring +// it. Nothing here is staged. The solver lays unit rhombi one at a time, obeying +// only the local matching rule, and strands itself. The sketch draws what the +// solver did. +// +// THE MATCHING RULE (vertex-star formulation, equivalent to Penrose's arrow +// decorations). In a legal P3 tiling the cyclic sequence of rhombus CORNER +// ANGLES around every interior vertex is one of exactly seven "vertex stars". +// Each corner is 36, 72, 108, or 144 degrees and the corners around a closed +// vertex sum to 360. The seven stars below were DERIVED from a real deflated +// tiling (every fully surrounded vertex of a five-times-deflated decagon) and +// cross-checked against the literature: there are seven, no more. A partial fan +// around a not-yet-closed vertex is legal iff its contiguous arc of placed +// corners occurs inside one of the seven stars; a closed vertex must equal a +// full star. naiveSolver.test.ts re-runs that derivation as an oracle: it +// accepts a real deflated patch with ZERO violations. That test is what keeps +// this module honest. +// +// THE DEAD-END the solver reaches. Seeded with one fat rhombus, growing toward +// whichever open edge is closest to closing a vertex, it strands after ten tiles +// about two edge-lengths from the seed. At the stranded vertex three fat corners +// are committed: [108, 108, 108], summing to 324, leaving a 36-degree wedge. A +// thin acute corner is exactly 36 degrees, so it FITS the gap geometrically. But +// it would close the vertex to [108, 108, 108, 36], which is not one of the +// seven stars, so the matching rule forbids it. Every other candidate overlaps a +// placed tile. A tile fits the gap and is still illegal: the rules leave no legal +// move. The claim is never "no tile fits". + +export const PHI = (1 + Math.sqrt(5)) / 2; + +export type Pt = readonly [number, number]; + +// --------------------------------------------------------------------------- +// The seven admissible vertex stars (cyclic sequences of corner angles). +// --------------------------------------------------------------------------- + +export const STARS: readonly (readonly number[])[] = [ + [72, 72, 72, 72, 72], // Sun + [36, 36, 72, 36, 36, 72, 72], // Star + [144, 144, 72], // Deuce + [144, 72, 72, 72], // Jack + [108, 108, 144], // (fat, fat, thin) + [108, 108, 36, 72, 36], // mixed + [36, 36, 72, 72, 72, 72], // mixed +]; + +// Every contiguous arc (in both cyclic directions) of every star, stored as a +// joined string for O(1) lookup. A partial fan is a legal arc iff it appears +// here. Built once at module load. +function buildArcSet(stars: readonly (readonly number[])[]): Set { + const set = new Set(); + for (const star of stars) { + const n = star.length; + for (const seq of [star, [...star].reverse()]) { + for (let start = 0; start < n; start++) { + const arc: number[] = []; + for (let len = 1; len <= n; len++) { + arc.push(seq[(start + len - 1) % n]); + set.add(arc.join(",")); + } + } + } + } + return set; +} +const ARCS = buildArcSet(STARS); + +// A contiguous fan of placed corner angles (in angular order) is legal iff it is +// an arc of some star and does not already exceed 360. Completeness is checked +// separately by isCompleteStar. +export function arcLegal(orderedAngles: readonly number[]): boolean { + if (orderedAngles.length === 0) return true; + if (orderedAngles.reduce((s, a) => s + a, 0) > 360 + 1e-6) return false; + return ARCS.has(orderedAngles.join(",")); +} + +// A closed vertex (corners sum to 360) is legal iff its cyclic arrangement is one +// of the seven stars. +export function isCompleteStar(orderedAngles: readonly number[]): boolean { + const sum = orderedAngles.reduce((s, a) => s + a, 0); + if (Math.abs(sum - 360) > 1e-6) return false; + return STARS.some((star) => cyclicEqual(orderedAngles, star)); +} + +function cyclicEqual(a: readonly number[], b: readonly number[]): boolean { + if (a.length !== b.length) return false; + const n = a.length; + for (const cand of [b, [...b].reverse()]) { + for (let r = 0; r < n; r++) { + let ok = true; + for (let i = 0; i < n; i++) { + if (a[i] !== cand[(r + i) % n]) { + ok = false; + break; + } + } + if (ok) return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Deflation and rhombus pairing, copied small so the oracle test can run against +// a real tiling without importing the explorer engine (a little copying is +// better than a little dependency). Robinson triangles: color 0 = acute (half a +// FAT rhombus), color 1 = obtuse (half a THIN rhombus). Two same-colour +// triangles sharing their base edge glue into one rhombus. +// --------------------------------------------------------------------------- + +type Tri = { color: 0 | 1; a: Pt; b: Pt; c: Pt }; +const lerpPhi = (p: Pt, q: Pt): Pt => [ + p[0] + (q[0] - p[0]) / PHI, + p[1] + (q[1] - p[1]) / PHI, +]; + +function subdivide(tris: readonly Tri[]): Tri[] { + const out: Tri[] = []; + for (const { color, a, b, c } of tris) { + if (color === 0) { + const p = lerpPhi(a, b); + out.push({ color: 0, a: c, b: p, c: b }, { color: 1, a: p, b: c, c: a }); + } else { + const q = lerpPhi(b, a); + const r = lerpPhi(b, c); + out.push( + { color: 1, a: r, b: c, c: a }, + { color: 1, a: q, b: r, c: b }, + { color: 0, a: r, b: q, c: a }, + ); + } + } + return out; +} + +function wheel(radius: number): Tri[] { + const t: Tri[] = []; + for (let i = 0; i < 10; i++) { + let b: Pt = [ + radius * Math.cos(((2 * i - 1) * Math.PI) / 10), + radius * Math.sin(((2 * i - 1) * Math.PI) / 10), + ]; + let c: Pt = [ + radius * Math.cos(((2 * i + 1) * Math.PI) / 10), + radius * Math.sin(((2 * i + 1) * Math.PI) / 10), + ]; + if (i % 2 === 0) [b, c] = [c, b]; + t.push({ color: 0, a: [0, 0], b, c }); + } + return t; +} + +// A deflated patch as paired rhombi: a genuine Penrose P3 tiling, used only to +// feed the legality oracle in the test. +export function deflatedRhombi( + levels: number, + radius = 1, +): { type: "fat" | "thin"; v: [Pt, Pt, Pt, Pt] }[] { + let t = wheel(radius); + for (let n = 0; n < levels; n++) t = subdivide(t); + return pairRhombi(t); +} + +function pairRhombi( + tris: readonly Tri[], +): { type: "fat" | "thin"; v: [Pt, Pt, Pt, Pt] }[] { + const byBase = new Map(); + tris.forEach((tri, ti) => { + const k = edgeKey(tri.b, tri.c); + const arr = byBase.get(k) ?? []; + arr.push(ti); + byBase.set(k, arr); + }); + const used = new Set(); + const out: { type: "fat" | "thin"; v: [Pt, Pt, Pt, Pt] }[] = []; + for (const [, refs] of byBase) { + if (refs.length !== 2) continue; + const [i1, i2] = refs; + if (used.has(i1) || used.has(i2)) continue; + const t1 = tris[i1]; + const t2 = tris[i2]; + if (t1.color !== t2.color) continue; + const v = ccw4([t1.a, t1.b, t2.a, t1.c]); + out.push({ type: t1.color === 1 ? "fat" : "thin", v }); + used.add(i1); + used.add(i2); + } + return out; +} + +function ccw4(pts: [Pt, Pt, Pt, Pt]): [Pt, Pt, Pt, Pt] { + const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4; + const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4; + return [...pts].sort( + (a, b) => + Math.atan2(a[1] - cy, a[0] - cx) - Math.atan2(b[1] - cy, b[0] - cx), + ) as [Pt, Pt, Pt, Pt]; +} + +// --------------------------------------------------------------------------- +// Geometry helpers shared by the board. +// --------------------------------------------------------------------------- + +export const EPS = 1e-7; +// Quantise a point to an integer grid key. Two points that snap to the same key +// are the same lattice vertex. Exported so modules built on this board (the +// unsolvable-future search) share one canonical vertex identity. +export function keyPt(p: Pt): string { + return `${Math.round(p[0] / EPS)},${Math.round(p[1] / EPS)}`; +} +// Canonical key for an undirected edge between two lattice points. +export function edgeKey(p: Pt, q: Pt): string { + const kp = keyPt(p); + const kq = keyPt(q); + return kp < kq ? `${kp}|${kq}` : `${kq}|${kp}`; +} +function sub(a: Pt, b: Pt): Pt { + return [a[0] - b[0], a[1] - b[1]]; +} +function add(a: Pt, b: Pt): Pt { + return [a[0] + b[0], a[1] + b[1]]; +} +function interiorAngle(prev: Pt, cur: Pt, next: Pt): number { + const u = sub(prev, cur); + const w = sub(next, cur); + const c = (u[0] * w[0] + u[1] * w[1]) / (Math.hypot(...u) * Math.hypot(...w)); + return (Math.acos(Math.max(-1, Math.min(1, c))) * 180) / Math.PI; +} +function snapAngle(deg: number): 36 | 72 | 108 | 144 { + return [36, 72, 108, 144].reduce( + (b, a) => (Math.abs(a - deg) < Math.abs(b - deg) ? a : b), + 36, + ) as 36 | 72 | 108 | 144; +} +const norm = (x: number) => ((x % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + +// --------------------------------------------------------------------------- +// A placed rhombus. +// --------------------------------------------------------------------------- + +export type Tile = { type: "fat" | "thin"; v: [Pt, Pt, Pt, Pt] }; + +const ACUTE = { fat: 72, thin: 36 } as const; + +// Seat a unit rhombus of `type` on the directed edge p->q so its body falls to +// the left (counter-clockwise), with interior angle `cornerAtP` at p. Combined +// with both edge directions and both corner choices this gives all eight ways a +// rhombus can share one physical edge. +function attach( + type: "fat" | "thin", + p: Pt, + q: Pt, + cornerAtP: number, +): Tile { + void ACUTE; // both types share the same corner pair {acute, obtuse} + const aP = cornerAtP; + const aQ = 180 - aP; + const base = Math.atan2(q[1] - p[1], q[0] - p[0]); + const dirP = base + (aP * Math.PI) / 180; + const v3: Pt = add(p, [Math.cos(dirP), Math.sin(dirP)]); + const dirQ = base + Math.PI - (aQ * Math.PI) / 180; + const v2: Pt = add(q, [Math.cos(dirQ), Math.sin(dirQ)]); + return { type, v: [p, q, v2, v3] }; +} + +// Separating-axis overlap test, touching allowed. True iff the polygons share +// interior area. +function overlap(A: readonly Pt[], B: readonly Pt[]): boolean { + const norms = (P: readonly Pt[]) => + P.map((_, i) => { + const e = sub(P[(i + 1) % P.length], P[i]); + return [-e[1], e[0]] as Pt; + }); + const proj = (P: readonly Pt[], ax: Pt): [number, number] => { + let lo = Infinity; + let hi = -Infinity; + for (const p of P) { + const d = p[0] * ax[0] + p[1] * ax[1]; + if (d < lo) lo = d; + if (d > hi) hi = d; + } + return [lo, hi]; + }; + for (const ax of [...norms(A), ...norms(B)]) { + const [a0, a1] = proj(A, ax); + const [b0, b1] = proj(B, ax); + if (a1 - b0 < 1e-6 || b1 - a0 < 1e-6) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// The board: placed tiles, edge ownership, per-vertex angular fans. +// --------------------------------------------------------------------------- + +type Corner = { angle: number; ccwStart: number }; + +// The board is the deep module the whole spine stands on: place a tile, ask the +// open frontier, ask whether the next tile overlaps or breaks a vertex. The +// naive solver drives it greedily; the unsolvable-future search drives it +// exhaustively. Same verified legality oracle, two callers. Exported so the +// search reuses it rather than duplicating the matching rule. +export class Board { + readonly tiles: Tile[] = []; + private edgeOwners = new Map(); + private vfan = new Map(); + + place(t: Tile): void { + this.tiles.push(t); + for (let i = 0; i < 4; i++) { + const k = edgeKey(t.v[i], t.v[(i + 1) % 4]); + this.edgeOwners.set(k, (this.edgeOwners.get(k) ?? 0) + 1); + } + for (const c of cornersOf(t)) { + const arr = this.vfan.get(c.key) ?? []; + arr.push({ angle: c.angle, ccwStart: c.ccwStart }); + this.vfan.set(c.key, arr); + } + } + + // Open edges (owned by exactly one tile) in a deterministic order. + openEdges(): { key: string; a: Pt; b: Pt }[] { + const out: { key: string; a: Pt; b: Pt }[] = []; + const seen = new Set(); + for (const t of this.tiles) { + for (let i = 0; i < 4; i++) { + const a = t.v[i]; + const b = t.v[(i + 1) % 4]; + const k = edgeKey(a, b); + if (this.edgeOwners.get(k) === 1 && !seen.has(k)) { + seen.add(k); + out.push({ key: k, a, b }); + } + } + } + out.sort((x, y) => (x.key < y.key ? -1 : 1)); + return out; + } + + overlapsAny(t: Tile): boolean { + return this.tiles.some((u) => overlap(t.v, u.v)); + } + + // Would placing t keep every vertex it touches legal? Returns the first + // offending reason so the dead-end can be explained. + legalAfter(t: Tile): { ok: boolean; reason?: string } { + for (const c of cornersOf(t)) { + const existing = this.vfan.get(c.key) ?? []; + const merged = [ + ...existing, + { angle: c.angle, ccwStart: c.ccwStart }, + ].sort((p, q) => p.ccwStart - q.ccwStart); + const sum = merged.reduce((s, m) => s + m.angle, 0); + if (sum > 360 + 1e-6) { + return { ok: false, reason: `vertex angle sum ${sum} > 360` }; + } + const angles = merged.map((m) => m.angle); + const contiguous = isContig(merged); + if (Math.abs(sum - 360) < 1e-6) { + if (!contiguous || !isCompleteStar(angles)) { + return { ok: false, reason: `closes to non-star [${angles}]` }; + } + } else if (contiguous) { + if (!arcLegal(angles)) { + return { ok: false, reason: `fan [${angles}] is not a legal arc` }; + } + } else if (!runsLegal(merged)) { + return { ok: false, reason: `runs not all legal arcs` }; + } + } + return { ok: true }; + } + + vertexFanAt(key: string): Corner[] { + return this.vfan.get(key) ?? []; + } +} + +function cornersOf(t: Tile): { key: string; angle: number; ccwStart: number }[] { + const out: { key: string; angle: number; ccwStart: number }[] = []; + for (let i = 0; i < 4; i++) { + const cur = t.v[i]; + const prev = t.v[(i + 3) % 4]; + const next = t.v[(i + 1) % 4]; + const angle = snapAngle(interiorAngle(prev, cur, next)); + const aPrev = Math.atan2(prev[1] - cur[1], prev[0] - cur[0]); + const aNext = Math.atan2(next[1] - cur[1], next[0] - cur[0]); + const rad = (angle * Math.PI) / 180; + const ccwStart = norm( + norm(aPrev + rad - aNext) < 1e-6 ? aPrev : aNext, + ); + out.push({ key: keyPt(cur), angle, ccwStart }); + } + return out; +} + +function isContig(merged: Corner[]): boolean { + for (let i = 0; i < merged.length - 1; i++) { + const end = merged[i].ccwStart + (merged[i].angle * Math.PI) / 180; + if ( + Math.abs( + ((end - merged[i + 1].ccwStart + Math.PI) % (2 * Math.PI)) - Math.PI, + ) > 1e-4 + ) { + return false; + } + } + return true; +} + +function runsLegal(merged: Corner[]): boolean { + let run: number[] = [merged[0].angle]; + for (let i = 0; i < merged.length - 1; i++) { + const end = merged[i].ccwStart + (merged[i].angle * Math.PI) / 180; + const adj = + Math.abs( + ((end - merged[i + 1].ccwStart + Math.PI) % (2 * Math.PI)) - Math.PI, + ) <= 1e-4; + if (adj) { + run.push(merged[i + 1].angle); + } else { + if (!arcLegal(run)) return false; + run = [merged[i + 1].angle]; + } + } + return arcLegal(run); +} + +// All eight rhombus placements that share the physical edge (a, b), in a fixed +// deterministic try-order. +export function candidates(a: Pt, b: Pt): Tile[] { + const out: Tile[] = []; + const dirs: [Pt, Pt][] = [ + [a, b], + [b, a], + ]; + const order: ["fat" | "thin", number][] = [ + ["fat", 72], + ["fat", 108], + ["thin", 36], + ["thin", 144], + ]; + for (const [p, q] of dirs) { + for (const [type, corner] of order) out.push(attach(type, p, q, corner)); + } + return out; +} + +// --------------------------------------------------------------------------- +// The greedy solver and its honest result. +// --------------------------------------------------------------------------- + +// One placed step plus the running tile count, so the sketch can reveal the +// build one tile at a time. +export type Step = { index: number; tile: Tile }; + +// The verified dead-end: the stranded vertex, the corners already committed +// there, the wedge still open, the thin tile that fits that wedge but is illegal, +// and the human-readable reason. +export type DeadEnd = { + vertex: Pt; + committedAngles: number[]; // e.g. [108, 108, 108] + gapAngle: number; // e.g. 36 + ghost: Tile; // the only non-overlapping candidate, an illegal thin fill + closesTo: number[]; // the forbidden star it would form, e.g. [108,108,108,36] + reason: string; +}; + +export type Solution = { + steps: Step[]; + deadEnd: DeadEnd; +}; + +function fatSeed(): Tile { + const A = (72 * Math.PI) / 180; + return { + type: "fat", + v: [ + [0, 0], + [1, 0], + [1 + Math.cos(A), Math.sin(A)], + [Math.cos(A), Math.sin(A)], + ], + }; +} + +function vertexSum(board: Board, key: string): number { + return board.vertexFanAt(key).reduce((s, c) => s + c.angle, 0); +} + +// Grow greedily toward closing vertices: at each step take the open edge whose +// endpoints are closest to a closed 360, place the first legal non-overlapping +// candidate, then look for a vertex whose remaining wedge no candidate can +// legally fill. That stranded vertex is the dead-end. Deterministic: a fat seed, +// a fixed edge order, a fixed candidate order. +export function solveToDeadEnd(): Solution { + const board = new Board(); + const seed = fatSeed(); + board.place(seed); + const steps: Step[] = [{ index: 0, tile: seed }]; + + for (let step = 0; step < 5000; step++) { + const open = board.openEdges(); + if (open.length === 0) break; + const scored = open.map((e) => ({ + e, + score: Math.max( + vertexSum(board, keyPt(e.a)), + vertexSum(board, keyPt(e.b)), + ), + })); + scored.sort((x, y) => y.score - x.score || (x.e.key < y.e.key ? -1 : 1)); + + let placed = false; + for (const { e } of scored) { + for (const c of candidates(e.a, e.b)) { + if (board.overlapsAny(c)) continue; + if (!board.legalAfter(c).ok) continue; + board.place(c); + steps.push({ index: steps.length, tile: c }); + placed = true; + break; + } + if (placed) break; + } + if (!placed) break; + + const dead = findStranded(board); + if (dead) return { steps, deadEnd: dead }; + } + + throw new Error("solver did not strand: the dead-end is not reproducible"); +} + +// A vertex is stranded if it is open, has at least one exposed edge, and no +// candidate on any exposed edge is both non-overlapping and legal. We then +// compute the wedge, the only non-overlapping candidate (the tempting illegal +// fill), and the forbidden star it would form. +function findStranded(board: Board): DeadEnd | null { + const verts = new Set(); + for (const t of board.tiles) for (const p of t.v) verts.add(keyPt(p)); + + for (const key of verts) { + const sum = vertexSum(board, key); + if (sum >= 360 - 1e-6) continue; + const open = board + .openEdges() + .filter((e) => keyPt(e.a) === key || keyPt(e.b) === key); + if (open.length === 0) continue; + + let fillable = false; + let ghost: Tile | null = null; + for (const e of open) { + for (const c of candidates(e.a, e.b)) { + if (board.overlapsAny(c)) continue; + if (board.legalAfter(c).ok) { + fillable = true; + break; + } + // Non-overlapping but illegal: this is the tempting fill. + if (!ghost) ghost = c; + } + if (fillable) break; + } + if (fillable || !ghost) continue; + + const committedAngles = board + .vertexFanAt(key) + .slice() + .sort((a, b) => a.ccwStart - b.ccwStart) + .map((c) => c.angle); + const gapAngle = 360 - sum; + const closesTo = [...committedAngles, gapAngle]; + const vertex = key.split(",").map((n) => Number(n) * EPS) as [ + number, + number, + ]; + const reason = board.legalAfter(ghost).reason ?? "illegal"; + return { vertex, committedAngles, gapAngle, ghost, closesTo, reason }; + } + return null; +} diff --git a/src/app/x/penrose/_components/lib/overlap.ts b/src/app/x/penrose/_components/lib/overlap.ts new file mode 100644 index 0000000..661c481 --- /dev/null +++ b/src/app/x/penrose/_components/lib/overlap.ts @@ -0,0 +1,119 @@ +// The real-overlap engine: convex polygon intersection by separating-axis +// penetration and Sutherland-Hodgman clipping. Dependency-free on purpose, so the +// geometry-only sketches (which run in the browser) and the geometry-only proof +// (which runs in bun:test) share ONE auditable overlap test without dragging the +// heavy deflation/search code into the client bundle. +// +// This is the whole answer to the Penrose expert's objection. The earlier sketches +// rejected a tempting move by the matching rule; a viewer can dispute a rule. Here +// the wall is measured: a candidate is rejected only when it shares real interior +// area with a committed tile. Edge-touching (the legitimate way two rhombi meet) +// is NOT overlap. Above the touch epsilon, one shape is genuinely on top of +// another, and we can shade the exact region where. + +export type Pt = readonly [number, number]; + +// Overlap depth below this on every axis counts as merely touching, the legitimate +// way two rhombi meet. Above it, real interior area is shared. +export const TOUCH_EPS = 1e-6; + +// Signed overlap depth. Positive means the two convex polygons truly overlap by +// that much along the tightest separating direction; non-positive means a +// separating axis exists (disjoint, or merely touching). Returns the minimum +// positive penetration over all axes, or the non-positive value on separation. +export function penetration(a: readonly Pt[], b: readonly Pt[]): number { + const axes: Pt[] = []; + for (const poly of [a, b]) { + for (let i = 0; i < poly.length; i++) { + const e: Pt = [ + poly[(i + 1) % poly.length][0] - poly[i][0], + poly[(i + 1) % poly.length][1] - poly[i][1], + ]; + const n: Pt = [-e[1], e[0]]; + const len = Math.hypot(n[0], n[1]) || 1; + axes.push([n[0] / len, n[1] / len]); + } + } + let minPen = Infinity; + for (const ax of axes) { + let aLo = Infinity; + let aHi = -Infinity; + let bLo = Infinity; + let bHi = -Infinity; + for (const p of a) { + const d = p[0] * ax[0] + p[1] * ax[1]; + if (d < aLo) aLo = d; + if (d > aHi) aHi = d; + } + for (const p of b) { + const d = p[0] * ax[0] + p[1] * ax[1]; + if (d < bLo) bLo = d; + if (d > bHi) bHi = d; + } + const overlap = Math.min(aHi, bHi) - Math.max(aLo, bLo); + if (overlap <= 0) return overlap; // separating axis found: no overlap + if (overlap < minPen) minPen = overlap; + } + return minPen; +} + +// True iff the polygons share real interior area (more than just an edge touch). +export function overlapsReal(a: readonly Pt[], b: readonly Pt[]): boolean { + return penetration(a, b) > TOUCH_EPS; +} + +// Sutherland-Hodgman clip of subject by a convex clipper, returning the clipped +// polygon (the shared interior, empty when disjoint). +export function overlapPolygon(subject: Pt[], clipper: Pt[]): Pt[] { + let out = subject.slice(); + const cl = signedArea(clipper) < 0 ? clipper.slice().reverse() : clipper.slice(); + for (let i = 0; i < cl.length; i++) { + const a = cl[i]; + const b = cl[(i + 1) % cl.length]; + const inside = (p: Pt) => + (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) >= 0; + const inter = (p: Pt, q: Pt): Pt => { + const a1 = b[1] - a[1]; + const b1 = a[0] - b[0]; + const c1 = a1 * a[0] + b1 * a[1]; + const a2 = q[1] - p[1]; + const b2 = p[0] - q[0]; + const c2 = a2 * p[0] + b2 * p[1]; + const det = a1 * b2 - a2 * b1; + if (Math.abs(det) < 1e-15) return q; + return [(b2 * c1 - b1 * c2) / det, (a1 * c2 - a2 * c1) / det]; + }; + const input = out; + out = []; + for (let j = 0; j < input.length; j++) { + const cur = input[j]; + const prv = input[(j + input.length - 1) % input.length]; + const curIn = inside(cur); + const prvIn = inside(prv); + if (curIn) { + if (!prvIn) out.push(inter(prv, cur)); + out.push(cur); + } else if (prvIn) { + out.push(inter(prv, cur)); + } + } + if (out.length === 0) return []; + } + return out.length < 3 ? [] : out; +} + +// The real shared area of two convex polygons. Zero when they do not overlap. +export function overlapArea(a: Pt[], b: Pt[]): number { + const c = overlapPolygon(a, b); + return c.length < 3 ? 0 : Math.abs(signedArea(c)); +} + +function signedArea(poly: Pt[]): number { + let s = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + s += p[0] * q[1] - q[0] * p[1]; + } + return s / 2; +} diff --git a/src/app/x/penrose/_components/lib/overlay.test.ts b/src/app/x/penrose/_components/lib/overlay.test.ts new file mode 100644 index 0000000..970c22e --- /dev/null +++ b/src/app/x/penrose/_components/lib/overlay.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { tileExists } from "../../explore/lib/pentagrid"; +import { + buildOverlay, + coincidentKeys, + COINCIDE_TOL, + edgeLengths, + FIFTH, + rotate, + thickThinRatio, +} from "./overlay"; + +// This test BINDS the interference-overlay sketch to the engine. The sketch overlays +// two real Penrose tilings and may tint where they coincide. The maintainer's two +// worries are answered here: (1) both layers are genuine enumerator output, not +// hand-drawn or randomized; (2) any "agreement" tint is REAL near-coincidence under +// the current transform, never a painted-on effect. If anyone fakes a layer or a +// highlight, one of these fails. + +const PHI = (1 + Math.sqrt(5)) / 2; +const o = buildOverlay(); + +describe("both layers are the same real enumerator patch", () => { + test("the patch is a genuine, non-trivial Penrose patch", () => { + expect(o.a.length).toBeGreaterThan(200); + }); + + test("layer A and layer B are the same real tiling (overlay is one patch turned over itself)", () => { + expect(o.b.length).toBe(o.a.length); + o.a.forEach((f, i) => { + expect(o.b[i].key).toBe(f.key); + }); + }); + + test("every tile names a rhombus the plane actually emits (tileExists)", () => { + for (const f of o.a) { + expect(tileExists(f.coord, f.j, f.k)).toBe(true); + } + }); + + test("every tile is a unit-edge rhombus (the engine's only output)", () => { + for (const f of o.a) { + for (const len of edgeLengths(f)) { + expect(Math.abs(len - 1)).toBeLessThan(1e-9); + } + } + }); + + test("every tile is thick or thin, and the thick:thin ratio is near φ", () => { + for (const f of o.a) { + expect(f.type === "thick" || f.type === "thin").toBe(true); + } + // Over a real Penrose patch the count of thick to thin tends to φ. + expect(Math.abs(thickThinRatio(o.a) - PHI)).toBeLessThan(0.05); + }); +}); + +describe("the coincidence tolerance is honest: a match means the same tile, not a neighbor", () => { + test("COINCIDE_TOL sits well below the smallest tile-to-tile spacing in the patch", () => { + // The closest two distinct centroids in the patch. A match within COINCIDE_TOL + // cannot be a different nearby tile if the tolerance is comfortably under this. + let minSpacing = Infinity; + for (let i = 0; i < o.a.length; i++) { + const [ax, ay] = o.a[i].centroid; + for (let k = i + 1; k < o.a.length; k++) { + const [bx, by] = o.a[k].centroid; + const d = Math.hypot(ax - bx, ay - by); + if (d < minSpacing) minSpacing = d; + } + } + expect(COINCIDE_TOL).toBeLessThan(minSpacing / 2); + }); +}); + +describe("the agreement tint is real near-coincidence under the transform, never painted on", () => { + // For a handful of angles across one fifth-turn, the tinted set must be exactly the + // tiles that genuinely line up: each tinted layer-A tile has a same-kind layer-B + // tile within COINCIDE_TOL after rotation, and no untinted tile does. Recomputing + // the predicate independently here is the audit. + const angles = [0, 0.2 * FIFTH, 0.475 * FIFTH, 0.5 * FIFTH, FIFTH]; + + function trulyCoincident(key: string, angle: number): boolean { + const a = o.a.find((f) => f.key === key); + if (!a) return false; + for (const b of o.b) { + if (b.type !== a.type) continue; + const [rx, ry] = rotate(b.centroid, angle); + if (Math.hypot(a.centroid[0] - rx, a.centroid[1] - ry) <= COINCIDE_TOL) return true; + } + return false; + } + + for (const angle of angles) { + test(`every tinted tile genuinely coincides, every untinted one does not (angle ${(angle / FIFTH).toFixed(3)} of a fifth-turn)`, () => { + const tinted = coincidentKeys(o, angle); + for (const f of o.a) { + expect(tinted.has(f.key)).toBe(trulyCoincident(f.key, angle)); + } + }); + } + + test("at 0 the two coincide everywhere (identity) but the demo's motion breaks it", () => { + // Layer B is layer A; with no rotation every tile coincides with itself. This is + // the trivial global match, and it is the ONLY angle that gives one. + const at0 = coincidentKeys(o, 0); + expect(at0.size).toBe(o.a.length); + }); + + test("turned off the trivial angle, agreement is partial: islands, not a global match", () => { + // The teaching claim made measurable: at a generic angle most tiles disagree, so + // the two never line up everywhere. Some agree (islands), most do not (veins). + const mid = coincidentKeys(o, 0.2 * FIFTH); + expect(mid.size).toBeGreaterThan(0); + expect(mid.size).toBeLessThan(o.a.length * 0.5); + }); +}); diff --git a/src/app/x/penrose/_components/lib/overlay.ts b/src/app/x/penrose/_components/lib/overlay.ts new file mode 100644 index 0000000..597651e --- /dev/null +++ b/src/app/x/penrose/_components/lib/overlay.ts @@ -0,0 +1,129 @@ +// Data for the interference-overlay sketch (spine section 7, "Slide one over +// another"). This is Penrose's overhead-projector demo: lay two of these tilings +// over each other, turn one, and broad regions snap into agreement while veins of +// mismatch ripple between them, all organized by the five-fold symmetry. +// +// Honesty is the whole point, so BOTH layers are real enumerator output. Layer A is +// a real patch from facesInViewport at the pinned window center, drawn filled. Layer +// B is the SAME real patch, drawn as edges, and rotated about the patch center by +// the slider. Rotating a Penrose tiling by a non-symmetry angle yields another valid +// Penrose tiling in a new orientation, which is exactly the transparency Penrose +// turned on his projector. Nothing here is hand-drawn or randomized. +// +// The interference is EMERGENT. Overlay the two and the agreement islands and veins +// appear to the eye on their own. We additionally label agreement only where it is +// REAL: a layer-A tile is "coincident" at a given angle iff some rotated layer-B +// tile of the same kind lands within COINCIDE_TOL of it. That set is what the sketch +// may tint, and what the colocated test pins, so no highlight is ever painted on. + +import { facesInViewport, GAMMA } from "../../explore/lib/pentagrid"; +import type { Pt, RenderFace } from "../../explore/lib/patch"; + +export type { Pt } from "../../explore/lib/patch"; + +// A square patch around the origin: enough tiles for the moiré to read, few enough +// to draw every edge cleanly. The center of rotation is the origin, the patch center. +// half defaults to a modest patch (the test reads this); the sketch asks for a larger, +// zoomed-out patch so the five-fold interference shows at scale. +const DEFAULT_HALF = 8.5; + +// Two tiles of the same kind whose centers fall within this distance (physical +// units, edge length 1) count as coincident: the same tile in the same place. Set +// well below the smallest tile-to-tile spacing so a match means genuine overlap, not +// a near neighbor. Validated by overlay.test.ts against the patch's spacing. +export const COINCIDE_TOL = 0.16; + +// One fifth-turn. The slider spans [0, FIFTH]; the five-fold symmetry means the +// moiré over this range is the whole story, and the angle reads as a fraction of the +// symmetry that built the tiles. +export const FIFTH = (2 * Math.PI) / 5; + +export type Overlay = { + // Layer A, filled rhombi, the real patch in its home orientation. + a: RenderFace[]; + // Layer B, the same real patch, drawn as edges and rotated at render time. Carried + // separately so the sketch can transform B without touching A. + b: RenderFace[]; +}; + +// Build the overlay: layer A and layer B are the SAME real enumerator patch. They +// are distinct arrays so the renderer can rotate B's geometry independently. The +// patch is sorted by key for determinism (the test reads buildOverlay() too). +export function buildOverlay(half = DEFAULT_HALF): Overlay { + const view = { minX: -half, minY: -half, maxX: half, maxY: half }; + const faces = facesInViewport(view, GAMMA).sort((x, y) => x.key.localeCompare(y.key)); + return { a: faces, b: faces }; +} + +// Rotate a point about the origin (the patch center) by `angle` radians. +export function rotate(p: Pt, angle: number): Pt { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [p[0] * c - p[1] * s, p[0] * s + p[1] * c]; +} + +// The set of layer-A tile keys that genuinely coincide with a rotated layer-B tile +// at `angle`: same rhombus kind, centers within COINCIDE_TOL. This is the only thing +// the sketch may tint as "agreement," and it reflects real near-coincidence under +// the current transform, computed from the two real patches. A spatial hash over +// rotated B centroids keeps it linear in the tile count. +export function coincidentKeys(o: Overlay, angle: number, tol = COINCIDE_TOL): Set { + const cell = Math.max(tol, 0.5); + const grid = new Map(); + const cellKey = (x: number, y: number) => `${Math.round(x / cell)},${Math.round(y / cell)}`; + for (const f of o.b) { + const r = rotate(f.centroid, angle); + // Bucket by kind so a thick never matches a thin. + const k = `${f.type}:${cellKey(r[0], r[1])}`; + const arr = grid.get(k); + if (arr) arr.push(r); + else grid.set(k, [r]); + } + const out = new Set(); + const tol2 = tol * tol; + for (const f of o.a) { + const [cx, cy] = f.centroid; + const bx = Math.round(cx / cell); + const by = Math.round(cy / cell); + let hit = false; + for (let dx = -1; dx <= 1 && !hit; dx++) { + for (let dy = -1; dy <= 1 && !hit; dy++) { + const bucket = grid.get(`${f.type}:${bx + dx},${by + dy}`); + if (!bucket) continue; + for (const [rx, ry] of bucket) { + const ex = cx - rx; + const ey = cy - ry; + if (ex * ex + ey * ey <= tol2) { + hit = true; + break; + } + } + } + } + if (hit) out.add(f.key); + } + return out; +} + +// The four edge lengths of a rhombus face, in physical units. Used by the test to +// prove every drawn tile is a unit-edge rhombus, the engine's only output. +export function edgeLengths(f: RenderFace): [number, number, number, number] { + const c = f.corners; + const len = (i: number): number => { + const p = c[i]; + const q = c[(i + 1) % 4]; + return Math.hypot(q[0] - p[0], q[1] - p[1]); + }; + return [len(0), len(1), len(2), len(3)]; +} + +// Thick:thin ratio over a set of faces. Over a real Penrose patch this tends to φ. +export function thickThinRatio(faces: readonly RenderFace[]): number { + let thick = 0; + let thin = 0; + for (const f of faces) { + if (f.type === "thick") thick++; + else thin++; + } + return thin === 0 ? Infinity : thick / thin; +} diff --git a/src/app/x/penrose/_components/lib/scaling.test.ts b/src/app/x/penrose/_components/lib/scaling.test.ts new file mode 100644 index 0000000..9d7a3c5 --- /dev/null +++ b/src/app/x/penrose/_components/lib/scaling.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; + +import { deflate, PHI } from "../../explore/lib/deflate"; +import { substitutionFaces } from "../../explore/lib/faces"; +import { + countSeries, + countsAt, + halfExtent, + hierarchyAt, + rhombiAt, + rhombiFromTris, +} from "./scaling"; + +// This test BINDS the two scaling sketches to the substitution engine. The +// golden-ratio sketch shows thick:thin counts that must equal the engine's own +// face counts at every level and must home in on phi as the level climbs. The +// hierarchy sketch must draw deflate(L) inside the real level-up tiles deflate(L-1), +// with the tile count growing by ~phi^2 per level. If anyone hand-authors the +// numbers or the supertiles, one of these fails. + +const MAX = 8; + +describe("the counts the golden-ratio sketch shows are the engine's faces", () => { + // The sketch counts rhombi (the things it draws). faces.ts counts rhombi a + // different way (lifting corners to Z^5 and pairing by axes). They must agree + // tile for tile, or the sketch is lying about what it drew. + for (let level = 1; level <= MAX; level++) { + test(`thick/thin at level ${level} equals substitutionFaces`, () => { + const c = countsAt(level); + const sf = substitutionFaces(level).faces; + const sThick = sf.filter((f) => f.type === "thick").length; + const sThin = sf.filter((f) => f.type === "thin").length; + expect(c.thick).toBe(sThick); + expect(c.thin).toBe(sThin); + }); + } + + test("the displayed ratio is exactly thick/thin (no fudge)", () => { + for (const c of countSeries(MAX)) { + expect(c.ratio).toBe(c.thick / c.thin); + } + }); + + test("there are real tiles of both kinds to count", () => { + for (const c of countSeries(MAX)) { + expect(c.thick).toBeGreaterThan(0); + expect(c.thin).toBeGreaterThan(0); + } + }); +}); + +describe("the ratio homes in on the golden ratio as the level climbs", () => { + // The teaching beat: subdivide and the fat:thin ratio approaches phi. We pin the + // trend, not a single value (the finite patch has boundary half-rhombi that make + // any one level a touch noisy). The gap at the top level is far smaller than the + // gap low down, and the deepest level is genuinely close to phi. + const series = countSeries(MAX); + const gap = (r: number) => Math.abs(r - PHI); + + test("the gap to phi shrinks from the low levels to the top", () => { + const low = gap(series[1].ratio); // level 2 + const top = gap(series[MAX - 1].ratio); // level MAX + expect(top).toBeLessThan(low * 0.1); + }); + + test("the deepest level sits within 0.01 of phi", () => { + expect(gap(series[MAX - 1].ratio)).toBeLessThan(0.01); + }); + + test("the displayed ratio rounds toward 1.618 by the deepest levels", () => { + expect(series[MAX - 1].ratio).toBeCloseTo(PHI, 2); + }); +}); + +describe("the hierarchy sketch draws real engine geometry at two depths", () => { + // The supertiles are the literal level-up tiles: deflate(L) = subdivide(deflate(L-1)), + // so deflate(L-1) is the coarser valid tiling the small tiles compose into. We do + // not hand-draw a single boundary. + test("hierarchyAt(L).supers equals rhombiAt(L-1) exactly", () => { + for (let level = 2; level <= 6; level++) { + const h = hierarchyAt(level); + const up = rhombiAt(level - 1); + expect(h.supers.length).toBe(up.length); + for (let i = 0; i < up.length; i++) { + expect(h.supers[i].kind).toBe(up[i].kind); + expect(h.supers[i].corners).toEqual(up[i].corners); + } + } + }); + + test("the small tiles are deflate(L), straight from the engine", () => { + for (let level = 2; level <= 6; level++) { + const h = hierarchyAt(level); + expect(h.small).toEqual(rhombiFromTris(deflate(level, 1))); + } + }); + + test("tile count grows by ~phi^2 per level (the substitution eigenvalue)", () => { + // Going from supertiles (L-1) to small tiles (L) multiplies the count by the + // substitution eigenvalue phi^2, the same growth deflate.test.ts pins on the + // triangles, here on the complete rhombi the sketch actually draws. + for (let level = 5; level <= MAX; level++) { + const small = rhombiAt(level).length; + const supers = rhombiAt(level - 1).length; + expect(small / supers).toBeCloseTo(PHI * PHI, 0); + } + }); +}); + +describe("the geometry is honest rhombi, not arbitrary quads", () => { + test("every drawn rhombus is a unit-edge quad in apex/base/apex/base order", () => { + // At unit wheel radius and level L the legs contract to 1/phi^L. We assert the + // four sides are equal (a rhombus) and the diagonals are the apex-apex and + // base-base pairs, i.e. corner 0..2 and 1..3 cross (a simple convex quad). + const rh = rhombiAt(4); + expect(rh.length).toBeGreaterThan(50); + for (const r of rh.slice(0, 40)) { + const c = r.corners; + const side = (i: number) => + Math.hypot(c[(i + 1) % 4][0] - c[i][0], c[(i + 1) % 4][1] - c[i][1]); + const s0 = side(0); + for (let i = 1; i < 4; i++) expect(side(i)).toBeCloseTo(s0, 9); + } + }); + + test("halfExtent frames an origin-centered patch", () => { + expect(halfExtent(rhombiAt(3))).toBeGreaterThan(0); + expect(halfExtent(rhombiAt(3))).toBeLessThanOrEqual(1.0001); + }); +}); diff --git a/src/app/x/penrose/_components/lib/scaling.ts b/src/app/x/penrose/_components/lib/scaling.ts new file mode 100644 index 0000000..edfda9c --- /dev/null +++ b/src/app/x/penrose/_components/lib/scaling.ts @@ -0,0 +1,101 @@ +// Data for the two scaling sketches (spine section 9, "More magic: scaling"). +// +// Both sketches stand on the real substitution engine (deflate, in +// explore/lib/deflate.ts), so the counts are genuine and the supertiles are not +// hand-drawn. A Penrose rhombus is two Robinson triangles glued on their shared +// base edge: two acute (color 0) triangles make a THIN rhombus, two obtuse +// (color 1) triangles make a THICK one. Pairing the deflation output on the base +// edge recovers every complete rhombus AND its kind, and those counts match +// faces.ts substitutionFaces tile for tile (see scaling.test.ts). +// +// GOLDEN RATIO At each level draw the patch and count thick:thin. The ratio +// homes in on phi as the level climbs, the same phi that set the +// tile angles. The object counted is the rhombus, the thing on +// screen. +// +// HIERARCHY deflate(L) is subdivide(deflate(L-1)), so the level-(L-1) +// rhombi ARE the supertiles the level-L rhombi compose into. +// Draw the small tiles filled and the genuine level-up tiles as +// ink outlines at the same physical scale: self-similarity, the +// engine's own output at two depths. + +import { deflate, type Pt, type Tri } from "../../explore/lib/deflate"; + +export type { Pt } from "../../explore/lib/deflate"; +export { PHI } from "../../explore/lib/deflate"; + +export type RhombusKind = "thick" | "thin"; + +// A complete rhombus recovered from the deflation: its kind and its four corners +// in draw order (apex, base, apex, base), a simple convex quad. +export type Rhombus = { kind: RhombusKind; corners: [Pt, Pt, Pt, Pt] }; + +const pkey = (p: Pt) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`; + +// Pair triangles on their shared base edge into complete rhombi. Half-rhombi at +// the patch boundary have only one triangle on their base and are dropped: a +// rhombus needs both halves, exactly as faces.ts requires both apexes. The kind +// follows the paired color: two obtuse (1) -> thick, two acute (0) -> thin. +export function rhombiFromTris(tris: readonly Tri[]): Rhombus[] { + const byBase = new Map(); + for (const t of tris) { + const k = [pkey(t.b), pkey(t.c)].sort().join("|"); + const e = byBase.get(k) ?? byBase.set(k, { apexes: [], base: [t.b, t.c], colors: [] }).get(k)!; + e.apexes.push(t.a); + e.colors.push(t.color); + } + const out: Rhombus[] = []; + for (const { apexes, base, colors } of byBase.values()) { + if (apexes.length !== 2) continue; + const kind: RhombusKind = colors[0] === 1 && colors[1] === 1 ? "thick" : "thin"; + out.push({ kind, corners: [apexes[0], base[0], apexes[1], base[1]] }); + } + return out; +} + +// The complete rhombi of the wheel deflated `level` times, at unit wheel radius. +export function rhombiAt(level: number): Rhombus[] { + return rhombiFromTris(deflate(level, 1)); +} + +// Thick and thin tile counts at a level, the numbers the golden-ratio sketch +// shows. These equal faces.ts substitutionFaces at every level (same rhombi). +export type Counts = { level: number; thick: number; thin: number; ratio: number }; + +export function countsAt(level: number): Counts { + let thick = 0; + let thin = 0; + for (const r of rhombiAt(level)) { + if (r.kind === "thick") thick++; + else thin++; + } + return { level, thick, thin, ratio: thin === 0 ? Infinity : thick / thin }; +} + +// The full count series the golden-ratio sketch steps through. The trend is the +// teaching point: |ratio - phi| at the top level is far smaller than at the +// bottom, the ratio homing in on the golden ratio that built the tiles. +export function countSeries(maxLevel: number): Counts[] { + const out: Counts[] = []; + for (let l = 1; l <= maxLevel; l++) out.push(countsAt(l)); + return out; +} + +// One depth of the hierarchy sketch: the small filled rhombi (level L) and the +// supertiles they compose into (level L-1), the genuine level-up tiles. Both come +// from deflate at the same wheel radius, so the small tiles sit inside their +// supertiles with no fitting by hand. +export type Hierarchy = { level: number; small: Rhombus[]; supers: Rhombus[] }; + +export function hierarchyAt(level: number): Hierarchy { + return { level, small: rhombiAt(level), supers: rhombiAt(level - 1) }; +} + +// Shared extent for fitting a patch into a viewBox: the max |coordinate| over all +// corners. The wheel is origin-centered, so a single half-extent frames it. +export function halfExtent(rhombi: readonly Rhombus[]): number { + let h = 0; + for (const r of rhombi) + for (const [x, y] of r.corners) h = Math.max(h, Math.abs(x), Math.abs(y)); + return h; +} diff --git a/src/app/x/penrose/_components/lib/scene.json b/src/app/x/penrose/_components/lib/scene.json new file mode 100644 index 0000000..ff0f990 --- /dev/null +++ b/src/app/x/penrose/_components/lib/scene.json @@ -0,0 +1,9967 @@ +{ + "meta": { + "levels": 5, + "anchor": [ + -3.259, + -1.059 + ], + "rhole": 2.3, + "holeCenter": [ + -3.259319169176594, + -1.059016994374946 + ], + "wallTiles": 410, + "holeEdges": 16, + "holeArea": 16.478231477884307, + "completionTiles": 20, + "completions": 1, + "deadEnds": 5, + "finitenessBound": 33, + "capHit": false, + "nodes": 26, + "branches": 25 + }, + "wall": [ + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 0 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 8.057480106940814, + 0 + ], + [ + 8.057480106940814, + 0.9999999999999999 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 10.547378391823594, + 2.4270509831248424 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.59632187552844, + 2.118033988749895 + ], + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 1.6180339887498947 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 8.420751370943494, + 2.118033988749895 + ], + [ + 7.469694854648341, + 2.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 8.057480106940814, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 10.547378391823594, + -1.809016994374947 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 9.95959313953112, + -1.6180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.008536623235967, + 1.3090169943749475 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 10.547378391823594, + 1.8090169943749475 + ], + [ + 9.59632187552844, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 10.547378391823594, + -0.809016994374947 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 10.547378391823594, + 0.190983005625053 + ], + [ + 9.95959313953112, + 1.0000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + 0.30901699437494756 + ], + [ + 9.59632187552844, + -0.4999999999999997 + ], + [ + 9.59632187552844, + 0.5000000000000002 + ], + [ + 9.008536623235967, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.61652530576288, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.97979656976556, + 0 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.97979656976556, + 0 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 0.9999999999999998 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.97979656976556, + 0.9999999999999998 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + -0.5 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 1.5388417685876266, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 1.5388417685876266, + -0.5 + ], + [ + 1.5388417685876266, + 0.5 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827798, + 0.19098300562505244 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.48989828488278, + -0.8090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 4.028740053470407, + 0.30901699437494745 + ], + [ + 3.077683537175253, + -2.220446049250313e-16 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 3.4409548011779334, + -0.5000000000000002 + ], + [ + 2.48989828488278, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 9.008536623235967, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 8.645265359233287, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 10.547378391823594, + -3.427050983124842 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235967, + -2.9270509831248424 + ], + [ + 9.95959313953112, + -2.618033988749895 + ], + [ + 9.008536623235967, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + -1.3090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.61652530576288, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ], + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 4.9797965697655595, + -1.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 8.057480106940814, + -2.618033988749895 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 6.5186383383531865, + -1.5 + ], + [ + 5.567581822058033, + -1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -1.5 + ], + [ + 7.10642359064566, + -2.3090169943749475 + ], + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + -1.3090169943749475 + ], + [ + 8.057480106940814, + -1.618033988749895 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.46969485464834, + -0.8090169943749475 + ], + [ + 8.057480106940814, + 0 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -0.5 + ], + [ + 7.10642359064566, + 0.3090169943749474 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.5186383383531865, + 0.5 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + 0.5 + ], + [ + 7.10642359064566, + 1.3090169943749472 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -0.8090169943749475 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 4.97979656976556, + 0 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.930853086060713, + 0.3090169943749474 + ], + [ + 6.5186383383531865, + -0.5 + ], + [ + 6.5186383383531865, + 0.5 + ], + [ + 5.930853086060714, + 1.3090169943749472 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 7.10642359064566, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 9.008536623235967, + 2.927050983124842 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.59632187552844, + 3.1180339887498945 + ], + [ + 10.547378391823594, + 3.427050983124842 + ], + [ + 9.95959313953112, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648341, + 2.427050983124842 + ], + [ + 8.057480106940814, + 3.23606797749979 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.10642359064566, + 7.163118960624632 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 9.008536623235967, + 3.9270509831248424 + ], + [ + 9.59632187552844, + 4.73606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 6.854101966249685 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.645265359233287, + 5.045084971874737 + ], + [ + 8.645265359233287, + 6.045084971874737 + ], + [ + 8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + 5.045084971874737 + ], + [ + 8.057480106940814, + 4.23606797749979 + ], + [ + 8.057480106940814, + 5.23606797749979 + ], + [ + 7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.61652530576288, + 2.118033988749895 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.028740053470408, + 2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 4.97979656976556, + 1.6180339887498945 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 4.61652530576288, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876266, + 0.5 + ], + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0, + 0 + ], + [ + 0.9510565162951534, + 0.3090169943749474 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 6.123233995736766e-17, + 1 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 4.028740053470407, + 1.3090169943749472 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.1266270208801, + 1.3090169943749475 + ], + [ + 3.077683537175253, + 0.9999999999999999 + ], + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 1.8090169943749475 + ], + [ + 3.440954801177934, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 2.4898982848827806, + 2.4270509831248424 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.538841768587627, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 6.518638338353187, + 6.354101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 6.518638338353187, + 5.73606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 6.518638338353187, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.930853086060715, + 7.163118960624632 + ], + [ + 6.518638338353187, + 7.97213595499958 + ], + [ + 5.567581822058035, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.4409548011779343, + 3.118033988749895 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.392011317473088, + 5.045084971874737 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.979796569765561, + 5.23606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ], + [ + 5.567581822058035, + 6.045084971874737 + ], + [ + 4.979796569765561, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 6.518638338353187, + 4.73606797749979 + ], + [ + 5.567581822058035, + 5.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.930853086060715, + 3.9270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 6.518638338353187, + 2.1180339887498945 + ], + [ + 6.518638338353187, + 3.118033988749895 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 2.9270509831248424 + ], + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 4.979796569765561, + 4.23606797749979 + ], + [ + 4.028740053470408, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 3.23606797749979 + ], + [ + 5.567581822058034, + 2.4270509831248424 + ], + [ + 5.567581822058035, + 3.4270509831248424 + ], + [ + 4.979796569765561, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 1.538841768587627, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 0.9510565162951543, + 10.781152949374528 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + 0.951056516295154, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + 0.9510565162951539, + 8.163118960624633 + ], + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 0.5877852522924735, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 7.663118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 8.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 8.163118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.616525305762881, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.616525305762881, + 8.97213595499958 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 9.47213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ], + [ + 1.5388417685876274, + 10.590169943749475 + ], + [ + 0.951056516295154, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 4.028740053470408, + 9.781152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 3.077683537175254, + 10.090169943749475 + ], + [ + 2.1266270208801004, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 8.663118960624631 + ], + [ + 3.4409548011779343, + 8.97213595499958 + ], + [ + 2.4898982848827806, + 9.281152949374526 + ], + [ + 1.538841768587627, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 0.5877852522924734, + 5.045084971874737 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.123233995736766e-17, + 1 + ], + [ + 0.9510565162951536, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924732, + 2.4270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951536, + 2.9270509831248424 + ], + [ + 1.5388417685876268, + 2.118033988749895 + ], + [ + 1.538841768587627, + 3.118033988749895 + ], + [ + 0.9510565162951541, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.979796569765561, + 7.854101966249685 + ], + [ + 4.028740053470408, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.4409548011779343, + 7.9721359549995805 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 5.567581822058035, + 7.663118960624632 + ], + [ + 5.567581822058034, + 8.663118960624633 + ], + [ + 4.979796569765561, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 3.4409548011779343, + 4.73606797749979 + ], + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.4898982848827806, + 3.4270509831248424 + ], + [ + 3.077683537175254, + 4.23606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 1.9021130325903075, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 4.979796569765561, + 6.854101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779343, + 5.73606797749979 + ], + [ + 4.392011317473088, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.4409548011779343, + 6.354101966249685 + ], + [ + 4.028740053470408, + 7.163118960624632 + ], + [ + 3.077683537175254, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 3.077683537175254, + 6.854101966249685 + ], + [ + 2.4898982848827806, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.9021130325903075, + 6.854101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924736, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.9510565162951541, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587627, + 4.73606797749979 + ], + [ + 2.4898982848827806, + 5.045084971874737 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587627, + 5.73606797749979 + ], + [ + 2.4898982848827806, + 6.045084971874737 + ], + [ + 1.538841768587627, + 6.354101966249685 + ], + [ + 0.5877852522924736, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.951056516295153, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.5388417685876261, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + 5.800010489189397e-16, + 9.47213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + 6.178447162232811e-16, + 10.090169943749475 + ], + [ + 6.790770561806488e-16, + 11.090169943749475 + ], + [ + -0.9510565162951526, + 10.781152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.951056516295153, + 8.163118960624633 + ], + [ + 4.809250416572305e-16, + 7.854101966249685 + ], + [ + -0.5877852522924726, + 8.663118960624633 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.616525305762879, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ], + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -4.028740053470406, + 8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -4.616525305762879, + 8.97213595499958 + ], + [ + -4.028740053470406, + 9.781152949374526 + ], + [ + -4.9797965697655595, + 9.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -0.951056516295153, + 9.781152949374526 + ], + [ + -1.5388417685876257, + 10.590169943749475 + ], + [ + -2.126627020880099, + 9.781152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177933, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ], + [ + -4.028740053470406, + 9.781152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.126627020880099, + 9.781152949374526 + ], + [ + -3.0776835371752522, + 10.090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 8.663118960624631 + ], + [ + -1.5388417685876261, + 8.97213595499958 + ], + [ + -2.4898982848827798, + 9.281152949374526 + ], + [ + -3.440954801177933, + 8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924728, + 5.045084971874737 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + 3.2061669443815366e-16, + 5.23606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + 9.907600726170916e-17, + 1.618033988749895 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + 0, + 0 + ], + [ + 6.123233995736766e-17, + 1 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + 2.59384354480786e-16, + 4.23606797749979 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.5877852522924728, + 2.4270509831248424 + ], + [ + 1.9815201452341832e-16, + 3.23606797749979 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -0.9510565162951532, + 2.9270509831248424 + ], + [ + -0.9510565162951532, + 3.9270509831248424 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -1.5388417685876263, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.028740053470406, + 8.163118960624633 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ], + [ + -3.440954801177933, + 7.9721359549995805 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -5.567581822058033, + 8.663118960624633 + ], + [ + -6.5186383383531865, + 8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -4.9797965697655595, + 7.854101966249685 + ], + [ + -5.567581822058033, + 8.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -1.9021130325903066, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.077683537175253, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -4.392011317473086, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779334, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -4.028740053470406, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175253, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ], + [ + -2.4898982848827798, + 7.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ], + [ + -1.9021130325903066, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + 4.196927016998628e-16, + 6.854101966249685 + ], + [ + -0.9510565162951532, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827798, + 5.045084971874737 + ], + [ + -1.5388417685876261, + 4.73606797749979 + ], + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876261, + 5.73606797749979 + ], + [ + -0.5877852522924727, + 6.045084971874737 + ], + [ + -1.5388417685876261, + 6.354101966249685 + ], + [ + -2.4898982848827798, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -7.10642359064566, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -9.008536623235967, + 3.927050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 3.4270509831248432 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.95959313953112, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -9.59632187552844, + 3.118033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 3.2360679774997902 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -7.46969485464834, + 3.4270509831248424 + ], + [ + -8.057480106940814, + 4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -8.057480106940814, + 6.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 5.73606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -7.46969485464834, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -7.10642359064566, + 7.163118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ], + [ + -7.46969485464834, + 7.663118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235967, + 3.927050983124843 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -9.59632187552844, + 4.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 6.854101966249685 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.645265359233287, + 5.045084971874737 + ], + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -8.057480106940814, + 5.23606797749979 + ], + [ + -8.645265359233287, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940814, + 4.23606797749979 + ], + [ + -7.46969485464834, + 5.045084971874737 + ], + [ + -7.46969485464834, + 6.045084971874737 + ], + [ + -8.057480106940814, + 5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ], + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.97979656976556, + 3.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.61652530576288, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ], + [ + -0.9510565162951533, + 1.3090169943749475 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 1.8090169943749475 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827798, + 1.8090169943749475 + ], + [ + -1.5388417685876263, + 2.118033988749895 + ], + [ + -2.4898982848827798, + 2.4270509831248424 + ], + [ + -3.4409548011779334, + 2.118033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 6.354101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.930853086060714, + 7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 4.73606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -5.567581822058033, + 6.045084971874737 + ], + [ + -6.518638338353187, + 5.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060714, + 7.163118960624632 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 7.663118960624632 + ], + [ + -6.5186383383531865, + 7.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -3.4409548011779334, + 4.73606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779334, + 3.118033988749895 + ], + [ + -2.4898982848827798, + 3.4270509831248424 + ], + [ + -3.077683537175253, + 4.23606797749979 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.9797965697655595, + 6.854101966249685 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.392011317473087, + 5.045084971874737 + ], + [ + -4.392011317473086, + 6.045084971874737 + ], + [ + -4.97979656976556, + 5.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -4.97979656976556, + 5.23606797749979 + ], + [ + -5.567581822058033, + 6.045084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060714, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 5.045084971874737 + ], + [ + -6.518638338353187, + 4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353187, + 3.1180339887498953 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.930853086060714, + 3.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ], + [ + -6.518638338353187, + 3.1180339887498953 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.028740053470407, + 2.9270509831248424 + ], + [ + -4.028740053470407, + 3.9270509831248424 + ], + [ + -4.97979656976556, + 4.23606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058034, + 2.427050983124843 + ], + [ + -4.97979656976556, + 3.23606797749979 + ], + [ + -4.97979656976556, + 4.23606797749979 + ], + [ + -5.567581822058034, + 3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ], + [ + -8.057480106940815, + 1.0000000000000018 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ], + [ + -9.008536623235967, + 2.9270509831248432 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823594, + 2.427050983124844 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -9.59632187552844, + 3.118033988749896 + ], + [ + -10.547378391823594, + 3.4270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -8.057480106940815, + 1.6180339887498962 + ], + [ + -7.469694854648341, + 2.4270509831248432 + ], + [ + -8.420751370943496, + 2.1180339887498962 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.008536623235969, + -1.309016994374944 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + -1.8090169943749426 + ], + [ + -9.959593139531123, + -2.61803398874989 + ], + [ + -9.959593139531123, + -1.6180339887498905 + ], + [ + -10.547378391823596, + -0.809016994374943 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.959593139531123, + 1.0000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 2.1180339887498967 + ], + [ + -10.547378391823594, + 1.8090169943749497 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -0.809016994374943 + ], + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -10.547378391823596, + 0.19098300562505632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -10.547378391823596, + 0.19098300562505632 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.959593139531123, + 1.0000000000000027 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -0.4999999999999964 + ], + [ + -9.008536623235969, + 0.3090169943749499 + ], + [ + -9.008536623235969, + 1.3090169943749492 + ], + [ + -9.596321875528442, + 0.5000000000000027 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.6180339887498953 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + 0, + 0 + ], + [ + -0.9510565162951534, + 0.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -9.008536623235969, + -1.309016994374944 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ], + [ + -8.645265359233289, + -0.8090169943749443 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -10.547378391823596, + -3.4270509831248366 + ], + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.008536623235969, + -2.9270509831248375 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.309016994374943 + ], + [ + -9.959593139531123, + -2.61803398874989 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -8.057480106940815, + -1.6180339887498911 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.518638338353188, + -1.499999999999997 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -6.518638338353188, + -1.499999999999997 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -1.6180339887498911 + ], + [ + -7.106423590645662, + -1.3090169943749443 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.469694854648342, + -0.8090169943749447 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -0.8090169943749447 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -8.057480106940815, + 2.220446049250313e-15 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.106423590645662, + 0.3090169943749492 + ], + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + 0.5000000000000016 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353187, + 2.1180339887498953 + ], + [ + -7.106423590645661, + 1.3090169943749486 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -0.4999999999999978 + ], + [ + -5.930853086060715, + 0.30901699437494906 + ], + [ + -5.930853086060715, + 1.3090169943749483 + ], + [ + -6.518638338353188, + 0.5000000000000016 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.106423590645662, + -5.545084971874735 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.979796569765561, + -5.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.930853086060715, + -5.5450849718747355 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -6.518638338353188, + -4.736067977499787 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -7.106423590645662, + -8.16311896062463 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.930853086060715, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -6.518638338353188, + -7.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -7.663118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.663118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -6.663118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -5.567581822058035, + -6.0450849718747355 + ], + [ + -6.518638338353188, + -6.354101966249683 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -9.008536623235969, + -4.545084971874733 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -7.469694854648342, + -4.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -4.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -4.7360679774997845 + ], + [ + -9.008536623235969, + -5.545084971874733 + ], + [ + -9.008536623235969, + -4.545084971874733 + ], + [ + -9.596321875528442, + -3.736067977499785 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -7.6631189606246295 + ], + [ + -6.518638338353188, + -7.354101966249683 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.6631189606246295 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.420751370943496, + -6.354101966249681 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ], + [ + -9.008536623235969, + -5.545084971874733 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.6631189606246295 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -8.420751370943496, + -6.354101966249681 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -7.469694854648342, + -6.045084971874735 + ], + [ + -6.518638338353188, + -6.354101966249683 + ], + [ + -7.106423590645662, + -5.545084971874735 + ], + [ + -8.057480106940815, + -5.236067977499786 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -4.028740053470408, + -4.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.616525305762881, + -3.736067977499788 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -3.077683537175254, + -4.236067977499789 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951539, + -1.3090169943749475 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ], + [ + -1.5388417685876268, + -0.4999999999999991 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -8.057480106940815, + -4.236067977499786 + ], + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -8.645265359233289, + -3.427050983124838 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -4.427050983124839 + ], + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -9.596321875528442, + -3.736067977499785 + ], + [ + -8.645265359233289, + -3.427050983124838 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ], + [ + -9.008536623235969, + -2.9270509831248375 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.469694854648342, + -3.427050983124839 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ], + [ + -8.057480106940815, + -2.6180339887498905 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.155367074350508, + -2.6180339887498913 + ], + [ + -7.106423590645662, + -2.3090169943749435 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -6.518638338353188, + -3.7360679774997867 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.1180339887498913 + ], + [ + -7.469694854648342, + -3.427050983124839 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -4.736067977499787 + ], + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -6.518638338353188, + -3.7360679774997867 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -5.567581822058035, + -4.427050983124841 + ], + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -5.236067977499788 + ], + [ + -4.028740053470408, + -5.545084971874736 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.979796569765561, + -4.236067977499788 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -3.4270509831248397 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.979796569765561, + -4.236067977499788 + ], + [ + -4.028740053470408, + -4.545084971874736 + ], + [ + -4.616525305762881, + -3.736067977499788 + ], + [ + -5.567581822058035, + -3.4270509831248397 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.4409548011779347, + -7.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.616525305762881, + -6.354101966249684 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -7.354101966249684 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -5.567581822058035, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -6.518638338353188, + -8.972135954999578 + ], + [ + -5.567581822058035, + -9.281152949374524 + ], + [ + -4.979796569765561, + -8.472135954999578 + ], + [ + -5.930853086060715, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.616525305762881, + -7.354101966249684 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.028740053470408, + -7.163118960624631 + ], + [ + -4.616525305762881, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.9021130325903086, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5388417685876281, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876281, + -10.590169943749475 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.5388417685876281, + -9.97213595499958 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -9.472135954999578 + ], + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -4.028740053470408, + -8.163118960624631 + ], + [ + -4.979796569765561, + -8.472135954999578 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -2.4898982848827815, + -10.281152949374526 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -9.163118960624631 + ], + [ + -3.440954801177935, + -9.972135954999578 + ], + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.4409548011779347, + -8.97213595499958 + ], + [ + -2.4898982848827815, + -9.281152949374526 + ], + [ + -3.0776835371752544, + -8.47213595499958 + ], + [ + -4.028740053470408, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -2.4898982848827806, + -4.4270509831248415 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -3.077683537175254, + -4.236067977499789 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ], + [ + 0, + 0 + ], + [ + -0.5877852522924732, + -0.8090169943749475 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951541, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876272, + -3.73606797749979 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.9510565162951541, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + -0.5877852522924737, + -3.4270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876281, + -8.97213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903086, + -8.47213595499958 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -2.4898982848827815, + -7.663118960624631 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.0372311685419464e-15, + -11.090169943749475 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -0.5877852522924749, + -10.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.5877852522924749, + -10.281152949374526 + ], + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -0.5877852522924748, + -9.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + -0.9510565162951542, + -4.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -9.618500833144608e-16, + -5.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + -0.5877852522924742, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -0.9510565162951552, + -8.163118960624631 + ], + [ + -0.9510565162951548, + -7.163118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.4898982848827815, + -7.663118960624631 + ], + [ + -1.538841768587628, + -7.354101966249685 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.663118960624632 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.4409548011779347, + -6.354101966249684 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.4409548011779347, + -6.354101966249684 + ], + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ], + [ + -4.028740053470408, + -5.545084971874736 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -0.9510565162951544, + -5.545084971874737 + ], + [ + -1.5388417685876274, + -4.73606797749979 + ], + [ + -2.126627020880101, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827815, + -6.045084971874737 + ], + [ + -1.5388417685876277, + -6.354101966249685 + ], + [ + -2.126627020880101, + -5.545084971874737 + ], + [ + -3.077683537175254, + -5.236067977499789 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 2.489898284882778, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -7.163118960624633 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 3.440954801177931, + -7.354101966249686 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -9.281152949374528 + ], + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 4.979796569765558, + -8.472135954999581 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 5.930853086060711, + -8.163118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.567581822058031, + -7.663118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 4.6165253057628775, + -7.354101966249686 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 4.028740053470404, + -7.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.077683537175251, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.5388417685876246, + -10.590169943749475 + ], + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 1.5388417685876246, + -9.97213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.979796569765558, + -9.472135954999581 + ], + [ + 4.979796569765558, + -8.472135954999581 + ], + [ + 4.028740053470404, + -8.163118960624633 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882778, + -10.281152949374528 + ], + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 2.489898284882778, + -9.281152949374528 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.4409548011779316, + -9.972135954999581 + ], + [ + 4.028740053470404, + -9.163118960624633 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.440954801177931, + -8.97213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882778, + -9.281152949374528 + ], + [ + 3.440954801177931, + -8.97213595499958 + ], + [ + 4.028740053470404, + -8.163118960624633 + ], + [ + 3.077683537175251, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -7.781530634423579e-16, + -4.23606797749979 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.5877852522924724, + -3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.077683537175252, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.489898284882779, + -4.427050983124843 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.809250416572304e-16, + -2.618033988749895 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + -2.9722802178512745e-16, + -1.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.9722802178512745e-16, + -1.618033988749895 + ], + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ], + [ + 0.5877852522924727, + -0.8090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 0.951056516295153, + -1.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 0.9510565162951529, + -2.3090169943749475 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.5877852522924724, + -3.4270509831248424 + ], + [ + 1.5388417685876254, + -3.7360679774997902 + ], + [ + 0.9510565162951528, + -2.9270509831248424 + ], + [ + -4.809250416572304e-16, + -2.618033988749895 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + 1.5388417685876246, + -8.97213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.902113032590305, + -8.47213595499958 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.740003146756819e-15, + -9.47213595499958 + ], + [ + 0.5877852522924711, + -10.281152949374526 + ], + [ + 0.5877852522924712, + -9.281152949374526 + ], + [ + -1.5563061268847159e-15, + -8.47213595499958 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.5877852522924716, + -6.663118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + -1.0753810852274853e-15, + -5.854101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -9.618500833144608e-16, + -5.23606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ], + [ + 0.9510565162951526, + -4.545084971874737 + ], + [ + -7.781530634423579e-16, + -4.23606797749979 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5563061268847159e-15, + -8.47213595499958 + ], + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + -1.3726091070126127e-15, + -7.47213595499958 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.3726091070126127e-15, + -7.47213595499958 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.5877852522924716, + -6.663118960624632 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 0.9510565162951516, + -8.163118960624633 + ], + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 0.9510565162951519, + -7.163118960624632 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.5388417685876248, + -7.354101966249685 + ], + [ + 2.489898284882778, + -7.663118960624633 + ], + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827784, + -6.663118960624633 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 1.538841768587625, + -6.354101966249685 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.4409548011779316, + -6.354101966249686 + ], + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 3.077683537175252, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ], + [ + 1.5388417685876257, + -4.73606797749979 + ], + [ + 0.9510565162951523, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.538841768587625, + -6.354101966249685 + ], + [ + 2.489898284882779, + -6.045084971874737 + ], + [ + 3.077683537175252, + -5.236067977499791 + ], + [ + 2.1266270208800986, + -5.545084971874737 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.930853086060713, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.6165253057628775, + -6.354101966249686 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.028740053470405, + -5.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.930853086060713, + -5.545084971874739 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 5.567581822058033, + -4.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058031, + -7.663118960624633 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.663118960624634 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353185, + -8.972135954999581 + ], + [ + 7.106423590645658, + -8.163118960624635 + ], + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 5.930853086060711, + -8.163118960624633 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058032, + -6.663118960624634 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 5.567581822058032, + -6.045084971874739 + ], + [ + 4.6165253057628775, + -6.354101966249686 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 8.057480106940814, + -4.236067977499792 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.106423590645659, + -5.545084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 9.596321875528439, + -4.7360679774997925 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235965, + -4.54508497187474 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.518638338353186, + -7.3541019662496865 + ], + [ + 7.469694854648338, + -7.663118960624635 + ], + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 9.008536623235965, + -5.54508497187474 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 7.469694854648339, + -6.663118960624635 + ], + [ + 8.420751370943492, + -6.354101966249687 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 6.518638338353186, + -6.3541019662496865 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.518638338353186, + -6.3541019662496865 + ], + [ + 7.469694854648339, + -6.045084971874739 + ], + [ + 8.057480106940812, + -5.2360679774997925 + ], + [ + 7.106423590645659, + -5.545084971874739 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 3.077683537175252, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 3.4409548011779325, + -3.7360679774997907 + ], + [ + 2.489898284882779, + -3.427050983124843 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 0.951056516295153, + -1.309016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.5388417685876266, + -0.5000000000000003 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 2.489898284882779, + -3.427050983124843 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 1.902113032590306, + -2.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 1.902113032590306, + -2.6180339887498953 + ], + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 2.48989828488278, + -0.809016994374948 + ], + [ + 1.9021130325903066, + -1.6180339887498953 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 2.4898982848827798, + -1.809016994374948 + ], + [ + 3.0776835371752522, + -2.618033988749896 + ], + [ + 3.077683537175253, + -1.6180339887498953 + ], + [ + 2.48989828488278, + -0.809016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 8.057480106940814, + -4.236067977499792 + ], + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.46969485464834, + -3.427050983124844 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 7.46969485464834, + -4.427050983124844 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 8.645265359233287, + -3.4270509831248446 + ], + [ + 9.59632187552844, + -3.736067977499792 + ], + [ + 9.008536623235967, + -2.9270509831248446 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ], + [ + 5.567581822058033, + -1.8090169943749488 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470406, + -2.3090169943749483 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.9797965697655595, + -1.618033988749896 + ], + [ + 4.028740053470407, + -1.3090169943749483 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 8.057480106940814, + -2.6180339887498967 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 7.10642359064566, + -2.3090169943749492 + ], + [ + 6.155367074350506, + -2.6180339887498962 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 7.46969485464834, + -3.427050983124844 + ], + [ + 6.5186383383531865, + -3.1180339887498962 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 6.5186383383531865, + -4.736067977499792 + ], + [ + 6.5186383383531865, + -3.7360679774997916 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 5.567581822058033, + -4.427050983124843 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.028740053470405, + -5.545084971874738 + ], + [ + 4.9797965697655595, + -5.236067977499791 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 4.028740053470405, + -4.545084971874738 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + 4.616525305762879, + -3.7360679774997907 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.9797965697655595, + -2.618033988749896 + ], + [ + 4.028740053470406, + -2.9270509831248432 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + 4.028740053470405, + -4.545084971874738 + ], + [ + 4.9797965697655595, + -4.236067977499791 + ], + [ + 5.567581822058033, + -3.4270509831248437 + ], + [ + 4.616525305762879, + -3.7360679774997907 + ] + ] + } + ], + "hole": [ + [ + -1.5388417685876268, + -0.4999999999999991 + ], + [ + -1.5388417685876266, + 0.5000000000000001 + ], + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 1.7763568394002505e-15 + ], + [ + -5.567581822058035, + -0.8090169943749452 + ], + [ + -5.567581822058035, + -1.8090169943749443 + ], + [ + -4.979796569765561, + -2.6180339887498922 + ], + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124842 + ], + [ + -1.9021130325903075, + -2.618033988749895 + ], + [ + -0.951056516295154, + -2.3090169943749475 + ], + [ + -0.9510565162951539, + -1.3090169943749475 + ] + ], + "completion": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951545, + -2.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.4898982848827806, + -0.8090169943749477 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -2.4898982848827815, + -3.4270509831248424 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.077683537175254, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.979796569765561, + -2.618033988749893 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -2.4898982848827806, + -0.8090169943749472 + ], + [ + -3.0776835371752536, + -1.6180339887498945 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.0776835371752536, + -1.6180339887498945 + ], + [ + -2.4898982848827806, + -0.8090169943749472 + ], + [ + -3.4409548011779343, + -0.4999999999999997 + ], + [ + -4.028740053470407, + -1.309016994374947 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175254, + -2.6180339887498945 + ], + [ + -3.0776835371752536, + -1.6180339887498945 + ], + [ + -4.028740053470407, + -1.309016994374947 + ], + [ + -4.028740053470408, + -2.3090169943749466 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.979796569765561, + -2.618033988749893 + ], + [ + -4.028740053470408, + -2.3090169943749457 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.979796569765561, + -2.618033988749893 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ], + [ + -5.567581822058035, + -0.8090169943749457 + ], + [ + -5.567581822058035, + -1.8090169943749457 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -5.567581822058035, + -0.8090169943749457 + ], + [ + -4.979796569765561, + -1.6180339887498931 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -4.616525305762881, + -0.49999999999999817 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.616525305762881, + -0.49999999999999817 + ], + [ + -4.028740053470408, + -1.3090169943749457 + ], + [ + -3.4409548011779343, + -0.4999999999999982 + ], + [ + -4.028740053470408, + 0.3090169943749493 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -3.077683537175253, + 1 + ], + [ + -3.077683537175253, + 2.220446049250313e-16 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -3.077683537175253, + 2.220446049250313e-16 + ], + [ + -4.028740053470407, + 0.30901699437494773 + ], + [ + -3.4409548011779343, + -0.4999999999999995 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -3.077683537175253, + 2.220446049250313e-16 + ], + [ + -3.077683537175253, + 1 + ], + [ + -4.028740053470407, + 1.3090169943749475 + ], + [ + -4.028740053470407, + 0.30901699437494773 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -4.616525305762881, + -0.49999999999999817 + ], + [ + -4.028740053470408, + 0.3090169943749493 + ], + [ + -4.979796569765561, + 1.9984014443252818e-15 + ], + [ + -5.567581822058035, + -0.8090169943749455 + ] + ] + } + ], + "deadEnds": [ + { + "depth": 3, + "fill": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -3.0776835371752536, + 2.220446049250313e-16 + ], + [ + -2.1266270208801, + 0.30901699437494745 + ] + ] + } + ], + "doomedEdge": [ + [ + -2.126627, + 1.3090169999999999 + ], + [ + -2.126627, + 0.309017 + ] + ], + "verdicts": [ + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "illegal", + "type": "thin", + "reason": "closes to non-star [144,72,36,108]" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + } + ], + "reason": "the only tile that fits this edge closes an illegal vertex (closes to non-star [144,72,36,108]), so this edge can never close" + }, + { + "depth": 7, + "fill": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951545, + -2.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.8531695488854614, + -1.3090169943749477 + ], + [ + -2.853169548885462, + -2.309016994374947 + ] + ] + } + ], + "doomedEdge": [ + [ + -1.902113, + -1.618034 + ], + [ + -2.4898982999999997, + -0.809017 + ] + ], + "verdicts": [ + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "illegal", + "type": "thin", + "reason": "closes to non-star [108,36,108,108]" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + } + ], + "reason": "the only tile that fits this edge closes an illegal vertex (closes to non-star [108,36,108,108]), so this edge can never close" + }, + { + "depth": 8, + "fill": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -0.9510565162951545, + -2.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.4898982848827806, + -0.8090169943749477 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.9021130325903082, + -2.618033988749895 + ], + [ + -2.4898982848827806, + -1.8090169943749472 + ], + [ + -3.4409548011779343, + -1.4999999999999998 + ], + [ + -2.853169548885462, + -2.309016994374947 + ] + ] + } + ], + "doomedEdge": [ + [ + -1.902113, + -2.6180339999999998 + ], + [ + -2.8531695, + -2.309017 + ] + ], + "verdicts": [ + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + } + ], + "reason": "every tile that fits this edge overlaps a committed tile (8), so this edge can never close" + }, + { + "depth": 6, + "fill": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.538841768587627, + -0.5000000000000002 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -1.538841768587627, + -0.5000000000000002 + ], + [ + -2.4898982848827806, + -0.8090169943749475 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -0.9510565162951542, + -1.309016994374948 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -0.9510565162951542, + -1.309016994374948 + ], + [ + -1.9021130325903077, + -1.6180339887498951 + ], + [ + -2.489898284882781, + -2.4270509831248424 + ], + [ + -1.5388417685876277, + -2.118033988749895 + ] + ] + } + ], + "doomedEdge": [ + [ + -1.5388418, + -2.1180339999999998 + ], + [ + -2.4898982999999997, + -2.427051 + ] + ], + "verdicts": [ + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + } + ], + "reason": "every tile that fits this edge overlaps a committed tile (8), so this edge can never close" + }, + { + "depth": 4, + "fill": [ + { + "type": "fat", + "v": [ + [ + -4.028740053470408, + -2.9270509831248406 + ], + [ + -3.4409548011779343, + -3.7360679774997885 + ], + [ + -2.4898982848827806, + -3.427050983124841 + ], + [ + -3.077683537175254, + -2.618033988749893 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -4.028740053470407, + 1.3090169943749477 + ], + [ + -4.97979656976556, + 1.0000000000000009 + ], + [ + -4.979796569765561, + 8.881784197001252e-16 + ], + [ + -4.028740053470408, + 0.3090169943749477 + ] + ] + }, + { + "type": "fat", + "v": [ + [ + -2.1266270208800995, + 1.3090169943749475 + ], + [ + -3.077683537175253, + 1.0000000000000002 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -1.5388417685876266, + 0.4999999999999998 + ] + ] + }, + { + "type": "thin", + "v": [ + [ + -1.5388417685876266, + 0.4999999999999998 + ], + [ + -2.48989828488278, + 0.19098300562505255 + ], + [ + -3.0776835371752536, + -0.6180339887498947 + ], + [ + -2.1266270208801, + -0.30901699437494745 + ] + ] + } + ], + "doomedEdge": [ + [ + -1.5388418, + 0.5 + ], + [ + -2.126627, + -0.309017 + ] + ], + "verdicts": [ + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "illegal", + "type": "thin", + "reason": "closes to non-star [72,72,36,36,144]" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "fat" + }, + { + "kind": "overlap", + "type": "thin" + }, + { + "kind": "overlap", + "type": "thin" + } + ], + "reason": "the only tile that fits this edge closes an illegal vertex (closes to non-star [72,72,36,36,144]), so this edge can never close" + } + ] +} diff --git a/src/app/x/penrose/_components/lib/tiles.test.ts b/src/app/x/penrose/_components/lib/tiles.test.ts new file mode 100644 index 0000000..ded0ecc --- /dev/null +++ b/src/app/x/penrose/_components/lib/tiles.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { PHI, THICK, THIN, type Rhombus } from "./tiles"; + +const dist = (a: readonly [number, number], b: readonly [number, number]) => + Math.hypot(a[0] - b[0], a[1] - b[1]); + +describe("Penrose rhombi geometry", () => { + const cases: { name: string; tile: Rhombus; acute: number; obtuse: number }[] = [ + { name: "thick", tile: THICK, acute: 72, obtuse: 108 }, + { name: "thin", tile: THIN, acute: 36, obtuse: 144 }, + ]; + + for (const { name, tile, acute, obtuse } of cases) { + test(`${name}: interior angles are ${acute} and ${obtuse}`, () => { + expect(tile.acute).toBe(acute); + expect(tile.obtuse).toBe(obtuse); + expect(tile.acute + tile.obtuse).toBe(180); + }); + + test(`${name}: all four edges have unit length`, () => { + const [a, b, c, d] = tile.corners; + for (const e of [dist(a, b), dist(b, c), dist(c, d), dist(d, a)]) { + expect(e).toBeCloseTo(1, 12); + } + }); + + test(`${name}: diagonals match the corner spans`, () => { + const [right, top, left, bottom] = tile.corners; + expect(dist(right, left)).toBeCloseTo(tile.longDiagonal, 12); + expect(dist(top, bottom)).toBeCloseTo(tile.shortDiagonal, 12); + }); + } + + test("the golden ratio hides in the diagonals", () => { + // The whole teaching point: with a unit edge, phi is a diagonal length. + expect(THICK.longDiagonal).toBeCloseTo(PHI, 12); + expect(THIN.shortDiagonal).toBeCloseTo(1 / PHI, 12); + }); +}); diff --git a/src/app/x/penrose/_components/lib/tiles.ts b/src/app/x/penrose/_components/lib/tiles.ts new file mode 100644 index 0000000..5306fc8 --- /dev/null +++ b/src/app/x/penrose/_components/lib/tiles.ts @@ -0,0 +1,54 @@ +// Authored geometry for the two Penrose rhombi, used by the "Meet the two tiles" +// sketch. Both tiles share a unit edge. The numbers here are the teaching point: +// with a unit edge, the thick rhombus's long diagonal is exactly the golden ratio +// and the thin rhombus's short diagonal is exactly 1/phi. That is where phi hides. + +export const PHI = (1 + Math.sqrt(5)) / 2; + +export type Pt = readonly [number, number]; + +export type RhombusKind = "thick" | "thin"; + +// A rhombus centred on the origin, unit edge, with its acute vertices on the +// horizontal axis. acute is the small interior angle in degrees; obtuse is its +// supplement. corners run counter-clockwise from the right (acute) vertex. +export type Rhombus = { + kind: RhombusKind; + acute: number; // degrees + obtuse: number; // degrees + corners: readonly [Pt, Pt, Pt, Pt]; + longDiagonal: number; // tip-to-tip across the obtuse corners + shortDiagonal: number; // tip-to-tip across the acute corners +}; + +const deg = (d: number) => (d * Math.PI) / 180; + +// Build a unit-edge rhombus from its acute interior angle. The acute corners sit +// on the x-axis, the obtuse corners on the y-axis, so the long diagonal is +// horizontal and the short diagonal vertical. Half-diagonals come straight from +// the half-angles: cos(acute/2) and sin(acute/2). +function rhombus(kind: RhombusKind, acute: number): Rhombus { + const half = deg(acute / 2); + const hx = Math.cos(half); // half the long diagonal + const hy = Math.sin(half); // half the short diagonal + const corners: readonly [Pt, Pt, Pt, Pt] = [ + [hx, 0], // right, acute + [0, hy], // top, obtuse + [-hx, 0], // left, acute + [0, -hy], // bottom, obtuse + ]; + return { + kind, + acute, + obtuse: 180 - acute, + corners, + longDiagonal: 2 * hx, + shortDiagonal: 2 * hy, + }; +} + +// The thick (fat) rhombus: interior angles 72 and 108. Its long diagonal is phi. +export const THICK: Rhombus = rhombus("thick", 72); + +// The thin (skinny) rhombus: interior angles 36 and 144. Its short diagonal is 1/phi. +export const THIN: Rhombus = rhombus("thin", 36); diff --git a/src/app/x/penrose/_components/lib/unsolvableFuture.test.ts b/src/app/x/penrose/_components/lib/unsolvableFuture.test.ts new file mode 100644 index 0000000..9108ad7 --- /dev/null +++ b/src/app/x/penrose/_components/lib/unsolvableFuture.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; + +import { isCompleteStar } from "./naiveSolver"; +import committed from "./scene.json"; +import { + computeScene, + isGenuinelyDoomed, + type DeadEnd, +} from "./unsolvableFuture"; + +// This test BINDS the section-5 sketch to the proof. The sketch renders +// scene.json; this test re-runs the exhaustive search that produced it and +// asserts both that the committed data matches the live computation and that the +// proof's honesty invariants hold. If anyone weakens the search into an +// illustration (a "dead-end" that actually had a legal fill, a completion count +// that is not 1, a hole that is not enclosed), this test fails. +// +// The verified spike's result, reproduced here: a 410-tile wall around a single +// closed 16-edge hole; exactly ONE completion (20 tiles, rigid); exactly FIVE +// dead-ends at fill-depths 3, 4, 6, 7, 8, each a legal partial fill whose chosen +// frontier edge can never close. + +const scene = computeScene(); + +describe("the unsolvable-future search reproduces the verified proof", () => { + test("the wall is a 410-tile sub-patch around a single 16-edge closed hole", () => { + expect(scene.meta.wallTiles).toBe(410); + expect(scene.meta.holeEdges).toBe(16); + // A single simple closed loop: the polygon has as many vertices as edges. + expect(scene.hole.length).toBe(16); + }); + + test("the hole has EXACTLY ONE completion, rigid at 20 tiles", () => { + expect(scene.meta.completions).toBe(1); + expect(scene.completion.length).toBe(20); + expect(scene.meta.completionTiles).toBe(20); + }); + + test("the search is exhaustive: the finiteness cap is never hit", () => { + // Exhausting a finite tree is the proof. If a branch had exceeded the + // area-based bound, the search would have leaked and the result would be + // meaningless. It must not. + expect(scene.meta.capHit).toBe(false); + expect(scene.meta.finitenessBound).toBeGreaterThan(scene.completion.length); + }); + + test("there are EXACTLY FIVE dead-ends, at fill-depths 3, 4, 6, 7, 8", () => { + expect(scene.meta.deadEnds).toBe(5); + expect(scene.deadEnds.length).toBe(5); + const depths = scene.deadEnds.map((d) => d.depth).sort((a, b) => a - b); + expect(depths).toEqual([3, 4, 6, 7, 8]); + }); +}); + +describe("every dead-end's doomed edge is genuinely unsolvable (the honesty lock)", () => { + // The whole job is correctness: each dead-end must be a REAL dead-end. For its + // chosen frontier edge there must be NO legal, non-overlapping candidate. Every + // candidate either overlaps committed material or closes a vertex outside the + // seven-star atlas. We assert this directly per dead-end, re-deriving the + // verdicts from the recomputed scene rather than trusting a label. + for (const d of scene.deadEnds) { + test(`depth ${d.depth}: no candidate on the doomed edge is legal and non-overlapping`, () => { + // Every candidate has a verdict, and none is "legal" (there is no such + // verdict kind: a candidate is either overlap or illegal). + expect(d.verdicts.length).toBeGreaterThan(0); + for (const v of d.verdicts) { + expect(["overlap", "illegal"]).toContain(v.kind); + if (v.kind === "illegal") { + // An illegal candidate must close a vertex that is NOT one of the + // seven stars. Re-check it against the atlas oracle directly. + const angles = parseClosure(v.reason); + expect(angles).not.toBeNull(); + expect(isCompleteStar(angles!)).toBe(false); + } + } + // And the module's own invariant agrees. + expect(isGenuinelyDoomed(d)).toBe(true); + }); + } + + test("at least one dead-end is the sharp case: a tile FITS the gap but is illegal", () => { + // The honest headline: a wrong but legal move dooms an edge whose only + // non-overlapping candidate closes an illegal vertex. At least one dead-end + // must exhibit exactly that (the proof shows three: closures + // [144,72,36,108], [108,36,108,108], [72,72,36,36,144]). + const sharp = scene.deadEnds.filter((d) => + d.verdicts.some((v) => v.kind === "illegal"), + ); + expect(sharp.length).toBeGreaterThanOrEqual(1); + for (const d of sharp) { + const illegal = d.verdicts.filter((v) => v.kind === "illegal"); + // Exactly one tile fits the gap in the sharp case; it is the illegal one. + expect(illegal.length).toBe(1); + } + }); + + test("a faked dead-end (a doomed flag on an edge with a legal fill) is rejected", () => { + // Guard the guard: if we hand isGenuinelyDoomed a dead-end whose only + // "illegal" candidate actually closes a real star, it must return false. This + // is the failure mode the honesty lock exists to catch. + const faked: DeadEnd = { + depth: 0, + fill: [], + doomedEdge: [ + [0, 0], + [1, 0], + ], + // [72,72,72,72,72] is the Sun, a legal complete star. A search that called + // this a dead-end would be lying. + verdicts: [ + { kind: "illegal", type: "fat", reason: "closes to non-star [72,72,72,72,72]" }, + ], + reason: "fake", + }; + expect(isGenuinelyDoomed(faked)).toBe(false); + }); +}); + +describe("the committed scene.json matches the live computation", () => { + // The sketch renders scene.json. This asserts the committed snapshot is exactly + // what the search produces now, so the shipped data cannot drift from the + // proof. Serialise both through JSON so number formatting is identical. + test("scene.json equals computeScene() byte-for-byte", () => { + const live = JSON.parse(JSON.stringify(scene)); + expect(committed).toEqual(live); + }); +}); + +// Parse a "closes to non-star [a,b,c]" reason into its angle list. Mirrors the +// extraction in isGenuinelyDoomed so the test checks the atlas independently. +function parseClosure(reason: string): number[] | null { + const m = reason.match(/\[([\d,\s]+)\]/); + if (!m) return null; + return m[1].split(",").map((s) => Number(s.trim())); +} diff --git a/src/app/x/penrose/_components/lib/unsolvableFuture.ts b/src/app/x/penrose/_components/lib/unsolvableFuture.ts new file mode 100644 index 0000000..4532f88 --- /dev/null +++ b/src/app/x/penrose/_components/lib/unsolvableFuture.ts @@ -0,0 +1,432 @@ +// The "unsolvable future" proof, ported faithfully from a verified spike and run +// against the same verified legality oracle the naive solver uses. This module +// COMPUTES the scene the section-5 sketch renders; it does not author it. +// +// THE CLAIM, made precise. Carve a single closed hole out of a real deflated +// Penrose patch. The surrounding wall is legal and reachable (it is a sub-patch +// of a genuine tiling). Search the hole's fill-space EXHAUSTIVELY, rejecting a +// candidate tile ONLY when it overlaps committed material or closes a vertex to +// an arrangement outside the seven-star atlas. The result: the hole has exactly +// one completion (rigid), and exactly five legal partial fills from which the +// hole can NEVER be completed. Each dead-end picks a frontier edge that is +// permanently doomed: every tile that could seat on it either overlaps a placed +// tile or closes an illegal vertex. The rules are local; whether a legal move +// dooms you is not. Nothing local tells you which continuation survives. +// +// WHY EXHAUSTION IS A PROOF. The hole is bounded with finite area A. Every fill +// tile shares an edge with the hole region and may not overlap the wall, so all +// fill tiles lie inside the hole. Each rhombus has area >= sin(36 deg) > 0, so at +// most ceil(A / sin36) tiles fit. The search tree is finite. We also guard that +// bound explicitly: if any branch exceeds it the run is INVALID (capHit). It +// never approaches it in practice. +// +// DETERMINISM. Fixed anchor (-3.259, -1.059), fixed RHOLE = 2.3, fixed level 5, +// fixed candidate try-order (from candidates()), fixed frontier ordering. The +// scene is a pure function of nothing. The committed scene.json is its snapshot; +// unsolvableFuture.test.ts re-runs this and asserts both the snapshot match and +// the honesty invariants, so the sketch can never drift into a fake. + +import { + Board, + candidates, + deflatedRhombi, + edgeKey, + isCompleteStar, + keyPt, + PHI, + type Pt, + type Tile, +} from "./naiveSolver"; + +export type { Pt, Tile }; + +// --------------------------------------------------------------------------- +// Deterministic parameters of the proof. Do not change without re-running the +// search: the scene, the test, and the committed scene.json all depend on them. +// --------------------------------------------------------------------------- + +const LEVELS = 5; +const ANCHOR: Pt = [-3.259, -1.059]; +const RHOLE = 2.3; +const SIN36 = Math.sin((36 * Math.PI) / 180); + +// Unit-edge tiles. deflatedRhombi(levels, radius) divides the wheel by phi each +// deflation, so seeding the wheel at phi^levels yields unit edges, matching the +// verified spike's unitTiling exactly (byte-for-byte identical geometry). +// +// Exported so the geometry-only follow-through (geomWall.ts) carves its own rigid +// hexagon scene from the same tiling rather than duplicating the deflation. +export function unitTiling(levels = LEVELS): Tile[] { + return deflatedRhombi(levels, Math.pow(PHI, levels)) as Tile[]; +} + +export function centroid(t: Tile): Pt { + let x = 0; + let y = 0; + for (const p of t.v) { + x += p[0]; + y += p[1]; + } + return [x / 4, y / 4]; +} + +function boardFrom(tiles: Tile[]): Board { + const b = new Board(); + for (const t of tiles) b.place(t); + return b; +} + +function tileId(t: Tile): string { + return t.v.map(keyPt).sort().join("#") + ":" + t.type; +} + +// --------------------------------------------------------------------------- +// Carve the hole and extract its boundary loop. +// --------------------------------------------------------------------------- + +// Remove every tile whose centroid is within RHOLE of `center`; the rest is the +// wall. A clean disk carved from a real tiling leaves a single enclosed hole. +// Exported so geomWall.ts carves its rigid-hexagon scene with the same routine. +export function carve( + tiles: Tile[], + center: Pt, + rhole: number, +): { removed: Tile[]; kept: Tile[] } { + const removed: Tile[] = []; + const kept: Tile[] = []; + for (const t of tiles) { + const c = centroid(t); + const d = Math.hypot(c[0] - center[0], c[1] - center[1]); + (d <= rhole ? removed : kept).push(t); + } + return { removed, kept }; +} + +// The hole boundary is the set of the wall's open edges that were also edges of a +// removed tile. We assemble them into one ordered loop, accepting only a single +// simple closed loop (every vertex degree 2). Returns null otherwise: we only +// keep clean holes, so a messy carve is rejected rather than silently fudged. +// Exported so geomWall.ts extracts its hexagon boundary with the same routine. +export function holePolygon(kept: Tile[], removed: Tile[]): Pt[] | null { + const removedEdges = new Set(); + for (const t of removed) { + for (let i = 0; i < 4; i++) { + removedEdges.add(edgeKey(t.v[i], t.v[(i + 1) % 4])); + } + } + const b = boardFrom(kept); + const boundary: [Pt, Pt][] = []; + for (const e of b.openEdges()) { + if (removedEdges.has(edgeKey(e.a, e.b))) boundary.push([e.a, e.b]); + } + if (boundary.length < 3) return null; + + const adj = new Map(); + const ptByKey = new Map(); + for (const [a, bb] of boundary) { + ptByKey.set(keyPt(a), a); + ptByKey.set(keyPt(bb), bb); + (adj.get(keyPt(a)) ?? adj.set(keyPt(a), []).get(keyPt(a))!).push(bb); + (adj.get(keyPt(bb)) ?? adj.set(keyPt(bb), []).get(keyPt(bb))!).push(a); + } + for (const [, ns] of adj) if (ns.length !== 2) return null; + + const startK = [...adj.keys()].sort()[0]; + const loop: Pt[] = [ptByKey.get(startK)!]; + let prevK = ""; + let curK = startK; + for (let i = 0; i < adj.size; i++) { + const ns = adj.get(curK)!; + const nextPt = keyPt(ns[0]) !== prevK ? ns[0] : ns[1]; + const nextK = keyPt(nextPt); + if (nextK === startK) break; + loop.push(nextPt); + prevK = curK; + curK = nextK; + } + if (loop.length !== adj.size) return null; + return loop; +} + +export function inPoly(p: Pt, poly: Pt[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0]; + const yi = poly[i][1]; + const xj = poly[j][0]; + const yj = poly[j][1]; + const hit = + yi > p[1] !== yj > p[1] && + p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi; + if (hit) inside = !inside; + } + return inside; +} + +export function polyArea(poly: Pt[]): number { + let a = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + a += p[0] * q[1] - q[0] * p[1]; + } + return Math.abs(a) / 2; +} + +const edgeEndpoints = (key: string): [Pt, Pt] => { + const [ka, kb] = key.split("|"); + const toPt = (s: string): Pt => { + const [x, y] = s.split(",").map(Number); + return [x * 1e-7, y * 1e-7]; + }; + return [toPt(ka), toPt(kb)]; +}; + +// --------------------------------------------------------------------------- +// The exhaustive bounded search. +// --------------------------------------------------------------------------- + +// Frontier edges of the hole: open edges of the current board whose midpoint +// lies inside the hole polygon. We use the polygon ONLY to choose which edges to +// fill (the hole, not the outer rim). We never use it to reject a candidate +// tile; rejection is overlap or illegal vertex alone. Sorted for determinism. +function holeFrontier( + b: Board, + poly: Pt[], +): { key: string; a: Pt; bb: Pt }[] { + const out: { key: string; a: Pt; bb: Pt }[] = []; + const seen = new Set(); + for (const e of b.openEdges()) { + const mid: Pt = [(e.a[0] + e.b[0]) / 2, (e.a[1] + e.b[1]) / 2]; + if (inPoly(mid, poly) && !seen.has(e.key)) { + seen.add(e.key); + out.push({ key: e.key, a: e.a, bb: e.b }); + } + } + out.sort((x, y) => (x.key < y.key ? -1 : 1)); + return out; +} + +// Tiles that can legally seat on edge (a, bb): no overlap, every touched vertex +// stays legal. De-duplicated by tile identity, in the fixed candidate order. +function legalFills(b: Board, a: Pt, bb: Pt): Tile[] { + const seen = new Set(); + const out: Tile[] = []; + for (const c of candidates(a, bb)) { + if (b.overlapsAny(c)) continue; + if (!b.legalAfter(c).ok) continue; + const id = tileId(c); + if (seen.has(id)) continue; + seen.add(id); + out.push(c); + } + return out; +} + +// Every distinct candidate tile on a doomed edge, with its plain verdict. This +// is the honest reason the edge can never close: each candidate either overlaps +// committed material or closes a vertex outside the atlas. There is no third +// outcome and no candidate is LEGAL. +export type Verdict = + | { kind: "overlap"; type: "fat" | "thin" } + | { kind: "illegal"; type: "fat" | "thin"; reason: string }; + +function verdictsAt(b: Board, a: Pt, bb: Pt): Verdict[] { + const seen = new Set(); + const out: Verdict[] = []; + for (const c of candidates(a, bb)) { + const id = tileId(c); + if (seen.has(id)) continue; + seen.add(id); + if (b.overlapsAny(c)) { + out.push({ kind: "overlap", type: c.type }); + } else { + const leg = b.legalAfter(c); + out.push({ + kind: "illegal", + type: c.type, + reason: leg.reason ?? "illegal vertex", + }); + } + } + return out; +} + +export type DeadEnd = { + // Number of legal fill tiles placed before the doomed edge was reached. + depth: number; + // The fill tiles placed so far, in placement order. All legal, all clean. + fill: Tile[]; + // The frontier edge that can never close. + doomedEdge: [Pt, Pt]; + // Every candidate on that edge and why it fails. None is LEGAL. + verdicts: Verdict[]; + // A one-line, honest reason for the caption. + reason: string; +}; + +export type Scene = { + meta: { + levels: number; + anchor: Pt; + rhole: number; + holeCenter: Pt; + wallTiles: number; + holeEdges: number; + holeArea: number; + completionTiles: number; + completions: number; + deadEnds: number; + finitenessBound: number; + capHit: boolean; + nodes: number; + branches: number; + }; + wall: Tile[]; + hole: Pt[]; + completion: Tile[]; + deadEnds: DeadEnd[]; +}; + +type SearchResult = { + completions: number; + firstCompletion: Tile[] | null; + nodes: number; + branches: number; + capHit: boolean; + deadEnds: { depth: number; edge: string; fill: Tile[] }[]; +}; + +function search(fixed: Tile[], poly: Pt[]): SearchResult { + const res: SearchResult = { + completions: 0, + firstCompletion: null, + nodes: 0, + branches: 0, + capHit: false, + deadEnds: [], + }; + const maxFill = Math.ceil(polyArea(poly) / SIN36) + 4; // finiteness guard + const extra: Tile[] = []; + + function rec(): void { + res.nodes++; + if (extra.length > maxFill) { + res.capHit = true; + return; + } + const b = boardFrom([...fixed, ...extra]); + const front = holeFrontier(b, poly); + if (front.length === 0) { + res.completions++; + if (!res.firstCompletion) res.firstCompletion = [...extra]; + return; + } + // Drive the most-constrained edge: fewest legal fills first. If any frontier + // edge has zero legal fills, the current partial fill is a dead-end and that + // edge is doomed. + let chosen = front[0]; + let fills = legalFills(b, front[0].a, front[0].bb); + for (let i = 1; i < front.length; i++) { + const f = legalFills(b, front[i].a, front[i].bb); + if (f.length < fills.length) { + chosen = front[i]; + fills = f; + } + } + if (fills.length === 0) { + res.deadEnds.push({ depth: extra.length, edge: chosen.key, fill: [...extra] }); + return; + } + for (const cand of fills) { + res.branches++; + extra.push(cand); + rec(); + extra.pop(); + } + } + rec(); + return res; +} + +// --------------------------------------------------------------------------- +// The one public entry point: compute the whole scene deterministically. +// --------------------------------------------------------------------------- + +export function computeScene(): Scene { + const tiles = unitTiling(LEVELS); + + // Hole center: the tile centroid nearest the fixed anchor. Deterministic. + let center: Pt = ANCHOR; + let best = Infinity; + for (const t of tiles) { + const c = centroid(t); + const d = Math.hypot(c[0] - ANCHOR[0], c[1] - ANCHOR[1]); + if (d < best) { + best = d; + center = c; + } + } + + const { removed, kept } = carve(tiles, center, RHOLE); + const poly = holePolygon(kept, removed); + if (!poly) throw new Error("unsolvableFuture: hole is not a single closed loop"); + + const r = search(kept, poly); + if (!r.firstCompletion) throw new Error("unsolvableFuture: no completion found"); + + const deadEnds: DeadEnd[] = r.deadEnds.map((d) => { + const board = boardFrom([...kept, ...d.fill]); + const [a, bb] = edgeEndpoints(d.edge); + const verdicts = verdictsAt(board, a, bb); + const overlaps = verdicts.filter((v) => v.kind === "overlap").length; + const illegal = verdicts.find((v) => v.kind === "illegal") as + | Extract + | undefined; + const reason = illegal + ? `the only tile that fits this edge closes an illegal vertex (${illegal.reason}), so this edge can never close` + : `every tile that fits this edge overlaps a committed tile (${overlaps}), so this edge can never close`; + return { depth: d.depth, fill: d.fill, doomedEdge: [a, bb], verdicts, reason }; + }); + + return { + meta: { + levels: LEVELS, + anchor: ANCHOR, + rhole: RHOLE, + holeCenter: center, + wallTiles: kept.length, + holeEdges: poly.length, + holeArea: polyArea(poly), + completionTiles: r.firstCompletion.length, + completions: r.completions, + deadEnds: deadEnds.length, + finitenessBound: Math.ceil(polyArea(poly) / SIN36) + 4, + capHit: r.capHit, + nodes: r.nodes, + branches: r.branches, + }, + wall: kept, + hole: poly, + completion: r.firstCompletion, + deadEnds, + }; +} + +// True iff this dead-end's doomed edge has NO legal, non-overlapping candidate: +// every candidate either overlaps or closes a vertex not in the seven-star +// atlas. This is the honesty invariant the test asserts per dead-end. If anyone +// weakens the search into a fake (an edge that actually had a legal fill), this +// returns false and the test fails. +export function isGenuinelyDoomed(d: DeadEnd): boolean { + if (d.verdicts.length === 0) return false; + for (const v of d.verdicts) { + if (v.kind === "overlap") continue; + // An illegal verdict must close a vertex that is NOT a complete star. + const m = v.reason.match(/\[([\d,\s]+)\]/); + if (!m) return false; // not a vertex-closure reason: reject + const angles = m[1].split(",").map((s) => Number(s.trim())); + if (isCompleteStar(angles)) return false; // it WAS a legal star: not doomed + } + return true; +} diff --git a/src/app/x/penrose/explore/PenroseExplorer.tsx b/src/app/x/penrose/explore/PenroseExplorer.tsx new file mode 100644 index 0000000..7dd3589 --- /dev/null +++ b/src/app/x/penrose/explore/PenroseExplorer.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { ChunkCache } from "./lib/chunks"; +import { GAMMA, tileCentroid, tileExists } from "./lib/pentagrid"; +import { buildHitIndex, hitFace, type HitIndex } from "./lib/hitTest"; +import { encodeTile, decodeTile, parseSeed, parseZoom, type TileAddress } from "./lib/codec"; +import type { RenderFace } from "./lib/patch"; + +const DEFAULT_ZOOM = 40; // px per unit edge + +function readCssVar(name: string): string { + if (typeof document === "undefined") return "#000"; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +// Deterministic seed to camera center: a pure hash to a world position. The plane +// is edgeless and homogeneous away from the origin, so any point is a valid +// generic location; the cache generates whatever the viewport lands on. Spreads +// seeds across a wide annulus off the singular sun center at the origin. +function seedToCenter(seed: string): readonly [number, number] { + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + const r = 30 + ((h >>> 0) % 400); + const a = ((h >>> 8) % 360) * (Math.PI / 180); + return [r * Math.cos(a), r * Math.sin(a)]; +} + +export default function PenroseExplorer({ seed = "funclol" }: { seed?: string }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const cacheRef = useRef(null); + const hitRef = useRef(null); + const facesRef = useRef([]); + const colorsRef = useRef<{ thick: string; thin: string; grout: string; ink: string }>({ + thick: "#C89B3C", + thin: "#3E6B7C", + grout: "#0f0e0c", + ink: "#ede9d8", + }); + const offsetRef = useRef<[number, number]>([0, 0]); + const zoomRef = useRef(DEFAULT_ZOOM); + const dprRef = useRef(1); + const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + const dirtyRef = useRef(true); + const rafRef = useRef(null); + const pinnedRef = useRef(null); + + const [hoverAddress, setHoverAddress] = useState(null); + const [pinnedAddress, setPinnedAddress] = useState(null); + const [ready, setReady] = useState(false); + + // The plane is generated per viewport, so there is no whole-plane build to wait + // on. Create the chunk cache, then read the share URL once: a pinned address and + // zoom override the seed-derived center, so a shared link reopens on the exact + // tile. The pin centers on the tile's physical centroid; the render loop then + // generates that tile in view and the hit index resolves it on the first frame. + useEffect(() => { + cacheRef.current = new ChunkCache(GAMMA); + + const params = + typeof window !== "undefined" + ? new URLSearchParams(window.location.search) + : new URLSearchParams(); + const tAddr = decodeTile(params.get("t") ?? undefined); + const z = parseZoom(params.get("z") ?? undefined); + if (z !== null) zoomRef.current = z; + + // decodeTile only range-checks shape, so a hand-edited or stale ?t= can name a + // rhombus the plane never emits. Pin it only when tileExists confirms all four + // corners are accepted vertices; otherwise fall through to the seed center and + // leave nothing pinned, so the HUD never shows a phantom pin over empty space. + if (tAddr && tileExists(tAddr.coord, tAddr.j, tAddr.k)) { + const addr: TileAddress = { coord: tAddr.coord, j: tAddr.j, k: tAddr.k }; + pinnedRef.current = addr; + setPinnedAddress(addr); + const c = tileCentroid(tAddr.coord, tAddr.j, tAddr.k); + offsetRef.current = [c[0], c[1]]; + } else { + const c = seedToCenter(seed); + offsetRef.current = [c[0], c[1]]; + } + // No multi-second build; the cache fills lazily as the viewport asks for cells. + setReady(true); + }, [seed]); + + useEffect(() => { + if (!ready) return; + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const requestRender = () => { + dirtyRef.current = true; + if (rafRef.current === null) rafRef.current = requestAnimationFrame(render); + }; + + // getComputedStyle is a layout read, so cache the three theme colors and refresh + // them only when the theme flips or the element resizes, not on every frame. + const readColors = () => { + colorsRef.current = { + thick: readCssVar("--color-penrose-thick") || "#C89B3C", + thin: readCssVar("--color-penrose-thin") || "#3E6B7C", + grout: readCssVar("--color-paper") || "#0f0e0c", + ink: readCssVar("--color-ink") || "#ede9d8", + }; + }; + + // The hit index is rebuilt lazily from the last rendered faces. render() + // invalidates it; the first hover or click after a frame pays to build it once. + // This also fixes a click landing before the first frame: facesRef is empty, + // so the build is skipped rather than reading a stale or null index. + const getHitIndex = (): HitIndex | null => { + if (!hitRef.current && facesRef.current.length) { + hitRef.current = buildHitIndex(facesRef.current); + } + return hitRef.current; + }; + + // Debounced share-URL writer. Reads the live camera refs and the seed prop, + // then replaces the query string in place. No history entry, no navigation. + let writeTimer: ReturnType | null = null; + const writeUrl = () => { + if (writeTimer) clearTimeout(writeTimer); + writeTimer = setTimeout(() => { + const params = new URLSearchParams(); + const s = parseSeed(seed); + if (s) params.set("s", s); + if (pinnedRef.current) params.set("t", encodeTile(pinnedRef.current)); + params.set("z", String(Math.round(zoomRef.current))); + window.history.replaceState(null, "", `?${params.toString()}`); + }, 250); + }; + + const resize = () => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + dprRef.current = dpr; + sizeRef.current = { w: rect.width, h: rect.height }; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + readColors(); + requestRender(); + }; + + const ro = new ResizeObserver(resize); + ro.observe(container); + readColors(); + resize(); + + const themeObserver = new MutationObserver(() => { + readColors(); + requestRender(); + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + const pointers = new Map(); + let gesture: { midX: number; midY: number; dist: number } | null = null; + + const updateHover = (clientX: number, clientY: number) => { + const rect = canvas.getBoundingClientRect(); + const cx = clientX - rect.left - sizeRef.current.w / 2; + const cy = clientY - rect.top - sizeRef.current.h / 2; + const wx = cx / zoomRef.current + offsetRef.current[0]; + const wy = cy / zoomRef.current + offsetRef.current[1]; + const idx = getHitIndex(); + const f = idx ? hitFace(idx, wx, wy) : null; + setHoverAddress(f ? { coord: f.coord, j: f.j, k: f.k } : null); + }; + + const refreshGesture = () => { + if (pointers.size < 2) { gesture = null; return; } + const pts = [...pointers.values()]; + const midX = (pts[0][0] + pts[1][0]) / 2, midY = (pts[0][1] + pts[1][1]) / 2; + const dist = Math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); + gesture = { midX, midY, dist }; + }; + + const onPointerDown = (e: PointerEvent) => { + canvas.setPointerCapture(e.pointerId); + pointers.set(e.pointerId, [e.clientX, e.clientY]); + refreshGesture(); + }; + + const onPointerMove = (e: PointerEvent) => { + const prev = pointers.get(e.pointerId); + if (prev) { + const dx = e.clientX - prev[0], dy = e.clientY - prev[1]; + pointers.set(e.pointerId, [e.clientX, e.clientY]); + if (pointers.size === 1) { + offsetRef.current[0] -= dx / zoomRef.current; + offsetRef.current[1] -= dy / zoomRef.current; + requestRender(); + writeUrl(); + } else if (pointers.size >= 2 && gesture !== null) { + const pts = [...pointers.values()]; + const midX = (pts[0][0] + pts[1][0]) / 2, midY = (pts[0][1] + pts[1][1]) / 2; + const dist = Math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); + if (dist > 0 && gesture.dist > 0) { + const rect = canvas.getBoundingClientRect(); + const px = midX - rect.left - sizeRef.current.w / 2; + const py = midY - rect.top - sizeRef.current.h / 2; + const worldX = px / zoomRef.current + offsetRef.current[0]; + const worldY = py / zoomRef.current + offsetRef.current[1]; + const newZoom = clamp(zoomRef.current * (dist / gesture.dist), 4, 800); + zoomRef.current = newZoom; + offsetRef.current[0] = worldX - px / newZoom; + offsetRef.current[1] = worldY - py / newZoom; + offsetRef.current[0] -= (midX - gesture.midX) / newZoom; + offsetRef.current[1] -= (midY - gesture.midY) / newZoom; + requestRender(); + writeUrl(); + } + gesture = { midX, midY, dist }; + } + } else { + // Pure hover: no captured pointer for this id, so no button is held. Only here + // do we update the address. A drag or pinch (the if branch) must not run a + // setState per move, which would re-render the HUD on every frame of the gesture. + updateHover(e.clientX, e.clientY); + } + }; + + const onPointerUp = (e: PointerEvent) => { + pointers.delete(e.pointerId); + try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ } + refreshGesture(); + }; + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left - sizeRef.current.w / 2; + const cy = e.clientY - rect.top - sizeRef.current.h / 2; + const worldX = cx / zoomRef.current + offsetRef.current[0]; + const worldY = cy / zoomRef.current + offsetRef.current[1]; + // Zoom speed. macOS trackpad pinch arrives as a wheel event with ctrlKey and + // small, frequent deltas, so it needs a higher factor than a discrete mouse + // wheel notch to feel responsive. Tune these two if it is too fast or too slow. + const factor = e.ctrlKey ? 0.01 : 0.0025; + const newZoom = clamp(zoomRef.current * Math.exp(-e.deltaY * factor), 4, 800); + zoomRef.current = newZoom; + offsetRef.current[0] = worldX - cx / newZoom; + offsetRef.current[1] = worldY - cy / newZoom; + requestRender(); + writeUrl(); + }; + + // Click to pin. The pinned tile becomes the camera origin and the share + // address. A pin fires only for a genuine single-pointer tap: a small + // movement threshold separates a click from a pan, and a press that ever + // saw a second pointer (a pinch) never pins, so a pinch release cannot + // mutate the share URL. onPointerUp (the pan handler) deletes the lifted + // pointer before this runs, so pointers.size === 0 means the last pointer. + let downAt: { x: number; y: number } | null = null; + let wasMultiTouch = false; + const onClickDown = (e: PointerEvent) => { + if (pointers.size > 1) wasMultiTouch = true; + if (!downAt) downAt = { x: e.clientX, y: e.clientY }; + }; + const onClickUp = (e: PointerEvent) => { + const lastPointer = pointers.size === 0; + const start = downAt; + const multi = wasMultiTouch; + if (lastPointer) { downAt = null; wasMultiTouch = false; } + if (!start || !lastPointer || multi) return; + const moved = Math.hypot(e.clientX - start.x, e.clientY - start.y); + if (moved > 6) return; // a drag, not a click + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left - sizeRef.current.w / 2; + const cy = e.clientY - rect.top - sizeRef.current.h / 2; + const wx = cx / zoomRef.current + offsetRef.current[0]; + const wy = cy / zoomRef.current + offsetRef.current[1]; + const idx = getHitIndex(); + const f = idx ? hitFace(idx, wx, wy) : null; + if (!f) return; + const addr: TileAddress = { coord: f.coord, j: f.j, k: f.k }; + pinnedRef.current = addr; + setPinnedAddress(addr); + offsetRef.current = [f.centroid[0], f.centroid[1]]; + requestRender(); + writeUrl(); + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); + canvas.addEventListener("pointerdown", onClickDown); + canvas.addEventListener("pointerup", onClickUp); + canvas.addEventListener("pointercancel", onClickUp); + + function render() { + rafRef.current = null; + if (!dirtyRef.current) return; + dirtyRef.current = false; + const cache = cacheRef.current; + if (!cache) return; + const { w, h } = sizeRef.current; + const dpr = dprRef.current; + const { thick, thin, grout, ink } = colorsRef.current; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx!.fillStyle = grout; + ctx!.fillRect(0, 0, w, h); + + const zoom = zoomRef.current; + const [ox, oy] = offsetRef.current; + const cx = w / 2, cy = h / 2; + const halfW = w / 2 / zoom + 2, halfH = h / 2 / zoom + 2; + const x0 = ox - halfW, x1 = ox + halfW, y0 = oy - halfH, y1 = oy + halfH; + + // Generate exactly the tiles the viewport touches. The cache returns only + // in-view faces, so there is no centroid cull here. Stash the faces and + // invalidate the hit index instead of rebuilding it every frame; the next + // hover or click rebuilds it once via getHitIndex, so a frame that nothing + // interacts with never pays for the index. + const faces = cache.facesInView({ minX: x0, minY: y0, maxX: x1, maxY: y1 }); + facesRef.current = faces; + hitRef.current = null; + + ctx!.lineJoin = "round"; + ctx!.lineWidth = 1; + ctx!.strokeStyle = grout; + for (const f of faces) { + const [a, b, c, d] = f.corners; + ctx!.beginPath(); + ctx!.moveTo((a[0] - ox) * zoom + cx, (a[1] - oy) * zoom + cy); + ctx!.lineTo((b[0] - ox) * zoom + cx, (b[1] - oy) * zoom + cy); + ctx!.lineTo((c[0] - ox) * zoom + cx, (c[1] - oy) * zoom + cy); + ctx!.lineTo((d[0] - ox) * zoom + cx, (d[1] - oy) * zoom + cy); + ctx!.closePath(); + ctx!.fillStyle = f.type === "thick" ? thick : thin; + ctx!.fill(); + ctx!.stroke(); + } + + // Mark the pinned tile. Match by the full tile identity (the engine + // Face.key) so a coord shared by several rhombi still rings the right + // one, and stroke its outline in ink at a heavier width. + const pin = pinnedRef.current; + if (pin) { + const pinKey = `${pin.coord.join(",")}|${pin.j}${pin.k}`; + const f = faces.find((face) => face.key === pinKey); + if (f) { + const [a, b, c, d] = f.corners; + ctx!.beginPath(); + ctx!.moveTo((a[0] - ox) * zoom + cx, (a[1] - oy) * zoom + cy); + ctx!.lineTo((b[0] - ox) * zoom + cx, (b[1] - oy) * zoom + cy); + ctx!.lineTo((c[0] - ox) * zoom + cx, (c[1] - oy) * zoom + cy); + ctx!.lineTo((d[0] - ox) * zoom + cx, (d[1] - oy) * zoom + cy); + ctx!.closePath(); + ctx!.strokeStyle = ink; + ctx!.lineWidth = 2; + ctx!.stroke(); + } + } + } + + requestRender(); + + return () => { + ro.disconnect(); + themeObserver.disconnect(); + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerUp); + canvas.removeEventListener("wheel", onWheel); + canvas.removeEventListener("pointerdown", onClickDown); + canvas.removeEventListener("pointerup", onClickUp); + canvas.removeEventListener("pointercancel", onClickUp); + if (writeTimer) clearTimeout(writeTimer); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- writeUrl reads `seed`, but seed is a stable prop in v1 (a seed change recreates the cache in the other effect, which flips `ready` and rewires this one). Keep the deps as [ready]. + }, [ready]); + + return ( +
+ +
+
+
seed  {seed}
+ {hoverAddress && ( +
+ address [{hoverAddress.coord.join(",")}] j{hoverAddress.j} k{hoverAddress.k} +
+ )} + {pinnedAddress && ( +
+ pinned [{pinnedAddress.coord.join(",")}] j{pinnedAddress.j} k{pinnedAddress.k} +
+ )} +
+
+
+ ); +} + +function clamp(x: number, lo: number, hi: number): number { + return Math.min(Math.max(x, lo), hi); +} diff --git a/src/app/x/penrose/explore/lib/bridge.test.ts b/src/app/x/penrose/explore/lib/bridge.test.ts new file mode 100644 index 0000000..502d631 --- /dev/null +++ b/src/app/x/penrose/explore/lib/bridge.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { lift } from "./bridge"; + +const PHI = (1 + Math.sqrt(5)) / 2; +const L = lift(6); + +describe("the substitution tiling lifts to ℤ⁵ as a cut-and-project tiling", () => { + test("every unit edge lies on a ζ^l direction", () => { + expect(L.verts.length).toBeGreaterThan(500); + expect(L.badEdges).toBe(0); + }); + + test("the ℤ⁵ lift is path-independent: every rhombus closes", () => { + expect(L.inconsistencies).toBe(0); + expect(L.unassigned).toBe(0); + }); + + test("indices obey the de Bruijn index theorem (exactly 4 consecutive values)", () => { + expect(L.indices.length).toBe(4); + expect(L.indices[3] - L.indices[0]).toBe(3); + }); + + test("internal projections are bounded (cut-and-project, not a periodic grid)", () => { + expect(L.maxInternal).toBeLessThan(PHI + 0.1); + }); + + test("the window is four pentagons with the 1:φ:φ:1 size ratio", () => { + const radii = L.indices.map((i) => L.internalByIndex.get(i)!).sort((a, b) => a - b); + expect(radii.length).toBe(4); + const small = (radii[0] + radii[1]) / 2; + const large = (radii[2] + radii[3]) / 2; + // the two large pentagons are τ× the two small ones + expect(large / small).toBeGreaterThan(1.5); + expect(large / small).toBeLessThan(1.75); + }); +}); diff --git a/src/app/x/penrose/explore/lib/bridge.ts b/src/app/x/penrose/explore/lib/bridge.ts new file mode 100644 index 0000000..b60df11 --- /dev/null +++ b/src/app/x/penrose/explore/lib/bridge.ts @@ -0,0 +1,113 @@ +// The bridge: lift a substitution tiling to ℤ⁵ and emit de Bruijn coordinates. +// +// Build the tiling by deflation (substitution), then integrate its edges: each +// unit edge points along some ζ^l, so walking it adds ±e_l to the ℤ⁵ coordinate. +// Starting from one vertex this assigns a ℤ⁵ point to every vertex. The lift is +// path-independent (rhombi close), the indices obey the de Bruijn index theorem +// (4 consecutive values), and the internal projections land in the cut-and-project +// window — so the substitution tiling IS a cut-and-project tiling, and the lift is +// the substitution-address → de-Bruijn-coordinate map. + +import { deflate, PHI, type Pt } from "./deflate"; + +const Z = [0, 1, 2, 3, 4].map((l) => [Math.cos((2 * Math.PI * l) / 5), Math.sin((2 * Math.PI * l) / 5)] as const); +const ZINT = [0, 1, 2, 3, 4].map((l) => [Math.cos((4 * Math.PI * l) / 5), Math.sin((4 * Math.PI * l) / 5)] as const); + +export type LiftedVertex = { pos: Pt; coord: readonly number[] }; +export type Lift = { + verts: LiftedVertex[]; + badEdges: number; // unit edges not lying on a ζ^l direction + inconsistencies: number; // loop-closure failures during integration + unassigned: number; // vertices the BFS could not reach + indices: number[]; // sorted distinct index (Σ coord) values + maxInternal: number; // max |π'(coord)| + internalByIndex: Map; // index → max |π'| (the window pentagons) +}; + +function rotate(p: Pt, deg: number): Pt { + const a = (deg * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a); + return [p[0] * c - p[1] * s, p[0] * s + p[1] * c]; +} + +export function lift(levels: number): Lift { + const scale = PHI ** levels; // deflated legs → unit length + const tris = deflate(levels, 1); + + // vertices (deduped) + unit edges (the triangle legs) + const vmap = new Map(); + const pos: Pt[] = []; + const vid = (p: Pt): number => { + const x = p[0] * scale, y = p[1] * scale; + const k = `${x.toFixed(4)},${y.toFixed(4)}`; + let id = vmap.get(k); + if (id === undefined) { id = pos.length; pos.push([x, y]); vmap.set(k, id); } + return id; + }; + const eseen = new Set(); + const edges: [number, number][] = []; + for (const t of tris) { + for (const [u, w] of [[t.a, t.b], [t.a, t.c]] as const) { + const a = vid(u), b = vid(w); + const k = a < b ? `${a},${b}` : `${b},${a}`; + if (!eseen.has(k)) { eseen.add(k); edges.push([a, b]); } + } + } + + // Rotate so edge directions align with ζ^l (edges sit at 18° + k·36°). + const offs = edges.slice(0, 200).map(([a, b]) => { + const ang = (Math.atan2(pos[b][1] - pos[a][1], pos[b][0] - pos[a][0]) * 180) / Math.PI; + return ((ang % 36) + 36) % 36; + }).sort((a, b) => a - b); + const offset = offs[Math.floor(offs.length / 2)]; + const V = pos.map((p) => rotate(p, -offset)); + + // Edge directions (l, sign): V[b] − V[a] ≈ sign · ζ^l. + const adj: { to: number; l: number; sign: number }[][] = V.map(() => []); + let badEdges = 0; + for (const [a, b] of edges) { + const dx = V[b][0] - V[a][0], dy = V[b][1] - V[a][1]; + let found: { l: number; sign: number } | null = null; + for (let l = 0; l < 5 && !found; l++) { + if (Math.hypot(dx - Z[l][0], dy - Z[l][1]) < 0.05) found = { l, sign: 1 }; + else if (Math.hypot(dx + Z[l][0], dy + Z[l][1]) < 0.05) found = { l, sign: -1 }; + } + if (!found) { badEdges++; continue; } + adj[a].push({ to: b, l: found.l, sign: found.sign }); + adj[b].push({ to: a, l: found.l, sign: -found.sign }); + } + + // BFS edge-integration from the most central vertex. + let start = 0, best = Infinity; + V.forEach((p, i) => { const r = Math.hypot(p[0], p[1]); if (r < best) { best = r; start = i; } }); + const coord: (number[] | null)[] = V.map(() => null); + coord[start] = [0, 0, 0, 0, 0]; + const queue = [start]; + let inconsistencies = 0; + while (queue.length) { + const u = queue.shift()!; + const cu = coord[u]!; + for (const { to, l, sign } of adj[u]) { + const cn = [...cu]; cn[l] += sign; + if (coord[to] === null) { coord[to] = cn; queue.push(to); } + else if (coord[to]!.some((v, i) => v !== cn[i])) inconsistencies++; + } + } + + const verts: LiftedVertex[] = []; + const idxSet = new Set(); + const internalByIndex = new Map(); + let maxInternal = 0, unassigned = 0; + for (let i = 0; i < V.length; i++) { + const c = coord[i]; + if (!c) { unassigned++; continue; } + verts.push({ pos: pos[i], coord: c }); + const idx = c.reduce((s, v) => s + v, 0); + idxSet.add(idx); + let ix = 0, iy = 0; + for (let l = 0; l < 5; l++) { ix += c[l] * ZINT[l][0]; iy += c[l] * ZINT[l][1]; } + const r = Math.hypot(ix, iy); + maxInternal = Math.max(maxInternal, r); + internalByIndex.set(idx, Math.max(internalByIndex.get(idx) ?? 0, r)); + } + return { verts, badEdges, inconsistencies, unassigned, indices: [...idxSet].sort((a, b) => a - b), maxInternal, internalByIndex }; +} diff --git a/src/app/x/penrose/explore/lib/cap.test.ts b/src/app/x/penrose/explore/lib/cap.test.ts new file mode 100644 index 0000000..e378fd3 --- /dev/null +++ b/src/app/x/penrose/explore/lib/cap.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; + +import { + TAU, + physical, + internal, + index, + inWindow, + A, + generate, + unitEdges, + rhombi, + PCOS, + PSIN, + type Vec5, +} from "./cap"; + +// A patch with a generic offset (avoids the singular symmetric center). +const VX = 0.137, VY = -0.081; +const patch = generate(6, VX, VY); + +describe("inflation matrix A is exactly the φ-inflation", () => { + const samples: Vec5[] = [ + [1, 0, 0, 0, 0], + [0, 1, 1, 0, 0], + [2, -1, 0, 3, -1], + [1, 1, 1, 1, 0], + [-2, 1, 0, 1, 1], + ]; + for (const n of samples) { + test(`physical ×(−φ), internal ×(1/φ), index ×2 for [${n}]`, () => { + const [px, py] = physical(n); + const [ax, ay] = physical(A(n)); + expect(ax).toBeCloseTo(-TAU * px, 9); + expect(ay).toBeCloseTo(-TAU * py, 9); + + const [ix, iy] = internal(n); + const [bx, by] = internal(A(n)); + expect(bx).toBeCloseTo(ix / TAU, 9); + expect(by).toBeCloseTo(iy / TAU, 9); + + expect(index(A(n))).toBe(2 * index(n)); + }); + } +}); + +describe("the window generates a correct Penrose tiling", () => { + test("produces a real patch, every vertex index ∈ {1,2,3,4}", () => { + expect(patch.length).toBeGreaterThan(100); + for (const v of patch) { + const i = index(v.n); + expect(i).toBeGreaterThanOrEqual(1); + expect(i).toBeLessThanOrEqual(4); + expect(inWindow(v.n, VX, VY)).toBe(true); + } + }); + + test("every unit edge lies on one of the five ζ^l directions", () => { + const dirs = [0, 1, 2, 3, 4].map((l) => ((Math.atan2(PSIN[l], PCOS[l]) * 180) / Math.PI + 180) % 180); + const edges = unitEdges(patch); + expect(edges.length).toBeGreaterThan(150); + let off = 0; + for (const [a, b] of edges) { + const ang = ((Math.atan2(patch[b].p[1] - patch[a].p[1], patch[b].p[0] - patch[a].p[0]) * 180) / Math.PI + 180) % 180; + const onDir = dirs.some((d) => Math.min(Math.abs(ang - d), 180 - Math.abs(ang - d)) < 0.5); + if (!onDir) off++; + } + expect(off).toBe(0); + }); + + test("internal projections are bounded (cut-and-project, not a periodic grid)", () => { + let maxR = 0; + for (const v of patch) { + const [ix, iy] = internal(v.n); + maxR = Math.max(maxR, Math.hypot(ix - VX, iy - VY)); + } + // bounded by the large pentagon (circumradius τ); never grows with the patch + expect(maxR).toBeLessThan(TAU + 1e-6); + }); + + test("vertex count scales with area (a genuine, non-degenerate 2D tiling)", () => { + const small = generate(4, VX, VY).length; + const big = generate(6, VX, VY).length; + // area grows like radius²: (6/4)² = 2.25; generous bounds for finite-patch noise + expect(big / small).toBeGreaterThan(1.7); + expect(big / small).toBeLessThan(2.9); + }); + + // thick:thin → φ is proven exact in faces.test.ts: the corner-acceptance face + // condition matches the substitution tile-for-tile (no phantoms) on a large patch. +}); diff --git a/src/app/x/penrose/explore/lib/cap.ts b/src/app/x/penrose/explore/lib/cap.ts new file mode 100644 index 0000000..7f19cff --- /dev/null +++ b/src/app/x/penrose/explore/lib/cap.ts @@ -0,0 +1,117 @@ +// Cut-and-project Penrose engine (research prototype). +// +// A Penrose vertex is a point n ∈ ℤ⁵ whose internal projection lands in the +// acceptance window for its index. Physical position = projection to the plane. +// Window and inflation operator are the exact ones from Cotfas (math-ph/0403062, +// 0710.3845); see research/penrose/07-cut-and-project-window.md. + +export const TAU = (1 + Math.sqrt(5)) / 2; + +const L = [0, 1, 2, 3, 4] as const; +// ζ^l physical direction, ζ^{2l} internal direction +export const PCOS = L.map((l) => Math.cos((2 * Math.PI * l) / 5)); +export const PSIN = L.map((l) => Math.sin((2 * Math.PI * l) / 5)); +export const ICOS = L.map((l) => Math.cos((4 * Math.PI * l) / 5)); +export const ISIN = L.map((l) => Math.sin((4 * Math.PI * l) / 5)); + +export type Vec5 = readonly [number, number, number, number, number]; +export type Pt = readonly [number, number]; + +export function physical(n: Vec5): Pt { + let x = 0, y = 0; + for (let l = 0; l < 5; l++) { x += n[l] * PCOS[l]; y += n[l] * PSIN[l]; } + return [x, y]; +} +export function internal(n: Vec5): Pt { + let x = 0, y = 0; + for (let l = 0; l < 5; l++) { x += n[l] * ICOS[l]; y += n[l] * ISIN[l]; } + return [x, y]; +} +export function index(n: Vec5): number { + return n[0] + n[1] + n[2] + n[3] + n[4]; +} + +// Membership in the unit regular pentagon P (circumradius 1, a vertex at angle 0). +const APOTHEM = Math.cos(Math.PI / 5); +const NORMALS = L.map((k) => [ + Math.cos(((36 + 72 * k) * Math.PI) / 180), + Math.sin(((36 + 72 * k) * Math.PI) / 180), +] as const); +export function inPentagon(x: number, y: number, eps = 1e-9): boolean { + for (const [dx, dy] of NORMALS) if (x * dx + y * dy > APOTHEM + eps) return false; + return true; +} + +// The four windows by index: K1 = v+P, K2 = v−τP, K3 = v+τP, K4 = v−P. +const SCALE_BY_INDEX = [0, 1, -TAU, TAU, -1]; +export function inWindow(n: Vec5, vx = 0, vy = 0): boolean { + const idx = index(n); + if (idx < 1 || idx > 4) return false; + const [ix, iy] = internal(n); + const s = SCALE_BY_INDEX[idx]; + return inPentagon((ix - vx) / s, (iy - vy) / s); +} + +export type Vertex = { n: Vec5; p: Pt }; + +// All tiling vertices whose physical position lies within `radius` of the origin. +export function generate(radius: number, vx = 0, vy = 0): Vertex[] { + const N = Math.ceil(radius) + 2; + const out: Vertex[] = []; + const n = [0, 0, 0, 0, 0]; + for (n[0] = -N; n[0] <= N; n[0]++) + for (n[1] = -N; n[1] <= N; n[1]++) + for (n[2] = -N; n[2] <= N; n[2]++) + for (n[3] = -N; n[3] <= N; n[3]++) + for (n[4] = -N; n[4] <= N; n[4]++) { + const v = n as unknown as Vec5; + if (!inWindow(v, vx, vy)) continue; + const p = physical(v); + if (Math.hypot(p[0], p[1]) > radius) continue; + out.push({ n: [n[0], n[1], n[2], n[3], n[4]], p }); + } + return out; +} + +// The φ-inflation: integer circulant A with first row (0,0,1,1,0). +const ROW0 = [0, 0, 1, 1, 0]; +export function A(n: Vec5): Vec5 { + const o = [0, 0, 0, 0, 0]; + for (let i = 0; i < 5; i++) for (let j = 0; j < 5; j++) o[i] += ROW0[(j - i + 5) % 5] * n[j]; + return o as unknown as Vec5; +} + +export const key = (n: Vec5): string => n.join(","); + +// Unit-length edges (index pairs into `verts`). +export function unitEdges(verts: Vertex[]): [number, number][] { + const out: [number, number][] = []; + for (let a = 0; a < verts.length; a++) + for (let b = a + 1; b < verts.length; b++) { + const d = Math.hypot(verts[b].p[0] - verts[a].p[0], verts[b].p[1] - verts[a].p[1]); + if (Math.abs(d - 1) < 1e-6) out.push([a, b]); + } + return out; +} + +// Rhombus faces, classified thick (edges 72° apart) / thin (144° apart). A rhombus +// has corners n, n+e_j, n+e_k, n+e_j+e_k for two families j key(v.n))); + const bump = (n: Vec5, l: number, d: number): Vec5 => { + const c = [...n] as number[]; c[l] += d; return c as unknown as Vec5; + }; + let thick = 0, thin = 0; + for (const v of verts) { + for (let j = 0; j < 5; j++) + for (let k = j + 1; k < 5; k++) { + const ej = bump(v.n, j, 1), ek = bump(v.n, k, 1), ejk = bump(ej, k, 1); + if (set.has(key(ej)) && set.has(key(ek)) && set.has(key(ejk))) { + const diff = k - j; + if (diff === 1 || diff === 4) thick++; + else thin++; + } + } + } + return { thick, thin }; +} diff --git a/src/app/x/penrose/explore/lib/chunks.test.ts b/src/app/x/penrose/explore/lib/chunks.test.ts new file mode 100644 index 0000000..d7b582f --- /dev/null +++ b/src/app/x/penrose/explore/lib/chunks.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; + +import { facesInViewport, GAMMA, type Rect } from "./pentagrid"; +import { ChunkCache, CELL } from "./chunks"; + +const keys = (faces: { key: string }[]) => new Set(faces.map((f) => f.key)); + +describe("chunk cache reconstructs a region seam-free", () => { + // The cache, queried over a region, must return exactly the tiles whose centroid is + // in that region (matching a single facesInViewport call restricted by centroid). + for (const at of [{ x: 0, y: 0 }, { x: 40, y: 40 }, { x: -45, y: 12 }]) { + test(`region at (${at.x},${at.y}) near and far from origin`, () => { + const view: Rect = { minX: at.x - 12, minY: at.y - 12, maxX: at.x + 12, maxY: at.y + 12 }; + const cache = new ChunkCache(GAMMA); + const fromCache = cache.facesInView(view); + // ground truth: one enumeration, restricted to centroids strictly inside the view + const inView = (c: readonly [number, number]) => + c[0] >= view.minX && c[0] < view.maxX && c[1] >= view.minY && c[1] < view.maxY; + const truth = facesInViewport(view, GAMMA).filter((f) => inView(f.centroid)); + const cacheInView = fromCache.filter((f) => inView(f.centroid)); + // every tile whose centroid is in the view is present exactly once, no extras + expect(keys(cacheInView)).toEqual(keys(truth)); + expect(cacheInView.length).toBe(truth.length); // no duplicates + }); + } +}); + +describe("determinism and eviction", () => { + test("two caches over the same view return identical key sets", () => { + const view: Rect = { minX: 20, minY: -5, maxX: 32, maxY: 7 }; + const a = new ChunkCache(GAMMA).facesInView(view); + const b = new ChunkCache(GAMMA).facesInView(view); + expect(keys(a)).toEqual(keys(b)); + }); + + // CELL=8, KEEP_RING=1, EVICT_MARGIN=4: a 10-unit view spans 2-3 cells, kept out to + // EVICT_MARGIN. The cache holds at most a few dozen cells, far less than the cell + // count of a region 200 units away, so querying B must drop every A cell. + const cellsIn = (view: Rect) => { + const x0 = Math.floor(view.minX / CELL) - 1, x1 = Math.floor(view.maxX / CELL) + 1; + const y0 = Math.floor(view.minY / CELL) - 1, y1 = Math.floor(view.maxY / CELL) + 1; + return { x0, x1, y0, y1 }; + }; + + test("querying a far region evicts the first, and the cache stays bounded", () => { + const cache = new ChunkCache(GAMMA); + const a: Rect = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + const b: Rect = { minX: 200, minY: 200, maxX: 210, maxY: 210 }; + cache.facesInView(a); + cache.facesInView(b); + // A's cells are gone: nothing within A's generated cell range is still cached. + const ar = cellsIn(a); + for (let cx = ar.x0; cx <= ar.x1; cx++) + for (let cy = ar.y0; cy <= ar.y1; cy++) + expect(cache["cells"].has(`${cx},${cy}`)).toBe(false); + // The cache is bounded to roughly the B viewport plus the evict margin. + const span = (b.maxX - b.minX) / CELL + 2 * (1 + 4) + 2; + expect(cache.size).toBeLessThanOrEqual(Math.ceil(span) ** 2); + }); + + test("re-querying an evicted region regenerates it key-for-key", () => { + const cache = new ChunkCache(GAMMA); + const a: Rect = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + const far: Rect = { minX: 200, minY: 200, maxX: 210, maxY: 210 }; + const first = keys(cache.facesInView(a)); + cache.facesInView(far); // evicts A + const again = keys(cache.facesInView(a)); + expect(again).toEqual(first); // regeneration is lossless + }); + + test("every cell strictly inside the view survives the eviction sweep", () => { + const cache = new ChunkCache(GAMMA); + const view: Rect = { minX: -30, minY: -30, maxX: 30, maxY: 30 }; + cache.facesInView(view); + // A cell (cx,cy) is strictly inside the view when its [min,max) square lies wholly + // within the view. Those cells are drawn, so they must remain cached afterward. + const cx0 = Math.ceil(view.minX / CELL), cx1 = Math.floor(view.maxX / CELL) - 1; + const cy0 = Math.ceil(view.minY / CELL), cy1 = Math.floor(view.maxY / CELL) - 1; + for (let cx = cx0; cx <= cx1; cx++) + for (let cy = cy0; cy <= cy1; cy++) + expect(cache["cells"].has(`${cx},${cy}`)).toBe(true); + }); +}); diff --git a/src/app/x/penrose/explore/lib/chunks.ts b/src/app/x/penrose/explore/lib/chunks.ts new file mode 100644 index 0000000..d1cd0d5 --- /dev/null +++ b/src/app/x/penrose/explore/lib/chunks.ts @@ -0,0 +1,81 @@ +// Physical-space chunk cache over the pentagrid enumerator. Cells are squares of side +// CELL in the physical (render) frame. A cell owns tiles whose physical(K) centroid is +// in its half-open [min,max) bounds, so the union over cells is seam-free (each tile in +// exactly one cell). Cells are generated on demand and evicted once they fall outside the +// viewport plus a margin, so the cache stays bounded to what the view can reach. + +import { facesInViewport, type Rect } from "./pentagrid"; +import type { RenderFace } from "./patch"; + +export const CELL = 8; +const KEEP_RING = 1; // generate one ring of cells beyond the viewport +// Derived so the evict band sits strictly outside the keep band by construction. +const EVICT_MARGIN = KEEP_RING + 3; + +const cellKey = (cx: number, cy: number) => `${cx},${cy}`; + +export class ChunkCache { + private cells = new Map(); + // Memo of the last visible cell window and its assembled faces. An unchanged + // integer window returns the cached array and skips both re-concat and eviction. + private lastWindow: [number, number, number, number] | null = null; + private lastFaces: RenderFace[] = []; + + constructor(private gamma: readonly number[]) {} + + get size(): number { + return this.cells.size; + } + + private cellFaces(cx: number, cy: number): RenderFace[] { + const key = cellKey(cx, cy); + const hit = this.cells.get(key); + if (hit) return hit; + // Generate the cell: enumerate over the cell's physical bounds, then keep tiles whose + // centroid is in this cell's half-open bounds. facesInViewport already grows the search + // region by its grid + physical margins, so every tile touching the cell is enumerated. + const minX = cx * CELL, + minY = cy * CELL, + maxX = minX + CELL, + maxY = minY + CELL; + const faces = facesInViewport({ minX, minY, maxX, maxY }, this.gamma).filter( + (f) => + f.centroid[0] >= minX && + f.centroid[0] < maxX && + f.centroid[1] >= minY && + f.centroid[1] < maxY, + ); + this.cells.set(key, faces); + return faces; + } + + facesInView(view: Rect): RenderFace[] { + const cx0 = Math.floor(view.minX / CELL) - KEEP_RING; + const cx1 = Math.floor(view.maxX / CELL) + KEEP_RING; + const cy0 = Math.floor(view.minY / CELL) - KEEP_RING; + const cy1 = Math.floor(view.maxY / CELL) + KEEP_RING; + // Unchanged window: nothing to re-concat and nothing to evict. Return the cached array. + const last = this.lastWindow; + if (last && last[0] === cx0 && last[1] === cx1 && last[2] === cy0 && last[3] === cy1) { + return this.lastFaces; + } + const out: RenderFace[] = []; + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) out.push(...this.cellFaces(cx, cy)); + } + // Evict everything past the viewport plus EVICT_MARGIN. The cache is bounded to the + // visible window plus a ring, and no visible cell is ever evicted because EVICT_MARGIN + // exceeds KEEP_RING. The min zoom on a large display no longer thrashes a fixed cap. + const keepX0 = cx0 - EVICT_MARGIN, keepX1 = cx1 + EVICT_MARGIN; + const keepY0 = cy0 - EVICT_MARGIN, keepY1 = cy1 + EVICT_MARGIN; + for (const key of this.cells.keys()) { + const comma = key.indexOf(","); + const cx = Number(key.slice(0, comma)); + const cy = Number(key.slice(comma + 1)); + if (cx < keepX0 || cx > keepX1 || cy < keepY0 || cy > keepY1) this.cells.delete(key); + } + this.lastWindow = [cx0, cx1, cy0, cy1]; + this.lastFaces = out; + return out; + } +} diff --git a/src/app/x/penrose/explore/lib/codec.test.ts b/src/app/x/penrose/explore/lib/codec.test.ts new file mode 100644 index 0000000..371cceb --- /dev/null +++ b/src/app/x/penrose/explore/lib/codec.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeTile, decodeTile, type TileAddress, parseSeed, parseZoom } from "./codec"; + +describe("tile codec round-trips the full [n; j, k] address", () => { + const cases: TileAddress[] = [ + { coord: [0, 0, 0, 0, 0], j: 0, k: 1 }, + { coord: [3, -1, 0, 2, -4], j: 1, k: 4 }, + { coord: [10, 11, -12, 13, -14], j: 2, k: 3 }, + { coord: [-1, -1, -1, -1, -1], j: 3, k: 4 }, + { coord: [100000, -100000, 0, 7, -7], j: 0, k: 4 }, + ]; + for (const t of cases) { + test(`round-trips ${encodeTile(t)}`, () => { + expect(decodeTile(encodeTile(t))).toEqual(t); + }); + } +}); + +describe("decodeTile rejects bad input", () => { + const bad: (string | string[] | undefined)[] = [ + undefined, + ["0.0.0.0.0.0.1"], // array, not a string + "", // empty + "0.0.0.0.0.0", // too few (6 parts) + "0.0.0.0.0.0.1.2", // too many (8 parts) + "1.2.3.4.5", // old 5-int form, too few + "1.2.x.4.5.0.1", // non-integer coord + "1.2.3.4.5.1.", // empty trailing part + "1.2.3.4.5..1", // empty interior part + "999999.0.0.0.0.0.1", // coord out of range (|n| > 100000) + "0.0.0.0.0.2.1", // bad axes: j >= k + "0.0.0.0.0.1.1", // bad axes: j == k + "0.0.0.0.0.0.5", // bad axes: k > 4 + "0.0.0.0.0.-1.2", // bad axes: negative j + ]; + for (const raw of bad) { + test(`rejects ${JSON.stringify(raw)}`, () => { + expect(decodeTile(raw)).toBeNull(); + }); + } +}); + +describe("parseSeed", () => { + test("accepts a short alnum seed", () => { + expect(parseSeed("funclol")).toBe("funclol"); + expect(parseSeed("a_b-9")).toBe("a_b-9"); + }); + test("rejects empty, array, too long, or illegal chars", () => { + expect(parseSeed("")).toBeNull(); + expect(parseSeed(["x"])).toBeNull(); + expect(parseSeed("a".repeat(33))).toBeNull(); + expect(parseSeed("has space")).toBeNull(); + }); +}); + +describe("parseZoom", () => { + test("accepts and clamps", () => { + expect(parseZoom("40")).toBe(40); + expect(parseZoom("1")).toBe(4); // clamped up + expect(parseZoom("9999")).toBe(800); // clamped down + }); + test("rejects non-numbers", () => { + expect(parseZoom("abc")).toBeNull(); + expect(parseZoom(undefined)).toBeNull(); + expect(parseZoom(["40"])).toBeNull(); + }); +}); diff --git a/src/app/x/penrose/explore/lib/codec.ts b/src/app/x/penrose/explore/lib/codec.ts new file mode 100644 index 0000000..1b4fd8c --- /dev/null +++ b/src/app/x/penrose/explore/lib/codec.ts @@ -0,0 +1,41 @@ +// URL serialization for the explorer's share link. A tile is the rhombus +// [n; j, k]: its base-corner ℤ⁵ coordinate (five small signed integers) plus the +// two varying axes. Encoding the corner alone is ambiguous because many rhombi +// share an n, so the address is all seven integers. The camera adds seed and +// zoom. Every parser returns null on bad input. The caller treats null as +// "ignore this param, use the default," never an error. Decimal encoding keeps +// signs trivial and is the v2 seam (widen here when coords become BigInt). + +export type TileAddress = { coord: readonly number[]; j: number; k: number }; + +export function encodeTile(t: TileAddress): string { + return [...t.coord, t.j, t.k].join("."); +} + +export function decodeTile( + raw: string | string[] | undefined, +): TileAddress | null { + if (typeof raw !== "string") return null; + const parts = raw.split("."); + if (parts.length !== 7) return null; + if (parts.some((s) => s.trim() === "")) return null; + const nums = parts.map((s) => Number(s)); + if (nums.some((n) => !Number.isInteger(n))) return null; + const coord = nums.slice(0, 5); + if (coord.some((n) => Math.abs(n) > 100000)) return null; + const j = nums[5], k = nums[6]; + if (!(j >= 0 && j < k && k <= 4)) return null; + return { coord, j, k }; +} + +export function parseSeed(raw: string | string[] | undefined): string | null { + if (typeof raw !== "string") return null; + return /^[A-Za-z0-9_-]{1,32}$/.test(raw) ? raw : null; +} + +export function parseZoom(raw: string | string[] | undefined): number | null { + if (typeof raw !== "string") return null; + const z = Number(raw); + if (!Number.isFinite(z)) return null; + return Math.min(Math.max(z, 4), 800); +} diff --git a/src/app/x/penrose/explore/lib/deflate.test.ts b/src/app/x/penrose/explore/lib/deflate.test.ts new file mode 100644 index 0000000..d0bb2d3 --- /dev/null +++ b/src/app/x/penrose/explore/lib/deflate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { + PHI, + deflate, + subdivide, + wheel, + colorCounts, + legLength, + otherLeg, +} from "./deflate"; + +describe("deflation produces a valid Penrose tiling at every level", () => { + // [level, decimal precision] — the color ratio converges to φ. + const ratioCases: [number, number][] = [[5, 2], [6, 2], [7, 3], [8, 3]]; + for (const [level, prec] of ratioCases) { + test(`thick:thin (color) ratio → φ at level ${level}`, () => { + const { c0, c1 } = colorCounts(deflate(level)); + expect(c0).toBeGreaterThan(0); + expect(c1 / c0).toBeCloseTo(PHI, prec); + }); + } + + test("every triangle stays a valid (isoceles) Robinson triangle", () => { + for (const t of deflate(7)) { + expect(legLength(t)).toBeCloseTo(otherLeg(t), 9); + } + }); + + test("each level contracts tiles by exactly 1/φ (so inflation ×φ inverts it)", () => { + let t = wheel(1); + let prevLeg = legLength(t[0]); + expect(prevLeg).toBeCloseTo(1, 9); + for (let n = 1; n <= 8; n++) { + t = subdivide(t); + const leg = legLength(t[0]); + expect(prevLeg / leg).toBeCloseTo(PHI, 6); + prevLeg = leg; + } + }); + + test("tile count grows by exactly φ² per level (the substitution eigenvalue)", () => { + let t = wheel(1); + let prev = t.length; + for (let n = 1; n <= 8; n++) { + t = subdivide(t); + if (n >= 4) expect(t.length / prev).toBeCloseTo(PHI * PHI, 1); + prev = t.length; + } + }); + + test("deflation is deterministic (same input → byte-identical output)", () => { + const a = deflate(6), b = deflate(6); + expect(a.length).toBe(b.length); + for (let i = 0; i < a.length; i++) { + expect(a[i].color).toBe(b[i].color); + expect(a[i].a[0]).toBe(b[i].a[0]); + expect(a[i].a[1]).toBe(b[i].a[1]); + } + }); +}); diff --git a/src/app/x/penrose/explore/lib/deflate.ts b/src/app/x/penrose/explore/lib/deflate.ts new file mode 100644 index 0000000..0af72c6 --- /dev/null +++ b/src/app/x/penrose/explore/lib/deflate.ts @@ -0,0 +1,63 @@ +// Deflation (the Penrose substitution) on Robinson triangles. Two triangles glue +// into a P3 rhombus; subdividing every triangle by the golden ratio refines the +// tiling one level. The unique supertile grouping makes this deterministic and +// exactly invertible (composition = inflation), so deflation is reliable at any +// depth. Preshing's formulation. + +export const PHI = (1 + Math.sqrt(5)) / 2; + +export type Pt = readonly [number, number]; +// color 0 = acute (golden) triangle, color 1 = obtuse (gnomon). Apex is `a`, +// the two equal legs are a-b and a-c. +export type Tri = { color: 0 | 1; a: Pt; b: Pt; c: Pt }; + +const lerpPhi = (p: Pt, q: Pt): Pt => [p[0] + (q[0] - p[0]) / PHI, p[1] + (q[1] - p[1]) / PHI]; +const dist = (p: Pt, q: Pt): number => Math.hypot(p[0] - q[0], p[1] - q[1]); + +export function subdivide(tris: readonly Tri[]): Tri[] { + const out: Tri[] = []; + for (const { color, a, b, c } of tris) { + if (color === 0) { + const p = lerpPhi(a, b); + out.push({ color: 0, a: c, b: p, c: b }, { color: 1, a: p, b: c, c: a }); + } else { + const q = lerpPhi(b, a); + const r = lerpPhi(b, c); + out.push({ color: 1, a: r, b: c, c: a }, { color: 1, a: q, b: r, c: b }, { color: 0, a: r, b: q, c: a }); + } + } + return out; +} + +// Wheel of 10 acute triangles around the origin (legs of length `radius`). +export function wheel(radius = 1): Tri[] { + const t: Tri[] = []; + for (let i = 0; i < 10; i++) { + let b: Pt = [radius * Math.cos(((2 * i - 1) * Math.PI) / 10), radius * Math.sin(((2 * i - 1) * Math.PI) / 10)]; + let c: Pt = [radius * Math.cos(((2 * i + 1) * Math.PI) / 10), radius * Math.sin(((2 * i + 1) * Math.PI) / 10)]; + if (i % 2 === 0) [b, c] = [c, b]; + t.push({ color: 0, a: [0, 0], b, c }); + } + return t; +} + +// Deflate `levels` times from a starting wheel. +export function deflate(levels: number, radius = 1): Tri[] { + let t: Tri[] = wheel(radius); + for (let n = 0; n < levels; n++) t = subdivide(t); + return t; +} + +export function colorCounts(tris: readonly Tri[]): { c0: number; c1: number } { + let c0 = 0, c1 = 0; + for (const t of tris) (t.color === 0 ? c0++ : c1++); + return { c0, c1 }; +} + +// The leg length (a-b); for a Robinson triangle the two legs are equal. +export const legLength = (t: Tri): number => dist(t.a, t.b); +export const otherLeg = (t: Tri): number => dist(t.a, t.c); + +// Two triangles sharing their long edge form a rhombus; thick:thin follows the +// color ratio, so we count colors (a reliable invariant, unlike face extraction +// from a bare vertex set). diff --git a/src/app/x/penrose/explore/lib/faces.test.ts b/src/app/x/penrose/explore/lib/faces.test.ts new file mode 100644 index 0000000..56f0f73 --- /dev/null +++ b/src/app/x/penrose/explore/lib/faces.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { extractFaces, substitutionFaces, thickThinRatio } from "./faces"; + +const PHI = (1 + Math.sqrt(5)) / 2; + +describe("face extraction is exact against the substitution ground truth", () => { + const { faces: subFaces, verts } = substitutionFaces(6); + const naiveFaces = extractFaces(verts); + const subKeys = new Set(subFaces.map((f) => f.key)); + const naiveKeys = new Set(naiveFaces.map((f) => f.key)); + + test("the corner-acceptance condition has no phantom faces and misses none", () => { + expect(subKeys.size).toBeGreaterThan(800); + // no phantoms: every naive face is a real substitution face + for (const f of naiveFaces) expect(subKeys.has(f.key)).toBe(true); + // complete: every substitution face is found by the naive extractor + for (const f of subFaces) expect(naiveKeys.has(f.key)).toBe(true); + expect(naiveKeys.size).toBe(subKeys.size); + }); + + test("thick/thin classification agrees on every face", () => { + const subType = new Map(subFaces.map((f) => [f.key, f.type])); + for (const f of naiveFaces) expect(f.type).toBe(subType.get(f.key)); + }); + + test("thick:thin ratio approaches φ", () => { + expect(thickThinRatio(naiveFaces)).toBeCloseTo(PHI, 1); // within 0.05 + }); +}); diff --git a/src/app/x/penrose/explore/lib/faces.ts b/src/app/x/penrose/explore/lib/faces.ts new file mode 100644 index 0000000..0d0a30a --- /dev/null +++ b/src/app/x/penrose/explore/lib/faces.ts @@ -0,0 +1,84 @@ +// Face extraction from a cut-and-project vertex set, and the substitution-pairing +// ground truth used to validate it. The face condition is the naive one — a +// 2-face [n; j,k] is a tile iff all four corners n, n+e_j, n+e_k, n+e_j+e_k are +// accepted vertices — which turns out to be exact (no phantoms), validated tile- +// for-tile against the substitution. A rhombus is thick when |j−k| ∈ {1,4} (edges +// 72° apart), thin when {2,3} (144°). + +import { deflate, PHI, type Pt } from "./deflate"; +import { lift, type LiftedVertex } from "./bridge"; + +const SCALE_FOR = (levels: number) => PHI ** levels; + +// key has the format "n0,n1,n2,n3,n4|jk", parsed by lib/patch.ts to recover the +// base corner n and the two axes j,k. That split is a contract dependency: if +// this format changes, patch.ts must change with it. +export type Face = { key: string; type: "thick" | "thin" }; + +const bump = (n: readonly number[], l: number): number[] => { + const c = [...n]; + c[l]++; + return c; +}; + +// Extract faces from a vertex set by the corner-acceptance condition. +export function extractFaces(verts: readonly LiftedVertex[]): Face[] { + const set = new Set(verts.map((v) => v.coord.join(","))); + const out: Face[] = []; + for (const v of verts) { + for (let j = 0; j < 5; j++) + for (let k = j + 1; k < 5; k++) { + const ej = bump(v.coord, j), ek = bump(v.coord, k), ejk = bump(ej, k); + if (set.has(ej.join(",")) && set.has(ek.join(",")) && set.has(ejk.join(","))) { + const d = k - j; + out.push({ key: `${v.coord.join(",")}|${j}${k}`, type: d === 1 || d === 4 ? "thick" : "thin" }); + } + } + } + return out; +} + +// Ground-truth faces from the substitution: pair triangles by their shared base +// edge, map each rhombus's corners to lifted ℤ⁵ coordinates, recover (n; j,k). +export function substitutionFaces(levels: number): { faces: Face[]; verts: LiftedVertex[] } { + const scale = SCALE_FOR(levels); + const L = lift(levels); + const pkey = (p: Pt) => `${p[0].toFixed(3)},${p[1].toFixed(3)}`; + const coordAt = new Map(); + for (const v of L.verts) coordAt.set(pkey(v.pos), v.coord); + + const tris = deflate(levels, 1); + const byBase = new Map(); + for (const t of tris) { + const b: Pt = [t.b[0] * scale, t.b[1] * scale]; + const c: Pt = [t.c[0] * scale, t.c[1] * scale]; + const a: Pt = [t.a[0] * scale, t.a[1] * scale]; + const k = [pkey(b), pkey(c)].sort().join("|"); + const e = byBase.get(k) ?? byBase.set(k, { apexes: [], base: [b, c] }).get(k)!; + e.apexes.push(a); + } + + const faces: Face[] = []; + for (const { apexes, base } of byBase.values()) { + if (apexes.length !== 2) continue; + const corners = [apexes[0], base[0], apexes[1], base[1]]; + const coords = corners.map((p) => coordAt.get(pkey(p))).filter(Boolean) as number[][]; + if (coords.length !== 4) continue; + const varying: number[] = []; + for (let s = 0; s < 5; s++) if (new Set(coords.map((c) => c[s])).size > 1) varying.push(s); + if (varying.length !== 2) continue; + const [j, k] = varying; + const minJ = Math.min(...coords.map((c) => c[j])), minK = Math.min(...coords.map((c) => c[k])); + const n = coords.find((c) => c[j] === minJ && c[k] === minK); + if (!n) continue; + const d = k - j; + faces.push({ key: `${n.join(",")}|${j}${k}`, type: d === 1 || d === 4 ? "thick" : "thin" }); + } + return { faces, verts: L.verts }; +} + +export function thickThinRatio(faces: readonly Face[]): number { + const thick = faces.filter((f) => f.type === "thick").length; + const thin = faces.filter((f) => f.type === "thin").length; + return thick / thin; +} diff --git a/src/app/x/penrose/explore/lib/fold.test.ts b/src/app/x/penrose/explore/lib/fold.test.ts new file mode 100644 index 0000000..b1831f7 --- /dev/null +++ b/src/app/x/penrose/explore/lib/fold.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; + +import { lift } from "./bridge"; +import { nextCoord, nextCoordCanonical, goldenPoint } from "./fold"; +import { A, physical, internal, type Vec5 } from "./cap"; + +const PHI = (1 + Math.sqrt(5)) / 2; +const ONES: Vec5 = [1, 1, 1, 1, 1]; +const wheelKey = (pos: readonly [number, number], level: number): string => { + const w = PHI ** level; + return `${(pos[0] / w).toFixed(4)},${(pos[1] / w).toFixed(4)}`; +}; + +describe("[1,1,1,1,1] is the forced index-gauge direction (not a fitted constant)", () => { + test("it is A's eigenvector for eigenvalue 2 (the index axis)", () => { + expect([...A(ONES)]).toEqual([2, 2, 2, 2, 2]); + }); + test("it is the kernel of both projections — adding it moves no geometry", () => { + const [px, py] = physical(ONES); + const [ix, iy] = internal(ONES); + expect(Math.hypot(px, py)).toBeLessThan(1e-12); + expect(Math.hypot(ix, iy)).toBeLessThan(1e-12); + }); +}); + +describe("the closed-form recursion holds at every level pair", () => { + // Earlier the carry keyed off the source band and failed at half the pairs. + // The forced rule keys off the target band; it must be exact everywhere. + for (const N of [3, 4, 5, 6]) { + test(`coord' = −A·coord + carry·ones reproduces every persistent vertex, level ${N}→${N + 1}`, () => { + const LN = lift(N); + const LM = lift(N + 1); + const targetBandMin = Math.min(...LM.verts.map((v) => v.coord.reduce((s, x) => s + x, 0))); + const mapM = new Map(LM.verts.map((v) => [wheelKey(v.pos, N + 1), v.coord])); + + let matched = 0, exact = 0; + for (const v of LN.verts) { + const cM = mapM.get(wheelKey(v.pos, N)); + if (!cM) continue; + matched++; + const pred = nextCoord(v.coord as Vec5, targetBandMin); + if (pred.every((x, i) => x === cM[i])) exact++; + } + expect(matched).toBeGreaterThan(50); + expect(exact).toBe(matched); + }); + } +}); + +describe("canonical-frame rule and exactness", () => { + test("canonical carry m = ⌈(1+2·index)/5⌉ maps {1,2,3,4} → {1,2,3,4} bijectively", () => { + // index' = −2·index mod 5 is a permutation of {1,2,3,4} + const out = new Set(); + for (let index = 1; index <= 4; index++) { + const m = Math.ceil((1 + 2 * index) / 5); + const idxPrime = -2 * index + 5 * m; + expect(idxPrime).toBeGreaterThanOrEqual(1); + expect(idxPrime).toBeLessThanOrEqual(4); + out.add(idxPrime); + } + expect(out.size).toBe(4); // a bijection, every target index hit once + }); + + test("the recursion stays exact integer to any depth", () => { + let c: Vec5 = [1, 0, 0, 0, 0]; + for (let i = 0; i < 40; i++) { + c = nextCoordCanonical(c); + for (const x of c) expect(Number.isInteger(x)).toBe(true); + } + }); +}); + +describe("the golden-point rule completes coordinate-space deflation", () => { + test("every deflation-created vertex equals goldenPoint(A, l) = fold(A) + e_l", () => { + const N = 5; + const LN = lift(N); + const LM = lift(N + 1); + const targetBandMin = Math.min(...LM.verts.map((v) => v.coord.reduce((s, x) => s + x, 0))); + const key = (r: readonly [number, number]) => `${r[0].toFixed(5)},${r[1].toFixed(5)}`; + const rawOf = (p: readonly [number, number], level: number): [number, number] => [p[0] / PHI ** level, p[1] / PHI ** level]; + const coordM = new Map(LM.verts.map((v) => [key(rawOf(v.pos, N + 1)), v.coord])); + const vN = LN.verts.map((v) => ({ raw: rawOf(v.pos, N), coord: v.coord })); + const edgeLen = 1 / PHI ** N; + + let checked = 0, exact = 0; + for (let a = 0; a < vN.length; a++) { + for (let b = a + 1; b < vN.length; b++) { + const dx = vN[b].raw[0] - vN[a].raw[0], dy = vN[b].raw[1] - vN[a].raw[1]; + if (Math.abs(Math.hypot(dx, dy) - edgeLen) > edgeLen * 1e-4) continue; + const diff = vN[b].coord.map((v, i) => v - vN[a].coord[i]); + const nz = diff.map((v, i) => [v, i] as [number, number]).filter(([v]) => v !== 0); + if (nz.length !== 1 || Math.abs(nz[0][0]) !== 1) continue; + const l = nz[0][1], sign = nz[0][0]; + const A0 = sign > 0 ? vN[a] : vN[b]; + const B0 = sign > 0 ? vN[b] : vN[a]; + const P: [number, number] = [A0.raw[0] + (B0.raw[0] - A0.raw[0]) / PHI, A0.raw[1] + (B0.raw[1] - A0.raw[1]) / PHI]; + const actual = coordM.get(key(P)); + if (!actual) continue; + checked++; + const expected = goldenPoint(A0.coord as Vec5, l, targetBandMin); + if (expected.every((x, i) => x === actual[i])) exact++; + } + } + expect(checked).toBeGreaterThan(100); + expect(exact).toBe(checked); + }); +}); diff --git a/src/app/x/penrose/explore/lib/fold.ts b/src/app/x/penrose/explore/lib/fold.ts new file mode 100644 index 0000000..e020338 --- /dev/null +++ b/src/app/x/penrose/explore/lib/fold.ts @@ -0,0 +1,47 @@ +// The closed-form inter-level coordinate recursion — the local, O(log) form of +// the substitution-address → de-Bruijn-coordinate map. +// +// coord' = −A·coord + m·[1,1,1,1,1] +// +// Every term is forced by structure, none of it is fitted: +// • −A is the inflation operator (eigenvalues −φ, 1/φ, 2). +// • [1,1,1,1,1] is A's eigenvector for eigenvalue 2 AND the kernel of both +// projections (π and π' send it to 0), so adding it shifts the de Bruijn index +// by exactly 5 and moves the tile not at all. It is the unique index-gauge +// direction — det A = 2 is why a single such digit suffices. +// • m is the integer carry forced by requiring index(coord') = −2·index + 5m to +// land in the valid 4-value band. In the canonical band {1,2,3,4} this is +// m = ⌈(1 + 2·index)/5⌉ ∈ {1,2} (since −2 is invertible mod 5, index' = −2·index +// mod 5 is always back in {1,2,3,4}). +// +// Iterating from a coarse seed reaches any depth in O(levels) exact-integer steps. + +import { A, type Vec5 } from "./cap"; + +const apply = (coord: Vec5, m: number): Vec5 => { + const a = A(coord); + return [m - a[0], m - a[1], m - a[2], m - a[3], m - a[4]]; +}; + +// Canonical frame: index ∈ {1,2,3,4}. The carry is fully determined by the index. +export function nextCoordCanonical(coord: Vec5): Vec5 { + const index = coord[0] + coord[1] + coord[2] + coord[3] + coord[4]; + return apply(coord, Math.ceil((1 + 2 * index) / 5)); +} + +// Frame-relative: choose the carry so index' lands in the target level's band +// [bandMin, bandMin+3]. (bandMin = 1 in the canonical frame.) +export function nextCoord(coord: Vec5, targetBandMin: number): Vec5 { + const index = coord[0] + coord[1] + coord[2] + coord[3] + coord[4]; + return apply(coord, Math.ceil((targetBandMin + 2 * index) / 5)); +} + +// Deflation also creates a NEW vertex on each coarse edge: the golden-section point +// at the lower (A) end of an edge in direction l. Its finer coordinate is the fold +// of A plus one step in the edge direction — a single basis vector. Together, +// nextCoord (existing vertices) and this (new vertices) deflate the tiling entirely +// in coordinate space, no geometry required. +export function goldenPoint(coordA: Vec5, l: number, targetBandMin: number): Vec5 { + const c = nextCoord(coordA, targetBandMin); + return [c[0] + (l === 0 ? 1 : 0), c[1] + (l === 1 ? 1 : 0), c[2] + (l === 2 ? 1 : 0), c[3] + (l === 3 ? 1 : 0), c[4] + (l === 4 ? 1 : 0)]; +} diff --git a/src/app/x/penrose/explore/lib/hitTest.test.ts b/src/app/x/penrose/explore/lib/hitTest.test.ts new file mode 100644 index 0000000..ca8d353 --- /dev/null +++ b/src/app/x/penrose/explore/lib/hitTest.test.ts @@ -0,0 +1,55 @@ +// src/app/x/penrose/explore/lib/hitTest.test.ts +import { describe, expect, test } from "bun:test"; + +import { buildPatch } from "./patch"; +import { buildHitIndex, hitFace } from "./hitTest"; + +describe("hit-testing returns the tile under a point", () => { + const patch = buildPatch(6); + const index = buildHitIndex(patch.faces); + + test("a face centroid hits its own face", () => { + // Sample across the patch to keep the test fast but representative. + const step = Math.max(1, Math.floor(patch.faces.length / 200)); + for (let i = 0; i < patch.faces.length; i += step) { + const f = patch.faces[i]; + const hit = hitFace(index, f.centroid[0], f.centroid[1]); + expect(hit?.key).toBe(f.key); + } + }); + + test("a point well outside the patch hits nothing", () => { + const far = patch.bounds.maxX + 1000; + expect(hitFace(index, far, far)).toBeNull(); + }); + + test("an off-centroid interior point hits, a near-edge exterior point does not", () => { + const f = patch.faces[Math.floor(patch.faces.length / 2)]; + const g = f.centroid; + const c = f.corners; + + // A point pulled 40% of the way from the centroid toward a corner is still + // strictly interior to the convex rhombus, but it is not the centroid. + const interior: readonly [number, number] = [ + g[0] + 0.4 * (c[0][0] - g[0]), + g[1] + 0.4 * (c[0][1] - g[1]), + ]; + const inHit = hitFace(index, interior[0], interior[1]); + expect(inHit?.key).toBe(f.key); + + // Step just outside an edge along its outward normal. The midpoint of edge + // c0->c1, nudged away from the centroid, lands in the grout or a neighbor, + // so it must not hit this face. + const a = c[0], b = c[1]; + const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2; + let nx = -(b[1] - a[1]), ny = b[0] - a[0]; + if ((mx - g[0]) * nx + (my - g[1]) * ny < 0) { nx = -nx; ny = -ny; } + const len = Math.hypot(nx, ny); + const exterior: readonly [number, number] = [ + mx + 0.05 * (nx / len), + my + 0.05 * (ny / len), + ]; + const outHit = hitFace(index, exterior[0], exterior[1]); + expect(outHit?.key).not.toBe(f.key); + }); +}); diff --git a/src/app/x/penrose/explore/lib/hitTest.ts b/src/app/x/penrose/explore/lib/hitTest.ts new file mode 100644 index 0000000..a905a2e --- /dev/null +++ b/src/app/x/penrose/explore/lib/hitTest.ts @@ -0,0 +1,59 @@ +// src/app/x/penrose/explore/lib/hitTest.ts +// Point to tile, accelerated by a uniform spatial grid. Tiles are ~unit sized in +// the pos frame, so a cell near 1.5 units keeps buckets small. Each face is +// bucketed into every cell its bounding box overlaps; a query tests only its +// own cell. Faces are non-overlapping, so the first containing face wins. + +import type { Pt, RenderFace } from "./patch"; + +export type HitIndex = { cell: number; grid: Map }; + +const cellKey = (cx: number, cy: number) => `${cx},${cy}`; + +export function buildHitIndex(faces: readonly RenderFace[], cell = 1.5): HitIndex { + const grid = new Map(); + for (const f of faces) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const [x, y] of f.corners) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + const cx0 = Math.floor(minX / cell), cx1 = Math.floor(maxX / cell); + const cy0 = Math.floor(minY / cell), cy1 = Math.floor(maxY / cell); + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const key = cellKey(cx, cy); + const bucket = grid.get(key); + if (bucket) bucket.push(f); + else grid.set(key, [f]); + } + } + } + return { cell, grid }; +} + +function pointInQuad(px: number, py: number, q: readonly [Pt, Pt, Pt, Pt]): boolean { + let sign = 0; + for (let i = 0; i < 4; i++) { + const a = q[i], b = q[(i + 1) % 4]; + const cross = (b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0]); + if (cross !== 0) { + const s = cross > 0 ? 1 : -1; + if (sign === 0) sign = s; + else if (s !== sign) return false; + } + } + return true; +} + +export function hitFace(index: HitIndex, x: number, y: number): RenderFace | null { + const cx = Math.floor(x / index.cell), cy = Math.floor(y / index.cell); + const bucket = index.grid.get(cellKey(cx, cy)); + if (!bucket) return null; + for (const f of bucket) { + if (pointInQuad(x, y, f.corners)) return f; + } + return null; +} diff --git a/src/app/x/penrose/explore/lib/patch.test.ts b/src/app/x/penrose/explore/lib/patch.test.ts new file mode 100644 index 0000000..9bf9bcd --- /dev/null +++ b/src/app/x/penrose/explore/lib/patch.test.ts @@ -0,0 +1,101 @@ +// src/app/x/penrose/explore/lib/patch.test.ts +import { describe, expect, test } from "bun:test"; + +import { buildPatch, findFaceByTile } from "./patch"; +import { encodeTile, decodeTile } from "./codec"; + +describe("buildPatch produces a render-ready patch in the pos frame", () => { + const patch = buildPatch(6); + + test("returns faces with the level recorded", () => { + expect(patch.level).toBe(6); + expect(patch.faces.length).toBeGreaterThan(100); + }); + + test("every face has a 5-component address, two axes, a type, and four finite corners", () => { + for (const f of patch.faces) { + expect(f.coord.length).toBe(5); + expect(f.j).toBeGreaterThanOrEqual(0); + expect(f.j).toBeLessThan(f.k); + expect(f.k).toBeLessThanOrEqual(4); + expect(f.type === "thick" || f.type === "thin").toBe(true); + expect(f.corners.length).toBe(4); + for (const [x, y] of f.corners) { + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + } + } + }); + + test("corners form a rhombus: all four edges are ~unit length", () => { + for (const f of patch.faces) { + const c = f.corners; + for (let i = 0; i < 4; i++) { + const a = c[i], b = c[(i + 1) % 4]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + expect(Math.abs(len - 1)).toBeLessThan(0.02); + } + } + }); + + test("centroid is the corner average and lies inside the bounds", () => { + for (const f of patch.faces) { + const mx = (f.corners[0][0] + f.corners[1][0] + f.corners[2][0] + f.corners[3][0]) / 4; + const my = (f.corners[0][1] + f.corners[1][1] + f.corners[2][1] + f.corners[3][1]) / 4; + expect(Math.abs(f.centroid[0] - mx)).toBeLessThan(1e-9); + expect(Math.abs(f.centroid[1] - my)).toBeLessThan(1e-9); + expect(f.centroid[0]).toBeGreaterThanOrEqual(patch.bounds.minX); + expect(f.centroid[0]).toBeLessThanOrEqual(patch.bounds.maxX); + expect(f.centroid[1]).toBeGreaterThanOrEqual(patch.bounds.minY); + expect(f.centroid[1]).toBeLessThanOrEqual(patch.bounds.maxY); + } + }); + + test("thick:thin ratio approaches phi on a real patch", () => { + const thick = patch.faces.filter((f) => f.type === "thick").length; + const thin = patch.faces.filter((f) => f.type === "thin").length; + expect(thick / thin).toBeGreaterThan(1.5); + expect(thick / thin).toBeLessThan(1.75); + }); + + test("face keys are unique", () => { + const keys = new Set(patch.faces.map((f) => f.key)); + expect(keys.size).toBe(patch.faces.length); + }); +}); + +// Regression guard for the pin/share cross-task bug: the address must identify +// the full tile [n; j, k], not just its base corner n. About 42% of tiles share +// an n with a neighbor, so encoding n alone reopens on the wrong rhombus. +describe("tile address round-trip distinguishes tiles that share a base corner", () => { + const patch = buildPatch(6); + + // Find a base corner n that two faces share with different (j, k). + const byCoord = new Map(); + for (const f of patch.faces) { + const key = f.coord.join(","); + const list = byCoord.get(key); + if (list) list.push(f); + else byCoord.set(key, [f]); + } + const shared = [...byCoord.values()].find((list) => list.length >= 2); + + test("the patch actually contains a shared base corner", () => { + expect(shared).toBeDefined(); + expect(shared!.length).toBeGreaterThanOrEqual(2); + // The two faces share n but differ in (j, k). + expect(shared![0].coord.join(",")).toBe(shared![1].coord.join(",")); + expect(`${shared![0].j}${shared![0].k}`).not.toBe(`${shared![1].j}${shared![1].k}`); + }); + + test("each of the two faces round-trips to itself, not its neighbor", () => { + for (const f of shared!) { + const encoded = encodeTile({ coord: f.coord, j: f.j, k: f.k }); + const decoded = decodeTile(encoded); + expect(decoded).not.toBeNull(); + const found = findFaceByTile(patch, decoded!); + expect(found).not.toBeNull(); + expect(found!.key).toBe(f.key); + } + }); +}); diff --git a/src/app/x/penrose/explore/lib/patch.ts b/src/app/x/penrose/explore/lib/patch.ts new file mode 100644 index 0000000..9de9a10 --- /dev/null +++ b/src/app/x/penrose/explore/lib/patch.ts @@ -0,0 +1,95 @@ +// src/app/x/penrose/explore/lib/patch.ts +// Turn the tested engine's faces into a render model: each rhombus carries its +// four corner positions (cyclic order) and centroid, all in the LiftedVertex.pos +// frame. Corner positions are looked up by coord key, never recomputed via +// physical(). pos and physical(coord) differ by a fixed rotation. + +import { substitutionFaces } from "./faces"; + +export type Pt = readonly [number, number]; + +export type RenderFace = { + key: string; // the engine Face.key, "n0,n1,n2,n3,n4|jk", the full tile identity + coord: readonly number[]; // base-corner vertex n (length 5) + j: number; // first varying axis of the rhombus (0..4) + k: number; // second varying axis (j < k <= 4) + type: "thick" | "thin"; + corners: readonly [Pt, Pt, Pt, Pt]; // cyclic: n, n+e_j, n+e_j+e_k, n+e_k + centroid: Pt; +}; + +export type Patch = { + level: number; + faces: RenderFace[]; + bounds: { minX: number; minY: number; maxX: number; maxY: number }; +}; + +const bump = (n: readonly number[], l: number): number[] => { + const c = [...n]; + c[l]++; + return c; +}; + +export function buildPatch(level: number): Patch { + const { faces, verts } = substitutionFaces(level); + + const posByCoord = new Map(); + for (const v of verts) posByCoord.set(v.coord.join(","), v.pos); + + const out: RenderFace[] = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + for (const f of faces) { + const [coordStr, jk] = f.key.split("|"); + const n = coordStr.split(",").map(Number); + const j = Number(jk[0]); + const k = Number(jk[1]); + + // Cyclic corner coords around the rhombus. + const cn = n; + const cj = bump(n, j); + const cjk = bump(cj, k); + const ck = bump(n, k); + + const p0 = posByCoord.get(cn.join(",")); + const p1 = posByCoord.get(cj.join(",")); + const p2 = posByCoord.get(cjk.join(",")); + const p3 = posByCoord.get(ck.join(",")); + if (!p0 || !p1 || !p2 || !p3) continue; // corner-acceptance guarantees presence + + const corners: readonly [Pt, Pt, Pt, Pt] = [p0, p1, p2, p3]; + const centroid: Pt = [ + (p0[0] + p1[0] + p2[0] + p3[0]) / 4, + (p0[1] + p1[1] + p2[1] + p3[1]) / 4, + ]; + for (const [x, y] of corners) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + out.push({ key: f.key, coord: n, j, k, type: f.type, corners, centroid }); + } + + return { level, faces: out, bounds: { minX, minY, maxX, maxY } }; +} + +// Find a face by its full tile identity. A tile is the rhombus [n; j, k], not +// just its base corner n: many rhombi share an n, so matching on coord alone can +// return a neighbor. This matches every coord component and both axes. Returns +// null when no face matches (engine change or hand-edited URL), which the caller +// treats as "fall back to the seed center." +export function findFaceByTile( + patch: Patch, + addr: { coord: readonly number[]; j: number; k: number }, +): RenderFace | null { + return ( + patch.faces.find( + (f) => + f.j === addr.j && + f.k === addr.k && + f.coord.length === addr.coord.length && + f.coord.every((v, i) => v === addr.coord[i]), + ) ?? null + ); +} diff --git a/src/app/x/penrose/explore/lib/pentagrid.test.ts b/src/app/x/penrose/explore/lib/pentagrid.test.ts new file mode 100644 index 0000000..e9f1521 --- /dev/null +++ b/src/app/x/penrose/explore/lib/pentagrid.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; + +import { generate, physical } from "./cap"; +import { extractFaces } from "./faces"; +import { facesInViewport, gammaFromWindowCenter, GAMMA, tileCentroid, tileExists, WINDOW_CENTER, type Rect } from "./pentagrid"; + +// Oracle: the tested cut-and-project generate(), as faces, for the same tiling. +// generate() yields Vertex{n,p}; extractFaces wants LiftedVertex{pos,coord}. +function oracleFaces(radius: number, vx: number, vy: number) { + const verts = generate(radius, vx, vy).map((v) => ({ pos: physical(v.n), coord: v.n })); + return extractFaces(verts); +} +const inDisk = (cx: number, cy: number, r: number) => Math.hypot(cx, cy) <= r; + +describe("facesInViewport matches the generate() oracle key-for-key", () => { + const [vx, vy] = WINDOW_CENTER; + // A few origin-ish and off-origin regions. Compare only faces whose centroid is + // inside an inner disk where the disk-clipped oracle is complete. + const cases = [ + { R: 16, cx: 0, cy: 0 }, + { R: 16, cx: 6, cy: 4 }, + ]; + for (const { R, cx, cy } of cases) { + test(`region r=${R} at (${cx},${cy})`, () => { + const inner = R - 5; + const oracle = new Map( + oracleFaces(R, vx, vy) + .map((f) => [f.key, f] as const), + ); + // restrict oracle to faces with centroid in the inner disk + const oracleKeys = new Set( + [...oracle.keys()].filter((key) => { + const f = centroidFromKey(key); + return inDisk(f[0], f[1], inner); + }), + ); + const view: Rect = { minX: cx - R, minY: cy - R, maxX: cx + R, maxY: cy + R }; + const enumFaces = facesInViewport(view, GAMMA); + const enumKeys = new Set( + enumFaces + .filter((f) => inDisk(f.centroid[0], f.centroid[1], inner)) + .map((f) => f.key), + ); + const missing = [...oracleKeys].filter((k) => !enumKeys.has(k)); + const extra = [...enumKeys].filter((k) => !oracleKeys.has(k)); + expect(oracleKeys.size).toBeGreaterThan(100); + expect(missing).toEqual([]); + expect(extra).toEqual([]); + }); + } +}); + +// helper: physical centroid of a face from its "n0,n1,n2,n3,n4|jk" key, via tileCentroid. +function centroidFromKey(key: string): [number, number] { + const [coordStr, jk] = key.split("|"); + const coord = coordStr.split(",").map(Number); + return tileCentroid(coord, Number(jk[0]), Number(jk[1])); +} + +describe("far-from-origin viewports drop nothing", () => { + test("a small viewport far out still returns its tiles, all with finite corners", () => { + const view: Rect = { minX: 45, minY: 45, maxX: 50, maxY: 50 }; + const faces = facesInViewport(view, GAMMA); + expect(faces.length).toBeGreaterThan(5); + for (const f of faces) { + expect(f.coord.length).toBe(5); + expect(f.corners.length).toBe(4); + for (const [x, y] of f.corners) { + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + } + // every returned tile's centroid is within one tile of the view + expect(f.centroid[0]).toBeGreaterThan(view.minX - 2); + expect(f.centroid[0]).toBeLessThan(view.maxX + 2); + } + }); +}); + +describe("tiling validity", () => { + const faces = facesInViewport({ minX: -12, minY: -12, maxX: 12, maxY: 12 }, GAMMA); + test("corners are unit-edge rhombi", () => { + for (const f of faces) { + for (let i = 0; i < 4; i++) { + const a = f.corners[i], b = f.corners[(i + 1) % 4]; + expect(Math.abs(Math.hypot(b[0] - a[0], b[1] - a[1]) - 1)).toBeLessThan(0.02); + } + } + }); + test("base corner is the componentwise min on axes j,k", () => { + for (const f of faces) { + const { coord: n, j, k } = f; + // n must be <= n+e_j and n+e_k on those axes, i.e. it is the min corner + expect(n[j]).toBe(Math.min(n[j], n[j] + 1)); + expect(n[k]).toBe(Math.min(n[k], n[k] + 1)); + } + }); + test("thick:thin ratio approaches phi", () => { + const thick = faces.filter((f) => f.type === "thick").length; + const thin = faces.filter((f) => f.type === "thin").length; + expect(thick / thin).toBeGreaterThan(1.55); + expect(thick / thin).toBeLessThan(1.7); + }); + test("keys are unique", () => { + expect(new Set(faces.map((f) => f.key)).size).toBe(faces.length); + }); +}); + +describe("tileCentroid agrees with the enumerator's centroid", () => { + test("a returned face's centroid equals tileCentroid(coord, j, k)", () => { + const f = facesInViewport({ minX: -4, minY: -4, maxX: 4, maxY: 4 }, GAMMA)[0]; + const c = tileCentroid(f.coord, f.j, f.k); + expect(c[0]).toBeCloseTo(f.centroid[0], 12); + expect(c[1]).toBeCloseTo(f.centroid[1], 12); + }); +}); + +describe("tileExists validates a shared address against the real tiling", () => { + test("every face the enumerator emits passes tileExists", () => { + const faces = facesInViewport({ minX: -12, minY: -12, maxX: 12, maxY: 12 }, GAMMA); + expect(faces.length).toBeGreaterThan(50); + for (const f of faces) { + expect(tileExists(f.coord, f.j, f.k)).toBe(true); + } + }); + test("a fabricated address names empty space, so tileExists is false", () => { + // shape-valid (decodeTile would accept it) but no such tile exists + expect(tileExists([7, 7, 7, 7, 7], 0, 1)).toBe(false); + }); +}); + +describe("genericity: the pinned window center has no on-boundary ties", () => { + test("gammaFromWindowCenter reproduces the window center via internal projection", () => { + const g = gammaFromWindowCenter(0.137, -0.081); + // internal(g) = Σ g_l ζ^{2l} = (vx,vy) + const ICOS = [0, 1, 2, 3, 4].map((l) => Math.cos((4 * Math.PI * l) / 5)); + const ISIN = [0, 1, 2, 3, 4].map((l) => Math.sin((4 * Math.PI * l) / 5)); + let vx = 0, vy = 0; + for (let l = 0; l < 5; l++) { vx += g[l] * ICOS[l]; vy += g[l] * ISIN[l]; } + expect(vx).toBeCloseTo(0.137, 9); + expect(vy).toBeCloseTo(-0.081, 9); + expect(g.reduce((s, x) => s + x, 0)).toBeCloseTo(0, 9); // sum 0 -> index band {1,2,3,4} + }); +}); diff --git a/src/app/x/penrose/explore/lib/pentagrid.ts b/src/app/x/penrose/explore/lib/pentagrid.ts new file mode 100644 index 0000000..a73eb84 --- /dev/null +++ b/src/app/x/penrose/explore/lib/pentagrid.ts @@ -0,0 +1,158 @@ +// src/app/x/penrose/explore/lib/pentagrid.ts +// Fast pentagrid viewport enumerator for a fixed generic Penrose tiling. +// +// A tile is a de Bruijn line-crossing of families j= (2/5)*phi ~= 0.65 covers the bounded term + +const fl = (x: number, y: number, l: number) => x * PCOS[l] + y * PSIN[l]; + +function physicalGamma(gamma: readonly number[]): [number, number] { + let x = 0, y = 0; + for (let l = 0; l < 5; l++) { x += gamma[l] * PCOS[l]; y += gamma[l] * PSIN[l]; } + return [x, y]; +} + +function lineRange(rect: Rect, l: number, gamma: readonly number[]): [number, number] { + const c = [ + fl(rect.minX, rect.minY, l), fl(rect.maxX, rect.minY, l), + fl(rect.minX, rect.maxY, l), fl(rect.maxX, rect.maxY, l), + ]; + return [Math.ceil(Math.min(...c) + gamma[l]), Math.floor(Math.max(...c) + gamma[l])]; +} + +function solveCrossing(j: number, k: number, aj: number, ak: number): [number, number] { + const a = PCOS[j], b = PSIN[j], c = PCOS[k], d = PSIN[k]; + const det = a * d - b * c; + return [(aj * d - b * ak) / det, (a * ak - aj * c) / det]; +} + +// The four cyclic corner coords of the rhombus [coord; j,k]: n, n+e_j, n+e_j+e_k, n+e_k. +// The bump sequence and the Vec5 cast live only here; every caller routes through this. +function corners4(coord: readonly number[], j: number, k: number): [Vec5, Vec5, Vec5, Vec5] { + const c0 = [...coord]; + const c1 = [...c0]; c1[j]++; + const c2 = [...c1]; c2[k]++; + const c3 = [...c0]; c3[k]++; + return [c0, c1, c2, c3] as unknown as [Vec5, Vec5, Vec5, Vec5]; +} + +// Average of the given points. +function centroid(pts: readonly Pt[]): Pt { + let x = 0, y = 0; + for (const [px, py] of pts) { x += px; y += py; } + return [x / pts.length, y / pts.length]; +} + +// Canonical face key for a tile address: "n0,n1,n2,n3,n4|jk". +function faceKey(coord: readonly number[], j: number, k: number): string { + return `${coord.join(",")}|${j}${k}`; +} + +// A rhombus is thick when its family gap is 1 or 4, thin otherwise. +function rhombusType(j: number, k: number): "thick" | "thin" { + return k - j === 1 || k - j === 4 ? "thick" : "thin"; +} + +// Physical centroid of the rhombus [coord; j,k]: average of its four corners +// n, n+e_j, n+e_j+e_k, n+e_k under physical(). Task 3 reuses this to recenter the +// camera on a tile address without rebuilding the whole face. +export function tileCentroid(coord: readonly number[], j: number, k: number): Pt { + return centroid(corners4(coord, j, k).map(physical)); +} + +// Is the rhombus [n; j,k] a real tile? It exists iff all four corners +// n, n+e_j, n+e_k, n+e_j+e_k are accepted vertices, the corner-acceptance +// condition faces.ts uses, evaluated against this tiling's window center. +// A shared URL decodes to a shape-valid address; only this confirms it names a +// tile the plane actually emits, so the camera does not pin empty space. +export function tileExists(coord: readonly number[], j: number, k: number): boolean { + return corners4(coord, j, k).every((c) => inWindow(c, WINDOW_CENTER[0], WINDOW_CENTER[1])); +} + +export function facesInViewport(view: Rect, gamma: readonly number[], physicalMargin = 1.5): RenderFace[] { + const out: RenderFace[] = []; + const seen = new Set(); + + // Step 1: physical viewport -> grid-space z region. + const [pgx, pgy] = physicalGamma(gamma); + const zx0 = (2 / 5) * (view.minX - pgx), zx1 = (2 / 5) * (view.maxX - pgx); + const zy0 = (2 / 5) * (view.minY - pgy), zy1 = (2 / 5) * (view.maxY - pgy); + const zRegion: Rect = { + minX: Math.min(zx0, zx1) - GRID_MARGIN, maxX: Math.max(zx0, zx1) + GRID_MARGIN, + minY: Math.min(zy0, zy1) - GRID_MARGIN, maxY: Math.max(zy0, zy1) + GRID_MARGIN, + }; + + // Step 2: per-family line ranges over the z region. + const ranges: [number, number][] = []; + for (let l = 0; l < 5; l++) ranges.push(lineRange(zRegion, l, gamma)); + + const keepMinX = view.minX - physicalMargin, keepMaxX = view.maxX + physicalMargin; + const keepMinY = view.minY - physicalMargin, keepMaxY = view.maxY + physicalMargin; + + for (let j = 0; j < 5; j++) { + for (let k = j + 1; k < 5; k++) { + const [mjLo, mjHi] = ranges[j]; + const [mkLo, mkHi] = ranges[k]; + for (let mj = mjLo; mj <= mjHi; mj++) { + for (let mk = mkLo; mk <= mkHi; mk++) { + const [x, y] = solveCrossing(j, k, mj - gamma[j], mk - gamma[k]); + if (x < zRegion.minX || x > zRegion.maxX || y < zRegion.minY || y > zRegion.maxY) continue; + + // Step 3: local address. Nudge +eps along both family normals so ceil resolves + // into the cell whose min-on-(j,k) corner is the (mj,mk) crossing corner. + const eps = 1e-7; + const nx = x + eps * PCOS[j] + eps * PCOS[k]; + const ny = y + eps * PSIN[j] + eps * PSIN[k]; + const K = new Array(5) as number[]; + for (let l = 0; l < 5; l++) K[l] = Math.ceil(fl(nx, ny, l) + gamma[l]); + K[j] = mj; K[k] = mk; + + // corners n, n+e_j, n+e_j+e_k, n+e_k (cyclic), positions via physical. + const [p0, p1, p2, p3] = corners4(K, j, k).map(physical); + const c: Pt = centroid([p0, p1, p2, p3]); + + // Step 4: filter by physical centroid. + if (c[0] < keepMinX || c[0] > keepMaxX || c[1] < keepMinY || c[1] > keepMaxY) continue; + + const key = faceKey(K, j, k); + if (seen.has(key)) continue; + seen.add(key); + out.push({ + key, coord: K, j, k, + type: rhombusType(j, k), + corners: [p0, p1, p2, p3], centroid: c, + }); + } + } + } + } + return out; +} diff --git a/src/app/x/penrose/explore/page.tsx b/src/app/x/penrose/explore/page.tsx new file mode 100644 index 0000000..3371656 --- /dev/null +++ b/src/app/x/penrose/explore/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import ThemeToggle from "@/components/ThemeToggle"; + +import PenroseExplorer from "./PenroseExplorer"; + +export const metadata: Metadata = { + title: "Penrose Explorer — func.lol", + description: "An endless Penrose tiling, generated per viewport, every tile carrying its exact de Bruijn coordinate. Any view is a shareable link.", +}; + +export default function PenroseExplorePage() { + return ( +
+
+ + ← penrose + + +
+
+ +
+
+ ); +} diff --git a/src/app/x/penrose/page.tsx b/src/app/x/penrose/page.tsx new file mode 100644 index 0000000..a6f2e9f --- /dev/null +++ b/src/app/x/penrose/page.tsx @@ -0,0 +1,386 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import ThemeToggle from "@/components/ThemeToggle"; + +import { experimentNumber } from "../page"; +import AddressWalk from "./_components/AddressWalk"; +import CutAndProject from "./_components/CutAndProject"; +import FibonacciStrip from "./_components/FibonacciStrip"; +import GoldenRatio from "./_components/GoldenRatio"; +import InterferenceOverlay from "./_components/InterferenceOverlay"; +import MeetTheTiles from "./_components/MeetTheTiles"; +import StopTilingByHand from "./_components/StopTilingByHand"; +import UnsolvableFuture from "./_components/UnsolvableFuture"; +import ZoomHierarchy from "./_components/ZoomHierarchy"; + +export const metadata: Metadata = { + title: "Penrose — func.lol", + description: + "How two tiles cover the infinite plane and never repeat, told in order, ending at an explorer you can pan forever where every tile names its exact coordinate.", +}; + +const RESEARCH_URL = + "https://github.com/funcimp/func.lol/tree/main/research/penrose"; + +// Shared classes for the section headings and the prose blocks, kept to the +// DESIGN.md scale (h2 28/32, body 16/1.65, prose max-width ~60ch). +const H2 = + "text-[24px] sm:text-[32px] font-bold leading-[1.05] tracking-[-0.03em] mb-5 mt-16"; +const PROSE = + "prose-hyphens flex flex-col gap-4 text-[16px] leading-[1.65] max-w-[60ch]"; + +export default function PenrosePage() { + // The badge number is derived from publication order, not hand-set: penrose is + // the third experiment by date, so this reads "experiment 03" and stays correct + // if the catalogue grows or reorders. + const number = String(experimentNumber("penrose")).padStart(2, "0"); + return ( +
+
+
+ + ← experiments + + +
+ +
+

+ penrose +

+

+ Two tiles cover the infinite plane and never repeat. Here is how, in + order, ending at a plane you can walk. +

+
+ +
+ experiment {number} + 2026-05-11 +
+ + {/* 1. The question. */} +
+

+ Take a bag of tiles. Lay them edge to edge across a floor that runs + on forever, no gaps, no overlaps. Most tiles you can think of fall + into a march: shift the pattern over by some fixed amount and it + lands on itself, exact. Square tiles do it. Hexagons do it. The + wallpaper in your hallway does it. +

+

+ So here is the question. Is there a set of tiles that covers the + whole plane but never falls into that march? A pattern that + keeps going forever and never once repeats itself? For a long time + nobody knew. +

+
+ + {/* 2. The history. */} +

Two tiles, after a long climb down

+
+

+ In 1961 Hao Wang asked a sharper version. He worked with square + tiles whose edges carry colors, joined only where colors match. His + conjecture: any such set that can tile the plane at all can also tile + it periodically. If true, a tiling that never repeats was impossible. +

+

+ Wang was wrong. In 1966 his student Robert Berger built a set that + tiles the plane and only ever aperiodically. The catch was the size. + His first set used 20,426 tiles. Over the years that + number fell. Berger himself trimmed it, Donald Knuth got it lower, + Raphael Robinson reached six in 1971. +

+

+ Then in 1974 Roger Penrose reached two. Two simple + rhombi, plus a rule about how their edges may meet. That is the floor + this whole page stands on, and it is the one the explorer paints. +

+
+ + {/* 3. How the two tiles work. */} +

The two tiles

+
+

+ Here they are. A thick rhombus, wide and squat, and + a thin one, long and narrow. Same edge length, + different angles. Every tile in the explorer is one of these two, + rotated and dropped into place. +

+

+ The angles are not arbitrary. They come from fifths of a turn: 36, + 72, 108, 144. That family is the golden ratio φ in disguise. Draw the + long diagonal of the thick rhombus and its length is exactly φ. Draw + the short diagonal of the thin one and it is exactly 1/φ. φ is why + these two shapes fit the plane with no repeat. +

+
+ + + + {/* 4. A geometric dead-end: a piece fits, then strands you. */} +

A piece fits, and still strands you

+
+

+ Start with the gentle version, and make it airtight. Carve a small + hole out of a real patch, six edges, and try to fill it back. This + hole has exactly one filling. One. Watch the constrained edge: two + different rhombi fit it with no overlap at all, so both look fine. +

+

+ The sketch below takes the tempting one and seats it cleanly. It + fits. But it fills the hole the wrong way. What it leaves, shown in + red, is two triangles, and no rhombus fits a triangle. No rule was + invoked to say so. Only the other first move completes the hole, and + the shapes alone tell you which. +

+
+ + + +
+

+ On the open plane the bare shapes never trap you like this; they would + tile, boringly, forever. That is exactly why Penrose added the + matching marks. Inside a bounded hole the geometry can speak for + itself, and here it does: the gap is empty space you can see, not a + rule you have to take on faith. +

+
+ + {/* 5. Deeper: the move an expert says fits, followed through. */} +

The thin fits. Place it. Now nothing fits.

+
+

+ Here is the hard one, and it answers the obvious objection. Take a + bigger hole, sixteen edges, carved from a real patch. A Penrose + expert looks at one edge and says, rightly, a thin rhombus fits + there. It does. Zero overlap. The piece sits in the gap. +

+

+ So place it. Lay a few more legal tiles, all fine, then fill in the + rest of the hole as far as the shapes allow. Tiles still cannot cover + everything. A gap is left, shown in red, that no rhombus fits. The + thin fit, you placed it, you filled the rest, and the red gap remains. + Not because a rule says no. Because the shapes collide. Out of all the + ways to start, only one survives to finish the hole. +

+
+ + + +
+

+ A move can fit and still doom you, and whether it does is not + something the edge in front of you can tell. Only one continuation + survives, and nothing local points to it. The fix is not a smarter + local move. It is to stop tiling by hand and compute the plane + globally. +

+
+ + {/* 6. So you solve it globally. */} +

So you stop tiling by hand

+
+

+ If laying tiles one at a time can dead-end, stop laying them one at a + time. Compute the whole plane at once, with a method called{" "} + cut and project. It is easiest to see one dimension + down, so start there. Take the integer grid in the plane. Draw a line + through it at the golden slope, and a thin strip along the line. Keep + only the points that fall inside the strip, and drop each one straight + down onto the line. +

+
+ + + +
+

+ Those dropped points tile the line with just two gaps, long and short, + in the ratio φ, in an order that never repeats. Cut is the + strip. Project is the drop. That is the whole method, and the + two lengths are already a hint of the two tiles to come. +

+

+ Penrose is this same construction one stage up, and the panel at the + foot of the sketch shows it running. The grid is the integer lattice + ℤ⁵, five dimensions. The line becomes our plane, and the strip becomes + a window shaped like nested pentagons. A tile exists exactly when its + 5D shadow lands in that window, a test you run on one point alone, with + no backtracking. Each tile is decided on its own, so the plane is{" "} + computed, never assembled, and it can never dead-end. Scan the + strip across the grid below: the line fills as it crosses points, and the + plane is computed outward from its centre. This is what the explorer runs. +

+
+ + + +
+

+ The dead-ends came from deciding locally. Here every tile is decided + by where its 5D shadow falls, a test that never traps you, and that + same coordinate is the address the explorer reads under your cursor. +

+
+ + {/* 7. The overlay. */} +

Slide one over another

+
+

+ Penrose noticed something when he laid two of these tilings over each + other on his overhead projector and turned one across the other. + Large regions snap into agreement, the two patterns locking tile for + tile. Between those islands run shifting veins where they disagree, + and the whole map is organized by the same five-fold symmetry that + built the tiles. Turn the top layer below across a fifth of a turn, or + drag it around, and watch the rosettes bloom and drift. +

+
+ + + +
+

+ Overlay two of these tilings and turn one, and you see broad regions + agree while veins of mismatch ripple between them, organized by the + five-fold symmetry. That is the strange part. Any two Penrose tilings + share every finite patch you could name. Whatever stretch you see in + one, you will find an exact copy somewhere in the other. Yet slide and + rotate all you like, the two never line up everywhere at once. + Infinitely alike up close, never the same as a whole. This is what + Penrose saw on his projector. +

+
+ + {/* 8. A coordinate system: the address is a walk along five directions. */} +

Every tile knows its address

+
+

+ Because every tile is the shadow of one lattice point, every tile + carries that point as a name: five integers, exact, no two tiles + alike. And every edge of the tiling runs in one of five fixed + directions, so you can walk to any tile along its edges. Trace the + route below from a starting tile and watch it land on the addressed + tile, right on the real boundaries. +

+
+ + + +
+

+ That is the trick that makes the explorer possible. Move anywhere, + zoom anywhere, and the tile under your cursor can tell you precisely + where you are, by reading its own address off the lattice. It is a + full coordinate system for a floor with no edges. A shared link is + just those five numbers, and it drops the next person on the exact + same tile. +

+
+ + {/* 9. More magic: scaling. */} +

It folds into itself

+
+

+ One more piece of magic. Take any valid Penrose tiling and cut each + tile into smaller rhombi by a fixed rule. What you get is another + valid Penrose tiling, finer, scaled down by φ. Run the rule backward + and tiles fuse into bigger ones, a coarser valid tiling scaled up by + φ. You can do this forever in either direction. +

+

+ Count the fat tiles and the thin ones and stack them up. Below, the gold + stack grows out to φ times the blue as you deflate, landing on the + golden-ratio mark. The count of fat to thin tiles is the golden ratio, + the same φ that set the angles in the first place. +

+
+ + + +
+

+ The reason is the same self-similarity, seen the other way. Those + smaller tiles are not just finer, they group back into larger tiles of + the very same two shapes. Below, each deflation level is drawn as a line + grid in its own colour, gold and blue alternating so neighbouring levels + stay distinct. Zoom in and a finer grid nests inside every tile, the + same two shapes 1/φ the size, diving level after level. +

+

+ Inflate or deflate as far as you like and you always land on another + valid Penrose tiling, scaled by φ. The pattern that never repeats is, + at every scale, a copy of itself. +

+
+ + + + {/* 10. The explorer. */} +

Now walk it

+
+

+ The explorer generates whatever patch you are looking at on the fly, + from the cut-and-project method, so you can pan in any direction + forever. There is no edge to reach. Every tile carries its exact + coordinate, shown under the cursor, and any view is a link you can + share. +

+
+ +
+ + open explorer → + +
+ +
+

+ research +

+
+

+ Five substrate questions decided before any explorer code landed: + precision drift of Float64 vs BigInt, URL share-link encoding, + enumeration throughput, the BigInt-truth / Float64-view + viewport-anchor pattern, and a Go comparison. The findings inform + the engine and its addressing. +

+

+ + research on github → + +

+
+
+ + +
+
+ ); +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..3ee1269 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2024", + "lib": ["dom", "dom.iterable", "ES2024"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "research"] }