Added QFT

This commit is contained in:
2026-05-29 15:40:42 -04:00
parent e59b024b62
commit 56c59a1f9c
148 changed files with 428741 additions and 7 deletions

89
src/qft/distortion.js Normal file
View File

@@ -0,0 +1,89 @@
/* ============================================================
qft/distortion.js — radial distortion of vertex positions.
Calm at the centre (no displacement); growing turbulent
toward the frame edges. Seeded random displacement per
vertex; smooth via a low-frequency noise so neighbouring
vertices move coherently (the grid LOCALLY warps rather
than each vertex jittering independently).
============================================================ */
import { gauss, makeRng } from '../rng.js';
/* VORTEX — swirling rotation around a centre with Gaussian falloff. Vertices
near the centre rotate tangentially; distant vertices are unaffected. Reads
as angular momentum / spin / a quantum vortex.
`vortices` is an array of { x, y, strength, sigma }. strength in radians (max). */
export function applyVortices(vertices, vortices_) {
if (!vortices_ || !vortices_.length) return;
for (const v of vertices) {
for (const vx of vortices_) {
const dx = v.x - vx.x, dy = v.y - vx.y;
const r = Math.hypot(dx, dy);
if (r < 1e-6) continue;
const falloff = Math.exp(-(r * r) / (2 * vx.sigma * vx.sigma));
const angle = vx.strength * falloff;
const cs = Math.cos(angle), sn = Math.sin(angle);
v.x = vx.x + dx * cs - dy * sn;
v.y = vx.y + dx * sn + dy * cs;
}
}
}
/* STANDING WAVES — global planar sinusoidal modulation. Each wave is a plane
wave with wavevector (kx, ky) and amplitude. Vertex displacement is
PERPENDICULAR to the wave direction (transverse wave) so the lattice ripples
like a drum membrane. Multiple waves superpose into interference patterns —
Chladni-plate / quantum nodal-line patterns.
`waves` is an array of { kx, ky, amplitude, phase? }. */
export function applyStandingWaves(vertices, waves) {
if (!waves || !waves.length) return;
for (const v of vertices) {
for (const w of waves) {
const phase = w.kx * v.x + w.ky * v.y + (w.phase ?? 0);
const disp = w.amplitude * Math.sin(phase);
const kMag = Math.hypot(w.kx, w.ky);
if (kMag < 1e-6) continue;
v.x += -w.ky / kMag * disp;
v.y += w.kx / kMag * disp;
}
}
}
/* WAVEPACKET — local Gaussian perturbation of the field around a centre point.
Pushes vertices RADIALLY outward (or inward, if amplitude is negative) with a
Gaussian envelope, so the lattice bulges/dimples locally. The visible signature
of a Schrödinger-style wavepacket: a localized particle expressed as a feature
OF the field itself, not as a separate object placed on top.
`wavepackets` is an array of { x, y, amplitude, sigma }. */
export function applyWavepackets(vertices, wavepackets) {
if (!wavepackets || !wavepackets.length) return;
for (const v of vertices) {
for (const w of wavepackets) {
const dx = v.x - w.x, dy = v.y - w.y;
const r = Math.hypot(dx, dy);
if (r < 1e-6) continue;
const falloff = Math.exp(-(r * r) / (2 * w.sigma * w.sigma));
const displace = w.amplitude * falloff;
v.x += (dx / r) * displace;
v.y += (dy / r) * displace;
}
}
}
/* Apply distortion in-place to a vertex list.
rCalm: radius within which there is no distortion
rMax: radius at which displacement is at maximum
strength: max displacement magnitude (in [-1,1] coords) */
export function distortVertices(vertices, rCalm, rMax, strength, seed, salt = 'distort') {
const rng = makeRng(seed, salt);
// pre-roll smooth-ish per-vertex displacements
for (const v of vertices) {
const r = Math.hypot(v.x, v.y);
// s ∈ [0,1]: 0 inside rCalm, 1 outside rMax, smooth between
const t = Math.max(0, Math.min(1, (r - rCalm) / Math.max(1e-6, rMax - rCalm)));
const s = t * t * (3 - 2 * t); // smoothstep
const dx = gauss(rng) * strength * s;
const dy = gauss(rng) * strength * s;
v.x += dx;
v.y += dy;
}
}

60
src/qft/palette.js Normal file
View File

