196 lines
9.6 KiB
TypeScript
196 lines
9.6 KiB
TypeScript
/**
|
|
* Bounded weight-paint operation contract shared by validation and tests.
|
|
* Native Blender remains authoritative for Main writes; these helpers make
|
|
* the ordering and symmetry rules explicit before a command crosses the
|
|
* Worker boundary.
|
|
*/
|
|
|
|
export const WEIGHT_PAINT_SCHEMA_VERSION = 1 as const;
|
|
export const WEIGHT_PAINT_BUDGET = {
|
|
maxVertices: 1_000_000,
|
|
maxInfluencesPerVertex: 32,
|
|
maxMirrorTolerance: 1,
|
|
} as const;
|
|
|
|
export interface WeightPaintOptionsIR {
|
|
schemaVersion: typeof WEIGHT_PAINT_SCHEMA_VERSION;
|
|
normalize: boolean;
|
|
limit?: number;
|
|
mirror: boolean;
|
|
mirrorAxis: 0 | 1 | 2;
|
|
mirrorTolerance: number;
|
|
}
|
|
|
|
export interface WeightPaintVertexIR {
|
|
index: number;
|
|
position: [number, number, number];
|
|
influences: Array<{ group: string; weight: number }>;
|
|
}
|
|
|
|
export interface WeightPaintPatchIR {
|
|
vertexGroup: string;
|
|
indices: number[];
|
|
values: number[];
|
|
options: WeightPaintOptionsIR;
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
throw new Error(`PAINT_SCHEMA_INVALID: ${message}`);
|
|
}
|
|
|
|
function finite(value: unknown, label: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`);
|
|
return value;
|
|
}
|
|
|
|
function integer(value: unknown, label: string): number {
|
|
const result = finite(value, label);
|
|
if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be a non-negative safe integer`);
|
|
return result;
|
|
}
|
|
|
|
function string(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > 63) fail(`${label} is outside the bounded range`);
|
|
return value;
|
|
}
|
|
|
|
function boolean(value: unknown, label: string): boolean {
|
|
if (typeof value !== "boolean") fail(`${label} must be boolean`);
|
|
return value;
|
|
}
|
|
|
|
export function parseWeightPaintOptions(value: unknown = {}): WeightPaintOptionsIR {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("options must be an object");
|
|
const source = value as Record<string, unknown>;
|
|
const allowed = new Set(["schemaVersion", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]);
|
|
if (Object.keys(source).some((key) => !allowed.has(key))) fail("options contains undeclared fields");
|
|
if (source.schemaVersion !== undefined && source.schemaVersion !== WEIGHT_PAINT_SCHEMA_VERSION) fail("options.schemaVersion is unsupported");
|
|
const normalize = source.normalize === undefined ? false : boolean(source.normalize, "options.normalize");
|
|
const mirror = source.mirror === undefined ? false : boolean(source.mirror, "options.mirror");
|
|
const mirrorAxisValue = source.mirrorAxis === undefined ? 0 : integer(source.mirrorAxis, "options.mirrorAxis");
|
|
if (mirrorAxisValue > 2) fail("options.mirrorAxis must be 0, 1 or 2");
|
|
const mirrorTolerance = source.mirrorTolerance === undefined ? 1e-4 : finite(source.mirrorTolerance, "options.mirrorTolerance");
|
|
if (mirrorTolerance <= 0 || mirrorTolerance > WEIGHT_PAINT_BUDGET.maxMirrorTolerance) fail("options.mirrorTolerance is outside the bounded range");
|
|
let limit: number | undefined;
|
|
if (source.limit !== undefined) {
|
|
limit = integer(source.limit, "options.limit");
|
|
if (limit < 1 || limit > WEIGHT_PAINT_BUDGET.maxInfluencesPerVertex) fail("options.limit is outside the bounded range");
|
|
}
|
|
if (!mirror && (source.mirrorAxis !== undefined || source.mirrorTolerance !== undefined)) fail("mirrorAxis/mirrorTolerance require mirror=true");
|
|
return {
|
|
schemaVersion: WEIGHT_PAINT_SCHEMA_VERSION,
|
|
normalize,
|
|
...(limit === undefined ? {} : { limit }),
|
|
mirror,
|
|
mirrorAxis: mirrorAxisValue as 0 | 1 | 2,
|
|
mirrorTolerance,
|
|
};
|
|
}
|
|
|
|
export function parseWeightPaintPatch(value: unknown): WeightPaintPatchIR {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("patch must be an object");
|
|
const source = value as Record<string, unknown>;
|
|
const vertexGroup = string(source.vertexGroup, "patch.vertexGroup");
|
|
if (!Array.isArray(source.indices) || !Array.isArray(source.values) || source.indices.length === 0 || source.indices.length !== source.values.length) fail("patch indices and values must have equal non-empty lengths");
|
|
if (source.indices.length > WEIGHT_PAINT_BUDGET.maxVertices) throw new Error("PAINT_BUDGET_EXCEEDED: patch exceeds the vertex budget");
|
|
const seen = new Set<number>();
|
|
const indices = source.indices.map((value, offset) => {
|
|
const index = integer(value, `patch.indices[${offset}]`);
|
|
if (seen.has(index)) fail(`patch.indices[${offset}] contains a duplicate vertex`);
|
|
seen.add(index);
|
|
return index;
|
|
});
|
|
const values = source.values.map((value, offset) => {
|
|
const weight = finite(value, `patch.values[${offset}]`);
|
|
if (weight < 0 || weight > 1) fail(`patch.values[${offset}] must be in [0,1]`);
|
|
return weight;
|
|
});
|
|
return {
|
|
vertexGroup,
|
|
indices,
|
|
values,
|
|
options: parseWeightPaintOptions({
|
|
schemaVersion: source.schemaVersion,
|
|
normalize: source.normalize,
|
|
limit: source.limit,
|
|
mirror: source.mirror,
|
|
mirrorAxis: source.mirrorAxis,
|
|
mirrorTolerance: source.mirrorTolerance,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function mirrorMap(vertices: readonly WeightPaintVertexIR[], axis: 0 | 1 | 2, tolerance: number): Map<number, number> {
|
|
const result = new Map<number, number>();
|
|
for (const vertex of vertices) {
|
|
let best: WeightPaintVertexIR | undefined;
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
for (const candidate of vertices) {
|
|
const reflected = [...vertex.position] as [number, number, number];
|
|
reflected[axis] = -reflected[axis];
|
|
const distance = Math.hypot(...reflected.map((value, component) => value - candidate.position[component]));
|
|
if (distance < bestDistance || (distance === bestDistance && (best === undefined || candidate.index < best.index))) {
|
|
best = candidate;
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
if (!best || bestDistance > tolerance) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED");
|
|
result.set(vertex.index, best.index);
|
|
}
|
|
for (const [source, target] of result) if (result.get(target) !== source) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED");
|
|
return result;
|
|
}
|
|
|
|
function normalize(influences: Array<{ group: string; weight: number }>): void {
|
|
const total = influences.reduce((sum, influence) => sum + influence.weight, 0);
|
|
if (total > 0) for (const influence of influences) influence.weight /= total;
|
|
}
|
|
|
|
function limit(influences: Array<{ group: string; weight: number }>, count: number): void {
|
|
influences.sort((left, right) => right.weight - left.weight || left.group.localeCompare(right.group));
|
|
influences.splice(count);
|
|
}
|
|
|
|
/** Apply the deterministic bounded operation used by the desktop comparison. */
|
|
export function applyWeightPaintPatch(verticesValue: unknown, patchValue: unknown): WeightPaintVertexIR[] {
|
|
if (!Array.isArray(verticesValue) || verticesValue.length === 0 || verticesValue.length > WEIGHT_PAINT_BUDGET.maxVertices) fail("vertices exceeds the weight paint budget");
|
|
const vertices = verticesValue.map((value, offset) => {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`vertices[${offset}] must be an object`);
|
|
const source = value as Record<string, unknown>;
|
|
const index = integer(source.index, `vertices[${offset}].index`);
|
|
const positionValue = source.position;
|
|
if (!Array.isArray(positionValue) || positionValue.length !== 3) fail(`vertices[${offset}].position must contain three numbers`);
|
|
const position = positionValue.map((component, axis) => finite(component, `vertices[${offset}].position[${axis}]`)) as [number, number, number];
|
|
if (!Array.isArray(source.influences)) fail(`vertices[${offset}].influences must be an array`);
|
|
const influences = source.influences.map((item, influenceIndex) => {
|
|
if (typeof item !== "object" || item === null || Array.isArray(item)) fail(`vertices[${offset}].influences[${influenceIndex}] must be an object`);
|
|
const entry = item as Record<string, unknown>;
|
|
const weight = finite(entry.weight, `vertices[${offset}].influences[${influenceIndex}].weight`);
|
|
if (weight < 0 || weight > 1) fail("influence weight must be in [0,1]");
|
|
return { group: string(entry.group, `vertices[${offset}].influences[${influenceIndex}].group`), weight };
|
|
});
|
|
return { index, position, influences };
|
|
});
|
|
const patch = parseWeightPaintPatch(patchValue);
|
|
const byIndex = new Map(vertices.map((vertex) => [vertex.index, vertex]));
|
|
const targets = new Map<number, number>();
|
|
patch.indices.forEach((index, offset) => {
|
|
if (!byIndex.has(index)) fail(`patch.indices[${offset}] references an unknown vertex`);
|
|
targets.set(index, patch.values[offset]);
|
|
});
|
|
if (patch.options.mirror) {
|
|
const mirrored = mirrorMap(vertices, patch.options.mirrorAxis, patch.options.mirrorTolerance);
|
|
for (const [index, value] of [...targets]) targets.set(mirrored.get(index)!, value);
|
|
}
|
|
for (const [index, value] of targets) {
|
|
const vertex = byIndex.get(index)!;
|
|
const influence = vertex.influences.find((item) => item.group === patch.vertexGroup);
|
|
if (value === 0) vertex.influences.splice(vertex.influences.indexOf(influence!), influence ? 1 : 0);
|
|
else if (influence) influence.weight = value;
|
|
else vertex.influences.push({ group: patch.vertexGroup, weight: value });
|
|
if (patch.options.limit !== undefined) limit(vertex.influences, patch.options.limit);
|
|
if (patch.options.normalize) normalize(vertex.influences);
|
|
}
|
|
return vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], influences: vertex.influences.map((influence) => ({ ...influence })) }));
|
|
}
|