Compare commits

...

3 Commits

Author SHA1 Message Date
fdea58f18d Merge pull request 'Add Goldberg Polyhedron Paint experiment' (#1) from mikael-lovqvists-claude-agent/websperiments:main into main
Reviewed-on: #1
2026-05-10 16:07:52 +00:00
e6bbfb2dbe Remove maze feature; move adjacency into topology
Maze overlay and wall shaders removed per review feedback.
build_goldberg_adjacency moved from maze.mjs into topology.mjs
where it belongs — it is pure face-graph topology used by paint tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:06:05 +00:00
9600a2bc2a Add Goldberg Polyhedron Paint experiment
Interactive WebGL Goldberg polyhedron viewer and painter with PBR
shading, adjustable environment lighting, paint tools (pen, brush,
circle, fill, line, pick), undo/redo, colour palettes, and mesh
relaxation. Added to the standalone experiments index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:00:19 +00:00
23 changed files with 3796 additions and 0 deletions

View File

@@ -45,6 +45,7 @@
<li><a href="standalone/delaunay.html">Delaunay dataset</a></li> <li><a href="standalone/delaunay.html">Delaunay dataset</a></li>
<li><a href="standalone/delaunay-edge-relax.html">Delaunay dataset + Edge relaxation (Currently broken)</a></li> <li><a href="standalone/delaunay-edge-relax.html">Delaunay dataset + Edge relaxation (Currently broken)</a></li>
<li><a href="standalone/fluidsynth-reverb.html">Reverb configurator for FluidSynth CLI UI</a></li> <li><a href="standalone/fluidsynth-reverb.html">Reverb configurator for FluidSynth CLI UI</a></li>
<li><a href="standalone/goldberg-sphere/index.html">Goldberg Polyhedron Paint</a></li>
</ul> </ul>
</li> </li>

View File

@@ -0,0 +1,52 @@
// Converts azimuth + elevation (degrees) to a normalised direction vector.
// Y = up, Z = towards camera. Azimuth is measured from +Z towards +X.
function _dir(azim_deg, elev_deg) {
const a = azim_deg * Math.PI / 180;
const e = elev_deg * Math.PI / 180;
return new Float32Array([Math.cos(e)*Math.sin(a), Math.sin(e), Math.cos(e)*Math.cos(a)]);
}
function _parse_hex(hex) {
return [parseInt(hex.slice(1,3),16)/255, parseInt(hex.slice(3,5),16)/255, parseInt(hex.slice(5,7),16)/255];
}
function _to_hex(c) {
const h = v => Math.round(Math.min(v,1)*255).toString(16).padStart(2,'0');
return `#${h(c[0])}${h(c[1])}${h(c[2])}`;
}
export class Env_State {
constructor() {
// Light 1 — warm key light (approximates the former hardcoded l1)
this.light1_azim = 27; // degrees
this.light1_elev = 34; // degrees
this.light1_color = [1.0, 0.98, 0.94]; // normalised hue
this.light1_intensity = 1.5;
// Light 2 — cool fill light (approximates former l2)
this.light2_azim = -58;
this.light2_elev = -28;
this.light2_color = [0.55, 0.65, 1.0];
this.light2_intensity = 0.5;
// Ambient — uniform baseline, no direction
this.ambient_color = [1.0, 1.0, 1.0];
this.ambient_intensity = 0.07;
}
light1_dir() { return _dir(this.light1_azim, this.light1_elev); }
light2_dir() { return _dir(this.light2_azim, this.light2_elev); }
// Final light colour = normalised hue × intensity (may exceed 1.0).
light1_col() { return new Float32Array(this.light1_color.map(v => v * this.light1_intensity)); }
light2_col() { return new Float32Array(this.light2_color.map(v => v * this.light2_intensity)); }
ambient_col() { return new Float32Array(this.ambient_color.map(v => v * this.ambient_intensity)); }
hex_light1() { return _to_hex(this.light1_color); }
hex_light2() { return _to_hex(this.light2_color); }
hex_ambient() { return _to_hex(this.ambient_color); }
set_hex_light1(hex) { this.light1_color = _parse_hex(hex); }
set_hex_light2(hex) { this.light2_color = _parse_hex(hex); }
set_hex_ambient(hex) { this.ambient_color = _parse_hex(hex); }
}

View File

@@ -0,0 +1,98 @@
// Pure 3D geometry utilities — no DOM, no topology, only math.
export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
export class Vec3 {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
add(v) { return new Vec3(this.x + v.x, this.y + v.y, this.z + v.z); }
sub(v) { return new Vec3(this.x - v.x, this.y - v.y, this.z - v.z); }
scale(s) { return new Vec3(this.x * s, this.y * s, this.z * s); }
dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; }
cross(v) {
return new Vec3(
this.y * v.z - this.z * v.y,
this.z * v.x - this.x * v.z,
this.x * v.y - this.y * v.x,
);
}
length() { return Math.sqrt(this.dot(this)); }
normalize() { return this.scale(1 / this.length()); }
lerp(v, t) { return this.scale(1 - t).add(v.scale(t)); }
to_array() { return [this.x, this.y, this.z]; }
toString() { return `Vec3(${this.x.toFixed(4)}, ${this.y.toFixed(4)}, ${this.z.toFixed(4)})`; }
}
// Project a Vec3 onto the unit sphere.
export function normalize_to_sphere(v) {
return v.normalize();
}
// Midpoint between two Vec3 (not yet normalized).
export function midpoint(a, b) {
return a.add(b).scale(0.5);
}
// Returns n [x, y] pairs for a regular n-gon centered at origin with given circumradius.
// Vertices start at angle `start_angle` (default: top, -π/2).
export function regular_polygon_2d(n, circumradius, start_angle = -Math.PI / 2) {
const pts = [];
for (let i = 0; i < n; i++) {
const angle = start_angle + (2 * Math.PI * i) / n;
pts.push([circumradius * Math.cos(angle), circumradius * Math.sin(angle)]);
}
return pts;
}
// Centroid of an array of [x, y] points.
export function centroid_2d(pts) {
let sx = 0, sy = 0;
for (const [x, y] of pts) { sx += x; sy += y; }
return [sx / pts.length, sy / pts.length];
}
// Apply a 2D similarity transform (rotation + uniform scale + translation)
// represented as { a, b, tx, ty } where the mapping is:
// [x', y'] = [a*x - b*y + tx, b*x + a*y + ty]
// (a + bi is the complex multiplier, tx+ty is the translation)
export function apply_transform_2d(transform, pt) {
const { a, b, tx, ty } = transform;
return [
a * pt[0] - b * pt[1] + tx,
b * pt[0] + a * pt[1] + ty,
];
}
// Compute the similarity transform that maps (src0 -> dst0, src1 -> dst1).
// Returns { a, b, tx, ty }.
export function similarity_transform_2d(src0, src1, dst0, dst1) {
// Treat points as complex numbers.
// We want w = c * z + d where c = a+bi, d = tx+ty*i
// c = (dst1 - dst0) / (src1 - src0) [complex division]
// d = dst0 - c * src0
const [sx0, sy0] = src0;
const [sx1, sy1] = src1;
const [dx0, dy0] = dst0;
const [dx1, dy1] = dst1;
// src_vec = src1 - src0
const svx = sx1 - sx0, svy = sy1 - sy0;
// dst_vec = dst1 - dst0
const dvx = dx1 - dx0, dvy = dy1 - dy0;
// c = dst_vec / src_vec (complex division)
const denom = svx * svx + svy * svy;
const a = (dvx * svx + dvy * svy) / denom;
const b = (dvy * svx - dvx * svy) / denom;
// d = dst0 - c * src0
const tx = dx0 - (a * sx0 - b * sy0);
const ty = dy0 - (b * sx0 + a * sy0);
return { a, b, tx, ty };
}

View File

@@ -0,0 +1,23 @@
export function compile_shader(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(s)); }
return s;
}
export function create_program(gl, vs_src, fs_src) {
const p = gl.createProgram();
gl.attachShader(p, compile_shader(gl, gl.VERTEX_SHADER, vs_src));
gl.attachShader(p, compile_shader(gl, gl.FRAGMENT_SHADER, fs_src));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(p)); }
return p;
}
export function make_buffer(gl, data) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
return buf;
}

View File

