Files
websperiments/standalone/goldberg-sphere/playground_main.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

1237 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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+(g<b?6:0) : mx===g ? (b-r)/d+2 : (r-g)/d+4;
return [Math.round(h/6*360), Math.round(s*100), Math.round(l*100)];
}
function _hsl_to_rgb(h,s,l) {
s/=100; l/=100;
const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2;
let r,g,b;
if (h<60) { r=c;g=x;b=0; } else if (h<120) { r=x;g=c;b=0; }
else if (h<180) { r=0;g=c;b=x; } else if (h<240) { r=0;g=x;b=c; }
else if (h<300) { r=x;g=0;b=c; } else { r=c;g=0;b=x; }
return [(r+m)*255, (g+m)*255, (b+m)*255];
}
{
const dlg = document.getElementById('color-dialog');
const preview = document.getElementById('cd-preview');
const hex_input = document.getElementById('cd-hex');
const panels = { hsl: document.getElementById('cd-panel-hsl'), rgb: document.getElementById('cd-panel-rgb'), hex: document.getElementById('cd-panel-hex') };
let active_model = 'hsl';
// cd_target = { get: ()=>hex, 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();