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>
237 lines
7.9 KiB
JavaScript
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;
|
|
}
|