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

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

179 lines
6.2 KiB
JavaScript

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