/** * 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; 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; 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(); 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 { const result = new Map(); 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; 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; 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(); 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 })) })); }