@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Goldberg Polyhedron Paint</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #080c10; color: #bcd; font-family: monospace; font-size: 13px;
display: flex; height: 100vh; overflow: hidden; }
/* ── sidebar ───────────────────────────────────────── */
#sidebar {
width: 240px; min-width: 240px;
background: #0c1218; border-right: 1px solid #1a2a3a;
padding: 10px; overflow-y: auto;
display: flex; flex-direction: column; gap: 12px;
}
h3 { color: #4af; font-size: 12px; text-transform: uppercase;
letter-spacing: 1px; border-bottom: 1px solid #1a2a3a; padding-bottom: 4px; }
/* stage buttons */
.stage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; }
.sb {
background: #0f1e2a; border: 1px solid #2a3a4a; color: #7ab;
padding: 6px 2px; cursor: pointer; font-family: monospace; font-size: 11px;
text-align: center; line-height: 1.3;
}
.sb:hover { background: #1a3040; color: #cef; }
.sb.active { background: #0a2d4a; border-color: #4af; color: #4af; }
/* param rows */
.param { display: flex; align-items: center; gap: 6px; }
.param label { width: 80px; color: #7ab; font-size: 11px; flex-shrink: 0; }
.param input[type=range] { flex: 1; max-width: 90px; }
.param input[type=number] {
width: 72px; background: #091520; border: 1px solid #1e3448; color: #cdf;
padding: 3px 5px; font-family: monospace; font-size: 12px;
}
.param select {
flex: 1; background: #091520; border: 1px solid #1e3448; color: #cdf;
padding: 3px 5px; font-family: monospace; font-size: 12px;
}
.val { width: 24px; text-align: right; color: #cdf; font-size: 11px; }
#spin-btn.active { background: #0a2d4a; border-color: #4af; color: #4af; }
.swatch { width: 18px; height: 18px; cursor: pointer; border: 1px solid #1a2a3a; flex-shrink: 0; }
.swatch:hover { outline: 2px solid #fff; outline-offset: 1px; }
#cd-swatch-grid .swatch { width: 28px; height: 28px; }
.swatch-tab { flex: 1; background: #0a1a2a; border: 1px solid #2a4a6a; color: #7ab; padding: 3px 2px; cursor: pointer; font-family: monospace; font-size: 10px; text-align: center; }
.swatch-tab.active { background: #0a2d4a; border-color: #4af; color: #4af; }
/* ── color picker dialog ─────────────────────────────── */
#color-dialog {
background: #0c1218; border: 1px solid #2a4a6a; color: #bcd;
padding: 16px; width: 420px; border-radius: 3px;
font-family: monospace; font-size: 12px;
position: fixed; inset: 0; margin: auto; height: fit-content;
}
#color-dialog::backdrop { background: rgba(0,0,0,0.65); }
.cd-model-tab {
flex: 1; background: #0a1a2a; border: 1px solid #2a4a6a; color: #7ab;
padding: 4px 2px; cursor: pointer; font-family: monospace; font-size: 11px; text-align: center;
}
.cd-model-tab.active { background: #0a2d4a; border-color: #4af; color: #4af; }
.cd-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.cd-row label { width: 16px; color: #7ab; font-size: 11px; flex-shrink: 0; }
.cd-row input[type=range] { flex: 1; }
.cd-row .val { width: 28px; text-align: right; color: #cdf; font-size: 11px; }
#paint-btn.active, #paint-brush-btn.active, #paint-circle-btn.active, #paint-fill-btn.active, #paint-line-btn.active, #paint-pick-btn.active { background: #1a2d0a; border-color: #8d4; color: #8d4; }
#build-btn {
background: #0a2d4a; border: 1px solid #4af; color: #4af;
padding: 7px; cursor: pointer; font-family: monospace; font-size: 13px; width: 100%;
}
#build-btn:hover { background: #1a3d5a; }
#build-btn:disabled { opacity: 0.45; cursor: default; }
#status { color: #fa8; font-size: 11px; min-height: 14px; }
#stats { color: #6a9; font-size: 11px; line-height: 1.7; white-space: pre; }
/* ── canvas ─────────────────────────────────────────── */
#canvas-wrap { flex: 1; position: relative; overflow: hidden; }
canvas { display: block; width: 100%; height: 100%; cursor: grab; }
canvas:active { cursor: grabbing; }
</style>
</head>
<body>
<div id="sidebar">
<div>
<h3>Sphere Playground</h3>
</div>
<div>
<h3>Pipeline stage</h3>
<div class="stage-grid" style="margin-top:6px">
<button class="sb" data-stage="ico">Icosahedron</button>
<button class="sb" data-stage="subdiv">Subdivided</button>
<button class="sb" data-stage="goldberg">Goldberg</button>
<button class="sb active" data-stage="relaxed">Relaxed</button>
</div>
</div>
<div>
<h3>Palette</h3>
<div id="palette-grid" class="stage-grid" style="margin-top:6px">
<!-- populated by JS -->
</div>
</div>
<div>
<h3>Mesh</h3>
<div class="param" style="margin-top:6px">
<label>Depth</label>
<input type="range" id="depth" min="1" max="5" value="4">
<span class="val" id="depth-val">4</span>
</div>
</div>
<div>
<h3>Relaxation</h3>
<div class="param" style="margin-top:6px">
<label>Iterations</label>
<input type="number" id="iters" value="32" min="0" step="50">
</div>
<div class="param">
<label>α edge</label>
<input type="number" id="alpha-edge" value="0" min="0" step="0.01">
</div>
<div class="param">
<label>α centroid</label>
<input type="number" id="alpha-centroid" value="0.04" min="0" step="0.001">
</div>
</div>
<div>
<h3>Paint</h3>
<div style="display:flex; gap:4px; margin-top:6px">
<button id="brush-tab-left" style="flex:1; background:#0a2d4a; border:1px solid #4af; color:#4af; padding:5px; cursor:pointer; font-family:monospace; font-size:11px">◀ Left</button>
<button id="brush-tab-right" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px; cursor:pointer; font-family:monospace; font-size:11px">Right ▶</button>
</div>
<div class="param" style="margin-top:6px">
<label>Color</label>
<div id="paint-color-btn" style="flex:1; height:26px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
</div>
<div id="sidebar-swatch-tabs" style="display:flex; gap:2px; margin-top:4px"></div>
<div id="sidebar-swatch-grid" style="display:flex; flex-wrap:wrap; gap:2px; margin-top:3px"></div>
<div class="param">
<label>Metallic</label>
<input type="range" id="metallic" min="0" max="1" step="0.01" value="0">
<span class="val" id="metallic-val">0.00</span>
</div>
<div class="param">
<label>Roughness</label>
<input type="range" id="roughness" min="0" max="1" step="0.01" value="0.6">
<span class="val" id="roughness-val">0.60</span>
</div>
<div style="display:flex; gap:3px; margin-top:6px; flex-wrap:wrap">
<button id="paint-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">✏ Pen</button>
<button id="paint-brush-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">⬤ Brush</button>
<button id="paint-circle-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">○ Circle</button>
<button id="paint-fill-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">⬛ Fill</button>
<button id="paint-line-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px"> Line</button>
<button id="paint-pick-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">✦ Pick</button>
<button id="paint-clear" style="background:#1a0a0a; border:1px solid #4a2a2a; color:#a66; padding:5px 6px; cursor:pointer; font-family:monospace; font-size:11px"></button>
</div>
<div class="param" id="brush-params" style="display:none; margin-top:4px">
<label>Radius</label>
<input type="range" id="brush-radius" min="0" max="10" step="1" value="2">
<span class="val" id="brush-radius-val">2</span>
</div>
<div style="display:flex; gap:4px; margin-top:4px">
<button id="paint-save" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px">↓ Save</button>
<button id="paint-load" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px">↑ Load</button>
<input type="file" id="paint-load-input" accept=".json" style="display:none">
</div>
</div>
<div>
<h3>Noise</h3>
<div style="display:flex; gap:3px; margin-top:6px; flex-wrap:wrap">
<button class="noise-preset" data-preset="flat" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Flat</button>
<button class="noise-preset" data-preset="snow" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Snow</button>
<button class="noise-preset" data-preset="water" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Water</button>
<button class="noise-preset" data-preset="dunes" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Dunes</button>
<button class="noise-preset" data-preset="rock" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Rock</button>
</div>
<div class="param" style="margin-top:4px">
<label>Scale</label>
<input type="range" id="noise-scale" min="0" max="20" step="0.5" value="0">
<span class="val" id="noise-scale-val">0.0</span>
</div>
<div class="param">
<label>Strength</label>
<input type="range" id="noise-strength" min="0" max="3" step="0.05" value="0">
<span class="val" id="noise-strength-val">0.00</span>
</div>
<div class="param">
<label>Octaves</label>
<input type="range" id="noise-octaves" min="1" max="8" step="1" value="4">
<span class="val" id="noise-octaves-val">4</span>
</div>
<div class="param">
<label>Gain</label>
<input type="range" id="noise-gain" min="0.2" max="0.8" step="0.05" value="0.5">
<span class="val" id="noise-gain-val">0.50</span>
</div>
</div>
<div>
<h3>Environment</h3>
<div style="color:#5a8; font-size:10px; margin-top:4px; margin-bottom:2px; letter-spacing:0.5px">LIGHT 1</div>
<div class="param">
<label>Azimuth</label>
<input type="range" id="l1-azim" min="-180" max="180" step="1" value="27">
<span class="val" id="l1-azim-val">27°</span>
</div>
<div class="param">
<label>Elevation</label>
<input type="range" id="l1-elev" min="-90" max="90" step="1" value="34">
<span class="val" id="l1-elev-val">34°</span>
</div>
<div class="param">
<label>Color</label>
<div id="l1-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
<input type="range" id="l1-intensity" min="0" max="4" step="0.05" value="1.5" style="flex:1; max-width:60px">
<span class="val" id="l1-intensity-val">1.50</span>
</div>
<div style="color:#5a8; font-size:10px; margin-top:6px; margin-bottom:2px; letter-spacing:0.5px">LIGHT 2</div>
<div class="param">
<label>Azimuth</label>
<input type="range" id="l2-azim" min="-180" max="180" step="1" value="-58">
<span class="val" id="l2-azim-val">-58°</span>
</div>
<div class="param">
<label>Elevation</label>
<input type="range" id="l2-elev" min="-90" max="90" step="1" value="-28">
<span class="val" id="l2-elev-val">-28°</span>
</div>
<div class="param">
<label>Color</label>
<div id="l2-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
<input type="range" id="l2-intensity" min="0" max="4" step="0.05" value="0.5" style="flex:1; max-width:60px">
<span class="val" id="l2-intensity-val">0.50</span>
</div>
<div style="color:#5a8; font-size:10px; margin-top:6px; margin-bottom:2px; letter-spacing:0.5px">AMBIENT</div>
<div class="param">
<label>Color</label>
<div id="amb-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
<input type="range" id="amb-intensity" min="0" max="4" step="0.05" value="0.07" style="flex:1; max-width:60px">
<span class="val" id="amb-intensity-val">0.07</span>
</div>
</div>
<button id="spin-btn" style="background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:7px; cursor:pointer; font-family:monospace; font-size:13px; width:100%">↻ Auto-spin</button>
<button id="build-btn">▶ Build</button>
<div>
<div id="status">Ready</div>
<div id="stats"></div>
</div>
</div>
<div id="canvas-wrap">
<canvas id="gl-canvas"></canvas>
</div>
<dialog id="color-dialog">
<div id="cd-preview" style="height:32px; border-radius:2px; margin-bottom:10px; border:1px solid #2a4a6a;"></div>
<div style="display:flex; gap:3px; margin-bottom:2px">
<button class="cd-model-tab active" data-model="hsl">HSL</button>
<button class="cd-model-tab" data-model="rgb">RGB</button>
<button class="cd-model-tab" data-model="hex">Hex</button>
</div>
<div id="cd-panel-hsl">
<div class="cd-row"><label>H</label><input type="range" id="cd-h" min="0" max="360" step="1"><span class="val" id="cd-h-val">0</span></div>
<div class="cd-row"><label>S</label><input type="range" id="cd-s" min="0" max="100" step="1"><span class="val" id="cd-s-val">0%</span></div>
<div class="cd-row"><label>L</label><input type="range" id="cd-l" min="0" max="100" step="1"><span class="val" id="cd-l-val">0%</span></div>
</div>
<div id="cd-panel-rgb" style="display:none">
<div class="cd-row"><label>R</label><input type="range" id="cd-r" min="0" max="255" step="1"><span class="val" id="cd-r-val">0</span></div>
<div class="cd-row"><label>G</label><input type="range" id="cd-g" min="0" max="255" step="1"><span class="val" id="cd-g-val">0</span></div>
<div class="cd-row"><label>B</label><input type="range" id="cd-b" min="0" max="255" step="1"><span class="val" id="cd-b-val">0</span></div>
</div>
<div id="cd-panel-hex" style="display:none; margin-top:6px">
<input type="text" id="cd-hex" maxlength="7" style="width:100%; background:#091520; border:1px solid #1e3448; color:#cdf; padding:5px; font-family:monospace; font-size:13px; text-align:center;">
</div>
<div style="margin-top:10px; border-top:1px solid #1a2a3a; padding-top:8px">
<div id="cd-swatch-tabs" style="display:flex; gap:2px; margin-bottom:4px"></div>
<div id="cd-swatch-grid" style="display:flex; flex-wrap:wrap; gap:2px;"></div>
</div>
<div style="display:flex; gap:6px; margin-top:10px">
<button id="cd-close" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px;">Close</button>
</div>
</dialog>
<script type="module" src="./playground_main.mjs"></script>
</body>
</html>

View File

@@ -0,0 +1,56 @@
export function mat4_mul(a, b) {
const out = new Float32Array(16);
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
let s = 0;
for (let k = 0; k < 4; k++) { s += a[k*4+r] * b[c*4+k]; }
out[c*4+r] = s;
}
}
return out;
}
export function mat4_perspective(fov, aspect, near, far) {
const f = 1 / Math.tan(fov / 2), nf = 1 / (near - far);
return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]);
}
export function mat4_translate_z(z) {
return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,z,1]);
}
export function quat_identity() { return [0, 0, 0, 1]; }
export function quat_from_axis_angle(ax, ay, az, a) {
const s = Math.sin(a/2);
return [ax*s, ay*s, az*s, Math.cos(a/2)];
}
export function quat_mul([ax,ay,az,aw], [bx,by,bz,bw]) {
return [aw*bx+ax*bw+ay*bz-az*by, aw*by-ax*bz+ay*bw+az*bx,
aw*bz+ax*by-ay*bx+az*bw, aw*bw-ax*bx-ay*by-az*bz];
}
export function quat_conj([x,y,z,w]) { return [-x,-y,-z,w]; }
export function quat_rotate_vec([qx,qy,qz,qw], [vx,vy,vz]) {
const tx = 2*(qy*vz - qz*vy);
const ty = 2*(qz*vx - qx*vz);
const tz = 2*(qx*vy - qy*vx);
return [vx + qw*tx + qy*tz - qz*ty,
vy + qw*ty + qz*tx - qx*tz,
vz + qw*tz + qx*ty - qy*tx];
}
export function mat4_from_quat([x,y,z,w]) {
return new Float32Array([
1-2*(y*y+z*z), 2*(x*y+w*z), 2*(x*z-w*y), 0,
2*(x*y-w*z), 1-2*(x*x+z*z), 2*(y*z+w*x), 0,
2*(x*z+w*y), 2*(y*z-w*x), 1-2*(x*x+y*y), 0,
0, 0, 0, 1,
]);
}
export function mat3_from_mat4(m) {
return new Float32Array([m[0],m[1],m[2], m[4],m[5],m[6], m[8],m[9],m[10]]);
}

View File

@@ -0,0 +1,88 @@
export const PAINT_BG = [0.10, 0.13, 0.17];
export const PAINT_BG_PBR = [0.0, 0.8];
export const PAINT_BG_NOISE = [0.0, 0.0, 4.0, 0.5]; // scale, strength, octaves, gain
export class Paint_State {
constructor() {
this.enabled = false;
this.tool = 'pen'; // 'pen' | 'fill' | 'line'
this.face_colors = new Map(); // fi → [r, g, b]
this.face_pbr = new Map(); // fi → [metallic, roughness]
this.face_noise = new Map(); // fi → [scale, strength, octaves, gain]
this.face_key = new Map(); // fi → material key string (pre-computed at paint time)
this.color_left = [0.85, 0.25, 0.12];
this.color_right = [0.18, 0.45, 0.85];
this.metallic_left = 0.0;
this.metallic_right = 0.0;
this.roughness_left = 0.6;
this.roughness_right = 0.6;
this.noise_left = [0.0, 0.0, 4.0, 0.5]; // scale, strength, octaves, gain
this.noise_right = [0.0, 0.0, 4.0, 0.5];
this.preview = null; // { faces: Set<fi>, color, pbr, noise } | null
}
_parse_hex(hex) {
return [parseInt(hex.slice(1,3),16)/255, parseInt(hex.slice(3,5),16)/255, parseInt(hex.slice(5,7),16)/255];
}
_to_hex(c) {
const h = v => Math.round(v*255).toString(16).padStart(2,'0');
return `#${h(c[0])}${h(c[1])}${h(c[2])}`;
}
set_hex_left(hex) { this.color_left = this._parse_hex(hex); }
set_hex_right(hex) { this.color_right = this._parse_hex(hex); }
hex_left() { return this._to_hex(this.color_left); }
hex_right() { return this._to_hex(this.color_right); }
hex(side) { return side === 'right' ? this.hex_right() : this.hex_left(); }
_make_key(color, pbr, noise) {
return `${color.map(v=>v.toFixed(5)).join(',')}|${pbr.map(v=>v.toFixed(5)).join(',')}|${noise.map(v=>v.toFixed(3)).join(',')}`;
}
paint(fi, button) {
const color = button === 2 ? this.color_right : this.color_left;
const metallic = button === 2 ? this.metallic_right : this.metallic_left;
const roughness= button === 2 ? this.roughness_right: this.roughness_left;
const noise = button === 2 ? this.noise_right : this.noise_left;
const pbr = [metallic, roughness];
this.face_colors.set(fi, [...color]);
this.face_pbr.set(fi, pbr);
this.face_noise.set(fi, [...noise]);
this.face_key.set(fi, this._make_key(color, pbr, noise));
}
clear() {
this.face_colors.clear();
this.face_pbr.clear();
this.face_noise.clear();
this.face_key.clear();
}
set_preview(faces, button) {
const color = button === 2 ? this.color_right : this.color_left;
const metallic = button === 2 ? this.metallic_right : this.metallic_left;
const roughness= button === 2 ? this.roughness_right: this.roughness_left;
const noise = button === 2 ? this.noise_right : this.noise_left;
this.preview = { faces: new Set(faces), color: [...color], pbr: [metallic, roughness], noise: [...noise] };
}
commit_preview() {
if (!this.preview) { return; }
const color = this.preview.color;
const pbr = this.preview.pbr ?? PAINT_BG_PBR;
const noise = this.preview.noise ?? PAINT_BG_NOISE;
const key = this._make_key(color, pbr, noise);
for (const fi of this.preview.faces) {
this.face_colors.set(fi, [...color]);
this.face_pbr.set(fi, [...pbr]);
this.face_noise.set(fi, [...noise]);
this.face_key.set(fi, key);
}
this.preview = null;
}
clear_preview() {
this.preview = null;
}
}

View File

@@ -0,0 +1,80 @@
import { desaturate } from './render_geo.mjs';
function t_from_ratio(ratio) {
return Math.min(Math.max((ratio - 1) / 0.4, 0), 1);
}
// Hue-wheel position (0-1) → RGB at full saturation and value.
function hue_to_rgb(h) {
const s = 6 * (h % 1);
const i = Math.floor(s);
const f = s - i;
const q = 1 - f;
if (i === 0) { return [1, f, 0]; }
if (i === 1) { return [q, 1, 0]; }
if (i === 2) { return [0, 1, f]; }
if (i === 3) { return [0, q, 1]; }
if (i === 4) { return [f, 0, 1]; }
return [1, 0, q];
}
export const PALETTES = [
{
id: 'classic',
name: 'Classic',
face_color(ratio, is_pentagon) {
if (is_pentagon) { return desaturate([1.0, 0.85, 0.23], 0.45); }
const t = t_from_ratio(ratio);
return desaturate([Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)], 0.45);
},
},
{
id: 'vibrant',
name: 'Vibrant',
face_color(ratio, is_pentagon) {
if (is_pentagon) { return [1.0, 0.82, 0.10]; }
const t = t_from_ratio(ratio);
return [Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)];
},
},
{
id: 'arctic',
name: 'Arctic',
// Low distortion = pale ice blue, high = deep indigo.
face_color(ratio, is_pentagon) {
if (is_pentagon) { return [0.95, 0.75, 0.20]; }
const t = t_from_ratio(ratio);
return desaturate([
0.55 - t * 0.45,
0.80 - t * 0.50,
1.0 - t * 0.15,
], 0.7 + t * 0.3);
},
},
{
id: 'ember',
name: 'Ember',
// Low distortion = dark red, high = bright yellow-white.
face_color(ratio, is_pentagon) {
if (is_pentagon) { return desaturate([0.25, 0.55, 0.85], 0.6); }
const t = t_from_ratio(ratio);
return [
0.35 + t * 0.65,
t * t * 0.85,
t * t * t * 0.25,
];
},
},
{
id: 'rainbow',
name: 'Rainbow',
// Full hue sweep: low distortion = blue (240°), high = red (0°).
face_color(ratio, is_pentagon) {
if (is_pentagon) { return [1.0, 1.0, 1.0]; }
const t = t_from_ratio(ratio);
return desaturate(hue_to_rgb(0.67 - t * 0.67), 0.85);
},
},
];
export const DEFAULT_PALETTE = PALETTES[0];

View File

@@ -0,0 +1,77 @@
// Gnomonic projection of the relaxed 3D Goldberg sphere.
//
// Projects each vertex from the sphere's centre onto the tangent plane at
// the current face's centroid. Equivalent to placing your eye at the centre
// of the sphere and looking outward — geodesics project to straight lines
// and tile shapes stay natural even for faces away from centre.
// Near the 90° horizon the projection diverges; faces that far out are rare
// in typical neighbourhood depths so this is acceptable.
//
// Requires goldberg.relax_sphere() to have been called first.
// Falls back to vertices_3d if relaxed_vertices_3d is not set.
//
// Returns Map<face_index, { vertices_2d, centroid_2d, depth }>
import { Vec3, centroid_2d } from './geometry.mjs';
export function place_gnomonic_3d(poly, neighborhood, root_face_index, cx, cy, face_size) {
const root_face = poly.faces[root_face_index];
const root_verts = root_face.relaxed_vertices_3d ?? root_face.vertices_3d;
// Normalised centroid of root face = projection centre C.
let sx = 0, sy = 0, sz = 0;
for (const v of root_verts) { sx += v.x; sy += v.y; sz += v.z; }
const cl = Math.sqrt(sx*sx + sy*sy + sz*sz);
const C = new Vec3(sx / cl, sy / cl, sz / cl);
// Orthonormal tangent frame {u, v} at C.
const up = Math.abs(C.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0);
const u = C.cross(up).normalize();
const v = C.cross(u).normalize();
// Gnomonic project a unit-sphere point P onto the tangent plane at C.
// Returns [tx, ty] in tangent-plane coords, or null if behind the plane.
const project = (P) => {
const d = P.dot(C);
if (d <= 1e-6) { return null; }
// Point on tangent plane: P/d. Subtract C to get offset, decompose into u,v.
const inv_d = 1 / d;
const qx = P.x * inv_d - C.x;
const qy = P.y * inv_d - C.y;
const qz = P.z * inv_d - C.z;
return [qx * u.x + qy * u.y + qz * u.z,
qx * v.x + qy * v.y + qz * v.z];
};
// Determine canvas scale from root face's projected circumradius.
const root_proj = root_verts.map(p => project(p));
if (root_proj.some(p => p === null)) { return new Map(); }
const rcx = root_proj.reduce((s, p) => s + p[0], 0) / root_proj.length;
const rcy = root_proj.reduce((s, p) => s + p[1], 0) / root_proj.length;
const root_r = Math.max(...root_proj.map(([x, y]) =>
Math.sqrt((x - rcx) ** 2 + (y - rcy) ** 2)));
if (root_r < 1e-10) { return new Map(); }
const scale = face_size / root_r;
// Build placements.
const placements = new Map();
for (const entry of neighborhood) {
const face = entry.face;
const verts = face.relaxed_vertices_3d ?? face.vertices_3d;
const pts = [];
let ok = true;
for (const P of verts) {
const proj = project(P);
if (!proj) { ok = false; break; }
pts.push([cx + proj[0] * scale, cy - proj[1] * scale]);
}
if (!ok) { continue; }
placements.set(face.index, {
vertices_2d: pts,
centroid_2d: centroid_2d(pts),
depth: entry.depth,
});
}
return placements;
}

View File

@@ -0,0 +1,65 @@
// Orthographic projection of the relaxed 3D Goldberg sphere.
//
// Rotates the sphere so the current face's centroid points toward +z,
// then projects each vertex's (x, y) components directly to canvas.
// Faces on the far hemisphere (z < 0) are still included if in the
// neighbourhood — they'll just appear behind the centre.
//
// Requires goldberg.relax_sphere() to have been called first.
// Falls back to vertices_3d if relaxed_vertices_3d is not set.
//
// Returns Map<face_index, { vertices_2d, centroid_2d, depth }>
import { Vec3, centroid_2d } from './geometry.mjs';
export function place_ortho_3d(poly, neighborhood, root_face_index, cx, cy, face_size) {
// --- Determine projection centre from root face ---
const root_face = poly.faces[root_face_index];
const root_verts = root_face.relaxed_vertices_3d ?? root_face.vertices_3d;
// Normalised centroid of root face = the outward direction we point toward +z.
let sx = 0, sy = 0, sz = 0;
for (const v of root_verts) { sx += v.x; sy += v.y; sz += v.z; }
const cl = Math.sqrt(sx * sx + sy * sy + sz * sz);
const C = new Vec3(sx / cl, sy / cl, sz / cl);
// Build orthonormal tangent frame {u, v, w=C}.
// u = perpendicular to C in the horizontal plane; v = C × u.
const up = Math.abs(C.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0);
const u = C.cross(up).normalize(); // points "right" on screen
const v = C.cross(u).normalize(); // points "up" on screen (then flipped for canvas)
// Project a Vec3 onto the tangent frame, returns [canvas_x, canvas_y, z_depth].
const project = (P) => [
P.dot(u), // tangent x
P.dot(v), // tangent y (flipped to canvas below)
P.dot(C), // depth (1 = facing viewer, -1 = back)
];
// Determine scale from root face's projected circumradius.
const root_proj = root_verts.map(project);
const rcx = root_proj.reduce((s, p) => s + p[0], 0) / root_proj.length;
const rcy = root_proj.reduce((s, p) => s + p[1], 0) / root_proj.length;
const root_r = Math.max(...root_proj.map(([x, y]) =>
Math.sqrt((x - rcx) ** 2 + (y - rcy) ** 2)));
if (root_r < 1e-10) { return new Map(); }
const scale = face_size / root_r;
// --- Build placements ---
const placements = new Map();
for (const entry of neighborhood) {
const face = entry.face;
const verts = face.relaxed_vertices_3d ?? face.vertices_3d;
const pts = verts.map(P => {
const [tx, ty] = project(P);
return [cx + tx * scale, cy - ty * scale]; // flip y: +v → up on canvas
});
placements.set(face.index, {
vertices_2d: pts,
centroid_2d: centroid_2d(pts),
depth: entry.depth,
});
}
return placements;
}

View File

@@ -0,0 +1,236 @@
// Shared-vertex spring relaxation.
//
// Builds an indexed mesh (union-find over per-face vertex slots) so
// topologically identical vertices are the same array entry — sharing is
// exact, not glued. Two spring types per iteration:
// 1. Edge-length: each edge pulls toward a depth-scaled target length.
// 2. Radial: each vertex pulls toward its face's depth-scaled circumradius.
//
// curvature=0 → flat (uniform size); >0 → outer tiles shrink (sphere-like);
// <0 → outer tiles grow (hyperbolic-like). depth_scale(d) = (1-curvature)^d.
//
// Displacement is clamped per iteration to spread unavoidable curvature
// (from pentagon defects) evenly rather than concentrating it in a few faces.
//
// Returns Map<face_index, { vertices_2d, centroid_2d, depth }>
import {
regular_polygon_2d,
centroid_2d,
similarity_transform_2d,
apply_transform_2d,
} from './geometry.mjs';
const RELAX_ITERS = 30;
const ALPHA_EDGE = 0.5;
const ALPHA_RADIAL = 0.4;
const MAX_MOVE_FRAC = 0.12;
export function place_relax(poly, neighborhood, root_face_index, cx, cy, face_size, curvature = 0) {
const placed_set = new Set(neighborhood.map(e => e.face.index));
// --- Union-find over per-face vertex slots ---
const face_base = new Map();
let uf_size = 0;
for (const entry of neighborhood) {
face_base.set(entry.face.index, uf_size);
uf_size += entry.face.size;
}
const uf_parent = Array.from({ length: uf_size }, (_, i) => i);
const uf_find = (i) => {
while (uf_parent[i] !== i) { uf_parent[i] = uf_parent[uf_parent[i]]; i = uf_parent[i]; }
return i;
};
const uf_union = (i, j) => { uf_parent[uf_find(i)] = uf_find(j); };
const adj_seen = new Set();
for (const entry of neighborhood) {
const fi_a = entry.face.index;
const face_a = entry.face;
const na = face_a.size;
const base_a = face_base.get(fi_a);
for (let ea = 0; ea < na; ea++) {
const fi_b = face_a.edge_neighbors[ea];
if (fi_b === null || fi_b === undefined || !placed_set.has(fi_b)) { continue; }
const pair_key = fi_a < fi_b ? `${fi_a}:${fi_b}` : `${fi_b}:${fi_a}`;
if (adj_seen.has(pair_key)) { continue; }
adj_seen.add(pair_key);
const face_b = poly.faces[fi_b];
const nb = face_b.size;
const base_b = face_base.get(fi_b);
let eb = -1;
for (let j = 0; j < nb; j++) {
if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; }
}
if (eb === -1) { continue; }
// Opposite winding: A's slot ea ≡ B's slot (eb+1)%nb, A's (ea+1)%na ≡ B's eb.
uf_union(base_a + ea, base_b + (eb + 1) % nb);
uf_union(base_a + (ea + 1) % na, base_b + eb);
}
}
// Assign compact position IDs to canonical slots.
const canon_to_pid = new Map();
let pid_count = 0;
const face_pids = new Map();
for (const entry of neighborhood) {
const fi = entry.face.index;
const n = entry.face.size;
const base = face_base.get(fi);
const pids = [];
for (let i = 0; i < n; i++) {
const canon = uf_find(base + i);
if (!canon_to_pid.has(canon)) { canon_to_pid.set(canon, pid_count++); }
pids.push(canon_to_pid.get(canon));
}
face_pids.set(fi, pids);
}
// --- Initial positions via BFS unfolding (no depth shrink) ---
const positions = Array.from({ length: pid_count }, () => [0, 0]);
const placed_pid = new Uint8Array(pid_count);
for (const entry of neighborhood) {
const fi = entry.face.index;
const face = entry.face;
const n = face.size;
const pids = face_pids.get(fi);
if (entry.parent_index === null) {
const pts = regular_polygon_2d(n, face_size).map(([x, y]) => [x + cx, y + cy]);
for (let i = 0; i < n; i++) { positions[pids[i]] = pts[i]; placed_pid[pids[i]] = 1; }
} else {
const parent_fi = entry.parent_index;
const parent_face = poly.faces[parent_fi];
const parent_pids = face_pids.get(parent_fi);
const ea = entry.parent_edge_index;
let eb = -1;
for (let j = 0; j < n; j++) {
if (face.edge_neighbors[j] === parent_fi) { eb = j; break; }
}
if (eb === -1) { continue; }
const local_pts = regular_polygon_2d(n, 1);
const pa = positions[parent_pids[ea]];
const pb = positions[parent_pids[(ea + 1) % parent_face.size]];
const transform = similarity_transform_2d(local_pts[eb], local_pts[(eb + 1) % n], pb, pa);
for (let i = 0; i < n; i++) {
if (!placed_pid[pids[i]]) {
positions[pids[i]] = apply_transform_2d(transform, local_pts[i]);
placed_pid[pids[i]] = 1;
}
}
}
}
// --- Collect unique edges ---
const edge_seen = new Set();
const edges = [];
for (const [, pids] of face_pids) {
const n = pids.length;
for (let i = 0; i < n; i++) {
const u = pids[i], v = pids[(i + 1) % n];
const ek = u < v ? `${u}:${v}` : `${v}:${u}`;
if (!edge_seen.has(ek)) { edge_seen.add(ek); edges.push([u, v]); }
}
}
// --- Target length from root face ---
const root_pids = face_pids.get(root_face_index);
let target_len = face_size;
if (root_pids) {
const n = root_pids.length;
let sum = 0;
for (let i = 0; i < n; i++) {
const a = positions[root_pids[i]], b = positions[root_pids[(i + 1) % n]];
sum += Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
}
target_len = sum / n;
}
// Pin root face positions.
const pinned = new Set(root_pids ?? []);
// depth_scale(d) = (1 - curvature)^d
const depth_scale = (d) => Math.pow(1 - curvature, d);
// Per-vertex average depth (for interpolating target length across edges).
const pid_depth_sum = new Float64Array(pid_count);
const pid_depth_count = new Uint32Array(pid_count);
for (const entry of neighborhood) {
for (const pid of face_pids.get(entry.face.index)) {
pid_depth_sum[pid] += entry.depth;
pid_depth_count[pid] += 1;
}
}
const pid_depth = Array.from({ length: pid_count },
(_, i) => pid_depth_count[i] > 0 ? pid_depth_sum[i] / pid_depth_count[i] : 0);
const max_move = target_len * MAX_MOVE_FRAC;
// --- Spring relaxation ---
for (let iter = 0; iter < RELAX_ITERS; iter++) {
const deltas = Array.from({ length: pid_count }, () => [0, 0]);
// Spring 1: edge-length (depth-scaled target).
for (const [u, v] of edges) {
const pu = positions[u], pv = positions[v];
const dx = pv[0] - pu[0], dy = pv[1] - pu[1];
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1e-6) { continue; }
const t_len = target_len * depth_scale((pid_depth[u] + pid_depth[v]) / 2);
const err = (len - t_len) / len;
const fx = dx * err * 0.5 * ALPHA_EDGE;
const fy = dy * err * 0.5 * ALPHA_EDGE;
if (!pinned.has(u)) { deltas[u][0] += fx; deltas[u][1] += fy; }
if (!pinned.has(v)) { deltas[v][0] -= fx; deltas[v][1] -= fy; }
}
// Spring 2: radial (vertex-to-centroid → depth-scaled circumradius).
for (const entry of neighborhood) {
const fi = entry.face.index;
const pids = face_pids.get(fi);
const n = pids.length;
const t_r = target_len * depth_scale(entry.depth);
let fcx = 0, fcy = 0;
for (const pid of pids) { fcx += positions[pid][0]; fcy += positions[pid][1]; }
fcx /= n; fcy /= n;
for (const pid of pids) {
if (pinned.has(pid)) { continue; }
const vx = positions[pid][0] - fcx;
const vy = positions[pid][1] - fcy;
const dist = Math.sqrt(vx * vx + vy * vy);
if (dist < 1e-6) { continue; }
const err = (dist - t_r) / dist;
deltas[pid][0] -= vx * err * ALPHA_RADIAL;
deltas[pid][1] -= vy * err * ALPHA_RADIAL;
}
}
// Apply with displacement clamp.
for (let i = 0; i < pid_count; i++) {
if (pinned.has(i)) { continue; }
const dx = deltas[i][0], dy = deltas[i][1];
const d = Math.sqrt(dx * dx + dy * dy);
const sc = d > max_move ? max_move / d : 1;
positions[i][0] += dx * sc;
positions[i][1] += dy * sc;
}
}
// --- Build placements map ---
const placements = new Map();
for (const entry of neighborhood) {
const fi = entry.face.index;
const pts = face_pids.get(fi).map(pid => positions[pid]);
placements.set(fi, { vertices_2d: pts, centroid_2d: centroid_2d(pts), depth: entry.depth });
}
return placements;
}