@@ -0,0 +1,60 @@
/* ============================================================
qft/palette.js — per-field hue gradients + substrate variants.
Each FIELD (one lattice) is parameterised as a slider:
hueStart, hueEnd, saturation, lightness, opacity
The renderer asks for the colour at a t∈[0,1] along an edge (or
at an (x,y) position when the gradient mode is radial/linear).
"Hue gradient over time" = per-edge progression, the QFT analog
of the bubble-chamber lifecycle palette.
Self-contained: no dependency on the bubble-chamber palette
registry. Reuses only the generic hslToRgb util.
============================================================ */
import { hslToRgb } from '../render/palette.js';
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* Substrate variants — the "chemistry" the lattices sit on. */
export const SUBSTRATES = {
vintage: { paper: { flat: [42, 32, 22], glowIn: [62, 48, 32], glowOut: [22, 16, 10] },
vign: [10, 7, 4], feature: [178, 138, 78] },
void: { paper: { flat: [10, 10, 14], glowIn: [22, 22, 30], glowOut: [3, 3, 6] },
vign: [0, 0, 0], feature: [150, 150, 180] },
cream: { paper: { flat: [207, 200, 180], glowIn: [226, 219, 199], glowOut: [179, 170, 146] },
vign: [56, 46, 32], feature: [42, 30, 20] },
selenium: { paper: { flat: [190, 181, 197], glowIn: [210, 201, 217], glowOut: [156, 146, 166] },
vign: [42, 38, 54], feature: [58, 50, 72] },
cyanotype:{ paper: { flat: [22, 48, 82], glowIn: [38, 70, 110], glowOut: [10, 22, 44] },
vign: [6, 12, 22], feature: [180, 210, 235] },
};
export function resolveSubstrate(id = 'vintage') {
return SUBSTRATES[id] || SUBSTRATES.vintage;
}
/* Sample the gradient parameter s ∈ [0, 1] for a point on an edge.
- along-edge: s = t (the per-edge t the renderer passes in)
- radial: s = min(1, |(x, y)|) (centre → 0, corners → 1)
- linear-x: s = (x + 1) / 2 (left → 0, right → 1)
- linear-y: s = (y + 1) / 2 (top → 0, bottom → 1) */
export function gradientS(t, x, y, mode = 'along-edge') {
switch (mode) {
case 'radial': return clamp(Math.hypot(x, y), 0, 1);
case 'linear-x': return clamp((x + 1) / 2, 0, 1);
case 'linear-y': return clamp((y + 1) / 2, 0, 1);
case 'along-edge':
default: return clamp(t, 0, 1);
}
}
/* Compute the colour for a field at gradient parameter s. Hue rotates from
hueStart → hueEnd by the SHORT way around the circle (so a small Δ stays
small even when crossing 1.0). */
export function fieldColor(field, s) {
let dh = field.hueEnd - field.hueStart;
// wrap to shortest-path delta (-0.5 .. +0.5)
if (dh > 0.5) dh -= 1;
if (dh < -0.5) dh += 1;
const h = field.hueStart + s * dh;
return hslToRgb(h, field.saturation, field.lightness);
}

88
src/qft/params.js Normal file
View File

@@ -0,0 +1,88 @@
/* ============================================================
qft/params.js — full plate fingerprint from a seed.
Same pattern as bubble-chamber / milkweed: seed → archetype +
jitter → complete params object the QFT scene and renderer
consume. Per-field hue/saturation/lightness/opacity sliders
make each lattice independently parameterisable.
============================================================ */
import { makeRng } from '../rng.js';
const ARCHETYPES = [
{ name: 'balanced', w: 0.40 },
{ name: 'tight', w: 0.25 },
{ name: 'turbulent', w: 0.20 },
{ name: 'sparse', w: 0.15 },
];
function pickArchetype(rng) {
const total = ARCHETYPES.reduce((s, a) => s + a.w, 0);
let t = rng() * total;
for (const a of ARCHETYPES) { if ((t -= a.w) <= 0) return a.name; }
return 'balanced';
}
export function paramsFromSeed(seed) {
const rng = makeRng(seed, 'qft-params');
const arch = pickArchetype(rng);
const r = (lo, hi) => lo + (hi - lo) * rng();
const p = {
seed, archetype: arch,
// ---- lattice scale, rotation, and origin (in [-1,1] coords) ----
cubicScale: r(0.92, 1.05), cubicRot: r(-0.25, 0.25),
cubicOriginX: 0, cubicOriginY: 0,
cubicN: 1, // half-range of the cubic lattice. 1=3³=27 verts/~54 edges (default); 2=5³=125 verts/~300 edges (denser); 3=7³=343 verts (heaviest).
photonCyclesPerUnit: 12, // wave frequency along photon edges; higher = finer ripples
schlegelScale: r(0.88, 1.02), schlegelRot: r(-0.30, 0.30),
schlegelOriginX: 0, schlegelOriginY: 0,
schlegelOuterR: 0.78, schlegelInnerR: 0.32, schlegelRot3D: 0.42, // play with "shifting axes" of the 4D projection
// E8 is now SPLIT into multiple smaller petal clusters at distinct origins
// — addresses the "circular field too concentrated" feedback and gives
// the cross-field links real anchor-points to connect to
e8Count: 3, // number of E8 petal clusters
e8Scale: r(0.16, 0.24), // each cluster smaller than before
e8Rot: r(-0.40, 0.40),
e8OriginRadius: r(0.45, 0.60), // how far from centre the clusters sit
// ---- composition: move the whole scene on the paper ----
compositionOffsetX: 0, compositionOffsetY: 0,
// ---- cross-field interactions (Feynman symbols LINKING fields) ----
linkCount: 8,
linkPropagator: 'mixed', // 'mixed' | 'photon' | 'scalar' | 'gluon'
// ---- distortion (calm centre → turbulent edges) ----
distRCalm: r(0.32, 0.45), distRMax: r(0.90, 1.05),
distStrength: r(0.035, 0.065),
// ---- stroke ----
stroke: r(0.95, 1.30), // single weight; activity comes from hue, not thickness
// ---- per-field sliders (the playground) ----
// Each lattice has its own hue gradient (hueStart → hueEnd), saturation,
// lightness, and opacity. Tune any of these independently.
fields: {
cubic: { hueStart: 0.55, hueEnd: 0.48, saturation: 0.40, lightness: 0.58, opacity: 0.70 },
schlegel: { hueStart: 0.88, hueEnd: 0.96, saturation: 0.35, lightness: 0.52, opacity: 0.65 },
e8: { hueStart: 0.08, hueEnd: 0.14, saturation: 0.55, lightness: 0.58, opacity: 0.85 },
// cross-field links: bright saturated accents (the "interaction" colour)
links: { hueStart: 0.10, hueEnd: 0.04, saturation: 0.90, lightness: 0.65, opacity: 0.95 },
},
gradientMode: 'along-edge', // along-edge | radial | linear-x | linear-y
substrate: 'vintage', // vintage | void | cream | selenium | cyanotype
segmentsPerEdge: 12, // colour stops per edge polyline (smoothness ↔ filesize)
// ---- substrate / vignette ----
glow: 0.5,
vign: r(0.28, 0.42),
grain: r(0.35, 0.55),
showHeader: true,
};
if (arch === 'tight') {
Object.assign(p, { distRCalm: r(0.50, 0.62), distStrength: r(0.025, 0.045) });
} else if (arch === 'turbulent') {
Object.assign(p, { distRCalm: r(0.18, 0.28), distStrength: r(0.060, 0.090) });
} else if (arch === 'sparse') {
Object.assign(p, {
cubicScale: r(0.60, 0.72), schlegelScale: r(0.65, 0.78), e8Scale: r(0.30, 0.42),
});
}
return p;
}

