app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
← Commit history
Merge PR #181: Add circuit generators that follow rendered text contours
19 files changed
+4382−8
README.md+36package.json+2−1public/hero.css+9public/index.html+4public/js/app.js+109−1public/js/circuit-traces.mjs+154public/js/wizard.js+8−1public/studio.css+16public/vendor/circuit-trace-generators/README.md+12public/vendor/circuit-trace-generators/circuit-animation-conductor.js+471public/vendor/circuit-trace-generators/circuit-background.js+1830public/vendor/circuit-trace-generators/circuit-geometry-v9.js+1112public/vendor/circuit-trace-generators/circuit-obstacles-v9.js+405public/vendor/circuit-trace-generators/package.json+1render-hero.mjs+2render-pup.mjs+8server.js+19−4test/circuit-traces.mjs+182test/syntax.mjs+2−1README.md+36@@ -26,6 +26,42 @@ adom-wiki page hero <owner>/<slug> --image docs/hero.png `hero-studio doctor` verifies both render paths (local headless Chrome, or the host browser via the desktop bridge) and prints exact fixes. +## Circuit Trace Generators++The Background step and Classic controls include two options from+[Circuit Trace Generators](https://wiki.adom.inc/adom/circuit-trace-generators):++- **Routed field** (`circuit-field`): the v9 continuous router uses the rendered+ letter shapes, showcase, and badges as obstacles. Moving the showcase, changing+ the headline, or switching to a mirrored layout recomposes the field.+- **Seamless traces** (`circuit-tile`): the torus router retains its repeating+ geometry, with a soft mask clearing those same content areas.++Both continue through open space at the bottom, use the page owner's+primary/background colors (neutral ink until a guide resolves), and expose+Density, Trace size, Strength, Clear space, a numeric Seed, and New pattern.+The controls live below the theme picker; no imported full background is needed.+The original Generated traces option remains unchanged.++```bash+hero-studio --ai-thread "hero circuit review" set theme=circuit-field circuitSeed=1234+hero-studio --ai-thread "hero circuit review" set circuit.density=0.8 circuit.scale=1.4 circuit.strength=0.22 circuit.clearance=8+```++Settings and seed survive snapshots, saved styles, and PNG provenance. Geometry+is measured at the export size, then reused in the live hero, wiki marquee, and+index cards. DOM Range measures actual wrapped text runs; Canvas paints their+loaded fonts into a contour stencil. Empty column space remains available to+the router. The homepage overlay supplies its own scrim, so no bottom strip is+removed from the artwork. Font and showcase loading remeasure the composition. Full-hero+imports hide the generators. These options render **still artwork**: the source+playground's animation/reveal systems do not run behind a PNG preview.++The source engines are bundled, with their upstream revision recorded under+`public/vendor/circuit-trace-generators/README.md`; previews need no external+generator service. Run `npm run test:traces` with Hero Studio's usual Chrome and+Puppeteer renderer dependencies for geometry, API, wizard, and export checks.+ ## Scale: the human gates the style once For fleets of similar pages, gate the LOOK, not every part: pose one
package.json+2−1@@ -68,7 +68,8 @@ "test:render": "node test/render-view.mjs", "test:renderer": "node test/renderer.mjs", "test:syntax": "node test/syntax.mjs",- "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+ "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs && node test/circuit-traces.mjs",+ "test:traces": "node test/circuit-traces.mjs", "prepublishOnly": "node test/syntax.mjs", "test:platform": "node test/platform.mjs" },
public/hero.css+9@@ -752,6 +752,15 @@ body[data-mode=component] .mode-app { display:none; } .hero[data-theme=circuitry] #circuitcanvas { display:block; } +/* A separate transparent composition layer; no baked text or showcase. */+.trace-art { display:none; position:absolute; inset:0; width:100%; height:100%; pointer-events:none; z-index:0; }+.hero[data-theme=circuit-field] .trace-art, .hero[data-theme=circuit-tile] .trace-art { display:block; }+.hero.fullimport .trace-art { display:none; }+.hero[data-theme=circuit-field] .bg, .hero[data-theme=circuit-tile] .bg,+#hero.branded[data-theme=circuit-field] .bg, #hero.branded[data-theme=circuit-tile] .bg {+ background:var(--brand-bg, #0d1117);+}+ .hero.no-chrome .window .titlebar { display:none; } /* bare (#34.1): the import IS the artwork; no frame, no bg, no shadow, alpha kept */
public/index.html+4@@ -128,9 +128,12 @@ <div class="swatch sw-aurora" data-v="aurora" data-tip="Aurora: layered soft radials"></div> <div class="swatch sw-mesh" data-v="mesh" data-tip="Mesh: conic color fields, angle follows the gradient slider"></div> <div class="swatch sw-circuitry" data-v="circuitry" data-tip="Circuitry: procedurally generated PCB traces, gently different every roll"></div>+ <button class="swatch sw-circuit-field" data-v="circuit-field" aria-label="Routed field" data-tip="Continuous traces routed around your text and showcase"></button>+ <button class="swatch sw-circuit-tile" data-v="circuit-tile" aria-label="Seamless traces" data-tip="Seamless routed tile with quiet space around your composition"></button> <div class="swatch sw-image" data-v="image" id="sw-image" data-tip="Imported background art: your own brand texture as the theme (tile or cover). Import below; composed text, layouts, and the linter all still apply"></div> </div> <button class="btn" id="btn-reroll" style="display:none" data-tip="Re-roll the procedural pattern">Reroll circuitry pattern</button>+ <div id="trace-controls"></div> <input type="file" id="bg-file" accept="image/*" data-tip="Import background art for the image theme"> <label class="chk"><input type="checkbox" id="bg-cover"> Cover (scale to fill) instead of tiling</label> @@ -205,6 +208,7 @@ </div> <div id="hero" class="hero" data-theme="midnight" data-layout="bleed-bottom"> <div class="bg"></div>+ <img class="trace-art" id="trace-art" alt="" aria-hidden="true"> <div class="textzone"> <div class="wordmark" id="el-logo" data-tip="Wordmark: adom-org pages get the house mark automatically; other owners show nothing here">
public/js/app.js+109−1@@ -376,7 +376,8 @@ function render() { scheduleLint(); renderPageFields(); if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);- $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none';+ $('btn-reroll').style.display = state.theme === 'circuitry' || isTraceTheme() ? '' : 'none';+ renderTraceControls($('trace-controls')); // marquee overlay preview (ask 5): today's REAL homepage treatment, // composited over the hero so redundancy and balance are judgeable@@ -593,6 +594,7 @@ function render() { $('chk-author').checked = state.overlay.author; $('chk-slug').checked = state.overlay.slug; + scheduleTraces(); syncPreviews(); } @@ -980,6 +982,9 @@ function computeLint() { const H = hero.getBoundingClientRect(); if (H.width < 60) return null; const items = [];+ if (isTraceTheme() && !(state.showcase && state.showcase.full) && window.__traceError) {+ items.push({ id: 'circuit-render', level: 'fail', el: null, msg: 'Circuit traces could not render: ' + window.__traceError });+ } const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height }); const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2; const boxOf = (el) => rel(el.getBoundingClientRect());@@ -1563,6 +1568,109 @@ document.addEventListener('keydown', (e) => { } }); +/* Context-aware trace generators. Keep the existing circuitry theme intact. */+function isTraceTheme() { return state && ['circuit-field', 'circuit-tile'].includes(state.theme); }+const TRACE_DEFAULTS = { density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 };+let traceModule, traceKey = '', traceRevision = 0;+window.__traceReady = Promise.resolve();+window.__tracePending = false;+window.__traceError = '';+function measureTraceKeepouts(mod, clearance) {+ const hero = $('hero'), win = hero.querySelector('.window');+ const heroStyle = hero.style.cssText, winStyle = win.style.cssText;+ // Measure the live composition at the export size. Pixel-sized borders and+ // font rounding otherwise shift a grid cell between the wizard and export.+ // Restore synchronously, before paint; no duplicate DOM or changed UI state.+ try {+ for (const [key, value] of Object.entries({ width:'1600px', height:'1000px', 'max-width':'none', 'aspect-ratio':'auto', border:'none', flex:'none' })) hero.style.setProperty(key,value,'important');+ fitWindowToShowcase();+ return mod.keepouts(hero, clearance);+ } finally { hero.style.cssText = heroStyle; win.style.cssText = winStyle; }+}+function scheduleTraces() {+ const revision = ++traceRevision;+ const img = $('trace-art');+ if (!isTraceTheme() || (state.showcase && state.showcase.full)) {+ img.removeAttribute('src'); traceKey = ''; window.__tracePending = false; window.__traceError = '';+ window.__traceReady = Promise.resolve();+ return;+ }+ window.__tracePending = true;+ window.__traceReady = (async () => {+ // Font loading and showcase fitting must finish before measuring keepouts.+ traceModule ||= import('./circuit-traces.mjs');+ const [mod] = await Promise.all([traceModule, document.fonts.ready]);+ if (!RENDER_VIEW && !document.hidden) await new Promise(resolve => requestAnimationFrame(resolve));+ if (revision !== traceRevision) return;+ const hero = $('hero');+ if (hero.getBoundingClientRect().width < 10) return;+ const settings = { ...TRACE_DEFAULTS, ...state.circuit };+ const colors = (guideBrand() || {}).colors || {};+ const spec = { theme: state.theme, seed: state.circuitSeed, settings,+ boxes: measureTraceKeepouts(mod, settings.clearance),+ color: colors.primary || '#8b949e', background: colors.background || '#0d1117' };+ const key = JSON.stringify(spec);+ if (key !== traceKey) {+ const art = mod.composeTraces(spec);+ img.src = art.src;+ await img.decode();+ if (revision !== traceRevision) return;+ traceKey = key;+ img.dataset.routes = art.count;+ }+ window.__traceError = '';+ window.__tracePending = false;+ syncPreviews();+ })().catch(e => {+ if (revision !== traceRevision) return;+ traceKey = ''; img.removeAttribute('src');+ window.__tracePending = false; window.__traceError = String(e.message || e);+ console.error('Circuit traces:', e); scheduleLint();+ });+}+new ResizeObserver(() => { if (state) scheduleTraces(); }).observe($('hero'));+document.fonts.addEventListener('loadingdone', () => { if (state) scheduleTraces(); });+$('showcase-img').addEventListener('load', () => { if (state) scheduleTraces(); });++// One control builder for Classic and the wizard. Local feedback during a drag;+// persist on change so a slider never creates a queue of expensive reroutes.+function renderTraceControls(host) {+ if (!host) return;+ const active = isTraceTheme();+ host.hidden = !active;+ if (!active) { host.textContent = ''; return; }+ if (host.contains(document.activeElement)) return;+ host.textContent = ''; host.className = 'trace-controls';+ const full = !!(state.showcase && state.showcase.full);+ const note = document.createElement('div'); note.className = 'hint';+ note.textContent = full ? 'Full hero import is active. Switch it off to compose traces around your content.'+ : 'Follows the shapes of your letters and showcase. Clear space adjusts the breathing room around them.';+ host.appendChild(note);+ const config = { ...TRACE_DEFAULTS, ...state.circuit };+ for (const [key, label, min, max, step] of [['density','Density',0.25,1.25,0.05], ['scale','Trace size',0.8,2.5,0.1], ['strength','Strength',0.05,0.5,0.01], ['clearance','Clear space',8,40,2]]) {+ const lab = document.createElement('label'); lab.textContent = label;+ const input = document.createElement('input'); input.type = 'range'; input.min = min; input.max = max; input.step = step;+ input.value = config[key]; input.disabled = full; input.setAttribute('aria-label', label);+ const output = document.createElement('output'); output.textContent = input.value;+ input.addEventListener('input', () => { output.textContent = input.value; });+ input.addEventListener('change', () => {+ state.circuit = { ...TRACE_DEFAULTS, ...state.circuit, [key]: Number(input.value) };+ render(); send({ circuit: { [key]: Number(input.value) } });+ });+ lab.append(input, output); host.appendChild(lab);+ }+ const label = document.createElement('label'); label.textContent = 'Seed';+ const seed = document.createElement('input'); seed.type = 'number'; seed.min = '0'; seed.max = '2147483646'; seed.step = '1';+ seed.value = state.circuitSeed; seed.disabled = full; seed.setAttribute('aria-label', 'Trace seed');+ seed.addEventListener('change', () => {+ if (!seed.value || !seed.checkValidity()) { seed.value = state.circuitSeed; return; }+ state.circuitSeed = Number(seed.value); render(); send({ circuitSeed: state.circuitSeed });+ });+ label.appendChild(seed); host.appendChild(label);+ const reroll = document.createElement('button'); reroll.className = 'btn small'; reroll.textContent = 'New pattern'; reroll.disabled = full;+ reroll.addEventListener('click', () => post('reroll').catch(() => {})); host.appendChild(reroll);+}+ /* procedural circuitry background (seeded, gently different every roll) */ function mulberry32(a) { return function () {
public/js/circuit-traces.mjsadded+154@@ -0,0 +1,154 @@+import { generateCircuitTile, tileSvg } from '../vendor/circuit-trace-generators/circuit-background.js';+import { generateCircuitField, pathData } from '../vendor/circuit-trace-generators/circuit-geometry-v9.js';++// Work in hero coordinates, never viewport pixels: a thumbnail and a PNG use+// the same routes. Only the composition's measured keepouts change geometry.+export const WIDTH = 1000, HEIGHT = 625;+export const DEFAULTS = Object.freeze({ density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 });+const attr = value => String(value).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));+const dataUrl = svg => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);++// Range supplies the browser's actual line breaks and positions, including+// inline accents, cap trimming and overflowing words. Canvas supplies ink at+// those positions, using the already loaded font (not the layout column).+function paintTextInk(ctx, el, origin, sx, sy, clearance) {+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);+ const range = document.createRange();+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });+ for (let node; (node = walker.nextNode());) {+ const style = getComputedStyle(node.parentElement);+ if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;+ ctx.save();+ ctx.scale(sx, sy);+ ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;+ ctx.fontKerning = style.fontKerning;+ ctx.fontStretch = style.fontStretch;+ ctx.fontVariantCaps = style.fontVariantCaps;+ ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing;+ ctx.wordSpacing = style.wordSpacing;+ ctx.direction = style.direction;+ ctx.textAlign = style.direction === 'rtl' ? 'right' : 'left';+ ctx.textBaseline = 'alphabetic';+ ctx.lineJoin = 'round';+ ctx.lineWidth = 2 * clearance / sx;+ let line;+ const flush = () => {+ if (!line || !line.text.trim()) return;+ const text = style.textTransform === 'uppercase' ? line.text.toUpperCase()+ : style.textTransform === 'lowercase' ? line.text.toLowerCase() : line.text;+ const metrics = ctx.measureText(text);+ // Range's inline font box spans fontBoundingBoxAscent/Descent, even+ // with text-box-trim. The alphabetic baseline is inside that box.+ const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;+ const y = line.top - origin.top + (line.height - fontHeight) / 2 + metrics.fontBoundingBoxAscent;+ const x = (style.direction === 'rtl' ? line.right : line.left) - origin.left;+ if (clearance > 0) ctx.strokeText(text, x, y);+ ctx.fillText(text, x, y);+ };+ for (const { segment, index } of segmenter.segment(node.data)) {+ range.setStart(node, index); range.setEnd(node, index + segment.length);+ const rect = range.getBoundingClientRect();+ if (!rect.width || !rect.height) continue;+ if (line && Math.abs(line.top - rect.top) > 1) { flush(); line = null; }+ if (!line) line = { text:'', top:rect.top, height:rect.height, left:rect.left, right:rect.right };+ line.text += segment;+ line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right);+ }+ flush(); ctx.restore();+ }+}++// Convert the painted ink into disjoint scanline polygons on a two-unit grid.+// Merge equal spans vertically. This keeps curved/stepped letter contours and+// counters without a font parser, external service, or thousands of obstacles.+function inkRings(ctx) {+ const step = 2, pixels = ctx.getImageData(0, 0, WIDTH, HEIGHT).data;+ const rectangles = []; let active = new Map();+ for (let y = 0; y < HEIGHT; y += step) {+ const next = new Map(); let start = -1;+ for (let x = 0; x <= WIDTH; x += step) {+ let ink = false;+ for (let dy = 0; !ink && dy < step && y + dy < HEIGHT; dy++) {+ for (let dx = 0; dx < step && x + dx < WIDTH; dx++) {+ if (pixels[((y + dy) * WIDTH + x + dx) * 4 + 3] > 16) { ink = true; break; }+ }+ }+ if (ink && start < 0) start = x;+ if (!ink && start >= 0) {+ const key = `${start}:${x}`, rect = active.get(key) || { x:start, y, right:x, bottom:y };+ rect.bottom = Math.min(HEIGHT, y + step); next.set(key, rect);+ if (!active.has(key)) rectangles.push(rect);+ start = -1;+ }+ }+ active = next;+ }+ return rectangles.map(r => [[r.x,r.y],[r.right,r.y],[r.right,r.bottom],[r.x,r.bottom],[r.x,r.y]]);+}++export function keepouts(hero, clearance = DEFAULTS.clearance) {+ const base = hero.getBoundingClientRect();+ const sx = WIDTH / (base.width - hero.clientLeft * 2);+ const sy = HEIGHT / (base.height - hero.clientTop * 2);+ const boxes = [];+ const visible = el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden';+ const canvas = document.createElement('canvas'); canvas.width = WIDTH; canvas.height = HEIGHT;+ const ctx = canvas.getContext('2d', { willReadFrequently:true });+ const origin = { left:base.left + hero.clientLeft, top:base.top + hero.clientTop };+ for (const selector of ['.h-title', '.h-sub', '.h-desc', '.c-name', '.c-mpn', '.c-spec', '.slug-br']) {+ const el = hero.querySelector(selector);+ if (el && visible(el)) paintTextInk(ctx, el, origin, sx, sy, clearance);+ }+ const rings = inkRings(ctx);+ if (rings.length) boxes.push({ id:'text-ink', type:'path', space:'world', rings });+ // These objects really are solid shapes. Never include a text container or+ // the simulated homepage overlay, which adds its own scrim at display time.+ const selectors = ['.window', '.comp-side', '.pill-tr', '.c-chips .c-family', '.wordmark:not(.noadom) .wmark'];+ for (const selector of selectors) {+ for (const el of hero.querySelectorAll(selector)) {+ if (!visible(el)) continue;+ const r = el.getBoundingClientRect();+ if (r.width < 1 || r.height < 1) continue;+ // Conservative 4-unit grid removes subpixel differences between surfaces.+ const x = Math.max(0, Math.floor(((r.left - base.left - hero.clientLeft) * sx - clearance) / 4) * 4);+ const y = Math.max(0, Math.floor(((r.top - base.top - hero.clientTop) * sy - clearance) / 4) * 4);+ const right = Math.min(WIDTH, Math.ceil(((r.right - base.left - hero.clientLeft) * sx + clearance) / 4) * 4);+ const bottom = Math.min(HEIGHT, Math.ceil(((r.bottom - base.top - hero.clientTop) * sy + clearance) / 4) * 4);+ if (right > x && bottom > y) boxes.push({ id: selector.slice(1), x, y, width: right - x, height: bottom - y });+ }+ }+ return boxes;+}++export function composeTraces({ theme, seed, settings = {}, boxes = [], color = '#8b949e', background = '#0d1117' }) {+ const config = { ...DEFAULTS, ...settings };+ const { density, scale, strength } = config;+ let art, defs = '', count;+ if (theme === 'circuit-tile') {+ const tile = generateCircuitTile({ seed: String(seed), density });+ const svg = tileSvg(tile, { traceColor: color, holeColor: background });+ const w = tile.width * scale, h = tile.height * scale;+ defs = `<pattern id="tile" width="${w}" height="${h}" patternUnits="userSpaceOnUse"><image width="${w}" height="${h}" href="${attr(dataUrl(svg))}"/></pattern>`;+ art = `<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#tile)"/>`;+ count = tile.traces.length;+ } else {+ const field = generateCircuitField({+ seed: String(seed), width: WIDTH + 24, height: HEIGHT + 24,+ featureScale: scale, fill: 0.668 * density, obstaclePreset: 'none',+ obstacleMode: 'avoid', obstacleClearance: 0,+ obstacles: boxes.map(b => b.rings ? b : ({ ...b, type: 'rect', space: 'world', x: b.x + b.width / 2, y: b.y + b.height / 2 })),+ });+ art = field.traces.map(t => `<path d="${pathData(t.points)}"/>`+ + [t.points[0], t.points[t.points.length - 1]].map(([x,y]) => `<circle cx="${x}" cy="${y}" r="${2.55 * scale}" stroke-width="${1.58 * scale}"/>`).join('')).join('');+ art = `<g fill="none" stroke="${attr(color)}" stroke-width="${1.58 * scale}" stroke-linecap="butt" stroke-linejoin="miter">${art}</g>`;+ count = field.traces.length;+ }+ // Tile geometry remains seamless; soften it around the same keepouts the+ // field router avoids. Masked SVG is self-contained inside one cloned img.+ const holes = boxes.map(b => b.rings+ ? `<path d="${b.rings.map(r => pathData(r) + 'Z').join('')}" fill="black"/>`+ : `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="8" fill="black"/>`).join('');+ if (theme === 'circuit-tile') defs += `<filter id="soft" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="3"/></filter><mask id="quiet" maskUnits="userSpaceOnUse" x="0" y="0" width="${WIDTH}" height="${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="white"/><g filter="url(#soft)">${holes}</g>${holes}</mask>`;+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><defs>${defs}</defs><g opacity="${strength}"${theme === 'circuit-tile' ? ' mask="url(#quiet)"' : ''}>${art}</g></svg>`;+ return { src: dataUrl(svg), count };+}
public/js/wizard.js+8−1@@ -84,6 +84,8 @@ const WIZ_THEMES = [ { v: 'aurora', label: 'Aurora', sw: 'sw-aurora' }, { v: 'mesh', label: 'Color mesh', sw: 'sw-mesh' }, { v: 'circuitry', label: 'Generated traces', sw: 'sw-circuitry' },+ { v: 'circuit-field', label: 'Routed field', sw: 'sw-circuit-field' },+ { v: 'circuit-tile', label: 'Seamless traces', sw: 'sw-circuit-tile' }, { v: 'image', label: 'Your image', sw: 'sw-image' }, ]; @@ -819,7 +821,7 @@ function wizSignature() { const l = state.lint || []; const v = state.showcase || {}; return JSON.stringify([wizStep, wizExpanded, state.page, state.layout, state.theme,- state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm,+ state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm, state.circuit, state.circuitSeed, state.flags, state.marqueePreview, state.gatesDone, wizUploads.length, v.full, (v.src || '').length, l.filter(x => x.level === 'fail').length, l.filter(x => x.level === 'warn').length, document.body.classList.contains('show-ctx')]);@@ -827,6 +829,11 @@ function wizSignature() { function wizardRender() { if (!document.body.classList.contains('wizard')) return; if (!state || !pages.length || !$('wdock')) return;+ let traces = $('wtrace-controls');+ if (!traces) { traces = document.createElement('div'); traces.id = 'wtrace-controls'; $('wdock').appendChild(traces); }+ if (wizStep === 'background') renderTraceControls(traces);+ else traces.hidden = true;+ $('wdock').classList.toggle('has-trace-controls', wizStep === 'background' && isTraceTheme()); const sig = wizSignature(); if (sig === wizLastSig) return; const focused = document.activeElement && $('wdock').contains(document.activeElement)
public/studio.css+16@@ -518,3 +518,19 @@ body.branded .sw-circuitry { background: var(--brand-bg); } background: color-mix(in srgb, #f0a33c 12%, transparent); } #wcards .card.wiz-confirm-cta .card-label { color: #f0a33c; font-weight: 700; }+/* Circuit options use the owner's colors in the picker as well as the art. */+.sw-circuit-field, .sw-circuit-tile {+ background-color:var(--brand-bg, #0d1117);+ background-image:repeating-linear-gradient(135deg, transparent 0 13px, color-mix(in srgb, var(--brand-primary, #00b8b0) 45%, transparent) 14px 15px, transparent 16px 26px);+}+.sw-circuit-tile { background-size:28px 28px; background-image:repeating-linear-gradient(90deg, transparent 0 12px, var(--brand-primary, #00b8b0) 13px 14px, transparent 15px 28px); }+.trace-controls { display:flex; flex-wrap:wrap; gap:10px 16px; padding:12px; align-items:center; }+.trace-controls[hidden] { display:none; }+.trace-controls label { display:flex; align-items:center; gap:8px; font-size:12px; }+.trace-controls input[type=range] { width:95px; }+.trace-controls input[type=number] { width:115px; }+.trace-controls button { width:auto; }+.trace-controls output { min-width:28px; font-variant-numeric:tabular-nums; }+.trace-controls .hint { flex-basis:100%; }+#wtrace-controls { flex:0 0 auto; max-height:140px; overflow:auto; }+#wdock.has-trace-controls { height:min(55vh, calc(var(--dock-h, min(44vh, 330px)) + 90px)); }
public/vendor/circuit-trace-generators/README.mdadded+12@@ -0,0 +1,12 @@+# Circuit Trace Generators++Unmodified source from [adom/circuit-trace-generators](https://wiki.adom.inc/adom/circuit-trace-generators),+version 0.3.0, repo commit `2a73b150b944301c057318ae8a2e14d36fca514e`.+Authored by AdityaAngajala; retained here for the requested Hero Studio integration.++`circuit-background.js` supplies the torus tile router. `circuit-geometry-v9.js`+supplies the continuous field router and imports the obstacle and conductor+modules. Hero Studio calls only their pure geometry functions, never their+animation or mounting APIs. The adapter lives in `../../js/circuit-traces.mjs`.+These files are bundled for offline previews and identical local/pup exports.+The package.json here marks the upstream browser modules as ESM for Node tests.
public/vendor/circuit-trace-generators/circuit-animation-conductor.jsadded+471@@ -0,0 +1,471 @@+const SVG_NS = "http://www.w3.org/2000/svg";++function svgElement(name, attributes = {}) {+ const element = document.createElementNS(SVG_NS, name);+ for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));+ return element;+}++function pathData(points) {+ return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");+}++function integer(random, min, max) {+ return Math.floor(random() * (max - min + 1)) + min;+}++function weighted(random, entries) {+ let total = 0;+ for (const [, weight] of entries) total += weight;+ let roll = random() * total;+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries.at(-1)?.[0];+}++function shuffle(random, values) {+ for (let index = values.length - 1; index > 0; index -= 1) {+ const swap = integer(random, 0, index);+ [values[index], values[swap]] = [values[swap], values[index]];+ }+ return values;+}++function compareTraceDistance(a, b) {+ return a.distance - b.distance || a.index - b.index;+}++function compareTraceKey(distance, index, ranked) {+ return distance - ranked.distance || index - ranked.index;+}++function siftDownTraceDistance(heap, index) {+ while (true) {+ const left = index * 2 + 1;+ if (left >= heap.length) return;+ const right = left + 1;+ let largest = left;+ if (right < heap.length && compareTraceDistance(heap[left], heap[right]) < 0) largest = right;+ if (compareTraceDistance(heap[index], heap[largest]) >= 0) return;+ [heap[index], heap[largest]] = [heap[largest], heap[index]];+ index = largest;+ }+}++function nearestTraces(traces, origin, count) {+ if (!(count > 0)) return [];+ const nearest = [];+ for (let index = 0; index < traces.length; index += 1) {+ const trace = traces[index];+ const midpoint = trace.points[Math.floor(trace.points.length / 2)];+ const distance = Math.hypot(midpoint[0] - origin.x, midpoint[1] - origin.y);+ if (nearest.length < count) {+ nearest.push({ trace, distance, index });+ let child = nearest.length - 1;+ while (child > 0) {+ const parent = Math.floor((child - 1) / 2);+ if (compareTraceDistance(nearest[parent], nearest[child]) >= 0) break;+ [nearest[parent], nearest[child]] = [nearest[child], nearest[parent]];+ child = parent;+ }+ } else if (compareTraceKey(distance, index, nearest[0]) < 0) {+ nearest[0] = { trace, distance, index };+ siftDownTraceDistance(nearest, 0);+ }+ }+ return nearest.sort(compareTraceDistance);+}++export function createConductorAnimation(svg, field, config, random) {+ const ambientLayer = svgElement("g", {+ "data-animation": "conductor-ambient", fill: "none", "pointer-events": "none",+ });+ const signalLayer = svgElement("g", {+ "data-animation": "conductor-signals", fill: "none", "pointer-events": "none",+ });+ svg.append(ambientLayer, signalLayer);++ let timer;+ let waveTimer;+ let activeSignals = 0;+ let ambientCurrentCount = 0;+ let ambientNodeCount = 0;+ let wavesTriggered = 0;+ let pointer = null;+ let lastPointerWake = 0;+ const scheduled = new Set();+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++ function life() {+ return Math.max(0, Math.min(1.5, config.liveliness));+ }++ function featureScale() {+ return Math.max(0.25, config.featureScale);+ }++ function trackTimeout(callback, delay) {+ const timeout = window.setTimeout(() => {+ scheduled.delete(timeout);+ callback();+ }, Math.max(0, delay));+ scheduled.add(timeout);+ return timeout;+ }++ function clearScheduled() {+ for (const timeout of scheduled) window.clearTimeout(timeout);+ scheduled.clear();+ }++ function nearestTrace(origin = pointer) {+ if (!origin || !field.traces.length) return null;+ let result = null;+ let best = Infinity;+ const attempts = Math.min(72, field.traces.length);+ for (let attempt = 0; attempt < attempts; attempt += 1) {+ const trace = field.traces[integer(random, 0, field.traces.length - 1)];+ const point = trace.points[Math.floor(trace.points.length / 2)];+ const distance = Math.hypot(point[0] - origin.x, point[1] - origin.y);+ if (distance < best) {+ best = distance;+ result = trace;+ }+ }+ return result;+ }++ function flashNode(point, color, delay = 0, intensity = 1) {+ if (delay > 0) {+ trackTimeout(() => flashNode(point, color, 0, intensity), delay);+ return;+ }+ if (!signalLayer.isConnected || reducedMotion || !config.motion || !config.nodeFlashes) return;+ const scale = featureScale();+ const radius = config.viaOuterRadius * scale;+ const ring = svgElement("circle", {+ cx: point[0], cy: point[1], r: radius * 0.7,+ fill: "none", stroke: color,+ "stroke-width": Math.max(0.8, config.traceWidth * scale * 0.85),+ opacity: 0,+ });+ ring.style.filter = `drop-shadow(0 0 ${3.5 * scale}px ${color})`;+ signalLayer.appendChild(ring);+ const duration = 480 + intensity * 220;+ ring.animate(+ [+ { opacity: 0, transform: "scale(.45)" },+ { offset: 0.18, opacity: Math.min(1, 0.55 + intensity * 0.25), transform: "scale(.9)" },+ { opacity: 0, transform: `scale(${2.2 + intensity * 0.7})` },+ ],+ { duration, easing: "cubic-bezier(.16,.8,.3,1)", fill: "forwards" },+ );+ ring.style.transformBox = "fill-box";+ ring.style.transformOrigin = "center";+ trackTimeout(() => ring.remove(), duration + 40);+ }++ function spawnSignal({+ nearPointer = false,+ trace = null,+ delay = 0,+ reverse,+ color,+ intensity = 1,+ lengthScale = 1,+ speedScale = 1,+ endpointFlash = false,+ } = {}) {+ if (delay > 0) {+ trackTimeout(() => spawnSignal({+ nearPointer, trace, reverse, color, intensity, lengthScale, speedScale, endpointFlash,+ }), delay);+ return true;+ }+ if (!config.motion || reducedMotion || activeSignals >= config.maxSignals || !field.traces.length) return false;+ const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+ const reversed = reverse ?? random() < 0.5;+ const points = reversed ? [...selected.points].reverse() : selected.points;+ const selectedColor = color || weighted(random, config.signalColors);+ const scale = featureScale();+ const requestedLength = config.signalLength * scale * lengthScale;+ const signalLength = Math.max(12 * scale, Math.min(requestedLength, selected.length * 0.72));+ const signalSpeed = config.signalSpeed * scale * (0.78 + life() * 0.27) * speedScale;+ const duration = Math.max(460, ((selected.length + signalLength) / signalSpeed) * 1000);+ const dashGap = selected.length + signalLength;+ const group = svgElement("g", { "data-signal": "" });+ const halo = svgElement("path", {+ d: pathData(points), stroke: selectedColor,+ "stroke-width": (5.2 + intensity * 2.2) * scale,+ "stroke-linecap": "round",+ "stroke-dasharray": `${signalLength} ${dashGap}`,+ "stroke-dashoffset": signalLength,+ opacity: 0,+ });+ const core = svgElement("path", {+ d: pathData(points), stroke: selectedColor,+ "stroke-width": (1.25 + intensity * 1.25) * scale,+ "stroke-linecap": "round",+ "stroke-dasharray": `${signalLength} ${dashGap}`,+ "stroke-dashoffset": signalLength,+ opacity: 0,+ });+ halo.style.filter = `blur(${1.6 * scale}px) drop-shadow(0 0 ${4.5 * scale}px ${selectedColor})`;+ core.style.filter = `drop-shadow(0 0 ${2.8 * scale}px ${selectedColor})`;+ if (config.signalHalos) group.appendChild(halo);+ group.appendChild(core);+ signalLayer.appendChild(group);+ activeSignals += 1;+ const movement = [+ { strokeDashoffset: signalLength, opacity: 0 },+ { offset: 0.08, opacity: 0.92 },+ { offset: 0.84, opacity: 0.72 },+ { strokeDashoffset: -selected.length, opacity: 0 },+ ];+ if (config.signalHalos) {+ halo.animate(movement.map((frame) => ({ ...frame, opacity: (frame.opacity || 0) * 0.22 * intensity })), {+ duration, easing: "linear", fill: "forwards",+ });+ }+ core.animate(movement, { duration, easing: "linear", fill: "forwards" });+ if (endpointFlash) {+ flashNode(points[0], selectedColor, 0, intensity);+ flashNode(points.at(-1), selectedColor, duration * 0.87, intensity * 0.8);+ }+ trackTimeout(() => {+ group.remove();+ activeSignals = Math.max(0, activeSignals - 1);+ }, duration + 100);+ return true;+ }++ function spawnConvoy({ nearPointer = false, trace = null } = {}) {+ if (!config.motion || !config.signalConvoys || reducedMotion || !field.traces.length) return;+ const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+ const count = Math.max(2, Math.min(4, Math.round(2 + life() + random())));+ const selectedColor = weighted(random, config.signalColors);+ const reverse = random() < 0.5;+ for (let index = 0; index < count; index += 1) {+ spawnSignal({+ trace: selected,+ reverse,+ color: selectedColor,+ delay: index * (78 + random() * 54),+ intensity: 0.62 + index * 0.08,+ lengthScale: 0.28 + random() * 0.22,+ speedScale: 0.92 + random() * 0.18,+ endpointFlash: index === 0,+ });+ }+ }++ function ripple(origin, color) {+ if (!signalLayer.isConnected) return;+ const scale = featureScale();+ const startRadius = config.pitch * scale * 0.8;+ const ring = svgElement("circle", {+ cx: origin.x, cy: origin.y, r: startRadius,+ fill: "none", stroke: color,+ "stroke-width": Math.max(0.7, config.traceWidth * scale * 0.72),+ opacity: 0.72,+ });+ ring.style.filter = `drop-shadow(0 0 ${4 * scale}px ${color})`;+ signalLayer.appendChild(ring);+ ring.animate(+ [+ { opacity: 0.72, transform: "scale(.35)" },+ { offset: 0.3, opacity: 0.34 },+ { opacity: 0, transform: "scale(6)" },+ ],+ { duration: 980, easing: "cubic-bezier(.12,.72,.18,1)", fill: "forwards" },+ );+ ring.style.transformBox = "fill-box";+ ring.style.transformOrigin = "center";+ trackTimeout(() => ring.remove(), 1040);+ }++ function spawnWave(origin = null) {+ if (!config.motion || !config.signalWaves || reducedMotion || !field.traces.length) return false;+ const source = origin || {+ x: field.width * (0.14 + random() * 0.72),+ y: field.height * (0.14 + random() * 0.72),+ };+ const selectedColor = weighted(random, config.signalColors);+ ripple(source, selectedColor);+ const count = Math.min(field.traces.length, Math.max(6, Math.round(6 + life() * 8)));+ const ranked = nearestTraces(field.traces, source, count);+ const outerDistance = ranked[count - 1]?.distance || 1;+ for (let index = 0; index < count; index += 1) {+ const { trace, distance } = ranked[index];+ const start = trace.points[0];+ const end = trace.points.at(-1);+ const reverse = Math.hypot(end[0] - source.x, end[1] - source.y)+ < Math.hypot(start[0] - source.x, start[1] - source.y);+ spawnSignal({+ trace,+ reverse,+ color: selectedColor,+ delay: 90 + distance / outerDistance * 560 + index * 14,+ intensity: 0.64 + (1 - distance / outerDistance) * 0.34,+ lengthScale: 0.44 + random() * 0.2,+ speedScale: 1.02 + random() * 0.18,+ endpointFlash: index % 4 === 0,+ });+ }+ wavesTriggered += 1;+ return true;+ }++ function seedAmbientLife() {+ ambientCurrentCount = 0;+ ambientNodeCount = 0;+ if (!config.motion || reducedMotion || life() <= 0.02 || !field.traces.length) return;+ const scale = featureScale();+ const eligible = field.traces.filter((trace) => trace.length > 28 * scale);+ const currents = config.ambientCurrents+ ? Math.min(eligible.length, Math.max(1, Math.round(config.ambientCurrentCount * life())))+ : 0;+ shuffle(random, eligible);+ for (const trace of eligible.slice(0, currents)) {+ const color = weighted(random, config.signalColors);+ const dash = Math.max(12 * scale, trace.length * (0.07 + random() * 0.1));+ const current = svgElement("path", {+ d: pathData(random() < 0.5 ? [...trace.points].reverse() : trace.points),+ stroke: color,+ "stroke-width": config.traceWidth * scale * (0.9 + random() * 0.55),+ "stroke-linecap": "round",+ "stroke-dasharray": `${dash} ${trace.length + dash}`,+ "stroke-dashoffset": dash,+ opacity: 0,+ });+ current.style.filter = `drop-shadow(0 0 ${2.2 * scale}px ${color})`;+ ambientLayer.appendChild(current);+ const duration = (6400 + random() * 9000) / (0.72 + life() * 0.35);+ current.animate(+ [+ { strokeDashoffset: dash, opacity: 0 },+ { offset: 0.14, opacity: 0.08 + life() * 0.055 },+ { offset: 0.78, opacity: 0.05 + life() * 0.04 },+ { strokeDashoffset: -trace.length, opacity: 0 },+ ],+ { duration, delay: -random() * duration, iterations: Infinity, easing: "linear" },+ );+ ambientCurrentCount += 1;+ }++ const nodes = config.ambientNodes+ ? Math.min(field.traces.length, Math.max(1, Math.round(config.ambientNodeCount * life())))+ : 0;+ for (let index = 0; index < nodes; index += 1) {+ const trace = field.traces[integer(random, 0, field.traces.length - 1)];+ const point = random() < 0.5 ? trace.points[0] : trace.points.at(-1);+ const color = weighted(random, config.signalColors);+ const node = svgElement("circle", {+ cx: point[0], cy: point[1],+ r: config.viaOuterRadius * scale * (0.34 + random() * 0.18),+ fill: color, opacity: 0,+ });+ node.style.filter = `drop-shadow(0 0 ${3.2 * scale}px ${color})`;+ node.style.transformBox = "fill-box";+ node.style.transformOrigin = "center";+ ambientLayer.appendChild(node);+ const duration = 2600 + random() * 4300;+ node.animate(+ [+ { opacity: 0, transform: "scale(.65)" },+ { offset: 0.48, opacity: 0.16 + life() * 0.11, transform: "scale(1.22)" },+ { opacity: 0, transform: "scale(.72)" },+ ],+ { duration, delay: -random() * duration, iterations: Infinity, easing: "ease-in-out" },+ );+ ambientNodeCount += 1;+ }+ }++ function pointerMoved(origin) {+ pointer = origin;+ if (!pointer) return;+ const wakeInterval = config.pointerWakeInterval / Math.max(0.55, 0.55 + life() * 0.5);+ if (config.motion && config.pointerWake && !reducedMotion && pointer.at - lastPointerWake > wakeInterval) {+ lastPointerWake = pointer.at;+ spawnSignal({+ nearPointer: true,+ intensity: 0.5 + life() * 0.16,+ lengthScale: 0.34,+ speedScale: 1.3,+ });+ }+ }++ function clicked(origin) {+ pointer = origin;+ spawnWave(pointer);+ if (config.signalConvoys) trackTimeout(() => spawnConvoy({ nearPointer: true }), 160);+ }++ function stop() {+ window.clearInterval(timer);+ window.clearInterval(waveTimer);+ timer = undefined;+ waveTimer = undefined;+ clearScheduled();+ }++ function start() {+ stop();+ if (!config.motion || reducedMotion || life() <= 0.02) return;+ seedAmbientLife();+ const interval = Math.max(130, config.signalInterval / (0.55 + life() * 0.78));+ timer = window.setInterval(() => {+ if (document.hidden) return;+ const nearPointer = pointer && performance.now() - pointer.at < 3200;+ if (config.signalConvoys && random() < 0.12 + life() * 0.13) spawnConvoy({ nearPointer });+ else spawnSignal({+ nearPointer,+ intensity: 0.62 + random() * 0.34,+ lengthScale: 0.52 + random() * 0.58,+ speedScale: 0.86 + random() * 0.34,+ endpointFlash: random() < 0.12,+ });+ }, interval);+ if (config.signalWaves) {+ const intervalForWaves = Math.max(2800, config.waveInterval / (0.66 + life() * 0.48));+ waveTimer = window.setInterval(() => {+ if (!document.hidden) spawnWave();+ }, intervalForWaves);+ }+ if (config.signalConvoys) trackTimeout(() => spawnConvoy(), 360);+ if (config.signalWaves) trackTimeout(() => spawnWave(), 1450);+ }++ function destroy() {+ stop();+ ambientLayer.remove();+ signalLayer.remove();+ }++ return {+ start,+ stop,+ destroy,+ pointerMoved,+ clicked,+ trigger: spawnWave,+ get stats() {+ return {+ system: "original-conductor",+ activeSignals,+ ambientCurrentCount,+ ambientNodeCount,+ wavesTriggered,+ reducedMotion,+ signalHalos: config.signalHalos,+ signalConvoys: config.signalConvoys,+ signalWaves: config.signalWaves,+ pointerWake: config.pointerWake,+ };+ },+ };+}
public/vendor/circuit-trace-generators/circuit-background.jsadded+1830@@ -0,0 +1,1830 @@+const SVG_NS = "http://www.w3.org/2000/svg";++// Geometry constants measured from the reference artwork (adom.inc hero):+// a 114 x 57 cell routing grid with a 150/19 ~= 7.895 unit pitch, 1.58 unit+// traces, and 3.34 / 1.76 annular vias sitting exactly on grid nodes.+const DEFAULTS = Object.freeze({+ seed: "traces",+ tileWidth: 900,+ tileHeight: 450,+ pitch: 150 / 19,+ scale: 1000 / 900,+ density: 1.05,+ traceColor: "#151b25",+ traceWidth: 1.58,+ viaOuterRadius: 3.34,+ viaInnerRadius: 1.76,+ // --- generation tuning -----------------------------------------------+ formations: 96, // terminal groups (via ladders/rows) per tile+ busWidth: 1, // scales pins per formation => bundle width+ turnPenalty: 0.55, // higher = longer, calmer runs+ weave: 0.22, // preference for 2-lane spacing between neighbors+ cramp: 0.4, // aversion to 1-lane spacing outside bundles+ horizontalBias: 0.6, // 0.5 = isotropic, higher = more horizontal flow+ netReach: 42, // max cells a net may span+ jogBias: 5, // preferred endpoint misalignment => staircase jogs+ stubChance: 0, // leftover pins that get short escape stubs+ inlineViaChance: 0.03, // per-step chance of a mid-trace via+ combs: 8, // max tap-comb motifs per tile+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signals: true,+ interactive: true,+ signalInterval: 240, // passive spawn cadence — a fresh signal appears near the cursor this often+ maxSignals: 12, // fill target for the ambient fountain (maxBurstSignals is the hard ceiling)+ spawnRadius: 110, // spawn scatter around the pointer (soft jitter, not a hard cutoff)+ signalSpeed: 420,+ signalLength: 120,+ burstBoost: 7, // click: how many extra signals join at once+ hopSurvival: 0.4, // per-hop survival for PASSIVE signals; low = short-lived, high = long chains+ burstSurvival: 0.72, // per-hop survival for CLICKED signals — tuned independently of hopSurvival+ hopRadius: 440, // a surviving signal hops to a via within this range+ colorDrift: 0.18, // chance per hop that a chain crossfades to a new color+ maxBurstSignals: 25,+});++// 8 directions, clockwise from east; even indices are cardinal.+const DIR8 = [+ [1, 0],+ [1, 1],+ [0, 1],+ [-1, 1],+ [-1, 0],+ [-1, -1],+ [0, -1],+ [1, -1],+];++// Walker start-direction weights derived from horizontalBias (0.5 = even).+function startDirectionWeights(horizontalBias) {+ const ew = 0.48 * horizontalBias;+ const ns = 0.24 * (1 - horizontalBias);+ const diagonal = Math.max(0.005, (1 - 2 * ew - 2 * ns) / 4);+ return [+ [0, ew],+ [4, ew],+ [2, ns],+ [6, ns],+ [1, diagonal],+ [3, diagonal],+ [5, diagonal],+ [7, diagonal],+ ];+}++let instanceCount = 0;++function svgEl(name, attrs = {}) {+ const node = document.createElementNS(SVG_NS, name);+ for (const [key, value] of Object.entries(attrs)) {+ node.setAttribute(key, String(value));+ }+ return node;+}++function hashSeed(input) {+ let hash = 2166136261;+ for (const char of String(input)) {+ hash ^= char.charCodeAt(0);+ hash = Math.imul(hash, 16777619);+ }+ return hash >>> 0;+}++function mulberry32(seed) {+ return function random() {+ let value = (seed += 0x6d2b79f5);+ value = Math.imul(value ^ (value >>> 15), value | 1);+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;+ };+}++function weightedChoice(random, entries) {+ let roll = random();+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries[0][0];+}++// --- routing engine ---------------------------------------------------------+//+// Occupancy lives on a doubled ("half step") grid wrapped on a torus: every+// cell step claims its end node and the midpoint it crosses, which blocks+// same-lane overlap and any crossing (two diagonals share a midpoint, a+// diagonal crosses a cardinal only through a node). The torus wrap is what+// makes the finished tile seamless.++function createRouter(random, columns, rows) {+ const wide = columns * 2;+ const tall = rows * 2;+ // Occupancy as a flat typed array — this sits in the A* hot path.+ const occupied = new Uint8Array(wide * tall);+ const viaNodes = new Uint8Array(wide * tall);++ const halfKey = (hx, hy) => (((hy % tall) + tall) % tall) * wide + (((hx % wide) + wide) % wide);+ const nodeKey = (x, y) => halfKey(x * 2, y * 2);++ function claim(trace, key) {+ occupied[key] = 1;+ trace.claims.push(key);+ trace.ownKeys.add(key);+ }++ return {+ columns,+ rows,+ nodeKey,+ nodeFree: (x, y) => !occupied[nodeKey(x, y)],+ crowding(x, y) {+ let count = 0;+ for (let dy = -4; dy <= 4; dy += 2) {+ for (let dx = -4; dx <= 4; dx += 2) {+ if (occupied[nodeKey(x + dx, y + dy)]) count += 1;+ }+ }+ return count;+ },+ stepFree(x, y, direction) {+ const [dx, dy] = DIR8[direction];+ return (+ !occupied[halfKey(x * 2 + dx, y * 2 + dy)] &&+ !occupied[halfKey(x * 2 + dx * 2, y * 2 + dy * 2)]+ );+ },+ // Like stepFree, but the destination node may be the (already-claimed)+ // goal pin the route is allowed to land on.+ routeStepFree(x, y, direction, goalKey) {+ const [dx, dy] = DIR8[direction];+ if (occupied[halfKey(x * 2 + dx, y * 2 + dy)]) return false;+ const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+ return node === goalKey || !occupied[node];+ },+ // How close the nearest parallel neighbor sits, perpendicular to travel:+ // 1 = one lane away (cramped), 2 = two lanes away (the reference's even+ // weave), 0 = open field. The A* cost prefers 2 and avoids 1.+ laneNeighbors(x, y, direction) {+ const [dx, dy] = DIR8[direction];+ if (occupied[nodeKey(x - dy, y + dx)] || occupied[nodeKey(x + dy, y - dx)]) return 1;+ if (occupied[nodeKey(x - dy * 2, y + dx * 2)] || occupied[nodeKey(x + dy * 2, y - dx * 2)]) return 2;+ return 0;+ },+ claimNode(trace, x, y) {+ claim(trace, nodeKey(x, y));+ },+ claimStep(trace, x, y, direction) {+ const [dx, dy] = DIR8[direction];+ claim(trace, halfKey(x * 2 + dx, y * 2 + dy));+ let count = 1;+ const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+ if (!occupied[node]) {+ claim(trace, node);+ count = 2;+ }+ trace.stepClaims.push(count);+ },+ // Remove the record's most recent step and exactly the claims it made.+ // Only valid while nothing later than that step has claimed (sealEnd+ // never trims past the record's own last via).+ unclaimLastStep(record) {+ const count = record.stepClaims.pop() || 0;+ for (let index = 0; index < count; index += 1) {+ const key = record.claims.pop();+ occupied[key] = 0;+ record.ownKeys.delete(key);+ }+ const step = record.steps.pop();+ record.end = [record.end[0] - DIR8[step][0], record.end[1] - DIR8[step][1]];+ },+ // A via may not sit on or cardinally beside another via (their annular+ // rings would overlap at one-pitch spacing), and the four diagonal+ // midpoints around it must not carry someone else's trace.+ viaAllowed(trace, x, y) {+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {+ if (viaNodes[nodeKey(x + dx, y + dy)]) return false;+ }+ for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+ const key = halfKey(x * 2 + dx, y * 2 + dy);+ if (occupied[key] && !trace.ownKeys.has(key)) return false;+ }+ return true;+ },+ placeVia(trace, x, y) {+ viaNodes[nodeKey(x, y)] = 1;+ trace.viaCells.push([x, y]);+ trace.viaSteps.add(trace.steps.length);+ for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+ const key = halfKey(x * 2 + dx, y * 2 + dy);+ if (!occupied[key]) claim(trace, key);+ }+ },+ rollback(trace) {+ for (const key of trace.claims) occupied[key] = 0;+ for (const [x, y] of trace.viaCells) viaNodes[nodeKey(x, y)] = 0;+ },+ };+}++function newTraceRecord(x, y) {+ return {+ start: [x, y],+ end: [x, y],+ steps: [],+ claims: [],+ stepClaims: [], // claims added per step, so ends can be trimmed precisely+ ownKeys: new Set(),+ viaCells: [],+ viaSteps: new Set(),+ };+}++// Every trace must end on a via. If the end position has no clearance, trim+// the trace back one step at a time (never past its own last via) until a+// via fits. Returns false when no compliant ending exists.+function sealEnd(router, record, minSteps) {+ for (;;) {+ if (record.viaSteps.has(record.steps.length)) return true;+ const [x, y] = record.end;+ if (router.viaAllowed(record, x, y)) {+ router.placeVia(record, x, y);+ return true;+ }+ const floor = Math.max(minSteps, record.viaSteps.size ? Math.max(...record.viaSteps) : 0);+ if (record.steps.length <= floor) return false;+ router.unclaimLastStep(record);+ }+}++// --- A* net router ----------------------------------------------------------+//+// Routes run between terminal formations the way a human lays out a board:+// they escape a pin, join a corridor beside existing traces (hug bonus), pay+// for every 45 degree turn (long calm runs), pay a small surcharge for+// sustained diagonals (the reference uses short 45 degree jogs), and finish+// into the far pin. States are (node, incoming direction) so turn costs are+// exact. Everything wraps on the torus.++class MinHeap {+ constructor() {+ this.costs = [];+ this.values = [];+ }++ get size() {+ return this.costs.length;+ }++ push(cost, value) {+ const { costs, values } = this;+ let index = costs.length;+ costs.push(cost);+ values.push(value);+ while (index > 0) {+ const parent = (index - 1) >> 1;+ if (costs[parent] <= costs[index]) break;+ [costs[parent], costs[index]] = [costs[index], costs[parent]];+ [values[parent], values[index]] = [values[index], values[parent]];+ index = parent;+ }+ }++ pop() {+ const { costs, values } = this;+ const top = values[0];+ const lastCost = costs.pop();+ const lastValue = values.pop();+ if (costs.length) {+ costs[0] = lastCost;+ values[0] = lastValue;+ let index = 0;+ for (;;) {+ const left = index * 2 + 1;+ const right = left + 1;+ let smallest = index;+ if (left < costs.length && costs[left] < costs[smallest]) smallest = left;+ if (right < costs.length && costs[right] < costs[smallest]) smallest = right;+ if (smallest === index) break;+ [costs[smallest], costs[index]] = [costs[index], costs[smallest]];+ [values[smallest], values[index]] = [values[index], values[smallest]];+ index = smallest;+ }+ }+ return top;+ }+}++function createPathfinder(router, columns, rows, config) {+ const turnPenalty = config.turnPenalty;+ const crampPenalty = config.cramp;+ const weaveBonus = config.weave;+ const stateCount = columns * rows * 8;+ const bestCost = new Float64Array(stateCount);+ const seen = new Int32Array(stateCount);+ const closed = new Int32Array(stateCount);+ const parent = new Int32Array(stateCount);+ let generation = 0;++ const wrapX = (value) => ((value % columns) + columns) % columns;+ const wrapY = (value) => ((value % rows) + rows) % rows;++ // start: {x, y, direction}; goal: {x, y, arrivals: Set<direction>}+ // bundleMode flips the lane preference: followers rejoining their bundle+ // WANT to ride one lane off their neighbor.+ return function route(random, start, goal, why = {}, bundleMode = false) {+ generation += 1;+ const heap = new MinHeap();+ const goalX = wrapX(goal.x);+ const goalY = wrapY(goal.y);+ const goalKey = router.nodeKey(goalX, goalY);++ const heuristic = (x, y) => {+ let dx = Math.abs(x - goalX);+ dx = Math.min(dx, columns - dx);+ let dy = Math.abs(y - goalY);+ dy = Math.min(dy, rows - dy);+ return Math.max(dx, dy) + 0.42 * Math.min(dx, dy);+ };++ const startX = wrapX(start.x);+ const startY = wrapY(start.y);+ // Seed the facing direction free, and the two perpendiculars at a small+ // cost — a pin whose lane is congested can still hook out sideways.+ const h0 = heuristic(startX, startY);+ for (const [turn, cost] of [[0, 0], [2, 0.9], [6, 0.9]]) {+ const startState = (startY * columns + startX) * 8 + ((start.direction + turn) % 8);+ seen[startState] = generation;+ bestCost[startState] = cost;+ parent[startState] = -1;+ heap.push(cost + h0, startState);+ }++ const straightestFirst = heuristic(startX, startY);+ let goalState = -1;+ let expansions = 0;+ const TURNS = [0, 1, 7];++ // Weighted A* (heuristic scaled below): meaningfully faster, and the mild+ // suboptimality reads as organic hand-routing rather than error.+ while (heap.size && expansions < 9000) {+ expansions += 1;+ const state = heap.pop();+ if (closed[state] === generation) continue;+ closed[state] = generation;+ const direction = state % 8;+ const nodeIndex = (state - direction) / 8;+ const x = nodeIndex % columns;+ const y = (nodeIndex - x) / columns;++ if (x === goalX && y === goalY) {+ if (goal.arrivals.has(direction)) {+ goalState = state;+ break;+ }+ continue;+ }++ const cost = bestCost[state];+ for (const turn of TURNS) {+ const next = (direction + turn) % 8;+ if (!router.routeStepFree(x, y, next, goalKey)) continue;+ const nx = wrapX(x + DIR8[next][0]);+ const ny = wrapY(y + DIR8[next][1]);+ let stepCost = next % 2 === 0 ? 1 : 1.414;+ if (turn !== 0) stepCost += turnPenalty;+ const lane = router.laneNeighbors(nx, ny, next);+ if (bundleMode) {+ if (lane === 1) stepCost -= 0.22;+ } else if (lane === 1) stepCost += crampPenalty;+ else if (lane === 2) stepCost -= weaveBonus;+ stepCost += random() * 0.05;+ const nextState = (ny * columns + nx) * 8 + next;+ const total = cost + stepCost;+ if (seen[nextState] === generation && bestCost[nextState] <= total) continue;+ seen[nextState] = generation;+ bestCost[nextState] = total;+ parent[nextState] = state;+ heap.push(total + heuristic(nx, ny) * 1.2, nextState);+ }+ }++ if (goalState < 0) {+ why.reason = heap.size ? "expansions" : "exhausted";+ return null;+ }+ const directions = [];+ let cursor = goalState;+ while (parent[cursor] >= 0) {+ directions.push(cursor % 8);+ cursor = parent[cursor];+ }+ directions.reverse();+ // Reject absurd detours (torus wrap can produce them when hemmed in).+ if (directions.length > straightestFirst * 2.4 + 12) {+ why.reason = "detour";+ return null;+ }+ return directions;+ };+}++// --- terminal formations ----------------------------------------------------+//+// The reference pattern's vias are not sprinkled at random: they sit in small+// deliberate formations — diagonal ladders (staggered bus fanouts), rows, and+// singles — and traces escape them on a consistent side. Formations are placed+// with best-candidate sampling so the tile keeps even coverage and breathing+// room between groups.++function placeFormations(random, router, columns, rows, count, config) {+ const formations = [];+ const centers = [];+ const busWidth = config.busWidth;+ const horizontalEscape = Math.min(0.95, config.horizontalBias + 0.02);+ const rowSplit = 0.52 + 0.26 * config.horizontalBias;+ const wrapDist = (a, b, m) => {+ const d = Math.abs(a - b) % m;+ return Math.min(d, m - d);+ };++ for (let index = 0; index < count; index += 1) {+ let best = null;+ let bestClearance = -1;+ for (let candidate = 0; candidate < 8; candidate += 1) {+ const x = Math.floor(random() * columns);+ const y = Math.floor(random() * rows);+ let clearance = Infinity;+ for (const [cx, cy] of centers) {+ clearance = Math.min(+ clearance,+ Math.hypot(wrapDist(x, cx, columns), wrapDist(y, cy, rows)),+ );+ }+ if (clearance > bestClearance) {+ bestClearance = clearance;+ best = [x, y];+ }+ }++ const [x, y] = best;+ const roll = random();+ const pins = [];+ let signature;+ if (roll < 0.52) {+ // Diagonal via ladder — the staggered bus-fanout motif.+ const length = Math.max(2, Math.min(8, Math.round((3 + random() * 4) * busWidth)));+ const slope = random() < 0.5 ? -1 : 1;+ const escape = random() < horizontalEscape ? 0 : 4;+ signature = `ladder${slope}`;+ for (let k = 0; k < length; k += 1) pins.push({ x: x + k, y: y + k * slope, escape });+ } else if (roll < rowSplit) {+ // Vertical via row (2-cell spacing), horizontal escapes.+ const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+ const escape = random() < 0.5 ? 0 : 4;+ signature = "vrow";+ for (let k = 0; k < length; k += 1) pins.push({ x, y: y + k * 2, escape });+ } else if (roll < 0.78) {+ // Horizontal via row, vertical escapes.+ const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+ const escape = random() < 0.5 ? 2 : 6;+ signature = "hrow";+ for (let k = 0; k < length; k += 1) pins.push({ x: x + k * 2, y, escape });+ } else {+ signature = "solo";+ pins.push({ x, y, escape: [0, 4, 2, 6][Math.floor(random() * 4)] });+ }++ // Reserve the pin nodes now; the via graphics are only placed when a net+ // or stub actually connects, so a formation that stays unconnected leaves+ // no orphaned via row behind.+ const record = newTraceRecord(pins[0].x, pins[0].y);+ let placeable = true;+ for (const pin of pins) {+ if (!router.nodeFree(pin.x, pin.y)) {+ placeable = false;+ break;+ }+ router.claimNode(record, pin.x, pin.y);+ }+ if (!placeable) {+ router.rollback(record);+ continue;+ }+ centers.push([x, y]);+ formations.push({ pins: pins.map((pin) => ({ ...pin, used: false })), record, signature });+ }+ return formations;+}++// Start a trace record at a pin with its mandatory via already in place.+// Returns null when the pin has no via clearance (the pin is then skipped —+// every trace must begin on a via).+function startAtPin(router, pin) {+ const record = newTraceRecord(pin.x, pin.y);+ if (!router.viaAllowed(record, pin.x, pin.y)) return null;+ router.placeVia(record, pin.x, pin.y);+ return record;+}++function signedWrap(delta, modulus) {+ let d = ((delta % modulus) + modulus) % modulus;+ if (d > modulus / 2) d -= modulus;+ return d;+}++// A route may enter a pin from the facing side or hook in from up to 90+// degrees off — only a straight-through backside entry is forbidden.+const ARRIVALS = DIR8.map((_, escape) => {+ const reverse = (escape + 4) % 8;+ return new Set([+ (reverse + 6) % 8,+ (reverse + 7) % 8,+ reverse,+ (reverse + 1) % 8,+ (reverse + 2) % 8,+ ]);+});++// Route bundles of nets between compatible formations. Only the FIRST pin+// pair is routed with A*; the remaining pins get pure translated copies of+// the leader. Translation is what the reference does: neighbors that bend at+// the same places, producing nested chevrons and group staircases. Followers+// that hit an obstruction stop early and drop their via there — a natural+// fanout.+function routeNets(random, router, route, formations, addRecord, stats, config) {+ const majorFor = (escape) => (escape === 0 || escape === 4 ? 0 : 1);+ const sameNode = (x1, y1, x2, y2) =>+ router.nodeKey(x1, y1) === router.nodeKey(x2, y2);++ for (const formation of formations) {+ const minePins = formation.pins.filter((pin) => !pin.used);+ if (!minePins.length) continue;++ const escape = formation.pins[0].escape;+ const axis = majorFor(escape);+ // Find a partner formation whose pins face back toward this one; a+ // partner with the same shape nests perfectly under translation.+ let partner = null;+ let partnerScore = Infinity;+ for (const other of formations) {+ if (other === formation) continue;+ const otherFree = other.pins.filter((pin) => !pin.used);+ if (!otherFree.length) continue;+ if (other.pins[0].escape !== (escape + 4) % 8) continue;+ const dx = signedWrap(other.pins[0].x - formation.pins[0].x, router.columns);+ const dy = signedWrap(other.pins[0].y - formation.pins[0].y, router.rows);+ const major = axis === 0 ? dx : dy;+ const minor = axis === 0 ? dy : dx;+ const forward = escape === 0 || escape === 2 ? major : -major;+ if (forward < 8 || forward > config.netReach) continue;+ if (Math.abs(minor) > 12) continue;+ // Prefer a few cells of misalignment: it forces the staircase jogs the+ // reference is full of. Perfect alignment yields sterile laser lines.+ let score =+ Math.abs(Math.abs(minor) - config.jogBias) * 0.8 ++ Math.abs(forward - 22) * 0.3 ++ random() * 5;+ if (other.signature === formation.signature) score -= 4;+ if (score < partnerScore) {+ partnerScore = score;+ partner = other;+ }+ }+ if (!partner) continue;++ const minorOf = (pin) => (axis === 0 ? pin.y : pin.x);+ const mine = minePins.sort((a, b) => minorOf(a) - minorOf(b));+ const theirs = partner.pins+ .filter((pin) => !pin.used)+ .sort((a, b) => minorOf(a) - minorOf(b));+ const pairCount = Math.min(mine.length, theirs.length);+ if (!pairCount) continue;++ // Leader. Both pin vias go in before routing — every trace begins and+ // ends on a via, so a net is only viable if both terminals have+ // clearance.+ const leadFrom = mine[0];+ const leadTo = theirs[0];+ const leadRecord = startAtPin(router, leadFrom);+ if (!leadRecord) continue;+ if (!router.viaAllowed(leadRecord, leadTo.x, leadTo.y)) {+ router.rollback(leadRecord);+ continue;+ }+ router.placeVia(leadRecord, leadTo.x, leadTo.y);+ const why = {};+ const directions = route(+ random,+ { x: leadFrom.x, y: leadFrom.y, direction: leadFrom.escape },+ { x: leadTo.x, y: leadTo.y, arrivals: ARRIVALS[leadTo.escape] },+ why,+ );+ if (!directions) {+ router.rollback(leadRecord);+ stats.leaderFails += 1;+ stats[why.reason] = (stats[why.reason] || 0) + 1;+ continue;+ }+ stats.leaders += 1;+ stats.followerTries += pairCount - 1;+ {+ let x = leadFrom.x;+ let y = leadFrom.y;+ for (const step of directions) {+ router.claimStep(leadRecord, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ leadRecord.steps.push(step);+ }+ leadRecord.end = [x, y];+ leadFrom.used = true;+ leadTo.used = true;+ addRecord(leadRecord);+ }++ // Followers: translated copies pin-by-pin. A follower whose translated+ // corridor is blocked early gets rerouted with A* in bundle mode so it+ // wraps the obstacle and rejoins the group. Every follower starts on its+ // pin via and seals its far end with a via (trimming back if needed).+ for (let index = 1; index < pairCount; index += 1) {+ const from = mine[index];+ const to = theirs[index];+ const goalKey = router.nodeKey(to.x, to.y);++ const record = startAtPin(router, from);+ if (!record) {+ stats.followerFails += 1;+ continue;+ }++ const clearSteps = () => {+ while (record.steps.length) router.unclaimLastStep(record);+ };+ const layTranslation = () => {+ let x = from.x;+ let y = from.y;+ for (const step of directions) {+ if (!router.routeStepFree(x, y, step, goalKey)) break;+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ if (sameNode(x, y, to.x, to.y)) break;+ }+ record.end = [x, y];+ };++ layTranslation();+ let reached = sameNode(record.end[0], record.end[1], to.x, to.y);+ if (!reached && record.steps.length < Math.max(6, Math.floor(directions.length * 0.6))) {+ clearSteps();+ const alt = route(+ random,+ { x: from.x, y: from.y, direction: from.escape },+ { x: to.x, y: to.y, arrivals: ARRIVALS[to.escape] },+ {},+ true,+ );+ if (alt) {+ let x = from.x;+ let y = from.y;+ for (const step of alt) {+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ }+ record.end = [x, y];+ reached = true;+ stats.followersRerouted += 1;+ } else {+ layTranslation();+ reached = sameNode(record.end[0], record.end[1], to.x, to.y);+ }+ }++ if (record.steps.length < 4 || !sealEnd(router, record, 4)) {+ router.rollback(record);+ stats.followerFails += 1;+ continue;+ }+ from.used = true;+ if (sameNode(record.end[0], record.end[1], to.x, to.y)) {+ to.used = true;+ stats.followersFull += 1;+ } else {+ stats.followersPartial += 1;+ }+ addRecord(record);+ }+ }+}++// Tap combs: short perpendicular stubs dropping off a long run at a regular+// pitch, each ending in a via — the power-rail tap motif that punctuates the+// reference artwork.+function tapCombs(random, router, records, addRecord, config) {+ const runs = [];+ for (const record of records) {+ let x = record.start[0];+ let y = record.start[1];+ let runX = x;+ let runY = y;+ let runDir = -1;+ let runLen = 0;+ const flush = () => {+ if (runDir >= 0 && runDir % 2 === 0 && runLen >= 8) {+ runs.push({ x: runX, y: runY, dir: runDir, len: runLen });+ }+ };+ for (const step of record.steps) {+ if (step === runDir) runLen += 1;+ else {+ flush();+ runDir = step;+ runX = x;+ runY = y;+ runLen = 1;+ }+ x += DIR8[step][0];+ y += DIR8[step][1];+ }+ flush();+ }++ let combs = 0;+ for (const run of runs) {+ if (combs >= config.combs || random() >= 0.28) continue;+ const [dx, dy] = DIR8[run.dir];+ const side = random() < 0.5 ? 1 : -1;+ const px = -dy * side;+ const py = dx * side;+ const tapDir = DIR8.findIndex(([tx, ty]) => tx === px && ty === py);+ const teeth = 2 + Math.floor(random() * 3);+ // Depth 2-3: the junction via and tip via need a two-cell gap to clear+ // each other's annular rings.+ const depth = 2 + (random() < 0.35 ? 1 : 0);+ const span = teeth * 2;+ if (run.len < span + 2) continue;+ const offset = 1 + Math.floor(random() * (run.len - span - 1));+ const made = [];+ for (let tooth = 0; tooth < teeth; tooth += 1) {+ const bx = run.x + dx * (offset + tooth * 2);+ const by = run.y + dy * (offset + tooth * 2);+ // The tap starts ON the parent trace (a T junction, marked with its+ // own via) and steps outward to a tip via.+ const record = newTraceRecord(bx, by);+ if (!router.viaAllowed(record, bx, by)) continue;+ router.placeVia(record, bx, by);+ let cx = bx;+ let cy = by;+ for (let d = 0; d < depth; d += 1) {+ if (!router.stepFree(cx, cy, tapDir)) break;+ router.claimStep(record, cx, cy, tapDir);+ cx += px;+ cy += py;+ record.steps.push(tapDir);+ }+ record.end = [cx, cy];+ if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+ router.rollback(record);+ continue;+ }+ made.push(record);+ }+ if (made.length >= 2) {+ for (const record of made) addRecord(record);+ combs += 1;+ } else {+ for (const record of made) router.rollback(record);+ }+ }+}++// Short bare-ended escape stubs for pins that never got a net — the reference+// is full of these little launches.+function routeStubs(random, router, formations, addRecord, config) {+ for (const formation of formations) {+ for (const pin of formation.pins) {+ if (pin.used || random() >= config.stubChance) continue;+ const record = startAtPin(router, pin);+ if (!record) continue;+ let x = pin.x;+ let y = pin.y;+ let direction = pin.escape;+ const length = 2 + Math.floor(random() * 5);+ for (let step = 0; step < length; step += 1) {+ if (step > 0 && random() < 0.2) {+ const jog = (direction + (random() < 0.5 ? 1 : 7)) % 8;+ if (router.stepFree(x, y, jog)) direction = jog;+ }+ if (!router.stepFree(x, y, direction)) break;+ router.claimStep(record, x, y, direction);+ x += DIR8[direction][0];+ y += DIR8[direction][1];+ record.steps.push(direction);+ }+ record.end = [x, y];+ if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+ router.rollback(record);+ continue;+ }+ pin.used = true;+ addRecord(record);+ }+ }+}++// Translated companions for a filler walk — same idea as the net followers:+// staggered trims and via ladders at the ends, bending wherever the base+// bends. In the reference almost nothing travels alone.+function replicateWalk(random, router, base, addRecord) {+ if (random() >= 0.55 || base.steps.length < 8) return;+ let horizontalSteps = 0;+ let verticalSteps = 0;+ for (const step of base.steps) {+ if (step === 0 || step === 4) horizontalSteps += 1;+ else if (step === 2 || step === 6) verticalSteps += 1;+ }+ const horizontal = horizontalSteps >= verticalSteps;+ const spacing = random() < 0.7 ? 1 : 2;+ const side = random() < 0.5 ? 1 : -1;+ const shift = horizontal ? [0, spacing * side] : [spacing * side, 0];+ const copies = 1 + Math.floor(random() * 3);+ const trimStartUnit = random() < 0.5 ? 1 : 0;+ const trimEndUnit = random() < 0.8 ? 1 : 2;++ for (let copy = 1; copy <= copies; copy += 1) {+ const trimStart = copy * trimStartUnit;+ const trimEnd = copy * trimEndUnit;+ if (base.steps.length - trimStart - trimEnd < 5) break;+ let x = base.start[0] + shift[0] * copy;+ let y = base.start[1] + shift[1] * copy;+ for (let index = 0; index < trimStart; index += 1) {+ x += DIR8[base.steps[index]][0];+ y += DIR8[base.steps[index]][1];+ }+ if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+ const record = newTraceRecord(x, y);+ router.claimNode(record, x, y);+ router.placeVia(record, x, y); // every trace starts on a via+ for (let index = trimStart; index < base.steps.length - trimEnd; index += 1) {+ const step = base.steps[index];+ if (!router.stepFree(x, y, step)) break;+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ }+ record.end = [x, y];+ if (record.steps.length < 5 || !sealEnd(router, record, 5)) {+ router.rollback(record);+ continue;+ }+ addRecord(record);+ }+}++// --- filler walker ----------------------------------------------------------+//+// A small amount of free-roaming texture between the routed bundles, using the+// same collision rules. Kept from the earlier engine but used sparingly.++const SCRATCH_RECORD = { ownKeys: new Set() };++function walkTrace(random, router, config, startWeights) {+ let sx = -1;+ let sy = -1;+ let bestCrowding = Infinity;+ for (let attempt = 0, found = 0; attempt < 24 && found < 3; attempt += 1) {+ const x = Math.floor(random() * router.columns);+ const y = Math.floor(random() * router.rows);+ if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+ found += 1;+ const crowding = router.crowding(x, y);+ if (crowding < bestCrowding) {+ bestCrowding = crowding;+ sx = x;+ sy = y;+ }+ }+ if (sx < 0) return null;++ const trace = newTraceRecord(sx, sy);+ router.claimNode(trace, sx, sy);+ router.placeVia(trace, sx, sy); // every trace starts on a via++ let direction = weightedChoice(random, startWeights);+ let lastCardinal = direction % 2 === 0 ? direction : (direction + (random() < 0.5 ? 1 : 7)) % 8;+ const budget = 14 + Math.floor(random() * 22);+ let x = sx;+ let y = sy;+ let sinceVia = 4;+ let turnStreak = 0; // consecutive same-side turns; capped to avoid closed loops++ while (trace.steps.length < budget) {+ const cardinal = direction % 2 === 0;+ const runLength = cardinal+ ? 3 + Math.floor(random() * random() * 9)+ : 1 + Math.floor(random() * 3);++ for (let step = 0; step < runLength && trace.steps.length < budget; step += 1) {+ if (!router.stepFree(x, y, direction)) break;+ router.claimStep(trace, x, y, direction);+ x += DIR8[direction][0];+ y += DIR8[direction][1];+ trace.steps.push(direction);+ sinceVia += 1;+ if (+ cardinal &&+ sinceVia > 4 &&+ trace.steps.length < budget - 2 &&+ random() < config.inlineViaChance &&+ router.viaAllowed(trace, x, y)+ ) {+ router.placeVia(trace, x, y);+ sinceVia = 0;+ }+ }++ if (trace.steps.length >= budget) break;++ let next;+ if (cardinal) {+ // Curling the same way repeatedly closes octagon loops; alternate.+ const side = Math.abs(turnStreak) >= 3 ? -Math.sign(turnStreak) : random() < 0.5 ? 1 : -1;+ next = (direction + (side > 0 ? 1 : 7)) % 8;+ } else {+ const optionA = (direction + 1) % 8;+ const optionB = (direction + 7) % 8;+ const preferPrevious = (optionA === lastCardinal || optionB === lastCardinal) && random() < 0.7;+ next = preferPrevious ? lastCardinal : random() < 0.5 ? optionA : optionB;+ }+ if (!router.stepFree(x, y, next)) {+ const mirrored = (direction + (8 - ((next - direction + 8) % 8))) % 8;+ if (!router.stepFree(x, y, mirrored)) break;+ next = mirrored;+ }+ const turnSide = ((next - direction + 8) % 8) === 1 ? 1 : -1;+ turnStreak = Math.sign(turnStreak) === turnSide ? turnStreak + turnSide : turnSide;+ direction = next;+ if (direction % 2 === 0) lastCardinal = direction;+ }++ trace.end = [x, y];+ if (trace.steps.length < 7 || !sealEnd(router, trace, 7)) {+ router.rollback(trace);+ return null;+ }+ return trace;+}++function toTileTrace(record, pitch) {+ const points = [[record.start[0], record.start[1]]];+ let previous = -1;+ let [x, y] = record.start;+ for (const step of record.steps) {+ x += DIR8[step][0];+ y += DIR8[step][1];+ if (step === previous) points[points.length - 1] = [x, y];+ else points.push([x, y]);+ previous = step;+ }+ const scaled = points.map(([px, py]) => [px * pitch, py * pitch]);+ let length = 0;+ for (let index = 1; index < scaled.length; index += 1) {+ length += Math.hypot(+ scaled[index][0] - scaled[index - 1][0],+ scaled[index][1] - scaled[index - 1][1],+ );+ }+ return {+ points: scaled,+ length,+ vias: record.viaCells.map(([vx, vy]) => [vx * pitch, vy * pitch]),+ };+}++export function generateCircuitTile(options = {}) {+ const config = { ...DEFAULTS, ...options };+ const random = mulberry32(hashSeed(config.seed));+ const columns = Math.round(config.tileWidth / config.pitch);+ const rows = Math.round(config.tileHeight / config.pitch);+ const router = createRouter(random, columns, rows);+ const route = createPathfinder(router, columns, rows, config);+ const startWeights = startDirectionWeights(config.horizontalBias);++ const records = [];+ let routedSteps = 0;+ const addRecord = (record) => {+ records.push(record);+ routedSteps += record.steps.length;+ };++ // 1. Terminal formations: via ladders, rows, singles.+ const formations = placeFormations(+ random,+ router,+ columns,+ rows,+ Math.round(config.formations * config.density),+ config,+ );+ for (const formation of formations) records.push(formation.record);++ // 2. Nets between formations — the flow. Two passes so pins left over from+ // the first pairing find another partner.+ const stats = {+ formations: formations.length,+ leaders: 0,+ leaderFails: 0,+ followerTries: 0,+ followersFull: 0,+ followersPartial: 0,+ followersRerouted: 0,+ followerFails: 0,+ };+ routeNets(random, router, route, formations, addRecord, stats, config);+ routeNets(random, router, route, formations, addRecord, stats, config);++ // 3. Escape stubs for leftover pins.+ routeStubs(random, router, formations, addRecord, config);++ // 4. A sprinkle of free-roaming filler until the reference ink density.+ const targetSteps = Math.round(4800 * config.density);+ let attempts = 0;+ while (routedSteps < targetSteps && attempts < 3200) {+ attempts += 1;+ const trace = walkTrace(random, router, config, startWeights);+ if (trace) {+ addRecord(trace);+ replicateWalk(random, router, trace, addRecord);+ }+ }++ // 5. Tap combs hanging off the long runs (nets and filler alike).+ if (config.combs > 0) tapCombs(random, router, [...records], addRecord, config);++ return {+ width: columns * config.pitch,+ height: rows * config.pitch,+ pitch: config.pitch,+ stats,+ traces: records.map((record) => toTileTrace(record, config.pitch)),+ };+}++export function pathData(points, offsetX = 0, offsetY = 0) {+ return points+ .map(([x, y], index) => `${index ? "L" : "M"}${(x + offsetX).toFixed(2)},${(y + offsetY).toFixed(2)}`)+ .join("");+}++// Standalone seamless tile: the geometry is drawn 3 x 3 around the viewBox and+// clipped, so traces that wrap the torus edge reappear on the opposite side.+export function tileSvg(tile, options = {}) {+ const config = { ...DEFAULTS, ...options };+ const { width, height } = tile;+ const body = [];+ for (const trace of tile.traces) {+ if (trace.points.length < 2) continue;+ body.push(+ `<path d="${pathData(trace.points)}" fill="none" stroke="${config.traceColor}" ` ++ `stroke-width="${config.traceWidth}" stroke-linecap="butt" stroke-linejoin="miter"/>`,+ );+ }+ const vias = [];+ for (const trace of tile.traces) {+ for (const [cx, cy] of trace.vias) {+ vias.push(+ `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaOuterRadius}" fill="${config.traceColor}"/>` ++ `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaInnerRadius}" fill="${config.holeColor || config.background || "#0d1117"}"/>`,+ );+ }+ }+ const uses = [];+ for (let row = -1; row <= 1; row += 1) {+ for (let column = -1; column <= 1; column += 1) {+ uses.push(`<use href="#tile" transform="translate(${column * width}, ${row * height})"/>`);+ }+ }+ const backgroundRect = config.background+ ? `<rect width="${width}" height="${height}" fill="${config.background}"/>`+ : "";+ return (+ `<?xml version="1.0" encoding="UTF-8"?>\n` ++ `<svg xmlns="${SVG_NS}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` ++ `<defs><g id="tile">${body.join("")}${vias.join("")}</g>` ++ `<clipPath id="edge"><rect width="${width}" height="${height}"/></clipPath></defs>` ++ `${backgroundRect}<g clip-path="url(#edge)">${uses.join("")}</g></svg>`+ );+}++// --- signal routing ---------------------------------------------------------+//+// Signals ride the real centerlines. Each trace is resampled to ~3.5 unit+// spacing and indexed in a spatial hash; when a pulse reaches the end of a+// trace it looks for a nearby, direction-aligned point on another trace (in+// this tile or a neighbor) and hops there, exactly like the reference site.++const SIGNAL_SAMPLE = 3.5;+const HOP_RADIUS = 3.5;+const HASH_CELL = 8;+const signalIndexCache = new WeakMap();++function ensureSignalIndex(tile) {+ let index = signalIndexCache.get(tile);+ if (index) return index;+ const dense = tile.traces.map((trace) => {+ if (trace.points.length < 2) return [];+ const points = [];+ for (let segment = 1; segment < trace.points.length; segment += 1) {+ const [ax, ay] = trace.points[segment - 1];+ const [bx, by] = trace.points[segment];+ const steps = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / SIGNAL_SAMPLE));+ for (let step = segment === 1 ? 0 : 1; step <= steps; step += 1) {+ points.push([ax + ((bx - ax) * step) / steps, ay + ((by - ay) * step) / steps]);+ }+ }+ return points;+ });+ const buckets = new Map();+ dense.forEach((points, traceIndex) => {+ points.forEach((point, pointIndex) => {+ const key = `${Math.floor(point[0] / HASH_CELL)}:${Math.floor(point[1] / HASH_CELL)}`;+ let bucket = buckets.get(key);+ if (!bucket) buckets.set(key, (bucket = []));+ bucket.push([traceIndex, pointIndex]);+ });+ });+ // Trace endpoints (all mandated vias) — the launch pads for via hopping.+ const endpoints = [];+ dense.forEach((points, traceIndex) => {+ if (points.length < 6) return;+ endpoints.push({ trace: traceIndex, point: 0, direction: 1, x: points[0][0], y: points[0][1] });+ const last = points.length - 1;+ endpoints.push({ trace: traceIndex, point: last, direction: -1, x: points[last][0], y: points[last][1] });+ });+ index = { dense, buckets, endpoints };+ signalIndexCache.set(tile, index);+ return index;+}++function findHop(tile, absX, absY, dirX, dirY, visited) {+ const { dense, buckets } = ensureSignalIndex(tile);+ const baseTileX = Math.floor(absX / tile.width);+ const baseTileY = Math.floor(absY / tile.height);+ let best = null;++ for (let tileY = baseTileY - 2; tileY <= baseTileY + 2; tileY += 1) {+ for (let tileX = baseTileX - 2; tileX <= baseTileX + 2; tileX += 1) {+ const localX = absX - tileX * tile.width;+ const localY = absY - tileY * tile.height;+ const minCellX = Math.floor((localX - HOP_RADIUS) / HASH_CELL);+ const maxCellX = Math.floor((localX + HOP_RADIUS) / HASH_CELL);+ const minCellY = Math.floor((localY - HOP_RADIUS) / HASH_CELL);+ const maxCellY = Math.floor((localY + HOP_RADIUS) / HASH_CELL);+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {+ const bucket = buckets.get(`${cellX}:${cellY}`);+ if (!bucket) continue;+ for (const [traceIndex, pointIndex] of bucket) {+ if (visited.has(`${traceIndex}:${tileX}:${tileY}`)) continue;+ const point = dense[traceIndex][pointIndex];+ const distance = Math.hypot(point[0] - localX, point[1] - localY);+ if (distance >= HOP_RADIUS) continue;+ if (distance >= 1.2) {+ const toward =+ ((point[0] - localX) * dirX + (point[1] - localY) * dirY) / (distance || 1);+ if (toward < 0.35) continue;+ }+ for (const hopDirection of [1, -1]) {+ const ahead = pointIndex + hopDirection * 3;+ if (ahead < 0 || ahead >= dense[traceIndex].length) continue;+ const segX = dense[traceIndex][ahead][0] - point[0];+ const segY = dense[traceIndex][ahead][1] - point[1];+ const segLength = Math.hypot(segX, segY) || 1;+ const score = (segX * dirX + segY * dirY) / segLength - distance * 0.05;+ if (!best || score > best.score) {+ best = {+ trace: traceIndex,+ tileX,+ tileY,+ point: pointIndex,+ direction: hopDirection,+ score,+ };+ }+ }+ }+ }+ }+ }+ }+ return best && best.score > 0.1 ? best : null;+}++function buildSignalPath(tile, start, budget, minLength) {+ const { dense } = ensureSignalIndex(tile);+ const visited = new Set();+ const points = [];+ let travelled = 0;+ let cursor = { ...start };++ // The walk always finishes at a trace end (a mandated via) — the budget+ // only limits further mid-route hops, never cuts a pulse off mid-trace.+ for (;;) {+ const tracePoints = dense[cursor.trace];+ visited.add(`${cursor.trace}:${cursor.tileX}:${cursor.tileY}`);+ const offsetX = cursor.tileX * tile.width;+ const offsetY = cursor.tileY * tile.height;+ for (+ let index = cursor.point;+ index >= 0 && index < tracePoints.length;+ index += cursor.direction+ ) {+ const x = tracePoints[index][0] + offsetX;+ const y = tracePoints[index][1] + offsetY;+ if (points.length) {+ const last = points[points.length - 1];+ travelled += Math.hypot(x - last[0], y - last[1]);+ }+ points.push([x, y]);+ }+ if (points.length < 2 || travelled >= budget) break;++ const last = points[points.length - 1];+ const before = points[points.length - 2];+ const stepLength = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;+ const hop = findHop(+ tile,+ last[0],+ last[1],+ (last[0] - before[0]) / stepLength,+ (last[1] - before[1]) / stepLength,+ visited,+ );+ if (!hop) break;+ cursor = hop;+ }++ return travelled >= minLength ? { points, length: travelled } : null;+}++// --- DOM mount ---------------------------------------------------------------++export function createCircuitBackground(mount, options = {}) {+ if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");++ const id = `circuit-${++instanceCount}`;+ let config = { ...DEFAULTS, ...options };+ let random = mulberry32(hashSeed(`${config.seed}:signals`));+ let tile = generateCircuitTile(config);+ let svg;+ let usesGroup;+ let signalGroup;+ let resizeObserver;+ let activeSignals = 0;+ let signalTimer;+ let epoch = 0; // invalidates in-flight signal timeouts across regenerations+ let tilesAcross = 1;+ let tilesDown = 1;+ let topOffset = -config.tileHeight / 2;+ let pointer = null;+ const interactionTarget = mount.parentElement || mount;+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++ const originalPosition = mount.style.position;+ const computedPosition = getComputedStyle(mount).position;+ if (computedPosition === "static") mount.style.position = "relative";+ mount.style.overflow = "hidden";++ function buildSvg() {+ svg?.remove();+ epoch += 1;+ activeSignals = 0;+ tile = generateCircuitTile(config);+ random = mulberry32(hashSeed(`${config.seed}:signals`));+ svg = svgEl("svg", {+ "aria-hidden": "true",+ focusable: "false",+ xmlns: SVG_NS,+ });+ svg.dataset.circuitBackground = "";+ Object.assign(svg.style, {+ position: "absolute",+ left: "0",+ top: "0",+ display: "block",+ maxWidth: "none",+ pointerEvents: "none",+ overflow: "visible",+ });++ const defs = svgEl("defs");+ const tileGroup = svgEl("g", { id: `${id}-tile` });+ const traceGroup = svgEl("g", {+ fill: "none",+ stroke: config.traceColor,+ "stroke-width": config.traceWidth,+ "stroke-linecap": "butt",+ "stroke-linejoin": "miter",+ });+ const viaGroup = svgEl("g");+ for (const trace of tile.traces) {+ if (trace.points.length >= 2) {+ traceGroup.appendChild(svgEl("path", { d: pathData(trace.points) }));+ }+ for (const [cx, cy] of trace.vias) {+ viaGroup.appendChild(+ svgEl("circle", { cx, cy, r: config.viaOuterRadius, fill: config.traceColor }),+ );+ viaGroup.appendChild(+ svgEl("circle", {+ cx,+ cy,+ r: config.viaInnerRadius,+ fill: "var(--circuit-hole, #0d1117)",+ }),+ );+ }+ }+ tileGroup.append(traceGroup, viaGroup);+ defs.appendChild(tileGroup);+ svg.appendChild(defs);++ usesGroup = svgEl("g");+ signalGroup = svgEl("g", { fill: "none" });+ svg.append(usesGroup, signalGroup);+ mount.prepend(svg);+ layout();+ }++ function layout() {+ const width = Math.max(1, mount.clientWidth);+ const height = Math.max(1, mount.clientHeight);+ const tilePixelWidth = tile.width * config.scale;+ const tilePixelHeight = tile.height * config.scale;+ tilesAcross = Math.max(1, Math.ceil(width / tilePixelWidth));+ tilesDown = Math.max(2, Math.ceil(height / tilePixelHeight) + 1);+ topOffset = -tile.height / 2;+ const viewWidth = tilesAcross * tile.width;+ const viewHeight = tilesDown * tile.height;++ svg.setAttribute("width", `${viewWidth * config.scale}`);+ svg.setAttribute("height", `${viewHeight * config.scale}`);+ svg.setAttribute("viewBox", `0 ${-topOffset} ${viewWidth} ${viewHeight}`);+ usesGroup.replaceChildren();+ // The extra ring of tiles paints the traces that wrap in from neighbors.+ for (let row = -1; row <= tilesDown; row += 1) {+ for (let column = -1; column <= tilesAcross; column += 1) {+ usesGroup.appendChild(+ svgEl("use", {+ href: `#${id}-tile`,+ transform: `translate(${column * tile.width}, ${row * tile.height})`,+ }),+ );+ }+ }+ }++ // Pointer-biased launch pad: candidates are scored by the distance from+ // the ACTUAL launch point (a trace end, not the trace middle) to the+ // cursor, plus spawnRadius worth of jitter — so spawns cluster within+ // roughly spawnRadius of the pointer without a hard cutoff.+ function pickStart() {+ const { dense } = ensureSignalIndex(tile);+ const nearPointer = pointer && performance.now() - pointer.at < 4000;+ let choice = null;+ let bestScore = Infinity;+ const tries = nearPointer ? 40 : 4;+ for (let attempt = 0; attempt < tries; attempt += 1) {+ const trace = Math.floor(random() * dense.length);+ const tileX = Math.floor(random() * tilesAcross);+ const tileY = Math.floor(random() * tilesDown);+ if (dense[trace].length < 6) continue;+ const fromStart = random() < 0.5;+ const candidate = {+ trace,+ tileX,+ tileY,+ point: fromStart ? 0 : dense[trace].length - 1,+ direction: fromStart ? 1 : -1,+ };+ if (!nearPointer) return candidate;+ const [padX, padY] = dense[trace][candidate.point];+ const score =+ Math.hypot(padX + tileX * tile.width - pointer.x, padY + tileY * tile.height - pointer.y) ++ config.spawnRadius * random();+ if (score < bestScore) {+ bestScore = score;+ choice = candidate;+ }+ }+ return choice;+ }++ // Launch pads (trace-end vias) near an ended signal, closest first, so the+ // replacement reads as the signal hopping vias onto a neighboring trace.+ function nearbyEndpointStarts(absX, absY, excludeTrace) {+ const { endpoints } = ensureSignalIndex(tile);+ const baseTileX = Math.floor(absX / tile.width);+ const baseTileY = Math.floor(absY / tile.height);+ const candidates = [];+ for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+ for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+ const localX = absX - tileX * tile.width;+ const localY = absY - tileY * tile.height;+ for (const endpoint of endpoints) {+ if (endpoint.trace === excludeTrace) continue;+ const distance = Math.hypot(endpoint.x - localX, endpoint.y - localY);+ if (distance > 2 && distance < config.hopRadius) {+ candidates.push({ endpoint, tileX, tileY, distance: distance + random() * 20 });+ }+ }+ }+ }+ candidates.sort((a, b) => a.distance - b.distance);+ return candidates.slice(0, 6).map((pick) => ({+ trace: pick.endpoint.trace,+ tileX: pick.tileX,+ tileY: pick.tileY,+ point: pick.endpoint.point,+ direction: pick.endpoint.direction,+ }));+ }++ // phase (0..1) fast-forwards the animation so boot signals are already+ // mid-journey. A chain keeps its speedFactor, color AND survival rate across+ // hops — the signal that entered blue exits blue, and a click-born chain+ // keeps decaying at burstSurvival while ambient chains use hopSurvival.+ // Occasionally (colorDrift) a leg crossfades to a new color over its whole+ // run, so palettes shift gradually, never pop.+ function launchSignal(path, phase = 0, speedFactor, chainColor, survival) {+ const factor = speedFactor ?? 0.85 + random() * 0.3;+ const surviveChance = survival ?? config.hopSurvival;+ const colorFrom = chainColor ?? weightedChoice(random, config.signalColors);+ const colorTo =+ random() < config.colorDrift ? weightedChoice(random, config.signalColors) : colorFrom;+ const d = pathData(path.points);+ const speed = config.signalSpeed * factor;+ const head = config.signalLength;+ const tail = head * 2.2;+ const length = path.length;+ const total = length + tail;+ const duration = (total / speed) * 1000;+ const group = svgEl("g");+ // Faint persistent wake along the whole route.+ const wake = svgEl("path", { d, stroke: colorFrom, "stroke-width": 2, opacity: 0 });+ // Soft glow under the head — a wide translucent stroke is far cheaper+ // than a drop-shadow filter re-rasterizing every frame.+ const glow = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 7,+ "stroke-linecap": "round",+ "stroke-opacity": 0.1,+ "stroke-dasharray": `${head} ${total}`,+ "stroke-dashoffset": head,+ });+ // Long dim comet tail behind the head.+ const comet = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 2,+ "stroke-linecap": "round",+ "stroke-opacity": 0.16,+ "stroke-dasharray": `${tail} ${total}`,+ "stroke-dashoffset": tail,+ });+ // Bright head.+ const pulse = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 2.4,+ "stroke-linecap": "round",+ "stroke-dasharray": `${head} ${total}`,+ "stroke-dashoffset": head,+ "stroke-opacity": 0.72,+ });+ group.append(wake, glow, comet, pulse);+ signalGroup.appendChild(group);+ activeSignals += 1;++ // All layers share the same leading edge; each runs from its own dash+ // length down to -(total - itsDash) so they exit together. Color rides+ // along in the same keyframes when this leg is a crossfade.+ const timing = { duration, easing: "linear", fill: "forwards" };+ const animations = [+ pulse.animate(+ [+ { strokeDashoffset: head, stroke: colorFrom },+ { strokeDashoffset: head - total, stroke: colorTo },+ ],+ timing,+ ),+ glow.animate(+ [+ { strokeDashoffset: head, stroke: colorFrom },+ { strokeDashoffset: head - total, stroke: colorTo },+ ],+ timing,+ ),+ comet.animate(+ [+ { strokeDashoffset: tail, stroke: colorFrom },+ { strokeDashoffset: tail - total, stroke: colorTo },+ ],+ timing,+ ),+ wake.animate(+ [+ { opacity: 0, stroke: colorFrom },+ { opacity: 0.06 },+ { opacity: 0.06 },+ { opacity: 0, stroke: colorTo },+ ],+ { duration, fill: "forwards" },+ ),+ ];+ const headArrival = (duration * length) / total;+ const skip = Math.min(phase, 0.95) * headArrival;+ if (skip > 0) for (const animation of animations) animation.currentTime = skip;++ // The signal is logically done when its head reaches the final via (the+ // tail after that is just residue): blip, release its population slot,+ // and — in the same beat — launch its successor from a nearby via. The+ // handoff is synchronous so motion never pauses.+ const [endX, endY] = path.points[path.points.length - 1];+ const myEpoch = epoch;+ window.setTimeout(() => {+ if (myEpoch !== epoch) return;+ activeSignals -= 1;+ const blip = svgEl("circle", {+ cx: endX,+ cy: endY,+ r: 2,+ fill: "none",+ stroke: colorTo,+ "stroke-width": 1.5,+ "stroke-opacity": 0.6,+ });+ group.appendChild(blip);+ blip.animate(+ [+ { r: 2, strokeOpacity: 0.6 },+ { r: 8, strokeOpacity: 0 },+ ],+ { duration: 500, easing: "ease-out", fill: "forwards" },+ );+ // The successor inherits the color the chain ARRIVED with.+ continueChain(endX, endY, path.startTrace, factor, colorTo, surviveChance);+ }, headArrival - skip);++ window.setTimeout(() => {+ group.remove();+ }, duration + 620 - skip);+ }++ // Every signal is mortal: on each via hop it rolls against its chain's+ // survival rate (hopSurvival for ambient chains, burstSurvival for+ // click-born ones — passed in so a chain keeps its own decay for life).+ // Survivors continue synchronously from a nearby via, inheriting speed and+ // color (arrive blue → depart blue); the rest just die with their arrival+ // blip. Replenishment is NOT done here — the ambient spawn timer refills+ // the population near the cursor, so a death simply lowers the census and+ // the next tick fountains a fresh signal in. Low survival => most signals+ // die after one leg => the fountain stays busy near the pointer.+ function continueChain(endX, endY, excludeTrace, speedFactor, chainColor, survival) {+ if (!config.signals || reducedMotion || document.hidden) return;+ if (random() >= (survival ?? config.hopSurvival)) return;+ if (activeSignals >= config.maxBurstSignals) return;+ spawnSignal(nearbyEndpointStarts(endX, endY, excludeTrace), 0, speedFactor, chainColor, survival);+ }++ // preferred: array of nearby launch pads tried in order (short legs are+ // fine for chain continuations); falls back to random picks elsewhere.+ function spawnSignal(preferred, phase = 0, speedFactor, chainColor, survival) {+ if (!config.signals || reducedMotion) return false;+ if (activeSignals >= config.maxBurstSignals) return false;+ const preferredCount = preferred ? preferred.length : 0;+ const candidates = preferred ? [...preferred] : [];+ for (let attempt = 0; attempt < 8; attempt += 1) {+ const start = pickStart();+ if (start) candidates.push(start);+ }+ for (let index = 0; index < candidates.length; index += 1) {+ const minLength = index < preferredCount ? 60 : 140;+ const path = buildSignalPath(tile, candidates[index], 450 + random() * 900, minLength);+ if (path) {+ path.startTrace = candidates[index].trace;+ launchSignal(path, phase, speedFactor, chainColor, survival);+ return true;+ }+ }+ return false;+ }++ // Fill the population up to maxSignals with fast-forwarded signals, so a+ // fresh board (or a tab returning to view after its chains drained) is+ // already mid-journey instead of visibly cold-starting.+ function prewarm() {+ if (!config.signals || reducedMotion || document.hidden) return;+ let guard = 0;+ while (activeSignals < config.maxSignals && guard < config.maxSignals + 2) {+ guard += 1;+ if (!spawnSignal(null, random() * 0.85)) break;+ }+ }++ // The passive fountain, like the reference site: a steady cadence of new+ // signals near the cursor (pickStart biases toward the pointer when it moved+ // recently, spreads across the board when idle), refilling toward+ // maxSignals. Because signals are mortal, the census keeps dipping below the+ // target and the timer keeps topping it up — so fresh signals keep appearing+ // at the pointer. The balance the user wants is fast spawn + short life:+ // a small signalInterval with a low hopSurvival gives a lively cursor+ // fountain; long life lets the population sit at the cap and spawn rarely.+ function startSignals() {+ window.clearInterval(signalTimer);+ if (!config.signals || reducedMotion) return;+ prewarm();+ signalTimer = window.setInterval(() => {+ if (!document.hidden && activeSignals < config.maxSignals) spawnSignal(null);+ }, config.signalInterval);+ }++ function burstAt(x, y) {+ const { dense } = ensureSignalIndex(tile);+ const candidates = [];+ const baseTileX = Math.floor(x / tile.width);+ const baseTileY = Math.floor(y / tile.height);+ for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+ for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+ if (tileX < -1 || tileX > tilesAcross || tileY < -1 || tileY > tilesDown) continue;+ const localX = x - tileX * tile.width;+ const localY = y - tileY * tile.height;+ for (let trace = 0; trace < dense.length; trace += 1) {+ let bestPoint = -1;+ let bestDistance = 40;+ for (let point = 0; point < dense[trace].length; point += 1) {+ const distance = Math.hypot(dense[trace][point][0] - localX, dense[trace][point][1] - localY);+ if (distance < bestDistance) {+ bestDistance = distance;+ bestPoint = point;+ }+ }+ if (bestPoint >= 0) candidates.push({ trace, tileX, tileY, point: bestPoint, distance: bestDistance });+ }+ }+ }+ candidates.sort((a, b) => a.distance - b.distance);+ let spawned = 0;+ for (const candidate of candidates.slice(0, Math.ceil(config.burstBoost / 2) + 1)) {+ for (const direction of [1, -1]) {+ if (activeSignals >= config.maxBurstSignals || spawned >= config.burstBoost) return;+ const path = buildSignalPath(+ tile,+ { trace: candidate.trace, tileX: candidate.tileX, tileY: candidate.tileY, point: candidate.point, direction },+ 260 + random() * 380,+ 50,+ );+ if (path) {+ path.startTrace = candidate.trace;+ // Clicked chains decay at burstSurvival, independent of the ambient+ // hopSurvival, so a click can ripple long even when passive life is short.+ launchSignal(path, 0, undefined, undefined, config.burstSurvival);+ spawned += 1;+ }+ }+ }+ }++ function onPointerMove(event) {+ const rect = mount.getBoundingClientRect();+ pointer = {+ x: (event.clientX - rect.left) / config.scale,+ y: (event.clientY - rect.top) / config.scale - topOffset,+ at: performance.now(),+ };+ }++ function onClick(event) {+ if (!config.interactive || !config.signals || reducedMotion) return;+ const rect = mount.getBoundingClientRect();+ burstAt(+ (event.clientX - rect.left) / config.scale,+ (event.clientY - rect.top) / config.scale - topOffset,+ );+ }++ function onVisibility() {+ if (!document.hidden) prewarm();+ }++ function regenerate(nextOptions = {}) {+ config = { ...config, ...nextOptions };+ buildSvg();+ startSignals();+ return api;+ }++ function exportSvg() {+ const clone = svg.cloneNode(true);+ clone.querySelectorAll("g[fill='none'] > g").forEach((node) => node.remove());+ clone.removeAttribute("style");+ clone.setAttribute("width", String(tilesAcross * tile.width * config.scale));+ clone.setAttribute("height", String(tilesDown * tile.height * config.scale));+ return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+ }++ function exportTileSvg(tileOptions = {}) {+ return tileSvg(tile, { ...config, ...tileOptions });+ }++ function destroy() {+ epoch += 1;+ window.clearInterval(signalTimer);+ document.removeEventListener("visibilitychange", onVisibility);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("click", onClick);+ svg?.remove();+ if (computedPosition === "static") mount.style.position = originalPosition;+ }++ const api = {+ regenerate,+ spawnSignal,+ exportSvg,+ exportTileSvg,+ destroy,+ get tile() {+ return tile;+ },+ };+ buildSvg();+ resizeObserver = new ResizeObserver(layout);+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ // Hidden tabs let the census drain (the timer no-ops); refill on return.+ document.addEventListener("visibilitychange", onVisibility);+ startSignals();+ return api;+}++// Named animation presets. Each is a partial config merged over the current+// settings; only signal/animation knobs are touched so generation geometry is+// left alone. `signals: false` gives a fully static board.+const ANIMATION_PRESETS = Object.freeze({+ "None": { signals: false },+ "Fireflies": {+ // A few long-lived signals drifting/bouncing across the whole screen.+ signals: true,+ signalInterval: 850,+ maxSignals: 5,+ spawnRadius: 200,+ hopSurvival: 0.92,+ burstSurvival: 0.9,+ colorDrift: 0.12,+ },+ "Swarm": {+ // Same persistent drift, but a dense population.+ signals: true,+ signalInterval: 360,+ maxSignals: 18,+ spawnRadius: 200,+ hopSurvival: 0.9,+ burstSurvival: 0.9,+ colorDrift: 0.18,+ },+ "Cursor sparks": {+ // Really short life + very fast spawning tight around the cursor.+ signals: true,+ signalInterval: 90,+ maxSignals: 14,+ spawnRadius: 35,+ hopSurvival: 0.1,+ burstSurvival: 0.6,+ colorDrift: 0.15,+ },+ "Slow streams": {+ // Longer-lived flowing chains at a relaxed cadence.+ signals: true,+ signalInterval: 560,+ maxSignals: 8,+ spawnRadius: 130,+ hopSurvival: 0.72,+ burstSurvival: 0.82,+ colorDrift: 0.2,+ },+});++export { DEFAULTS, ANIMATION_PRESETS };
public/vendor/circuit-trace-generators/circuit-geometry-v9.jsadded+1112@@ -0,0 +1,1112 @@+import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";+import {+ createObstacleField,+ obstacleEdgeWeight,+ obstacleOutlineTraces,+} from "./circuit-obstacles-v9.js?v=1";++const SVG_NS = "http://www.w3.org/2000/svg";++const DEFAULTS = Object.freeze({+ seed: "nitrogen",+ pitch: 7.895,+ scale: 1000 / 900,+ density: 1,+ fill: 0.668,+ routeLength: 0.78,+ horizontalFlow: 0.82,+ flowAngle: 0,+ swirl: 0.28,+ swirlScale: 0.8,+ diagonalFlow: 0.58,+ jogLength: 0.72,+ channelAttraction: 0.78,+ longFlow: 0.18,+ edgeDensity: 0.22,+ obstaclePreset: "none",+ obstacleMode: "avoid",+ obstacleScale: 0.66,+ obstacleX: 0.5,+ obstacleY: 0.66,+ obstacleClearance: 1.1,+ obstaclePull: 0.82,+ obstacles: Object.freeze([]),+ traceColor: "#151b25",+ traceWidth: 1.58,+ featureScale: 1,+ viaOuterRadius: 3.34,+ viaInnerRadius: 1.76,+ motion: true,+ interactive: true,+ animationSystem: "conductor",+ liveliness: 0.82,+ signalHalos: true,+ signalConvoys: true,+ signalWaves: true,+ pointerWake: true,+ nodeFlashes: true,+ ambientCurrents: true,+ ambientNodes: true,+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signalInterval: 420,+ signalSpeed: 800,+ signalLength: 110,+ maxSignals: 28,+ ambientCurrentCount: 18,+ ambientNodeCount: 22,+ waveInterval: 6200,+ pointerWakeInterval: 170,+ sweepDuration: 15200,+ sweepAngle: 18,+ sweepWidth: 0.075,+ bloomInterval: 7600,+ bloomDuration: 4800,+ pointerRadius: 0.19,+ atmosphereStrength: 1,+});++const ANIMATION_PRESETS = Object.freeze({+ none: Object.freeze({+ animationSystem: "none",+ motion: false,+ liveliness: 0,+ atmosphereStrength: 0,+ }),+ conductor: Object.freeze({+ animationSystem: "conductor",+ motion: true,+ liveliness: 0.82,+ signalHalos: true,+ signalConvoys: true,+ signalWaves: true,+ pointerWake: true,+ nodeFlashes: true,+ ambientCurrents: true,+ ambientNodes: true,+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signalInterval: 420,+ signalSpeed: 800,+ signalLength: 110,+ maxSignals: 28,+ ambientCurrentCount: 18,+ ambientNodeCount: 22,+ waveInterval: 6200,+ pointerWakeInterval: 170,+ atmosphereStrength: 1,+ }),+ adom: Object.freeze({+ animationSystem: "adom",+ motion: true,+ liveliness: 0.88,+ sweepDuration: 15200,+ sweepAngle: 18,+ sweepWidth: 0.075,+ bloomInterval: 7600,+ bloomDuration: 4800,+ pointerRadius: 0.19,+ atmosphereStrength: 1,+ }),+});++const DIRECTIONS = [[1, 0], [0, 1], [-1, 0], [0, -1]];+let instanceCount = 0;++function hashSeed(value) {+ let hash = 2166136261;+ for (const char of String(value)) {+ hash ^= char.charCodeAt(0);+ hash = Math.imul(hash, 16777619);+ }+ return hash >>> 0;+}++function mulberry32(seed) {+ return function random() {+ let value = (seed += 0x6d2b79f5);+ value = Math.imul(value ^ (value >>> 15), value | 1);+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;+ };+}++function integer(random, min, max) {+ return Math.floor(random() * (max - min + 1)) + min;+}++function weighted(random, entries) {+ let total = 0;+ for (const [, weight] of entries) total += weight;+ let roll = random() * total;+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries.at(-1)?.[0];+}++function shuffle(random, values) {+ for (let index = values.length - 1; index > 0; index -= 1) {+ const swap = integer(random, 0, index);+ [values[index], values[swap]] = [values[swap], values[index]];+ }+ return values;+}++function cellIndex(x, y, columns) {+ return y * columns + x;+}++function runLength(random, perpendicular, config, vector = [1, 0]) {+ const localDiagonalness = diagonalness(vector);+ let result;+ if (perpendicular) {+ result = random() < 0.22 ? integer(random, 4, 7) : integer(random, 1, 4);+ const scale = config.jogLength + (1 - config.jogLength) * localDiagonalness;+ return Math.max(1, Math.round(result * scale));+ }+ const roll = random();+ if (roll < 0.14) result = integer(random, 16, 28);+ else if (roll < 0.52) result = integer(random, 9, 16);+ else result = integer(random, 4, 10);+ const scale = 1 - localDiagonalness * config.diagonalFlow * 0.9;+ return Math.max(1, Math.round(result * scale));+}++function traceBudget(random, columns, rows, config) {+ const ceiling = Math.max(12, Math.min(86, Math.floor(Math.hypot(columns, rows) * 0.52)));+ const roll = random();+ let result;+ if (roll < 0.2) result = integer(random, Math.min(42, ceiling), ceiling);+ else if (roll < 0.65) result = integer(random, 24, Math.min(50, ceiling));+ else result = integer(random, 12, Math.min(28, ceiling));+ return Math.max(6, Math.round(result * config.routeLength));+}++function createFlowField(config, columns, rows) {+ const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}:flow-field`));+ const minimumDimension = Math.min(columns, rows);+ const count = Math.max(2, Math.min(5, Math.round(columns * rows / 5200)));+ const vortices = [];+ for (let index = 0; index < count; index += 1) {+ const radius = minimumDimension * config.swirlScale * (0.55 + random() * 0.45);+ vortices.push({+ x: columns * (0.08 + random() * 0.84),+ y: rows * (0.08 + random() * 0.84),+ radius: Math.max(8, radius),+ spin: random() < 0.5 ? -1 : 1,+ strength: 0.72 + random() * 0.56,+ });+ }+ return { vortices };+}++function flowVector(x, y, angleDegrees, field, config) {+ const radians = angleDegrees * Math.PI / 180;+ let vx = Math.cos(radians);+ let vy = Math.sin(radians);+ for (const vortex of field.vortices) {+ const dx = x - vortex.x;+ const dy = y - vortex.y;+ const distance = Math.hypot(dx, dy);+ if (distance < 0.0001) continue;+ const falloff = Math.exp(-(distance * distance) / (2 * vortex.radius * vortex.radius));+ const influence = config.swirl * vortex.strength * falloff;+ vx += -dy / distance * vortex.spin * influence;+ vy += dx / distance * vortex.spin * influence;+ }+ const length = Math.hypot(vx, vy) || 1;+ return [vx / length, vy / length];+}++function closestDirection(vector) {+ let bestDirection = 0;+ let bestScore = -Infinity;+ for (let direction = 0; direction < DIRECTIONS.length; direction += 1) {+ const score = DIRECTIONS[direction][0] * vector[0] + DIRECTIONS[direction][1] * vector[1];+ if (score > bestScore) {+ bestScore = score;+ bestDirection = direction;+ }+ }+ return bestDirection;+}++function diagonalness(vector) {+ return 1 - Math.abs(Math.abs(vector[0]) - Math.abs(vector[1]));+}++function lookAhead(x, y, direction, blocked, local, columns, rows) {+ const [dx, dy] = DIRECTIONS[direction];+ let open = 0;+ for (let step = 1; step <= 5; step += 1) {+ const nx = x + dx * step;+ const ny = y + dy * step;+ if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) break;+ const index = cellIndex(nx, ny, columns);+ if (blocked[index] || local.has(index)) break;+ open += 1;+ }+ return open;+}++function growOne(start, initialDirection, limit, blocked, local, columns, rows, random, config, field, angleDegrees, travelSign = 1, obstacle = null) {+ const points = [start];+ let [x, y] = start;+ let direction = initialDirection;+ const initialVector = flowVector(x, y, angleDegrees, field, config);+ const initialTarget = [initialVector[0] * travelSign, initialVector[1] * travelSign];+ let remaining = runLength(random, false, config, initialTarget);++ while (points.length <= limit) {+ const candidates = [];+ const directions = [direction, (direction + 1) % 4, (direction + 3) % 4];+ const sampledVector = flowVector(x, y, angleDegrees, field, config);+ const targetVector = [sampledVector[0] * travelSign, sampledVector[1] * travelSign];+ const primaryDirection = closestDirection(targetVector);+ for (const candidate of directions) {+ const [dx, dy] = DIRECTIONS[candidate];+ const nx = x + dx;+ const ny = y + dy;+ // Keep every endpoint (and therefore every via) fully inside the canvas.+ if (nx < 1 || ny < 1 || nx >= columns - 1 || ny >= rows - 1) continue;+ const index = cellIndex(nx, ny, columns);+ if (blocked[index] || local.has(index)) continue;+ const open = lookAhead(x, y, candidate, blocked, local, columns, rows);+ if (!open) continue;+ const straight = candidate === direction;+ let weight = straight ? (remaining > 0 ? 15 : 1.6) : (remaining > 0 ? 0.12 : 4.2);+ const alignment = dx * targetVector[0] + dy * targetVector[1];+ if (alignment < -0.16) continue;+ weight *= 0.45 + open * 0.34;+ let neighbors = 0;+ if (candidate % 2 === 0) {+ weight *= 0.45 + Math.max(0, alignment) * 2.2;+ if (ny > 0 && blocked[cellIndex(nx, ny - 1, columns)]) neighbors += 1;+ if (ny < rows - 1 && blocked[cellIndex(nx, ny + 1, columns)]) neighbors += 1;+ } else {+ if (nx > 0 && blocked[cellIndex(nx - 1, ny, columns)]) neighbors += 1;+ if (nx < columns - 1 && blocked[cellIndex(nx + 1, ny, columns)]) neighbors += 1;+ }+ weight *= 1 + neighbors * config.channelAttraction;+ weight *= obstacleEdgeWeight(+ obstacle,+ nx,+ ny,+ dx,+ dy,+ columns,+ Math.max(0, Number(config.obstaclePull) || 0),+ );+ candidates.push([candidate, weight]);+ }+ if (!candidates.length) break;+ const nextDirection = weighted(random, candidates);+ if (nextDirection !== direction) {+ direction = nextDirection;+ remaining = runLength(random, direction % 2 !== primaryDirection % 2, config, targetVector);+ } else {+ remaining -= 1;+ }+ x += DIRECTIONS[direction][0];+ y += DIRECTIONS[direction][1];+ const index = cellIndex(x, y, columns);+ local.add(index);+ points.push([x, y]);+ }+ return points;+}++function chamfer(path) {+ if (path.length < 3) return path;+ const result = [path[0]];+ for (let index = 1; index < path.length - 1; index += 1) {+ const previous = path[index - 1];+ const current = path[index];+ const next = path[index + 1];+ const ax = current[0] - previous[0];+ const ay = current[1] - previous[1];+ const bx = next[0] - current[0];+ const by = next[1] - current[1];+ if (ax * bx + ay * by === 0 && (ax !== bx || ay !== by)) continue;+ result.push(current);+ }+ result.push(path.at(-1));+ return result;+}++function simplify(path) {+ if (path.length < 3) return path;+ const result = [path[0]];+ for (let index = 1; index < path.length - 1; index += 1) {+ const previous = result.at(-1);+ const current = path[index];+ const next = path[index + 1];+ const ax = Math.sign(current[0] - previous[0]);+ const ay = Math.sign(current[1] - previous[1]);+ const bx = Math.sign(next[0] - current[0]);+ const by = Math.sign(next[1] - current[1]);+ if (ax !== bx || ay !== by) result.push(current);+ }+ result.push(path.at(-1));+ return result;+}++function pathLength(points) {+ let length = 0;+ for (let index = 1; index < points.length; index += 1) {+ length += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);+ }+ return length;+}++function commitTrace(cells, state) {+ if (cells.length < 6) return false;+ const { blocked, columns, rows, pitch, traces } = state;+ for (const [x, y] of cells) blocked[cellIndex(x, y, columns)] = 1;+ state.occupied += cells.length;+ const shaped = simplify(chamfer(cells));+ const points = shaped.map(([x, y]) => [x * pitch, y * pitch]);+ traces.push({+ points,+ length: pathLength(points),+ viaStart: true,+ viaEnd: true,+ });+ return true;+}++function makeInteriorTrace(start, state, random, config) {+ const { blocked, columns, rows } = state;+ const startIndex = cellIndex(start[0], start[1], columns);+ if (blocked[startIndex]) return false;+ const followsPrimary = random() < config.horizontalFlow;+ const angleDegrees = config.flowAngle + (followsPrimary ? 0 : 90);+ const startVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+ const direction = closestDirection(startVector);+ const budget = traceBudget(random, columns, rows, config);+ const forwardBudget = Math.max(3, Math.floor(budget * (0.42 + random() * 0.16)));+ const local = new Set([startIndex]);+ const forward = growOne(start, direction, forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, 1, state.obstacle);+ const backward = growOne(start, (direction + 2) % 4, budget - forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, -1, state.obstacle);+ const cells = backward.slice(1).reverse().concat(forward);+ return commitTrace(cells, state);+}++function makeEdgeTrace(start, direction, state, random, config, options = {}) {+ const { blocked, columns, rows } = state;+ const startIndex = cellIndex(start[0], start[1], columns);+ if (blocked[startIndex]) return false;+ const local = new Set([startIndex]);+ const baseBudget = integer(random, 10, Math.max(14, Math.min(48, Math.floor(Math.hypot(columns, rows) * 0.3))));+ const budget = options.budget ?? Math.max(6, Math.round(baseBudget * config.routeLength));+ const angleDegrees = options.angleDegrees ?? config.flowAngle + (random() < config.horizontalFlow ? 0 : 90);+ const sampledVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+ const inward = DIRECTIONS[direction][0] * sampledVector[0] + DIRECTIONS[direction][1] * sampledVector[1];+ const cells = growOne(start, direction, budget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, inward >= 0 ? 1 : -1, state.obstacle);+ if (cells.length < (options.minimumLength ?? 6)) return false;+ return commitTrace(cells, state);+}++export function generateCircuitField(options = {}) {+ const config = { ...DEFAULTS, ...options };+ const width = Math.max(240, config.width || 900);+ const height = Math.max(180, config.height || 450);+ const featureScale = Math.max(0.25, config.featureScale);+ const pitch = config.pitch * featureScale;+ const columns = Math.max(30, Math.floor(width / pitch));+ const rows = Math.max(22, Math.floor(height / pitch));+ const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}`));+ const normalizedWidth = (columns - 1) * pitch;+ const normalizedHeight = (rows - 1) * pitch;+ const obstacle = createObstacleField(config, {+ width: normalizedWidth,+ height: normalizedHeight,+ pitch,+ columns,+ rows,+ });+ const state = {+ blocked: obstacle ? obstacle.blocked.slice() : new Uint8Array(columns * rows),+ columns,+ rows,+ pitch,+ traces: [],+ occupied: 0,+ obstacle,+ flowField: createFlowField(config, columns, rows),+ };+ const outlines = obstacleOutlineTraces(obstacle, {+ pitch,+ viaOuterRadius: config.viaOuterRadius * featureScale,+ });+ state.traces.push(...outlines);++ const flowRadians = config.flowAngle * Math.PI / 180;+ const absoluteX = Math.abs(Math.cos(flowRadians));+ const absoluteY = Math.abs(Math.sin(flowRadians));+ const primaryHorizontalWeight = absoluteX / (absoluteX + absoluteY || 1);+ const horizontalWeight = config.horizontalFlow * primaryHorizontalWeight + (1 - config.horizontalFlow) * (1 - primaryHorizontalWeight);+ const verticalWeight = 1 - horizontalWeight;+ const horizontalCarriers = [];+ for (let y = 1; y < rows - 1; y += 1) {+ horizontalCarriers.push([[1, y], 0], [[columns - 2, y], 2]);+ }+ shuffle(random, horizontalCarriers);+ const horizontalTarget = Math.round(rows * config.longFlow * horizontalWeight);+ let horizontalCarrierCount = 0;+ for (const [start, direction] of horizontalCarriers) {+ if (horizontalCarrierCount >= horizontalTarget) break;+ const minimumBudget = Math.max(12, Math.round(columns * 0.58 * config.routeLength));+ const maximumBudget = Math.max(minimumBudget, Math.round(columns * 1.02 * config.routeLength));+ const budget = integer(random, minimumBudget, maximumBudget);+ if (makeEdgeTrace(start, direction, state, random, config, {+ budget,+ angleDegrees: config.flowAngle,+ minimumLength: Math.round(columns * 0.3),+ })) horizontalCarrierCount += 1;+ }++ const verticalCarriers = [];+ for (let x = 1; x < columns - 1; x += 1) {+ verticalCarriers.push([[x, 1], 1], [[x, rows - 2], 3]);+ }+ shuffle(random, verticalCarriers);+ const verticalTarget = Math.round(columns * config.longFlow * verticalWeight * 0.34);+ let verticalCarrierCount = 0;+ for (const [start, direction] of verticalCarriers) {+ if (verticalCarrierCount >= verticalTarget) break;+ const minimumBudget = Math.max(12, Math.round(rows * 0.58 * config.routeLength));+ const maximumBudget = Math.max(minimumBudget, Math.round(rows * 1.02 * config.routeLength));+ const budget = integer(random, minimumBudget, maximumBudget);+ if (makeEdgeTrace(start, direction, state, random, config, {+ angleDegrees: config.flowAngle,+ budget,+ minimumLength: Math.round(rows * 0.3),+ })) verticalCarrierCount += 1;+ }++ const edgeSeeds = [];+ for (let x = 1; x < columns - 1; x += 1) {+ edgeSeeds.push([[x, 1], 1], [[x, rows - 2], 3]);+ }+ for (let y = 1; y < rows - 1; y += 1) {+ edgeSeeds.push([[1, y], 0], [[columns - 2, y], 2]);+ }+ shuffle(random, edgeSeeds);+ const edgeTarget = Math.round((columns + rows) * config.edgeDensity);+ let edgeCount = 0;+ for (const [start, direction] of edgeSeeds) {+ if (edgeCount >= edgeTarget) break;+ if (makeEdgeTrace(start, direction, state, random, config)) edgeCount += 1;+ }++ const interiorColumns = columns - 2;+ const interiorRows = rows - 2;+ const seeds = shuffle(random, Array.from(+ { length: interiorColumns * interiorRows },+ (_, index) => (Math.floor(index / interiorColumns) + 1) * columns + index % interiorColumns + 1,+ ));+ // Density controls occupied routing cells only. Feature scale changes the+ // physical size of the whole motif without secretly changing this target.+ const targetFill = Math.min(0.82, Math.max(0.18, config.fill * config.density));+ const fillCapacity = obstacle+ ? Math.max(1, interiorColumns * interiorRows - obstacle.blockedCells)+ : columns * rows;+ for (const index of seeds) {+ if (state.occupied / fillCapacity >= targetFill) break;+ if (state.blocked[index]) continue;+ makeInteriorTrace([index % columns, Math.floor(index / columns)], state, random, config);+ }++ return {+ width: normalizedWidth,+ height: normalizedHeight,+ columns,+ rows,+ fill: state.occupied / fillCapacity,+ traces: state.traces,+ obstacle: obstacle ? Object.freeze({+ count: obstacle.count,+ blockedCells: obstacle.blockedCells,+ outlineTraceCount: outlines.length,+ mode: obstacle.mode,+ }) : null,+ };+}++export function pathData(points) {+ return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");+}++function svgElement(name, attributes = {}) {+ const element = document.createElementNS(SVG_NS, name);+ for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));+ return element;+}++export function createCircuitBackground(mount, options = {}) {+ if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");+ const id = `circuit-field-${++instanceCount}`;+ let config = { ...DEFAULTS, ...options };+ let field;+ let svg;+ let motionCanvas;+ let motionContext;+ let brandGradient;+ let conductor;+ let resizeObserver;+ let resizeTimer;+ let frameRequest;+ let frameCount = 0;+ let activeTraces = 0;+ let bloomsTriggered = 0;+ let visuals = [];+ let blooms = [];+ let pointer = null;+ let motionRandom;+ let lastSize = "";+ let sceneStart = 0;+ let lastFrameTime = 0;+ let lastPaintTime = 0;+ let nextBloomAt = 0;+ let bloomCursor = 0;+ let focus = { x: 0, y: 0 };+ let rendererPixelRatio = 1;+ let targetFrameRate = 60;+ let frameInterval = 1000 / targetFrameRate;+ let energyBudget = 0;+ let sparkBudget = 0;+ let nodeBudget = 0;+ let drawnEnergyTraces = 0;+ let drawnSparks = 0;+ let drawnNodes = 0;+ const energyCandidates = [];+ const sparkCandidates = [];+ const nodeCandidates = [];+ const interactionTarget = mount.parentElement || mount;+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;+ const originalPosition = mount.style.position;+ if (getComputedStyle(mount).position === "static") mount.style.position = "relative";+ mount.style.overflow = "hidden";++ function life() {+ return Math.max(0, Math.min(1.5, config.liveliness));+ }++ function featureScale() {+ return Math.max(0.25, config.featureScale);+ }++ function clamp(value, minimum = 0, maximum = 1) {+ return Math.max(minimum, Math.min(maximum, value));+ }++ function colorAt(position) {+ if (position < 0.46) return "#00e6dc";+ if (position < 0.76) return "#39b8ff";+ return "#8c6bf7";+ }++ function addVia(group, point) {+ const scale = featureScale();+ group.append(+ svgElement("circle", { cx: point[0], cy: point[1], r: config.viaOuterRadius * scale, fill: config.traceColor }),+ svgElement("circle", { cx: point[0], cy: point[1], r: config.viaInnerRadius * scale, fill: "var(--circuit-hole, #0d1117)" }),+ );+ }++ function build(force = false) {+ const pixelWidth = Math.max(1, mount.clientWidth);+ const pixelHeight = Math.max(1, mount.clientHeight);+ const worldWidth = pixelWidth / config.scale;+ const worldHeight = pixelHeight / config.scale;+ const pitch = config.pitch * featureScale();+ const size = `${Math.floor(worldWidth / pitch)}x${Math.floor(worldHeight / pitch)}`;+ if (!force && size === lastSize) return false;+ lastSize = size;+ activeTraces = 0;+ visuals = [];+ blooms = [];+ conductor?.destroy();+ conductor = undefined;+ motionCanvas?.remove();+ motionCanvas = undefined;+ motionContext = undefined;+ brandGradient = undefined;+ svg?.remove();+ field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });+ const motionSeed = config.animationSystem === "conductor"+ ? `${config.seed}:${size}:signals`+ : `${config.seed}:${size}:adom-motion`;+ motionRandom = mulberry32(hashSeed(motionSeed));+ svg = svgElement("svg", {+ id, "aria-hidden": true, focusable: false,+ viewBox: `0 0 ${field.width} ${field.height}`,+ preserveAspectRatio: "none",+ });+ Object.assign(svg.style, {+ position: "absolute", inset: "0", width: "100%", height: "100%",+ display: "block", pointerEvents: "none",+ });++ const traceLayer = svgElement("g", { "data-static-traces": "" });+ for (const trace of field.traces) {+ traceLayer.appendChild(svgElement("path", {+ d: pathData(trace.points), fill: "none", stroke: config.traceColor,+ "stroke-width": config.traceWidth * config.featureScale,+ "stroke-linecap": "butt", "stroke-linejoin": "miter",+ }));+ if (trace.viaStart) addVia(traceLayer, trace.points[0]);+ if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));+ }++ if (config.animationSystem === "none") {+ svg.append(traceLayer);+ mount.prepend(svg);+ return true;+ }++ if (config.animationSystem === "conductor") {+ svg.append(traceLayer);+ mount.prepend(svg);+ conductor = createConductorAnimation(svg, field, config, motionRandom);+ return true;+ }++ const angle = config.sweepAngle * Math.PI / 180;+ const axisX = Math.cos(angle);+ const axisY = Math.sin(angle);+ let projectionMin = Infinity;+ let projectionMax = -Infinity;+ const prepared = field.traces.map((trace, index) => {+ const midpoint = trace.points[Math.floor(trace.points.length / 2)];+ const projection = midpoint[0] * axisX + midpoint[1] * axisY;+ projectionMin = Math.min(projectionMin, projection);+ projectionMax = Math.max(projectionMax, projection);+ return { trace, index, midpoint, projection };+ });+ const projectionSpan = Math.max(1, projectionMax - projectionMin);+ const scale = featureScale();++ for (const preparedTrace of prepared) {+ const { trace, index, midpoint } = preparedTrace;+ const normalizedProjection = (preparedTrace.projection - projectionMin) / projectionSpan;+ const directionBias = trace.points[0][0] <= trace.points.at(-1)[0] ? 1 : -1;+ const sparkLength = clamp(trace.length * (0.1 + motionRandom() * 0.06), 10 * scale, 58 * scale);+ const endpoint = trace.points[(index + Math.round(normalizedProjection * 5)) % 2 ? 0 : trace.points.length - 1];+ let canvasPath = null;+ if (typeof Path2D === "function") {+ canvasPath = new Path2D();+ trace.points.forEach(([x, y], pointIndex) => {+ if (pointIndex) canvasPath.lineTo(x, y);+ else canvasPath.moveTo(x, y);+ });+ }+ visuals.push({+ trace, midpoint, projection: normalizedProjection,+ canvasPath, endpoint, nodeColor: colorAt(normalizedProjection),+ sparkLength, directionBias,+ charge: 0, phase: motionRandom(), sparkEligible: motionRandom() > 0.34,+ energyOpacity: 0, sparkOpacity: 0, nodeOpacity: 0,+ });+ }++ svg.append(traceLayer);+ mount.prepend(svg);++ const traceCount = visuals.length;+ targetFrameRate = traceCount > 1500 ? 24 : traceCount > 900 ? 30 : traceCount > 420 ? 45 : 60;+ frameInterval = 1000 / targetFrameRate;+ energyBudget = traceCount > 1500 ? 420 : traceCount > 900 ? 500 : traceCount > 420 ? 620 : traceCount;+ sparkBudget = traceCount > 1500 ? 28 : traceCount > 900 ? 36 : traceCount > 420 ? 46 : 58;+ nodeBudget = traceCount > 1500 ? 48 : traceCount > 900 ? 64 : traceCount > 420 ? 82 : 104;+ const deviceRatio = Math.max(1, window.devicePixelRatio || 1);+ rendererPixelRatio = Math.min(deviceRatio, traceCount > 1200 ? 1 : traceCount > 600 ? 1.25 : 1.5);++ motionCanvas = document.createElement("canvas");+ motionCanvas.setAttribute("aria-hidden", "true");+ motionCanvas.setAttribute("data-animation", "routed-field-canvas");+ Object.assign(motionCanvas.style, {+ position: "absolute", inset: "0", width: "100%", height: "100%",+ display: "block", pointerEvents: "none", mixBlendMode: "screen", zIndex: "1",+ });+ motionCanvas.width = Math.max(1, Math.round(pixelWidth * rendererPixelRatio));+ motionCanvas.height = Math.max(1, Math.round(pixelHeight * rendererPixelRatio));+ mount.appendChild(motionCanvas);+ motionContext = motionCanvas.getContext("2d", { alpha: true, desynchronized: true })+ || motionCanvas.getContext("2d");+ if (motionContext) {+ motionContext.setTransform(+ motionCanvas.width / field.width, 0,+ 0, motionCanvas.height / field.height, 0, 0,+ );+ brandGradient = motionContext.createLinearGradient(+ 0, field.height * 0.15, field.width, field.height * 0.85,+ );+ brandGradient.addColorStop(0, "#00e6dc");+ brandGradient.addColorStop(0.6, "#39b8ff");+ brandGradient.addColorStop(1, "#8c6bf7");+ }+ focus = { x: field.width * 0.5, y: field.height * 0.5 };+ return true;+ }++ function spawnBloom(origin = null, intensity = 1) {+ if (config.animationSystem !== "adom" || !config.motion || reducedMotion || !field?.traces.length) return false;+ const source = origin || { x: field.width * 0.24, y: field.height * 0.62 };+ const angle = Math.atan2(source.y - field.height * 0.5, source.x - field.width * 0.5);+ blooms.push({+ x: clamp(source.x, 0, field.width),+ y: clamp(source.y, 0, field.height),+ startedAt: performance.now(),+ duration: config.bloomDuration / clamp(life(), 0.55, 1.35),+ intensity: clamp(intensity, 0.35, 1.4),+ spin: angle / (Math.PI * 2) + motionRandom() * 0.18,+ });+ if (blooms.length > 3) blooms.shift();+ bloomsTriggered += 1;+ return true;+ }++ function spawnAutomaticBloom() {+ if (!visuals.length) return;+ const stride = Math.max(1, Math.floor(visuals.length * 0.381966));+ bloomCursor = (bloomCursor + stride) % visuals.length;+ const anchor = visuals[bloomCursor].midpoint;+ spawnBloom({ x: anchor[0], y: anchor[1] }, 0.78 + motionRandom() * 0.18);+ }++ function energyFromBloom(visual, bloom, timestamp, maximumDimension) {+ const progress = (timestamp - bloom.startedAt) / bloom.duration;+ if (progress < 0 || progress > 1) return 0;+ const dx = visual.midpoint[0] - bloom.x;+ const dy = visual.midpoint[1] - bloom.y;+ const distance = Math.hypot(dx, dy) / maximumDimension;+ const angle = (Math.atan2(dy, dx) / (Math.PI * 2) + 1 + bloom.spin) % 1;+ const arrival = distance * 1.08 + angle * 0.105;+ const distanceFromFront = progress - arrival;+ const crest = Math.exp(-(distanceFromFront * distanceFromFront) / (2 * 0.052 * 0.052));+ const afterglow = distanceFromFront > 0 && distanceFromFront < 0.24+ ? (1 - distanceFromFront / 0.24) ** 2 * 0.23+ : 0;+ return (crest + afterglow) * bloom.intensity;+ }++ function onPointerMove(event) {+ if (!field) return;+ const rect = mount.getBoundingClientRect();+ pointer = {+ x: (event.clientX - rect.left) / rect.width * field.width,+ y: (event.clientY - rect.top) / rect.height * field.height,+ at: performance.now(),+ };+ conductor?.pointerMoved(pointer);+ }++ function onPointerLeave() {+ if (pointer) pointer.at = performance.now() - 1800;+ }++ function onClick(event) {+ onPointerMove(event);+ if (config.animationSystem === "conductor") conductor?.clicked(pointer);+ else spawnBloom(pointer, 1.18);+ }++ function strokeCanvasPath(context, visual) {+ if (visual.canvasPath) {+ context.stroke(visual.canvasPath);+ return;+ }+ context.beginPath();+ visual.trace.points.forEach(([x, y], index) => {+ if (index) context.lineTo(x, y);+ else context.moveTo(x, y);+ });+ context.stroke();+ }++ function renderFrame(timestamp) {+ if (!motionCanvas?.isConnected || !motionContext || !visuals.length) return;+ frameRequest = window.requestAnimationFrame(renderFrame);+ if (document.hidden) return;+ const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;+ if (sincePaint < frameInterval) return;+ lastPaintTime = lastPaintTime+ ? timestamp - (sincePaint % frameInterval)+ : timestamp;+ frameCount += 1;+ const frameDelta = lastFrameTime ? clamp(timestamp - lastFrameTime, 8, 50) : 16;+ lastFrameTime = timestamp;++ const strength = life();+ const maximumDimension = Math.max(field.width, field.height);+ const elapsed = timestamp - sceneStart;+ const sweepPhase = ((elapsed % config.sweepDuration) + config.sweepDuration) % config.sweepDuration / config.sweepDuration;+ const sweepActive = sweepPhase < 0.73;+ const sweepFront = sweepActive ? -0.14 + sweepPhase / 0.73 * 1.28 : 2;+ const sweepWidth = config.sweepWidth / clamp(strength, 0.65, 1.25);++ if (timestamp >= nextBloomAt) {+ spawnAutomaticBloom();+ nextBloomAt = timestamp + config.bloomInterval / clamp(0.72 + strength * 0.28, 0.72, 1.12);+ }+ blooms = blooms.filter((bloom) => timestamp - bloom.startedAt <= bloom.duration);++ const pointerAge = pointer ? timestamp - pointer.at : Infinity;+ const pointerPresence = clamp(1 - pointerAge / 1450);+ const automaticFocus = {+ x: field.width * (0.5 + Math.sin(elapsed * 0.000095) * 0.27),+ y: field.height * (0.48 + Math.sin(elapsed * 0.000071 + 1.4) * 0.21),+ };+ const focusTarget = pointerPresence > 0.02 ? pointer : automaticFocus;+ const focusEase = pointerPresence > 0.02 ? 0.105 : 0.018;+ focus.x += (focusTarget.x - focus.x) * focusEase;+ focus.y += (focusTarget.y - focus.y) * focusEase;++ energyCandidates.length = 0;+ sparkCandidates.length = 0;+ nodeCandidates.length = 0;+ let active = 0;+ for (const visual of visuals) {+ const delta = sweepFront - visual.projection;+ const crest = sweepActive+ ? Math.exp(-(delta * delta) / (2 * sweepWidth * sweepWidth))+ : 0;+ const trail = sweepActive && delta > 0 && delta < 0.28+ ? (1 - delta / 0.28) ** 2 * 0.24+ : 0;+ let bloomEnergy = 0;+ for (const bloom of blooms) {+ bloomEnergy = Math.max(bloomEnergy, energyFromBloom(visual, bloom, timestamp, maximumDimension));+ }+ let pointerEnergy = 0;+ if (pointerPresence > 0.001) {+ const distance = Math.hypot(visual.midpoint[0] - pointer.x, visual.midpoint[1] - pointer.y);+ const radius = maximumDimension * config.pointerRadius;+ pointerEnergy = Math.exp(-(distance * distance) / (2 * radius * radius)) * pointerPresence * 0.42;+ }+ const ambient = 0.018 + (Math.sin(timestamp * 0.0005 + visual.phase * Math.PI * 2) + 1) * 0.006;+ const target = clamp((crest * 0.96 + trail + bloomEnergy + pointerEnergy + ambient) * strength, 0, 1.18);+ const response = target > visual.charge ? 1 - Math.exp(-frameDelta / 72) : 1 - Math.exp(-frameDelta / 610);+ visual.charge += (target - visual.charge) * response;++ const energyOpacity = clamp((visual.charge - 0.018) * 0.72, 0, 0.74);+ const sparkOpacity = visual.sparkEligible+ ? clamp((visual.charge - 0.25) * 1.48, 0, 0.92)+ : 0;+ const nodePulse = 0.82 + Math.sin(timestamp * 0.0031 + visual.phase * Math.PI * 2) * 0.18;+ const nodeOpacity = clamp((visual.charge - 0.34) * 1.65 * nodePulse, 0, 0.88);++ visual.energyOpacity = energyOpacity;+ visual.sparkOpacity = sparkOpacity;+ visual.nodeOpacity = nodeOpacity;+ if (energyOpacity > 0.008) energyCandidates.push(visual);+ if (sparkOpacity > 0.01) sparkCandidates.push(visual);+ if (nodeOpacity > 0.01) nodeCandidates.push(visual);+ if (visual.charge > 0.13) active += 1;+ }+ activeTraces = active;++ energyCandidates.sort((a, b) => b.energyOpacity - a.energyOpacity);+ sparkCandidates.sort((a, b) => b.sparkOpacity - a.sparkOpacity);+ nodeCandidates.sort((a, b) => b.nodeOpacity - a.nodeOpacity);+ drawnEnergyTraces = Math.min(energyBudget, energyCandidates.length);+ drawnSparks = Math.min(sparkBudget, sparkCandidates.length);+ drawnNodes = Math.min(nodeBudget, nodeCandidates.length);++ const context = motionContext;+ context.setTransform(1, 0, 0, 1, 0, 0);+ context.clearRect(0, 0, motionCanvas.width, motionCanvas.height);+ context.setTransform(+ motionCanvas.width / field.width, 0,+ 0, motionCanvas.height / field.height, 0, 0,+ );++ const focusWash = context.createRadialGradient(+ focus.x, focus.y, 0,+ focus.x, focus.y, maximumDimension * 0.54,+ );+ focusWash.addColorStop(0, "rgba(0,230,220,.16)");+ focusWash.addColorStop(0.35, "rgba(57,184,255,.075)");+ focusWash.addColorStop(0.72, "rgba(140,107,247,.025)");+ focusWash.addColorStop(1, "rgba(140,107,247,0)");+ context.globalCompositeOperation = "source-over";+ context.globalAlpha = (0.58 + pointerPresence * 0.24) * clamp(strength, 0, 1.2);+ context.fillStyle = focusWash;+ context.fillRect(0, 0, field.width, field.height);++ context.globalCompositeOperation = "lighter";+ context.strokeStyle = brandGradient;+ context.lineWidth = config.traceWidth * featureScale() * 1.18;+ context.lineCap = "round";+ context.lineJoin = "miter";+ context.setLineDash([]);+ for (let index = 0; index < drawnEnergyTraces; index += 1) {+ const visual = energyCandidates[index];+ context.globalAlpha = visual.energyOpacity;+ strokeCanvasPath(context, visual);+ }++ context.lineCap = "round";+ context.lineJoin = "round";+ for (let index = 0; index < drawnSparks; index += 1) {+ const visual = sparkCandidates[index];+ const distance = visual.trace.length + visual.sparkLength;+ const travel = (timestamp * (0.052 + strength * 0.026) + visual.phase * distance) % distance;+ context.setLineDash([visual.sparkLength, distance]);+ context.lineDashOffset = -travel * visual.directionBias;+ context.lineWidth = config.traceWidth * featureScale() * 3.15;+ context.globalAlpha = visual.sparkOpacity * 0.16;+ strokeCanvasPath(context, visual);+ context.lineWidth = config.traceWidth * featureScale() * 1.42;+ context.globalAlpha = visual.sparkOpacity;+ strokeCanvasPath(context, visual);+ }+ context.setLineDash([]);+ context.lineDashOffset = 0;++ const nodeRadius = config.viaInnerRadius * featureScale() * 0.78;+ for (let index = 0; index < drawnNodes; index += 1) {+ const visual = nodeCandidates[index];+ context.fillStyle = visual.nodeColor;+ context.globalAlpha = visual.nodeOpacity * 0.14;+ context.beginPath();+ context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius * 2.65, 0, Math.PI * 2);+ context.fill();+ context.globalAlpha = visual.nodeOpacity;+ context.beginPath();+ context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius, 0, Math.PI * 2);+ context.fill();+ }+ context.globalAlpha = 1;+ context.globalCompositeOperation = "source-over";+ }++ function stopMotion() {+ window.cancelAnimationFrame(frameRequest);+ conductor?.stop();+ frameRequest = undefined;+ activeTraces = 0;+ lastFrameTime = 0;+ lastPaintTime = 0;+ }++ function startMotion() {+ stopMotion();+ if (config.animationSystem === "none" || !config.motion || reducedMotion || life() <= 0.02) return;+ if (config.animationSystem === "conductor") {+ conductor?.start();+ return;+ }+ const now = performance.now();+ sceneStart = now - 760;+ nextBloomAt = now + 1180;+ frameCount = 0;+ spawnBloom({ x: field.width * 0.2, y: field.height * 0.64 }, 0.88);+ frameRequest = window.requestAnimationFrame(renderFrame);+ }++ function regenerate(nextOptions = {}) {+ stopMotion();+ config = { ...config, ...nextOptions };+ lastSize = "";+ build(true);+ startMotion();+ return api;+ }++ function exportSvg() {+ const clone = svg.cloneNode(true);+ clone.querySelectorAll("[data-animation]").forEach((layer) => layer.remove());+ clone.removeAttribute("style");+ return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+ }++ function destroy() {+ stopMotion();+ conductor?.destroy();+ conductor = undefined;+ window.clearTimeout(resizeTimer);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("pointerleave", onPointerLeave);+ interactionTarget.removeEventListener("click", onClick);+ motionCanvas?.remove();+ motionCanvas = undefined;+ motionContext = undefined;+ svg?.remove();+ mount.style.position = originalPosition;+ }++ const api = {+ regenerate,+ triggerBloom: spawnBloom,+ triggerAnimation(origin = null) {+ return config.animationSystem === "conductor"+ ? conductor?.trigger(origin)+ : spawnBloom(origin, 1.12);+ },+ exportSvg,+ destroy,+ get field() { return field; },+ get animationStats() {+ if (config.animationSystem === "none") {+ return {+ system: "none",+ animationNodes: 0,+ frameCount: 0,+ reducedMotion,+ };+ }+ if (config.animationSystem === "conductor") return conductor?.stats;+ return {+ system: "adom-routed-field",+ activeTraces,+ activeBlooms: blooms.length,+ bloomsTriggered,+ frameCount,+ renderer: "canvas",+ animationNodes: motionCanvas ? 1 : 0,+ traceCount: visuals.length,+ targetFrameRate,+ rendererPixelRatio,+ energyBudget,+ sparkBudget,+ nodeBudget,+ drawnEnergyTraces,+ drawnSparks,+ drawnNodes,+ reducedMotion,+ };+ },+ };+ build(true);+ resizeObserver = new ResizeObserver(() => {+ window.clearTimeout(resizeTimer);+ resizeTimer = window.setTimeout(() => {+ if (build()) {+ stopMotion();+ startMotion();+ }+ }, 220);+ });+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("pointerleave", onPointerLeave, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ startMotion();+ return api;+}++export { DEFAULTS, ANIMATION_PRESETS };
public/vendor/circuit-trace-generators/circuit-obstacles-v9.jsadded+405@@ -0,0 +1,405 @@+// Worker-safe obstacle geometry for circuit-background-v9.+// The ADOM contour is sourced from adom-elements/src/adom-white.svg.++const ADOM_PATH = "M1065.5,25.22h-58.49c-2.98,0-5.68,1.87-6.73,4.65l-44.4,117.54c-.06.17-.31.17-.37,0l-45.44-117.59c-1.07-2.75-3.76-4.6-6.72-4.6h-58.79c-4.09,0-7.4,3.31-7.4,7.4v35.06l-.03-.12c-1.24,4.99-5.89,6-5.89,6,0,0-4.51,1.02-7.61-2.53v.03c-17.82-29.81-50.31-48.93-89.04-48.93-41.5,0-75.86,21.85-92.76,55.23v-.07c-2.86,5.19-8.74,5.13-8.74,5.13,0,0-5.63-.01-8.41-4.61-16.49-32.68-50.56-52.59-94.79-52.59h-73.46c-4.09,0-7.4,3.31-7.4,7.4v121.27s0,.03,0,.04c-.95,5.15-4.75,5.94-4.75,5.94,0,0-3.88.85-6.77-3.32l.05.23-46.05-126.83c-1.03-2.84-3.75-4.74-6.77-4.74h-57.14c-3.01,0-5.73,1.9-6.76,4.73l-68.67,188.38c-1.05,2.88-.18,6.21,2.4,8.19,1.31,1.01,2.98,1.47,4.64,1.47h34.96s6.43,0,6.43,0c2.5,0,4.83-1.39,5.9-3.65,8.49-17.86,26.5-29.64,48.37-29.64s39.71,11.73,48.19,29.53c1.1,2.32,3.49,3.76,6.06,3.76h120.78c31.22,0,57.85-9.95,77.02-28.77,7.38-7.25,13.43-15.72,18.05-25.1v.09c2.74-5.19,8.37-5.13,8.37-5.13,0,0,5.43,0,8.08,4.64,4.88,9.88,11.26,18.78,18.91,26.42,18.6,18.6,44.61,29.78,74.26,29.78,38.74,0,71.23-19.18,89.05-49.07l-.02.07c3.1-3.55,7.61-2.53,7.61-2.53,0,0,4.65,1,5.89,6l.03-.12v36.32c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4v-86.26c0-.23.32-.28.39-.06l30.37,88.86c1,2.91,3.74,4.87,6.81,4.87h39.69c3.08,0,5.82-1.96,6.81-4.87l30.1-88.02c.07-.21.39-.16.39.06v85.43c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4V32.62c0-4.09-3.31-7.4-7.4-7.4ZM372.31,147.57c-.15.21-.5.57-1.1.57h-30.33c-.61,0-.95-.35-1.1-.56-.15-.21-.38-.65-.19-1.23l15.06-45.79c.27-.81.92-.94,1.29-.94h0c.37,0,1.02.12,1.29.93l15.26,45.8c.19.58-.04,1.01-.19,1.23ZM525.72,173.84h-14.52c-.86,0-1.56-.7-1.56-1.56v-92.16c0-.86.7-1.56,1.56-1.56h14.52c30.81,0,48.48,17.47,48.48,47.92s-17.67,47.36-48.48,47.36ZM734.6,173.26c-26.51,0-42.33-17.71-42.34-47.36,0-14.59,3.88-26.68,11.21-34.98,7.33-8.29,18.09-12.67,31.13-12.67,12.11,0,22.28,3.84,29.58,11.13.5.5.98,1.01,1.45,1.54,7.4,8.33,11.31,20.42,11.31,34.97,0,29.21-16.22,47.36-42.34,47.36Z";++const COMMAND = /^[a-z]$/i;+const NUMBER = /[a-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/gi;++function cubicPoint(start, first, second, end, t) {+ const inverse = 1 - t;+ const a = inverse * inverse * inverse;+ const b = 3 * inverse * inverse * t;+ const c = 3 * inverse * t * t;+ const d = t * t * t;+ return [+ a * start[0] + b * first[0] + c * second[0] + d * end[0],+ a * start[1] + b * first[1] + c * second[1] + d * end[1],+ ];+}++function flattenSvgPath(data, curveSteps = 14) {+ const tokens = data.match(NUMBER) || [];+ const rings = [];+ let index = 0;+ let command = null;+ let current = [0, 0];+ let start = [0, 0];+ let previousControl = null;+ let previousCommand = null;+ let ring = [];++ const number = () => Number(tokens[index++]);+ const point = (relative) => {+ const result = [number(), number()];+ if (relative) {+ result[0] += current[0];+ result[1] += current[1];+ }+ return result;+ };+ const finishRing = () => {+ if (ring.length < 3) return;+ const last = ring.at(-1);+ if (last[0] !== start[0] || last[1] !== start[1]) ring.push(start.slice());+ rings.push(ring);+ ring = [];+ };++ while (index < tokens.length) {+ if (COMMAND.test(tokens[index])) command = tokens[index++];+ if (!command) throw new Error("Invalid ADOM path data");+ const relative = command === command.toLowerCase();+ const upper = command.toUpperCase();++ if (upper === "M") {+ if (ring.length) finishRing();+ current = point(relative);+ start = current.slice();+ ring = [current.slice()];+ command = relative ? "l" : "L";+ previousControl = null;+ } else if (upper === "L") {+ current = point(relative);+ ring.push(current.slice());+ previousControl = null;+ } else if (upper === "H") {+ const x = number() + (relative ? current[0] : 0);+ current = [x, current[1]];+ ring.push(current.slice());+ previousControl = null;+ } else if (upper === "V") {+ const y = number() + (relative ? current[1] : 0);+ current = [current[0], y];+ ring.push(current.slice());+ previousControl = null;+ } else if (upper === "C") {+ const first = point(relative);+ const second = point(relative);+ const end = point(relative);+ const origin = current;+ for (let step = 1; step <= curveSteps; step += 1) {+ ring.push(cubicPoint(origin, first, second, end, step / curveSteps));+ }+ current = end;+ previousControl = second;+ } else if (upper === "S") {+ const reflected = previousControl && (previousCommand === "C" || previousCommand === "S")+ ? [2 * current[0] - previousControl[0], 2 * current[1] - previousControl[1]]+ : current.slice();+ const second = point(relative);+ const end = point(relative);+ const origin = current;+ for (let step = 1; step <= curveSteps; step += 1) {+ ring.push(cubicPoint(origin, reflected, second, end, step / curveSteps));+ }+ current = end;+ previousControl = second;+ } else if (upper === "Z") {+ finishRing();+ current = start.slice();+ previousControl = null;+ command = null;+ } else {+ throw new Error(`Unsupported SVG path command: ${command}`);+ }+ previousCommand = upper;+ }+ if (ring.length) finishRing();+ return rings;+}++function boundsOfRings(rings) {+ let minX = Infinity;+ let minY = Infinity;+ let maxX = -Infinity;+ let maxY = -Infinity;+ for (const ring of rings) {+ for (const [x, y] of ring) {+ minX = Math.min(minX, x);+ minY = Math.min(minY, y);+ maxX = Math.max(maxX, x);+ maxY = Math.max(maxY, y);+ }+ }+ return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };+}++const ADOM_RINGS = flattenSvgPath(ADOM_PATH);+const ADOM_BOUNDS = boundsOfRings(ADOM_RINGS);++function pointInRing(x, y, ring) {+ let inside = false;+ for (let current = 0, previous = ring.length - 1; current < ring.length; previous = current, current += 1) {+ const [cx, cy] = ring[current];+ const [px, py] = ring[previous];+ if ((cy > y) === (py > y)) continue;+ const crossing = (px - cx) * (y - cy) / (py - cy || Number.EPSILON) + cx;+ if (x < crossing) inside = !inside;+ }+ return inside;+}++function pointInRings(x, y, rings) {+ let inside = false;+ for (const ring of rings) if (pointInRing(x, y, ring)) inside = !inside;+ return inside;+}++function transformWordmark(spec, width, height) {+ const targetWidth = Math.max(24, width * Number(spec.width ?? spec.scale ?? 0.66));+ const targetHeight = targetWidth * ADOM_BOUNDS.height / ADOM_BOUNDS.width;+ const centerX = width * Number(spec.x ?? 0.5);+ const centerY = height * Number(spec.y ?? 0.66);+ const scale = targetWidth / ADOM_BOUNDS.width;+ return ADOM_RINGS.map((ring) => ring.map(([x, y]) => [+ centerX + (x - ADOM_BOUNDS.minX - ADOM_BOUNDS.width / 2) * scale,+ centerY + (y - ADOM_BOUNDS.minY - ADOM_BOUNDS.height / 2) * scale,+ ]));+}++function resolveValue(value, extent, normalized) {+ return Number(value || 0) * (normalized ? extent : 1);+}++function compileGenericObstacle(spec, width, height) {+ const normalized = spec.space !== "world";+ const centerX = resolveValue(spec.x ?? 0.5, width, normalized);+ const centerY = resolveValue(spec.y ?? 0.5, height, normalized);+ const obstacleWidth = Math.max(0, resolveValue(spec.width ?? 0.2, width, normalized));+ const obstacleHeight = Math.max(0, resolveValue(spec.height ?? 0.2, height, normalized));++ if (spec.type === "rect") {+ const left = centerX - obstacleWidth / 2;+ const top = centerY - obstacleHeight / 2;+ const ring = [[left, top], [left + obstacleWidth, top], [left + obstacleWidth, top + obstacleHeight], [left, top + obstacleHeight], [left, top]];+ return { id: spec.id || "rect", rings: [ring], contains: (x, y) => x >= left && x <= left + obstacleWidth && y >= top && y <= top + obstacleHeight };+ }+ if (spec.type === "ellipse") {+ const rx = obstacleWidth / 2;+ const ry = obstacleHeight / 2;+ const ring = Array.from({ length: 97 }, (_, index) => {+ const angle = index / 96 * Math.PI * 2;+ return [centerX + Math.cos(angle) * rx, centerY + Math.sin(angle) * ry];+ });+ return { id: spec.id || "ellipse", rings: [ring], contains: (x, y) => ((x - centerX) / (rx || 1)) ** 2 + ((y - centerY) / (ry || 1)) ** 2 <= 1 };+ }+ const sourceRings = spec.type === "polygon" ? [spec.points || []] : spec.rings || [];+ const rings = sourceRings.map((ring) => {+ const transformed = ring.map(([x, y]) => [resolveValue(x, width, normalized), resolveValue(y, height, normalized)]);+ if (transformed.length && transformed.at(-1).some((value, axis) => value !== transformed[0][axis])) transformed.push(transformed[0].slice());+ return transformed;+ }).filter((ring) => ring.length >= 4);+ return { id: spec.id || spec.type || "shape", rings, contains: (x, y) => pointInRings(x, y, rings) };+}++function normalizeSpecs(config) {+ const specs = Array.isArray(config.obstacles) ? config.obstacles.slice() : [];+ if (config.obstaclePreset === "adom") {+ specs.unshift({+ id: "adom-wordmark",+ type: "wordmark",+ preset: "adom",+ x: Number(config.obstacleX ?? 0.5),+ y: Number(config.obstacleY ?? 0.66),+ width: Number(config.obstacleScale ?? 0.66),+ });+ }+ return specs.filter((spec) => spec && spec.enabled !== false);+}++function compileObstacle(spec, width, height) {+ if (spec.type === "wordmark" || spec.preset === "adom") {+ const rings = transformWordmark(spec, width, height);+ return { id: spec.id || "adom-wordmark", rings, contains: (x, y) => pointInRings(x, y, rings) };+ }+ return compileGenericObstacle(spec, width, height);+}++function dilateMask(source, columns, rows, radius) {+ if (radius <= 0) return source.slice();+ const result = source.slice();+ const offsets = [];+ for (let dy = -radius; dy <= radius; dy += 1) {+ for (let dx = -radius; dx <= radius; dx += 1) {+ if (dx * dx + dy * dy <= radius * radius) offsets.push([dx, dy]);+ }+ }+ for (let y = 1; y < rows - 1; y += 1) {+ for (let x = 1; x < columns - 1; x += 1) {+ if (!source[y * columns + x]) continue;+ for (const [dx, dy] of offsets) {+ const nx = x + dx;+ const ny = y + dy;+ if (nx > 0 && ny > 0 && nx < columns - 1 && ny < rows - 1) result[ny * columns + nx] = 1;+ }+ }+ }+ return result;+}++function distanceField(blocked, columns, rows, band) {+ const unseen = 65535;+ const distance = new Uint16Array(columns * rows);+ distance.fill(unseen);+ const queue = new Int32Array(columns * rows);+ let head = 0;+ let tail = 0;+ for (let index = 0; index < blocked.length; index += 1) {+ if (!blocked[index]) continue;+ distance[index] = 0;+ queue[tail++] = index;+ }+ while (head < tail) {+ const index = queue[head++];+ const nextDistance = distance[index] + 1;+ if (nextDistance > band) continue;+ const x = index % columns;+ const y = Math.floor(index / columns);+ const neighbors = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];+ for (const [nx, ny] of neighbors) {+ if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) continue;+ const next = ny * columns + nx;+ if (distance[next] <= nextDistance) continue;+ distance[next] = nextDistance;+ queue[tail++] = next;+ }+ }+ return distance;+}++function rasterizeObstacle(mask, obstacle, pitch, columns, rows) {+ for (let row = 1; row < rows - 1; row += 1) {+ const worldY = row * pitch;+ const intersections = [];+ for (const ring of obstacle.rings) {+ for (let current = 0, previous = ring.length - 1; current < ring.length; previous = current, current += 1) {+ const [cx, cy] = ring[current];+ const [px, py] = ring[previous];+ if ((cy > worldY) === (py > worldY)) continue;+ intersections.push(cx + (px - cx) * (worldY - cy) / (py - cy || Number.EPSILON));+ }+ }+ intersections.sort((left, right) => left - right);+ for (let pair = 0; pair + 1 < intersections.length; pair += 2) {+ const first = Math.max(1, Math.ceil(intersections[pair] / pitch));+ const last = Math.min(columns - 2, Math.floor(intersections[pair + 1] / pitch));+ for (let column = first; column <= last; column += 1) mask[row * columns + column] = 1;+ }+ }+}++export function createObstacleField(config, dimensions) {+ const { width, height, pitch, columns, rows } = dimensions;+ const specs = normalizeSpecs(config);+ if (!specs.length) return null;+ const compiled = specs.map((spec) => compileObstacle(spec, width, height)).filter((item) => item.rings.length);+ if (!compiled.length) return null;++ const inside = new Uint8Array(columns * rows);+ for (const obstacle of compiled) rasterizeObstacle(inside, obstacle, pitch, columns, rows);+ const viaClearance = Math.max(0, Number(config.viaOuterRadius) || 0) / pitch;+ const radius = Math.max(1, Math.ceil(Math.max(0, Number(config.obstacleClearance) || 0) + viaClearance));+ const blocked = dilateMask(inside, columns, rows, radius);+ const band = Math.max(4, Math.ceil(6 + (Number(config.obstaclePull) || 0) * 2));+ const distance = distanceField(blocked, columns, rows, band + 1);+ let blockedCells = 0;+ for (let y = 1; y < rows - 1; y += 1) {+ for (let x = 1; x < columns - 1; x += 1) blockedCells += blocked[y * columns + x];+ }+ return {+ blocked,+ distance,+ band,+ rings: compiled.flatMap((obstacle) => obstacle.rings.map((ring) => ({ id: obstacle.id, points: ring }))),+ count: compiled.length,+ blockedCells,+ mode: config.obstacleMode || "avoid",+ };+}++function sampleClosedRing(points, spacing) {+ const ring = points.slice();+ if (ring.length > 1 && ring[0][0] === ring.at(-1)[0] && ring[0][1] === ring.at(-1)[1]) ring.pop();+ if (ring.length < 3) return [];+ const segments = [];+ let total = 0;+ for (let index = 0; index < ring.length; index += 1) {+ const start = ring[index];+ const end = ring[(index + 1) % ring.length];+ const length = Math.hypot(end[0] - start[0], end[1] - start[1]);+ segments.push({ start, end, length, at: total });+ total += length;+ }+ const count = Math.max(12, Math.ceil(total / spacing));+ const result = [];+ let segmentIndex = 0;+ for (let sample = 0; sample < count; sample += 1) {+ const target = sample / count * total;+ while (segmentIndex < segments.length - 1 && segments[segmentIndex].at + segments[segmentIndex].length < target) segmentIndex += 1;+ const segment = segments[segmentIndex];+ const local = (target - segment.at) / (segment.length || 1);+ result.push([+ segment.start[0] + (segment.end[0] - segment.start[0]) * local,+ segment.start[1] + (segment.end[1] - segment.start[1]) * local,+ ]);+ }+ return result;+}++export function obstacleOutlineTraces(field, options) {+ if (!field || !field.mode.includes("outline")) return [];+ const pitch = options.pitch;+ const spacing = Math.max(2, pitch * 0.48);+ const viaRadius = Math.max(0, Number(options.viaOuterRadius) || 0);+ const traces = [];+ for (let ringIndex = 0; ringIndex < field.rings.length; ringIndex += 1) {+ const ring = field.rings[ringIndex];+ const sampled = sampleClosedRing(ring.points, spacing);+ if (sampled.length < 12) continue;+ let seam = 0;+ for (let index = 1; index < sampled.length; index += 1) {+ if (sampled[index][1] > sampled[seam][1] || (sampled[index][1] === sampled[seam][1] && sampled[index][0] > sampled[seam][0])) seam = index;+ }+ const gap = Math.min(Math.floor(sampled.length * 0.14), Math.max(2, Math.ceil((viaRadius * 2.4 + pitch * 0.45) / spacing)));+ const start = (seam + Math.ceil(gap / 2)) % sampled.length;+ const points = [];+ for (let offset = 0; offset <= sampled.length - gap; offset += 1) points.push(sampled[(start + offset) % sampled.length]);+ if (points.length < 6) continue;+ let length = 0;+ for (let index = 1; index < points.length; index += 1) length += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);+ traces.push({+ points,+ length,+ viaStart: true,+ viaEnd: true,+ kind: "obstacle-outline",+ obstacleId: ring.id,+ ringIndex,+ });+ }+ return traces;+}++export function obstacleEdgeWeight(field, x, y, dx, dy, columns, pull) {+ if (!field || !field.mode.includes("wrap") || pull <= 0) return 1;+ const index = y * columns + x;+ const distance = field.distance[index];+ if (!distance || distance > field.band) return 1;+ const left = field.distance[index - 1];+ const right = field.distance[index + 1];+ const up = field.distance[index - columns];+ const down = field.distance[index + columns];+ const gx = Math.min(right, field.band + 1) - Math.min(left, field.band + 1);+ const gy = Math.min(down, field.band + 1) - Math.min(up, field.band + 1);+ const magnitude = Math.hypot(gx, gy);+ if (magnitude < 0.001) return 1;+ const tangent = Math.abs((-gy * dx + gx * dy) / magnitude);+ const normal = Math.abs((gx * dx + gy * dy) / magnitude);+ const proximity = 1 - (distance - 1) / Math.max(1, field.band);+ const attraction = 1 + pull * proximity * (0.55 + tangent * tangent * 3.4);+ const approachPenalty = 1 - Math.min(0.68, pull * proximity * normal * 0.34);+ return Math.max(0.16, attraction * approachPenalty);+}++export const ADOM_WORDMARK_ASPECT = ADOM_BOUNDS.width / ADOM_BOUNDS.height;
public/vendor/circuit-trace-generators/package.jsonadded+1@@ -0,0 +1 @@+{"type":"module"}
render-hero.mjs+2@@ -17,6 +17,8 @@ try { await page.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 }); await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); await new Promise(r => setTimeout(r, 1200)); // fonts + images settle+ await page.waitForFunction(() => !window.__tracePending, { timeout: 30000 });+ await page.evaluate(async () => { await window.__traceReady; if (window.__traceError) throw new Error(window.__traceError); }); const hero = await page.$('#hero'); const box = await hero.boundingBox(); await page.screenshot({ path: out, clip: box });
render-pup.mjs+8@@ -60,6 +60,14 @@ if (!ready) die('render view never became ready (#hero not found at ' + url + ') // host allows it, then let fonts and images settle. await evalIn('window.resizeTo(1720, 1180)'); await sleep(2000);+let tracesReady = false;+for (let i = 0; i < 30 && !tracesReady; i++) {+ const r = await evalIn("window.__traceError ? 'HS-TRACE-ERROR' : window.__tracePending ? 'HS-TRACE-WAIT' : 'HS-TRACE-READY'");+ if (r && r.includes('HS-TRACE-ERROR')) die('circuit composition failed; read the studio console');+ if (r && r.includes('HS-TRACE-READY')) tracesReady = true;+ else await sleep(1000);+}+if (!tracesReady) die('circuit composition did not settle before export'); // 5. measure #hero in viewport pixels + devicePixelRatio. Numbers only, // joined with commas: no quotes means no escaping ambiguity in the transport.
server.js+19−4@@ -100,6 +100,7 @@ let state = { slotSize: null, // measured showcase slot in export px, posted back by the UI ({w,h}) bgImage: { src: '', cover: false }, // imported background art for theme 'image' (hero-studio#24) circuitSeed: Date.now() % 2147483647, // procedural circuitry theme seed; /reroll re-rolls+ circuit: { density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 }, fontSize: { title: 8, sub: 2.5, desc: 1.35, leading: 1.02, gap: 1 }, // cqw sizes + title line-height + text-block gap (em); author-adjustable, defaults tuned for dock legibility headline: '', // what BAKES as the hero title; '' = use the page title (hero-studio#33: title != headline) subtitle: '', // the text under the title; '' = the page brief's first sentence (wizard round 3)@@ -129,6 +130,7 @@ const POSE_TRACKED = [ 'overlay.pill', 'overlay.scores', 'overlay.author', 'overlay.slug', 'overlay.logo', 'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'subtitle', 'accentWords', 'showcase.chrome', 'showcase.full', 'showcase.cover',+ 'circuitSeed', 'circuit.density', 'circuit.scale', 'circuit.strength', 'circuit.clearance', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); function snapshotPose() {@@ -175,7 +177,7 @@ const PRESETS = { const ENUMS = { layout: ['bleed-bottom', 'bleed-right', 'bleed-top', 'bleed-corner-br', 'bleed-corner-tr', 'contained', 'mirror', 'showcase'],- theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'image'],+ theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'circuit-field', 'circuit-tile', 'image'], titleMode: ['overlay-only', 'image-only', 'both'], mode: ['app', 'component'], };@@ -349,7 +351,7 @@ process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); }); process.on('SIGINT', () => { deregisterInstance(); process.exit(0); }); const MIME = {- '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', };@@ -894,7 +896,8 @@ function route(req, res, url, p) { show: state.show, guides: false, showcase: { chrome: (state.showcase || {}).chrome || '', cover: !!(state.showcase || {}).cover }, mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined,- circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined,+ circuitSeed: state.theme.startsWith('circuit') ? state.circuitSeed : undefined,+ circuit: state.circuit, }; const rec = { name, style, gate: Object.assign({}, state.approved, { thread: AI_THREAD, savedAt: new Date().toISOString() }) }; const dir = path.join(INSTANCES_DIR, 'styles');@@ -945,6 +948,16 @@ function route(req, res, url, p) { } } const pageChanged = typeof body.page === 'string' && body.page !== state.page;+ const badTrace = 'circuit' in body && (!body.circuit || typeof body.circuit !== 'object' || Array.isArray(body.circuit)+ || Object.entries(body.circuit).some(([key, value]) => {+ const ranges = { density: [0.25, 1.25], scale: [0.8, 2.5], strength: [0.05, 0.5], clearance: [8, 40] };+ const range = Object.hasOwn(ranges, key) ? ranges[key] : null;+ return !range || typeof value !== 'number' || !Number.isFinite(value) || value < range[0] || value > range[1];+ }));+ if (badTrace || ('circuitSeed' in body && (!Number.isInteger(body.circuitSeed) || body.circuitSeed < 0 || body.circuitSeed >= 2147483647))) {+ res.writeHead(400, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, error: 'invalid circuit settings', valid: { density: [0.25,1.25], scale: [0.8,2.5], strength: [0.05,0.5], clearance: [8,40], circuitSeed: 'integer 0..2147483646' } }));+ } const turningFullOn = body.showcase && body.showcase.full && !(state.showcase && state.showcase.full); if (turningFullOn && !body.overlay) { body.overlay = { pill: false, scores: false, author: false, slug: false, logo: false };@@ -1190,8 +1203,10 @@ function route(req, res, url, p) { return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second. state.fitPending stays set until a surface answers: if it is still there after a few seconds, no surface is actually responding' })); } if (p === '/reroll' && req.method === 'POST') {+ const before = snapshotPose(); poseRev += 1; state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647;+ notePoseDiff(before); broadcast('state', state); res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, seed: state.circuitSeed }));@@ -1658,7 +1673,7 @@ function route(req, res, url, p) { const branding = g.owner || String(state.page || '').split('/')[0] || null; const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, branding, brand_sha: g.sha || null, brand_version: g.version || null, brand_status: g.status || 'missing',- state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+ state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme.startsWith('circuit') ? state.circuitSeed : undefined, circuit: state.circuit }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined }); const py = 'import sys\n' + 'from PIL import Image\n' + 'from PIL.PngImagePlugin import PngInfo\n' +
test/circuit-traces.mjsadded+182@@ -0,0 +1,182 @@+// Exercise the real surface + read-only export; no Generate gate or wiki writes.+import assert from 'node:assert/strict';+import { spawn } from 'node:child_process';+import http from 'node:http';+import net from 'node:net';+import fs from 'node:fs';+import path from 'node:path';+import { fileURLToPath } from 'node:url';+import { loadPuppeteer, findChrome } from '../doctor.mjs';+import { composeTraces } from '../public/js/circuit-traces.mjs';+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');+const screenshotDir = process.env.TRACE_SCREENSHOTS;+if (screenshotDir) fs.mkdirSync(screenshotDir, { recursive: true });+let passed = 0;+const ok = (name, value) => { assert.ok(value, name); console.log(' ok ' + name); passed++; };+const spec = { seed: 1234, color:'#e0a020', boxes:[{x:30,y:40,width:380,height:240}] };+for (const theme of ['circuit-field', 'circuit-tile']) {+ const a = composeTraces({ ...spec, theme });+ ok(theme + ' has real routes', a.count > 20);+ ok(theme + ' is deterministic', a.src === composeTraces({ ...spec, theme }).src);+ ok(theme + ' rerolls', a.src !== composeTraces({ ...spec, theme, seed: 1235 }).src);+}+const registry = http.createServer((req,res) => { res.writeHead(404, { 'Content-Type':'application/json' }); res.end('{}'); });+await new Promise(r => registry.listen(0, '127.0.0.1', r));+const port = await new Promise(r => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); });+const base = 'http://127.0.0.1:' + port;+const server = spawn(process.execPath, ['server.js','--port',String(port),'--ai-thread','circuit integration test ' + port], {+ cwd:ROOT, env:{...process.env, ADOM_WIKI_REGISTRY:'http://127.0.0.1:' + registry.address().port}, stdio:'pipe',+});+let browser;+try {+ let up = false;+ for (let i=0; i<80 && !up; i++) { try { up=(await fetch(base+'/state')).ok; } catch {} if(!up) await new Promise(r=>setTimeout(r,100)); }+ assert.ok(up, 'test server starts');+ const patch = async body => {+ const r = await fetch(base+'/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aiThread:'circuit tests',...body})});+ return r;+ };+ for (const bad of [{circuit:{scale:0}}, {circuit:{density:99}}, {circuit:{clearance:-1}}, {circuit:{strength:'0.2'}}, {circuit:null}, {circuitSeed:1.5}, {circuit:{surprise:true}}]) {+ ok('rejects ' + JSON.stringify(bad), (await patch(bad)).status === 400);+ }+ const showcase = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="600" height="450"><rect width="600" height="450" fill="#29364c"/><circle cx="300" cy="200" r="125" fill="#5780a0"/></svg>');+ await patch({ theme:'circuit-field', circuitSeed:1234, headline:'Traces with purpose', subtitle:'Make room for the story', layout:'contained', showcase:{src:showcase,full:false}, show:{logo:false,description:false}, guide:{status:'confirmed',sha:'test',brand:{colors:{primary:'#e0a020',background:'#142030'}}} });+ browser = await (await loadPuppeteer()).launch({executablePath:findChrome(),headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});+ const page = await browser.newPage();+ await page.setViewport({width:1680,height:1080,deviceScaleFactor:1});+ const errors=[]; page.on('pageerror', e=>errors.push(e.message));+ await page.goto(base+'/?render=1',{waitUntil:'networkidle2'});+ const settled = async () => {+ await page.waitForFunction(() => typeof state !== 'undefined' && state && !window.__tracePending);+ await page.evaluate(async()=>{await window.__traceReady; if(window.__traceError) throw Error(window.__traceError);});+ };+ const setLocal = async body => { await page.evaluate(p=>{wizMerge(state,p);render();},body); await settled(); };+ await settled();+ const src = () => page.$eval('#trace-art',im=>im.src);+ const field = await src();+ ok('brand color in generated SVG', decodeURIComponent(field).includes('#e0a020'));+ ok('preview clones carry the exact artwork', await page.$$eval('#contexts .trace-art',els=>els.length>0 && els.every(el=>el.src===document.querySelector('#trace-art').src)));+ ok('glyph contours and showcase are measured without column/bottom rectangles', await page.evaluate(async()=>{+ const m=await import('./js/circuit-traces.mjs'); const boxes=m.keepouts(document.querySelector('#hero'));+ return ['text-ink','window'].every(id=>boxes.some(b=>b.id===id))+ && !boxes.some(b=>['textzone','ctextzone','homepage-band','lower'].includes(b.id))+ && boxes.find(b=>b.id==='text-ink').rings.length>20;+ }));+ const bottomInk = () => page.evaluate(()=>{+ const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+ const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);+ return ctx.getImageData(25,485,950,100).data.filter((v,i)=>i%4===3 && v>10).length;+ });+ ok('field routes continue through the lower quarter',await bottomInk()>500);+ // Independently compare the stencil against Chromium's painted DOM, not+ // another Canvas measurement. This catches wrong baselines/font metrics,+ // cap trimming, kerning, inline accents and wrapped-line reconstruction.+ const inkStyle = await page.addStyleTag({content:`+ html,body,#hero { background:black!important; }+ #hero > :not(.textzone) { visibility:hidden!important; }+ #hero .wordmark { visibility:hidden!important; }+ #hero .textzone * { color:white!important;-webkit-text-fill-color:white!important;background:none!important;text-shadow:none!important; }+ `});+ const domInk = await (await page.$('#hero')).screenshot({encoding:'base64'});+ await inkStyle.evaluate(el=>el.remove());+ await inkStyle.dispose();+ ok('ink stencil covers the actual browser-painted glyphs',await page.evaluate(async png=>{+ const m=await import('./js/circuit-traces.mjs'),b=m.keepouts(document.querySelector('#hero'),0).find(b=>b.id==='text-ink');+ const mask=new Uint8Array(1000*625);+ for(const r of b.rings) for(let y=r[0][1];y<r[2][1];y++) for(let x=r[0][0];x<r[1][0];x++) mask[y*1000+x]=1;+ const image=new Image();image.src='data:image/png;base64,'+png;await image.decode();+ const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+ const ctx=cv.getContext('2d');ctx.drawImage(image,0,0,1000,625);+ const pixels=ctx.getImageData(0,0,1000,625).data;let ink=0,miss=0;+ for(let i=0;i<mask.length;i++) if(pixels[i*4]>100){ink++;if(!mask[i])miss++;}+ return ink>1000 && miss/ink<0.015;+ },domInk));+ await setLocal({layout:'mirror'});+ ok('mirrored composition reroutes the field', field !== await src());+ const mirrored=await src();+ await setLocal({marqueePreview:true});+ ok('homepage overlay toggle preserves geometry', mirrored === await src());+ await setLocal({layout:'contained',marqueePreview:false});+ ok('returning to a composition reproduces it', field === await src());+ await setLocal({fontSize:{title:11},headline:'A longer headline wraps into more lines'});+ ok('text changes update the keepout', field !== await src());+ await setLocal({headline:'Traces with purpose',fontSize:{title:8}});+ if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});+ await setLocal({theme:'circuit-tile'});+ ok('tile artwork loads in browser', await page.$eval('#trace-art',im=>im.complete && im.naturalWidth===1000));+ ok('tile pixels are visible outside the showcase', await page.evaluate(()=>{+ const cv=document.createElement('canvas');cv.width=1000;cv.height=625;+ const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);+ const data=ctx.getImageData(0,0,1000,625).data;+ let ink=0; for(let i=3;i<data.length;i+=4) if(data[i]>10) ink++;+ return ink>1000 && ctx.getImageData(600,160,1,1).data[3]===0;+ }));+ ok('tile continues through the lower quarter',await bottomInk()>500);+ // A large O must retain its counter; a layout/line/character rectangle+ // cannot pass this. Range and font measurements must follow the actual ink.+ await setLocal({headline:'O',show:{subtitle:false},fontSize:{title:20}});+ ok('letter contours preserve open counters and unused column space',await page.evaluate(async()=>{+ const m=await import('./js/circuit-traces.mjs');const hero=document.querySelector('#hero');+ const text=m.keepouts(hero,0).find(b=>b.id==='text-ink');+ const range=document.createRange();range.selectNodeContents(document.querySelector('#el-title'));+ const r=range.getBoundingClientRect(),h=hero.getBoundingClientRect();+ const x=(r.left+r.width/2-h.left)*1000/h.width;+ // Cap-height O's counter is above the baseline, not the line box center.+ const y=(document.querySelector('#el-title').getBoundingClientRect().top+r.width*0.45-h.top)*625/h.height;+ const contains=(px,py)=>text.rings.some(r=>px>=r[0][0] && px<r[1][0] && py>=r[0][1] && py<r[2][1]);+ return text.rings.length>20 && !contains(x,y) && !contains(390,y)+ && text.rings.some(r=>r[0][0]<x && r[1][0]>x);+ }));+ await setLocal({headline:'Traces with purpose',show:{subtitle:true},fontSize:{title:8}});+ if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});+ const tile=await src();+ await setLocal({circuitSeed:4321});+ ok('seed control changes visible tile',tile!==await src());+ await setLocal({showcase:{full:true}});+ ok('full imports disable traces',await page.$eval('#trace-art',im=>!im.getAttribute('src') && getComputedStyle(im).display==='none'));+ await setLocal({showcase:{full:false},theme:'midnight'});+ ok('existing themes clear the trace layer',await page.$eval('#trace-art',im=>!im.getAttribute('src')));+ // Real wizard interaction + API persistence, separate from read-only export.+ const ui=await browser.newPage(); await ui.setViewport({width:1440,height:1000});+ await ui.goto(base+'/',{waitUntil:'networkidle2'});+ await ui.waitForFunction(()=>typeof state!=='undefined' && state);+ await ui.evaluate(()=>{wizStep='background';wizardRender();});+ await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();+ await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);+ ok('wizard offers both generators',await ui.$$eval('#wcards .card',els=>['Routed field','Seamless traces'].every(s=>els.some(el=>el.textContent.includes(s)))));+ await ui.$eval('#wtrace-controls input[aria-label="Trace seed"]',el=>{el.value='7654';el.dispatchEvent(new Event('change',{bubbles:true}));});+ await new Promise(r=>setTimeout(r,500));+ ok('wizard persists seed to API',(await (await fetch(base+'/state')).json()).circuitSeed===7654);+ await ui.waitForFunction(()=>!window.__tracePending);+ const uiArt=await ui.$eval('#trace-art',im=>im.src);+ await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();+ ok('wizard and export use identical artwork',uiArt===await src());+ await ui.bringToFront(); await ui.setViewport({width:1100,height:800});+ await new Promise(r=>setTimeout(r,300)); await ui.waitForFunction(()=>!window.__tracePending);+ ok('resizing preserves the seeded tile',uiArt===await ui.$eval('#trace-art',im=>im.src));+ await ui.setViewport({width:1440,height:1000});+ await ui.locator('#wcards .card:has(.sw-circuit-field)').click();+ await ui.waitForFunction(()=>state.theme==='circuit-field' && !window.__tracePending);+ const uiField=await ui.$eval('#trace-art',im=>im.src);+ await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();+ ok('routed field also matches the export',uiField===await src());+ await ui.bringToFront();+ const status=await (await fetch(base+'/changes')).json();+ ok('change ledger records seed',JSON.stringify(status).includes('circuitSeed'));+ if(process.env.TRACE_SHOWCASE) {+ await patch({showcase:{src:'data:image/png;base64,'+fs.readFileSync(process.env.TRACE_SHOWCASE).toString('base64'),full:false},headline:'Circuit traces',subtitle:'Made for your composition',fontSize:{title:8},guide:{status:'confirmed',brand:{colors:{primary:'#00b8b1',background:'#0d1117'}}}});+ await ui.waitForFunction(()=>state.headline==='Circuit traces' && !window.__tracePending);+ }+ if(screenshotDir) {+ await ui.screenshot({path:path.join(screenshotDir,'wizard-field.png')});+ await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});+ await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();+ await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);+ await ui.screenshot({path:path.join(screenshotDir,'wizard.png')});+ await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});+ }+ ok('no browser runtime errors', errors.length===0);+ console.log('\n'+passed+' circuit checks passed');+} finally {+ if(browser) await browser.close(); server.kill(); registry.close();+}
test/syntax.mjs+2−1@@ -9,7 +9,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const files = [- ...fs.readdirSync(path.join(ROOT, 'public/js')).filter((f) => f.endsWith('.js')).map((f) => 'public/js/' + f),+ ...fs.readdirSync(path.join(ROOT, 'public/js')).filter((f) => /\.m?js$/.test(f)).map((f) => 'public/js/' + f),+ ...fs.readdirSync(path.join(ROOT, 'public/vendor/circuit-trace-generators')).filter((f) => f.endsWith('.js')).map((f) => 'public/vendor/circuit-trace-generators/' + f), ...fs.readdirSync(ROOT).filter((f) => /\.(m|c)?js$/.test(f)), ...fs.readdirSync(path.join(ROOT, 'test')).filter((f) => /\.m?js$/.test(f)).map((f) => 'test/' + f), ];