first commit
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const baseUrl = process.env.AI_DEPTH_BASE_URL || 'http://127.0.0.1:5174/';
|
||||
const outputDir = process.env.AI_DEPTH_OUTPUT_DIR || 'test-results/ai-depth';
|
||||
const executablePath = process.env.CHROME_EXECUTABLE_PATH || process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
};
|
||||
if (executablePath) launchOptions.executablePath = executablePath;
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const browser = await chromium.launch(launchOptions);
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push({ type: 'pageerror', message: error.message }));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push({ type: 'console', message: message.text() });
|
||||
});
|
||||
page.on('requestfailed', (request) => errors.push({ type: 'requestfailed', url: request.url(), message: request.failure()?.errorText ?? '' }));
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const workerResults = [];
|
||||
Object.defineProperty(window, '__aiDepthWorkerResults', { value: workerResults });
|
||||
const NativeWorker = window.Worker;
|
||||
window.Worker = new Proxy(NativeWorker, {
|
||||
construct(target, args) {
|
||||
const worker = new target(...args);
|
||||
worker.addEventListener('message', (event) => {
|
||||
const result = event.data;
|
||||
if (result?.type !== 'result' || !(result.values instanceof ArrayBuffer)) return;
|
||||
const values = new Uint8Array(result.values);
|
||||
const landmarks = {
|
||||
nose: [235, 310],
|
||||
leftEye: [198, 278],
|
||||
rightEye: [249, 273],
|
||||
face: [221, 267],
|
||||
leftEar: [158, 213],
|
||||
rightEar: [262, 206],
|
||||
body: [139, 419],
|
||||
};
|
||||
const samples = Object.fromEntries(Object.entries(landmarks).map(([name, [x, y]]) => {
|
||||
const sx = Math.min(result.width - 1, Math.max(0, Math.round(x * result.width / 415)));
|
||||
const sy = Math.min(result.height - 1, Math.max(0, Math.round(y * result.height / 739)));
|
||||
return [name, { x: sx, y: sy, depth: values[sy * result.width + sx] / 255 }];
|
||||
}));
|
||||
workerResults.push({ width: result.width, height: result.height, samples });
|
||||
});
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const report = { baseUrl, screenshots: {}, errors };
|
||||
try {
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#sample').click();
|
||||
await page.waitForFunction(() => document.querySelector('#depth-provenance')?.textContent?.startsWith('수작업 테스트 Depth'), null, { timeout: 20000 });
|
||||
await page.locator('#ai-depth').click();
|
||||
await page.waitForFunction(() => {
|
||||
const provenance = document.querySelector('#depth-provenance')?.textContent ?? '';
|
||||
const status = document.querySelector('#status')?.textContent ?? '';
|
||||
return provenance.startsWith('AI 추정') || status.includes('AI 깊이 생성 실패');
|
||||
}, null, { timeout: Number(process.env.AI_DEPTH_TIMEOUT_MS || 240000) });
|
||||
|
||||
const status = await page.locator('#status').textContent();
|
||||
const provenance = await page.locator('#depth-provenance').textContent();
|
||||
if (!provenance?.startsWith('AI 추정')) throw new Error(`AI inference did not complete: ${status}`);
|
||||
await page.screenshot({ path: `${outputDir}/no-glass.png`, fullPage: true });
|
||||
report.screenshots.noGlass = `${outputDir}/no-glass.png`;
|
||||
report.statusAfterInference = status;
|
||||
report.provenance = provenance;
|
||||
report.depthResults = await page.evaluate(() => window.__aiDepthWorkerResults);
|
||||
|
||||
const stage = await page.locator('#canvas-wrap').boundingBox();
|
||||
if (!stage) throw new Error('Canvas stage is unavailable for drag QA.');
|
||||
await page.mouse.move(stage.x + stage.width * 0.5, stage.y + stage.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(stage.x + stage.width * 0.62, stage.y + stage.height * 0.48, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${outputDir}/no-glass-drag.png`, fullPage: true });
|
||||
report.screenshots.noGlassDrag = `${outputDir}/no-glass-drag.png`;
|
||||
|
||||
await page.locator('#view-mode').selectOption('final');
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `${outputDir}/final-drag.png`, fullPage: true });
|
||||
report.screenshots.finalAfterDrag = `${outputDir}/final-drag.png`;
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(300);
|
||||
report.mobileHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
|
||||
if (report.mobileHorizontalOverflow) throw new Error('The page has horizontal overflow at 390px.');
|
||||
await page.screenshot({ path: `${outputDir}/final-mobile.png`, fullPage: true });
|
||||
report.screenshots.finalMobile = `${outputDir}/final-mobile.png`;
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.locator('#view-mode').selectOption('noglass');
|
||||
await page.locator('#ai-depth').click();
|
||||
await page.waitForFunction(() => document.querySelector('#ai-depth')?.textContent?.includes('생성 중'), null, { timeout: 5000 });
|
||||
await page.locator('#image').setInputFiles('public/assets/qa/dog1.jpeg');
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('#ai-depth');
|
||||
const provenance = document.querySelector('#depth-provenance');
|
||||
return button && !button.disabled && provenance?.hidden;
|
||||
}, null, { timeout: 20000 });
|
||||
await page.waitForTimeout(1000);
|
||||
report.photoSwitchDuringAI = {
|
||||
passed: await page.locator('#depth-provenance').isHidden(),
|
||||
buttonEnabled: await page.locator('#ai-depth').isEnabled(),
|
||||
};
|
||||
if (!report.photoSwitchDuringAI.passed || !report.photoSwitchDuringAI.buttonEnabled) {
|
||||
throw new Error('AI result was applied after the active photo changed.');
|
||||
}
|
||||
} catch (error) {
|
||||
report.failure = error instanceof Error ? error.message : String(error);
|
||||
report.status = await page.locator('#status').textContent().catch(() => '');
|
||||
report.provenance = await page.locator('#depth-provenance').textContent().catch(() => '');
|
||||
report.depthResults = await page.evaluate(() => window.__aiDepthWorkerResults).catch(() => []);
|
||||
} finally {
|
||||
report.errors = errors;
|
||||
await writeFile(`${outputDir}/report.json`, JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (report.failure) process.exitCode = 1;
|
||||
@@ -0,0 +1,73 @@
|
||||
param(
|
||||
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\tests\fixtures')
|
||||
)
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
|
||||
|
||||
function New-Fixture {
|
||||
param(
|
||||
[string]$Path,
|
||||
[int]$Width,
|
||||
[int]$Height,
|
||||
[int]$CellSize
|
||||
)
|
||||
|
||||
$bitmap = [System.Drawing.Bitmap]::new($Width, $Height)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
|
||||
|
||||
$bands = @(
|
||||
[System.Drawing.Color]::FromArgb(255, 246, 226),
|
||||
[System.Drawing.Color]::FromArgb(255, 218, 185),
|
||||
[System.Drawing.Color]::FromArgb(202, 234, 229),
|
||||
[System.Drawing.Color]::FromArgb(190, 218, 245)
|
||||
)
|
||||
$bandHeight = [math]::Ceiling($Height / $bands.Count)
|
||||
for ($index = 0; $index -lt $bands.Count; $index++) {
|
||||
$brush = [System.Drawing.SolidBrush]::new($bands[$index])
|
||||
$graphics.FillRectangle($brush, 0, $index * $bandHeight, $Width, $bandHeight)
|
||||
$brush.Dispose()
|
||||
}
|
||||
|
||||
$gridPen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(125, 37, 54, 69), 2)
|
||||
for ($x = 0; $x -le $Width; $x += $CellSize) {
|
||||
$graphics.DrawLine($gridPen, $x, 0, $x, $Height)
|
||||
}
|
||||
for ($y = 0; $y -le $Height; $y += $CellSize) {
|
||||
$graphics.DrawLine($gridPen, 0, $y, $Width, $y)
|
||||
}
|
||||
$gridPen.Dispose()
|
||||
|
||||
$circleBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(215, 214, 72, 94))
|
||||
$circlePen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(255, 120, 30, 58), 5)
|
||||
$circleSize = [math]::Min($Width, $Height) * 0.28
|
||||
$graphics.FillEllipse($circleBrush, $Width * 0.58, $Height * 0.18, $circleSize, $circleSize)
|
||||
$graphics.DrawEllipse($circlePen, $Width * 0.58, $Height * 0.18, $circleSize, $circleSize)
|
||||
$circleBrush.Dispose()
|
||||
$circlePen.Dispose()
|
||||
|
||||
$fontSize = [math]::Max(14, [math]::Floor($CellSize * 0.28))
|
||||
$font = [System.Drawing.Font]::new('Arial', $fontSize, [System.Drawing.FontStyle]::Bold)
|
||||
$textBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(255, 24, 44, 57))
|
||||
$columns = [math]::Ceiling($Width / $CellSize)
|
||||
$rows = [math]::Ceiling($Height / $CellSize)
|
||||
for ($row = 0; $row -lt $rows; $row++) {
|
||||
for ($column = 0; $column -lt $columns; $column++) {
|
||||
$number = $row * $columns + $column + 1
|
||||
$graphics.DrawString([string]$number, $font, $textBrush, $column * $CellSize + 8, $row * $CellSize + 7)
|
||||
}
|
||||
}
|
||||
|
||||
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$font.Dispose()
|
||||
$textBrush.Dispose()
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
New-Fixture (Join-Path $OutputDirectory 'landscape.png') 800 400 100
|
||||
New-Fixture (Join-Path $OutputDirectory 'portrait.png') 200 800 100
|
||||
Write-Output "Created fixtures in $OutputDirectory"
|
||||
@@ -0,0 +1,61 @@
|
||||
param(
|
||||
[string]$OutputPng = (Join-Path $PSScriptRoot '..\public\assets\sample-pet-depth.png'),
|
||||
[string]$OutputJson = (Join-Path $PSScriptRoot '..\public\assets\sample-pet-depth.json')
|
||||
)
|
||||
|
||||
# Hand-authored procedural fixture for local parallax tests.
|
||||
# Depth convention: 0 = far/background, 1 = near/foreground.
|
||||
# These values are visual approximations, not AI estimates or measurements.
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$width = 415
|
||||
$height = 739
|
||||
$background = 0.08
|
||||
|
||||
$landmarks = @(
|
||||
[pscustomobject]@{ name = 'nose'; x = 235; y = 310; depth = 0.98; rx = 23; ry = 18 },
|
||||
[pscustomobject]@{ name = 'left-eye'; x = 198; y = 278; depth = 0.68; rx = 27; ry = 21 },
|
||||
[pscustomobject]@{ name = 'right-eye'; x = 249; y = 273; depth = 0.68; rx = 27; ry = 21 },
|
||||
[pscustomobject]@{ name = 'face'; x = 221; y = 267; depth = 0.60; rx = 91; ry = 78 },
|
||||
[pscustomobject]@{ name = 'left-ear'; x = 158; y = 213; depth = 0.43; rx = 66; ry = 91 },
|
||||
[pscustomobject]@{ name = 'right-ear'; x = 262; y = 206; depth = 0.43; rx = 69; ry = 96 },
|
||||
[pscustomobject]@{ name = 'body'; x = 139; y = 419; depth = 0.30; rx = 139; ry = 238 },
|
||||
[pscustomobject]@{ name = 'muzzle'; x = 224; y = 305; depth = 0.82; rx = 58; ry = 43 }
|
||||
)
|
||||
|
||||
$bitmap = [System.Drawing.Bitmap]::new($width, $height, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
|
||||
try {
|
||||
for ($y = 0; $y -lt $height; $y++) {
|
||||
for ($x = 0; $x -lt $width; $x++) {
|
||||
$value = $background
|
||||
foreach ($shape in $landmarks) {
|
||||
$dx = ($x - $shape.x) / [double]$shape.rx
|
||||
$dy = ($y - $shape.y) / [double]$shape.ry
|
||||
$gaussian = [Math]::Exp(-0.5 * ($dx * $dx + $dy * $dy))
|
||||
# Max-combine softly feathered ellipses so overlaps keep the nearer layer.
|
||||
$candidate = $background + ($shape.depth - $background) * $gaussian
|
||||
if ($candidate -gt $value) { $value = $candidate }
|
||||
}
|
||||
$byte = [int][Math]::Round([Math]::Min(1.0, [Math]::Max(0.0, $value)) * 255.0)
|
||||
$color = [System.Drawing.Color]::FromArgb($byte, $byte, $byte)
|
||||
$bitmap.SetPixel($x, $y, $color)
|
||||
}
|
||||
}
|
||||
$bitmap.Save($OutputPng, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
$metadata = [ordered]@{
|
||||
width = $width
|
||||
height = $height
|
||||
convention = '0 = far/background, 1 = near/foreground'
|
||||
source = 'Hand-authored procedural Gaussian ellipses for local testing; not AI-estimated or physically measured.'
|
||||
landmarks = @($landmarks | ForEach-Object {
|
||||
[ordered]@{ name = $_.name; x = $_.x; y = $_.y; depth = $_.depth }
|
||||
})
|
||||
}
|
||||
$metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $OutputJson -Encoding utf8
|
||||
Write-Output "Created $OutputPng"
|
||||
Write-Output "Created $OutputJson"
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const controller = await readFile(new URL('../src/input/CameraController.ts', import.meta.url), 'utf8');
|
||||
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
||||
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
|
||||
const mosaicShader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
assert.match(controller, /maxOffset = 0\.5/);
|
||||
assert.match(controller, /clamp\(value, 0, 0\.9\)/);
|
||||
assert.match(controller, /multiplyScalar\(sensitivity\)/);
|
||||
assert.match(html, /id="camera-offset" type="range" min="0" max="0\.9" step="0\.01" value="0\.5"/);
|
||||
assert.match(html, /id="sensitivity" type="range" min="0\.2" max="2"/);
|
||||
assert.match(html, /Camera Offset <output id="cameraOut">0\.50/);
|
||||
assert.match(html, /권장 0\.35–0\.65 · 시점 이동 범위/);
|
||||
assert.match(mosaicShader, /edgeTaper=style<=\.5\?1\.25:\.75/);
|
||||
assert.match(mosaicShader, /historyTexel=clamp\(floor\(fract\(gridUv\)\*8\.\),vec2\(0\.\),vec2\(7\.\)\)/);
|
||||
assert.equal(Math.floor(0.45 * 8), 3, 'split sample just left of the half-cell boundary stays in texel 3');
|
||||
assert.equal(Math.floor(0.50 * 8), 4, 'split sample at the half-cell boundary enters texel 4');
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const cameraPosition = (target, sensitivity, maxOffset) => clamp(target * sensitivity, -1, 1) * maxOffset;
|
||||
assert.equal(cameraPosition(1, 1, 0.5), 0.5, 'default offset reaches the configured midpoint');
|
||||
assert.equal(cameraPosition(1, 1, 0.9), 0.9, 'maximum offset reaches 0.9');
|
||||
assert.equal(cameraPosition(1, 2, 0.9), 0.9, 'sensitivity does not exceed the view clamp');
|
||||
assert.equal(clamp(1, 0, 0.9), 0.9, 'UI maximum matches controller clamp');
|
||||
|
||||
const signatureStart = main.indexOf('function currentSplitHistorySignature()');
|
||||
const signatureEnd = main.indexOf('\nfunction clearSplitHistory()', signatureStart);
|
||||
assert.ok(signatureStart >= 0 && signatureEnd > signatureStart, 'history reset signature exists');
|
||||
const signature = main.slice(signatureStart, signatureEnd);
|
||||
for (const key of ['mode', 'effectInput.value', 'mosaicStyleInput.value', 'active.uuid', 'depth.uuid', 'uRegionAvailable', 'uGridSize', 'uStrength', 'uRegionStrength', 'uCurvature', 'uPaneSize', 'matrixWorld', 'uDepthScale', 'uDepthBias', 'renderTarget.width', 'regionAtlasTarget.width', 'regionPaletteTarget.width']) {
|
||||
assert.ok(signature.includes(key), `model-reset signature includes ${key}`);
|
||||
}
|
||||
assert.doesNotMatch(signature, /uContentViewProjection|cameraController|uContentCameraPosition/, 'camera movement must not reset split history');
|
||||
assert.match(main, /const writeIndex = 1 - splitHistoryRead/);
|
||||
assert.match(main, /uPreviousHistory\.value = splitHistoryTargets\[splitHistoryRead\]\.texture/);
|
||||
assert.match(main, /uHistoryBlend\.value = splitHistoryInitialized \? 1 - Math\.exp\(-dt \/ 0\.07\) : 1/);
|
||||
assert.match(main, /updateSplitHistory\(dt\); renderer\.setRenderTarget\(null\); renderer\.render\(scene, camera\)/, 'inactive view updates the signature and clears stale history');
|
||||
assert.match(main, /splitHistoryTargets\.forEach\(\(target\) => target\.dispose\(\)\)/);
|
||||
assert.match(main, /target\.setSize\(atlasWidth, atlasHeight\)/);
|
||||
assert.match(historyShader, /if\(uHistoryBlend>=\.9999\)\{gl_FragColor=current;return;\}/, 'first frame avoids sampling uninitialized history');
|
||||
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
|
||||
|
||||
function advance(fps, seconds, initial, target) {
|
||||
let value = initial;
|
||||
const dt = 1 / fps;
|
||||
for (let frame = 0; frame < Math.round(fps * seconds); frame++) {
|
||||
const alpha = 1 - Math.exp(-dt / 0.07);
|
||||
value += (target - value) * alpha;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const cadence = [30, 60, 120].map(fps => advance(fps, 0.7, 0, 1));
|
||||
assert.ok(Math.max(...cadence) - Math.min(...cadence) < 1e-12, `30/60/120Hz history cadence differs: ${cadence}`);
|
||||
assert.ok(Math.abs(cadence[0] - (1 - Math.exp(-0.7 / 0.07))) < 1e-12, 'history follows the 70ms exponential time constant');
|
||||
assert.ok(cadence[0] > 0.999, 'a settled model reaches its target after 700ms');
|
||||
|
||||
const color = [0.8, 0.25, 0.1], confidence = 0.6;
|
||||
const premultiplied = [...color.map(channel => channel * confidence), confidence];
|
||||
assert.deepEqual(premultiplied.slice(0, 3).map(channel => channel / premultiplied[3]), color, 'split color is stored premultiplied by confidence');
|
||||
const previous = [0, 0, 0, 0], blend = 0.25;
|
||||
const filtered = previous.map((channel, index) => channel + (premultiplied[index] - channel) * blend);
|
||||
assert.equal(filtered[3], confidence * blend);
|
||||
assert.deepEqual(filtered.slice(0, 3).map(channel => channel / filtered[3]), color, 'filtered premultiplied color retains its hue');
|
||||
|
||||
console.log(`Camera/history checks passed: default/max clamp, model reset keys, ping-pong and disposal, premultiplied color, cadence ${cadence.map(v => v.toFixed(6)).join('/')}, 70ms settling.`);
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { DepthProcessor } from '../src/depth/DepthProcessor.ts';
|
||||
|
||||
function texture(values) { return DepthProcessor.fromNormalizedDepth(new Uint8Array(values), values.length, 1); }
|
||||
const flat = texture(Array(100).fill(128));
|
||||
assert.deepEqual(DepthProcessor.evidenceRange(flat), [1, 1]);
|
||||
flat.dispose();
|
||||
const scene = texture([...Array(60).fill(50), ...Array(25).fill(170), ...Array(14).fill(190), 255]);
|
||||
const [start, end] = DepthProcessor.evidenceRange(scene);
|
||||
assert.ok(start < 170 / 255 && end <= 190 / 255, 'face-range depths must contribute below the old 0.8 cutoff; one near outlier must not set the gate');
|
||||
assert.ok(end > start, 'non-flat evidence range is ordered');
|
||||
scene.dispose();
|
||||
for (const nearCount of [10, 60]) {
|
||||
const bimodal = texture([...Array(100 - nearCount).fill(50), ...Array(nearCount).fill(240)]);
|
||||
const [low, high] = DepthProcessor.evidenceRange(bimodal);
|
||||
assert.ok(low < high && high <= 240 / 255, 'two distinct depth layers must not be treated as flat');
|
||||
bimodal.dispose();
|
||||
}
|
||||
console.log('Depth histogram: flat disabled, mid-depth foreground retained, sparse near outlier rejected.');
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as THREE from 'three';
|
||||
import { DEPTH_REFRACTION } from '../src/constants/depth.ts';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
||||
const media = main.slice(main.indexOf('function applyMedia('), main.indexOf('function setDepth('));
|
||||
assert.doesNotMatch(media, /photo(?:Zoom|PanX|PanY)\s*=/, 'media replacement preserves manual framing');
|
||||
assert.match(shader, /sceneHit=refineSceneHit\(gh,normalize\(mix\(i,curvedRay,strength\)\),sceneHit,cellWorld\)/);
|
||||
assert.match(shader, /if\(!reconstructScenePoint\(projectUv\(hit\),surface\)\)return artisticHit/);
|
||||
assert.match(shader, /confidence=1\.-smoothstep/);
|
||||
assert.match(shader, /delta\*=min\(1\.,limit\/max\(length\(delta\),.00001\)\)/);
|
||||
// Analytic planar case for the correction's geometry. GPU execution is checked in-browser.
|
||||
const camera = new THREE.Vector3(.25, 0, 3), glass = new THREE.Vector3(0, 0, .6);
|
||||
const ray = new THREE.Vector3(-.15, 0, -1).normalize(), baseZ = .31;
|
||||
const plane = glass.clone().addScaledVector(ray, (baseZ - glass.z) / ray.z);
|
||||
function projectedAt(z) {
|
||||
const hit = glass.clone().addScaledVector(ray, (z - glass.z) / ray.z);
|
||||
const view = hit.clone().sub(camera);
|
||||
return camera.clone().addScaledVector(view, (baseZ - camera.z) / view.z);
|
||||
}
|
||||
assert.ok(projectedAt(baseZ).distanceTo(plane) < 1e-10, 'reference plane introduces no correction');
|
||||
assert.ok(projectedAt(.55).distanceTo(plane) > .001, 'near surface changes refracted sample');
|
||||
assert.ok(DEPTH_REFRACTION.blend > 0 && DEPTH_REFRACTION.blend <= 1);
|
||||
assert.ok(DEPTH_REFRACTION.blend * DEPTH_REFRACTION.maxCellCorrection < .25, 'correction stays below a quarter cell');
|
||||
assert.match(shader, /environmentWeight=.*glossStrength.*clamp\(uStrength,0\.,1\.\)/);
|
||||
console.log('Depth correction: planar identity, nonzero near correction, bounded shift, framing and reflection gates.');
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
assert.match(shader, /curvedSlope=\.30\*cellDimensions\/max\(abs\(opticalTravel\),\.6\)\*signedCurvatureScale\(\)\*sin\(6\.2831853\*local\)/);
|
||||
assert.match(shader, /smoothFacet=\.003\*vec2\(sin\(6\.2831853\*paneUv\.x\),sin\(6\.2831853\*paneUv\.y\)\)/);
|
||||
assert.match(shader, /float opticalTravel=-max\(abs\(uSourceZ-gh\.z\),\.6\)/);
|
||||
assert.match(shader, /if\(style<=\.5\)\{\s*vec2 smoothFacet/);
|
||||
assert.match(shader, /vec3 referenceI=normalize\(gh-vec3\(0\.,0\.,uContentCameraPosition\.z\)\),referenceRay=bend\(referenceI,nCurved\)/);
|
||||
assert.match(shader, /incidenceDelta\/sqrt\(incidenceDelta\*incidenceDelta\+vec2\(\.025\*\.025\)\)/);
|
||||
assert.match(shader, /cellEnvelope\*cellDimensions\*signedCurvatureScale\(\)\*\.18/);
|
||||
assert.match(shader, /edgeTaper=style<=\.5\?1\.25:\.75/);
|
||||
assert.match(shader, /float normalBlend=\.16/);
|
||||
assert.match(shader, /vec3 sceneHit=straightHit\+vec3\(worldDelta\*strength,0\.\);vec2 finalSceneUv=projectUv\(sceneHit\)/);
|
||||
assert.match(shader, /color=sampleScene\(finalSceneUv\)/);
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const cameraZ = 3;
|
||||
const glassZ = 0.6;
|
||||
const strengthSetting = 0.9;
|
||||
const fovScale = Math.tan(20 * Math.PI / 180);
|
||||
const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||
const sub = (a, b) => a.map((v, i) => v - b[i]);
|
||||
const mul = (a, s) => a.map(v => v * s);
|
||||
const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
|
||||
const norm = a => mul(a, 1 / Math.hypot(...a));
|
||||
const safeZ = z => Math.abs(z) < 1e-4 ? (z < 0 ? -1e-4 : 1e-4) : z;
|
||||
const smoothstep = (a, b, x) => {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const softLimit = (value, limit) => value * limit / (limit + Math.abs(value));
|
||||
const surfaceCurvature = curvature => {
|
||||
const clamped = Math.max(-1, Math.min(1, curvature));
|
||||
const excess = Math.max(clamped - 0.65, 0);
|
||||
return clamped - (0.25 / 0.35) * excess * excess;
|
||||
};
|
||||
const curvatureScale = curvature => Math.max(-1.25, Math.min(1.25, surfaceCurvature(curvature) / 0.65));
|
||||
function refract(i, n) {
|
||||
const eta = 0.75;
|
||||
const d = dot(n, i);
|
||||
const k = 1 - eta * eta * (1 - d * d);
|
||||
if (k < 0) return i;
|
||||
return sub(mul(i, eta), mul(n, eta * d + Math.sqrt(k)));
|
||||
}
|
||||
|
||||
function mapToSource(uv, { gridSize, curvature, pane, cameraXY, sourceZ }) {
|
||||
const [width, height] = pane;
|
||||
const grid = [gridSize, Math.max(1, gridSize * height / width)];
|
||||
const cellSize = [1 / grid[0], 1 / grid[1]];
|
||||
const cellDimensions = [width / grid[0], height / grid[1]];
|
||||
const cellWorld = Math.min(...cellDimensions);
|
||||
const gx = uv[0] * grid[0], gy = uv[1] * grid[1];
|
||||
const local = [gx - Math.floor(gx) - 0.5, gy - Math.floor(gy) - 0.5];
|
||||
const gh = [(uv[0] - 0.5) * width, (uv[1] - 0.5) * height, glassZ];
|
||||
const camera = [cameraXY[0], cameraXY[1], cameraZ];
|
||||
const i = norm(sub(gh, camera));
|
||||
const opticalTravel = -Math.max(Math.abs(sourceZ - gh[2]), 0.6);
|
||||
const signedScale = curvatureScale(curvature);
|
||||
const curvedSlope = [0, 1].map(axis =>
|
||||
0.30 * cellDimensions[axis] / Math.max(Math.abs(opticalTravel), 0.6) * signedScale * Math.sin(TAU * local[axis])
|
||||
);
|
||||
const smoothFacet = [0.003 * Math.sin(TAU * uv[0]), 0.003 * Math.sin(TAU * uv[1])];
|
||||
const nCurved = norm([curvedSlope[0] + smoothFacet[0], curvedSlope[1] + smoothFacet[1], 1]);
|
||||
const curvedRay = refract(i, nCurved);
|
||||
const rawDelta = [0, 1].map(axis =>
|
||||
(curvedRay[axis] / safeZ(curvedRay[2]) - i[axis] / safeZ(i[2])) * opticalTravel
|
||||
);
|
||||
const worldDelta = rawDelta.map(value => softLimit(value, cellWorld * 0.55));
|
||||
const referenceI = norm([gh[0], gh[1], gh[2] - cameraZ]);
|
||||
const referenceRay = refract(referenceI, nCurved);
|
||||
const cellEnvelope = local.map(value => 0.5 + 0.5 * Math.cos(TAU * value));
|
||||
const incidenceDelta = [0, 1].map(axis => curvedRay[axis] / -curvedRay[2] - referenceRay[axis] / -referenceRay[2]);
|
||||
const boundedIncidence = incidenceDelta.map(value => value / Math.sqrt(value * value + 0.025 ** 2));
|
||||
for (let axis = 0; axis < 2; axis++) {
|
||||
worldDelta[axis] += boundedIncidence[axis] * cellEnvelope[axis] * cellDimensions[axis] * signedScale * 0.18;
|
||||
}
|
||||
const straightT = (sourceZ - gh[2]) / safeZ(i[2]);
|
||||
const straightHit = add(gh, mul(i, straightT));
|
||||
const edgeDistance = Math.min(uv[0], uv[1], 1 - uv[0], 1 - uv[1]);
|
||||
const edgeWeight = smoothstep(0, Math.max(...cellSize) * 1.25, edgeDistance);
|
||||
const sceneHit = add(straightHit, [worldDelta[0] * strengthSetting * edgeWeight, worldDelta[1] * strengthSetting * edgeWeight, 0]);
|
||||
const depth = cameraZ - sceneHit[2];
|
||||
return [
|
||||
0.5 + 0.5 * (sceneHit[0] - cameraXY[0]) / (depth * fovScale),
|
||||
0.5 + 0.5 * (sceneHit[1] - cameraXY[1]) / (depth * fovScale)
|
||||
];
|
||||
}
|
||||
|
||||
const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
const grids = [6, 10, 96];
|
||||
const curvatures = [-1, 0, 0.65, 1];
|
||||
const panes = [[1.2, 0.8], [1.28, 1.6]];
|
||||
const cameras = [[0, 0], [0.3, -0.3], [-0.6, 0.6], [-0.9, 0.9], [0.9, -0.9]];
|
||||
const sourcePlanes = [0.16, 0.56];
|
||||
const phases = [0.03, 0.25, 0.49, 0.5, 0.75, 0.97];
|
||||
const interiorPhases = [-0.4, -0.25, 0, 0.25, 0.4];
|
||||
let pointsChecked = 0;
|
||||
let seamsChecked = 0;
|
||||
|
||||
for (const gridSize of grids) for (const curvature of curvatures) for (const pane of panes) {
|
||||
for (const cameraXY of cameras) for (const sourceZ of sourcePlanes) {
|
||||
const config = { gridSize, curvature, pane, cameraXY, sourceZ };
|
||||
const points = [];
|
||||
for (let line = 0; line <= gridSize; line++) {
|
||||
const seam = line / gridSize;
|
||||
for (const phase of phases) {
|
||||
points.push([seam, phase], [phase, seam]);
|
||||
}
|
||||
for (let cell = 1; cell < gridSize; cell += Math.max(1, Math.floor(gridSize / 4))) {
|
||||
for (const local of interiorPhases) {
|
||||
points.push([(cell + 0.5 + local) / gridSize, (cell + 0.5) / gridSize]);
|
||||
points.push([(cell + 0.5) / gridSize, (cell + 0.5 + local) / gridSize]);
|
||||
}
|
||||
}
|
||||
}
|
||||
points.push([0.001, 0.001], [0.999, 0.001], [0.001, 0.999], [0.999, 0.999]);
|
||||
const h = Math.min(1e-6, 1e-4 / gridSize);
|
||||
for (const [u0, v0] of points) {
|
||||
const u = Math.max(h * 2, Math.min(1 - h * 2, u0));
|
||||
const v = Math.max(h * 2, Math.min(1 - h * 2, v0));
|
||||
const left = mapToSource([u - h, v], config), right = mapToSource([u + h, v], config);
|
||||
const down = mapToSource([u, v - h], config), up = mapToSource([u, v + h], config);
|
||||
const dx = [(right[0] - left[0]) / (2 * h), (right[1] - left[1]) / (2 * h)];
|
||||
const dy = [(up[0] - down[0]) / (2 * h), (up[1] - down[1]) / (2 * h)];
|
||||
const determinant = dx[0] * dy[1] - dy[0] * dx[1];
|
||||
assert.ok(dx[0] > 0 && dy[1] > 0 && determinant > 0,
|
||||
`folded projected UV at grid=${gridSize}, curvature=${curvature}, pane=${pane}, camera=${cameraXY}, sourceZ=${sourceZ}, uv=${u},${v}; jac=[${dx},${dy}]`);
|
||||
pointsChecked++;
|
||||
}
|
||||
|
||||
const epsilon = 1e-4 / gridSize;
|
||||
for (let line = 1; line < gridSize; line++) {
|
||||
const seam = line / gridSize;
|
||||
for (const phase of [0.07, 0.31, 0.5, 0.69, 0.93]) {
|
||||
for (const [axis, other] of [[0, phase], [1, phase]]) {
|
||||
const a = axis === 0 ? [seam - epsilon, other] : [other, seam - epsilon];
|
||||
const b = axis === 0 ? [seam + epsilon, other] : [other, seam + epsilon];
|
||||
const aHalf = axis === 0 ? [seam - epsilon / 2, other] : [other, seam - epsilon / 2];
|
||||
const bHalf = axis === 0 ? [seam + epsilon / 2, other] : [other, seam + epsilon / 2];
|
||||
const fullStep = distance(mapToSource(a, config), mapToSource(b, config));
|
||||
const halfStep = distance(mapToSource(aHalf, config), mapToSource(bHalf, config));
|
||||
assert.ok(fullStep > 0 && Math.abs(fullStep - 2 * halfStep) < fullStep * 0.03,
|
||||
`seam does not converge continuously at grid=${gridSize}, curvature=${curvature}, uv=${seam},${other}`);
|
||||
seamsChecked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displacementAt(uv, config) {
|
||||
const { gridSize, pane, cameraXY, sourceZ } = config;
|
||||
const [width, height] = pane;
|
||||
const grid = [gridSize, Math.max(1, gridSize * height / width)];
|
||||
const gh = [(uv[0] - 0.5) * width, (uv[1] - 0.5) * height, glassZ];
|
||||
const i = norm(sub(gh, [cameraXY[0], cameraXY[1], cameraZ]));
|
||||
const cell = [Math.floor(uv[0] * grid[0]), Math.floor(uv[1] * grid[1])];
|
||||
const local = [uv[0] * grid[0] - cell[0] - 0.5, uv[1] * grid[1] - cell[1] - 0.5];
|
||||
const cellDimensions = [width / grid[0], height / grid[1]];
|
||||
const travel = -Math.max(Math.abs(sourceZ - glassZ), 0.6);
|
||||
const scale = curvatureScale(config.curvature);
|
||||
const slope = local.map((v, axis) => 0.30 * cellDimensions[axis] / Math.max(Math.abs(travel), 0.6) * scale * Math.sin(TAU * v));
|
||||
const smoothFacet = [0.003 * Math.sin(TAU * uv[0]), 0.003 * Math.sin(TAU * uv[1])];
|
||||
const n = norm([slope[0] + smoothFacet[0], slope[1] + smoothFacet[1], 1]);
|
||||
const ray = refract(i, n);
|
||||
const reference = refract(norm([gh[0], gh[1], gh[2] - cameraZ]), n);
|
||||
const envelope = local.map(v => 0.5 + 0.5 * Math.cos(TAU * v));
|
||||
return [0, 1].map(axis => {
|
||||
const a = ray[axis] / -ray[2] - reference[axis] / -reference[2];
|
||||
return a / Math.sqrt(a * a + 0.025 ** 2) * envelope[axis] * cellDimensions[axis] * scale * 0.18;
|
||||
});
|
||||
}
|
||||
|
||||
for (const curvature of [-1, -0.65, 0.65, 1]) {
|
||||
const centered = { gridSize: 10, curvature, pane: [1.2, 0.8], cameraXY: [0, 0], sourceZ: 0.16 };
|
||||
const cameraView = { ...centered, cameraXY: [0.3, -0.3] };
|
||||
const gridY = Math.max(1, centered.gridSize * centered.pane[1] / centered.pane[0]);
|
||||
const cellCenter = [(4 + 0.5) / centered.gridSize, (Math.floor(gridY / 2) + 0.5) / gridY];
|
||||
const xEdges = [4 / centered.gridSize, 5 / centered.gridSize];
|
||||
const yIndex = Math.floor(gridY / 2), yEdges = [yIndex / gridY, (yIndex + 1) / gridY];
|
||||
const atCellCenter = displacementAt(cellCenter, centered);
|
||||
assert.ok(Math.hypot(...atCellCenter) < 1e-10, `center view should add no local displacement at curvature=${curvature}`);
|
||||
const edgePoints = [
|
||||
[[xEdges[0], cellCenter[1]], 0], [[xEdges[1], cellCenter[1]], 0],
|
||||
[[cellCenter[0], yEdges[0]], 1], [[cellCenter[0], yEdges[1]], 1]
|
||||
];
|
||||
for (const [uv, fixedAxis] of edgePoints) {
|
||||
assert.ok(Math.abs(displacementAt(uv, cameraView)[fixedAxis]) < 1e-8, `cell edge axis should remain fixed at curvature=${curvature}, uv=${uv}`);
|
||||
}
|
||||
const samples = [
|
||||
[cellCenter[0] - 0.02, cellCenter[1]], cellCenter, [cellCenter[0] + 0.02, cellCenter[1]],
|
||||
[cellCenter[0], cellCenter[1] - 0.02], [cellCenter[0], cellCenter[1] + 0.02]
|
||||
];
|
||||
const offsets = samples.map(uv => displacementAt(uv, cameraView));
|
||||
const mean = offsets.reduce((sum, value) => add(sum, value), [0, 0]).map(value => value / offsets.length);
|
||||
const variance = offsets.reduce((sum, value) => sum + dot(sub(value, mean), sub(value, mean)), 0) / offsets.length;
|
||||
assert.ok(variance > 1e-8, `camera incidence should create varying within-cell displacement at curvature=${curvature}`);
|
||||
}
|
||||
|
||||
console.log(`Mosaic projected-UV continuity passed: ${pointsChecked} Jacobians, ${seamsChecked} seam convergence checks, center/edge anchors, and view-dependent within-cell displacement.`);
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
||||
assert.match(shader, /uniform sampler2D uSceneTexture,uSceneDepth/);
|
||||
assert.match(shader, /color=sampleScene\(finalSceneUv\)/);
|
||||
assert.match(shader, /regionSurfaceValid\(finalSceneUv,gh\)/);
|
||||
assert.match(shader, /float depth=texture2D\(uSceneDepth,sceneUv\)\.x/);
|
||||
assert.doesNotMatch(shader, /smoothstep\(\.045,\.14,gap\)/);
|
||||
assert.match(shader, /uRegionStrength\*clamp\(uStrength,0\.,1\.\)\*regionSurfaceValid\(finalSceneUv,gh\)/);
|
||||
assert.match(shader, /uniform sampler2D uProtectionDepthMap/);
|
||||
assert.match(shader, /uniform mat4 uPetWorldToLocal/);
|
||||
assert.match(shader, /uProtectionDepthMap,sourceUv\)\.r/);
|
||||
assert.match(shader, /return smoothstep\(uDepthEvidenceRange.x,uDepthEvidenceRange.y,sourceDepth\)/);
|
||||
assert.match(main, /depthEvidenceUniform.value.fromArray\(DepthProcessor.evidenceRange\(texture\)\)/);
|
||||
assert.match(shader, /if\(!reconstructScenePoint\(sceneUv,worldPoint\)\)return 0\./);
|
||||
assert.match(shader, /if\(localPoint\.x<-.5\|\|localPoint\.x>\.5\|\|localPoint\.y<-.5\|\|localPoint\.y>\.5\)return 0\./);
|
||||
assert.match(shader, /atlasEvidence=regionProtectionWeight\(finalSceneUv\)\*\(1\.-smoothstep\(\.10,\.20,regionLuminance\(atlasCenterColor\)\)\)/);
|
||||
assert.match(shader, /color\.a=atlasEvidence/);
|
||||
assert.match(shader, /gl_FragColor=vec4\(sampleScene\(screenUv\)\.rgb,0\.\)/);
|
||||
assert.match(shader, /gl_FragColor=vec4\(uBackgroundColor,0\.\)/);
|
||||
assert.doesNotMatch(shader, /protectDark|protectedColor|ownPalette|darkestPalette/);
|
||||
assert.match(main, /uProtectionDepthMap: depthUniforms\.uDepthMap/);
|
||||
assert.match(main, /uPetWorldToLocal: \{ value: new THREE\.Matrix4\(\) \}/);
|
||||
assert.match(main, /uPetWorldToLocal\.value\.copy\(petDepth\.matrixWorld\)\.invert\(\)/);
|
||||
assert.match(main, /renderTarget\.depthTexture\s*=\s*new THREE\.DepthTexture/);
|
||||
|
||||
function reconstructPoint(uv, depth, inverseViewProjection) {
|
||||
const world = new THREE.Vector4(uv.x * 2 - 1, uv.y * 2 - 1, depth * 2 - 1, 1)
|
||||
.applyMatrix4(inverseViewProjection);
|
||||
return new THREE.Vector3(world.x / world.w, world.y / world.w, world.z / world.w);
|
||||
}
|
||||
|
||||
function validAtDepth(depth, uv, glassHit, inverseViewProjection, worldW = 1) {
|
||||
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) return 0;
|
||||
if (depth >= 0.999999 || Math.abs(worldW) < 0.0001) return 0;
|
||||
const subject = reconstructPoint(uv, depth, inverseViewProjection);
|
||||
const axialGap = glassHit.z - subject.z;
|
||||
if (axialGap <= 0.0001) return 0;
|
||||
return 1;
|
||||
}
|
||||
function protectionWeight(sourceDepth) {
|
||||
const t = Math.max(0, Math.min(1, (sourceDepth - 0.8) / (0.95 - 0.8)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
function projectSourceUv(sourceUv, sourceDepth, depthScale, photoZoom, photoPan, cameraPosition) {
|
||||
const baseW = 1.65, baseH = 1.2, worldScale = 0.48, baseZ = 0.42, depthBias = 0.1;
|
||||
const position = new THREE.Vector3(
|
||||
baseW * (photoZoom - 1) * 0.5 * photoPan.x * worldScale,
|
||||
baseH * (photoZoom - 1) * 0.5 * photoPan.y * worldScale,
|
||||
baseZ
|
||||
);
|
||||
const scale = new THREE.Vector3(baseW * photoZoom * worldScale, baseH * photoZoom * worldScale, 1);
|
||||
const petMatrix = new THREE.Matrix4().compose(position, new THREE.Quaternion(), scale);
|
||||
const local = new THREE.Vector3(sourceUv.x - 0.5, sourceUv.y - 0.5, (sourceDepth - depthBias) * depthScale);
|
||||
const worldPoint = local.clone().applyMatrix4(petMatrix);
|
||||
const camera = new THREE.PerspectiveCamera(40, 1.4, 0.1, 100);
|
||||
camera.position.copy(cameraPosition);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
const viewProjection = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
||||
const inverseViewProjection = viewProjection.clone().invert();
|
||||
const clip = new THREE.Vector4(worldPoint.x, worldPoint.y, worldPoint.z, 1).applyMatrix4(viewProjection);
|
||||
const ndc = new THREE.Vector3(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
|
||||
const screenUv = new THREE.Vector2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5);
|
||||
const reconstructed = reconstructPoint(screenUv, ndc.z * 0.5 + 0.5, inverseViewProjection);
|
||||
const recoveredLocal = reconstructed.applyMatrix4(petMatrix.clone().invert());
|
||||
return { screenUv, recoveredUv: new THREE.Vector2(recoveredLocal.x + 0.5, recoveredLocal.y + 0.5) };
|
||||
}
|
||||
|
||||
for (const [sourceDepth, expected] of [[0.79, 0], [0.8, 0], [0.875, 0.5], [0.95, 1], [1, 1]]) {
|
||||
assert.ok(Math.abs(protectionWeight(sourceDepth) - expected) < 1e-9,
|
||||
`near-depth gate at ${sourceDepth} should be ${expected}`);
|
||||
}
|
||||
assert.equal(protectionWeight(0), 0, 'flat black depth disables the protection gate');
|
||||
|
||||
let mapped = 0;
|
||||
for (const depthScale of [0, 0.25, 0.4]) for (const photo of [
|
||||
{ zoom: 1, pan: new THREE.Vector2(0, 0) },
|
||||
{ zoom: 1.6, pan: new THREE.Vector2(0.6, -0.35) },
|
||||
]) for (const cameraPosition of [new THREE.Vector3(0, 0, 3), new THREE.Vector3(0.35, -0.2, 3.2)]) {
|
||||
const sourceUv = new THREE.Vector2(0.12, 0.74);
|
||||
const result = projectSourceUv(sourceUv, 0.92, depthScale, photo.zoom, photo.pan, cameraPosition);
|
||||
assert.ok(result.recoveredUv.distanceTo(sourceUv) < 1e-5,
|
||||
`source depth lookup must survive depthScale ${depthScale}, zoom ${photo.zoom}, camera ${cameraPosition.toArray()}`);
|
||||
mapped++;
|
||||
}
|
||||
|
||||
let reconstructed = 0;
|
||||
for (const position of [[0, 0, 3], [1.8, 1.2, 3.4], [-1.8, -1.2, 3.4]]) {
|
||||
const camera = new THREE.PerspectiveCamera(40, 1.4, 0.1, 100);
|
||||
camera.position.set(...position);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
const viewProjection = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
||||
const inverseViewProjection = viewProjection.clone().invert();
|
||||
const rayDirection = camera.getWorldDirection(new THREE.Vector3());
|
||||
const rayToGlass = (0.6 - camera.position.z) / rayDirection.z;
|
||||
const glassHit = camera.position.clone().addScaledVector(rayDirection, rayToGlass);
|
||||
|
||||
for (const gap of [0.0002, 0.04, 0.045, 0.09, 0.14, 0.16, 1.2]) {
|
||||
const subject = glassHit.clone().addScaledVector(rayDirection, gap);
|
||||
const clip = new THREE.Vector4(subject.x, subject.y, subject.z, 1).applyMatrix4(viewProjection);
|
||||
const ndc = new THREE.Vector3(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
|
||||
const uv = new THREE.Vector2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5);
|
||||
const depth = ndc.z * 0.5 + 0.5;
|
||||
const recovered = reconstructPoint(uv, depth, inverseViewProjection);
|
||||
assert.ok(recovered.distanceTo(subject) < 1e-5, `world reconstruction failed at camera ${position}, gap ${gap}`);
|
||||
reconstructed++;
|
||||
assert.equal(validAtDepth(depth, uv, glassHit, inverseViewProjection), 1, `valid surface at gap ${gap} should receive full configured region strength`);
|
||||
}
|
||||
|
||||
assert.equal(validAtDepth(1, new THREE.Vector2(0.5, 0.5), glassHit, inverseViewProjection), 0, 'background depth must bypass simplification');
|
||||
assert.equal(validAtDepth(0.5, new THREE.Vector2(-0.01, 0.5), glassHit, inverseViewProjection), 0, 'out-of-range UV must bypass simplification');
|
||||
assert.equal(validAtDepth(0.5, new THREE.Vector2(0.5, 0.5), glassHit, inverseViewProjection, 0.00001), 0, 'invalid homogeneous world coordinate must bypass simplification');
|
||||
const frontPoint = glassHit.clone().addScaledVector(rayDirection, -0.04);
|
||||
const frontClip = new THREE.Vector4(frontPoint.x, frontPoint.y, frontPoint.z, 1).applyMatrix4(viewProjection);
|
||||
const frontNdc = new THREE.Vector3(frontClip.x / frontClip.w, frontClip.y / frontClip.w, frontClip.z / frontClip.w);
|
||||
assert.equal(validAtDepth(frontNdc.z * 0.5 + 0.5, new THREE.Vector2(frontNdc.x * 0.5 + 0.5, frontNdc.y * 0.5 + 0.5), glassHit, inverseViewProjection), 0, 'surface in front of glass must bypass simplification');
|
||||
const contactPoint = glassHit.clone().addScaledVector(rayDirection, 0.00005);
|
||||
const contactClip = new THREE.Vector4(contactPoint.x, contactPoint.y, contactPoint.z, 1).applyMatrix4(viewProjection);
|
||||
const contactNdc = new THREE.Vector3(contactClip.x / contactClip.w, contactClip.y / contactClip.w, contactClip.z / contactClip.w);
|
||||
assert.equal(validAtDepth(contactNdc.z * 0.5 + 0.5, new THREE.Vector2(contactNdc.x * 0.5 + 0.5, contactNdc.y * 0.5 + 0.5), glassHit, inverseViewProjection), 0, 'same or nearly coincident glass surface must bypass simplification');
|
||||
}
|
||||
|
||||
console.log(`Mosaic depth-region projection checks passed: ${reconstructed} perspective reconstructions, ${mapped} original-depth UV mappings across camera/zoom-pan/depthScale, near-depth gate, distance-independent color application, and invalid-surface guards.`);
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
|
||||
// This is a source/math-only regression. It mirrors the bounded scoring contract
|
||||
// used by REGION_ATLAS; it does not claim to execute GLSL or prove browser visuals.
|
||||
assert.match(shader, /regionAtlasProbe/,
|
||||
'REGION_ATLAS must expose the bounded probe path before this regression can pass');
|
||||
assert.match(shader, /regionAtlasEvidence/,
|
||||
'atlas probes must score RGB and front-depth evidence in the atlas pass');
|
||||
|
||||
const smoothstep = (edge0, edge1, value) => {
|
||||
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const luminance = color => color[0] * 0.299 + color[1] * 0.587 + color[2] * 0.114;
|
||||
const darkEvidence = (color, frontDepth, valid = true) => valid
|
||||
? smoothstep(0.8, 0.95, frontDepth) * (1 - smoothstep(0.10, 0.20, luminance(color)))
|
||||
: 0;
|
||||
|
||||
function baselinePointSample(fixture) {
|
||||
return { color: fixture.center.color, evidence: fixture.center.evidence };
|
||||
}
|
||||
|
||||
function atlasRepresentative(fixture) {
|
||||
const centerColor = fixture.center.color;
|
||||
const centerEvidence = fixture.center.evidence;
|
||||
const centerLuma = luminance(centerColor);
|
||||
let bestColor = centerColor;
|
||||
let bestEvidence = centerEvidence;
|
||||
let bestBlend = 0;
|
||||
|
||||
for (const probe of fixture.probes.slice(0, 4)) {
|
||||
const evidence = darkEvidence(probe.color, probe.frontDepth, probe.valid);
|
||||
const evidenceGain = evidence - centerEvidence;
|
||||
const darkGain = centerLuma - luminance(probe.color);
|
||||
let blend = 0;
|
||||
if (evidence > 0.05 && evidenceGain > 0.02 && darkGain > 0.015) {
|
||||
blend = Math.min(0.4, evidenceGain * 2) * Math.min(1, Math.max(0, darkGain * 4));
|
||||
}
|
||||
if (blend > bestBlend) {
|
||||
bestColor = centerColor.map((value, index) => value * (1 - blend) + probe.color[index] * blend);
|
||||
bestEvidence = Math.max(centerEvidence, evidence);
|
||||
bestBlend = blend;
|
||||
}
|
||||
}
|
||||
return { color: bestColor, evidence: bestEvidence };
|
||||
}
|
||||
|
||||
const missedEye = {
|
||||
center: { color: [0.62, 0.62, 0.62], evidence: 0 },
|
||||
probes: [
|
||||
{ color: [0.035, 0.035, 0.035], frontDepth: 0.92, valid: true },
|
||||
{ color: [0.62, 0.62, 0.62], frontDepth: 0.92, valid: true },
|
||||
{ color: [0.62, 0.62, 0.62], frontDepth: 0.92, valid: true },
|
||||
{ color: [0.62, 0.62, 0.62], frontDepth: 0.92, valid: true },
|
||||
],
|
||||
};
|
||||
const oldPoint = baselinePointSample(missedEye);
|
||||
const revised = atlasRepresentative(missedEye);
|
||||
assert.ok(luminance(oldPoint.color) > 0.5, 'baseline point sampling must miss the narrow dark feature');
|
||||
assert.ok(luminance(revised.color) < 0.5, 'atlas evidence must pull the representative toward the dark feature');
|
||||
assert.ok(revised.evidence > 0.5, 'the valid front-depth dark probe must carry evidence into atlas fitting');
|
||||
|
||||
for (const [name, fixture] of [
|
||||
['far', {
|
||||
center: { color: [0.62, 0.62, 0.62], evidence: 0 },
|
||||
probes: [{ color: [0.035, 0.035, 0.035], frontDepth: 0.79, valid: true }],
|
||||
}],
|
||||
['invalid', {
|
||||
center: { color: [0.62, 0.62, 0.62], evidence: 0 },
|
||||
probes: [{ color: [0.035, 0.035, 0.035], frontDepth: 0.92, valid: false }],
|
||||
}],
|
||||
['uniform', {
|
||||
center: { color: [0.42, 0.42, 0.42], evidence: 0 },
|
||||
probes: Array.from({ length: 4 }, () => ({ color: [0.42, 0.42, 0.42], frontDepth: 0.92, valid: true })),
|
||||
}],
|
||||
]) {
|
||||
const before = baselinePointSample(fixture);
|
||||
const after = atlasRepresentative(fixture);
|
||||
assert.deepEqual(after, before, `${name} fixture must remain identical to center sampling`);
|
||||
}
|
||||
|
||||
console.log('Region atlas dark sampling source/math-only regression passed: phase-offset narrow feature, far/invalid/uniform invariants, and four-probe bounded scoring contract.');
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
const paletteShader = await readFile(new URL('../src/shaders/region-palette.frag', import.meta.url), 'utf8');
|
||||
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
|
||||
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
||||
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
||||
assert.match(main, /uGridSize: \{ value: 13 \}/);
|
||||
assert.match(main, /uStrength: \{ value: 1 \}/);
|
||||
assert.match(main, /uRegionStrength: \{ value: 1 \}/);
|
||||
assert.match(main, /uCurvature: \{ value: 1 \}/);
|
||||
assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \}/);
|
||||
assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/);
|
||||
assert.match(main, /paletteWidth = gridX \* 5/);
|
||||
assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="13"/);
|
||||
assert.match(html, /id="curvature" type="range" min="-1" max="1" step="0\.01" value="1"/);
|
||||
assert.match(html, /id="gloss-strength" type="range" min="0" max="0\.4" step="0\.01" value="0\.30"/);
|
||||
assert.match(html, /id="region-fixed-note"/);
|
||||
assert.doesNotMatch(html, /id="region-strength"|id="strength"|id="strengthOut"/);
|
||||
assert.doesNotMatch(main, /strengthInput|strengthOutput|regionStrengthInput|regionStrengthOutput|region-strength|strengthOut/);
|
||||
for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) {
|
||||
assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`);
|
||||
}
|
||||
assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : 24; uniforms\.uStrength\.value = 1/);
|
||||
assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/);
|
||||
assert.match(html, /<details>\s*<summary>사진 구도<\/summary>/);
|
||||
assert.match(html, /<details>\s*<summary>개발 비교<\/summary>/);
|
||||
assert.match(html, /<summary>개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="mosaic-style"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
|
||||
assert.match(shader, /cell\.x\*5\.\+index/);
|
||||
assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/);
|
||||
assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/);
|
||||
assert.match(shader, /splitColor=splitHistory\.a>\.00001\?splitHistory\.rgb\/splitHistory\.a:radialColor/);
|
||||
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
|
||||
assert.match(historyShader, /vec4 current=vec4\(paletteColor\(cell,label\)\*confidence,confidence\)/);
|
||||
assert.match(historyShader, /cell\.x\*5\.\+4\./);
|
||||
assert.doesNotMatch(shader, /protectDark|ownPalette|darkestPalette|protectedColor|assignedColor/);
|
||||
assert.match(shader, /atlasEvidence=regionProtectionWeight\(finalSceneUv\)\*\(1\.-smoothstep\(\.10,\.20,regionLuminance\(atlasCenterColor\)\)\)/);
|
||||
assert.match(shader, /color\.a=atlasEvidence/);
|
||||
assert.equal((shader.match(/regionAtlasProbe\(/g) ?? []).length, 5, 'atlas sampling must use one probe helper plus exactly four fixed probe calls');
|
||||
assert.match(shader, /probeStep=uPaneSize\/\(uRegionGridSize\*8\.\)\*\.25/);
|
||||
assert.match(shader, /probeSceneUv=projectUv\(sceneHit\+vec3\(worldOffset,0\.\)\)/);
|
||||
assert.match(shader, /sampleScene\(probeSceneUv\)/);
|
||||
assert.match(shader, /regionAtlasEvidence\(probeSceneUv,probe\.rgb,glassHit\)/);
|
||||
assert.match(shader, /uRegionStrength\*clamp\(uStrength,0\.,1\.\)\*regionSurfaceValid/);
|
||||
assert.match(shader, /worldDelta\+=boundedIncidence\*cellEnvelope\*cellDimensions\*signedCurvatureScale\(\)\*\.18/);
|
||||
assert.match(paletteShader, /for\(int candidateIndex=0;candidateIndex<5;candidateIndex\+\+\)/);
|
||||
assert.match(paletteShader, /if\(candidateIndex==1\)cut=3\.;else if\(candidateIndex==2\)cut=5\.;else if\(candidateIndex==3\)cut=2\.;else if\(candidateIndex==4\)cut=6\./);
|
||||
assert.match(paletteShader, /candidateCost=sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)\+sideColorCost\(weightedVotesB,colorB,c0,c1,c2,c3\)/);
|
||||
assert.match(paletteShader, /weightedVotesA\+=oneHot\*sampleWeight/);
|
||||
assert.match(paletteShader, /sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)/);
|
||||
assert.match(paletteShader, /if\(pairCost<bestCost\)/);
|
||||
assert.match(paletteShader, /smoothstep\(\.55,\.75,accuracy\)\*smoothstep\(\.015625,\.078125,gain\)/);
|
||||
assert.match(paletteShader, /a!=b&&votesA\[a\]>0\.&&votesB\[b\]>0\./);
|
||||
assert.match(paletteShader, /float confidence=\.85\+\.15\*oldConfidence/);
|
||||
assert.match(paletteShader, /confidence=mix\(confidence,1\.,smoothstep\(\.02,\.06,cellEvidence\/64\.\)\)/);
|
||||
assert.ok(paletteShader.includes('metadata=vec4((bestAxis*8.+bestCut)/255.,bestA/255.,bestB/255.,bestConfidence);'));
|
||||
|
||||
const cuts = [4, 3, 5, 2, 6];
|
||||
const max4 = counts => Math.max(...counts);
|
||||
const palette = [0, 0.30, 0.70, 1].map(value => [value, value, value]);
|
||||
const distanceColor = (a, b) => {
|
||||
const d = a.map((value, i) => value - b[i]);
|
||||
const y = d[0] * 0.299 + d[1] * 0.587 + d[2] * 0.114;
|
||||
return d.reduce((sum, value) => sum + value * value, 0) + 0.35 * y * y;
|
||||
};
|
||||
function sideColorCost(votes, target) {
|
||||
return votes.reduce((sum, count, label) => sum + count * distanceColor(palette[label], palette[target]), 0);
|
||||
}
|
||||
const smoothstep = (a, b, x) => {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
function fitSplit(labels) {
|
||||
const whole = [0, 0, 0, 0];
|
||||
for (const label of labels) whole[label]++;
|
||||
const singleMajority = max4(whole) / 64;
|
||||
let best = null, bestCost = Infinity;
|
||||
for (let axis = 0; axis < 2; axis++) for (const cut of cuts) {
|
||||
const sideA = [0, 0, 0, 0], sideB = [0, 0, 0, 0];
|
||||
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
|
||||
const label = labels[y * 8 + x];
|
||||
const isA = axis === 0 ? x + 0.5 < cut : y + 0.5 < cut;
|
||||
(isA ? sideA : sideB)[label]++;
|
||||
}
|
||||
let pair = null, pairCost = Infinity;
|
||||
for (let labelA = 0; labelA < 4; labelA++) for (let labelB = 0; labelB < 4; labelB++) {
|
||||
if (labelA === labelB || sideA[labelA] === 0 || sideB[labelB] === 0) continue;
|
||||
const cost = sideColorCost(sideA, labelA) + sideColorCost(sideB, labelB);
|
||||
if (cost < pairCost) { pairCost = cost; pair = { labelA, labelB }; }
|
||||
}
|
||||
if (pair && pairCost < bestCost) {
|
||||
bestCost = pairCost;
|
||||
const accuracy = (sideA[pair.labelA] + sideB[pair.labelB]) / 64;
|
||||
const gain = accuracy - singleMajority;
|
||||
const oldConfidence = smoothstep(0.55, 0.75, accuracy) * smoothstep(0.015625, 0.078125, gain);
|
||||
best = { axis, cut, ...pair, accuracy, gain, cost: pairCost, confidence: 0.85 + 0.15 * oldConfidence };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function encodeDecode({ axis, cut, labelA, labelB, confidence }) {
|
||||
const rgba = [(axis * 8 + cut) / 255, labelA / 255, labelB / 255, confidence]
|
||||
.map((value, index) => index === 3 ? value : Math.round(value * 255) / 255);
|
||||
const packed = Math.floor(rgba[0] * 255 + 0.5);
|
||||
return { axis: Math.floor(packed / 8), cut: packed % 8,
|
||||
labelA: Math.floor(rgba[1] * 255 + 0.5), labelB: Math.floor(rgba[2] * 255 + 0.5), confidence: rgba[3] };
|
||||
}
|
||||
function splitFixture(axis, cut, labelA = 0, labelB = 1) {
|
||||
return Array.from({ length: 64 }, (_, index) => {
|
||||
const x = index % 8, y = Math.floor(index / 8);
|
||||
return (axis === 0 ? x + 0.5 < cut : y + 0.5 < cut) ? labelA : labelB;
|
||||
});
|
||||
}
|
||||
function darkSafeColor(sourceLuminance, ownIndex, paletteLuminance, candidateIndex) {
|
||||
let assignedIndex = candidateIndex;
|
||||
if (sourceLuminance < 0.20 && paletteLuminance[candidateIndex] - sourceLuminance > 0.16) {
|
||||
assignedIndex = ownIndex;
|
||||
if (paletteLuminance[ownIndex] - sourceLuminance > 0.16) {
|
||||
let darkestIndex = 0;
|
||||
for (let index = 1; index < paletteLuminance.length; index++) {
|
||||
if (paletteLuminance[index] < paletteLuminance[darkestIndex]) darkestIndex = index;
|
||||
}
|
||||
assignedIndex = paletteLuminance[darkestIndex] <= sourceLuminance + 0.16 ? darkestIndex : null;
|
||||
}
|
||||
}
|
||||
return assignedIndex;
|
||||
}
|
||||
for (const [axis, cut] of [[0, 2], [0, 6], [1, 2], [1, 6]]) {
|
||||
const model = fitSplit(splitFixture(axis, cut, 2, 3));
|
||||
assert.equal(model.axis, axis, `axis mismatch for split ${axis}/${cut}`);
|
||||
assert.equal(model.cut, cut, `cut mismatch for split ${axis}/${cut}`);
|
||||
assert.equal(model.accuracy, 1, `exact split should explain every sample for ${axis}/${cut}`);
|
||||
const decoded = encodeDecode(model);
|
||||
assert.deepEqual([decoded.axis, decoded.cut, decoded.labelA, decoded.labelB], [axis, cut, 2, 3], 'RGBA metadata must round-trip');
|
||||
}
|
||||
|
||||
const checkerboard = Array.from({ length: 64 }, (_, index) => ((index % 8) + Math.floor(index / 8)) % 2);
|
||||
const checkerboardModel = fitSplit(checkerboard);
|
||||
assert.ok(checkerboardModel, 'a cell with two observed labels on both sides can use the two-color model');
|
||||
assert.notEqual(checkerboardModel.labelA, checkerboardModel.labelB);
|
||||
assert.equal(checkerboardModel.accuracy, 0.5, 'checkerboard split should reflect its measured pair accuracy');
|
||||
assert.equal(checkerboardModel.confidence, 0.85, 'valid two-color model starts at the documented confidence floor');
|
||||
assert.equal(fitSplit(Array(64).fill(2)), null, 'a single-label cell must not invent a split');
|
||||
|
||||
const darkSource = 0.10;
|
||||
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.18, 0.30, 0.44], 0), 1,
|
||||
'a sufficiently dark own-palette color should preserve the observed dark shape');
|
||||
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.12, 0.44], 0), 2,
|
||||
'otherwise use the darkest palette color within the source luminance tolerance');
|
||||
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.31, 0.44], 0), null,
|
||||
'retain original pixel RGB only if no palette color can safely represent it');
|
||||
assert.equal(darkSafeColor(0.25, 1, [0.70, 0.40, 0.31, 0.44], 0), 0,
|
||||
'ordinary and non-dark source pixels follow the selected color without protection');
|
||||
|
||||
const skewedVotes = [6, 5, 0, 4];
|
||||
assert.equal(skewedVotes.indexOf(max4(skewedVotes)), 0, 'fixture has a count majority at label 0');
|
||||
assert.equal([0, 1, 2, 3].reduce((best, label) => sideColorCost(skewedVotes, label) < sideColorCost(skewedVotes, best) ? label : best, 0), 1,
|
||||
'SSE should choose the color representative closer to the full observed distribution');
|
||||
const nearDarkVotes = [8, 3, 0, 4];
|
||||
const nearDarkWeighted = nearDarkVotes.map((count, label) => count * (label === 0 ? 4 : 1));
|
||||
const sseWinner = votes => [0, 1, 2, 3].reduce((best, label) => sideColorCost(votes, label) < sideColorCost(votes, best) ? label : best, 0);
|
||||
assert.equal(sseWinner(nearDarkVotes), 1, 'without near-dark evidence, the ordinary SSE representative wins');
|
||||
assert.equal(sseWinner(nearDarkWeighted), 0, 'near-dark evidence changes only the weighted SSE representative');
|
||||
const evidenceConfidence = (confidence, evidence) => confidence + (1 - confidence) * smoothstep(0.02, 0.06, evidence);
|
||||
assert.equal(evidenceConfidence(0.85, 0), 0.85, 'far or invalid evidence leaves ordinary confidence unchanged');
|
||||
assert.equal(evidenceConfidence(0.85, 0.06), 1, 'enough near-dark evidence raises confidence to full strength');
|
||||
assert.ok(evidenceConfidence(0.85, 0.04) > 0.85 && evidenceConfidence(0.85, 0.04) < 1,
|
||||
'partial near-dark evidence increases confidence smoothly');
|
||||
|
||||
const radial = [0.2, 0.4, 0.6], split = [0.8, 0.4, 0.2], conf = 0.5;
|
||||
assert.deepEqual(radial.map((value, i) => value * (1 - conf) + split[i] * conf), [0.5, 0.4, 0.4], 'confidence blends split with radial fallback');
|
||||
|
||||
console.log('Region axis-split checks passed: SSE axis/cut/pair scoring, vertical/horizontal cuts 2/6, observed side-label pairs, checkerboard model, single-label fallback, metadata round-trip, confidence blending, dark eye/nose protection, and edge-independent color strength.');
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
const baseline = await readFile(new URL('../docs/qa-surface-reflection/baseline-mosaic.frag', import.meta.url), 'utf8');
|
||||
|
||||
function statement(source, type, name) {
|
||||
const match = source.match(new RegExp(`\\b${type} ${name}=([^;]+);`));
|
||||
assert.ok(match, `complete ${type} ${name} statement exists`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function effectiveRegionAtlasSource(source) {
|
||||
let active = true;
|
||||
const stack = [];
|
||||
const effective = [];
|
||||
for (const line of source.split('\n')) {
|
||||
const directive = line.trim();
|
||||
if (directive === '#ifdef REGION_ATLAS') {
|
||||
stack.push({ parentActive: active, ifdef: true });
|
||||
continue;
|
||||
}
|
||||
if (directive === '#ifndef REGION_ATLAS') {
|
||||
stack.push({ parentActive: active, ifdef: false });
|
||||
active = false;
|
||||
continue;
|
||||
}
|
||||
if (directive === '#else') {
|
||||
const frame = stack.at(-1);
|
||||
assert.ok(frame, 'REGION_ATLAS preprocessor else has a matching branch');
|
||||
active = frame.parentActive && !frame.ifdef;
|
||||
continue;
|
||||
}
|
||||
if (directive === '#endif') {
|
||||
const frame = stack.pop();
|
||||
assert.ok(frame, 'REGION_ATLAS preprocessor endif has a matching branch');
|
||||
active = frame.parentActive;
|
||||
continue;
|
||||
}
|
||||
if (active) effective.push(line);
|
||||
}
|
||||
assert.equal(stack.length, 0, 'REGION_ATLAS preprocessor branches are balanced');
|
||||
return effective.join('\n');
|
||||
}
|
||||
|
||||
function finalOutputBranches(source) {
|
||||
const reflectionStart = source.indexOf('#ifndef REGION_ATLAS\n color.a=1.;');
|
||||
const outputEnd = source.indexOf('\n#endif\n}', reflectionStart);
|
||||
assert.ok(reflectionStart >= 0 && outputEnd > reflectionStart,
|
||||
'surface reflections must be in the final non-atlas display pass');
|
||||
const output = source.slice(reflectionStart, outputEnd);
|
||||
const atlasMarker = '\n#else\n';
|
||||
const atlasIndex = output.indexOf(atlasMarker);
|
||||
assert.ok(atlasIndex >= 0, 'the final output must expose an effective atlas branch');
|
||||
return {
|
||||
reflectionStart,
|
||||
output,
|
||||
nonAtlas: output.slice(0, atlasIndex),
|
||||
atlas: output.slice(atlasIndex + atlasMarker.length),
|
||||
};
|
||||
}
|
||||
|
||||
function finalWeightExpression(nonAtlas) {
|
||||
const match = nonAtlas.match(/color\.rgb=mix\(color\.rgb,vec3\(1\.\),([^;]+)\);/);
|
||||
assert.ok(match, 'final surface composition must mix the color with white');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function expressions(source) {
|
||||
const { reflectionStart, output, nonAtlas, atlas } = finalOutputBranches(source);
|
||||
// Atlas now shares the depth-aware sampling correction. It must still contain
|
||||
// no surface reflection, rather than remain byte-identical to the old optics.
|
||||
assert.doesNotMatch(effectiveRegionAtlasSource(source), /\breflect\(|color\.rgb=mix\(color\.rgb,vec3\(1\.\)/,
|
||||
'complete effective REGION_ATLAS source must exclude surface reflection');
|
||||
const finalWeight = finalWeightExpression(nonAtlas);
|
||||
assert.equal(finalWeight, 'specGloss+broadReflection',
|
||||
'final composition must use the complete existing and broad amplitudes');
|
||||
assert.equal(statement(source, 'float', 'surfaceReflectionWeight'), 'mix(.35,1.,edgeWeight)',
|
||||
'surface reflection must use a separate bounded edge weight');
|
||||
assert.equal(statement(source, 'float', 'glossStrength'), 'clamp(uGlossStrength,0.,1.)',
|
||||
'both surface reflection terms must share the actual clamped gloss gate');
|
||||
assert.equal(statement(source, 'float', 'specGloss'),
|
||||
'spec*glossStrength*.12*surfaceReflectionWeight*clamp(uStrength,0.,1.)',
|
||||
'the existing rectangular reflection must use its complete amplitude expression');
|
||||
assert.equal(statement(source, 'vec2', 'broadWindowMask'),
|
||||
'1.-smoothstep(vec2(.15),vec2(1.75),windowCoord)',
|
||||
'the broad reflection must use its complete soft spatial mask');
|
||||
assert.equal(statement(source, 'float', 'broadFacing'), 'pow(max(dot(nCurved,v),0.),2.)',
|
||||
'the broad reflection factor must use the curved normal and view direction');
|
||||
assert.equal(statement(source, 'float', 'broadReflection'),
|
||||
'broadWindowMask.x*broadWindowMask.y*broadFacing*glossStrength*.035*surfaceReflectionWeight*clamp(uStrength,0.,1.)',
|
||||
'the broad reflection must use its complete spatial/view-dependent amplitude expression');
|
||||
for (const name of ['edgeWeight', 'strength', 'sceneHit']) {
|
||||
assert.equal(statement(source, name === 'sceneHit' ? 'vec3' : 'float', name),
|
||||
statement(baseline, name === 'sceneHit' ? 'vec3' : 'float', name),
|
||||
`${name} refraction expression remains unchanged from the preserved baseline`);
|
||||
}
|
||||
assert.ok(!nonAtlas.slice('#ifndef REGION_ATLAS\n'.length).includes('REGION_ATLAS'),
|
||||
'reflection composition must stay outside the atlas branch');
|
||||
assert.match(atlas, /color\.a=atlasEvidence;\s+gl_FragColor=color;/,
|
||||
'the effective atlas branch must preserve atlas evidence output');
|
||||
assert.doesNotMatch(atlas, /specGloss|broadReflection|surfaceReflectionWeight|color\.rgb=mix/,
|
||||
'the effective atlas branch must not receive final surface reflection');
|
||||
const simplification = source.indexOf('color.rgb=mix(color.rgb,candidateColor,');
|
||||
assert.ok(simplification >= 0 && simplification < reflectionStart,
|
||||
'color-plane simplification must precede final reflection composition');
|
||||
const finalMix = source.indexOf('color.rgb=mix', reflectionStart);
|
||||
const colorSpace = source.indexOf('#include <colorspace_fragment>', finalMix);
|
||||
assert.ok(finalMix >= 0 && colorSpace > finalMix,
|
||||
'reflection composition must happen before color-space conversion');
|
||||
return {
|
||||
surfaceWeight: statement(source, 'float', 'surfaceReflectionWeight'),
|
||||
gloss: statement(source, 'float', 'glossStrength'),
|
||||
spec: statement(source, 'float', 'specGloss'),
|
||||
broadMask: statement(source, 'vec2', 'broadWindowMask'),
|
||||
broadFacing: statement(source, 'float', 'broadFacing'),
|
||||
broad: statement(source, 'float', 'broadReflection'),
|
||||
finalWeight,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function mix(left, right, factor) {
|
||||
return left * (1 - factor) + right * factor;
|
||||
}
|
||||
|
||||
function evaluate(expression, variables) {
|
||||
const names = Object.keys(variables);
|
||||
return Function(...names, 'clamp', 'max', 'pow', 'mix', `return ${expression};`)(
|
||||
...names.map(name => variables[name]), clamp, Math.max, Math.pow, mix);
|
||||
}
|
||||
|
||||
function evaluateOutput(source, sample) {
|
||||
const expr = expressions(source);
|
||||
const surfaceReflectionWeight = evaluate(expr.surfaceWeight, { edgeWeight: sample.edgeWeight });
|
||||
const glossStrength = evaluate(expr.gloss, { uGlossStrength: sample.uGlossStrength });
|
||||
const variables = {
|
||||
...sample,
|
||||
surfaceReflectionWeight,
|
||||
glossStrength,
|
||||
};
|
||||
const specGloss = evaluate(expr.spec, variables);
|
||||
const broadReflection = evaluate(expr.broad, variables);
|
||||
const weight = evaluate(expr.finalWeight, { ...variables, specGloss, broadReflection });
|
||||
const output = sample.baseColor * (1 - weight) + weight;
|
||||
return { specGloss, broadReflection, weight, output };
|
||||
}
|
||||
|
||||
function assertBehavior(source) {
|
||||
const center = {
|
||||
spec: .82, broadWindowMask: { x: .95, y: .88 }, broadFacing: .92,
|
||||
edgeWeight: .9, uGlossStrength: .3, uStrength: 1, baseColor: .22,
|
||||
};
|
||||
const viewTilt = { ...center, broadFacing: .25 };
|
||||
const spatialEdge = {
|
||||
...center, spec: .18, broadWindowMask: { x: .40, y: .55 },
|
||||
edgeWeight: .1, baseColor: .31,
|
||||
};
|
||||
const glossZero = evaluateOutput(source, { ...center, uGlossStrength: 0 });
|
||||
const centerOutput = evaluateOutput(source, center);
|
||||
const viewTiltOutput = evaluateOutput(source, viewTilt);
|
||||
const spatialEdgeOutput = evaluateOutput(source, spatialEdge);
|
||||
|
||||
assert.equal(glossZero.specGloss, 0, 'gloss 0 removes the existing reflection amplitude');
|
||||
assert.equal(glossZero.broadReflection, 0, 'gloss 0 removes the broad reflection amplitude');
|
||||
assert.equal(glossZero.weight, 0, 'gloss 0 removes the actual final reflection mix weight');
|
||||
assert.equal(glossZero.output, center.baseColor, 'gloss 0 preserves the pre-reflection color');
|
||||
assert.ok(centerOutput.specGloss > 0 && centerOutput.broadReflection > 0,
|
||||
'representative positive-gloss input produces both actual reflection amplitudes');
|
||||
assert.notEqual(centerOutput.broadReflection, viewTiltOutput.broadReflection,
|
||||
'the broad amplitude varies with representative view/normal input');
|
||||
assert.notEqual(centerOutput.broadReflection, spatialEdgeOutput.broadReflection,
|
||||
'the broad amplitude varies with representative spatial input');
|
||||
assert.notEqual(centerOutput.output, viewTiltOutput.output,
|
||||
'the actual final output varies with representative view input');
|
||||
assert.notEqual(centerOutput.output, spatialEdgeOutput.output,
|
||||
'the actual final output varies with representative spatial input');
|
||||
}
|
||||
|
||||
expressions(shader);
|
||||
assertBehavior(shader);
|
||||
const constantOutputMutation = shader.replace(
|
||||
'color.rgb=mix(color.rgb,vec3(1.),specGloss+broadReflection);',
|
||||
'color.rgb=mix(color.rgb,vec3(1.),.5);');
|
||||
assert.notEqual(constantOutputMutation, shader, 'constant-output mutation must change the shader');
|
||||
assert.throws(() => {
|
||||
expressions(constantOutputMutation);
|
||||
assertBehavior(constantOutputMutation);
|
||||
}, /complete existing and broad amplitudes|actual final output varies/,
|
||||
'constant 50% white output mutation must fail the covering assertions');
|
||||
|
||||
const atlasReflectionMutation = shader.replace(
|
||||
' vec3 atlasCenterColor=sampleScene(finalSceneUv).rgb;',
|
||||
' vec3 atlasCenterColor=sampleScene(finalSceneUv).rgb; float atlasGloss=clamp(uGlossStrength,0.,1.)*.5; color.rgb=mix(color.rgb,vec3(1.),atlasGloss);');
|
||||
assert.notEqual(atlasReflectionMutation, shader, 'atlas-reflection mutation must change the shader');
|
||||
assert.throws(() => expressions(atlasReflectionMutation), /complete effective REGION_ATLAS source/,
|
||||
'an earlier REGION_ATLAS reflection mutation must fail the covering assertions');
|
||||
|
||||
console.log('surface reflection source/math regression passed; constant-output and atlas-reflection mutations rejected');
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as THREE from 'three';
|
||||
import { WindowReveal } from '../src/objects/WindowReveal.ts';
|
||||
|
||||
const glassZ = 0.6;
|
||||
const cameraZ = 3;
|
||||
const depthScales = [0, 0.25, 0.4];
|
||||
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) {
|
||||
const backZ = glassZ - 0.04 - 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} 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, ${offsets.length} view offsets (including neutral) and ${samples.length ** 2} aperture samples; geometry reuse/disposal verified.`);
|
||||
Reference in New Issue
Block a user