Last active
August 4, 2026 15:15
-
-
Save ifilipis/26dddab7985c991ff1ed76d618ffa77b to your computer and use it in GitHub Desktop.
Derivative Field Bézier Surface Editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Derivative Field Bézier Surface Editor</title> | |
| <style> | |
| :root{--chrome:#e4e6e8;--chrome-hi:#f7f7f7;--border:#929aa3;--text:#1e252b;--muted:#5f6a73;--blue:#2672b8;--u:#c62828;--v:#22863a} | |
| *{box-sizing:border-box} | |
| html,body{width:100%;height:100%;overflow:hidden} | |
| body{margin:0;color:var(--text);background:#bfc4c9;font:12px/1.35 "Segoe UI",Arial,sans-serif} | |
| button,select,input{font:inherit} | |
| button,select{min-height:26px;border:1px solid #98a0a8;border-radius:2px;color:#20272d;background:linear-gradient(#fff,#dfe3e6)} | |
| button{cursor:pointer;padding:3px 8px} | |
| button:hover{border-color:#5685b2;background:linear-gradient(#fff,#d6e5f4)} | |
| select{padding:2px 5px} | |
| input[type=range]{width:100%;accent-color:var(--blue)} | |
| output{color:#135d9c;font-variant-numeric:tabular-nums} | |
| #cadApp{width:100vw;height:100vh;display:grid;grid-template-columns:minmax(0,1fr) 360px;grid-template-rows:38px minmax(0,1fr) 24px} | |
| .topbar{grid-column:1/3;display:flex;align-items:center;gap:7px;padding:5px 7px;border-bottom:1px solid var(--border);background:linear-gradient(var(--chrome-hi),var(--chrome))} | |
| .brand{margin-right:8px;padding-right:13px;border-right:1px solid #aab0b6;font-weight:700;white-space:nowrap} | |
| .topbar label{display:flex;align-items:center;gap:5px;color:#4b555e} | |
| .topbar select{min-width:116px} | |
| .stepExport{display:flex;align-items:center;gap:5px;margin-left:7px} | |
| .stepExport select{min-width:92px} | |
| .topbar .spacer{flex:1} | |
| .viewport{position:relative;min-width:0;min-height:0;grid-column:1;grid-row:2;overflow:hidden;background:linear-gradient(135deg,#e3e7ea,#b9c1c9)} | |
| #surface{position:absolute;inset:0;z-index:2;display:block;width:100%;height:100%;touch-action:none;user-select:none;cursor:crosshair} | |
| #threeCanvas{position:absolute;inset:0;z-index:1;display:none;width:100%;height:100%;pointer-events:none} | |
| .viewLabel{position:absolute;left:8px;top:7px;padding:3px 6px;color:#29333b;border:1px solid rgba(80,90,100,.38);background:rgba(244,246,247,.76);pointer-events:none} | |
| .navHint{position:absolute;right:8px;bottom:7px;color:#32404a;padding:3px 6px;background:rgba(244,246,247,.72);pointer-events:none} | |
| .inspector{grid-column:2;grid-row:2;min-height:0;overflow:auto;border-left:1px solid var(--border);background:#e4e6e8} | |
| .dock{border-bottom:1px solid #9fa6ad} | |
| .dock h2{margin:0;padding:6px 8px;border-bottom:1px solid #b3b8bd;background:linear-gradient(#f2f3f4,#d7dbde);font-size:11px;font-weight:700} | |
| .dockBody{display:grid;gap:7px;padding:8px} | |
| .property{display:grid;grid-template-columns:118px minmax(0,1fr);align-items:center;gap:7px} | |
| .property label{color:#47525c} | |
| .property output{justify-self:end} | |
| #fieldView{display:block;width:100%;height:286px;background:#f8f9fa;cursor:crosshair;touch-action:none} | |
| .fieldHint{padding:5px 8px;color:#5b6670;border-top:1px solid #b9bec3;font-size:10px} | |
| #status{padding:7px 8px;color:#3e4952;background:#f4f5f6;border-top:1px solid #b4bac0} | |
| details{color:#59646e;font-size:10px} | |
| summary{cursor:pointer;color:#313b44} | |
| code{color:#175f87;font:10px ui-monospace,SFMono-Regular,Menlo,monospace} | |
| .statusbar{grid-column:1/3;display:flex;align-items:center;gap:17px;padding:0 8px;border-top:1px solid var(--border);background:#e5e7e9;color:#4a555e;font-size:10px;white-space:nowrap} | |
| .statusbar strong{color:#20272d;font-weight:600} | |
| @media(max-width:900px){#cadApp{grid-template-columns:minmax(0,1fr) 295px}.topbar .optional{display:none}.property{grid-template-columns:95px minmax(0,1fr)}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="cadApp"> | |
| <header class="topbar"> | |
| <div class="brand">Bézier Surface</div> | |
| <button id="resetView">Fit view</button> | |
| <label>Projection <select id="projection"><option value="perspective">Perspective</option><option value="orthographic" selected>Orthographic</option></select></label> | |
| <label>Display <select id="displayMode"><option value="shaded" selected>Shaded</option><option value="curvature">Mean curvature</option><option value="zebra">Zebra</option><option value="shadedZebra">Shaded + zebra</option></select></label> | |
| <label>Zebra <select id="zebraOrientation"><option value="horizontal" selected>Horizontal</option><option value="vertical">Vertical</option></select></label> | |
| <div class="stepExport"><label for="stepProtocol">STEP</label><select id="stepProtocol" aria-label="STEP application protocol"><option value="ap203" selected>AP203</option><option value="ap214">AP214</option></select><button id="saveStep" type="button">Save STP</button></div> | |
| <div class="spacer"></div> | |
| <span class="optional">LMB auto-pick CV / surface · MMB pan · RMB orbit · wheel dolly</span> | |
| </header> | |
| <section class="viewport"> | |
| <svg id="surface" viewBox="0 0 1000 700" preserveAspectRatio="none" aria-label="Interactive Bézier surface viewport"></svg> | |
| <canvas id="threeCanvas" aria-label="three.js Bézier surface renderer"></canvas> | |
| <div class="viewLabel">Orthographic · Shaded</div> | |
| <div class="navHint">MMB pan · RMB orbit · wheel dolly</div> | |
| </section> | |
| <aside class="inspector"> | |
| <section class="dock"> | |
| <h2>Surface</h2> | |
| <div class="dockBody"> | |
| <div class="property"><label for="degreeU">Degree U</label><div><input id="degreeU" type="range" min="2" max="10" value="4"><output id="degreeUOut">4</output></div></div> | |
| <div class="property"><label for="degreeV">Degree V</label><div><input id="degreeV" type="range" min="2" max="10" value="4"><output id="degreeVOut">4</output></div></div> | |
| <div class="property"><label for="tessellation">Tessellation</label><div><input id="tessellation" type="range" min="12" max="64" step="4" value="32"><output id="tessellationOut">32</output></div></div> | |
| <button id="resetSurface">Reset sculpted patch</button> | |
| </div> | |
| </section> | |
| <section class="dock"> | |
| <h2>Derivative-field manipulator</h2> | |
| <div class="dockBody"> | |
| <div class="property"><label for="fieldMode">Field</label><select id="fieldMode"><option value="position">Position height</option><option value="tangent">Tangent magnitude</option><option value="curvature" selected>Mean curvature</option><option value="acceleration">Normal acceleration</option></select></div> | |
| <div class="property"><label for="editScope">Edit domain</label><select id="editScope"><option value="uv" selected>Field · UV</option><option value="u">U section</option><option value="v">V section</option></select></div> | |
| <div class="property"><label for="influence">Influence σ</label><div><input id="influence" type="range" min="0.08" max="0.62" step="0.01" value="0.24"><output id="influenceOut">0.24</output></div></div> | |
| <div class="property"><label for="stepLimit">Max CV step</label><div><input id="stepLimit" type="range" min="0.05" max="5" step="0.01" value="0.42"><output id="stepLimitOut">0.42</output></div></div> | |
| <div class="property"><label for="edgeOrder">Edge continuity</label><select id="edgeOrder"><option value="off" selected>Off</option><option value="g0">G0 · freeze 1 row</option><option value="g1">G1 · freeze 2 rows</option><option value="g2">G2 · freeze 3 rows</option><option value="g3">G3 · freeze 4 rows</option></select></div> | |
| <div class="property"><label for="edgeSet">Constrained edges</label><select id="edgeSet"><option value="all" selected>All four edges</option><option value="u0">U = 0</option><option value="u1">U = 1</option><option value="v0">V = 0</option><option value="v1">V = 1</option></select></div> | |
| </div> | |
| </section> | |
| <section class="dock"> | |
| <h2 id="fieldTitle">Mean-curvature field · UV</h2> | |
| <svg id="fieldView" viewBox="0 0 360 300" preserveAspectRatio="none" aria-label="Two-dimensional derivative field and smoothing brush"></svg> | |
| <div class="dockBody"> | |
| <div class="property"><label for="brushWidth">Brush width</label><div><input id="brushWidth" type="range" min="0.06" max="0.42" step="0.01" value="0.18"><output id="brushWidthOut">0.18</output></div></div> | |
| <button id="smoothAll">Gaussian smooth selected field domain</button> | |
| </div> | |
| <div class="fieldHint">Drag in the UV map to Gaussian-smooth the measured 2D field.</div> | |
| <div id="status"><b>Mean-curvature field constraint.</b> Drag the blue normal arrow; the full UV field updates continuously.</div> | |
| </section> | |
| <section class="dock"> | |
| <div class="dockBody"> | |
| <details><summary>Field equations</summary><p><code>h=n₀·S</code>, <code>T=||(n₀·Sᵤ,n₀·Sᵥ)||</code>, and <code>H</code> is signed mean curvature. Acceleration is third-order: <code>A=||∇ₛH||</code> in UV mode, <code>Aᵤ=Hᵤ/||Sᵤ||</code> in a U section, and <code>Aᵥ=Hᵥ/||Sᵥ||</code> in a V section; <code>Hᵤ,Hᵥ</code> are evaluated analytically from <code>Sᵤᵤᵤ,Sᵤᵤᵥ,Sᵤᵥᵥ,Sᵥᵥᵥ</code>.</p></details> | |
| <details><summary>Bézier edge constraint equations</summary><p>For a degree-<code>p</code> cross-edge direction at <code>u=0</code>, <code>∂ᵣₙS=(p!/(p-r)!) Σⱼ Bⱼ(v) ΔᵣP₀ⱼ</code>, where <code>ΔP₀ⱼ=P₁ⱼ-P₀ⱼ</code>, <code>Δ²P₀ⱼ=P₂ⱼ-2P₁ⱼ+P₀ⱼ</code>, and <code>Δ³P₀ⱼ=P₃ⱼ-3P₂ⱼ+3P₁ⱼ-P₀ⱼ</code>. The opposite and V edges use inward/backward differences. <code>Gk: P=Pref</code> on control bands <code>0…k</code>, so <code>∂ᵣₙS=∂ᵣₙR</code> for every <code>r≤k</code>. Therefore G0/G1/G2/G3 freeze exactly 1/2/3/4 rows.</p></details> | |
| </div> | |
| </section> | |
| </aside> | |
| <footer class="statusbar" aria-live="polite"> | |
| <span>Patch <strong id="patchLabel">degree 4 × 4 · 25 CVs</strong></span> | |
| <span>Selection <strong id="selectedLabel">u 0.50 · v 0.50</strong></span> | |
| <span>Field <strong id="fieldLabel">H(u,v)</strong></span> | |
| <span>Edge <strong id="edgeLabel">Off</strong></span> | |
| <span>Delta <strong id="deltaLabel">—</strong></span> | |
| <span>Solver <strong id="fitLabel">Ready</strong></span> | |
| </footer> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script> | |
| <script type="importmap"> | |
| {"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.min.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}} | |
| </script> | |
| <script type="module"> | |
| import {RGBELoader} from "three/addons/loaders/RGBELoader.js"; | |
| window.RGBELoader=RGBELoader; | |
| window.dispatchEvent(new Event("three-rgbe-loader-ready")); | |
| </script> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const EPS=1e-8,FIELD_N=13,EDGE_SAMPLES=9,FIELD_VIEW={w:360,h:300,l:30,r:16,t:14,b:28}; | |
| const q=s=>document.querySelector(s); | |
| const ui={ | |
| degreeU:q("#degreeU"),degreeV:q("#degreeV"),tessellation:q("#tessellation"),influence:q("#influence"),stepLimit:q("#stepLimit"), | |
| projection:q("#projection"),displayMode:q("#displayMode"),zebraOrientation:q("#zebraOrientation"),threeCanvas:q("#threeCanvas"), | |
| fieldMode:q("#fieldMode"),editScope:q("#editScope"),brushWidth:q("#brushWidth"),edgeOrder:q("#edgeOrder"),edgeSet:q("#edgeSet"), | |
| degreeUOut:q("#degreeUOut"),degreeVOut:q("#degreeVOut"),tessellationOut:q("#tessellationOut"), | |
| influenceOut:q("#influenceOut"),stepLimitOut:q("#stepLimitOut"),brushWidthOut:q("#brushWidthOut"),patchLabel:q("#patchLabel"), | |
| selectedLabel:q("#selectedLabel"),fieldLabel:q("#fieldLabel"),edgeLabel:q("#edgeLabel"),deltaLabel:q("#deltaLabel"),fitLabel:q("#fitLabel"), | |
| fieldTitle:q("#fieldTitle"),status:q("#status"),surface:q("#surface"),fieldView:q("#fieldView"), | |
| viewLabel:q(".viewLabel") | |
| }; | |
| let degreeU=4,degreeV=4,net=[],derivativeNets={}; | |
| let selected={u:.5,v:.5},selectedCV=null,chartNormal={x:0,y:1,z:0}; | |
| let yaw=.77,pitch=.43,cameraDistance=6.7,cameraTarget={x:0,y:.22,z:0}; | |
| let surfaceW=1000,surfaceH=700,drag=null,brush=null,fieldPreview=null,pendingLive=null,liveFrame=null,edgeReference=null; | |
| let threeRenderer=null; | |
| let threeScene=null,threeCamera=null,threeCameraMode="",threeSurfaceGeometry=null,threeSurfaceMaterial=null,threeZebraMaterial=null,threeZebraMatcaps=null,threeSurfaceMesh=null; | |
| let threePositions=null,threeNormals=null,threeUvs=null,threeGridResolution=0,threeEnvironment=null,hdrLoadStarted=false,hdrReadyListener=false; | |
| const STUDIO_HDRI_URL="https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/studio_small_09_1k.hdr"; | |
| const V=(x=0,y=0,z=0)=>({x,y,z}); | |
| const plus=(a,b)=>V(a.x+b.x,a.y+b.y,a.z+b.z); | |
| const minus=(a,b)=>V(a.x-b.x,a.y-b.y,a.z-b.z); | |
| const mul=(a,k)=>V(a.x*k,a.y*k,a.z*k); | |
| const dot=(a,b)=>a.x*b.x+a.y*b.y+a.z*b.z; | |
| const cross=(a,b)=>V(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); | |
| const len=a=>Math.sqrt(dot(a,a)); | |
| const norm=a=>mul(a,1/(len(a)||1)); | |
| const clamp=(x,a,b)=>Math.max(a,Math.min(b,x)); | |
| const cloneNet=n=>n.map(row=>row.map(p=>V(p.x,p.y,p.z))); | |
| const f2=n=>Number.isFinite(n)?n.toFixed(2):"0"; | |
| const pointsString=points=>points.map(p=>f2(p.x)+","+f2(p.y)).join(" "); | |
| function choose(n,k){if(k<0||k>n)return 0;let r=1;for(let i=1;i<=k;i++)r=r*(n-k+i)/i;return r} | |
| function B(n,i,t){return choose(n,i)*Math.pow(t,i)*Math.pow(1-t,n-i)} | |
| function makeSculptedNet(nu=degreeU,nv=degreeV){ | |
| return Array.from({length:nv+1},(_,j)=>Array.from({length:nu+1},(_,i)=>{ | |
| const u=i/nu,v=j/nv,x=(u-.5)*3.45,z=(v-.5)*2.95; | |
| const y=.22+.55*Math.exp(-11*((u-.38)**2+(v-.56)**2))-.35*Math.exp(-17*((u-.74)**2+(v-.29)**2))+.16*Math.sin(Math.PI*2*u+.35)*Math.sin(Math.PI*v); | |
| return V(x,y,z); | |
| })); | |
| } | |
| function differentiate(grid,nu,nv,du,dv){ | |
| if(du>nu||dv>nv)return {grid:[[V()]],nu:0,nv:0}; | |
| let out=grid,u=nu,v=nv; | |
| for(let r=0;r<du;r++){out=Array.from({length:v+1},(_,j)=>Array.from({length:u},(_,i)=>mul(minus(out[j][i+1],out[j][i]),u)));u--} | |
| for(let r=0;r<dv;r++){out=Array.from({length:v},(_,j)=>Array.from({length:u+1},(_,i)=>mul(minus(out[j+1][i],out[j][i]),v)));v--} | |
| return {grid:out,nu:u,nv:v}; | |
| } | |
| function rebuildDerivativeNets(){ | |
| derivativeNets={ | |
| p:{grid:net,nu:degreeU,nv:degreeV}, | |
| u:differentiate(net,degreeU,degreeV,1,0),v:differentiate(net,degreeU,degreeV,0,1), | |
| uu:differentiate(net,degreeU,degreeV,2,0),uv:differentiate(net,degreeU,degreeV,1,1), | |
| vv:differentiate(net,degreeU,degreeV,0,2),uuu:differentiate(net,degreeU,degreeV,3,0), | |
| uuv:differentiate(net,degreeU,degreeV,2,1),uvv:differentiate(net,degreeU,degreeV,1,2), | |
| vvv:differentiate(net,degreeU,degreeV,0,3) | |
| }; | |
| } | |
| function evalGrid(info,u,v){ | |
| let out=V(); | |
| for(let j=0;j<=info.nv;j++)for(let i=0;i<=info.nu;i++)out=plus(out,mul(info.grid[j][i],B(info.nu,i,u)*B(info.nv,j,v))); | |
| return out; | |
| } | |
| function evaluate(u,v){ | |
| const p=evalGrid(derivativeNets.p,u,v),du=evalGrid(derivativeNets.u,u,v),dv=evalGrid(derivativeNets.v,u,v); | |
| const duu=evalGrid(derivativeNets.uu,u,v),duv=evalGrid(derivativeNets.uv,u,v),dvv=evalGrid(derivativeNets.vv,u,v); | |
| const n=norm(cross(dv,du)),E=dot(du,du),F=dot(du,dv),G=dot(dv,dv),e=dot(n,duu),f=dot(n,duv),g=dot(n,dvv); | |
| const metricDet=E*G-F*F,den=Math.max(metricDet,EPS),meanNumerator=e*G-2*f*F+g*E; | |
| const mean=meanNumerator/(2*den),gaussian=(e*g-f*f)/den; | |
| return {p,du,dv,duu,duv,dvv,n,E,F,G,e,f,g,metricDet,den,meanNumerator,mean,gaussian}; | |
| } | |
| function evaluateJet3(u,v){ | |
| const g=evaluate(u,v),uuu=evalGrid(derivativeNets.uuu,u,v),uuv=evalGrid(derivativeNets.uuv,u,v); | |
| const uvv=evalGrid(derivativeNets.uvv,u,v),vvv=evalGrid(derivativeNets.vvv,u,v); | |
| const normalBase=cross(g.dv,g.du),normalScale=Math.max(len(normalBase),EPS); | |
| const normalDerivative=d=>mul(minus(d,mul(g.n,dot(g.n,d))),1/normalScale); | |
| const nu=normalDerivative(plus(cross(g.duv,g.du),cross(g.dv,g.duu))); | |
| const nv=normalDerivative(plus(cross(g.dvv,g.du),cross(g.dv,g.duv))); | |
| const Eu=2*dot(g.du,g.duu),Ev=2*dot(g.du,g.duv); | |
| const Fu=dot(g.duu,g.dv)+dot(g.du,g.duv),Fv=dot(g.duv,g.dv)+dot(g.du,g.dvv); | |
| const Gu=2*dot(g.dv,g.duv),Gv=2*dot(g.dv,g.dvv); | |
| const eu=dot(nu,g.duu)+dot(g.n,uuu),ev=dot(nv,g.duu)+dot(g.n,uuv); | |
| const fu=dot(nu,g.duv)+dot(g.n,uuv),fv=dot(nv,g.duv)+dot(g.n,uvv); | |
| const gu=dot(nu,g.dvv)+dot(g.n,uvv),gv=dot(nv,g.dvv)+dot(g.n,vvv); | |
| const numeratorU=eu*g.G+g.e*Gu-2*(fu*g.F+g.f*Fu)+gu*g.E+g.g*Eu; | |
| const numeratorV=ev*g.G+g.e*Gv-2*(fv*g.F+g.f*Fv)+gv*g.E+g.g*Ev; | |
| const determinantU=Eu*g.G+g.E*Gu-2*g.F*Fu,determinantV=Ev*g.G+g.E*Gv-2*g.F*Fv; | |
| const regular=g.metricDet>EPS; | |
| const meanU=regular?(numeratorU*g.den-g.meanNumerator*determinantU)/(2*g.den*g.den):0; | |
| const meanV=regular?(numeratorV*g.den-g.meanNumerator*determinantV)/(2*g.den*g.den):0; | |
| const accelerationU=meanU/Math.sqrt(Math.max(g.E,EPS)),accelerationV=meanV/Math.sqrt(Math.max(g.G,EPS)); | |
| const acceleration=regular?Math.sqrt(Math.max(0,(g.G*meanU*meanU-2*g.F*meanU*meanV+g.E*meanV*meanV)/g.den)):0; | |
| return {...g,uuu,uuv,uvv,vvv,meanU,meanV,accelerationU,accelerationV,acceleration}; | |
| } | |
| function evaluateRender(u,v,needCurvature){ | |
| if(needCurvature)return evaluate(u,v); | |
| const p=evalGrid(derivativeNets.p,u,v),du=evalGrid(derivativeNets.u,u,v),dv=evalGrid(derivativeNets.v,u,v); | |
| return {p,n:norm(cross(dv,du)),mean:0}; | |
| } | |
| function selectedGeometry(){return evaluate(selected.u,selected.v)} | |
| function setChartFrame(){chartNormal=selectedGeometry().n} | |
| function fieldValue(g,nref){ | |
| const mode=ui.fieldMode.value; | |
| if(mode==="position")return dot(g.p,nref); | |
| if(mode==="tangent")return Math.hypot(dot(g.du,nref),dot(g.dv,nref)); | |
| if(mode==="curvature")return g.mean; | |
| if(ui.editScope.value==="u")return g.accelerationU; | |
| if(ui.editScope.value==="v")return g.accelerationV; | |
| return g.acceleration; | |
| } | |
| function measuredField(nref=chartNormal){ | |
| const values=[],thirdOrder=ui.fieldMode.value==="acceleration"; | |
| for(let j=0;j<FIELD_N;j++)for(let i=0;i<FIELD_N;i++){ | |
| const u=i/(FIELD_N-1),v=j/(FIELD_N-1),g=thirdOrder?evaluateJet3(u,v):evaluate(u,v); | |
| values.push(fieldValue(g,nref)); | |
| } | |
| return {values,normal:nref}; | |
| } | |
| function fieldMeta(){ | |
| return { | |
| position:["Position-height field","h(u,v)"], | |
| tangent:["Tangent-magnitude field","T(u,v)"], | |
| curvature:["Mean-curvature field","H(u,v)"], | |
| acceleration:["Normal-acceleration field","A(u,v)"] | |
| }[ui.fieldMode.value]; | |
| } | |
| function fieldSymbol(){return {position:"h",tangent:"T",curvature:"H",acceleration:"A"}[ui.fieldMode.value]} | |
| function signedValue(value){const n=Math.abs(value)<.0005?0:value;return (n>=0?"+":"")+n.toFixed(3)} | |
| function sampleField(values,u,v){ | |
| const x=clamp(u,0,1)*(FIELD_N-1),y=clamp(v,0,1)*(FIELD_N-1),i=Math.floor(x),j=Math.floor(y),i1=Math.min(FIELD_N-1,i+1),j1=Math.min(FIELD_N-1,j+1),tx=x-i,ty=y-j; | |
| const a=values[j*FIELD_N+i]*(1-tx)+values[j*FIELD_N+i1]*tx,b=values[j1*FIELD_N+i]*(1-tx)+values[j1*FIELD_N+i1]*tx;return a*(1-ty)+b*ty; | |
| } | |
| function showNormalDelta(travel,source,target){const fieldDelta=sampleField(target,selected.u,selected.v)-sampleField(source,selected.u,selected.v);ui.deltaLabel.textContent="ΔN "+signedValue(travel)+" · Δ"+fieldSymbol()+" "+signedValue(fieldDelta)} | |
| function showBrushDelta(source,target){let sum=0;for(let i=0;i<source.length;i++)sum+=(target[i]-source[i])**2;ui.deltaLabel.textContent="Δ"+fieldSymbol()+" rms "+Math.sqrt(sum/source.length).toFixed(3)} | |
| function scopeLabel(){return {uv:"UV",u:"U section",v:"V section"}[ui.editScope.value]} | |
| function sectionThickness(){return Math.max(.025,.62/(FIELD_N-1))} | |
| function scopeWeight(u,v,center=selected){ | |
| const scope=ui.editScope.value,sigma=Math.max(.035,+ui.influence.value),thin=sectionThickness(); | |
| if(scope==="u")return Math.exp(-((u-center.u)**2)/(2*sigma*sigma)-((v-center.v)**2)/(2*thin*thin)); | |
| if(scope==="v")return Math.exp(-((v-center.v)**2)/(2*sigma*sigma)-((u-center.u)**2)/(2*thin*thin)); | |
| return Math.exp(-((u-center.u)**2+(v-center.v)**2)/(2*sigma*sigma)); | |
| } | |
| function edgeOrderLevel(){return {off:-1,g0:0,g1:1,g2:2,g3:3}[ui.edgeOrder.value]} | |
| function selectedEdgeKeys(){return ui.edgeSet.value==="all"?["u0","u1","v0","v1"]:[ui.edgeSet.value]} | |
| function edgeShortName(){return {all:"All",u0:"U0",u1:"U1",v0:"V0",v1:"V1"}[ui.edgeSet.value]} | |
| function edgeNormalDegree(key){return key[0]==="u"?degreeU:degreeV} | |
| function edgeActive(){return edgeOrderLevel()>=0&&edgeReference} | |
| function lockedRowsForKey(key){const order=edgeActive()?edgeReference.order:edgeOrderLevel();return Math.min(Math.max(0,order+1),edgeNormalDegree(key)+1)} | |
| function lockedRowsLabel(){ | |
| if(edgeOrderLevel()<0)return "0 rows";const keys=edgeActive()?Object.keys(edgeReference.edges):selectedEdgeKeys(),counts=keys.map(lockedRowsForKey),requested=(edgeActive()?edgeReference.order:edgeOrderLevel())+1,min=Math.min(...counts),max=Math.max(...counts); | |
| const actual=min===max?String(min):min+"–"+max;return actual+(max<requested?" of "+requested:"")+" row"+(max===1?"":"s"); | |
| } | |
| function edgeJet(key,s,order=edgeOrderLevel()){ | |
| let u,v,t,c,tt,tc,cc,ttt,tcc,ttc,ccc,sign=1; | |
| if(key==="u0"||key==="u1"){u=key==="u0"?0:1;v=s;sign=key==="u0"?1:-1}else{u=s;v=key==="v0"?0:1;sign=key==="v0"?1:-1} | |
| const g=order>=3?evaluateJet3(u,v):evaluate(u,v); | |
| if(key[0]==="u"){ | |
| t=g.dv;c=mul(g.du,sign);tt=g.dvv;tc=mul(g.duv,sign);cc=g.duu; | |
| if(order>=3){ttt=g.vvv;ttc=mul(g.uvv,sign);tcc=g.uuv;ccc=mul(g.uuu,sign)} | |
| }else{ | |
| t=g.du;c=mul(g.dv,sign);tt=g.duu;tc=mul(g.duv,sign);cc=g.dvv; | |
| if(order>=3){ttt=g.uuu;ttc=mul(g.uuv,sign);tcc=g.uvv;ccc=mul(g.vvv,sign)} | |
| } | |
| return {p:g.p,t,c,tt,tc,cc,ttt,ttc,tcc,ccc}; | |
| } | |
| function patchScale(){let lo=V(Infinity,Infinity,Infinity),hi=V(-Infinity,-Infinity,-Infinity);for(const row of net)for(const p of row){lo=V(Math.min(lo.x,p.x),Math.min(lo.y,p.y),Math.min(lo.z,p.z));hi=V(Math.max(hi.x,p.x),Math.max(hi.y,p.y),Math.max(hi.z,p.z))}return Math.max(.1,len(minus(hi,lo)))} | |
| function jetScales(j,pScale){return [pScale,Math.max(.1,len(j.t),len(j.c)),Math.max(.1,len(j.tt),len(j.tc),len(j.cc)),Math.max(.1,len(j.ttt||V()),len(j.ttc||V()),len(j.tcc||V()),len(j.ccc||V()))]} | |
| function captureEdgeReference(){ | |
| const order=edgeOrderLevel();if(order<0){edgeReference=null;updateUI();drawSurface();return} | |
| const pScale=patchScale(),edges={};for(const key of selectedEdgeKeys())edges[key]=Array.from({length:EDGE_SAMPLES},(_,i)=>{const jet=edgeJet(key,i/(EDGE_SAMPLES-1),order);return {jet,scales:jetScales(jet,pScale)}}); | |
| edgeReference={order,edges,pScale,net:cloneNet(net),degreeU,degreeV};ui.fitLabel.textContent=ui.edgeOrder.value.toUpperCase()+" captured · "+lockedRowsLabel()+" frozen";updateUI();drawSurface(); | |
| } | |
| function pushResidual(out,value,scale){out.push(value.x/scale,value.y/scale,value.z/scale)} | |
| function edgeResidualVector(){ | |
| if(!edgeActive())return [];const out=[],order=edgeReference.order; | |
| for(const key of Object.keys(edgeReference.edges))for(let i=0;i<EDGE_SAMPLES;i++){ | |
| const ref=edgeReference.edges[key][i],r=ref.jet,s=edgeJet(key,i/(EDGE_SAMPLES-1),order),sc=ref.scales;pushResidual(out,minus(s.p,r.p),sc[0]);if(order<1)continue; | |
| pushResidual(out,minus(s.c,r.c),sc[1]);if(order<2)continue;pushResidual(out,minus(s.cc,r.cc),sc[2]);if(order<3)continue;pushResidual(out,minus(s.ccc,r.ccc),sc[3]); | |
| }return out; | |
| } | |
| function edgeError(){const r=edgeResidualVector();if(!r.length)return 0;return Math.sqrt(r.reduce((s,x)=>s+x*x,0)/r.length)} | |
| function cvOnConstrainedEdge(i,j){ | |
| if(!edgeActive())return false;const k=edgeReference.order,keys=Object.keys(edgeReference.edges); | |
| return keys.some(key=>(key==="u0"&&i<=k)||(key==="u1"&&i>=degreeU-k)||(key==="v0"&&j<=k)||(key==="v1"&&j>=degreeV-k)); | |
| } | |
| function restoreConstrainedBands(){ | |
| if(!edgeActive()||edgeReference.degreeU!==degreeU||edgeReference.degreeV!==degreeV)return; | |
| for(let j=0;j<=degreeV;j++)for(let i=0;i<=degreeU;i++)if(cvOnConstrainedEdge(i,j))net[j][i]={...edgeReference.net[j][i]}; | |
| } | |
| function constrainedEdgeMarkup(){ | |
| if(!edgeActive())return "";const rows=new Set(),k=edgeReference.order;let out='<g fill="none" stroke="#9b4dca" stroke-width="1.6" stroke-opacity=".72" pointer-events="none">'; | |
| for(const key of Object.keys(edgeReference.edges))for(let r=0;r<=Math.min(k,edgeNormalDegree(key));r++){ | |
| if(key==="u0")rows.add("u:"+r);if(key==="u1")rows.add("u:"+(degreeU-r));if(key==="v0")rows.add("v:"+r);if(key==="v1")rows.add("v:"+(degreeV-r)); | |
| } | |
| for(const row of rows){const [axis,indexText]=row.split(":"),index=+indexText,points=axis==="u"?Array.from({length:degreeV+1},(_,j)=>project(net[j][index])):net[index].map(project);out+='<polyline points="'+pointsString(points)+'"/>'} | |
| out+='</g><g fill="none" stroke="#7b2fa5" stroke-width="3" pointer-events="none">';for(const key of Object.keys(edgeReference.edges)){const points=[];for(let i=0;i<=48;i++)points.push(project(edgeJet(key,i/48,0).p));out+='<polyline points="'+pointsString(points)+'"/>'}return out+"</g>"; | |
| } | |
| function constrainedUVMarkup(){ | |
| if(!edgeActive())return "";const rows=new Set(),k=edgeReference.order;let out='<g stroke="#7b2fa5" stroke-width="1.5" stroke-opacity=".72" pointer-events="none">'; | |
| for(const key of Object.keys(edgeReference.edges))for(let r=0;r<=Math.min(k,edgeNormalDegree(key));r++){ | |
| if(key==="u0")rows.add("u:"+r);if(key==="u1")rows.add("u:"+(degreeU-r));if(key==="v0")rows.add("v:"+r);if(key==="v1")rows.add("v:"+(degreeV-r)); | |
| } | |
| for(const row of rows){const [axis,indexText]=row.split(":"),index=+indexText;if(axis==="u"){const x=fieldPoint(index/degreeU,0).x;out+='<line x1="'+f2(x)+'" y1="'+FIELD_VIEW.t+'" x2="'+f2(x)+'" y2="'+(FIELD_VIEW.h-FIELD_VIEW.b)+'"/>'}else{const y=fieldPoint(0,index/degreeV).y;out+='<line x1="'+FIELD_VIEW.l+'" y1="'+f2(y)+'" x2="'+(FIELD_VIEW.w-FIELD_VIEW.r)+'" y2="'+f2(y)+'"/>'}} | |
| return out+"</g>"; | |
| } | |
| function cameraFrame(){ | |
| const toEye=norm(V(Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch),Math.cos(yaw)*Math.cos(pitch))); | |
| const eye=plus(cameraTarget,mul(toEye,cameraDistance)),forward=norm(minus(cameraTarget,eye)); | |
| const right=norm(cross(forward,V(0,1,0))),up=norm(cross(right,forward)); | |
| return {eye,forward,right,up}; | |
| } | |
| function project(p){ | |
| const cam=cameraFrame(),rel=minus(p,cam.eye),x=dot(rel,cam.right),y=dot(rel,cam.up),z=dot(rel,cam.forward); | |
| const focal=surfaceH*1.05,scale=ui.projection.value==="perspective"?focal/Math.max(.18,z):surfaceH/(cameraDistance*1.05); | |
| return {x:surfaceW*.5+x*scale,y:surfaceH*.5-y*scale,d:z}; | |
| } | |
| function updateSurfaceSize(){ | |
| const rect=ui.surface.getBoundingClientRect(); | |
| surfaceW=Math.max(480,Math.round(rect.width)||1000);surfaceH=Math.max(360,Math.round(rect.height)||700); | |
| ui.surface.setAttribute("viewBox","0 0 "+surfaceW+" "+surfaceH); | |
| } | |
| function blend(a,b,t){return a.map((v,i)=>Math.round(v+(b[i]-v)*t))} | |
| function rgb(c){return "rgb("+c[0]+","+c[1]+","+c[2]+")"} | |
| function curvatureColor(value,scale){ | |
| const t=clamp(value/(scale||1),-1,1),neutral=[224,225,221],side=t<0?[55,112,190]:[205,72,55]; | |
| return rgb(blend(neutral,side,Math.abs(t))); | |
| } | |
| function fieldColor(value,scale){ | |
| const nonnegative=ui.fieldMode.value==="tangent"||(ui.fieldMode.value==="acceleration"&&ui.editScope.value==="uv"); | |
| if(nonnegative)return rgb(blend([230,233,231],[34,105,166],clamp(value/(scale||1),0,1))); | |
| return curvatureColor(value,scale); | |
| } | |
| function makeStripeMatcap(vertical){ | |
| const size=256,data=new Uint8Array(size*size*4),light=norm(V(-.42,.78,.42)),half=norm(plus(light,V(0,0,1))); | |
| for(let y=0;y<size;y++)for(let x=0;x<size;x++){ | |
| const sx=(x+.5)/size*2-1,sy=1-(y+.5)/size*2,r2=sx*sx+sy*sy,k=(y*size+x)*4; | |
| if(r2>1){data[k]=8;data[k+1]=10;data[k+2]=12;data[k+3]=255;continue} | |
| const n=V(sx,sy,Math.sqrt(Math.max(0,1-r2))),coord=vertical?sx:sy,phase=18*Math.asin(clamp(coord,-1,1)); | |
| const band=Math.sin(phase)>=0?1:0,diffuse=.9+.1*clamp(.5+.5*dot(n,light),0,1),specular=Math.pow(Math.max(0,dot(n,half)),96),edge=.985+.015*n.z; | |
| const dark=[.012,.015,.018],white=[.94,.95,.94],r=clamp((dark[0]+(white[0]-dark[0])*band)*diffuse*edge+specular*.035,0,1),g=clamp((dark[1]+(white[1]-dark[1])*band)*diffuse*edge+specular*.035,0,1),b=clamp((dark[2]+(white[2]-dark[2])*band)*diffuse*edge+specular*.035,0,1); | |
| data[k]=Math.round(r*255);data[k+1]=Math.round(g*255);data[k+2]=Math.round(b*255);data[k+3]=255; | |
| } | |
| const texture=new THREE.DataTexture(data,size,size,THREE.RGBAFormat,THREE.UnsignedByteType);texture.minFilter=THREE.LinearFilter;texture.magFilter=THREE.LinearFilter;texture.wrapS=THREE.ClampToEdgeWrapping;texture.wrapT=THREE.ClampToEdgeWrapping;texture.generateMipmaps=false;if("colorSpace" in texture&&THREE.SRGBColorSpace)texture.colorSpace=THREE.SRGBColorSpace;texture.needsUpdate=true;return texture; | |
| } | |
| function initThreeRenderer(){ | |
| if(threeRenderer)return true; | |
| if(typeof THREE==="undefined"){ui.fitLabel.textContent="three.js unavailable";return false} | |
| try{ | |
| threeRenderer=new THREE.WebGLRenderer({canvas:ui.threeCanvas,alpha:true,antialias:true,powerPreference:"high-performance"}); | |
| threeRenderer.setPixelRatio(1);threeRenderer.setClearColor(0x000000,0); | |
| if("outputColorSpace" in threeRenderer&&THREE.SRGBColorSpace)threeRenderer.outputColorSpace=THREE.SRGBColorSpace; | |
| if("toneMapping" in threeRenderer&&THREE.ACESFilmicToneMapping){threeRenderer.toneMapping=THREE.ACESFilmicToneMapping;threeRenderer.toneMappingExposure=1.08} | |
| threeScene=new THREE.Scene();threeScene.background=null; | |
| threeScene.add(new THREE.HemisphereLight(0xdfe8ef,0x26313a,.7)); | |
| const key=new THREE.DirectionalLight(0xffffff,1.15);key.position.set(-3.5,5.5,4.5);threeScene.add(key); | |
| threeSurfaceMaterial=new THREE.MeshPhysicalMaterial({ | |
| color:0xc8d1d7,metalness:.78,roughness:.16,clearcoat:.72,clearcoatRoughness:.07, | |
| envMapIntensity:1.35,side:THREE.DoubleSide | |
| }); | |
| threeZebraMatcaps={horizontal:makeStripeMatcap(false),vertical:makeStripeMatcap(true)}; | |
| threeZebraMaterial=new THREE.MeshMatcapMaterial({matcap:threeZebraMatcaps.horizontal,color:0xffffff,side:THREE.DoubleSide,flatShading:false}); | |
| threeSurfaceGeometry=new THREE.BufferGeometry(); | |
| threeSurfaceMesh=new THREE.Mesh(threeSurfaceGeometry,threeSurfaceMaterial);threeSurfaceMesh.frustumCulled=false;threeScene.add(threeSurfaceMesh); | |
| startHDRI(); | |
| return true; | |
| }catch(error){threeRenderer=null;ui.fitLabel.textContent="WebGL initialization failed";return false} | |
| } | |
| function startHDRI(){ | |
| if(threeEnvironment||hdrLoadStarted||!threeRenderer)return; | |
| if(typeof window.RGBELoader!=="function"){ | |
| if(!hdrReadyListener){window.addEventListener("three-rgbe-loader-ready",startHDRI,{once:true});hdrReadyListener=true} | |
| return; | |
| } | |
| hdrLoadStarted=true;ui.fitLabel.textContent="loading studio HDRI"; | |
| new window.RGBELoader().load(STUDIO_HDRI_URL,texture=>{ | |
| texture.mapping=THREE.EquirectangularReflectionMapping; | |
| const pmrem=new THREE.PMREMGenerator(threeRenderer);pmrem.compileEquirectangularShader(); | |
| threeEnvironment=pmrem.fromEquirectangular(texture).texture;pmrem.dispose();texture.dispose(); | |
| threeScene.environment=threeEnvironment;threeSurfaceMaterial.envMap=threeEnvironment;threeSurfaceMaterial.needsUpdate=true; | |
| ui.fitLabel.textContent="Studio HDRI ready";if(ui.displayMode.value!=="curvature")drawSurface(); | |
| },undefined,()=>{ui.fitLabel.textContent="HDRI unavailable"}); | |
| } | |
| function updateThreeSurfaceGeometry(){ | |
| const R=clamp(Math.round(+ui.tessellation.value),12,64),count=(R+1)*(R+1); | |
| if(threeGridResolution!==R||!threePositions){ | |
| threePositions=new Float32Array(count*3);threeNormals=new Float32Array(count*3);threeUvs=new Float32Array(count*2); | |
| const indices=new Uint32Array(R*R*6);let k=0; | |
| for(let j=0;j<R;j++)for(let i=0;i<R;i++){const a=j*(R+1)+i,b=a+1,c=a+R+2,d=a+R+1;indices[k++]=a;indices[k++]=b;indices[k++]=d;indices[k++]=b;indices[k++]=c;indices[k++]=d} | |
| threeSurfaceGeometry.dispose();threeSurfaceGeometry=new THREE.BufferGeometry(); | |
| threeSurfaceGeometry.setAttribute("position",new THREE.BufferAttribute(threePositions,3)); | |
| threeSurfaceGeometry.setAttribute("normal",new THREE.BufferAttribute(threeNormals,3)); | |
| threeSurfaceGeometry.setAttribute("uv",new THREE.BufferAttribute(threeUvs,2)); | |
| threeSurfaceGeometry.setIndex(new THREE.BufferAttribute(indices,1));threeSurfaceMesh.geometry=threeSurfaceGeometry;threeGridResolution=R; | |
| } | |
| for(let j=0;j<=R;j++)for(let i=0;i<=R;i++){ | |
| const k=j*(R+1)+i,g=evaluate(i/R,j/R),n=g.n; | |
| threePositions[k*3]=g.p.x;threePositions[k*3+1]=g.p.y;threePositions[k*3+2]=g.p.z; | |
| threeNormals[k*3]=n.x;threeNormals[k*3+1]=n.y;threeNormals[k*3+2]=n.z;threeUvs[k*2]=i/R;threeUvs[k*2+1]=j/R; | |
| } | |
| threeSurfaceGeometry.attributes.position.needsUpdate=true;threeSurfaceGeometry.attributes.normal.needsUpdate=true;threeSurfaceGeometry.attributes.uv.needsUpdate=true; | |
| threeSurfaceGeometry.computeBoundingSphere(); | |
| } | |
| function updateThreeCamera(){ | |
| const mode=ui.projection.value,cam=cameraFrame(); | |
| if(mode!==threeCameraMode||!threeCamera){ | |
| if(mode==="perspective")threeCamera=new THREE.PerspectiveCamera(50.9,surfaceW/surfaceH,.01,100); | |
| else threeCamera=new THREE.OrthographicCamera(-1,1,1,-1,.01,100); | |
| threeCameraMode=mode; | |
| } | |
| if(mode==="perspective"){ | |
| threeCamera.fov=2*Math.atan(surfaceH/(2*surfaceH*1.05))*180/Math.PI;threeCamera.aspect=surfaceW/surfaceH; | |
| }else{ | |
| const halfH=cameraDistance*.525,halfW=halfH*surfaceW/surfaceH;threeCamera.left=-halfW;threeCamera.right=halfW;threeCamera.top=halfH;threeCamera.bottom=-halfH; | |
| } | |
| threeCamera.near=.01;threeCamera.far=100;threeCamera.position.set(cam.eye.x,cam.eye.y,cam.eye.z);threeCamera.up.set(cam.up.x,cam.up.y,cam.up.z);threeCamera.lookAt(cameraTarget.x,cameraTarget.y,cameraTarget.z);threeCamera.updateProjectionMatrix();threeCamera.updateMatrixWorld(); | |
| } | |
| function drawThreeOverlay(){ | |
| let markup="",surfaceSource=ui.displayMode.value==="shaded"?"Poly Haven Studio Small 09 HDRI":"horizontal/vertical stripe matcap"; | |
| if(!selectedCV){ | |
| markup+='<g fill="none" pointer-events="none"><path d="'+ringPath(2)+'" stroke="rgba(45,111,183,.55)" stroke-width="1.2" stroke-dasharray="4 4"/>'; | |
| markup+='<path d="'+ringPath(1)+'" stroke="rgba(13,89,164,.98)" stroke-width="2"/><path d="'+ringPath(.5)+'" stroke="rgba(45,111,183,.72)" stroke-width="1.2" stroke-dasharray="2 3"/></g>'; | |
| } | |
| markup+='<g fill="none" stroke="rgba(44,57,71,.72)" stroke-width="1" stroke-dasharray="4 3" pointer-events="none">'; | |
| for(let j=0;j<=degreeV;j++)markup+='<polyline points="'+pointsString(net[j].map(project))+'"/>'; | |
| for(let i=0;i<=degreeU;i++)markup+='<polyline points="'+pointsString(Array.from({length:degreeV+1},(_,j)=>project(net[j][i])))+'"/>'; | |
| markup+='</g>'+constrainedEdgeMarkup()+'<g pointer-events="none">'; | |
| for(let j=0;j<=degreeV;j++)for(let i=0;i<=degreeU;i++){ | |
| const p=project(net[j][i]),on=selectedCV&&selectedCV.i===i&&selectedCV.j===j,locked=cvOnConstrainedEdge(i,j); | |
| markup+='<circle cx="'+f2(p.x)+'" cy="'+f2(p.y)+'" r="'+(on?6:locked?4.7:4)+'" fill="'+(on?"#ed9b50":locked?"#eadcf1":"#f9fafb")+'" stroke="'+(on?"#814012":locked?"#7b2fa5":"#31485e")+'" stroke-width="'+(locked?1.8:1.35)+'"/>'; | |
| } | |
| markup+='</g><g pointer-events="none">'+gizmoMarkup()+triadMarkup()+'</g>'; | |
| markup+='<g fill="rgba(34,43,52,.85)" font-family="Arial" font-size="12" pointer-events="none"><text x="18" y="25">three.js Bézier surface · '+ui.displayMode.options[ui.displayMode.selectedIndex].text+'</text><text x="18" y="42">'+surfaceSource+' · '+ui.tessellation.value+'×'+ui.tessellation.value+' cells · 1× pixel ratio</text></g>'; | |
| ui.surface.innerHTML=markup; | |
| } | |
| function setThreeShadingMode(){ | |
| const mode=ui.displayMode.value==="zebra"?1:ui.displayMode.value==="shadedZebra"?2:0,zebra=mode>0; | |
| threeSurfaceMesh.material=zebra?threeZebraMaterial:threeSurfaceMaterial; | |
| if(zebra){const vertical=ui.zebraOrientation.value==="vertical",matcap=vertical?threeZebraMatcaps.vertical:threeZebraMatcaps.horizontal;if(threeZebraMaterial.matcap!==matcap){threeZebraMaterial.matcap=matcap;threeZebraMaterial.needsUpdate=true}} | |
| } | |
| function drawThreeSurface(){ | |
| if(!initThreeRenderer())return; | |
| ui.threeCanvas.style.display="block";ui.surface.style.opacity="1";startHDRI();setThreeShadingMode();updateThreeSurfaceGeometry();updateThreeCamera();threeRenderer.setSize(surfaceW,surfaceH,false);threeRenderer.render(threeScene,threeCamera);drawThreeOverlay(); | |
| ui.viewLabel.textContent=ui.projection.options[ui.projection.selectedIndex].text+" · "+ui.displayMode.options[ui.displayMode.selectedIndex].text; | |
| } | |
| function surfaceColor(cell,cam,curvScale){ | |
| const light=clamp(.58+.37*Math.max(0,dot(cell.n,norm(V(-.4,.85,.32)))),.38,1); | |
| const base=Math.round(188*light),view=norm(minus(cam.eye,cell.center)),reflection=minus(mul(cell.n,2*dot(cell.n,view)),view); | |
| const stripeCoordinate=ui.zebraOrientation.value==="vertical"?dot(reflection,cam.right):dot(reflection,cam.up); | |
| const stripe=Math.sin(18*Math.asin(clamp(stripeCoordinate,-1,1))),mode=ui.displayMode.value; | |
| if(mode==="curvature")return curvatureColor(cell.mean,curvScale); | |
| if(mode==="zebra")return stripe>.1?"#f7f7f4":"#303438"; | |
| if(mode==="shadedZebra"){const k=stripe>.18?1.08:.48,c=clamp(Math.round(base*k),38,235);return "rgb("+c+","+(c+2)+","+(c+5)+")"} | |
| const c=clamp(base,92,225);return "rgb("+c+","+(c+3)+","+(c+7)+")"; | |
| } | |
| function arrowMarkup(a,b,color,width,label){ | |
| const dx=b.x-a.x,dy=b.y-a.y,l=Math.hypot(dx,dy)||1,ux=dx/l,uy=dy/l,head=10,wing=5; | |
| const p1={x:b.x-ux*head-uy*wing,y:b.y-uy*head+ux*wing},p2={x:b.x-ux*head+uy*wing,y:b.y-uy*head-ux*wing}; | |
| return '<line x1="'+f2(a.x)+'" y1="'+f2(a.y)+'" x2="'+f2(b.x)+'" y2="'+f2(b.y)+'" stroke="'+color+'" stroke-width="'+width+'" stroke-linecap="round"/>'+ | |
| '<polygon points="'+pointsString([b,p1,p2])+'" fill="'+color+'"/>'+ | |
| '<text x="'+f2(b.x+6)+'" y="'+f2(b.y-5)+'" fill="'+color+'" font-size="12" font-weight="700">'+label+"</text>"; | |
| } | |
| function ringPath(scale){ | |
| const radius=+ui.influence.value*scale,parts=[];let segment=[]; | |
| for(let n=0;n<=80;n++){ | |
| const a=n/80*Math.PI*2,u=selected.u+Math.cos(a)*radius,v=selected.v+Math.sin(a)*radius; | |
| if(u<0||u>1||v<0||v>1){if(segment.length>1)parts.push(segment);segment=[];continue} | |
| segment.push(project(evalGrid(derivativeNets.p,u,v))); | |
| } | |
| if(segment.length>1)parts.push(segment); | |
| return parts.map(points=>"M "+points.map(p=>f2(p.x)+" "+f2(p.y)).join(" L ")).join(" "); | |
| } | |
| function normalGizmo(){ | |
| const g=selectedGeometry(),base=project(g.p),end=project(plus(g.p,mul(g.n,.62))); | |
| return {type:"normal",base,end,dir:g.n,length:.62}; | |
| } | |
| function cvGizmos(){ | |
| if(!selectedCV)return []; | |
| const baseP=net[selectedCV.j][selectedCV.i],base=project(baseP); | |
| return [[V(1,0,0),"#c84c50","X"],[V(0,1,0),"#218a55","Y"],[V(0,0,1),"#2d6fb7","Z"]].map(item=>({ | |
| type:"axis",base,end:project(plus(baseP,mul(item[0],.58))),dir:item[0],length:.58,color:item[1],label:item[2] | |
| })); | |
| } | |
| function gizmoMarkup(){ | |
| if(selectedCV)return cvGizmos().map(a=>arrowMarkup(a.base,a.end,a.color,2.4,a.label)).join(""); | |
| const g=selectedGeometry(),base=project(g.p),uEnd=project(plus(g.p,mul(norm(g.du),.42))),vEnd=project(plus(g.p,mul(norm(g.dv),.42))),n=normalGizmo(); | |
| return arrowMarkup(base,uEnd,"#c62828",1.7,"U")+arrowMarkup(base,vEnd,"#22863a",1.7,"V")+ | |
| arrowMarkup(n.base,n.end,"#2d6fb7",3,"N")+ | |
| '<circle cx="'+f2(n.base.x)+'" cy="'+f2(n.base.y)+'" r="5" fill="#2d6fb7"/>'; | |
| } | |
| function triadMarkup(){ | |
| const o={x:50,y:surfaceH-48},cam=cameraFrame(),c=project(cameraTarget); | |
| return [[V(1,0,0),"#c84c50","X"],[V(0,1,0),"#218a55","Y"],[V(0,0,1),"#2d6fb7","Z"]].map(item=>{ | |
| const e=project(plus(cameraTarget,mul(item[0],.42))),p={x:o.x+e.x-c.x,y:o.y+e.y-c.y}; | |
| return arrowMarkup(o,p,item[1],1.6,item[2]); | |
| }).join(""); | |
| } | |
| function drawSurface(){ | |
| updateSurfaceSize(); | |
| if(ui.displayMode.value==="shaded"||ui.displayMode.value==="zebra"||ui.displayMode.value==="shadedZebra"){drawThreeSurface();return} | |
| ui.threeCanvas.style.display="none";ui.surface.style.opacity="1"; | |
| const R=+ui.tessellation.value,needCurvature=ui.displayMode.value==="curvature",cam=cameraFrame(); | |
| const vertices=Array.from({length:R+1},()=>Array(R+1)),cells=[]; | |
| for(let j=0;j<=R;j++)for(let i=0;i<=R;i++){const g=evaluateRender(i/R,j/R,needCurvature);vertices[j][i]={...g,pr:project(g.p)}} | |
| for(let j=0;j<R;j++)for(let i=0;i<R;i++){ | |
| const quad=[vertices[j][i],vertices[j][i+1],vertices[j+1][i+1],vertices[j+1][i]]; | |
| const n=norm(plus(plus(quad[0].n,quad[1].n),plus(quad[2].n,quad[3].n))); | |
| const center=mul(plus(plus(quad[0].p,quad[1].p),plus(quad[2].p,quad[3].p)),.25); | |
| const mean=(quad[0].mean+quad[1].mean+quad[2].mean+quad[3].mean)*.25; | |
| cells.push({points:quad.map(x=>x.pr),depth:quad.reduce((s,x)=>s+x.pr.d,0)/4,n,center,mean}); | |
| } | |
| let curvScale=1; | |
| if(needCurvature){const abs=cells.map(c=>Math.abs(c.mean)).sort((a,b)=>a-b);curvScale=Math.max(.025,abs[Math.floor(abs.length*.92)]||1)} | |
| cells.sort((a,b)=>b.depth-a.depth); | |
| let markup='<g class="surface-faces" pointer-events="none">'; | |
| for(const cell of cells){const fill=surfaceColor(cell,cam,curvScale);markup+='<polygon points="'+pointsString(cell.points)+'" fill="'+fill+'" stroke="'+fill+'" stroke-width="1" stroke-linejoin="round"/>'} | |
| markup+="</g>"; | |
| if(!selectedCV){ | |
| markup+='<g fill="none" pointer-events="none"><path d="'+ringPath(2)+'" stroke="rgba(45,111,183,.55)" stroke-width="1.2" stroke-dasharray="4 4"/>'; | |
| markup+='<path d="'+ringPath(1)+'" stroke="rgba(13,89,164,.98)" stroke-width="2"/>'; | |
| markup+='<path d="'+ringPath(.5)+'" stroke="rgba(45,111,183,.72)" stroke-width="1.2" stroke-dasharray="2 3"/></g>'; | |
| } | |
| markup+='<g fill="none" stroke="rgba(44,57,71,.72)" stroke-width="1" stroke-dasharray="4 3" pointer-events="none">'; | |
| for(let j=0;j<=degreeV;j++)markup+='<polyline points="'+pointsString(net[j].map(project))+'"/>'; | |
| for(let i=0;i<=degreeU;i++)markup+='<polyline points="'+pointsString(Array.from({length:degreeV+1},(_,j)=>project(net[j][i])))+'"/>'; | |
| markup+="</g>"+constrainedEdgeMarkup()+'<g pointer-events="none">'; | |
| for(let j=0;j<=degreeV;j++)for(let i=0;i<=degreeU;i++){ | |
| const p=project(net[j][i]),on=selectedCV&&selectedCV.i===i&&selectedCV.j===j,locked=cvOnConstrainedEdge(i,j); | |
| markup+='<circle cx="'+f2(p.x)+'" cy="'+f2(p.y)+'" r="'+(on?6:locked?4.7:4)+'" fill="'+(on?"#ed9b50":locked?"#eadcf1":"#f9fafb")+'" stroke="'+(on?"#814012":locked?"#7b2fa5":"#31485e")+'" stroke-width="'+(locked?1.8:1.35)+'"/>'; | |
| } | |
| markup+="</g><g pointer-events=\"none\">"+gizmoMarkup()+triadMarkup()+"</g>"; | |
| markup+='<g fill="rgba(34,43,52,.85)" font-family="Arial" font-size="12" pointer-events="none"><text x="18" y="25">Bézier control net</text><text x="18" y="42">0.5σ · 1σ · 2σ</text></g>'; | |
| ui.surface.innerHTML=markup; | |
| const pText=ui.projection.options[ui.projection.selectedIndex].text,dText=ui.displayMode.options[ui.displayMode.selectedIndex].text; | |
| ui.viewLabel.textContent=pText+" · "+dText; | |
| } | |
| function fieldPoint(u,v){return {x:FIELD_VIEW.l+u*(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r),y:FIELD_VIEW.t+(1-v)*(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b)}} | |
| function format(n){const a=Math.abs(n);return a>=100||(a<.01&&a>0)?n.toExponential(2):n.toFixed(3)} | |
| function drawField(){ | |
| const actual=measuredField(),values=fieldPreview?fieldPreview.target:actual.values; | |
| const abs=values.map(Math.abs).sort((a,b)=>a-b),scale=Math.max(.001,abs[Math.floor(abs.length*.94)]||1); | |
| const cellW=(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r)/(FIELD_N-1),cellH=(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b)/(FIELD_N-1); | |
| let markup='<rect x="'+FIELD_VIEW.l+'" y="'+FIELD_VIEW.t+'" width="'+(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r)+'" height="'+(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b)+'" fill="#eef1f2"/><g pointer-events="none">'; | |
| for(let j=0;j<FIELD_N-1;j++)for(let i=0;i<FIELD_N-1;i++){ | |
| const k=j*FIELD_N+i,value=(values[k]+values[k+1]+values[k+FIELD_N]+values[k+FIELD_N+1])*.25; | |
| const p=fieldPoint(i/(FIELD_N-1),(j+1)/(FIELD_N-1)); | |
| markup+='<rect x="'+f2(p.x-.2)+'" y="'+f2(p.y-.2)+'" width="'+f2(cellW+.4)+'" height="'+f2(cellH+.4)+'" fill="'+fieldColor(value,scale)+'" stroke="none"/>'; | |
| } | |
| const s=fieldPoint(selected.u,selected.v),rx=+ui.influence.value*(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r),ry=+ui.influence.value*(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b); | |
| markup+=constrainedUVMarkup(); | |
| if(ui.editScope.value==="uv")markup+='<ellipse cx="'+f2(s.x)+'" cy="'+f2(s.y)+'" rx="'+f2(rx)+'" ry="'+f2(ry)+'" fill="none" stroke="#175f9f" stroke-width="2"/>'; | |
| if(ui.editScope.value==="u")markup+='<line x1="'+FIELD_VIEW.l+'" y1="'+f2(s.y)+'" x2="'+(FIELD_VIEW.w-FIELD_VIEW.r)+'" y2="'+f2(s.y)+'" stroke="#c62828" stroke-width="2" stroke-dasharray="5 3"/>'; | |
| if(ui.editScope.value==="v")markup+='<line x1="'+f2(s.x)+'" y1="'+FIELD_VIEW.t+'" x2="'+f2(s.x)+'" y2="'+(FIELD_VIEW.h-FIELD_VIEW.b)+'" stroke="#22863a" stroke-width="2" stroke-dasharray="5 3"/>'; | |
| if(brush){const b=fieldPoint(brush.center.u,brush.center.v),br=+ui.brushWidth.value;markup+='<ellipse cx="'+f2(b.x)+'" cy="'+f2(b.y)+'" rx="'+f2(br*(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r))+'" ry="'+f2(br*(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b))+'" fill="none" stroke="#1f2933" stroke-width="1.5" stroke-dasharray="4 3"/>'} | |
| markup+='<circle cx="'+f2(s.x)+'" cy="'+f2(s.y)+'" r="4.5" fill="#fff" stroke="#175f9f" stroke-width="2"/>'; | |
| const nonnegativeLegend=ui.fieldMode.value==="tangent"||(ui.fieldMode.value==="acceleration"&&ui.editScope.value==="uv"); | |
| const lowLabel=nonnegativeLegend?"0":format(-scale),midLabel=nonnegativeLegend?format(scale*.5):"0"; | |
| markup+='</g><g fill="#53606a" font-family="Arial" font-size="11" pointer-events="none"><text x="'+FIELD_VIEW.l+'" y="294">U = 0</text><text x="306" y="294">U = 1</text><text x="4" y="23" fill="#22863a">V</text><text x="4" y="38">1</text><text x="4" y="274">0</text><text x="'+FIELD_VIEW.l+'" y="11">'+lowLabel+'</text><text x="162" y="11">'+midLabel+'</text><text x="296" y="11">'+format(scale)+"</text></g>"; | |
| ui.fieldView.innerHTML=markup; | |
| } | |
| function updateUI(){ | |
| const meta=fieldMeta(); | |
| ui.degreeUOut.textContent=degreeU;ui.degreeVOut.textContent=degreeV;ui.tessellationOut.textContent=ui.tessellation.value; | |
| ui.influenceOut.textContent=(+ui.influence.value).toFixed(2);ui.stepLimitOut.textContent=(+ui.stepLimit.value).toFixed(2);ui.brushWidthOut.textContent=(+ui.brushWidth.value).toFixed(2); | |
| ui.patchLabel.textContent="degree "+degreeU+" × "+degreeV+" · "+((degreeU+1)*(degreeV+1))+" CVs"; | |
| ui.selectedLabel.textContent=selectedCV?"CV "+selectedCV.i+", "+selectedCV.j:"u "+selected.u.toFixed(2)+" · v "+selected.v.toFixed(2); | |
| ui.fieldLabel.textContent=meta[1];ui.fieldTitle.textContent=meta[0]+" · "+scopeLabel();ui.edgeSet.disabled=edgeOrderLevel()<0; | |
| const edgeExact=edgeActive()&&edgeError()<1e-10;ui.edgeLabel.textContent=edgeActive()?ui.edgeOrder.value.toUpperCase()+" · "+edgeShortName()+" · "+lockedRowsLabel()+" · "+(edgeExact?"exact":edgeError().toExponential(1)):"Off"; | |
| const edgeStatus=edgeActive()?" "+ui.edgeOrder.value.toUpperCase()+" freezes "+lockedRowsLabel()+" on "+edgeShortName()+".":""; | |
| if(selectedCV)ui.status.innerHTML="<b>CV "+selectedCV.i+", "+selectedCV.j+" selected.</b> "+(cvOnConstrainedEdge(selectedCV.i,selectedCV.j)?"This control vertex is inside the frozen "+ui.edgeOrder.value.toUpperCase()+" row band.":"Drag X, Y, or Z to move the control vertex.")+edgeStatus; | |
| else ui.status.innerHTML="<b>"+meta[0]+" constraint.</b> The blue arrow targets the "+scopeLabel()+" domain; the patch and UV field update live."+edgeStatus; | |
| } | |
| function refresh(){rebuildDerivativeNets();setChartFrame();updateUI();drawSurface();drawField()} | |
| function screenPoint(event,element,w,h){ | |
| const r=element.getBoundingClientRect();return {x:(event.clientX-r.left)*w/r.width,y:(event.clientY-r.top)*h/r.height}; | |
| } | |
| function closestSurface(screen){ | |
| const hit=22,best={d:Infinity}; | |
| for(let j=0;j<=44;j++)for(let i=0;i<=44;i++){const u=i/44,v=j/44,p=project(evalGrid(derivativeNets.p,u,v)),d=(p.x-screen.x)**2+(p.y-screen.y)**2;if(d<best.d)Object.assign(best,{d,u,v})} | |
| return best.d<hit*hit?best:null; | |
| } | |
| function closestCV(screen){ | |
| const hit=10;let best=null; | |
| for(let j=0;j<=degreeV;j++)for(let i=0;i<=degreeU;i++){const p=project(net[j][i]),d=(p.x-screen.x)**2+(p.y-screen.y)**2;if(d<hit*hit&&(!best||d<best.d))best={i,j,d}} | |
| return best; | |
| } | |
| function hitGizmo(screen){ | |
| const all=selectedCV?cvGizmos():[normalGizmo()],hit=10;let best=null; | |
| for(const a of all){const vx=a.end.x-a.base.x,vy=a.end.y-a.base.y,l2=vx*vx+vy*vy||1,t=clamp(((screen.x-a.base.x)*vx+(screen.y-a.base.y)*vy)/l2,0,1),x=a.base.x+t*vx,y=a.base.y+t*vy,d=(screen.x-x)**2+(screen.y-y)**2;if(d<hit*hit&&(!best||d<best.d))best={...a,d}} | |
| return best; | |
| } | |
| function fieldCoordinates(event){ | |
| const p=screenPoint(event,ui.fieldView,FIELD_VIEW.w,FIELD_VIEW.h),u=clamp((p.x-FIELD_VIEW.l)/(FIELD_VIEW.w-FIELD_VIEW.l-FIELD_VIEW.r),0,1); | |
| const v=clamp(1-(p.y-FIELD_VIEW.t)/(FIELD_VIEW.h-FIELD_VIEW.t-FIELD_VIEW.b),0,1);return {u,v}; | |
| } | |
| function arrowFieldTarget(source,delta,mode=ui.fieldMode.value){ | |
| const gain={position:.7,tangent:.42,curvature:.95,acceleration:.82}[mode],target=source.slice(); | |
| const nonnegative=mode==="tangent"||(mode==="acceleration"&&ui.editScope.value==="uv"); | |
| for(let j=0;j<FIELD_N;j++)for(let i=0;i<FIELD_N;i++){const k=j*FIELD_N+i,w=scopeWeight(i/(FIELD_N-1),j/(FIELD_N-1));target[k]+=delta*gain*w;if(nonnegative)target[k]=Math.max(0,target[k])} | |
| return target; | |
| } | |
| function gaussianAxis(values,width,axis){ | |
| const sigma=Math.max(.65,.55+width*(FIELD_N-1)*.34),out=Array(values.length).fill(0); | |
| for(let j=0;j<FIELD_N;j++)for(let i=0;i<FIELD_N;i++){ | |
| let sum=0,ws=0; | |
| if(axis==="u"){for(let k=0;k<FIELD_N;k++){const z=(i-k)/sigma,w=Math.exp(-.5*z*z);sum+=values[j*FIELD_N+k]*w;ws+=w}} | |
| else{for(let k=0;k<FIELD_N;k++){const z=(j-k)/sigma,w=Math.exp(-.5*z*z);sum+=values[k*FIELD_N+i]*w;ws+=w}} | |
| out[j*FIELD_N+i]=sum/ws; | |
| } | |
| return out; | |
| } | |
| function gaussianField(values,width){ | |
| const horizontal=gaussianAxis(values,width,"u");return gaussianAxis(horizontal,width,"v"); | |
| } | |
| function filteredField(values,width){ | |
| return ui.editScope.value==="uv"?gaussianField(values,width):gaussianAxis(values,width,ui.editScope.value); | |
| } | |
| function smoothedFieldTarget(source,coverage,width){ | |
| const filtered=filteredField(source,width),target=source.slice(),thin=sectionThickness(); | |
| for(let j=0;j<FIELD_N;j++)for(let i=0;i<FIELD_N;i++){ | |
| const k=j*FIELD_N+i,u=i/(FIELD_N-1),v=j/(FIELD_N-1); | |
| let domain=1;if(ui.editScope.value==="u")domain=Math.exp(-((v-selected.v)**2)/(2*thin*thin));if(ui.editScope.value==="v")domain=Math.exp(-((u-selected.u)**2)/(2*thin*thin)); | |
| const strength=.88*coverage[k]*domain;target[k]=source[k]+(filtered[k]-source[k])*strength; | |
| } | |
| return target; | |
| } | |
| function movableCVs(unbounded=false){ | |
| const r=+ui.influence.value,out=[]; | |
| for(let j=0;j<=degreeV;j++)for(let i=0;i<=degreeU;i++){ | |
| if(cvOnConstrainedEdge(i,j))continue;if(unbounded){out.push({i,j,w:1});continue} | |
| const u=i/degreeU,v=j/degreeV,d2=(u-selected.u)**2+(v-selected.v)**2,w=Math.exp(-d2/(2*r*r));if(w>.035)out.push({i,j,w}); | |
| } | |
| return unbounded?out:out.sort((a,b)=>b.w-a.w).slice(0,28); | |
| } | |
| function scoreTarget(target,nref){ | |
| const current=measuredField(nref).values,spread=Math.max(.06,Math.max(...target)-Math.min(...target),Math.max(...current)-Math.min(...current));let e=0; | |
| for(let i=0;i<current.length;i++)e+=((current[i]-target[i])/spread)**2;return e/current.length; | |
| } | |
| function solveLinear(a,b){ | |
| const n=b.length,m=a.map((r,i)=>r.slice().concat(b[i])); | |
| for(let col=0;col<n;col++){let p=col;for(let r=col+1;r<n;r++)if(Math.abs(m[r][col])>Math.abs(m[p][col]))p=r;[m[col],m[p]]=[m[p],m[col]];const d=m[col][col]||EPS;for(let k=col;k<=n;k++)m[col][k]/=d;for(let r=0;r<n;r++)if(r!==col){const f=m[r][col];for(let k=col;k<=n;k++)m[r][k]-=f*m[col][k]}} | |
| return m.map(r=>r[n]); | |
| } | |
| function prepareLiveSolver(nref,unbounded=false){ | |
| const baseNet=cloneNet(net),moves=movableCVs(unbounded),source=measuredField(nref).values,count=source.length; | |
| const spread=Math.max(.06,Math.max(...source)-Math.min(...source)),J=Array.from({length:count},()=>Array(moves.length).fill(0)),epsilon=.01; | |
| for(let c=0;c<moves.length;c++){ | |
| const m=moves[c],original=net[m.j][m.i];net[m.j][m.i]=plus(original,mul(nref,epsilon*m.w));rebuildDerivativeNets(); | |
| const perturbed=measuredField(nref).values;net[m.j][m.i]=original;rebuildDerivativeNets(); | |
| for(let i=0;i<count;i++)J[i][c]=(perturbed[i]-source[i])/(epsilon*spread); | |
| } | |
| const A=Array.from({length:moves.length},()=>Array(moves.length).fill(0)); | |
| for(let a=0;a<moves.length;a++){for(let b=0;b<moves.length;b++)for(let i=0;i<count;i++)A[a][b]+=J[i][a]*J[i][b];A[a][a]+=.07+.16*(1-moves[a].w)} | |
| net=cloneNet(baseNet);restoreConstrainedBands();rebuildDerivativeNets();return {baseNet,moves,source,spread,J,A,nref}; | |
| } | |
| function applyLiveTarget(solver,target,label){ | |
| const rhs=Array(solver.moves.length).fill(0); | |
| for(let a=0;a<solver.moves.length;a++)for(let i=0;i<solver.source.length;i++)rhs[a]+=solver.J[i][a]*(target[i]-solver.source[i])/solver.spread; | |
| const raw=solveLinear(solver.A,rhs),limit=+ui.stepLimit.value,capped=raw.filter(x=>Math.abs(x)>limit).length,step=raw.map(x=>clamp(Number.isFinite(x)?x:0,-limit,limit)); | |
| net=cloneNet(solver.baseNet);solver.moves.forEach((m,i)=>net[m.j][m.i]=plus(net[m.j][m.i],mul(solver.nref,step[i]*m.w)));restoreConstrainedBands();rebuildDerivativeNets(); | |
| fieldPreview={target,normal:solver.nref};ui.fitLabel.textContent=solver.moves.length?(capped?capped+" CV steps capped at ±"+limit.toFixed(2):label+" "+scoreTarget(target,solver.nref).toFixed(3)):"No free CVs outside frozen bands";updateUI();drawSurface();drawField();return {capped,limit}; | |
| } | |
| function scheduleLiveTarget(solver,target,label){ | |
| pendingLive={solver,target,label};if(liveFrame!==null)return; | |
| if(typeof requestAnimationFrame==="function")liveFrame=requestAnimationFrame(()=>{liveFrame=null;const job=pendingLive;pendingLive=null;if(job)applyLiveTarget(job.solver,job.target,job.label)}); | |
| else{const job=pendingLive;pendingLive=null;applyLiveTarget(job.solver,job.target,job.label)} | |
| } | |
| function flushLiveTarget(){ | |
| if(!pendingLive)return;if(liveFrame!==null&&typeof cancelAnimationFrame==="function")cancelAnimationFrame(liveFrame);liveFrame=null; | |
| const job=pendingLive;pendingLive=null;applyLiveTarget(job.solver,job.target,job.label); | |
| } | |
| function commitTarget(target,nref,label,unbounded=false){ | |
| const solver=prepareLiveSolver(nref,unbounded),before=scoreTarget(target,nref),result=applyLiveTarget(solver,target,label); | |
| const after=scoreTarget(target,nref);fieldPreview=null;setChartFrame();ui.fitLabel.textContent=result.capped?result.capped+" CV steps capped at ±"+result.limit.toFixed(2):label+" "+before.toFixed(3)+" → "+after.toFixed(3);updateUI();drawSurface();drawField(); | |
| } | |
| function fitDegree(targetU,targetV){ | |
| const count=(targetU+1)*(targetV+1),M=Array.from({length:count},()=>Array(count).fill(0)),rx=Array(count).fill(0),ry=Array(count).fill(0),rz=Array(count).fill(0),n=Math.max(12,Math.max(targetU,targetV)+5); | |
| for(let j=0;j<n;j++)for(let i=0;i<n;i++){ | |
| const u=i/(n-1),v=j/(n-1),p=evaluate(u,v).p,row=[]; | |
| for(let b=0;b<=targetV;b++)for(let a=0;a<=targetU;a++)row.push(B(targetU,a,u)*B(targetV,b,v)); | |
| for(let a=0;a<count;a++){rx[a]+=row[a]*p.x;ry[a]+=row[a]*p.y;rz[a]+=row[a]*p.z;for(let b=0;b<count;b++)M[a][b]+=row[a]*row[b]} | |
| } | |
| for(let i=0;i<count;i++)M[i][i]+=1e-9; | |
| const sx=solveLinear(M,rx),sy=solveLinear(M,ry),sz=solveLinear(M,rz);degreeU=targetU;degreeV=targetV; | |
| net=Array.from({length:degreeV+1},(_,j)=>Array.from({length:degreeU+1},(_,i)=>{const k=j*(degreeU+1)+i;return V(sx[k],sy[k],sz[k])})); | |
| selectedCV=null;ui.fitLabel.textContent="degree refit complete";refresh();if(edgeOrderLevel()>=0)captureEdgeReference(); | |
| } | |
| function panCamera(dx,dy){ | |
| const cam=cameraFrame(),focal=surfaceH*1.05,scale=ui.projection.value==="perspective"?focal/cameraDistance:surfaceH/(cameraDistance*1.05); | |
| cameraTarget=plus(cameraTarget,plus(mul(cam.right,-dx/scale),mul(cam.up,dy/scale))); | |
| } | |
| function stepQuote(value){return "'"+String(value).replace(/'/g,"''")+"'"} | |
| function stepNumber(value){const n=Math.abs(value)<5e-13?0:value;let s=n.toFixed(12).replace(/0+$/g,"");if(!s.includes("."))s+=".";return s} | |
| function stepRef(id){return "#"+id} | |
| function stepRefs(ids){return "("+ids.map(stepRef).join(",")+")"} | |
| function stepList(values){return "("+values.join(",")+")"} | |
| function buildStepFile(protocol){ | |
| const spec=protocol==="ap214"?{ | |
| label:"AP214",schema:"AUTOMOTIVE_DESIGN_CC2",application:"automotive_design", | |
| context:"core data for automotive mechanical design processes",year:1997 | |
| }:{ | |
| label:"AP203",schema:"AP203_CONFIGURATION_CONTROLLED_3D_DESIGN_OF_MECHANICAL_PARTS_AND_ASSEMBLIES_MIM_LF", | |
| application:"config_control_design",context:"configuration controlled 3d designs of mechanical parts and assemblies",year:2011 | |
| }; | |
| const now=new Date(),stamp=now.toISOString().slice(0,19),fileName="bezier_surface_"+protocol+".stp"; | |
| let nextId=1;const entities=[]; | |
| const add=(type,args)=>{const id=nextId++;entities.push("#"+id+"="+type+"("+args+");");return id}; | |
| const addRaw=body=>{const id=nextId++;entities.push("#"+id+"="+body+";");return id}; | |
| const applicationContext=add("APPLICATION_CONTEXT",stepQuote(spec.context)); | |
| add("APPLICATION_PROTOCOL_DEFINITION",[stepQuote("international standard"),stepQuote(spec.application),spec.year,stepRef(applicationContext)].join(",")); | |
| const productContext=add("PRODUCT_CONTEXT",[stepQuote(""),stepRef(applicationContext),stepQuote("mechanical")].join(",")); | |
| const product=add("PRODUCT",[stepQuote("Bezier Surface"),stepQuote("Bezier Surface"),stepQuote(""),stepRefs([productContext])].join(",")); | |
| const formation=add("PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE",[stepQuote(""),stepQuote(""),stepRef(product),".MADE."].join(",")); | |
| const definitionContext=add("PRODUCT_DEFINITION_CONTEXT",[stepQuote("part definition"),stepRef(applicationContext),stepQuote("design")].join(",")); | |
| const productDefinition=add("PRODUCT_DEFINITION",[stepQuote("design"),stepQuote(""),stepRef(formation),stepRef(definitionContext)].join(",")); | |
| const productDefinitionShape=add("PRODUCT_DEFINITION_SHAPE",[stepQuote(""),stepQuote(""),stepRef(productDefinition)].join(",")); | |
| const lengthUnit=addRaw("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT($,.METRE.))"); | |
| const planeAngleUnit=addRaw("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))"); | |
| const solidAngleUnit=addRaw("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())"); | |
| const uncertainty=add("UNCERTAINTY_MEASURE_WITH_UNIT",["LENGTH_MEASURE(1.E-6)",stepRef(lengthUnit),stepQuote("distance_accuracy_value"),stepQuote("confusion accuracy")].join(",")); | |
| const representationContext=addRaw("(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT("+stepRefs([uncertainty])+") GLOBAL_UNIT_ASSIGNED_CONTEXT("+stepRefs([lengthUnit,planeAngleUnit,solidAngleUnit])+") REPRESENTATION_CONTEXT('', 'MODEL SPACE'))"); | |
| const pointIds=Array.from({length:degreeV+1},(_,j)=>Array.from({length:degreeU+1},(_,i)=>{ | |
| const p=net[j][i];return add("CARTESIAN_POINT",[stepQuote("CV U"+i+" V"+j),stepList([stepNumber(p.x),stepNumber(p.y),stepNumber(p.z)])].join(",")); | |
| })); | |
| const controlRows=Array.from({length:degreeU+1},(_,i)=>stepRefs(Array.from({length:degreeV+1},(_,j)=>pointIds[j][i]))); | |
| const surface=add("B_SPLINE_SURFACE_WITH_KNOTS",[ | |
| stepQuote("Bezier Surface"),degreeU,degreeV,stepList(controlRows),".UNSPECIFIED.",".F.",".F.",".F.", | |
| stepList([degreeU+1,degreeU+1]),stepList([degreeV+1,degreeV+1]),stepList([stepNumber(0),stepNumber(1)]),stepList([stepNumber(0),stepNumber(1)]),".UNSPECIFIED." | |
| ].join(",")); | |
| const boundaryDefinitions=[ | |
| {name:"V0",controlIds:Array.from({length:degreeU+1},(_,i)=>pointIds[0][i]),start:pointIds[0][0],end:pointIds[0][degreeU]}, | |
| {name:"U1",controlIds:Array.from({length:degreeV+1},(_,j)=>pointIds[j][degreeU]),start:pointIds[0][degreeU],end:pointIds[degreeV][degreeU]}, | |
| {name:"V1",controlIds:Array.from({length:degreeU+1},(_,i)=>pointIds[degreeV][degreeU-i]),start:pointIds[degreeV][degreeU],end:pointIds[degreeV][0]}, | |
| {name:"U0",controlIds:Array.from({length:degreeV+1},(_,j)=>pointIds[degreeV-j][0]),start:pointIds[degreeV][0],end:pointIds[0][0]} | |
| ]; | |
| const orientedEdges=boundaryDefinitions.map(edgeDefinition=>{ | |
| const curve=add("B_SPLINE_CURVE_WITH_KNOTS",[ | |
| stepQuote("Boundary "+edgeDefinition.name),edgeDefinition.controlIds.length-1,stepRefs(edgeDefinition.controlIds), | |
| ".UNSPECIFIED.",".F.",".F.",stepList([edgeDefinition.controlIds.length,edgeDefinition.controlIds.length]), | |
| stepList([stepNumber(0),stepNumber(1)]),".UNSPECIFIED." | |
| ].join(",")); | |
| const edge=add("EDGE_CURVE",[stepQuote("Boundary "+edgeDefinition.name),stepRef(edgeDefinition.start),stepRef(edgeDefinition.end),stepRef(curve),".T."].join(",")); | |
| return add("ORIENTED_EDGE",[stepQuote(""),"*","*",stepRef(edge),".T."].join(",")); | |
| }); | |
| const edgeLoop=add("EDGE_LOOP",[stepQuote("Surface Boundary"),stepRefs(orientedEdges)].join(",")); | |
| const outerBound=add("FACE_OUTER_BOUND",[stepQuote("Outer Boundary"),stepRef(edgeLoop),".T."].join(",")); | |
| const face=add("ADVANCED_FACE",[stepQuote("Bezier Surface Face"),stepRefs([outerBound]),stepRef(surface),".T."].join(",")); | |
| const shell=add("OPEN_SHELL",[stepQuote("Bezier Surface Shell"),stepRefs([face])].join(",")); | |
| const surfaceModel=add("SHELL_BASED_SURFACE_MODEL",[stepQuote("Bezier Surface Model"),stepRefs([shell])].join(",")); | |
| const representation=add("MANIFOLD_SURFACE_SHAPE_REPRESENTATION",[stepQuote("Bezier Surface Representation"),stepRefs([surfaceModel]),stepRef(representationContext)].join(",")); | |
| add("SHAPE_DEFINITION_REPRESENTATION",[stepRef(productDefinitionShape),stepRef(representation)].join(",")); | |
| return [ | |
| "ISO-10303-21;", | |
| "HEADER;", | |
| "FILE_DESCRIPTION("+stepList([stepQuote("Bezier surface editor export"),stepQuote(spec.label)])+",'2;1');", | |
| "FILE_NAME("+[stepQuote(fileName),stepQuote(stamp),stepList([stepQuote("Bezier Surface Editor")]),stepList([stepQuote("OpenAI")]),stepQuote("Browser STEP exporter"),stepQuote("Bezier Surface Editor"),stepQuote("")].join(",")+");", | |
| "FILE_SCHEMA("+stepList([stepQuote(spec.schema)])+");", | |
| "ENDSEC;", | |
| "DATA;", | |
| entities.join("\n"), | |
| "ENDSEC;", | |
| "END-ISO-10303-21;" | |
| ].join("\n"); | |
| } | |
| function saveStepFile(){ | |
| flushLiveTarget(); | |
| const protocol=q("#stepProtocol").value,content=buildStepFile(protocol),blob=new Blob([content],{type:"application/step"}),url=URL.createObjectURL(blob),link=document.createElement("a"); | |
| link.href=url;link.download="bezier_surface_"+protocol+".stp";document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),0); | |
| ui.fitLabel.textContent=protocol.toUpperCase()+" STP saved"; | |
| } | |
| ui.surface.addEventListener("contextmenu",event=>event.preventDefault()); | |
| ui.surface.addEventListener("mousedown",event=>{if(event.button===1||event.button===2)event.preventDefault()},{passive:false}); | |
| ui.surface.addEventListener("auxclick",event=>event.preventDefault()); | |
| ui.surface.addEventListener("dragstart",event=>event.preventDefault()); | |
| ui.surface.addEventListener("pointerdown",event=>{ | |
| event.preventDefault();const p=screenPoint(event,ui.surface,surfaceW,surfaceH);ui.surface.setPointerCapture(event.pointerId); | |
| if(event.button===1||(event.button===0&&event.shiftKey)){ui.deltaLabel.textContent="—";drag={kind:"pan",last:p};ui.surface.style.cursor="grabbing";return} | |
| if(event.button===2){ui.deltaLabel.textContent="—";drag={kind:"orbit",last:p};ui.surface.style.cursor="grabbing";return} | |
| const cv=closestCV(p),gizmo=hitGizmo(p);if(cv&&cv.d<=36){ui.deltaLabel.textContent="—";selectedCV=cv;setChartFrame();refresh();return} | |
| if(gizmo){ | |
| if(gizmo.type==="normal"){const field=measuredField(),solver=prepareLiveSolver(field.normal);drag={kind:"normal",gizmo,last:p,total:0,source:field.values,normal:field.normal,mode:ui.fieldMode.value,solver};showNormalDelta(0,field.values,field.values)} | |
| else{if(cvOnConstrainedEdge(selectedCV.i,selectedCV.j)){ui.deltaLabel.textContent=ui.edgeOrder.value.toUpperCase()+" row band locked";return}drag={kind:gizmo.type,gizmo,last:p,total:0};ui.deltaLabel.textContent="Δ"+gizmo.label+" +0.000"}ui.surface.style.cursor="grabbing";return; | |
| } | |
| if(cv){ui.deltaLabel.textContent="—";selectedCV=cv;setChartFrame();refresh();return} | |
| const hit=closestSurface(p);if(hit){ui.deltaLabel.textContent="—";selected={u:hit.u,v:hit.v};selectedCV=null;setChartFrame();refresh()} | |
| }); | |
| ui.surface.addEventListener("pointermove",event=>{ | |
| if(!drag)return;const p=screenPoint(event,ui.surface,surfaceW,surfaceH),dx=p.x-drag.last.x,dy=p.y-drag.last.y; | |
| if(drag.kind==="orbit"){yaw-=dx*.008;pitch=clamp(pitch+dy*.008,-1.22,1.22);drawSurface()} | |
| else if(drag.kind==="pan"){panCamera(dx,dy);drawSurface()} | |
| else{ | |
| const a=drag.gizmo,vx=a.end.x-a.base.x,vy=a.end.y-a.base.y,l2=vx*vx+vy*vy||1,amount=(dx*vx+dy*vy)/l2*a.length; | |
| if(drag.kind==="normal"){drag.total+=amount;const target=arrowFieldTarget(drag.source,drag.total,drag.mode);showNormalDelta(drag.total,drag.source,target);scheduleLiveTarget(drag.solver,target,"live field")} | |
| else{drag.total+=amount;ui.deltaLabel.textContent="Δ"+a.label+" "+signedValue(drag.total);const c=net[selectedCV.j][selectedCV.i];net[selectedCV.j][selectedCV.i]=plus(c,mul(a.dir,amount));refresh()} | |
| } | |
| drag.last=p; | |
| }); | |
| function endSurfaceDrag(){ | |
| const done=drag;if(done&&done.kind==="normal")flushLiveTarget();drag=null;ui.surface.style.cursor="crosshair"; | |
| if(done&&done.kind==="normal"){fieldPreview=null;setChartFrame();updateUI();drawSurface();drawField()}ui.deltaLabel.textContent="—"; | |
| } | |
| ui.surface.addEventListener("pointerup",endSurfaceDrag);ui.surface.addEventListener("pointercancel",endSurfaceDrag); | |
| ui.surface.addEventListener("wheel",event=>{event.preventDefault();cameraDistance=clamp(cameraDistance*(event.deltaY>0?1.1:.9),2.35,18);drawSurface()},{passive:false}); | |
| function paintBrush(event){ | |
| if(!brush)return;const center=fieldCoordinates(event);brush.center=center;selected={u:center.u,v:center.v}; | |
| const width=+ui.brushWidth.value,thin=sectionThickness(); | |
| for(let j=0;j<FIELD_N;j++)for(let i=0;i<FIELD_N;i++){ | |
| const k=j*FIELD_N+i,u=i/(FIELD_N-1),v=j/(FIELD_N-1);let d2; | |
| if(ui.editScope.value==="u")d2=((u-center.u)/width)**2+((v-center.v)/thin)**2; | |
| else if(ui.editScope.value==="v")d2=((v-center.v)/width)**2+((u-center.u)/thin)**2; | |
| else d2=((u-center.u)/width)**2+((v-center.v)/width)**2; | |
| brush.coverage[k]=Math.max(brush.coverage[k],Math.exp(-.5*d2)); | |
| } | |
| brush.target=smoothedFieldTarget(brush.source,brush.coverage,width);showBrushDelta(brush.source,brush.target);scheduleLiveTarget(brush.solver,brush.target,"live field smooth"); | |
| } | |
| ui.fieldView.addEventListener("pointerdown",event=>{ | |
| event.preventDefault();selected=fieldCoordinates(event);selectedCV=null;setChartFrame(); | |
| const field=measuredField(),solver=prepareLiveSolver(field.normal); | |
| brush={source:field.values.slice(),target:field.values.slice(),coverage:Array(field.values.length).fill(0),normal:field.normal,center:{...selected},solver}; | |
| ui.fieldView.setPointerCapture(event.pointerId);paintBrush(event); | |
| }); | |
| ui.fieldView.addEventListener("pointermove",event=>{if(brush)paintBrush(event)}); | |
| function endBrush(){ | |
| if(!brush)return;flushLiveTarget();brush=null;fieldPreview=null;setChartFrame();updateUI();drawSurface();drawField();ui.deltaLabel.textContent="—"; | |
| } | |
| ui.fieldView.addEventListener("pointerup",endBrush);ui.fieldView.addEventListener("pointercancel",endBrush); | |
| ui.degreeU.addEventListener("input",()=>ui.degreeUOut.textContent=ui.degreeU.value); | |
| ui.degreeV.addEventListener("input",()=>ui.degreeVOut.textContent=ui.degreeV.value); | |
| ui.degreeU.addEventListener("change",()=>fitDegree(+ui.degreeU.value,degreeV)); | |
| ui.degreeV.addEventListener("change",()=>fitDegree(degreeU,+ui.degreeV.value)); | |
| ui.tessellation.addEventListener("input",()=>{ui.tessellationOut.textContent=ui.tessellation.value;drawSurface()}); | |
| ui.influence.addEventListener("input",()=>{ui.influenceOut.textContent=(+ui.influence.value).toFixed(2);drawSurface();drawField()}); | |
| ui.stepLimit.addEventListener("input",()=>ui.stepLimitOut.textContent=(+ui.stepLimit.value).toFixed(2)); | |
| ui.projection.addEventListener("input",drawSurface);ui.displayMode.addEventListener("input",drawSurface);ui.zebraOrientation.addEventListener("input",drawSurface); | |
| ui.fieldMode.addEventListener("input",()=>{fieldPreview=null;setChartFrame();ui.fitLabel.textContent="Ready";updateUI();drawSurface();drawField()}); | |
| ui.editScope.addEventListener("input",()=>{fieldPreview=null;ui.fitLabel.textContent="Ready";updateUI();drawSurface();drawField()}); | |
| ui.edgeOrder.addEventListener("input",()=>{fieldPreview=null;captureEdgeReference();drawField()});ui.edgeSet.addEventListener("input",()=>{if(edgeOrderLevel()>=0)captureEdgeReference()}); | |
| ui.brushWidth.addEventListener("input",()=>{ui.brushWidthOut.textContent=(+ui.brushWidth.value).toFixed(2);drawField()}); | |
| q("#saveStep").onclick=saveStepFile; | |
| q("#smoothAll").onclick=()=>{const field=measuredField(),coverage=Array(field.values.length).fill(1),target=smoothedFieldTarget(field.values,coverage,+ui.brushWidth.value);commitTarget(target,field.normal,"Gaussian field",true)}; | |
| q("#resetSurface").onclick=()=>{degreeU=4;degreeV=4;ui.degreeU.value=4;ui.degreeV.value=4;net=makeSculptedNet();selected={u:.5,v:.5};selectedCV=null;ui.fitLabel.textContent="reset to sculpted surface";refresh();if(edgeOrderLevel()>=0)captureEdgeReference()}; | |
| q("#resetView").onclick=()=>{yaw=.77;pitch=.43;cameraDistance=6.7;cameraTarget=V(0,.22,0);drawSurface()}; | |
| net=makeSculptedNet();rebuildDerivativeNets();setChartFrame();refresh(); | |
| if(typeof ResizeObserver!=="undefined"){const observer=new ResizeObserver(()=>drawSurface());observer.observe(ui.surface)} | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment