/* ============================================================ 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 ` ${bg} ${body} `; } 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 ` ${bg} ${body} `; }