Files
behind_glass/scripts/test-mesh-refraction.mjs
T
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

223 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});