72
src/qft/propagator.js Normal file
View File

@@ -0,0 +1,72 @@
/* ============================================================
qft/propagator.js — turn a straight edge (a→b) into the SVG
path data for its propagator decoration. Three styles:
- photon: sinusoidal wave perpendicular to the edge
- scalar: a straight line (the renderer applies dasharray)
- gluon: high-frequency tight oscillation reading as a spring
All return an array of {x,y} points; the renderer turns those
into an SVG <path> or <polyline>. Coordinates are in the same
space as the input endpoints (post-distortion).
============================================================ */
/* Decorate an edge with a propagator style. amp/cycles are in the same scale
as the edge length. opts.amp default scales with the edge length.
If opts.curvature is provided (non-zero), the propagator follows a quadratic
bezier through a perpendicular midpoint offset; otherwise it's straight.
This is how Feynman cross-field links get their organic tangle. */
export function decorateEdge(a, b, kind, opts = {}) {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-6) return [a, b];
const curvature = opts.curvature ?? 0;
const curved = Math.abs(curvature) > 1e-3;
if (kind === 'scalar' && !curved) {
return [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
}
// bezier control point (used only when curved)
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
const mx = (a.x + b.x) * 0.5, my = (a.y + b.y) * 0.5;
const cpX = mx + px * curvature * len * 0.5;
const cpY = my + py * curvature * len * 0.5;
// sampling density
const samples = Math.max(28, Math.floor(len * (kind === 'gluon' ? 130 : 75)));
const cyclesPerUnit = opts.cyclesPerUnit ?? (kind === 'gluon' ? 18 : 12);
const cycles = cyclesPerUnit * len;
const amp = (opts.amp ?? (kind === 'gluon' ? 0.014 : 0.020));
const isScalar = kind === 'scalar';
const pts = new Array(samples + 1);
for (let i = 0; i <= samples; i++) {
const t = i / samples;
// spine point: straight or bezier
let sx, sy, tx, ty;
if (curved) {
const u = 1 - t;
sx = u * u * a.x + 2 * u * t * cpX + t * t * b.x;
sy = u * u * a.y + 2 * u * t * cpY + t * t * b.y;
// bezier tangent
tx = 2 * u * (cpX - a.x) + 2 * t * (b.x - cpX);
ty = 2 * u * (cpY - a.y) + 2 * t * (b.y - cpY);
} else {
sx = a.x + ux * len * t;
sy = a.y + uy * len * t;
tx = ux; ty = uy;
}
if (isScalar) { pts[i] = { x: sx, y: sy }; continue; }
// perpendicular sine offset for photon & gluon
const tMag = Math.hypot(tx, ty);
const perpX = -ty / Math.max(tMag, 1e-9);
const perpY = tx / Math.max(tMag, 1e-9);
const taper = Math.sin(t * Math.PI);
const phase = t * 2 * Math.PI * cycles;
const off = amp * taper * Math.sin(phase);
pts[i] = { x: sx + perpX * off, y: sy + perpY * off };
}
return pts;
}

187
src/qft/renderer.js Normal file
View File

