From 9600a2bc2aa47454f859ef2dbcb1350aa3304ff8 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 10 May 2026 16:00:19 +0000 Subject: [PATCH 1/2] 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 --- index.html | 1 + standalone/goldberg-sphere/env_state.mjs | 52 + standalone/goldberg-sphere/geometry.mjs | 98 ++ standalone/goldberg-sphere/gl_utils.mjs | 23 + standalone/goldberg-sphere/index.html | 346 +++++ standalone/goldberg-sphere/math.mjs | 56 + standalone/goldberg-sphere/maze.mjs | 336 +++++ standalone/goldberg-sphere/paint_state.mjs | 88 ++ standalone/goldberg-sphere/palettes.mjs | 80 ++ .../goldberg-sphere/placer_gnomonic_3d.mjs | 77 + .../goldberg-sphere/placer_ortho_3d.mjs | 65 + standalone/goldberg-sphere/placer_relax.mjs | 236 ++++ .../goldberg-sphere/placer_relax_loose.mjs | 97 ++ standalone/goldberg-sphere/placer_unfold.mjs | 68 + .../goldberg-sphere/playground_main.mjs | 1236 +++++++++++++++++ standalone/goldberg-sphere/render_geo.mjs | 117 ++ standalone/goldberg-sphere/renderer.mjs | 178 +++ standalone/goldberg-sphere/shaders/edge.frag | 2 + standalone/goldberg-sphere/shaders/edge.vert | 3 + standalone/goldberg-sphere/shaders/face.frag | 112 ++ standalone/goldberg-sphere/shaders/face.vert | 21 + standalone/goldberg-sphere/shaders/wall.frag | 2 + standalone/goldberg-sphere/shaders/wall.vert | 3 + standalone/goldberg-sphere/spin_state.mjs | 45 + standalone/goldberg-sphere/topology.mjs | 719 ++++++++++ standalone/goldberg-sphere/undo_state.mjs | 146 ++ 26 files changed, 4207 insertions(+) create mode 100644 standalone/goldberg-sphere/env_state.mjs create mode 100644 standalone/goldberg-sphere/geometry.mjs create mode 100644 standalone/goldberg-sphere/gl_utils.mjs create mode 100644 standalone/goldberg-sphere/index.html create mode 100644 standalone/goldberg-sphere/math.mjs create mode 100644 standalone/goldberg-sphere/maze.mjs create mode 100644 standalone/goldberg-sphere/paint_state.mjs create mode 100644 standalone/goldberg-sphere/palettes.mjs create mode 100644 standalone/goldberg-sphere/placer_gnomonic_3d.mjs create mode 100644 standalone/goldberg-sphere/placer_ortho_3d.mjs create mode 100644 standalone/goldberg-sphere/placer_relax.mjs create mode 100644 standalone/goldberg-sphere/placer_relax_loose.mjs create mode 100644 standalone/goldberg-sphere/placer_unfold.mjs create mode 100644 standalone/goldberg-sphere/playground_main.mjs create mode 100644 standalone/goldberg-sphere/render_geo.mjs create mode 100644 standalone/goldberg-sphere/renderer.mjs create mode 100644 standalone/goldberg-sphere/shaders/edge.frag create mode 100644 standalone/goldberg-sphere/shaders/edge.vert create mode 100644 standalone/goldberg-sphere/shaders/face.frag create mode 100644 standalone/goldberg-sphere/shaders/face.vert create mode 100644 standalone/goldberg-sphere/shaders/wall.frag create mode 100644 standalone/goldberg-sphere/shaders/wall.vert create mode 100644 standalone/goldberg-sphere/spin_state.mjs create mode 100644 standalone/goldberg-sphere/topology.mjs create mode 100644 standalone/goldberg-sphere/undo_state.mjs diff --git a/index.html b/index.html index 7d6d915..c2ad4db 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,7 @@
  • Delaunay dataset
  • Delaunay dataset + Edge relaxation (Currently broken)
  • Reverb configurator for FluidSynth CLI UI
  • +
  • Goldberg Polyhedron Paint
  • diff --git a/standalone/goldberg-sphere/env_state.mjs b/standalone/goldberg-sphere/env_state.mjs new file mode 100644 index 0000000..2a9473a --- /dev/null +++ b/standalone/goldberg-sphere/env_state.mjs @@ -0,0 +1,52 @@ +// Converts azimuth + elevation (degrees) to a normalised direction vector. +// Y = up, Z = towards camera. Azimuth is measured from +Z towards +X. +function _dir(azim_deg, elev_deg) { + const a = azim_deg * Math.PI / 180; + const e = elev_deg * Math.PI / 180; + return new Float32Array([Math.cos(e)*Math.sin(a), Math.sin(e), Math.cos(e)*Math.cos(a)]); +} + +function _parse_hex(hex) { + return [parseInt(hex.slice(1,3),16)/255, parseInt(hex.slice(3,5),16)/255, parseInt(hex.slice(5,7),16)/255]; +} + +function _to_hex(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])}`; +} + +export class Env_State { + constructor() { + // Light 1 — warm key light (approximates the former hardcoded l1) + this.light1_azim = 27; // degrees + this.light1_elev = 34; // degrees + this.light1_color = [1.0, 0.98, 0.94]; // normalised hue + this.light1_intensity = 1.5; + + // Light 2 — cool fill light (approximates former l2) + this.light2_azim = -58; + this.light2_elev = -28; + this.light2_color = [0.55, 0.65, 1.0]; + this.light2_intensity = 0.5; + + // Ambient — uniform baseline, no direction + this.ambient_color = [1.0, 1.0, 1.0]; + this.ambient_intensity = 0.07; + } + + light1_dir() { return _dir(this.light1_azim, this.light1_elev); } + light2_dir() { return _dir(this.light2_azim, this.light2_elev); } + + // Final light colour = normalised hue × intensity (may exceed 1.0). + light1_col() { return new Float32Array(this.light1_color.map(v => v * this.light1_intensity)); } + light2_col() { return new Float32Array(this.light2_color.map(v => v * this.light2_intensity)); } + ambient_col() { return new Float32Array(this.ambient_color.map(v => v * this.ambient_intensity)); } + + hex_light1() { return _to_hex(this.light1_color); } + hex_light2() { return _to_hex(this.light2_color); } + hex_ambient() { return _to_hex(this.ambient_color); } + + set_hex_light1(hex) { this.light1_color = _parse_hex(hex); } + set_hex_light2(hex) { this.light2_color = _parse_hex(hex); } + set_hex_ambient(hex) { this.ambient_color = _parse_hex(hex); } +} diff --git a/standalone/goldberg-sphere/geometry.mjs b/standalone/goldberg-sphere/geometry.mjs new file mode 100644 index 0000000..d0e5375 --- /dev/null +++ b/standalone/goldberg-sphere/geometry.mjs @@ -0,0 +1,98 @@ +// Pure 3D geometry utilities — no DOM, no topology, only math. + +export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + +export class Vec3 { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + + add(v) { return new Vec3(this.x + v.x, this.y + v.y, this.z + v.z); } + sub(v) { return new Vec3(this.x - v.x, this.y - v.y, this.z - v.z); } + scale(s) { return new Vec3(this.x * s, this.y * s, this.z * s); } + dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; } + cross(v) { + return new Vec3( + this.y * v.z - this.z * v.y, + this.z * v.x - this.x * v.z, + this.x * v.y - this.y * v.x, + ); + } + length() { return Math.sqrt(this.dot(this)); } + normalize() { return this.scale(1 / this.length()); } + lerp(v, t) { return this.scale(1 - t).add(v.scale(t)); } + + to_array() { return [this.x, this.y, this.z]; } + toString() { return `Vec3(${this.x.toFixed(4)}, ${this.y.toFixed(4)}, ${this.z.toFixed(4)})`; } +} + +// Project a Vec3 onto the unit sphere. +export function normalize_to_sphere(v) { + return v.normalize(); +} + +// Midpoint between two Vec3 (not yet normalized). +export function midpoint(a, b) { + return a.add(b).scale(0.5); +} + +// Returns n [x, y] pairs for a regular n-gon centered at origin with given circumradius. +// Vertices start at angle `start_angle` (default: top, -π/2). +export function regular_polygon_2d(n, circumradius, start_angle = -Math.PI / 2) { + const pts = []; + for (let i = 0; i < n; i++) { + const angle = start_angle + (2 * Math.PI * i) / n; + pts.push([circumradius * Math.cos(angle), circumradius * Math.sin(angle)]); + } + return pts; +} + +// Centroid of an array of [x, y] points. +export function centroid_2d(pts) { + let sx = 0, sy = 0; + for (const [x, y] of pts) { sx += x; sy += y; } + return [sx / pts.length, sy / pts.length]; +} + +// Apply a 2D similarity transform (rotation + uniform scale + translation) +// represented as { a, b, tx, ty } where the mapping is: +// [x', y'] = [a*x - b*y + tx, b*x + a*y + ty] +// (a + bi is the complex multiplier, tx+ty is the translation) +export function apply_transform_2d(transform, pt) { + const { a, b, tx, ty } = transform; + return [ + a * pt[0] - b * pt[1] + tx, + b * pt[0] + a * pt[1] + ty, + ]; +} + +// Compute the similarity transform that maps (src0 -> dst0, src1 -> dst1). +// Returns { a, b, tx, ty }. +export function similarity_transform_2d(src0, src1, dst0, dst1) { + // Treat points as complex numbers. + // We want w = c * z + d where c = a+bi, d = tx+ty*i + // c = (dst1 - dst0) / (src1 - src0) [complex division] + // d = dst0 - c * src0 + const [sx0, sy0] = src0; + const [sx1, sy1] = src1; + const [dx0, dy0] = dst0; + const [dx1, dy1] = dst1; + + // src_vec = src1 - src0 + const svx = sx1 - sx0, svy = sy1 - sy0; + // dst_vec = dst1 - dst0 + const dvx = dx1 - dx0, dvy = dy1 - dy0; + + // c = dst_vec / src_vec (complex division) + const denom = svx * svx + svy * svy; + const a = (dvx * svx + dvy * svy) / denom; + const b = (dvy * svx - dvx * svy) / denom; + + // d = dst0 - c * src0 + const tx = dx0 - (a * sx0 - b * sy0); + const ty = dy0 - (b * sx0 + a * sy0); + + return { a, b, tx, ty }; +} diff --git a/standalone/goldberg-sphere/gl_utils.mjs b/standalone/goldberg-sphere/gl_utils.mjs new file mode 100644 index 0000000..e15778b --- /dev/null +++ b/standalone/goldberg-sphere/gl_utils.mjs @@ -0,0 +1,23 @@ +export function compile_shader(gl, type, src) { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(s)); } + return s; +} + +export function create_program(gl, vs_src, fs_src) { + const p = gl.createProgram(); + gl.attachShader(p, compile_shader(gl, gl.VERTEX_SHADER, vs_src)); + gl.attachShader(p, compile_shader(gl, gl.FRAGMENT_SHADER, fs_src)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(p)); } + return p; +} + +export function make_buffer(gl, data) { + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + return buf; +} diff --git a/standalone/goldberg-sphere/index.html b/standalone/goldberg-sphere/index.html new file mode 100644 index 0000000..938b628 --- /dev/null +++ b/standalone/goldberg-sphere/index.html @@ -0,0 +1,346 @@ + + + + + Goldberg Polyhedron Paint + + + + + +
    + +
    + + +
    +
    + + + +
    +
    +
    0
    +
    0%
    +
    0%
    +
    + + +
    +
    +
    +
    +
    + +
    +
    + + + + diff --git a/standalone/goldberg-sphere/math.mjs b/standalone/goldberg-sphere/math.mjs new file mode 100644 index 0000000..8234e2b --- /dev/null +++ b/standalone/goldberg-sphere/math.mjs @@ -0,0 +1,56 @@ +export function mat4_mul(a, b) { + const out = new Float32Array(16); + for (let c = 0; c < 4; c++) { + for (let r = 0; r < 4; r++) { + let s = 0; + for (let k = 0; k < 4; k++) { s += a[k*4+r] * b[c*4+k]; } + out[c*4+r] = s; + } + } + return out; +} + +export function mat4_perspective(fov, aspect, near, far) { + const f = 1 / Math.tan(fov / 2), nf = 1 / (near - far); + return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]); +} + +export function mat4_translate_z(z) { + return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,z,1]); +} + +export function quat_identity() { return [0, 0, 0, 1]; } + +export function quat_from_axis_angle(ax, ay, az, a) { + const s = Math.sin(a/2); + return [ax*s, ay*s, az*s, Math.cos(a/2)]; +} + +export function quat_mul([ax,ay,az,aw], [bx,by,bz,bw]) { + return [aw*bx+ax*bw+ay*bz-az*by, aw*by-ax*bz+ay*bw+az*bx, + aw*bz+ax*by-ay*bx+az*bw, aw*bw-ax*bx-ay*by-az*bz]; +} + +export function quat_conj([x,y,z,w]) { return [-x,-y,-z,w]; } + +export function quat_rotate_vec([qx,qy,qz,qw], [vx,vy,vz]) { + const tx = 2*(qy*vz - qz*vy); + const ty = 2*(qz*vx - qx*vz); + const tz = 2*(qx*vy - qy*vx); + return [vx + qw*tx + qy*tz - qz*ty, + vy + qw*ty + qz*tx - qx*tz, + vz + qw*tz + qx*ty - qy*tx]; +} + +export function mat4_from_quat([x,y,z,w]) { + return new Float32Array([ + 1-2*(y*y+z*z), 2*(x*y+w*z), 2*(x*z-w*y), 0, + 2*(x*y-w*z), 1-2*(x*x+z*z), 2*(y*z+w*x), 0, + 2*(x*z+w*y), 2*(y*z-w*x), 1-2*(x*x+y*y), 0, + 0, 0, 0, 1, + ]); +} + +export function mat3_from_mat4(m) { + return new Float32Array([m[0],m[1],m[2], m[4],m[5],m[6], m[8],m[9],m[10]]); +} diff --git a/standalone/goldberg-sphere/maze.mjs b/standalone/goldberg-sphere/maze.mjs new file mode 100644 index 0000000..3357a06 --- /dev/null +++ b/standalone/goldberg-sphere/maze.mjs @@ -0,0 +1,336 @@ +import { make_buffer } from './gl_utils.mjs'; + +// --------------------------------------------------------------------------- +// Face adjacency +// --------------------------------------------------------------------------- + +// Build adjacency list for Goldberg faces via shared edges. +// Uses unrelaxed vertex positions as canonical keys — topology is stable across stages. +export function build_goldberg_adjacency(goldberg) { + const edge_to_entries = new Map(); + for (let fi = 0; fi < goldberg.faces.length; fi++) { + const verts = goldberg.faces[fi].vertices_3d; + const n = verts.length; + for (let i = 0; i < n; i++) { + const a = verts[i], b = verts[(i + 1) % n]; + const ka = `${a.x.toFixed(5)},${a.y.toFixed(5)},${a.z.toFixed(5)}`; + const kb = `${b.x.toFixed(5)},${b.y.toFixed(5)},${b.z.toFixed(5)}`; + const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + if (!edge_to_entries.has(key)) { edge_to_entries.set(key, []); } + edge_to_entries.get(key).push({ fi, edge_idx: i }); + } + } + const adj = Array.from({ length: goldberg.faces.length }, () => []); + for (const entries of edge_to_entries.values()) { + if (entries.length === 2) { + const [e0, e1] = entries; + adj[e0.fi].push({ fj: e1.fi, fi_edge_idx: e0.edge_idx }); + adj[e1.fi].push({ fj: e0.fi, fi_edge_idx: e1.edge_idx }); + } + } + return adj; +} + +// --------------------------------------------------------------------------- +// Prim island maze +// --------------------------------------------------------------------------- + +function grow_island(seed, max_cells, adj, cell_region, region_id) { + cell_region[seed] = region_id; + const frontier = []; + const add_frontier = fi => { + for (const { fj, fi_edge_idx } of adj[fi]) { + if (cell_region[fj] === -1) { frontier.push({ fi, fj, fi_edge_idx }); } + } + }; + add_frontier(seed); + + const passage_pairs = new Set(); + let count = 1; + while (frontier.length > 0 && count < max_cells) { + const idx = Math.floor(Math.random() * frontier.length); + const { fi, fj, fi_edge_idx } = frontier[idx]; + frontier[idx] = frontier[frontier.length - 1]; + frontier.pop(); + if (cell_region[fj] !== -1) { continue; } + const pk = fi < fj ? `${fi},${fj}` : `${fj},${fi}`; + passage_pairs.add(pk); + cell_region[fj] = region_id; + count++; + add_frontier(fj); + } + return passage_pairs; +} + +function generate_maze_walls(goldberg, seed_count, max_cells) { + const adj = build_goldberg_adjacency(goldberg); + const n = goldberg.faces.length; + const cell_region = new Int32Array(n).fill(-1); + const all_passages = new Set(); + + const order = Array.from({ length: n }, (_, i) => i); + for (let i = n - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + + let region_id = 0; + for (let s = 0; s < seed_count && s < n; s++) { + const seed = order[s]; + if (cell_region[seed] !== -1) { continue; } + const passages = grow_island(seed, max_cells, adj, cell_region, region_id); + for (const pk of passages) { all_passages.add(pk); } + region_id++; + } + + const wall_edges = []; + for (let fi = 0; fi < n; fi++) { + if (cell_region[fi] === -1) { continue; } + for (const { fj, fi_edge_idx } of adj[fi]) { + if (fi >= fj) { continue; } + if (cell_region[fj] === -1) { + wall_edges.push({ fi, edge_idx: fi_edge_idx }); + } else if (cell_region[fj] === cell_region[fi]) { + const pk = `${fi},${fj}`; + if (!all_passages.has(pk)) { wall_edges.push({ fi, edge_idx: fi_edge_idx }); } + } + } + } + return wall_edges; +} + +// --------------------------------------------------------------------------- +// Edge-walker maze +// --------------------------------------------------------------------------- + +function build_vertex_graph(goldberg) { + const pos_to_id = new Map(); + let nv = 0; + const get_vid = v => { + const k = `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; + if (!pos_to_id.has(k)) { pos_to_id.set(k, nv++); } + return pos_to_id.get(k); + }; + for (const face of goldberg.faces) { + for (const v of face.vertices_3d) { get_vid(v); } + } + const vertex_adj = Array.from({ length: nv }, () => []); + const edge_seen = new Set(); + const edge_data = new Map(); + for (let fi = 0; fi < goldberg.faces.length; fi++) { + const verts = goldberg.faces[fi].vertices_3d; + const n = verts.length; + for (let i = 0; i < n; i++) { + const va = get_vid(verts[i]); + const vb = get_vid(verts[(i + 1) % n]); + const ek = va < vb ? `${va},${vb}` : `${vb},${va}`; + if (!edge_seen.has(ek)) { + edge_seen.add(ek); + edge_data.set(ek, { fi, edge_idx: i }); + vertex_adj[va].push({ vj: vb, ek }); + vertex_adj[vb].push({ vj: va, ek }); + } + } + } + return { nv, vertex_adj, edge_data }; +} + +function generate_walker_walls(goldberg, walker_count, stop_prob, branch_prob, close_prob) { + const { nv, vertex_adj, edge_data } = build_vertex_graph(goldberg); + const walled = new Set(); + const vertex_walled = new Uint8Array(nv); + + const order = Array.from({ length: nv }, (_, i) => i); + for (let i = nv - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + + const active = []; + for (let w = 0; w < Math.min(walker_count, nv); w++) { + active.push({ vi: order[w], prev_vi: -1 }); + } + + while (active.length > 0) { + const wi = Math.floor(Math.random() * active.length); + const walker = active[wi]; + const { vi, prev_vi } = walker; + if (Math.random() < stop_prob) { active.splice(wi, 1); continue; } + const candidates = []; + for (const { vj, ek } of vertex_adj[vi]) { + if (vj === prev_vi) { continue; } + if (walled.has(ek)) { continue; } + if (vertex_walled[vj] && Math.random() >= close_prob) { continue; } + candidates.push({ vj, ek }); + } + if (candidates.length === 0) { active.splice(wi, 1); continue; } + const { vj, ek } = candidates[Math.floor(Math.random() * candidates.length)]; + walled.add(ek); + vertex_walled[vi] = 1; + vertex_walled[vj] = 1; + if (Math.random() < branch_prob) { active.push({ vi, prev_vi }); } + walker.vi = vj; + walker.prev_vi = vi; + } + + const wall_edges = []; + for (const ek of walled) { + const entry = edge_data.get(ek); + if (entry) { wall_edges.push({ fi: entry.fi, edge_idx: entry.edge_idx }); } + } + return wall_edges; +} + +// --------------------------------------------------------------------------- +// Wall geometry +// --------------------------------------------------------------------------- + +function build_wall_geo(goldberg, use_relaxed, wall_edges, wall_width) { + if (wall_edges.length === 0) { return { wall_pos: new Float32Array(0), wall_verts: 0 }; } + + const LIFT = 1.010; + const hw = wall_width / 2; + const MITER_CLAMP = 4.0; + + const ev = wall_edges.map(({ fi, edge_idx }) => { + const face = goldberg.faces[fi]; + const verts = use_relaxed ? (face.relaxed_vertices_3d ?? face.vertices_3d) : face.vertices_3d; + return { a: verts[edge_idx], b: verts[(edge_idx + 1) % verts.length] }; + }); + + const pk = v => `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; + + const vtx_edges = new Map(); + for (let i = 0; i < ev.length; i++) { + for (const v of [ev[i].a, ev[i].b]) { + const k = pk(v); + if (!vtx_edges.has(k)) { vtx_edges.set(k, []); } + vtx_edges.get(k).push(i); + } + } + + const w_dir = (v, w) => { + const ex = w.x-v.x, ey = w.y-v.y, ez = w.z-v.z; + const er = Math.sqrt(ex*ex + ey*ey + ez*ez); + if (er < 1e-10) { return [0, 0, 0]; } + const vr = Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z); + const nx = v.x/vr, ny = v.y/vr, nz = v.z/vr; + const edx = ex/er, edy = ey/er, edz = ez/er; + return [ny*edz - nz*edy, nz*edx - nx*edz, nx*edy - ny*edx]; + }; + + const miter_offset = (v, other_v, i) => { + const vk = pk(v); + const adj = vtx_edges.get(vk).filter(j => j !== i); + const w1 = w_dir(v, other_v); + if (adj.length === 0) { return [0, 0, 0]; } + let sx = w1[0], sy = w1[1], sz = w1[2]; + for (const j of adj) { + const { a, b } = ev[j]; + const far = pk(a) === vk ? b : a; + const wj = w_dir(v, far); + sx += wj[0]; sy += wj[1]; sz += wj[2]; + } + const sr = Math.sqrt(sx*sx + sy*sy + sz*sz); + if (sr < 1e-6) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; } + const m = [sx/sr, sy/sr, sz/sr]; + const d = w1[0]*m[0] + w1[1]*m[1] + w1[2]*m[2]; + if (d < 0.05) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; } + const len = Math.min(hw / d, hw * MITER_CLAMP); + return [m[0]*len, m[1]*len, m[2]*len]; + }; + + const pos = []; + for (let i = 0; i < ev.length; i++) { + const { a, b } = ev[i]; + const oa = miter_offset(a, b, i); + const ob = miter_offset(b, a, i); + const ax = a.x*LIFT, ay = a.y*LIFT, az = a.z*LIFT; + const bx = b.x*LIFT, by = b.y*LIFT, bz = b.z*LIFT; + const term_a = oa[0] === 0 && oa[1] === 0 && oa[2] === 0; + const term_b = ob[0] === 0 && ob[1] === 0 && ob[2] === 0; + + if (term_a && term_b) { + const mx = (a.x+b.x)/2, my = (a.y+b.y)/2, mz = (a.z+b.z)/2; + const mr = Math.sqrt(mx*mx+my*my+mz*mz); + const ex = b.x-a.x, ey = b.y-a.y, ez = b.z-a.z; + const er = Math.sqrt(ex*ex+ey*ey+ez*ez); + const wd = [ + (my/mr)*(ez/er) - (mz/mr)*(ey/er), + (mz/mr)*(ex/er) - (mx/mr)*(ez/er), + (mx/mr)*(ey/er) - (my/mr)*(ex/er), + ]; + pos.push( + ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw, + ax+wd[0]*hw, ay+wd[1]*hw, az+wd[2]*hw, + bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw, + ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw, + bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw, + bx-wd[0]*hw, by-wd[1]*hw, bz-wd[2]*hw, + ); + continue; + } + + const v0 = [ax-oa[0], ay-oa[1], az-oa[2]]; + const v1 = [ax+oa[0], ay+oa[1], az+oa[2]]; + const v2 = [bx-ob[0], by-ob[1], bz-ob[2]]; + const v3 = [bx+ob[0], by+ob[1], bz+ob[2]]; + + if (term_a) { + pos.push(ax, ay, az, ...v2, ...v3); + } else if (term_b) { + pos.push(...v0, ...v1, bx, by, bz); + } else { + pos.push(...v0, ...v1, ...v2, ...v0, ...v2, ...v3); + } + } + return { wall_pos: new Float32Array(pos), wall_verts: pos.length / 3 }; +} + +// --------------------------------------------------------------------------- +// Maze_State +// --------------------------------------------------------------------------- + +export class Maze_State { + constructor() { + this.wall_edges = null; + this.pos_buf = null; + this.vert_count = 0; + } + + generate(goldberg, p) { + if (p.algo === 'walker') { + this.wall_edges = generate_walker_walls(goldberg, p.walkers, p.stop_prob, p.branch_prob, p.close_prob); + } else { + this.wall_edges = generate_maze_walls(goldberg, p.seeds, p.max_cells); + } + } + + upload(gl, goldberg, use_relaxed, wall_width) { + if (!this.wall_edges) { return; } + const geo = build_wall_geo(goldberg, use_relaxed, this.wall_edges, wall_width); + if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); } + this.pos_buf = make_buffer(gl, geo.wall_pos); + this.vert_count = geo.wall_verts; + } + + invalidate(gl) { + this.wall_edges = null; + if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); this.pos_buf = null; } + this.vert_count = 0; + } + + draw(gl, prog, mvp, show) { + if (!show || !this.pos_buf || this.vert_count === 0) { return; } + gl.useProgram(prog); + gl.uniformMatrix4fv(gl.getUniformLocation(prog, 'u_mvp'), false, mvp); + const ap = gl.getAttribLocation(prog, 'a_pos'); + gl.bindBuffer(gl.ARRAY_BUFFER, this.pos_buf); + gl.enableVertexAttribArray(ap); + gl.vertexAttribPointer(ap, 3, gl.FLOAT, false, 0, 0); + gl.disable(gl.CULL_FACE); + gl.drawArrays(gl.TRIANGLES, 0, this.vert_count); + gl.enable(gl.CULL_FACE); + gl.disableVertexAttribArray(ap); + } +} diff --git a/standalone/goldberg-sphere/paint_state.mjs b/standalone/goldberg-sphere/paint_state.mjs new file mode 100644 index 0000000..cc86e45 --- /dev/null +++ b/standalone/goldberg-sphere/paint_state.mjs @@ -0,0 +1,88 @@ +export const PAINT_BG = [0.10, 0.13, 0.17]; +export const PAINT_BG_PBR = [0.0, 0.8]; +export const PAINT_BG_NOISE = [0.0, 0.0, 4.0, 0.5]; // scale, strength, octaves, gain + +export class Paint_State { + constructor() { + this.enabled = false; + this.tool = 'pen'; // 'pen' | 'fill' | 'line' + this.face_colors = new Map(); // fi → [r, g, b] + this.face_pbr = new Map(); // fi → [metallic, roughness] + this.face_noise = new Map(); // fi → [scale, strength, octaves, gain] + this.face_key = new Map(); // fi → material key string (pre-computed at paint time) + this.color_left = [0.85, 0.25, 0.12]; + this.color_right = [0.18, 0.45, 0.85]; + this.metallic_left = 0.0; + this.metallic_right = 0.0; + this.roughness_left = 0.6; + this.roughness_right = 0.6; + this.noise_left = [0.0, 0.0, 4.0, 0.5]; // scale, strength, octaves, gain + this.noise_right = [0.0, 0.0, 4.0, 0.5]; + this.preview = null; // { faces: Set, color, pbr, noise } | null + } + + _parse_hex(hex) { + return [parseInt(hex.slice(1,3),16)/255, parseInt(hex.slice(3,5),16)/255, parseInt(hex.slice(5,7),16)/255]; + } + + _to_hex(c) { + const h = v => Math.round(v*255).toString(16).padStart(2,'0'); + return `#${h(c[0])}${h(c[1])}${h(c[2])}`; + } + + set_hex_left(hex) { this.color_left = this._parse_hex(hex); } + set_hex_right(hex) { this.color_right = this._parse_hex(hex); } + hex_left() { return this._to_hex(this.color_left); } + hex_right() { return this._to_hex(this.color_right); } + hex(side) { return side === 'right' ? this.hex_right() : this.hex_left(); } + + _make_key(color, pbr, noise) { + return `${color.map(v=>v.toFixed(5)).join(',')}|${pbr.map(v=>v.toFixed(5)).join(',')}|${noise.map(v=>v.toFixed(3)).join(',')}`; + } + + paint(fi, button) { + const color = button === 2 ? this.color_right : this.color_left; + const metallic = button === 2 ? this.metallic_right : this.metallic_left; + const roughness= button === 2 ? this.roughness_right: this.roughness_left; + const noise = button === 2 ? this.noise_right : this.noise_left; + const pbr = [metallic, roughness]; + this.face_colors.set(fi, [...color]); + this.face_pbr.set(fi, pbr); + this.face_noise.set(fi, [...noise]); + this.face_key.set(fi, this._make_key(color, pbr, noise)); + } + + clear() { + this.face_colors.clear(); + this.face_pbr.clear(); + this.face_noise.clear(); + this.face_key.clear(); + } + + set_preview(faces, button) { + const color = button === 2 ? this.color_right : this.color_left; + const metallic = button === 2 ? this.metallic_right : this.metallic_left; + const roughness= button === 2 ? this.roughness_right: this.roughness_left; + const noise = button === 2 ? this.noise_right : this.noise_left; + this.preview = { faces: new Set(faces), color: [...color], pbr: [metallic, roughness], noise: [...noise] }; + } + + commit_preview() { + if (!this.preview) { return; } + const color = this.preview.color; + const pbr = this.preview.pbr ?? PAINT_BG_PBR; + const noise = this.preview.noise ?? PAINT_BG_NOISE; + const key = this._make_key(color, pbr, noise); + for (const fi of this.preview.faces) { + this.face_colors.set(fi, [...color]); + this.face_pbr.set(fi, [...pbr]); + this.face_noise.set(fi, [...noise]); + this.face_key.set(fi, key); + } + this.preview = null; + } + + clear_preview() { + this.preview = null; + } +} diff --git a/standalone/goldberg-sphere/palettes.mjs b/standalone/goldberg-sphere/palettes.mjs new file mode 100644 index 0000000..ebad4ed --- /dev/null +++ b/standalone/goldberg-sphere/palettes.mjs @@ -0,0 +1,80 @@ +import { desaturate } from './render_geo.mjs'; + +function t_from_ratio(ratio) { + return Math.min(Math.max((ratio - 1) / 0.4, 0), 1); +} + +// Hue-wheel position (0-1) → RGB at full saturation and value. +function hue_to_rgb(h) { + const s = 6 * (h % 1); + const i = Math.floor(s); + const f = s - i; + const q = 1 - f; + if (i === 0) { return [1, f, 0]; } + if (i === 1) { return [q, 1, 0]; } + if (i === 2) { return [0, 1, f]; } + if (i === 3) { return [0, q, 1]; } + if (i === 4) { return [f, 0, 1]; } + return [1, 0, q]; +} + +export const PALETTES = [ + { + id: 'classic', + name: 'Classic', + face_color(ratio, is_pentagon) { + if (is_pentagon) { return desaturate([1.0, 0.85, 0.23], 0.45); } + const t = t_from_ratio(ratio); + return desaturate([Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)], 0.45); + }, + }, + { + id: 'vibrant', + name: 'Vibrant', + face_color(ratio, is_pentagon) { + if (is_pentagon) { return [1.0, 0.82, 0.10]; } + const t = t_from_ratio(ratio); + return [Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)]; + }, + }, + { + id: 'arctic', + name: 'Arctic', + // Low distortion = pale ice blue, high = deep indigo. + face_color(ratio, is_pentagon) { + if (is_pentagon) { return [0.95, 0.75, 0.20]; } + const t = t_from_ratio(ratio); + return desaturate([ + 0.55 - t * 0.45, + 0.80 - t * 0.50, + 1.0 - t * 0.15, + ], 0.7 + t * 0.3); + }, + }, + { + id: 'ember', + name: 'Ember', + // Low distortion = dark red, high = bright yellow-white. + face_color(ratio, is_pentagon) { + if (is_pentagon) { return desaturate([0.25, 0.55, 0.85], 0.6); } + const t = t_from_ratio(ratio); + return [ + 0.35 + t * 0.65, + t * t * 0.85, + t * t * t * 0.25, + ]; + }, + }, + { + id: 'rainbow', + name: 'Rainbow', + // Full hue sweep: low distortion = blue (240°), high = red (0°). + face_color(ratio, is_pentagon) { + if (is_pentagon) { return [1.0, 1.0, 1.0]; } + const t = t_from_ratio(ratio); + return desaturate(hue_to_rgb(0.67 - t * 0.67), 0.85); + }, + }, +]; + +export const DEFAULT_PALETTE = PALETTES[0]; diff --git a/standalone/goldberg-sphere/placer_gnomonic_3d.mjs b/standalone/goldberg-sphere/placer_gnomonic_3d.mjs new file mode 100644 index 0000000..489bc76 --- /dev/null +++ b/standalone/goldberg-sphere/placer_gnomonic_3d.mjs @@ -0,0 +1,77 @@ +// Gnomonic projection of the relaxed 3D Goldberg sphere. +// +// Projects each vertex from the sphere's centre onto the tangent plane at +// the current face's centroid. Equivalent to placing your eye at the centre +// of the sphere and looking outward — geodesics project to straight lines +// and tile shapes stay natural even for faces away from centre. +// Near the 90° horizon the projection diverges; faces that far out are rare +// in typical neighbourhood depths so this is acceptable. +// +// Requires goldberg.relax_sphere() to have been called first. +// Falls back to vertices_3d if relaxed_vertices_3d is not set. +// +// Returns Map + +import { Vec3, centroid_2d } from './geometry.mjs'; + +export function place_gnomonic_3d(poly, neighborhood, root_face_index, cx, cy, face_size) { + const root_face = poly.faces[root_face_index]; + const root_verts = root_face.relaxed_vertices_3d ?? root_face.vertices_3d; + + // Normalised centroid of root face = projection centre C. + let sx = 0, sy = 0, sz = 0; + for (const v of root_verts) { sx += v.x; sy += v.y; sz += v.z; } + const cl = Math.sqrt(sx*sx + sy*sy + sz*sz); + const C = new Vec3(sx / cl, sy / cl, sz / cl); + + // Orthonormal tangent frame {u, v} at C. + const up = Math.abs(C.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0); + const u = C.cross(up).normalize(); + const v = C.cross(u).normalize(); + + // Gnomonic project a unit-sphere point P onto the tangent plane at C. + // Returns [tx, ty] in tangent-plane coords, or null if behind the plane. + const project = (P) => { + const d = P.dot(C); + if (d <= 1e-6) { return null; } + // Point on tangent plane: P/d. Subtract C to get offset, decompose into u,v. + const inv_d = 1 / d; + const qx = P.x * inv_d - C.x; + const qy = P.y * inv_d - C.y; + const qz = P.z * inv_d - C.z; + return [qx * u.x + qy * u.y + qz * u.z, + qx * v.x + qy * v.y + qz * v.z]; + }; + + // Determine canvas scale from root face's projected circumradius. + const root_proj = root_verts.map(p => project(p)); + if (root_proj.some(p => p === null)) { return new Map(); } + const rcx = root_proj.reduce((s, p) => s + p[0], 0) / root_proj.length; + const rcy = root_proj.reduce((s, p) => s + p[1], 0) / root_proj.length; + const root_r = Math.max(...root_proj.map(([x, y]) => + Math.sqrt((x - rcx) ** 2 + (y - rcy) ** 2))); + if (root_r < 1e-10) { return new Map(); } + const scale = face_size / root_r; + + // Build placements. + const placements = new Map(); + for (const entry of neighborhood) { + const face = entry.face; + const verts = face.relaxed_vertices_3d ?? face.vertices_3d; + const pts = []; + let ok = true; + for (const P of verts) { + const proj = project(P); + if (!proj) { ok = false; break; } + pts.push([cx + proj[0] * scale, cy - proj[1] * scale]); + } + if (!ok) { continue; } + placements.set(face.index, { + vertices_2d: pts, + centroid_2d: centroid_2d(pts), + depth: entry.depth, + }); + } + + return placements; +} diff --git a/standalone/goldberg-sphere/placer_ortho_3d.mjs b/standalone/goldberg-sphere/placer_ortho_3d.mjs new file mode 100644 index 0000000..df42187 --- /dev/null +++ b/standalone/goldberg-sphere/placer_ortho_3d.mjs @@ -0,0 +1,65 @@ +// Orthographic projection of the relaxed 3D Goldberg sphere. +// +// Rotates the sphere so the current face's centroid points toward +z, +// then projects each vertex's (x, y) components directly to canvas. +// Faces on the far hemisphere (z < 0) are still included if in the +// neighbourhood — they'll just appear behind the centre. +// +// Requires goldberg.relax_sphere() to have been called first. +// Falls back to vertices_3d if relaxed_vertices_3d is not set. +// +// Returns Map + +import { Vec3, centroid_2d } from './geometry.mjs'; + +export function place_ortho_3d(poly, neighborhood, root_face_index, cx, cy, face_size) { + // --- Determine projection centre from root face --- + const root_face = poly.faces[root_face_index]; + const root_verts = root_face.relaxed_vertices_3d ?? root_face.vertices_3d; + + // Normalised centroid of root face = the outward direction we point toward +z. + let sx = 0, sy = 0, sz = 0; + for (const v of root_verts) { sx += v.x; sy += v.y; sz += v.z; } + const cl = Math.sqrt(sx * sx + sy * sy + sz * sz); + const C = new Vec3(sx / cl, sy / cl, sz / cl); + + // Build orthonormal tangent frame {u, v, w=C}. + // u = perpendicular to C in the horizontal plane; v = C × u. + const up = Math.abs(C.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0); + const u = C.cross(up).normalize(); // points "right" on screen + const v = C.cross(u).normalize(); // points "up" on screen (then flipped for canvas) + + // Project a Vec3 onto the tangent frame, returns [canvas_x, canvas_y, z_depth]. + const project = (P) => [ + P.dot(u), // tangent x + P.dot(v), // tangent y (flipped to canvas below) + P.dot(C), // depth (1 = facing viewer, -1 = back) + ]; + + // Determine scale from root face's projected circumradius. + const root_proj = root_verts.map(project); + const rcx = root_proj.reduce((s, p) => s + p[0], 0) / root_proj.length; + const rcy = root_proj.reduce((s, p) => s + p[1], 0) / root_proj.length; + const root_r = Math.max(...root_proj.map(([x, y]) => + Math.sqrt((x - rcx) ** 2 + (y - rcy) ** 2))); + if (root_r < 1e-10) { return new Map(); } + const scale = face_size / root_r; + + // --- Build placements --- + const placements = new Map(); + for (const entry of neighborhood) { + const face = entry.face; + const verts = face.relaxed_vertices_3d ?? face.vertices_3d; + const pts = verts.map(P => { + const [tx, ty] = project(P); + return [cx + tx * scale, cy - ty * scale]; // flip y: +v → up on canvas + }); + placements.set(face.index, { + vertices_2d: pts, + centroid_2d: centroid_2d(pts), + depth: entry.depth, + }); + } + + return placements; +} diff --git a/standalone/goldberg-sphere/placer_relax.mjs b/standalone/goldberg-sphere/placer_relax.mjs new file mode 100644 index 0000000..8a84568 --- /dev/null +++ b/standalone/goldberg-sphere/placer_relax.mjs @@ -0,0 +1,236 @@ +// Shared-vertex spring relaxation. +// +// Builds an indexed mesh (union-find over per-face vertex slots) so +// topologically identical vertices are the same array entry — sharing is +// exact, not glued. Two spring types per iteration: +// 1. Edge-length: each edge pulls toward a depth-scaled target length. +// 2. Radial: each vertex pulls toward its face's depth-scaled circumradius. +// +// curvature=0 → flat (uniform size); >0 → outer tiles shrink (sphere-like); +// <0 → outer tiles grow (hyperbolic-like). depth_scale(d) = (1-curvature)^d. +// +// Displacement is clamped per iteration to spread unavoidable curvature +// (from pentagon defects) evenly rather than concentrating it in a few faces. +// +// Returns Map + +import { + regular_polygon_2d, + centroid_2d, + similarity_transform_2d, + apply_transform_2d, +} from './geometry.mjs'; + +const RELAX_ITERS = 30; +const ALPHA_EDGE = 0.5; +const ALPHA_RADIAL = 0.4; +const MAX_MOVE_FRAC = 0.12; + +export function place_relax(poly, neighborhood, root_face_index, cx, cy, face_size, curvature = 0) { + const placed_set = new Set(neighborhood.map(e => e.face.index)); + + // --- Union-find over per-face vertex slots --- + const face_base = new Map(); + let uf_size = 0; + for (const entry of neighborhood) { + face_base.set(entry.face.index, uf_size); + uf_size += entry.face.size; + } + const uf_parent = Array.from({ length: uf_size }, (_, i) => i); + const uf_find = (i) => { + while (uf_parent[i] !== i) { uf_parent[i] = uf_parent[uf_parent[i]]; i = uf_parent[i]; } + return i; + }; + const uf_union = (i, j) => { uf_parent[uf_find(i)] = uf_find(j); }; + + const adj_seen = new Set(); + for (const entry of neighborhood) { + const fi_a = entry.face.index; + const face_a = entry.face; + const na = face_a.size; + const base_a = face_base.get(fi_a); + for (let ea = 0; ea < na; ea++) { + const fi_b = face_a.edge_neighbors[ea]; + if (fi_b === null || fi_b === undefined || !placed_set.has(fi_b)) { continue; } + const pair_key = fi_a < fi_b ? `${fi_a}:${fi_b}` : `${fi_b}:${fi_a}`; + if (adj_seen.has(pair_key)) { continue; } + adj_seen.add(pair_key); + + const face_b = poly.faces[fi_b]; + const nb = face_b.size; + const base_b = face_base.get(fi_b); + let eb = -1; + for (let j = 0; j < nb; j++) { + if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; } + } + if (eb === -1) { continue; } + + // Opposite winding: A's slot ea ≡ B's slot (eb+1)%nb, A's (ea+1)%na ≡ B's eb. + uf_union(base_a + ea, base_b + (eb + 1) % nb); + uf_union(base_a + (ea + 1) % na, base_b + eb); + } + } + + // Assign compact position IDs to canonical slots. + const canon_to_pid = new Map(); + let pid_count = 0; + const face_pids = new Map(); + for (const entry of neighborhood) { + const fi = entry.face.index; + const n = entry.face.size; + const base = face_base.get(fi); + const pids = []; + for (let i = 0; i < n; i++) { + const canon = uf_find(base + i); + if (!canon_to_pid.has(canon)) { canon_to_pid.set(canon, pid_count++); } + pids.push(canon_to_pid.get(canon)); + } + face_pids.set(fi, pids); + } + + // --- Initial positions via BFS unfolding (no depth shrink) --- + const positions = Array.from({ length: pid_count }, () => [0, 0]); + const placed_pid = new Uint8Array(pid_count); + + for (const entry of neighborhood) { + const fi = entry.face.index; + const face = entry.face; + const n = face.size; + const pids = face_pids.get(fi); + + if (entry.parent_index === null) { + const pts = regular_polygon_2d(n, face_size).map(([x, y]) => [x + cx, y + cy]); + for (let i = 0; i < n; i++) { positions[pids[i]] = pts[i]; placed_pid[pids[i]] = 1; } + } else { + const parent_fi = entry.parent_index; + const parent_face = poly.faces[parent_fi]; + const parent_pids = face_pids.get(parent_fi); + const ea = entry.parent_edge_index; + + let eb = -1; + for (let j = 0; j < n; j++) { + if (face.edge_neighbors[j] === parent_fi) { eb = j; break; } + } + if (eb === -1) { continue; } + + const local_pts = regular_polygon_2d(n, 1); + const pa = positions[parent_pids[ea]]; + const pb = positions[parent_pids[(ea + 1) % parent_face.size]]; + const transform = similarity_transform_2d(local_pts[eb], local_pts[(eb + 1) % n], pb, pa); + + for (let i = 0; i < n; i++) { + if (!placed_pid[pids[i]]) { + positions[pids[i]] = apply_transform_2d(transform, local_pts[i]); + placed_pid[pids[i]] = 1; + } + } + } + } + + // --- Collect unique edges --- + const edge_seen = new Set(); + const edges = []; + for (const [, pids] of face_pids) { + const n = pids.length; + for (let i = 0; i < n; i++) { + const u = pids[i], v = pids[(i + 1) % n]; + const ek = u < v ? `${u}:${v}` : `${v}:${u}`; + if (!edge_seen.has(ek)) { edge_seen.add(ek); edges.push([u, v]); } + } + } + + // --- Target length from root face --- + const root_pids = face_pids.get(root_face_index); + let target_len = face_size; + if (root_pids) { + const n = root_pids.length; + let sum = 0; + for (let i = 0; i < n; i++) { + const a = positions[root_pids[i]], b = positions[root_pids[(i + 1) % n]]; + sum += Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2); + } + target_len = sum / n; + } + + // Pin root face positions. + const pinned = new Set(root_pids ?? []); + + // depth_scale(d) = (1 - curvature)^d + const depth_scale = (d) => Math.pow(1 - curvature, d); + + // Per-vertex average depth (for interpolating target length across edges). + const pid_depth_sum = new Float64Array(pid_count); + const pid_depth_count = new Uint32Array(pid_count); + for (const entry of neighborhood) { + for (const pid of face_pids.get(entry.face.index)) { + pid_depth_sum[pid] += entry.depth; + pid_depth_count[pid] += 1; + } + } + const pid_depth = Array.from({ length: pid_count }, + (_, i) => pid_depth_count[i] > 0 ? pid_depth_sum[i] / pid_depth_count[i] : 0); + + const max_move = target_len * MAX_MOVE_FRAC; + + // --- Spring relaxation --- + for (let iter = 0; iter < RELAX_ITERS; iter++) { + const deltas = Array.from({ length: pid_count }, () => [0, 0]); + + // Spring 1: edge-length (depth-scaled target). + for (const [u, v] of edges) { + const pu = positions[u], pv = positions[v]; + const dx = pv[0] - pu[0], dy = pv[1] - pu[1]; + const len = Math.sqrt(dx * dx + dy * dy); + if (len < 1e-6) { continue; } + const t_len = target_len * depth_scale((pid_depth[u] + pid_depth[v]) / 2); + const err = (len - t_len) / len; + const fx = dx * err * 0.5 * ALPHA_EDGE; + const fy = dy * err * 0.5 * ALPHA_EDGE; + if (!pinned.has(u)) { deltas[u][0] += fx; deltas[u][1] += fy; } + if (!pinned.has(v)) { deltas[v][0] -= fx; deltas[v][1] -= fy; } + } + + // Spring 2: radial (vertex-to-centroid → depth-scaled circumradius). + for (const entry of neighborhood) { + const fi = entry.face.index; + const pids = face_pids.get(fi); + const n = pids.length; + const t_r = target_len * depth_scale(entry.depth); + + let fcx = 0, fcy = 0; + for (const pid of pids) { fcx += positions[pid][0]; fcy += positions[pid][1]; } + fcx /= n; fcy /= n; + + for (const pid of pids) { + if (pinned.has(pid)) { continue; } + const vx = positions[pid][0] - fcx; + const vy = positions[pid][1] - fcy; + const dist = Math.sqrt(vx * vx + vy * vy); + if (dist < 1e-6) { continue; } + const err = (dist - t_r) / dist; + deltas[pid][0] -= vx * err * ALPHA_RADIAL; + deltas[pid][1] -= vy * err * ALPHA_RADIAL; + } + } + + // Apply with displacement clamp. + for (let i = 0; i < pid_count; i++) { + if (pinned.has(i)) { continue; } + const dx = deltas[i][0], dy = deltas[i][1]; + const d = Math.sqrt(dx * dx + dy * dy); + const sc = d > max_move ? max_move / d : 1; + positions[i][0] += dx * sc; + positions[i][1] += dy * sc; + } + } + + // --- Build placements map --- + const placements = new Map(); + for (const entry of neighborhood) { + const fi = entry.face.index; + const pts = face_pids.get(fi).map(pid => positions[pid]); + placements.set(fi, { vertices_2d: pts, centroid_2d: centroid_2d(pts), depth: entry.depth }); + } + + return placements; +} diff --git a/standalone/goldberg-sphere/placer_relax_loose.mjs b/standalone/goldberg-sphere/placer_relax_loose.mjs new file mode 100644 index 0000000..63315cc --- /dev/null +++ b/standalone/goldberg-sphere/placer_relax_loose.mjs @@ -0,0 +1,97 @@ +// Loose relaxation: starts from the unfolded layout, then runs spring +// iterations that only pull BFS parent→child shared edges together. +// Sibling-sibling edges are ignored, so gaps remain between depth rings. +// Kept because the partial closing produces an interesting visual effect. +// +// Returns Map + +import { centroid_2d } from './geometry.mjs'; +import { place_unfold } from './placer_unfold.mjs'; + +const RELAX_ITERS = 14; +const RELAX_ALPHA = 0.38; + +export function place_relax_loose(poly, neighborhood, root_face_index, cx, cy, face_size) { + const placements = place_unfold(poly, neighborhood, root_face_index, cx, cy, face_size); + + // Build BFS-tree-only adjacency (parent → child edges). + const adj = []; + for (const entry of neighborhood) { + if (entry.parent_index === null) { continue; } + const fi_b = entry.face.index; + const fi_a = entry.parent_index; + if (!placements.has(fi_a) || !placements.has(fi_b)) { continue; } + + const face_a = poly.faces[fi_a]; + const face_b = entry.face; + const ea = entry.parent_edge_index; + + let eb = -1; + for (let j = 0; j < face_b.edge_neighbors.length; j++) { + if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; } + } + if (eb === -1) { continue; } + + adj.push({ fi_a, na: face_a.size, ea, fi_b, nb: face_b.size, eb }); + } + + // Pin center face vertices so layout stays anchored. + const pinned = new Set(); + const center_pl = placements.get(root_face_index); + if (center_pl) { + for (let i = 0; i < center_pl.vertices_2d.length; i++) { + pinned.add(`${root_face_index}:${i}`); + } + } + + for (let iter = 0; iter < RELAX_ITERS; iter++) { + const deltas = new Map(); + const delta = (fi, vi) => { + const k = `${fi}:${vi}`; + if (!deltas.has(k)) { deltas.set(k, [0, 0]); } + return deltas.get(k); + }; + + for (const { fi_a, na, ea, fi_b, nb, eb } of adj) { + const verts_a = placements.get(fi_a).vertices_2d; + const verts_b = placements.get(fi_b).vertices_2d; + + // A.v[ea] ↔ B.v[(eb+1)%nb] + const a0 = verts_a[ea], b0 = verts_b[(eb + 1) % nb]; + const mx0 = (a0[0] + b0[0]) / 2, my0 = (a0[1] + b0[1]) / 2; + + // A.v[(ea+1)%na] ↔ B.v[eb] + const a1 = verts_a[(ea + 1) % na], b1 = verts_b[eb]; + const mx1 = (a1[0] + b1[0]) / 2, my1 = (a1[1] + b1[1]) / 2; + + if (!pinned.has(`${fi_a}:${ea}`)) { + const d = delta(fi_a, ea); d[0] += mx0 - a0[0]; d[1] += my0 - a0[1]; + } + if (!pinned.has(`${fi_b}:${(eb + 1) % nb}`)) { + const d = delta(fi_b, (eb + 1) % nb); d[0] += mx0 - b0[0]; d[1] += my0 - b0[1]; + } + if (!pinned.has(`${fi_a}:${(ea + 1) % na}`)) { + const d = delta(fi_a, (ea + 1) % na); d[0] += mx1 - a1[0]; d[1] += my1 - a1[1]; + } + if (!pinned.has(`${fi_b}:${eb}`)) { + const d = delta(fi_b, eb); d[0] += mx1 - b1[0]; d[1] += my1 - b1[1]; + } + } + + for (const [key, [dx, dy]] of deltas) { + const colon = key.lastIndexOf(':'); + const fi = parseInt(key.slice(0, colon), 10); + const vi = parseInt(key.slice(colon + 1), 10); + const pl = placements.get(fi); + if (!pl) { continue; } + const v = pl.vertices_2d[vi]; + pl.vertices_2d[vi] = [v[0] + dx * RELAX_ALPHA, v[1] + dy * RELAX_ALPHA]; + } + + for (const pl of placements.values()) { + pl.centroid_2d = centroid_2d(pl.vertices_2d); + } + } + + return placements; +} diff --git a/standalone/goldberg-sphere/placer_unfold.mjs b/standalone/goldberg-sphere/placer_unfold.mjs new file mode 100644 index 0000000..fe18fcd --- /dev/null +++ b/standalone/goldberg-sphere/placer_unfold.mjs @@ -0,0 +1,68 @@ +// Placement via unfolding: each neighbor face is placed adjacent to its BFS +// parent by a similarity transform, then shrunk toward its centroid by +// DEPTH_SHRINK^depth so depth rings visually separate. +// +// Returns Map + +import { + regular_polygon_2d, + centroid_2d, + similarity_transform_2d, + apply_transform_2d, +} from './geometry.mjs'; + +const DEPTH_SHRINK = 0.82; + +export function place_unfold(poly, neighborhood, root_face_index, cx, cy, face_size) { + const placements = new Map(); + + for (const entry of neighborhood) { + const fi = entry.face.index; + const face = entry.face; + const n = face.size; + + if (entry.parent_index === null) { + // Root face: regular polygon centred on canvas. + const pts = regular_polygon_2d(n, face_size).map(([x, y]) => [x + cx, y + cy]); + placements.set(fi, { vertices_2d: pts, centroid_2d: [cx, cy], depth: 0 }); + } else { + const parent_pl = placements.get(entry.parent_index); + if (!parent_pl) { continue; } + + const parent_face = poly.faces[entry.parent_index]; + const pei = entry.parent_edge_index; + const pa = parent_pl.vertices_2d[pei]; + const pb = parent_pl.vertices_2d[(pei + 1) % parent_face.size]; + + // Find which edge of this face leads back to the parent. + let nei = -1; + for (let j = 0; j < face.edge_neighbors.length; j++) { + if (face.edge_neighbors[j] === entry.parent_index) { nei = j; break; } + } + if (nei === -1) { + console.warn(`place_unfold: face ${fi} has no reverse edge to parent ${entry.parent_index}`); + continue; + } + + // Winding is opposite: parent (a→b) ≡ neighbor (b→a). + const local_pts = regular_polygon_2d(n, 1); + const local_a = local_pts[nei]; + const local_b = local_pts[(nei + 1) % n]; + const transform = similarity_transform_2d(local_a, local_b, pb, pa); + + let vertices_2d = local_pts.map(pt => apply_transform_2d(transform, pt)); + const c = centroid_2d(vertices_2d); + + // Shrink toward centroid after transform (before would be cancelled by scaling). + const shrink = Math.pow(DEPTH_SHRINK, entry.depth); + vertices_2d = vertices_2d.map(([x, y]) => [ + c[0] + (x - c[0]) * shrink, + c[1] + (y - c[1]) * shrink, + ]); + + placements.set(fi, { vertices_2d, centroid_2d: c, depth: entry.depth }); + } + } + + return placements; +} diff --git a/standalone/goldberg-sphere/playground_main.mjs b/standalone/goldberg-sphere/playground_main.mjs new file mode 100644 index 0000000..cdd0ca9 --- /dev/null +++ b/standalone/goldberg-sphere/playground_main.mjs @@ -0,0 +1,1236 @@ +// Goldberg sphere build pipeline playground. + +import { Polyhedron, Goldberg_Polyhedron } from './topology.mjs'; +import { mat4_mul, mat4_perspective, mat4_translate_z, quat_identity, quat_from_axis_angle, quat_mul, quat_conj, quat_rotate_vec, mat4_from_quat, mat3_from_mat4 } from './math.mjs'; +import { create_program, make_buffer } from './gl_utils.mjs'; +import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE, Paint_State } from './paint_state.mjs'; +import { Spin_State } from './spin_state.mjs'; +import { shape_distortion, build_triangle_geo, build_goldberg_geo } from './render_geo.mjs'; +import { build_goldberg_adjacency, Maze_State } from './maze.mjs'; +import { Undo_State } from './undo_state.mjs'; +import { Env_State } from './env_state.mjs'; +import { PALETTES, DEFAULT_PALETTE } from './palettes.mjs'; + +// --------------------------------------------------------------------------- +// Load shaders +// --------------------------------------------------------------------------- + +const [face_vert_src, face_frag_src, edge_vert_src, edge_frag_src, wall_vert_src, wall_frag_src] = await Promise.all([ + fetch('./shaders/face.vert').then(r => r.text()), + fetch('./shaders/face.frag').then(r => r.text()), + fetch('./shaders/edge.vert').then(r => r.text()), + fetch('./shaders/edge.frag').then(r => r.text()), + fetch('./shaders/wall.vert').then(r => r.text()), + fetch('./shaders/wall.frag').then(r => r.text()), +]); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const cache = { depth: -1, ico: null, poly: null, goldberg: null, adj: null }; +const maze = new Maze_State(); +const spin = new Spin_State(); +const paint = new Paint_State(); +const undo_state = new Undo_State(); +const env = new Env_State(); +let current_geo = null; +let current_stage = 'relaxed'; +let current_palette = DEFAULT_PALETTE; +let building = false; +let active_brush_side = 'left'; + +const DEBOUNCE_MS = 500; +let paint_dirty = false; +let debounce_timer = null; + +function get_params() { + return { + depth: parseInt(document.getElementById('depth').value, 10), + iters: parseInt(document.getElementById('iters').value, 10), + alpha_edge: parseFloat(document.getElementById('alpha-edge').value), + alpha_centroid: parseFloat(document.getElementById('alpha-centroid').value), + }; +} + +function get_wall_width() { + return parseFloat(document.getElementById('maze-width').value) || 0.015; +} + +function get_maze_params() { + const algo = document.getElementById('maze-algo').value; + return { + algo, + seeds: parseInt(document.getElementById('maze-seeds').value, 10) || 12, + max_cells: parseInt(document.getElementById('maze-cells').value, 10) || 40, + walkers: parseInt(document.getElementById('maze-walkers').value, 10) || 20, + stop_prob: (parseInt(document.getElementById('maze-stop').value, 10) || 0) / 100, + branch_prob:(parseInt(document.getElementById('maze-branch').value, 10) || 0) / 100, + close_prob: (parseInt(document.getElementById('maze-close').value, 10) || 0) / 100, + }; +} + +function maze_applicable() { + return current_stage === 'goldberg' || current_stage === 'relaxed'; +} + +function update_maze() { + const show = document.getElementById('maze-show').checked; + if (!show || !maze_applicable() || !cache.goldberg) { + request_render(); + return; + } + if (!maze.wall_edges) { + maze.generate(cache.goldberg, get_maze_params()); + } + const use_relaxed = current_stage === 'relaxed'; + maze.upload(gl, cache.goldberg, use_relaxed, get_wall_width()); + request_render(); +} + +const status_el = document.getElementById('status'); +const stats_el = document.getElementById('stats'); +function set_status(s) { status_el.textContent = s; } +function set_stats(geo, stage_name, ms) { + const s = geo.stats; + let lines = `Stage: ${stage_name}\n`; + lines += `Faces: ${s.faces}`; + if (s.vertices !== undefined) { lines += ` Verts: ${s.vertices}`; } + if (s.pentagons !== undefined) { lines += ` (${s.pentagons} pent)`; } + lines += '\n'; + if (s.worst_shape !== null) { + lines += `Worst shape_r: ${s.worst_shape.toFixed(4)}\n`; + } + lines += `Build: ${ms}ms`; + stats_el.textContent = lines; +} + +async function yield_ui(ms = 0) { await new Promise(r => setTimeout(r, ms)); } + +// --------------------------------------------------------------------------- +// WebGL setup +// --------------------------------------------------------------------------- + +const canvas = document.getElementById('gl-canvas'); +const gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl'); +if (!gl) { throw new Error('WebGL not available'); } + +gl.enable(gl.DEPTH_TEST); +gl.enable(gl.CULL_FACE); +gl.enable(gl.POLYGON_OFFSET_FILL); +gl.polygonOffset(1, 1); + +const face_prog = create_program(gl, face_vert_src, face_frag_src); +const edge_prog = create_program(gl, edge_vert_src, edge_frag_src); +const wall_prog = create_program(gl, wall_vert_src, wall_frag_src); + +let face_pos_buf = null, face_col_buf = null, face_pbr_buf = null, face_noise_buf = null, edge_pos_buf = null; +let face_verts = 0, edge_verts = 0; + +function upload_geo() { + if (!current_geo) { return; } + if (face_pos_buf) { gl.deleteBuffer(face_pos_buf); } + if (face_col_buf) { gl.deleteBuffer(face_col_buf); } + if (face_pbr_buf) { gl.deleteBuffer(face_pbr_buf); } + if (face_noise_buf) { gl.deleteBuffer(face_noise_buf); } + if (edge_pos_buf) { gl.deleteBuffer(edge_pos_buf); } + face_pos_buf = make_buffer(gl, current_geo.face_pos); + face_col_buf = make_buffer(gl, current_geo.face_col); + face_pbr_buf = make_buffer(gl, current_geo.face_pbr); + face_noise_buf = make_buffer(gl, current_geo.face_noise); + edge_pos_buf = make_buffer(gl, current_geo.edge_pos); + face_verts = current_geo.face_verts; + edge_verts = current_geo.edge_verts; +} + +// Re-upload color, PBR, and noise buffers (positions unchanged) — used by paint mode. +function rebuild_face_colors() { + if (!cache.goldberg || !face_col_buf) { return; } + const goldberg = cache.goldberg; + const use_relaxed = current_stage === 'relaxed'; + const fcol = [], fpbr = [], fnoise = []; + for (let fi = 0; fi < goldberg.faces.length; fi++) { + const face = goldberg.faces[fi]; + const verts = use_relaxed ? (face.relaxed_vertices_3d ?? face.vertices_3d) : face.vertices_3d; + const n = verts.length; + const sr = shape_distortion(verts); + let c, pbr, noise; + if (paint.enabled) { + const in_preview = paint.preview?.faces.has(fi); + c = in_preview ? paint.preview.color : (paint.face_colors.get(fi) ?? PAINT_BG); + pbr = in_preview ? (paint.preview.pbr ?? PAINT_BG_PBR) : (paint.face_pbr.get(fi) ?? PAINT_BG_PBR); + noise = in_preview ? (paint.preview.noise ?? PAINT_BG_NOISE) : (paint.face_noise.get(fi) ?? PAINT_BG_NOISE); + } else { + c = current_palette.face_color(sr, face.is_pentagon); + pbr = [0.0, 0.6]; + noise = PAINT_BG_NOISE; + } + for (let i = 1; i < n - 1; i++) { + for (let j = 0; j < 3; j++) { + fcol.push(c[0], c[1], c[2]); + fpbr.push(pbr[0], pbr[1]); + fnoise.push(noise[0], noise[1], noise[2], noise[3]); + } + } + } + gl.bindBuffer(gl.ARRAY_BUFFER, face_col_buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fcol), gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, face_pbr_buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fpbr), gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, face_noise_buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fnoise), gl.STATIC_DRAW); + request_render(); +} + +// Sync all shared brush UI controls to the current active_brush_side. +function sync_brush_ui() { + const side = active_brush_side; + const noise = paint[`noise_${side}`]; + document.getElementById('paint-color-btn').style.background = paint.hex(side); + const met = paint[`metallic_${side}`]; + const rou = paint[`roughness_${side}`]; + document.getElementById('metallic').value = met; + document.getElementById('metallic-val').textContent = met.toFixed(2); + document.getElementById('roughness').value = rou; + document.getElementById('roughness-val').textContent = rou.toFixed(2); + document.getElementById('noise-scale').value = noise[0]; + document.getElementById('noise-scale-val').textContent = noise[0].toFixed(1); + document.getElementById('noise-strength').value = noise[1]; + document.getElementById('noise-strength-val').textContent = noise[1].toFixed(2); + document.getElementById('noise-octaves').value = noise[2]; + document.getElementById('noise-octaves-val').textContent = String(noise[2]); + document.getElementById('noise-gain').value = noise[3]; + document.getElementById('noise-gain-val').textContent = noise[3].toFixed(2); + // Tab highlight + const on = { background: '#0a2d4a', borderColor: '#4af', color: '#4af' }; + const off = { background: '#0a1a2a', borderColor: '#2a4a6a', color: '#7ab' }; + const apply = (el, s) => { el.style.background = s.background; el.style.borderColor = s.borderColor; el.style.color = s.color; }; + apply(document.getElementById('brush-tab-left'), side === 'left' ? on : off); + apply(document.getElementById('brush-tab-right'), side === 'right' ? on : off); +} + +// Take a snapshot immediately (bypasses debounce — use after build/load). +function snapshot_now() { + if (!cache.goldberg) { return; } + paint_dirty = false; + if (debounce_timer !== null) { clearTimeout(debounce_timer); debounce_timer = null; } + undo_state.push(paint, cache.goldberg.faces.length, active_brush_side); +} + +// Mark paint state dirty; schedule a debounced snapshot. +function mark_dirty() { + paint_dirty = true; + if (debounce_timer !== null) { clearTimeout(debounce_timer); } + debounce_timer = setTimeout(() => { + debounce_timer = null; + if (paint_dirty) { snapshot_now(); } + }, DEBOUNCE_MS); +} + +// Apply undo or redo; restore state and refresh UI. +function apply_history(side) { + if (side === null) { return; } + active_brush_side = side; + sync_brush_ui(); + update_paint_buttons(); + rebuild_face_colors(); +} + +// --------------------------------------------------------------------------- +// Build pipeline +// --------------------------------------------------------------------------- + +async function build() { + if (building) { return; } + building = true; + const btn = document.getElementById('build-btn'); + btn.disabled = true; + + const p = get_params(); + const t0 = Date.now(); + + try { + const depth_changed = cache.depth !== p.depth; + + if (depth_changed || !cache.ico) { + set_status('Building icosahedron…'); await yield_ui(30); + cache.ico = Polyhedron.build_icosahedron(); + cache.poly = null; + cache.goldberg = null; + } + if (current_stage === 'ico') { + current_geo = build_triangle_geo(cache.ico, current_palette); + upload_geo(); + cache.depth = p.depth; + set_stats(current_geo, 'Icosahedron', Date.now() - t0); + set_status(''); + return; + } + + if (depth_changed || !cache.poly) { + let poly = cache.ico; + for (let i = 0; i < p.depth; i++) { + set_status(`Subdividing ${i + 1}/${p.depth}…`); await yield_ui(30); + poly = poly.subdivide(); + } + cache.poly = poly; + cache.goldberg = null; + } + if (current_stage === 'subdiv') { + current_geo = build_triangle_geo(cache.poly, current_palette); + upload_geo(); + cache.depth = p.depth; + set_stats(current_geo, `Subdivided ×${p.depth}`, Date.now() - t0); + set_status(''); + return; + } + + if (depth_changed || !cache.goldberg) { + set_status('Building Goldberg dual…'); await yield_ui(30); + cache.goldberg = Goldberg_Polyhedron.from_subdivided(cache.poly); + cache.adj = null; + maze.invalidate(gl); + undo_state.invalidate(); + } + if (current_stage === 'goldberg') { + current_geo = build_goldberg_geo(cache.goldberg, false, paint, current_palette); + upload_geo(); + cache.depth = p.depth; + set_stats(current_geo, 'Goldberg (no relax)', Date.now() - t0); + set_status(''); + return; + } + + set_status(`Relaxing (${p.iters} iters, edge=${p.alpha_edge}, centroid=${p.alpha_centroid})…`); + await yield_ui(50); + cache.goldberg.relax_sphere(p.iters, p.alpha_edge, p.alpha_centroid); + current_geo = build_goldberg_geo(cache.goldberg, true, paint, current_palette); + upload_geo(); + cache.depth = p.depth; + set_stats(current_geo, 'Goldberg Relaxed', Date.now() - t0); + set_status(''); + + } finally { + building = false; + btn.disabled = false; + update_maze(); + snapshot_now(); + } +} + +// --------------------------------------------------------------------------- +// Face picking +// --------------------------------------------------------------------------- + +function pick_face(px, py) { + if (!cache.goldberg) { return -1; } + const fov = Math.PI / 3; + const aspect = canvas.width / canvas.height; + const tan_half = Math.tan(fov / 2); + const nx = (2 * px / canvas.width) - 1; + const ny = 1 - (2 * py / canvas.height); + + const vx = nx * tan_half * aspect, vy = ny * tan_half, vz = -1; + const vr = Math.sqrt(vx*vx + vy*vy + vz*vz); + const rd = quat_rotate_vec(quat_conj(rot_quat), [vx/vr, vy/vr, vz/vr]); + const ro = quat_rotate_vec(quat_conj(rot_quat), [0, 0, cam_dist]); + + const a = rd[0]*rd[0] + rd[1]*rd[1] + rd[2]*rd[2]; + const b = 2*(ro[0]*rd[0] + ro[1]*rd[1] + ro[2]*rd[2]); + const c = ro[0]*ro[0] + ro[1]*ro[1] + ro[2]*ro[2] - 1; + const d = b*b - 4*a*c; + if (d < 0) { return -1; } + const t = (-b - Math.sqrt(d)) / (2*a); + if (t < 0) { return -1; } + const hx = ro[0]+t*rd[0], hy = ro[1]+t*rd[1], hz = ro[2]+t*rd[2]; + + const goldberg = cache.goldberg; + const use_relaxed = current_stage === 'relaxed'; + let best_fi = -1, best_dot = -Infinity; + for (let fi = 0; fi < goldberg.faces.length; fi++) { + const face = goldberg.faces[fi]; + const verts = use_relaxed ? (face.relaxed_vertices_3d ?? face.vertices_3d) : face.vertices_3d; + let cx = 0, cy = 0, cz = 0; + for (const v of verts) { cx += v.x; cy += v.y; cz += v.z; } + const n = verts.length; + const dot = (cx/n)*hx + (cy/n)*hy + (cz/n)*hz; + if (dot > best_dot) { best_dot = dot; best_fi = fi; } + } + return best_fi; +} + +// Flood-fill from start_fi, replacing all connected same-material faces. +// Target material is identified by the pre-computed key stored at draw time. +function flood_fill(start_fi, button) { + if (!cache.goldberg) { return; } + if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); } + const adj = cache.adj; + const target_key = paint.face_key.get(start_fi) ?? ''; + const queue = [start_fi]; + const visited = new Set(queue); + while (queue.length > 0) { + const fi = queue.shift(); + paint.paint(fi, button); + for (const { fj } of adj[fi]) { + if (!visited.has(fj) && (paint.face_key.get(fj) ?? '') === target_key) { + visited.add(fj); + queue.push(fj); + } + } + } + rebuild_face_colors(); + mark_dirty(); +} + +// BFS shortest path between two faces; returns array of face indices. +function face_shortest_path(start_fi, end_fi) { + if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); } + if (start_fi === end_fi) { return [start_fi]; } + const adj = cache.adj; + const prev = new Map([[start_fi, -1]]); + const queue = [start_fi]; + outer: while (queue.length > 0) { + const fi = queue.shift(); + for (const { fj } of adj[fi]) { + if (!prev.has(fj)) { + prev.set(fj, fi); + if (fj === end_fi) { break outer; } + queue.push(fj); + } + } + } + if (!prev.has(end_fi)) { return [start_fi]; } + const path = []; + for (let cur = end_fi; cur !== -1; cur = prev.get(cur)) { path.push(cur); } + return path.reverse(); +} + +// BFS from center_fi up to `radius` hops; returns array of all face indices within radius. +function faces_in_radius(center_fi, radius) { + if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); } + const adj = cache.adj; + const visited = new Set([center_fi]); + const queue = [{ fi: center_fi, dist: 0 }]; + while (queue.length > 0) { + const { fi, dist } = queue.shift(); + if (dist >= radius) { continue; } + for (const { fj } of adj[fi]) { + if (!visited.has(fj)) { + visited.add(fj); + queue.push({ fi: fj, dist: dist + 1 }); + } + } + } + return [...visited]; +} + +// Return only faces at exactly `radius` BFS hops from center_fi (the ring). +function faces_at_radius(center_fi, radius) { + if (radius === 0) { return [center_fi]; } + if (!cache.adj) { cache.adj = build_goldberg_adjacency(cache.goldberg); } + const adj = cache.adj; + const dist = new Map([[center_fi, 0]]); + const queue = [center_fi]; + while (queue.length > 0) { + const fi = queue.shift(); + const d = dist.get(fi); + if (d >= radius) { continue; } + for (const { fj } of adj[fi]) { + if (!dist.has(fj)) { + dist.set(fj, d + 1); + queue.push(fj); + } + } + } + const result = []; + for (const [fi, d] of dist) { if (d === radius) { result.push(fi); } } + return result; +} + +// --------------------------------------------------------------------------- +// Paint save / load +// --------------------------------------------------------------------------- + +function save_paint() { + const depth = parseInt(document.getElementById('depth').value); + const faces = []; + for (const [fi, c] of paint.face_colors) { + const h = v => Math.round(v*255).toString(16).padStart(2, '0'); + const hex = `#${h(c[0])}${h(c[1])}${h(c[2])}`; + const pbr = paint.face_pbr.get(fi) ?? PAINT_BG_PBR; + faces.push([fi, hex, pbr[0], pbr[1]]); + } + const json = JSON.stringify({ version: 2, depth, faces }, null, 0); + const name = prompt('Save as:', `sphere-paint-d${depth}`); + if (!name) { return; } + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' })); + a.download = name.endsWith('.json') ? name : `${name}.json`; + a.click(); + URL.revokeObjectURL(a.href); +} + +function load_paint(file) { + const reader = new FileReader(); + reader.onload = e => { + try { + const data = JSON.parse(e.target.result); + const depth = parseInt(document.getElementById('depth').value); + if (data.depth !== depth) { + document.getElementById('depth').value = data.depth; + document.getElementById('depth-val').textContent = data.depth; + cache.poly = null; cache.goldberg = null; cache.adj = null; + maze.invalidate(gl); + } + paint.face_colors.clear(); + paint.face_pbr.clear(); + if (data.version >= 2) { + for (const [fi, hex, met, rou] of data.faces) { + paint.face_colors.set(fi, paint._parse_hex(hex)); + paint.face_pbr.set(fi, [met ?? 0.0, rou ?? 0.6]); + } + } else { + for (const [fi, hex] of data.colors) { + paint.face_colors.set(fi, paint._parse_hex(hex)); + } + } + if (!paint.enabled) { paint.enabled = true; update_paint_buttons(); } + build(); + } catch(err) { + console.error('Load failed:', err); + } + }; + reader.readAsText(file); +} + +// --------------------------------------------------------------------------- +// Camera +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Noise presets (scale, strength, octaves, gain) +// --------------------------------------------------------------------------- + +const NOISE_PRESETS = { + flat: [0, 0, 4, 0.5 ], + snow: [12, 0.6, 5, 0.5 ], + water: [5, 0.2, 3, 0.45], + dunes: [3, 1.2, 2, 0.6 ], + rock: [9, 0.75, 6, 0.5 ], +}; + +// --------------------------------------------------------------------------- +// Camera +// --------------------------------------------------------------------------- + +let rot_quat = quat_identity(); +let cam_dist = 3.0; +let drag = null; + +canvas.addEventListener('contextmenu', e => e.preventDefault()); + +canvas.addEventListener('mousedown', e => { + if (e.button === 1) { + e.preventDefault(); + spin.stop(); + document.getElementById('spin-btn').classList.remove('active'); + + const rect = canvas.getBoundingClientRect(); + const px = e.clientX - rect.left, py = e.clientY - rect.top; + const fov = Math.PI / 3; + const aspect = canvas.width / canvas.height; + const tan_half = Math.tan(fov / 2); + const nx = (2*px/canvas.width)-1, ny = 1-(2*py/canvas.height); + const vx = nx*tan_half*aspect, vy = ny*tan_half, vz = -1; + const vr = Math.sqrt(vx*vx+vy*vy+vz*vz); + const rd = quat_rotate_vec(quat_conj(rot_quat), [vx/vr, vy/vr, vz/vr]); + const ro = quat_rotate_vec(quat_conj(rot_quat), [0, 0, cam_dist]); + const ra = rd[0]*rd[0]+rd[1]*rd[1]+rd[2]*rd[2]; + const rb = 2*(ro[0]*rd[0]+ro[1]*rd[1]+ro[2]*rd[2]); + const rc = ro[0]*ro[0]+ro[1]*ro[1]+ro[2]*ro[2]-1; + const hits_sphere = (rb*rb - 4*ra*rc) >= 0; + + if (hits_sphere) { + drag = { rotating: true, mode: 'trackball', x: e.clientX, y: e.clientY }; + } else { + const cx = canvas.width/2, cy = canvas.height/2; + drag = { rotating: true, mode: 'roll', angle: Math.atan2(py-cy, px-cx) }; + } + return; + } + if (paint.enabled && (e.button === 0 || e.button === 2)) { + const rect = canvas.getBoundingClientRect(); + const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top); + if (fi >= 0) { + if (paint.tool === 'fill') { + flood_fill(fi, e.button); + } else if (paint.tool === 'pick') { + const side = active_brush_side; + const c = paint.face_colors.get(fi) ?? PAINT_BG; + const pbr = paint.face_pbr.get(fi) ?? PAINT_BG_PBR; + const noise = paint.face_noise.get(fi) ?? PAINT_BG_NOISE; + paint[`color_${side}`] = [...c]; + paint[`metallic_${side}`] = pbr[0]; + paint[`roughness_${side}`]= pbr[1]; + paint[`noise_${side}`] = [...noise]; + sync_brush_ui(); + } else if (paint.tool === 'brush') { + const radius = parseInt(document.getElementById('brush-radius').value, 10); + const faces = faces_in_radius(fi, radius); + drag = { painting: true, button: e.button, last_fi: fi }; + paint.clear_preview(); + for (const fj of faces) { paint.paint(fj, e.button); } + rebuild_face_colors(); + mark_dirty(); + } else if (paint.tool === 'circle') { + drag = { painting: true, button: e.button, circle_center: fi }; + paint.set_preview([fi], e.button); + rebuild_face_colors(); + } else if (paint.tool === 'line') { + drag = { painting: true, button: e.button, line_start: fi }; + paint.set_preview([fi], e.button); + rebuild_face_colors(); + } else { + drag = { painting: true, button: e.button }; + paint.paint(fi, e.button); + rebuild_face_colors(); + mark_dirty(); + } + } else if (paint.tool === 'pen' || paint.tool === 'brush') { + drag = { painting: true, button: e.button, last_fi: -1 }; + } + } +}); + +canvas.addEventListener('mousemove', e => { + if (!drag) { + // Brush hover preview when not dragging. + if (paint.enabled && paint.tool === 'brush' && cache.goldberg) { + const rect = canvas.getBoundingClientRect(); + const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top); + const radius = parseInt(document.getElementById('brush-radius').value, 10); + if (fi >= 0) { + paint.set_preview(faces_in_radius(fi, radius), 0); + } else { + paint.clear_preview(); + } + rebuild_face_colors(); + } + return; + } + if (drag.rotating) { + if (drag.mode === 'roll') { + const rect = canvas.getBoundingClientRect(); + const px = e.clientX - rect.left, py = e.clientY - rect.top; + const cx = canvas.width/2, cy = canvas.height/2; + const angle = Math.atan2(py-cy, px-cx); + let delta = angle - drag.angle; + if (delta > Math.PI) { delta -= 2*Math.PI; } + if (delta < -Math.PI) { delta += 2*Math.PI; } + drag.angle = angle; + rot_quat = quat_mul(quat_from_axis_angle(0, 0, 1, -delta), rot_quat); + } else { + const sens = 2 * Math.tan(Math.PI / 6) * (cam_dist - 1) / canvas.height; + const dx = (e.clientX - drag.x) * sens; + const dy = (e.clientY - drag.y) * sens; + drag.x = e.clientX; drag.y = e.clientY; + rot_quat = quat_mul(quat_mul(quat_from_axis_angle(0, 1, 0, dx), quat_from_axis_angle(1, 0, 0, dy)), rot_quat); + } + request_render(); + return; + } + if (drag.painting) { + const rect = canvas.getBoundingClientRect(); + const fi = pick_face(e.clientX - rect.left, e.clientY - rect.top); + if (drag.line_start !== undefined) { + if (fi >= 0) { + const path = face_shortest_path(drag.line_start, fi); + paint.set_preview(path, drag.button); + rebuild_face_colors(); + } + } else if (paint.tool === 'brush') { + if (fi >= 0 && fi !== drag.last_fi) { + drag.last_fi = fi; + const radius = parseInt(document.getElementById('brush-radius').value, 10); + const faces = faces_in_radius(fi, radius); + for (const fj of faces) { paint.paint(fj, drag.button); } + rebuild_face_colors(); + mark_dirty(); + } + } else if (drag.circle_center !== undefined) { + if (fi >= 0) { + const path = face_shortest_path(drag.circle_center, fi); + const radius = path.length - 1; + paint.set_preview(faces_at_radius(drag.circle_center, radius), drag.button); + rebuild_face_colors(); + } + } else if (fi >= 0) { + paint.paint(fi, drag.button); + rebuild_face_colors(); + mark_dirty(); + } + return; + } +}); + +canvas.addEventListener('mouseup', e => { + if (!drag) { return; } + if (e.button === 1 && drag.rotating) { drag = null; return; } + if (drag.painting) { + if (drag.line_start !== undefined || drag.circle_center !== undefined) { + paint.commit_preview(); + rebuild_face_colors(); + mark_dirty(); + } + drag = null; + } +}); + +canvas.addEventListener('mouseleave', () => { + paint.clear_preview(); + rebuild_face_colors(); + drag = null; +}); + +canvas.addEventListener('wheel', e => { + cam_dist = Math.max(1.05, Math.min(10, cam_dist * (1 + e.deltaY * 0.001))); + e.preventDefault(); + request_render(); +}, { passive: false }); + +// --------------------------------------------------------------------------- +// Resize / render +// --------------------------------------------------------------------------- + +function resize() { + const wrap = document.getElementById('canvas-wrap'); + canvas.width = wrap.clientWidth; + canvas.height = wrap.clientHeight; + gl.viewport(0, 0, canvas.width, canvas.height); + request_render(); +} +window.addEventListener('resize', resize); + +let render_queued = false; +function request_render() { + if (!render_queued) { render_queued = true; requestAnimationFrame(render_frame); } +} + +function render_frame(ts) { + render_queued = false; + + if (spin.active) { + const dt = spin.last_t !== null ? ts - spin.last_t : 0; + spin.last_t = ts; + rot_quat = spin.tick(dt, rot_quat); + render_queued = true; + requestAnimationFrame(render_frame); + } + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0.03, 0.04, 0.08, 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + if (!face_pos_buf) { return; } + + const aspect = canvas.width / canvas.height; + const rot = mat4_from_quat(rot_quat); + const mvp = mat4_mul(mat4_perspective(Math.PI/3, aspect, 0.01, 100), mat4_mul(mat4_translate_z(-cam_dist), rot)); + const norm = mat3_from_mat4(rot); + const cam_wpos = new Float32Array([0, 0, cam_dist]); + + gl.useProgram(face_prog); + gl.uniformMatrix4fv(gl.getUniformLocation(face_prog, 'u_mvp'), false, mvp); + gl.uniformMatrix3fv(gl.getUniformLocation(face_prog, 'u_norm'), false, norm); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_cam_pos'), cam_wpos); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light1_dir'), env.light1_dir()); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light1_color'), env.light1_col()); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light2_dir'), env.light2_dir()); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_light2_color'), env.light2_col()); + gl.uniform3fv(gl.getUniformLocation(face_prog, 'u_ambient'), env.ambient_col()); + const ap = gl.getAttribLocation(face_prog, 'a_pos'); + const ac = gl.getAttribLocation(face_prog, 'a_color'); + const apb = gl.getAttribLocation(face_prog, 'a_pbr'); + const an = gl.getAttribLocation(face_prog, 'a_noise'); + gl.bindBuffer(gl.ARRAY_BUFFER, face_pos_buf); gl.enableVertexAttribArray(ap); gl.vertexAttribPointer(ap, 3, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, face_col_buf); gl.enableVertexAttribArray(ac); gl.vertexAttribPointer(ac, 3, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, face_pbr_buf); gl.enableVertexAttribArray(apb); gl.vertexAttribPointer(apb, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, face_noise_buf); gl.enableVertexAttribArray(an); gl.vertexAttribPointer(an, 4, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.TRIANGLES, 0, face_verts); + gl.disableVertexAttribArray(ap); gl.disableVertexAttribArray(ac); gl.disableVertexAttribArray(apb); gl.disableVertexAttribArray(an); + + gl.disable(gl.POLYGON_OFFSET_FILL); + gl.useProgram(edge_prog); + gl.uniformMatrix4fv(gl.getUniformLocation(edge_prog, 'u_mvp'), false, mvp); + const ep = gl.getAttribLocation(edge_prog, 'a_pos'); + gl.bindBuffer(gl.ARRAY_BUFFER, edge_pos_buf); gl.enableVertexAttribArray(ep); gl.vertexAttribPointer(ep, 3, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.LINES, 0, edge_verts); + gl.disableVertexAttribArray(ep); + + const show_maze = document.getElementById('maze-show').checked && maze_applicable(); + maze.draw(gl, wall_prog, mvp, show_maze); + + gl.enable(gl.POLYGON_OFFSET_FILL); +} + +// --------------------------------------------------------------------------- +// UI wiring +// --------------------------------------------------------------------------- + +// Palette buttons (built dynamically from PALETTES array). +{ + const grid = document.getElementById('palette-grid'); + for (const p of PALETTES) { + const btn = document.createElement('button'); + btn.className = 'sb' + (p === DEFAULT_PALETTE ? ' active' : ''); + btn.textContent = p.name; + btn.addEventListener('click', () => { + current_palette = p; + grid.querySelectorAll('.sb').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + rebuild_face_colors(); + // Triangle stages need a full rebuild since positions are shared. + if (current_stage === 'ico' || current_stage === 'subdiv') { build(); } + }); + grid.appendChild(btn); + } +} + +document.querySelectorAll('.sb').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.sb').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + current_stage = btn.dataset.stage; + build(); + }); +}); + +document.getElementById('depth').addEventListener('input', () => { + const d = parseInt(document.getElementById('depth').value); + document.getElementById('depth-val').textContent = d; + document.getElementById('iters').value = d * 8; + cache.poly = null; + cache.goldberg = null; + cache.adj = null; + maze.invalidate(gl); +}); + +document.getElementById('build-btn').addEventListener('click', () => { + if (current_stage === 'relaxed') { + cache.goldberg = null; + cache.adj = null; + } + build(); +}); + +document.getElementById('maze-algo').addEventListener('change', e => { + const is_walker = e.target.value === 'walker'; + document.getElementById('prim-params').style.display = is_walker ? 'none' : ''; + document.getElementById('walker-params').style.display = is_walker ? '' : 'none'; +}); + +document.getElementById('maze-show').addEventListener('change', () => { update_maze(); }); +document.getElementById('maze-width').addEventListener('input', () => { update_maze(); }); +document.getElementById('maze-btn').addEventListener('click', () => { maze.invalidate(gl); update_maze(); }); + +document.getElementById('spin-btn').addEventListener('click', () => { + const btn = document.getElementById('spin-btn'); + if (spin.active) { + spin.stop(); + btn.classList.remove('active'); + } else { + spin.start(); + btn.classList.add('active'); + request_render(); + } +}); + +const update_paint_buttons = () => { + const enabled = paint.enabled; + document.getElementById('paint-btn').classList.toggle('active', enabled && paint.tool === 'pen'); + document.getElementById('paint-brush-btn').classList.toggle('active', enabled && paint.tool === 'brush'); + document.getElementById('paint-circle-btn').classList.toggle('active', enabled && paint.tool === 'circle'); + document.getElementById('paint-fill-btn').classList.toggle('active', enabled && paint.tool === 'fill'); + document.getElementById('paint-line-btn').classList.toggle('active', enabled && paint.tool === 'line'); + document.getElementById('paint-pick-btn').classList.toggle('active', enabled && paint.tool === 'pick'); + document.getElementById('brush-params').style.display = (enabled && paint.tool === 'brush') ? '' : 'none'; + canvas.style.cursor = enabled ? (paint.tool === 'pick' ? 'cell' : 'crosshair') : 'grab'; + if (!enabled) { paint.clear_preview(); rebuild_face_colors(); } +}; + +const make_tool_handler = tool => () => { + if (!paint.enabled || paint.tool !== tool) { + paint.enabled = true; + paint.tool = tool; + } else { + paint.enabled = false; + } + update_paint_buttons(); +}; + +document.getElementById('paint-btn').addEventListener('click', make_tool_handler('pen')); +document.getElementById('paint-brush-btn').addEventListener('click', make_tool_handler('brush')); +document.getElementById('paint-circle-btn').addEventListener('click', make_tool_handler('circle')); +document.getElementById('paint-fill-btn').addEventListener('click', make_tool_handler('fill')); +document.getElementById('paint-line-btn').addEventListener('click', make_tool_handler('line')); +document.getElementById('paint-pick-btn').addEventListener('click', make_tool_handler('pick')); + +// Brush tab switcher. +document.getElementById('brush-tab-left').addEventListener('click', () => { + active_brush_side = 'left'; + sync_brush_ui(); +}); +document.getElementById('brush-tab-right').addEventListener('click', () => { + active_brush_side = 'right'; + sync_brush_ui(); +}); + +// Color preview button opens the picker dialog. +document.getElementById('paint-color-btn').addEventListener('click', () => color_dialog_open({ + get: () => paint.hex(active_brush_side), + set: (hex) => set_brush_color(hex), +})); + +// set_brush_color: apply hex to active brush and refresh the preview swatch. +function set_brush_color(hex) { + if (active_brush_side === 'left') { paint.set_hex_left(hex); } else { paint.set_hex_right(hex); } + document.getElementById('paint-color-btn').style.background = hex; +} +document.getElementById('metallic').addEventListener('input', e => { + const val = parseFloat(e.target.value); + document.getElementById('metallic-val').textContent = val.toFixed(2); + paint[`metallic_${active_brush_side}`] = val; +}); +document.getElementById('roughness').addEventListener('input', e => { + const val = parseFloat(e.target.value); + document.getElementById('roughness-val').textContent = val.toFixed(2); + paint[`roughness_${active_brush_side}`] = val; +}); + +// Brush radius display. +document.getElementById('brush-radius').addEventListener('input', e => { + document.getElementById('brush-radius-val').textContent = e.target.value; +}); + +document.getElementById('paint-clear').addEventListener('click', () => { + paint.clear(); + rebuild_face_colors(); + mark_dirty(); +}); + +document.getElementById('paint-save').addEventListener('click', () => { save_paint(); }); + +// --------------------------------------------------------------------------- +// Noise UI (shared sliders, apply to active_brush_side) +// --------------------------------------------------------------------------- + +document.querySelectorAll('.noise-preset').forEach(btn => { + btn.addEventListener('click', () => { + const preset = NOISE_PRESETS[btn.dataset.preset]; + if (!preset) { return; } + paint[`noise_${active_brush_side}`] = [...preset]; + sync_brush_ui(); + }); +}); + +const make_noise_slider = (idx, decimals) => e => { + const val = parseFloat(e.target.value); + paint[`noise_${active_brush_side}`][idx] = val; + e.target.nextElementSibling.textContent = decimals === 0 ? String(val) : val.toFixed(decimals); +}; +document.getElementById('noise-scale').addEventListener('input', make_noise_slider(0, 1)); +document.getElementById('noise-strength').addEventListener('input', make_noise_slider(1, 2)); +document.getElementById('noise-octaves').addEventListener('input', make_noise_slider(2, 0)); +document.getElementById('noise-gain').addEventListener('input', make_noise_slider(3, 2)); + +document.getElementById('paint-load').addEventListener('click', () => { + document.getElementById('paint-load-input').click(); +}); +document.getElementById('paint-load-input').addEventListener('change', e => { + if (e.target.files[0]) { load_paint(e.target.files[0]); e.target.value = ''; } +}); + +// Init all shared UI to match left brush defaults. +sync_brush_ui(); + +// --------------------------------------------------------------------------- +// Color picker dialog +// --------------------------------------------------------------------------- + +const COLOR_PALETTES = { + 'Earth': [ + '#1b3a1f','#2d5a27','#3a7d44','#588157','#7fad6e','#a3b18a', + '#c8c49a','#dad7cd','#e8dfc8','#c9a96e','#b08040','#8b5e3c', + '#6b4226','#4a2c18','#2c4a6e','#3d7ab5','#7fb3d3','#b0d4e8', + ], + 'Vivid': [ + '#e63946','#c1121f','#f4623a','#f77f00','#f9c74f','#fcca46', + '#c8e06a','#a1c181','#52b788','#2d9b6e','#619b8a','#2196f3', + '#1565c0','#6a4c9c','#9c27b0','#d81b60','#ff4081','#00bcd4', + ], + 'Pastel': [ + '#ffadad','#ffb3c1','#ffc8dd','#ffd6a5','#fde4cf','#fdffb6', + '#e8f5a3','#caffbf','#b7e4c7','#9bf6ff','#caf0f8','#a0c4ff', + '#c0cfff','#bdb2ff','#d4b8f0','#ffc6ff','#ffd6f0','#fff0f3', + ], + 'Nordic': [ + '#0d1117','#1a1f2e','#2e3440','#3b4252','#434c5e','#4c566a', + '#6e7f96','#8898b0','#d8dee9','#e5e9f0','#eceff4','#8fbcbb', + '#88c0d0','#81a1c1','#5e81ac','#bf616a','#d08770','#ebcb8b', + ], + 'Neon': [ + '#ff0080','#ff1744','#ff4400','#ff6d00','#ffcc00','#c6ff00', + '#00ff41','#00e676','#00ffcc','#00eeff','#00b0ff','#0088ff', + '#2979ff','#7700ff','#aa00ff','#cc00ff','#ff00ff','#ff0099', + ], + 'Stone': [ + '#0a0a0a','#1a1a1a','#2d2d2d','#3f3f3f','#555555','#6b6b6b', + '#808080','#999999','#b0b0b0','#c8c8c8','#dcdcdc','#f0f0f0', + '#8b7355','#a08060','#b8956a','#d4b896','#e8d5b4','#f5efe6', + ], +}; + +// Small sidebar swatches (quick pick, no dialog). +{ + const pal_names = Object.keys(COLOR_PALETTES); + const tab_bar = document.getElementById('sidebar-swatch-tabs'); + const grid = document.getElementById('sidebar-swatch-grid'); + + function sidebar_show_palette(name) { + tab_bar.querySelectorAll('.swatch-tab').forEach(t => t.classList.toggle('active', t.dataset.pal === name)); + grid.innerHTML = ''; + for (const hex of COLOR_PALETTES[name]) { + const sw = document.createElement('div'); + sw.className = 'swatch'; + sw.style.background = hex; + sw.title = hex; + sw.addEventListener('click', () => set_brush_color(hex)); + grid.appendChild(sw); + } + } + for (const name of pal_names) { + const tab = document.createElement('button'); + tab.className = 'swatch-tab'; + tab.dataset.pal = name; + tab.textContent = name; + tab.addEventListener('click', () => sidebar_show_palette(name)); + tab_bar.appendChild(tab); + } + sidebar_show_palette(pal_names[0]); +} + +// Color conversion utilities. +function _hex_to_rgb(hex) { + return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; +} +function _rgb_to_hex(r,g,b) { + return '#'+[r,g,b].map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0')).join(''); +} +function _rgb_to_hsl(r,g,b) { + r/=255; g/=255; b/=255; + const mx=Math.max(r,g,b), mn=Math.min(r,g,b), l=(mx+mn)/2; + if (mx===mn) { return [0, 0, Math.round(l*100)]; } + const d=mx-mn, s=l>0.5?d/(2-mx-mn):d/(mx+mn); + let h = mx===r ? (g-b)/d+(ghex, set: (hex)=>void } — set by color_dialog_open. + let cd_target = null; + + function cd_update_from_hex(hex, source) { + preview.style.background = hex; + const [r,g,b] = _hex_to_rgb(hex); + const [h,s,l] = _rgb_to_hsl(r,g,b); + if (source !== 'hsl') { + document.getElementById('cd-h').value = h; document.getElementById('cd-h-val').textContent = h; + document.getElementById('cd-s').value = s; document.getElementById('cd-s-val').textContent = s+'%'; + document.getElementById('cd-l').value = l; document.getElementById('cd-l-val').textContent = l+'%'; + } + if (source !== 'rgb') { + document.getElementById('cd-r').value = Math.round(r); document.getElementById('cd-r-val').textContent = Math.round(r); + document.getElementById('cd-g').value = Math.round(g); document.getElementById('cd-g-val').textContent = Math.round(g); + document.getElementById('cd-b').value = Math.round(b); document.getElementById('cd-b-val').textContent = Math.round(b); + } + if (source !== 'hex') { hex_input.value = hex; } + if (cd_target) { cd_target.set(hex); } + } + + // Model tab switching. + dlg.querySelectorAll('.cd-model-tab').forEach(tab => { + tab.addEventListener('click', () => { + active_model = tab.dataset.model; + dlg.querySelectorAll('.cd-model-tab').forEach(t => t.classList.toggle('active', t===tab)); + Object.entries(panels).forEach(([k,el]) => { el.style.display = k===active_model ? '' : 'none'; }); + }); + }); + + // HSL sliders. + const hsl_update = () => { + const [r,g,b] = _hsl_to_rgb(+document.getElementById('cd-h').value, +document.getElementById('cd-s').value, +document.getElementById('cd-l').value); + document.getElementById('cd-h-val').textContent = document.getElementById('cd-h').value; + document.getElementById('cd-s-val').textContent = document.getElementById('cd-s').value+'%'; + document.getElementById('cd-l-val').textContent = document.getElementById('cd-l').value+'%'; + cd_update_from_hex(_rgb_to_hex(r,g,b), 'hsl'); + }; + ['cd-h','cd-s','cd-l'].forEach(id => document.getElementById(id).addEventListener('input', hsl_update)); + + // RGB sliders. + const rgb_update = () => { + const r=+document.getElementById('cd-r').value, g=+document.getElementById('cd-g').value, b=+document.getElementById('cd-b').value; + document.getElementById('cd-r-val').textContent = r; + document.getElementById('cd-g-val').textContent = g; + document.getElementById('cd-b-val').textContent = b; + cd_update_from_hex(_rgb_to_hex(r,g,b), 'rgb'); + }; + ['cd-r','cd-g','cd-b'].forEach(id => document.getElementById(id).addEventListener('input', rgb_update)); + + // Hex text input. + hex_input.addEventListener('input', () => { + const v = hex_input.value.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { cd_update_from_hex(v, 'hex'); } + }); + + // Swatch palettes inside dialog. + const pal_names = Object.keys(COLOR_PALETTES); + const tab_bar = document.getElementById('cd-swatch-tabs'); + const sw_grid = document.getElementById('cd-swatch-grid'); + + function cd_show_palette(name) { + tab_bar.querySelectorAll('.swatch-tab').forEach(t => t.classList.toggle('active', t.dataset.pal === name)); + sw_grid.innerHTML = ''; + for (const hex of COLOR_PALETTES[name]) { + const sw = document.createElement('div'); + sw.className = 'swatch'; + sw.style.background = hex; + sw.title = hex; + sw.addEventListener('click', () => cd_update_from_hex(hex, null)); + sw_grid.appendChild(sw); + } + } + for (const name of pal_names) { + const tab = document.createElement('button'); + tab.className = 'swatch-tab'; + tab.dataset.pal = name; + tab.textContent = name; + tab.addEventListener('click', () => cd_show_palette(name)); + tab_bar.appendChild(tab); + } + cd_show_palette(pal_names[0]); + + document.getElementById('cd-close').addEventListener('click', () => dlg.close()); + dlg.addEventListener('click', e => { if (e.target === dlg) { dlg.close(); } }); + + // Open dialog for any color target: { get: ()=>hex, set: (hex)=>void }. + window.color_dialog_open = (target) => { + cd_target = target; + cd_update_from_hex(target.get(), null); + dlg.showModal(); + }; +} + +// --------------------------------------------------------------------------- +// Environment UI +// --------------------------------------------------------------------------- + +{ + const wire_light = (prefix, light_num) => { + const azim_el = document.getElementById(`${prefix}-azim`); + const elev_el = document.getElementById(`${prefix}-elev`); + const col_btn = document.getElementById(`${prefix}-color-btn`); + const int_el = document.getElementById(`${prefix}-intensity`); + + azim_el.addEventListener('input', () => { + document.getElementById(`${prefix}-azim-val`).textContent = azim_el.value + '°'; + env[`light${light_num}_azim`] = parseFloat(azim_el.value); + request_render(); + }); + elev_el.addEventListener('input', () => { + document.getElementById(`${prefix}-elev-val`).textContent = elev_el.value + '°'; + env[`light${light_num}_elev`] = parseFloat(elev_el.value); + request_render(); + }); + col_btn.addEventListener('click', () => color_dialog_open({ + get: () => env[`hex_light${light_num}`](), + set: (hex) => { env[`set_hex_light${light_num}`](hex); col_btn.style.background = hex; request_render(); }, + })); + int_el.addEventListener('input', () => { + const v = parseFloat(int_el.value); + document.getElementById(`${prefix}-intensity-val`).textContent = v.toFixed(2); + env[`light${light_num}_intensity`] = v; + request_render(); + }); + + col_btn.style.background = env[`hex_light${light_num}`](); + }; + + wire_light('l1', 1); + wire_light('l2', 2); + + const amb_btn = document.getElementById('amb-color-btn'); + const amb_int = document.getElementById('amb-intensity'); + amb_btn.addEventListener('click', () => color_dialog_open({ + get: () => env.hex_ambient(), + set: (hex) => { env.set_hex_ambient(hex); amb_btn.style.background = hex; request_render(); }, + })); + amb_int.addEventListener('input', () => { + const v = parseFloat(amb_int.value); + document.getElementById('amb-intensity-val').textContent = v.toFixed(2); + env.ambient_intensity = v; + request_render(); + }); + + // Initialise all env UI from Env_State so HTML values are never the source of truth. + function sync_env_ui() { + for (const [prefix, n] of [['l1', 1], ['l2', 2]]) { + document.getElementById(`${prefix}-azim`).value = env[`light${n}_azim`]; + document.getElementById(`${prefix}-azim-val`).textContent = env[`light${n}_azim`] + '°'; + document.getElementById(`${prefix}-elev`).value = env[`light${n}_elev`]; + document.getElementById(`${prefix}-elev-val`).textContent = env[`light${n}_elev`] + '°'; + document.getElementById(`${prefix}-intensity`).value = env[`light${n}_intensity`]; + document.getElementById(`${prefix}-intensity-val`).textContent = env[`light${n}_intensity`].toFixed(2); + document.getElementById(`${prefix}-color-btn`).style.background = env[`hex_light${n}`](); + } + amb_int.value = env.ambient_intensity; + document.getElementById('amb-intensity-val').textContent = env.ambient_intensity.toFixed(2); + amb_btn.style.background = env.hex_ambient(); + } + sync_env_ui(); +} + +// --------------------------------------------------------------------------- +// Keyboard shortcuts +// --------------------------------------------------------------------------- + +window.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + apply_history(undo_state.undo(paint)); + } + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + apply_history(undo_state.redo(paint)); + } +}); + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +resize(); +build(); diff --git a/standalone/goldberg-sphere/render_geo.mjs b/standalone/goldberg-sphere/render_geo.mjs new file mode 100644 index 0000000..14a4ed9 --- /dev/null +++ b/standalone/goldberg-sphere/render_geo.mjs @@ -0,0 +1,117 @@ +import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE } from './paint_state.mjs'; + +export function desaturate(c, s) { + const lum = 0.299*c[0] + 0.587*c[1] + 0.114*c[2]; + return [lum + (c[0]-lum)*s, lum + (c[1]-lum)*s, lum + (c[2]-lum)*s]; +} + +export function distortion_color(ratio) { + const t = Math.min(Math.max((ratio - 1) / 0.4, 0), 1); + return desaturate([Math.min(1, 2*t), t < 0.5 ? t*1.4 : 0.7-(t-0.5)*1.4, Math.max(0, 1-2*t)], 0.45); +} + +export function shape_distortion(verts) { + const n = verts.length; + let cx=0, cy=0, cz=0; + for (const v of verts) { cx+=v.x; cy+=v.y; cz+=v.z; } + cx/=n; cy/=n; cz/=n; + let mn=Infinity, mx=0; + for (const v of verts) { + const d = Math.sqrt((v.x-cx)**2+(v.y-cy)**2+(v.z-cz)**2); + if (d < mn) { mn=d; } if (d > mx) { mx=d; } + } + return mn > 1e-12 ? mx/mn : 1; +} + +// Triangle mesh (icosahedron / subdivided). +export function build_triangle_geo(poly, palette) { + const fpos=[], fcol=[], fpbr=[], fnoise=[], epos=[]; + let worst_shape = 0; + for (const face of poly.faces) { + const v = face.vertices.map(vi => poly.vertices[vi].to_vec3()); + + const e1x=v[1].x-v[0].x, e1y=v[1].y-v[0].y, e1z=v[1].z-v[0].z; + const e2x=v[2].x-v[0].x, e2y=v[2].y-v[0].y, e2z=v[2].z-v[0].z; + const nx=e1y*e2z-e1z*e2y, ny=e1z*e2x-e1x*e2z, nz=e1x*e2y-e1y*e2x; + const cx=(v[0].x+v[1].x+v[2].x)/3, cy=(v[0].y+v[1].y+v[2].y)/3, cz=(v[0].z+v[1].z+v[2].z)/3; + if (nx*cx + ny*cy + nz*cz < 0) { [v[1], v[2]] = [v[2], v[1]]; } + + let mn=Infinity, mx=0; + for (let i=0; i<3; i++) { + const a=v[i], b=v[(i+1)%3]; + const l=Math.sqrt((b.x-a.x)**2+(b.y-a.y)**2+(b.z-a.z)**2); + if (lmx) { mx=l; } + } + const ratio = mn>1e-12 ? mx/mn : 1; + if (ratio > worst_shape) { worst_shape = ratio; } + const c = palette.face_color(ratio, false); + for (const vert of v) { + fpos.push(vert.x,vert.y,vert.z); + fcol.push(c[0],c[1],c[2]); + fpbr.push(0.0,0.6); + fnoise.push(...PAINT_BG_NOISE); + } + for (let i=0; i<3; i++) { + const a=v[i], b=v[(i+1)%3]; + epos.push(a.x,a.y,a.z, b.x,b.y,b.z); + } + } + return { + face_pos: new Float32Array(fpos), face_col: new Float32Array(fcol), + face_pbr: new Float32Array(fpbr), face_noise: new Float32Array(fnoise), + face_verts: fpos.length/3, + edge_pos: new Float32Array(epos), edge_verts: epos.length/3, + stats: { faces: poly.faces.length, vertices: poly.vertices.length, worst_shape }, + }; +} + +// Goldberg polygonal mesh. paint is a Paint_State (or null for non-paint mode). +export function build_goldberg_geo(goldberg, use_relaxed, paint, palette) { + const fpos=[], fcol=[], fpbr=[], fnoise=[], epos=[]; + let worst_shape = 0; + for (let fi=0; fi worst_shape) { worst_shape = sr; } + + 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 = palette.face_color(sr, face.is_pentagon); + pbr = [0.0, 0.6]; + noise = PAINT_BG_NOISE; + } + + for (let i=1; if.is_pentagon).length, + worst_shape, + }, + }; +} diff --git a/standalone/goldberg-sphere/renderer.mjs b/standalone/goldberg-sphere/renderer.mjs new file mode 100644 index 0000000..defeedb --- /dev/null +++ b/standalone/goldberg-sphere/renderer.mjs @@ -0,0 +1,178 @@ +// 2D canvas rendering for the sphere topology explorer. +// Reads Goldberg_Polyhedron topology, produces canvas output. +// Placement logic lives in separate placer_*.mjs modules. + +import { place_unfold } from './placer_unfold.mjs'; +import { place_relax } from './placer_relax.mjs'; +import { place_relax_loose } from './placer_relax_loose.mjs'; +import { place_ortho_3d } from './placer_ortho_3d.mjs'; +import { place_gnomonic_3d } from './placer_gnomonic_3d.mjs'; + +const EDGE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F']; + +// Colors +const COLOR_CENTER_FILL = '#1a2a3a'; +const COLOR_NEIGHBOR_FILL = '#0f1a24'; +const COLOR_CENTER_STROKE = '#4af'; +const COLOR_NEIGHBOR_STROKE = '#2a5a7a'; +const COLOR_DEEP_STROKE = '#1a3a4a'; +const COLOR_LABEL_CENTER = '#cef'; +const COLOR_LABEL_NEIGHBOR = '#7ab'; +const COLOR_EDGE_LABEL = '#fa8'; +const COLOR_BG = '#050d14'; + +export class Renderer { + constructor(canvas, goldberg_poly) { + this.canvas = canvas; + this.zoom = 1.0; + this.mode = 'unfold'; // 'unfold' | 'relax' | 'relax-loose' | 'ortho' | 'gnomonic' + this.curvature = 0; // 0 = flat; positive = sphere-like (outer tiles shrink) + this.ctx = canvas.getContext('2d'); + this.poly = goldberg_poly; + } + + render(current_face_index, view_depth, label_offset = 0, entry_edge = null) { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + const cx = w / 2, cy = h / 2; + + ctx.fillStyle = COLOR_BG; + ctx.fillRect(0, 0, w, h); + + const face_size = Math.min(w, h) * 0.18 * this.zoom; + const neighborhood = this.poly.get_neighborhood(current_face_index, view_depth); + + let placements; + if (this.mode === 'relax') { + placements = place_relax(this.poly, neighborhood, current_face_index, cx, cy, face_size, this.curvature); + } else if (this.mode === 'relax-loose') { + placements = place_relax_loose(this.poly, neighborhood, current_face_index, cx, cy, face_size); + } else if (this.mode === 'ortho') { + placements = place_ortho_3d(this.poly, neighborhood, current_face_index, cx, cy, face_size); + } else if (this.mode === 'gnomonic') { + placements = place_gnomonic_3d(this.poly, neighborhood, current_face_index, cx, cy, face_size); + } else { + placements = place_unfold(this.poly, neighborhood, current_face_index, cx, cy, face_size); + } + + // Rotate the whole view so the back edge (entry_edge) is horizontal at the + // bottom, with the face interior above it — like looking forward. + if (entry_edge !== null) { + const center_face = this.poly.faces[current_face_index]; + const center_pl = placements.get(current_face_index); + if (center_pl) { + const pts = center_pl.vertices_2d; + const n = center_face.size; + const pa = pts[entry_edge]; + const pb = pts[(entry_edge + 1) % n]; + const dx = pb[0] - pa[0], dy = pb[1] - pa[1]; + + // θ makes the edge vector point in the +x direction. + let theta = -Math.atan2(dy, dx); + + // Check: after rotation, the face centroid must be above the edge + // midpoint (lower canvas y). If not, flip 180°. + const cos_t = Math.cos(theta), sin_t = Math.sin(theta); + const rot_y = (px, py) => (px - cx) * sin_t + (py - cy) * cos_t + cy; + const [fcx, fcy] = center_pl.centroid_2d; + const em_y = rot_y((pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2); + if (rot_y(fcx, fcy) > em_y) { theta += Math.PI; } + + // Apply rotation around canvas centre to all placements. + const cos2 = Math.cos(theta), sin2 = Math.sin(theta); + const rot_pt = ([px, py]) => [ + (px - cx) * cos2 - (py - cy) * sin2 + cx, + (px - cx) * sin2 + (py - cy) * cos2 + cy, + ]; + for (const pl of placements.values()) { + pl.vertices_2d = pl.vertices_2d.map(rot_pt); + pl.centroid_2d = rot_pt(pl.centroid_2d); + } + } + } + + // Draw faces back-to-front by depth (deepest first so centre is on top). + const by_depth = [...neighborhood].sort((a, b) => b.depth - a.depth); + for (const entry of by_depth) { + const fi = entry.face.index; + if (!placements.has(fi)) { continue; } + this._draw_face(placements.get(fi), entry.face, entry.depth); + } + + // Draw edge labels on centre face only (on top of everything). + const center_face = this.poly.faces[current_face_index]; + const center_placement = placements.get(current_face_index); + if (center_placement) { + const back_label_idx = center_face.size === 6 ? 3 : 2; + this._draw_edge_labels(center_placement, center_face, label_offset, back_label_idx); + } + } + + _draw_face(placement, face, depth) { + const ctx = this.ctx; + const pts = placement.vertices_2d; + if (!pts || pts.length < 3) { return; } + + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) { + ctx.lineTo(pts[i][0], pts[i][1]); + } + ctx.closePath(); + + if (depth === 0) { + ctx.fillStyle = COLOR_CENTER_FILL; + ctx.strokeStyle = COLOR_CENTER_STROKE; + ctx.lineWidth = 2; + } else if (depth === 1) { + ctx.fillStyle = COLOR_NEIGHBOR_FILL; + ctx.strokeStyle = COLOR_NEIGHBOR_STROKE; + ctx.lineWidth = 1.5; + } else { + ctx.fillStyle = COLOR_NEIGHBOR_FILL; + ctx.strokeStyle = COLOR_DEEP_STROKE; + ctx.lineWidth = 1; + } + + ctx.fill(); + ctx.stroke(); + + // Face index label + const [cx, cy] = placement.centroid_2d; + const font_size = depth === 0 ? 18 : depth === 1 ? 13 : 10; + ctx.font = `${font_size}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = depth === 0 ? COLOR_LABEL_CENTER : COLOR_LABEL_NEIGHBOR; + ctx.fillText(String(face.index), cx, cy); + } + + _draw_edge_labels(placement, face, label_offset, back_label_idx) { + const ctx = this.ctx; + const pts = placement.vertices_2d; + const [cx, cy] = placement.centroid_2d; + const n = face.size; + + for (let i = 0; i < n; i++) { + const pa = pts[i]; + const pb = pts[(i + 1) % n]; + + // Midpoint of edge, nudged slightly toward centre. + const mx = (pa[0] + pb[0]) / 2; + const my = (pa[1] + pb[1]) / 2; + const inset = 0.25; + const lx = mx + (cx - mx) * inset; + const ly = my + (cy - my) * inset; + + const label_idx = (i + label_offset) % n; + const is_back = (label_idx === back_label_idx); + + ctx.font = 'bold 13px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = is_back ? '#557' : COLOR_EDGE_LABEL; + ctx.fillText(EDGE_LABELS[label_idx], lx, ly); + } + } +} diff --git a/standalone/goldberg-sphere/shaders/edge.frag b/standalone/goldberg-sphere/shaders/edge.frag new file mode 100644 index 0000000..c9a8140 --- /dev/null +++ b/standalone/goldberg-sphere/shaders/edge.frag @@ -0,0 +1,2 @@ +precision mediump float; +void main() { gl_FragColor = vec4(0.05, 0.05, 0.05, 1.0); } diff --git a/standalone/goldberg-sphere/shaders/edge.vert b/standalone/goldberg-sphere/shaders/edge.vert new file mode 100644 index 0000000..cf3741d --- /dev/null +++ b/standalone/goldberg-sphere/shaders/edge.vert @@ -0,0 +1,3 @@ +attribute vec3 a_pos; +uniform mat4 u_mvp; +void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); } diff --git a/standalone/goldberg-sphere/shaders/face.frag b/standalone/goldberg-sphere/shaders/face.frag new file mode 100644 index 0000000..90a691a --- /dev/null +++ b/standalone/goldberg-sphere/shaders/face.frag @@ -0,0 +1,112 @@ +precision mediump float; +varying vec3 v_pos; +varying vec3 v_normal; +varying vec3 v_color; +varying vec2 v_pbr; +varying vec3 v_obj_pos; +varying vec4 v_noise; // (scale, strength, octaves, gain); lacunarity fixed at 2.0 +uniform vec3 u_cam_pos; +uniform vec3 u_light1_dir; +uniform vec3 u_light1_color; +uniform vec3 u_light2_dir; +uniform vec3 u_light2_color; +uniform vec3 u_ambient; +const float PI = 3.14159265; +const float LACUNARITY = 2.0; + +// --------------------------------------------------------------------------- +// 3-D value noise (input: object-space position → no rotation seam) +// --------------------------------------------------------------------------- +float hash3(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.x + p.y) * p.z); +} +float vnoise(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash3(i), hash3(i+vec3(1,0,0)), u.x), + mix(hash3(i+vec3(0,1,0)), hash3(i+vec3(1,1,0)), u.x), u.y), + mix(mix(hash3(i+vec3(0,0,1)), hash3(i+vec3(1,0,1)), u.x), + mix(hash3(i+vec3(0,1,1)), hash3(i+vec3(1,1,1)), u.x), u.y), u.z); +} +// FBM: octaves is a float so partial octaves blend smoothly. +float fbm(vec3 p, float octaves, float gain) { + float val = 0.0, amp = 0.5, freq = 1.0, norm = 0.0; + for (int i = 0; i < 8; i++) { + float w = clamp(octaves - float(i), 0.0, 1.0); + float c = amp * w; + val += c * vnoise(p * freq); + norm += c; + freq *= LACUNARITY; + amp *= gain; + } + return norm > 0.001 ? val / norm : 0.5; +} +// Perturb n using the per-face noise params from v_noise. +// Uses object-space position so noise is fixed to the sphere, not world space. +// Strength is divided by scale for consistent visual amplitude across frequencies. +vec3 perturb_normal(vec3 n) { + float scale = v_noise.x; + float strength = v_noise.y; + float octaves = v_noise.z; + float gain = v_noise.w; + vec3 t1 = cross(n, vec3(0.0, 1.0, 0.0)); + if (length(t1) < 0.01) { t1 = cross(n, vec3(1.0, 0.0, 0.0)); } + t1 = normalize(t1); + vec3 t2 = normalize(cross(n, t1)); + float eps = 0.05; + vec3 sp = v_obj_pos * scale; + float n0 = fbm(sp, octaves, gain); + float dx = (fbm(sp + t1 * (eps * scale), octaves, gain) - n0) / eps; + float dy = (fbm(sp + t2 * (eps * scale), octaves, gain) - n0) / eps; + float str = strength / max(scale, 1.0); + return normalize(n + str * (dx * t1 + dy * t2)); +} + +// --------------------------------------------------------------------------- +// PBR (Cook-Torrance) +// --------------------------------------------------------------------------- +float D_GGX(float NdH, float a2) { + float d = NdH*NdH*(a2-1.0)+1.0; + return a2 / (PI*d*d + 0.0001); +} +float G_Smith(float NdX, float k) { + return NdX / (NdX*(1.0-k)+k+0.0001); +} +vec3 F_Schlick(float cosT, vec3 F0) { + return F0 + (1.0-F0)*pow(clamp(1.0-cosT,0.0,1.0),5.0); +} +vec3 pbr_light(vec3 n, vec3 v, vec3 l, vec3 lc, vec3 alb, float met, float rou) { + vec3 h = normalize(v+l); + float NdL = max(dot(n,l),0.0); + float NdV = max(dot(n,v),0.001); + float NdH = max(dot(n,h),0.0); + float VdH = max(dot(v,h),0.0); + float a2 = rou*rou*rou*rou; + float k = rou*rou/2.0; + vec3 F0 = mix(vec3(0.04), alb, met); + vec3 F = F_Schlick(VdH, F0); + float D = D_GGX(NdH, a2); + float G = G_Smith(NdL,k)*G_Smith(NdV,k); + vec3 spec = D*F*G / (4.0*NdL*NdV + 0.0001); + vec3 kd = (1.0-F)*(1.0-met); + return (kd*alb/PI + spec) * lc * NdL; +} + +void main() { + float met = v_pbr.x; + float rou = v_pbr.y; + vec3 alb = v_color; + vec3 n = normalize(v_normal); + if (v_noise.y > 0.001 && v_noise.x > 0.001) { + n = perturb_normal(n); + } + vec3 v = normalize(u_cam_pos - v_pos); + vec3 col = pbr_light(n, v, u_light1_dir, u_light1_color, alb, met, rou) + + pbr_light(n, v, u_light2_dir, u_light2_color, alb, met, rou); + col += alb * (1.0-met) * u_ambient; + gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/standalone/goldberg-sphere/shaders/face.vert b/standalone/goldberg-sphere/shaders/face.vert new file mode 100644 index 0000000..4986d6e --- /dev/null +++ b/standalone/goldberg-sphere/shaders/face.vert @@ -0,0 +1,21 @@ +attribute vec3 a_pos; +attribute vec3 a_color; +attribute vec2 a_pbr; +attribute vec4 a_noise; +uniform mat4 u_mvp; +uniform mat3 u_norm; +varying vec3 v_pos; +varying vec3 v_normal; +varying vec3 v_color; +varying vec2 v_pbr; +varying vec3 v_obj_pos; +varying vec4 v_noise; +void main() { + gl_Position = u_mvp * vec4(a_pos, 1.0); + v_pos = u_norm * a_pos; + v_normal = v_pos; + v_color = a_color; + v_pbr = a_pbr; + v_obj_pos = a_pos; + v_noise = a_noise; +} diff --git a/standalone/goldberg-sphere/shaders/wall.frag b/standalone/goldberg-sphere/shaders/wall.frag new file mode 100644 index 0000000..f943577 --- /dev/null +++ b/standalone/goldberg-sphere/shaders/wall.frag @@ -0,0 +1,2 @@ +precision mediump float; +void main() { gl_FragColor = vec4(1.0, 0.58, 0.08, 1.0); } diff --git a/standalone/goldberg-sphere/shaders/wall.vert b/standalone/goldberg-sphere/shaders/wall.vert new file mode 100644 index 0000000..cf3741d --- /dev/null +++ b/standalone/goldberg-sphere/shaders/wall.vert @@ -0,0 +1,3 @@ +attribute vec3 a_pos; +uniform mat4 u_mvp; +void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); } diff --git a/standalone/goldberg-sphere/spin_state.mjs b/standalone/goldberg-sphere/spin_state.mjs new file mode 100644 index 0000000..7c7c200 --- /dev/null +++ b/standalone/goldberg-sphere/spin_state.mjs @@ -0,0 +1,45 @@ +import { quat_from_axis_angle, quat_mul } from './math.mjs'; + +export class Spin_State { + constructor() { + this.active = false; + this.axis = [0, 1, 0]; + this.target = [0, 1, 0]; + this.speed = 0.00035; // radians per millisecond + this.last_t = null; + } + + _random_unit() { + let x, y, z, r; + do { + x = Math.random()*2-1; y = Math.random()*2-1; z = Math.random()*2-1; + r = Math.sqrt(x*x+y*y+z*z); + } while (r < 0.001 || r > 1); + return [x/r, y/r, z/r]; + } + + _new_target() { this.target = this._random_unit(); } + + // Advance spin by dt ms. Returns updated rot_quat (unchanged if not active). + tick(dt, rot_quat) { + if (!this.active) { return rot_quat; } + if (this.last_t === null) { this._new_target(); return rot_quat; } + + const drift = Math.min(1, dt * 0.0003); + const [ax,ay,az] = this.axis; + const [tx,ty,tz] = this.target; + let mx = ax + (tx-ax)*drift, my = ay + (ty-ay)*drift, mz = az + (tz-az)*drift; + const mr = Math.sqrt(mx*mx+my*my+mz*mz); + this.axis = [mx/mr, my/mr, mz/mr]; + + const dot = this.axis[0]*this.target[0] + this.axis[1]*this.target[1] + this.axis[2]*this.target[2]; + if (dot > 0.999) { this._new_target(); } + + const angle = this.speed * dt; + const [rx,ry,rz] = this.axis; + return quat_mul(quat_from_axis_angle(rx, ry, rz, angle), rot_quat); + } + + start() { this.active = true; this.last_t = null; } + stop() { this.active = false; this.last_t = null; } +} diff --git a/standalone/goldberg-sphere/topology.mjs b/standalone/goldberg-sphere/topology.mjs new file mode 100644 index 0000000..52e41c7 --- /dev/null +++ b/standalone/goldberg-sphere/topology.mjs @@ -0,0 +1,719 @@ +// Sphere topology: icosahedron construction, subdivision, and Goldberg merging. +// All data structures live here. No rendering, no DOM. + +import { Vec3, normalize_to_sphere, midpoint } from './geometry.mjs'; + +// --------------------------------------------------------------------------- +// Low-level data classes +// --------------------------------------------------------------------------- + +export class Vertex { + constructor(index, x, y, z) { + this.index = index; + this.x = x; + this.y = y; + this.z = z; + } + + to_vec3() { return new Vec3(this.x, this.y, this.z); } +} + +export class Edge { + constructor(index, vertex_a, vertex_b) { + // vertex_a < vertex_b always (canonical ordering) + this.index = index; + this.vertex_a = vertex_a; + this.vertex_b = vertex_b; + this.face_left = null; // face whose winding traverses a→b + this.face_right = null; // face whose winding traverses b→a + } +} + +export class Face { + constructor(index, vertices) { + this.index = index; + this.vertices = vertices; // ordered vertex indices + this.edges = []; // edge index for vertices[i]→vertices[(i+1)%n] + this.edge_neighbors = []; // face index across edges[i]; null = boundary (shouldn't happen on closed manifold) + this.edge_directions = []; // +1 if this face is on face_left of edge[i], -1 if face_right + } + + get size() { return this.vertices.length; } +} + +// --------------------------------------------------------------------------- +// Polyhedron — triangulated surface, used at icosahedron and subdivision levels +// --------------------------------------------------------------------------- + +export class Polyhedron { + constructor(vertices, faces) { + // vertices: array of Vertex + // faces: array of Face (edges/neighbors populated by build_topology) + this.vertices = vertices; + this.faces = faces; + this.edges = []; + this._edge_map = new Map(); // 'a,b' → Edge index + } + + // Build edge list and face connectivity from vertex/face data. + // Call this once after setting this.vertices and this.faces with vertex lists. + build_topology() { + this.edges = []; + this._edge_map = new Map(); + + for (const face of this.faces) { + const n = face.vertices.length; + face.edges = []; + face.edge_directions = []; + + for (let i = 0; i < n; i++) { + const va = face.vertices[i]; + const vb = face.vertices[(i + 1) % n]; + const key = va < vb ? `${va},${vb}` : `${vb},${va}`; + + if (!this._edge_map.has(key)) { + const edge = new Edge(this.edges.length, Math.min(va, vb), Math.max(va, vb)); + this._edge_map.set(key, edge.index); + this.edges.push(edge); + } + + const edge_index = this._edge_map.get(key); + face.edges.push(edge_index); + + // Determine if this face is left or right of the edge. + // Convention: if the face traverses a→b (i.e. va < vb in canonical form + // and that matches the face's traversal direction), it's face_left. + const edge = this.edges[edge_index]; + if (va === edge.vertex_a) { + // Face traverses a→b → face_left + face.edge_directions.push(1); + edge.face_left = face.index; + } else { + // Face traverses b→a → face_right + face.edge_directions.push(-1); + edge.face_right = face.index; + } + } + } + + // Populate edge_neighbors on each face + for (const face of this.faces) { + face.edge_neighbors = []; + for (let i = 0; i < face.edges.length; i++) { + const edge = this.edges[face.edges[i]]; + if (face.edge_directions[i] === 1) { + // This face is face_left; neighbor is face_right + face.edge_neighbors.push(edge.face_right); + } else { + // This face is face_right; neighbor is face_left + face.edge_neighbors.push(edge.face_left); + } + } + } + } + + get_edge(va, vb) { + const key = va < vb ? `${va},${vb}` : `${vb},${va}`; + const idx = this._edge_map.get(key); + return idx !== undefined ? this.edges[idx] : null; + } + + // Validate basic topological invariants. Returns array of error strings. + validate() { + const errors = []; + const v = this.vertices.length; + const e = this.edges.length; + const f = this.faces.length; + + if (v - e + f !== 2) { + errors.push(`Euler characteristic: V(${v}) - E(${e}) + F(${f}) = ${v - e + f}, expected 2`); + } + + for (const edge of this.edges) { + if (edge.face_left === null || edge.face_right === null) { + errors.push(`Edge ${edge.index} missing a face (left=${edge.face_left}, right=${edge.face_right})`); + } + } + + for (const face of this.faces) { + for (let i = 0; i < face.edge_neighbors.length; i++) { + if (face.edge_neighbors[i] === null || face.edge_neighbors[i] === undefined) { + errors.push(`Face ${face.index} edge_neighbor[${i}] is null`); + } + } + } + + return errors; + } + + // Subdivide every triangle into 4 triangles by inserting edge midpoints. + // Midpoints are projected onto the unit sphere. + // Returns a new Polyhedron. + subdivide() { + const new_vertices = this.vertices.map(v => new Vertex(v.index, v.x, v.y, v.z)); + const midpoint_map = new Map(); // edge_index → new vertex index + + // Create midpoint vertices for every edge + for (const edge of this.edges) { + const va = this.vertices[edge.vertex_a].to_vec3(); + const vb = this.vertices[edge.vertex_b].to_vec3(); + const mid = normalize_to_sphere(midpoint(va, vb)); + const new_index = new_vertices.length; + new_vertices.push(new Vertex(new_index, mid.x, mid.y, mid.z)); + midpoint_map.set(edge.index, new_index); + } + + // Split each triangle into 4 + const new_face_defs = []; + for (const face of this.faces) { + const [a, b, c] = face.vertices; + const edge_ab = this.get_edge(a, b).index; + const edge_bc = this.get_edge(b, c).index; + const edge_ca = this.get_edge(c, a).index; + const mab = midpoint_map.get(edge_ab); + const mbc = midpoint_map.get(edge_bc); + const mca = midpoint_map.get(edge_ca); + + // Preserve winding: original face is (a, b, c) CCW from outside. + new_face_defs.push([a, mab, mca]); + new_face_defs.push([mab, b, mbc]); + new_face_defs.push([mca, mbc, c]); + new_face_defs.push([mab, mbc, mca]); // center triangle + } + + const new_faces = new_face_defs.map((verts, i) => new Face(i, verts)); + const poly = new Polyhedron(new_vertices, new_faces); + poly.build_topology(); + return poly; + } + + // --------------------------------------------------------------------------- + // Icosahedron constructor — pentagonal antiprism method + // --------------------------------------------------------------------------- + + static build_icosahedron() { + // Edge length = 1. All geometry derived from the equal-edge constraint. + // + // Pentagon circumradius: R = 1 / (2 * sin(π/5)) + // Ring separation: Δz = sqrt(1 - 2R²(1 - cos(π/5))) + // Cap height above/below ring: h = sqrt(1 - R²) + // + // Vertex layout: + // 0 : top cap + // 1..5 : top ring, angle 2πk/5 + // 6..10 : bottom ring, angle 2πk/5 + π/5 (rotated 36°) + // 11 : bottom cap + + const R = 1 / (2 * Math.sin(Math.PI / 5)); + const delta_z = Math.sqrt(1 - 2 * R * R * (1 - Math.cos(Math.PI / 5))); + const h = Math.sqrt(1 - R * R); + + // Place rings symmetrically about z=0 + const z_top = delta_z / 2; + const z_bot = -delta_z / 2; + + const raw_vertices = []; + + // Top cap + raw_vertices.push([0, 0, z_top + h]); + + // Top ring + for (let k = 0; k < 5; k++) { + const angle = (2 * Math.PI * k) / 5; + raw_vertices.push([R * Math.cos(angle), R * Math.sin(angle), z_top]); + } + + // Bottom ring (rotated 36°) + for (let k = 0; k < 5; k++) { + const angle = (2 * Math.PI * k) / 5 + Math.PI / 5; + raw_vertices.push([R * Math.cos(angle), R * Math.sin(angle), z_bot]); + } + + // Bottom cap + raw_vertices.push([0, 0, z_bot - h]); + + // Normalize all vertices to unit sphere + const vertices = raw_vertices.map(([x, y, z], i) => { + const v = normalize_to_sphere(new Vec3(x, y, z)); + return new Vertex(i, v.x, v.y, v.z); + }); + + // Build faces from antiprism structure. + // All faces wound CCW when viewed from outside (outward normal). + // + // Top cap: vertex 0, top ring k and k+1 + // Antiprism upward triangles: top[k], bot[k+1], bot[k] (CCW from outside) + // Antiprism downward triangles: top[k], top[k+1], bot[k+1] (CCW from outside) + // Bottom cap: bot[k], bot[k+1], vertex 11 + + const top = k => 1 + (k % 5); + const bot = k => 6 + (k % 5); + const TOP_CAP = 0; + const BOT_CAP = 11; + + const face_defs = []; + + for (let k = 0; k < 5; k++) { + // Top cap triangle + face_defs.push([TOP_CAP, top(k), top(k + 1)]); + // Antiprism "up" triangle: top(k) flanked by bot(k-1) and bot(k), both 36° away. + // bot(k-1) = bot(k+4) using mod-5 arithmetic. + face_defs.push([top(k), bot(k + 4), bot(k)]); + // Antiprism "down" triangle: top(k), top(k+1) share bot(k) (36° from each). + // Winding reversed so outward normal points away from origin. + face_defs.push([top(k + 1), top(k), bot(k)]); + // Bottom cap triangle + face_defs.push([BOT_CAP, bot(k + 1), bot(k)]); + } + + const faces = face_defs.map((verts, i) => new Face(i, verts)); + const poly = new Polyhedron(vertices, faces); + poly.build_topology(); + return poly; + } +} + +// --------------------------------------------------------------------------- +// Goldberg polyhedron — hexagons and pentagons +// --------------------------------------------------------------------------- + +export class Goldberg_Face { + constructor(index, center_vertex, component_triangles, vertices_3d) { + this.index = index; + this.center_vertex = center_vertex; // vertex index in the subdivided Polyhedron + this.component_triangles = component_triangles; // triangle face indices (ordered ring) + this.vertices_3d = vertices_3d; // Vec3 centroids of component triangles, in ring order + this.relaxed_vertices_3d = null; // set by Goldberg_Polyhedron.relax_sphere() + this.edge_neighbors = []; // Goldberg_Face index across edge i + this.is_pentagon = false; + } + + get size() { return this.component_triangles.length; } +} + +export class Goldberg_Polyhedron { + constructor(faces) { + this.faces = faces; // array of Goldberg_Face + } + + // Build from a subdivided triangulated Polyhedron. + // Each vertex of the triangulation becomes one Goldberg face. + static from_subdivided(poly) { + // For each vertex, collect the ring of surrounding triangular faces. + const vertex_star = new Array(poly.vertices.length).fill(null).map(() => []); + + for (const face of poly.faces) { + for (const vi of face.vertices) { + vertex_star[vi].push(face.index); + } + } + + const goldberg_faces = []; + // Map from triangle face index → which Goldberg face it belongs to + const triangle_to_goldberg = new Map(); + + for (let vi = 0; vi < poly.vertices.length; vi++) { + const star = vertex_star[vi]; + if (star.length < 3) { continue; } + + // Order the star faces into a ring by following adjacency. + let ordered = order_face_ring(star, vi, poly); + if (ordered === null) { + console.warn(`Could not order face ring for vertex ${vi}`); + continue; + } + + // Compute centroids of the component triangles as Goldberg face vertices + const vertices_3d = ordered.map(fi => { + const face = poly.faces[fi]; + const va = poly.vertices[face.vertices[0]].to_vec3(); + const vb = poly.vertices[face.vertices[1]].to_vec3(); + const vc = poly.vertices[face.vertices[2]].to_vec3(); + return normalize_to_sphere(va.add(vb).add(vc).scale(1 / 3)); + }); + + // Enforce consistent CCW winding when viewed from outside the sphere. + // The outward direction at this vertex is its own position (unit sphere). + // Compute the ring's normal as the sum of cross products of consecutive + // centroid vectors; if it points inward (dot < 0 with center vertex), reverse. + { + const center = poly.vertices[vi].to_vec3(); + const n = vertices_3d.length; + let normal_x = 0, normal_y = 0, normal_z = 0; + for (let i = 0; i < n; i++) { + const a = vertices_3d[i]; + const b = vertices_3d[(i + 1) % n]; + normal_x += a.y * b.z - a.z * b.y; + normal_y += a.z * b.x - a.x * b.z; + normal_z += a.x * b.y - a.y * b.x; + } + const dot = normal_x * center.x + normal_y * center.y + normal_z * center.z; + if (dot < 0) { + ordered.reverse(); + vertices_3d.reverse(); + } + } + + const gf = new Goldberg_Face(goldberg_faces.length, vi, ordered, vertices_3d); + goldberg_faces.push(gf); + + for (const fi of ordered) { + triangle_to_goldberg.set(fi, gf.index); + } + } + + // Mark pentagons: vertices with a star of 5 faces. + // In a subdivided icosahedron, the 12 original vertices remain degree-5. + for (const gf of goldberg_faces) { + gf.is_pentagon = (gf.size === 5); + } + + // Build edge_neighbors using the dual graph relationship. + // + // Goldberg edge i runs from centroid(ring[i]) to centroid(ring[i+1]). + // By duality this edge separates the Goldberg face centered on `center_vertex` + // from the Goldberg face centered on the vertex shared by ring[i] and ring[i+1] + // that is NOT `center_vertex`. So the neighbor across edge i is simply: + // vertex_to_goldberg[ shared_outer_vertex(ring[i], ring[i+1], center_vertex) ] + // + // This avoids any triangle→Goldberg mapping ambiguity entirely. + + const vertex_to_goldberg = new Map(); + for (const gf of goldberg_faces) { + vertex_to_goldberg.set(gf.center_vertex, gf.index); + } + + for (const gf of goldberg_faces) { + gf.edge_neighbors = new Array(gf.size).fill(null); + for (let i = 0; i < gf.size; i++) { + const tri_a = poly.faces[gf.component_triangles[i]]; + const tri_b = poly.faces[gf.component_triangles[(i + 1) % gf.size]]; + const shared_vi = find_shared_outer_vertex(tri_a, tri_b, gf.center_vertex); + if (shared_vi !== null && vertex_to_goldberg.has(shared_vi)) { + gf.edge_neighbors[i] = vertex_to_goldberg.get(shared_vi); + } + } + } + + return new Goldberg_Polyhedron(goldberg_faces); + } + + validate() { + const errors = []; + let pentagon_count = 0; + + for (const gf of this.faces) { + if (gf.is_pentagon) { pentagon_count++; } + + for (let i = 0; i < gf.size; i++) { + const nbr_idx = gf.edge_neighbors[i]; + if (nbr_idx === null || nbr_idx === undefined) { + errors.push(`Face ${gf.index} edge_neighbor[${i}] is null`); + continue; + } + const nbr = this.faces[nbr_idx]; + // Neighbor symmetry: nbr should contain gf.index in its edge_neighbors + if (!nbr.edge_neighbors.includes(gf.index)) { + errors.push(`Face ${gf.index} → ${nbr_idx}, but ${nbr_idx} does not list ${gf.index} as neighbor`); + } + } + } + + if (pentagon_count !== 12) { + errors.push(`Expected 12 pentagons, found ${pentagon_count}`); + } + + return errors; + } + + // BFS from a face, up to `depth` steps. + // Returns array of { face, depth, parent_index, parent_edge_index } in BFS order. + // The root entry has parent_index = null, parent_edge_index = null. + get_neighborhood(face_index, depth) { + const result = []; + const visited = new Set(); + const queue = [{ face_index, depth: 0, parent_index: null, parent_edge_index: null }]; + visited.add(face_index); + + while (queue.length > 0) { + const entry = queue.shift(); + result.push({ + face: this.faces[entry.face_index], + depth: entry.depth, + parent_index: entry.parent_index, + parent_edge_index: entry.parent_edge_index, + }); + + if (entry.depth >= depth) { continue; } + + const face = this.faces[entry.face_index]; + for (let i = 0; i < face.edge_neighbors.length; i++) { + const nbr_idx = face.edge_neighbors[i]; + if (nbr_idx !== null && nbr_idx !== undefined && !visited.has(nbr_idx)) { + visited.add(nbr_idx); + queue.push({ + face_index: nbr_idx, + depth: entry.depth + 1, + parent_index: entry.face_index, + parent_edge_index: i, + }); + } + } + } + + return result; + } + + // Relax all Goldberg vertices onto the unit sphere. + // + // Two independent spring types, each with its own alpha (set to 0 to disable): + // alpha_edge — push each shared edge toward a uniform target length. + // Scales well; recommended default ≈ 0.1. + // alpha_centroid — push each vertex toward its face's average circumradius. + // Adds shape regularity but converges poorly at high + // subdivision depths; keep small (≤ 0.02) if used. + // + // Both springs are applied each iteration; vertices are re-normalised to the + // unit sphere afterwards. Stores results as relaxed_vertices_3d on each face. + relax_sphere(iters = 150, alpha_edge = 0, alpha_centroid = 0.04) { + const faces = this.faces; + const nf = faces.length; + + // --- Global union-find to identify shared vertices across faces --- + const face_base = new Array(nf); + let uf_size = 0; + for (let i = 0; i < nf; i++) { + face_base[i] = uf_size; + uf_size += faces[i].size; + } + const uf_parent = Array.from({ length: uf_size }, (_, i) => i); + const uf_find = (i) => { + while (uf_parent[i] !== i) { uf_parent[i] = uf_parent[uf_parent[i]]; i = uf_parent[i]; } + return i; + }; + const uf_union = (i, j) => { uf_parent[uf_find(i)] = uf_find(j); }; + + const adj_seen = new Set(); + for (let fi_a = 0; fi_a < nf; fi_a++) { + const face_a = faces[fi_a]; + const na = face_a.size; + const base_a = face_base[fi_a]; + for (let ea = 0; ea < na; ea++) { + const fi_b = face_a.edge_neighbors[ea]; + if (fi_b === null || fi_b === undefined) { continue; } + const pair_key = fi_a < fi_b ? fi_a * 65536 + fi_b : fi_b * 65536 + fi_a; + if (adj_seen.has(pair_key)) { continue; } + adj_seen.add(pair_key); + + const face_b = faces[fi_b]; + const nb = face_b.size; + const base_b = face_base[fi_b]; + let eb = -1; + for (let j = 0; j < nb; j++) { + if (face_b.edge_neighbors[j] === fi_a) { eb = j; break; } + } + if (eb === -1) { continue; } + + uf_union(base_a + ea, base_b + (eb + 1) % nb); + uf_union(base_a + (ea + 1) % na, base_b + eb); + } + } + + // Compact position IDs. + const canon_to_pid = new Map(); + let pid_count = 0; + const face_pids = new Array(nf); + for (let fi = 0; fi < nf; fi++) { + const n = faces[fi].size; + const base = face_base[fi]; + const pids = new Array(n); + for (let i = 0; i < n; i++) { + const canon = uf_find(base + i); + if (!canon_to_pid.has(canon)) { canon_to_pid.set(canon, pid_count++); } + pids[i] = canon_to_pid.get(canon); + } + face_pids[fi] = pids; + } + + // Initialise positions from vertices_3d (already on unit sphere). + const px = new Float64Array(pid_count); + const py = new Float64Array(pid_count); + const pz = new Float64Array(pid_count); + const written = new Uint8Array(pid_count); + for (let fi = 0; fi < nf; fi++) { + const face = faces[fi]; + const pids = face_pids[fi]; + for (let i = 0; i < face.size; i++) { + const pid = pids[i]; + if (!written[pid]) { + const v = face.vertices_3d[i]; + px[pid] = v.x; py[pid] = v.y; pz[pid] = v.z; + written[pid] = 1; + } + } + } + + // Collect unique edges (needed for edge springs). + const edge_seen = new Set(); + const eu = [], ev = []; + if (alpha_edge > 0) { + for (let fi = 0; fi < nf; fi++) { + const pids = face_pids[fi]; + const n = faces[fi].size; + for (let i = 0; i < n; i++) { + const u = pids[i], v = pids[(i + 1) % n]; + const ek = u < v ? u * 65536 + v : v * 65536 + u; + if (!edge_seen.has(ek)) { edge_seen.add(ek); eu.push(u); ev.push(v); } + } + } + } + const ne = eu.length; + + // Target edge length: average of all initial edge lengths. + let target_len = 0; + for (let e = 0; e < ne; e++) { + const u = eu[e], v = ev[e]; + const dx = px[v] - px[u], dy = py[v] - py[u], dz = pz[v] - pz[u]; + target_len += Math.sqrt(dx*dx + dy*dy + dz*dz); + } + if (ne > 0) { target_len /= ne; } + + // Target circumradius: average centroid-to-vertex distance (initial geometry). + let target_r = 0, tr_count = 0; + if (alpha_centroid > 0) { + for (let fi = 0; fi < nf; fi++) { + const pids = face_pids[fi]; + const n = faces[fi].size; + let fcx = 0, fcy = 0, fcz = 0; + for (let i = 0; i < n; i++) { fcx += px[pids[i]]; fcy += py[pids[i]]; fcz += pz[pids[i]]; } + fcx /= n; fcy /= n; fcz /= n; + for (let i = 0; i < n; i++) { + const p = pids[i]; + const dx = px[p] - fcx, dy = py[p] - fcy, dz = pz[p] - fcz; + target_r += Math.sqrt(dx*dx + dy*dy + dz*dz); + tr_count++; + } + } + target_r /= tr_count; + } + + // Spring relaxation loop. + const dx_buf = new Float64Array(pid_count); + const dy_buf = new Float64Array(pid_count); + const dz_buf = new Float64Array(pid_count); + + for (let iter = 0; iter < iters; iter++) { + dx_buf.fill(0); dy_buf.fill(0); dz_buf.fill(0); + + // Edge-length springs. + for (let e = 0; e < ne; e++) { + const u = eu[e], v = ev[e]; + const dx = px[v] - px[u], dy = py[v] - py[u], dz = pz[v] - pz[u]; + const len = Math.sqrt(dx*dx + dy*dy + dz*dz); + if (len < 1e-10) { continue; } + const err = (len - target_len) / len * 0.5 * alpha_edge; + dx_buf[u] += dx * err; dy_buf[u] += dy * err; dz_buf[u] += dz * err; + dx_buf[v] -= dx * err; dy_buf[v] -= dy * err; dz_buf[v] -= dz * err; + } + + // Centroid-to-vertex springs. + if (alpha_centroid > 0) { + for (let fi = 0; fi < nf; fi++) { + const pids = face_pids[fi]; + const n = faces[fi].size; + let fcx = 0, fcy = 0, fcz = 0; + for (let i = 0; i < n; i++) { fcx += px[pids[i]]; fcy += py[pids[i]]; fcz += pz[pids[i]]; } + fcx /= n; fcy /= n; fcz /= n; + for (let i = 0; i < n; i++) { + const p = pids[i]; + const dx = px[p] - fcx, dy = py[p] - fcy, dz = pz[p] - fcz; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + if (dist < 1e-10) { continue; } + const err = (dist - target_r) / dist * alpha_centroid; + dx_buf[p] -= dx * err; dy_buf[p] -= dy * err; dz_buf[p] -= dz * err; + } + } + } + + // Apply deltas and project back to unit sphere. + for (let i = 0; i < pid_count; i++) { + const nx = px[i] + dx_buf[i]; + const ny = py[i] + dy_buf[i]; + const nz = pz[i] + dz_buf[i]; + const r = Math.sqrt(nx*nx + ny*ny + nz*nz); + if (r < 1e-10) { continue; } + px[i] = nx / r; py[i] = ny / r; pz[i] = nz / r; + } + } + + // Write relaxed_vertices_3d back onto each face. + for (let fi = 0; fi < nf; fi++) { + const face = faces[fi]; + const pids = face_pids[fi]; + face.relaxed_vertices_3d = pids.map(pid => new Vec3(px[pid], py[pid], pz[pid])); + } + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// Order the faces in `star` (all containing vertex `vi`) into a ring. +// Traces the ring purely through shared outer vertices — no edge_neighbors dependency. +// +// Each face in the ring is a triangle [vi, va, vb]. Two consecutive ring faces share +// exactly one outer vertex (the one between them). We build a map from each outer +// vertex to the (at most 2) star faces that contain it, then follow the chain. +// Returns ordered array of face indices, or null on failure. +function order_face_ring(star, vi, poly) { + if (star.length === 0) { return []; } + if (star.length === 1) { return [star[0]]; } + + // Map: outer_vertex → [face_indices in this star that contain it] + const vertex_to_star_faces = new Map(); + for (const fi of star) { + for (const v of poly.faces[fi].vertices) { + if (v === vi) { continue; } + if (!vertex_to_star_faces.has(v)) { vertex_to_star_faces.set(v, []); } + vertex_to_star_faces.get(v).push(fi); + } + } + + // Trace the ring: from current face [vi, v_prev, v_next], the forward outer vertex + // is v_next. The next ring face is the other star face that contains vi and v_next. + const ordered = [star[0]]; + const visited = new Set([star[0]]); + + const first_face = poly.faces[star[0]]; + const first_outers = first_face.vertices.filter(v => v !== vi); + // Arbitrary starting direction; winding will be corrected after ring is built. + let forward_vertex = first_outers[1]; + + while (ordered.length < star.length) { + const candidates = vertex_to_star_faces.get(forward_vertex) || []; + const next_fi = candidates.find(fi => !visited.has(fi)); + if (next_fi === undefined) { return null; } + + ordered.push(next_fi); + visited.add(next_fi); + + // The next forward vertex is the outer vertex of next_fi that isn't forward_vertex. + const next_outers = poly.faces[next_fi].vertices.filter(v => v !== vi); + forward_vertex = next_outers.find(v => v !== forward_vertex); + if (forward_vertex === undefined) { return null; } + } + + return ordered; +} + +// Return the vertex that appears in both face_a and face_b but is not exclude_vi. +// This is the "shared outer vertex" between two consecutive ring triangles. +function find_shared_outer_vertex(face_a, face_b, exclude_vi) { + for (const va of face_a.vertices) { + if (va !== exclude_vi && face_b.vertices.includes(va)) { + return va; + } + } + return null; +} diff --git a/standalone/goldberg-sphere/undo_state.mjs b/standalone/goldberg-sphere/undo_state.mjs new file mode 100644 index 0000000..d730513 --- /dev/null +++ b/standalone/goldberg-sphere/undo_state.mjs @@ -0,0 +1,146 @@ +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; } +} From e6bbfb2dbeb804bdc9addbdc9b483e988adc84e6 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 10 May 2026 16:06:05 +0000 Subject: [PATCH 2/2] Remove maze feature; move adjacency into topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- standalone/goldberg-sphere/index.html | 48 --- standalone/goldberg-sphere/maze.mjs | 336 ------------------ .../goldberg-sphere/playground_main.mjs | 57 +-- standalone/goldberg-sphere/shaders/wall.frag | 2 - standalone/goldberg-sphere/shaders/wall.vert | 3 - standalone/goldberg-sphere/topology.mjs | 31 ++ 6 files changed, 33 insertions(+), 444 deletions(-) delete mode 100644 standalone/goldberg-sphere/maze.mjs delete mode 100644 standalone/goldberg-sphere/shaders/wall.frag delete mode 100644 standalone/goldberg-sphere/shaders/wall.vert diff --git a/standalone/goldberg-sphere/index.html b/standalone/goldberg-sphere/index.html index 938b628..44ed0f2 100644 --- a/standalone/goldberg-sphere/index.html +++ b/standalone/goldberg-sphere/index.html @@ -132,54 +132,6 @@ -
    -

    Maze

    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -

    Paint

    diff --git a/standalone/goldberg-sphere/maze.mjs b/standalone/goldberg-sphere/maze.mjs deleted file mode 100644 index 3357a06..0000000 --- a/standalone/goldberg-sphere/maze.mjs +++ /dev/null @@ -1,336 +0,0 @@ -import { make_buffer } from './gl_utils.mjs'; - -// --------------------------------------------------------------------------- -// Face adjacency -// --------------------------------------------------------------------------- - -// Build adjacency list for Goldberg faces via shared edges. -// Uses unrelaxed vertex positions as canonical keys — topology is stable across stages. -export function build_goldberg_adjacency(goldberg) { - const edge_to_entries = new Map(); - for (let fi = 0; fi < goldberg.faces.length; fi++) { - const verts = goldberg.faces[fi].vertices_3d; - const n = verts.length; - for (let i = 0; i < n; i++) { - const a = verts[i], b = verts[(i + 1) % n]; - const ka = `${a.x.toFixed(5)},${a.y.toFixed(5)},${a.z.toFixed(5)}`; - const kb = `${b.x.toFixed(5)},${b.y.toFixed(5)},${b.z.toFixed(5)}`; - const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - if (!edge_to_entries.has(key)) { edge_to_entries.set(key, []); } - edge_to_entries.get(key).push({ fi, edge_idx: i }); - } - } - const adj = Array.from({ length: goldberg.faces.length }, () => []); - for (const entries of edge_to_entries.values()) { - if (entries.length === 2) { - const [e0, e1] = entries; - adj[e0.fi].push({ fj: e1.fi, fi_edge_idx: e0.edge_idx }); - adj[e1.fi].push({ fj: e0.fi, fi_edge_idx: e1.edge_idx }); - } - } - return adj; -} - -// --------------------------------------------------------------------------- -// Prim island maze -// --------------------------------------------------------------------------- - -function grow_island(seed, max_cells, adj, cell_region, region_id) { - cell_region[seed] = region_id; - const frontier = []; - const add_frontier = fi => { - for (const { fj, fi_edge_idx } of adj[fi]) { - if (cell_region[fj] === -1) { frontier.push({ fi, fj, fi_edge_idx }); } - } - }; - add_frontier(seed); - - const passage_pairs = new Set(); - let count = 1; - while (frontier.length > 0 && count < max_cells) { - const idx = Math.floor(Math.random() * frontier.length); - const { fi, fj, fi_edge_idx } = frontier[idx]; - frontier[idx] = frontier[frontier.length - 1]; - frontier.pop(); - if (cell_region[fj] !== -1) { continue; } - const pk = fi < fj ? `${fi},${fj}` : `${fj},${fi}`; - passage_pairs.add(pk); - cell_region[fj] = region_id; - count++; - add_frontier(fj); - } - return passage_pairs; -} - -function generate_maze_walls(goldberg, seed_count, max_cells) { - const adj = build_goldberg_adjacency(goldberg); - const n = goldberg.faces.length; - const cell_region = new Int32Array(n).fill(-1); - const all_passages = new Set(); - - const order = Array.from({ length: n }, (_, i) => i); - for (let i = n - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [order[i], order[j]] = [order[j], order[i]]; - } - - let region_id = 0; - for (let s = 0; s < seed_count && s < n; s++) { - const seed = order[s]; - if (cell_region[seed] !== -1) { continue; } - const passages = grow_island(seed, max_cells, adj, cell_region, region_id); - for (const pk of passages) { all_passages.add(pk); } - region_id++; - } - - const wall_edges = []; - for (let fi = 0; fi < n; fi++) { - if (cell_region[fi] === -1) { continue; } - for (const { fj, fi_edge_idx } of adj[fi]) { - if (fi >= fj) { continue; } - if (cell_region[fj] === -1) { - wall_edges.push({ fi, edge_idx: fi_edge_idx }); - } else if (cell_region[fj] === cell_region[fi]) { - const pk = `${fi},${fj}`; - if (!all_passages.has(pk)) { wall_edges.push({ fi, edge_idx: fi_edge_idx }); } - } - } - } - return wall_edges; -} - -// --------------------------------------------------------------------------- -// Edge-walker maze -// --------------------------------------------------------------------------- - -function build_vertex_graph(goldberg) { - const pos_to_id = new Map(); - let nv = 0; - const get_vid = v => { - const k = `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; - if (!pos_to_id.has(k)) { pos_to_id.set(k, nv++); } - return pos_to_id.get(k); - }; - for (const face of goldberg.faces) { - for (const v of face.vertices_3d) { get_vid(v); } - } - const vertex_adj = Array.from({ length: nv }, () => []); - const edge_seen = new Set(); - const edge_data = new Map(); - for (let fi = 0; fi < goldberg.faces.length; fi++) { - const verts = goldberg.faces[fi].vertices_3d; - const n = verts.length; - for (let i = 0; i < n; i++) { - const va = get_vid(verts[i]); - const vb = get_vid(verts[(i + 1) % n]); - const ek = va < vb ? `${va},${vb}` : `${vb},${va}`; - if (!edge_seen.has(ek)) { - edge_seen.add(ek); - edge_data.set(ek, { fi, edge_idx: i }); - vertex_adj[va].push({ vj: vb, ek }); - vertex_adj[vb].push({ vj: va, ek }); - } - } - } - return { nv, vertex_adj, edge_data }; -} - -function generate_walker_walls(goldberg, walker_count, stop_prob, branch_prob, close_prob) { - const { nv, vertex_adj, edge_data } = build_vertex_graph(goldberg); - const walled = new Set(); - const vertex_walled = new Uint8Array(nv); - - const order = Array.from({ length: nv }, (_, i) => i); - for (let i = nv - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [order[i], order[j]] = [order[j], order[i]]; - } - - const active = []; - for (let w = 0; w < Math.min(walker_count, nv); w++) { - active.push({ vi: order[w], prev_vi: -1 }); - } - - while (active.length > 0) { - const wi = Math.floor(Math.random() * active.length); - const walker = active[wi]; - const { vi, prev_vi } = walker; - if (Math.random() < stop_prob) { active.splice(wi, 1); continue; } - const candidates = []; - for (const { vj, ek } of vertex_adj[vi]) { - if (vj === prev_vi) { continue; } - if (walled.has(ek)) { continue; } - if (vertex_walled[vj] && Math.random() >= close_prob) { continue; } - candidates.push({ vj, ek }); - } - if (candidates.length === 0) { active.splice(wi, 1); continue; } - const { vj, ek } = candidates[Math.floor(Math.random() * candidates.length)]; - walled.add(ek); - vertex_walled[vi] = 1; - vertex_walled[vj] = 1; - if (Math.random() < branch_prob) { active.push({ vi, prev_vi }); } - walker.vi = vj; - walker.prev_vi = vi; - } - - const wall_edges = []; - for (const ek of walled) { - const entry = edge_data.get(ek); - if (entry) { wall_edges.push({ fi: entry.fi, edge_idx: entry.edge_idx }); } - } - return wall_edges; -} - -// --------------------------------------------------------------------------- -// Wall geometry -// --------------------------------------------------------------------------- - -function build_wall_geo(goldberg, use_relaxed, wall_edges, wall_width) { - if (wall_edges.length === 0) { return { wall_pos: new Float32Array(0), wall_verts: 0 }; } - - const LIFT = 1.010; - const hw = wall_width / 2; - const MITER_CLAMP = 4.0; - - const ev = wall_edges.map(({ fi, edge_idx }) => { - const face = goldberg.faces[fi]; - const verts = use_relaxed ? (face.relaxed_vertices_3d ?? face.vertices_3d) : face.vertices_3d; - return { a: verts[edge_idx], b: verts[(edge_idx + 1) % verts.length] }; - }); - - const pk = v => `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; - - const vtx_edges = new Map(); - for (let i = 0; i < ev.length; i++) { - for (const v of [ev[i].a, ev[i].b]) { - const k = pk(v); - if (!vtx_edges.has(k)) { vtx_edges.set(k, []); } - vtx_edges.get(k).push(i); - } - } - - const w_dir = (v, w) => { - const ex = w.x-v.x, ey = w.y-v.y, ez = w.z-v.z; - const er = Math.sqrt(ex*ex + ey*ey + ez*ez); - if (er < 1e-10) { return [0, 0, 0]; } - const vr = Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z); - const nx = v.x/vr, ny = v.y/vr, nz = v.z/vr; - const edx = ex/er, edy = ey/er, edz = ez/er; - return [ny*edz - nz*edy, nz*edx - nx*edz, nx*edy - ny*edx]; - }; - - const miter_offset = (v, other_v, i) => { - const vk = pk(v); - const adj = vtx_edges.get(vk).filter(j => j !== i); - const w1 = w_dir(v, other_v); - if (adj.length === 0) { return [0, 0, 0]; } - let sx = w1[0], sy = w1[1], sz = w1[2]; - for (const j of adj) { - const { a, b } = ev[j]; - const far = pk(a) === vk ? b : a; - const wj = w_dir(v, far); - sx += wj[0]; sy += wj[1]; sz += wj[2]; - } - const sr = Math.sqrt(sx*sx + sy*sy + sz*sz); - if (sr < 1e-6) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; } - const m = [sx/sr, sy/sr, sz/sr]; - const d = w1[0]*m[0] + w1[1]*m[1] + w1[2]*m[2]; - if (d < 0.05) { return [w1[0]*hw, w1[1]*hw, w1[2]*hw]; } - const len = Math.min(hw / d, hw * MITER_CLAMP); - return [m[0]*len, m[1]*len, m[2]*len]; - }; - - const pos = []; - for (let i = 0; i < ev.length; i++) { - const { a, b } = ev[i]; - const oa = miter_offset(a, b, i); - const ob = miter_offset(b, a, i); - const ax = a.x*LIFT, ay = a.y*LIFT, az = a.z*LIFT; - const bx = b.x*LIFT, by = b.y*LIFT, bz = b.z*LIFT; - const term_a = oa[0] === 0 && oa[1] === 0 && oa[2] === 0; - const term_b = ob[0] === 0 && ob[1] === 0 && ob[2] === 0; - - if (term_a && term_b) { - const mx = (a.x+b.x)/2, my = (a.y+b.y)/2, mz = (a.z+b.z)/2; - const mr = Math.sqrt(mx*mx+my*my+mz*mz); - const ex = b.x-a.x, ey = b.y-a.y, ez = b.z-a.z; - const er = Math.sqrt(ex*ex+ey*ey+ez*ez); - const wd = [ - (my/mr)*(ez/er) - (mz/mr)*(ey/er), - (mz/mr)*(ex/er) - (mx/mr)*(ez/er), - (mx/mr)*(ey/er) - (my/mr)*(ex/er), - ]; - pos.push( - ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw, - ax+wd[0]*hw, ay+wd[1]*hw, az+wd[2]*hw, - bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw, - ax-wd[0]*hw, ay-wd[1]*hw, az-wd[2]*hw, - bx+wd[0]*hw, by+wd[1]*hw, bz+wd[2]*hw, - bx-wd[0]*hw, by-wd[1]*hw, bz-wd[2]*hw, - ); - continue; - } - - const v0 = [ax-oa[0], ay-oa[1], az-oa[2]]; - const v1 = [ax+oa[0], ay+oa[1], az+oa[2]]; - const v2 = [bx-ob[0], by-ob[1], bz-ob[2]]; - const v3 = [bx+ob[0], by+ob[1], bz+ob[2]]; - - if (term_a) { - pos.push(ax, ay, az, ...v2, ...v3); - } else if (term_b) { - pos.push(...v0, ...v1, bx, by, bz); - } else { - pos.push(...v0, ...v1, ...v2, ...v0, ...v2, ...v3); - } - } - return { wall_pos: new Float32Array(pos), wall_verts: pos.length / 3 }; -} - -// --------------------------------------------------------------------------- -// Maze_State -// --------------------------------------------------------------------------- - -export class Maze_State { - constructor() { - this.wall_edges = null; - this.pos_buf = null; - this.vert_count = 0; - } - - generate(goldberg, p) { - if (p.algo === 'walker') { - this.wall_edges = generate_walker_walls(goldberg, p.walkers, p.stop_prob, p.branch_prob, p.close_prob); - } else { - this.wall_edges = generate_maze_walls(goldberg, p.seeds, p.max_cells); - } - } - - upload(gl, goldberg, use_relaxed, wall_width) { - if (!this.wall_edges) { return; } - const geo = build_wall_geo(goldberg, use_relaxed, this.wall_edges, wall_width); - if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); } - this.pos_buf = make_buffer(gl, geo.wall_pos); - this.vert_count = geo.wall_verts; - } - - invalidate(gl) { - this.wall_edges = null; - if (this.pos_buf) { gl.deleteBuffer(this.pos_buf); this.pos_buf = null; } - this.vert_count = 0; - } - - draw(gl, prog, mvp, show) { - if (!show || !this.pos_buf || this.vert_count === 0) { return; } - gl.useProgram(prog); - gl.uniformMatrix4fv(gl.getUniformLocation(prog, 'u_mvp'), false, mvp); - const ap = gl.getAttribLocation(prog, 'a_pos'); - gl.bindBuffer(gl.ARRAY_BUFFER, this.pos_buf); - gl.enableVertexAttribArray(ap); - gl.vertexAttribPointer(ap, 3, gl.FLOAT, false, 0, 0); - gl.disable(gl.CULL_FACE); - gl.drawArrays(gl.TRIANGLES, 0, this.vert_count); - gl.enable(gl.CULL_FACE); - gl.disableVertexAttribArray(ap); - } -} diff --git a/standalone/goldberg-sphere/playground_main.mjs b/standalone/goldberg-sphere/playground_main.mjs index cdd0ca9..9a88ea7 100644 --- a/standalone/goldberg-sphere/playground_main.mjs +++ b/standalone/goldberg-sphere/playground_main.mjs @@ -6,7 +6,7 @@ import { create_program, make_buffer } from './gl_utils.mjs'; import { PAINT_BG, PAINT_BG_PBR, PAINT_BG_NOISE, Paint_State } from './paint_state.mjs'; import { Spin_State } from './spin_state.mjs'; import { shape_distortion, build_triangle_geo, build_goldberg_geo } from './render_geo.mjs'; -import { build_goldberg_adjacency, Maze_State } from './maze.mjs'; +import { 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'; @@ -15,13 +15,11 @@ import { PALETTES, DEFAULT_PALETTE } from './palettes.mjs'; // Load shaders // --------------------------------------------------------------------------- -const [face_vert_src, face_frag_src, edge_vert_src, edge_frag_src, wall_vert_src, wall_frag_src] = await Promise.all([ +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()), - fetch('./shaders/wall.vert').then(r => r.text()), - fetch('./shaders/wall.frag').then(r => r.text()), ]); // --------------------------------------------------------------------------- @@ -29,7 +27,6 @@ const [face_vert_src, face_frag_src, edge_vert_src, edge_frag_src, wall_vert_src // --------------------------------------------------------------------------- const cache = { depth: -1, ico: null, poly: null, goldberg: null, adj: null }; -const maze = new Maze_State(); const spin = new Spin_State(); const paint = new Paint_State(); const undo_state = new Undo_State(); @@ -53,40 +50,6 @@ function get_params() { }; } -function get_wall_width() { - return parseFloat(document.getElementById('maze-width').value) || 0.015; -} - -function get_maze_params() { - const algo = document.getElementById('maze-algo').value; - return { - algo, - seeds: parseInt(document.getElementById('maze-seeds').value, 10) || 12, - max_cells: parseInt(document.getElementById('maze-cells').value, 10) || 40, - walkers: parseInt(document.getElementById('maze-walkers').value, 10) || 20, - stop_prob: (parseInt(document.getElementById('maze-stop').value, 10) || 0) / 100, - branch_prob:(parseInt(document.getElementById('maze-branch').value, 10) || 0) / 100, - close_prob: (parseInt(document.getElementById('maze-close').value, 10) || 0) / 100, - }; -} - -function maze_applicable() { - return current_stage === 'goldberg' || current_stage === 'relaxed'; -} - -function update_maze() { - const show = document.getElementById('maze-show').checked; - if (!show || !maze_applicable() || !cache.goldberg) { - request_render(); - return; - } - if (!maze.wall_edges) { - maze.generate(cache.goldberg, get_maze_params()); - } - const use_relaxed = current_stage === 'relaxed'; - maze.upload(gl, cache.goldberg, use_relaxed, get_wall_width()); - request_render(); -} const status_el = document.getElementById('status'); const stats_el = document.getElementById('stats'); @@ -122,7 +85,6 @@ gl.polygonOffset(1, 1); const face_prog = create_program(gl, face_vert_src, face_frag_src); const edge_prog = create_program(gl, edge_vert_src, edge_frag_src); -const wall_prog = create_program(gl, wall_vert_src, wall_frag_src); let face_pos_buf = null, face_col_buf = null, face_pbr_buf = null, face_noise_buf = null, edge_pos_buf = null; let face_verts = 0, edge_verts = 0; @@ -289,7 +251,6 @@ async function build() { set_status('Building Goldberg dual…'); await yield_ui(30); cache.goldberg = Goldberg_Polyhedron.from_subdivided(cache.poly); cache.adj = null; - maze.invalidate(gl); undo_state.invalidate(); } if (current_stage === 'goldberg') { @@ -313,7 +274,6 @@ async function build() { } finally { building = false; btn.disabled = false; - update_maze(); snapshot_now(); } } @@ -480,7 +440,6 @@ function load_paint(file) { document.getElementById('depth').value = data.depth; document.getElementById('depth-val').textContent = data.depth; cache.poly = null; cache.goldberg = null; cache.adj = null; - maze.invalidate(gl); } paint.face_colors.clear(); paint.face_pbr.clear(); @@ -766,8 +725,6 @@ function render_frame(ts) { gl.drawArrays(gl.LINES, 0, edge_verts); gl.disableVertexAttribArray(ep); - const show_maze = document.getElementById('maze-show').checked && maze_applicable(); - maze.draw(gl, wall_prog, mvp, show_maze); gl.enable(gl.POLYGON_OFFSET_FILL); } @@ -811,7 +768,6 @@ document.getElementById('depth').addEventListener('input', () => { cache.poly = null; cache.goldberg = null; cache.adj = null; - maze.invalidate(gl); }); document.getElementById('build-btn').addEventListener('click', () => { @@ -822,15 +778,6 @@ document.getElementById('build-btn').addEventListener('click', () => { build(); }); -document.getElementById('maze-algo').addEventListener('change', e => { - const is_walker = e.target.value === 'walker'; - document.getElementById('prim-params').style.display = is_walker ? 'none' : ''; - document.getElementById('walker-params').style.display = is_walker ? '' : 'none'; -}); - -document.getElementById('maze-show').addEventListener('change', () => { update_maze(); }); -document.getElementById('maze-width').addEventListener('input', () => { update_maze(); }); -document.getElementById('maze-btn').addEventListener('click', () => { maze.invalidate(gl); update_maze(); }); document.getElementById('spin-btn').addEventListener('click', () => { const btn = document.getElementById('spin-btn'); diff --git a/standalone/goldberg-sphere/shaders/wall.frag b/standalone/goldberg-sphere/shaders/wall.frag deleted file mode 100644 index f943577..0000000 --- a/standalone/goldberg-sphere/shaders/wall.frag +++ /dev/null @@ -1,2 +0,0 @@ -precision mediump float; -void main() { gl_FragColor = vec4(1.0, 0.58, 0.08, 1.0); } diff --git a/standalone/goldberg-sphere/shaders/wall.vert b/standalone/goldberg-sphere/shaders/wall.vert deleted file mode 100644 index cf3741d..0000000 --- a/standalone/goldberg-sphere/shaders/wall.vert +++ /dev/null @@ -1,3 +0,0 @@ -attribute vec3 a_pos; -uniform mat4 u_mvp; -void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); } diff --git a/standalone/goldberg-sphere/topology.mjs b/standalone/goldberg-sphere/topology.mjs index 52e41c7..5952412 100644 --- a/standalone/goldberg-sphere/topology.mjs +++ b/standalone/goldberg-sphere/topology.mjs @@ -717,3 +717,34 @@ function find_shared_outer_vertex(face_a, face_b, exclude_vi) { } return null; } + +// --------------------------------------------------------------------------- +// Face adjacency (shared-edge graph) +// --------------------------------------------------------------------------- + +// Build adjacency list for Goldberg faces via shared edges. +// Uses unrelaxed vertex positions as canonical keys — topology is stable across stages. +export function build_goldberg_adjacency(goldberg) { + const edge_to_entries = new Map(); + for (let fi = 0; fi < goldberg.faces.length; fi++) { + const verts = goldberg.faces[fi].vertices_3d; + const n = verts.length; + for (let i = 0; i < n; i++) { + const a = verts[i], b = verts[(i + 1) % n]; + const ka = `${a.x.toFixed(5)},${a.y.toFixed(5)},${a.z.toFixed(5)}`; + const kb = `${b.x.toFixed(5)},${b.y.toFixed(5)},${b.z.toFixed(5)}`; + const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + if (!edge_to_entries.has(key)) { edge_to_entries.set(key, []); } + edge_to_entries.get(key).push({ fi, edge_idx: i }); + } + } + const adj = Array.from({ length: goldberg.faces.length }, () => []); + for (const entries of edge_to_entries.values()) { + if (entries.length === 2) { + const [e0, e1] = entries; + adj[e0.fi].push({ fj: e1.fi, fi_edge_idx: e0.edge_idx }); + adj[e1.fi].push({ fj: e0.fi, fi_edge_idx: e1.edge_idx }); + } + } + return adj; +}