Files
workinf_Blender_Wasm/web/protocol/paint.ts
2026-08-12 19:31:41 -04:00

265 lines
13 KiB
TypeScript

export const PAINT_BUDGET = {
maxSamples: 100_000,
maxWeightEntries: 1_000_000,
maxTextureTileBytes: 256 * 1024 * 1024,
maxStrokeBytes: 64 * 1024 * 1024,
} as const;
export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
export interface PaintHitIR {
position: [number, number, number];
normal?: [number, number, number];
faceIndex?: number;
barycentric?: [number, number, number];
uv?: [number, number];
pressure?: number;
}
export interface PaintStrokeIR {
schemaVersion: 1;
mode: PaintMode;
objectId: string;
revision: number;
radius: number;
strength: number;
samples: PaintHitIR[];
color?: [number, number, number, number];
vertexGroup?: string;
textureAssetId?: string;
textureTile?: number;
spacing?: number;
}
export interface WeightPatchIR {
schemaVersion: 1;
objectId: string;
revision: number;
vertexGroup: string;
indices: number[];
values: number[];
normalize?: boolean;
mirror?: boolean;
}
export interface PaintBrushVertexIR {
index: number;
position: [number, number, number];
normal?: [number, number, number];
occluded?: boolean;
}
export interface PaintBrushWeightIR { index: number; weight: number }
export interface UdimTilePatchIR {
schemaVersion: 1;
textureAssetId: string;
tile: number;
revision: number;
width: number;
height: number;
format: "RGBA8";
colorSpace: "SRGB" | "LINEAR";
baseSha256: string;
resultSha256: string;
byteOffset: number;
bytes: Uint8Array;
}
function fail(path: string, message: string, budget = false): never {
throw new Error(`${budget ? "PAINT_BUDGET_EXCEEDED" : "PAINT_SCHEMA_INVALID"}: ${path} ${message}`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
return value as Record<string, unknown>;
}
function finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(path, "must be finite");
return value;
}
function integer(value: unknown, path: string): number {
const result = finite(value, path);
if (!Number.isSafeInteger(result) || result < 0) fail(path, "must be a non-negative safe integer");
return result;
}
function tuple(value: unknown, length: number, path: string): number[] {
if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`);
if (value.some((item) => typeof item !== "number" || !Number.isFinite(item))) fail(path, "must contain finite numbers");
return value as number[];
}
function string(value: unknown, path: string, allowEmpty = false): string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.length > 255) fail(path, "is outside the bounded string range");
return value;
}
const SHA256 = /^[a-f0-9]{64}$/;
function digest(value: unknown, path: string): string {
const result = string(value, path);
if (!SHA256.test(result)) fail(path, "must be a lowercase SHA-256 digest");
return result;
}
function parseHit(value: unknown, path: string): PaintHitIR {
const hit = record(value, path);
const position = tuple(hit.position, 3, `${path}.position`) as [number, number, number];
const result: PaintHitIR = { position };
if (hit.normal !== undefined) result.normal = tuple(hit.normal, 3, `${path}.normal`) as [number, number, number];
if (hit.faceIndex !== undefined) result.faceIndex = integer(hit.faceIndex, `${path}.faceIndex`);
if (hit.barycentric !== undefined) {
result.barycentric = tuple(hit.barycentric, 3, `${path}.barycentric`) as [number, number, number];
if (result.barycentric.some((item) => item < 0 || item > 1) || Math.abs(result.barycentric.reduce((sum, item) => sum + item, 0) - 1) > 1e-4) fail(`${path}.barycentric`, "must be non-negative and sum to one");
}
if (hit.uv !== undefined) result.uv = tuple(hit.uv, 2, `${path}.uv`) as [number, number];
if (hit.pressure !== undefined) {
const pressure = finite(hit.pressure, `${path}.pressure`);
if (pressure < 0 || pressure > 1) fail(`${path}.pressure`, "must be in [0,1]");
result.pressure = pressure;
}
return result;
}
export function parsePaintStroke(value: unknown): PaintStrokeIR {
const stroke = record(value, "paintStroke");
if (stroke.schemaVersion !== 1) fail("paintStroke.schemaVersion", "is unsupported");
const mode = stroke.mode;
if (mode !== "VERTEX_COLOR" && mode !== "WEIGHT" && mode !== "TEXTURE") fail("paintStroke.mode", "is invalid");
const samples = Array.isArray(stroke.samples) ? stroke.samples : fail("paintStroke.samples", "must be an array");
if (samples.length === 0 || samples.length > PAINT_BUDGET.maxSamples) fail("paintStroke.samples", "exceeds the sample budget", samples.length > PAINT_BUDGET.maxSamples);
const radius = finite(stroke.radius, "paintStroke.radius");
const strength = finite(stroke.strength, "paintStroke.strength");
if (radius <= 0 || radius > 100_000) fail("paintStroke.radius", "is outside the bounded range");
if (strength < -1 || strength > 1) fail("paintStroke.strength", "must be in [-1,1]");
const revision = integer(stroke.revision, "paintStroke.revision");
const result: PaintStrokeIR = { schemaVersion: 1, mode, objectId: string(stroke.objectId, "paintStroke.objectId"), revision, radius, strength, samples: samples.map((sample, index) => parseHit(sample, `paintStroke.samples[${index}]`)) };
if (stroke.color !== undefined) {
result.color = tuple(stroke.color, 4, "paintStroke.color") as [number, number, number, number];
if (result.color.some((item) => item < 0 || item > 1)) fail("paintStroke.color", "must be in [0,1]");
}
if (stroke.vertexGroup !== undefined) result.vertexGroup = string(stroke.vertexGroup, "paintStroke.vertexGroup");
if (stroke.textureAssetId !== undefined) result.textureAssetId = string(stroke.textureAssetId, "paintStroke.textureAssetId");
if (stroke.textureTile !== undefined) result.textureTile = integer(stroke.textureTile, "paintStroke.textureTile");
if (stroke.spacing !== undefined) {
result.spacing = finite(stroke.spacing, "paintStroke.spacing");
if (result.spacing < 0 || result.spacing > 100_000) fail("paintStroke.spacing", "is outside the bounded range");
}
if (mode === "WEIGHT" && !result.vertexGroup) fail("paintStroke.vertexGroup", "is required for weight paint");
if (mode === "TEXTURE" && !result.textureAssetId) fail("paintStroke.textureAssetId", "is required for texture paint");
const estimatedBytes = samples.length * 64;
if (estimatedBytes > PAINT_BUDGET.maxStrokeBytes) fail("paintStroke", "exceeds the byte budget", true);
return result;
}
export function parseWeightPatch(value: unknown): WeightPatchIR {
const patch = record(value, "weightPatch");
if (patch.schemaVersion !== 1) fail("weightPatch.schemaVersion", "is unsupported");
if (!Array.isArray(patch.indices) || !Array.isArray(patch.values) || patch.indices.length !== patch.values.length) fail("weightPatch", "indices and values must have equal lengths");
if (patch.indices.length > PAINT_BUDGET.maxWeightEntries) fail("weightPatch", "exceeds the weight entry budget", true);
const indices = patch.indices.map((item, index) => integer(item, `weightPatch.indices[${index}]`));
const values = patch.values.map((item, index) => {
const result = finite(item, `weightPatch.values[${index}]`);
if (result < 0 || result > 1) fail(`weightPatch.values[${index}]`, "must be in [0,1]");
return result;
});
const result: WeightPatchIR = { schemaVersion: 1, objectId: string(patch.objectId, "weightPatch.objectId"), revision: integer(patch.revision, "weightPatch.revision"), vertexGroup: string(patch.vertexGroup, "weightPatch.vertexGroup"), indices, values };
if (patch.normalize !== undefined) {
if (typeof patch.normalize !== "boolean") fail("weightPatch.normalize", "must be boolean");
result.normalize = patch.normalize;
}
if (patch.mirror !== undefined) {
if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean");
result.mirror = patch.mirror;
}
return result;
}
export function computePaintBrushWeights(
verticesValue: unknown,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {},
): PaintBrushWeightIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
const strength = finite(strengthValue, "strength");
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]");
const viewDirection = options.viewDirection ?? [0, 0, -1];
tuple(viewDirection, 3, "viewDirection");
const result: PaintBrushWeightIR[] = [];
const seen = new Set<number>();
for (const [vertexIndex, item] of verticesValue.entries()) {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
if (vertex.normal !== undefined) {
const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue;
}
const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]);
if (distance > radius) continue;
const normalized = distance / radius;
const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized);
const weight = Math.max(0, Math.min(1, strength * smoothstep));
if (weight > 0) result.push({ index, weight });
}
return result.sort((left, right) => left.index - right.index);
}
export function parseUdimTilePatch(value: unknown): UdimTilePatchIR {
const patch = record(value, "udimPatch");
if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format");
const tile = integer(patch.tile, "udimPatch.tile");
if (tile < 1001 || tile > 1999) fail("udimPatch.tile", "must be in the supported UDIM range [1001,1999]");
const width = integer(patch.width, "udimPatch.width");
const height = integer(patch.height, "udimPatch.height");
if (width < 1 || height < 1 || width > 16_384 || height > 16_384) fail("udimPatch", "dimensions are outside the bounded range");
const byteOffset = integer(patch.byteOffset, "udimPatch.byteOffset");
if (!(patch.bytes instanceof Uint8Array) || patch.bytes.byteLength === 0 || patch.bytes.byteLength > PAINT_BUDGET.maxTextureTileBytes) fail("udimPatch.bytes", "exceeds the tile patch budget", true);
const tileBytes = width * height * 4;
if (tileBytes > PAINT_BUDGET.maxTextureTileBytes || byteOffset > tileBytes - patch.bytes.byteLength) fail("udimPatch.bytes", "is outside the RGBA8 tile range", tileBytes > PAINT_BUDGET.maxTextureTileBytes);
return {
schemaVersion: 1,
textureAssetId: string(patch.textureAssetId, "udimPatch.textureAssetId"),
tile,
revision: integer(patch.revision, "udimPatch.revision"),
width,
height,
format: "RGBA8",
colorSpace: patch.colorSpace,
baseSha256: digest(patch.baseSha256, "udimPatch.baseSha256"),
resultSha256: digest(patch.resultSha256, "udimPatch.resultSha256"),
byteOffset,
bytes: patch.bytes.slice(),
};
}
async function sha256(bytes: Uint8Array): Promise<string> {
const digestBytes = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
return Array.from(new Uint8Array(digestBytes), (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function applyUdimTilePatch(tileValue: unknown, patchValue: unknown, currentRevision: number): Promise<Uint8Array> {
if (!(tileValue instanceof Uint8Array)) fail("tile", "must be Uint8Array");
const patch = parseUdimTilePatch(patchValue);
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("currentRevision", "must be a non-negative safe integer");
if (patch.revision !== currentRevision) throw new Error("REVISION_CONFLICT: UDIM tile patch is stale");
if (tileValue.byteLength !== patch.width * patch.height * 4) fail("tile", "length does not match the declared dimensions");
if (await sha256(tileValue) !== patch.baseSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile base digest is stale");
const result = tileValue.slice();
result.set(patch.bytes, patch.byteOffset);
if (await sha256(result) !== patch.resultSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile result digest does not match the patch");
return result;
}