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