Files
websperiments/standalone/goldberg-sphere/maze.mjs
mikael-lovqvists-claude-agent 9600a2bc2a Add Goldberg Polyhedron Paint experiment
Interactive WebGL Goldberg polyhedron viewer and painter with PBR
shading, adjustable environment lighting, paint tools (pen, brush,
circle, fill, line, pick), undo/redo, colour palettes, and mesh
relaxation. Added to the standalone experiments index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:00:19 +00:00

337 lines
11 KiB
JavaScript

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);
}
}