Advance bounded editor and cache workflows

This commit is contained in:
mes123456
2026-08-12 19:31:41 -04:00
parent d54d5dd913
commit 3da1dfc804
19 changed files with 563 additions and 50 deletions

View File

@@ -42,6 +42,30 @@ export interface WeightPatchIR {
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}`);
}
@@ -73,6 +97,14 @@ function string(value: unknown, path: string, allowEmpty = false): string {
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];
@@ -146,3 +178,87 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
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;
}