Replace project with current server release and Windows setup
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.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/** Capture Three-expanded courtyard/photo sources for optional offline compilers.
|
||||
* This script creates no WebGL context and does not claim browser or GPU QA. */
|
||||
import fs from 'node:fs';
|
||||
import * as THREE from 'three';
|
||||
import {WebGLProgram} from 'three/src/renderers/webgl/WebGLProgram.js';
|
||||
import {loadTypescript} from './load-typescript.mjs';
|
||||
const {ExperimentalCourtyard}=await loadTypescript(new URL('../../src/objects/ExperimentalCourtyard.ts',import.meta.url));
|
||||
const {PetDepthMesh}=await loadTypescript(new URL('../../src/objects/PetDepthMesh.ts',import.meta.url));
|
||||
const {createHousePhotoOccluder}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
|
||||
const courtyard=new ExperimentalCourtyard(),photo=new PetDepthMesh(new THREE.Texture(),new THREE.Texture()),occluder=createHousePhotoOccluder(photo);
|
||||
for(const [name,material] of [['courtyard',courtyard.group.children[0].material],['peeking-photo',photo.material],['peeking-occluder',occluder.mesh.material]]){
|
||||
const params={vertexShader:material.vertexShader,fragmentShader:material.fragmentShader,uniforms:material.uniforms,defines:material.defines,precision:'highp',shaderType:'ShaderMaterial',shaderName:name,isRawShaderMaterial:false,toneMapping:THREE.NoToneMapping,outputColorSpace:THREE.LinearSRGBColorSpace,numDirLights:0,numPointLights:0,numSpotLights:0,numSpotLightMaps:0,numRectAreaLights:0,numHemiLights:0,numDirLightShadows:0,numSpotLightShadows:0,numSpotLightShadowsWithMaps:0,numPointLightShadows:0,numClippingPlanes:0,numClipIntersection:0};
|
||||
const sentinel=new Error('offline-source-capture-complete');
|
||||
const gl={VERTEX_SHADER:0,FRAGMENT_SHADER:1,createProgram:()=>({}),createShader:type=>({type}),shaderSource:(shader,source)=>fs.writeFileSync(`/tmp/b-review-${name}.${shader.type===0?'vert':'frag'}`,source),compileShader:shader=>{if(shader.type===1)throw sentinel;}};
|
||||
try{new WebGLProgram({getContext:()=>gl},name,params,{});throw new Error(`Missing capture for ${name}`);}catch(error){if(error!==sentinel)throw error;console.log(`Captured r169 ${name} shader sources (no GL execution)`);}
|
||||
}
|
||||
occluder.dispose();courtyard.dispose();photo.geometry.dispose();photo.material.dispose();
|
||||
photo.material.uniforms.uTexture.value.dispose();photo.material.uniforms.uDepthMap.value.dispose();
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Captures Three-expanded sources only. No WebGL context, browser, or GPU execution. */
|
||||
import fs from 'node:fs';
|
||||
import * as THREE from 'three';
|
||||
import {WebGLProgram} from 'three/src/renderers/webgl/WebGLProgram.js';
|
||||
import {loadTypescript} from './load-typescript.mjs';
|
||||
const {attachMeshRefraction}=await loadTypescript(new URL('../../src/rendering/MeshRefraction.ts',import.meta.url));
|
||||
const material=new THREE.MeshPhysicalMaterial({transmission:1});attachMeshRefraction(material,new THREE.Mesh());const shader={vertexShader:THREE.ShaderLib.physical.vertexShader,fragmentShader:THREE.ShaderLib.physical.fragmentShader,uniforms:{}};material.onBeforeCompile(shader,{});
|
||||
for(const area of [0,1]){
|
||||
const params={...shader,defines:material.defines,precision:'highp',shaderType:'MeshPhysicalMaterial',shaderName:'OfflineReview',transmission:true,thicknessMap:true,thicknessMapUv:'uv',envMap:true,envMapMode:THREE.CubeUVReflectionMapping,combine:THREE.MultiplyOperation,envMapCubeUVHeight:128,toneMapping:THREE.NoToneMapping,outputColorSpace:THREE.LinearSRGBColorSpace,numDirLights:0,numPointLights:0,numSpotLights:0,numSpotLightMaps:0,numRectAreaLights:area,numHemiLights:0,numDirLightShadows:0,numSpotLightShadows:0,numSpotLightShadowsWithMaps:0,numPointLightShadows:0,numClippingPlanes:1,numClipIntersection:0};
|
||||
const gl={VERTEX_SHADER:0,FRAGMENT_SHADER:1,createProgram:()=>({}),createShader:type=>({type}),shaderSource:(shader,source)=>fs.writeFileSync(`/tmp/b-review-${area}.${shader.type===0?'vert':'frag'}`,source),compileShader:shader=>{if(shader.type===1)throw new Error('Captured both sources; deliberately no GL compiler or driver used');}};
|
||||
try{new WebGLProgram({getContext:()=>gl},'offline-review',params,{});}catch(e){if(!e.message.startsWith('Captured'))throw e;console.log('Captured r169 shader sources for '+area+' area lights')}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as R from './mesh-refraction-reference.mjs';
|
||||
const {v3,makeDepthTexture,depthAt,photoSurface,triangleOracle,tracePhotoDDA}=R;
|
||||
const F=Math.fround,A=(a,b)=>F(a+b),S=(a,b)=>F(a-b),M=(a,b)=>F(a*b),D=(a,b)=>F(a/b),V=a=>a.map(F),add=(a,b)=>a.map((x,i)=>A(x,b[i])),sub=(a,b)=>a.map((x,i)=>S(x,b[i])),mul=(a,b)=>a.map(x=>M(x,b)),dot=(a,b)=>A(A(M(a[0],b[0]),M(a[1],b[1])),M(a[2],b[2])),cross=(a,b)=>[S(M(a[1],b[2]),M(a[2],b[1])),S(M(a[2],b[0]),M(a[0],b[2])),S(M(a[0],b[1]),M(a[1],b[0]))],INF=F(1e20);
|
||||
function triangle(o,r,a,b,c){let e1=sub(b,a),e2=sub(c,a),p=cross(r,e2),det=dot(e1,p);if(det<=F(1e-9))return INF;let s=sub(o,a),u=D(dot(s,p),det);if(u<F(-.00001)||u>F(1.00001))return INF;let q=cross(s,e1),v=D(dot(r,q),det);if(v<F(-.00001)||A(u,v)>F(1.00001))return INF;let t=D(dot(e2,q),det);return t>=0?t:INF;}
|
||||
function shaderDDA(surface,origin,ray){const o=V(origin.toArray()),r=V(ray.toArray());let near=0,far=INF;const bounds=[[-.5,.5],[-.5,.5],[0,.25]];for(let i=0;i<3;i++){let [lo,hi]=bounds[i];if(Math.abs(r[i])<F(1e-9)){if(!(o[i]>=lo&&o[i]<=hi))return null;}else{let a=D(S(lo,o[i]),r[i]),b=D(S(hi,o[i]),r[i]);near=Math.max(near,Math.min(a,b));far=Math.min(far,Math.max(a,b));if(far<near)return null;}}
|
||||
let p=add(o,mul(r,near)),direction=r.slice(0,2).map(Math.sign),cell=p.slice(0,2).map((v,i)=>Math.min(127,Math.max(0,Math.floor(A(M(A(v,.5),128),M(direction[i],.00001)))))),next=[INF,INF],delta=[INF,INF];for(let i=0;i<2;i++)if(Math.abs(r[i])>=F(1e-9)){next[i]=D(S(S(D(A(cell[i],r[i]>=0?1:0),128),.5),o[i]),r[i]);delta[i]=D(1,M(128,Math.abs(r[i])));}
|
||||
const vert=(x,y)=>[S(D(x,128),.5),S(D(y,128),.5),M(F(depthAt(surface.texture,D(x,128),D(y,128))),.25)];
|
||||
for(let i=0;i<257;i++){let ll=vert(cell[0],cell[1]),lr=vert(A(cell[0],1),cell[1]),ul=vert(cell[0],A(cell[1],1)),ur=vert(A(cell[0],1),A(cell[1],1));let t=Math.min(triangle(o,r,ul,ll,ur),triangle(o,r,ll,lr,ur)),end=Math.min(far,...next);if(t>=S(near,.00001)&&t<=A(end,.00001)&&t<F(1e19))return{t,point:add(o,mul(r,t)),cells:i+1};if(end>=S(far,.000001))return null;let cx=next[0]<=next[1],cy=next[1]<=next[0];near=end;if(cx){cell[0]=A(cell[0],direction[0]);next[0]=A(next[0],delta[0]);}if(cy){cell[1]=A(cell[1],direction[1]);next[1]=A(next[1],delta[1]);}if(cell.some(v=>v<0||v>=128))return null;}throw Error('257 cells exhausted');}
|
||||
const surface=photoSurface(makeDepthTexture(67,53,(x,y)=>.5+.21*Math.sin(x*1.2)+.25*Math.cos(y*.9+x*.4)));
|
||||
let seed=0x5eed,random=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/2**32;},cases=[];
|
||||
for(let i=0;i<1500;i++){const o=v3((random()-.5)*1.8,(random()-.5)*1.8,.4+random()),target=v3((random()-.5)*1.2,(random()-.5)*1.2,random()*.2);cases.push([o,target.sub(o).normalize()]);}
|
||||
for(const x of [-.5,-.25,0,.25,.5])for(const y of [-.5,-.25,0,.25,.5])for(const dx of [-.3,0,.3])for(const dy of [-.3,0,.3])cases.push([v3(x,y,1),v3(dx,dy,-1).normalize()]);
|
||||
for(let i=0;i<20;i++)cases.push([v3(-.6,-.45+i*.02,.249),v3(1,.85,-.0001).normalize()]);
|
||||
let misses=0,worst=0,maxcells=0;
|
||||
for(let i=0;i<cases.length;i++){const [o,r]=cases[i],actual=shaderDDA(surface,o,r),expected=tracePhotoDDA(surface,o,r).hit;assert.equal(Boolean(actual),Boolean(expected),`hit/miss ray ${i}`);if(actual){const error=v3(...actual.point).distanceTo(expected.point);worst=Math.max(worst,error);maxcells=Math.max(maxcells,actual.cells);assert.ok(error<.0001,`ray ${i} error ${error}`);}else misses++;if(i<40){const oracle=triangleOracle(surface,o,r);assert.equal(Boolean(actual),Boolean(oracle));}}
|
||||
console.log(JSON.stringify({kind:'CPU Float32 arithmetic mirror of production DDA and Moller triangles; not GPU',rays:cases.length,misses,worstHitPointError:worst,maxcells}));
|
||||
const longSurface=photoSurface(makeDepthTexture(2,2,(x,y)=>x*y));let longWorst=0,longMax=0,longCases=0;
|
||||
for(let i=0;i<80;i++){const o=v3(-.6,-.45+i*.005,.249),r=v3(1,.85,-.0001).normalize(),a=shaderDDA(longSurface,o,r),e=tracePhotoDDA(longSurface,o,r).hit;assert.equal(Boolean(a),Boolean(e),`long ray ${i}`);if(a){longMax=Math.max(longMax,a.cells);longWorst=Math.max(longWorst,v3(...a.point).distanceTo(e.point));assert.ok(v3(...a.point).distanceTo(e.point)<.0001);}longCases++;}
|
||||
console.log(JSON.stringify({kind:'CPU Float32 long XY traversal',rays:longCases,worstHitPointError:longWorst,maxcells:longMax}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import ctypes as C,re,sys
|
||||
from pathlib import Path
|
||||
s=C.CDLL('/usr/lib/x86_64-linux-gnu/libshaderc.so.1')
|
||||
def fn(name,restype,args):
|
||||
f=getattr(s,name);f.restype=restype;f.argtypes=args;return f
|
||||
init=fn('shaderc_compiler_initialize',C.c_void_p,[]); optinit=fn('shaderc_compile_options_initialize',C.c_void_p,[])
|
||||
setenv=fn('shaderc_compile_options_set_target_env',None,[C.c_void_p,C.c_int,C.c_uint]); auto=fn('shaderc_compile_options_set_auto_bind_uniforms',None,[C.c_void_p,C.c_bool]); loc=fn('shaderc_compile_options_set_auto_map_locations',None,[C.c_void_p,C.c_bool]); compile=fn('shaderc_compile_into_spv',C.c_void_p,[C.c_void_p,C.c_char_p,C.c_size_t,C.c_int,C.c_char_p,C.c_char_p,C.c_void_p]);status=fn('shaderc_result_get_compilation_status',C.c_int,[C.c_void_p]); msg=fn('shaderc_result_get_error_message',C.c_char_p,[C.c_void_p]);release=fn('shaderc_result_release',None,[C.c_void_p])
|
||||
c=init(); o=optinit();setenv(o,1,450);auto(o,True);loc(o,True)
|
||||
failed=0
|
||||
for p in sorted(p for p in Path('/tmp').glob('b-review-*.*') if p.suffix in ('.vert','.frag')):
|
||||
src=p.read_text().replace('#version 300 es','#version 310 es',1)
|
||||
src=re.sub(r'\baverage\b','threeAverage',src) # 'average' becomes reserved after production GLSL ES 300
|
||||
src=src.encode();r=compile(c,src,len(src),0 if p.suffix=='.vert' else 1,str(p).encode(),b'main',o)
|
||||
code=status(r);failed+=code!=0
|
||||
print(p.name,'status:',code,msg(r).decode());release(r)
|
||||
print('CPU/offline shaderc only; version raised 300 es -> 310 es solely for SPIR-V compiler acceptance and stock average() renamed to avoid later-version reserved keyword; auto bindings/locations. No changes to B code. No WebGL or GPU execution.')
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,17 @@
|
||||
// Offline composition proof only: no browser, WebGL optics, or user photos.
|
||||
import {writeFileSync} from 'node:fs';import * as T from 'three';
|
||||
import {loadTypescript} from './load-typescript.mjs';import {loadFrameGeometry} from './load-frame-geometry.mjs';
|
||||
const {prepareHouseAsset}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
|
||||
const {prepareFrameAsset,fitWindowScale}=await loadTypescript(new URL('../../src/objects/BlenderWindowFrame.ts',import.meta.url));
|
||||
const {CameraController}=await loadTypescript(new URL('../../src/input/CameraController.ts',import.meta.url));
|
||||
const views=[];for(const [label,aspect]of[['portrait',375/812],['landscape',844/390]]){
|
||||
const f=fitWindowScale(aspect,.8)*.82,w=f*.8*.8,h=f*.8,s=Math.min(w,h),room=loadFrameGeometry(new URL('../../public/assets/window/grandma-house-surround.glb',import.meta.url)),frame=loadFrameGeometry();
|
||||
prepareHouseAsset(room).resize(w,h,.6);prepareFrameAsset(frame).resize(w,h,.6);const root=new T.Group();root.add(room,frame);root.updateMatrixWorld(true);
|
||||
const meshes=[];root.traverse(m=>{if(!m.isMesh)return;const g=m.geometry.clone().applyMatrix4(m.matrixWorld);meshes.push({name:m.name,positions:[...g.attributes.position.array],indices:g.index?[...g.index.array]:Array.from({length:g.attributes.position.count},(_,i)=>i),colors:g.attributes.color?[...g.attributes.color.array]:null});});
|
||||
for(const variant of['left','right','neutral']){
|
||||
const c=new T.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(c);control.setRoomBounds(w,h,.6);control.setView(variant==='left'?-1:variant==='right'?1:0,0);control.update(10,1);
|
||||
c.updateMatrixWorld(true);
|
||||
views.push({name:label+'-'+variant,aspect,fov:c.fov,matrix:c.matrixWorld.toArray(),meshes});
|
||||
}
|
||||
}
|
||||
writeFileSync('/tmp/corner-qa/scenes.json',JSON.stringify(views));
|
||||
@@ -0,0 +1,17 @@
|
||||
// Offline wall-hug geometry evidence only; no WebGL/photo rendering.
|
||||
import {writeFileSync} from 'node:fs';import * as T from 'three';
|
||||
import {loadTypescript} from './load-typescript.mjs';import {loadFrameGeometry} from './load-frame-geometry.mjs';
|
||||
const {prepareHouseAsset}=await loadTypescript(new URL('../../src/objects/BlenderHouseSurround.ts',import.meta.url));
|
||||
const {prepareFrameAsset,fitWindowScale}=await loadTypescript(new URL('../../src/objects/BlenderWindowFrame.ts',import.meta.url));
|
||||
const {CameraController}=await loadTypescript(new URL('../../src/input/CameraController.ts',import.meta.url));
|
||||
const views=[];for(const [label,aspect]of[['portrait',375/812],['landscape',844/390]]){
|
||||
const f=fitWindowScale(aspect,.8)*.82,w=f*.8*.8,h=f*.8,s=Math.min(w,h),room=loadFrameGeometry(new URL('../../public/assets/window/grandma-house-surround.glb',import.meta.url)),frame=loadFrameGeometry();
|
||||
prepareHouseAsset(room).resize(w,h,.6,undefined,true);prepareFrameAsset(frame).resize(w,h,.6);const root=new T.Group();root.add(room,frame);root.updateMatrixWorld(true);
|
||||
const meshes=[];root.traverse(m=>{if(!m.isMesh)return;const g=m.geometry.clone().applyMatrix4(m.matrixWorld);meshes.push({name:m.name,positions:[...g.attributes.position.array],indices:g.index?[...g.index.array]:Array.from({length:g.attributes.position.count},(_,i)=>i),colors:g.attributes.color?[...g.attributes.color.array]:null});});
|
||||
for(const variant of['left','right','middle','neutral']){
|
||||
const c=new T.PerspectiveCamera(40,aspect,.1,100),control=new CameraController(c);control.setRoomBounds(w,h,.6);control.setCompositionMode('wall');control.setView(variant==='left'?-1:variant==='right'?1:variant==='middle'?.65:0,0);control.update(10,1);
|
||||
c.updateMatrixWorld(true);
|
||||
views.push({name:label+'-'+variant,aspect,fov:c.fov,matrix:c.matrixWorld.toArray(),meshes});
|
||||
}
|
||||
}
|
||||
writeFileSync('/tmp/wall-final-qa/scenes.json',JSON.stringify(views));
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as THREE from 'three';
|
||||
import {readFileSync} from 'node:fs';
|
||||
/** Geometry-only GLB reader for deterministic Node tests; browser uses GLTFLoader. */
|
||||
export function loadFrameGeometry(file=new URL('../../public/assets/window/grandma-wood-sash.glb',import.meta.url)){
|
||||
const bytes=readFileSync(file);
|
||||
const length=bytes.readUInt32LE(12),gltf=JSON.parse(bytes.subarray(20,20+length).toString()),bin=bytes.subarray(28+length);
|
||||
const types={5121:Uint8Array,5126:Float32Array,5123:Uint16Array,5125:Uint32Array},counts={SCALAR:1,VEC2:2,VEC3:3,VEC4:4};
|
||||
const accessor=i=>{const a=gltf.accessors[i],v=gltf.bufferViews[a.bufferView],T=types[a.componentType],n=counts[a.type],array=new T(a.count*n);for(let row=0;row<a.count;row++)for(let k=0;k<n;k++){const offset=(v.byteOffset??0)+(a.byteOffset??0)+row*(v.byteStride??n*T.BYTES_PER_ELEMENT)+k*T.BYTES_PER_ELEMENT;array[row*n+k]=a.componentType===5126?bin.readFloatLE(offset):a.componentType===5123?bin.readUInt16LE(offset):a.componentType===5121?bin.readUInt8(offset):bin.readUInt32LE(offset);}return new THREE.BufferAttribute(array,n,a.normalized??false);};
|
||||
const materials=gltf.materials.map(m=>new THREE.MeshStandardMaterial({name:m.name,side:m.doubleSided?THREE.DoubleSide:THREE.FrontSide}));
|
||||
const nodes=gltf.nodes.map(n=>{let obj=new THREE.Group();if(n.mesh!==undefined){const primitive=gltf.meshes[n.mesh].primitives[0],geometry=new THREE.BufferGeometry();for(const [key,value] of Object.entries(primitive.attributes))geometry.setAttribute({POSITION:'position',NORMAL:'normal',TEXCOORD_0:'uv',TANGENT:'tangent',COLOR_0:'color'}[key],accessor(value));geometry.setIndex(accessor(primitive.indices));obj=new THREE.Mesh(geometry,materials[primitive.material]);}obj.name=n.name;if(n.translation)obj.position.fromArray(n.translation);return obj;});
|
||||
gltf.nodes.forEach((node,i)=>(node.children??[]).forEach(c=>nodes[i].add(nodes[c])));
|
||||
const scene=new THREE.Group();gltf.scenes[gltf.scene??0].nodes.forEach(i=>scene.add(nodes[i]));return scene;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
const cache=new Map();
|
||||
export async function loadTypescript(file){const url=file instanceof URL?file:new URL(file,import.meta.url);return import(await moduleURL(url));}
|
||||
async function moduleURL(url){
|
||||
if(cache.has(url.href))return cache.get(url.href);
|
||||
let code=ts.transpileModule(await readFile(url,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
for(const match of [...code.matchAll(/(?:from\s*|import\s*)['"]([^'"]+)['"]/g)]){const name=match[1];let ref;if(name.endsWith('?raw'))ref='data:text/javascript;base64,'+Buffer.from('export default '+JSON.stringify(await readFile(new URL(name.slice(0,-4),url),'utf8'))).toString('base64');else if(name.startsWith('.'))ref=await moduleURL(new URL(name.endsWith('.ts')?name:name+'.ts',url));else if(!name.startsWith('data:')&&!name.startsWith('file:'))ref=import.meta.resolve(name);if(ref)code=code.replaceAll("'"+name+"'",JSON.stringify(ref)).replaceAll(JSON.stringify(name),JSON.stringify(ref));}
|
||||
code=code.replaceAll('import.meta.url',JSON.stringify(url.href));const result='data:text/javascript;base64,'+Buffer.from(code).toString('base64');cache.set(url.href,result);return result;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** Test-only, CPU numerical reference. Never imported by the renderer.
|
||||
* Photo intersections use analytic per-triangle height planes and a bounded XY DDA;
|
||||
* the independent oracle uses Three's indexed PlaneGeometry and Ray.intersectTriangle.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
const EPS = 1e-10;
|
||||
export const PHOTO_SEGMENTS = 128;
|
||||
export const MAX_DDA_CELLS = 257;
|
||||
export const v3 = (x=0,y=0,z=0) => new THREE.Vector3(x,y,z);
|
||||
|
||||
export function makeDepthTexture(width, height, sample) {
|
||||
const data = new Uint8Array(width*height*4);
|
||||
for(let y=0;y<height;y++) for(let x=0;x<width;x++) {
|
||||
const n=Math.round(255*THREE.MathUtils.clamp(sample(x,y),0,1)), i=4*(y*width+x);
|
||||
data[i]=data[i+1]=data[i+2]=n; data[i+3]=255;
|
||||
}
|
||||
const texture=new THREE.DataTexture(data,width,height);
|
||||
texture.minFilter=texture.magFilter=THREE.LinearFilter;
|
||||
texture.wrapS=texture.wrapT=THREE.ClampToEdgeWrapping;
|
||||
texture.generateMipmaps=false;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** GL normalized LinearFilter sampling at explicit LOD zero; Uint8 -> [0,1]. */
|
||||
export function depthAt(texture,u,v) {
|
||||
const {data,width,height}=texture.image;
|
||||
if(texture.flipY)v=1-v;
|
||||
const x=u*width-.5,y=v*height-.5,x0=Math.floor(x),y0=Math.floor(y),fx=x-x0,fy=y-y0;
|
||||
const fetch=(i,j)=>data[4*(THREE.MathUtils.clamp(j,0,height-1)*width+THREE.MathUtils.clamp(i,0,width-1))]/255;
|
||||
return THREE.MathUtils.lerp(THREE.MathUtils.lerp(fetch(x0,y0),fetch(x0+1,y0),fx),THREE.MathUtils.lerp(fetch(x0,y0+1),fetch(x0+1,y0+1),fx),fy);
|
||||
}
|
||||
|
||||
export function photoSurface(texture,{segments=128,depthScale=.25,depthBias=0,matrix=new THREE.Matrix4()}={}) {
|
||||
if(!Number.isInteger(segments)||segments<1||segments>128) throw new Error('Photo grid must be 1..128');
|
||||
const heights=new Float64Array((segments+1)**2);
|
||||
for(let y=0;y<=segments;y++) for(let x=0;x<=segments;x++) heights[y*(segments+1)+x]=(depthAt(texture,x/segments,y/segments)-depthBias)*depthScale;
|
||||
const geometry=new THREE.PlaneGeometry(1,1,segments,segments),p=geometry.attributes.position,uv=geometry.attributes.uv;
|
||||
for(let i=0;i<p.count;i++) p.setZ(i,(depthAt(texture,uv.getX(i),uv.getY(i))-depthBias)*depthScale);
|
||||
return {texture,segments,heights,geometry,matrix:matrix.clone(),inverse:matrix.clone().invert(),minZ:Math.min(...heights),maxZ:Math.max(...heights)};
|
||||
}
|
||||
|
||||
export function localRay(surface,origin,direction) {
|
||||
return {origin:origin.clone().applyMatrix4(surface.inverse),direction:direction.clone().applyMatrix3(new THREE.Matrix3().setFromMatrix4(surface.inverse))};
|
||||
}
|
||||
|
||||
export function triangleOracle(surface,origin,direction) {
|
||||
const local=localRay(surface,origin,direction),ray=new THREE.Ray(local.origin,local.direction),p=surface.geometry.attributes.position,indices=surface.geometry.index;
|
||||
const a=v3(),b=v3(),c=v3(),hit=v3();let result=null,minT=Infinity;
|
||||
for(let i=0;i<indices.count;i+=3) {
|
||||
a.fromBufferAttribute(p,indices.getX(i));b.fromBufferAttribute(p,indices.getX(i+1));c.fromBufferAttribute(p,indices.getX(i+2));
|
||||
if(!ray.intersectTriangle(a,b,c,true,hit))continue;
|
||||
const t=hit.clone().sub(local.origin).dot(local.direction)/local.direction.lengthSq();
|
||||
if(t>=-EPS&&t<minT) {minT=t;result={t,local:hit.clone(),point:hit.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(hit.x+.5,hit.y+.5),triangle:i/3};}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns nearest actual triangle hit, including multiple height crossings. */
|
||||
export function tracePhotoDDA(surface,origin,direction) {
|
||||
const {origin:o,direction:d}=localRay(surface,origin,direction),n=surface.segments,h=surface.heights;
|
||||
let enter=0,leave=Infinity,cells=0;
|
||||
for(const [axis,lo,hi] of [['x',-.5,.5],['y',-.5,.5],['z',surface.minZ,surface.maxZ]]) {
|
||||
if(Math.abs(d[axis])<1e-14) {if(o[axis]<lo-EPS||o[axis]>hi+EPS)return {hit:null,cells};continue;}
|
||||
const a=(lo-o[axis])/d[axis],b=(hi-o[axis])/d[axis];enter=Math.max(enter,Math.min(a,b));leave=Math.min(leave,Math.max(a,b));
|
||||
}
|
||||
if(leave<enter-EPS)return {hit:null,cells};
|
||||
const at=o.clone().addScaledVector(d,enter),grid=(x)=>THREE.MathUtils.clamp(Math.floor((x+.5)*n),0,n-1);
|
||||
let ix=grid(at.x),iy=grid(at.y),t=enter;
|
||||
const sx=Math.sign(d.x),sy=Math.sign(d.y);
|
||||
const next=(axis,cell,step)=>step===0?Infinity:((cell+(step>0?1:0))/n-.5-o[axis])/d[axis];
|
||||
let tx=next('x',ix,sx),ty=next('y',iy,sy);
|
||||
for(let step=0;step<MAX_DDA_CELLS;step++) {
|
||||
cells++;
|
||||
const end=Math.min(tx,ty,leave),k=iy*(n+1)+ix,z00=h[k],z10=h[k+1],z01=h[k+n+1],z11=h[k+n+2];
|
||||
let best=Infinity;
|
||||
// The two exact PlaneGeometry triangles share the lower-left/upper-right diagonal.
|
||||
for(const lower of [true,false]) {
|
||||
const ax=lower?z10-z00:z11-z01,ay=lower?z11-z10:z01-z00;
|
||||
const fx0=(o.x+.5)*n-ix,fy0=(o.y+.5)*n-iy;
|
||||
const denom=d.z-n*(ax*d.x+ay*d.y);
|
||||
if(denom>=-1e-14)continue; // FrontSide: approach the displaced photo front.
|
||||
const candidate=(z00+ax*fx0+ay*fy0-o.z)/denom;
|
||||
const fx=fx0+n*d.x*candidate,fy=fy0+n*d.y*candidate;
|
||||
if(candidate<t-EPS||candidate>end+EPS||candidate<0||fx<-EPS||fy<-EPS||fx>1+EPS||fy>1+EPS)continue;
|
||||
if(lower?fy>fx+EPS:fx>fy+EPS)continue;
|
||||
best=Math.min(best,candidate);
|
||||
}
|
||||
if(Number.isFinite(best)) {const local=o.clone().addScaledVector(d,best);return {hit:{t:best,local,point:local.clone().applyMatrix4(surface.matrix),uv:new THREE.Vector2(local.x+.5,local.y+.5)},cells};}
|
||||
if(end>=leave-EPS)return {hit:null,cells};
|
||||
const crossX=tx<=ty+EPS,crossY=ty<=tx+EPS;t=end;
|
||||
if(crossX) {ix+=sx;tx=next('x',ix,sx);}if(crossY) {iy+=sy;ty=next('y',iy,sy);}
|
||||
if(ix<0||ix>=n||iy<0||iy>=n)return {hit:null,cells};
|
||||
}
|
||||
throw new Error(`DDA exceeded ${MAX_DDA_CELLS} cells`);
|
||||
}
|
||||
|
||||
/** GLSL refract convention: normal opposes incident direction. */
|
||||
export function snell(incident,normal,eta) {
|
||||
const i=incident.clone().normalize(),n=normal.clone().normalize(),dot=i.dot(n),k=1-eta*eta*(1-dot*dot);
|
||||
if(k<0)return null;
|
||||
return i.multiplyScalar(eta).addScaledVector(n,-eta*dot-Math.sqrt(k)).normalize();
|
||||
}
|
||||
|
||||
/** Planar rear and finite side exits in local glass coordinates. */
|
||||
export function exitGlass(entry,inside,{width,height,backZ}) {
|
||||
const candidates=[];
|
||||
for(const [axis,bounds] of [['x',[-width/2,width/2]],['y',[-height/2,height/2]],['z',[backZ]]]) {
|
||||
if(Math.abs(inside[axis])<1e-14)continue;
|
||||
for(const value of bounds) {const t=(value-entry[axis])/inside[axis];if(t>EPS)candidates.push({t,axis,value});}
|
||||
}
|
||||
candidates.sort((a,b)=>a.t-b.t);
|
||||
for(const c of candidates) {
|
||||
const point=entry.clone().addScaledVector(inside,c.t);
|
||||
if(Math.abs(point.x)>width/2+EPS||Math.abs(point.y)>height/2+EPS||point.z<backZ-EPS)continue;
|
||||
const normal=v3();normal[c.axis]=c.axis==='z'?-1:Math.sign(c.value);
|
||||
return {point,normal,t:c.t,face:c.axis==='z'?'rear':`${c.axis}${Math.sign(c.value)>0?'+':'-'}`};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function traceGlass({entry,normal,incident,shape,ior=1.5,surface}) {
|
||||
const inside=snell(incident,normal,1/ior);if(!inside)return {status:'entry-tir'};
|
||||
const exit=exitGlass(entry,inside,shape);if(!exit)return {status:'no-exit'};
|
||||
const outgoing=snell(inside,exit.normal.clone().negate(),ior);if(!outgoing)return {status:'tir',inside,exit};
|
||||
const result=tracePhotoDDA(surface,exit.point,outgoing);
|
||||
return {status:result.hit?'hit':'miss',inside,exit,outgoing,...result};
|
||||
}
|
||||
|
||||
/** Stock r169 only advances its refracted ray by material thickness, projects that
|
||||
* endpoint, and looks up the already camera-rendered scene at that screen point. */
|
||||
export function stockR169PhotoHit({entry,normal,incident,camera,thickness,ior=1.5,surface}) {
|
||||
const inside=snell(incident,normal,1/ior);
|
||||
if(!inside)return null;
|
||||
const endpoint=entry.clone().addScaledVector(inside,thickness);
|
||||
return tracePhotoDDA(surface,camera,endpoint.sub(camera).normalize()).hit;
|
||||
}
|
||||
|
||||
/** Actual mesh intersection and perspective-correct interpolated vertex normal. */
|
||||
export function meshEntry(geometry,camera,target) {
|
||||
const material=new THREE.MeshBasicMaterial({side:THREE.FrontSide}),mesh=new THREE.Mesh(geometry,material);
|
||||
mesh.updateMatrixWorld(true);
|
||||
const incident=target.clone().sub(camera).normalize(),hit=new THREE.Raycaster(camera,incident).intersectObject(mesh,false)[0];
|
||||
material.dispose();
|
||||
if(!hit)return null;
|
||||
const p=geometry.attributes.position,n=geometry.attributes.normal,a=v3().fromBufferAttribute(p,hit.face.a),b=v3().fromBufferAttribute(p,hit.face.b),c=v3().fromBufferAttribute(p,hit.face.c);
|
||||
const bary=THREE.Triangle.getBarycoord(hit.point,a,b,c,v3());
|
||||
const normal=v3().fromBufferAttribute(n,hit.face.a).multiplyScalar(bary.x).addScaledVector(v3().fromBufferAttribute(n,hit.face.b),bary.y).addScaledVector(v3().fromBufferAttribute(n,hit.face.c),bary.z).normalize();
|
||||
return {entry:hit.point,normal,incident,bary,face:hit.face};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Reproducible synthetic fixtures and CPU-only codec oracle, never browser QA."""
|
||||
import sys, json, hashlib
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageOps
|
||||
import numpy as np
|
||||
|
||||
if sys.argv[1] == 'generate':
|
||||
root = Path(sys.argv[2]); root.mkdir(parents=True, exist_ok=True)
|
||||
rng = np.random.default_rng(20261007)
|
||||
width, height = 4032, 3024
|
||||
pixels = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
|
||||
rgb = Image.fromarray(pixels)
|
||||
alpha = Image.new('L', (width, height), 0)
|
||||
alpha.paste(64, (width//2, 0, width, height//2))
|
||||
alpha.paste(192, (0, height//2, width//2, height))
|
||||
alpha.paste(255, (width//2, height//2, width, height))
|
||||
rgba = rgb.copy(); rgba.putalpha(alpha)
|
||||
for name, image, orientation, options in [
|
||||
('phone-12mp-rotated.jpg', rgb, 6, {'quality':95}),
|
||||
('phone-12mp-alpha-rotated.png', rgba, 8, {}),
|
||||
('phone-12mp-alpha-mirrored.webp', rgba, 2, {'lossless':True}),
|
||||
]:
|
||||
exif = Image.Exif(); exif[274] = orientation
|
||||
image.save(root / name, exif=exif, **options)
|
||||
# Large pixel count with tiny encoded size exercises the pixel resource guard.
|
||||
Image.new('RGBA', (8000,6000), (19,101,224,128)).save(root/'phone-48mp-alpha.png')
|
||||
print(json.dumps({'directory':str(root),'files':[p.name for p in sorted(root.iterdir())]}))
|
||||
else:
|
||||
path = Path(sys.argv[2])
|
||||
with Image.open(path) as source:
|
||||
source_width, source_height = source.size
|
||||
orientation = source.getexif().get(274, 1)
|
||||
oriented = ImageOps.exif_transpose(source).convert('RGBA')
|
||||
if len(sys.argv) > 3:
|
||||
oriented = oriented.resize((int(sys.argv[3]), int(sys.argv[4])), Image.Resampling.LANCZOS)
|
||||
corners = [oriented.getpixel(point) for point in [(0,0),(oriented.width-1,0),(0,oriented.height-1),(oriented.width-1,oriented.height-1)]]
|
||||
# Verify transparent/partial-alpha values survive a local lossless PNG adapter.
|
||||
import io
|
||||
encoded = io.BytesIO(); oriented.save(encoded, format='PNG')
|
||||
decoded = Image.open(io.BytesIO(encoded.getvalue())).convert('RGBA')
|
||||
assert decoded.tobytes() == oriented.tobytes()
|
||||
print(json.dumps({'width':oriented.width,'height':oriented.height,'sourceWidth':source_width,'sourceHeight':source_height,'orientation':orientation,'corners':corners,'alphaExtrema':oriented.getchannel('A').getextrema(),'sourceBytes':path.stat().st_size,'workingPngBytes':len(encoded.getvalue()),'sourceRGBABytes':source_width*source_height*4,'workingRGBABytes':oriented.width*oriented.height*4,'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'pngRoundTripExact':True}))
|
||||
@@ -0,0 +1,23 @@
|
||||
import bpy,json,math
|
||||
from mathutils import Matrix
|
||||
views=json.load(open('/tmp/corner-qa/scenes.json'))
|
||||
for v in views:
|
||||
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
|
||||
for item in v['meshes']:
|
||||
p=item['positions'];ind=item['indices'];mesh=bpy.data.meshes.new(item['name']);mesh.from_pydata(list(zip(p[::3],p[1::3],p[2::3])),[],list(zip(ind[::3],ind[1::3],ind[2::3])));mesh.update();ob=bpy.data.objects.new(item['name'],mesh);bpy.context.collection.objects.link(ob)
|
||||
name=item['name'];color=(.67,.61,.49,1) if 'Plaster' in name or 'Shell' in name else (.3,.16,.08,1) if 'Wood' in name or 'Picture' in name else (.55,.51,.43,1)
|
||||
if 'Curtain' in name:color=(.88,.85,.72,1)
|
||||
mat=bpy.data.materials.new(name);mat.diffuse_color=color;ob.data.materials.append(mat)
|
||||
if 'Picture' in name and item['colors']:
|
||||
palette={};co=item['colors']
|
||||
for poly in mesh.polygons:
|
||||
i=poly.vertices[0];rgb=tuple(round(x,5) for x in co[i*3:i*3+3])
|
||||
if rgb not in palette:
|
||||
m=bpy.data.materials.new('Print');m.diffuse_color=(*rgb,1);ob.data.materials.append(m);palette[rgb]=len(ob.data.materials)-1
|
||||
poly.material_index=palette[rgb]
|
||||
if item['colors']:
|
||||
attr=mesh.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='POINT');co=item['colors']
|
||||
for i,d in enumerate(attr.data):d.color=(*co[i*3:i*3+3],1)
|
||||
camera=bpy.data.cameras.new('Camera');ob=bpy.data.objects.new('Camera',camera);bpy.context.collection.objects.link(ob);a=v['matrix'];ob.matrix_world=Matrix([[a[r+c*4] for c in range(4)]for r in range(4)]);camera.type='PERSP';camera.sensor_fit='VERTICAL';camera.sensor_height=24;camera.lens=24/(2*math.tan(math.radians(v['fov'])/2));bpy.context.scene.camera=ob
|
||||
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH';scene.display.shading.light='STUDIO';scene.display.shading.color_type='MATERIAL';scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True;scene.display.shading.cavity_type='BOTH';scene.display.shading.background_type='WORLD';scene.world.color=(.15,.15,.15)
|
||||
scene.render.resolution_y=750;scene.render.resolution_x=int(750*v['aspect']);scene.render.resolution_percentage=100;scene.render.image_settings.file_format='PNG';scene.render.filepath='/tmp/corner-qa/'+v['name']+'.png';bpy.ops.render.render(write_still=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
import bpy,json,math
|
||||
from mathutils import Matrix
|
||||
views=json.load(open('/tmp/wall-final-qa/scenes.json'))
|
||||
for v in views:
|
||||
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
|
||||
for item in v['meshes']:
|
||||
p=item['positions'];ind=item['indices'];mesh=bpy.data.meshes.new(item['name']);mesh.from_pydata(list(zip(p[::3],p[1::3],p[2::3])),[],list(zip(ind[::3],ind[1::3],ind[2::3])));mesh.update();ob=bpy.data.objects.new(item['name'],mesh);bpy.context.collection.objects.link(ob)
|
||||
name=item['name'];color=(.67,.61,.49,1) if 'Plaster' in name or 'Shell' in name else (.3,.16,.08,1) if 'Wood' in name or 'Picture' in name else (.55,.51,.43,1)
|
||||
if 'Curtain' in name:color=(.88,.85,.72,1)
|
||||
mat=bpy.data.materials.new(name);mat.diffuse_color=color;ob.data.materials.append(mat)
|
||||
if 'Picture' in name and item['colors']:
|
||||
palette={};co=item['colors']
|
||||
for poly in mesh.polygons:
|
||||
i=poly.vertices[0];rgb=tuple(round(x,5) for x in co[i*3:i*3+3])
|
||||
if rgb not in palette:
|
||||
m=bpy.data.materials.new('Print');m.diffuse_color=(*rgb,1);ob.data.materials.append(m);palette[rgb]=len(ob.data.materials)-1
|
||||
poly.material_index=palette[rgb]
|
||||
if item['colors']:
|
||||
attr=mesh.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='POINT');co=item['colors']
|
||||
for i,d in enumerate(attr.data):d.color=(*co[i*3:i*3+3],1)
|
||||
camera=bpy.data.cameras.new('Camera');ob=bpy.data.objects.new('Camera',camera);bpy.context.collection.objects.link(ob);a=v['matrix'];ob.matrix_world=Matrix([[a[r+c*4] for c in range(4)]for r in range(4)]);camera.type='PERSP';camera.sensor_fit='VERTICAL';camera.sensor_height=24;camera.lens=24/(2*math.tan(math.radians(v['fov'])/2));bpy.context.scene.camera=ob
|
||||
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH';scene.display.shading.light='STUDIO';scene.display.shading.color_type='MATERIAL';scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True;scene.display.shading.cavity_type='BOTH';scene.display.shading.background_type='WORLD';scene.world.color=(.15,.15,.15)
|
||||
scene.render.resolution_y=750;scene.render.resolution_x=int(750*v['aspect']);scene.render.resolution_percentage=100;scene.render.image_settings.file_format='PNG';scene.render.filepath='/tmp/wall-final-qa/'+v['name']+'.png';bpy.ops.render.render(write_still=True)
|
||||
Reference in New Issue
Block a user