Advance Blender WebEngine N-015 through N-022 parity

This commit is contained in:
mes123456
2026-08-12 13:26:34 -04:00
parent 9fd26010f6
commit b3cefaeec5
51 changed files with 1809 additions and 171 deletions

View File

@@ -1,5 +1,8 @@
import { parseNlaTracks, type NlaTrackIR } from "./nla";
import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil";
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
export type SceneNodeType =
| "EMPTY"
@@ -434,6 +437,10 @@ export interface SceneIR {
tint?: number;
whiteBalanceStatus?: "AVAILABLE" | "BLOCKED";
};
compositorGraph?: CompositorGraphIR;
compositorStatus?: "AVAILABLE" | "BLOCKED";
sequencerTimeline?: SequencerTimelineIR;
sequencerStatus?: "AVAILABLE" | "BLOCKED";
}
export interface SceneSnapshotIR {
@@ -465,6 +472,8 @@ export interface SceneSnapshotIR {
nonMeshData?: NonMeshDataIR[];
vfonts?: VFontResourceIR[];
greasePencils?: GreasePencilDataIR[];
trackingMasks?: TrackingMaskProjectIR;
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
libraries?: Array<{
id: string;
name: string;
@@ -1001,6 +1010,20 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
for (const field of ["temperature", "tint"] as const) if (scene.colorManagement[field] !== undefined) requireNumber(scene.colorManagement[field], `scenes[${index}].colorManagement.${field}`);
if (scene.colorManagement.whiteBalanceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.colorManagement.whiteBalanceStatus as string)) throw new Error(`scenes[${index}].colorManagement.whiteBalanceStatus is invalid`);
}
if (scene.compositorStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.compositorStatus as string)) {
throw new Error(`scenes[${index}].compositorStatus is invalid`);
}
if (scene.compositorGraph !== undefined) {
parseCompositorGraph(scene.compositorGraph);
if (scene.compositorStatus !== "AVAILABLE") throw new Error(`scenes[${index}].compositorStatus must be AVAILABLE when a graph is present`);
}
if (scene.sequencerStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.sequencerStatus as string)) {
throw new Error(`scenes[${index}].sequencerStatus is invalid`);
}
if (scene.sequencerTimeline !== undefined) {
parseSequencerTimeline(scene.sequencerTimeline);
if (scene.sequencerStatus !== "AVAILABLE") throw new Error(`scenes[${index}].sequencerStatus must be AVAILABLE when a timeline is present`);
}
}
for (const [index, animation] of (value.animations as unknown[]).entries()) {
if (!isRecord(animation)) throw new Error(`SceneIR.animations[${index}] must be an object`);
@@ -1025,5 +1048,12 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
}
if (value.nlaTracks !== undefined) parseNlaTracks(value.nlaTracks);
if (value.trackingMaskStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.trackingMaskStatus as string)) {
throw new Error("SceneIR.trackingMaskStatus is invalid");
}
if (value.trackingMasks !== undefined) {
parseTrackingMaskProject(value.trackingMasks);
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
}
return value as unknown as SceneSnapshotIR;
}

View File

