notes

Log | Files | Refs

commit 70bc8a44955f5542aee066952139a871b885fe91
parent 6f32d26433ea29f39972279b9312494ccba1f5d1
Author: ling0x <ling0x@users.noreply.github.com>
Date:   Tue, 28 Jul 2026 09:58:28 +0100

updates

Diffstat:
Mcommands/git.txt | 3+++
Acommands/pdf-image.txt | 14++++++++++++++
Amathematics/subdivision-interactive.html | 1475+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&nbsp;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> &nbsp;·&nbsp; VOCABULARY &amp; 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> &nbsp;·&nbsp; 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 &nbsp;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 &nbsp;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 &nbsp;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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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 &amp; 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> &nbsp;·&nbsp; 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>&amp;</code> / +<code>&amp;mut</code> is unrepresentable without <code>Rc&lt;RefCell&lt;_&gt;&gt;</code> — which +costs runtime borrow panics, cache locality, and <code>Send</code>.</p> +<p><strong>Use an arena with integer handles:</strong> <code>Vec&lt;HalfEdge&gt;</code> plus +<code>u32</code> indices. Contiguous, cache-friendly, trivially <code>Send + Sync</code>. Then +refinement is a pure <code>Mesh -&gt; Mesh</code>, the hierarchy is a +<code>Vec&lt;Mesh&gt;</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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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 &nbsp;Joy, <em>On-Line Geometric Modeling Notes</em> — Subdivision Curves → Surfaces → Doo-Sabin → Catmull-Clark → Loop<br> +2 &nbsp;Zorin &amp; Schröder, <em>Subdivision for Modeling and Animation</em>, SIGGRAPH '99 — Ch. 4 for schemes, analysis chapter for §05<br> +3 &nbsp;Stam 1998, <em>Exact Evaluation of Catmull-Clark Subdivision Surfaces at Arbitrary Parameter Values</em><br> +4 &nbsp;Loop &amp; Schaefer 2008, TOG 27(1) — the one for your purposes<br> +5 &nbsp;Reif 1995 — the \(C^1\) sufficiency proof<br> +6 &nbsp;Peters &amp; 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 &amp; 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>