// 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();