Files
websperiments/standalone/goldberg-sphere/undo_state.mjs
mikael-lovqvists-claude-agent 9600a2bc2a Add Goldberg Polyhedron Paint experiment
Interactive WebGL Goldberg polyhedron viewer and painter with PBR
shading, adjustable environment lighting, paint tools (pen, brush,
circle, fill, line, pick), undo/redo, colour palettes, and mesh
relaxation. Added to the standalone experiments index.

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

147 lines
4.9 KiB
JavaScript

import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE } from './paint_state.mjs';
const MAX_HISTORY = 64;
// Ensure key→value is in map/palette; return index.
function _intern(map, palette, key, val) {
if (!map.has(key)) { map.set(key, palette.length); palette.push(val); }
return map.get(key);
}
// Brush/tool settings at snapshot time.
class Tool_Snapshot {
constructor(active_brush_side, paint) {
this.active_brush_side = active_brush_side;
this.enabled = paint.enabled;
this.tool = paint.tool;
this.color_left = [...paint.color_left];
this.color_right = [...paint.color_right];
this.metallic_left = paint.metallic_left;
this.metallic_right = paint.metallic_right;
this.roughness_left = paint.roughness_left;
this.roughness_right = paint.roughness_right;
this.noise_left = [...paint.noise_left];
this.noise_right = [...paint.noise_right];
}
}
// Compressed face-paint snapshot.
// face_mat[fi] = 0 means default (unpainted); >0 = index into mat_palette.
// mat_palette entries = [color_idx, pbr_idx, noise_idx].
class Paint_Snapshot {
constructor(paint, face_count, tool) {
this.tool = tool;
const color_map = new Map();
const pbr_map = new Map();
const noise_map = new Map();
const mat_map = new Map();
this.color_palette = [];
this.pbr_palette = [];
this.noise_palette = [];
this.mat_palette = [];
// Index 0 reserved for default background material.
color_map.set(PAINT_BG.join(','), 0); this.color_palette.push([...PAINT_BG]);
pbr_map.set(PAINT_BG_PBR.join(','), 0); this.pbr_palette.push([...PAINT_BG_PBR]);
noise_map.set(PAINT_BG_NOISE.join(','), 0); this.noise_palette.push([...PAINT_BG_NOISE]);
mat_map.set('0|0|0', 0); this.mat_palette.push([0, 0, 0]);
this.face_mat = new Uint32Array(face_count); // default 0
for (let fi = 0; fi < face_count; fi++) {
const c = paint.face_colors.get(fi);
const pbr = paint.face_pbr.get(fi);
const noise = paint.face_noise.get(fi);
if (c === undefined && pbr === undefined && noise === undefined) { continue; }
const cv = c ?? PAINT_BG;
const pv = pbr ?? PAINT_BG_PBR;
const nv = noise ?? PAINT_BG_NOISE;
const c_idx = _intern(color_map, this.color_palette, cv.join(','), [...cv]);
const pbr_idx = _intern(pbr_map, this.pbr_palette, pv.join(','), [...pv]);
const noi_idx = _intern(noise_map, this.noise_palette, nv.join(','), [...nv]);
const mat_key = `${c_idx}|${pbr_idx}|${noi_idx}`;
const mat_idx = _intern(mat_map, this.mat_palette, mat_key, [c_idx, pbr_idx, noi_idx]);
// If the material happens to hash to default (all three indices = 0), leave face_mat = 0.
if (mat_idx !== 0) { this.face_mat[fi] = mat_idx; }
}
}
// Restore paint Maps from this snapshot. Returns the saved active_brush_side.
restore(paint) {
paint.face_colors.clear();
paint.face_pbr.clear();
paint.face_noise.clear();
paint.face_key.clear();
for (let fi = 0; fi < this.face_mat.length; fi++) {
const mat_idx = this.face_mat[fi];
if (mat_idx === 0) { continue; }
const [c_idx, pbr_idx, noi_idx] = this.mat_palette[mat_idx];
const c = this.color_palette[c_idx];
const pbr = this.pbr_palette[pbr_idx];
const noise = this.noise_palette[noi_idx];
paint.face_colors.set(fi, [...c]);
paint.face_pbr.set(fi, [...pbr]);
paint.face_noise.set(fi, [...noise]);
paint.face_key.set(fi, paint._make_key(c, pbr, noise));
}
const t = this.tool;
paint.enabled = t.enabled;
paint.tool = t.tool;
paint.color_left = [...t.color_left];
paint.color_right = [...t.color_right];
paint.metallic_left = t.metallic_left;
paint.metallic_right = t.metallic_right;
paint.roughness_left = t.roughness_left;
paint.roughness_right = t.roughness_right;
paint.noise_left = [...t.noise_left];
paint.noise_right = [...t.noise_right];
return t.active_brush_side;
}
byte_size() { return this.face_mat.byteLength; }
}
export class Undo_State {
constructor() {
this.history = [];
this.pos = -1;
}
// Push a new snapshot; truncates redo history.
push(paint, face_count, active_brush_side) {
this.history.splice(this.pos + 1);
this.history.push(new Paint_Snapshot(paint, face_count, new Tool_Snapshot(active_brush_side, paint)));
if (this.history.length > MAX_HISTORY) { this.history.shift(); } else { this.pos++; }
}
can_undo() { return this.pos > 0; }
can_redo() { return this.pos < this.history.length - 1; }
// Restore previous snapshot; returns active_brush_side or null if nothing to undo.
undo(paint) {
if (!this.can_undo()) { return null; }
this.pos--;
return this.history[this.pos].restore(paint);
}
// Restore next snapshot; returns active_brush_side or null if nothing to redo.
redo(paint) {
if (!this.can_redo()) { return null; }
this.pos++;
return this.history[this.pos].restore(paint);
}
// Drop all history (e.g. when mesh topology changes).
invalidate() { this.history = []; this.pos = -1; }
}