Skip to content

Instantly share code, notes, and snippets.

@ifilipis
Last active August 4, 2026 15:15
Show Gist options
  • Select an option

  • Save ifilipis/26dddab7985c991ff1ed76d618ffa77b to your computer and use it in GitHub Desktop.

Select an option

Save ifilipis/26dddab7985c991ff1ed76d618ffa77b to your computer and use it in GitHub Desktop.
Derivative Field Bézier Surface Editor
<!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