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: GOBANG_GLASS.grid \}/); assert.match(main, /uStrength: \{ value: 1 \}/); assert.match(main, /uRegionStrength: \{ value: 0 \}/); 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="20"/); 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 : style === 2 \? GOBANG_GLASS.grid : 24; uniforms\.uRegionStrength\.value = style === 0 \? 1 : 0; uniforms\.uStrength\.value = 1/); assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/); assert.match(html, /
\s*사진 구도<\/summary>/); assert.match(html, /
\s*개발 비교<\/summary>/); assert.match(html, /개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\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\(pairCost0\.&&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.');