forked from mikael-lovqvist/websperiments
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>
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
<li><a href="standalone/delaunay.html">Delaunay dataset</a></li>
|
<li><a href="standalone/delaunay.html">Delaunay dataset</a></li>
|
||||||
<li><a href="standalone/delaunay-edge-relax.html">Delaunay dataset + Edge relaxation (Currently broken)</a></li>
|
<li><a href="standalone/delaunay-edge-relax.html">Delaunay dataset + Edge relaxation (Currently broken)</a></li>
|
||||||
<li><a href="standalone/fluidsynth-reverb.html">Reverb configurator for FluidSynth CLI UI</a></li>
|
<li><a href="standalone/fluidsynth-reverb.html">Reverb configurator for FluidSynth CLI UI</a></li>
|
||||||
|
<li><a href="standalone/goldberg-sphere/index.html">Goldberg Polyhedron Paint</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
52
standalone/goldberg-sphere/env_state.mjs
Normal file
52
standalone/goldberg-sphere/env_state.mjs
Normal file
@@ -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); }
|
||||||
|
}
|
||||||
98
standalone/goldberg-sphere/geometry.mjs
Normal file
98
standalone/goldberg-sphere/geometry.mjs
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
23
standalone/goldberg-sphere/gl_utils.mjs
Normal file
23
standalone/goldberg-sphere/gl_utils.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
346
standalone/goldberg-sphere/index.html
Normal file
346
standalone/goldberg-sphere/index.html
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Goldberg Polyhedron Paint</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #080c10; color: #bcd; font-family: monospace; font-size: 13px;
|
||||||
|
display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── sidebar ───────────────────────────────────────── */
|
||||||
|
#sidebar {
|
||||||
|
width: 240px; min-width: 240px;
|
||||||
|
background: #0c1218; border-right: 1px solid #1a2a3a;
|
||||||
|
padding: 10px; overflow-y: auto;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
h3 { color: #4af; font-size: 12px; text-transform: uppercase;
|
||||||
|
letter-spacing: 1px; border-bottom: 1px solid #1a2a3a; padding-bottom: 4px; }
|
||||||
|
|
||||||
|
/* stage buttons */
|
||||||
|
.stage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; }
|
||||||
|
.sb {
|
||||||
|
background: #0f1e2a; border: 1px solid #2a3a4a; color: #7ab;
|
||||||
|
padding: 6px 2px; cursor: pointer; font-family: monospace; font-size: 11px;
|
||||||
|
text-align: center; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.sb:hover { background: #1a3040; color: #cef; }
|
||||||
|
.sb.active { background: #0a2d4a; border-color: #4af; color: #4af; }
|
||||||
|
|
||||||
|
/* param rows */
|
||||||
|
.param { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.param label { width: 80px; color: #7ab; font-size: 11px; flex-shrink: 0; }
|
||||||
|
.param input[type=range] { flex: 1; max-width: 90px; }
|
||||||
|
.param input[type=number] {
|
||||||
|
width: 72px; background: #091520; border: 1px solid #1e3448; color: #cdf;
|
||||||
|
padding: 3px 5px; font-family: monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.param select {
|
||||||
|
flex: 1; background: #091520; border: 1px solid #1e3448; color: #cdf;
|
||||||
|
padding: 3px 5px; font-family: monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.val { width: 24px; text-align: right; color: #cdf; font-size: 11px; }
|
||||||
|
|
||||||
|
#spin-btn.active { background: #0a2d4a; border-color: #4af; color: #4af; }
|
||||||
|
.swatch { width: 18px; height: 18px; cursor: pointer; border: 1px solid #1a2a3a; flex-shrink: 0; }
|
||||||
|
.swatch:hover { outline: 2px solid #fff; outline-offset: 1px; }
|
||||||
|
#cd-swatch-grid .swatch { width: 28px; height: 28px; }
|
||||||
|
.swatch-tab { flex: 1; background: #0a1a2a; border: 1px solid #2a4a6a; color: #7ab; padding: 3px 2px; cursor: pointer; font-family: monospace; font-size: 10px; text-align: center; }
|
||||||
|
.swatch-tab.active { background: #0a2d4a; border-color: #4af; color: #4af; }
|
||||||
|
|
||||||
|
/* ── color picker dialog ─────────────────────────────── */
|
||||||
|
#color-dialog {
|
||||||
|
background: #0c1218; border: 1px solid #2a4a6a; color: #bcd;
|
||||||
|
padding: 16px; width: 420px; border-radius: 3px;
|
||||||
|
font-family: monospace; font-size: 12px;
|
||||||
|
position: fixed; inset: 0; margin: auto; height: fit-content;
|
||||||
|
}
|
||||||
|
#color-dialog::backdrop { background: rgba(0,0,0,0.65); }
|
||||||
|
.cd-model-tab {
|
||||||
|
flex: 1; background: #0a1a2a; border: 1px solid #2a4a6a; color: #7ab;
|
||||||
|
padding: 4px 2px; cursor: pointer; font-family: monospace; font-size: 11px; text-align: center;
|
||||||
|
}
|
||||||
|
.cd-model-tab.active { background: #0a2d4a; border-color: #4af; color: #4af; }
|
||||||
|
.cd-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||||
|
.cd-row label { width: 16px; color: #7ab; font-size: 11px; flex-shrink: 0; }
|
||||||
|
.cd-row input[type=range] { flex: 1; }
|
||||||
|
.cd-row .val { width: 28px; text-align: right; color: #cdf; font-size: 11px; }
|
||||||
|
#paint-btn.active, #paint-brush-btn.active, #paint-circle-btn.active, #paint-fill-btn.active, #paint-line-btn.active, #paint-pick-btn.active { background: #1a2d0a; border-color: #8d4; color: #8d4; }
|
||||||
|
|
||||||
|
#build-btn {
|
||||||
|
background: #0a2d4a; border: 1px solid #4af; color: #4af;
|
||||||
|
padding: 7px; cursor: pointer; font-family: monospace; font-size: 13px; width: 100%;
|
||||||
|
}
|
||||||
|
#build-btn:hover { background: #1a3d5a; }
|
||||||
|
#build-btn:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|
||||||
|
#status { color: #fa8; font-size: 11px; min-height: 14px; }
|
||||||
|
#stats { color: #6a9; font-size: 11px; line-height: 1.7; white-space: pre; }
|
||||||
|
|
||||||
|
/* ── canvas ─────────────────────────────────────────── */
|
||||||
|
#canvas-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; cursor: grab; }
|
||||||
|
canvas:active { cursor: grabbing; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
|
<div>
|
||||||
|
<h3>Sphere Playground</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Pipeline stage</h3>
|
||||||
|
<div class="stage-grid" style="margin-top:6px">
|
||||||
|
<button class="sb" data-stage="ico">Icosahedron</button>
|
||||||
|
<button class="sb" data-stage="subdiv">Subdivided</button>
|
||||||
|
<button class="sb" data-stage="goldberg">Goldberg</button>
|
||||||
|
<button class="sb active" data-stage="relaxed">Relaxed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Palette</h3>
|
||||||
|
<div id="palette-grid" class="stage-grid" style="margin-top:6px">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Mesh</h3>
|
||||||
|
<div class="param" style="margin-top:6px">
|
||||||
|
<label>Depth</label>
|
||||||
|
<input type="range" id="depth" min="1" max="5" value="4">
|
||||||
|
<span class="val" id="depth-val">4</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Relaxation</h3>
|
||||||
|
<div class="param" style="margin-top:6px">
|
||||||
|
<label>Iterations</label>
|
||||||
|
<input type="number" id="iters" value="32" min="0" step="50">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>α edge</label>
|
||||||
|
<input type="number" id="alpha-edge" value="0" min="0" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>α centroid</label>
|
||||||
|
<input type="number" id="alpha-centroid" value="0.04" min="0" step="0.001">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Maze</h3>
|
||||||
|
<div class="param" style="margin-top:6px">
|
||||||
|
<label>Show</label>
|
||||||
|
<input type="checkbox" id="maze-show">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Wall width</label>
|
||||||
|
<input type="number" id="maze-width" value="0.015" min="0.003" max="0.08" step="0.003">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Algorithm</label>
|
||||||
|
<select id="maze-algo">
|
||||||
|
<option value="prim">Islands (Prim)</option>
|
||||||
|
<option value="walker">Edge walker</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="prim-params">
|
||||||
|
<div class="param">
|
||||||
|
<label>Seeds</label>
|
||||||
|
<input type="number" id="maze-seeds" value="12" min="1" max="200" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Max cells</label>
|
||||||
|
<input type="number" id="maze-cells" value="40" min="3" max="500" step="5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="walker-params" style="display:none">
|
||||||
|
<div class="param">
|
||||||
|
<label>Walkers</label>
|
||||||
|
<input type="number" id="maze-walkers" value="20" min="1" max="500" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Stop %</label>
|
||||||
|
<input type="number" id="maze-stop" value="5" min="0" max="100" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Branch %</label>
|
||||||
|
<input type="number" id="maze-branch" value="15" min="0" max="100" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Close %</label>
|
||||||
|
<input type="number" id="maze-close" value="0" min="0" max="100" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="maze-btn" style="margin-top:4px; background:#0a1f0a; border:1px solid #2a5a2a; color:#4c8; padding:5px; cursor:pointer; font-family:monospace; font-size:12px; width:100%">⟳ New maze</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Paint</h3>
|
||||||
|
<div style="display:flex; gap:4px; margin-top:6px">
|
||||||
|
<button id="brush-tab-left" style="flex:1; background:#0a2d4a; border:1px solid #4af; color:#4af; padding:5px; cursor:pointer; font-family:monospace; font-size:11px">◀ Left</button>
|
||||||
|
<button id="brush-tab-right" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px; cursor:pointer; font-family:monospace; font-size:11px">Right ▶</button>
|
||||||
|
</div>
|
||||||
|
<div class="param" style="margin-top:6px">
|
||||||
|
<label>Color</label>
|
||||||
|
<div id="paint-color-btn" style="flex:1; height:26px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-swatch-tabs" style="display:flex; gap:2px; margin-top:4px"></div>
|
||||||
|
<div id="sidebar-swatch-grid" style="display:flex; flex-wrap:wrap; gap:2px; margin-top:3px"></div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Metallic</label>
|
||||||
|
<input type="range" id="metallic" min="0" max="1" step="0.01" value="0">
|
||||||
|
<span class="val" id="metallic-val">0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Roughness</label>
|
||||||
|
<input type="range" id="roughness" min="0" max="1" step="0.01" value="0.6">
|
||||||
|
<span class="val" id="roughness-val">0.60</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:3px; margin-top:6px; flex-wrap:wrap">
|
||||||
|
<button id="paint-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">✏ Pen</button>
|
||||||
|
<button id="paint-brush-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">⬤ Brush</button>
|
||||||
|
<button id="paint-circle-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">○ Circle</button>
|
||||||
|
<button id="paint-fill-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">⬛ Fill</button>
|
||||||
|
<button id="paint-line-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">╱ Line</button>
|
||||||
|
<button id="paint-pick-btn" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:5px 2px; cursor:pointer; font-family:monospace; font-size:11px">✦ Pick</button>
|
||||||
|
<button id="paint-clear" style="background:#1a0a0a; border:1px solid #4a2a2a; color:#a66; padding:5px 6px; cursor:pointer; font-family:monospace; font-size:11px">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="param" id="brush-params" style="display:none; margin-top:4px">
|
||||||
|
<label>Radius</label>
|
||||||
|
<input type="range" id="brush-radius" min="0" max="10" step="1" value="2">
|
||||||
|
<span class="val" id="brush-radius-val">2</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:4px; margin-top:4px">
|
||||||
|
<button id="paint-save" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px">↓ Save</button>
|
||||||
|
<button id="paint-load" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px">↑ Load</button>
|
||||||
|
<input type="file" id="paint-load-input" accept=".json" style="display:none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Noise</h3>
|
||||||
|
<div style="display:flex; gap:3px; margin-top:6px; flex-wrap:wrap">
|
||||||
|
<button class="noise-preset" data-preset="flat" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Flat</button>
|
||||||
|
<button class="noise-preset" data-preset="snow" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Snow</button>
|
||||||
|
<button class="noise-preset" data-preset="water" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Water</button>
|
||||||
|
<button class="noise-preset" data-preset="dunes" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Dunes</button>
|
||||||
|
<button class="noise-preset" data-preset="rock" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:4px 2px; cursor:pointer; font-family:monospace; font-size:10px">Rock</button>
|
||||||
|
</div>
|
||||||
|
<div class="param" style="margin-top:4px">
|
||||||
|
<label>Scale</label>
|
||||||
|
<input type="range" id="noise-scale" min="0" max="20" step="0.5" value="0">
|
||||||
|
<span class="val" id="noise-scale-val">0.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Strength</label>
|
||||||
|
<input type="range" id="noise-strength" min="0" max="3" step="0.05" value="0">
|
||||||
|
<span class="val" id="noise-strength-val">0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Octaves</label>
|
||||||
|
<input type="range" id="noise-octaves" min="1" max="8" step="1" value="4">
|
||||||
|
<span class="val" id="noise-octaves-val">4</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Gain</label>
|
||||||
|
<input type="range" id="noise-gain" min="0.2" max="0.8" step="0.05" value="0.5">
|
||||||
|
<span class="val" id="noise-gain-val">0.50</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Environment</h3>
|
||||||
|
<div style="color:#5a8; font-size:10px; margin-top:4px; margin-bottom:2px; letter-spacing:0.5px">LIGHT 1</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Azimuth</label>
|
||||||
|
<input type="range" id="l1-azim" min="-180" max="180" step="1" value="27">
|
||||||
|
<span class="val" id="l1-azim-val">27°</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Elevation</label>
|
||||||
|
<input type="range" id="l1-elev" min="-90" max="90" step="1" value="34">
|
||||||
|
<span class="val" id="l1-elev-val">34°</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Color</label>
|
||||||
|
<div id="l1-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
|
||||||
|
<input type="range" id="l1-intensity" min="0" max="4" step="0.05" value="1.5" style="flex:1; max-width:60px">
|
||||||
|
<span class="val" id="l1-intensity-val">1.50</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#5a8; font-size:10px; margin-top:6px; margin-bottom:2px; letter-spacing:0.5px">LIGHT 2</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Azimuth</label>
|
||||||
|
<input type="range" id="l2-azim" min="-180" max="180" step="1" value="-58">
|
||||||
|
<span class="val" id="l2-azim-val">-58°</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Elevation</label>
|
||||||
|
<input type="range" id="l2-elev" min="-90" max="90" step="1" value="-28">
|
||||||
|
<span class="val" id="l2-elev-val">-28°</span>
|
||||||
|
</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Color</label>
|
||||||
|
<div id="l2-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
|
||||||
|
<input type="range" id="l2-intensity" min="0" max="4" step="0.05" value="0.5" style="flex:1; max-width:60px">
|
||||||
|
<span class="val" id="l2-intensity-val">0.50</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#5a8; font-size:10px; margin-top:6px; margin-bottom:2px; letter-spacing:0.5px">AMBIENT</div>
|
||||||
|
<div class="param">
|
||||||
|
<label>Color</label>
|
||||||
|
<div id="amb-color-btn" style="flex:1; height:22px; border:1px solid #1e3448; cursor:pointer; border-radius:2px;" title="Pick color…"></div>
|
||||||
|
<input type="range" id="amb-intensity" min="0" max="4" step="0.05" value="0.07" style="flex:1; max-width:60px">
|
||||||
|
<span class="val" id="amb-intensity-val">0.07</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="spin-btn" style="background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:7px; cursor:pointer; font-family:monospace; font-size:13px; width:100%">↻ Auto-spin</button>
|
||||||
|
<button id="build-btn">▶ Build</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div id="status">Ready</div>
|
||||||
|
<div id="stats"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="canvas-wrap">
|
||||||
|
<canvas id="gl-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="color-dialog">
|
||||||
|
<div id="cd-preview" style="height:32px; border-radius:2px; margin-bottom:10px; border:1px solid #2a4a6a;"></div>
|
||||||
|
<div style="display:flex; gap:3px; margin-bottom:2px">
|
||||||
|
<button class="cd-model-tab active" data-model="hsl">HSL</button>
|
||||||
|
<button class="cd-model-tab" data-model="rgb">RGB</button>
|
||||||
|
<button class="cd-model-tab" data-model="hex">Hex</button>
|
||||||
|
</div>
|
||||||
|
<div id="cd-panel-hsl">
|
||||||
|
<div class="cd-row"><label>H</label><input type="range" id="cd-h" min="0" max="360" step="1"><span class="val" id="cd-h-val">0</span></div>
|
||||||
|
<div class="cd-row"><label>S</label><input type="range" id="cd-s" min="0" max="100" step="1"><span class="val" id="cd-s-val">0%</span></div>
|
||||||
|
<div class="cd-row"><label>L</label><input type="range" id="cd-l" min="0" max="100" step="1"><span class="val" id="cd-l-val">0%</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="cd-panel-rgb" style="display:none">
|
||||||
|
<div class="cd-row"><label>R</label><input type="range" id="cd-r" min="0" max="255" step="1"><span class="val" id="cd-r-val">0</span></div>
|
||||||
|
<div class="cd-row"><label>G</label><input type="range" id="cd-g" min="0" max="255" step="1"><span class="val" id="cd-g-val">0</span></div>
|
||||||
|
<div class="cd-row"><label>B</label><input type="range" id="cd-b" min="0" max="255" step="1"><span class="val" id="cd-b-val">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="cd-panel-hex" style="display:none; margin-top:6px">
|
||||||
|
<input type="text" id="cd-hex" maxlength="7" style="width:100%; background:#091520; border:1px solid #1e3448; color:#cdf; padding:5px; font-family:monospace; font-size:13px; text-align:center;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px; border-top:1px solid #1a2a3a; padding-top:8px">
|
||||||
|
<div id="cd-swatch-tabs" style="display:flex; gap:2px; margin-bottom:4px"></div>
|
||||||
|
<div id="cd-swatch-grid" style="display:flex; flex-wrap:wrap; gap:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px; margin-top:10px">
|
||||||
|
<button id="cd-close" style="flex:1; background:#0a1a2a; border:1px solid #2a4a6a; color:#7ab; padding:6px; cursor:pointer; font-family:monospace; font-size:12px;">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script type="module" src="./playground_main.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
standalone/goldberg-sphere/math.mjs
Normal file
56
standalone/goldberg-sphere/math.mjs
Normal file
@@ -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]]);
|
||||||
|
}
|
||||||
336
standalone/goldberg-sphere/maze.mjs
Normal file
336
standalone/goldberg-sphere/maze.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
standalone/goldberg-sphere/paint_state.mjs
Normal file
88
standalone/goldberg-sphere/paint_state.mjs
Normal file
@@ -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<fi>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
standalone/goldberg-sphere/palettes.mjs
Normal file
80
standalone/goldberg-sphere/palettes.mjs
Normal file
@@ -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];
|
||||||
77
standalone/goldberg-sphere/placer_gnomonic_3d.mjs
Normal file
77
standalone/goldberg-sphere/placer_gnomonic_3d.mjs
Normal file
@@ -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<face_index, { vertices_2d, centroid_2d, depth }>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
65
standalone/goldberg-sphere/placer_ortho_3d.mjs
Normal file
65
standalone/goldberg-sphere/placer_ortho_3d.mjs
Normal file
@@ -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<face_index, { vertices_2d, centroid_2d, depth }>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
236
standalone/goldberg-sphere/placer_relax.mjs
Normal file
236
standalone/goldberg-sphere/placer_relax.mjs
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
97
standalone/goldberg-sphere/placer_relax_loose.mjs
Normal file
97
standalone/goldberg-sphere/placer_relax_loose.mjs
Normal file
@@ -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<face_index, { vertices_2d, centroid_2d, depth }>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
68
standalone/goldberg-sphere/placer_unfold.mjs
Normal file
68
standalone/goldberg-sphere/placer_unfold.mjs
Normal file
@@ -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<face_index, { vertices_2d, centroid_2d, depth }>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
1236
standalone/goldberg-sphere/playground_main.mjs
Normal file
1236
standalone/goldberg-sphere/playground_main.mjs
Normal file
File diff suppressed because it is too large
Load Diff
117
standalone/goldberg-sphere/render_geo.mjs
Normal file
117
standalone/goldberg-sphere/render_geo.mjs
Normal file
@@ -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 (l<mn) { mn=l; } if (l>mx) { 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<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);
|
||||||
|
if (sr > 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; i<n-1; i++) {
|
||||||
|
for (const v of [verts[0], verts[i], verts[i+1]]) {
|
||||||
|
fpos.push(v.x,v.y,v.z);
|
||||||
|
fcol.push(c[0],c[1],c[2]);
|
||||||
|
fpbr.push(pbr[0],pbr[1]);
|
||||||
|
fnoise.push(noise[0],noise[1],noise[2],noise[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i=0; i<n; i++) {
|
||||||
|
const a=verts[i], b=verts[(i+1)%n];
|
||||||
|
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: goldberg.faces.length,
|
||||||
|
pentagons: goldberg.faces.filter(f=>f.is_pentagon).length,
|
||||||
|
worst_shape,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
178
standalone/goldberg-sphere/renderer.mjs
Normal file
178
standalone/goldberg-sphere/renderer.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
standalone/goldberg-sphere/shaders/edge.frag
Normal file
2
standalone/goldberg-sphere/shaders/edge.frag
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
precision mediump float;
|
||||||
|
void main() { gl_FragColor = vec4(0.05, 0.05, 0.05, 1.0); }
|
||||||
3
standalone/goldberg-sphere/shaders/edge.vert
Normal file
3
standalone/goldberg-sphere/shaders/edge.vert
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
attribute vec3 a_pos;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }
|
||||||
112
standalone/goldberg-sphere/shaders/face.frag
Normal file
112
standalone/goldberg-sphere/shaders/face.frag
Normal file
@@ -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);
|
||||||
|
}
|
||||||
21
standalone/goldberg-sphere/shaders/face.vert
Normal file
21
standalone/goldberg-sphere/shaders/face.vert
Normal file
@@ -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;
|
||||||
|
}
|
||||||
2
standalone/goldberg-sphere/shaders/wall.frag
Normal file
2
standalone/goldberg-sphere/shaders/wall.frag
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
precision mediump float;
|
||||||
|
void main() { gl_FragColor = vec4(1.0, 0.58, 0.08, 1.0); }
|
||||||
3
standalone/goldberg-sphere/shaders/wall.vert
Normal file
3
standalone/goldberg-sphere/shaders/wall.vert
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
attribute vec3 a_pos;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }
|
||||||
45
standalone/goldberg-sphere/spin_state.mjs
Normal file
45
standalone/goldberg-sphere/spin_state.mjs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
719
standalone/goldberg-sphere/topology.mjs
Normal file
719
standalone/goldberg-sphere/topology.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
146
standalone/goldberg-sphere/undo_state.mjs
Normal file
146
standalone/goldberg-sphere/undo_state.mjs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user