import assert from 'node:assert/strict'; import test from 'node:test'; import { BufferAttribute, BufferGeometry, Group, Mesh, MeshStandardMaterial, PlaneGeometry, Vector2, Vector3 } from 'three'; import { applyCharacterAppearance, captureAppearanceEditor, clearAppearanceMarks, finishAppearanceStroke, getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor, } from '../src/utils/characterAppearance.js'; function fixture(material = new MeshStandardMaterial(), geometry = new PlaneGeometry(2, 2)) { const model = new Group(); const mesh = new Mesh(geometry, material); model.add(mesh); model.updateMatrixWorld(true); return { model, mesh }; } function shaderFor(material) { const shader = { uniforms: {}, vertexShader: '#include ', fragmentShader: '#include \n#include ' }; material.onBeforeCompile(shader); return shader; } function sampleMask(material, u, v) { const { data, width, height } = shaderFor(material).uniforms.falukantRegionMask.value.image; const index = (Math.floor(v * height) * width + Math.floor(u * width)) * 4; return Array.from(data.subarray(index, index + 2)); } function paint(mesh, point, radius = .16, region = 'iris') { return paintAppearanceRegion({ object: mesh, faceIndex: 0, point, uv: new Vector2(point.x / 2 + .5, point.y / 2 + .5) }, radius, region, new Vector3(0, 0, -1)); } test('a small iris brush paints pixels within a large triangle without changing any geometry', async () => { const { model, mesh } = fixture(); const original = mesh.geometry; const originalIndex = original.index.array.slice(); const originalUV = original.getAttribute('uv').array.slice(); await prepareAppearanceEditor(model, { maskSize: 128 }); const snapshot = captureAppearanceEditor(model); assert.equal(paint(mesh, new Vector3(.35, .1, 0)), true); const stats = getAppearanceStats(model); assert.ok(stats.iris > 10 && stats.iris < 500, 'The brush must not color the whole triangle'); assert.ok(sampleMask(mesh.material, .675, .55)[1] > 200); assert.deepEqual(sampleMask(mesh.material, .85, .75), [0, 0], 'Skin outside the brush stays unmarked'); assert.equal(mesh.geometry, original); assert.deepEqual(mesh.geometry.index.array, originalIndex); assert.deepEqual(mesh.geometry.getAttribute('uv').array, originalUV); finishAppearanceStroke(model); restoreAppearanceEditor(snapshot); assert.deepEqual(getAppearanceStats(model), { body: 128 * 128, hair: 0, iris: 0 }); }); test('hair and iris colors are independent across instances and can be disabled', async () => { const originalMaterial = new MeshStandardMaterial(); const first = fixture(originalMaterial), second = fixture(originalMaterial); await prepareAppearanceEditor(first.model, { maskSize: 32 }); await prepareAppearanceEditor(second.model, { maskSize: 32 }); applyCharacterAppearance(first.model, { hairColor: '#ff0000', eyeColor: '#0000ff' }); applyCharacterAppearance(second.model, { hairColor: '#00ff00' }); const firstShader = shaderFor(first.mesh.material), secondShader = shaderFor(second.mesh.material); assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'ff0000'); assert.equal(firstShader.uniforms.falukantIrisColor.value.getHexString(), '0000ff'); assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00'); assert.notEqual(firstShader.uniforms.falukantRegionMask.value, secondShader.uniforms.falukantRegionMask.value); paint(first.mesh, new Vector3(), .2, 'hair'); assert.equal(getAppearanceStats(second.model).hair, 0); applyCharacterAppearance(first.model, { hairColor: '#cccccc', eyeColor: null }); assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'cccccc'); assert.equal(firstShader.uniforms.falukantIrisEnabled.value, 0); assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00'); }); test('an iris brush spans separate UV islands along a shared surface edge', async () => { const geometry = new BufferGeometry(); geometry.setAttribute('position', new BufferAttribute(new Float32Array([ -1, -1, 0, 1, -1, 0, -1, 1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0, ]), 3)); geometry.setAttribute('uv', new BufferAttribute(new Float32Array([ 0, 0, .4, 0, 0, 1, .6, 0, 1, 1, .6, 1, ]), 2)); const { model, mesh } = fixture(undefined, geometry); await prepareAppearanceEditor(model, { maskSize: 128 }); assert.equal(paint(mesh, new Vector3(), .3), true); const { data, width } = shaderFor(mesh.material).uniforms.falukantRegionMask.value.image; const columns = []; for (let i = 0; i < data.length; i += 4) if (data[i + 1]) columns.push((i / 4) % width); assert.ok(columns.some(x => x < width * .4)); assert.ok(columns.some(x => x > width * .6)); }); test('erasing is pixel-precise, undo restores it and legacy material labels remain editable', async () => { const material = new MeshStandardMaterial(); material.userData.falukantAppearance = { version: 1, region: 'iris', source: 'material-0' }; const { model, mesh } = fixture(material); await prepareAppearanceEditor(model, { maskSize: 64 }); assert.equal(getAppearanceStats(model).iris, 64 * 64); const before = captureAppearanceEditor(model); assert.equal(paint(mesh, new Vector3(), .2, 'body'), true); finishAppearanceStroke(model); assert.deepEqual(sampleMask(mesh.material, .5, .5), [0, 0]); assert.equal(sampleMask(mesh.material, .9, .9)[1], 255); restoreAppearanceEditor(before); assert.equal(getAppearanceStats(model).iris, 64 * 64); const clearSnapshot = captureAppearanceEditor(model); clearAppearanceMarks(model); finishAppearanceStroke(model); assert.equal(getAppearanceStats(model).iris, 0); restoreAppearanceEditor(clearSnapshot); assert.equal(getAppearanceStats(model).iris, 64 * 64); await prepareAppearanceEditor(model); assert.equal(getAppearanceStats(model).iris, 64 * 64); });