View File

@@ -0,0 +1,97 @@
// Loose relaxation: starts from the unfolded layout, then runs spring
// iterations that only pull BFS parent→child shared edges together.
// Sibling-sibling edges are ignored, so gaps remain between depth rings.
// Kept because the partial closing produces an interesting visual effect.
//
// Returns Map<face_index, { vertices_2d, centroid_2d, depth }>
import { centroid_2d } from './geometry.mjs';
import { place_unfold } from './placer_unfold.mjs';
const RELAX_ITERS = 14;
const RELAX_ALPHA = 0.38;
export function place_relax_loose(poly, neighborhood, root_face_index, cx, cy, face_size) {
const placements = place_unfold(poly, neighborhood, root_face_index, cx, cy, face_size);
// Build BFS-tree-only adjacency (parent → child edges).
const adj = [];
for (const entry of neighborhood) {
if (entry.parent_index === null) { continue; }
const fi_b = entry.face.index;
const fi_a = entry.parent_index;
if (!placements.has(fi_a) || !placements.has(fi_b)) { continue; }
const face_a = poly.faces[fi_a];
const face_b = entry.face;
const ea = entry.parent_edge_index;
let eb = -1;
for (let j = 0; j < face_b.edge_neighbors.length; j++) {
if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; }
}
if (eb === -1) { continue; }
adj.push({ fi_a, na: face_a.size, ea, fi_b, nb: face_b.size, eb });
}
// Pin center face vertices so layout stays anchored.
const pinned = new Set();
const center_pl = placements.get(root_face_index);
if (center_pl) {
for (let i = 0; i < center_pl.vertices_2d.length; i++) {
pinned.add(`${root_face_index}:${i}`);
}
}
for (let iter = 0; iter < RELAX_ITERS; iter++) {
const deltas = new Map();
const delta = (fi, vi) => {
const k = `${fi}:${vi}`;
if (!deltas.has(k)) { deltas.set(k, [0, 0]); }
return deltas.get(k);
};
for (const { fi_a, na, ea, fi_b, nb, eb } of adj) {
const verts_a = placements.get(fi_a).vertices_2d;
const verts_b = placements.get(fi_b).vertices_2d;
// A.v[ea] ↔ B.v[(eb+1)%nb]
const a0 = verts_a[ea], b0 = verts_b[(eb + 1) % nb];
const mx0 = (a0[0] + b0[0]) / 2, my0 = (a0[1] + b0[1]) / 2;
// A.v[(ea+1)%na] ↔ B.v[eb]
const a1 = verts_a[(ea + 1) % na], b1 = verts_b[eb];
const mx1 = (a1[0] + b1[0]) / 2, my1 = (a1[1] + b1[1]) / 2;
if (!pinned.has(`${fi_a}:${ea}`)) {
const d = delta(fi_a, ea); d[0] += mx0 - a0[0]; d[1] += my0 - a0[1];
}
if (!pinned.has(`${fi_b}:${(eb + 1) % nb}`)) {
const d = delta(fi_b, (eb + 1) % nb); d[0] += mx0 - b0[0]; d[1] += my0 - b0[1];
}
if (!pinned.has(`${fi_a}:${(ea + 1) % na}`)) {
const d = delta(fi_a, (ea + 1) % na); d[0] += mx1 - a1[0]; d[1] += my1 - a1[1];
}
if (!pinned.has(`${fi_b}:${eb}`)) {
const d = delta(fi_b, eb); d[0] += mx1 - b1[0]; d[1] += my1 - b1[1];
}
}
for (const [key, [dx, dy]] of deltas) {
const colon = key.lastIndexOf(':');
const fi = parseInt(key.slice(0, colon), 10);
const vi = parseInt(key.slice(colon + 1), 10);
const pl = placements.get(fi);
if (!pl) { continue; }
const v = pl.vertices_2d[vi];
pl.vertices_2d[vi] = [v[0] + dx * RELAX_ALPHA, v[1] + dy * RELAX_ALPHA];
}
for (const pl of placements.values()) {
pl.centroid_2d = centroid_2d(pl.vertices_2d);
}
}
return placements;
}

