import assert from 'node:assert/strict'; import {test} from 'node:test'; import {existsSync,readFileSync} from 'node:fs'; import vm from 'node:vm';import ts from 'typescript';import * as THREE from 'three'; import {loadFrameGeometry} from './support/load-frame-geometry.mjs'; function module(){const path=new URL('../src/objects/BlenderWindowFrame.ts',import.meta.url);assert.ok(existsSync(path),'original Blender frame adapter must exist');const exports={};vm.runInNewContext(ts.transpileModule(readFileSync(path,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:name=>name==='three'?THREE:{GLTFLoader:class{}},AbortController,fetch:()=>{throw Error('tests must inject loader')}});return exports;} function trianglesOutside(root,w,h){let count=0;root.traverse(o=>{if(!o.isMesh)return;const p=o.geometry.attributes.position,i=o.geometry.index;for(let k=0;kp.getX(j)),ys=ids.map(j=>p.getY(j)),eps=1e-6*Math.min(w,h);assert.ok(Math.max(...xs)<=-w/2+eps||Math.min(...xs)>=w/2-eps||Math.max(...ys)<=-h/2+eps||Math.min(...ys)>=h/2-eps,'triangle must not cover clear opening');count++;}});assert.equal(count,6988);} test('actual Blender triangles resize without covering the aperture or distorting latch',()=>{ const {prepareFrameAsset}=module(),root=loadFrameGeometry(); const sourceLatch=root.getObjectByName('MovableSash_Brass');sourceLatch.geometry.computeBoundingBox();const originalLatchSize=sourceLatch.geometry.boundingBox.getSize(new THREE.Vector3()).toArray(); const asset=prepareFrameAsset(root); for(const [w,h] of [[1,1.32],[1,1],[3,1],[1,3],[.2,4],[4,.2]]){ asset.resize(w,h,.6);trianglesOutside(root,w,h);assert.equal(root.position.z,.6); const latch=root.getObjectByName('MovableSash_Brass'),size=new THREE.Vector3();latch.geometry.computeBoundingBox();latch.geometry.boundingBox.getSize(size);const s=Math.min(w,h); originalLatchSize.forEach((v,i)=>assert.ok(Math.abs(size.getComponent(i)-v*s)<1e-6)); const bounds=new THREE.Box3().setFromObject(root);assert.ok(Math.abs(bounds.min.x-(-w/2-.122*s))<1e-6);assert.ok(Math.abs(bounds.min.y-(-h/2-.1815*s))<1e-6); } const capture=()=>{const values=[];root.traverse(o=>{if(o.isMesh)values.push([...o.geometry.attributes.position.array],[...o.geometry.attributes.uv.array]);});return values;}; asset.resize(1,1.32,.6);const before=capture();asset.resize(3,1,.6);asset.resize(1,1.32,.6);assert.deepEqual(capture(),before,'resizing never accumulates deformation'); asset.dispose(); }); test('asset uses length-compensated grain UVs and mirror-corrected normal map',()=>{ const {prepareFrameAsset,configureWoodMaterial}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root); const wood=root.getObjectByName('FixedSurround_Wood'),uv=wood.geometry.attributes.uv; asset.resize(1,1.32,0);const canonical=[...uv.array];asset.resize(3,1,0);let changed=0; for(let i=0;i1e-5)changed++;}assert.ok(changed>0); const material=new THREE.MeshStandardMaterial({map:new THREE.Texture(),normalMap:new THREE.Texture(),roughnessMap:new THREE.Texture()});configureWoodMaterial(material); for(const texture of [material.map,material.normalMap,material.roughnessMap]){assert.equal(texture.wrapS,THREE.ClampToEdgeWrapping);assert.equal(texture.wrapT,THREE.MirroredRepeatWrapping);} const shader={fragmentShader:'#include '};material.onBeforeCompile(shader,{});assert.match(shader.fragmentShader,/mapN\.y.*floor\(\s*vNormalMapUv\.y/);asset.dispose(); }); test('frame loading failure retains fallback, retry uses latest aspect/theme, and teardown rejects stale loads',async()=>{ const {BlenderWindowFrame}=module(),scene=new THREE.Scene(),changes=[],pending=[]; const frame=new BlenderWindowFrame(scene,(active,state)=>changes.push({active,state}),()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))); frame.resize(2,1,.6);frame.apply('wood');const first=frame.load();pending.shift().reject(Error('offline'));await first;assert.equal(changes.at(-1).active,false);assert.equal(changes.at(-1).state,'error'); const retry=frame.load();frame.resize(1,3,.6);frame.apply('booth');pending.shift().resolve(loadFrameGeometry());await retry;assert.equal(changes.at(-1).active,false);frame.apply('wood');assert.equal(changes.at(-1).active,true);const asset=scene.children.find(x=>x.name==='blender-window-frame');assert.ok(asset.visible);trianglesOutside(asset,1,3);frame.dispose();assert.equal(scene.children.length,0); const next=new BlenderWindowFrame(scene,()=>{},()=>new Promise(resolve=>pending.push({resolve})));const load=next.load();next.dispose();const stale=loadFrameGeometry();let disposed=0;stale.traverse(o=>{if(o.isMesh)o.geometry.addEventListener('dispose',()=>disposed++);});pending.shift().resolve(stale);await load;assert.equal(disposed,4);assert.equal(scene.children.length,0); }); test('neutral camera contains the actual imported surround on phone, tablet and desktop',()=>{ const {prepareFrameAsset,fitWindowScale}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root); for(const [w,h] of [[375,812],[768,1024],[1440,900]])for(const aspect of [.25,1,4]){ const camera=new THREE.PerspectiveCamera(40,w/h,.1,100);camera.position.z=3;camera.updateMatrixWorld(); const scale=fitWindowScale(w/h,aspect);asset.resize(scale*aspect*.8,scale*.8,.6); root.traverse(object=>{if(!object.isMesh)return;const p=object.geometry.attributes.position;for(let i=0;i{ const {prepareFrameAsset,BlenderWindowFrame}=module(),root=loadFrameGeometry(),asset=prepareFrameAsset(root); asset.resize(2,1,.6);asset.setOpen(.5);root.updateMatrixWorld(true); assert.equal(root.getObjectByName('MovableSash').position.x,-1); assert.equal(root.getObjectByName('FixedSurround').position.x,0); const fixed=root.getObjectByName('FixedSurround_Wood'),sash=root.getObjectByName('MovableSash_Wood'); assert.equal(fixed.material.clippingPlanes,null);assert.equal(sash.material.clippingPlanes[0].constant,1.05); const handle=asset.handlePosition(new THREE.Vector3());assert.ok(Math.abs(handle.x-.026)<1e-8); asset.resize(1,2,.6);assert.equal(root.getObjectByName('MovableSash').position.x,-.5); asset.setOpen(4);assert.equal(root.getObjectByName('MovableSash').position.x,-1);asset.setOpen(-1);assert.equal(root.getObjectByName('MovableSash').position.x,0);asset.dispose(); const scene=new THREE.Scene();let resolve;const frame=new BlenderWindowFrame(scene,()=>{},()=>new Promise(r=>resolve=r)); frame.resize(2,1,.6);frame.setOpen(.4);const loaded=frame.load();resolve(loadFrameGeometry());await loaded; assert.equal(scene.children[0].getObjectByName('MovableSash').position.x,-.8);frame.dispose(); });