@@ -0,0 +1,187 @@
/* ============================================================
qft/renderer.js — render a QFT scene to SVG.
Stroke-based: each edge is decorated with its propagator shape
(photon-wave, scalar-dash, gluon-spring), then split into N
short sub-segments whose colours sample the field's hue
gradient (along-edge / radial / linear-x / linear-y). Activity
is carried by the gradient — there are no special "active"
edges.
Substrate (paper) is picked from a small set of named variants
AND can be overridden with arbitrary RGB via params.paperOverride.
============================================================ */
import { makeRng } from '../rng.js';
import { decorateEdge } from './propagator.js';
import { resolveSubstrate, gradientS, fieldColor } from './palette.js';
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const rgbHex = (c) => '#' + c.map(v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join('');
const rgbCss = (c) => 'rgb(' + c.map(v => clamp(Math.round(v), 0, 255)).join(',') + ')';
const PROP_DASH_UNIT = [7, 4];
function layer(id, label, content) {
return `<g id="${id}" inkscape:groupmode="layer" inkscape:label="${label}">\n${content}</g>\n`;
}
function esc(s) { return String(s).replace(/[<&]/g, c => c === '<' ? '&lt;' : '&amp;'); }
export function renderQFTSVG(scene, params, size = 4800) {
const w = size, h = size;
const u = size / 1000;
// resolve substrate; allow full override via params.paperOverride / vignOverride
const sub = resolveSubstrate(params.substrate);
const paper = params.paperOverride || sub.paper;
const vignCol = params.vignOverride || sub.vign;
const featCol = params.featureOverride || sub.feature;
// canvas [-1, 1] → pixel
const margin = 0.06 * size;
const span = size - 2 * margin;
const tx = (x) => margin + (x + 1) * 0.5 * span;
const ty = (y) => margin + (y + 1) * 0.5 * span;
/* ---------- defs (radial gas-glow + vignette) ---------- */
const glow = `<radialGradient id="qglow" cx="50%" cy="50%" r="62%">`
+ `<stop offset="0%" stop-color="${rgbCss(paper.glowIn)}" stop-opacity="1"/>`
+ `<stop offset="100%" stop-color="${rgbCss(paper.flat)}" stop-opacity="1"/>`
+ `</radialGradient>`;
const vignDef = `<radialGradient id="qvign" cx="50%" cy="50%" r="80%">`
+ `<stop offset="0%" stop-color="${rgbHex(vignCol)}" stop-opacity="0"/>`
+ `<stop offset="80%" stop-color="${rgbHex(vignCol)}" stop-opacity="0.32"/>`
+ `<stop offset="100%" stop-color="${rgbHex(vignCol)}" stop-opacity="0.75"/>`
+ `</radialGradient>`;
const paperLayer = `<rect width="${w}" height="${h}" fill="${rgbCss(paper.flat)}"/>`
+ `<rect width="${w}" height="${h}" fill="url(#qglow)" opacity="${(params.glow ?? 0.5)}"/>`;
/* ---------- per-grid layer: gradient-segmented edges ---------- */
const dashAttr = ` stroke-dasharray="${(PROP_DASH_UNIT[0] * u).toFixed(1)},${(PROP_DASH_UNIT[1] * u).toFixed(1)}" stroke-linecap="butt"`;
const N = Math.max(2, params.segmentsPerEdge | 0);
// propagator decoration options derived from params (lets variations dial finer
// photon waves or tighter gluon coils without touching the renderer call sites)
const propOpts = (kind) => {
if (kind === 'photon' && params.photonCyclesPerUnit != null)
return { cyclesPerUnit: params.photonCyclesPerUnit };
if (kind === 'gluon' && params.gluonCyclesPerUnit != null)
return { cyclesPerUnit: params.gluonCyclesPerUnit };
return {};
};
function gridLayer(g) {
const field = params.fields[g.id];
if (!field) return '';
// per-field stroke override: field.stroke wins over params.stroke
const stroke = (field.stroke ?? params.stroke) * u;
const op = field.opacity;
let out = '';
for (let i = 0; i < g.edges.length; i++) {
const e = g.edges[i];
const a = g.vertices[e.a], b = g.vertices[e.b];
const pts = decorateEdge(a, b, g.propagator, propOpts(g.propagator));
if (pts.length < 2) continue;
const dash = g.propagator === 'scalar' ? dashAttr : '';
// walk the decorated polyline; emit N sub-segments, each coloured by the
// gradient parameter s at its mid-position
const step = (pts.length - 1) / N;
for (let k = 0; k < N; k++) {
const i0 = Math.floor(k * step);
const i1 = Math.floor((k + 1) * step);
if (i1 <= i0) continue;
const tMid = (k + 0.5) / N;
const mx = (pts[i0].x + pts[i1].x) * 0.5;
const my = (pts[i0].y + pts[i1].y) * 0.5;
const s = gradientS(tMid, mx, my, params.gradientMode);
const col = fieldColor(field, s);
// emit a polyline for this sub-segment (preserves any wave/spring shape)
let d = `M ${tx(pts[i0].x).toFixed(1)} ${ty(pts[i0].y).toFixed(1)}`;
for (let j = i0 + 1; j <= i1; j++) d += ` L ${tx(pts[j].x).toFixed(1)} ${ty(pts[j].y).toFixed(1)}`;
out += `<path d="${d}" fill="none" stroke="${rgbHex(col)}" stroke-width="${stroke.toFixed(2)}" stroke-opacity="${op.toFixed(2)}"${dash} stroke-linejoin="round"/>\n`;
}
}
return out;
}
// there can now be MULTIPLE grids per id (e.g. several E8 clusters); collect
// them by their id so each goes into its named Inkscape layer
const byId = {};
for (const g of scene.grids) {
(byId[g.id] = byId[g.id] || []).push(gridLayer(g));
}
const cubicL = (byId.cubic || []).join('');
const schlL = (byId.schlegel || []).join('');
const e8L = (byId.e8 || []).join('');
/* ---------- cross-field LINKS (the Feynman symbols connecting fields) ----------
Each link is decorated with its own propagator style and coloured by a
dedicated `links` field config so it can stand out as the "interaction." */
let linksLayer = '';
if (scene.links && scene.links.length) {
// fall back to a sensible bright accent if a variation forgot to include links
const lf = params.fields.links || { hueStart: 0.10, hueEnd: 0.04, saturation: 0.90, lightness: 0.65, opacity: 0.95 };
const lstroke = (params.stroke * 1.5) * u; // slightly heavier than lattice edges
for (const link of scene.links) {
const linkOpts = propOpts(link.propagator);
if (link.curvature) linkOpts.curvature = link.curvature;
const pts = decorateEdge(link.a, link.b, link.propagator, linkOpts);
if (pts.length < 2) continue;
const dash = link.propagator === 'scalar' ? dashAttr : '';
const step = (pts.length - 1) / N;
for (let k = 0; k < N; k++) {
const i0 = Math.floor(k * step);
const i1 = Math.floor((k + 1) * step);
if (i1 <= i0) continue;
const tMid = (k + 0.5) / N;
const mx = (pts[i0].x + pts[i1].x) * 0.5;
const my = (pts[i0].y + pts[i1].y) * 0.5;
const s = gradientS(tMid, mx, my, params.gradientMode);
const col = fieldColor(lf, s);
let d = `M ${tx(pts[i0].x).toFixed(1)} ${ty(pts[i0].y).toFixed(1)}`;
for (let j = i0 + 1; j <= i1; j++) d += ` L ${tx(pts[j].x).toFixed(1)} ${ty(pts[j].y).toFixed(1)}`;
linksLayer += `<path d="${d}" fill="none" stroke="${rgbHex(col)}" stroke-width="${lstroke.toFixed(2)}" stroke-opacity="${lf.opacity.toFixed(2)}"${dash} stroke-linejoin="round"/>\n`;
}
}
}
/* ---------- subtle photographic grain ---------- */
let grain = '';
if (params.grain > 0) {
const gRng = makeRng(params.seed, 'qft-grain');
const nSpecks = Math.round(params.grain * 1400);
for (let i = 0; i < nSpecks; i++) {
const x = gRng() * w, y = gRng() * h;
const r = (0.4 + gRng() * 0.9) * u;
const op = (0.06 + gRng() * 0.10);
grain += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(2)}" fill="${rgbHex(paper.glowOut)}" fill-opacity="${op.toFixed(2)}"/>`;
}
}
/* ---------- header ---------- */
let header = '';
if (params.showHeader) {
const pad = 26 * u;
const ink = rgbHex(featCol);
header = `<g fill="${ink}" font-family="'JetBrains Mono', monospace">`
+ `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.70">${esc(scene.lab.toUpperCase())}</text>`
+ `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.55">SEED ${esc(params.seed)}</text>`
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.62">PLATE ${scene.plate}</text>`
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.62">RECORDED ${scene.exposure}</text>`
+ `</g>`;
}
const xmlns = 'xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"';
return `<?xml version="1.0" encoding="UTF-8"?>
<svg ${xmlns} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs>${glow}${vignDef}</defs>
${layer('paper', 'Paper', paperLayer)}
${layer('lattice-cubic', 'Lattice · cubic · photon', cubicL)}
${layer('lattice-schlegel', 'Lattice · Schlegel · scalar', schlL)}
${layer('lattice-e8', 'Lattice · E8 · gluon (clusters)', e8L)}
${layer('cross-links', 'Cross-field links (Feynman interactions)', linksLayer)}
${layer('grain', 'Grain', grain)}
${layer('vignette', 'Vignette', `<rect width="${w}" height="${h}" fill="url(#qvign)" opacity="${(params.vign ?? 0.5)}"/>`)}
${layer('header', 'Archival header', header)}
</svg>`;
}

