commit 70bc8a44955f5542aee066952139a871b885fe91
parent 6f32d26433ea29f39972279b9312494ccba1f5d1
Author: ling0x <ling0x@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:58:28 +0100
updates
Diffstat:
3 files changed, 1492 insertions(+), 0 deletions(-)
diff --git a/commands/git.txt b/commands/git.txt
@@ -2,3 +2,6 @@ GIT
Pretty display git log:
git log --graph --pretty=format:'%h - %10n: %s' --date=short
+
+Discard/Reset uncommited changes:
+git reset --hard HEAD && git status --short
diff --git a/commands/pdf-image.txt b/commands/pdf-image.txt
@@ -0,0 +1,13 @@
+PDF and Images related commands
+
+Combine PDFs:
+
+gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=combined.pdf input1.pdf input2.pdf
+
+With slight compression:
+
+gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook -sOutputFile=combined.pdf input1.pdf input2.pdf
+
+Rasterize PDF to reduce its size:
+
+convert -density 200 combined.pdf -quality 90 -compress jpeg combined_raster_hq.pdf
+\ No newline at end of file
diff --git a/mathematics/subdivision-interactive.html b/mathematics/subdivision-interactive.html
@@ -0,0 +1,1475 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Subdivision Surfaces — from the refinement equation</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css">
+<style>
+:root{
+ --ground:#10151a; --panel:#171e25; --panel2:#1d262e;
+ --chalk:#e8e6df; --dim:#9aa5ad; --cage:#6b7c8c;
+ --face:#f2b04a; --edge:#4fd6c4; --vert:#e86a92;
+ --rule:rgba(107,124,140,.22);
+ --mono:"IBM Plex Mono",ui-monospace,monospace;
+ --sans:"IBM Plex Sans",system-ui,sans-serif;
+ --cond:"IBM Plex Sans Condensed","IBM Plex Sans",system-ui,sans-serif;
+}
+*{box-sizing:border-box}
+html{-webkit-text-size-adjust:100%}
+body{margin:0;background:var(--ground);color:var(--chalk);font-family:var(--sans);
+ font-size:16.5px;line-height:1.65;-webkit-font-smoothing:antialiased}
+canvas{display:block;max-width:100%}
+.wrap{max-width:1080px;margin:0 auto;padding:0 22px 110px}
+.col{max-width:66ch}
+h1,h2,h3{font-family:var(--cond);font-weight:700;letter-spacing:-.015em;line-height:1.08;margin:0}
+h2{font-size:clamp(1.5rem,4.4vw,2.15rem);margin:0 0 .5rem}
+h4{font-family:var(--mono);font-size:.72rem;font-weight:600;letter-spacing:.14em;
+ text-transform:uppercase;color:var(--dim);margin:2rem 0 .6rem}
+p{margin:0 0 1rem} ul,ol{margin:0 0 1rem;padding-left:1.25rem} li{margin-bottom:.4rem}
+strong{font-weight:600;color:#fff} em{color:var(--chalk)}
+code{font-family:var(--mono);font-size:.87em;background:rgba(107,124,140,.16);
+ padding:.12em .38em;border-radius:3px}
+.hero{padding:clamp(52px,9vw,92px) 0 12px}
+.eyebrow{font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
+ color:var(--cage);margin-bottom:1.4rem}
+.hero h1{font-size:clamp(2.35rem,8vw,4.5rem);letter-spacing:-.03em;margin-bottom:1.1rem}
+.hero h1 .thin{display:block;font-weight:600;color:var(--dim);font-size:.44em;
+ letter-spacing:-.01em;margin-top:.55rem}
+.lede{font-size:clamp(1.02rem,2.4vw,1.2rem);color:var(--dim);max-width:56ch}
+.lede b{color:var(--chalk);font-weight:500}
+.legend{position:sticky;top:0;z-index:50;background:rgba(16,21,26,.94);
+ backdrop-filter:blur(10px);border-bottom:1px solid var(--rule);padding:9px 0;margin:52px 0 0}
+.legend-in{max-width:1080px;margin:0 auto;padding:0 22px;display:flex;gap:16px;
+ flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:.7rem}
+.key{display:flex;align-items:center;gap:6px;color:var(--dim);white-space:nowrap}
+.dot{width:9px;height:9px;border-radius:50%;flex:none}
+.dot.f{background:var(--face)}.dot.e{background:var(--edge)}
+.dot.v{background:var(--vert)}.dot.c{background:var(--cage)}
+section{padding:62px 0 0}
+.snum{font-family:var(--mono);font-size:.7rem;letter-spacing:.18em;color:var(--cage);
+ display:block;margin-bottom:.85rem}
+.snum b{color:var(--edge);font-weight:500}
+.deriv{border-left:2px solid var(--rule);padding:2px 0 2px 20px;margin:1.5rem 0}
+.deriv.f{border-color:rgba(242,176,74,.55)}
+.deriv.e{border-color:rgba(79,214,196,.55)}
+.deriv.v{border-color:rgba(232,106,146,.55)}
+.deriv h5{font-family:var(--mono);font-size:.7rem;letter-spacing:.13em;text-transform:uppercase;
+ color:var(--dim);margin:0 0 .55rem;font-weight:500}
+.deriv p:last-child{margin-bottom:0}
+.note{background:var(--panel);border:1px solid var(--rule);border-radius:5px;
+ padding:16px 18px;margin:1.5rem 0;font-size:.94rem}
+.note h5{font-family:var(--mono);font-size:.7rem;letter-spacing:.13em;text-transform:uppercase;
+ color:var(--edge);margin:0 0 .5rem;font-weight:600}
+.note p:last-child{margin-bottom:0}
+figure{margin:2.2rem 0;background:var(--panel);border:1px solid var(--rule);
+ border-radius:6px;overflow:hidden}
+.figbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:10px 14px;
+ border-bottom:1px solid var(--rule);background:var(--panel2)}
+.ftitle{font-family:var(--mono);font-size:.7rem;letter-spacing:.11em;text-transform:uppercase;
+ color:var(--dim);margin-right:auto}
+figure canvas{width:100%;height:auto;touch-action:none;cursor:grab;background:#0d1216}
+figure canvas:active{cursor:grabbing}
+figcaption{padding:12px 16px 14px;font-size:.85rem;color:var(--dim);
+ border-top:1px solid var(--rule);line-height:1.6}
+figcaption b{color:var(--chalk);font-weight:500}
+.readout{font-family:var(--mono);font-size:.73rem;color:var(--dim);padding:9px 14px;
+ border-top:1px solid var(--rule);min-height:34px;line-height:1.55}
+.readout b{color:var(--chalk);font-weight:500}
+.ctl{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:.71rem;color:var(--dim)}
+input[type=range]{-webkit-appearance:none;appearance:none;background:transparent;
+ width:104px;height:18px;margin:0;cursor:pointer}
+input[type=range]::-webkit-slider-runnable-track{height:2px;background:var(--rule)}
+input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;
+ border-radius:50%;background:var(--chalk);margin-top:-5px;border:none}
+input[type=range]::-moz-range-track{height:2px;background:var(--rule)}
+input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;
+ background:var(--chalk);border:none}
+input[type=range]:focus-visible{outline:2px solid var(--edge);outline-offset:3px}
+.seg{display:inline-flex;border:1px solid var(--rule);border-radius:4px;overflow:hidden}
+.seg button{font-family:var(--mono);font-size:.69rem;background:transparent;color:var(--dim);
+ border:0;padding:5px 9px;cursor:pointer;border-right:1px solid var(--rule)}
+.seg button:last-child{border-right:0}
+.seg button[aria-pressed=true]{background:var(--chalk);color:#0d1216;font-weight:600}
+.seg button:focus-visible{outline:2px solid var(--edge);outline-offset:-2px}
+.btn{font-family:var(--mono);font-size:.69rem;background:transparent;color:var(--dim);
+ border:1px solid var(--rule);border-radius:4px;padding:5px 10px;cursor:pointer}
+.btn:hover{color:var(--chalk);border-color:var(--cage)}
+.btn:focus-visible{outline:2px solid var(--edge);outline-offset:2px}
+.val{color:var(--chalk);min-width:1.6em;display:inline-block}
+label.chk{display:inline-flex;align-items:center;gap:5px;cursor:pointer}
+input[type=checkbox]{accent-color:var(--edge);width:13px;height:13px;margin:0}
+.stencils{display:flex;gap:22px;flex-wrap:wrap;margin:1.6rem 0}
+.sten{font-family:var(--mono);font-size:.72rem}
+.sten .lab{color:var(--dim);letter-spacing:.1em;text-transform:uppercase;
+ font-size:.66rem;margin-bottom:7px}
+.sten .grid{display:inline-grid;gap:2px;background:var(--rule);padding:2px;border-radius:3px}
+.sten .cell{background:var(--panel2);width:38px;height:30px;display:flex;
+ align-items:center;justify-content:center;color:var(--chalk)}
+.sten.fS .cell.hi{background:rgba(242,176,74,.2);color:var(--face)}
+.sten.eS .cell.hi{background:rgba(79,214,196,.2);color:var(--edge)}
+.sten.vS .cell.hi{background:rgba(232,106,146,.2);color:var(--vert)}
+.tbl{width:100%;overflow-x:auto;margin:1.7rem 0;-webkit-overflow-scrolling:touch}
+table{border-collapse:collapse;font-size:.86rem;min-width:520px;width:100%}
+th,td{text-align:left;padding:9px 13px 9px 0;border-bottom:1px solid var(--rule);
+ vertical-align:top}
+th{font-family:var(--mono);font-size:.67rem;letter-spacing:.1em;text-transform:uppercase;
+ color:var(--dim);font-weight:500;white-space:nowrap}
+td code{background:none;padding:0}
+pre{font-family:var(--mono);font-size:.79rem;background:var(--panel);
+ border:1px solid var(--rule);border-radius:5px;padding:14px 16px;overflow-x:auto;
+ line-height:1.7;color:var(--chalk)}
+hr{border:0;border-top:1px solid var(--rule);margin:58px 0 0}
+.end{font-family:var(--mono);font-size:.76rem;color:var(--cage);line-height:1.9;padding-top:46px}
+.katex{font-size:1.03em}
+.katex-display{margin:1.15rem 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
+@media (max-width:640px){
+ body{font-size:16px}
+ .wrap{padding:0 16px 80px}
+ .legend-in{padding:0 16px;gap:11px;font-size:.66rem}
+ .deriv{padding-left:14px}
+ .sten .cell{width:33px;height:27px}
+ .figbar{gap:10px;padding:9px 11px}
+ input[type=range]{width:84px}
+}
+@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header class="hero">
+ <div class="eyebrow">Refinement operators on meshes</div>
+ <h1>Subdivision<br>Surfaces
+ <span class="thin">Every mask below is a B-spline in disguise.</span></h1>
+ <p class="lede">Drag the white points. A <b>control polygon</b> is hit repeatedly by one
+ linear operator; in the limit it becomes a curve you never wrote an equation for. Everything
+ else here is that same idea applied to meshes.</p>
+</header>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 1 — refinement, live</span>
+ <div class="seg" id="f1scheme">
+ <button data-s="chaikin" aria-pressed="true">Chaikin · quadratic</button>
+ <button data-s="cubic" aria-pressed="false">Cubic B-spline</button></div>
+ <span class="ctl">level <input type="range" id="f1lvl" min="0" max="6" value="2">
+ <b class="val" id="f1lvlv">2</b></span>
+ <label class="chk ctl"><input type="checkbox" id="f1limit" checked> limit</label>
+ </div>
+ <canvas id="f1"></canvas>
+ <div class="readout" id="f1out"></div>
+ <figcaption>Drag the white points — one finger works on a phone. <b>Chaikin</b> discards the
+ old vertices and puts two new points on every edge (a <em>dual</em> scheme). <b>Cubic</b>
+ keeps them and relaxes them (<em>primal</em>). At level 6 the polygon has already become the
+ curve.</figcaption>
+</figure>
+
+<div class="legend"><div class="legend-in">
+ <span class="key" style="letter-spacing:.13em">COLOUR KEY</span>
+ <span class="key"><i class="dot c"></i>control cage, level k</span>
+ <span class="key"><i class="dot f"></i>face point</span>
+ <span class="key"><i class="dot e"></i>edge point</span>
+ <span class="key"><i class="dot v"></i>vertex point</span>
+</div></div>
+
+<section><div class="col">
+<span class="snum"><b>00</b> · VOCABULARY & THE COUNTING ARGUMENT</span>
+<h2>Why irregular vertices are unavoidable</h2>
+<p>A polygon mesh \(M=(V,E,F)\) carries two independent kinds of information, and subdivision
+treats them separately:</p>
+<ul>
+<li><strong>Topology</strong> — which vertices join to which. Purely combinatorial.</li>
+<li><strong>Geometry</strong> — the map \(V\to\mathbb{R}^3\) of positions. Purely numerical.</li>
+</ul>
+<p>Each step applies a <em>topological rule</em> (split faces) then a <em>geometric rule</em>
+(average positions). Keeping them apart is what lets the method work on arbitrary shapes.</p>
+<h4>Definitions</h4>
+<div class="tbl"><table>
+<tr><th>Term</th><th>Meaning</th></tr>
+<tr><td><strong>Valence</strong> \(n(v)\)</td><td>number of edges incident to \(v\)</td></tr>
+<tr><td><strong>\(k\)-ring</strong> of \(v\)</td><td>vertices at graph distance \(\le k\)</td></tr>
+<tr><td><strong>Manifold</strong></td><td>every edge has 1 or 2 faces; every vertex's faces form one fan</td></tr>
+<tr><td><strong>Regular vertex</strong></td><td>quad mesh: \(n=4\). Triangle mesh: \(n=6\)</td></tr>
+<tr><td><strong>Extraordinary vertex</strong> (EV)</td><td>any vertex that is not regular</td></tr>
+<tr><td><strong>Regular region</strong></td><td>submesh with no EV — locally a \(\mathbb{Z}^2\) grid</td></tr>
+</table></div>
+<div class="deriv">
+<h5>Derivation — the Euler obstruction</h5>
+<p>For a closed orientable mesh, \(V-E+F=2-2g\). Suppose a closed quad mesh had <em>every</em>
+vertex of valence 4. Each face has 4 edges, each edge shared by 2 faces, so \(4F=2E\Rightarrow
+F=E/2\). Each vertex has 4 edge-ends, each edge has 2 ends, so \(4V=2E\Rightarrow V=E/2\).
+Then</p>
+\[\frac{E}{2}-E+\frac{E}{2}=0=2-2g\;\Longrightarrow\;g=1\]
+<p>So <strong>only a torus admits an all-regular closed quad mesh.</strong> A sphere, a car
+body, a wing fairing — every one <em>must</em> contain extraordinary vertices.</p>
+</div>
+<p>This drives everything below. Tensor-product B-splines need a global rectangular domain and
+therefore cannot cover a closed surface. Subdivision throws away the global domain and keeps
+only the <em>local</em> averaging rule — after which the whole literature is one question:
+<em>what happens at the EVs?</em></p>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 2 — valence, and where the EVs sit</span>
+ <div class="seg" id="f2shape">
+ <button data-s="cube" aria-pressed="true">Cube</button>
+ <button data-s="torus" aria-pressed="false">Torus</button>
+ <button data-s="lshape" aria-pressed="false">L-block</button></div>
+ </div>
+ <canvas id="f2"></canvas>
+ <div class="readout" id="f2out"></div>
+ <figcaption>Drag to rotate. Vertices coloured by valence:
+ <b style="color:#6b7c8c">grey = 4, regular</b>;
+ <b style="color:#e86a92">rose = extraordinary</b>. The cube's 8 corners are all valence 3 —
+ every one an EV. The torus is the single shape that gets away with none.</figcaption>
+</figure>
+</section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>01</b> · WHERE THE MASKS COME FROM</span>
+<h2>The refinement equation</h2>
+<p>Subdivision is read <em>backwards</em> out of B-spline theory, so we need that first —
+briefly, and only the part that matters.</p>
+<h4>Bases</h4>
+<p>Bernstein: \(B_i^n(t)=\binom{n}{i}t^i(1-t)^{n-i}\), giving
+\(\mathbf{C}(t)=\sum_i\mathbf{P}_iB_i^n(t)\). Since \(B_i^n\ge0\) and \(\sum_iB_i^n\equiv1\) —
+a <strong>partition of unity</strong> — affine invariance and the convex-hull property come
+free. Every mask below satisfies that pair.</p>
+<p>B-splines replace Bernstein with Cox–de Boor on a knot vector:</p>
+\[N_{i,p}(t)=\frac{t-t_i}{t_{i+p}-t_i}N_{i,p-1}(t)+\frac{t_{i+p+1}-t}{t_{i+p+1}-t_{i+1}}N_{i+1,p-1}(t)\]
+<p><strong>Uniform</strong> means \(t_i=i\); then every basis function is an integer translate of
+one <strong>cardinal B-spline</strong>, a repeated box convolution:</p>
+\[N_p=\underbrace{\chi_{[0,1]}*\cdots*\chi_{[0,1]}}_{p+1\ \text{factors}},\qquad
+\operatorname{supp}N_p=[0,p+1],\qquad N_p\in C^{p-1}\]
+<div class="deriv e">
+<h5>Derivation — the two-scale relation</h5>
+<p>Cardinal B-splines are <strong>refinable</strong>. In Fourier,
+\(\widehat{N_p}(\omega)=\big(\tfrac{1-e^{-i\omega}}{i\omega}\big)^{p+1}\), so halving the
+frequency gives \(\widehat{N_p}(\omega)=a(e^{-i\omega/2})\widehat{N_p}(\omega/2)\) with
+\(a(z)=(1+z)^{p+1}/2^{p}\). Transforming back:</p>
+\[\boxed{\;N_p(t)=\frac{1}{2^{p}}\sum_{k=0}^{p+1}\binom{p+1}{k}N_p(2t-k)\;}\]
+<p>Call \(a_k=2^{-p}\binom{p+1}{k}\) the <strong>mask</strong> and \(a(z)=\sum_ka_kz^k\) the
+<strong>symbol</strong>. This is the whole trick: <em>the same curve, on a knot grid twice as
+fine, has different but computable control points.</em> Refinement replaces evaluation.</p>
+</div>
+<div class="deriv">
+<h5>Derivation — mask to operator</h5>
+\[\boxed{\;\mathbf{p}^{k+1}_i=\sum_j a_{\,i-2j}\,\mathbf{p}^k_j\;}\]
+<p>The \(-2j\) encodes the scale change. Splitting by the parity of \(i\) gives two
+independent <strong>stencils</strong>:</p>
+\[\text{even (vertex):}\ \mathbf{p}^{k+1}_{2i}=\sum_j a_{2j}\mathbf{p}^k_{i-j}
+\qquad \text{odd (edge):}\ \mathbf{p}^{k+1}_{2i+1}=\sum_j a_{2j+1}\mathbf{p}^k_{i-j}\]
+<p><strong>Convergence:</strong> \(a(1)=2\) and \(a(-1)=0\) — equivalently the even
+coefficients sum to 1 <em>and</em> the odd ones sum to 1. Each stencil is separately a
+partition of unity, so affine invariance and convex-hull containment hold at <em>every</em>
+level, not just in the limit.</p>
+</div>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 3 — symbol, mask, stencils</span>
+ <span class="ctl">degree p <input type="range" id="f3p" min="1" max="5" value="3">
+ <b class="val" id="f3pv">3</b></span>
+ </div>
+ <canvas id="f3"></canvas>
+ <div class="readout" id="f3out"></div>
+ <figcaption>Left: \(N_p\) in white, drawn as the sum of its own half-scale copies weighted by
+ the mask (<b style="color:#e86a92">even k</b>, <b style="color:#4fd6c4">odd k</b>) — that is
+ the two-scale relation, plotted. Right: the mask split by parity. Degree 2 is Chaikin;
+ degree 3 is split-and-smooth, which becomes Catmull–Clark.</figcaption>
+</figure>
+
+<div class="col">
+<div class="deriv e">
+<h5>Degree 2 — Chaikin, 1974</h5>
+<p>\(a(z)=\tfrac14(1+z)^3\), mask \(\tfrac14[1,3,3,1]\). Parity split:</p>
+\[\mathbf{q}_i=\tfrac34\mathbf{p}_i+\tfrac14\mathbf{p}_{i+1},\qquad
+\mathbf{r}_i=\tfrac14\mathbf{p}_i+\tfrac34\mathbf{p}_{i+1}\]
+<p>Two new points per edge at \(\tfrac14,\tfrac34\); old vertices <em>discarded</em>. Chaikin
+invented it as pure <strong>corner cutting</strong>, no spline in mind — Riesenfeld later
+proved the limit is the uniform quadratic B-spline. The idea traces to de Rham in the
+1940s.</p>
+</div>
+<div class="deriv v">
+<h5>Degree 3 — split and smooth</h5>
+<p>\(a(z)=\tfrac18(1+z)^4\), mask \(\tfrac18[1,4,6,4,1]\). Parity split:</p>
+\[\text{odd}=\tfrac18[4,4]=[\tfrac12,\tfrac12],\qquad
+\text{even}=\tfrac18[1,6,1]=[\tfrac18,\tfrac34,\tfrac18]\]
+<p>As an algorithm: <strong>insert every edge midpoint, then relax every old vertex by
+\(\tfrac18,\tfrac34,\tfrac18\)</strong>. That relaxation weight is the direct ancestor of every
+surface vertex rule on this page.</p>
+</div>
+<div class="note"><h5>Lane–Riesenfeld, 1980</h5>
+<p>The general degree-\(p\) scheme is one midpoint-doubling pass then \(p\) passes of pairwise
+averaging — convolving with \(\tfrac12[1,1]\) raises the degree by one each time. \(O(p)\) per
+point and trivially vectorised.</p></div>
+<h4>Primal vs dual — a distinction that persists</h4>
+<div class="tbl"><table>
+<tr><th></th><th>Old vertices</th><th>New mesh sits</th><th>Curve</th><th>Surface</th></tr>
+<tr><td><strong>Dual</strong></td><td>discarded</td><td>in the <em>faces</em></td><td>Chaikin</td><td>Doo–Sabin</td></tr>
+<tr><td><strong>Primal</strong></td><td>retained, moved</td><td>on <em>vertices + edges</em></td><td>cubic</td><td>Catmull–Clark, Loop</td></tr>
+</table></div>
+<div class="deriv">
+<h5>Derivation — smoothness of the limit</h5>
+<p>Given \(a(1)=2,a(-1)=0\) we factor \(a(z)=\tfrac{1+z}{2}a_{[1]}(z)\); then \(a_{[1]}\) is the
+symbol acting on forward differences \(\Delta\mathbf{p}_i=\mathbf{p}_{i+1}-\mathbf{p}_i\).
+<strong>Dyn–Gregory–Levin:</strong> if the difference scheme is contractive,
+\(\|S_{a_{[1]}}^{L}\|_\infty<1\) for some \(L\), then \(S_a\) converges to a \(C^0\) limit;
+recursing on divided differences gives \(C^1,C^2,\dots\) For \(a(z)=(1+z)^{p+1}/2^p\) this
+returns <strong>\(C^{p-1}\)</strong>. Cubic \(\Rightarrow C^2\). Hold on to that number.</p>
+</div>
+<h4>Tensor product, and exactly where it breaks</h4>
+\[\mathbf{S}(u,v)=\sum_{i=0}^{3}\sum_{j=0}^{3}\mathbf{P}_{ij}N_3(u-i)N_3(v-j)\]
+<p>Masks tensor too — the bicubic surface masks are outer products of \(\tfrac18[1,4,6,4,1]\)
+with itself:</p>
+<div class="stencils">
+ <div class="sten vS"><div class="lab">vertex 1/64</div><div class="grid">
+ <div class="cell">1</div><div class="cell">6</div><div class="cell">1</div>
+ <div class="cell">6</div><div class="cell hi">36</div><div class="cell">6</div>
+ <div class="cell">1</div><div class="cell">6</div><div class="cell">1</div></div></div>
+ <div class="sten eS"><div class="lab">edge 1/16</div><div class="grid">
+ <div class="cell">1</div><div class="cell">1</div>
+ <div class="cell hi">6</div><div class="cell hi">6</div>
+ <div class="cell">1</div><div class="cell">1</div></div></div>
+ <div class="sten fS"><div class="lab">face 1/4</div><div class="grid">
+ <div class="cell hi">1</div><div class="cell hi">1</div>
+ <div class="cell hi">1</div><div class="cell hi">1</div></div></div>
+</div>
+<p>Here is the problem in one sentence. <strong>Tensor product requires a rectangular parameter
+domain</strong> — the mesh must be combinatorially \(\mathbb{Z}^2\). By §00 no closed surface
+except a torus admits that. Trimming, \(G^1\) patch stitching, singular polar caps: all the
+familiar NURBS pain.</p>
+<p>But look at the masks again — they are <em>local</em>, reading only a 1-ring. So stop asking
+for a global grid. Generalise the mask to arbitrary valence and apply it everywhere. That is
+the content of the next three sections.</p>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>02</b> · LIFTING CHAIKIN — DOO–SABIN, 1978</span>
+<h2>The dual scheme on surfaces</h2>
+<div class="deriv f"><h5>Topological rule</h5>
+<p>One new point per face-corner. Then three families of new faces: an <strong>F-face</strong>
+per old face, an <strong>E-face</strong> per old edge (4 points), and a <strong>V-face</strong>
+per old vertex \(v\) — an \(n(v)\)-gon. Old vertices vanish.</p></div>
+<div class="deriv"><h5>Geometric rule</h5>
+<p>For an \(n\)-gon, the new point at corner \(i\) is
+\(\mathbf{p}'_i=\sum_j\alpha_{ij}\mathbf{p}_j\) with</p>
+\[\alpha_{ii}=\frac{n+5}{4n},\qquad
+\alpha_{ij}=\frac{3+2\cos\!\big(\tfrac{2\pi(i-j)}{n}\big)}{4n}\quad(i\ne j)\]
+<p><strong>Check \(n=4\):</strong> \(\alpha_{ii}=9/16\); adjacent \(=3/16\); opposite
+\(=1/16\) — exactly \(\tfrac14[3,1]\otimes\tfrac14[3,1]=\tfrac1{16}[9,3,3,1]\), the
+biquadratic mask. ✓</p></div>
+<p>All faces become quads; the limit is biquadratic B-spline on regular regions, \(C^1\) at
+EVs. The \(\cos(2\pi k/n)\) is not decoration — it is the discrete Fourier basis on the
+\(n\)-cycle, the first hint that EV analysis will be spectral. §05 makes that explicit.</p>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>03</b> · LIFTING CUBIC — CATMULL–CLARK, 1978</span>
+<h2>Three passes, and one very useful accident</h2>
+<p>Catmull and Clark were graduate students at Utah. Doo and Sabin, in Europe, pointed out that
+the arbitrary-topology extension applied to the cubic case too; Catmull and Clark wrote it in
+the form everyone now uses. It is the industry standard — Pixar, OpenSubdiv, Maya, Blender.</p>
+<div class="deriv f"><h5>Pass 1 — face points</h5>
+\[\boxed{\;\mathbf{f}=\frac{1}{m}\sum_{i=1}^{m}\mathbf{v}_i\;}\]</div>
+<div class="deriv e"><h5>Pass 2 — edge points</h5>
+<p>Endpoints \(\mathbf{v}_1,\mathbf{v}_2\) and the two <em>new</em> face points:</p>
+\[\boxed{\;\mathbf{e}=\tfrac14(\mathbf{v}_1+\mathbf{v}_2+\mathbf{f}_1+\mathbf{f}_2)\;}\]</div>
+<div class="deriv v"><h5>Pass 3 — vertex points</h5>
+<p>\(Q\) = average of incident new face points, \(R\) = average of incident edge
+<em>midpoints</em>:</p>
+\[\boxed{\;\mathbf{v}'=\frac{Q+2R+(n-3)\mathbf{v}}{n}\;}\]
+<p>Equivalently, and cheaper:</p>
+\[\mathbf{v}'=\frac{n-2}{n}\mathbf{v}+\frac{1}{n^2}\sum_i\mathbf{e}_i+\frac{1}{n^2}\sum_i\mathbf{f}_i\]</div>
+<p>Then reconnect: every new face is
+<span style="color:#f2b04a">face point</span> → <span style="color:#4fd6c4">edge point</span>
+→ <span style="color:#e86a92">vertex point</span> → <span style="color:#4fd6c4">edge
+point</span>. All output faces are quads. The chain \(\mathbf{f}\to\mathbf{e}\to\mathbf{v}'\) is
+why it is three passes and not one.</p>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 4 — the three passes, with masks</span>
+ <div class="seg" id="f4pass">
+ <button data-p="0" aria-pressed="true">cage</button>
+ <button data-p="1" aria-pressed="false">+ face</button>
+ <button data-p="2" aria-pressed="false">+ edge</button>
+ <button data-p="3" aria-pressed="false">+ vertex</button>
+ <button data-p="4" aria-pressed="false">reconnect</button></div>
+ <button class="btn" id="f4reset">reset cage</button>
+ </div>
+ <canvas id="f4"></canvas>
+ <div class="readout" id="f4out">Step through the passes, then tap or hover any coloured point to see the stencil that produced it.</div>
+ <figcaption>A planar cage with a <b style="color:#e86a92">valence-5 vertex</b> at the centre.
+ <b>Hover or tap a coloured point</b> — the contributing cage points light up with their
+ weights and dashed lines to the result. White points are draggable.</figcaption>
+</figure>
+
+<div class="col">
+<div class="deriv">
+<h5>Derivation — why this <em>is</em> bicubic, not an approximation of it</h5>
+<p>Set \(n=4\) in a regular grid. Each incident face point is
+\(\mathbf{f}_i=\tfrac14(\mathbf{v}+\mathbf{e}_a+\mathbf{e}_b+\mathbf{d})\); summing over four
+faces (each edge-neighbour appears twice, each diagonal once):</p>
+\[Q=\tfrac1{16}\Big(4\mathbf{v}+2\textstyle\sum\mathbf{e}+\sum\mathbf{d}\Big),\qquad
+R=\tfrac12\mathbf{v}+\tfrac18\textstyle\sum\mathbf{e}\]
+<p>Substituting into \(\mathbf{v}'=\tfrac14[Q+2R+\mathbf{v}]\):</p>
+\[\mathbf{v}'=\tfrac{9}{16}\mathbf{v}+\tfrac{3}{32}\textstyle\sum\mathbf{e}+\tfrac{1}{64}\sum\mathbf{d}\]
+<p>Against the bicubic mask \(\tfrac1{64}[1,6,1;6,36,6;1,6,1]\): centre \(36/64=9/16\) ✓,
+edge-neighbour \(6/64=3/32\) ✓, diagonal \(1/64\) ✓. So <strong>on regular regions
+Catmull–Clark <em>is</em> the bicubic uniform B-spline, exactly.</strong> The generalisation
+only ever acts near an EV.</p>
+</div>
+<h4>The valence bookkeeping — the structurally important part</h4>
+<div class="tbl"><table>
+<tr><th>New vertex</th><th>Valence</th></tr>
+<tr><td><span style="color:#f2b04a">face point</span> of an \(m\)-gon</td><td>\(m\)</td></tr>
+<tr><td><span style="color:#4fd6c4">edge point</span></td><td><strong>always 4</strong></td></tr>
+<tr><td><span style="color:#e86a92">vertex point</span> from \(\mathbf{v}\)</td><td>\(n(\mathbf{v})\) — <strong>preserved</strong></td></tr>
+</table></div>
+<ol>
+<li>After one step <strong>all faces are quads</strong>, so from step 2 on all face points have valence 4.</li>
+<li>The EV set is <strong>frozen after step 1</strong>. No new EVs are ever created.</li>
+<li>Each step halves edge lengths, so <strong>EVs become progressively isolated</strong>.</li>
+</ol>
+<p>Point 3 is the payoff. Once EVs are more than two rings apart, every remaining region has a
+\(4\times4\) net in rectangular topology — a genuine bicubic patch.</p>
+<div class="note"><h5>The limit surface</h5>
+<p>Catmull–Clark converges to a surface that is <strong>piecewise bicubic and \(C^2\)
+everywhere, except at finitely many isolated points where it is \(C^1\)</strong>. Those points
+are exactly the EVs of the original cage, and there are never more of them than you started
+with.</p></div>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 5 — Catmull–Clark in 3D</span>
+ <div class="seg" id="f5shape">
+ <button data-s="cube" aria-pressed="true">Cube</button>
+ <button data-s="lshape" aria-pressed="false">L-block</button>
+ <button data-s="pent" aria-pressed="false">Pentagon prism</button>
+ <button data-s="tet" aria-pressed="false">Tetrahedron</button></div>
+ <span class="ctl">level <input type="range" id="f5lvl" min="0" max="4" value="0">
+ <b class="val" id="f5lvlv">0</b></span>
+ <label class="chk ctl"><input type="checkbox" id="f5ev" checked> mark EVs</label>
+ <label class="chk ctl"><input type="checkbox" id="f5cage"> show cage</label>
+ </div>
+ <canvas id="f5"></canvas>
+ <div class="readout" id="f5out"></div>
+ <figcaption>Drag to rotate. Watch the EV count: it jumps once at level 1 and then
+ <b>never changes again</b>, exactly as the bookkeeping predicts. The surface also pulls
+ inward — see the shrinkage note in §04.</figcaption>
+</figure>
+</section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>04</b> · TRIANGLES — LOOP, 1987</span>
+<h2>Where 5/8 and 3/8 come from</h2>
+<p>Charles Loop's Utah master's thesis. Triangles only, regular valence 6, topologically the
+1-to-4 split.</p>
+<div class="deriv e"><h5>Edge (odd) rule</h5>
+<p>\(\mathbf{v}_3,\mathbf{v}_4\) are the opposite vertices of the two adjacent triangles:</p>
+\[\boxed{\;\mathbf{e}=\tfrac38(\mathbf{v}_1+\mathbf{v}_2)+\tfrac18(\mathbf{v}_3+\mathbf{v}_4)\;}\]</div>
+<div class="deriv v"><h5>Vertex (even) rule</h5>
+\[\boxed{\;\mathbf{v}'=(1-n\beta)\mathbf{v}+\beta\sum_{i=1}^{n}\mathbf{v}_i\;}\qquad
+\beta(n)=\frac{1}{n}\left[\frac58-\left(\frac38+\frac14\cos\frac{2\pi}{n}\right)^{\!2}\right]\]
+<p>Warren's substitute: \(\beta=\tfrac{3}{8n}\) for \(n>3\), \(\beta=\tfrac{3}{16}\) for
+\(n=3\). <strong>Checks.</strong> At \(n=6\):
+\(\tfrac16[\tfrac58-(\tfrac12)^2]=\tfrac1{16}\), and \(\tfrac{3}{48}=\tfrac1{16}\) ✓. At
+\(n=3\): \(\tfrac13[\tfrac58-\tfrac1{16}]=\tfrac{3}{16}\) ✓. They agree at \(n=3,6\) and differ
+elsewhere — at \(n=4\), \(31/256\) versus \(3/32\).</p></div>
+<p>The limit is the <strong>quartic box spline</strong> on regular regions — \(C^2\) there,
+\(C^1\) at EVs.</p>
+<h4>Approximating vs interpolating — "why does it shrink?"</h4>
+<p>Every mask above is non-negative and sums to 1, so each new point lies in the convex hull of
+its stencil, and the limit surface lies in the convex hull of the cage. It pulls
+<em>inward</em>, most visibly at sharp corners and in concave regions. These are
+<strong>approximating</strong> schemes: control points are not on the surface.</p>
+<p><strong>Interpolating</strong> schemes — the 4-point rule, Butterfly — fix the old points and
+use negative weights:</p>
+\[\mathbf{p}^{k+1}_{2i+1}=-\tfrac1{16}\mathbf{p}_{i-1}+\tfrac9{16}\mathbf{p}_i
++\tfrac9{16}\mathbf{p}_{i+1}-\tfrac1{16}\mathbf{p}_{i+2}\]
+<p>The negative weights buy interpolation but lose the convex-hull property, with worse fairness
+and less stable behaviour. Production accepts the shrinkage.</p>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 6 — Loop, and the shrinkage</span>
+ <div class="seg" id="f6shape">
+ <button data-s="tet" aria-pressed="true">Tetrahedron</button>
+ <button data-s="icosa" aria-pressed="false">Icosahedron</button>
+ <button data-s="octa" aria-pressed="false">Octahedron</button></div>
+ <span class="ctl">level <input type="range" id="f6lvl" min="0" max="4" value="0">
+ <b class="val" id="f6lvlv">0</b></span>
+ <label class="chk ctl"><input type="checkbox" id="f6cage" checked> show cage</label>
+ </div>
+ <canvas id="f6"></canvas>
+ <div class="readout" id="f6out"></div>
+ <figcaption>The dashed grey wireframe is the level-0 cage. The shaded surface sits strictly
+ <em>inside</em> it — the convex-hull property made visible, and the reason approximating
+ schemes never interpolate their control points.</figcaption>
+</figure>
+
+<div class="col">
+<h4>The three schemes side by side</h4>
+<div class="tbl"><table>
+<tr><th></th><th>Doo–Sabin</th><th>Catmull–Clark</th><th>Loop</th></tr>
+<tr><td>Primal / dual</td><td>dual</td><td>primal</td><td>primal</td></tr>
+<tr><td>Input faces</td><td>any</td><td>any</td><td>triangles only</td></tr>
+<tr><td>Output faces</td><td>quads</td><td>quads</td><td>triangles</td></tr>
+<tr><td>Regular limit</td><td>biquadratic</td><td><strong>bicubic B-spline</strong></td><td>quartic box spline</td></tr>
+<tr><td>Continuity</td><td>\(C^1\)</td><td>\(C^2\), \(C^1\) at EV</td><td>\(C^2\), \(C^1\) at EV</td></tr>
+<tr><td>Regular valence</td><td>4</td><td>4</td><td>6</td></tr>
+</table></div>
+<p>Catmull–Clark won industrially for the \(C^2\) and, more importantly, for the clean
+bicubic-patch correspondence that makes §06 possible.</p>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>05</b> · WHAT HAPPENS AT AN EV</span>
+<h2>The local subdivision matrix</h2>
+<p>This is the part the lecture defers to "next quarter". It is the mathematical core, and it is
+entirely linear algebra.</p>
+<div class="deriv"><h5>Setup</h5>
+<p>Near an EV of valence \(n\), subdivision is a linear map on a finite neighbourhood. Collect
+the 1-ring control points — centre, \(n\) edge-neighbours, \(n\) face-diagonals — into
+\(\mathbf{P}^k\in\mathbb{R}^{2n+1}\) per coordinate:</p>
+\[\boxed{\;\mathbf{P}^{k+1}=A\mathbf{P}^{k}\;}\qquad\Longrightarrow\qquad\mathbf{P}^{k}=A^{k}\mathbf{P}^{0}\]
+<p>\(A\) is the <strong>local subdivision matrix</strong>. Everything about the surface at the EV
+is in its spectrum.</p></div>
+<div class="deriv"><h5>(a) \(\lambda_0=1\) always</h5>
+<p>Every row of \(A\) sums to 1 — partition of unity again — so \(A\mathbf{1}=\mathbf{1}\). That
+is affine invariance. Convergence forces \(|\lambda_1|<1\).</p></div>
+<div class="deriv"><h5>(b) Limit position — jump straight to the surface</h5>
+<p>With right eigenvectors \(\mathbf{x}_i\) and left eigenvectors \(\boldsymbol{\ell}_i\),
+\(A^k\to\mathbf{x}_0\boldsymbol{\ell}_0^{\top}\), so
+\(\mathbf{v}^{\infty}=\boldsymbol{\ell}_0^{\top}\mathbf{P}^0\). For Catmull–Clark
+(Halstead–Kass–DeRose 1993) this is the <strong>limit-point mask</strong></p>
+\[\boxed{\;\mathbf{v}^{\infty}=\frac{n^{2}\mathbf{v}+4\sum_i\mathbf{e}_i+\sum_i\mathbf{f}_i}{n(n+5)}\;}\]
+<p><strong>Check \(n=4\):</strong> \(\tfrac1{36}(16\mathbf{v}+4\sum\mathbf{e}+\sum\mathbf{f})\),
+matching \(\tfrac16[1,4,1]\otimes\tfrac16[1,4,1]\) ✓. One dot product and you are on the
+surface — no iteration.</p></div>
+<div class="deriv"><h5>(c) Tangent plane from the subdominant pair</h5>
+<p>Expanding \(\mathbf{P}^0=\sum_ic_i\mathbf{x}_i\),</p>
+\[\mathbf{P}^k=c_0\mathbf{x}_0+\lambda_1^kc_1\mathbf{x}_1+\lambda_2^kc_2\mathbf{x}_2+O(|\lambda_3|^k)\]
+<p>so the <strong>tangent plane is spanned by \(\boldsymbol{\ell}_1^{\top}\mathbf{P}^0\) and
+\(\boldsymbol{\ell}_2^{\top}\mathbf{P}^0\)</strong> — the two tangent masks; the normal is their
+cross product. Exact normals at an EV, no differencing.</p></div>
+<div class="deriv"><h5>(d) The \(C^1\) conditions</h5>
+<p><em>Necessary:</em> \(1>\lambda_1=\lambda_2>|\lambda_3|\), \(\lambda_1\) real with a
+two-dimensional eigenspace. <em>Sufficient</em> (Reif 1995): the <strong>characteristic
+map</strong> \(\Phi:\mathbb{R}^2\to\mathbb{R}^2\) built from
+\(\boldsymbol{\ell}_1,\boldsymbol{\ell}_2\) must be regular and injective — a finite check per
+valence, tabulated numerically once. For Catmull–Clark, with \(c=\cos(2\pi/n)\):</p>
+\[\lambda(n)=\tfrac{1}{16}\left(c+5+\sqrt{(c+9)(c+1)}\right)\]
+<p>At \(n=4\): \(\tfrac1{16}(5+3)=\tfrac12\) ✓ — the bicubic contraction rate. At \(n=3\):
+\(\approx0.4101\).</p></div>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 7 — subdominant eigenvalue vs valence</span>
+ <span class="ctl">valence n <input type="range" id="f7n" min="3" max="12" value="4">
+ <b class="val" id="f7nv">4</b></span>
+ </div>
+ <canvas id="f7"></canvas>
+ <div class="readout" id="f7out"></div>
+ <figcaption>\(\lambda(n)\) drifts away from the regular value \(\tfrac12\) in both directions.
+ Since \(\lambda\) sets the contraction rate, and \(C^2\) needs a specific relation between
+ \(\lambda^2\) and the next eigenvalue group, <b>high-valence vertices genuinely degrade the
+ surface</b>. "Keep valences near 4" is a statement about eigenvalues, not taste.</figcaption>
+</figure>
+
+<div class="col">
+<div class="note"><h5>Curvature — where Catmull–Clark actually fails</h5>
+<p>\(C^2\) at an EV needs conditions relating \(\lambda_1^2\) to the next eigenvalue group, and
+Catmull–Clark generically <strong>fails</strong> them: curvature there is unbounded or zero.
+Tuned schemes (Sabin; Prautzsch–Umlauf; Karčiauskas–Peters) fix it at some other cost. For aero
+surfaces this is not academic — curvature-discontinuous points show up in reflection lines and
+pressure-gradient artefacts.</p></div>
+<h4>Exact evaluation — Stam, 1998</h4>
+<ol>
+<li>Map \((u,v)\) into a dyadic annulus — find the \(k\) after which it lies in a regular
+sub-patch.</li>
+<li>Apply \(A^k=X\Lambda^kX^{-1}\), so \(\Lambda^k\) is just scalar powers. \(O(1)\) in
+\(k\).</li>
+<li>Evaluate the resulting bicubic patch directly.</li>
+</ol>
+<p><strong>Exact positions and exact first and second derivatives at arbitrary \((u,v)\), in
+closed form.</strong> This is what turns a subdivision surface from a refinement process into
+an honest parametric surface.</p>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>06</b> · BACK TO PATCHES</span>
+<h2>B-spline to Bézier, and the 2008 shortcut</h2>
+<div class="deriv"><h5>Uniform bicubic B-spline → Bézier</h5>
+\[M=\frac16\begin{bmatrix}1&4&1&0\\0&4&2&0\\0&2&4&0\\0&1&4&1\end{bmatrix},\qquad B=MPM^{\!\top}\]
+<p>\(B\) is the Bézier net of the identical patch. Every regular Catmull–Clark region converts
+to a bicubic Bézier patch <em>exactly</em> — which is how subdivision surfaces enter GPU
+tessellation and interoperate with NURBS CAD.</p></div>
+<div class="note"><h5>Loop & Schaefer, TOG 2008</h5>
+<p>For EV-adjacent patches, drop exact evaluation: one bicubic patch for position plus two
+separately-constructed tangent fields for normals. \(C^0\) in position with small controlled
+error, but \(G^1\) in the normal field — so it <em>looks</em> right. Uniform patch
+representation, no eigen-machinery, no branching. This is what OpenSubdiv's feature-adaptive GPU
+path is built on, and the construction to reach for if you want closed-form
+\(\partial\mathbf{S}/\partial u\) across a whole surface.</p></div>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>07</b> · THE DATA STRUCTURE FROM HELL</span>
+<h2>Half-edges, and two permutations</h2>
+<p>The three passes need these in \(O(1)\) or \(O(\text{valence})\):</p>
+<div class="tbl"><table>
+<tr><th>Query</th><th>Needed by</th></tr>
+<tr><td>face → its vertices, in order</td><td><span style="color:#f2b04a">face points</span></td></tr>
+<tr><td><strong>edge → its two incident faces</strong></td><td><span style="color:#4fd6c4">edge points</span></td></tr>
+<tr><td><strong>vertex → its ordered 1-ring</strong></td><td><span style="color:#e86a92">vertex points</span></td></tr>
+</table></div>
+<p>An indexed face list gives you the first and neither of the others without a full scan. Hence
+the nickname.</p>
+<p>The "split edge" is what is now called the <strong>half-edge</strong> (also <em>dart</em>, or
+<em>directed edge</em>). Split every undirected edge into two opposite directed halves:</p>
+<pre>struct HalfEdge {
+ origin: VertexId, <span style="color:#9aa5ad">// vertex it emanates from</span>
+ twin: HalfEdgeId, <span style="color:#9aa5ad">// the opposite half-edge</span>
+ next: HalfEdgeId, <span style="color:#9aa5ad">// next half-edge round this face (CCW)</span>
+ face: FaceId, <span style="color:#9aa5ad">// the face on its left</span>
+}</pre>
+<p>Each vertex stores one outgoing half-edge; each face stores one of its half-edges.</p>
+<div class="deriv"><h5>The algebraic view — a combinatorial map</h5>
+<p>Two permutations on the set \(H\) of half-edges, \(|H|=2E\): the twin map \(\alpha\), an
+<strong>involution</strong> with \(\alpha^2=\mathrm{id}\) and no fixed points; and the next map
+\(\nu\). The entire topology is their orbits:</p>
+\[\text{faces}=\text{orbits of }\nu,\qquad\text{vertices}=\text{orbits of }\nu\circ\alpha,
+\qquad\text{edges}=\text{orbits of }\alpha\]
+<p>and \(V-E+F=2-2g\) recovers the genus. Two permutations; that is the whole structure.</p></div>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 8 — traversal</span>
+ <div class="seg" id="f8mode">
+ <button data-m="face" aria-pressed="true">face loop · ν</button>
+ <button data-m="ring" aria-pressed="false">1-ring · ν∘α</button>
+ <button data-m="twin" aria-pressed="false">twins · α</button></div>
+ <button class="btn" id="f8step">step</button>
+ <button class="btn" id="f8play">auto</button>
+ </div>
+ <canvas id="f8"></canvas>
+ <div class="readout" id="f8out"></div>
+ <figcaption>Each undirected edge is two opposed arrows. <b>Face loop</b> follows
+ <code>next</code> until it closes. <b>1-ring</b> alternates <code>twin</code> then
+ <code>next</code> — the "alternate twin and next" move, at \(O(\text{valence})\).</figcaption>
+</figure>
+
+<div class="col">
+<h4>Invariants</h4>
+\[\alpha(\alpha(h))=h,\quad \nu\text{-orbits}=\text{faces},\quad
+\mathrm{face}(\nu(h))=\mathrm{face}(h),\quad \mathrm{origin}(\alpha(h))=\mathrm{origin}(\nu(h))\]
+<p>Every traversal assumes all four. During construction they are <em>transiently violated</em> —
+which is exactly the segfault complaint. Not a soft warning: a half-built half-edge mesh either
+loops forever or dereferences garbage.</p>
+<p><strong>Restrictions.</strong> Manifold only — an edge with three faces has no well-defined
+twin. Boundaries need a null-face sentinel or a virtual boundary loop; the latter keeps the
+invariants total, and is what I would use.</p>
+<div class="note"><h5>Rust, specifically</h5>
+<p>Do not model this with references. The pointer graph is cyclic, so <code>&</code> /
+<code>&mut</code> is unrepresentable without <code>Rc<RefCell<_>></code> — which
+costs runtime borrow panics, cache locality, and <code>Send</code>.</p>
+<p><strong>Use an arena with integer handles:</strong> <code>Vec<HalfEdge></code> plus
+<code>u32</code> indices. Contiguous, cache-friendly, trivially <code>Send + Sync</code>. Then
+refinement is a pure <code>Mesh -> Mesh</code>, the hierarchy is a
+<code>Vec<Mesh></code>, and each level parallelises directly with rayon since all three
+passes are read-only on level \(k\) and write-only to level \(k{+}1\).</p>
+<p>Two refinements worth taking: store half-edges in pairs so <code>twin(h) = h ^ 1</code>
+(saves a field and an indirection), and use a slotmap with generational indices if you need
+handles stable under edge collapse. Alternatives: <em>directed edges</em>
+(Campagna–Kobbelt–Seidel 1998) for triangle-only meshes at roughly half the memory;
+<em>quad-edge</em> if you want the dual for free.</p></div>
+</div></section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>08</b> · A DIFFERENT ANIMAL</span>
+<h2>The geodesic sphere</h2>
+<p>Do not conflate this with the above. The problem is: how do you tessellate a sphere? Not with
+patches — a UV sphere degenerates at the poles and pinches textures; rational patches
+distort.</p>
+<ol>
+<li>Start with a regular <strong>icosahedron</strong>: \(V{=}12,E{=}30,F{=}20\), every vertex
+valence 5. Euler: \(12-30+20=2\) ✓. The closed triangle mesh with the most uniform possible
+vertex distribution.</li>
+<li><strong>1-to-4 split</strong> each triangle — the same topological operation as Loop.</li>
+<li><strong>Project</strong> radially: \(\mathbf{p}\mapsto r\mathbf{p}/\|\mathbf{p}\|\).</li>
+<li>Repeat. After \(k\) steps, \(F=20\cdot4^k\) and \(V=10\cdot4^k+2\).</li>
+</ol>
+<div class="note"><h5>The distinction that matters</h5>
+<p>Radial projection is <strong>interpolating</strong> and exact against the analytic sphere at
+every level. Running <strong>Loop</strong> on the same icosahedron is
+<strong>approximating</strong>: it shrinks inward and converges to a smooth blob that is
+<em>not</em> a sphere. Same combinatorial split, completely different geometry. Toggle below and
+watch the radius readout.</p></div>
+</div>
+
+<figure>
+ <div class="figbar">
+ <span class="ftitle">Fig 9 — project vs subdivide</span>
+ <div class="seg" id="f9mode">
+ <button data-m="project" aria-pressed="true">radial project</button>
+ <button data-m="loop" aria-pressed="false">Loop subdivide</button></div>
+ <span class="ctl">level <input type="range" id="f9lvl" min="0" max="4" value="2">
+ <b class="val" id="f9lvlv">2</b></span>
+ </div>
+ <canvas id="f9"></canvas>
+ <div class="readout" id="f9out"></div>
+ <figcaption>Same 1-to-4 split in both cases. <b>Radial project</b> holds
+ \(\|\mathbf{p}\|=1\) exactly. <b>Loop</b> lets the radius collapse — the min/max readout shows
+ it drifting well below 1 and staying non-constant.</figcaption>
+</figure>
+</section>
+
+<hr>
+
+<section><div class="col">
+<span class="snum"><b>09</b> · DIFFERENTIATING THROUGH IT</span>
+<h2>What is smooth in the parameters, and what is not</h2>
+<div class="deriv"><h5>Positions are linear in the control points</h5>
+\[\mathbf{S}^{\infty}=L\,A^{k}\,\mathbf{P}^{0}\qquad\Longrightarrow\qquad
+\frac{\partial\mathbf{S}^{\infty}}{\partial\mathbf{P}^{0}}=L\,A^{k}\]
+<p>with \(L\) the limit/evaluation operator. This Jacobian is <strong>exact, sparse and
+constant</strong> — no approximation, no finite differencing. Autodiff through \(k\) rounds is
+\(k\) sparse matmuls with a known transpose; reverse mode is cheap, and you can regenerate the
+sparsity pattern rather than store it.</p></div>
+<p><strong>Surface derivatives</strong> come from §05 (Stam, exact) or §06 (Loop–Schaefer,
+closed-form per patch). Either gives differentiable normals — what you need for pressure
+integration or any normal-dependent objective.</p>
+<div class="note"><h5>The non-differentiable boundary is topology</h5>
+<p>Valence, connectivity and the EV set are discrete. You can freely optimise vertex positions
+with gradients; you cannot differentiate through an edge collapse, a valence change or a
+remesh. Same wall as differentiable iso-surface extraction: the geometry is smooth in the
+parameters, the combinatorics is not. Standard practice — fix the cage topology, optimise
+positions only, treat remeshing as a discrete outer step.</p></div>
+<p><strong>Sharp features.</strong> Production Catmull–Clark adds a per-edge crease sharpness
+\(\sigma\in[0,\infty)\), blending between the smooth rule and the piecewise-linear rule.
+Semi-sharp creases are \(C^0\) along the crease, and \(\sigma\) is a <em>continuous</em>
+parameter — so it is differentiable, useful if you ever want to optimise feature-line strength
+directly.</p>
+</div></section>
+
+<div class="col end">
+<div style="color:#9aa5ad;letter-spacing:.14em">READING ORDER</div>
+<div style="margin-top:12px">
+1 Joy, <em>On-Line Geometric Modeling Notes</em> — Subdivision Curves → Surfaces → Doo-Sabin → Catmull-Clark → Loop<br>
+2 Zorin & Schröder, <em>Subdivision for Modeling and Animation</em>, SIGGRAPH '99 — Ch. 4 for schemes, analysis chapter for §05<br>
+3 Stam 1998, <em>Exact Evaluation of Catmull-Clark Subdivision Surfaces at Arbitrary Parameter Values</em><br>
+4 Loop & Schaefer 2008, TOG 27(1) — the one for your purposes<br>
+5 Reif 1995 — the \(C^1\) sufficiency proof<br>
+6 Peters & Reif, <em>Subdivision Surfaces</em>, Springer 2008 — the monograph
+</div>
+</div>
+
+</div>
+
+<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
+<script>
+const C={ground:'#0d1216',chalk:'#e8e6df',dim:'#9aa5ad',cage:'#6b7c8c',
+ face:'#f2b04a',edge:'#4fd6c4',vert:'#e86a92'};
+const REDUCED=matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+function setup(cv,ar){
+ cv._ar=ar; const ctx=cv.getContext('2d');
+ function fit(){
+ const r=cv.getBoundingClientRect(), dpr=Math.min(devicePixelRatio||1,2);
+ const w=Math.max(1,r.width), h=w*ar;
+ cv.width=Math.round(w*dpr); cv.height=Math.round(h*dpr);
+ cv.style.height=h+'px'; ctx.setTransform(dpr,0,0,dpr,0,0);
+ return {w,h};
+ }
+ return {ctx,fit};
+}
+function seg(id,cb){
+ const el=document.getElementById(id);
+ el.addEventListener('click',e=>{
+ const b=e.target.closest('button'); if(!b)return;
+ [...el.querySelectorAll('button')].forEach(x=>x.setAttribute('aria-pressed',x===b));
+ cb(b.dataset);
+ });
+}
+function slider(id,vid,cb){
+ const s=document.getElementById(id), v=document.getElementById(vid);
+ s.addEventListener('input',()=>{v.textContent=s.value; cb(+s.value);});
+}
+const V={
+ add:(a,b)=>[a[0]+b[0],a[1]+b[1],(a[2]||0)+(b[2]||0)],
+ sub:(a,b)=>[a[0]-b[0],a[1]-b[1],(a[2]||0)-(b[2]||0)],
+ mul:(a,s)=>[a[0]*s,a[1]*s,(a[2]||0)*s],
+ cross:(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],
+ dot:(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2],
+ len:a=>Math.hypot(a[0],a[1],a[2]||0),
+ norm:a=>{const l=V.len(a)||1;return [a[0]/l,a[1]/l,(a[2]||0)/l];},
+ avg:ps=>V.mul(ps.reduce((a,p)=>V.add(a,p),[0,0,0]),1/ps.length)
+};
+function edgeTable(faces){
+ const m=new Map();
+ faces.forEach((f,fi)=>{for(let i=0;i<f.length;i++){
+ const a=f[i],b=f[(i+1)%f.length],lo=Math.min(a,b),hi=Math.max(a,b),k=lo+','+hi;
+ if(!m.has(k))m.set(k,{a:lo,b:hi,faces:[]});
+ m.get(k).faces.push(fi);}});
+ return m;
+}
+function valences(verts,faces){
+ const val=new Array(verts.length).fill(0);
+ edgeTable(faces).forEach(e=>{val[e.a]++;val[e.b]++;});
+ return val;
+}
+
+/* ---------- FIG 1 ---------- */
+(function(){
+ const cv=document.getElementById('f1'),{ctx,fit}=setup(cv,.49);
+ const out=document.getElementById('f1out');
+ let scheme='chaikin',lvl=2,limit=true,drag=-1,W=900,H=440;
+ let P=[[.10,.72],[.22,.20],[.44,.80],[.60,.22],[.78,.70],[.92,.32]];
+ function refine(p){
+ const n=p.length,o=[];
+ if(scheme==='chaikin'){
+ for(let i=0;i<n-1;i++){const a=p[i],b=p[i+1];
+ o.push([.75*a[0]+.25*b[0],.75*a[1]+.25*b[1]]);
+ o.push([.25*a[0]+.75*b[0],.25*a[1]+.75*b[1]]);}
+ }else{
+ o.push(p[0]);
+ for(let i=0;i<n-1;i++){const a=p[i],b=p[i+1];
+ o.push([(a[0]+b[0])/2,(a[1]+b[1])/2]);
+ if(i<n-2){const c=p[i+2];
+ o.push([(a[0]+6*b[0]+c[0])/8,(a[1]+6*b[1]+c[1])/8]);}}
+ o.push(p[n-1]);
+ }
+ return o;
+ }
+ const chain=k=>{let p=P;for(let i=0;i<k;i++)p=refine(p);return p;};
+ const X=p=>p[0]*W,Y=p=>p[1]*H;
+ function poly(pts,col,lw,dash){
+ ctx.save();ctx.strokeStyle=col;ctx.lineWidth=lw;ctx.setLineDash(dash||[]);
+ ctx.lineJoin='round';ctx.beginPath();
+ pts.forEach((p,i)=>i?ctx.lineTo(X(p),Y(p)):ctx.moveTo(X(p),Y(p)));
+ ctx.stroke();ctx.restore();
+ }
+ function draw(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);
+ ctx.strokeStyle='rgba(107,124,140,.08)';ctx.lineWidth=1;
+ for(let i=1;i<10;i++){ctx.beginPath();ctx.moveTo(W*i/10,0);ctx.lineTo(W*i/10,H);ctx.stroke();}
+ for(let i=1;i<5;i++){ctx.beginPath();ctx.moveTo(0,H*i/5);ctx.lineTo(W,H*i/5);ctx.stroke();}
+ if(limit)poly(chain(7),'rgba(232,230,223,.28)',3.4);
+ poly(P,C.cage,1.4,[4,4]);
+ const cur=chain(lvl),col=scheme==='chaikin'?C.edge:C.vert;
+ poly(cur,col,2.2);
+ if(lvl<=4){ctx.fillStyle=col;cur.forEach(p=>{ctx.beginPath();ctx.arc(X(p),Y(p),2.7,0,7);ctx.fill();});}
+ P.forEach((p,i)=>{ctx.beginPath();ctx.arc(X(p),Y(p),i===drag?8:6.4,0,7);
+ ctx.fillStyle=C.chalk;ctx.fill();ctx.lineWidth=2;ctx.strokeStyle=C.ground;ctx.stroke();});
+ out.innerHTML='mask <b>'+(scheme==='chaikin'?'¼[1,3,3,1]':'⅛[1,4,6,4,1]')+'</b> · '
+ +(scheme==='chaikin'?'dual — old vertices discarded':'primal — old vertices retained')
+ +' · level <b>'+lvl+'</b>: '+P.length+' → <b>'+cur.length+'</b> points · limit is the uniform <b>'
+ +(scheme==='chaikin'?'quadratic':'cubic')+'</b> B-spline, <b>C'
+ +(scheme==='chaikin'?'¹':'²')+'</b>';
+ }
+ const pos=e=>{const r=cv.getBoundingClientRect();
+ return [(e.clientX-r.left)/r.width,(e.clientY-r.top)/(r.width*.49)];};
+ cv.addEventListener('pointerdown',e=>{const m=pos(e);let b=-1,bd=9;
+ P.forEach((p,i)=>{const d=Math.hypot(p[0]-m[0],(p[1]-m[1])*.5);if(d<bd){bd=d;b=i;}});
+ if(bd<.05){drag=b;cv.setPointerCapture(e.pointerId);draw();}});
+ cv.addEventListener('pointermove',e=>{if(drag<0)return;const m=pos(e);
+ P[drag]=[Math.max(.03,Math.min(.97,m[0])),Math.max(.05,Math.min(.95,m[1]))];draw();});
+ cv.addEventListener('pointerup',()=>{drag=-1;draw();});
+ seg('f1scheme',d=>{scheme=d.s;draw();});
+ slider('f1lvl','f1lvlv',v=>{lvl=v;draw();});
+ document.getElementById('f1limit').onchange=e=>{limit=e.target.checked;draw();};
+ new ResizeObserver(draw).observe(cv);draw();
+})();
+
+/* ---------- 3D engine ---------- */
+function make3D(id,ar){
+ const cv=document.getElementById(id),{ctx,fit}=setup(cv,ar);
+ let yaw=.6,pitch=-.42,dn=false,lx=0,ly=0,spin=!REDUCED,W=900,H=400,model=null,o={};
+ function rot(p){let[x,y,z]=p;
+ let c=Math.cos(yaw),s=Math.sin(yaw);[x,z]=[x*c-z*s,x*s+z*c];
+ c=Math.cos(pitch);s=Math.sin(pitch);[y,z]=[y*c-z*s,y*s+z*c];return[x,y,z];}
+ function pj(p,sc,cx,cy){const d=4.4,k=d*sc/(d-p[2]);return[cx+p[0]*k,cy-p[1]*k,p[2]];}
+ function render(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);if(!model)return;
+ const cx=W/2,cy=H/2,sc=Math.min(W,H)*.40;
+ const RV=model.verts.map(rot),PV=RV.map(p=>pj(p,sc,cx,cy));
+ const L=V.norm([.4,.75,1]),tint=o.tint||[112,158,172];
+ model.faces.map((f,fi)=>({f,z:f.reduce((a,i)=>a+RV[i][2],0)/f.length}))
+ .sort((a,b)=>a.z-b.z).forEach(t=>{
+ const f=t.f,a=RV[f[0]],b=RV[f[1]],c=RV[f[2]];
+ const nr=V.norm(V.cross(V.sub(b,a),V.sub(c,a)));
+ if(nr[2]<-.02)return;
+ const sh=.11+.66*Math.pow(Math.max(0,V.dot(nr,L)),.85);
+ ctx.beginPath();
+ f.forEach((i,k)=>{const p=PV[i];k?ctx.lineTo(p[0],p[1]):ctx.moveTo(p[0],p[1]);});
+ ctx.closePath();
+ ctx.fillStyle='rgb('+Math.round(tint[0]*sh)+','+Math.round(tint[1]*sh)+','+Math.round(tint[2]*sh)+')';
+ ctx.fill();ctx.strokeStyle='rgba(232,230,223,.13)';ctx.lineWidth=.8;ctx.stroke();});
+ if(o.cage){
+ const cg=o.cage.verts.map(p=>pj(rot(p),sc,cx,cy));
+ ctx.save();ctx.strokeStyle='rgba(107,124,140,.6)';ctx.lineWidth=1.1;ctx.setLineDash([3,3]);
+ o.cage.faces.forEach(f=>{ctx.beginPath();
+ f.forEach((i,k)=>{const p=cg[i];k?ctx.lineTo(p[0],p[1]):ctx.moveTo(p[0],p[1]);});
+ ctx.closePath();ctx.stroke();});
+ ctx.restore();
+ }
+ if(o.marks)o.marks.forEach(m=>{const p=PV[m.i];
+ if(!p||RV[m.i][2]<-.55)return;
+ ctx.beginPath();ctx.arc(p[0],p[1],m.r||4.2,0,7);ctx.fillStyle=m.c;ctx.fill();});
+ }
+ cv.addEventListener('pointerdown',e=>{dn=true;spin=false;lx=e.clientX;ly=e.clientY;
+ cv.setPointerCapture(e.pointerId);});
+ cv.addEventListener('pointermove',e=>{if(!dn)return;
+ yaw+=(e.clientX-lx)*.008;pitch+=(e.clientY-ly)*.008;
+ pitch=Math.max(-1.35,Math.min(1.35,pitch));lx=e.clientX;ly=e.clientY;render();});
+ cv.addEventListener('pointerup',()=>{dn=false;});
+ new ResizeObserver(render).observe(cv);
+ if(!REDUCED)(function l(){if(spin){yaw+=.0032;render();}requestAnimationFrame(l);})();
+ return {set:(m,op)=>{model=m;o=op||{};render();}};
+}
+
+/* ---------- base meshes ---------- */
+const cubeMesh=()=>({verts:[[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]]
+ .map(p=>V.mul(p,.72)),faces:[[0,3,2,1],[4,5,6,7],[0,1,5,4],[2,3,7,6],[1,2,6,5],[0,4,7,3]]});
+function lshapeMesh(){
+ const s=.52,v=[[0,0,0],[2,0,0],[2,1,0],[1,1,0],[1,2,0],[0,2,0],
+ [0,0,1],[2,0,1],[2,1,1],[1,1,1],[1,2,1],[0,2,1]]
+ .map(p=>V.mul([p[0]-1,p[1]-1,p[2]-.5],s));
+ return {verts:v,faces:[[0,5,4,3],[3,2,1,0],[6,7,8,9],[9,10,11,6],
+ [0,1,7,6],[1,2,8,7],[2,3,9,8],[3,4,10,9],[4,5,11,10],[5,0,6,11]]};
+}
+function pentPrism(){
+ const v=[],f=[],n=5,r=.72,h=.6;
+ for(let i=0;i<n;i++){const a=i/n*Math.PI*2;
+ v.push([r*Math.cos(a),-h,r*Math.sin(a)]);v.push([r*Math.cos(a),h,r*Math.sin(a)]);}
+ const bot=[],top=[];
+ for(let i=0;i<n;i++){bot.push(2*i);top.push(2*(n-1-i)+1);}
+ f.push(bot.slice().reverse());f.push(top.slice().reverse());
+ for(let i=0;i<n;i++){const j=(i+1)%n;f.push([2*i,2*j,2*j+1,2*i+1]);}
+ return {verts:v,faces:f};
+}
+const tetraMesh=()=>({verts:[[1,1,1],[-1,-1,1],[-1,1,-1],[1,-1,-1]].map(p=>V.mul(V.norm(p),.92)),
+ faces:[[0,1,2],[0,3,1],[0,2,3],[1,3,2]]});
+const octaMesh=()=>({verts:[[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]].map(p=>V.mul(p,.88)),
+ faces:[[0,2,4],[2,1,4],[1,3,4],[3,0,4],[2,0,5],[1,2,5],[3,1,5],[0,3,5]]});
+function icosaMesh(r){
+ r=r||.86;const t=(1+Math.sqrt(5))/2,v=[];
+ [[-1,t,0],[1,t,0],[-1,-t,0],[1,-t,0],[0,-1,t],[0,1,t],[0,-1,-t],[0,1,-t],
+ [t,0,-1],[t,0,1],[-t,0,-1],[-t,0,1]].forEach(p=>v.push(V.mul(V.norm(p),r)));
+ return {verts:v,faces:[[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],[1,5,9],[5,11,4],
+ [11,10,2],[10,7,6],[7,1,8],[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],
+ [4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]]};
+}
+function torusMesh(){
+ const R=.66,r=.27,nu=12,nv=8,v=[],f=[];
+ for(let i=0;i<nu;i++)for(let j=0;j<nv;j++){
+ const a=i/nu*Math.PI*2,b=j/nv*Math.PI*2;
+ v.push([(R+r*Math.cos(b))*Math.cos(a),r*Math.sin(b),(R+r*Math.cos(b))*Math.sin(a)]);}
+ const id=(i,j)=>(((i%nu)+nu)%nu)*nv+(((j%nv)+nv)%nv);
+ for(let i=0;i<nu;i++)for(let j=0;j<nv;j++)
+ f.push([id(i,j),id(i+1,j),id(i+1,j+1),id(i,j+1)]);
+ return {verts:v,faces:f};
+}
+
+/* ---------- Catmull–Clark ---------- */
+function catmullClark(mesh){
+ const {verts,faces}=mesh;
+ const fp=faces.map(f=>V.avg(f.map(i=>verts[i])));
+ const et=edgeTable(faces),keys=[...et.keys()];
+ const eI=new Map(keys.map((k,i)=>[k,i]));
+ const ep=keys.map(k=>{const e=et.get(k);
+ return e.faces.length===2
+ ? V.mul(V.add(V.add(verts[e.a],verts[e.b]),V.add(fp[e.faces[0]],fp[e.faces[1]])),.25)
+ : V.mul(V.add(verts[e.a],verts[e.b]),.5);});
+ const vf=verts.map(()=>[]),ve=verts.map(()=>[]);
+ faces.forEach((f,fi)=>f.forEach(i=>vf[i].push(fi)));
+ keys.forEach((k,i)=>{const e=et.get(k);ve[e.a].push(i);ve[e.b].push(i);});
+ const nv=verts.map((v,i)=>{
+ const n=ve[i].length;if(!n||!vf[i].length)return v;
+ const F=V.avg(vf[i].map(fi=>fp[fi]));
+ const R=V.avg(ve[i].map(ei=>{const e=et.get(keys[ei]);
+ return V.mul(V.add(verts[e.a],verts[e.b]),.5);}));
+ return V.add(V.add(V.mul(F,1/n),V.mul(R,2/n)),V.mul(v,(n-3)/n));});
+ const oF=verts.length,oE=verts.length+faces.length;
+ const outV=[...nv,...fp,...ep],outF=[];
+ faces.forEach((f,fi)=>{const m=f.length;
+ for(let i=0;i<m;i++){
+ const vi=f[i],pv=f[(i-1+m)%m],nx=f[(i+1)%m];
+ const e1=eI.get(Math.min(vi,nx)+','+Math.max(vi,nx));
+ const e0=eI.get(Math.min(pv,vi)+','+Math.max(pv,vi));
+ outF.push([vi,oE+e1,oF+fi,oE+e0]);}});
+ return {verts:outV,faces:outF};
+}
+
+/* ---------- Loop ---------- */
+function loopSub(mesh){
+ const {verts,faces}=mesh;
+ const et=edgeTable(faces),keys=[...et.keys()];
+ const eI=new Map(keys.map((k,i)=>[k,i]));
+ const opp=keys.map(()=>[]);
+ faces.forEach(f=>{for(let i=0;i<3;i++){
+ const a=f[i],b=f[(i+1)%3],c=f[(i+2)%3];
+ opp[eI.get(Math.min(a,b)+','+Math.max(a,b))].push(c);}});
+ const ep=keys.map((k,i)=>{const e=et.get(k),o=opp[i];
+ return o.length===2
+ ? V.add(V.mul(V.add(verts[e.a],verts[e.b]),3/8),V.mul(V.add(verts[o[0]],verts[o[1]]),1/8))
+ : V.mul(V.add(verts[e.a],verts[e.b]),.5);});
+ const ring=verts.map(()=>new Set());
+ et.forEach(e=>{ring[e.a].add(e.b);ring[e.b].add(e.a);});
+ const nv=verts.map((v,i)=>{
+ const nb=[...ring[i]],n=nb.length;if(!n)return v;
+ const c=Math.cos(2*Math.PI/n);
+ const beta=(5/8-Math.pow(3/8+c/4,2))/n;
+ const s=nb.reduce((a,j)=>V.add(a,verts[j]),[0,0,0]);
+ return V.add(V.mul(v,1-n*beta),V.mul(s,beta));});
+ const oE=verts.length,outV=[...nv,...ep],outF=[];
+ faces.forEach(f=>{const[a,b,c]=f;
+ const ab=oE+eI.get(Math.min(a,b)+','+Math.max(a,b));
+ const bc=oE+eI.get(Math.min(b,c)+','+Math.max(b,c));
+ const ca=oE+eI.get(Math.min(c,a)+','+Math.max(c,a));
+ outF.push([a,ab,ca],[b,bc,ab],[c,ca,bc],[ab,bc,ca]);});
+ return {verts:outV,faces:outF};
+}
+function splitProject(mesh,r){
+ const {verts,faces}=mesh;
+ const et=edgeTable(faces),keys=[...et.keys()];
+ const eI=new Map(keys.map((k,i)=>[k,i]));
+ const ep=keys.map(k=>{const e=et.get(k);
+ return V.mul(V.norm(V.mul(V.add(verts[e.a],verts[e.b]),.5)),r);});
+ const oE=verts.length,outV=[...verts,...ep],outF=[];
+ faces.forEach(f=>{const[a,b,c]=f;
+ const ab=oE+eI.get(Math.min(a,b)+','+Math.max(a,b));
+ const bc=oE+eI.get(Math.min(b,c)+','+Math.max(b,c));
+ const ca=oE+eI.get(Math.min(c,a)+','+Math.max(c,a));
+ outF.push([a,ab,ca],[b,bc,ab],[c,ca,bc],[ab,bc,ca]);});
+ return {verts:outV,faces:outF};
+}
+
+/* ---------- FIG 2 ---------- */
+(function(){
+ const R=make3D('f2',.44),out=document.getElementById('f2out');
+ const build={cube:cubeMesh,torus:torusMesh,lshape:lshapeMesh};
+ let shape='cube';
+ function go(){
+ const m=build[shape](),val=valences(m.verts,m.faces);
+ const marks=val.map((n,i)=>({i,c:n===4?C.cage:C.vert,r:n===4?3.4:5.2}));
+ const ev=val.filter(n=>n!==4).length;
+ const E=[...edgeTable(m.faces).keys()].length;
+ const chi=m.verts.length-E+m.faces.length;
+ R.set(m,{marks,tint:[104,150,164]});
+ out.innerHTML='V=<b>'+m.verts.length+'</b> E=<b>'+E+'</b> F=<b>'+m.faces.length
+ +'</b> · χ = V−E+F = <b>'+chi+'</b> → genus <b>'+(2-chi)/2
+ +'</b> · extraordinary vertices: <b style="color:'+(ev?C.vert:C.edge)+'">'+ev+'</b>'
+ +(ev===0?' — only the torus gets away with this':'');
+ }
+ seg('f2shape',d=>{shape=d.s;go();});go();
+})();
+
+/* ---------- FIG 3 ---------- */
+(function(){
+ const cv=document.getElementById('f3'),{ctx,fit}=setup(cv,.333);
+ const out=document.getElementById('f3out');let p=3,W=900,H=300;
+ function N(i,k,x){
+ if(k===0)return (x>=i&&x<i+1)?1:0;
+ return (x-i)/k*N(i,k-1,x)+(i+k+1-x)/k*N(i+1,k-1,x);
+ }
+ const bs=(d,t)=>N(0,d,t);
+ function binom(n,k){let r=1;for(let i=0;i<k;i++)r=r*(n-i)/(i+1);return r;}
+ function draw(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);
+ const mask=[];for(let k=0;k<=p+1;k++)mask.push(binom(p+1,k)/Math.pow(2,p));
+ const pad=30,lw=W*.55,lh=H-2*pad-14,x0=pad,y0=pad;
+ const sup=p+1,span=sup*1.35;
+ const px=t=>x0+t/span*lw, maxN=bs(p,sup/2)||1, py=v=>y0+lh-v/(maxN*1.14)*lh;
+ ctx.strokeStyle='rgba(107,124,140,.25)';ctx.lineWidth=1;
+ ctx.beginPath();ctx.moveTo(x0,y0+lh);ctx.lineTo(x0+lw,y0+lh);ctx.stroke();
+ mask.forEach((a,k)=>{ctx.beginPath();let st=true;
+ for(let t=0;t<=span;t+=.01){const v=a*bs(p,2*t-k);
+ if(v<=0&&st)continue;const Xc=px(t),Yc=py(v);
+ st?(ctx.moveTo(Xc,Yc),st=false):ctx.lineTo(Xc,Yc);}
+ ctx.strokeStyle=k%2===0?'rgba(232,106,146,.6)':'rgba(79,214,196,.6)';
+ ctx.lineWidth=1.4;ctx.stroke();});
+ ctx.beginPath();
+ for(let t=0;t<=span;t+=.006){const v=bs(p,t);
+ t===0?ctx.moveTo(px(t),py(v)):ctx.lineTo(px(t),py(v));}
+ ctx.strokeStyle=C.chalk;ctx.lineWidth=2.5;ctx.stroke();
+ ctx.font='500 11px "IBM Plex Mono",monospace';
+ ctx.fillStyle=C.chalk;ctx.fillText('N'+p+' = Σ of the copies below',x0+6,y0+12);
+ ctx.fillStyle='rgba(232,106,146,.95)';ctx.fillText('even k',x0+6,y0+29);
+ ctx.fillStyle='rgba(79,214,196,.95)';ctx.fillText('odd k',x0+62,y0+29);
+ const rx=W*.63,rw=W-rx-pad,mx=Math.max(...mask);
+ const bw=Math.min(28,rw/(mask.length+.8));
+ mask.forEach((a,k)=>{
+ const h=a/mx*(lh-24),Xc=rx+k*(bw+4),Yc=y0+lh-h;
+ ctx.fillStyle=k%2===0?'rgba(232,106,146,.8)':'rgba(79,214,196,.8)';
+ ctx.fillRect(Xc,Yc,bw,h);
+ ctx.fillStyle=C.dim;ctx.font='500 9px "IBM Plex Mono",monospace';
+ ctx.fillText(Math.round(a*Math.pow(2,p)),Xc+bw/2-3,Yc-5);
+ ctx.fillText('a'+k,Xc+bw/2-6,y0+lh+13);});
+ ctx.strokeStyle='rgba(107,124,140,.25)';
+ ctx.beginPath();ctx.moveTo(rx-4,y0+lh);ctx.lineTo(rx+rw,y0+lh);ctx.stroke();
+ const ev=mask.filter((_,k)=>k%2===0).reduce((a,b)=>a+b,0);
+ const od=mask.filter((_,k)=>k%2===1).reduce((a,b)=>a+b,0);
+ const fr=mask.map(a=>Math.round(a*Math.pow(2,p)));
+ out.innerHTML='a(z) = (1+z)<sup>'+(p+1)+'</sup>/2<sup>'+p+'</sup> · mask <b>1/'
+ +Math.pow(2,p)+'·['+fr.join(',')+']</b> · Σeven = <b>'+ev.toFixed(3)
+ +'</b>, Σodd = <b>'+od.toFixed(3)+'</b> (both must be 1) · limit <b>C'
+ +(p-1)+'</b>'+(p===2?' ← Chaikin':p===3?' ← split & smooth → Catmull–Clark':'');
+ }
+ slider('f3p','f3pv',v=>{p=v;draw();});
+ new ResizeObserver(draw).observe(cv);draw();
+})();
+
+/* ---------- FIG 4 ---------- */
+(function(){
+ const cv=document.getElementById('f4'),{ctx,fit}=setup(cv,.60);
+ const out=document.getElementById('f4out');
+ let pass=0,W=900,H=540,drag=-1,hov=null;
+ function baseCage(){
+ const cx=.5,cy=.5,R=.135,verts=[[cx,cy]];
+ for(let i=0;i<5;i++){const a=-Math.PI/2+i*2*Math.PI/5;
+ verts.push([cx+R*1.45*Math.cos(a),cy+R*Math.sin(a)]);}
+ for(let i=0;i<5;i++){const a=-Math.PI/2+(i+.5)*2*Math.PI/5;
+ verts.push([cx+R*2.5*1.45*Math.cos(a),cy+R*2.5*Math.sin(a)]);}
+ const faces=[];
+ for(let i=0;i<5;i++){const j=(i+1)%5;faces.push([0,1+i,6+i,1+j]);}
+ return {verts,faces};
+ }
+ let cage=baseCage();
+ function compute(){
+ const {verts,faces}=cage;
+ const fp=faces.map(f=>{const s=f.reduce((a,i)=>[a[0]+verts[i][0],a[1]+verts[i][1]],[0,0]);
+ return [s[0]/f.length,s[1]/f.length];});
+ const et=edgeTable(faces),keys=[...et.keys()];
+ const ep=keys.map(k=>{const e=et.get(k);
+ if(e.faces.length===2)return [(verts[e.a][0]+verts[e.b][0]+fp[e.faces[0]][0]+fp[e.faces[1]][0])/4,
+ (verts[e.a][1]+verts[e.b][1]+fp[e.faces[0]][1]+fp[e.faces[1]][1])/4];
+ return [(verts[e.a][0]+verts[e.b][0])/2,(verts[e.a][1]+verts[e.b][1])/2];});
+ const vf=verts.map(()=>[]),ve=verts.map(()=>[]);
+ faces.forEach((f,fi)=>f.forEach(i=>vf[i].push(fi)));
+ keys.forEach((k,i)=>{const e=et.get(k);ve[e.a].push(i);ve[e.b].push(i);});
+ const vp=verts.map((v,i)=>{
+ const n=ve[i].length;if(!n||!vf[i].length)return v.slice();
+ const F=vf[i].reduce((a,fi)=>[a[0]+fp[fi][0],a[1]+fp[fi][1]],[0,0]).map(x=>x/vf[i].length);
+ const Rr=ve[i].reduce((a,ei)=>{const e=et.get(keys[ei]);
+ return [a[0]+(verts[e.a][0]+verts[e.b][0])/2,a[1]+(verts[e.a][1]+verts[e.b][1])/2];},[0,0])
+ .map(x=>x/ve[i].length);
+ return [(F[0]+2*Rr[0]+(n-3)*v[0])/n,(F[1]+2*Rr[1]+(n-3)*v[1])/n];});
+ return {fp,ep,vp,keys,et,vf,ve};
+ }
+ const X=p=>p[0]*W,Y=p=>p[1]*H;
+ function draw(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);
+ const {fp,ep,vp,keys,et,vf,ve}=compute(),{verts,faces}=cage;
+ ctx.strokeStyle='rgba(107,124,140,.5)';ctx.lineWidth=1.3;
+ et.forEach(e=>{ctx.beginPath();ctx.moveTo(X(verts[e.a]),Y(verts[e.a]));
+ ctx.lineTo(X(verts[e.b]),Y(verts[e.b]));ctx.stroke();});
+ if(pass>=4){
+ faces.forEach((f,fi)=>{const m=f.length;
+ for(let i=0;i<m;i++){
+ const vi=f[i],pv=f[(i-1+m)%m],nx=f[(i+1)%m];
+ const e1=keys.indexOf(Math.min(vi,nx)+','+Math.max(vi,nx));
+ const e0=keys.indexOf(Math.min(pv,vi)+','+Math.max(pv,vi));
+ const q=[vp[vi],ep[e1],fp[fi],ep[e0]];
+ ctx.beginPath();q.forEach((p,k)=>k?ctx.lineTo(X(p),Y(p)):ctx.moveTo(X(p),Y(p)));
+ ctx.closePath();ctx.fillStyle='rgba(232,230,223,.05)';ctx.fill();
+ ctx.strokeStyle='rgba(232,230,223,.45)';ctx.lineWidth=1.1;ctx.stroke();}});
+ }
+ if(hov){
+ let cs=[],tgt,col;
+ if(hov.t==='f'){col=C.face;tgt=fp[hov.i];
+ cs=faces[hov.i].map(i=>({p:verts[i],w:'1/'+faces[hov.i].length}));}
+ if(hov.t==='e'){col=C.edge;tgt=ep[hov.i];const e=et.get(keys[hov.i]);
+ cs=[{p:verts[e.a],w:'¼'},{p:verts[e.b],w:'¼'}];
+ e.faces.forEach(fi=>cs.push({p:fp[fi],w:'¼'}));}
+ if(hov.t==='v'){col=C.vert;tgt=vp[hov.i];
+ cs=[{p:verts[hov.i],w:'(n−2)/n'}];
+ vf[hov.i].forEach(fi=>cs.push({p:fp[fi],w:'1/n²'}));
+ ve[hov.i].forEach(ei=>{const e=et.get(keys[ei]);
+ cs.push({p:verts[e.a===hov.i?e.b:e.a],w:'1/n²'});});}
+ ctx.save();ctx.font='600 10px "IBM Plex Mono",monospace';
+ cs.forEach(c=>{
+ ctx.globalAlpha=.45;ctx.strokeStyle=col;ctx.lineWidth=1.4;ctx.setLineDash([3,3]);
+ ctx.beginPath();ctx.moveTo(X(c.p),Y(c.p));ctx.lineTo(X(tgt),Y(tgt));ctx.stroke();
+ ctx.setLineDash([]);ctx.globalAlpha=1;
+ ctx.beginPath();ctx.arc(X(c.p),Y(c.p),9,0,7);ctx.lineWidth=2;ctx.stroke();
+ ctx.fillStyle=col;ctx.fillText(c.w,X(c.p)+12,Y(c.p)-9);});
+ ctx.restore();
+ }
+ const dot=(p,c,r)=>{ctx.beginPath();ctx.arc(X(p),Y(p),r,0,7);ctx.fillStyle=c;ctx.fill();};
+ if(pass>=1)fp.forEach((p,i)=>dot(p,C.face,hov&&hov.t==='f'&&hov.i===i?7:4.8));
+ if(pass>=2)ep.forEach((p,i)=>dot(p,C.edge,hov&&hov.t==='e'&&hov.i===i?7:4.3));
+ if(pass>=3)vp.forEach((p,i)=>{if(vf[i].length)dot(p,C.vert,hov&&hov.t==='v'&&hov.i===i?7:4.8);});
+ verts.forEach((p,i)=>{ctx.beginPath();ctx.arc(X(p),Y(p),i===drag?6.6:5.2,0,7);
+ ctx.fillStyle='rgba(232,230,223,.92)';ctx.fill();
+ ctx.lineWidth=1.6;ctx.strokeStyle=C.ground;ctx.stroke();});
+ if(!hov){
+ const L=['cage only — 11 points, 5 quads, one valence-5 centre',
+ 'face points: <b style="color:'+C.face+'">f = (1/m)·Σvᵢ</b> — one per face',
+ 'edge points: <b style="color:'+C.edge+'">e = ¼(v₁+v₂+f₁+f₂)</b> — needs pass 1, so this is pass 2',
+ 'vertex points: <b style="color:'+C.vert+'">v′ = (Q+2R+(n−3)v)/n</b> — needs both, so pass 3',
+ 'reconnect: each quad is <b style="color:'+C.vert+'">v′</b> → <b style="color:'+C.edge+'">e</b> → <b style="color:'+C.face+'">f</b> → <b style="color:'+C.edge+'">e</b>'];
+ out.innerHTML=L[pass]+(pass>0?' · hover or tap a coloured point for its stencil':'');
+ }
+ }
+ const pos=e=>{const r=cv.getBoundingClientRect();
+ return [(e.clientX-r.left)/r.width,(e.clientY-r.top)/(r.width*.60)];};
+ function pick(m){
+ const {fp,ep,vp,vf}=compute(),c=[];
+ if(pass>=1)fp.forEach((p,i)=>c.push({t:'f',i,p}));
+ if(pass>=2)ep.forEach((p,i)=>c.push({t:'e',i,p}));
+ if(pass>=3)vp.forEach((p,i)=>{if(vf[i].length)c.push({t:'v',i,p});});
+ let b=null,bd=9;
+ c.forEach(x=>{const d=Math.hypot(x.p[0]-m[0],(x.p[1]-m[1])*.6);if(d<bd){bd=d;b=x;}});
+ return bd<.024?b:null;
+ }
+ function mask(h){
+ const {ve,et,keys}=compute();
+ if(h.t==='f'){const m=cage.faces[h.i].length;
+ out.innerHTML='<b style="color:'+C.face+'">face point</b> of a '+m+'-gon · f = (1/'+m
+ +')·Σvᵢ · new valence <b>'+m+'</b>'
+ +(m!==4?' → <b style="color:'+C.vert+'">becomes an EV</b>':' → regular');}
+ if(h.t==='e'){const e=et.get(keys[h.i]);
+ out.innerHTML='<b style="color:'+C.edge+'">edge point</b> · '
+ +(e.faces.length<2?'boundary → ½(v₁+v₂)':'e = ¼(v₁ + v₂ + f₁ + f₂)')
+ +' · new valence <b>4 always</b> → never an EV';}
+ if(h.t==='v'){const n=ve[h.i].length;
+ out.innerHTML='<b style="color:'+C.vert+'">vertex point</b>, n = <b>'+n
+ +'</b> · v′ = (n−2)/n·v + (1/n²)Σeᵢ + (1/n²)Σfᵢ · centre weight <b>'
+ +((n-2)/n).toFixed(3)+'</b> · valence <b>preserved</b>'
+ +(n!==4?' → <b style="color:'+C.vert+'">stays an EV forever</b>':'');}
+ }
+ cv.addEventListener('pointerdown',e=>{
+ const m=pos(e),h=pick(m);
+ if(h){hov=h;drag=-1;mask(h);draw();return;}
+ let b=-1,bd=9;
+ cage.verts.forEach((p,i)=>{const d=Math.hypot(p[0]-m[0],(p[1]-m[1])*.6);if(d<bd){bd=d;b=i;}});
+ if(bd<.03){drag=b;hov=null;cv.setPointerCapture(e.pointerId);draw();}});
+ cv.addEventListener('pointermove',e=>{
+ const m=pos(e);
+ if(drag>=0){cage.verts[drag]=[Math.max(.03,Math.min(.97,m[0])),
+ Math.max(.04,Math.min(.96,m[1]))];draw();return;}
+ if(e.pointerType==='mouse'){const h=pick(m);
+ const ch=(h&&!hov)||(!h&&hov)||(h&&hov&&(h.t!==hov.t||h.i!==hov.i));
+ if(ch){hov=h;if(h)mask(h);draw();}}});
+ cv.addEventListener('pointerup',()=>{drag=-1;});
+ cv.addEventListener('pointerleave',()=>{if(hov){hov=null;draw();}});
+ seg('f4pass',d=>{pass=+d.p;hov=null;draw();});
+ document.getElementById('f4reset').onclick=()=>{cage=baseCage();hov=null;draw();};
+ new ResizeObserver(draw).observe(cv);draw();
+})();
+
+/* ---------- FIG 5 ---------- */
+(function(){
+ const R=make3D('f5',.522),out=document.getElementById('f5out');
+ const build={cube:cubeMesh,lshape:lshapeMesh,pent:pentPrism,tet:tetraMesh};
+ let shape='cube',lvl=0,mk=true,cg=false;
+ function go(){
+ const base=build[shape]();let m=base;
+ for(let i=0;i<lvl;i++)m=catmullClark(m);
+ const val=valences(m.verts,m.faces);
+ const ev=val.map((n,i)=>n!==4?i:-1).filter(i=>i>=0);
+ R.set(m,{marks:mk?ev.map(i=>({i,c:C.vert,r:4.4})):null,cage:cg?base:null,tint:[112,158,172]});
+ const E=[...edgeTable(m.faces).keys()].length;
+ out.innerHTML='level <b>'+lvl+'</b> · V=<b>'+m.verts.length+'</b> E=<b>'+E
+ +'</b> F=<b>'+m.faces.length+'</b> · extraordinary vertices <b style="color:'+C.vert+'">'
+ +ev.length+'</b>'+(lvl>=1?' — frozen from level 1 on':' — about to freeze')
+ +' · all quads: <b>'+(m.faces.every(f=>f.length===4)?'yes':'no')+'</b>';
+ }
+ seg('f5shape',d=>{shape=d.s;go();});
+ slider('f5lvl','f5lvlv',v=>{lvl=v;go();});
+ document.getElementById('f5ev').onchange=e=>{mk=e.target.checked;go();};
+ document.getElementById('f5cage').onchange=e=>{cg=e.target.checked;go();};
+ go();
+})();
+
+/* ---------- FIG 6 ---------- */
+(function(){
+ const R=make3D('f6',.50),out=document.getElementById('f6out');
+ const build={tet:tetraMesh,icosa:()=>icosaMesh(.86),octa:octaMesh};
+ let shape='tet',lvl=0,cg=true;
+ function go(){
+ const base=build[shape]();let m=base;
+ for(let i=0;i<lvl;i++)m=loopSub(m);
+ const val=valences(m.verts,m.faces),ev=val.filter(n=>n!==6).length;
+ const rB=Math.max(...base.verts.map(V.len)),rN=Math.max(...m.verts.map(V.len));
+ R.set(m,{cage:cg?base:null,tint:[150,140,175]});
+ out.innerHTML='level <b>'+lvl+'</b> · V=<b>'+m.verts.length+'</b> F=<b>'+m.faces.length
+ +'</b> · irregular (n≠6) <b style="color:'+C.vert+'">'+ev+'</b> · max radius <b>'
+ +rN.toFixed(3)+'</b> vs cage <b>'+rB.toFixed(3)+'</b> → shrunk to <b>'
+ +(100*rN/rB).toFixed(1)+'%</b> (convex hull property)';
+ }
+ seg('f6shape',d=>{shape=d.s;go();});
+ slider('f6lvl','f6lvlv',v=>{lvl=v;go();});
+ document.getElementById('f6cage').onchange=e=>{cg=e.target.checked;go();};
+ go();
+})();
+
+/* ---------- FIG 7 ---------- */
+(function(){
+ const cv=document.getElementById('f7'),{ctx,fit}=setup(cv,.366);
+ const out=document.getElementById('f7out');let n=4,W=900,H=330;
+ const lam=k=>{const c=Math.cos(2*Math.PI/k);return (c+5+Math.sqrt((c+9)*(c+1)))/16;};
+ function draw(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);
+ const pl=52,pr=22,pt=24,pb=38,gw=W-pl-pr,gh=H-pt-pb;
+ const n0=3,n1=12,y0=.35,y1=.62;
+ const px=k=>pl+(k-n0)/(n1-n0)*gw, py=v=>pt+gh-(v-y0)/(y1-y0)*gh;
+ ctx.font='500 10px "IBM Plex Mono",monospace';
+ for(let v=.35;v<=.621;v+=.05){
+ ctx.strokeStyle='rgba(107,124,140,.13)';ctx.lineWidth=1;
+ ctx.beginPath();ctx.moveTo(pl,py(v));ctx.lineTo(pl+gw,py(v));ctx.stroke();
+ ctx.fillStyle=C.dim;ctx.fillText(v.toFixed(2),12,py(v)+3);}
+ ctx.strokeStyle='rgba(79,214,196,.55)';ctx.lineWidth=1.4;ctx.setLineDash([5,4]);
+ ctx.beginPath();ctx.moveTo(pl,py(.5));ctx.lineTo(pl+gw,py(.5));ctx.stroke();
+ ctx.setLineDash([]);ctx.fillStyle=C.edge;
+ ctx.fillText('λ = ½ regular, n = 4',pl+8,py(.5)-7);
+ ctx.beginPath();
+ for(let k=n0;k<=n1;k+=.02){k===n0?ctx.moveTo(px(k),py(lam(k))):ctx.lineTo(px(k),py(lam(k)));}
+ ctx.strokeStyle=C.chalk;ctx.lineWidth=2.2;ctx.stroke();
+ for(let k=n0;k<=n1;k++){
+ ctx.beginPath();ctx.arc(px(k),py(lam(k)),k===n?7:3.6,0,7);
+ ctx.fillStyle=k===n?C.vert:(k===4?C.edge:'rgba(232,230,223,.55)');ctx.fill();
+ ctx.fillStyle=C.dim;ctx.fillText(k,px(k)-3,pt+gh+16);}
+ ctx.fillText('valence n',pl+gw/2-22,pt+gh+31);
+ const l=lam(n);
+ out.innerHTML='n = <b>'+n+'</b> · λ = <b>'+l.toFixed(5)+'</b> · deviation from ½ <b style="color:'
+ +(Math.abs(l-.5)<1e-9?C.edge:C.vert)+'">'+(l>=.5?'+':'')+(l-.5).toFixed(5)
+ +'</b> · contraction per level ≈ <b>'+(l*100).toFixed(1)+'%</b>'
+ +(n===4?' — exactly the bicubic rate':n===3?' — fastest contraction, flattest limit'
+ :' — slower contraction, curvature degrades');
+ }
+ slider('f7n','f7nv',v=>{n=v;draw();});
+ new ResizeObserver(draw).observe(cv);draw();
+})();
+
+/* ---------- FIG 8 ---------- */
+(function(){
+ const cv=document.getElementById('f8'),{ctx,fit}=setup(cv,.467);
+ const out=document.getElementById('f8out');
+ let W=900,H=420,mode='face',k=0,timer=null;
+ const P=[[.24,.22],[.50,.17],[.76,.24],[.20,.50],[.50,.50],[.80,.50],
+ [.24,.80],[.50,.85],[.76,.78]];
+ const F=[[0,3,4,1],[1,4,5,2],[3,6,7,4],[4,7,8,5]];
+ const HE=[],hm=new Map();
+ F.forEach((f,fi)=>{const s=HE.length;
+ f.forEach((v,i)=>{HE.push({o:v,d:f[(i+1)%f.length],face:fi,next:s+((i+1)%f.length),twin:-1});
+ hm.set(v+'>'+f[(i+1)%f.length],HE.length-1);});});
+ HE.forEach(h=>{const t=hm.get(h.d+'>'+h.o);h.twin=t===undefined?-1:t;});
+ const vOut=new Map();HE.forEach((h,i)=>{if(!vOut.has(h.o))vOut.set(h.o,i);});
+ const X=p=>p[0]*W,Y=p=>p[1]*H;
+ const sFace=()=>{const s=0,o=[s];let h=HE[s].next;while(h!==s){o.push(h);h=HE[h].next;}return o;};
+ const sRing=()=>{const s=vOut.get(4),o=[s];let h=s;
+ for(let i=0;i<10;i++){const t=HE[h].twin;if(t<0)break;h=HE[t].next;if(h===s)break;o.push(h);}
+ return o;};
+ const sTwin=()=>{const o=[];HE.forEach((h,i)=>{if(h.twin>i)o.push(i);});return o;};
+ function arrow(a,b,col,lw){
+ const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy),ux=dx/L,uy=dy/L;
+ const ox=-uy*.012,oy=ux*.012,s=.19;
+ const A=[a[0]+ux*L*s+ox,a[1]+uy*L*s+oy],B=[b[0]-ux*L*s+ox,b[1]-uy*L*s+oy];
+ ctx.strokeStyle=col;ctx.lineWidth=lw;ctx.lineCap='round';
+ ctx.beginPath();ctx.moveTo(X(A),Y(A));ctx.lineTo(X(B),Y(B));ctx.stroke();
+ const hx=X(B),hy=Y(B),an=Math.atan2(Y(B)-Y(A),X(B)-X(A)),s2=8;
+ ctx.beginPath();ctx.moveTo(hx,hy);
+ ctx.lineTo(hx-s2*Math.cos(an-.42),hy-s2*Math.sin(an-.42));
+ ctx.lineTo(hx-s2*Math.cos(an+.42),hy-s2*Math.sin(an+.42));
+ ctx.closePath();ctx.fillStyle=col;ctx.fill();
+ }
+ function draw(){
+ const d=fit();W=d.w;H=d.h;ctx.clearRect(0,0,W,H);
+ F.forEach(f=>{ctx.beginPath();
+ f.forEach((i,j)=>{const p=P[i];j?ctx.lineTo(X(p),Y(p)):ctx.moveTo(X(p),Y(p));});
+ ctx.closePath();ctx.fillStyle='rgba(107,124,140,.07)';ctx.fill();});
+ const sq=mode==='face'?sFace():mode==='ring'?sRing():sTwin();
+ const cur=sq[k%sq.length];
+ const col=mode==='face'?C.face:mode==='ring'?C.vert:C.edge;
+ HE.forEach(h=>arrow(P[h.o],P[h.d],'rgba(107,124,140,.32)',1.2));
+ sq.slice(0,(k%sq.length)+1).forEach(i=>arrow(P[HE[i].o],P[HE[i].d],col,2.4));
+ const h=HE[cur];arrow(P[h.o],P[h.d],'#fff',3.2);
+ if(mode==='twin'&&h.twin>=0){const t=HE[h.twin];arrow(P[t.o],P[t.d],C.edge,3.2);}
+ P.forEach((p,i)=>{const c=mode==='ring'&&i===4;
+ ctx.beginPath();ctx.arc(X(p),Y(p),c?7:5.4,0,7);
+ ctx.fillStyle=c?C.vert:'rgba(232,230,223,.9)';ctx.fill();
+ ctx.lineWidth=1.6;ctx.strokeStyle=C.ground;ctx.stroke();
+ ctx.font='500 10px "IBM Plex Mono",monospace';ctx.fillStyle=C.dim;
+ ctx.fillText('v'+i,X(p)+9,Y(p)-8);});
+ out.innerHTML=(mode==='face'
+ ?'<b>ν orbit</b> = face '+h.face+' · h = '+cur+', next = '+h.next
+ :mode==='ring'
+ ?'<b>ν∘α orbit</b> = 1-ring of v4 · h = '+cur+', twin = '+h.twin+', then next'
+ :'<b>α involution</b> · h = '+cur+' ⟷ twin = '+h.twin)
+ +' · step <b>'+((k%sq.length)+1)+'/'+sq.length+'</b> · |H| = 2E = <b>'+HE.length+'</b>';
+ }
+ seg('f8mode',d=>{mode=d.m;k=0;draw();});
+ document.getElementById('f8step').onclick=()=>{k++;draw();};
+ document.getElementById('f8play').onclick=e=>{
+ if(timer){clearInterval(timer);timer=null;e.target.textContent='auto';return;}
+ e.target.textContent='stop';timer=setInterval(()=>{k++;draw();},620);};
+ new ResizeObserver(draw).observe(cv);draw();
+})();
+
+/* ---------- FIG 9 ---------- */
+(function(){
+ const R=make3D('f9',.50),out=document.getElementById('f9out');
+ let mode='project',lvl=2;
+ function go(){
+ let m=icosaMesh(1);
+ for(let i=0;i<lvl;i++)m=mode==='project'?splitProject(m,1):loopSub(m);
+ const rs=m.verts.map(V.len),rmin=Math.min(...rs),rmax=Math.max(...rs);
+ R.set({verts:m.verts.map(p=>V.mul(p,.86)),faces:m.faces},
+ {tint:mode==='project'?[110,170,180]:[172,140,160]});
+ out.innerHTML='level <b>'+lvl+'</b> · V=<b>'+m.verts.length+'</b> F=<b>'+m.faces.length
+ +'</b> (= 20·4<sup>'+lvl+'</sup>) · radius min <b>'+rmin.toFixed(4)+'</b> max <b>'
+ +rmax.toFixed(4)+'</b> · '+(mode==='project'
+ ?'<b style="color:'+C.edge+'">exactly on the sphere — interpolating</b>'
+ :'<b style="color:'+C.vert+'">not a sphere — approximating, radius down to '
+ +(rmin*100).toFixed(1)+'%</b>');
+ }
+ seg('f9mode',d=>{mode=d.m;go();});
+ slider('f9lvl','f9lvlv',v=>{lvl=v;go();});
+ go();
+})();
+
+/* ---------- math ---------- */
+function typeset(){
+ if(!window.renderMathInElement)return;
+ renderMathInElement(document.body,{
+ delimiters:[{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],
+ throwOnError:false,ignoredTags:['script','noscript','style','textarea','pre','code']
+ });
+}
+if(document.readyState==='complete')typeset();
+else addEventListener('load',typeset);
+</script>
+</body>
+</html>