HomeJournalThis post

Marching Cubes for Browser Sculptures

Sample a generated scalar field, expose cube ambiguity, validate mesh topology, and export a reproducible browser sculpture.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Marching cubes turns a sampled scalar field into triangles, but a convincing browser sculpture still needs explicit ambiguity policy, finite geometry, topology checks, and reproducible export. This tutorial builds one generated field, inspects the cube cases, and refuses to call the result watertight unless edge-incidence evidence earns that claim.

Marching cubes begins with a scalar field

Define a scalar function over three-dimensional coordinates and an isovalue whose level set becomes the surface. This isosurface extraction turns scalar field meshing into an inspectable pipeline. Analytic spheres, tori, gyroids, blended primitives, or seeded noise combinations produce rights-clear geometry and make the field reproducible without importing a scan.

The original Marching Cubes paper is the primary source for the classic extraction method. Modern implementations vary in lookup tables, interpolation, ambiguity handling, deduplication, normals, and acceleration, so document the exact algorithm rather than citing the name as a complete contract.

The bundled marching cubes browser visual samples a generated perturbed sphere on a ten-by-ten-by-ten lattice. It executes a pinned 256-row triangle table, hashes that table and its derived edge masks, and validates the resulting indexed mesh instead of substituting rings or marching tetrahedra.

A ten-sample-axis synthetic scalar grid evaluates a perturbed sphere field. Every cell computes an eight-bit cube case under the printed corner order, looks up the corresponding 16-slot triangle row, and archives its coordinate, edge mask, triangle count, and selected policy.

Pinned corner, edge, and case-table orderEight declared corners produce one case index; twelve declared edges address a pinned 256-row marching-cubes triangle table.corner bits 0…7 · edges 0…11casetriangle edge triples256 × 16 · SHA-256
Executed table contract
Corner order0…3 on z0, 4…7 on z1
Edge order0…7 around both rings, 8…11 vertical
Case rowsixteen slots, terminated by minus one
Verification256 masks plus pinned table hashes
Figure 1: The cube and lookup row share one printed indexing contract, so a copied table cannot silently use another corner order.

Sample a bounded grid with stable coordinates

Choose bounds, cell counts, coordinate mapping, scalar precision, and isovalue before extraction. Sample every lattice point once into a typed array, reject non-finite values, and include the field parameters plus grid dimensions in the edition receipt.

Resolution changes both geometric fidelity and computational cost. A coarse voxel mesh can create a deliberate faceted language, while a finer grid may reveal field detail and amplify numerical or memory problems; neither resolution is objectively correct for every sculpture.

Marching cubes should retain the sampled field or a reproducible generator digest so later renderer work cannot silently alter source geometry. Separate sculpture design parameters from camera, light, and material parameters used only for presentation.

The extractor pins the classic 256-case triangle table to a named three.js source commit and verifies its SHA-256 digest in the browser. An edge table is derived from those rows, independently hashed, and compared with corner-sign crossings for all 256 cases before geometry is accepted.

Turn corner signs into a verified case table

For each cell, compare its eight corner values with the isovalue and pack the results into a case index under a documented corner ordering. This reference pins the triangle rows to a specific three.js source commit, derives each edge mask from the row, and verifies both byte sequences with SHA-256. A different ordering requires a corresponding table transform.

Interpolate an edge crossing with t=(iso-a)/(b-a), handling equal or nearly equal endpoints through a stable policy. Clamp only when justified, retain finite checks, and orient triangle winding consistently with the chosen inside convention.

The marching cubes case figure labels bits, crossed edges, interpolation points, and winding. It exists to make table assumptions reviewable instead of hiding topology inside a large copied constant.

Edge intersections use the zero isovalue and a deterministic denominator guard. A canonical key built from each lattice edge welds the same intersection across adjacent cells, while the receipt retains the originating cell, edge number, and interpolation fraction for every indexed vertex.

Resolve face saddles and expose interior limits

Some face or interior configurations admit more than one plausible connectivity. Choosing table triangles without a consistent disambiguation rule can create holes, disconnected sheets, or topology that flips under tiny field changes.

The Asymptotic Decider provides foundational context for resolving ambiguity using interpolant behavior. A production implementation should identify which ambiguous cases it handles, how decisions agree across shared faces, and which configurations remain unsupported.

This marching cubes tutorial applies q=f0·f2−f1·f3 to alternating-sign faces, with the same four shared samples producing the same pairing in adjacent cells. Exhaustive positive-q and negative-q fixtures exercise both branches. Multiple loops that need an interior decision remain explicitly unresolved and prevent a watertight claim. The default generated field currently contains no such unresolved cell.

Every triangle receives finite-coordinate, grid-bound, area, and analytic-gradient winding checks. Undirected indexed edges are then classified as boundary, paired, or non-manifold, and face adjacency produces explicit component counts rather than treating a convincing silhouette as topology evidence.

