Maze overlay and wall shaders removed per review feedback. build_goldberg_adjacency moved from maze.mjs into topology.mjs where it belongs — it is pure face-graph topology used by paint tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1184 lines
45 KiB
JavaScript
1184 lines
45 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_triangle_geo, 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 { PALETTES, 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();
|
||
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),
|
||
};
|
||
}
|
||
|
||
|
||
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);
|
||
|
||
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;
|
||
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;
|
||
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;
|
||
}
|
||
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);
|
||
|
||
|
||
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;
|
||
});
|
||
|
||
document.getElementById('build-btn').addEventListener('click', () => {
|
||
if (current_stage === 'relaxed') {
|
||
cache.goldberg = null;
|
||
cache.adj = null;
|
||
}
|
||
build();
|
||
});
|
||
|
||
|
||
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();
|