forked from mikael-lovqvist/websperiments
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>
337 lines
11 KiB
JavaScript
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);
|
|
}
|
|
}
|