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

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

237 lines
7.9 KiB
JavaScript

// 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<face_index, { vertices_2d, centroid_2d, depth }>
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;
}