Remove maze feature; move adjacency into topology

Maze overlay and wall shaders removed per review feedback.
build_goldberg_adjacency moved from maze.mjs into topology.mjs
where it belongs — it is pure face-graph topology used by paint tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:06:05 +00:00
parent 9600a2bc2a
commit e6bbfb2dbe
6 changed files with 33 additions and 444 deletions

View File

@@ -132,54 +132,6 @@
</div>
</div>
<div>
<h3>Maze</h3>
<div class="param" style="margin-top:6px">
<label>Show</label>
<input type="checkbox" id="maze-show">
</div>
<div class="param">
<label>Wall width</label>
<input type="number" id="maze-width" value="0.015" min="0.003" max="0.08" step="0.003">
</div>
<div class="param">
<label>Algorithm</label>
<select id="maze-algo">
<option value="prim">Islands (Prim)</option>
<option value="walker">Edge walker</option>
</select>
</div>
<div id="prim-params">
<div class="param">
<label>Seeds</label>
<input type="number" id="maze-seeds" value="12" min="1" max="200" step="1">
</div>
<div class="param">
<label>Max cells</label>
<input type="number" id="maze-cells" value="40" min="3" max="500" step="5">
</div>
</div>
<div id="walker-params" style="display:none">
<div class="param">
<label>Walkers</label>
<input type="number" id="maze-walkers" value="20" min="1" max="500" step="1">
</div>
<div class="param">
<label>Stop %</label>
<input type="number" id="maze-stop" value="5" min="0" max="100" step="1">
</div>
<div class="param">
<label>Branch %</label>
<input type="number" id="maze-branch" value="15" min="0" max="100" step="1">
</div>
<div class="param">
<label>Close %</label>
<input type="number" id="maze-close" value="0" min="0" max="100" step="1">
</div>
</div>
<button id="maze-btn" style="margin-top:4px; background:#0a1f0a; border:1px solid #2a5a2a; color:#4c8; padding:5px; cursor:pointer; font-family:monospace; font-size:12px; width:100%">⟳ New maze</button>
</div>
<div>
<h3>Paint</h3>
<div style="display:flex; gap:4px; margin-top:6px">

View File

@@ -1,336 +0,0 @@
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);
}
}

View File

@@ -6,7 +6,7 @@ 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 { build_goldberg_adjacency } from './topology.mjs';
import { Undo_State } from './undo_state.mjs';
import { Env_State } from './env_state.mjs';
import { PALETTES, DEFAULT_PALETTE } from './palettes.mjs';
@@ -15,13 +15,11 @@ 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([
const [face_vert_src, face_frag_src, edge_vert_src, edge_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()),
]);
// ---------------------------------------------------------------------------
@@ -29,7 +27,6 @@ const [face_vert_src, face_frag_src, edge_vert_src, edge_frag_src, wall_vert_src
// ---------------------------------------------------------------------------
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();
@@ -53,40 +50,6 @@ function get_params() {
};
}
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');
@@ -122,7 +85,6 @@ 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;
@@ -289,7 +251,6 @@ async function build() {
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') {
@@ -313,7 +274,6 @@ async function build() {
} finally {
building = false;
btn.disabled = false;
update_maze();
snapshot_now();
}
}
@@ -480,7 +440,6 @@ function load_paint(file) {
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();
@@ -766,8 +725,6 @@ function render_frame(ts) {
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);
}
@@ -811,7 +768,6 @@ document.getElementById('depth').addEventListener('input', () => {
cache.poly = null;
cache.goldberg = null;
cache.adj = null;
maze.invalidate(gl);
});
document.getElementById('build-btn').addEventListener('click', () => {
@@ -822,15 +778,6 @@ document.getElementById('build-btn').addEventListener('click', () => {
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');

View File

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

View File

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

View File

@@ -717,3 +717,34 @@ function find_shared_outer_vertex(face_a, face_b, exclude_vi) {
}
return null;
}
// ---------------------------------------------------------------------------
// Face adjacency (shared-edge graph)
// ---------------------------------------------------------------------------
// Build adjacency list for Goldberg faces via shared edges.
// Uses unrelaxed vertex positions as canonical keys — topology is stable across stages.
export function build_goldberg_adjacency(goldberg) {
const edge_to_entries = new Map();
for (let fi = 0; fi < goldberg.faces.length; fi++) {
const verts = goldberg.faces[fi].vertices_3d;
const n = verts.length;
for (let i = 0; i < n; i++) {
const a = verts[i], b = verts[(i + 1) % n];
const ka = `${a.x.toFixed(5)},${a.y.toFixed(5)},${a.z.toFixed(5)}`;
const kb = `${b.x.toFixed(5)},${b.y.toFixed(5)},${b.z.toFixed(5)}`;
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
if (!edge_to_entries.has(key)) { edge_to_entries.set(key, []); }
edge_to_entries.get(key).push({ fi, edge_idx: i });
}
}
const adj = Array.from({ length: goldberg.faces.length }, () => []);
for (const entries of edge_to_entries.values()) {
if (entries.length === 2) {
const [e0, e1] = entries;
adj[e0.fi].push({ fj: e1.fi, fi_edge_idx: e0.edge_idx });
adj[e1.fi].push({ fj: e0.fi, fi_edge_idx: e1.edge_idx });
}
}
return adj;
}