@@ -1,63 +1,296 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const SELECTION_HISTORY_SCHEMA = 1 as const;
export const SELECTION_HISTORY_BUDGET = { maxEntries: 256, maxObjects: 100_000, maxElements: 1_000_000 } as const;
export const SELECTION_HISTORY_SCHEMA = 2 as const;
export const SELECTION_HISTORY_BUDGET = {
maxEntries: 256,
maxObjects: 100_000,
maxTargets: 100_000,
maxElements: 1_000_000,
maxRangePatches: 4096,
} as const;
export type SelectionElementMode = "VERT" | "EDGE" | "FACE";
export type NonMeshSelectionKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
export interface SelectionStateIR { activeObjectId: string | null; objectIds: string[]; meshId: string | null; elementMode: SelectionElementMode; elementIndices: number[]; nonMeshKind?: NonMeshSelectionKind }
export interface SelectionHistoryIR { schemaVersion: typeof SELECTION_HISTORY_SCHEMA; revision: number; cursor: number; entries: SelectionStateIR[] }
export interface RaycastSelectionHitIR { sourceRevision: number; dataId: string; mode: SelectionElementMode; index: number; distance: number; point: [number, number, number]; nonMeshKind?: NonMeshSelectionKind }
export interface SelectionElementTargetIR {
objectId: string;
dataId: string;
mode: SelectionElementMode;
indices: number[];
nonMeshKind?: NonMeshSelectionKind;
}
export interface SelectionStateIR {
activeObjectId: string | null;
objectIds: string[];
targets: SelectionElementTargetIR[];
}
export interface SelectionHistoryIR {
schemaVersion: typeof SELECTION_HISTORY_SCHEMA;
revision: number;
cursor: number;
entries: SelectionStateIR[];
}
export interface SelectionRangePatchIR {
objectId: string;
dataId: string;
mode: SelectionElementMode;
start: number;
end: number;
selected: boolean;
nonMeshKind?: NonMeshSelectionKind;
}
export interface RaycastSelectionHitIR {
sourceRevision: number;
objectId?: string;
dataId: string;
mode: SelectionElementMode;
index: number;
distance: number;
point: [number, number, number];
nonMeshKind?: NonMeshSelectionKind;
}
export class SelectionHistoryValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "SelectionHistoryValidationError"; this.code = code; }
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "SelectionHistoryValidationError";
this.code = code;
}
}
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`); return value; }
function ids(value: unknown, name: string, maximum: number): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) throw new SelectionHistoryValidationError(value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", `${name} is invalid`); return [...value] as string[]; }
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`);
}
return value;
}
function id(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`);
}
return value;
}
function ids(value: unknown, name: string, maximum: number): string[] {
if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) {
throw new SelectionHistoryValidationError(
value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
`${name} is invalid`,
);
}
return [...value] as string[];
}
function mode(value: unknown, name: string): SelectionElementMode {
if (!["VERT", "EDGE", "FACE"].includes(value as string)) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`);
}
return value as SelectionElementMode;
}
function kind(value: unknown, code: "SELECTION_HISTORY_INVALID" | "RAYCAST_HIT_INVALID"): NonMeshSelectionKind | undefined {
if (value === undefined) return undefined;
if (!["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value as string)) {
throw new SelectionHistoryValidationError(code, "Non-mesh selection identity is invalid");
}
return value as NonMeshSelectionKind;
}
function indices(value: unknown, name: string): number[] {
if (!Array.isArray(value) || value.length > SELECTION_HISTORY_BUDGET.maxElements || value.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value).size !== value.length) {
throw new SelectionHistoryValidationError(
value instanceof Array && value.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
`${name} is invalid`,
);
}
return [...value].sort((left, right) => left - right) as number[];
}
function targetKey(target: Pick<SelectionElementTargetIR, "objectId" | "dataId" | "mode" | "nonMeshKind">): string {
return `${target.objectId}\0${target.dataId}\0${target.mode}\0${target.nonMeshKind ?? "MESH"}`;
}
function parseTarget(value: unknown, path: string): SelectionElementTargetIR {
if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${path} is invalid`);
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
return {
objectId: id(value.objectId, `${path}.objectId`),
dataId: id(value.dataId, `${path}.dataId`),
mode: mode(value.mode, `${path}.mode`),
indices: indices(value.indices, `${path}.indices`),
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
};
}
function migrateLegacyTarget(value: Record<string, unknown>, objectIds: string[], activeObjectId: string | null): SelectionElementTargetIR[] {
const legacyIndices = value.elementIndices === undefined ? [] : indices(value.elementIndices, "elementIndices");
if (legacyIndices.length === 0) return [];
const dataId = value.meshId === null ? null : id(value.meshId, "meshId");
if (!dataId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires dataId");
const objectId = activeObjectId ?? objectIds[0];
if (!objectId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires an owning object");
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
return [{
objectId,
dataId,
mode: mode(value.elementMode, "elementMode"),
indices: legacyIndices,
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
}];
}
export function parseSelectionState(value: unknown): SelectionStateIR {
if (!record(value) || !["VERT", "EDGE", "FACE"].includes(value.elementMode as string)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid");
if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid");
const objectIds = ids(value.objectIds, "objectIds", SELECTION_HISTORY_BUDGET.maxObjects);
const activeObjectId = value.activeObjectId === null ? null : typeof value.activeObjectId === "string" ? value.activeObjectId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "activeObjectId is invalid"); })();
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
if (!Array.isArray(value.elementIndices) || value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements || value.elementIndices.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value.elementIndices).size !== value.elementIndices.length) throw new SelectionHistoryValidationError(value.elementIndices instanceof Array && value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", "elementIndices are invalid");
const meshId = value.meshId === null ? null : typeof value.meshId === "string" && value.meshId.length > 0 ? value.meshId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "meshId is invalid"); })();
if (meshId === null && value.elementIndices.length > 0) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires meshId");
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "nonMeshKind is invalid"); })();
return { activeObjectId, objectIds, meshId, elementMode: value.elementMode as SelectionElementMode, elementIndices: [...value.elementIndices].sort((a, b) => a - b) as number[], ...(nonMeshKind ? { nonMeshKind } : {}) };
const activeObjectId = value.activeObjectId === null ? null : id(value.activeObjectId, "activeObjectId");
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
}
const targets = Array.isArray(value.targets)
? value.targets.map((target, index) => parseTarget(target, `targets[${index}]`))
: migrateLegacyTarget(value, objectIds, activeObjectId);
if (targets.length > SELECTION_HISTORY_BUDGET.maxTargets) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection target count exceeds the budget");
}
let elementCount = 0;
const keys = new Set<string>();
for (const target of targets) {
if (!objectIds.includes(target.objectId)) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element target owner must be selected");
}
const key = targetKey(target);
if (keys.has(key)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection targets must be unique");
keys.add(key);
elementCount += target.indices.length;
if (!Number.isSafeInteger(elementCount) || elementCount > SELECTION_HISTORY_BUDGET.maxElements) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selected element count exceeds the budget");
}
}
targets.sort((left, right) => targetKey(left).localeCompare(targetKey(right)));
return { activeObjectId, objectIds: [...objectIds].sort(), targets };
}
export function parseSelectionHistory(value: unknown): SelectionHistoryIR {
if (!record(value) || value.schemaVersion !== SELECTION_HISTORY_SCHEMA || !Array.isArray(value.entries)) throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema");
if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget");
return { schemaVersion: SELECTION_HISTORY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1), entries: value.entries.map(parseSelectionState) };
if (!record(value) || ![1, SELECTION_HISTORY_SCHEMA].includes(value.schemaVersion as number) || !Array.isArray(value.entries)) {
throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema");
}
if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget");
}
return {
schemaVersion: SELECTION_HISTORY_SCHEMA,
revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1),
entries: value.entries.map(parseSelectionState),
};
}
function equalState(a: SelectionStateIR, b: SelectionStateIR): boolean { return a.activeObjectId === b.activeObjectId && a.meshId === b.meshId && a.elementMode === b.elementMode && a.nonMeshKind === b.nonMeshKind && a.objectIds.join("\0") === b.objectIds.join("\0") && a.elementIndices.join(",") === b.elementIndices.join(","); }
function equalState(left: SelectionStateIR, right: SelectionStateIR): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export function recordSelection(value: unknown, revision: number, stateValue: unknown): SelectionHistoryIR {
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const state = parseSelectionState(stateValue);
const history = parseSelectionHistory(value);
if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale");
const state = parseSelectionState(stateValue);
if (equalState(history.entries[history.cursor], state)) return history;
const entries = history.entries.slice(0, history.cursor + 1); entries.push(state); if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift();
const entries = history.entries.slice(0, history.cursor + 1);
entries.push(state);
if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift();
return parseSelectionHistory({ schemaVersion: SELECTION_HISTORY_SCHEMA, revision: history.revision + 1, cursor: entries.length - 1, entries });
}
export function stepSelectionHistory(value: unknown, revision: number, direction: "UNDO" | "REDO"): SelectionHistoryIR {
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const cursor = history.cursor + (direction === "UNDO" ? -1 : 1);
if (cursor < 0 || cursor >= history.entries.length) throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`);
const history = parseSelectionHistory(value);
if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale");
const cursor = history.cursor + (direction === "UNDO" ? -1 : 1);
if (cursor < 0 || cursor >= history.entries.length) {
throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`);
}
return { ...history, revision: history.revision + 1, cursor };
}
export function patchSelectionRanges(stateValue: unknown, patchesValue: unknown): SelectionStateIR {
const state = parseSelectionState(stateValue);
if (!Array.isArray(patchesValue) || patchesValue.length === 0 || patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches) {
throw new SelectionHistoryValidationError(
Array.isArray(patchesValue) && patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
"Selection range patches are invalid",
);
}
const targets = new Map(state.targets.map((target) => [targetKey(target), { ...target, indices: new Set(target.indices) }]));
let touched = 0;
for (let patchIndex = 0; patchIndex < patchesValue.length; patchIndex++) {
const value = patchesValue[patchIndex];
if (!record(value) || typeof value.selected !== "boolean") {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] is invalid`);
}
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
const patch: SelectionRangePatchIR = {
objectId: id(value.objectId, `patches[${patchIndex}].objectId`),
dataId: id(value.dataId, `patches[${patchIndex}].dataId`),
mode: mode(value.mode, `patches[${patchIndex}].mode`),
start: integer(value.start, `patches[${patchIndex}].start`, 0, Number.MAX_SAFE_INTEGER),
end: integer(value.end, `patches[${patchIndex}].end`, 0, Number.MAX_SAFE_INTEGER),
selected: value.selected,
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
};
if (patch.end < patch.start) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] range is reversed`);
const length = patch.end - patch.start + 1;
touched += length;
if (!Number.isSafeInteger(touched) || touched > SELECTION_HISTORY_BUDGET.maxElements) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection range patch span exceeds the budget");
}
if (!state.objectIds.includes(patch.objectId)) {
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection range patch owner must be selected");
}
const key = targetKey(patch);
const target = targets.get(key) ?? { objectId: patch.objectId, dataId: patch.dataId, mode: patch.mode, indices: new Set<number>(), ...(patch.nonMeshKind ? { nonMeshKind: patch.nonMeshKind } : {}) };
for (let index = patch.start; index <= patch.end; index++) {
if (patch.selected) target.indices.add(index);
else target.indices.delete(index);
}
if (target.indices.size === 0) targets.delete(key);
else targets.set(key, target);
}
return parseSelectionState({
activeObjectId: state.activeObjectId,
objectIds: state.objectIds,
targets: [...targets.values()].map((target) => ({ ...target, indices: [...target.indices] })),
});
}
export function parseRaycastSelectionHit(value: unknown, expectedRevision: number): RaycastSelectionHitIR {
if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid");
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast non-mesh identity is invalid"); })();
return { sourceRevision: value.sourceRevision as number, dataId: value.dataId, mode: value.mode as SelectionElementMode, index: value.index as number, distance: value.distance, point: value.point as [number, number, number], ...(nonMeshKind ? { nonMeshKind } : {}) };
if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid");
}
const parsedKind = kind(value.nonMeshKind, "RAYCAST_HIT_INVALID");
const objectId = value.objectId === undefined ? undefined : id(value.objectId, "objectId");
return {
sourceRevision: value.sourceRevision as number,
...(objectId ? { objectId } : {}),
dataId: value.dataId,
mode: value.mode as SelectionElementMode,
index: value.index as number,
distance: value.distance,
point: value.point as [number, number, number],
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
};
}
export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult {
if (operation !== "GIZMO") return readyGate("N-015", operation);
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve/non-mesh gizmo interaction is not implemented")]);
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]);
}

View File

@@ -2,6 +2,11 @@ import type { ErrorCode } from "./error";
export const SIMULATION_CACHE_SCHEMA = 1 as const;
export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
export const SIMULATION_CACHE_BUDGET = {
maxCacheBytes: 16 * 1024 * 1024 * 1024,
maxFrameBytes: 512 * 1024 * 1024,
maxFrames: 100_000,
} as const;
export interface SimulationCacheFrameIR {
frame: number;
@@ -69,7 +74,10 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
const frameStart = integer(value.frameStart, "frameStart", -1_000_000);
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000);
const byteLength = integer(value.byteLength, "byteLength", 1);
if (frameEnd < frameStart || frameEnd - frameStart > 100_000 || !Array.isArray(value.frames)) {
if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache exceeds the byte budget");
}
if (frameEnd < frameStart || frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames || !Array.isArray(value.frames)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid");
}
if (value.frames.length !== frameEnd - frameStart + 1) {
@@ -81,7 +89,10 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000);
const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`);
const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1);
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset + frameByteLength > byteLength) {
if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] exceeds the byte budget`);
}
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`);
}
nextOffset += frameByteLength;
@@ -122,6 +133,30 @@ export async function verifySimulationCache(manifestValue: unknown, data: ArrayB
return manifest;
}
export function selectSimulationCacheFrame(manifestValue: unknown, frame: number): SimulationCacheFrameIR {
const manifest = parseSimulationCacheManifest(manifestValue);
if (!Number.isSafeInteger(frame) || frame < manifest.frameStart || frame > manifest.frameEnd) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
}
const selected = manifest.frames[frame - manifest.frameStart];
if (!selected || selected.frame !== frame) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
}
return selected;
}
export async function verifySimulationCacheFrame(
manifestValue: unknown,
frame: number,
data: ArrayBuffer,
): Promise<SimulationCacheFrameIR> {
const selected = selectSimulationCacheFrame(manifestValue, frame);
if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`);
}
return selected;
}
export function simulationCacheKey(manifest: SimulationCacheManifestIR): string {
return `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`;
}

View File

@@ -169,6 +169,13 @@ export interface StorageSimulationCacheReadResult extends StorageSimulationCache
data: ArrayBuffer;
}
export interface StorageSimulationCacheFrameReadResult extends StorageSimulationCacheResult {
frame: number;
byteOffset: number;
byteLength: number;
data: ArrayBuffer;
}
export interface StorageSimulationCacheListResult {
projectId: string;
caches: Array<{
@@ -206,13 +213,14 @@ export interface StorageRequest {
| { type: "pruneLOD"; projectId: string; maxBytes: number }
| { type: "putSimulationCache"; projectId: string; manifest: SimulationCacheManifestIR; data: ArrayBuffer }
| { type: "readSimulationCache"; projectId: string; cacheKey: string }
| { type: "readSimulationCacheFrame"; projectId: string; cacheKey: string; frame: number }
| { type: "listSimulationCaches"; projectId: string };
}
export interface StorageResponse {
requestId: string;
ok: boolean;
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheListResult;
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCacheListResult;
error?: string;
errorCode?: ErrorCode;
}

View File

@@ -122,6 +122,7 @@ export type WebEngineEditCommand =
| { 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 } }
| { type: "setCameraProperties"; dataId: string; properties: { projection?: "PERSPECTIVE" | "ORTHOGRAPHIC"; lensMm?: number; sensorWidthMm?: number; sensorHeightMm?: number; sensorFit?: 0 | 1 | 2; shift?: [number, number]; near?: number; far?: number; orthoScale?: number; depthOfField?: { enabled?: boolean; focusDistance?: number; apertureFStop?: number; apertureBlades?: number; apertureRotation?: number; apertureRatio?: number } } }
| { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } }
| { type: "setMetaballElements"; dataId: string; elements: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }> }
| { type: "sculptStroke"; stroke: SculptStrokeIR }