import assert from 'node:assert/strict'; import { test } from 'node:test'; import { readFile } from 'node:fs/promises'; import * as THREE from 'three'; import ts from 'typescript'; import { PHOTO_SEGMENTS,MAX_DDA_CELLS,v3,makeDepthTexture,depthAt,photoSurface,tracePhotoDDA,triangleOracle,snell,exitGlass,traceGlass,stockR169PhotoHit,meshEntry } from './support/mesh-refraction-reference.mjs'; async function loadTS(relative) { async function compile(file) { let source=await readFile(file,'utf8'); source=source.replace(/from ['"]three['"]/g,`from '${new URL('../node_modules/three/build/three.module.js',import.meta.url)}'`); for(const m of [...source.matchAll(/from ['"](\.[^'"]+)['"]/g)])source=source.replace(m[0],`from '${await compile(new URL(m[1].endsWith('.ts')?m[1]:m[1]+'.ts',file))}'`); return `data:text/javascript;base64,${Buffer.from(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ES2020,target:ts.ScriptTarget.ES2020}}).outputText).toString('base64')}`; } return import(await compile(new URL(relative,import.meta.url))); } const {createPatternedGlassGeometry}=await loadTS('../src/rendering/PatternedGlassGeometry.ts'); const {SceneZoomController}=await loadTS('../src/input/SceneZoomController.ts'); const {DepthProcessor}=await loadTS('../src/depth/DepthProcessor.ts'); const near=(a,b,epsilon=2e-7,label='numbers agree')=>assert.ok(Math.abs(a-b)<=epsilon,`${label}: ${a} vs ${b}`); const vectorNear=(a,b,epsilon=2e-7,label='points agree')=>assert.ok(a.distanceTo(b)<=epsilon,`${label}: distance ${a.distanceTo(b)}`); const flatTexture=makeDepthTexture(2,2,()=>1); const texture=makeDepthTexture(19,13,(x,y)=>.5+.22*Math.sin(x*1.8)+.25*Math.cos(y*1.23+x*.4)); const flat=(z,options={})=>photoSurface(flatTexture,{depthScale:0,matrix:new THREE.Matrix4().makeTranslation(0,0,z),...options}); const shape={width:1,height:1.32,grid:20,curvature:1}; const slab={width:1,height:1.32,backZ:-.01225}; function compare(surface,o,d,label='DDA vs independent triangle oracle') { const {hit,cells}=tracePhotoDDA(surface,o,d),oracle=triangleOracle(surface,o,d); assert.ok(cells<=MAX_DDA_CELLS,'bounded crossed cells'); assert.equal(Boolean(hit),Boolean(oracle),label); if(hit) {vectorNear(hit.point,oracle.point,3e-7,label);near(hit.t,oracle.t,4e-7,label);} return {hit,cells}; } // Regressions are deliberately independent of the production fragment math. The // separate test-mesh-refraction-material.mjs checks that the shader is attached. test('depth samples 8-bit normalized linear texels at LOD zero before vertex displacement',()=>{ const t=makeDepthTexture(2,2,(x,y)=>(x+2*y)/3); near(depthAt(t,0,0),0);near(depthAt(t,1,1),1);near(depthAt(t,.5,.5),.5); near(depthAt(t,.25,.25),0);near(depthAt(t,.75,.25),1/3); const s=photoSurface(t,{depthScale:.25,depthBias:.2}); assert.equal(s.heights.length,129*129);near(s.heights[0],-.05);near(s.heights.at(-1),.2); assert.equal(s.texture.type,THREE.UnsignedByteType); const actual=DepthProcessor.fromNormalizedDepth(new Uint8Array([0,85,170,255]),2,2); assert.equal(actual.flipY,true);near(depthAt(actual,.25,.75),0);near(depthAt(actual,.75,.25),1); compare(photoSurface(actual),v3(.1,.2,1),v3(0,0,-1),'production normalized texture'); }); test('photo follows actual lower-left to upper-right mesh triangles, not continuous bilinear depth',()=>{ const t=makeDepthTexture(2,2,(x,y)=>x===1&&y===1?1:0),s=photoSurface(t,{segments:1,depthScale:1}); const {hit}=compare(s,v3(-.25,0,2),v3(0,0,-1)); near(hit.local.z,.25);assert.ok(Math.abs(hit.local.z-depthAt(t,.25,.5))>.1); const indices=s.geometry.index.array;assert.deepEqual(Array.from(indices),[0,2,1,2,3,1]); }); test('128×128 DDA agrees with independent triangle oracle for deterministic rays and high-frequency depth',()=>{ const s=photoSurface(texture);let seed=0x5eed,cases=0; const random=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/2**32;}; for(let i=0;i<72;i++) { const o=v3((random()-.5)*1.8,(random()-.5)*1.8,.4+random()),target=v3((random()-.5)*1.2,(random()-.5)*1.2,random()*.2); compare(s,o,target.sub(o).normalize(),`random ray ${i}`);cases++; } assert.equal(cases,72);assert.equal(s.segments,PHOTO_SEGMENTS); }); test('DDA handles zero ray XY, exact diagonal, cell corners, reverse directions and outside misses',()=>{ const s=photoSurface(texture); for(const [o,d] of [ [v3(0,0,1),v3(0,0,-1)],[v3(.5,.5,1),v3(0,0,-1)], [v3(-1,-1,1),v3(1,1,-1)],[v3(1,1,1),v3(-1,-1,-1)], [v3(0,.25,1),v3(0,-.3,-1)],[v3(.5,0,1),v3(-.3,0,-1)], [v3(.6,0,1),v3(0,0,-1)],[v3(0,0,1),v3(0,0,1)], [v3(-1,0,.1),v3(1,0,0)], ])compare(s,o,d.clone().normalize()); }); test('photo FrontSide rejects back-facing crossings while preserving the next front-facing triangle',()=>{ const s=photoSurface(texture); const fromBelow=compare(flat(0),v3(0,0,-1),v3(0,0,1)); assert.equal(fromBelow.hit,null); // These rays can cross a displaced heightfield more than once; the oracle // independently chooses the nearest intersection of an actual front face. for(const direction of [v3(1,0,-.03),v3(-1,0,-.03),v3(0,1,-.03),v3(0,-1,-.03)]) { const origin=direction.clone().multiplyScalar(-.6);origin.z=.13; compare(s,origin,direction.normalize()); } }); test('DDA crosses the whole maximal grid within its fixed 257-cell loop bound',()=>{ const s=photoSurface(makeDepthTexture(2,2,(x,y)=>x*y)); // Almost horizontal rays exercise many XY cells before finding the first hit. let most=0; for(let i=0;i<12;i++) { const {cells}=compare(s,v3(-.6,-.45+i*.02,.249),v3(1,.85,-.0001).normalize());most=Math.max(most,cells); } assert.ok(most>128,`long traversal covered ${most} cells`); assert.ok(most<=257); assert.throws(()=>photoSurface(texture,{segments:129}),/1\.\.128/); }); test('photo object pan, nonuniform scale, rotation and depth bias transform correctly',()=>{ const matrix=new THREE.Matrix4().compose(v3(.18,-.23,-.3),new THREE.Quaternion().setFromEuler(new THREE.Euler(.16,-.22,.11)),v3(1.8,.76,1.35)); const s=photoSurface(texture,{matrix,depthScale:.25,depthBias:.2}); for(let i=0;i<16;i++) { const localTarget=v3(-.4+i*.05,Math.sin(i)*.3,.1),worldTarget=localTarget.applyMatrix4(matrix),o=v3(.2,-.1,1.2); compare(s,o,worldTarget.sub(o).normalize(),`transformed photo ${i}`); } }); test('Snell handles air→glass and glass→air independently, including total internal reflection',()=>{ const i=v3(Math.sin(.6),0,-Math.cos(.6)),inside=snell(i,v3(0,0,1),1/1.5); near(inside.x,Math.sin(.6)/1.5,1e-12);vectorNear(snell(inside,v3(0,0,1),1.5),i,1e-12); assert.equal(snell(v3(.9,0,-Math.sqrt(1-.81)),v3(0,0,1),1.5),null); vectorNear(snell(i,v3(.3,0,1),1),i,1e-12); }); test('flat normal straight-on is undistorted, and oblique slab yields analytic lateral shift',()=>{ const s=flat(-.3),entry=v3(.08,-.1,0),normal=v3(0,0,1); const straight=traceGlass({entry,normal,incident:v3(0,0,-1),shape:slab,surface:s}); vectorNear(straight.hit.point,v3(.08,-.1,-.3),1e-12); const theta=.5,incident=v3(Math.sin(theta),0,-Math.cos(theta)),result=traceGlass({entry,normal,incident,shape:slab,surface:s}); assert.equal(result.exit.face,'rear');vectorNear(result.outgoing,incident,1e-12); const insideAngle=Math.asin(Math.sin(theta)/1.5),expectedX=entry.x+(-slab.backZ)*Math.tan(insideAngle)+(.3+slab.backZ)*Math.tan(theta); near(result.hit.point.x,expectedX,1e-12); const noGlass=entry.x+.3*Math.tan(theta); near(result.hit.point.x-noGlass,(-slab.backZ)*(Math.tan(insideAngle)-Math.tan(theta)),1e-12); }); test('finite side exits use their own face normal, including side total internal reflection',()=>{ const exit=exitGlass(v3(.49,0,0),v3(.9,0,-.435889894).normalize(),slab); assert.equal(exit.face,'x+');near(exit.point.x,.5,1e-12);assert.ok(exit.point.z>slab.backZ); const outgoing=snell(v3(.9,0,-.435889894).normalize(),exit.normal.clone().negate(),1.5); assert.ok(outgoing);assert.ok(outgoing.x>0); const tir=traceGlass({entry:v3(.4999,0,0),normal:v3(0,0,1),incident:v3(.4,0,-1).normalize(),shape:slab,surface:flat(-.3)}); assert.equal(tir.exit.face,'x+');assert.equal(tir.status,'tir'); const rear=exitGlass(v3(.1,0,0),v3(0,0,-1),slab);assert.equal(rear.face,'rear');near(rear.point.z,slab.backZ); for(const [entry,ray,face,normal] of [ [v3(-.49,0,0),v3(-.9,0,-.4359),'x-',v3(-1,0,0)], [v3(0,.65,0),v3(0,.9,-.4359),'y+',v3(0,1,0)], [v3(0,-.65,0),v3(0,-.9,-.4359),'y-',v3(0,-1,0)], ]) { const side=exitGlass(entry,ray.normalize(),slab);assert.equal(side.face,face);vectorNear(side.normal,normal,1e-12); const leaving=snell(ray,normal.clone().negate(),1.5);assert.ok(leaving);assert.ok(leaving.dot(normal)>0); } }); test('IOR 1 stays on the unbent incident ray through actual patterned mesh at every gap',()=>{ const built=createPatternedGlassGeometry(shape),camera=v3(.2,-.1,3); const entry=meshEntry(built.geometry,camera,v3(.123,.181,0));assert.ok(entry); for(const gap of [.04,.1296,.1576]) { const s=photoSurface(texture,{matrix:new THREE.Matrix4().makeTranslation(0,0,-gap-.25)}); const glass=traceGlass({...entry,shape:{...shape,backZ:built.backZ},surface:s,ior:1}),direct=tracePhotoDDA(s,camera,entry.incident); assert.equal(glass.status,'hit');vectorNear(glass.hit.point,direct.hit.point,1e-10); } built.geometry.dispose(); }); test('real interpolated mesh normals produce opposite signed convex/concave offsets and visible default-gap refraction beyond stock r169',()=>{ const camera=v3(0,0,3),results=[]; for(const curvature of [1,-1]) { const built=createPatternedGlassGeometry({...shape,curvature}),entry=meshEntry(built.geometry,camera,v3(.011,.014,0)); assert.ok(entry);near(entry.bary.x+entry.bary.y+entry.bary.z,1,1e-12); assert.ok(Math.min(entry.bary.x,entry.bary.y,entry.bary.z)>0,'point lies inside actual triangle, not an analytic normal sample'); const offsets=[]; for(const gap of [.04,.1296,.1576]) { const surface=flat(-gap),args={...entry,shape:{...shape,backZ:built.backZ},surface}; const result=traceGlass(args),direct=tracePhotoDDA(surface,camera,entry.incident).hit; const stock=stockR169PhotoHit({...args,camera,thickness:entry.entry.z-built.backZ}); assert.equal(result.status,'hit');assert.ok(stock); const shift=result.hit.point.clone().sub(direct.point),stockShift=stock.point.clone().sub(direct.point); assert.ok(shift.length()>stockShift.length()*2,`gap ${gap}: actual ${shift.length()} vs stock ${stockShift.length()}`); assert.ok(shift.length()>.001,`visible scene-unit displacement at gap ${gap}`); offsets.push(shift); compare(surface,result.exit.point,result.outgoing,'complete ray vs photo triangle oracle'); if(gap===.1296)results.push({curvature,shift:shift.toArray(),stockShift:stockShift.toArray(),ratio:shift.length()/stockShift.length()}); } assert.ok(offsets[2].length()>offsets[1].length()&&offsets[1].length()>offsets[0].length(),'gap scales the outgoing path'); built.geometry.dispose(); } assert.ok(v3(...results[0].shift).dot(v3(...results[1].shift))<0,'curvature flips offset direction'); console.log('Default-gap actual mesh offsets vs stock r169:',JSON.stringify(results)); }); test('depth .25 is intersected as geometry at all supported gap extrema and default',()=>{ const built=createPatternedGlassGeometry(shape),camera=v3(.15,-.08,3); for(const gap of [.04,.1296,.1576]) { const s=photoSurface(texture,{depthScale:.25,matrix:new THREE.Matrix4().makeTranslation(0,0,-gap-.25)}); for(const target of [v3(.011,.014,0),v3(-.173,.217,0),v3(.23,-.13,0)]) { const e=meshEntry(built.geometry,camera,target),r=traceGlass({...e,shape:{...shape,backZ:built.backZ},surface:s}); assert.equal(r.status,'hit');compare(s,r.exit.point,r.outgoing); assert.ok(r.hit.point.z<=-gap+1e-10); } } built.geometry.dispose(); }); test('camera anchored zoom preserves the world ray and mesh/photo hit at the anchor',()=>{ const camera=new THREE.PerspectiveCamera(35,1.4,.01,100);camera.position.set(.1,-.05,3);camera.lookAt(0,0,0);camera.updateMatrixWorld(); const anchor=new THREE.Vector2(.33,-.24),raycaster=new THREE.Raycaster();raycaster.setFromCamera(anchor,camera); const original=raycaster.ray.clone(),zoom=new SceneZoomController(camera);zoom.setZoom(2.3,anchor);raycaster.setFromCamera(anchor,camera); vectorNear(raycaster.ray.direction,original.direction,1e-12); const built=createPatternedGlassGeometry(shape),s=photoSurface(texture,{matrix:new THREE.Matrix4().makeTranslation(0,0,-.42)}); const e1=meshEntry(built.geometry,camera.position,original.at(2,v3())),e2=meshEntry(built.geometry,camera.position,raycaster.ray.at(2,v3())); assert.ok(e1&&e2);vectorNear(e1.entry,e2.entry,1e-12); const r1=traceGlass({...e1,shape:{...shape,backZ:built.backZ},surface:s}),r2=traceGlass({...e2,shape:{...shape,backZ:built.backZ},surface:s}); vectorNear(r1.hit.point,r2.hit.point,1e-12);built.geometry.dispose(); }); test('shader source contract (not execution): mesh triangle traversal and both Snell transitions stay connected',async()=>{ const {meshRefractionShader:shader}=await loadTS('../src/rendering/MeshRefraction.ts'); assert.match(shader,/refract\(\s*-viewDirection\s*,\s*normalize\(normal\)\s*,\s*1\.\s*\/\s*ior\s*\)/,'air to glass uses eta=1/ior'); assert.match(shader,/refract\(\s*internalRay\s*,\s*inwardNormal\s*,\s*ior\s*\)/,'glass to air uses eta=ior'); assert.match(shader,/if\(det\s*<=\s*1e-9\)return/,'photo triangle winding culls its back face'); assert.match(shader,/iteration\s*<\s*257/,'bounded grid traversal'); assert.match(shader,/bTriangle\(origin,ray,ul,ll,ur\)/,'upper-left triangle shares lower-left to upper-right diagonal'); assert.match(shader,/bTriangle\(origin,ray,ll,lr,ur\)/,'lower-right triangle shares the same diagonal'); assert.match(shader,/textureLod\(\s*uBDepthMap\s*,\s*uv\s*,\s*0\.\s*\)/,'same base LOD as vertex depth displacement'); assert.match(shader,/uBPhotoWorldToLocal\s*\*\s*vec4\(worldRay,0\.\)/,'direction transforms without translation'); assert.match(shader,/uBPhotoWorldToLocal\s*\*\s*vec4\(worldOrigin,1\.\)/,'origin transforms with translation'); assert.match(shader,/dot\(outgoing,outgoing\)\s*<\s*1e-8/,'TIR never becomes a fabricated photo intersection'); });