feat(web): complete product workflow implementation
This commit is contained in:
@@ -11,6 +11,18 @@ export const animationRenderCommands = Object.freeze({
|
||||
deleteKeyframe(objectId: string, frame: number, property?: AnimationCommand<"deleteObjectKeyframe">["property"]): AnimationCommand<"deleteObjectKeyframe"> {
|
||||
return { type: "deleteObjectKeyframe", objectId, frame, ...(property ? { property } : {}) };
|
||||
},
|
||||
createAction(name: string, objectId?: string): AnimationCommand<"createAction"> {
|
||||
return { type: "createAction", name, ...(objectId ? { objectId } : {}) };
|
||||
},
|
||||
removeAction(actionId: string): AnimationCommand<"removeAction"> {
|
||||
return { type: "removeAction", actionId };
|
||||
},
|
||||
setKeyframe(animationId: string, path: string, frame: number, options: Omit<AnimationCommand<"setKeyframe">, "type" | "animationId" | "path" | "frame"> = {}): AnimationCommand<"setKeyframe"> {
|
||||
return { type: "setKeyframe", animationId, path, frame, ...options };
|
||||
},
|
||||
setTimelineMarkers(dataId: string, markers: AnimationCommand<"setTimelineMarkers">["markers"]): AnimationCommand<"setTimelineMarkers"> {
|
||||
return { type: "setTimelineMarkers", dataId, markers: markers.map((marker) => ({ ...marker })) };
|
||||
},
|
||||
setConstraint(objectId: string, constraintName: string, enabled: boolean, influence: number): AnimationCommand<"setConstraint"> {
|
||||
return { type: "setConstraint", objectId, constraintName, enabled, influence };
|
||||
},
|
||||
|
||||
36
web/protocol/blender-operator.ts
Normal file
36
web/protocol/blender-operator.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type BlenderOperatorPropertyType = "boolean" | "number" | "enum" | "string";
|
||||
|
||||
export type BlenderOperatorPropertyValue = string | number | boolean;
|
||||
|
||||
export interface BlenderOperatorEnumItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BlenderOperatorPropertySpec {
|
||||
name: string;
|
||||
type: BlenderOperatorPropertyType;
|
||||
required: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultValue?: BlenderOperatorPropertyValue;
|
||||
min?: number;
|
||||
max?: number;
|
||||
enumItems?: BlenderOperatorEnumItem[];
|
||||
}
|
||||
|
||||
export interface BlenderOperatorDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
family: string;
|
||||
context: "scene" | "object" | "main";
|
||||
backgroundSafe: true;
|
||||
properties: BlenderOperatorPropertySpec[];
|
||||
}
|
||||
|
||||
export interface BlenderOperatorInvocation {
|
||||
operator: string;
|
||||
properties?: Record<string, BlenderOperatorPropertyValue>;
|
||||
baseRevision?: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WebEngineEditCommand } from "./web-engine";
|
||||
import type { MeshPrimitive, WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
type CoreCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
|
||||
type Vector3 = [number, number, number];
|
||||
@@ -7,6 +7,9 @@ const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1]
|
||||
|
||||
/** Typed constructors used by the workbench for the source-backed scene API. */
|
||||
export const coreSceneCommands = Object.freeze({
|
||||
createPrimitive(primitive: MeshPrimitive, location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
|
||||
return { type: "createPrimitive", primitive, location: vector3(location), ...(name ? { name } : {}) };
|
||||
},
|
||||
createCube(location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
|
||||
return { type: "createPrimitive", primitive: "CUBE", location: vector3(location), ...(name ? { name } : {}) };
|
||||
},
|
||||
|
||||
@@ -1,8 +1,70 @@
|
||||
import type { GeometryNodeGraphIR } from "./geometry-nodes";
|
||||
import { GEOMETRY_NODE_GRAPH_SCHEMA, type GeometryNodeDataType, type GeometryNodeGraphIR, type GeometryNodeIR, type GeometryNodeLinkIR, type GeometryNodeSocketIR } from "./geometry-nodes";
|
||||
import type { WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const socket = (id: string, name: string, direction: "INPUT" | "OUTPUT", dataType: GeometryNodeDataType, defaultValue?: boolean | number | string | number[]): GeometryNodeSocketIR => ({ id, name, direction, dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
|
||||
|
||||
const nodeSockets = (type: string): GeometryNodeSocketIR[] => {
|
||||
if (type === "NodeGroupInput") return [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "NodeGroupOutput") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeTransform") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Translation", "Translation", "INPUT", "VECTOR", [0, 0, 0]), socket("Rotation", "Rotation", "INPUT", "VECTOR", [0, 0, 0]), socket("Scale", "Scale", "INPUT", "VECTOR", [1, 1, 1]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeSetPosition") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Offset", "Offset", "INPUT", "VECTOR", [0, 0, 0]), socket("Position", "Position", "INPUT", "VECTOR", [0, 0, 0]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeJoinGeometry") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeRealizeInstances") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "FunctionNodeInputInt") return [socket("Value", "Value", "OUTPUT", "INT", 0)];
|
||||
if (type === "FunctionNodeInputVector") return [socket("Vector", "Vector", "OUTPUT", "VECTOR", [0, 0, 0])];
|
||||
if (type === "ShaderNodeValue") return [socket("Value", "Value", "OUTPUT", "FLOAT", 0)];
|
||||
if (type === "ShaderNodeMath") return [socket("Value", "Value", "INPUT", "FLOAT", 0), socket("Value_001", "Value", "INPUT", "FLOAT", 0), socket("Value", "Value", "OUTPUT", "FLOAT")];
|
||||
return [];
|
||||
};
|
||||
|
||||
function uniqueId(base: string, ids: ReadonlySet<string>): string {
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (ids.has(id)) id = `${base}-${suffix++}`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Workbench constructors for validated Geometry Node graph transactions. */
|
||||
export const geometryNodeCommands = Object.freeze({
|
||||
createGraph(meshId: string, name = "Web Geometry") : GeometryNodeGraphIR {
|
||||
const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")] };
|
||||
const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")] };
|
||||
return { schemaVersion: GEOMETRY_NODE_GRAPH_SCHEMA, id: `geometry-graph:${meshId}`, name, interfaceInputs: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")], interfaceOutputs: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")], nodes: [input, output], links: [{ fromNodeId: input.id, fromSocketId: "Geometry", toNodeId: output.id, toSocketId: "Geometry" }] };
|
||||
},
|
||||
addNode(graph: GeometryNodeGraphIR, type: string, name = type): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
const ids = new Set(next.nodes.map((node) => node.id));
|
||||
const id = uniqueId(`node-${type.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`, ids);
|
||||
const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type) };
|
||||
next.nodes.push(node);
|
||||
return next;
|
||||
},
|
||||
removeNode(graph: GeometryNodeGraphIR, nodeId: string): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
|
||||
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
|
||||
return next;
|
||||
},
|
||||
connect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
|
||||
return next;
|
||||
},
|
||||
disconnect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
return next;
|
||||
},
|
||||
setSocketValue(graph: GeometryNodeGraphIR, nodeId: string, socketId: string, value: GeometryNodeSocketIR["defaultValue"]): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
|
||||
if (target) target.defaultValue = value;
|
||||
return next;
|
||||
},
|
||||
setGraph(meshId: string, graph: GeometryNodeGraphIR): Extract<WebEngineEditCommand, { type: "setGeometryNodeGraph" }> {
|
||||
return { type: "setGeometryNodeGraph", meshId, graph };
|
||||
},
|
||||
|
||||
@@ -51,6 +51,9 @@ const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
|
||||
ALEMBIC: [".abc"],
|
||||
};
|
||||
|
||||
/** Formats with a browser file boundary and a native WebEngine command route. */
|
||||
const LOCAL_SCENE_FORMATS: readonly IOFormatMatrixFormat[] = ["GLTF", "GLB", "OBJ", "STL", "PLY"];
|
||||
|
||||
function routeFor(
|
||||
matrix: IOFormatCapabilityMatrixIR,
|
||||
operation: IOFormatMatrixOperation,
|
||||
@@ -60,7 +63,8 @@ function routeFor(
|
||||
.filter((entry) => {
|
||||
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
|
||||
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
|
||||
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
|
||||
return route.status === "READY" && route.execution === execution &&
|
||||
(execution === "SERVER" || (runtimeStatus === "AVAILABLE" && LOCAL_SCENE_FORMATS.includes(entry.format)));
|
||||
})
|
||||
.map((entry) => ({
|
||||
format: entry.format,
|
||||
@@ -70,6 +74,15 @@ function routeFor(
|
||||
}));
|
||||
}
|
||||
|
||||
function browserSceneRoutes(operation: IOFormatMatrixOperation): IOFormatUIRouteIR[] {
|
||||
return LOCAL_SCENE_FORMATS.map((format) => ({
|
||||
format,
|
||||
operation,
|
||||
execution: "LOCAL" as const,
|
||||
extensions: [...FORMAT_EXTENSIONS[format]],
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
|
||||
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
|
||||
return {
|
||||
@@ -77,8 +90,8 @@ export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, pare
|
||||
task: IO_FORMAT_UI_TASK,
|
||||
parentMatrixSha256,
|
||||
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
|
||||
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
|
||||
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
|
||||
importRoutes: [...browserSceneRoutes("IMPORT"), ...routeFor(matrix, "IMPORT", "SERVER")],
|
||||
exportRoutes: [...browserSceneRoutes("EXPORT"), ...routeFor(matrix, "EXPORT", "SERVER")],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,9 +109,9 @@ function record(value: unknown, path: string): Record<string, unknown> {
|
||||
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
|
||||
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
|
||||
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || !["LOCAL", "SERVER"].includes(input.execution as string)) throw new IOFormatUIGateError(`${path} route identity is invalid`);
|
||||
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
|
||||
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
|
||||
return { format: input.format as IOFormatMatrixFormat, operation, execution: input.execution as "LOCAL" | "SERVER", extensions: [...input.extensions as string[]] };
|
||||
}
|
||||
|
||||
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
|
||||
@@ -135,11 +148,11 @@ export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUI
|
||||
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
|
||||
}
|
||||
|
||||
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
|
||||
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "READY"; kind: "SCENE"; route: IOFormatUIRouteIR } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
|
||||
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
|
||||
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
|
||||
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
|
||||
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
if (route) return { status: "READY", kind: "SCENE", route };
|
||||
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,32 @@ import type { WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
type IOCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
|
||||
|
||||
export type IOSceneFormat = "GLTF" | "GLB" | "OBJ" | "STL" | "PLY" | "USD" | "ALEMBIC";
|
||||
|
||||
export interface IOSceneImportCommand {
|
||||
type: "importScene";
|
||||
format: IOSceneFormat;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
export interface IOSceneExportCommand {
|
||||
type: "exportScene";
|
||||
format: IOSceneFormat;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export type IOSceneCommand = IOSceneImportCommand | IOSceneExportCommand;
|
||||
|
||||
/** Typed constructors for media import and persistence-facing edit commands. */
|
||||
export const ioMediaCommands = Object.freeze({
|
||||
importScene(format: IOSceneFormat, fileName: string, mimeType: string, base64: string): IOSceneImportCommand {
|
||||
return { type: "importScene", format, fileName, mimeType, base64 };
|
||||
},
|
||||
exportScene(format: IOSceneFormat, fileName: string): IOSceneExportCommand {
|
||||
return { type: "exportScene", format, fileName };
|
||||
},
|
||||
importImage(name: string, mimeType: IOCommand<"importImage">["mimeType"], width: number, height: number, base64: string): IOCommand<"importImage"> {
|
||||
return { type: "importImage", name, mimeType, width, height, base64 };
|
||||
},
|
||||
|
||||
@@ -9,6 +9,36 @@ function fnv1a(value: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export type ModifierParameterMetadata = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
kind: "boolean" | "number" | "string" | "enum" | "object";
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
/** Derives editable fields from the metadata carried by the authoritative modifier IR. */
|
||||
export function modifierParameterMetadata(modifier: ModifierIR): ModifierParameterMetadata[] {
|
||||
return Object.entries(modifier.parameters ?? {}).map(([key, rawValue]) => {
|
||||
// Main may carry a descriptor when RNA exposes enum choices alongside the
|
||||
// current value. Keep the descriptor opaque to callers while deriving the
|
||||
// editable value and choices from it.
|
||||
const descriptor = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) &&
|
||||
"value" in rawValue && ("kind" in rawValue || "options" in rawValue)
|
||||
? rawValue as { value: unknown; kind?: ModifierParameterMetadata["kind"]; options?: unknown[] }
|
||||
: undefined;
|
||||
const rawOptions = descriptor?.options ?? (Array.isArray(rawValue) && rawValue.every((entry) => typeof entry === "string") ? rawValue : undefined);
|
||||
const options = rawOptions?.map(String);
|
||||
const value = descriptor?.value ?? (options ? options[0] : rawValue);
|
||||
const kind = descriptor?.kind ?? (
|
||||
typeof value === "boolean" ? "boolean" :
|
||||
typeof value === "number" ? "number" :
|
||||
typeof value === "string" ? (options ? "enum" : "string") :
|
||||
"object"
|
||||
);
|
||||
return { key, value, kind, options: options ?? undefined };
|
||||
});
|
||||
}
|
||||
|
||||
/** Hashes evaluated modifier identity/state, excluding display timestamps. */
|
||||
export function modifierStackHash(stack: readonly ModifierIR[] | undefined): string {
|
||||
const canonical = (stack ?? []).map((modifier) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
|
||||
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR, SculptStrokeSampleIR } from "./sculpt";
|
||||
|
||||
export const PAINT_BUDGET = {
|
||||
maxSamples: 100_000,
|
||||
@@ -106,12 +106,30 @@ export function createSculptStroke(
|
||||
pressure: number,
|
||||
symmetry: [boolean, boolean, boolean],
|
||||
mirrorObjectSpace: boolean,
|
||||
cursorSamples: ReadonlyArray<SculptStrokeSampleIR> = [],
|
||||
): SculptStrokeIR {
|
||||
const samples = cursorSamples.length > 0
|
||||
? cursorSamples.slice(0, 2048).map((sample) => ({
|
||||
position: [...sample.position] as [number, number, number],
|
||||
normal: [...sample.normal] as [number, number, number],
|
||||
radius,
|
||||
strength,
|
||||
pressure,
|
||||
time: sample.time,
|
||||
}))
|
||||
: Array.from({ length: 3 }, (_, index) => ({
|
||||
position: [0, 0, index * 0.01] as [number, number, number],
|
||||
normal: [0, 0, 1] as [number, number, number],
|
||||
radius,
|
||||
strength,
|
||||
pressure,
|
||||
time: index,
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
meshId,
|
||||
brush,
|
||||
samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius, strength, pressure, time: 0 }],
|
||||
samples,
|
||||
symmetry,
|
||||
mirrorObjectSpace,
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SceneNodeIR {
|
||||
transform: SceneTransformIR;
|
||||
assetData?: AssetMetadataIR;
|
||||
constraints?: ConstraintIR[];
|
||||
drivers?: DriverIR[];
|
||||
lightgroup?: string | null;
|
||||
}
|
||||
|
||||
@@ -72,6 +73,39 @@ export interface ConstraintIR {
|
||||
targetObjectId?: string | null;
|
||||
}
|
||||
|
||||
export interface DriverVariableIR {
|
||||
name: string;
|
||||
type: number;
|
||||
targetCount: number;
|
||||
targets?: DriverTargetIR[];
|
||||
}
|
||||
|
||||
export interface DriverTargetIR {
|
||||
idName: string;
|
||||
dataPath: string;
|
||||
boneTarget: string;
|
||||
transformType: number;
|
||||
rotationMode: number;
|
||||
flags: number;
|
||||
options: number;
|
||||
idType: number;
|
||||
contextProperty: number;
|
||||
fallbackValue: number;
|
||||
}
|
||||
|
||||
export interface DriverIR {
|
||||
path: string;
|
||||
arrayIndex: number;
|
||||
editable: boolean;
|
||||
enabled: boolean;
|
||||
expression: string;
|
||||
type: string;
|
||||
typeCode: number;
|
||||
flags: number;
|
||||
influence: number;
|
||||
variables: DriverVariableIR[];
|
||||
}
|
||||
|
||||
export interface ModifierIR {
|
||||
uuid: string;
|
||||
type: string;
|
||||
@@ -101,6 +135,8 @@ export interface ArmatureBoneIR {
|
||||
parentId: string | null;
|
||||
head: [number, number, number];
|
||||
tail: [number, number, number];
|
||||
roll?: number;
|
||||
connected?: boolean;
|
||||
restMatrix: number[];
|
||||
poseMatrix?: number[];
|
||||
}
|
||||
@@ -444,6 +480,12 @@ export interface SpeakerResourceIR {
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
export interface TextLineIR {
|
||||
index: number;
|
||||
body: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface TextResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -451,6 +493,7 @@ export interface TextResourceIR {
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
lineCount: number;
|
||||
lines?: TextLineIR[];
|
||||
internal: boolean;
|
||||
sourcePath?: string;
|
||||
isDirty: boolean;
|
||||
@@ -530,7 +573,9 @@ export interface AnimationIR {
|
||||
targetId: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
|
||||
slots?: Array<{ identifier: string; idtype: number; handle: number; flags: number }>;
|
||||
strips?: Array<{ name: string; type: "KEYFRAME"; frameStart: number; frameEnd: number; dataIndex: number }>;
|
||||
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; keyframeSource?: "BEZIER" | "SAMPLED"; modifiers?: Array<{ name: string; type: string; typeCode: number; flags: number; expanded: boolean; influence: number; frameStart: number; frameEnd: number; blendIn: number; blendOut: number }>; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; handleLeft?: [number, number]; handleRight?: [number, number]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
|
||||
}
|
||||
|
||||
export interface CollectionIR {
|
||||
@@ -590,6 +635,7 @@ export interface SceneIR {
|
||||
previewStart: number;
|
||||
previewEnd: number;
|
||||
};
|
||||
timelineMarkers?: Array<{ frame: number; name: string }>;
|
||||
pipelineSettings?: {
|
||||
useCompositor: boolean;
|
||||
useSequencer: boolean;
|
||||
@@ -654,7 +700,7 @@ export interface SceneSnapshotIR {
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
renderRoutingStatus?: "AVAILABLE" | "BLOCKED";
|
||||
renderRoutingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
renderRouting?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:render_capability_routing";
|
||||
@@ -665,7 +711,7 @@ export interface SceneSnapshotIR {
|
||||
recovery: { cancellation: "OPEN_CANCELLED"; deviceLoss: "RELEASED"; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED";
|
||||
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
serverJobBinding?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:server_job_result_binding";
|
||||
@@ -677,7 +723,7 @@ export interface SceneSnapshotIR {
|
||||
lifecycle: { duplicate: "REUSED"; stale: "BLOCKED"; cancel: "CANCELLED"; timeout: "TIMEOUT"; oom: "OOM"; publishedOnFailure: false; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
scriptingPolicy?: {
|
||||
schemaVersion: 1;
|
||||
policyId: "workflow:scripting_default_deny_audit";
|
||||
@@ -697,7 +743,7 @@ export interface SceneSnapshotIR {
|
||||
policyHash: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED";
|
||||
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
performanceFaultMatrix?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:performance_fault_recovery_matrix";
|
||||
@@ -708,7 +754,7 @@ export interface SceneSnapshotIR {
|
||||
matrixHash: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED";
|
||||
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
releaseCandidate?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:offline_ci_sbom_and_rollback_release";
|
||||
@@ -720,7 +766,7 @@ export interface SceneSnapshotIR {
|
||||
rollback: { runbookSha256: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
gapInventoryStatus?: "AVAILABLE" | "BLOCKED";
|
||||
gapInventoryStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
gapInventory?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:cluster_full_blender_gap_inventory";
|
||||
@@ -751,7 +797,7 @@ export interface SceneSnapshotIR {
|
||||
nextTask: "WBV2-P7-002";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED";
|
||||
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
atomicGapQueue?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:generate_atomic_family_gap_queue";
|
||||
@@ -762,7 +808,7 @@ export interface SceneSnapshotIR {
|
||||
nextTask: "WBV2-P7-003";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED";
|
||||
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
parityReleaseGate?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:full_parity_candidate_release_gate";
|
||||
@@ -1072,6 +1118,16 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
|
||||
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
|
||||
if (text.lines !== undefined) {
|
||||
if (!Array.isArray(text.lines) || text.lines.length !== lineCount) throw new Error(`SceneIR.texts[${index}].lines is invalid`);
|
||||
text.lines.forEach((line, lineIndex) => {
|
||||
if (!isRecord(line)) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}] must be an object`);
|
||||
if (line.index !== lineIndex) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].index is invalid`);
|
||||
const body = requireString(line.body, `texts[${index}].lines[${lineIndex}].body`);
|
||||
const lineByteLength = requireNumber(line.byteLength, `texts[${index}].lines[${lineIndex}].byteLength`);
|
||||
if (!Number.isSafeInteger(lineByteLength) || lineByteLength !== new TextEncoder().encode(body).byteLength) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].byteLength is invalid`);
|
||||
});
|
||||
}
|
||||
requireBoolean(text.internal, `texts[${index}].internal`);
|
||||
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
|
||||
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
|
||||
@@ -1153,6 +1209,34 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireTuple(node.transform.rotationEuler, 3, `nodes[${index}].transform.rotationEuler`);
|
||||
requireTuple(node.transform.scale, 3, `nodes[${index}].transform.scale`);
|
||||
requireNumber(node.transform.rotationMode, `nodes[${index}].transform.rotationMode`);
|
||||
if (node.drivers !== undefined) {
|
||||
const drivers = requireArray(node.drivers, `nodes[${index}].drivers`);
|
||||
if (drivers.length > 256) throw new Error(`SceneIR.nodes[${index}].drivers exceeds the budget`);
|
||||
for (const [driverIndex, driver] of drivers.entries()) {
|
||||
if (!isRecord(driver)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}] must be an object`);
|
||||
for (const field of ["path", "expression", "type"] as const) requireString(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
|
||||
for (const field of ["arrayIndex", "typeCode", "flags", "influence"] as const) requireNumber(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
|
||||
requireBoolean(driver.editable, `nodes[${index}].drivers[${driverIndex}].editable`);
|
||||
requireBoolean(driver.enabled, `nodes[${index}].drivers[${driverIndex}].enabled`);
|
||||
const variables = requireArray(driver.variables, `nodes[${index}].drivers[${driverIndex}].variables`);
|
||||
if (variables.length > 64) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables exceeds the budget`);
|
||||
for (const [variableIndex, variable] of variables.entries()) {
|
||||
if (!isRecord(variable)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}] must be an object`);
|
||||
requireString(variable.name, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].name`);
|
||||
requireNumber(variable.type, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].type`);
|
||||
requireNumber(variable.targetCount, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targetCount`);
|
||||
if (variable.targets !== undefined) {
|
||||
const targets = requireArray(variable.targets, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets`);
|
||||
if (targets.length !== variable.targetCount) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets count mismatch`);
|
||||
for (const [targetIndex, target] of targets.entries()) {
|
||||
if (!isRecord(target)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}] must be an object`);
|
||||
for (const field of ["idName", "dataPath", "boneTarget"] as const) requireString(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
|
||||
for (const field of ["transformType", "rotationMode", "flags", "options", "idType", "contextProperty", "fallbackValue"] as const) requireNumber(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, mesh] of meshes.entries()) {
|
||||
if (!isRecord(mesh)) throw new Error(`SceneIR.meshes[${index}] must be an object`);
|
||||
@@ -1287,6 +1371,8 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
if (bone.parentId !== null) requireString(bone.parentId, `armatures[${index}].bones[${boneIndex}].parentId`);
|
||||
requireTuple(bone.head, 3, `armatures[${index}].bones[${boneIndex}].head`);
|
||||
requireTuple(bone.tail, 3, `armatures[${index}].bones[${boneIndex}].tail`);
|
||||
if (bone.roll !== undefined) requireNumber(bone.roll, `armatures[${index}].bones[${boneIndex}].roll`);
|
||||
if (bone.connected !== undefined) requireBoolean(bone.connected, `armatures[${index}].bones[${boneIndex}].connected`);
|
||||
requireTuple(bone.restMatrix, 16, `armatures[${index}].bones[${boneIndex}].restMatrix`);
|
||||
if (bone.poseMatrix !== undefined) requireTuple(bone.poseMatrix, 16, `armatures[${index}].bones[${boneIndex}].poseMatrix`);
|
||||
}
|
||||
@@ -1624,6 +1710,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
for (const field of ["fps", "fpsBase", "frameStep", "previewStart", "previewEnd"] as const) requireNumber(scene.timelineSettings[field], `scenes[${index}].timelineSettings.${field}`);
|
||||
requireBoolean(scene.timelineSettings.usePreviewRange, `scenes[${index}].timelineSettings.usePreviewRange`);
|
||||
}
|
||||
if (scene.timelineMarkers !== undefined) {
|
||||
const markers = requireArray(scene.timelineMarkers, `scenes[${index}].timelineMarkers`);
|
||||
for (const [markerIndex, marker] of markers.entries()) {
|
||||
if (!isRecord(marker)) throw new Error(`SceneIR.scenes[${index}].timelineMarkers[${markerIndex}] must be an object`);
|
||||
requireNumber(marker.frame, `scenes[${index}].timelineMarkers[${markerIndex}].frame`);
|
||||
requireString(marker.name, `scenes[${index}].timelineMarkers[${markerIndex}].name`);
|
||||
}
|
||||
}
|
||||
if (scene.pipelineSettings !== undefined) {
|
||||
if (!isRecord(scene.pipelineSettings)) throw new Error(`SceneIR.scenes[${index}].pipelineSettings must be an object`);
|
||||
for (const field of ["useCompositor", "useSequencer", "noOverwrite", "saveOutput", "alphaPremultiplied"] as const) requireBoolean(scene.pipelineSettings[field], `scenes[${index}].pipelineSettings.${field}`);
|
||||
@@ -1719,10 +1813,45 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireString(animation.targetId, `animations[${index}].targetId`);
|
||||
requireNumber(animation.frameStart, `animations[${index}].frameStart`);
|
||||
requireNumber(animation.frameEnd, `animations[${index}].frameEnd`);
|
||||
if (animation.slots !== undefined) {
|
||||
const slots = requireArray(animation.slots, `animations[${index}].slots`);
|
||||
if (slots.length > 1024) throw new Error(`animations[${index}].slots exceeds the budget`);
|
||||
for (const [slotIndex, slot] of slots.entries()) {
|
||||
if (!isRecord(slot)) throw new Error(`animations[${index}].slots[${slotIndex}] must be an object`);
|
||||
requireString(slot.identifier, `animations[${index}].slots[${slotIndex}].identifier`);
|
||||
for (const field of ["idtype", "handle", "flags"]) requireNumber(slot[field], `animations[${index}].slots[${slotIndex}].${field}`);
|
||||
}
|
||||
}
|
||||
if (animation.strips !== undefined) {
|
||||
const strips = requireArray(animation.strips, `animations[${index}].strips`);
|
||||
if (strips.length > 1024) throw new Error(`animations[${index}].strips exceeds the budget`);
|
||||
for (const [stripIndex, strip] of strips.entries()) {
|
||||
if (!isRecord(strip)) throw new Error(`animations[${index}].strips[${stripIndex}] must be an object`);
|
||||
requireString(strip.name, `animations[${index}].strips[${stripIndex}].name`);
|
||||
if (strip.type !== "KEYFRAME") throw new Error(`animations[${index}].strips[${stripIndex}].type is invalid`);
|
||||
requireNumber(strip.frameStart, `animations[${index}].strips[${stripIndex}].frameStart`);
|
||||
requireNumber(strip.frameEnd, `animations[${index}].strips[${stripIndex}].frameEnd`);
|
||||
requireNumber(strip.dataIndex, `animations[${index}].strips[${stripIndex}].dataIndex`);
|
||||
}
|
||||
}
|
||||
const channels = requireArray(animation.channels, `animations[${index}].channels`);
|
||||
for (const [channelIndex, channel] of channels.entries()) {
|
||||
if (!isRecord(channel)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}] must be an object`);
|
||||
requireString(channel.path, `animations[${index}].channels[${channelIndex}].path`);
|
||||
if (channel.keyframeSource !== undefined && channel.keyframeSource !== "BEZIER" && channel.keyframeSource !== "SAMPLED") {
|
||||
throw new Error(`animations[${index}].channels[${channelIndex}].keyframeSource is invalid`);
|
||||
}
|
||||
if (channel.modifiers !== undefined) {
|
||||
const modifiers = requireArray(channel.modifiers, `animations[${index}].channels[${channelIndex}].modifiers`);
|
||||
if (modifiers.length > 64) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers exceeds the budget`);
|
||||
for (const [modifierIndex, modifier] of modifiers.entries()) {
|
||||
if (!isRecord(modifier)) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}] must be an object`);
|
||||
requireString(modifier.name, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].name`);
|
||||
requireString(modifier.type, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].type`);
|
||||
for (const field of ["typeCode", "flags", "influence", "frameStart", "frameEnd", "blendIn", "blendOut"]) requireNumber(modifier[field], `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].${field}`);
|
||||
if (typeof modifier.expanded !== "boolean") throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].expanded is invalid`);
|
||||
}
|
||||
}
|
||||
const keyframes = requireArray(channel.keyframes, `animations[${index}].channels[${channelIndex}].keyframes`);
|
||||
for (const [keyframeIndex, keyframe] of keyframes.entries()) {
|
||||
if (!isRecord(keyframe)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}] must be an object`);
|
||||
@@ -1809,7 +1938,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.renderRoutingStatus as string)) {
|
||||
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.renderRoutingStatus as string)) {
|
||||
throw new Error("SceneIR.renderRoutingStatus is invalid");
|
||||
}
|
||||
if (value.renderRouting !== undefined) {
|
||||
@@ -1825,7 +1954,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.renderRoutingStatus !== "AVAILABLE") throw new Error("SceneIR.renderRoutingStatus must be AVAILABLE when renderRouting is present");
|
||||
}
|
||||
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.serverJobBindingStatus as string)) {
|
||||
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.serverJobBindingStatus as string)) {
|
||||
throw new Error("SceneIR.serverJobBindingStatus is invalid");
|
||||
}
|
||||
if (value.serverJobBinding !== undefined) {
|
||||
@@ -1843,7 +1972,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.serverJobBindingStatus !== "AVAILABLE") throw new Error("SceneIR.serverJobBindingStatus must be AVAILABLE when serverJobBinding is present");
|
||||
}
|
||||
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptingPolicyStatus as string)) {
|
||||
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.scriptingPolicyStatus as string)) {
|
||||
throw new Error("SceneIR.scriptingPolicyStatus is invalid");
|
||||
}
|
||||
if (value.scriptingPolicy !== undefined) {
|
||||
@@ -1871,7 +2000,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.scriptingPolicyStatus !== "AVAILABLE") throw new Error("SceneIR.scriptingPolicyStatus must be AVAILABLE when scriptingPolicy is present");
|
||||
}
|
||||
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.performanceFaultMatrixStatus as string)) {
|
||||
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.performanceFaultMatrixStatus as string)) {
|
||||
throw new Error("SceneIR.performanceFaultMatrixStatus is invalid");
|
||||
}
|
||||
if (value.performanceFaultMatrix !== undefined) {
|
||||
@@ -1892,7 +2021,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.performanceFaultMatrixStatus !== "AVAILABLE") throw new Error("SceneIR.performanceFaultMatrixStatus must be AVAILABLE when performanceFaultMatrix is present");
|
||||
}
|
||||
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.releaseCandidateStatus as string)) {
|
||||
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.releaseCandidateStatus as string)) {
|
||||
throw new Error("SceneIR.releaseCandidateStatus is invalid");
|
||||
}
|
||||
if (value.releaseCandidate !== undefined) {
|
||||
@@ -1917,7 +2046,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.releaseCandidateStatus !== "AVAILABLE") throw new Error("SceneIR.releaseCandidateStatus must be AVAILABLE when releaseCandidate is present");
|
||||
}
|
||||
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.gapInventoryStatus as string)) {
|
||||
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.gapInventoryStatus as string)) {
|
||||
throw new Error("SceneIR.gapInventoryStatus is invalid");
|
||||
}
|
||||
if (value.gapInventory !== undefined) {
|
||||
@@ -1953,7 +2082,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.gapInventoryStatus !== "AVAILABLE") throw new Error("SceneIR.gapInventoryStatus must be AVAILABLE when gapInventory is present");
|
||||
}
|
||||
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.atomicGapQueueStatus as string)) {
|
||||
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.atomicGapQueueStatus as string)) {
|
||||
throw new Error("SceneIR.atomicGapQueueStatus is invalid");
|
||||
}
|
||||
if (value.atomicGapQueue !== undefined) {
|
||||
@@ -1988,7 +2117,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
throw new Error("SceneIR.atomicGapQueueStatus must be AVAILABLE for a valid queue");
|
||||
}
|
||||
}
|
||||
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.parityReleaseGateStatus as string)) {
|
||||
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.parityReleaseGateStatus as string)) {
|
||||
throw new Error("SceneIR.parityReleaseGateStatus is invalid");
|
||||
}
|
||||
if (value.parityReleaseGate !== undefined) {
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import type { ShaderGraphIR, ShaderSocketIR } from "./shader-graph";
|
||||
import { SHADER_GRAPH_SCHEMA, type ShaderGraphIR, type ShaderLinkIR, type ShaderNodeIR, type ShaderNodeType, type ShaderSocketIR } from "./shader-graph";
|
||||
|
||||
const input = (id: string, name: string, dataType: ShaderSocketIR["dataType"], defaultValue?: ShaderSocketIR["defaultValue"]): ShaderSocketIR => ({ id, name, direction: "INPUT", dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
|
||||
const output = (id: string, name: string, dataType: ShaderSocketIR["dataType"]): ShaderSocketIR => ({ id, name, direction: "OUTPUT", dataType });
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const nodeSockets = (type: ShaderNodeType): ShaderSocketIR[] => {
|
||||
if (type === "RGB") return [output("Color", "Color", "COLOR")];
|
||||
if (type === "VALUE") return [output("Value", "Value", "VALUE")];
|
||||
if (type === "MATH") return [input("Value", "Value", "VALUE", 0), input("Value_001", "Value", "VALUE", 0), output("Value", "Value", "VALUE")];
|
||||
if (type === "PRINCIPLED") return [input("Base Color", "Base Color", "COLOR", [0.8, 0.8, 0.8, 1]), input("Metallic", "Metallic", "VALUE", 0), input("Roughness", "Roughness", "VALUE", 0.4), output("BSDF", "BSDF", "SHADER")];
|
||||
if (type === "MATERIAL_OUTPUT") return [input("Surface", "Surface", "SHADER")];
|
||||
if (type === "IMAGE_TEXTURE") return [input("Vector", "Vector", "VECTOR", [0, 0, 0]), output("Color", "Color", "COLOR")];
|
||||
return [];
|
||||
};
|
||||
|
||||
function uniqueId(base: string, ids: ReadonlySet<string>): string {
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (ids.has(id)) id = `${base}-${suffix++}`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Build a valid bounded graph for the source-backed Principled writer. */
|
||||
export const shaderGraphCommands = Object.freeze({
|
||||
addNode(graph: ShaderGraphIR, type: ShaderNodeType, name = type): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
const id = uniqueId(`node-${type.toLowerCase()}`, new Set(next.nodes.map((node) => node.id)));
|
||||
const node: ShaderNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type), ...(type === "MATH" ? { properties: { operation: "ADD" } } : {}) };
|
||||
next.nodes.push(node);
|
||||
if (type === "MATERIAL_OUTPUT") next.outputNodeId = id;
|
||||
return next;
|
||||
},
|
||||
removeNode(graph: ShaderGraphIR, nodeId: string): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
|
||||
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
|
||||
if (next.outputNodeId === nodeId) next.outputNodeId = next.nodes.find((node) => node.type === "MATERIAL_OUTPUT")?.id;
|
||||
return next;
|
||||
},
|
||||
connect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
|
||||
return next;
|
||||
},
|
||||
disconnect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
return next;
|
||||
},
|
||||
setSocketValue(graph: ShaderGraphIR, nodeId: string, socketId: string, value: ShaderSocketIR["defaultValue"]): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
|
||||
if (target) target.defaultValue = value;
|
||||
return next;
|
||||
},
|
||||
setPrincipledGraph(materialId: string, baseColor: readonly number[], roughness: number, metallic: number): ShaderGraphIR {
|
||||
const principled = "shader:principled";
|
||||
const materialOutput = "shader:material-output";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: SHADER_GRAPH_SCHEMA,
|
||||
id: `shader-graph:${materialId}`,
|
||||
materialId,
|
||||
nodes: [
|
||||
|
||||
@@ -4,6 +4,13 @@ export const WEB_ENGINE_CAPABILITY_REGISTRY_SCHEMA = 1 as const;
|
||||
export type WebEngineCommandType = WebEngineEditCommand["type"];
|
||||
export type WebEngineCommandRoute = "MAIN_AUTHORITATIVE" | "MAIN_GUARDED" | "LOCAL_HISTORY" | "PREVIEW";
|
||||
|
||||
export type WebEngineOperatorCommand = "listBlenderOperators" | "invokeBlenderOperator";
|
||||
|
||||
export interface WebEngineOperatorCapability {
|
||||
readonly route: "MAIN_READ" | "MAIN_AUTHORITATIVE";
|
||||
readonly implementation: "SOURCE_BACKED";
|
||||
}
|
||||
|
||||
export interface WebEngineCommandCapability {
|
||||
readonly family: string;
|
||||
readonly route: WebEngineCommandRoute;
|
||||
@@ -117,6 +124,12 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
|
||||
} satisfies Record<WebEngineCommandType, WebEngineCommandCapability>;
|
||||
|
||||
/** Native operators are deliberately a separate registry from edit commands. */
|
||||
export const WEB_ENGINE_OPERATOR_CAPABILITIES = {
|
||||
listBlenderOperators: { route: "MAIN_READ", implementation: "SOURCE_BACKED" },
|
||||
invokeBlenderOperator: { route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
} satisfies Record<WebEngineOperatorCommand, WebEngineOperatorCapability>;
|
||||
|
||||
export function getWebEngineCommandCapability(type: unknown): WebEngineCommandCapability | null {
|
||||
if (typeof type !== "string") return null;
|
||||
return Object.prototype.hasOwnProperty.call(WEB_ENGINE_COMMAND_CAPABILITIES, type)
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
} from "./paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append";
|
||||
import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorInvocation, BlenderOperatorPropertyValue } from "./blender-operator";
|
||||
import type { SequencerTimelineIR } from "./sequencer";
|
||||
|
||||
export interface MeshGeometryBuffer {
|
||||
schemaVersion: 1;
|
||||
@@ -75,20 +77,28 @@ export interface MeshGeometryDelta {
|
||||
}
|
||||
|
||||
export type MeshElementMode = "VERT" | "EDGE" | "FACE";
|
||||
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT";
|
||||
export type MeshPrimitive = "CUBE" | "PLANE" | "UV_SPHERE" | "ICO_SPHERE" | "CYLINDER" | "CONE" | "TORUS";
|
||||
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT" | "SUBDIVIDE" | "FILL" | "TRIANGULATE" | "RECALCULATE_NORMALS" | "BRIDGE_LOOPS" | "DELETE";
|
||||
export type FCurveInterpolation = "CONSTANT" | "LINEAR" | "BEZIER";
|
||||
export type KeyframeHandle = [number, number];
|
||||
|
||||
export type WebEngineEditCommand =
|
||||
| { type: "setFrame"; frame: number }
|
||||
| { type: "setObjectVisibility"; objectId: string; visible: boolean }
|
||||
| { type: "setModifierEnabled"; meshId: string; modifierUuid: string; enabled: boolean }
|
||||
| { type: "setModifierVisibility"; meshId: string; modifierUuid: string; showViewport: boolean; showRender: boolean; showEditMode: boolean; showOnCage: boolean }
|
||||
| { type: "addModifier"; meshId: string; modifierType: string; name?: string }
|
||||
| { type: "removeModifier"; meshId: string; modifierUuid: string }
|
||||
| { type: "moveModifier"; meshId: string; modifierUuid: string; direction?: "UP" | "DOWN"; index?: number }
|
||||
| { type: "setModifierParameters"; meshId: string; modifierUuid: string; parameters: Record<string, unknown> }
|
||||
| { type: "decimateMesh"; meshId: string; profile: SimplifyProfile }
|
||||
| { type: "previewDecimateMesh"; meshId: string; profile: SimplifyProfile }
|
||||
| { type: "createPrimitive"; primitive: "CUBE" | "PLANE"; name?: string; location: [number, number, number] }
|
||||
| { type: "createPrimitive"; primitive: MeshPrimitive; name?: string; location: [number, number, number] }
|
||||
| { type: "duplicateObject"; objectId: string; offset?: [number, number, number]; linked?: boolean }
|
||||
| { type: "deleteObject"; objectId: string }
|
||||
| { type: "setObjectTransform"; objectId: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
|
||||
| { type: "setArmatureBones"; objectId: string; bones: Array<{ name: string; parentName: string | null; head: [number, number, number]; tail: [number, number, number]; roll: number; connected?: boolean }> }
|
||||
| { type: "setPoseBoneTransform"; objectId: string; boneName: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
|
||||
| { type: "translateMeshVertices"; meshId: string; vertexIndices: number[]; offset: [number, number, number] }
|
||||
| { type: "meshEdit"; meshId: string; operation: MeshEditOperation; selectionMode: MeshElementMode; elementIndices: number[]; offset?: [number, number, number]; amount?: number; segments?: number }
|
||||
| { type: "createUVMap"; meshId: string; name: string }
|
||||
@@ -102,6 +112,10 @@ export type WebEngineEditCommand =
|
||||
| { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "NORMAL"; uvMap?: string }
|
||||
| { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation }
|
||||
| { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" }
|
||||
| { type: "createAction"; name: string; objectId?: string }
|
||||
| { type: "removeAction"; actionId: string }
|
||||
| { type: "setKeyframe"; animationId: string; path: string; frame: number; value?: number[]; handleLeft?: KeyframeHandle; handleRight?: KeyframeHandle; interpolation?: FCurveInterpolation }
|
||||
| { type: "setTimelineMarkers"; dataId: string; markers: Array<{ frame: number; name: string }> }
|
||||
| { type: "setFCurveInterpolation"; animationId: string; path: string; interpolation: FCurveInterpolation }
|
||||
| { type: "setActiveAction"; objectId: string; actionId: string | null }
|
||||
| { type: "setConstraint"; objectId: string; constraintName: string; enabled: boolean; influence: number }
|
||||
@@ -149,6 +163,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "setSceneUnitSettings"; dataId: string; properties: { system?: number; scaleLength?: number; rotationSystem?: number; lengthUnit?: number; massUnit?: number; timeUnit?: number; temperatureUnit?: number } }
|
||||
| { type: "setSceneTimelineSettings"; dataId: string; properties: { fps?: number; fpsBase?: number; frameStep?: number; usePreviewRange?: boolean; previewStart?: number; previewEnd?: number } }
|
||||
| { type: "setScenePipelineSettings"; dataId: string; properties: { useCompositor?: boolean; useSequencer?: boolean; noOverwrite?: boolean; saveOutput?: boolean; alphaPremultiplied?: boolean } }
|
||||
| { type: "setSequencerStrips"; sceneId: string; timeline: SequencerTimelineIR }
|
||||
| { type: "setSceneBindings"; dataId: string; properties: { cameraObjectId?: string | null; worldId?: string | null } }
|
||||
| { type: "setSceneViewLayerSettings"; dataId: string; layerName: string; properties: { useRender?: boolean; useFreestyle?: boolean; samples?: number } }
|
||||
| { type: "setSceneCollectionSettings"; dataId: string; layerName: string; collectionId: string; properties: { exclude?: boolean; holdout?: boolean; indirectOnly?: boolean; hide?: boolean } }
|
||||
@@ -180,13 +195,33 @@ export type WebEngineEditCommand =
|
||||
| { type: "undo" }
|
||||
| { type: "redo" };
|
||||
|
||||
/** JSON values accepted by the allowlisted native operator bridge. */
|
||||
export type WebEngineOperatorProperty = BlenderOperatorPropertyValue | null | WebEngineOperatorProperty[] | { [key: string]: WebEngineOperatorProperty };
|
||||
export type WebEngineOperatorPropertySpec = BlenderOperatorPropertySpec;
|
||||
export type WebEngineOperatorDescriptor = BlenderOperatorDescriptor;
|
||||
export type WebEngineOperatorInvocation = BlenderOperatorInvocation;
|
||||
|
||||
/** Read-only commands supported by the stable Worker-to-WASM JSON facade. */
|
||||
export type WebEngineJsonCommand =
|
||||
| "snapshot"
|
||||
| "delta"
|
||||
| "depsgraph"
|
||||
| "memoryStats"
|
||||
| "applyCommand"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "listBlenderOperators"
|
||||
| "invokeBlenderOperator";
|
||||
|
||||
export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "init" } }
|
||||
| { requestId: string; command: { type: "loadWasmModule"; id: string } }
|
||||
| { requestId: string; command: { type: "crashForTest" } }
|
||||
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
|
||||
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
|
||||
| { requestId: string; command: { type: "openResourceStatus" } }
|
||||
| { requestId: string; command: { type: "snapshot" } }
|
||||
| { requestId: string; command: { type: "jsonCommand"; cmd: WebEngineJsonCommand; params?: Record<string, unknown> } }
|
||||
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
|
||||
| { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } }
|
||||
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
|
||||
@@ -262,12 +297,14 @@ export interface WebEngineResult {
|
||||
lod?: WebEngineLODResult;
|
||||
asset?: AssetRequestResult;
|
||||
capabilityGate?: CapabilityGateResult;
|
||||
json?: unknown;
|
||||
shaderCompile?: ShaderCompileReport;
|
||||
depsgraph?: DepsgraphEvaluationIR;
|
||||
blend?: ArrayBuffer;
|
||||
openResources?: WebEngineOpenResourceStatus;
|
||||
paintStrokeSession?: PaintStrokeSessionReceiptIR;
|
||||
libraryAppend?: LibraryMainAppendReceiptIR;
|
||||
wasmModules?: Array<{ id: string; lifecycle: "complete" | "debug"; status: "pending" | "loaded" | "failed"; byteLength: number; error?: string }>;
|
||||
}
|
||||
|
||||
export type WebEngineResponse =
|
||||
|
||||
Reference in New Issue
Block a user