Files
websperiments/standalone/goldberg-sphere/playground_main.mjs
mikael-lovqvists-claude-agent 7c60252904 Add grid color and opacity controls
Edge shader now takes u_edge_color and u_edge_opacity uniforms.
Grid section added to sidebar with a color picker and opacity slider.
Blending enabled around edge draw call to support sub-1 opacity.

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

1141 lines
43 KiB
JavaScript

// 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_goldberg_geo } from './render_geo.mjs';
import { build_goldberg_adjacency } from './topology.mjs';
import { Undo_State } from './undo_state.mjs';
import { Env_State } from './env_state.mjs';
import { DEFAULT_PALETTE } from './palettes.mjs';
// ---------------------------------------------------------------------------
// Load shaders
// ---------------------------------------------------------------------------
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()),
]);
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const cache = { depth: -1, ico: null, poly: null, goldberg: null, adj: null };
const spin = new Spin_State();
const paint = new Paint_State();
const undo_state = new Undo_State();
const env = new Env_State();
const grid_state = { color: [0.05, 0.05, 0.05], opacity: 1.0 };
let current_geo = null;
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: 5, iters: 500, alpha_edge: 0, alpha_centroid: 0.04 };
}
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';
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);
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 = true;
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 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 (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 (depth_changed || !cache.goldberg) {
set_status('Building Goldberg dual…'); await yield_ui(30);
cache.goldberg = Goldberg_Polyhedron.from_subdivided(cache.poly);
cache.adj = null;
undo_state.invalidate();
}
set_status(`Relaxing (${p.iters} iters)…`);
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;
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 = true;
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 = 5;
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);
if (data.depth !== 5) {
cache.poly = null; cache.goldberg = null; cache.adj = null;
}
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);
gl.uniform3fv(gl.getUniformLocation(edge_prog, 'u_edge_color'), grid_state.color);
gl.uniform1f(gl.getUniformLocation(edge_prog, 'u_edge_opacity'), grid_state.opacity);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
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);
gl.disable(gl.BLEND);
gl.enable(gl.POLYGON_OFFSET_FILL);
}
// ---------------------------------------------------------------------------
// UI wiring
// ---------------------------------------------------------------------------
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 => () => {
paint.enabled = true;
paint.tool = tool;
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();
}
// ---------------------------------------------------------------------------
// Grid UI wiring
// ---------------------------------------------------------------------------
{
const btn = document.getElementById('grid-color-btn');
const opacity = document.getElementById('grid-opacity');
const opacity_val = document.getElementById('grid-opacity-val');
const hex_from = c => {
const h = v => Math.round(Math.min(v, 1) * 255).toString(16).padStart(2, '0');
return `#${h(c[0])}${h(c[1])}${h(c[2])}`;
};
btn.style.background = hex_from(grid_state.color);
btn.addEventListener('click', () => {
color_dialog_open({
get: () => hex_from(grid_state.color),
set: hex => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
grid_state.color = [r, g, b];
btn.style.background = hex;
request_render();
},
});
});
opacity.addEventListener('input', () => {
grid_state.opacity = parseFloat(opacity.value);
opacity_val.textContent = grid_state.opacity.toFixed(2);
request_render();
});
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
paint.enabled = true;
update_paint_buttons();
resize();
build();