import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; import ts from 'typescript'; import * as THREE from 'three'; import {loadFrameGeometry} from './support/load-frame-geometry.mjs'; import {loadTypescript} from './support/load-typescript.mjs'; let fetchFrameAllowed=false; import {mergeGeometries} from 'three/addons/utils/BufferGeometryUtils.js'; import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js'; import { DepthProcessor } from '../src/depth/DepthProcessor.ts'; import * as depthConstants from '../src/constants/depth.ts'; import * as glassConstants from '../src/constants/glass.ts'; const {CameraController}=await loadTypescript(new URL('../src/input/CameraController.ts',import.meta.url)); // Browser boundaries are doubles; production main state machine, camera, THREE objects, // and the single-view rendering state machine execute unchanged. This is not GPU QA. class Element { value = ''; open = false; isConnected = true; inert = false; hidden = false; disabled = false; textContent = ''; checked = false; style = {}; dataset = {}; attributes = {}; listeners = new Map(); width = 960; height = 600; clientWidth = 960; clientHeight = 600; classList = { add(){}, remove(){} }; addEventListener(name, fn) { const list = this.listeners.get(name) ?? []; list.push(fn); this.listeners.set(name,list); } dispatch(name, extra={}) { for (const fn of this.listeners.get(name) ?? []) fn({type:name,target:this,...extra}); } setAttribute(key,value){this.attributes[key]=value;} removeAttribute(key){delete this.attributes[key];} focus(){documentRoot.activeElement=this;} showModal(){this.open=true;} close(){this.open=false;this.dispatch('close');} dispatchEvent(event){this.dispatch(event.type);} removeEventListener() {} append() {} remove() {} closest() { return new Element(); } getBoundingClientRect() { return {x:0,y:0,left:0,top:0,width:this.width,height:this.height}; } getContext() { return { createLinearGradient:()=>({addColorStop(){}}), fillRect(){}, drawImage(){}, getImageData:()=>({data:new Uint8ClampedArray(this.width*this.height*4).fill(128)}) }; } } // Mirror actual HTML membership so a removed/mistyped control cannot be invented by the harness. const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); const elements = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([,id]) => [`#${id}`,new Element()])); if (/)/.test(html)) elements.set('aside',new Element()); const groups = new Map(); for (const attr of ['tool','panel','pattern']) { const matches=[...html.matchAll(new RegExp(`<[^>]+data-${attr}="([^"]+)"[^>]*>`,'g'))]; groups.set(`[data-${attr}]`, matches.map(([tag,value])=>{const id=tag.match(/id="([^"]+)"/)?.[1];const element=id?elements.get(`#${id}`):new Element();element.dataset[attr]=value;return element;})); } const querySelector = selector => elements.get(selector) ?? null; const el = id => { const element = querySelector(id); assert.ok(element, `Missing HTML control: ${id}`); return element; }; for (const [id,value] of Object.entries({'#mosaic-style':'2','#effect':'mosaic'})) el(id).value=value; let frame, teardown, provider, loader, pointer, renderer, inspection, resizeCallback; let animationNow=null; let peekingCallbacks,peekingState; const peekingBoundary={cancelCalls:0,disposeCalls:0,setState(state){peekingState={...state};},cancel(){this.cancelCalls++;},dispose(){this.disposeCalls++;}}; class FakeImage { src='blob:photo'; width=400; height=500; naturalWidth=400; naturalHeight=500; async decode(){} } class Renderer { domElement=new Element(); capabilities={maxTextureSize:4096}; autoClear=true; target=null; scissor=false; viewport=[]; draws=[]; color=new THREE.Color(); alpha=1; constructor(){renderer=this;} extensions={has:name=>name==='OES_texture_float_linear'}; debug={onShaderError:null}; pixelRatio=1; lost=false; internals=[]; info={autoReset:true,render:{calls:0,triangles:0},memory:{geometries:0,textures:0},reset:()=>{this.info.render.calls=0;this.info.render.triangles=0;}}; getContext(){return {isContextLost:()=>this.lost};} getPixelRatio(){return this.pixelRatio;} setPixelRatio(v){this.pixelRatio=v;} setSize(w,h){this.domElement.clientWidth=w;this.domElement.clientHeight=h;} setRenderTarget(v){this.target=v;} getRenderTarget(){return this.target;} setViewport(...v){this.viewport=v;} setScissor(...v){this.scissorRect=v;} setScissorTest(v){this.scissor=v;} getClearColor(result){return result.copy(this.color);} getClearAlpha(){return this.alpha;} setClearColor(v,a){this.color.set(v);this.alpha=a;} clear(){} clearDepth(){} dispose(){} render(scene,camera){ const physical=scene.children.find(child=>child.visible&&child.material?.isMeshPhysicalMaterial); if(physical&&this.failNextMesh){this.failNextMesh=false;this.debug.onShaderError?.({},null,null,null);} if(physical){ if(!scene.userData.internal){scene.userData.internal=new THREE.WebGLRenderTarget(1,1);this.internals.push(scene.userData.internal);scene.userData.internal.userData={disposed:0};scene.userData.internal.addEventListener('dispose',()=>scene.userData.internal.userData.disposed++);} const output=this.target;this.target=scene.userData.internal; for(const child of scene.children)if(child.visible&&child.material&&!child.material.isMeshPhysicalMaterial)child.onBeforeRender(this,scene,camera,child.geometry,child.material,null); this.target=output; } for(const child of scene.children)if(child.visible&&child.geometry){this.info.render.calls++;this.info.render.triangles+=(child.geometry.index?.count??0)/3;} const object=scene.children.find(child=>child.visible&&child.material?.colorWrite!==false&&(child.material?.uniforms?.uDepthMap || child.material?.uniforms?.uProtectionDepthMap)); const optics=object?.material.uniforms; this.draws.push({objects:scene.children,target:this.target,scissor:this.scissor,viewport:[...this.viewport],camera:camera.clone(),depth:optics?.uDepthMap?.value??optics?.uProtectionDepthMap?.value,history:optics?.uRegionHistory?.value,grid:optics?.uGridSize?.value,curvature:optics?.uCurvature?.value,gloss:optics?.uGlossStrength?.value,sceneZoom:optics?.uSceneZoom?.value,sceneOffset:optics?.uSceneOffset?.value.clone(),glassZ:optics?.uGlassZ?.value,sourceZ:optics?.uSourceZ?.value,depthScale:optics?.uDepthScale?.value,depthBias:optics?.uDepthBias?.value,position:object?.position.clone(),scale:object?.scale.clone(),photo:optics?.uTexture?.value,background:scene.background?.clone(),shaderBackground:optics?.uBackgroundColor?.value?.clone(),projection:optics?.uContentViewProjection?.value.clone(),inverseProjection:optics?.uContentInverseViewProjection?.value.clone()}); } } class Provider { calls=[]; pending=[]; constructor(){provider=this;} getDepthMap(image){this.calls.push({image,argumentCount:arguments.length});return new Promise((resolve,reject)=>this.pending.push({resolve,reject}));} cancel(){for(const pending of this.pending.splice(0)) pending.reject(new Error('cancelled'));} finish(){const pending=this.pending.shift();assert.ok(pending);const texture=DepthProcessor.fromNormalizedDepth(new Uint8Array([0,100,255,128]),2,2);texture.userData.aiDepthRun={model:'small',device:'wasm',dtype:'q8',preparationMs:10,inferenceMs:20,totalMs:30};texture.userData.disposals=0;texture.addEventListener('dispose',()=>texture.userData.disposals++);pending.resolve(texture);return texture;} } class Loader {loads=0;constructor(apply,status){this.apply=apply;this.status=status;loader=this;} dispose(){} cancelPending(){} retry(){} load(file){this.loads++;this.lastFile=file;} } class Pointer {constructor(_element,move,_onDrag,zoom){this.move=move;this.zoom=zoom;pointer=this;}cancelGesture(){this.cancelled=true;} dispose(){} } let gyroDisconnects=0;class Gyro {disconnect(){gyroDisconnects++;}reset(){} setPaused(){} syncManualView(){} dispose(){} } class Inspection {constructor(){inspection=this;} setDepth(_image,texture){this.texture=texture;} updateDistance(){} } class Debug {clear(){} update(){return '';} } const modules = new Map(); // The default static build must not initialize or call analytics dependencies. const analyticsDisabled = () => { throw new Error('static build invoked analytics'); }; modules.set('Identity', { getVisitorId: analyticsDisabled, makeDogId: analyticsDisabled }); modules.set('Transport', { createTransport: analyticsDisabled }); modules.set('ArrivalTracker', { createArrivalTracker: analyticsDisabled }); modules.set('ArrivalCount', { renderArrivalCount: analyticsDisabled }); modules.set('PetName',await loadTypescript(new URL('../src/state/PetName.ts',import.meta.url))); modules.set('FirstGuide',await loadTypescript(new URL('../src/ui/FirstGuide.ts',import.meta.url))); modules.set('ExportUI',await loadTypescript(new URL('../src/ui/ExportUI.ts',import.meta.url))); modules.set('SceneExport',await loadTypescript(new URL('../src/rendering/SceneExport.ts',import.meta.url))); modules.set('MobileDevice',await loadTypescript(new URL('../src/ui/MobileDevice.ts',import.meta.url))); modules.set('EntryNotices',await loadTypescript(new URL('../src/ui/EntryNotices.ts',import.meta.url))); modules.set('ImagePaste',await loadTypescript(new URL('../src/input/ImagePaste.ts',import.meta.url))); modules.set('CameraComposition',await loadTypescript(new URL('../src/input/CameraComposition.ts',import.meta.url))); // Geometry, shading, and composition are production modules. Only fetch/decode, // browser UI, and GL calls are doubles; the opt-in UI is tested separately. for(const key of ['RoomComposition','ExperimentalCourtyard','PeekingDoorGrid','PeekingComposition','WindowReveal','PetDepthMesh']){ modules.set(key,await loadTypescript(new URL(`../src/objects/${key}.ts`,import.meta.url))); } modules.set('PeekingExperimentUI',{initPeekingExperimentUI(_document,_signal,callbacks){peekingCallbacks=callbacks;return peekingBoundary;}}); for (const key of ['SceneHints','themes','ExperienceUI','RenderRecovery','PhotoBoothUI','WindowFinish','BlenderWindowFrame','BlenderHouseSurround','WindowSlideController','SceneZoomController','ComparisonLayout','ComparisonMetrics','RenderDemand','PatternedGlassGeometry','MeshRefraction','MeshGlassRenderer','OriginalGlassRenderer','PhysicalGlassComparison']) { const output={}; const path=['ComparisonLayout','ComparisonMetrics','RenderDemand','PatternedGlassGeometry','MeshRefraction','MeshGlassRenderer','OriginalGlassRenderer','PhysicalGlassComparison'].includes(key)?'rendering':['WindowFinish','BlenderWindowFrame','BlenderHouseSurround'].includes(key)?'objects':['WindowSlideController','SceneZoomController'].includes(key)?'input':'ui'; const text=await readFile(new URL(`../src/${path}/${key}.ts`,import.meta.url),'utf8'); vm.runInNewContext(ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports:output,Event,AbortController,fetch:async()=>{if(fetchFrameAllowed)return {ok:true,arrayBuffer:async()=>new ArrayBuffer(0)};throw Error('offline asset fixture');},require:name=>name.endsWith('?raw')?{default:''}:name==='three'?THREE:name.includes('GLTFLoader')?{GLTFLoader:class{async parseAsync(){return {scene:loadFrameGeometry(key==='BlenderHouseSurround'?new URL('../public/assets/window/grandma-house-surround.glb',import.meta.url):undefined)};}}}:name.includes('BufferGeometryUtils')?{mergeGeometries}:name.includes('RectAreaLightUniformsLib')?{RectAreaLightUniformsLib}:name.endsWith('/glass')?glassConstants:modules.get(name.split('/').at(-1))}); modules.set(key,output); } const require = name => { if(modules.has(name.split('/').at(-1))) return modules.get(name.split('/').at(-1)); if(name==='three') return {...THREE,WebGLRenderer:Renderer}; if(name.endsWith('?raw')) return {default:''}; if(name.endsWith('?url')) return {default:'test.png'}; if(name.endsWith('.css')) return {}; const classes={MediaLoader:Loader,PointerController:Pointer,CameraController,GyroController:Gyro,StaticDepthProvider:class{},AiDepthProvider:Provider,DepthInspection:Inspection,DepthDebug:Debug}; const key=name.split('/').at(-1);if(classes[key])return {[key]:classes[key]}; if(key==='DepthProcessor')return {DepthProcessor};if(key==='depth')return depthConstants;if(key==='glass')return glassConstants; throw new Error(`unexpected import ${name}`); }; const source=await readFile(new URL('../src/main.ts',import.meta.url),'utf8'); const documentEvents=new Element(); const motionPreference=new Element();motionPreference.matches=false; const documentRoot={addEventListener:(...args)=>documentEvents.addEventListener(...args),hidden:false,querySelector,querySelectorAll:selector=>groups.get(selector)??[],createElement:()=>new Element(),documentElement:new Element(),defaultView:{URL:{createObjectURL:()=>'blob:export',revokeObjectURL(){}},navigator:{},matchMedia:()=>motionPreference,localStorage:{getItem(key){return key==='behind-glass.first-guide.v1'?'1':null;},setItem(){}}},activeElement:null}; const timers=new Map();let timerId=0; const compiled=ts.transpileModule(source.replaceAll('import.meta','testImportMeta'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; vm.runInNewContext(compiled,{require,exports:{},testImportMeta:{env:{VITE_ARRIVAL_ANALYTICS:'false'},hot:{dispose(fn){teardown=fn;}}},document:documentRoot,setTimeout:fn=>{timers.set(++timerId,fn);return timerId;},clearTimeout:id=>timers.delete(id),devicePixelRatio:1,HTMLImageElement:FakeImage,Image:FakeImage,AbortController,ResizeObserver:class{constructor(fn){resizeCallback=fn;}observe(){}disconnect(){}},performance:{now:()=>animationNow??performance.now()},queueMicrotask,requestAnimationFrame:fn=>{frame=fn;return 1;},cancelAnimationFrame(){},console,DOMException,URL,Promise,File,Blob}); const flush=async()=>{for(let i=0;i<10;i++)await Promise.resolve();}; const beforeNameInference = provider.calls.length, beforeNameLoads = loader.loads; el('#entry-pet-name').value='호롱'; el('#entry-pet-name').dispatch('input'); assert.equal(el('#settings-pet-name').value,'호롱'); assert.equal(el('#entry-photo-button').textContent,'호롱이 사진 고르기'); assert.equal(provider.calls.length,beforeNameInference,'editing name never starts AI'); assert.equal(loader.loads,beforeNameLoads,'editing name never reloads media'); assert.equal(el('#depth-image').disabled,true,'manual depth requires a decoded still photo'); renderer.draws=[];frame(performance.now()+16); assert.ok(renderer.draws.some(draw=>draw.objects.some(object=>object.material?.isMeshPhysicalMaterial)),'B is the default full-size renderer'); assert.equal(renderer.draws.some(draw=>draw.gloss!==undefined),false,'default rendering never submits A'); assert.equal(renderer.draws.some(draw=>draw.target?.depthTexture),false,'default does not allocate or draw A scene target'); assert.equal(el('#glass-renderer').value,'b'); // Run the existing A optics regression through its explicit preserved diagnostic entry. el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush(); assert.equal(el('#glass-renderer').value,'a'); // A demand-rendered canvas keeps its last presented image when no new draw is needed. const sampleFrame=now=>{const previous=renderer.draws;renderer.draws=[];frame(now);if(!renderer.draws.length&&!el('#render-recovery').open&&!el('#mesh-render-error').open&&el('#experience').dataset.flow!=='loading')renderer.draws=previous;}; const renderOptics=()=>{sampleFrame(performance.now()+16);return renderer.draws.find(draw=>draw.target===null&&draw.gloss!==undefined);}; assert.equal(renderOptics().curvature,1,'convex is the initial shader setting'); assert.equal(renderOptics().gloss,.3,'gloss is fixed at 0.30'); for(const value of ['-1','1','-1','1']){ el('#curvature').value=value;el('#curvature').dispatch('change'); assert.equal(renderOptics().curvature,Number(value),'both choices reach the shader on repeated selection'); assert.equal(renderOptics().gloss,.3,'curvature changes do not alter gloss'); assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.z,3,'curvature does not move the camera behind the glass'); } for(const effect of ['reeded','frosted','mosaic']){ el('#effect').value=effect;el('#effect').dispatch('change'); assert.equal(el('#curvature').disabled,effect!=='mosaic'); assert.equal(el('#curvature-controls').hidden,effect!=='mosaic'); assert.equal(renderOptics().gloss,.3,'switching effects preserves fixed gloss'); } for(const style of ['0','1','2']){ el('#mosaic-style').value=style;el('#mosaic-style').dispatch('change'); assert.equal(renderOptics().gloss,.3,'switching glass patterns preserves fixed gloss'); } el('#grid-size').value='11';el('#grid-size').dispatch('input'); groups.get('[data-pattern]').find(card=>card.dataset.pattern==='2').dispatch('click'); assert.equal(renderOptics().grid,11,'reselecting the current card preserves the custom grid in the renderer'); const assertGap=(expected)=>{ const optics=renderOptics(); const content=renderer.draws.find(draw=>draw.target?.depthTexture); const maxDepth=content.depth.image.data.reduce((max,value,index)=>index%4===0?Math.max(max,value):max,0)/255; const closestZ=content.position.z+(maxDepth-content.depthBias)*content.depthScale; assert.ok(Math.abs(optics.glassZ-closestZ-expected)<1e-12,`nearest surface gap should be ${expected}`); assert.equal(optics.sourceZ,content.position.z,'refraction source plane follows the depth mesh base'); }; assertGap(.1296); loader.apply(new THREE.Texture(new FakeImage()),.8,null);await flush(); renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'hidden 3D rendering pauses during inference'); assert.equal(provider.calls.length,1,'one automatic depth request per photo'); assert.equal(el('#experience').dataset.flow,'loading','only a real pending operation shows delivery'); assert.equal(el('#flow-overlay').dataset.journey,'driving','real AI work drives the photo passenger'); const pendingCalls=provider.calls.length;el('#theme-select').value='booth';el('#theme-select').dispatch('change'); assert.equal(provider.calls.length,pendingCalls,'theme switch during loading must not restart inference'); assert.equal(provider.pending.length,1); assert.equal(el('#experience').dataset.flow,'loading'); assert.equal(provider.calls[0].argumentCount,1,'model and preset are no longer selected by the UI'); // Give the actual presentation controller controllable browser timers for this AI completion. const journeyTimers=new Map();let journeyTimerId=0; documentRoot.defaultView.setTimeout=(fn,ms)=>{journeyTimers.set(++journeyTimerId,{fn,ms});return journeyTimerId;}; documentRoot.defaultView.clearTimeout=id=>journeyTimers.delete(id); const advanceJourney=expected=>{const [id,timer]=journeyTimers.entries().next().value;assert.equal(timer.ms,expected);journeyTimers.delete(id);timer.fn();}; const first=provider.finish();await flush(); assert.equal(el('#ai-depth').disabled,false); assert.equal(inspection.texture,first); assert.equal(el('#experience').dataset.flow,'ready'); assert.equal(el('#flow-overlay').dataset.journey,'arrived','only applied current AI success arrives'); assert.match(el('#depth-provenance').textContent,/Small.*770/); renderer.draws=[];frame(performance.now()+16); const screen=renderer.draws.filter(draw=>draw.target===null&&draw.depth); assert.equal(screen.length,1,'a single full-width view is rendered'); assert.equal(screen[0].depth,first); assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.aspect,960/600); assert.equal(el('#canvas-wrap').inert,true,'arrival keeps scene interaction blocked'); assert.equal(el('#hint').dataset.phase,'hidden','hint waits until the scene can be used'); const arrivalInferenceCalls=provider.calls.length; const arrivalPosition=screen[0].camera.position.x, arrivalDraws=renderer.draws; pointer.move(.5,0);renderer.draws=[];frame(performance.now()+20); assert.equal(renderer.draws.length,0,'static arrival submits no extra scene render'); assert.equal(screen[0].camera.position.x,arrivalPosition,'arrival blocks hidden camera input'); advanceJourney(1350); assert.equal(el('#flow-overlay').dataset.journey,'unpacking'); assert.equal(el('#flow-title').textContent,'짐을 푸는 중…'); renderer.draws=[];frame(performance.now()+24); assert.equal(renderer.draws.length,0,'unpacking adds no GPU scene work'); advanceJourney(1500); assert.equal(el('#flow-overlay').hidden,true);assert.equal(el('#canvas-wrap').inert,false); assert.equal(el('#hint').dataset.phase,'hidden','hint starts only after five idle seconds beyond the reveal'); assert.equal(provider.calls.length,arrivalInferenceCalls,'the arrival story never starts inference'); delete documentRoot.defaultView.setTimeout;delete documentRoot.defaultView.clearTimeout; renderer.draws=arrivalDraws; // Keep the last framebuffer snapshot for the remaining demand-rendering checks. assertGap(.1296); el('#photo-reset').dispatch('click');assertGap(.1296); el('#reset').onclick();assertGap(.1296); el('#scene-zoom-reset').dispatch('click');assertGap(.1296); for(const [value,gap] of [['0',.04],['10',.096],['16',.1296],['21',.1576]]){ el('#photo-glass-distance').value=value;el('#photo-glass-distance').dispatch('input'); assert.equal(el('#photoGlassDistanceOut').value,`${value}%`); assertGap(gap); } el('#photo-reset').dispatch('click');assertGap(.1576); el('#reset').onclick();assertGap(.1576); el('#scene-zoom-reset').dispatch('click');assertGap(.1576); assert.equal(el('#photoGlassDistanceOut').value,'21%','framing and viewpoint resets preserve the chosen distance'); for(const value of ['0.35','0.50','0.65','0.90']){ el('#camera-offset').value=value;el('#camera-offset').dispatch('input'); assert.equal(el('#cameraOut').value,Number(value).toFixed(2)); } const isolatedCamera=new THREE.PerspectiveCamera(); const isolatedController=new CameraController(isolatedCamera); isolatedController.add(1,-1);isolatedController.update(10,1); assert.equal(isolatedCamera.position.x,.75,'camera default is the gentler 0.75'); for(const [requested,expected] of [[0,.35],[.35,.35],[.5,.5],[.65,.65],[.9,.9],[1.2,.9]]){ isolatedController.setMaxOffset(requested);isolatedController.update(10,2); assert.equal(isolatedCamera.position.x,expected,'camera uses the bounded offset'); assert.equal(isolatedCamera.position.y,-expected); } pointer.move(.1,0);renderer.draws=[];frame(performance.now()+32); assert.ok(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.x>0); // Keyboard is an equivalent, bounded preview input rather than a new camera algorithm. el('#reset').onclick();frame(performance.now()+40); let prevented = false; el('#canvas-wrap').dispatch('keydown',{key:'ArrowRight',preventDefault(){prevented=true;}}); assert.equal(prevented,true,'preview consumes its arrow-key input'); renderer.draws=[];frame(performance.now()+80); assert.ok(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.x>0,'keyboard moves the shared camera'); // Guidance is temporary and remains hidden once the user has acted. assert.equal(el('#hint').dataset.phase,'hidden','keyboard interaction hides the hint'); const hintCallbacks=[...timers.values()];hintCallbacks.forEach(fn=>fn());assert.equal(el('#hint').dataset.phase,'hidden'); // Scene gestures magnify the common projection, leaving physical photo/depth framing alone. assert.ok(pointer.zoom,'scene gesture zoom is connected'); assert.equal(pointer.zoom.getZoom(),1); const sceneBefore=renderOptics();const physicalBefore=renderer.draws.find(draw=>draw.target?.depthTexture);const inferenceBefore=provider.calls.length; pointer.zoom.setZoom(2, { x: .5, y: -.25 });const sceneAfter=renderOptics();const physicalAfter=renderer.draws.find(draw=>draw.target?.depthTexture); assert.equal(physicalAfter.camera.projectionMatrix.elements[8], .5, 'off-center zoom translates the common projection');assert.equal(physicalAfter.camera.projectionMatrix.elements[9], -.25); assert.equal(physicalAfter.camera.zoom,2);assert.equal(sceneAfter.sceneZoom,2,'screen-relative legacy glass receives the same zoom');assert.deepEqual(sceneAfter.sceneOffset.toArray(),[-.5,.25],'legacy patterns receive the same projection shift'); assert.equal(physicalAfter.camera.projectionMatrix.elements[0],physicalBefore.camera.projectionMatrix.elements[0]*2,'zoom updates the actual projection matrix'); const shaderProjection=new THREE.Matrix4().multiplyMatrices(physicalAfter.camera.projectionMatrix,physicalAfter.camera.matrixWorldInverse); assert.deepEqual(sceneAfter.projection.elements,shaderProjection.elements,'glass projection follows zoomed content camera'); const identity=sceneAfter.projection.clone().multiply(sceneAfter.inverseProjection); identity.elements.forEach((value,index)=>assert.ok(Math.abs(value-(index%5===0?1:0))<1e-10,'glass inverse projection stays synchronized')); assert.equal(physicalAfter.camera.position.z,3);assert.deepEqual(physicalAfter.scale.toArray(),physicalBefore.scale.toArray());assert.equal(physicalAfter.position.z,physicalBefore.position.z);assert.equal(sceneAfter.sourceZ,sceneBefore.sourceZ);assert.equal(sceneAfter.depth,sceneBefore.depth);assert.equal(el('#photo-zoom').value,'1.00');assert.equal(provider.calls.length,inferenceBefore); assert.equal(el('#scene-zoom-reset').hidden,false);resizeCallback();assert.equal(renderOptics().camera.zoom,1,'post camera is independent');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.zoom,2,'resize preserves projection zoom');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.projectionMatrix.elements[8],.5,'resize reapplies anchored offset'); const canvas=el('#canvas-wrap');const oldWidth=canvas.width;canvas.width=375;resizeCallback();renderOptics();assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.projectionMatrix.elements[8],.5,'aspect changes retain normalized anchored crop');canvas.width=oldWidth;resizeCallback(); el('#scene-zoom-reset').dispatch('click');assert.equal(pointer.zoom.getZoom(),1);assert.equal(el('#scene-zoom-reset').hidden,true);assert.deepEqual(renderOptics().sceneOffset.toArray(),[0,0],'whole view resets the offset too'); // Theme and overlay changes preserve camera, depth, photo, optics and manual framing. pointer.zoom.setZoom(1.5, { x: -.4, y: .6 });const beforeTheme=renderOptics(); const callsBeforeTheme=provider.calls.length; const beforeCamera=renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.clone(); el('#theme-select').value='wood';el('#theme-select').dispatch('change'); const afterTheme=renderOptics();assert.equal(afterTheme.depth,beforeTheme.depth);assert.equal(afterTheme.sourceZ,beforeTheme.sourceZ);assert.equal(afterTheme.grid,beforeTheme.grid);assert.equal(provider.calls.length,callsBeforeTheme); assert.equal(afterTheme.shaderBackground.getHexString(),'e6dfcf');assert.deepEqual(afterTheme.sceneOffset.toArray(),beforeTheme.sceneOffset.toArray(),'theme preserves anchored crop'); const photoCamera=renderer.draws.find(draw=>draw.target?.depthTexture).camera;assert.deepEqual(renderer.draws.at(-1).camera.projectionMatrix.elements,photoCamera.projectionMatrix.elements,'wood frame shares the same anchored projection'); assert.equal(el('#theme-select').value,'wood');assert.equal(renderer.draws.find(draw=>draw.target?.depthTexture).camera.zoom,1.5,'theme keeps whole-scene zoom'); el('#settings-open').dispatch('click');const blocked=renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.clone(); pointer.move(.5,.5);el('#canvas-wrap').dispatch('keydown',{key:'ArrowLeft',preventDefault(){}});renderOptics();assert.deepEqual(renderer.draws.find(draw=>draw.target?.depthTexture).camera.position.toArray(),blocked.toArray(),'camera stays still behind modal'); el('#settings-close').dispatch('click'); // Zoom changes XY photo size, not the camera, source Z, depth identity or closest gap. const zoomStart=renderOptics();const contentStart=renderer.draws.find(draw=>draw.target?.depthTexture); el('#photo-zoom').value='2';el('#photo-zoom').dispatch('input');const zoomEnd=renderOptics();const contentEnd=renderer.draws.find(draw=>draw.target?.depthTexture); assert.equal(contentEnd.scale.x,contentStart.scale.x*2);assert.equal(contentEnd.scale.y,contentStart.scale.y*2);assert.equal(contentEnd.scale.z,1);assert.equal(contentEnd.position.z,contentStart.position.z);assert.equal(zoomEnd.sourceZ,zoomStart.sourceZ);assert.equal(contentEnd.depth,contentStart.depth);assert.equal(contentEnd.photo,contentStart.photo);assert.equal(contentEnd.depthScale,contentStart.depthScale);assert.equal(provider.calls.length,callsBeforeTheme);assertGap(.1576);el('#scene-zoom-reset').dispatch('click');assert.equal(el('#photo-zoom').value,'2.00','full-view reset leaves hidden photo framing untouched'); // The real optional adapter executes with only GL/browser calls doubled. No model duplication. const compareCalls=provider.calls.length; const beforeComparison=renderOptics(); for(let toggle=0;toggle<3;toggle++){ el('#comparison-toggle').dispatch('click');await flush(); assert.equal(el('#comparison-overlay').hidden,false,'opt-in comparison opens'); assert.equal(el('#comparison-exit').hidden,false); renderer.draws=[];frame(performance.now()+1000); const custom=renderer.draws.find(draw=>draw.gloss!==undefined); const photoDraws=renderer.draws.filter(draw=>draw.photo); assert.ok(custom.target,'A renders its unchanged shader to a comparison target'); assert.ok(photoDraws.length>=2,'both use an actual photo depth mesh'); assert.equal(photoDraws[0].photo,photoDraws[1].photo);assert.equal(photoDraws[0].depth,photoDraws[1].depth); assert.deepEqual(photoDraws[0].camera.projectionMatrix.elements,photoDraws[1].camera.projectionMatrix.elements); assert.deepEqual(photoDraws[0].position.toArray(),photoDraws[1].position.toArray()); assert.deepEqual(photoDraws[0].scale.toArray(),photoDraws[1].scale.toArray()); assert.equal(provider.calls.length,compareCalls,'opening and frames share one inference'); assert.equal(renderer.draws.filter(draw=>draw.target===null).length,2,'both panes composited simultaneously'); el('#comparison-exit').dispatch('click'); assert.equal(el('#comparison-overlay').hidden,true); assert.equal(renderer.internals.at(-1).userData.disposed,0,'shared standalone B remains reusable after comparison closes'); const restored=renderOptics();assert.equal(restored.depth,beforeComparison.depth);assert.equal(restored.sourceZ,beforeComparison.sourceZ); assert.deepEqual(restored.sceneOffset.toArray(),beforeComparison.sceneOffset.toArray()); } assert.equal(renderer.info.autoReset,true); // Cancellation before the lazy import settles must not resurrect the comparison. el('#comparison-toggle').dispatch('click');el('#comparison-toggle').dispatch('click');await flush(); assert.equal(el('#comparison-overlay').hidden,true); // Renderer fallback is explicit and avoids r169’s invalid half-float LTC branch. el('#comparison-toggle').dispatch('click');await flush(); assert.match(el('#comparison-label-b').textContent,/맞춤 굴절/); el('#comparison-exit').dispatch('click'); // Unsupported optics return to A without changing the chosen controls. el('#comparison-toggle').dispatch('click');await flush(); el('#effect').value='frosted';el('#effect').dispatch('change');await flush(); assert.equal(el('#comparison-overlay').hidden,true);assert.equal(el('#effect').value,'frosted'); el('#effect').value='mosaic';el('#effect').dispatch('change');await flush(); // Existing depth survives a failed rerun, and controls become usable for retry. el('#ai-depth').dispatch('click');await flush(); el('#ai-depth').dispatch('click');await flush(); assert.equal(provider.pending.length,1,'repeat clicks while busy cannot duplicate inference'); provider.pending.shift().reject(new Error('test failure'));await flush(); assert.equal(inspection.texture,first); assert.equal(el('#ai-depth').disabled,false); assert.equal(el('#flow-overlay').dataset.journey,'idle','failed AI stops travel'); assert.match(el('#status').textContent,/실패/);assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,false);assert.equal(el('#flow-flat').hidden,false); el('#ai-depth').dispatch('click');await flush(); const second=provider.finish();await flush(); assert.equal(inspection.texture,second); assert.equal(first.userData.disposals,1); // Framing and distance remain manual through another AI result. for (const [id,value] of [['#photo-zoom','2'],['#photo-pan-x','0.4'],['#photo-pan-y','-0.2'],['#photo-glass-distance','21']]) {el(id).value=value;el(id).dispatch('input');} el('#ai-depth').dispatch('click');await flush();provider.finish();await flush(); assert.equal(el('#photo-zoom').value,'2.00'); assert.equal(el('#photo-pan-x').value,'0.40'); assert.equal(el('#photo-pan-y').value,'-0.20'); assert.equal(el('#photoGlassDistanceOut').value,'21%'); assertGap(.1576); // A late result cannot replace depth for a newly selected photo. el('#ai-depth').dispatch('click');await flush();const stale=provider.finish(); el('#image').files=[{type:'image/png'}];el('#image').onchange({target:el('#image')});await flush(); assert.equal(stale.userData.disposals,1); assert.notEqual(el('#flow-overlay').dataset.journey,'arrived','stale success must not arrive for replacement photo'); loader.apply(new THREE.Texture(new FakeImage()),.8,null);await flush();const latest=provider.finish();await flush(); assert.equal(inspection.texture,latest); assert.equal(el('#photoGlassDistanceOut').value,'21%'); assertGap(.1576); // Browser image decoding and canvas pixels are doubles; the production manual-depth // handler and texture/provenance state execute unchanged. globalThis.document={createElement:()=>new Element()}; el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})]; el('#depth-image').dispatch('change');await flush(); assert.equal(el('#depth-provenance').hidden,false,'manual provenance stays visible outside developer tools'); assert.match(el('#depth-provenance').textContent,/직접 올린.*AI 추정 아님/); assert.equal(el('#ai-depth').disabled,false); assertGap(.1576); delete globalThis.document; // A manual depth upload during pending inference cancels inference and exits loading. el('#ai-depth').dispatch('click');await flush();assert.equal(el('#experience').dataset.flow,'loading'); globalThis.document={createElement:()=>new Element()};el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})];el('#depth-image').dispatch('change');await flush();delete globalThis.document; assert.equal(el('#experience').dataset.flow,'ready','successful manual depth must recover the actual experience state'); assert.equal(el('#flow-overlay').dataset.journey,'idle','manual depth does not claim an AI arrival'); // Failed replacement must never offer a retry/flat action that silently uses the prior photo. el('#image').files=[{type:'image/png'}];el('#image').onchange({target:el('#image')});loader.status('이미지를 읽지 못했습니다.');await flush();assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,true);assert.equal(el('#flow-flat').hidden,true); // A video remains flat, with AI disabled. const callsBeforeVideo=provider.calls.length; loader.apply(new THREE.Texture({}),.8,{});await flush(); assert.equal(el('#ai-depth').disabled,true); assert.equal(provider.calls.length,callsBeforeVideo); loader.status('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');assert.equal(el('#experience').dataset.flow,'error');assert.equal(el('#flow-retry').hidden,false,'video playback gets a retry');assert.equal(el('#flow-flat').hidden,true); loader.status('');assert.equal(el('#experience').dataset.flow,'ready','successful retry status restores the video scene'); // Project the actual Three.js frame assemblies at neutral view: no screenshot/render claim. for (const [width,height] of [[375,812],[768,1024],[1440,900]]) for (const photoAspect of [.25,1,4]) { el('#canvas-wrap').width=width;el('#canvas-wrap').height=height;resizeCallback(); loader.apply(new THREE.Texture(new FakeImage()),photoAspect,null);await flush();provider.finish();await flush(); el('#reset').onclick();renderOptics(); const frameDraw=renderer.draws.find(draw=>draw.target===null&&draw.objects.some(object=>object.isGroup)); const points=[]; for(const object of frameDraw.objects) {object.updateMatrixWorld(true);object.traverseVisible(child=>{const positions=child.geometry?.getAttribute('position');if(!positions)return;for(let i=0;ip.y)),maxY=Math.max(...points.map(p=>p.y)),maxX=Math.max(...points.map(p=>Math.abs(p.x))); assert.ok(minY>=-1&&maxY<=1&&maxX<=1,`neutral full assembly fits ${width}x${height}, photo aspect ${photoAspect}: ${minY},${maxY},${maxX}`); } // Execute production loss/recovery integration with DOM/WebGL boundaries doubled. el('#reset').onclick();const beforeLoss=renderOptics();const callsBeforeLoss=provider.calls.length; el('#comparison-toggle').dispatch('click');await flush();frame(performance.now()+1000); assert.equal(el('#comparison-overlay').hidden,false); renderer.lost=true; renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}}); assert.equal(el('#render-recovery').open,true);assert.equal(el('#canvas-wrap').inert,true); assert.equal(el('#comparison-overlay').hidden,true,'context loss ends the extra renderer'); assert.equal(renderer.internals.at(-1).userData.disposed,0,'B stays selected/available through loss until restore invalidates its targets'); renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'lost context receives no frame draws'); assert.equal(pointer.zoom.isEnabled(),false,'loss blocks pointer and gyro through experience state'); // A picker can have been opened before context loss, and return its result afterward. const loadsBeforeLoss=loader.loads; for(const id of ['#image','#video']) {el(id).files=[{type:'image/png'}];el(id).onchange({target:el(id)});} assert.equal(loader.loads,loadsBeforeLoss,'late picker changes cannot start media during render loss'); assert.equal(el('#experience').dataset.flow,'ready','late picker changes cannot trap the restored scene in loading'); renderer.lost=false;renderer.domElement.dispatch('webglcontextrestored');await flush(); assert.equal(el('#comparison-overlay').hidden,true,'restoration never silently restarts double rendering'); assert.equal(el('#render-recovery').open,false);assert.equal(el('#canvas-wrap').inert,false); assert.equal(provider.calls.length,callsBeforeLoss,'restoration never automatically restarts AI'); const restored=renderOptics();assert.equal(restored.depth,beforeLoss.depth);assert.equal(restored.sourceZ,beforeLoss.sourceZ); assert.deepEqual(restored.sceneOffset.toArray(),beforeLoss.sceneOffset.toArray()); el('#ai-depth').dispatch('click');await flush();assert.equal(provider.pending.length,1); renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush(); assert.equal(provider.pending.length,0);assert.equal(el('#experience').dataset.flow,'error'); assert.equal(el('#ai-depth').disabled,true);renderer.domElement.dispatch('webglcontextrestored');await flush(); assert.equal(el('#ai-depth').disabled,false);assert.equal(provider.pending.length,0,'interrupted AI waits for explicit retry'); el('#flow-retry').dispatch('click');await flush();const pendingAtLoss=provider.finish(); renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush(); assert.equal(pendingAtLoss.userData.disposals,1,'a transferred but not yet applied stale result is disposed'); renderer.domElement.dispatch('webglcontextrestored');await flush(); el('#depth-image').value='C:/fakepath/depth.png';el('#depth-image').files=[new Blob(['depth'],{type:'image/png'})]; el('#depth-image').dispatch('change');renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});await flush(); assert.equal(el('#depth-image').value,'','interrupted manual depth can be reselected after recovery'); renderer.domElement.dispatch('webglcontextrestored');await flush(); // Normal B flow remains independent after leaving the explicit A regression. el('#effect').value='frosted';el('#effect').dispatch('change');assert.equal(el('#pattern-cards').hidden,true); el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush(); assert.equal(el('#pattern-cards').hidden,false,'returning from A Frosted to B restores the Gobang card'); const drawB=(advance=16)=>{if(animationNow!==null)animationNow+=advance;sampleFrame(animationNow??performance.now()+16);return renderer.draws.find(draw=>draw.objects.some(object=>object.material?.isMeshPhysicalMaterial));}; // Trigger a fresh source upload before injecting a shader failure: idle frames intentionally never compile/draw. const injectMeshFailure=()=>{const photo=renderer.draws.find(draw=>draw.photo)?.photo;assert.ok(photo);photo.needsUpdate=true;renderer.failNextMesh=true;}; let bDraw=drawB();assert.ok(bDraw);assert.equal(renderer.draws.some(draw=>draw.gloss!==undefined),false); for(const value of ['a','b','a']){el('#glass-renderer').value=value;el('#glass-renderer').dispatch('change');} await flush();assert.equal(el('#glass-renderer').value,'a');assert.ok(renderOptics(),'latest A intent survives stale A-B-A lazy completions'); el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.ok(drawB()); el('#comparison-toggle').dispatch('click');await flush();assert.equal(el('#comparison-overlay').hidden,false); el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush(); assert.equal(el('#comparison-overlay').hidden,true);assert.ok(renderOptics(),'B comparison to A retains the incoming renderer'); el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.ok(drawB()); const beforeBDepth=inspection.texture; el('#photo-glass-distance').value='16';el('#photo-glass-distance').dispatch('input'); pointer.zoom.setZoom(2,{x:.4,y:-.2});bDraw=drawB(); assert.equal(bDraw.camera.zoom,2);assert.equal(bDraw.depth,beforeBDepth); const bGlass=bDraw.objects.find(object=>object.material?.isMeshPhysicalMaterial),bPhoto=bDraw.objects.find(object=>object.material?.uniforms?.uDepthMap); const maxB=bPhoto.material.uniforms.uDepthMap.value.image.data.reduce((n,v,i)=>i%4===0?Math.max(n,v/255):n,0); assert.ok(Math.abs(bGlass.position.z-bPhoto.position.z-maxB*.25-.1296)<1e-9,'default B keeps the selected closest-surface gap'); el('#reset').onclick();assert.equal(drawB().camera.zoom,1);assert.equal(el('#photoGlassDistanceOut').value,'16%'); loader.apply(new THREE.Texture(new FakeImage()),1.5,null);await flush();renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0); const newB=provider.finish();await flush();assert.equal(drawB().depth,newB,'B receives replacement photo/depth after AI'); renderer.lost=true;renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0); renderer.lost=false;renderer.domElement.dispatch('webglcontextrestored');await flush();assert.equal(el('#glass-renderer').value,'b');assert.equal(drawB().depth,newB,'context restoration retains B and its depth'); injectMeshFailure();drawB();assert.equal(el('#mesh-render-error').open,true);assert.equal(el('#glass-renderer').value,'b','shader failure does not silently choose A'); renderer.draws=[];frame(performance.now()+16);assert.equal(renderer.draws.length,0,'failure waits for an explicit choice'); el('#mesh-retry').dispatch('click');assert.equal(el('#mesh-render-error').open,false);assert.ok(drawB(),'explicit retry rebuilds B'); injectMeshFailure();drawB();el('#mesh-use-a').dispatch('click');await flush();assert.equal(el('#glass-renderer').value,'a');assert.equal(el('#mesh-render-error').open,false);assert.ok(renderOptics(),'A requires the user recovery choice'); // Delayed real-geometry asset load enables door mechanics without another AI run. el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush(); drawB();if(el('#mesh-render-error').open)el('#mesh-retry').dispatch('click'); fetchFrameAllowed=true;el('#frame-retry').dispatch('click');await flush(); const roomCallsBefore=provider.calls.length,roomDepthBefore=inspection.texture; assert.equal(el('#house-retry').hidden,false,'failed optional room has an explicit retry without blocking the photo'); el('#house-retry').dispatch('click');await flush();drawB(); const houseRoot=renderer.draws.flatMap(draw=>draw.objects).find(object=>object.name==='blender-house-surround'); const houseDepth=renderer.draws.flatMap(draw=>draw.objects).find(object=>object.name==='house-photo-depth-occluder'); assert.ok(houseRoot?.visible);assert.equal(houseDepth.visible,true);assert.equal(houseDepth.material.uniforms.uDepthMap.value,roomDepthBefore); assert.equal(provider.calls.length,roomCallsBefore,'optional house retry does not start inference');assert.equal(el('#house-retry').hidden,true); el('#settings-dialog').close();drawB(); // At the corner pose, distance/settings/resize must not re-scale the photo. animationNow=performance.now()+2000;el('#camera-offset').value='0.75';el('#camera-offset').dispatch('input'); const neutralPhotoScale=drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.clone(); pointer.move(2,0);for(let i=0;i<180;i++)drawB(16);let closeView=drawB(); assert.equal(closeView.camera.fov,40,'new default keeps a fixed lens rather than fitting the doorway'); const closeDirection=closeView.camera.getWorldDirection(new THREE.Vector3());assert.ok(Math.abs(Math.atan2(-closeDirection.z,Math.abs(closeDirection.x))*180/Math.PI-25)<1e-6,'default maximum drag uses the approved wall-hug gaze'); const closePhoto=closeView.objects.find(o=>o.material?.uniforms?.uDepthMap);assert.deepEqual(closePhoto.scale.toArray(),neutralPhotoScale.toArray()); const framingBeforeResize=closePhoto.scale.toArray();resizeCallback();assert.deepEqual(drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.toArray(),framingBeforeResize,'same-size resize at close viewpoint must not crop photo'); el('#photo-glass-distance').value='16';el('#photo-glass-distance').dispatch('input');assert.deepEqual(drawB().objects.find(o=>o.material?.uniforms?.uDepthMap).scale.toArray(),framingBeforeResize,'reapplying distance at close viewpoint uses neutral geometry'); el('#reset').onclick();drawB(); assert.equal(el('#door-controls').hidden,false);assert.equal(renderer.localClippingEnabled,true); animationNow=performance.now()+2000; const beforeDoorPhoto=drawB().position.clone(); const glassPosition=()=>renderer.draws.find(draw=>draw.objects.some(o=>o.material?.isMeshPhysicalMaterial)).objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x; el('#door-toggle').dispatch('click');drawB(0); assert.equal(glassPosition(),0,'a discrete open request starts at the rendered position, not its target'); drawB(175);const halfOpen=glassPosition();assert.ok(halfOpen<0); drawB(175);assert.ok(glassPosition()o.material?.isMeshPhysicalMaterial); assert.ok(slidingGlass.position.x<0);assert.deepEqual(bDraw.position.toArray(),beforeDoorPhoto.toArray(),'opening leaves the photo fixed'); const partialSlide=slidingGlass.position.x; el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();renderOptics();assert.equal(el('#door-controls').hidden,true);assert.match(el('#door-notice').textContent,/A 진단.*닫힌/); el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide); el('#comparison-toggle').dispatch('click');await flush();drawB();assert.equal(el('#door-controls').hidden,true);assert.match(el('#door-notice').textContent,/A·B 비교.*닫힌/); el('#comparison-exit').dispatch('click');assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide); renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});frame(performance.now()+16);assert.equal(el('#door-controls').hidden,true); renderer.domElement.dispatch('webglcontextrestored');assert.equal(drawB().objects.find(o=>o.material?.isMeshPhysicalMaterial).position.x,partialSlide); el('#door-toggle').dispatch('keydown',{key:'End',preventDefault(){}});bDraw=drawB();assert.ok(bDraw.objects.find(o=>o.material?.isMeshPhysicalMaterial).position.xo.material?.isMeshPhysicalMaterial).position.x,0); // Ordinary sub-threshold tap jitter still toggles from the start position. let handleX=parseFloat(el('#door-handle').style.left),handleY=parseFloat(el('#door-handle').style.top); assert.equal(pointer.zoom.subjectDrag.begin({x:handleX,y:handleY}),true); pointer.zoom.subjectDrag.move({x:handleX-.5,y:handleY});pointer.zoom.subjectDrag.end(false); assert.ok(drawB(350).objects.find(o=>o.material?.isMeshPhysicalMaterial).position.xo.material?.isMeshPhysicalMaterial).position.x<0,'cancel retains reached partial position'); pointer.cancelled=false;documentRoot.hidden=true;documentEvents.dispatch('visibilitychange');assert.equal(pointer.cancelled,true,'background cancels door/view interaction');documentRoot.hidden=false; pointer.cancelled=false;resizeCallback();assert.equal(pointer.cancelled,true,'resize cancels stale world-space drag anchors'); // Deterministic production-main animation/interrupt regressions. GL and DOM remain doubles. const keyDoor=key=>el('#door-toggle').dispatch('keydown',{key,preventDefault(){}}); const closeDoor=()=>{keyDoor('Home');drawB(350);assert.equal(glassPosition(),0);}; const near=(a,b,message)=>assert.ok(Math.abs(a-b)<1e-10,message??`${a} != ${b}`); const verifySynchronized=()=>{ const draw=renderer.draws.find(d=>d.objects.some(o=>o.material?.isMeshPhysicalMaterial)); const glass=draw.objects.find(o=>o.material?.isMeshPhysicalMaterial); const sash=renderer.draws.flatMap(d=>d.objects).map(o=>o.getObjectByName('MovableSash')).find(Boolean); assert.ok(sash);near(sash.position.x,glass.position.x,'sash and actual pane share every interpolated position'); const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};glass.material.onBeforeCompile(shader,{}); near(shader.uniforms.uBPaneCenterX.value,glass.position.x,'translated shader exits track the visible pane'); const pull=sash.getObjectByName('BrassLatch').getWorldPosition(new THREE.Vector3()).project(draw.camera); near(parseFloat(el('#door-handle').style.left),(pull.x+1)*renderer.domElement.width/2,'44px hit target tracks moving brass pull'); near(parseFloat(el('#door-handle').style.top),(1-pull.y)*renderer.domElement.height/2); assert.deepEqual(draw.position.toArray(),beforeDoorPhoto.toArray(),'animation does not move the photo'); return glass.position.x; }; closeDoor();el('#door-toggle').dispatch('click');drawB(0);verifySynchronized(); let previous=0;for(let i=0;i<10;i++){drawB(35);const x=verifySynchronized();assert.ok(xreversingFrom);const openingAgain=glassPosition(); el('#door-toggle').dispatch('click');drawB(0);assert.equal(glassPosition(),openingAgain); drawB(350);near(glassPosition(),fullSlide); closeDoor();keyDoor('ArrowLeft');keyDoor('ArrowLeft');keyDoor('ArrowLeft');drawB(350);near(glassPosition(),fullSlide*.3,'key repeats accumulate target increments'); // A manual grab interrupts the trajectory at the visible handle, without jumping. closeDoor();keyDoor('End');drawB(140);const grabbed=glassPosition(); handleX=parseFloat(el('#door-handle').style.left);handleY=parseFloat(el('#door-handle').style.top); assert.equal(pointer.zoom.subjectDrag.begin({x:handleX,y:handleY}),true);drawB(700);assert.equal(glassPosition(),grabbed); pointer.zoom.subjectDrag.move({x:handleX-24,y:handleY});const manualPosition=glassPosition(); assert.ok(manualPositioncaptures.add(id),hasPointerCapture:id=>captures.has(id),releasePointerCapture:id=>captures.delete(id)}); const realPointer=new pointerModule.PointerController(renderer.domElement,pointer.move,undefined,pointer.zoom); const touch=(type,id,x,y)=>renderer.domElement.dispatch(type,{pointerId:id,pointerType:'touch',button:0,clientX:x,clientY:y}); closeDoor();keyDoor('End');drawB(105);const pinchFrozen=glassPosition(); touch('pointerdown',1,100,100);touch('pointerdown',2,200,100);drawB(700);assert.equal(glassPosition(),pinchFrozen,'pinch outside handle cancels button motion'); touch('pointerup',2,200,100);touch('pointerup',1,100,100); handleX=parseFloat(el('#door-handle').style.left);handleY=parseFloat(el('#door-handle').style.top); touch('pointerdown',3,handleX,handleY);touch('pointermove',3,handleX-24,handleY);const beforePinch=glassPosition(); touch('pointerdown',4,handleX-90,handleY);touch('pointerup',4,handleX-90,handleY);touch('pointermove',3,handleX-40,handleY);drawB(700);assert.equal(glassPosition(),beforePinch,'pinch handoff does not resume dragging from stale anchors');touch('pointerup',3,handleX-40,handleY); realPointer.dispose(); // Every interruption holds the last frame even after a long gap and restoration. for(const reason of ['background','resize','settings','renderer','comparison','theme','diagnostic','context','mesh failure']){ closeDoor();keyDoor('End');drawB(105);const held=glassPosition(); if(reason==='background'){documentRoot.hidden=true;documentEvents.dispatch('visibilitychange');animationNow+=5000;documentRoot.hidden=false;documentEvents.dispatch('visibilitychange');} if(reason==='resize'){resizeCallback();animationNow+=5000;} if(reason==='settings'){el('#settings-open').dispatch('click');animationNow+=5000;el('#settings-close').dispatch('click');} if(reason==='renderer'){el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();animationNow+=5000;el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();} if(reason==='comparison'){el('#comparison-toggle').dispatch('click');await flush();drawB(5000);el('#comparison-exit').dispatch('click');} if(reason==='theme'){el('#theme-select').value='booth';el('#theme-select').dispatch('change');drawB(5000);el('#theme-select').value='wood';el('#theme-select').dispatch('change');} if(reason==='diagnostic'){el('#view-mode').value='noglass';el('#view-mode').dispatch('change');drawB(5000);el('#view-mode').value='final';el('#view-mode').dispatch('change');} if(reason==='context'){renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});drawB(5000);assert.equal(renderer.draws.length,0);renderer.domElement.dispatch('webglcontextrestored');} if(reason==='mesh failure'){injectMeshFailure();drawB(0);assert.equal(el('#mesh-render-error').open,true);drawB(5000);assert.equal(renderer.draws.length,0);el('#mesh-retry').dispatch('click');} drawB(0);assert.equal(glassPosition(),held,`${reason} must not finish or restart its stale motion`);drawB(700);assert.equal(glassPosition(),held); } motionPreference.matches=true;keyDoor('Home');drawB(0);assert.equal(glassPosition(),0);keyDoor('End');drawB(0);near(glassPosition(),fullSlide,'reduced-motion request is immediate'); motionPreference.matches=false;keyDoor('Home');drawB(105);assert.ok(glassPosition()<0);motionPreference.matches=true;drawB(0);assert.equal(glassPosition(),0,'live reduced-motion change settles on next scene frame'); motionPreference.matches=false; // Room geometry uses the existing camera/zoom and never resets photo analysis or framing. pointer.zoom.setZoom(2,{x:.4,y:-.2});const roomBefore=drawB(),roomProjection=roomBefore.camera.projectionMatrix.elements.slice(),roomPhoto=roomBefore.photo; const roomMatrix=houseRoot.matrixWorld.toArray(),roomInference=provider.calls.length; el('#theme-select').value='booth';el('#theme-select').dispatch('change');const boothRoom=drawB(); assert.equal(houseRoot.visible,false);assert.equal(houseDepth.visible,false);assert.equal(boothRoom.camera.zoom,2);assert.equal(boothRoom.photo,roomPhoto);assert.equal(boothRoom.depth,roomBefore.depth); assert.equal(boothRoom.camera.projectionMatrix.elements[8],roomProjection[8]);assert.equal(boothRoom.camera.projectionMatrix.elements[9],roomProjection[9]); el('#theme-select').value='wood';el('#theme-select').dispatch('change');const restoredRoom=drawB();assert.equal(houseRoot.visible,true);assert.equal(houseDepth.visible,true);assert.deepEqual(restoredRoom.scale.toArray(),roomBefore.scale.toArray());assert.deepEqual(houseRoot.matrixWorld.toArray(),roomMatrix); assert.equal(provider.calls.length,roomInference);el('#scene-zoom-reset').dispatch('click');assert.equal(drawB().camera.zoom,1); el('#view-mode').value='glass';el('#view-mode').dispatch('change');await flush();frame(animationNow);assert.equal(houseDepth.visible,false,'Glass Only never seeds a hidden photo into the room depth buffer'); el('#view-mode').value='final';el('#view-mode').dispatch('change');el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();drawB();assert.equal(houseDepth.visible,true); renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}});renderer.domElement.dispatch('webglcontextrestored');drawB();assert.equal(houseRoot.visible,true);assert.equal(provider.calls.length,roomInference,'context restoration never reloads AI for the room'); // Explicit photo adjustment changes shared framing, never the camera or scene zoom. el('#flow-flat').dispatch('click');el('#reset').dispatch('click');drawB(0); const editBefore=drawB(),editCamera=editBefore.camera.matrixWorld.toArray(),editProjection=editBefore.camera.projectionMatrix.toArray(),editCalls=provider.calls.length; el('#photo-adjust-open').dispatch('click');assert.equal(el('#experience').dataset.photoAdjust,'true'); pointer.zoom.setZoom(2,{x:0,y:0},{x:0,y:0});pointer.move(.05,.04); const editAfter=drawB();assert.equal(el('#photo-zoom').value,'2.00');assert.notEqual(el('#photo-pan-x').value,'0.00'); assert.deepEqual(editAfter.camera.matrixWorld.toArray(),editCamera);assert.deepEqual(editAfter.camera.projectionMatrix.toArray(),editProjection);assert.equal(provider.calls.length,editCalls); assert.equal(pointer.zoom.subjectDrag.begin({x:480,y:300}),true);pointer.zoom.subjectDrag.move({x:490,y:310});pointer.zoom.subjectDrag.end(false); el('#photo-adjust-done').dispatch('click');assert.equal(el('#experience').dataset.photoAdjust,undefined); const preserved=el('#photo-pan-x').value;pointer.zoom.setZoom(1.5,{x:0,y:0});assert.equal(el('#photo-pan-x').value,preserved);assert.equal(drawB().camera.zoom,1.5); el('#scene-zoom-reset').dispatch('click');el('#photo-reset').dispatch('click');drawB(); // Real main opt-in integration. The experiment's preview/segmentation boundary // supplies an accepted result; original RGB, depth, composition and render state // below execute unchanged. This is CPU state/geometry QA, not browser pixels. assert.ok(peekingCallbacks?.onAccept&&peekingCallbacks?.onToggle); assert.equal(peekingState.available,false,'ordinary uploads never silently enable the experiment'); assert.equal(peekingState.enabled,false); const shaderFor=draw=>{ const material=draw.objects.find(object=>object.material?.isMeshPhysicalMaterial).material; const shader={uniforms:{},fragmentShader:THREE.ShaderLib.physical.fragmentShader};material.onBeforeCompile(shader,{});return shader; }; const photoObject=draw=>draw.objects.find(object=>object.material?.uniforms?.uDepthMap); const assertFullPhoto=(draw,texture,depth)=>{ assert.equal(draw.photo,texture,'fallback displays the whole original RGB texture'); assert.equal(draw.depth,depth,'fallback reuses the accepted original-RGB depth'); assert.equal(photoObject(draw).material.uniforms.uUsePhotoAlpha.value,false,'fallback is the original opaque photo path'); assert.equal(shaderFor(draw).uniforms.uYardEnabled.value,0,'fallback disables the courtyard ray path'); const yard=draw.objects.find(object=>object.name==='experimental-courtyard'); if(yard)assert.equal(yard.visible,false,'fallback hides courtyard raster geometry'); }; const assertIdle=label=>{ // Complete camera settling and any finite history work before measuring. for(let i=0;i<240;i++){animationNow+=1000/60;frame(animationNow);} renderer.draws=[]; for(let i=0;i<600;i++){animationNow+=1000/60;frame(animationNow);} assert.equal(renderer.draws.length,0,`${label}: ten seconds of idle submit no scene draws`); }; const trajectory=()=>{ // Establish an exact integer timestamp before repeated 16 ms steps. Idle // harness frames use 1000/60 and otherwise accumulate floating-point dt drift. animationNow=Math.round(animationNow??performance.now()); const poses=[]; for(const [x,y] of [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]]){ el('#reset').onclick();drawB(0);pointer.move(x,y); for(let i=0;i<90;i++){ const camera=drawB(16).camera; poses.push([...camera.position.toArray(),...camera.quaternion.toArray(),camera.near,...camera.projectionMatrix.elements]); } } el('#reset').onclick();drawB(0);return poses; }; const legacyTrajectory=trajectory(); assertIdle('ordinary B before opt-in'); const prepared={ originalBlob:new Blob(['complete unmasked RGB fixture'],{type:'image/png'}), originalImage:new FakeImage(),cutoutImage:new FakeImage(),width:4,height:4, alpha:new Uint8Array([255,255,255,255,255,255,255,255,64,0,0,0,0,0,0,0]), bounds:{x0:0,y0:0,x1:4,y1:2},disposals:0,dispose(){this.disposals++;} }; prepared.originalImage.src='blob:accepted-original';prepared.cutoutImage.src='blob:accepted-alpha'; const inferenceBeforeAccept=provider.calls.length; peekingCallbacks.onAccept(prepared); assert.equal(provider.calls.length,inferenceBeforeAccept,'acceptance first decodes the original file'); assert.equal(await loader.lastFile.text(),await prepared.originalBlob.text(),'the loader receives original PNG bytes, never the cutout preview'); assert.equal(peekingState.busy,true); const acceptedRGB=new THREE.Texture(prepared.originalImage); loader.apply(acceptedRGB,1.5,null);await flush(); assert.equal(provider.calls.length,inferenceBeforeAccept+1); assert.equal(provider.calls.at(-1).image,prepared.originalImage,'Depth Anything receives the unmasked original image'); assert.notEqual(provider.calls.at(-1).image,prepared.cutoutImage); peekingCallbacks.onToggle(false); assert.equal(peekingState.enabled,true,'busy inference blocks toggle changes'); const acceptedDepth=provider.finish();await flush(); let acceptedDraw=drawB();const acceptedPhoto=photoObject(acceptedDraw),acceptedCutout=acceptedDraw.photo; assert.equal(acceptedCutout.image,prepared.cutoutImage,'accepted alpha becomes the B photo texture'); assert.equal(acceptedPhoto.material.uniforms.uUsePhotoAlpha.value,true); assert.equal(shaderFor(acceptedDraw).uniforms.uBUsePhotoAlpha.value,true); assert.equal(shaderFor(acceptedDraw).uniforms.uYardEnabled.value,1); assert.equal(acceptedDraw.objects.find(object=>object.name==='experimental-courtyard')?.visible,true,'B raster and refraction share the enabled courtyard'); assert.equal(houseDepth.material.uniforms,acceptedPhoto.material.uniforms,'room depth occluder shares the actual alpha/depth uniforms'); assert.equal(acceptedDraw.depth,acceptedDepth); assert.ok(Math.abs(acceptedDraw.position.z-(.6-.1296-.25))<1e-10,'conservative full-depth maximum keeps every soft-alpha interpolated triangle behind glass'); assert.ok(acceptedDraw.position.z+.25<=.6-.1296+1e-10,'alpha=64 edge at maximum depth preserves the original gap'); assert.equal(peekingState.available,true);assert.equal(peekingState.enabled,true);assert.equal(peekingState.busy,false); assert.equal(prepared.disposals,0,'an applied result remains owned by the scene'); const courtyardTrajectory=trajectory();assert.equal(courtyardTrajectory.length,720);assert.ok(courtyardTrajectory.every(pose=>pose.every(Number.isFinite)),'all actual courtyard camera/projection poses remain finite');assert.notDeepEqual(courtyardTrajectory,legacyTrajectory,'courtyard corner path uses physical door dimensions');assert.deepEqual(drawB().camera.position.toArray(),[0,0,3],'courtyard reset remains neutral'); assertIdle('experimental B'); const inferenceAfterAccept=provider.calls.length; // Camera rollback is immediate and never reloads/reframes the accepted photo. pointer.zoom.setZoom(2.3,{x:.5,y:-.4});pointer.move(.8,.3);for(let i=0;i<180;i++)drawB(16); const beforeComposition=drawB(),compositionInference=provider.calls.length; for(const composition of ['legacy','wall','legacy','wall']){ el('#camera-composition').value=composition;el('#camera-composition').dispatch('change'); const after=drawB();assert.equal(after.photo,beforeComposition.photo);assert.equal(after.depth,beforeComposition.depth); assert.deepEqual(after.position.toArray(),beforeComposition.position.toArray());assert.deepEqual(after.scale.toArray(),beforeComposition.scale.toArray()); assert.equal(after.camera.zoom,beforeComposition.camera.zoom);for(const index of [8,9])assert.equal(after.camera.projectionMatrix.elements[index],beforeComposition.camera.projectionMatrix.elements[index]); assert.equal(provider.calls.length,compositionInference,'camera switch never repeats inference'); } assert.match(el('#camera-composition-note').textContent,/이번 선택이 유지되지 않을 수/,'storage failure clearly describes reload behavior'); el('#reset').onclick();drawB(); // Toggling resets only the user-facing photo placement controls and reuses RGB/depth. for(const [id,value] of [['#photo-zoom','1.7'],['#photo-pan-x','.6'],['#photo-pan-y','-.4']]){el(id).value=value;el(id).dispatch('input');} peekingCallbacks.onToggle(false);let originalDraw=drawB();assertFullPhoto(originalDraw,acceptedRGB,acceptedDepth); assert.equal(peekingState.available,true);assert.equal(peekingState.enabled,false); for(const [id,value] of [['#photo-zoom','1.00'],['#photo-pan-x','0.00'],['#photo-pan-y','0.00']])assert.equal(el(id).value,value,'toggle resets photo placement'); const restoredTrajectory=trajectory();assert.equal(restoredTrajectory.length,legacyTrajectory.length); const trajectoryError=Math.max(...restoredTrajectory.flatMap((pose,i)=>pose.map((value,j)=>Math.abs(value-legacyTrajectory[i][j])))); assert.deepEqual(restoredTrajectory,legacyTrajectory,'leaving courtyard restores the original-photo physical room path'); console.log('Restored original-photo trajectory maximum error:',trajectoryError);assertIdle('full-photo B fallback'); peekingCallbacks.onToggle(true);acceptedDraw=drawB();assert.equal(acceptedDraw.photo,acceptedCutout,'re-enabling reuses the accepted cutout'); assert.equal(provider.calls.length,inferenceAfterAccept,'toggles perform no new depth inference'); // Theme and renderer fallbacks retain the original, with no experiment geometry. el('#theme-select').value='booth';el('#theme-select').dispatch('change');assertFullPhoto(drawB(),acceptedRGB,acceptedDepth); el('#theme-select').value='wood';el('#theme-select').dispatch('change');assert.equal(drawB().photo,acceptedCutout); el('#glass-renderer').value='a';el('#glass-renderer').dispatch('change');await flush();renderOptics(); const fallbackA=renderer.draws.find(draw=>draw.target?.depthTexture); assert.equal(fallbackA.photo,acceptedRGB,'preserved A uses the complete original photo');assert.equal(fallbackA.depth,acceptedDepth); assert.equal(photoObject(fallbackA).material.uniforms.uUsePhotoAlpha.value,false); el('#glass-renderer').value='b';el('#glass-renderer').dispatch('change');await flush();assert.equal(drawB().photo,acceptedCutout); el('#comparison-toggle').dispatch('click');await flush();drawB(); const comparisonPhotos=renderer.draws.filter(draw=>draw.photo); assert.ok(comparisonPhotos.length>=2);assert.ok(comparisonPhotos.every(draw=>draw.photo===acceptedRGB),'both comparison panes fall back to complete original RGB'); assert.ok(comparisonPhotos.every(draw=>draw.depth===acceptedDepth)); el('#comparison-exit').dispatch('click');assert.equal(drawB().photo,acceptedCutout); renderer.domElement.dispatch('webglcontextlost',{preventDefault(){}}); assert.equal(peekingState.busy,true); renderer.domElement.dispatch('webglcontextrestored');await flush();assert.equal(drawB().photo,acceptedCutout); assert.equal(provider.calls.length,inferenceAfterAccept,'theme/renderer/comparison/context changes reuse inference'); // Replacing with an ordinary photo releases the experimental result and alpha texture. let cutoutDisposals=0;acceptedCutout.addEventListener('dispose',()=>cutoutDisposals++); // A successfully accepted new photo clears per-photo interaction state; opening a picker does not. for(const [id,value] of [['#photo-zoom','2.2'],['#photo-pan-x','.6'],['#photo-pan-y','-.4']]){el(id).value=value;el(id).dispatch('input');} pointer.zoom.setZoom(2.4,{x:.4,y:-.2});pointer.move(.4,.2);drawB(100);keyDoor('End');drawB(700); const dirtyReplacementView=drawB();assert.equal(dirtyReplacementView.camera.zoom,2.4);assert.notEqual(dirtyReplacementView.camera.projectionMatrix.elements[8],0);assert.notDeepEqual(dirtyReplacementView.camera.position.toArray(),[0,0,3]);assert.notEqual(glassPosition(),0); const disconnectsBeforeReplacement=gyroDisconnects; // Cancelled picker and rejected type must not erase the existing adjustments. el('#image').files=[];el('#image').onchange({target:el('#image')});assert.equal(el('#photo-zoom').value,'2.20');assert.equal(gyroDisconnects,disconnectsBeforeReplacement); el('#image').files=[new File(['bad'],'note.txt',{type:'text/plain'})];el('#image').onchange({target:el('#image')});assert.equal(el('#photo-zoom').value,'2.20');assert.equal(gyroDisconnects,disconnectsBeforeReplacement); el('#image').files=[new File(['replacement'],'replacement.png',{type:'image/png'})];el('#image').onchange({target:el('#image')}); assert.equal(gyroDisconnects,disconnectsBeforeReplacement); const replacementRGB=new THREE.Texture(new FakeImage());loader.apply(replacementRGB,1.5,null);assert.equal(gyroDisconnects,disconnectsBeforeReplacement+1);assert.equal(el('#photo-zoom').value,'1.00');assert.equal(el('#photo-pan-x').value,'0.00');assert.equal(el('#photo-pan-y').value,'0.00');assert.equal(el('#scene-zoom-reset').hidden,true);await flush(); const replacementDepth=provider.finish();await flush();assertFullPhoto(drawB(),replacementRGB,replacementDepth);const resetView=drawB();assert.equal(resetView.camera.zoom,1);assert.equal(Math.abs(resetView.camera.projectionMatrix.elements[8]),0);assert.equal(Math.abs(resetView.camera.projectionMatrix.elements[9]),0);assert.deepEqual(resetView.camera.position.toArray(),[0,0,3]);assert.equal(glassPosition(),0); assert.equal(prepared.disposals,1);assert.equal(cutoutDisposals,1); assert.equal(peekingState.available,false);assert.equal(peekingState.enabled,false); peekingCallbacks.onToggle(true);assertFullPhoto(drawB(),replacementRGB,replacementDepth); assert.equal(provider.calls.length,inferenceAfterAccept+1,'one ordinary replacement produces exactly one new inference'); assertIdle('ordinary B after replacement'); // View settings prevent the canvas keyboard route moving behind its controls. el('#settings-open').dispatch('click');el('#tab-view').dispatch('click'); assert.equal(pointer.zoom.isEnabled(),false,'movement panel suspends canvas gestures'); let consumedWhilePanelOpen=false; el('#canvas-wrap').dispatch('keydown',{key:'ArrowRight',preventDefault(){consumedWhilePanelOpen=true;}}); assert.equal(consumedWhilePanelOpen,false,'canvas keyboard view pauses with the movement panel open'); el('#settings-close').dispatch('click'); assert.equal(pointer.zoom.isEnabled(),true); let sharedPhotoDisposed=0;houseDepth.geometry.addEventListener('dispose',()=>sharedPhotoDisposed++); teardown();assert.equal(peekingBoundary.disposeCalls,1,'teardown releases the experiment UI boundary');assert.equal(sharedPhotoDisposed,1,'the depth-only room pass never takes ownership of shared photo geometry'); console.log('Main integration passed (default B recovery + preserved A + optional comparison lifecycle): fixed gloss, binary curvature, control limits, original distance mapping/persistence, automatic inference, full-width rendering/drag, inspector, repeat/failure/retry, manual controls/provenance, card reselect, keyboard, stale result and video handling; accepted original-RGB depth, cutout toggles and fallbacks, 720 per-mode physical-room camera poses, four 600-frame idle windows, replacement cleanup (no GPU execution)');