Import the 2026-10-09 source release, Fastify/SQLite backend, restored source assets, and analytics-enabled server build. Replace files from the previous release while retaining the existing repository history. Make builds portable across Windows and Linux, configure local startup, normalize Windows static asset paths, and fix test fixture cleanup and file URL handling. Exclude local environment settings, databases, dependencies, validation output, and TypeScript build cache from version control. Validation: npm ci, static and server builds, 106 arrival tests, and actual local HTTP checks passed. The full suite passed 467 of 470 tests; three tests still require QA fixtures omitted from the supplied release, documented in README-서버실행.md.
759 lines
73 KiB
JavaScript
759 lines
73 KiB
JavaScript
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 (/<aside(?:\s|>)/.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;i<positions.count;i++) points.push(new THREE.Vector3().fromBufferAttribute(positions,i).applyMatrix4(child.matrixWorld).project(frameDraw.camera));});}
|
||
const minY=Math.min(...points.map(p=>p.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()<halfOpen,'open passes through an intermediate position before the endpoint');
|
||
el('#door-toggle').dispatch('click');drawB(350);assert.equal(glassPosition(),0);
|
||
|
||
el('#door-toggle').dispatch('keydown',{key:'ArrowLeft',preventDefault(){}});
|
||
bDraw=drawB();let slidingGlass=bDraw.objects.find(o=>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.x<partialSlide);
|
||
el('#door-toggle').dispatch('keydown',{key:'Home',preventDefault(){}});assert.equal(drawB(350).objects.find(o=>o.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.x<partialSlide,'a jittery closed-handle tap opens fully');
|
||
el('#door-toggle').dispatch('click');drawB(350);
|
||
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-20,y:handleY});pointer.zoom.subjectDrag.end(true);
|
||
assert.ok(drawB().objects.find(o=>o.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(x<previous);previous=x;}
|
||
const fullSlide=previous;
|
||
// Reverse open-close-open between frames: no endpoint jump, and latest intent wins.
|
||
closeDoor();el('#door-toggle').dispatch('click');drawB(140);const reversingFrom=glassPosition();
|
||
el('#door-toggle').dispatch('click');drawB(0);assert.equal(glassPosition(),reversingFrom);
|
||
drawB(35);assert.ok(glassPosition()>reversingFrom);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(manualPosition<grabbed,'manual drag updates directly before any RAF');pointer.zoom.subjectDrag.end(false);drawB(700);assert.equal(glassPosition(),manualPosition,'partial release does not snap');
|
||
// Exercise the real pointer controller with production-main callbacks for pinch priority.
|
||
const pointerSource=await readFile(new URL('../src/input/PointerController.ts',import.meta.url),'utf8');const pointerModule={};
|
||
vm.runInNewContext(ts.transpileModule(pointerSource,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText,{exports:pointerModule});
|
||
const captures=new Set();Object.assign(renderer.domElement,{setPointerCapture:id=>captures.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)');
|