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.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
@@ -0,0 +1,149 @@
/** 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<height;y++) for(let x=0;x<width;x++) {
const n=Math.round(255*THREE.MathUtils.clamp(sample(x,y),0,1)), i=4*(y*width+x);
data[i]=data[i+1]=data[i+2]=n; data[i+3]=255;
}
const texture=new THREE.DataTexture(data,width,height);
texture.minFilter=texture.magFilter=THREE.LinearFilter;
texture.wrapS=texture.wrapT=THREE.ClampToEdgeWrapping;
texture.generateMipmaps=false;
return texture;
}
/** GL normalized LinearFilter sampling at explicit LOD zero; Uint8 -> [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<p.count;i++) p.setZ(i,(depthAt(texture,uv.getX(i),uv.getY(i))-depthBias)*depthScale);
return {texture,segments,heights,geometry,matrix:matrix.clone(),inverse:matrix.clone().invert(),minZ:Math.min(...heights),maxZ:Math.max(...heights)};
}
export function localRay(surface,origin,direction) {
return {origin:origin.clone().applyMatrix4(surface.inverse),direction:direction.clone().applyMatrix3(new THREE.Matrix3().setFromMatrix4(surface.inverse))};
}
export function triangleOracle(surface,origin,direction) {
const local=localRay(surface,origin,direction),ray=new THREE.Ray(local.origin,local.direction),p=surface.geometry.attributes.position,indices=surface.geometry.index;
const a=v3(),b=v3(),c=v3(),hit=v3();let result=null,minT=Infinity;
for(let i=0;i<indices.count;i+=3) {
a.fromBufferAttribute(p,indices.getX(i));b.fromBufferAttribute(p,indices.getX(i+1));c.fromBufferAttribute(p,indices.getX(i+2));
if(!ray.intersectTriangle(a,b,c,true,hit))continue;
const t=hit.clone().sub(local.origin).dot(local.direction)/local.direction.lengthSq();
if(t>=-EPS&&t<minT) {minT=t;result={t,local:hit.clone(),point:hit.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(hit.x+.5,hit.y+.5),triangle:i/3};}
}
return result;
}
/** Returns nearest actual triangle hit, including multiple height crossings. */
export function tracePhotoDDA(surface,origin,direction) {
const {origin:o,direction:d}=localRay(surface,origin,direction),n=surface.segments,h=surface.heights;
let enter=0,leave=Infinity,cells=0;
for(const [axis,lo,hi] of [['x',-.5,.5],['y',-.5,.5],['z',surface.minZ,surface.maxZ]]) {
if(Math.abs(d[axis])<1e-14) {if(o[axis]<lo-EPS||o[axis]>hi+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(leave<enter-EPS)return {hit:null,cells};
const at=o.clone().addScaledVector(d,enter),grid=(x)=>THREE.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<MAX_DDA_CELLS;step++) {
cells++;
const end=Math.min(tx,ty,leave),k=iy*(n+1)+ix,z00=h[k],z10=h[k+1],z01=h[k+n+1],z11=h[k+n+2];
let best=Infinity;
// The two exact PlaneGeometry triangles share the lower-left/upper-right diagonal.
for(const lower of [true,false]) {
const ax=lower?z10-z00:z11-z01,ay=lower?z11-z10:z01-z00;
const fx0=(o.x+.5)*n-ix,fy0=(o.y+.5)*n-iy;
const denom=d.z-n*(ax*d.x+ay*d.y);
if(denom>=-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(candidate<t-EPS||candidate>end+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.z<backZ-EPS)continue;
const normal=v3();normal[c.axis]=c.axis==='z'?-1:Math.sign(c.value);
return {point,normal,t:c.t,face:c.axis==='z'?'rear':`${c.axis}${Math.sign(c.value)>0?'+':'-'}`};
}
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};
}