// 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; }