Files
workinf_Blender_Wasm/web/protocol/weight-paint.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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