View File

@@ -0,0 +1,68 @@
// Placement via unfolding: each neighbor face is placed adjacent to its BFS
// parent by a similarity transform, then shrunk toward its centroid by
// DEPTH_SHRINK^depth so depth rings visually separate.
//
// Returns Map<face_index, { vertices_2d, centroid_2d, depth }>
import {
regular_polygon_2d,
centroid_2d,
similarity_transform_2d,
apply_transform_2d,
} from './geometry.mjs';
const DEPTH_SHRINK = 0.82;
export function place_unfold(poly, neighborhood, root_face_index, cx, cy, face_size) {
const placements = new Map();
for (const entry of neighborhood) {
const fi = entry.face.index;
const face = entry.face;
const n = face.size;
if (entry.parent_index === null) {
// Root face: regular polygon centred on canvas.
const pts = regular_polygon_2d(n, face_size).map(([x, y]) => [x + cx, y + cy]);
placements.set(fi, { vertices_2d: pts, centroid_2d: [cx, cy], depth: 0 });
} else {
const parent_pl = placements.get(entry.parent_index);
if (!parent_pl) { continue; }
const parent_face = poly.faces[entry.parent_index];
const pei = entry.parent_edge_index;
const pa = parent_pl.vertices_2d[pei];
const pb = parent_pl.vertices_2d[(pei + 1) % parent_face.size];
// Find which edge of this face leads back to the parent.
let nei = -1;
for (let j = 0; j < face.edge_neighbors.length; j++) {
if (face.edge_neighbors[j] === entry.parent_index) { nei = j; break; }
}
if (nei === -1) {
console.warn(`place_unfold: face ${fi} has no reverse edge to parent ${entry.parent_index}`);
continue;
}
// Winding is opposite: parent (a→b) ≡ neighbor (b→a).
const local_pts = regular_polygon_2d(n, 1);
const local_a = local_pts[nei];
const local_b = local_pts[(nei + 1) % n];
const transform = similarity_transform_2d(local_a, local_b, pb, pa);
let vertices_2d = local_pts.map(pt => apply_transform_2d(transform, pt));
const c = centroid_2d(vertices_2d);
// Shrink toward centroid after transform (before would be cancelled by scaling).
const shrink = Math.pow(DEPTH_SHRINK, entry.depth);
vertices_2d = vertices_2d.map(([x, y]) => [
c[0] + (x - c[0]) * shrink,
c[1] + (y - c[1]) * shrink,
]);
placements.set(fi, { vertices_2d, centroid_2d: c, depth: entry.depth });
}
}
return placements;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE } from './paint_state.mjs';
export function desaturate(c, s) {
const lum = 0.299*c[0] + 0.587*c[1] + 0.114*c[2];
return [lum + (c[0]-lum)*s, lum + (c[1]-lum)*s, lum + (c[2]-lum)*s];
}
export function distortion_color(ratio) {
const t = Math.min(Math.max((ratio - 1) / 0.4, 0), 1);
return desaturate([Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)], 0.45);
}
export function shape_distortion(verts) {
const n = verts.length;
let cx=0, cy=0, cz=0;
for (const v of verts) { cx+=v.x; cy+=v.y; cz+=v.z; }
cx/=n; cy/=n; cz/=n;
let mn=Infinity, mx=0;
for (const v of verts) {
const d = Math.sqrt((v.x-cx)**2+(v.y-cy)**2+(v.z-cz)**2);
if (d < mn) { mn=d; } if (d > mx) { mx=d; }
}
return mn > 1e-12 ? mx/mn : 1;
}
// Triangle mesh (icosahedron / subdivided).
export function build_triangle_geo(poly, palette) {
const fpos=[], fcol=[], fpbr=[], fnoise=[], epos=[];
let worst_shape = 0;
for (const face of poly.faces) {
const v = face.vertices.map(vi => poly.vertices[vi].to_vec3());
const e1x=v[1].x-v[0].x, e1y=v[1].y-v[0].y, e1z=v[1].z-v[0].z;
const e2x=v[2].x-v[0].x, e2y=v[2].y-v[0].y, e2z=v[2].z-v[0].z;
const nx=e1y*e2z-e1z*e2y, ny=e1z*e2x-e1x*e2z, nz=e1x*e2y-e1y*e2x;
const cx=(v[0].x+v[1].x+v[2].x)/3, cy=(v[0].y+v[1].y+v[2].y)/3, cz=(v[0].z+v[1].z+v[2].z)/3;
if (nx*cx + ny*cy + nz*cz < 0) { [v[1], v[2]] = [v[2], v[1]]; }
let mn=Infinity, mx=0;
for (let i=0; i<3; i++) {
const a=v[i], b=v[(i+1)%3];
const l=Math.sqrt((b.x-a.x)**2+(b.y-a.y)**2+(b.z-a.z)**2);
if (l<mn) { mn=l; } if (l>mx) { mx=l; }
}
const ratio = mn>1e-12 ? mx/mn : 1;
if (ratio > worst_shape) { worst_shape = ratio; }
const c = palette.face_color(ratio, false);
for (const vert of v) {
fpos.push(vert.x,vert.y,vert.z);
fcol.push(c[0],c[1],c[2]);
fpbr.push(0.0,0.6);
fnoise.push(...PAINT_BG_NOISE);
}
for (let i=0; i<3; i++) {
const a=v[i], b=v[(i+1)%3];
epos.push(a.x,a.y,a.z, b.x,b.y,b.z);
}
}
return {
face_pos: new Float32Array(fpos), face_col: new Float32Array(fcol),
face_pbr: new Float32Array(fpbr), face_noise: new Float32Array(fnoise),
face_verts: fpos.length/3,
edge_pos: new Float32Array(epos), edge_verts: epos.length/3,
stats: { faces: poly.faces.length, vertices: poly.vertices.length, worst_shape },
};
}
// Goldberg polygonal mesh. paint is a Paint_State (or null for non-paint mode).
export function build_goldberg_geo(goldberg, use_relaxed, paint, palette) {
const fpos=[], fcol=[], fpbr=[], fnoise=[], epos=[];
let worst_shape = 0;
for (let fi=0; fi<goldberg.faces.length; fi++) {
const face = goldberg.faces[fi];
const verts = use_relaxed
? (face.relaxed_vertices_3d ?? face.vertices_3d)
: face.vertices_3d;
const n = verts.length;
const sr = shape_distortion(verts);
if (sr > worst_shape) { worst_shape = sr; }
let c, pbr, noise;
if (paint?.enabled) {
const in_preview = paint.preview?.faces.has(fi);
c = in_preview ? paint.preview.color : (paint.face_colors.get(fi) ?? PAINT_BG);
pbr = in_preview ? (paint.preview.pbr ?? PAINT_BG_PBR) : (paint.face_pbr.get(fi) ?? PAINT_BG_PBR);
noise = in_preview ? (paint.preview.noise ?? PAINT_BG_NOISE) : (paint.face_noise.get(fi) ?? PAINT_BG_NOISE);
} else {
c = palette.face_color(sr, face.is_pentagon);
pbr = [0.0, 0.6];
noise = PAINT_BG_NOISE;
}
for (let i=1; i<n-1; i++) {
for (const v of [verts[0], verts[i], verts[i+1]]) {
fpos.push(v.x,v.y,v.z);
fcol.push(c[0],c[1],c[2]);
fpbr.push(pbr[0],pbr[1]);
fnoise.push(noise[0],noise[1],noise[2],noise[3]);
}
}
for (let i=0; i<n; i++) {
const a=verts[i], b=verts[(i+1)%n];
epos.push(a.x,a.y,a.z, b.x,b.y,b.z);
}
}
return {
face_pos: new Float32Array(fpos), face_col: new Float32Array(fcol),
face_pbr: new Float32Array(fpbr), face_noise: new Float32Array(fnoise),
face_verts: fpos.length/3,
edge_pos: new Float32Array(epos), edge_verts: epos.length/3,
stats: {
faces: goldberg.faces.length,
pentagons: goldberg.faces.filter(f=>f.is_pentagon).length,
worst_shape,
},
};
}