Asymptotic face-decision branchesThe same alternating face signs choose opposite edge pairings when the bilinear saddle quantity changes sign.q ≥ 0 · pair 01 | 23q < 0 · pair 03 | 12q = f0·f2 − f1·f3
  1. Detect four crossed edges on one shared cube face.
  2. Evaluate q from the four isovalue-relative corner samples.
  3. Use pairing 01|23 when q is nonnegative.
  4. Use pairing 03|12 when q is negative.
  5. Store the face name, q value, and chosen pairing.
Figure 2: Two exhaustive fixtures force both saddle branches; multi-loop interior choices remain separately unresolved.

Deduplicate vertices without losing provenance

Emitting three fresh vertices per triangle is simple but duplicates shared positions and complicates smooth normals, topology checks, and export size. Cache crossings by a canonical grid-edge key so neighboring cells reuse the same vertex when their interpolation inputs agree.

Store each vertex's source edge, interpolation fraction, position, and optional scalar gradient. That provenance helps diagnose cracks caused by inconsistent edge keys, reversed endpoints, precision changes, or cells using different ambiguity decisions.

Marching cubes mesh optimization should happen after a correct reference extractor. Index compaction, quantization, cache reordering, and GPU generation must reproduce bounds, orientation, topology statistics, and a tolerance-aware geometry digest before replacing the scalar path.

The case histogram includes empty, full, and surface cells. The table fixture separately checks all 256 sentinel-terminated rows, edge masks, complement masks, legal edge indices, and the hand-checkable case-one triangle, so scalar field meshing cannot be replaced by a relabeled analytic drawing.

