forked from mikael-lovqvist/websperiments
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>
179 lines
6.2 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|