View File

@@ -0,0 +1,178 @@
// 2D canvas rendering for the sphere topology explorer.
// Reads Goldberg_Polyhedron topology, produces canvas output.
// Placement logic lives in separate placer_*.mjs modules.
import { place_unfold } from './placer_unfold.mjs';
import { place_relax } from './placer_relax.mjs';
import { place_relax_loose } from './placer_relax_loose.mjs';
import { place_ortho_3d } from './placer_ortho_3d.mjs';
import { place_gnomonic_3d } from './placer_gnomonic_3d.mjs';
const EDGE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
// Colors
const COLOR_CENTER_FILL = '#1a2a3a';
const COLOR_NEIGHBOR_FILL = '#0f1a24';
const COLOR_CENTER_STROKE = '#4af';
const COLOR_NEIGHBOR_STROKE = '#2a5a7a';
const COLOR_DEEP_STROKE = '#1a3a4a';
const COLOR_LABEL_CENTER = '#cef';
const COLOR_LABEL_NEIGHBOR = '#7ab';
const COLOR_EDGE_LABEL = '#fa8';
const COLOR_BG = '#050d14';
export class Renderer {
constructor(canvas, goldberg_poly) {
this.canvas = canvas;
this.zoom = 1.0;
this.mode = 'unfold'; // 'unfold' | 'relax' | 'relax-loose' | 'ortho' | 'gnomonic'
this.curvature = 0; // 0 = flat; positive = sphere-like (outer tiles shrink)
this.ctx = canvas.getContext('2d');
this.poly = goldberg_poly;
}
render(current_face_index, view_depth, label_offset = 0, entry_edge = null) {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
const cx = w / 2, cy = h / 2;
ctx.fillStyle = COLOR_BG;
ctx.fillRect(0, 0, w, h);
const face_size = Math.min(w, h) * 0.18 * this.zoom;
const neighborhood = this.poly.get_neighborhood(current_face_index, view_depth);
let placements;
if (this.mode === 'relax') {
placements = place_relax(this.poly, neighborhood, current_face_index, cx, cy, face_size, this.curvature);
} else if (this.mode === 'relax-loose') {
placements = place_relax_loose(this.poly, neighborhood, current_face_index, cx, cy, face_size);
} else if (this.mode === 'ortho') {
placements = place_ortho_3d(this.poly, neighborhood, current_face_index, cx, cy, face_size);
} else if (this.mode === 'gnomonic') {
placements = place_gnomonic_3d(this.poly, neighborhood, current_face_index, cx, cy, face_size);
} else {
placements = place_unfold(this.poly, neighborhood, current_face_index, cx, cy, face_size);
}
// Rotate the whole view so the back edge (entry_edge) is horizontal at the
// bottom, with the face interior above it — like looking forward.
if (entry_edge !== null) {
const center_face = this.poly.faces[current_face_index];
const center_pl = placements.get(current_face_index);
if (center_pl) {
const pts = center_pl.vertices_2d;
const n = center_face.size;
const pa = pts[entry_edge];
const pb = pts[(entry_edge + 1) % n];
const dx = pb[0] - pa[0], dy = pb[1] - pa[1];
// θ makes the edge vector point in the +x direction.
let theta = -Math.atan2(dy, dx);
// Check: after rotation, the face centroid must be above the edge
// midpoint (lower canvas y). If not, flip 180°.
const cos_t = Math.cos(theta), sin_t = Math.sin(theta);
const rot_y = (px, py) => (px - cx) * sin_t + (py - cy) * cos_t + cy;
const [fcx, fcy] = center_pl.centroid_2d;
const em_y = rot_y((pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2);
if (rot_y(fcx, fcy) > em_y) { theta += Math.PI; }
// Apply rotation around canvas centre to all placements.
const cos2 = Math.cos(theta), sin2 = Math.sin(theta);
const rot_pt = ([px, py]) => [
(px - cx) * cos2 - (py - cy) * sin2 + cx,
(px - cx) * sin2 + (py - cy) * cos2 + cy,
];
for (const pl of placements.values()) {
pl.vertices_2d = pl.vertices_2d.map(rot_pt);
pl.centroid_2d = rot_pt(pl.centroid_2d);
}
}
}
// Draw faces back-to-front by depth (deepest first so centre is on top).
const by_depth = [...neighborhood].sort((a, b) => b.depth - a.depth);
for (const entry of by_depth) {
const fi = entry.face.index;
if (!placements.has(fi)) { continue; }
this._draw_face(placements.get(fi), entry.face, entry.depth);
}
// Draw edge labels on centre face only (on top of everything).
const center_face = this.poly.faces[current_face_index];
const center_placement = placements.get(current_face_index);
if (center_placement) {
const back_label_idx = center_face.size === 6 ? 3 : 2;
this._draw_edge_labels(center_placement, center_face, label_offset, back_label_idx);
}
}
_draw_face(placement, face, depth) {
const ctx = this.ctx;
const pts = placement.vertices_2d;
if (!pts || pts.length < 3) { return; }
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.closePath();
if (depth === 0) {
ctx.fillStyle = COLOR_CENTER_FILL;
ctx.strokeStyle = COLOR_CENTER_STROKE;
ctx.lineWidth = 2;
} else if (depth === 1) {
ctx.fillStyle = COLOR_NEIGHBOR_FILL;
ctx.strokeStyle = COLOR_NEIGHBOR_STROKE;
ctx.lineWidth = 1.5;
} else {
ctx.fillStyle = COLOR_NEIGHBOR_FILL;
ctx.strokeStyle = COLOR_DEEP_STROKE;
ctx.lineWidth = 1;
}
ctx.fill();
ctx.stroke();
// Face index label
const [cx, cy] = placement.centroid_2d;
const font_size = depth === 0 ? 18 : depth === 1 ? 13 : 10;
ctx.font = `${font_size}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = depth === 0 ? COLOR_LABEL_CENTER : COLOR_LABEL_NEIGHBOR;
ctx.fillText(String(face.index), cx, cy);
}
_draw_edge_labels(placement, face, label_offset, back_label_idx) {
const ctx = this.ctx;
const pts = placement.vertices_2d;
const [cx, cy] = placement.centroid_2d;
const n = face.size;
for (let i = 0; i < n; i++) {
const pa = pts[i];
const pb = pts[(i + 1) % n];
// Midpoint of edge, nudged slightly toward centre.
const mx = (pa[0] + pb[0]) / 2;
const my = (pa[1] + pb[1]) / 2;
const inset = 0.25;
const lx = mx + (cx - mx) * inset;
const ly = my + (cy - my) * inset;
const label_idx = (i + label_offset) % n;
const is_back = (label_idx === back_label_idx);
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = is_back ? '#557' : COLOR_EDGE_LABEL;
ctx.fillText(EDGE_LABELS[label_idx], lx, ly);
}
}
}

View File

@@ -0,0 +1,2 @@
precision mediump float;
void main() { gl_FragColor = vec4(0.05, 0.05, 0.05, 1.0); }

View File

@@ -0,0 +1,3 @@
attribute vec3 a_pos;
uniform mat4 u_mvp;
void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }

View File

@@ -0,0 +1,112 @@
precision mediump float;
varying vec3 v_pos;
varying vec3 v_normal;
varying vec3 v_color;
varying vec2 v_pbr;
varying vec3 v_obj_pos;
varying vec4 v_noise; // (scale, strength, octaves, gain); lacunarity fixed at 2.0
uniform vec3 u_cam_pos;
uniform vec3 u_light1_dir;
uniform vec3 u_light1_color;
uniform vec3 u_light2_dir;
uniform vec3 u_light2_color;
uniform vec3 u_ambient;
const float PI = 3.14159265;
const float LACUNARITY = 2.0;
// ---------------------------------------------------------------------------
// 3-D value noise (input: object-space position → no rotation seam)
// ---------------------------------------------------------------------------
float hash3(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yxz + 33.33);
return fract((p.x + p.y) * p.z);
}
float vnoise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash3(i), hash3(i+vec3(1,0,0)), u.x),
mix(hash3(i+vec3(0,1,0)), hash3(i+vec3(1,1,0)), u.x), u.y),
mix(mix(hash3(i+vec3(0,0,1)), hash3(i+vec3(1,0,1)), u.x),
mix(hash3(i+vec3(0,1,1)), hash3(i+vec3(1,1,1)), u.x), u.y), u.z);
}
// FBM: octaves is a float so partial octaves blend smoothly.
float fbm(vec3 p, float octaves, float gain) {
float val = 0.0, amp = 0.5, freq = 1.0, norm = 0.0;
for (int i = 0; i < 8; i++) {
float w = clamp(octaves - float(i), 0.0, 1.0);
float c = amp * w;
val += c * vnoise(p * freq);
norm += c;
freq *= LACUNARITY;
amp *= gain;
}
return norm > 0.001 ? val / norm : 0.5;
}
// Perturb n using the per-face noise params from v_noise.
// Uses object-space position so noise is fixed to the sphere, not world space.
// Strength is divided by scale for consistent visual amplitude across frequencies.
vec3 perturb_normal(vec3 n) {
float scale = v_noise.x;
float strength = v_noise.y;
float octaves = v_noise.z;
float gain = v_noise.w;
vec3 t1 = cross(n, vec3(0.0, 1.0, 0.0));
if (length(t1) < 0.01) { t1 = cross(n, vec3(1.0, 0.0, 0.0)); }
t1 = normalize(t1);
vec3 t2 = normalize(cross(n, t1));
float eps = 0.05;
vec3 sp = v_obj_pos * scale;
float n0 = fbm(sp, octaves, gain);
float dx = (fbm(sp + t1 * (eps * scale), octaves, gain) - n0) / eps;
float dy = (fbm(sp + t2 * (eps * scale), octaves, gain) - n0) / eps;
float str = strength / max(scale, 1.0);
return normalize(n + str * (dx * t1 + dy * t2));
}
// ---------------------------------------------------------------------------
// PBR (Cook-Torrance)
// ---------------------------------------------------------------------------
float D_GGX(float NdH, float a2) {
float d = NdH*NdH*(a2-1.0)+1.0;
return a2 / (PI*d*d + 0.0001);
}
float G_Smith(float NdX, float k) {
return NdX / (NdX*(1.0-k)+k+0.0001);
}
vec3 F_Schlick(float cosT, vec3 F0) {
return F0 + (1.0-F0)*pow(clamp(1.0-cosT,0.0,1.0),5.0);
}
vec3 pbr_light(vec3 n, vec3 v, vec3 l, vec3 lc, vec3 alb, float met, float rou) {
vec3 h = normalize(v+l);
float NdL = max(dot(n,l),0.0);
float NdV = max(dot(n,v),0.001);
float NdH = max(dot(n,h),0.0);
float VdH = max(dot(v,h),0.0);
float a2 = rou*rou*rou*rou;
float k = rou*rou/2.0;
vec3 F0 = mix(vec3(0.04), alb, met);
vec3 F = F_Schlick(VdH, F0);
float D = D_GGX(NdH, a2);
float G = G_Smith(NdL,k)*G_Smith(NdV,k);
vec3 spec = D*F*G / (4.0*NdL*NdV + 0.0001);
vec3 kd = (1.0-F)*(1.0-met);
return (kd*alb/PI + spec) * lc * NdL;
}
void main() {
float met = v_pbr.x;
float rou = v_pbr.y;
vec3 alb = v_color;
vec3 n = normalize(v_normal);
if (v_noise.y > 0.001 && v_noise.x > 0.001) {
n = perturb_normal(n);
}
vec3 v = normalize(u_cam_pos - v_pos);
vec3 col = pbr_light(n, v, u_light1_dir, u_light1_color, alb, met, rou)
+ pbr_light(n, v, u_light2_dir, u_light2_color, alb, met, rou);
col += alb * (1.0-met) * u_ambient;
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}

View File

@@ -0,0 +1,21 @@
attribute vec3 a_pos;
attribute vec3 a_color;
attribute vec2 a_pbr;
attribute vec4 a_noise;
uniform mat4 u_mvp;
uniform mat3 u_norm;
varying vec3 v_pos;
varying vec3 v_normal;
varying vec3 v_color;
varying vec2 v_pbr;
varying vec3 v_obj_pos;
varying vec4 v_noise;
void main() {
gl_Position = u_mvp * vec4(a_pos, 1.0);
v_pos = u_norm * a_pos;
v_normal = v_pos;
v_color = a_color;
v_pbr = a_pbr;
v_obj_pos = a_pos;
v_noise = a_noise;
}

View File

@@ -0,0 +1,45 @@
import { quat_from_axis_angle, quat_mul } from './math.mjs';
export class Spin_State {
constructor() {
this.active = false;
this.axis = [0, 1, 0];
this.target = [0, 1, 0];
this.speed = 0.00035; // radians per millisecond
this.last_t = null;
}
_random_unit() {
let x, y, z, r;
do {
x = Math.random()*2-1; y = Math.random()*2-1; z = Math.random()*2-1;
r = Math.sqrt(x*x+y*y+z*z);
} while (r < 0.001 || r > 1);
return [x/r, y/r, z/r];
}
_new_target() { this.target = this._random_unit(); }
// Advance spin by dt ms. Returns updated rot_quat (unchanged if not active).
tick(dt, rot_quat) {
if (!this.active) { return rot_quat; }
if (this.last_t === null) { this._new_target(); return rot_quat; }
const drift = Math.min(1, dt * 0.0003);
const [ax,ay,az] = this.axis;
const [tx,ty,tz] = this.target;
let mx = ax + (tx-ax)*drift, my = ay + (ty-ay)*drift, mz = az + (tz-az)*drift;
const mr = Math.sqrt(mx*mx+my*my+mz*mz);
this.axis = [mx/mr, my/mr, mz/mr];
const dot = this.axis[0]*this.target[0] + this.axis[1]*this.target[1] + this.axis[2]*this.target[2];
if (dot > 0.999) { this._new_target(); }
const angle = this.speed * dt;
const [rx,ry,rz] = this.axis;
return quat_mul(quat_from_axis_angle(rx, ry, rz, angle), rot_quat);
}
start() { this.active = true; this.last_t = null; }
stop() { this.active = false; this.last_t = null; }
}

View File

@@ -0,0 +1,750 @@
// Sphere topology: icosahedron construction, subdivision, and Goldberg merging.
// All data structures live here. No rendering, no DOM.
import { Vec3, normalize_to_sphere, midpoint } from './geometry.mjs';
// ---------------------------------------------------------------------------
// Low-level data classes
// ---------------------------------------------------------------------------
export class Vertex {
constructor(index, x, y, z) {
this.index = index;
this.x = x;
this.y = y;
this.z = z;
}
to_vec3() { return new Vec3(this.x, this.y, this.z); }
}
export class Edge {
constructor(index, vertex_a, vertex_b) {
// vertex_a < vertex_b always (canonical ordering)
this.index = index;
this.vertex_a = vertex_a;
this.vertex_b = vertex_b;
this.face_left = null; // face whose winding traverses a→b
this.face_right = null; // face whose winding traverses b→a
}
}
export class Face {
constructor(index, vertices) {
this.index = index;
this.vertices = vertices; // ordered vertex indices
this.edges = []; // edge index for vertices[i]→vertices[(i+1)%n]
this.edge_neighbors = []; // face index across edges[i]; null = boundary (shouldn't happen on closed manifold)
this.edge_directions = []; // +1 if this face is on face_left of edge[i], -1 if face_right
}
get size() { return this.vertices.length; }
}
// ---------------------------------------------------------------------------
// Polyhedron — triangulated surface, used at icosahedron and subdivision levels
// ---------------------------------------------------------------------------
export class Polyhedron {
constructor(vertices, faces) {
// vertices: array of Vertex
// faces: array of Face (edges/neighbors populated by build_topology)
this.vertices = vertices;
this.faces = faces;
this.edges = [];
this._edge_map = new Map(); // 'a,b' → Edge index
}
// Build edge list and face connectivity from vertex/face data.
// Call this once after setting this.vertices and this.faces with vertex lists.
build_topology() {
this.edges = [];
this._edge_map = new Map();
for (const face of this.faces) {
const n = face.vertices.length;
face.edges = [];
face.edge_directions = [];
for (let i = 0; i < n; i++) {
const va = face.vertices[i];
const vb = face.vertices[(i + 1) % n];
const key = va < vb ? `${va},${vb}` : `${vb},${va}`;
if (!this._edge_map.has(key)) {
const edge = new Edge(this.edges.length, Math.min(va, vb), Math.max(va, vb));
this._edge_map.set(key, edge.index);
this.edges.push(edge);
}
const edge_index = this._edge_map.get(key);
face.edges.push(edge_index);
// Determine if this face is left or right of the edge.
// Convention: if the face traverses a→b (i.e. va < vb in canonical form
// and that matches the face's traversal direction), it's face_left.
const edge = this.edges[edge_index];
if (va === edge.vertex_a) {
// Face traverses a→b → face_left
face.edge_directions.push(1);
edge.face_left = face.index;
} else {
// Face traverses b→a → face_right
face.edge_directions.push(-1);
edge.face_right = face.index;
}
}
}
// Populate edge_neighbors on each face
for (const face of this.faces) {
face.edge_neighbors = [];
for (let i = 0; i < face.edges.length; i++) {
const edge = this.edges[face.edges[i]];
if (face.edge_directions[i] === 1) {
// This face is face_left; neighbor is face_right
face.edge_neighbors.push(edge.face_right);
} else {
// This face is face_right; neighbor is face_left
face.edge_neighbors.push(edge.face_left);
}
}
}
}
get_edge(va, vb) {
const key = va < vb ? `${va},${vb}` : `${vb},${va}`;
const idx = this._edge_map.get(key);
return idx !== undefined ? this.edges[idx] : null;
}
// Validate basic topological invariants. Returns array of error strings.
validate() {
const errors = [];
const v = this.vertices.length;
const e = this.edges.length;
const f = this.faces.length;
if (v - e + f !== 2) {
errors.push(`Euler characteristic: V(${v}) - E(${e}) + F(${f}) = ${v - e + f}, expected 2`);
}
for (const edge of this.edges) {
if (edge.face_left === null || edge.face_right === null) {
errors.push(`Edge ${edge.index} missing a face (left=${edge.face_left}, right=${edge.face_right})`);
}
}
for (const face of this.faces) {
for (let i = 0; i < face.edge_neighbors.length; i++) {
if (face.edge_neighbors[i] === null || face.edge_neighbors[i] === undefined) {
errors.push(`Face ${face.index} edge_neighbor[${i}] is null`);
}
}
}
return errors;
}
// Subdivide every triangle into 4 triangles by inserting edge midpoints.
// Midpoints are projected onto the unit sphere.
// Returns a new Polyhedron.
subdivide() {
const new_vertices = this.vertices.map(v => new Vertex(v.index, v.x, v.y, v.z));
const midpoint_map = new Map(); // edge_index → new vertex index
// Create midpoint vertices for every edge
for (const edge of this.edges) {
const va = this.vertices[edge.vertex_a].to_vec3();
const vb = this.vertices[edge.vertex_b].to_vec3();
const mid = normalize_to_sphere(midpoint(va, vb));
const new_index = new_vertices.length;
new_vertices.push(new Vertex(new_index, mid.x, mid.y, mid.z));
midpoint_map.set(edge.index, new_index);
}
// Split each triangle into 4
const new_face_defs = [];
for (const face of this.faces) {
const [a, b, c] = face.vertices;
const edge_ab = this.get_edge(a, b).index;
const edge_bc = this.get_edge(b, c).index;
const edge_ca = this.get_edge(c, a).index;
const mab = midpoint_map.get(edge_ab);
const mbc = midpoint_map.get(edge_bc);
const mca = midpoint_map.get(edge_ca);
// Preserve winding: original face is (a, b, c) CCW from outside.
new_face_defs.push([a, mab, mca]);
new_face_defs.push([mab, b, mbc]);
new_face_defs.push([mca, mbc, c]);
new_face_defs.push([mab, mbc, mca]); // center triangle
}
const new_faces = new_face_defs.map((verts, i) => new Face(i, verts));
const poly = new Polyhedron(new_vertices, new_faces);
poly.build_topology();
return poly;
}
// ---------------------------------------------------------------------------
// Icosahedron constructor — pentagonal antiprism method
// ---------------------------------------------------------------------------
static build_icosahedron() {
// Edge length = 1. All geometry derived from the equal-edge constraint.
//
// Pentagon circumradius: R = 1 / (2 * sin(π/5))
// Ring separation: Δz = sqrt(1 - 2R²(1 - cos(π/5)))
// Cap height above/below ring: h = sqrt(1 - R²)
//
// Vertex layout:
// 0 : top cap
// 1..5 : top ring, angle 2πk/5
// 6..10 : bottom ring, angle 2πk/5 + π/5 (rotated 36°)
// 11 : bottom cap
const R = 1 / (2 * Math.sin(Math.PI / 5));
const delta_z = Math.sqrt(1 - 2 * R * R * (1 - Math.cos(Math.PI / 5)));
const h = Math.sqrt(1 - R * R);
// Place rings symmetrically about z=0
const z_top = delta_z / 2;
const z_bot = -delta_z / 2;
const raw_vertices = [];
// Top cap
raw_vertices.push([0, 0, z_top + h]);
// Top ring
for (let k = 0; k < 5; k++) {
const angle = (2 * Math.PI * k) / 5;
raw_vertices.push([R * Math.cos(angle), R * Math.sin(angle), z_top]);
}
// Bottom ring (rotated 36°)
for (let k = 0; k < 5; k++) {
const angle = (2 * Math.PI * k) / 5 + Math.PI / 5;
raw_vertices.push([R * Math.cos(angle), R * Math.sin(angle), z_bot]);
}
// Bottom cap
raw_vertices.push([0, 0, z_bot - h]);
// Normalize all vertices to unit sphere
const vertices = raw_vertices.map(([x, y, z], i) => {
const v = normalize_to_sphere(new Vec3(x, y, z));
return new Vertex(i, v.x, v.y, v.z);
});
// Build faces from antiprism structure.
// All faces wound CCW when viewed from outside (outward normal).
//
// Top cap: vertex 0, top ring k and k+1
// Antiprism upward triangles: top[k], bot[k+1], bot[k] (CCW from outside)
// Antiprism downward triangles: top[k], top[k+1], bot[k+1] (CCW from outside)
// Bottom cap: bot[k], bot[k+1], vertex 11
const top = k => 1 + (k % 5);
const bot = k => 6 + (k % 5);
const TOP_CAP = 0;
const BOT_CAP = 11;
const face_defs = [];
for (let k = 0; k < 5; k++) {
// Top cap triangle
face_defs.push([TOP_CAP, top(k), top(k + 1)]);
// Antiprism "up" triangle: top(k) flanked by bot(k-1) and bot(k), both 36° away.
// bot(k-1) = bot(k+4) using mod-5 arithmetic.
face_defs.push([top(k), bot(k + 4), bot(k)]);
// Antiprism "down" triangle: top(k), top(k+1) share bot(k) (36° from each).
// Winding reversed so outward normal points away from origin.
face_defs.push([top(k + 1), top(k), bot(k)]);
// Bottom cap triangle
face_defs.push([BOT_CAP, bot(k + 1), bot(k)]);
}
const faces = face_defs.map((verts, i) => new Face(i, verts));
const poly = new Polyhedron(vertices, faces);
poly.build_topology();
return poly;
}
}
// ---------------------------------------------------------------------------
// Goldberg polyhedron — hexagons and pentagons
// ---------------------------------------------------------------------------
export class Goldberg_Face {
constructor(index, center_vertex, component_triangles, vertices_3d) {
this.index = index;
this.center_vertex = center_vertex; // vertex index in the subdivided Polyhedron
this.component_triangles = component_triangles; // triangle face indices (ordered ring)
this.vertices_3d = vertices_3d; // Vec3 centroids of component triangles, in ring order
this.relaxed_vertices_3d = null; // set by Goldberg_Polyhedron.relax_sphere()
this.edge_neighbors = []; // Goldberg_Face index across edge i
this.is_pentagon = false;
}
get size() { return this.component_triangles.length; }
}
export class Goldberg_Polyhedron {
constructor(faces) {
this.faces = faces; // array of Goldberg_Face
}
// Build from a subdivided triangulated Polyhedron.
// Each vertex of the triangulation becomes one Goldberg face.
static from_subdivided(poly) {
// For each vertex, collect the ring of surrounding triangular faces.
const vertex_star = new Array(poly.vertices.length).fill(null).map(() => []);
for (const face of poly.faces) {
for (const vi of face.vertices) {
vertex_star[vi].push(face.index);
}
}
const goldberg_faces = [];
// Map from triangle face index → which Goldberg face it belongs to
const triangle_to_goldberg = new Map();
for (let vi = 0; vi < poly.vertices.length; vi++) {
const star = vertex_star[vi];
if (star.length < 3) { continue; }
// Order the star faces into a ring by following adjacency.
let ordered = order_face_ring(star, vi, poly);
if (ordered === null) {
console.warn(`Could not order face ring for vertex ${vi}`);
continue;
}
// Compute centroids of the component triangles as Goldberg face vertices
const vertices_3d = ordered.map(fi => {
const face = poly.faces[fi];
const va = poly.vertices[face.vertices[0]].to_vec3();
const vb = poly.vertices[face.vertices[1]].to_vec3();
const vc = poly.vertices[face.vertices[2]].to_vec3();
return normalize_to_sphere(va.add(vb).add(vc).scale(1 / 3));
});
// Enforce consistent CCW winding when viewed from outside the sphere.
// The outward direction at this vertex is its own position (unit sphere).
// Compute the ring's normal as the sum of cross products of consecutive
// centroid vectors; if it points inward (dot < 0 with center vertex), reverse.
{
const center = poly.vertices[vi].to_vec3();
const n = vertices_3d.length;
let normal_x = 0, normal_y = 0, normal_z = 0;
for (let i = 0; i < n; i++) {
const a = vertices_3d[i];
const b = vertices_3d[(i + 1) % n];
normal_x += a.y * b.z - a.z * b.y;
normal_y += a.z * b.x - a.x * b.z;
normal_z += a.x * b.y - a.y * b.x;
}
const dot = normal_x * center.x + normal_y * center.y + normal_z * center.z;
if (dot < 0) {
ordered.reverse();
vertices_3d.reverse();
}
}
const gf = new Goldberg_Face(goldberg_faces.length, vi, ordered, vertices_3d);
goldberg_faces.push(gf);
for (const fi of ordered) {
triangle_to_goldberg.set(fi, gf.index);
}
}
// Mark pentagons: vertices with a star of 5 faces.
// In a subdivided icosahedron, the 12 original vertices remain degree-5.
for (const gf of goldberg_faces) {
gf.is_pentagon = (gf.size === 5);
}
// Build edge_neighbors using the dual graph relationship.
//
// Goldberg edge i runs from centroid(ring[i]) to centroid(ring[i+1]).
// By duality this edge separates the Goldberg face centered on `center_vertex`
// from the Goldberg face centered on the vertex shared by ring[i] and ring[i+1]
// that is NOT `center_vertex`. So the neighbor across edge i is simply:
// vertex_to_goldberg[ shared_outer_vertex(ring[i], ring[i+1], center_vertex) ]
//
// This avoids any triangle→Goldberg mapping ambiguity entirely.
const vertex_to_goldberg = new Map();
for (const gf of goldberg_faces) {
vertex_to_goldberg.set(gf.center_vertex, gf.index);
}
for (const gf of goldberg_faces) {
gf.edge_neighbors = new Array(gf.size).fill(null);
for (let i = 0; i < gf.size; i++) {
const tri_a = poly.faces[gf.component_triangles[i]];
const tri_b = poly.faces[gf.component_triangles[(i + 1) % gf.size]];
const shared_vi = find_shared_outer_vertex(tri_a, tri_b, gf.center_vertex);
if (shared_vi !== null && vertex_to_goldberg.has(shared_vi)) {
gf.edge_neighbors[i] = vertex_to_goldberg.get(shared_vi);
}
}
}
return new Goldberg_Polyhedron(goldberg_faces);
}
validate() {
const errors = [];
let pentagon_count = 0;
for (const gf of this.faces) {
if (gf.is_pentagon) { pentagon_count++; }
for (let i = 0; i < gf.size; i++) {
const nbr_idx = gf.edge_neighbors[i];
if (nbr_idx === null || nbr_idx === undefined) {
errors.push(`Face ${gf.index} edge_neighbor[${i}] is null`);
continue;
}
const nbr = this.faces[nbr_idx];
// Neighbor symmetry: nbr should contain gf.index in its edge_neighbors
if (!nbr.edge_neighbors.includes(gf.index)) {
errors.push(`Face ${gf.index}${nbr_idx}, but ${nbr_idx} does not list ${gf.index} as neighbor`);
}
}
}
if (pentagon_count !== 12) {
errors.push(`Expected 12 pentagons, found ${pentagon_count}`);
}
return errors;
}
// BFS from a face, up to `depth` steps.
// Returns array of { face, depth, parent_index, parent_edge_index } in BFS order.
// The root entry has parent_index = null, parent_edge_index = null.
get_neighborhood(face_index, depth) {
const result = [];
const visited = new Set();
const queue = [{ face_index, depth: 0, parent_index: null, parent_edge_index: null }];
visited.add(face_index);
while (queue.length > 0) {
const entry = queue.shift();
result.push({
face: this.faces[entry.face_index],
depth: entry.depth,
parent_index: entry.parent_index,
parent_edge_index: entry.parent_edge_index,
});
if (entry.depth >= depth) { continue; }
const face = this.faces[entry.face_index];
for (let i = 0; i < face.edge_neighbors.length; i++) {
const nbr_idx = face.edge_neighbors[i];
if (nbr_idx !== null && nbr_idx !== undefined && !visited.has(nbr_idx)) {
visited.add(nbr_idx);
queue.push({
face_index: nbr_idx,
depth: entry.depth + 1,
parent_index: entry.face_index,
parent_edge_index: i,
});
}
}
}
return result;
}
// Relax all Goldberg vertices onto the unit sphere.
//
// Two independent spring types, each with its own alpha (set to 0 to disable):
// alpha_edge — push each shared edge toward a uniform target length.
// Scales well; recommended default ≈ 0.1.
// alpha_centroid — push each vertex toward its face's average circumradius.
// Adds shape regularity but converges poorly at high
// subdivision depths; keep small (≤ 0.02) if used.
//
// Both springs are applied each iteration; vertices are re-normalised to the
// unit sphere afterwards. Stores results as relaxed_vertices_3d on each face.
relax_sphere(iters = 150, alpha_edge = 0, alpha_centroid = 0.04) {
const faces = this.faces;
const nf = faces.length;
// --- Global union-find to identify shared vertices across faces ---
const face_base = new Array(nf);
let uf_size = 0;
for (let i = 0; i < nf; i++) {
face_base[i] = uf_size;
uf_size += faces[i].size;
}
const uf_parent = Array.from({ length: uf_size }, (_, i) => i);
const uf_find = (i) => {
while (uf_parent[i] !== i) { uf_parent[i] = uf_parent[uf_parent[i]]; i = uf_parent[i]; }
return i;
};
const uf_union = (i, j) => { uf_parent[uf_find(i)] = uf_find(j); };
const adj_seen = new Set();
for (let fi_a = 0; fi_a < nf; fi_a++) {
const face_a = faces[fi_a];
const na = face_a.size;
const base_a = face_base[fi_a];
for (let ea = 0; ea < na; ea++) {
const fi_b = face_a.edge_neighbors[ea];
if (fi_b === null || fi_b === undefined) { continue; }
const pair_key = fi_a < fi_b ? fi_a * 65536 + fi_b : fi_b * 65536 + fi_a;
if (adj_seen.has(pair_key)) { continue; }
adj_seen.add(pair_key);
const face_b = faces[fi_b];
const nb = face_b.size;
const base_b = face_base[fi_b];
let eb = -1;
for (let j = 0; j < nb; j++) {
if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; }
}
if (eb === -1) { continue; }
uf_union(base_a + ea, base_b + (eb + 1) % nb);
uf_union(base_a + (ea + 1) % na, base_b + eb);
}
}
// Compact position IDs.
const canon_to_pid = new Map();
let pid_count = 0;
const face_pids = new Array(nf);
for (let fi = 0; fi < nf; fi++) {
const n = faces[fi].size;
const base = face_base[fi];
const pids = new Array(n);
for (let i = 0; i < n; i++) {
const canon = uf_find(base + i);
if (!canon_to_pid.has(canon)) { canon_to_pid.set(canon, pid_count++); }
pids[i] = canon_to_pid.get(canon);
}
face_pids[fi] = pids;
}
// Initialise positions from vertices_3d (already on unit sphere).
const px = new Float64Array(pid_count);
const py = new Float64Array(pid_count);
const pz = new Float64Array(pid_count);
const written = new Uint8Array(pid_count);
for (let fi = 0; fi < nf; fi++) {
const face = faces[fi];
const pids = face_pids[fi];
for (let i = 0; i < face.size; i++) {
const pid = pids[i];
if (!written[pid]) {
const v = face.vertices_3d[i];
px[pid] = v.x; py[pid] = v.y; pz[pid] = v.z;
written[pid] = 1;
}
}
}
// Collect unique edges (needed for edge springs).
const edge_seen = new Set();
const eu = [], ev = [];
if (alpha_edge > 0) {
for (let fi = 0; fi < nf; fi++) {
const pids = face_pids[fi];
const n = faces[fi].size;
for (let i = 0; i < n; i++) {
const u = pids[i], v = pids[(i + 1) % n];
const ek = u < v ? u * 65536 + v : v * 65536 + u;
if (!edge_seen.has(ek)) { edge_seen.add(ek); eu.push(u); ev.push(v); }
}
}
}
const ne = eu.length;
// Target edge length: average of all initial edge lengths.
let target_len = 0;
for (let e = 0; e < ne; e++) {
const u = eu[e], v = ev[e];
const dx = px[v] - px[u], dy = py[v] - py[u], dz = pz[v] - pz[u];
target_len += Math.sqrt(dx*dx + dy*dy + dz*dz);
}
if (ne > 0) { target_len /= ne; }
// Target circumradius: average centroid-to-vertex distance (initial geometry).
let target_r = 0, tr_count = 0;
if (alpha_centroid > 0) {
for (let fi = 0; fi < nf; fi++) {
const pids = face_pids[fi];
const n = faces[fi].size;
let fcx = 0, fcy = 0, fcz = 0;
for (let i = 0; i < n; i++) { fcx += px[pids[i]]; fcy += py[pids[i]]; fcz += pz[pids[i]]; }
fcx /= n; fcy /= n; fcz /= n;
for (let i = 0; i < n; i++) {
const p = pids[i];
const dx = px[p] - fcx, dy = py[p] - fcy, dz = pz[p] - fcz;
target_r += Math.sqrt(dx*dx + dy*dy + dz*dz);
tr_count++;
}
}
target_r /= tr_count;
}
// Spring relaxation loop.
const dx_buf = new Float64Array(pid_count);
const dy_buf = new Float64Array(pid_count);
const dz_buf = new Float64Array(pid_count);
for (let iter = 0; iter < iters; iter++) {
dx_buf.fill(0); dy_buf.fill(0); dz_buf.fill(0);
// Edge-length springs.
for (let e = 0; e < ne; e++) {
const u = eu[e], v = ev[e];
const dx = px[v] - px[u], dy = py[v] - py[u], dz = pz[v] - pz[u];
const len = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (len < 1e-10) { continue; }
const err = (len - target_len) / len * 0.5 * alpha_edge;
dx_buf[u] += dx * err; dy_buf[u] += dy * err; dz_buf[u] += dz * err;
dx_buf[v] -= dx * err; dy_buf[v] -= dy * err; dz_buf[v] -= dz * err;
}
// Centroid-to-vertex springs.
if (alpha_centroid > 0) {
for (let fi = 0; fi < nf; fi++) {
const pids = face_pids[fi];
const n = faces[fi].size;
let fcx = 0, fcy = 0, fcz = 0;
for (let i = 0; i < n; i++) { fcx += px[pids[i]]; fcy += py[pids[i]]; fcz += pz[pids[i]]; }
fcx /= n; fcy /= n; fcz /= n;
for (let i = 0; i < n; i++) {
const p = pids[i];
const dx = px[p] - fcx, dy = py[p] - fcy, dz = pz[p] - fcz;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < 1e-10) { continue; }
const err = (dist - target_r) / dist * alpha_centroid;
dx_buf[p] -= dx * err; dy_buf[p] -= dy * err; dz_buf[p] -= dz * err;
}
}
}
// Apply deltas and project back to unit sphere.
for (let i = 0; i < pid_count; i++) {
const nx = px[i] + dx_buf[i];
const ny = py[i] + dy_buf[i];
const nz = pz[i] + dz_buf[i];
const r = Math.sqrt(nx*nx + ny*ny + nz*nz);
if (r < 1e-10) { continue; }
px[i] = nx / r; py[i] = ny / r; pz[i] = nz / r;
}
}
// Write relaxed_vertices_3d back onto each face.
for (let fi = 0; fi < nf; fi++) {
const face = faces[fi];
const pids = face_pids[fi];
face.relaxed_vertices_3d = pids.map(pid => new Vec3(px[pid], py[pid], pz[pid]));
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// Order the faces in `star` (all containing vertex `vi`) into a ring.
// Traces the ring purely through shared outer vertices — no edge_neighbors dependency.
//
// Each face in the ring is a triangle [vi, va, vb]. Two consecutive ring faces share
// exactly one outer vertex (the one between them). We build a map from each outer
// vertex to the (at most 2) star faces that contain it, then follow the chain.
// Returns ordered array of face indices, or null on failure.
function order_face_ring(star, vi, poly) {
if (star.length === 0) { return []; }
if (star.length === 1) { return [star[0]]; }
// Map: outer_vertex → [face_indices in this star that contain it]
const vertex_to_star_faces = new Map();
for (const fi of star) {
for (const v of poly.faces[fi].vertices) {
if (v === vi) { continue; }
if (!vertex_to_star_faces.has(v)) { vertex_to_star_faces.set(v, []); }
vertex_to_star_faces.get(v).push(fi);
}
}
// Trace the ring: from current face [vi, v_prev, v_next], the forward outer vertex
// is v_next. The next ring face is the other star face that contains vi and v_next.
const ordered = [star[0]];
const visited = new Set([star[0]]);
const first_face = poly.faces[star[0]];
const first_outers = first_face.vertices.filter(v => v !== vi);
// Arbitrary starting direction; winding will be corrected after ring is built.
let forward_vertex = first_outers[1];
while (ordered.length < star.length) {
const candidates = vertex_to_star_faces.get(forward_vertex) || [];
const next_fi = candidates.find(fi => !visited.has(fi));
if (next_fi === undefined) { return null; }
ordered.push(next_fi);
visited.add(next_fi);
// The next forward vertex is the outer vertex of next_fi that isn't forward_vertex.
const next_outers = poly.faces[next_fi].vertices.filter(v => v !== vi);
forward_vertex = next_outers.find(v => v !== forward_vertex);
if (forward_vertex === undefined) { return null; }
}
return ordered;
}
// Return the vertex that appears in both face_a and face_b but is not exclude_vi.
// This is the "shared outer vertex" between two consecutive ring triangles.
function find_shared_outer_vertex(face_a, face_b, exclude_vi) {
for (const va of face_a.vertices) {
if (va !== exclude_vi && face_b.vertices.includes(va)) {
return va;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Face adjacency (shared-edge graph)
// ---------------------------------------------------------------------------
// Build adjacency list for Goldberg faces via shared edges.
// Uses unrelaxed vertex positions as canonical keys — topology is stable across stages.
export function build_goldberg_adjacency(goldberg) {
const edge_to_entries = new Map();
for (let fi = 0; fi < goldberg.faces.length; fi++) {
const verts = goldberg.faces[fi].vertices_3d;
const n = verts.length;
for (let i = 0; i < n; i++) {
const a = verts[i], b = verts[(i + 1) % n];
const ka = `${a.x.toFixed(5)},${a.y.toFixed(5)},${a.z.toFixed(5)}`;
const kb = `${b.x.toFixed(5)},${b.y.toFixed(5)},${b.z.toFixed(5)}`;
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
if (!edge_to_entries.has(key)) { edge_to_entries.set(key, []); }
edge_to_entries.get(key).push({ fi, edge_idx: i });
}
}
const adj = Array.from({ length: goldberg.faces.length }, () => []);
for (const entries of edge_to_entries.values()) {
if (entries.length === 2) {
const [e0, e1] = entries;
adj[e0.fi].push({ fj: e1.fi, fi_edge_idx: e0.edge_idx });
adj[e1.fi].push({ fj: e0.fi, fi_edge_idx: e1.edge_idx });
}
}
return adj;
}

View File

@@ -0,0 +1,146 @@
import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE } from './paint_state.mjs';
const MAX_HISTORY = 64;
// Ensure key→value is in map/palette; return index.
function _intern(map, palette, key, val) {
if (!map.has(key)) { map.set(key, palette.length); palette.push(val); }
return map.get(key);
}
// Brush/tool settings at snapshot time.
class Tool_Snapshot {
constructor(active_brush_side, paint) {
this.active_brush_side = active_brush_side;
this.enabled = paint.enabled;
this.tool = paint.tool;
this.color_left = [...paint.color_left];
this.color_right = [...paint.color_right];
this.metallic_left = paint.metallic_left;
this.metallic_right = paint.metallic_right;
this.roughness_left = paint.roughness_left;
this.roughness_right = paint.roughness_right;
this.noise_left = [...paint.noise_left];
this.noise_right = [...paint.noise_right];
}
}
// Compressed face-paint snapshot.
// face_mat[fi] = 0 means default (unpainted); >0 = index into mat_palette.
// mat_palette entries = [color_idx, pbr_idx, noise_idx].
class Paint_Snapshot {
constructor(paint, face_count, tool) {
this.tool = tool;
const color_map = new Map();
const pbr_map = new Map();
const noise_map = new Map();
const mat_map = new Map();
this.color_palette = [];
this.pbr_palette = [];
this.noise_palette = [];
this.mat_palette = [];
// Index 0 reserved for default background material.
color_map.set(PAINT_BG.join(','), 0); this.color_palette.push([...PAINT_BG]);
pbr_map.set(PAINT_BG_PBR.join(','), 0); this.pbr_palette.push([...PAINT_BG_PBR]);
noise_map.set(PAINT_BG_NOISE.join(','), 0); this.noise_palette.push([...PAINT_BG_NOISE]);
mat_map.set('0|0|0', 0); this.mat_palette.push([0, 0, 0]);
this.face_mat = new Uint32Array(face_count); // default 0
for (let fi = 0; fi < face_count; fi++) {
const c = paint.face_colors.get(fi);
const pbr = paint.face_pbr.get(fi);
const noise = paint.face_noise.get(fi);
if (c === undefined && pbr === undefined && noise === undefined) { continue; }
const cv = c ?? PAINT_BG;
const pv = pbr ?? PAINT_BG_PBR;
const nv = noise ?? PAINT_BG_NOISE;
const c_idx = _intern(color_map, this.color_palette, cv.join(','), [...cv]);
const pbr_idx = _intern(pbr_map, this.pbr_palette, pv.join(','), [...pv]);
const noi_idx = _intern(noise_map, this.noise_palette, nv.join(','), [...nv]);
const mat_key = `${c_idx}|${pbr_idx}|${noi_idx}`;
const mat_idx = _intern(mat_map, this.mat_palette, mat_key, [c_idx, pbr_idx, noi_idx]);
// If the material happens to hash to default (all three indices = 0), leave face_mat = 0.
if (mat_idx !== 0) { this.face_mat[fi] = mat_idx; }
}
}
// Restore paint Maps from this snapshot. Returns the saved active_brush_side.
restore(paint) {
paint.face_colors.clear();
paint.face_pbr.clear();
paint.face_noise.clear();
paint.face_key.clear();
for (let fi = 0; fi < this.face_mat.length; fi++) {
const mat_idx = this.face_mat[fi];
if (mat_idx === 0) { continue; }
const [c_idx, pbr_idx, noi_idx] = this.mat_palette[mat_idx];
const c = this.color_palette[c_idx];
const pbr = this.pbr_palette[pbr_idx];
const noise = this.noise_palette[noi_idx];
paint.face_colors.set(fi, [...c]);
paint.face_pbr.set(fi, [...pbr]);
paint.face_noise.set(fi, [...noise]);
paint.face_key.set(fi, paint._make_key(c, pbr, noise));
}
const t = this.tool;
paint.enabled = t.enabled;
paint.tool = t.tool;
paint.color_left = [...t.color_left];
paint.color_right = [...t.color_right];
paint.metallic_left = t.metallic_left;
paint.metallic_right = t.metallic_right;
paint.roughness_left = t.roughness_left;
paint.roughness_right = t.roughness_right;
paint.noise_left = [...t.noise_left];
paint.noise_right = [...t.noise_right];
return t.active_brush_side;
}
byte_size() { return this.face_mat.byteLength; }
}
export class Undo_State {
constructor() {
this.history = [];
this.pos = -1;
}
// Push a new snapshot; truncates redo history.
push(paint, face_count, active_brush_side) {
this.history.splice(this.pos + 1);
this.history.push(new Paint_Snapshot(paint, face_count, new Tool_Snapshot(active_brush_side, paint)));
if (this.history.length > MAX_HISTORY) { this.history.shift(); } else { this.pos++; }
}
can_undo() { return this.pos > 0; }
can_redo() { return this.pos < this.history.length - 1; }
// Restore previous snapshot; returns active_brush_side or null if nothing to undo.
undo(paint) {
if (!this.can_undo()) { return null; }
this.pos--;
return this.history[this.pos].restore(paint);
}
// Restore next snapshot; returns active_brush_side or null if nothing to redo.
redo(paint) {
if (!this.can_redo()) { return null; }
this.pos++;
return this.history[this.pos].restore(paint);
}
// Drop all history (e.g. when mesh topology changes).
invalidate() { this.history = []; this.pos = -1; }
}