/* ============================================================
qft/perspgrid.js — a PERSPECTIVE DEPTH GRID that RIPPLES.
Draughtsman's construction lines converging to a vanishing point
(VP, need not be on the horizon), but every line is displaced by a
shared wave field W(r,θ) so the whole grid undulates like the sea.
Two layers with DISTINCT ripple (frequency/phase) overlaid → gentle
moiré interference. Kept faint / near the paper tone — a structure
half-seen, a guess at the unknowable geometry behind the field.
• rays — recede from the VP, wiggling perpendicular to their run
• depth — transverse rings at perspective-spaced radii, breathing
in/out radially
Lines fade toward the VP (atmospheric convergence to infinity).
modes: solid (opaque paper) · plate (transparent, for plexi stacking)
============================================================ */
import { makeRng, range } from '../rng.js';
import { resolveSubstrate } from './palette.js';
function hslToRgb(h, s, l) {
h = ((h % 1) + 1) % 1;
const a = s * Math.min(l, 1 - l);
const f = (n) => { const k = (n + h * 12) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
const css = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
function smoothPath(pts, close = false) {
if (pts.length < 3) return 'M ' + pts.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ');
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} `;
const n = pts.length;
const get = (i) => pts[close ? (i + n) % n : Math.max(0, Math.min(n - 1, i))];
for (let i = 0; i < (close ? n : n - 1); i++) {
const p0 = get(i - 1), p1 = get(i), p2 = get(i + 1), p3 = get(i + 2);
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
d += `C ${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${p2.x.toFixed(1)} ${p2.y.toFixed(1)} `;
}
if (close) d += 'Z';
return d;
}
/* perspFloorSVG — a STRAIGHT-LINED perspective grid: a planar cartesian grid
(rails + ties) projected with a movable camera, converging to a vanishing
point. Reads instantly as receding space. Optional GENTLE floor-height ripple
(default ~0, stays straight). yaw>0 → two-point perspective. */
export function perspFloorSVG(size, opts = {}) {
const o = Object.assign({
seed: 'GAUGE-2046', salt: 'floor', mode: 'solid', substrate: 'cream',
nx: 14, nz: 22, yaw: 0, pitch: 0.5, roll: 0, persp: 1.0, dist: 3.0, zShift: 0,
scale: 1.0, originX: 0, originY: 0.32,
rippleAmp: 0, rippleFreqI: 0.5, rippleFreqK: 0.4, ripplePhase: 0,
hue: 0.56, hue2: 0.5, sat: 0.26, lightNear: 0.32, lightFar: 0.62,
stroke: 1.5, strokeFar: 0.45, opacityMul: 1,
}, opts);
const W = size, H = size, u = size / 1000;
const paper = resolveSubstrate(o.substrate).paper.flat;
const transparent = o.mode === 'plate';
const cy = Math.cos(o.yaw), sy = Math.sin(o.yaw), cx = Math.cos(o.pitch), sx = Math.sin(o.pitch), cz = Math.cos(o.roll), sz = Math.sin(o.roll);
const dist = o.dist * Math.max(1, o.nz), near = 0.12 * dist;
const Lh = size / 2;
const cssR = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
// project a floor point (i, k); returns screen px + depth + clip flag
const proj = (i, k) => {
const yWorld = o.rippleAmp ? o.rippleAmp * Math.sin(o.rippleFreqI * i + o.rippleFreqK * k + o.ripplePhase) : 0;
let x = i, y = yWorld, z = k;
const x1 = x * cy + z * sy, z1 = -x * sy + z * cy, y1 = y;
const y2 = y1 * cx - z1 * sx, z2 = y1 * sx + z1 * cx, x2 = x1;
const xr = x2 * cz - y2 * sz, yr = x2 * sz + y2 * cz, zr = z2 + o.zShift;
const denom = dist - zr * o.persp, clip = o.persp > 0 && denom <= near;
const f = o.persp > 0 ? dist / Math.max(denom, near) : 1;
return {
x: W / 2 + (xr * f * o.scale * 0.5 + o.originX) * Lh,
y: H / 2 + (-yr * f * o.scale * 0.5 + o.originY) * Lh,
depth: zr, clip,
};
};
// grid of vertices
const V = [];
for (let i = -o.nx; i <= o.nx; i++) { const row = []; for (let k = -o.nz; k <= o.nz; k++) row.push(proj(i, k)); V.push(row); }
// depth range for atmospheric fade
let zmin = Infinity, zmax = -Infinity;
for (const row of V) for (const p of row) { if (p.clip) continue; if (p.depth < zmin) zmin = p.depth; if (p.depth > zmax) zmax = p.depth; }
const span = (zmax - zmin) || 1;
const fade = (d) => Math.min(1, Math.max(0, (zmax - d) / span)); // 1 near → 0 far
const colAt = (t) => cssR(hslToRgb(o.hue + (o.hue2 - o.hue) * (1 - t), o.sat * (0.5 + 0.5 * t), o.lightFar + (o.lightNear - o.lightFar) * t));
const swAt = (t) => (o.strokeFar + (o.stroke - o.strokeFar) * t) * u;
const opAt = (t) => ((transparent ? 0.18 + 0.55 * t : 0.28 + 0.55 * t) * o.opacityMul);
const seg = (a, b) => {
if (a.clip || b.clip) return '';
const t = fade((a.depth + b.depth) / 2);
return ``;
};
let body = '';
for (let i = 0; i < V.length; i++) for (let k = 0; k < V[i].length; k++) {
if (k + 1 < V[i].length) body += seg(V[i][k], V[i][k + 1]); // rails (constant i, into depth)
if (i + 1 < V.length) body += seg(V[i][k], V[i + 1][k]); // ties (constant k, across)
}
const bg = transparent ? '' : ``;
return `
`;
}
export function perspectiveGridSVG(size, opts = {}) {
const o = Object.assign({
seed: 'GAUGE-2046', salt: 'persp', mode: 'solid', substrate: 'cream',
vp: [0, -0.1], dir: Math.PI / 2, spread: Math.PI * 2,
rays: 30, depthLines: 16, depthPow: 2.3, rMin: 0.05, rMax: 2.8,
// ripple wave field
rippleAmp: 0.07, // perpendicular wiggle of rays (normalized)
rippleAmpRad: 0.06, // radial breathing of depth rings
rippleFreqR: 2.4, // cycles along the radius
rippleFreqA: 5, // cycles around the angle
ripplePhase: 0,
// faint, near-paper colour by default
hue: 0.55, hue2: 0.5, sat: 0.28, lightNear: 0.42, lightFar: 0.66,
stroke: 1.4, strokeFar: 0.5, opacityMul: 1,
}, opts);
const W = size, H = size, u = size / 1000;
const paper = resolveSubstrate(o.substrate).paper.flat;
const rng = makeRng(o.seed, o.salt);
const transparent = o.mode === 'plate';
const X = (nx) => (nx + 1) / 2 * W, Y = (ny) => (ny + 1) / 2 * H, L = size / 2;
const vpx = X(o.vp[0]), vpy = Y(o.vp[1]);
const full = o.spread >= Math.PI * 2 - 1e-3;
const a0 = o.dir - o.spread / 2;
const da = o.spread / (full ? o.rays : (o.rays - 1));
const angles = [];
for (let i = 0; i < o.rays; i++) angles.push(a0 + i * da);
// shared wave field — rays and rings ripple together (coherent), but each
// LAYER gets a distinct phase/freq so two overlaid layers interfere.
const Wf = (rN, ang) => Math.sin(2 * Math.PI * o.rippleFreqR * rN + o.rippleFreqA * ang + o.ripplePhase)
+ 0.4 * Math.sin(2 * Math.PI * o.rippleFreqR * 1.7 * rN + o.ripplePhase * 1.3 + 1.1);
const colAt = (t) => css(hslToRgb(o.hue + (o.hue2 - o.hue) * t, o.sat * (0.5 + 0.5 * t), o.lightFar + (o.lightNear - o.lightFar) * t));
const swAt = (t) => (o.strokeFar + (o.stroke - o.strokeFar) * t) * u;
const opAt = (t) => ((transparent ? 0.22 + 0.5 * t : 0.32 + 0.5 * t) * o.opacityMul);
let body = '';
// ---- rays (receding orthogonals, wiggling) ----
for (const ang of angles) {
const dx = Math.cos(ang), dy = Math.sin(ang), px = -Math.sin(ang), py = Math.cos(ang);
const pts = []; const steps = 44;
for (let s = 0; s <= steps; s++) {
const r = o.rMin + (o.rMax - o.rMin) * (s / steps), rN = r / o.rMax;
const off = o.rippleAmp * Wf(rN, ang) * (0.15 + 0.85 * rN);
pts.push({ x: vpx + (dx * r + px * off) * L, y: vpy + (dy * r + py * off) * L });
}
const t = 0.7;
body += `\n`;
}
// ---- depth rings (transverse, breathing radially) ----
const aSamples = full ? Math.max(o.rays, 48) : o.rays;
for (let j = 0; j < o.depthLines; j++) {
const rj = o.rMin + (o.rMax - o.rMin) * Math.pow((j + 1) / o.depthLines, o.depthPow), rN = rj / o.rMax;
const pts = [];
for (let i = 0; i < aSamples; i++) {
const ang = full ? (i / aSamples) * Math.PI * 2 : a0 + (i / (aSamples - 1)) * o.spread;
const rr = rj * (1 + o.rippleAmpRad * Wf(rN, ang));
pts.push({ x: vpx + Math.cos(ang) * rr * L, y: vpy + Math.sin(ang) * rr * L });
}
body += `\n`;
}
const bg = transparent ? '' : ``;
return `
`;
}