Runnable artifact — Run the pinned 256-case table, exercise both face-decider branches, weld lattice-edge vertices, validate indexed topology, and export OBJ plus JSON.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Marching cubes sculpture lab</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.5 system-ui;max-width:960px;margin:auto;padding:24px;background:#101b24;color:#f5f3ea}button,select,a{font:inherit;padding:.72rem 1rem;margin:.35rem;border:2px solid #7ce5c3;border-radius:.6rem;background:#172b36;color:#fff}button:focus-visible,select:focus-visible,a:focus-visible{outline:4px solid #ffd66b;outline-offset:3px}canvas{width:100%;height:auto;border:1px solid #78909c;background:#081116}output{display:block;white-space:pre-wrap;padding:1rem;background:#081116;border-radius:.6rem;margin-top:1rem;overflow-wrap:anywhere}small{display:block;color:#b8cbd4}.exports{display:flex;flex-wrap:wrap;gap:.5rem}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}</style><main><h1>Marching cubes sculpture lab</h1><p>A pinned 256-case marching-cubes table extracts a generated scalar field. Shared lattice-edge vertices are indexed, ambiguous faces use a bilinear asymptotic decider, and unresolved interior choices stay visible.</p><button id="generate">Generate bounded isosurface</button><canvas id="mesh" width="640" height="420" aria-label="Generated bounded marching-cubes sculpture"></canvas><div class="exports"><a id="objExport" download="sculpture.obj">Export OBJ</a><a id="jsonExport" download="sculpture.json">Export JSON</a></div><output id="receipt" aria-live="polite"></output></main><script>const N=10,iso=0,corner=[[0,0,0],[1,0,0],[1,1,0],[0,1,0],[0,0,1],[1,0,1],[1,1,1],[0,1,1]],edgeCorners=[[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]],cubeFaces=[{name:"z0",corners:[0,1,2,3],edges:[0,1,2,3]},{name:"z1",corners:[4,5,6,7],edges:[4,5,6,7]},{name:"y0",corners:[0,1,5,4],edges:[0,9,4,8]},{name:"y1",corners:[3,2,6,7],edges:[2,10,6,11]},{name:"x0",corners:[0,3,7,4],edges:[3,11,7,8]},{name:"x1",corners:[1,2,6,5],edges:[1,10,5,9]}],tableBytes=Uint8Array.from(atob("/////////////////////wAIA/////////////////8AAQn/////////////////AQgDCQgB/////////////wECCv////////////////8ACAMBAgr/////////////CQIKAAIJ/////////////wIIAwIKCAoJCP////////8DCwL/////////////////AAsCCAsA/////////////wEJAAIDC/////////////8BCwIBCQsJCAv/////////AwoBCwoD/////////////wAKAQAICggLCv////////8DCQADCwkLCgn/////////CQgKCggL/////////////wQHCP////////////////8EAwAHAwT/////////////AAEJCAQH/////////////wQBCQQHAQcDAf////////8BAgoIBAf/////////////AwQHAwAEAQIK/////////wkCCgkAAggEB/////////8CCgkCCQcCBwMHCQT/////CAQHAwsC/////////////wsEBwsCBAIABP////////8JAAEIBAcCAwv/////////BAcLCQQLCQsCCQIB/////wMKAQMLCgcIBP////////8BCwoBBAsBAAQHCwT/////BAcICQALCQsKCwAD/////wQHCwQLCQkLCv////////8JBQT/////////////////CQUEAAgD/////////////wAFBAEFAP////////////8IBQQIAwUDAQX/////////AQIKCQUE/////////////wMACAECCgQJBf////////8FAgoFBAIEAAL/////////AgoFAwIFAwUEAwQI/////wkFBAIDC/////////////8ACwIACAsECQX/////////AAUEAAEFAgML/////////wIBBQIFCAIICwQIBf////8KAwsKAQMJBQT/////////BAkFAAgBCAoBCAsK/////wUEAAUACwULCgsAA/////8FBAgFCAoKCAv/////////CQcIBQcJ/////////////wkDAAkFAwUHA/////////8ABwgAAQcBBQf/////////AQUDAwUH/////////////wkHCAkFBwoBAv////////8KAQIJBQAFAwAFBwP/////CAACCAIFCAUHCgUC/////wIKBQIFAwMFB/////////8HCQUHCAkDCwL/////////CQUHCQcCCQIAAgcL/////wIDCwABCAEHCAEFB/////8LAgELAQcHAQX/////////CQUICAUHCgEDCgML/////wUHAAUACQcLAAEACgsKAP8LCgALAAMKBQAIAAcFBwD/CwoFBwsF/////////////woGBf////////////////8ACAMFCgb/////////////CQABBQoG/////////////wEIAwEJCAUKBv////////8BBgUCBgH/////////////AQYFAQIGAwAI/////////wkGBQkABgACBv////////8FCQgFCAIFAgYDAgj/////AgMLCgYF/////////////wsACAsCAAoGBf////////8AAQkCAwsFCgb/////////BQoGAQkCCQsCCQgL/////wYDCwYFAwUBA/////////8ACAsACwUABQEFCwb/////AwsGAAMGAAYFAAUJ/////wYFCQYJCwsJCP////////8FCgYEBwj/////////////BAMABAcDBgUK/////////wEJAAUKBggEB/////////8KBgUBCQcBBwMHCQT/////BgECBgUBBAcI/////////wECBQUCBgMABAMEB/////8IBAcJAAUABgUAAgb/////BwMJBwkEAwIJBQkGAgYJ/wMLAgcIBAoGBf////////8FCgYEBwIEAgACBwv/////AAEJBAcIAgMLBQoG/////wkCAQkLAgkECwcLBAUKBv8IBAcDCwUDBQEFCwb/////BQELBQsGAQALBwsEAAQL/wAFCQAGBQADBgsGAwgEB/8GBQkGCQsEBwkHCwn/////CgQJBgQK/////////////wQKBgQJCgAIA/////////8KAAEKBgAGBAD/////////CAMBCAEGCAYEBgEK/////wEECQECBAIGBP////////8DAAgBAgkCBAkCBgT/////AAIEBAIG/////////////wgDAggCBAQCBv////////8KBAkKBgQLAgP/////////AAgCAggLBAkKBAoG/////wMLAgABBgAGBAYBCv////8GBAEGAQoECAECAQsICwH/CQYECQMGCQEDCwYD/////wgLAQgBAAsGAQkBBAYEAf8DCwYDBgAABgT/////////BgQICwYI/////////////wcKBgcICggJCv////////8ABwMACgcACQoGBwr/////CgYHAQoHAQcIAQgA/////woGBwoHAQEHA/////////8BAgYBBggBCAkIBgf/////AgYJAgkBBgcJAAkDBwMJ/wcIAAcABgYAAv////////8HAwIGBwL/////////////AgMLCgYICggJCAYH/////wIABwIHCwAJBwYHCgkKB/8BCAABBwgBCgcGBwoCAwv/CwIBCwEHCgYBBgcB/////wgJBggGBwkBBgsGAwEDBv8ACQELBgf/////////////BwgABwAGAwsACwYA/////wcLBv////////////////8HBgv/////////////////AwAICwcG/////////////wABCQsHBv////////////8IAQkIAwELBwb/////////CgECBgsH/////////////wECCgMACAYLB/////////8CCQACCgkGCwf/////////BgsHAgoDCggDCgkI/////wcCAwYCB/////////////8HAAgHBgAGAgD/////////AgcGAgMHAAEJ/////////wEGAgEIBgEJCAgHBv////8KBwYKAQcBAwf/////////CgcGAQcKAQgHAQAI/////wADBwAHCgAKCQYKB/////8HBgoHCggICgn/////////BggECwgG/////////////wMGCwMABgAEBv////////8IBgsIBAYJAAH/////////CQQGCQYDCQMBCwMG/////wYIBAYLCAIKAf////////8BAgoDAAsABgsABAb/////BAsIBAYLAAIJAgoJ/////woJAwoDAgkEAwsDBgQGA/8IAgMIBAIEBgL/////////AAQCBAYC/////////////wEJAAIDBAIEBgQDCP////8BCQQBBAICBAb/////////CAEDCAYBCAQGBgoB/////woBAAoABgYABP////////8EBgMEAwgGCgMAAwkKCQP/CgkEBgoE/////////////wQJBQcGC/////////////8ACAMECQULBwb/////////BQABBQQABwYL/////////wsHBggDBAMFBAMBBf////8JBQQKAQIHBgv/////////BgsHAQIKAAgDBAkF/////wcGCwUECgQCCgQAAv////8DBAgDBQQDAgUKBQILBwb/BwIDBwYCBQQJ/////////wkFBAAIBgAGAgYIB/////8DBgIDBwYBBQAFBAD/////BgIIBggHAgEIBAgFAQUI/wkFBAoBBgEHBgEDB/////8BBgoBBwYBAAcIBwAJBQT/BAAKBAoFAAMKBgoHAwcK/wcGCgcKCAUECgQICv////8GCQUGCwkLCAn/////////AwYLAAYDAAUGAAkF/////wALCAAFCwABBQUGC/////8GCwMGAwUFAwH/////////AQIKCQULCQsICwUG/////wALAwAGCwAJBgUGCQECCv8LCAULBQYIAAUKBQIAAgX/BgsDBgMFAgoDCgUD/////wUICQUCCAUGAgMIAv////8JBQYJBgAABgL/////////AQUIAQgABQYIAwgCBgII/wEFBgIBBv////////////8BAwYBBgoDCAYFBgkICQb/CgEACgAGCQUABQYA/////wADCAUGCv////////////8KBQb/////////////////CwUKBwUL/////////////wsFCgsHBQgDAP////////8FCwcFCgsBCQD/////////CgcFCgsHCQgBCAMB/////wsBAgsHAQcFAf////////8ACAMBAgcBBwUHAgv/////CQcFCQIHCQACAgsH/////wcFAgcCCwUJAgMCCAkIAv8CBQoCAwUDBwX/////////CAIACAUCCAcFCgIF/////wkAAQUKAwUDBwMKAv////8JCAIJAgEIBwIKAgUHBQL/AQMFAwcF/////////////wAIBwAHAQEHBf////////8JAAMJAwUFAwf/////////CQgHBQkH/////////////wUIBAUKCAoLCP////////8FAAQFCwAFCgsLAwD/////AAEJCAQKCAoLCgQF/////woLBAoEBQsDBAkEAQMBBP8CBQECCAUCCwgEBQj/////AAQLAAsDBAULAgsBBQEL/wACBQAFCQILBQQFCAsIBf8JBAUCCwP/////////////AgUKAwUCAwQFAwgE/////wUKAgUCBAQCAP////////8DCgIDBQoDCAUEBQgAAQn/BQoCBQIEAQkCCQQC/////wgEBQgFAwMFAf////////8ABAUBAAX/////////////CAQFCAUDCQAFAAMF/////wkEBf////////////////8ECwcECQsJCgv/////////AAgDBAkHCQsHCQoL/////wEKCwELBAEEAAcEC/////8DAQQDBAgBCgQHBAsKCwT/BAsHCQsECQILCQEC/////wkHBAkLBwkBCwILAQAIA/8LBwQLBAICBAD/////////CwcECwQCCAMEAwIE/////wIJCgIHCQIDBwcECf////8JCgcJBwQKAgcIBwACAAf/AwcKAwoCBwQKAQoABAAK/wEKAggHBP////////////8ECQEEAQcHAQP/////////BAkBBAEHAAgBCAcB/////wQAAwcEA/////////////8ECAf/////////////////CQoICgsI/////////////wMACQMJCwsJCv////////8AAQoACggICgv/////////AwEKCwMK/////////////wECCwELCQkLCP////////8DAAkDCQsBAgkCCwn/////AAILCAAL/////////////wMCC/////////////////8CAwgCCAoKCAn/////////CQoCAAkC/////////////wIDCAIICgABCAEKCP////8BCgL/////////////////AQMICQEI/////////////wAJAf////////////////8AAwj//////////////////////////////////////w=="),c=>c.charCodeAt(0)),tableCommit="20bd26f2d1f2797602c084975b793591b3472558",expectedTableHash="19bf7699e214903d72c94c296546f2e31337d637a1e4b118c3108a0f428e809b",expectedEdgeHash="2572f4b459bce46d930e57b2c261ef8143b6d43157272c698c64d70dd295b596",triRow=c=>Array.from(tableBytes.slice(c*16,c*16+16),x=>x===255?-1:x),edgeTable=Uint16Array.from({length:256},(_,c)=>triRow(c).filter(x=>x>=0).reduce((m,e)=>m|(1<<e),0)),sha=async bytes=>[...new Uint8Array(await crypto.subtle.digest("SHA-256",bytes))].map(x=>x.toString(16).padStart(2,"0")).join("");const field=(x,y,z)=>{const dx=(x/(N-1)-.5)*2,dy=(y/(N-1)-.5)*2,dz=(z/(N-1)-.5)*2;return .62-Math.sqrt(dx*dx+dy*dy+dz*dz)+.035*Math.sin(x*1.7)*Math.cos(z*1.3)},gradient=p=>{const h=1e-3;return[[h,0,0],[0,h,0],[0,0,h]].map(d=>(field(p[0]+d[0],p[1]+d[1],p[2]+d[2])-field(p[0]-d[0],p[1]-d[1],p[2]-d[2]))/(2*h))},lerp=(a,b,va,vb)=>{const denominator=vb-va,t=Math.abs(denominator)<1e-12?.5:Math.max(0,Math.min(1,(iso-va)/denominator));return{t,position:a.map((v,i)=>v+(b[i]-v)*t)}};function expectedMask(c){let mask=0;for(let e=0;e<12;e++){const[a,b]=edgeCorners[e];if(Boolean(c&(1<<a))!==Boolean(c&(1<<b)))mask|=1<<e}return mask}function faceDecision(values,face){const f=face.corners.map(i=>values[i]-iso),q=f[0]*f[2]-f[1]*f[3],pairing=q>=0?"01|23":"03|12",pairs=q>=0?[[face.edges[0],face.edges[1]],[face.edges[2],face.edges[3]]]:[[face.edges[0],face.edges[3]],[face.edges[1],face.edges[2]]];return{face:face.name,q:+q.toFixed(9),pairing,pairs}}function tableFixtures(){const failures=[],triangleCounts=[],ambiguousCases=[];for(let c=0;c<256;c++){const row=triRow(c),end=row.indexOf(-1),active=end<0?row:row.slice(0,end),tail=end<0?[]:row.slice(end);if(end<0||tail.some(x=>x!==-1))failures.push("sentinel:"+c);if(active.length%3||active.length>15||active.some(x=>x<0||x>11))failures.push("row:"+c);const mask=active.reduce((m,e)=>m|(1<<e),0);if(mask!==expectedMask(c)||edgeTable[c]!==mask)failures.push("edge-mask:"+c);if(edgeTable[c]!==edgeTable[255-c])failures.push("complement-mask:"+c);triangleCounts.push(active.length/3);const values=Array.from({length:8},(_,i)=>c&(1<<i)?1:-1);if(cubeFaces.some(face=>face.corners.filter(i=>values[i]>=0).length===2&&face.corners.every((v,i)=>Math.sign(values[v])!==Math.sign(values[face.corners[(i+1)%4]]))))ambiguousCases.push(c)}const a=faceDecision([2,-1,2,-1,-1,-1,-1,-1],cubeFaces[0]),b=faceDecision([.5,-2,.5,-2,-1,-1,-1,-1],cubeFaces[0]);if(JSON.stringify(triRow(1).slice(0,3))!==JSON.stringify([0,8,3]))failures.push("case-1");if(a.pairing===b.pairing)failures.push("decider-branch");return{casesChecked:256,failures,pass:failures.length===0,triangleRange:[Math.min(...triangleCounts),Math.max(...triangleCounts)],ambiguousCaseCount:new Set(ambiguousCases).size,deciderFixture:{positiveQ:a,negativeQ:b}}}function pointKey(p){return p.join(",")}function globalEdgeKey(x,y,z,e){const[a,b]=edgeCorners[e],p=corner[a].map((v,i)=>v+[x,y,z][i]),q=corner[b].map((v,i)=>v+[x,y,z][i]),pa=pointKey(p),qa=pointKey(q);return pa<qa?pa+"|"+qa:qa+"|"+pa}function edgeVertex(data,x,y,z,e,points,values){const key=globalEdgeKey(x,y,z,e);if(data.vertexByEdge.has(key))return data.vertexByEdge.get(key);const[a,b]=edgeCorners[e],hit=lerp(points[a],points[b],values[a],values[b]),id=data.vertices.length;data.vertices.push(hit.position);data.vertexSources.push({key,cell:[x,y,z],edge:e,t:+hit.t.toFixed(9)});data.vertexByEdge.set(key,id);return id}function orientFace(face,vertices){const[a,b,c]=face.map(i=>vertices[i]),u=b.map((v,i)=>v-a[i]),v=c.map((q,i)=>q-a[i]),normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],center=a.map((n,i)=>(n+b[i]+c[i])/3),g=gradient(center),dot=normal.reduce((s,n,i)=>s+n*g[i],0);if(dot<0)[face[1],face[2]]=[face[2],face[1]];return dot===0?0:1}function addTableTriangles(data,x,y,z,c,points,values){const row=triRow(c);for(let i=0;row[i]>=0;i+=3){const face=[edgeVertex(data,x,y,z,row[i],points,values),edgeVertex(data,x,y,z,row[i+1],points,values),edgeVertex(data,x,y,z,row[i+2],points,values)];orientFace(face,data.vertices);data.faces.push(face)}}function addDecidedTriangles(data,x,y,z,points,values,decisions){const active=new Set;for(let e=0;e<12;e++)if((values[edgeCorners[e][0]]>=iso)!==(values[edgeCorners[e][1]]>=iso))active.add(e);const adjacency=new Map([...active].map(e=>[e,[]])),decisionByFace=new Map(decisions.map(d=>[d.face,d]));for(const face of cubeFaces){const hits=face.edges.filter(e=>active.has(e));let pairs=[];if(hits.length===2)pairs=[hits];else if(hits.length===4)pairs=decisionByFace.get(face.name).pairs;for(const[a,b]of pairs){adjacency.get(a).push(b);adjacency.get(b).push(a)}}const unused=new Set(active),loops=[];while(unused.size){const start=Math.min(...unused),loop=[start];unused.delete(start);let previous=-1,current=start;for(let guard=0;guard<16;guard++){const options=(adjacency.get(current)||[]).filter(e=>e!==previous).sort((a,b)=>a-b),next=options[0];if(next==null)break;if(next===start){loops.push(loop);break}loop.push(next);unused.delete(next);previous=current;current=next}}let unresolved=0;for(const loop of loops){if(loop.length<3){unresolved++;continue}const ids=loop.map(e=>edgeVertex(data,x,y,z,e,points,values)),center=ids.reduce((sum,id)=>sum.map((n,i)=>n+data.vertices[id][i]),[0,0,0]).map(n=>n/ids.length),centerId=data.vertices.length;data.vertices.push(center);data.vertexSources.push({key:"cell:"+[x,y,z].join(",")+":loop:"+centerId,cell:[x,y,z],edge:null,t:null});for(let i=0;i<ids.length;i++){const face=[centerId,ids[i],ids[(i+1)%ids.length]];orientFace(face,data.vertices);data.faces.push(face)}}return{loops:loops.length,unresolved}}function executeMesh(){const data={vertices:[],faces:[],vertexSources:[],vertexByEdge:new Map,caseArchive:[],caseHistogram:{},ambiguityRecords:[],unresolvedInterior:0,tableCells:0,deciderCells:0};for(let z=0;z<N-1;z++)for(let y=0;y<N-1;y++)for(let x=0;x<N-1;x++){const points=corner.map(c=>[x+c[0],y+c[1],z+c[2]]),values=points.map(p=>field(...p)),cubeCase=values.reduce((n,v,i)=>n|((v>=iso?1:0)<<i),0);data.caseHistogram[cubeCase]=(data.caseHistogram[cubeCase]||0)+1;if(cubeCase===0||cubeCase===255)continue;const decisions=[];for(const face of cubeFaces){const signs=face.corners.map(i=>values[i]>=iso);if(signs[0]!==signs[1]&&signs[1]!==signs[2]&&signs[2]!==signs[3]&&signs[3]!==signs[0])decisions.push(faceDecision(values,face))}let policy="classic-table";if(decisions.length){policy="bilinear-face-decider";const decided=addDecidedTriangles(data,x,y,z,points,values,decisions);data.deciderCells++;data.unresolvedInterior+=decided.unresolved;if(decided.loops>1)data.unresolvedInterior++;data.ambiguityRecords.push({cell:[x,y,z],cubeCase,decisions,loops:decided.loops,unresolved:decided.unresolved})}else{addTableTriangles(data,x,y,z,cubeCase,points,values);data.tableCells++}if(data.caseArchive.length<32)data.caseArchive.push({cell:[x,y,z],cubeCase,edgeMask:"0x"+edgeTable[cubeCase].toString(16).padStart(3,"0"),tableTriangles:triRow(cubeCase).filter(x=>x>=0).length/3,policy,faceDecisions:decisions})}return data}function inspect(data){let degenerate=0,orientationFinite=true,windingAligned=0,windingOpposed=0,windingZero=0;const edges=new Map,faceEdges=[];for(const face of data.faces){const[a,b,c]=face.map(i=>data.vertices[i]),u=b.map((v,i)=>v-a[i]),v=c.map((q,i)=>q-a[i]),normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],area=Math.hypot(...normal),center=a.map((n,i)=>(n+b[i]+c[i])/3),dot=normal.reduce((s,n,i)=>s+n*gradient(center)[i],0);if(area<1e-8)degenerate++;if(!Number.isFinite(area)||!Number.isFinite(dot))orientationFinite=false;else if(Math.abs(dot)<1e-10)windingZero++;else if(dot>0)windingAligned++;else windingOpposed++;const keys=[];for(let i=0;i<3;i++){const key=[face[i],face[(i+1)%3]].sort((m,n)=>m-n).join(":");keys.push(key);const list=edges.get(key)||[];list.push(faceEdges.length);edges.set(key,list)}faceEdges.push(keys)}const adjacency=Array.from({length:data.faces.length},()=>new Set);for(const ids of edges.values())if(ids.length===2){adjacency[ids[0]].add(ids[1]);adjacency[ids[1]].add(ids[0])}const seen=new Set,componentSizes=[];for(let start=0;start<data.faces.length;start++)if(!seen.has(start)){let size=0,stack=[start];seen.add(start);while(stack.length){const id=stack.pop();size++;for(const next of adjacency[id])if(!seen.has(next)){seen.add(next);stack.push(next)}}componentSizes.push(size)}const incidence=[...edges.values()].map(x=>x.length),boundary=incidence.filter(x=>x===1).length,paired=incidence.filter(x=>x===2).length,nonManifold=incidence.filter(x=>x>2).length,finite=data.vertices.flat().every(Number.isFinite),bounded=data.vertices.flat().every(v=>v>=0&&v<=N-1),watertight=finite&&bounded&&degenerate===0&&orientationFinite&&windingOpposed===0&&boundary===0&&nonManifold===0&&data.unresolvedInterior===0;return{finite,bounded,degenerate,winding:{finite:orientationFinite,aligned:windingAligned,opposed:windingOpposed,nearZero:windingZero,policy:"cross-product normal points toward increasing analytic field"},edgeIncidence:{boundary,paired,nonManifold,total:edges.size},components:{count:componentSizes.length,triangleCounts:componentSizes.sort((a,b)=>b-a)},ambiguity:{faceDecisions:data.ambiguityRecords.length,unresolvedInterior:data.unresolvedInterior,claimBoundary:"face saddles use the bilinear decider; multi-loop interior connectivity stays unresolved"},watertightClaim:watertight}}function paint(data){const ctx=mesh.getContext("2d");ctx.clearRect(0,0,640,420);ctx.strokeStyle="#7ce5c3";ctx.lineWidth=1;for(const face of data.faces.slice(0,2200)){ctx.beginPath();face.forEach((id,k)=>{const[x,y,z]=data.vertices[id],px=100+x*42+z*14,py=360-y*32-z*10;k?ctx.lineTo(px,py):ctx.moveTo(px,py)});ctx.closePath();ctx.stroke()}}async function execute(){try{const fixtures=tableFixtures(),tableHash=await sha(tableBytes),edgeBytes=new Uint8Array(edgeTable.length*2);edgeTable.forEach((v,i)=>{edgeBytes[i*2]=v&255;edgeBytes[i*2+1]=v>>8});const edgeHash=await sha(edgeBytes),data=executeMesh(),diagnostics=inspect(data);paint(data);const obj=[...data.vertices.map(v=>"v "+v.map(n=>n.toFixed(6)).join(" ")),...data.faces.map(f=>"f "+f.map(i=>i+1).join(" "))].join("\n")+"\n",tableIdentity={name:"classic-marching-cubes-triangle-table",source:"three.js examples/jsm/objects/MarchingCubes.js",sourceCommit:tableCommit,sourceUrl:"https://github.com/mrdoob/three.js/blob/"+tableCommit+"/examples/jsm/objects/MarchingCubes.js",cornerOrder:corner,edgeOrder:edgeCorners,rows:256,entriesPerRow:16,triTableSha256:tableHash,expectedTriTableSha256:expectedTableHash,edgeTableSha256:edgeHash,expectedEdgeTableSha256:expectedEdgeHash,hashVerified:tableHash===expectedTableHash&&edgeHash===expectedEdgeHash},summary={algorithm:"reference marching cubes with pinned 256-case triangle table, shared-edge indexing, and bilinear face decider",grid:[N,N,N],iso,field:"generated perturbed sphere wholly inside the boundary",vertices:data.vertices.length,triangles:data.faces.length,tableCells:data.tableCells,deciderCells:data.deciderCells,tableIdentity,tableFixtures:fixtures,caseHistogram:data.caseHistogram,caseArchive:data.caseArchive,ambiguityPolicy:{faces:"bilinear asymptotic decider q=f0*f2-f1*f3 with shared-face values",interior:"multi-loop cells are reported unresolved and prevent watertightness claims",records:data.ambiguityRecords.slice(0,24)},vertexIndexing:{mode:"global lattice-edge key",edgeVertices:data.vertexByEdge.size,cellCenterVertices:data.vertices.length-data.vertexByEdge.size,sourceRecords:data.vertexSources.length},diagnostics},objBlob=new Blob([obj],{type:"text/plain"}),jsonBlob=new Blob([JSON.stringify(summary,null,2)],{type:"application/json"});objExport.href=URL.createObjectURL(objBlob);jsonExport.href=URL.createObjectURL(jsonBlob);summary.exports={obj:{bytes:objBlob.size,sha256:await sha(await objBlob.arrayBuffer()),vertices:(obj.match(/^v /gm)||[]).length,faces:(obj.match(/^f /gm)||[]).length},json:{bytes:jsonBlob.size,sha256:await sha(await jsonBlob.arrayBuffer())}};const pass=fixtures.pass&&tableIdentity.hashVerified&&diagnostics.finite&&diagnostics.bounded&&!diagnostics.degenerate&&diagnostics.winding.finite&&!diagnostics.winding.opposed&&!diagnostics.edgeIncidence.nonManifold&&diagnostics.components.count>0&&summary.triangles>0&&summary.exports.obj.vertices===summary.vertices&&summary.exports.obj.faces===summary.triangles;receipt.dataset.execution=JSON.stringify(summary);receipt.value=(pass?"PASS: ":"FAIL: ")+JSON.stringify(summary,null,2)}catch(error){receipt.dataset.execution=JSON.stringify({unexpectedError:error.name+": "+error.message});receipt.value="FAIL: unexpected "+error.message}}generate.onclick=()=>void execute();void execute()</script></html>

