Advance bounded editor and cache workflows
This commit is contained in:
@@ -17,7 +17,26 @@ export type GreasePencilEditorEditIR =
|
||||
| { type: "SET_FRAME"; revision: number; frame: number }
|
||||
| { type: "SET_LAYER"; revision: number; layerId: string }
|
||||
| { type: "SET_ONION"; revision: number; enabled: boolean }
|
||||
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] };
|
||||
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] }
|
||||
| { type: "TRANSLATE_POINTS"; revision: number; translation: [number, number, number] };
|
||||
|
||||
export interface GreasePencilEditablePointIR {
|
||||
position: [number, number, number];
|
||||
radius?: number;
|
||||
opacity?: number;
|
||||
vertexColor?: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface GreasePencilEditableStrokeIR {
|
||||
cyclic?: boolean;
|
||||
materialIndex?: number;
|
||||
points: GreasePencilEditablePointIR[];
|
||||
}
|
||||
|
||||
export interface GreasePencilPointEditResultIR {
|
||||
editor: GreasePencilEditorIR;
|
||||
strokes: GreasePencilEditableStrokeIR[];
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); }
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
@@ -32,6 +51,14 @@ function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) fail(path, "is outside the supported frame/index range");
|
||||
return value;
|
||||
}
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1_000_000) fail(path, "must be finite and bounded");
|
||||
return value;
|
||||
}
|
||||
function tuple(value: unknown, length: number, path: string): number[] {
|
||||
if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`);
|
||||
return value.map((item, index) => finite(item, `${path}[${index}]`));
|
||||
}
|
||||
function nonnegativeIndex(value: unknown, path: string): number {
|
||||
const result = integer(value, path);
|
||||
if (result < 0) fail(path, "must be non-negative");
|
||||
@@ -87,6 +114,75 @@ export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown):
|
||||
if (typeof edit.enabled !== "boolean") fail("edit.enabled", "must be boolean");
|
||||
return parseGreasePencilEditor({ ...editor, revision, onionSkinning: edit.enabled });
|
||||
case "SET_SELECTION": return parseGreasePencilEditor({ ...editor, revision, selectedStrokeIndices: uniqueIndices(edit.strokeIndices, "edit.strokeIndices"), selectedPoints: points(edit.points, "edit.points") });
|
||||
case "TRANSLATE_POINTS":
|
||||
tuple(edit.translation, 3, "edit.translation");
|
||||
if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation");
|
||||
return parseGreasePencilEditor({ ...editor, revision });
|
||||
default: fail("edit.type", "is unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
function parseEditablePoint(value: unknown, path: string): GreasePencilEditablePointIR {
|
||||
const point = record(value, path);
|
||||
const result: GreasePencilEditablePointIR = { position: tuple(point.position, 3, `${path}.position`) as [number, number, number] };
|
||||
if (point.radius !== undefined) {
|
||||
result.radius = finite(point.radius, `${path}.radius`);
|
||||
if (result.radius < 0) fail(`${path}.radius`, "must be non-negative");
|
||||
}
|
||||
if (point.opacity !== undefined) {
|
||||
result.opacity = finite(point.opacity, `${path}.opacity`);
|
||||
if (result.opacity < 0 || result.opacity > 1) fail(`${path}.opacity`, "must be in [0,1]");
|
||||
}
|
||||
if (point.vertexColor !== undefined) {
|
||||
result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number];
|
||||
if (result.vertexColor.some((component) => component < 0 || component > 1)) fail(`${path}.vertexColor`, "must be in [0,1]");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseEditableStrokes(value: unknown): GreasePencilEditableStrokeIR[] {
|
||||
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the stroke budget");
|
||||
let pointCount = 0;
|
||||
return value.map((item, strokeIndex) => {
|
||||
const stroke = record(item, `strokes[${strokeIndex}]`);
|
||||
if (!Array.isArray(stroke.points) || stroke.points.length === 0) fail(`strokes[${strokeIndex}].points`, "must be a non-empty array");
|
||||
pointCount += stroke.points.length;
|
||||
if (pointCount > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the point budget");
|
||||
if (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") fail(`strokes[${strokeIndex}].cyclic`, "must be boolean");
|
||||
if (stroke.materialIndex !== undefined) nonnegativeIndex(stroke.materialIndex, `strokes[${strokeIndex}].materialIndex`);
|
||||
return {
|
||||
...(stroke.cyclic !== undefined ? { cyclic: stroke.cyclic } : {}),
|
||||
...(stroke.materialIndex !== undefined ? { materialIndex: stroke.materialIndex as number } : {}),
|
||||
points: stroke.points.map((point, pointIndex) => parseEditablePoint(point, `strokes[${strokeIndex}].points[${pointIndex}]`)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyGreasePencilPointTranslation(
|
||||
editorValue: unknown,
|
||||
strokesValue: unknown,
|
||||
editValue: unknown,
|
||||
): GreasePencilPointEditResultIR {
|
||||
const editor = parseGreasePencilEditor(editorValue);
|
||||
const strokes = parseEditableStrokes(strokesValue);
|
||||
const edit = record(editValue, "edit");
|
||||
if (edit.type !== "TRANSLATE_POINTS") fail("edit.type", "must be TRANSLATE_POINTS");
|
||||
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
|
||||
const translation = tuple(edit.translation, 3, "edit.translation") as [number, number, number];
|
||||
if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation");
|
||||
const selected = new Set(editor.selectedPoints.map((point) => `${point.strokeIndex}:${point.pointIndex}`));
|
||||
for (const point of editor.selectedPoints) {
|
||||
if (!strokes[point.strokeIndex]?.points[point.pointIndex]) fail("editor.selectedPoints", `references missing point ${point.strokeIndex}:${point.pointIndex}`);
|
||||
}
|
||||
const translated = strokes.map((stroke, strokeIndex) => ({
|
||||
...stroke,
|
||||
points: stroke.points.map((point, pointIndex) => selected.has(`${strokeIndex}:${pointIndex}`) ? {
|
||||
...point,
|
||||
position: point.position.map((component, axis) => finite(component + translation[axis], `translated[${strokeIndex}][${pointIndex}][${axis}]`)) as [number, number, number],
|
||||
} : { ...point, position: [...point.position] as [number, number, number] }),
|
||||
}));
|
||||
return {
|
||||
editor: parseGreasePencilEditor({ ...editor, revision: editor.revision + 1 }),
|
||||
strokes: translated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,19 @@ export interface PhysicsFamilyCapabilityIR {
|
||||
serverJob: "BLOCKED";
|
||||
}
|
||||
|
||||
export interface BrowserTransformCacheObjectIR {
|
||||
objectId: string;
|
||||
translation: [number, number, number];
|
||||
rotationQuaternion: [number, number, number, number];
|
||||
scale: [number, number, number];
|
||||
}
|
||||
|
||||
export interface BrowserTransformCacheFrameIR {
|
||||
schemaVersion: 1;
|
||||
frame: number;
|
||||
objects: BrowserTransformCacheObjectIR[];
|
||||
}
|
||||
|
||||
export class PhysicsSimulationValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
@@ -237,3 +250,46 @@ export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame:
|
||||
}
|
||||
return { cacheKey: cache.cacheKey, frame: requestedFrame };
|
||||
}
|
||||
|
||||
const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1
|
||||
const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16;
|
||||
const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72;
|
||||
|
||||
export function decodeBrowserTransformCacheFrame(value: ArrayBuffer): BrowserTransformCacheFrameIR {
|
||||
if (!(value instanceof ArrayBuffer) || value.byteLength < BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame is truncated");
|
||||
}
|
||||
const view = new DataView(value);
|
||||
if (view.getUint32(0, true) !== BROWSER_TRANSFORM_CACHE_MAGIC || view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
|
||||
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported browser transform cache frame schema");
|
||||
}
|
||||
const frameNumber = view.getInt32(8, true);
|
||||
const count = view.getUint32(12, true);
|
||||
if (frameNumber < -1_000_000 || frameNumber > 1_000_000 || count > PHYSICS_SIMULATION_BUDGET.maxSystems || value.byteLength !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES + count * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame length or count is invalid");
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const objects: BrowserTransformCacheObjectIR[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = BROWSER_TRANSFORM_CACHE_HEADER_BYTES + index * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES;
|
||||
const idLength = view.getUint8(offset);
|
||||
if (idLength === 0 || idLength > 31) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid ID length`);
|
||||
let objectId: string;
|
||||
try { objectId = decoder.decode(new Uint8Array(value, offset + 1, idLength)); }
|
||||
catch { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has invalid UTF-8`); }
|
||||
if (!objectId.startsWith("object:") || ids.has(objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid or duplicate ID`);
|
||||
ids.add(objectId);
|
||||
const numbers = Array.from({ length: 10 }, (_, component) => view.getFloat32(offset + 32 + component * 4, true));
|
||||
if (numbers.some((component) => !Number.isFinite(component))) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has non-finite transforms`);
|
||||
const quaternionLength = Math.hypot(numbers[3], numbers[4], numbers[5], numbers[6]);
|
||||
if (Math.abs(quaternionLength - 1) > 1e-3 || numbers.slice(7).some((component) => component === 0)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid rotation or scale`);
|
||||
objects.push({
|
||||
objectId,
|
||||
translation: numbers.slice(0, 3) as [number, number, number],
|
||||
rotationQuaternion: numbers.slice(3, 7) as [number, number, number, number],
|
||||
scale: numbers.slice(7, 10) as [number, number, number],
|
||||
});
|
||||
}
|
||||
return { schemaVersion: 1, frame: frameNumber, objects };
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }
|
||||
| { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number }
|
||||
| { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number }
|
||||
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
|
||||
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; baseRevision?: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
|
||||
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
|
||||
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
|
||||
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }
|
||||
|
||||
Reference in New Issue
Block a user