Add gobang surface refraction and shared photo/depth distance adjustment. Compare V2 Small and Base at input 770 with maximum-depth masks and pixel diagnostics. Record browser QA and update runtime dependencies and model notices.
111 lines
5.5 KiB
JavaScript
111 lines
5.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import * as THREE from 'three';
|
|
import { WindowReveal } from '../src/objects/WindowReveal.ts';
|
|
import { PHOTO_GLASS_DISTANCE } from '../src/constants/glass.ts';
|
|
|
|
const glassZ = 0.6;
|
|
const cameraZ = 3;
|
|
const depthScales = [0, 0.25, 0.4];
|
|
const glassGaps = [PHOTO_GLASS_DISTANCE.minGap, PHOTO_GLASS_DISTANCE.maxGap];
|
|
const aspects = [0.7, 1.6];
|
|
const offsets = [
|
|
[0, 0],
|
|
[0.5, 0], [-0.5, 0], [0, 0.5], [0, -0.5], [0.9, 0], [-0.9, 0], [0, 0.9], [0, -0.9],
|
|
[0.5, 0.5], [-0.5, 0.5], [0.5, -0.5], [-0.5, -0.5],
|
|
[0.9, 0.9], [-0.9, 0.9], [0.9, -0.9], [-0.9, -0.9]
|
|
];
|
|
const samples = [-1, -0.75, -0.5, 0, 0.5, 0.75, 1];
|
|
|
|
function makePhoto(width, height, z) {
|
|
const photo = new THREE.Mesh(new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
|
|
photo.position.z = z;
|
|
photo.updateMatrixWorld(true);
|
|
return photo;
|
|
}
|
|
|
|
function makePriorFrame(frontWidth, frontHeight) {
|
|
const frameThickness = Math.min(frontWidth, frontHeight) * 0.035;
|
|
const frameDepth = frameThickness * 1.5;
|
|
const frameZ = glassZ + frameDepth * 0.25;
|
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
|
|
const left = new THREE.Mesh(geometry, material);
|
|
const right = new THREE.Mesh(geometry, material);
|
|
const top = new THREE.Mesh(geometry, material);
|
|
const bottom = new THREE.Mesh(geometry, material);
|
|
left.position.set(-(frontWidth + frameThickness) * 0.5, 0, frameZ);
|
|
right.position.set((frontWidth + frameThickness) * 0.5, 0, frameZ);
|
|
top.position.set(0, (frontHeight + frameThickness) * 0.5, frameZ);
|
|
bottom.position.set(0, -(frontHeight + frameThickness) * 0.5, frameZ);
|
|
left.scale.set(frameThickness, frontHeight, frameDepth);
|
|
right.scale.set(frameThickness, frontHeight, frameDepth);
|
|
top.scale.set(frontWidth + frameThickness * 2, frameThickness, frameDepth);
|
|
bottom.scale.set(frontWidth + frameThickness * 2, frameThickness, frameDepth);
|
|
const meshes = [left, right, top, bottom];
|
|
meshes.forEach((mesh) => mesh.updateMatrixWorld(true));
|
|
return { meshes, geometry, material };
|
|
}
|
|
|
|
function coveredBy(objects, camera, x, y, frontZ) {
|
|
const point = new THREE.Vector3(x, y, frontZ);
|
|
const direction = point.clone().sub(camera.position).normalize();
|
|
const intersections = new THREE.Raycaster(camera.position, direction).intersectObjects(objects, false);
|
|
return intersections.length > 0;
|
|
}
|
|
|
|
let baselineMisses = 0;
|
|
for (const aspect of aspects) {
|
|
const baseW = aspect;
|
|
const baseH = 1;
|
|
const frontW = baseW * (3 - glassZ) / 3;
|
|
const frontH = baseH * (3 - glassZ) / 3;
|
|
for (const depthScale of depthScales) {
|
|
for (const glassGap of glassGaps) {
|
|
const backZ = glassZ - glassGap - depthScale;
|
|
const backW = baseW * (3 - backZ) / 3;
|
|
const backH = baseH * (3 - backZ) / 3;
|
|
const photo = makePhoto(backW, backH, backZ);
|
|
const priorFrame = makePriorFrame(frontW, frontH);
|
|
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
|
|
const reveal = new WindowReveal(material);
|
|
reveal.update(frontW, frontH, backW, backH, glassZ, backZ);
|
|
reveal.meshes.forEach((mesh) => mesh.updateMatrixWorld(true));
|
|
for (const [offsetX, offsetY] of offsets) {
|
|
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
|
|
camera.position.set(offsetX, offsetY, cameraZ);
|
|
camera.lookAt(0, 0, 0);
|
|
for (const xRatio of samples) {
|
|
for (const yRatio of samples) {
|
|
const x = xRatio * frontW / 2;
|
|
const y = yRatio * frontH / 2;
|
|
if (!coveredBy([photo, ...priorFrame.meshes], camera, x, y, glassZ)) baselineMisses += 1;
|
|
assert.equal(coveredBy([photo, ...reveal.meshes], camera, x, y, glassZ), true, `uncovered aperture ray aspect=${aspect} depth=${depthScale} gap=${glassGap} offset=${offsetX},${offsetY} sample=${xRatio},${yRatio}`);
|
|
}
|
|
}
|
|
}
|
|
photo.geometry.dispose(); photo.material.dispose(); priorFrame.geometry.dispose(); priorFrame.material.dispose(); reveal.dispose(); material.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.ok(baselineMisses > 0, 'the previous shallow frame must leave some lateral aperture rays uncovered');
|
|
|
|
const reuseMaterial = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
|
|
const reusableReveal = new WindowReveal(reuseMaterial);
|
|
reusableReveal.update(0.8, 0.6, 0.85, 0.64, glassZ, 0.52);
|
|
const reusableGeometries = reusableReveal.meshes.map((mesh) => mesh.geometry);
|
|
const reusablePositions = reusableGeometries.map((geometry) => geometry.getAttribute('position'));
|
|
const disposeCounts = reusableGeometries.map((geometry) => {
|
|
let count = 0;
|
|
geometry.addEventListener('dispose', () => { count += 1; });
|
|
return () => count;
|
|
});
|
|
reusableReveal.update(0.9, 0.7, 0.96, 0.75, glassZ, 0.48);
|
|
assert.deepEqual(reusableReveal.meshes.map((mesh) => mesh.geometry), reusableGeometries, 'updates preserve geometry identities');
|
|
assert.deepEqual(reusableGeometries.map((geometry) => geometry.getAttribute('position')), reusablePositions, 'updates preserve position buffer identities');
|
|
reusableReveal.dispose();
|
|
assert.deepEqual(disposeCounts.map((getCount) => getCount()), [1, 1, 1, 1], 'disposal emits exactly once per reveal geometry');
|
|
reuseMaterial.dispose();
|
|
|
|
console.log(`Window reveal coverage passed: ${baselineMisses} previous-frame misses sealed across ${aspects.length} aspects, ${depthScales.length} depth scales, ${glassGaps.length} glass gaps, ${offsets.length} view offsets (including neutral) and ${samples.length ** 2} aperture samples; geometry reuse/disposal verified.`);
|