Validate topology before choosing a material

Reject non-finite coordinates, out-of-bounds indices, degenerate triangles, inconsistent winding where detectable, and positions outside declared bounds. Count connected components and edge incidence: a closed orientable manifold generally expects each undirected edge twice, while boundaries or non-manifold junctions produce other counts.

Do not report watertight based on a visually opaque render. Record boundary edges, non-manifold edges, unresolved ambiguities, component sizes, normal validity, and whether the field is expected to intersect the outer grid boundary. In this frozen generated edition, one component, zero boundary edges, zero non-manifold edges, zero degenerate triangles, and consistently gradient-aligned winding earn a result for this fixture only.

Start with marching squares metaballs to understand sign cases and interpolation in two dimensions. Marching cubes adds connectivity and topology concerns that a collection of attractive triangles can easily conceal.

Alternating-sign faces execute a bilinear asymptotic decision using the shared four corner values. Positive and negative saddle fixtures must choose different pairings. Multi-loop interior choices remain counted as unresolved and would block a watertight claim instead of being hidden by the face rule.

Render accessibly with a static fallback

A browser 3D geometry view should offer keyboard controls, a pause action, reduced-motion behavior, focus visibility, and a static image plus textual mesh summary. Continuous rotation is presentation, not proof; default to still or respect reduced motion immediately.

