176 lines
9.3 KiB
JavaScript
176 lines
9.3 KiB
JavaScript
/* ============================================================
|
|
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 `<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" stroke="${colAt(t)}" stroke-width="${swAt(t).toFixed(2)}" stroke-opacity="${opAt(t).toFixed(2)}" stroke-linecap="round"/>`;
|
|
};
|
|
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 ? '' : `<rect width="${W}" height="${H}" fill="${cssR(paper)}"/>`;
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
${bg}
|
|
<g>${body}</g>
|
|
</svg>`;
|
|
}
|
|
|
|
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 += `<path d="${smoothPath(pts)}" fill="none" stroke="${colAt(t)}" stroke-width="${swAt(t).toFixed(2)}" stroke-opacity="${opAt(t).toFixed(2)}" stroke-linecap="round"/>\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 += `<path d="${smoothPath(pts, full)}" fill="none" stroke="${colAt(rN)}" stroke-width="${swAt(rN).toFixed(2)}" stroke-opacity="${opAt(rN).toFixed(2)}" stroke-linejoin="round"/>\n`;
|
|
}
|
|
|
|
const bg = transparent ? '' : `<rect width="${W}" height="${H}" fill="${css(paper)}"/>`;
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
${bg}
|
|
<g>${body}</g>
|
|
</svg>`;
|
|
}
|