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
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import * as THREE from 'three';
import { loadTypescript } from './support/load-typescript.mjs';
import { loadFrameGeometry } from './support/load-frame-geometry.mjs';
const modulePath = new URL('../src/objects/BlenderHouseSurround.ts', import.meta.url);
const assetPath = new URL('../public/assets/window/grandma-house-surround.glb', import.meta.url);
const load = async () => { assert.ok(existsSync(modulePath), 'A bounded adapter must attach the authored room around the intact photo'); return loadTypescript(modulePath); };
const geometry = () => loadFrameGeometry(assetPath);
function vertices(root) { const result=[]; root.traverse(o=>{if(o.isMesh)result.push([...o.geometry.attributes.position.array]);}); return result; }
test('house surround attaches to the existing frame scene and has a bounded room fit', async () => {
const { houseViewScale } = await load();
assert.equal(houseViewScale('wood'), .82);
assert.equal(houseViewScale('booth'), 1);
});
test('authored house geometry preserves an open photo aperture, stable resize and slipper proportions', async () => {
const { prepareHouseAsset } = await load(), root=geometry(), slippers=root.getObjectByName('HouseSlippers');
slippers.geometry.computeBoundingBox(); const original=slippers.geometry.boundingBox.getSize(new THREE.Vector3());
const asset=prepareHouseAsset(root);
for(const [w,h] of [[1,1.32],[1,1],[3,1],[1,3],[.2,4],[4,.2]]) {
asset.resize(w,h,.6); root.updateMatrixWorld(true);
root.traverse(o=>{if(!o.isMesh)return;const p=o.geometry.attributes.position,n=o.geometry.attributes.normal;
for(let i=0;i<p.count;i++){assert.ok([p.getX(i),p.getY(i),p.getZ(i),n.getX(i),n.getY(i),n.getZ(i)].every(Number.isFinite));}
});
for(const x of [-.4,0,.4])for(const y of [-.4,0,.4]) {
const ray=new THREE.Raycaster(new THREE.Vector3(x*w,y*h,4),new THREE.Vector3(0,0,-1));
assert.equal(ray.intersectObject(root,true).length,0,'room geometry must not replace the intact-photo opening');
}
slippers.geometry.computeBoundingBox();const size=slippers.geometry.boundingBox.getSize(new THREE.Vector3()),s=Math.min(w,h);
for(let i=0;i<3;i++)assert.ok(Math.abs(size.getComponent(i)-original.getComponent(i)*s)<1e-5,'slippers keep physical proportions');
assert.equal(root.position.z,.6);
}
asset.resize(1,1.32,.6);const before=vertices(root);asset.resize(3,1,.6);asset.resize(1,1.32,.6);assert.deepEqual(vertices(root),before);asset.dispose();
});
test('house loader keeps single ownership through repeat loads, hidden theme, failed retry and late teardown', async () => {
const { BlenderHouseSurround }=await load(),scene=new THREE.Scene(),pending=[],states=[];
const room=new BlenderHouseSurround(scene,state=>states.push(state),signal=>new Promise((resolve,reject)=>pending.push({resolve,reject,signal})));
room.resize(1,2,.6);const first=room.load();assert.equal(first,room.load());pending[0].reject(new Error('offline'));await first;assert.equal(states.at(-1),'error');assert.equal(scene.children.length,0);
const retry=room.load();room.apply('booth');room.resize(2,1,.6);pending[1].resolve(geometry());await retry;assert.equal(states.at(-1),'ready');assert.equal(scene.children[0].visible,false);room.apply('wood');assert.equal(scene.children[0].visible,true);
const root=scene.children[0];let disposed=0;root.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>disposed++);});room.dispose();room.dispose();assert.equal(disposed,10);assert.equal(scene.children.length,0);
const late=new BlenderHouseSurround(scene,()=>{},signal=>new Promise(resolve=>pending.push({resolve,signal})));const loading=late.load();late.dispose();assert.equal(pending[2].signal.aborted,true);const stale=geometry();let staleDisposed=0;stale.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>staleDisposed++);});pending[2].resolve(stale);await loading;assert.equal(staleDisposed,7);assert.equal(scene.children.length,0);
});
test('room fit keeps a useful neutral photo and central view clear during gentle room-relative lean', async () => {
const { prepareHouseAsset,houseViewScale }=await load();const{fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url));
const root=geometry(),asset=prepareHouseAsset(root);
const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url));
for(const [width,height] of [[375,812],[844,390],[1440,900]])for(const aspect of [.56,1,1.77]) {
const fit=fitWindowScale(width/height,aspect)*houseViewScale('wood'),w=fit*aspect*.8,h=fit*.8;asset.resize(w,h,.6);root.updateMatrixWorld(true);
// Fit is an18% global composition margin, never a crop or an extra photo transform.
const camera=new THREE.PerspectiveCamera(40,width/height,.1,100);camera.position.z=3;camera.updateMatrixWorld(true);
const a=new THREE.Vector3(-w/2,-h/2,.6).project(camera),b=new THREE.Vector3(w/2,h/2,.6).project(camera);
assert.ok(Math.max((b.x-a.x)*width/2,(b.y-a.y)*height/2)>=Math.min(width,height)*.5,'photo remains a substantial part of the view');
const controller=new CameraController(camera);controller.setRoomBounds(w,h,.6);
for(const x of [-.2,0,.2])for(const y of [-.2,0,.2])for(const px of [-.2,0,.2])for(const py of [-.2,0,.2]) {
controller.setView(x,y);controller.update(10,1);const origin=camera.position.clone(),target=new THREE.Vector3(px*w,py*h,.6),direction=target.clone().sub(origin),distance=direction.length();
const ray=new THREE.Raycaster(origin,direction.normalize(),0,distance-.0001);
assert.equal(ray.intersectObject(root,true).length,0,`central photo blocked at ${width}×${height}, aspect${aspect}, view${x},${y}, target${px},${py}`);
}
}
asset.dispose();
});
test('main lifecycle shares room framing, reuses current zoom and excludes room changes from photo/depth/glass optics', async () => {
const main=readFileSync(new URL('../src/main.ts',import.meta.url),'utf8');
assert.match(main,/new BlenderHouseSurround\(frameScene/);
assert.match(main,/houseSurround\.resize\(frameW,frameH,uniforms\.uGlassZ\.value,undefined,\{grid:uniforms\.uGridSize\.value,curvature:uniforms\.uCurvature\.value\}\)/);
assert.match(main,/houseSurround\.apply\(theme\)/);
assert.match(main,/houseSurround\.dispose\(\)/);
assert.match(main,/houseViewScale\(blenderFrame\.isWood\?'wood':'booth'\)/);
assert.match(main,/if\(sceneLayoutReady\)resize\(\)/);
assert.doesNotMatch(main,/houseSurround[\s\S]{0,80}(?:photoZoom\s*=|sceneZoom\.setZoom|cameraController\.reset)/);
});
test('canonical room resize preserves authored smooth normals and tangents exactly inside unchanged detail bounds', async () => {
const { prepareHouseAsset }=await load(),root=geometry(),authored=new Map();
root.traverse(o=>{if(o.isMesh)authored.set(o.name,{positions:[...o.geometry.attributes.position.array],normal:[...o.geometry.attributes.normal.array],tangent:o.geometry.attributes.tangent?[...o.geometry.attributes.tangent.array]:null});});
const asset=prepareHouseAsset(root);asset.resize(1,1.32,0);
root.traverse(o=>{if(!o.isMesh||!authored.has(o.name))return;const a=authored.get(o.name),n=o.geometry.attributes.normal,t=o.geometry.attributes.tangent;
for(let i=0;i<n.count;i++){const [x,y,z]=a.positions.slice(i*3,i*3+3);if(Math.abs(x)>1.3||y>1.15||z>1.3)continue;
for(let k=0;k<3;k++)assert.equal(n.array[i*3+k],a.normal[i*3+k],o.name+' detailed area preserves Blender normals');
if(t)for(let k=0;k<4;k++)assert.equal(t.array[i*4+k],a.tangent[i*4+k],o.name+' detailed area preserves Blender tangents');
}});
asset.dispose();
});
test('room pass seeds the real shared photo depth before opaque surroundings without owning photo resources',async()=>{
const {createHousePhotoOccluder}=await load();assert.equal(typeof createHousePhotoOccluder,'function','rear threshold must be tested against photo depth after clearDepth');
const uniforms={uDepthMap:{value:new THREE.Texture()},uDepthScale:{value:.25},uDepthBias:{value:0}};
const material=new THREE.ShaderMaterial({uniforms,vertexShader:'void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',fragmentShader:'void main(){gl_FragColor=vec4(1.);}'});
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),material);source.position.set(.1,-.2,.2204);source.scale.set(.8,1.4,1);source.updateMatrixWorld(true);
const depth=createHousePhotoOccluder(source);depth.sync(true);
assert.equal(depth.mesh.geometry,source.geometry);assert.equal(depth.mesh.material.uniforms,uniforms);assert.equal(depth.mesh.material.vertexShader,material.vertexShader);
assert.equal(depth.mesh.material.colorWrite,false);assert.equal(depth.mesh.material.depthWrite,true);assert.equal(depth.mesh.material.depthTest,true);assert.ok(depth.mesh.renderOrder<0);
assert.deepEqual(depth.mesh.matrix.toArray(),source.matrixWorld.toArray());assert.equal(depth.mesh.visible,true);
source.position.x=.4;source.updateMatrixWorld(true);depth.sync(false);assert.equal(depth.mesh.visible,false);assert.equal(depth.mesh.matrix.elements[12],.4);
let sourceDisposed=0,materialDisposed=0;source.geometry.addEventListener('dispose',()=>sourceDisposed++);material.addEventListener('dispose',()=>sourceDisposed++);depth.mesh.material.addEventListener('dispose',()=>materialDisposed++);
depth.dispose();depth.dispose();assert.equal(sourceDisposed,0);assert.equal(materialDisposed,1);source.geometry.dispose();material.dispose();uniforms.uDepthMap.value.dispose();
});
test('rear threshold cannot win the photo depth test at a steep downward view',async()=>{
const {prepareHouseAsset,createHousePhotoOccluder}=await load(),root=geometry(),asset=prepareHouseAsset(root);
asset.resize(.49601915524754814,.8857484915134788,.6);
const source=new THREE.Mesh(new THREE.PlaneGeometry(1,1,128,128),new THREE.ShaderMaterial({uniforms:{uDepthScale:{value:0}},vertexShader:'void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}'}));
source.position.z=.4704;source.scale.set(.522804190, .933578910, 1);source.updateMatrixWorld(true);
const target=new THREE.Vector3(0,-.448*.933578910,.4704),origin=new THREE.Vector3(0,.65,3),ray=new THREE.Raycaster(origin,target.clone().sub(origin).normalize());
const photoHit=ray.intersectObject(source)[0],rearHit=ray.intersectObject(root,true).find(hit=>hit.object.name==='HouseThreshold');
assert.ok(photoHit&&rearHit&&rearHit.distance>photoHit.distance+.3,'reproduces the old clearDepth overpaint, not an empty or front-occluded fixture');
const depth=createHousePhotoOccluder(source),pass=new THREE.Scene();pass.add(depth.mesh,root);depth.sync(true);pass.updateMatrixWorld(true);
const hits=ray.intersectObject(pass,true);assert.equal(hits[0].object,depth.mesh);
assert.ok(hits.filter(hit=>hit.object!==depth.mesh).every(hit=>hit.distance>hits[0].distance),'seeded LESS_EQUAL photo depth rejects every rear fragment on this ray');
depth.dispose();asset.dispose();source.geometry.dispose();source.material.dispose();
});
test('refined room keeps portable contact shading in all authored meshes through resize',async()=>{
const {prepareHouseAsset,HOUSE_ASSET_URL}=await load(),root=geometry(),colors=new Map();
assert.ok(HOUSE_ASSET_URL.endsWith('?v=2'),'refined asset must replace the cached v1 transfer');
root.traverse(mesh=>{if(!mesh.isMesh)return;const attribute=mesh.geometry.attributes.color;assert.ok(attribute,'every room role carries portable authored contact shading');colors.set(mesh.name,[...attribute.array]);});
const asset=prepareHouseAsset(root);asset.resize(.72,1.4,.6);asset.resize(1.2,.6,.6);
root.traverse(mesh=>{if(mesh.isMesh&&colors.has(mesh.name))assert.deepEqual([...mesh.geometry.attributes.color.array],colors.get(mesh.name),'layout must not discard or regenerate authored occlusion');});asset.dispose();
});