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
{
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;i1.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();
});