import assert from 'node:assert/strict'; 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 {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url)); const {prepareHouseAsset,houseRoomBounds}=await loadTypescript(new URL('../src/objects/BlenderHouseSurround.ts',import.meta.url)); const {fitWindowScale}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url)); test('horizontal endpoints compose perpendicular room wall, picture, floor and side-positioned door',()=>{ for(const aspect of[375/812,844/390,1.6])for(const photoAspect of[.2,.5,.8,1,2,5])for(const range of[.75,.9])for(const side of[-1,1]){ const fit=fitWindowScale(aspect,photoAspect)*.82,w=fit*photoAspect*.8,h=fit*.8,s=Math.min(w,h),bounds=houseRoomBounds(w,h,.6); const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),room=prepareHouseAsset(root);room.resize(w,h,.6); const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera);control.setRoomBounds(w,h,.6);control.setMaxOffset(range);control.setView(side,0);control.update(10,1);camera.updateMatrixWorld(true); const door=new THREE.Vector3(0,0,.6).project(camera); assert.ok(Math.abs(door.x)>.25&&Math.abs(door.x)<.95,JSON.stringify({aspect,photoAspect,range,side,door:door.toArray()})); const picture=root.getObjectByName(side>0?'RoomPictureLeft':'RoomPictureRight');assert.ok(picture,'static frame belongs on perpendicular wall'); const screen=picture.getWorldPosition(new THREE.Vector3()).project(camera);assert.ok(Math.abs(screen.x)<.95&&Math.abs(screen.y)<.8,'wall picture is in viewport');assert.ok(screen.x*door.x<0,'wall artwork and door occupy opposite sides'); for(const [px,py] of[[0,0],[-.15,-.19],[.15,.19],[-.15,.19],[.15,-.19]]){const point=picture.localToWorld(new THREE.Vector3(px,py,.024)),ndc=point.clone().project(camera);assert.ok(Math.abs(ndc.x)<1&&Math.abs(ndc.y)<1,'frame corners in viewport');const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(ndc.x,ndc.y),camera);assert.equal(ray.intersectObject(root,true)[0]?.object,picture,'artwork is first visible surface');} let wall=0,floor=0;for(let x=-.9;x<=.91;x+=.15)for(let y=-.9;y<=.91;y+=.15){const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2(x,y),camera);const hit=ray.intersectObject(root,true)[0];if(!hit)continue;if(Math.abs(Math.abs(hit.point.x)-bounds.halfWidth)<.002)wall++;if(Math.abs(hit.point.y-bounds.floor)<.002)floor++;} assert.ok(wall>18,'broad visible perpendicular wall, not a strip');assert.ok(floor>=10,JSON.stringify({aspect,photoAspect,range,side,wall,floor})); control.reset();control.update(10,1);assert.equal(camera.fov,40);assert.deepEqual(camera.position.toArray(),[0,0,3]);room.dispose(); } }); test('lens and camera remain continuous across viewport rotation while anchored zoom is preserved',async()=>{ const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url)); const camera=new THREE.PerspectiveCamera(40,.9999,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);control.setRoomBounds(.76,.95,.6);control.setView(1,0);zoom.setZoom(2.5,{x:.5,y:-.4});control.update(10,1);const before=camera.position.clone(),fov=camera.fov,offset=zoom.offset.toArray();camera.aspect=1.0001;zoom.updateProjection();control.update(10,1);assert.ok(camera.position.distanceTo(before)<.001);assert.ok(Math.abs(camera.fov-fov)<.01);assert.deepEqual(zoom.offset.toArray(),offset);assert.equal(camera.zoom,2.5); const matrix=new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.projectionMatrixInverse);assert.ok(matrix.elements.every((v,i)=>Math.abs(v-new THREE.Matrix4().elements[i])<1e-10));control.reset();control.update(10,1);assert.equal(camera.fov,40);assert.equal(camera.zoom,2.5);assert.deepEqual(zoom.offset.toArray(),offset); }); test('original and courtyard rooms contain zoomed anchored near frusta through open/partial/closed doors',async()=>{ const {SceneZoomController}=await loadTypescript(new URL('../src/input/SceneZoomController.ts',import.meta.url)); const {prepareFrameAsset}=await loadTypescript(new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url)); const {peekingLayout}=await loadTypescript(new URL('../src/objects/PeekingComposition.ts',import.meta.url)); let rays=0; for(const aspect of[375/812,844/390])for(const mode of['original','courtyard'])for(const photoAspect of[.2,.8,5]){ const fit=fitWindowScale(aspect,photoAspect)*.82,layout=mode==='courtyard'?peekingLayout(aspect,photoAspect):{width:fit*photoAspect*.8,height:fit*.8},w=layout.width,h=layout.height,bounds=houseRoomBounds(w,h,.6); const root=loadFrameGeometry(new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url)),room=prepareHouseAsset(root),frame=loadFrameGeometry(),sash=prepareFrameAsset(frame),group=new THREE.Group();group.add(root,frame);room.resize(w,h,.6,layout.clearanceScale);sash.resize(w,h,.6); const camera=new THREE.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(camera),zoom=new SceneZoomController(camera);control.setRoomBounds(w,h,.6); for(const range of[.75,.9])for(const open of[0,.5,1])for(const [x,y]of[[-1,0],[1,0],[-1,-1],[1,1],[0,-1]])for(const z of[1,2,3]){ control.setMaxOffset(range);control.setView(x,y);control.update(10,1);sash.setOpen(open);zoom.setZoom(1);zoom.setZoom(z,{x:x,y:y||1});camera.updateMatrixWorld(true);group.updateMatrixWorld(true); for(const px of[-1,0,1])for(const py of[-1,0,1]){const near=new THREE.Vector3(px,py,-1).unproject(camera);assert.ok(near.y>bounds.floor-1e-8&&Math.abs(near.x)!(h.object.material.clippingPlanes??[]).some(p=>p.distanceToPoint(h.point)<0));if(hit)assert.ok(-hit.point.clone().applyMatrix4(camera.matrixWorldInverse).z>=camera.near-1e-7,'nearest room/frame surface stays beyond near plane');rays++;} } room.dispose();sash.dispose(); } console.log('Anchored original/courtyard near-frustum rays:',rays); });