Added QFT
This commit is contained in:
89
src/qft/distortion.js
Normal file
89
src/qft/distortion.js
Normal 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
60
src/qft/palette.js
Normal 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
88
src/qft/params.js
Normal 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
72
src/qft/propagator.js
Normal 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
187
src/qft/renderer.js
Normal 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 === '<' ? '<' : '&'); }
|
||||
|
||||
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
190
src/qft/scene.js
Normal 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
177
src/qft/topology.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user