492 lines
26 KiB
TypeScript
492 lines
26 KiB
TypeScript
export const PAINT_BUDGET = {
|
|
maxSamples: 100_000,
|
|
maxWeightEntries: 1_000_000,
|
|
maxTextureTileBytes: 256 * 1024 * 1024,
|
|
maxStrokeBytes: 64 * 1024 * 1024,
|
|
maxSpatialCells: 1_000_000,
|
|
} 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;
|
|
limit?: number;
|
|
mirror?: boolean;
|
|
mirrorAxis?: 0 | 1 | 2;
|
|
mirrorTolerance?: number;
|
|
}
|
|
|
|
export interface PaintBrushVertexIR {
|
|
index: number;
|
|
position: [number, number, number];
|
|
normal?: [number, number, number];
|
|
occluded?: boolean;
|
|
}
|
|
|
|
export interface PaintBrushWeightIR { index: number; weight: number }
|
|
|
|
export interface PaintBrushQueryOptionsIR {
|
|
ignoreOccluded?: boolean;
|
|
frontFaceOnly?: boolean;
|
|
viewDirection?: [number, number, number];
|
|
visibleVertexIndices?: readonly number[];
|
|
requireVisibility?: boolean;
|
|
selectedVertexIndices?: readonly number[];
|
|
requireSelection?: boolean;
|
|
maskWeights?: readonly PaintBrushWeightIR[];
|
|
}
|
|
|
|
function validateBrushGateIdentities(vertices: readonly PaintBrushVertexIR[], options: PaintBrushQueryOptionsIR): void {
|
|
const known = new Set(vertices.map((vertex) => vertex.index));
|
|
for (const [path, values] of [["visibleVertexIndices", options.visibleVertexIndices], ["selectedVertexIndices", options.selectedVertexIndices]] as const) {
|
|
values?.forEach((value, index) => {
|
|
const vertexIndex = integer(value, `${path}[${index}]`);
|
|
if (!known.has(vertexIndex)) fail(`${path}[${index}]`, "references an unknown vertex identity");
|
|
});
|
|
}
|
|
options.maskWeights?.forEach((value, index) => {
|
|
const entry = record(value, `maskWeights[${index}]`);
|
|
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
|
|
if (!known.has(vertexIndex)) fail(`maskWeights[${index}].index`, "references an unknown vertex identity");
|
|
});
|
|
}
|
|
|
|
export interface PaintBrushSpatialIndex {
|
|
readonly schemaVersion: 1;
|
|
readonly cellSize: number;
|
|
readonly vertices: readonly PaintBrushVertexIR[];
|
|
readonly cells: ReadonlyMap<string, readonly number[]>;
|
|
}
|
|
|
|
export interface PaintBrushSpatialQueryIR {
|
|
weights: PaintBrushWeightIR[];
|
|
candidateCount: number;
|
|
visitedCellCount: number;
|
|
}
|
|
|
|
export interface PaintColorPatchIR { indices: number[]; colors: number[] }
|
|
|
|
function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] {
|
|
if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true);
|
|
if (value.length === 0) fail(path, "must contain at least one brush hit");
|
|
const seen = new Set<number>();
|
|
return value.map((item, index) => {
|
|
const entry = record(item, `${path}[${index}]`);
|
|
const vertexIndex = integer(entry.index, `${path}[${index}].index`);
|
|
const weight = finite(entry.weight, `${path}[${index}].weight`);
|
|
if (weight < 0 || weight > 1) fail(`${path}[${index}].weight`, "must be in [0,1]");
|
|
if (seen.has(vertexIndex)) fail(`${path}[${index}].index`, "contains a duplicate vertex");
|
|
seen.add(vertexIndex);
|
|
return { index: vertexIndex, weight };
|
|
}).sort((left, right) => left.index - right.index);
|
|
}
|
|
|
|
export function composePaintWeightPatch(
|
|
objectId: string,
|
|
vertexGroup: string,
|
|
revision: number,
|
|
currentRevision: number,
|
|
currentWeightsValue: unknown,
|
|
brushWeightsValue: unknown,
|
|
targetValue: unknown,
|
|
): WeightPatchIR {
|
|
const parsedRevision = integer(revision, "revision");
|
|
if (parsedRevision !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint weight stroke is stale");
|
|
if (!Array.isArray(currentWeightsValue)) fail("currentWeights", "must be an array");
|
|
const currentWeights = currentWeightsValue.map((value, index) => {
|
|
const weight = finite(value, `currentWeights[${index}]`);
|
|
if (weight < 0 || weight > 1) fail(`currentWeights[${index}]`, "must be in [0,1]");
|
|
return weight;
|
|
});
|
|
const target = finite(targetValue, "targetWeight");
|
|
if (target < 0 || target > 1) fail("targetWeight", "must be in [0,1]");
|
|
const weights = parseBrushWeights(brushWeightsValue);
|
|
if (weights.some((entry) => entry.index >= currentWeights.length)) fail("brushWeights", "references an unknown current weight");
|
|
return parseWeightPatch({ schemaVersion: 1, objectId, revision: parsedRevision, vertexGroup, indices: weights.map((entry) => entry.index), values: weights.map((entry) => currentWeights[entry.index] + (target - currentWeights[entry.index]) * entry.weight), normalize: false });
|
|
}
|
|
|
|
export function composePaintColorPatch(
|
|
revision: number,
|
|
currentRevision: number,
|
|
currentColorsValue: unknown,
|
|
brushWeightsValue: unknown,
|
|
targetColorValue: unknown,
|
|
): PaintColorPatchIR {
|
|
if (integer(revision, "revision") !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint color stroke is stale");
|
|
if (!Array.isArray(currentColorsValue) || currentColorsValue.length % 4 !== 0) fail("currentColors", "must contain RGBA values");
|
|
const currentColors = currentColorsValue.map((value, index) => {
|
|
const component = finite(value, `currentColors[${index}]`);
|
|
if (component < 0 || component > 1) fail(`currentColors[${index}]`, "must be in [0,1]");
|
|
return component;
|
|
});
|
|
const target = tuple(targetColorValue, 4, "targetColor");
|
|
if (target.some((component) => component < 0 || component > 1)) fail("targetColor", "must be in [0,1]");
|
|
const weights = parseBrushWeights(brushWeightsValue);
|
|
if (weights.some((entry) => entry.index >= currentColors.length / 4)) fail("brushWeights", "references an unknown current color");
|
|
return {
|
|
indices: weights.map((entry) => entry.index),
|
|
colors: weights.flatMap((entry) => Array.from({ length: 4 }, (_, component) => currentColors[entry.index * 4 + component] + (target[component] - currentColors[entry.index * 4 + component]) * entry.weight)),
|
|
};
|
|
}
|
|
|
|
const paintBrushSpatialIndexes = new WeakMap<object, { cellSize: number; vertices: PaintBrushVertexIR[]; cells: Map<string, 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.limit !== undefined) {
|
|
const limit = integer(patch.limit, "weightPatch.limit");
|
|
if (limit < 1 || limit > 32) fail("weightPatch.limit", "must be in [1,32]");
|
|
result.limit = limit;
|
|
}
|
|
if (patch.mirror !== undefined) {
|
|
if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean");
|
|
result.mirror = patch.mirror;
|
|
}
|
|
if (patch.mirrorAxis !== undefined) {
|
|
const axis = integer(patch.mirrorAxis, "weightPatch.mirrorAxis");
|
|
if (axis > 2) fail("weightPatch.mirrorAxis", "must be 0, 1 or 2");
|
|
result.mirrorAxis = axis as 0 | 1 | 2;
|
|
}
|
|
if (patch.mirrorTolerance !== undefined) {
|
|
const tolerance = finite(patch.mirrorTolerance, "weightPatch.mirrorTolerance");
|
|
if (tolerance <= 0 || tolerance > 1) fail("weightPatch.mirrorTolerance", "must be in (0,1]");
|
|
result.mirrorTolerance = tolerance;
|
|
}
|
|
if (!result.mirror && (result.mirrorAxis !== undefined || result.mirrorTolerance !== undefined)) fail("weightPatch.mirrorAxis/mirrorTolerance", "require mirror=true");
|
|
return result;
|
|
}
|
|
|
|
function parsePaintBrushVertices(verticesValue: unknown): PaintBrushVertexIR[] {
|
|
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
|
|
const seen = new Set<number>();
|
|
return verticesValue.map((item, vertexIndex) => {
|
|
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 (position.some((component) => Math.abs(component) > 1_000_000_000)) fail(`vertices[${vertexIndex}].position`, "is outside the spatial index range");
|
|
const result: PaintBrushVertexIR = { index, position };
|
|
if (vertex.occluded !== undefined) {
|
|
if (typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
|
|
result.occluded = vertex.occluded;
|
|
}
|
|
if (vertex.normal !== undefined) result.normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
|
|
return result;
|
|
});
|
|
}
|
|
|
|
function brushWeights(
|
|
vertices: readonly PaintBrushVertexIR[],
|
|
centerValue: unknown,
|
|
radiusValue: unknown,
|
|
strengthValue: unknown,
|
|
options: PaintBrushQueryOptionsIR = {},
|
|
): PaintBrushWeightIR[] {
|
|
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");
|
|
if (options.requireVisibility && options.visibleVertexIndices === undefined) fail("visibleVertexIndices", "is required for depth-gated brush queries");
|
|
if (options.visibleVertexIndices !== undefined && (!Array.isArray(options.visibleVertexIndices) || options.visibleVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("visibleVertexIndices", "exceeds the visibility budget", true);
|
|
const visible = options.visibleVertexIndices === undefined ? undefined : new Set(options.visibleVertexIndices.map((value, index) => integer(value, `visibleVertexIndices[${index}]`)));
|
|
if (visible && visible.size !== options.visibleVertexIndices?.length) fail("visibleVertexIndices", "contains duplicates");
|
|
if (options.requireSelection && options.selectedVertexIndices === undefined) fail("selectedVertexIndices", "is required for selection-gated brush queries");
|
|
if (options.selectedVertexIndices !== undefined && (!Array.isArray(options.selectedVertexIndices) || options.selectedVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("selectedVertexIndices", "exceeds the selection budget", true);
|
|
const selected = options.selectedVertexIndices === undefined ? undefined : new Set(options.selectedVertexIndices.map((value, index) => integer(value, `selectedVertexIndices[${index}]`)));
|
|
if (selected && selected.size !== options.selectedVertexIndices?.length) fail("selectedVertexIndices", "contains duplicates");
|
|
if (options.maskWeights !== undefined && (!Array.isArray(options.maskWeights) || options.maskWeights.length > PAINT_BUDGET.maxWeightEntries)) fail("maskWeights", "exceeds the mask budget", true);
|
|
const mask = options.maskWeights === undefined ? undefined : new Map<number, number>();
|
|
options.maskWeights?.forEach((value, index) => {
|
|
const entry = record(value, `maskWeights[${index}]`);
|
|
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
|
|
const weight = finite(entry.weight, `maskWeights[${index}].weight`);
|
|
if (weight < 0 || weight > 1) fail(`maskWeights[${index}].weight`, "must be in [0,1]");
|
|
if (mask!.has(vertexIndex)) fail(`maskWeights[${index}].index`, "contains a duplicate vertex");
|
|
mask!.set(vertexIndex, weight);
|
|
});
|
|
const result: PaintBrushWeightIR[] = [];
|
|
for (const vertex of vertices) {
|
|
const { index, position } = vertex;
|
|
if (visible && !visible.has(index)) continue;
|
|
if (selected && !selected.has(index)) continue;
|
|
const maskWeight = mask?.get(index) ?? (mask ? 0 : 1);
|
|
if (maskWeight === 0) continue;
|
|
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
|
|
if (vertex.normal !== undefined) {
|
|
const normal = tuple(vertex.normal, 3, `vertices[${index}].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 * maskWeight));
|
|
if (weight > 0) result.push({ index, weight });
|
|
}
|
|
return result.sort((left, right) => left.index - right.index);
|
|
}
|
|
|
|
export function computePaintBrushWeights(
|
|
verticesValue: unknown,
|
|
centerValue: unknown,
|
|
radiusValue: unknown,
|
|
strengthValue: unknown,
|
|
options: PaintBrushQueryOptionsIR = {},
|
|
): PaintBrushWeightIR[] {
|
|
const vertices = parsePaintBrushVertices(verticesValue);
|
|
validateBrushGateIdentities(vertices, options);
|
|
return brushWeights(vertices, centerValue, radiusValue, strengthValue, options);
|
|
}
|
|
|
|
function spatialCell(position: readonly number[], cellSize: number): [number, number, number] {
|
|
return [Math.floor(position[0] / cellSize), Math.floor(position[1] / cellSize), Math.floor(position[2] / cellSize)];
|
|
}
|
|
|
|
function spatialKey(x: number, y: number, z: number): string { return `${x}:${y}:${z}`; }
|
|
|
|
export function buildPaintBrushSpatialIndex(verticesValue: unknown, cellSizeValue: unknown): PaintBrushSpatialIndex {
|
|
const vertices = parsePaintBrushVertices(verticesValue);
|
|
const cellSize = finite(cellSizeValue, "cellSize");
|
|
if (cellSize < 1e-6 || cellSize > 100_000) fail("cellSize", "is outside the bounded range");
|
|
const cells = new Map<string, number[]>();
|
|
vertices.forEach((vertex, offset) => {
|
|
const cell = spatialCell(vertex.position, cellSize);
|
|
if (cell.some((component) => !Number.isSafeInteger(component))) fail("vertices", "produces an unsafe spatial cell");
|
|
const key = spatialKey(...cell);
|
|
const offsets = cells.get(key) ?? [];
|
|
offsets.push(offset);
|
|
cells.set(key, offsets);
|
|
});
|
|
if (cells.size > PAINT_BUDGET.maxSpatialCells) fail("vertices", "exceeds the spatial cell budget", true);
|
|
const publicVertices = vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], ...(vertex.normal ? { normal: [...vertex.normal] as [number, number, number] } : {}) }));
|
|
const publicCells = new Map([...cells].map(([key, offsets]) => [key, [...offsets]]));
|
|
const index: PaintBrushSpatialIndex = Object.freeze({ schemaVersion: 1, cellSize, vertices: publicVertices, cells: publicCells });
|
|
paintBrushSpatialIndexes.set(index, { cellSize, vertices, cells });
|
|
return index;
|
|
}
|
|
|
|
export function queryPaintBrushSpatialIndex(
|
|
index: PaintBrushSpatialIndex,
|
|
centerValue: unknown,
|
|
radiusValue: unknown,
|
|
strengthValue: unknown,
|
|
options: PaintBrushQueryOptionsIR = {},
|
|
): PaintBrushSpatialQueryIR {
|
|
const source = paintBrushSpatialIndexes.get(index);
|
|
if (!source) fail("spatialIndex", "is invalid");
|
|
validateBrushGateIdentities(source.vertices, options);
|
|
const center = tuple(centerValue, 3, "center") as [number, number, number];
|
|
const radius = finite(radiusValue, "radius");
|
|
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
|
|
const minimum = spatialCell(center.map((component) => component - radius), source.cellSize);
|
|
const maximum = spatialCell(center.map((component) => component + radius), source.cellSize);
|
|
const spans = maximum.map((component, axis) => component - minimum[axis] + 1);
|
|
if (spans.some((span) => !Number.isSafeInteger(span) || span <= 0) || spans[0] > PAINT_BUDGET.maxSpatialCells / spans[1] / spans[2]) fail("spatialQuery", "exceeds the visited cell budget", true);
|
|
const offsets = new Set<number>();
|
|
let visitedCellCount = 0;
|
|
for (let x = minimum[0]; x <= maximum[0]; x++) for (let y = minimum[1]; y <= maximum[1]; y++) for (let z = minimum[2]; z <= maximum[2]; z++) {
|
|
visitedCellCount++;
|
|
for (const offset of source.cells.get(spatialKey(x, y, z)) ?? []) offsets.add(offset);
|
|
}
|
|
const candidates = [...offsets].sort((left, right) => left - right).map((offset) => source.vertices[offset]);
|
|
return { weights: brushWeights(candidates, center, radius, strengthValue, options), candidateCount: candidates.length, visitedCellCount };
|
|
}
|
|
|
|
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;
|
|
}
|