Ersetze grobe 3D-Markierungen durch präzise UV-Farbmasken
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 16:16:01 +01:00
parent b805632826
commit dd409a6a6a
5 changed files with 477 additions and 249 deletions

View File

@@ -1,76 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { BufferAttribute, BufferGeometry, Group, Mesh, MeshStandardMaterial, PlaneGeometry, Vector2, Vector3 } from 'three';
import {
applyCharacterAppearance, captureAppearanceEditor, getAppearanceRegion,
applyCharacterAppearance, captureAppearanceEditor, clearAppearanceMarks, finishAppearanceStroke,
getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor,
} from '../src/utils/characterAppearance.js';
function fixture(material = new MeshStandardMaterial()) {
function fixture(material = new MeshStandardMaterial(), geometry = new PlaneGeometry(2, 2)) {
const model = new Group();
const mesh = new Mesh(new BoxGeometry(), material);
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('painting separates material regions while preserving the original mesh and UVs', () => {
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();
prepareAppearanceEditor(model);
assert.notEqual(mesh.geometry, original);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
await prepareAppearanceEditor(model, { maskSize: 128 });
const snapshot = captureAppearanceEditor(model);
const indexBuffer = mesh.geometry.index;
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3(.5, 0, 0) }, 0, 'hair', new Vector3(-1, 0, 0)), true);
assert.deepEqual(getAppearanceStats(model), { body: 11, hair: 1, iris: 0 });
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);
assert.equal(mesh.geometry.index, indexBuffer);
assert.equal(original.groups.length, 6);
finishAppearanceStroke(model);
restoreAppearanceEditor(snapshot);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3() }, 1, 'unknown', new Vector3()), false);
assert.deepEqual(getAppearanceStats(model), { body: 128 * 128, hair: 0, iris: 0 });
});
test('hair and iris colors remain independent of other instances and can return to the original', () => {
test('hair and iris colors are independent across instances and can be disabled', async () => {
const originalMaterial = new MeshStandardMaterial();
const first = fixture(originalMaterial);
const second = fixture(originalMaterial);
prepareAppearanceEditor(first.model);
prepareAppearanceEditor(second.model);
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 shader = () => ({ uniforms: {}, fragmentShader: '#include <map_fragment>\n#include <emissivemap_fragment>' });
const hairShader = shader(), irisShader = shader(), otherShader = shader();
first.mesh.material[1].onBeforeCompile(hairShader);
first.mesh.material[2].onBeforeCompile(irisShader);
second.mesh.material[1].onBeforeCompile(otherShader);
assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'ff0000');
assert.equal(irisShader.uniforms.falukantTargetColor.value.getHexString(), '0000ff');
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '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(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'cccccc');
assert.equal(irisShader.uniforms.falukantColorEnabled.value, 0);
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
applyCharacterAppearance(first.model, { hairColor: 'invalid' });
assert.equal(hairShader.uniforms.falukantColorEnabled.value, 0);
assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'cccccc');
assert.equal(firstShader.uniforms.falukantIrisEnabled.value, 0);
assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00');
});
test('saved glTF material metadata can be prepared again without multiplying materials', () => {
const first = fixture();
prepareAppearanceEditor(first.model);
const model = new Group();
const mesh = new Mesh(first.mesh.geometry.clone(), first.mesh.material.map(material => {
const copy = material.clone();
copy.userData = JSON.parse(JSON.stringify(material.userData));
return copy;
}));
model.add(mesh);
prepareAppearanceEditor(model);
assert.equal(mesh.material.length, 3);
assert.equal(getAppearanceRegion(mesh.material[1]), 'hair');
assert.equal(getAppearanceRegion(mesh.material[2]), 'iris');
prepareAppearanceEditor(model);
assert.equal(mesh.material.length, 3);
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);
});