Feature-detect the WebGPU working draft path and retain a Canvas, WebGL, SVG, or raster fallback whose label names what executed. Use WebGPU generative art for renderer architecture without mixing extraction correctness into GPU availability.

Marching cubes art direction enters through field composition, isovalue, crop, camera, material, and light after the mesh receipt passes. Preserve a diagnostic wireframe so surface defects remain visible beneath a polished shader.

The Canvas view projects only a bounded subset of generated triangles for legibility, while OBJ contains the complete face list. Rendering and export therefore share geometry without pretending the preview is a full 3D engine. Reduced motion leaves that projection completely static and inspectable.

Indexed topology receiptWelded lattice-edge vertices feed finite, bounds, degeneracy, winding, incidence, component, and export checks before any topology claim.finite + boundsdegenerate = 0winding alignededge incidence 2components + hashes
Vertex identity
one index for each canonical lattice-edge key.
Geometry checks
finite bounds, nonzero area, and analytic-gradient winding.
Topology checks
boundary, paired, non-manifold edges, and face components.
Claim boundary
watertight only for a run with no boundary, defect, or unresolved choice.
Exports
indexed OBJ and full diagnostic JSON with SHA-256.
Figure 3: The bounded generated edition earns its own result; the validator does not promise that every field is watertight.

