112 lines
5.8 KiB
JavaScript
112 lines
5.8 KiB
JavaScript
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 <uv_vertex>', fragmentShader: '#include <map_fragment>\n#include <emissivemap_fragment>' };
|
|
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);
|
|
});
|