190
src/qft/scene.js Normal file
View File

@@ -0,0 +1,190 @@
/* ============================================================
qft/scene.js — assemble a QFT plate from params.
Per-field origins (each lattice movable), multi-E8 clusters
scattered around the canvas, and explicit cross-field LINKS
(Feynman propagator symbols connecting a vertex in one field
to a vertex in another — the "interactions" between fields).
Returns:
{ grids: [...], links: [...], hash, plate, exposure, lab }
Each grid: { id, propagator, vertices:[{x,y}], edges:[{a,b}] }
Each link: { a:{x,y}, b:{x,y}, propagator: 'photon'|'scalar'|'gluon' }
============================================================ */
import { makeRng, cyrb53, pick } from '../rng.js';
import { buildCubic, buildSchlegel, buildE8, buildNautilus, buildRipples } from './topology.js';
import { distortVertices, applyWavepackets, applyVortices, applyStandingWaves } from './distortion.js';
const LABS = [
'LATTICE QFT · LANL',
'BNL · ALGEBRAIC GEOMETRY GROUP',
'IHES · BURES-SUR-YVETTE',
'PERIMETER INSTITUTE · WATERLOO',
'INST. THEORETICAL PHYSICS · COPENHAGEN',
];
function rotateScale(verts, angle, scale) {
const c = Math.cos(angle), s = Math.sin(angle);
for (const v of verts) {
const x = v.x * c - v.y * s;
const y = v.x * s + v.y * c;
v.x = x * scale; v.y = y * scale;
}
}
function translateVerts(verts, dx, dy) {
for (const v of verts) { v.x += dx; v.y += dy; }
}
export function generateQFTScene(params) {
// ---- cubic ---- (cubicN controls density: 1=27v, 2=125v, 3=343v)
const cubic = buildCubic(Math.max(1, params.cubicN | 0));
rotateScale(cubic.vertices, params.cubicRot, params.cubicScale);
translateVerts(cubic.vertices, params.cubicOriginX, params.cubicOriginY);
cubic.id = 'cubic'; cubic.propagator = 'photon';
// ---- schlegel ---- (outer/inner radius and 3D rotation now parameterised)
const schl = buildSchlegel(
params.schlegelOuterR ?? 0.78,
params.schlegelInnerR ?? 0.32,
params.schlegelRot3D ?? 0.42,
);
rotateScale(schl.vertices, params.schlegelRot, params.schlegelScale);
translateVerts(schl.vertices, params.schlegelOriginX, params.schlegelOriginY);
schl.id = 'schlegel'; schl.propagator = 'scalar';
// ---- E8 clusters ----
// If params.e8Origins (array of {x,y}) is provided, place clusters exactly
// at those positions (count comes from the array length). Otherwise auto-
// distribute on a ring of radius e8OriginRadius.
const e8Rng = makeRng(params.seed, 'qft-e8');
const e8Instances = [];
let e8Origins;
if (Array.isArray(params.e8Origins) && params.e8Origins.length) {
e8Origins = params.e8Origins;
} else {
const N = Math.max(1, params.e8Count | 0);
e8Origins = [];
const angleJitter = 0.35;
for (let i = 0; i < N; i++) {
const baseAng = (i / N) * Math.PI * 2;
const ang = baseAng + (e8Rng() - 0.5) * angleJitter;
const radius = N === 1 ? 0 : params.e8OriginRadius;
e8Origins.push({ x: Math.cos(ang) * radius, y: Math.sin(ang) * radius });
}
}
for (let i = 0; i < e8Origins.length; i++) {
const o = e8Origins[i];
// per-cluster scale override: e8Origins[i] may include {x, y, scale}
const clusterScale = (typeof o.scale === 'number') ? o.scale : params.e8Scale;
// pick rosette geometry: open NAUTILUS spiral (default for chaotic-growth feel)
// or closed concentric RINGS (the older E8-Coxeter-style mandala).
const style = params.e8Style ?? 'nautilus';
const e = style === 'rings'
? buildE8(4, 18)
: buildNautilus(params.nautilusTurns ?? 2.6,
params.nautilusPerTurn ?? 14,
params.nautilusGrowth ?? 0.21);
rotateScale(e.vertices, params.e8Rot + (e8Rng() - 0.5) * 0.6, clusterScale);
translateVerts(e.vertices, o.x, o.y);
e.id = 'e8';
e.instance = i;
e.propagator = 'gluon';
e8Instances.push(e);
}
// ---- RIPPLES — concentric wavefronts from source points ----
// params.ripples: [{ x, y, count?, r0?, dR?, propagator? }]
const rippleInstances = [];
if (Array.isArray(params.ripples)) {
for (let i = 0; i < params.ripples.length; i++) {
const rs = params.ripples[i];
const r = buildRipples(
{ x: rs.x, y: rs.y },
rs.count ?? 6,
rs.r0 ?? 0.06,
rs.dR ?? 0.09,
rs.segments ?? 40,
);
r.id = 'ripple';
r.instance = i;
r.propagator = rs.propagator ?? 'photon';
rippleInstances.push(r);
}
}
const allFields = [cubic, schl, ...e8Instances, ...rippleInstances];
// ---- FIELD PERTURBATIONS — applied to cubic + schlegel after scale/rotate ----
// wavepackets: Gaussian bulge/dimple (Schrödinger particle as field feature)
if (Array.isArray(params.wavepackets) && params.wavepackets.length) {
applyWavepackets(cubic.vertices, params.wavepackets);
applyWavepackets(schl.vertices, params.wavepackets);
}
// vortices: swirling Gaussian rotation (angular momentum / spin)
if (Array.isArray(params.vortices) && params.vortices.length) {
applyVortices(cubic.vertices, params.vortices);
applyVortices(schl.vertices, params.vortices);
}
// standing waves: global plane-wave sinusoidal modulation (Chladni / nodal lines)
if (Array.isArray(params.standingWaves) && params.standingWaves.length) {
applyStandingWaves(cubic.vertices, params.standingWaves);
applyStandingWaves(schl.vertices, params.standingWaves);
}
// distortion (per-field salt so neighbouring lattices warp differently)
for (const g of allFields) {
const salt = 'dist:' + g.id + (g.instance != null ? ':' + g.instance : '');
distortVertices(g.vertices, params.distRCalm, params.distRMax, params.distStrength,
params.seed, salt);
}
// composition offset — translate the whole scene on the paper
if (params.compositionOffsetX || params.compositionOffsetY) {
for (const g of allFields) translateVerts(g.vertices, params.compositionOffsetX, params.compositionOffsetY);
}
// ---- cross-field links ----
const linkRng = makeRng(params.seed, 'qft-links');
const propChoices = ['photon', 'scalar', 'gluon'];
const linkCount = Math.max(0, params.linkCount | 0);
const links = [];
// pair fields by their INDEX in allFields so multi-E8 clusters can interact
// with cubic/schlegel and with each other
for (let i = 0; i < linkCount && allFields.length >= 2; i++) {
let f1Idx, f2Idx, attempts = 0;
do {
f1Idx = Math.floor(linkRng() * allFields.length);
f2Idx = Math.floor(linkRng() * allFields.length);
attempts++;
} while ((f1Idx === f2Idx || allFields[f1Idx].id === allFields[f2Idx].id) && attempts < 20);
const f1 = allFields[f1Idx], f2 = allFields[f2Idx];
const v1 = f1.vertices[Math.floor(linkRng() * f1.vertices.length)];
const v2 = f2.vertices[Math.floor(linkRng() * f2.vertices.length)];
const prop = params.linkPropagator === 'mixed'
? propChoices[Math.floor(linkRng() * propChoices.length)]
: (params.linkPropagator || 'photon');
// per-link curvature: random sign × random magnitude up to params.linkCurvature.
// a fraction of links stay straight (curvature=0) so the result is mixed-tangle.
let curvature = 0;
const lc = params.linkCurvature ?? 0;
if (lc > 1e-3) {
// 30% straight, 70% curved; signed
if (linkRng() > 0.30) {
const sign = linkRng() < 0.5 ? -1 : 1;
curvature = sign * lc * (0.45 + linkRng() * 0.55);
}
}
links.push({ a: { x: v1.x, y: v1.y }, b: { x: v2.x, y: v2.y }, propagator: prop, curvature });
}
// archival metadata
const hash = cyrb53(params.seed);
const ds = parseInt(hash.slice(0, 8), 16);
const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0');
const year = 1985 + (ds % 30);
const month = 1 + ((ds >> 4) % 12);
const day = 1 + ((ds >> 8) % 28);
const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
const lab = pick(makeRng(params.seed, 'qft-lab'), LABS);
return { grids: allFields, links, hash, plate, exposure, lab };
}

