diff --git a/index.html b/index.html
index 7d6d915..c2ad4db 100644
--- a/index.html
+++ b/index.html
@@ -45,6 +45,7 @@
Delaunay dataset
Delaunay dataset + Edge relaxation (Currently broken)
Reverb configurator for FluidSynth CLI UI
+ Goldberg Polyhedron Paint
diff --git a/standalone/goldberg-sphere/env_state.mjs b/standalone/goldberg-sphere/env_state.mjs
new file mode 100644
index 0000000..2a9473a
--- /dev/null
+++ b/standalone/goldberg-sphere/env_state.mjs
@@ -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); }
+}
diff --git a/standalone/goldberg-sphere/geometry.mjs b/standalone/goldberg-sphere/geometry.mjs
new file mode 100644
index 0000000..d0e5375
--- /dev/null
+++ b/standalone/goldberg-sphere/geometry.mjs
@@ -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 };
+}
diff --git a/standalone/goldberg-sphere/gl_utils.mjs b/standalone/goldberg-sphere/gl_utils.mjs
new file mode 100644
index 0000000..e15778b
--- /dev/null
+++ b/standalone/goldberg-sphere/gl_utils.mjs
@@ -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;
+}
diff --git a/standalone/goldberg-sphere/index.html b/standalone/goldberg-sphere/index.html
new file mode 100644
index 0000000..938b628
--- /dev/null
+++ b/standalone/goldberg-sphere/index.html
@@ -0,0 +1,346 @@
+
+
+
+
+ Goldberg Polyhedron Paint
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/standalone/goldberg-sphere/math.mjs b/standalone/goldberg-sphere/math.mjs
new file mode 100644
index 0000000..8234e2b
--- /dev/null
+++ b/standalone/goldberg-sphere/math.mjs
@@ -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]]);
+}
diff --git a/standalone/goldberg-sphere/maze.mjs b/standalone/goldberg-sphere/maze.mjs
new file mode 100644
index 0000000..3357a06
--- /dev/null
+++ b/standalone/goldberg-sphere/maze.mjs
@@ -0,0 +1,336 @@
+import { make_buffer } from './gl_utils.mjs';
+
+// ---------------------------------------------------------------------------
+// Face adjacency
+// ---------------------------------------------------------------------------
+
+// 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;
+}
+
+// ---------------------------------------------------------------------------
+// Prim island maze
+// ---------------------------------------------------------------------------
+
+function grow_island(seed, max_cells, adj, cell_region, region_id) {
+ cell_region[seed] = region_id;
+ const frontier = [];
+ const add_frontier = fi => {
+ for (const { fj, fi_edge_idx } of adj[fi]) {
+ if (cell_region[fj] === -1) { frontier.push({ fi, fj, fi_edge_idx }); }
+ }
+ };
+ add_frontier(seed);
+
+ const passage_pairs = new Set();
+ let count = 1;
+ while (frontier.length > 0 && count < max_cells) {
+ const idx = Math.floor(Math.random() * frontier.length);
+ const { fi, fj, fi_edge_idx } = frontier[idx];
+ frontier[idx] = frontier[frontier.length - 1];
+ frontier.pop();
+ if (cell_region[fj] !== -1) { continue; }
+ const pk = fi < fj ? `${fi},${fj}` : `${fj},${fi}`;
+ passage_pairs.add(pk);
+ cell_region[fj] = region_id;
+ count++;
+ add_frontier(fj);
+ }
+ return passage_pairs;
+}
+
+function generate_maze_walls(goldberg, seed_count, max_cells) {
+ const adj = build_goldberg_adjacency(goldberg);
+ const n = goldberg.faces.length;
+ const cell_region = new Int32Array(n).fill(-1);
+ const all_passages = new Set();
+
+ const order = Array.from({ length: n }, (_, i) => i);
+ for (let i = n - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [order[i], order[j]] = [order[j], order[i]];
+ }
+
+ let region_id = 0;
+ for (let s = 0; s < seed_count && s < n; s++) {
+ const seed = order[s];
+ if (cell_region[seed] !== -1) { continue; }
+ const passages = grow_island(seed, max_cells, adj, cell_region, region_id);
+ for (const pk of passages) { all_passages.add(pk); }
+ region_id++;
+ }
+
+ const wall_edges = [];
+ for (let fi = 0; fi < n; fi++) {
+ if (cell_region[fi] === -1) { continue; }
+ for (const { fj, fi_edge_idx } of adj[fi]) {
+ if (fi >= fj) { continue; }
+ if (cell_region[fj] === -1) {
+ wall_edges.push({ fi, edge_idx: fi_edge_idx });
+ } else if (cell_region[fj] === cell_region[fi]) {
+ const pk = `${fi},${fj}`;
+ if (!all_passages.has(pk)) { wall_edges.push({ fi, edge_idx: fi_edge_idx }); }
+ }
+ }
+ }
+ return wall_edges;
+}
+
+// ---------------------------------------------------------------------------
+// Edge-walker maze
+// ---------------------------------------------------------------------------
+
+function build_vertex_graph(goldberg) {
+ const pos_to_id = new Map();
+ let nv = 0;
+ const get_vid = v => {
+ const k = `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`;
+ if (!pos_to_id.has(k)) { pos_to_id.set(k, nv++); }
+ return pos_to_id.get(k);
+ };
+ for (const face of goldberg.faces) {
+ for (const v of face.vertices_3d) { get_vid(v); }
+ }
+ const vertex_adj = Array.from({ length: nv }, () => []);
+ const edge_seen = new Set();
+ const edge_data = 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 va = get_vid(verts[i]);
+ const vb = get_vid(verts[(i + 1) % n]);
+ const ek = va < vb ? `${va},${vb}` : `${vb},${va}`;
+ if (!edge_seen.has(ek)) {
+ edge_seen.add(ek);
+ edge_data.set(ek, { fi, edge_idx: i });
+ vertex_adj[va].push({ vj: vb, ek });
+ vertex_adj[vb].push({ vj: va, ek });
+ }
+ }
+ }
+ return { nv, vertex_adj, edge_data };
+}
+
+function generate_walker_walls(goldberg, walker_count, stop_prob, branch_prob, close_prob) {
+ const { nv, vertex_adj, edge_data } = build_vertex_graph(goldberg);
+ const walled = new Set();
+ const vertex_walled = new Uint8Array(nv);
+
+ const order = Array.from({ length: nv }, (_, i) => i);
+ for (let i = nv - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [order[i], order[j]] = [order[j], order[i]];
+ }
+
+ const active = [];
+ for (let w = 0; w < Math.min(walker_count, nv); w++) {
+ active.push({ vi: order[w], prev_vi: -1 });
+ }
+
+ while (active.length > 0) {
+ const wi = Math.floor(Math.random() * active.length);
+ const walker = active[wi];
+ const { vi, prev_vi } = walker;
+ if (Math.random() < stop_prob) { active.splice(wi, 1); continue; }
+ const candidates = [];
+ for (const { vj, ek } of vertex_adj[vi]) {
+ if (vj === prev_vi) { continue; }
+ if (walled.has(ek)) { continue; }
+ if (vertex_walled[vj] && Math.random() >= close_prob) { continue; }
+ candidates.push({ vj, ek });
+ }
+ if (candidates.length === 0) { active.splice(wi, 1); continue; }
+ const { vj, ek } = candidates[Math.floor(Math.random() * candidates.length)];
+ walled.add(ek);
+ vertex_walled[vi] = 1;
+ vertex_walled[vj] = 1;
+ if (Math.random() < branch_prob) { active.push({ vi, prev_vi }); }
+ walker.vi = vj;
+ walker.prev_vi = vi;
+ }
+
+ const wall_edges = [];
+ for (const ek of walled) {
+ const entry = edge_data.get(ek);
+ if (entry) { wall_edges.push({ fi: entry.fi, edge_idx: entry.edge_idx }); }
+ }
+ return wall_edges;
+}
+
+// ---------------------------------------------------------------------------
+// Wall geometry
+// ---------------------------------------------------------------------------
+
+function build_wall_geo(goldberg, use_relaxed, wall_edges, wall_width) {
+ if (wall_edges.length === 0) { return { wall_pos: new Float32Array(0), wall_verts: 0 }; }
+
+ const LIFT = 1.010;
+ const hw = wall_width / 2;
+ const MITER_CLAMP = 4.0;
+
+ const ev = wall_edges.map(({ fi, edge_idx }) => {
+ const face = goldberg.faces[fi];
+ const verts = use_relaxed ? (face.relaxed_vertices_3d ?? face.vertices_3d) : face.vertices_3d;
+ return { a: verts[edge_idx], b: verts[(edge_idx + 1) % verts.length] };
+ });
+
+ const pk = v => `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`;
+
+ const vtx_edges = new Map();
+ for (let i = 0; i < ev.length; i++) {
+ for (const v of [ev[i].a, ev[i].b]) {
+ const k = pk(v);
+ if (!vtx_edges.has(k)) { vtx_edges.set(k, []); }
+ vtx_edges.get(k).push(i);
+ }
+ }
+
+ const w_dir = (v, w) => {
+ const ex = w.x-v.x, ey = w.y-v.y, ez = w.z-v.z;
+ const er = Math.sqrt(ex*ex + ey*ey + ez*ez);
+ if (er < 1e-10) { return [0, 0, 0]; }
+ const vr = Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
+ const nx = v.x/vr, ny = v.y/vr, nz = v.z/vr;
+ const edx = ex/er, edy = ey/er, edz = ez/er;
+ return [ny*edz - nz*edy, nz*edx - nx*edz, nx*edy - ny*edx];
+ };
+
+ const miter_offset = (v, other_v, i) => {
+ const vk = pk(v);
+ const adj = vtx_edges.get(vk).filter(j => j !== i);
+ const w1 = w_dir(v, other_v);
+ if (adj.length === 0) { return [0, 0, 0]; }
+ let sx = w1[0], sy = w1[1], sz = w1[2];
+ for (const j of adj) {
+ const { a, b } = ev[j];
+ const far = pk(a) === vk ? b : a;
+ const wj = w_dir(v, far);
+ sx += wj[0]; sy += wj[1]; sz += wj[2];
+ }
+ const sr = Math.sqrt(sx*sx + sy*sy + sz*sz);
+ if (sr < 1e-6) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; }
+ const m = [sx/sr, sy/sr, sz/sr];
+ const d = w1[0]*m[0] + w1[1]*m[1] + w1[2]*m[2];
+ if (d < 0.05) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; }
+ const len = Math.min(hw / d, hw * MITER_CLAMP);
+ return [m[0]*len, m[1]*len, m[2]*len];
+ };
+
+ const pos = [];
+ for (let i = 0; i < ev.length; i++) {
+ const { a, b } = ev[i];
+ const oa = miter_offset(a, b, i);
+ const ob = miter_offset(b, a, i);
+ const ax = a.x*LIFT, ay = a.y*LIFT, az = a.z*LIFT;
+ const bx = b.x*LIFT, by = b.y*LIFT, bz = b.z*LIFT;
+ const term_a = oa[0] === 0 && oa[1] === 0 && oa[2] === 0;
+ const term_b = ob[0] === 0 && ob[1] === 0 && ob[2] === 0;
+
+ if (term_a && term_b) {
+ const mx = (a.x+b.x)/2, my = (a.y+b.y)/2, mz = (a.z+b.z)/2;
+ const mr = Math.sqrt(mx*mx+my*my+mz*mz);
+ const ex = b.x-a.x, ey = b.y-a.y, ez = b.z-a.z;
+ const er = Math.sqrt(ex*ex+ey*ey+ez*ez);
+ const wd = [
+ (my/mr)*(ez/er) - (mz/mr)*(ey/er),
+ (mz/mr)*(ex/er) - (mx/mr)*(ez/er),
+ (mx/mr)*(ey/er) - (my/mr)*(ex/er),
+ ];
+ pos.push(
+ ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw,
+ ax+wd[0]*hw, ay+wd[1]*hw, az+wd[2]*hw,
+ bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw,
+ ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw,
+ bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw,
+ bx-wd[0]*hw, by-wd[1]*hw, bz-wd[2]*hw,
+ );
+ continue;
+ }
+
+ const v0 = [ax-oa[0], ay-oa[1], az-oa[2]];
+ const v1 = [ax+oa[0], ay+oa[1], az+oa[2]];
+ const v2 = [bx-ob[0], by-ob[1], bz-ob[2]];
+ const v3 = [bx+ob[0], by+ob[1], bz+ob[2]];
+
+ if (term_a) {
+ pos.push(ax, ay, az, ...v2, ...v3);
+ } else if (term_b) {
+ pos.push(...v0, ...v1, bx, by, bz);
+ } else {
+ pos.push(...v0, ...v1, ...v2, ...v0, ...v2, ...v3);
+ }
+ }
+ return { wall_pos: new Float32Array(pos), wall_verts: pos.length / 3 };
+}
+
+// ---------------------------------------------------------------------------
+// Maze_State
+// ---------------------------------------------------------------------------
+
+export class Maze_State {
+ constructor() {
+ this.wall_edges = null;
+ this.pos_buf = null;
+ this.vert_count = 0;
+ }
+
+ generate(goldberg, p) {
+ if (p.algo === 'walker') {
+ this.wall_edges = generate_walker_walls(goldberg, p.walkers, p.stop_prob, p.branch_prob, p.close_prob);
+ } else {
+ this.wall_edges = generate_maze_walls(goldberg, p.seeds, p.max_cells);
+ }
+ }
+
+ upload(gl, goldberg, use_relaxed, wall_width) {
+ if (!this.wall_edges) { return; }
+ const geo = build_wall_geo(goldberg, use_relaxed, this.wall_edges, wall_width);
+ if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); }
+ this.pos_buf = make_buffer(gl, geo.wall_pos);
+ this.vert_count = geo.wall_verts;
+ }
+
+ invalidate(gl) {
+ this.wall_edges = null;
+ if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); this.pos_buf = null; }
+ this.vert_count = 0;
+ }
+
+ draw(gl, prog, mvp, show) {
+ if (!show || !this.pos_buf || this.vert_count === 0) { return; }
+ gl.useProgram(prog);
+ gl.uniformMatrix4fv(gl.getUniformLocation(prog, 'u_mvp'), false, mvp);
+ const ap = gl.getAttribLocation(prog, 'a_pos');
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.pos_buf);
+ gl.enableVertexAttribArray(ap);
+ gl.vertexAttribPointer(ap, 3, gl.FLOAT, false, 0, 0);
+ gl.disable(gl.CULL_FACE);
+ gl.drawArrays(gl.TRIANGLES, 0, this.vert_count);
+ gl.enable(gl.CULL_FACE);
+ gl.disableVertexAttribArray(ap);
+ }
+}
diff --git a/standalone/goldberg-sphere/paint_state.mjs b/standalone/goldberg-sphere/paint_state.mjs
new file mode 100644
index 0000000..cc86e45
--- /dev/null
+++ b/standalone/goldberg-sphere/paint_state.mjs
@@ -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, 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;
+ }
+}
diff --git a/standalone/goldberg-sphere/palettes.mjs b/standalone/goldberg-sphere/palettes.mjs
new file mode 100644
index 0000000..ebad4ed
--- /dev/null
+++ b/standalone/goldberg-sphere/palettes.mjs
@@ -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];
diff --git a/standalone/goldberg-sphere/placer_gnomonic_3d.mjs b/standalone/goldberg-sphere/placer_gnomonic_3d.mjs
new file mode 100644
index 0000000..489bc76
--- /dev/null
+++ b/standalone/goldberg-sphere/placer_gnomonic_3d.mjs
@@ -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
+
+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;
+}
diff --git a/standalone/goldberg-sphere/placer_ortho_3d.mjs b/standalone/goldberg-sphere/placer_ortho_3d.mjs
new file mode 100644
index 0000000..df42187
--- /dev/null
+++ b/standalone/goldberg-sphere/placer_ortho_3d.mjs
@@ -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
+
+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;
+}
diff --git a/standalone/goldberg-sphere/placer_relax.mjs b/standalone/goldberg-sphere/placer_relax.mjs
new file mode 100644
index 0000000..8a84568
--- /dev/null
+++ b/standalone/goldberg-sphere/placer_relax.mjs
@@ -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
+
+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;
+}
diff --git a/standalone/goldberg-sphere/placer_relax_loose.mjs b/standalone/goldberg-sphere/placer_relax_loose.mjs
new file mode 100644
index 0000000..63315cc
--- /dev/null
+++ b/standalone/goldberg-sphere/placer_relax_loose.mjs
@@ -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
+
+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;
+}
diff --git a/standalone/goldberg-sphere/placer_unfold.mjs b/standalone/goldberg-sphere/placer_unfold.mjs
new file mode 100644
index 0000000..fe18fcd
--- /dev/null
+++ b/standalone/goldberg-sphere/placer_unfold.mjs
@@ -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
+
+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;
+}
diff --git a/standalone/goldberg-sphere/playground_main.mjs b/standalone/goldberg-sphere/playground_main.mjs
new file mode 100644
index 0000000..cdd0ca9
--- /dev/null
+++ b/standalone/goldberg-sphere/playground_main.mjs
@@ -0,0 +1,1236 @@
+// Goldberg sphere build pipeline playground.
+
+import { Polyhedron, Goldberg_Polyhedron } from './topology.mjs';
+import { mat4_mul, mat4_perspective, mat4_translate_z, quat_identity, quat_from_axis_angle, quat_mul, quat_conj, quat_rotate_vec, mat4_from_quat, mat3_from_mat4 } from './math.mjs';
+import { create_program, make_buffer } from './gl_utils.mjs';
+import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE, Paint_State } from './paint_state.mjs';
+import { Spin_State } from './spin_state.mjs';
+import { shape_distortion, build_triangle_geo, build_goldberg_geo } from './render_geo.mjs';
+import { build_goldberg_adjacency, Maze_State } from './maze.mjs';
+import { Undo_State } from './undo_state.mjs';
+import { Env_State } from './env_state.mjs';
+import { PALETTES, DEFAULT_PALETTE } from './palettes.mjs';
+
+// ---------------------------------------------------------------------------
+// Load shaders
+// ---------------------------------------------------------------------------
+
+const [face_vert_src, face_frag_src, edge_vert_src, edge_frag_src, wall_vert_src, wall_frag_src] = await Promise.all([
+ fetch('./shaders/face.vert').then(r => r.text()),
+ fetch('./shaders/face.frag').then(r => r.text()),
+ fetch('./shaders/edge.vert').then(r => r.text()),
+ fetch('./shaders/edge.frag').then(r => r.text()),
+ fetch('./shaders/wall.vert').then(r => r.text()),
+ fetch('./shaders/wall.frag').then(r => r.text()),
+]);
+
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+
+const cache = { depth: -1, ico: null, poly: null, goldberg: null, adj: null };
+const maze = new Maze_State();
+const spin = new Spin_State();
+const paint = new Paint_State();
+const undo_state = new Undo_State();
+const env = new Env_State();
+let current_geo = null;
+let current_stage = 'relaxed';
+let current_palette = DEFAULT_PALETTE;
+let building = false;
+let active_brush_side = 'left';
+
+const DEBOUNCE_MS = 500;
+let paint_dirty = false;
+let debounce_timer = null;
+
+function get_params() {
+ return {
+ depth: parseInt(document.getElementById('depth').value, 10),
+ iters: parseInt(document.getElementById('iters').value, 10),
+ alpha_edge: parseFloat(document.getElementById('alpha-edge').value),
+ alpha_centroid: parseFloat(document.getElementById('alpha-centroid').value),
+ };
+}
+
+function get_wall_width() {
+ return parseFloat(document.getElementById('maze-width').value) || 0.015;
+}
+
+function get_maze_params() {
+ const algo = document.getElementById('maze-algo').value;
+ return {
+ algo,
+ seeds: parseInt(document.getElementById('maze-seeds').value, 10) || 12,
+ max_cells: parseInt(document.getElementById('maze-cells').value, 10) || 40,
+ walkers: parseInt(document.getElementById('maze-walkers').value, 10) || 20,
+ stop_prob: (parseInt(document.getElementById('maze-stop').value, 10) || 0) / 100,
+ branch_prob:(parseInt(document.getElementById('maze-branch').value, 10) || 0) / 100,
+ close_prob: (parseInt(document.getElementById('maze-close').value, 10) || 0) / 100,
+ };
+}
+
+function maze_applicable() {
+ return current_stage === 'goldberg' || current_stage === 'relaxed';
+}
+
+function update_maze() {
+ const show = document.getElementById('maze-show').checked;
+ if (!show || !maze_applicable() || !cache.goldberg) {
+ request_render();
+ return;
+ }
+ if (!maze.wall_edges) {
+ maze.generate(cache.goldberg, get_maze_params());
+ }
+ const use_relaxed = current_stage === 'relaxed';
+ maze.upload(gl, cache.goldberg, use_relaxed, get_wall_width());
+ request_render();
+}
+
+const status_el = document.getElementById('status');
+const stats_el = document.getElementById('stats');
+function set_status(s) { status_el.textContent = s; }
+function set_stats(geo, stage_name, ms) {
+ const s = geo.stats;
+ let lines = `Stage: ${stage_name}\n`;
+ lines += `Faces: ${s.faces}`;
+ if (s.vertices !== undefined) { lines += ` Verts: ${s.vertices}`; }
+ if (s.pentagons !== undefined) { lines += ` (${s.pentagons} pent)`; }
+ lines += '\n';
+ if (s.worst_shape !== null) {
+ lines += `Worst shape_r: ${s.worst_shape.toFixed(4)}\n`;
+ }
+ lines += `Build: ${ms}ms`;
+ stats_el.textContent = lines;
+}
+
+async function yield_ui(ms = 0) { await new Promise(r => setTimeout(r, ms)); }
+
+// ---------------------------------------------------------------------------
+// WebGL setup
+// ---------------------------------------------------------------------------
+
+const canvas = document.getElementById('gl-canvas');
+const gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl');
+if (!gl) { throw new Error('WebGL not available'); }
+
+gl.enable(gl.DEPTH_TEST);
+gl.enable(gl.CULL_FACE);
+gl.enable(gl.POLYGON_OFFSET_FILL);
+gl.polygonOffset(1, 1);
+
+const face_prog = create_program(gl, face_vert_src, face_frag_src);
+const edge_prog = create_program(gl, edge_vert_src, edge_frag_src);
+const wall_prog = create_program(gl, wall_vert_src, wall_frag_src);
+
+let face_pos_buf = null, face_col_buf = null, face_pbr_buf = null, face_noise_buf = null, edge_pos_buf = null;
+let face_verts = 0, edge_verts = 0;
+
+function upload_geo() {
+ if (!current_geo) { return; }
+ if (face_pos_buf) { gl.deleteBuffer(face_pos_buf); }
+ if (face_col_buf) { gl.deleteBuffer(face_col_buf); }
+ if (face_pbr_buf) { gl.deleteBuffer(face_pbr_buf); }
+ if (face_noise_buf) { gl.deleteBuffer(face_noise_buf); }
+ if (edge_pos_buf) { gl.deleteBuffer(edge_pos_buf); }
+ face_pos_buf = make_buffer(gl, current_geo.face_pos);
+ face_col_buf = make_buffer(gl, current_geo.face_col);
+ face_pbr_buf = make_buffer(gl, current_geo.face_pbr);
+ face_noise_buf = make_buffer(gl, current_geo.face_noise);
+ edge_pos_buf = make_buffer(gl, current_geo.edge_pos);
+ face_verts = current_geo.face_verts;
+ edge_verts = current_geo.edge_verts;
+}
+
+// Re-upload color, PBR, and noise buffers (positions unchanged) — used by paint mode.
+function rebuild_face_colors() {
+ if (!cache.goldberg || !face_col_buf) { return; }
+ const goldberg = cache.goldberg;
+ const use_relaxed = current_stage === 'relaxed';
+ const fcol = [], fpbr = [], fnoise = [];
+ 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);
+ 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 = current_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 (let j = 0; j < 3; j++) {
+ fcol.push(c[0], c[1], c[2]);
+ fpbr.push(pbr[0], pbr[1]);
+ fnoise.push(noise[0], noise[1], noise[2], noise[3]);
+ }
+ }
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_col_buf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fcol), gl.STATIC_DRAW);
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_pbr_buf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fpbr), gl.STATIC_DRAW);
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_noise_buf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fnoise), gl.STATIC_DRAW);
+ request_render();
+}
+
+// Sync all shared brush UI controls to the current active_brush_side.
+function sync_brush_ui() {
+ const side = active_brush_side;
+ const noise = paint[`noise_${side}`];
+ document.getElementById('paint-color-btn').style.background = paint.hex(side);
+ const met = paint[`metallic_${side}`];
+ const rou = paint[`roughness_${side}`];
+ document.getElementById('metallic').value = met;
+ document.getElementById('metallic-val').textContent = met.toFixed(2);
+ document.getElementById('roughness').value = rou;
+ document.getElementById('roughness-val').textContent = rou.toFixed(2);
+ document.getElementById('noise-scale').value = noise[0];
+ document.getElementById('noise-scale-val').textContent = noise[0].toFixed(1);
+ document.getElementById('noise-strength').value = noise[1];
+ document.getElementById('noise-strength-val').textContent = noise[1].toFixed(2);
+ document.getElementById('noise-octaves').value = noise[2];
+ document.getElementById('noise-octaves-val').textContent = String(noise[2]);
+ document.getElementById('noise-gain').value = noise[3];
+ document.getElementById('noise-gain-val').textContent = noise[3].toFixed(2);
+ // Tab highlight
+ const on = { background: '#0a2d4a', borderColor: '#4af', color: '#4af' };
+ const off = { background: '#0a1a2a', borderColor: '#2a4a6a', color: '#7ab' };
+ const apply = (el, s) => { el.style.background = s.background; el.style.borderColor = s.borderColor; el.style.color = s.color; };
+ apply(document.getElementById('brush-tab-left'), side === 'left' ? on : off);
+ apply(document.getElementById('brush-tab-right'), side === 'right' ? on : off);
+}
+
+// Take a snapshot immediately (bypasses debounce — use after build/load).
+function snapshot_now() {
+ if (!cache.goldberg) { return; }
+ paint_dirty = false;
+ if (debounce_timer !== null) { clearTimeout(debounce_timer); debounce_timer = null; }
+ undo_state.push(paint, cache.goldberg.faces.length, active_brush_side);
+}
+
+// Mark paint state dirty; schedule a debounced snapshot.
+function mark_dirty() {
+ paint_dirty = true;
+ if (debounce_timer !== null) { clearTimeout(debounce_timer); }
+ debounce_timer = setTimeout(() => {
+ debounce_timer = null;
+ if (paint_dirty) { snapshot_now(); }
+ }, DEBOUNCE_MS);
+}
+
+// Apply undo or redo; restore state and refresh UI.
+function apply_history(side) {
+ if (side === null) { return; }
+ active_brush_side = side;
+ sync_brush_ui();
+ update_paint_buttons();
+ rebuild_face_colors();
+}
+
+// ---------------------------------------------------------------------------
+// Build pipeline
+// ---------------------------------------------------------------------------
+
+async function build() {
+ if (building) { return; }
+ building = true;
+ const btn = document.getElementById('build-btn');
+ btn.disabled = true;
+
+ const p = get_params();
+ const t0 = Date.now();
+
+ try {
+ const depth_changed = cache.depth !== p.depth;
+
+ if (depth_changed || !cache.ico) {
+ set_status('Building icosahedron…'); await yield_ui(30);
+ cache.ico = Polyhedron.build_icosahedron();
+ cache.poly = null;
+ cache.goldberg = null;
+ }
+ if (current_stage === 'ico') {
+ current_geo = build_triangle_geo(cache.ico, current_palette);
+ upload_geo();
+ cache.depth = p.depth;
+ set_stats(current_geo, 'Icosahedron', Date.now() - t0);
+ set_status('');
+ return;
+ }
+
+ if (depth_changed || !cache.poly) {
+ let poly = cache.ico;
+ for (let i = 0; i < p.depth; i++) {
+ set_status(`Subdividing ${i + 1}/${p.depth}…`); await yield_ui(30);
+ poly = poly.subdivide();
+ }
+ cache.poly = poly;
+ cache.goldberg = null;
+ }
+ if (current_stage === 'subdiv') {
+ current_geo = build_triangle_geo(cache.poly, current_palette);
+ upload_geo();
+ cache.depth = p.depth;
+ set_stats(current_geo, `Subdivided ×${p.depth}`, Date.now() - t0);
+ set_status('');
+ return;
+ }
+
+ if (depth_changed || !cache.goldberg) {
+ set_status('Building Goldberg dual…'); await yield_ui(30);
+ cache.goldberg = Goldberg_Polyhedron.from_subdivided(cache.poly);
+ cache.adj = null;
+ maze.invalidate(gl);
+ undo_state.invalidate();
+ }
+ if (current_stage === 'goldberg') {
+ current_geo = build_goldberg_geo(cache.goldberg, false, paint, current_palette);
+ upload_geo();
+ cache.depth = p.depth;
+ set_stats(current_geo, 'Goldberg (no relax)', Date.now() - t0);
+ set_status('');
+ return;
+ }
+
+ set_status(`Relaxing (${p.iters} iters, edge=${p.alpha_edge}, centroid=${p.alpha_centroid})…`);
+ await yield_ui(50);
+ cache.goldberg.relax_sphere(p.iters, p.alpha_edge, p.alpha_centroid);
+ current_geo = build_goldberg_geo(cache.goldberg, true, paint, current_palette);
+ upload_geo();
+ cache.depth = p.depth;
+ set_stats(current_geo, 'Goldberg Relaxed', Date.now() - t0);
+ set_status('');
+
+ } finally {
+ building = false;
+ btn.disabled = false;
+ update_maze();
+ snapshot_now();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Face picking
+// ---------------------------------------------------------------------------
+
+function pick_face(px, py) {
+ if (!cache.goldberg) { return -1; }
+ const fov = Math.PI / 3;
+ const aspect = canvas.width / canvas.height;
+ const tan_half = Math.tan(fov / 2);
+ const nx = (2 * px / canvas.width) - 1;
+ const ny = 1 - (2 * py / canvas.height);
+
+ const vx = nx * tan_half * aspect, vy = ny * tan_half, vz = -1;
+ const vr = Math.sqrt(vx*vx + vy*vy + vz*vz);
+ const rd = quat_rotate_vec(quat_conj(rot_quat), [vx/vr, vy/vr, vz/vr]);
+ const ro = quat_rotate_vec(quat_conj(rot_quat), [0, 0, cam_dist]);
+
+ const a = rd[0]*rd[0] + rd[1]*rd[1] + rd[2]*rd[2];
+ const b = 2*(ro[0]*rd[0] + ro[1]*rd[1] + ro[2]*rd[2]);
+ const c = ro[0]*ro[0] + ro[1]*ro[1] + ro[2]*ro[2] - 1;
+ const d = b*b - 4*a*c;
+ if (d < 0) { return -1; }
+ const t = (-b - Math.sqrt(d)) / (2*a);
+ if (t < 0) { return -1; }
+ const hx = ro[0]+t*rd[0], hy = ro[1]+t*rd[1], hz = ro[2]+t*rd[2];
+
+ const goldberg = cache.goldberg;
+ const use_relaxed = current_stage === 'relaxed';
+ let best_fi = -1, best_dot = -Infinity;
+ 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;
+ let cx = 0, cy = 0, cz = 0;
+ for (const v of verts) { cx += v.x; cy += v.y; cz += v.z; }
+ const n = verts.length;
+ const dot = (cx/n)*hx + (cy/n)*hy + (cz/n)*hz;
+ if (dot > best_dot) { best_dot = dot; best_fi = fi; }
+ }
+ return best_fi;
+}
+
+// Flood-fill from start_fi, replacing all connected same-material faces.
+// Target material is identified by the pre-computed key stored at draw time.
+function flood_fill(start_fi, button) {
+ if (!cache.goldberg) { return; }
+ if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); }
+ const adj = cache.adj;
+ const target_key = paint.face_key.get(start_fi) ?? '';
+ const queue = [start_fi];
+ const visited = new Set(queue);
+ while (queue.length > 0) {
+ const fi = queue.shift();
+ paint.paint(fi, button);
+ for (const { fj } of adj[fi]) {
+ if (!visited.has(fj) && (paint.face_key.get(fj) ?? '') === target_key) {
+ visited.add(fj);
+ queue.push(fj);
+ }
+ }
+ }
+ rebuild_face_colors();
+ mark_dirty();
+}
+
+// BFS shortest path between two faces; returns array of face indices.
+function face_shortest_path(start_fi, end_fi) {
+ if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); }
+ if (start_fi === end_fi) { return [start_fi]; }
+ const adj = cache.adj;
+ const prev = new Map([[start_fi, -1]]);
+ const queue = [start_fi];
+ outer: while (queue.length > 0) {
+ const fi = queue.shift();
+ for (const { fj } of adj[fi]) {
+ if (!prev.has(fj)) {
+ prev.set(fj, fi);
+ if (fj === end_fi) { break outer; }
+ queue.push(fj);
+ }
+ }
+ }
+ if (!prev.has(end_fi)) { return [start_fi]; }
+ const path = [];
+ for (let cur = end_fi; cur !== -1; cur = prev.get(cur)) { path.push(cur); }
+ return path.reverse();
+}
+
+// BFS from center_fi up to `radius` hops; returns array of all face indices within radius.
+function faces_in_radius(center_fi, radius) {
+ if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); }
+ const adj = cache.adj;
+ const visited = new Set([center_fi]);
+ const queue = [{ fi: center_fi, dist: 0 }];
+ while (queue.length > 0) {
+ const { fi, dist } = queue.shift();
+ if (dist >= radius) { continue; }
+ for (const { fj } of adj[fi]) {
+ if (!visited.has(fj)) {
+ visited.add(fj);
+ queue.push({ fi: fj, dist: dist + 1 });
+ }
+ }
+ }
+ return [...visited];
+}
+
+// Return only faces at exactly `radius` BFS hops from center_fi (the ring).
+function faces_at_radius(center_fi, radius) {
+ if (radius === 0) { return [center_fi]; }
+ if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); }
+ const adj = cache.adj;
+ const dist = new Map([[center_fi, 0]]);
+ const queue = [center_fi];
+ while (queue.length > 0) {
+ const fi = queue.shift();
+ const d = dist.get(fi);
+ if (d >= radius) { continue; }
+ for (const { fj } of adj[fi]) {
+ if (!dist.has(fj)) {
+ dist.set(fj, d + 1);
+ queue.push(fj);
+ }
+ }
+ }
+ const result = [];
+ for (const [fi, d] of dist) { if (d === radius) { result.push(fi); } }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Paint save / load
+// ---------------------------------------------------------------------------
+
+function save_paint() {
+ const depth = parseInt(document.getElementById('depth').value);
+ const faces = [];
+ for (const [fi, c] of paint.face_colors) {
+ const h = v => Math.round(v*255).toString(16).padStart(2, '0');
+ const hex = `#${h(c[0])}${h(c[1])}${h(c[2])}`;
+ const pbr = paint.face_pbr.get(fi) ?? PAINT_BG_PBR;
+ faces.push([fi, hex, pbr[0], pbr[1]]);
+ }
+ const json = JSON.stringify({ version: 2, depth, faces }, null, 0);
+ const name = prompt('Save as:', `sphere-paint-d${depth}`);
+ if (!name) { return; }
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
+ a.download = name.endsWith('.json') ? name : `${name}.json`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+}
+
+function load_paint(file) {
+ const reader = new FileReader();
+ reader.onload = e => {
+ try {
+ const data = JSON.parse(e.target.result);
+ const depth = parseInt(document.getElementById('depth').value);
+ if (data.depth !== depth) {
+ document.getElementById('depth').value = data.depth;
+ document.getElementById('depth-val').textContent = data.depth;
+ cache.poly = null; cache.goldberg = null; cache.adj = null;
+ maze.invalidate(gl);
+ }
+ paint.face_colors.clear();
+ paint.face_pbr.clear();
+ if (data.version >= 2) {
+ for (const [fi, hex, met, rou] of data.faces) {
+ paint.face_colors.set(fi, paint._parse_hex(hex));
+ paint.face_pbr.set(fi, [met ?? 0.0, rou ?? 0.6]);
+ }
+ } else {
+ for (const [fi, hex] of data.colors) {
+ paint.face_colors.set(fi, paint._parse_hex(hex));
+ }
+ }
+ if (!paint.enabled) { paint.enabled = true; update_paint_buttons(); }
+ build();
+ } catch(err) {
+ console.error('Load failed:', err);
+ }
+ };
+ reader.readAsText(file);
+}
+
+// ---------------------------------------------------------------------------
+// Camera
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Noise presets (scale, strength, octaves, gain)
+// ---------------------------------------------------------------------------
+
+const NOISE_PRESETS = {
+ flat: [0, 0, 4, 0.5 ],
+ snow: [12, 0.6, 5, 0.5 ],
+ water: [5, 0.2, 3, 0.45],
+ dunes: [3, 1.2, 2, 0.6 ],
+ rock: [9, 0.75, 6, 0.5 ],
+};
+
+// ---------------------------------------------------------------------------
+// Camera
+// ---------------------------------------------------------------------------
+
+let rot_quat = quat_identity();
+let cam_dist = 3.0;
+let drag = null;
+
+canvas.addEventListener('contextmenu', e => e.preventDefault());
+
+canvas.addEventListener('mousedown', e => {
+ if (e.button === 1) {
+ e.preventDefault();
+ spin.stop();
+ document.getElementById('spin-btn').classList.remove('active');
+
+ const rect = canvas.getBoundingClientRect();
+ const px = e.clientX - rect.left, py = e.clientY - rect.top;
+ const fov = Math.PI / 3;
+ const aspect = canvas.width / canvas.height;
+ const tan_half = Math.tan(fov / 2);
+ const nx = (2*px/canvas.width)-1, ny = 1-(2*py/canvas.height);
+ const vx = nx*tan_half*aspect, vy = ny*tan_half, vz = -1;
+ const vr = Math.sqrt(vx*vx+vy*vy+vz*vz);
+ const rd = quat_rotate_vec(quat_conj(rot_quat), [vx/vr, vy/vr, vz/vr]);
+ const ro = quat_rotate_vec(quat_conj(rot_quat), [0, 0, cam_dist]);
+ const ra = rd[0]*rd[0]+rd[1]*rd[1]+rd[2]*rd[2];
+ const rb = 2*(ro[0]*rd[0]+ro[1]*rd[1]+ro[2]*rd[2]);
+ const rc = ro[0]*ro[0]+ro[1]*ro[1]+ro[2]*ro[2]-1;
+ const hits_sphere = (rb*rb - 4*ra*rc) >= 0;
+
+ if (hits_sphere) {
+ drag = { rotating: true, mode: 'trackball', x: e.clientX, y: e.clientY };
+ } else {
+ const cx = canvas.width/2, cy = canvas.height/2;
+ drag = { rotating: true, mode: 'roll', angle: Math.atan2(py-cy, px-cx) };
+ }
+ return;
+ }
+ if (paint.enabled && (e.button === 0 || e.button === 2)) {
+ const rect = canvas.getBoundingClientRect();
+ const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top);
+ if (fi >= 0) {
+ if (paint.tool === 'fill') {
+ flood_fill(fi, e.button);
+ } else if (paint.tool === 'pick') {
+ const side = active_brush_side;
+ const c = paint.face_colors.get(fi) ?? PAINT_BG;
+ const pbr = paint.face_pbr.get(fi) ?? PAINT_BG_PBR;
+ const noise = paint.face_noise.get(fi) ?? PAINT_BG_NOISE;
+ paint[`color_${side}`] = [...c];
+ paint[`metallic_${side}`] = pbr[0];
+ paint[`roughness_${side}`]= pbr[1];
+ paint[`noise_${side}`] = [...noise];
+ sync_brush_ui();
+ } else if (paint.tool === 'brush') {
+ const radius = parseInt(document.getElementById('brush-radius').value, 10);
+ const faces = faces_in_radius(fi, radius);
+ drag = { painting: true, button: e.button, last_fi: fi };
+ paint.clear_preview();
+ for (const fj of faces) { paint.paint(fj, e.button); }
+ rebuild_face_colors();
+ mark_dirty();
+ } else if (paint.tool === 'circle') {
+ drag = { painting: true, button: e.button, circle_center: fi };
+ paint.set_preview([fi], e.button);
+ rebuild_face_colors();
+ } else if (paint.tool === 'line') {
+ drag = { painting: true, button: e.button, line_start: fi };
+ paint.set_preview([fi], e.button);
+ rebuild_face_colors();
+ } else {
+ drag = { painting: true, button: e.button };
+ paint.paint(fi, e.button);
+ rebuild_face_colors();
+ mark_dirty();
+ }
+ } else if (paint.tool === 'pen' || paint.tool === 'brush') {
+ drag = { painting: true, button: e.button, last_fi: -1 };
+ }
+ }
+});
+
+canvas.addEventListener('mousemove', e => {
+ if (!drag) {
+ // Brush hover preview when not dragging.
+ if (paint.enabled && paint.tool === 'brush' && cache.goldberg) {
+ const rect = canvas.getBoundingClientRect();
+ const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top);
+ const radius = parseInt(document.getElementById('brush-radius').value, 10);
+ if (fi >= 0) {
+ paint.set_preview(faces_in_radius(fi, radius), 0);
+ } else {
+ paint.clear_preview();
+ }
+ rebuild_face_colors();
+ }
+ return;
+ }
+ if (drag.rotating) {
+ if (drag.mode === 'roll') {
+ const rect = canvas.getBoundingClientRect();
+ const px = e.clientX - rect.left, py = e.clientY - rect.top;
+ const cx = canvas.width/2, cy = canvas.height/2;
+ const angle = Math.atan2(py-cy, px-cx);
+ let delta = angle - drag.angle;
+ if (delta > Math.PI) { delta -= 2*Math.PI; }
+ if (delta < -Math.PI) { delta += 2*Math.PI; }
+ drag.angle = angle;
+ rot_quat = quat_mul(quat_from_axis_angle(0, 0, 1, -delta), rot_quat);
+ } else {
+ const sens = 2 * Math.tan(Math.PI / 6) * (cam_dist - 1) / canvas.height;
+ const dx = (e.clientX - drag.x) * sens;
+ const dy = (e.clientY - drag.y) * sens;
+ drag.x = e.clientX; drag.y = e.clientY;
+ rot_quat = quat_mul(quat_mul(quat_from_axis_angle(0, 1, 0, dx), quat_from_axis_angle(1, 0, 0, dy)), rot_quat);
+ }
+ request_render();
+ return;
+ }
+ if (drag.painting) {
+ const rect = canvas.getBoundingClientRect();
+ const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top);
+ if (drag.line_start !== undefined) {
+ if (fi >= 0) {
+ const path = face_shortest_path(drag.line_start, fi);
+ paint.set_preview(path, drag.button);
+ rebuild_face_colors();
+ }
+ } else if (paint.tool === 'brush') {
+ if (fi >= 0 && fi !== drag.last_fi) {
+ drag.last_fi = fi;
+ const radius = parseInt(document.getElementById('brush-radius').value, 10);
+ const faces = faces_in_radius(fi, radius);
+ for (const fj of faces) { paint.paint(fj, drag.button); }
+ rebuild_face_colors();
+ mark_dirty();
+ }
+ } else if (drag.circle_center !== undefined) {
+ if (fi >= 0) {
+ const path = face_shortest_path(drag.circle_center, fi);
+ const radius = path.length - 1;
+ paint.set_preview(faces_at_radius(drag.circle_center, radius), drag.button);
+ rebuild_face_colors();
+ }
+ } else if (fi >= 0) {
+ paint.paint(fi, drag.button);
+ rebuild_face_colors();
+ mark_dirty();
+ }
+ return;
+ }
+});
+
+canvas.addEventListener('mouseup', e => {
+ if (!drag) { return; }
+ if (e.button === 1 && drag.rotating) { drag = null; return; }
+ if (drag.painting) {
+ if (drag.line_start !== undefined || drag.circle_center !== undefined) {
+ paint.commit_preview();
+ rebuild_face_colors();
+ mark_dirty();
+ }
+ drag = null;
+ }
+});
+
+canvas.addEventListener('mouseleave', () => {
+ paint.clear_preview();
+ rebuild_face_colors();
+ drag = null;
+});
+
+canvas.addEventListener('wheel', e => {
+ cam_dist = Math.max(1.05, Math.min(10, cam_dist * (1 + e.deltaY * 0.001)));
+ e.preventDefault();
+ request_render();
+}, { passive: false });
+
+// ---------------------------------------------------------------------------
+// Resize / render
+// ---------------------------------------------------------------------------
+
+function resize() {
+ const wrap = document.getElementById('canvas-wrap');
+ canvas.width = wrap.clientWidth;
+ canvas.height = wrap.clientHeight;
+ gl.viewport(0, 0, canvas.width, canvas.height);
+ request_render();
+}
+window.addEventListener('resize', resize);
+
+let render_queued = false;
+function request_render() {
+ if (!render_queued) { render_queued = true; requestAnimationFrame(render_frame); }
+}
+
+function render_frame(ts) {
+ render_queued = false;
+
+ if (spin.active) {
+ const dt = spin.last_t !== null ? ts - spin.last_t : 0;
+ spin.last_t = ts;
+ rot_quat = spin.tick(dt, rot_quat);
+ render_queued = true;
+ requestAnimationFrame(render_frame);
+ }
+
+ gl.viewport(0, 0, canvas.width, canvas.height);
+ gl.clearColor(0.03, 0.04, 0.08, 1);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ if (!face_pos_buf) { return; }
+
+ const aspect = canvas.width / canvas.height;
+ const rot = mat4_from_quat(rot_quat);
+ const mvp = mat4_mul(mat4_perspective(Math.PI/3, aspect, 0.01, 100), mat4_mul(mat4_translate_z(-cam_dist), rot));
+ const norm = mat3_from_mat4(rot);
+ const cam_wpos = new Float32Array([0, 0, cam_dist]);
+
+ gl.useProgram(face_prog);
+ gl.uniformMatrix4fv(gl.getUniformLocation(face_prog, 'u_mvp'), false, mvp);
+ gl.uniformMatrix3fv(gl.getUniformLocation(face_prog, 'u_norm'), false, norm);
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_cam_pos'), cam_wpos);
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light1_dir'), env.light1_dir());
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light1_color'), env.light1_col());
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light2_dir'), env.light2_dir());
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light2_color'), env.light2_col());
+ gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_ambient'), env.ambient_col());
+ const ap = gl.getAttribLocation(face_prog, 'a_pos');
+ const ac = gl.getAttribLocation(face_prog, 'a_color');
+ const apb = gl.getAttribLocation(face_prog, 'a_pbr');
+ const an = gl.getAttribLocation(face_prog, 'a_noise');
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_pos_buf); gl.enableVertexAttribArray(ap); gl.vertexAttribPointer(ap, 3, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_col_buf); gl.enableVertexAttribArray(ac); gl.vertexAttribPointer(ac, 3, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_pbr_buf); gl.enableVertexAttribArray(apb); gl.vertexAttribPointer(apb, 2, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, face_noise_buf); gl.enableVertexAttribArray(an); gl.vertexAttribPointer(an, 4, gl.FLOAT, false, 0, 0);
+ gl.drawArrays(gl.TRIANGLES, 0, face_verts);
+ gl.disableVertexAttribArray(ap); gl.disableVertexAttribArray(ac); gl.disableVertexAttribArray(apb); gl.disableVertexAttribArray(an);
+
+ gl.disable(gl.POLYGON_OFFSET_FILL);
+ gl.useProgram(edge_prog);
+ gl.uniformMatrix4fv(gl.getUniformLocation(edge_prog, 'u_mvp'), false, mvp);
+ const ep = gl.getAttribLocation(edge_prog, 'a_pos');
+ gl.bindBuffer(gl.ARRAY_BUFFER, edge_pos_buf); gl.enableVertexAttribArray(ep); gl.vertexAttribPointer(ep, 3, gl.FLOAT, false, 0, 0);
+ gl.drawArrays(gl.LINES, 0, edge_verts);
+ gl.disableVertexAttribArray(ep);
+
+ const show_maze = document.getElementById('maze-show').checked && maze_applicable();
+ maze.draw(gl, wall_prog, mvp, show_maze);
+
+ gl.enable(gl.POLYGON_OFFSET_FILL);
+}
+
+// ---------------------------------------------------------------------------
+// UI wiring
+// ---------------------------------------------------------------------------
+
+// Palette buttons (built dynamically from PALETTES array).
+{
+ const grid = document.getElementById('palette-grid');
+ for (const p of PALETTES) {
+ const btn = document.createElement('button');
+ btn.className = 'sb' + (p === DEFAULT_PALETTE ? ' active' : '');
+ btn.textContent = p.name;
+ btn.addEventListener('click', () => {
+ current_palette = p;
+ grid.querySelectorAll('.sb').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ rebuild_face_colors();
+ // Triangle stages need a full rebuild since positions are shared.
+ if (current_stage === 'ico' || current_stage === 'subdiv') { build(); }
+ });
+ grid.appendChild(btn);
+ }
+}
+
+document.querySelectorAll('.sb').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.sb').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ current_stage = btn.dataset.stage;
+ build();
+ });
+});
+
+document.getElementById('depth').addEventListener('input', () => {
+ const d = parseInt(document.getElementById('depth').value);
+ document.getElementById('depth-val').textContent = d;
+ document.getElementById('iters').value = d * 8;
+ cache.poly = null;
+ cache.goldberg = null;
+ cache.adj = null;
+ maze.invalidate(gl);
+});
+
+document.getElementById('build-btn').addEventListener('click', () => {
+ if (current_stage === 'relaxed') {
+ cache.goldberg = null;
+ cache.adj = null;
+ }
+ build();
+});
+
+document.getElementById('maze-algo').addEventListener('change', e => {
+ const is_walker = e.target.value === 'walker';
+ document.getElementById('prim-params').style.display = is_walker ? 'none' : '';
+ document.getElementById('walker-params').style.display = is_walker ? '' : 'none';
+});
+
+document.getElementById('maze-show').addEventListener('change', () => { update_maze(); });
+document.getElementById('maze-width').addEventListener('input', () => { update_maze(); });
+document.getElementById('maze-btn').addEventListener('click', () => { maze.invalidate(gl); update_maze(); });
+
+document.getElementById('spin-btn').addEventListener('click', () => {
+ const btn = document.getElementById('spin-btn');
+ if (spin.active) {
+ spin.stop();
+ btn.classList.remove('active');
+ } else {
+ spin.start();
+ btn.classList.add('active');
+ request_render();
+ }
+});
+
+const update_paint_buttons = () => {
+ const enabled = paint.enabled;
+ document.getElementById('paint-btn').classList.toggle('active', enabled && paint.tool === 'pen');
+ document.getElementById('paint-brush-btn').classList.toggle('active', enabled && paint.tool === 'brush');
+ document.getElementById('paint-circle-btn').classList.toggle('active', enabled && paint.tool === 'circle');
+ document.getElementById('paint-fill-btn').classList.toggle('active', enabled && paint.tool === 'fill');
+ document.getElementById('paint-line-btn').classList.toggle('active', enabled && paint.tool === 'line');
+ document.getElementById('paint-pick-btn').classList.toggle('active', enabled && paint.tool === 'pick');
+ document.getElementById('brush-params').style.display = (enabled && paint.tool === 'brush') ? '' : 'none';
+ canvas.style.cursor = enabled ? (paint.tool === 'pick' ? 'cell' : 'crosshair') : 'grab';
+ if (!enabled) { paint.clear_preview(); rebuild_face_colors(); }
+};
+
+const make_tool_handler = tool => () => {
+ if (!paint.enabled || paint.tool !== tool) {
+ paint.enabled = true;
+ paint.tool = tool;
+ } else {
+ paint.enabled = false;
+ }
+ update_paint_buttons();
+};
+
+document.getElementById('paint-btn').addEventListener('click', make_tool_handler('pen'));
+document.getElementById('paint-brush-btn').addEventListener('click', make_tool_handler('brush'));
+document.getElementById('paint-circle-btn').addEventListener('click', make_tool_handler('circle'));
+document.getElementById('paint-fill-btn').addEventListener('click', make_tool_handler('fill'));
+document.getElementById('paint-line-btn').addEventListener('click', make_tool_handler('line'));
+document.getElementById('paint-pick-btn').addEventListener('click', make_tool_handler('pick'));
+
+// Brush tab switcher.
+document.getElementById('brush-tab-left').addEventListener('click', () => {
+ active_brush_side = 'left';
+ sync_brush_ui();
+});
+document.getElementById('brush-tab-right').addEventListener('click', () => {
+ active_brush_side = 'right';
+ sync_brush_ui();
+});
+
+// Color preview button opens the picker dialog.
+document.getElementById('paint-color-btn').addEventListener('click', () => color_dialog_open({
+ get: () => paint.hex(active_brush_side),
+ set: (hex) => set_brush_color(hex),
+}));
+
+// set_brush_color: apply hex to active brush and refresh the preview swatch.
+function set_brush_color(hex) {
+ if (active_brush_side === 'left') { paint.set_hex_left(hex); } else { paint.set_hex_right(hex); }
+ document.getElementById('paint-color-btn').style.background = hex;
+}
+document.getElementById('metallic').addEventListener('input', e => {
+ const val = parseFloat(e.target.value);
+ document.getElementById('metallic-val').textContent = val.toFixed(2);
+ paint[`metallic_${active_brush_side}`] = val;
+});
+document.getElementById('roughness').addEventListener('input', e => {
+ const val = parseFloat(e.target.value);
+ document.getElementById('roughness-val').textContent = val.toFixed(2);
+ paint[`roughness_${active_brush_side}`] = val;
+});
+
+// Brush radius display.
+document.getElementById('brush-radius').addEventListener('input', e => {
+ document.getElementById('brush-radius-val').textContent = e.target.value;
+});
+
+document.getElementById('paint-clear').addEventListener('click', () => {
+ paint.clear();
+ rebuild_face_colors();
+ mark_dirty();
+});
+
+document.getElementById('paint-save').addEventListener('click', () => { save_paint(); });
+
+// ---------------------------------------------------------------------------
+// Noise UI (shared sliders, apply to active_brush_side)
+// ---------------------------------------------------------------------------
+
+document.querySelectorAll('.noise-preset').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const preset = NOISE_PRESETS[btn.dataset.preset];
+ if (!preset) { return; }
+ paint[`noise_${active_brush_side}`] = [...preset];
+ sync_brush_ui();
+ });
+});
+
+const make_noise_slider = (idx, decimals) => e => {
+ const val = parseFloat(e.target.value);
+ paint[`noise_${active_brush_side}`][idx] = val;
+ e.target.nextElementSibling.textContent = decimals === 0 ? String(val) : val.toFixed(decimals);
+};
+document.getElementById('noise-scale').addEventListener('input', make_noise_slider(0, 1));
+document.getElementById('noise-strength').addEventListener('input', make_noise_slider(1, 2));
+document.getElementById('noise-octaves').addEventListener('input', make_noise_slider(2, 0));
+document.getElementById('noise-gain').addEventListener('input', make_noise_slider(3, 2));
+
+document.getElementById('paint-load').addEventListener('click', () => {
+ document.getElementById('paint-load-input').click();
+});
+document.getElementById('paint-load-input').addEventListener('change', e => {
+ if (e.target.files[0]) { load_paint(e.target.files[0]); e.target.value = ''; }
+});
+
+// Init all shared UI to match left brush defaults.
+sync_brush_ui();
+
+// ---------------------------------------------------------------------------
+// Color picker dialog
+// ---------------------------------------------------------------------------
+
+const COLOR_PALETTES = {
+ 'Earth': [
+ '#1b3a1f','#2d5a27','#3a7d44','#588157','#7fad6e','#a3b18a',
+ '#c8c49a','#dad7cd','#e8dfc8','#c9a96e','#b08040','#8b5e3c',
+ '#6b4226','#4a2c18','#2c4a6e','#3d7ab5','#7fb3d3','#b0d4e8',
+ ],
+ 'Vivid': [
+ '#e63946','#c1121f','#f4623a','#f77f00','#f9c74f','#fcca46',
+ '#c8e06a','#a1c181','#52b788','#2d9b6e','#619b8a','#2196f3',
+ '#1565c0','#6a4c9c','#9c27b0','#d81b60','#ff4081','#00bcd4',
+ ],
+ 'Pastel': [
+ '#ffadad','#ffb3c1','#ffc8dd','#ffd6a5','#fde4cf','#fdffb6',
+ '#e8f5a3','#caffbf','#b7e4c7','#9bf6ff','#caf0f8','#a0c4ff',
+ '#c0cfff','#bdb2ff','#d4b8f0','#ffc6ff','#ffd6f0','#fff0f3',
+ ],
+ 'Nordic': [
+ '#0d1117','#1a1f2e','#2e3440','#3b4252','#434c5e','#4c566a',
+ '#6e7f96','#8898b0','#d8dee9','#e5e9f0','#eceff4','#8fbcbb',
+ '#88c0d0','#81a1c1','#5e81ac','#bf616a','#d08770','#ebcb8b',
+ ],
+ 'Neon': [
+ '#ff0080','#ff1744','#ff4400','#ff6d00','#ffcc00','#c6ff00',
+ '#00ff41','#00e676','#00ffcc','#00eeff','#00b0ff','#0088ff',
+ '#2979ff','#7700ff','#aa00ff','#cc00ff','#ff00ff','#ff0099',
+ ],
+ 'Stone': [
+ '#0a0a0a','#1a1a1a','#2d2d2d','#3f3f3f','#555555','#6b6b6b',
+ '#808080','#999999','#b0b0b0','#c8c8c8','#dcdcdc','#f0f0f0',
+ '#8b7355','#a08060','#b8956a','#d4b896','#e8d5b4','#f5efe6',
+ ],
+};
+
+// Small sidebar swatches (quick pick, no dialog).
+{
+ const pal_names = Object.keys(COLOR_PALETTES);
+ const tab_bar = document.getElementById('sidebar-swatch-tabs');
+ const grid = document.getElementById('sidebar-swatch-grid');
+
+ function sidebar_show_palette(name) {
+ tab_bar.querySelectorAll('.swatch-tab').forEach(t => t.classList.toggle('active', t.dataset.pal === name));
+ grid.innerHTML = '';
+ for (const hex of COLOR_PALETTES[name]) {
+ const sw = document.createElement('div');
+ sw.className = 'swatch';
+ sw.style.background = hex;
+ sw.title = hex;
+ sw.addEventListener('click', () => set_brush_color(hex));
+ grid.appendChild(sw);
+ }
+ }
+ for (const name of pal_names) {
+ const tab = document.createElement('button');
+ tab.className = 'swatch-tab';
+ tab.dataset.pal = name;
+ tab.textContent = name;
+ tab.addEventListener('click', () => sidebar_show_palette(name));
+ tab_bar.appendChild(tab);
+ }
+ sidebar_show_palette(pal_names[0]);
+}
+
+// Color conversion utilities.
+function _hex_to_rgb(hex) {
+ return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)];
+}
+function _rgb_to_hex(r,g,b) {
+ return '#'+[r,g,b].map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0')).join('');
+}
+function _rgb_to_hsl(r,g,b) {
+ r/=255; g/=255; b/=255;
+ const mx=Math.max(r,g,b), mn=Math.min(r,g,b), l=(mx+mn)/2;
+ if (mx===mn) { return [0, 0, Math.round(l*100)]; }
+ const d=mx-mn, s=l>0.5?d/(2-mx-mn):d/(mx+mn);
+ let h = mx===r ? (g-b)/d+(ghex, set: (hex)=>void } — set by color_dialog_open.
+ let cd_target = null;
+
+ function cd_update_from_hex(hex, source) {
+ preview.style.background = hex;
+ const [r,g,b] = _hex_to_rgb(hex);
+ const [h,s,l] = _rgb_to_hsl(r,g,b);
+ if (source !== 'hsl') {
+ document.getElementById('cd-h').value = h; document.getElementById('cd-h-val').textContent = h;
+ document.getElementById('cd-s').value = s; document.getElementById('cd-s-val').textContent = s+'%';
+ document.getElementById('cd-l').value = l; document.getElementById('cd-l-val').textContent = l+'%';
+ }
+ if (source !== 'rgb') {
+ document.getElementById('cd-r').value = Math.round(r); document.getElementById('cd-r-val').textContent = Math.round(r);
+ document.getElementById('cd-g').value = Math.round(g); document.getElementById('cd-g-val').textContent = Math.round(g);
+ document.getElementById('cd-b').value = Math.round(b); document.getElementById('cd-b-val').textContent = Math.round(b);
+ }
+ if (source !== 'hex') { hex_input.value = hex; }
+ if (cd_target) { cd_target.set(hex); }
+ }
+
+ // Model tab switching.
+ dlg.querySelectorAll('.cd-model-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ active_model = tab.dataset.model;
+ dlg.querySelectorAll('.cd-model-tab').forEach(t => t.classList.toggle('active', t===tab));
+ Object.entries(panels).forEach(([k,el]) => { el.style.display = k===active_model ? '' : 'none'; });
+ });
+ });
+
+ // HSL sliders.
+ const hsl_update = () => {
+ const [r,g,b] = _hsl_to_rgb(+document.getElementById('cd-h').value, +document.getElementById('cd-s').value, +document.getElementById('cd-l').value);
+ document.getElementById('cd-h-val').textContent = document.getElementById('cd-h').value;
+ document.getElementById('cd-s-val').textContent = document.getElementById('cd-s').value+'%';
+ document.getElementById('cd-l-val').textContent = document.getElementById('cd-l').value+'%';
+ cd_update_from_hex(_rgb_to_hex(r,g,b), 'hsl');
+ };
+ ['cd-h','cd-s','cd-l'].forEach(id => document.getElementById(id).addEventListener('input', hsl_update));
+
+ // RGB sliders.
+ const rgb_update = () => {
+ const r=+document.getElementById('cd-r').value, g=+document.getElementById('cd-g').value, b=+document.getElementById('cd-b').value;
+ document.getElementById('cd-r-val').textContent = r;
+ document.getElementById('cd-g-val').textContent = g;
+ document.getElementById('cd-b-val').textContent = b;
+ cd_update_from_hex(_rgb_to_hex(r,g,b), 'rgb');
+ };
+ ['cd-r','cd-g','cd-b'].forEach(id => document.getElementById(id).addEventListener('input', rgb_update));
+
+ // Hex text input.
+ hex_input.addEventListener('input', () => {
+ const v = hex_input.value.trim();
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) { cd_update_from_hex(v, 'hex'); }
+ });
+
+ // Swatch palettes inside dialog.
+ const pal_names = Object.keys(COLOR_PALETTES);
+ const tab_bar = document.getElementById('cd-swatch-tabs');
+ const sw_grid = document.getElementById('cd-swatch-grid');
+
+ function cd_show_palette(name) {
+ tab_bar.querySelectorAll('.swatch-tab').forEach(t => t.classList.toggle('active', t.dataset.pal === name));
+ sw_grid.innerHTML = '';
+ for (const hex of COLOR_PALETTES[name]) {
+ const sw = document.createElement('div');
+ sw.className = 'swatch';
+ sw.style.background = hex;
+ sw.title = hex;
+ sw.addEventListener('click', () => cd_update_from_hex(hex, null));
+ sw_grid.appendChild(sw);
+ }
+ }
+ for (const name of pal_names) {
+ const tab = document.createElement('button');
+ tab.className = 'swatch-tab';
+ tab.dataset.pal = name;
+ tab.textContent = name;
+ tab.addEventListener('click', () => cd_show_palette(name));
+ tab_bar.appendChild(tab);
+ }
+ cd_show_palette(pal_names[0]);
+
+ document.getElementById('cd-close').addEventListener('click', () => dlg.close());
+ dlg.addEventListener('click', e => { if (e.target === dlg) { dlg.close(); } });
+
+ // Open dialog for any color target: { get: ()=>hex, set: (hex)=>void }.
+ window.color_dialog_open = (target) => {
+ cd_target = target;
+ cd_update_from_hex(target.get(), null);
+ dlg.showModal();
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Environment UI
+// ---------------------------------------------------------------------------
+
+{
+ const wire_light = (prefix, light_num) => {
+ const azim_el = document.getElementById(`${prefix}-azim`);
+ const elev_el = document.getElementById(`${prefix}-elev`);
+ const col_btn = document.getElementById(`${prefix}-color-btn`);
+ const int_el = document.getElementById(`${prefix}-intensity`);
+
+ azim_el.addEventListener('input', () => {
+ document.getElementById(`${prefix}-azim-val`).textContent = azim_el.value + '°';
+ env[`light${light_num}_azim`] = parseFloat(azim_el.value);
+ request_render();
+ });
+ elev_el.addEventListener('input', () => {
+ document.getElementById(`${prefix}-elev-val`).textContent = elev_el.value + '°';
+ env[`light${light_num}_elev`] = parseFloat(elev_el.value);
+ request_render();
+ });
+ col_btn.addEventListener('click', () => color_dialog_open({
+ get: () => env[`hex_light${light_num}`](),
+ set: (hex) => { env[`set_hex_light${light_num}`](hex); col_btn.style.background = hex; request_render(); },
+ }));
+ int_el.addEventListener('input', () => {
+ const v = parseFloat(int_el.value);
+ document.getElementById(`${prefix}-intensity-val`).textContent = v.toFixed(2);
+ env[`light${light_num}_intensity`] = v;
+ request_render();
+ });
+
+ col_btn.style.background = env[`hex_light${light_num}`]();
+ };
+
+ wire_light('l1', 1);
+ wire_light('l2', 2);
+
+ const amb_btn = document.getElementById('amb-color-btn');
+ const amb_int = document.getElementById('amb-intensity');
+ amb_btn.addEventListener('click', () => color_dialog_open({
+ get: () => env.hex_ambient(),
+ set: (hex) => { env.set_hex_ambient(hex); amb_btn.style.background = hex; request_render(); },
+ }));
+ amb_int.addEventListener('input', () => {
+ const v = parseFloat(amb_int.value);
+ document.getElementById('amb-intensity-val').textContent = v.toFixed(2);
+ env.ambient_intensity = v;
+ request_render();
+ });
+
+ // Initialise all env UI from Env_State so HTML values are never the source of truth.
+ function sync_env_ui() {
+ for (const [prefix, n] of [['l1', 1], ['l2', 2]]) {
+ document.getElementById(`${prefix}-azim`).value = env[`light${n}_azim`];
+ document.getElementById(`${prefix}-azim-val`).textContent = env[`light${n}_azim`] + '°';
+ document.getElementById(`${prefix}-elev`).value = env[`light${n}_elev`];
+ document.getElementById(`${prefix}-elev-val`).textContent = env[`light${n}_elev`] + '°';
+ document.getElementById(`${prefix}-intensity`).value = env[`light${n}_intensity`];
+ document.getElementById(`${prefix}-intensity-val`).textContent = env[`light${n}_intensity`].toFixed(2);
+ document.getElementById(`${prefix}-color-btn`).style.background = env[`hex_light${n}`]();
+ }
+ amb_int.value = env.ambient_intensity;
+ document.getElementById('amb-intensity-val').textContent = env.ambient_intensity.toFixed(2);
+ amb_btn.style.background = env.hex_ambient();
+ }
+ sync_env_ui();
+}
+
+// ---------------------------------------------------------------------------
+// Keyboard shortcuts
+// ---------------------------------------------------------------------------
+
+window.addEventListener('keydown', e => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ apply_history(undo_state.undo(paint));
+ }
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
+ e.preventDefault();
+ apply_history(undo_state.redo(paint));
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Boot
+// ---------------------------------------------------------------------------
+
+resize();
+build();
diff --git a/standalone/goldberg-sphere/render_geo.mjs b/standalone/goldberg-sphere/render_geo.mjs
new file mode 100644
index 0000000..14a4ed9
--- /dev/null
+++ b/standalone/goldberg-sphere/render_geo.mjs
@@ -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 (lmx) { 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 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; if.is_pentagon).length,
+ worst_shape,
+ },
+ };
+}
diff --git a/standalone/goldberg-sphere/renderer.mjs b/standalone/goldberg-sphere/renderer.mjs
new file mode 100644
index 0000000..defeedb
--- /dev/null
+++ b/standalone/goldberg-sphere/renderer.mjs
@@ -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);
+ }
+ }
+}
diff --git a/standalone/goldberg-sphere/shaders/edge.frag b/standalone/goldberg-sphere/shaders/edge.frag
new file mode 100644
index 0000000..c9a8140
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/edge.frag
@@ -0,0 +1,2 @@
+precision mediump float;
+void main() { gl_FragColor = vec4(0.05, 0.05, 0.05, 1.0); }
diff --git a/standalone/goldberg-sphere/shaders/edge.vert b/standalone/goldberg-sphere/shaders/edge.vert
new file mode 100644
index 0000000..cf3741d
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/edge.vert
@@ -0,0 +1,3 @@
+attribute vec3 a_pos;
+uniform mat4 u_mvp;
+void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }
diff --git a/standalone/goldberg-sphere/shaders/face.frag b/standalone/goldberg-sphere/shaders/face.frag
new file mode 100644
index 0000000..90a691a
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/face.frag
@@ -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);
+}
diff --git a/standalone/goldberg-sphere/shaders/face.vert b/standalone/goldberg-sphere/shaders/face.vert
new file mode 100644
index 0000000..4986d6e
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/face.vert
@@ -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;
+}
diff --git a/standalone/goldberg-sphere/shaders/wall.frag b/standalone/goldberg-sphere/shaders/wall.frag
new file mode 100644
index 0000000..f943577
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/wall.frag
@@ -0,0 +1,2 @@
+precision mediump float;
+void main() { gl_FragColor = vec4(1.0, 0.58, 0.08, 1.0); }
diff --git a/standalone/goldberg-sphere/shaders/wall.vert b/standalone/goldberg-sphere/shaders/wall.vert
new file mode 100644
index 0000000..cf3741d
--- /dev/null
+++ b/standalone/goldberg-sphere/shaders/wall.vert
@@ -0,0 +1,3 @@
+attribute vec3 a_pos;
+uniform mat4 u_mvp;
+void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }
diff --git a/standalone/goldberg-sphere/spin_state.mjs b/standalone/goldberg-sphere/spin_state.mjs
new file mode 100644
index 0000000..7c7c200
--- /dev/null
+++ b/standalone/goldberg-sphere/spin_state.mjs
@@ -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; }
+}
diff --git a/standalone/goldberg-sphere/topology.mjs b/standalone/goldberg-sphere/topology.mjs
new file mode 100644
index 0000000..52e41c7
--- /dev/null
+++ b/standalone/goldberg-sphere/topology.mjs
@@ -0,0 +1,719 @@
+// 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;
+}
diff --git a/standalone/goldberg-sphere/undo_state.mjs b/standalone/goldberg-sphere/undo_state.mjs
new file mode 100644
index 0000000..d730513
--- /dev/null
+++ b/standalone/goldberg-sphere/undo_state.mjs
@@ -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; }
+}