Files
yourpart3/frontend/scripts/test-character-appearance.mjs
Torsten Schulz (local) dd409a6a6a
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s
Ersetze grobe 3D-Markierungen durch präzise UV-Farbmasken
2026-11-04 16:16:01 +01:00

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);
});