177
src/qft/topology.js Normal file
View File

@@ -0,0 +1,177 @@
/* ============================================================
qft/topology.js — vertex + edge generators for the three grids.
Each builder returns:
{ vertices: [{x,y}], edges: [{a, b}] }
in a normalized [-1,1] coordinate frame. The scene then scales
and rotates each grid into its place on the canvas.
- buildCubic: a small 3D cubic lattice projected isometrically
- buildSchlegel: a 4D tesseract Schlegel diagram (cube-in-cube)
- buildE8: concentric petal-ring lattice (E8-Coxeter feel,
simplified for legibility without literal E8 fidelity)
============================================================ */
/* 3D cubic lattice, isometric-projected to 2D and centred at origin.
N is half-range: vertices at integer (i,j,k) ∈ [-N..N]^3.
N=1 → 3×3×3 = 27 vertices, ~54 edges. Plenty at thumbnail scale. */
export function buildCubic(N = 1, scale = 1.0) {
const vs = [], v3to1 = new Map();
// standard isometric, centred: x = (i-k)*cos30, y = (i+k)*sin30 - j
const proj = (i, j, k) => {
const ix = (i - k) * 0.866;
const iy = (i + k) * 0.5 - j;
// normalise so the cube fits within ~[-1,1] at scale=1
return { x: ix * scale * 0.5, y: iy * scale * 0.5 };
};
for (let i = -N; i <= N; i++)
for (let j = -N; j <= N; j++)
for (let k = -N; k <= N; k++) {
v3to1.set(`${i},${j},${k}`, vs.length);
vs.push(proj(i, j, k));
}
// edges along each axis (i / j / k adjacency)
const es = [];
for (let i = -N; i <= N; i++)
for (let j = -N; j <= N; j++)
for (let k = -N; k <= N; k++) {
const a = v3to1.get(`${i},${j},${k}`);
if (i < N) es.push({ a, b: v3to1.get(`${i + 1},${j},${k}`) });
if (j < N) es.push({ a, b: v3to1.get(`${i},${j + 1},${k}`) });
if (k < N) es.push({ a, b: v3to1.get(`${i},${j},${k + 1}`) });
}
return { vertices: vs, edges: es };
}
/* 4D tesseract Schlegel diagram: an outer cube projected from the "outside" 4D
vertex inward, so it appears as an inner cube nested inside an outer cube,
with 8 connecting edges. Total: 16 vertices, 32 edges. */
export function buildSchlegel(outerR = 0.78, innerR = 0.32, rot3D = 0.4) {
// 4D vertices at (±1, ±1, ±1, ±1). Project from a 4D camera so the +w vertex
// group lands on the inner cube, the -w group on the outer.
const vs = [];
const cs = Math.cos(rot3D), sn = Math.sin(rot3D);
for (let w = 0; w < 2; w++)
for (let z = 0; z < 2; z++)
for (let y = 0; y < 2; y++)
for (let x = 0; x < 2; x++) {
// pre-projection 3D coords (after Schlegel from 4D)
const r = w === 0 ? outerR : innerR;
let X = (x ? 1 : -1) * r;
let Y = (y ? 1 : -1) * r;
let Z = (z ? 1 : -1) * r;
// slight 3D rotation (around y axis) so the cube has perspective
const Xr = X * cs - Z * sn;
const Zr = X * sn + Z * cs;
// project to 2D: simple perspective on z
const persp = 1 / (1.8 - Zr);
vs.push({ x: Xr * 1.4 * persp, y: Y * 1.4 * persp });
}
const idx = (x, y, z, w) => ((w * 2 + z) * 2 + y) * 2 + x;
const es = [];
for (let w = 0; w < 2; w++)
for (let z = 0; z < 2; z++)
for (let y = 0; y < 2; y++)
for (let x = 0; x < 2; x++) {
// 12 cube edges per w-slice
if (x === 0) es.push({ a: idx(0, y, z, w), b: idx(1, y, z, w) });
if (y === 0) es.push({ a: idx(x, 0, z, w), b: idx(x, 1, z, w) });
if (z === 0) es.push({ a: idx(x, y, 0, w), b: idx(x, y, 1, w) });
// 8 connecting edges between outer and inner cube
if (w === 0) es.push({ a: idx(x, y, z, 0), b: idx(x, y, z, 1) });
}
return { vertices: vs, edges: es };
}
/* RIPPLES — concentric expanding waves from a source point. The visible signature
of a "perturbation in a field" — a localized disturbance propagating outward
through the medium. Each ring is a many-segmented polyline so the renderer's
propagator decoration applies to each segment. Outer rings can fade by tuning
opacity in the field config; the ring radius progression (r0 + k·dR) gives an
even spacing — set dR larger for more dramatic wavelength. */
export function buildRipples(center = { x: 0, y: 0 }, count = 5, r0 = 0.08, dR = 0.10, segments = 40) {
const vs = [];
const es = [];
for (let k = 0; k < count; k++) {
const r = r0 + k * dR;
const startIdx = vs.length;
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * Math.PI * 2;
vs.push({ x: center.x + Math.cos(a) * r, y: center.y + Math.sin(a) * r });
}
for (let i = 0; i < segments; i++) {
es.push({ a: startIdx + i, b: startIdx + i + 1 });
}
}
return { vertices: vs, edges: es };
}
/* Open NAUTILUS spiral — vertices sit on a logarithmic spiral r = r0·exp(k·φ);
each successive turn is exponentially larger so the "chambers" (the
quadrilaterals between two spiral arms and two radial spokes) grow with
radius. Reads as evolving/emergent rather than static like a closed ring.
Edges: (a) along the spiral (each vertex to its next), (b) radial — each
vertex to the one one full turn ahead at roughly the same angle. */
export function buildNautilus(turns = 2.6, perTurn = 14, growth = 0.21, r0 = 0.025) {
const N = Math.max(2, Math.floor(turns * perTurn));
const dPhi = 2 * Math.PI / perTurn;
const vs = [];
for (let i = 0; i < N; i++) {
const phi = i * dPhi;
const r = r0 * Math.exp(growth * phi);
vs.push({ x: Math.cos(phi) * r, y: Math.sin(phi) * r });
}
const es = [];
for (let i = 0; i < N - 1; i++) es.push({ a: i, b: i + 1 }); // along the spiral
for (let i = 0; i + perTurn < N; i++) es.push({ a: i, b: i + perTurn }); // radial (one turn ahead)
return { vertices: vs, edges: es };
}
/* Concentric petal rings — an E8-Coxeter-feel projection without literal E8
fidelity. nRings concentric circles, vertices per ring, each ring rotated by
a phase so vertices interlock. Edges: along each ring (circular adjacency)
plus radial connections to next-ring nearest-neighbours. */
export function buildE8(nRings = 4, perRing = 18, r0 = 0.22, rStep = 0.18) {
const vs = [];
const rings = []; // index ranges per ring
for (let i = 0; i < nRings; i++) {
const r = r0 + i * rStep;
const phase = i * Math.PI / perRing; // alternate rings slightly rotated
const start = vs.length;
for (let k = 0; k < perRing; k++) {
const a = phase + k * 2 * Math.PI / perRing;
vs.push({ x: Math.cos(a) * r, y: Math.sin(a) * r });
}
rings.push({ start, count: perRing });
}
const es = [];
// along each ring
for (const ring of rings) {
for (let k = 0; k < ring.count; k++) {
const a = ring.start + k;
const b = ring.start + (k + 1) % ring.count;
es.push({ a, b });
}
}
// between adjacent rings — each vertex connects to its TWO nearest neighbours
// on the next ring (so the petal pattern emerges)
for (let i = 0; i < rings.length - 1; i++) {
const A = rings[i], B = rings[i + 1];
for (let k = 0; k < A.count; k++) {
const a = A.start + k;
// nearest on B by angle
const ax = vs[a].x, ay = vs[a].y;
let best1 = -1, best2 = -1, d1 = Infinity, d2 = Infinity;
for (let kk = 0; kk < B.count; kk++) {
const bIdx = B.start + kk;
const dx = vs[bIdx].x - ax, dy = vs[bIdx].y - ay;
const d = dx * dx + dy * dy;
if (d < d1) { d2 = d1; best2 = best1; d1 = d; best1 = bIdx; }
else if (d < d2) { d2 = d; best2 = bIdx; }
}
// single radial connection — cuts E8 edge count roughly in half so the
// gluon-spring decoration doesn't pile into visual noise
if (best1 >= 0) es.push({ a, b: best1 });
}
}
return { vertices: vs, edges: es };
}