Advance bounded editor and cache workflows
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user