/** Test-only, CPU numerical reference. Never imported by the renderer. * Photo intersections use analytic per-triangle height planes and a bounded XY DDA; * the independent oracle uses Three's indexed PlaneGeometry and Ray.intersectTriangle. */ import * as THREE from 'three'; const EPS = 1e-10; export const PHOTO_SEGMENTS = 128; export const MAX_DDA_CELLS = 257; export const v3 = (x=0,y=0,z=0) => new THREE.Vector3(x,y,z); export function makeDepthTexture(width, height, sample) { const data = new Uint8Array(width*height*4); for(let y=0;y [0,1]. */ export function depthAt(texture,u,v) { const {data,width,height}=texture.image; if(texture.flipY)v=1-v; const x=u*width-.5,y=v*height-.5,x0=Math.floor(x),y0=Math.floor(y),fx=x-x0,fy=y-y0; const fetch=(i,j)=>data[4*(THREE.MathUtils.clamp(j,0,height-1)*width+THREE.MathUtils.clamp(i,0,width-1))]/255; return THREE.MathUtils.lerp(THREE.MathUtils.lerp(fetch(x0,y0),fetch(x0+1,y0),fx),THREE.MathUtils.lerp(fetch(x0,y0+1),fetch(x0+1,y0+1),fx),fy); } export function photoSurface(texture,{segments=128,depthScale=.25,depthBias=0,matrix=new THREE.Matrix4()}={}) { if(!Number.isInteger(segments)||segments<1||segments>128) throw new Error('Photo grid must be 1..128'); const heights=new Float64Array((segments+1)**2); for(let y=0;y<=segments;y++) for(let x=0;x<=segments;x++) heights[y*(segments+1)+x]=(depthAt(texture,x/segments,y/segments)-depthBias)*depthScale; const geometry=new THREE.PlaneGeometry(1,1,segments,segments),p=geometry.attributes.position,uv=geometry.attributes.uv; for(let i=0;i=-EPS&&thi+EPS)return {hit:null,cells};continue;} const a=(lo-o[axis])/d[axis],b=(hi-o[axis])/d[axis];enter=Math.max(enter,Math.min(a,b));leave=Math.min(leave,Math.max(a,b)); } if(leaveTHREE.MathUtils.clamp(Math.floor((x+.5)*n),0,n-1); let ix=grid(at.x),iy=grid(at.y),t=enter; const sx=Math.sign(d.x),sy=Math.sign(d.y); const next=(axis,cell,step)=>step===0?Infinity:((cell+(step>0?1:0))/n-.5-o[axis])/d[axis]; let tx=next('x',ix,sx),ty=next('y',iy,sy); for(let step=0;step=-1e-14)continue; // FrontSide: approach the displaced photo front. const candidate=(z00+ax*fx0+ay*fy0-o.z)/denom; const fx=fx0+n*d.x*candidate,fy=fy0+n*d.y*candidate; if(candidateend+EPS||candidate<0||fx<-EPS||fy<-EPS||fx>1+EPS||fy>1+EPS)continue; if(lower?fy>fx+EPS:fx>fy+EPS)continue; best=Math.min(best,candidate); } if(Number.isFinite(best)) {const local=o.clone().addScaledVector(d,best);return {hit:{t:best,local,point:local.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(local.x+.5,local.y+.5)},cells};} if(end>=leave-EPS)return {hit:null,cells}; const crossX=tx<=ty+EPS,crossY=ty<=tx+EPS;t=end; if(crossX) {ix+=sx;tx=next('x',ix,sx);}if(crossY) {iy+=sy;ty=next('y',iy,sy);} if(ix<0||ix>=n||iy<0||iy>=n)return {hit:null,cells}; } throw new Error(`DDA exceeded ${MAX_DDA_CELLS} cells`); } /** GLSL refract convention: normal opposes incident direction. */ export function snell(incident,normal,eta) { const i=incident.clone().normalize(),n=normal.clone().normalize(),dot=i.dot(n),k=1-eta*eta*(1-dot*dot); if(k<0)return null; return i.multiplyScalar(eta).addScaledVector(n,-eta*dot-Math.sqrt(k)).normalize(); } /** Planar rear and finite side exits in local glass coordinates. */ export function exitGlass(entry,inside,{width,height,backZ}) { const candidates=[]; for(const [axis,bounds] of [['x',[-width/2,width/2]],['y',[-height/2,height/2]],['z',[backZ]]]) { if(Math.abs(inside[axis])<1e-14)continue; for(const value of bounds) {const t=(value-entry[axis])/inside[axis];if(t>EPS)candidates.push({t,axis,value});} } candidates.sort((a,b)=>a.t-b.t); for(const c of candidates) { const point=entry.clone().addScaledVector(inside,c.t); if(Math.abs(point.x)>width/2+EPS||Math.abs(point.y)>height/2+EPS||point.z0?'+':'-'}`}; } return null; } export function traceGlass({entry,normal,incident,shape,ior=1.5,surface}) { const inside=snell(incident,normal,1/ior);if(!inside)return {status:'entry-tir'}; const exit=exitGlass(entry,inside,shape);if(!exit)return {status:'no-exit'}; const outgoing=snell(inside,exit.normal.clone().negate(),ior);if(!outgoing)return {status:'tir',inside,exit}; const result=tracePhotoDDA(surface,exit.point,outgoing); return {status:result.hit?'hit':'miss',inside,exit,outgoing,...result}; } /** Stock r169 only advances its refracted ray by material thickness, projects that * endpoint, and looks up the already camera-rendered scene at that screen point. */ export function stockR169PhotoHit({entry,normal,incident,camera,thickness,ior=1.5,surface}) { const inside=snell(incident,normal,1/ior); if(!inside)return null; const endpoint=entry.clone().addScaledVector(inside,thickness); return tracePhotoDDA(surface,camera,endpoint.sub(camera).normalize()).hit; } /** Actual mesh intersection and perspective-correct interpolated vertex normal. */ export function meshEntry(geometry,camera,target) { const material=new THREE.MeshBasicMaterial({side:THREE.FrontSide}),mesh=new THREE.Mesh(geometry,material); mesh.updateMatrixWorld(true); const incident=target.clone().sub(camera).normalize(),hit=new THREE.Raycaster(camera,incident).intersectObject(mesh,false)[0]; material.dispose(); if(!hit)return null; const p=geometry.attributes.position,n=geometry.attributes.normal,a=v3().fromBufferAttribute(p,hit.face.a),b=v3().fromBufferAttribute(p,hit.face.b),c=v3().fromBufferAttribute(p,hit.face.c); const bary=THREE.Triangle.getBarycoord(hit.point,a,b,c,v3()); const normal=v3().fromBufferAttribute(n,hit.face.a).multiplyScalar(bary.x).addScaledVector(v3().fromBufferAttribute(n,hit.face.b),bary.y).addScaledVector(v3().fromBufferAttribute(n,hit.face.c),bary.z).normalize(); return {entry:hit.point,normal,incident,bary,face:hit.face}; }