Export an OBJ and its topology receipt

Write vertices and one-based face indices in deterministic order, with explicit numeric precision and line endings. Hash the exact OBJ bytes, then save field parameters, grid, isovalue, case-table identity, ambiguity policy, validator output, camera, and preview digest beside it.

Compare mesh delivery with Gaussian splats and NeRF browser scenes only after naming the product requirement. Marching cubes produces explicit surface geometry; neural scene representations carry different rendering, editing, and asset contracts.

Generate one marching cubes sculpture and inspect finite vertices, triangle degeneracy, boundary edges, components, and ambiguity before admiring the material. Reproducible browser sculpture is the union of field, extractor, validator, export, and accessible presentation—not a rotating canvas alone.

OBJ and diagnostic JSON receive SHA-256 digests and byte counts. The JSON preserves table identity, corner and edge order, exhaustive fixture result, grid, isovalue, histogram, decisions, welded indexing, topology diagnostics, and the exact claim boundary beside the sculpture.

Test every table row and one closed field

A reference extractor needs more than a few attractive fields. This artifact enumerates all 256 case indices, verifies sentinel placement, legal edge numbers, triangle grouping, derived edge masks, complement masks, and a hand-checkable single-corner row. It does not claim exhaustive rotation equivalence or every numerical boundary condition.

The Node test then extracts a separate closed sphere, reuses canonical lattice-edge keys, requires every undirected edge to occur twice, and verifies one connected face component. For ambiguous face configurations, two frozen value sets preserve the chosen connectivity rather than only the case number; the same sign pattern selects opposite pairings when the bilinear saddle changes sign.

Marching cubes tables are easy to copy and hard to verify after changing corner order. Generate a visual atlas of accepted cases, hash the table, and require the reference tests before GPU or worker acceleration so a topology regression cannot hide inside a smoother sculpture preset.

This generated field stays inside the sampled boundary and currently earns a fixture-specific watertight result: every indexed mesh edge is paired, one connected component is measured, and no degeneracy, non-manifold edge, unresolved choice, or opposed winding remains. The receipt does not generalize that result to other fields.

Art-direct form without editing the extracted truth

Shape the sculpture by changing explicit scalar primitives, transforms, blend operations, and isovalue, then rerun extraction and validation. Avoid grabbing individual output vertices as the primary editing workflow because those changes break the link between field, case evidence, normals, and deterministic OBJ export.

Use camera crop, orthographic or perspective projection, material roughness, light direction, background, and static pose to establish the edition. Keep one unlit wireframe and one slice view beside the polished rendering, allowing the viewer to see cavities, thin features, and disconnected components that material shading might conceal.

Marching cubes can produce exquisite browser 3D geometry precisely because an implicit field supports continuous composition. Preserve that source as the artwork's editable score, while the mesh remains a validated performance and distribution artifact derived from it.

The scheduled review freezes the scalar function, table commit and hashes, corner order, ambiguity formula, mesh diagnostics, OBJ bytes, and JSON bytes. A later browser sculpture edition can alter artistic parameters only after the exhaustive reference and topology tests pass again.