feat(web): complete portable Blender feature gaps
This commit is contained in:
@@ -10,6 +10,7 @@ import { evaluateSkinSimplifyGate } from "../../../protocol/skin";
|
||||
import { evaluateMeshModifierStack } from "../../../protocol/modifier-graph";
|
||||
import { parseDepsgraphEvaluation } from "../../../protocol/depsgraph";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { parseAssetLibraryCommand } from "../../../protocol/asset-library-io";
|
||||
import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
|
||||
import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt";
|
||||
import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes";
|
||||
@@ -283,6 +284,52 @@ function sculptMeshGate(meshId: string): CapabilityGateResult {
|
||||
}
|
||||
|
||||
function assertFutureCapability(payload: Extract<WebEngineRequest["command"], { type: "applyCommand" }>["payload"]): void {
|
||||
if (payload.type === "renderFrame") {
|
||||
if (typeof payload.dataId !== "string" || payload.dataId.length === 0 || (payload.frame !== undefined && (!Number.isSafeInteger(payload.frame) || payload.frame < -1000000 || payload.frame > 1000000))) throw report("RENDER_PROPERTY_INVALID", "renderFrame target or frame is invalid");
|
||||
return;
|
||||
}
|
||||
if (payload.type === "renderAnimation") {
|
||||
if (typeof payload.dataId !== "string" || payload.dataId.length === 0 || (payload.frameStart !== undefined && !Number.isSafeInteger(payload.frameStart)) || (payload.frameEnd !== undefined && !Number.isSafeInteger(payload.frameEnd)) || (payload.frameStart !== undefined && payload.frameEnd !== undefined && payload.frameStart > payload.frameEnd) || (payload.step !== undefined && (!Number.isSafeInteger(payload.step) || payload.step < 1 || payload.step > 1000))) throw report("RENDER_PROPERTY_INVALID", "renderAnimation range is invalid");
|
||||
return;
|
||||
}
|
||||
if (payload.type === "setRenderPasses") {
|
||||
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!scene || typeof payload.layerName !== "string" || payload.layerName.length === 0 || !validPropertyKeys(payload.passes, ["passMask", "cryptomatteMask", "cryptomatteLevels", "alphaThreshold"])) throw report("RENDER_PROPERTY_INVALID", "setRenderPasses target or passes are invalid");
|
||||
for (const field of ["passMask", "cryptomatteMask"] as const) if (payload.passes[field] !== undefined && (!Number.isSafeInteger(payload.passes[field]) || payload.passes[field] < 0 || payload.passes[field] > 0x7fffffff)) throw report("RENDER_PROPERTY_INVALID", `setRenderPasses ${field} is outside the bounded range`);
|
||||
if (payload.passes.cryptomatteLevels !== undefined && (!Number.isSafeInteger(payload.passes.cryptomatteLevels) || payload.passes.cryptomatteLevels < 1 || payload.passes.cryptomatteLevels > 16)) throw report("RENDER_PROPERTY_INVALID", "setRenderPasses cryptomatteLevels is invalid");
|
||||
if (payload.passes.alphaThreshold !== undefined && !boundedNumber(payload.passes.alphaThreshold, 0, 1)) throw report("RENDER_PROPERTY_INVALID", "setRenderPasses alphaThreshold is invalid");
|
||||
return;
|
||||
}
|
||||
if (payload.type === "setCameraView") {
|
||||
if (!currentSnapshot?.scenes.some((scene) => scene.id === payload.dataId) || (payload.cameraObjectId !== null && (typeof payload.cameraObjectId !== "string" || !currentSnapshot?.nodes.some((node) => node.id === payload.cameraObjectId && node.type === "CAMERA")))) throw report("RENDER_PROPERTY_INVALID", "setCameraView target is invalid");
|
||||
return;
|
||||
}
|
||||
if (payload.type === "setLightLinking") {
|
||||
if (typeof payload.dataId !== "string" || payload.dataId.length === 0 || (payload.target !== undefined && payload.target !== "OBJECT" && payload.target !== "WORLD") || (payload.lightgroup !== null && (typeof payload.lightgroup !== "string" || payload.lightgroup.length === 0 || new TextEncoder().encode(payload.lightgroup).byteLength > 63))) throw report("RENDER_PROPERTY_INVALID", "setLightLinking target is invalid");
|
||||
return;
|
||||
}
|
||||
if (payload.type === "meshSubdivide" || payload.type === "meshTriangulate" ||
|
||||
payload.type === "meshRecalculateNormals" || payload.type === "meshBridgeLoops" ||
|
||||
payload.type === "meshDeleteElements" || payload.type === "meshUVProject") {
|
||||
if (typeof payload.meshId !== "string" || payload.meshId.length === 0) {
|
||||
throw report("INVALID_ARGUMENT", `${payload.type} requires meshId`, true);
|
||||
}
|
||||
const indices = "faceIndices" in payload
|
||||
? payload.faceIndices
|
||||
: "edgeIndices" in payload
|
||||
? payload.edgeIndices
|
||||
: payload.elementIndices;
|
||||
if (!Array.isArray(indices) || indices.some((index) => !Number.isSafeInteger(index) || index < 0)) {
|
||||
throw report("INVALID_ARGUMENT", `${payload.type} contains invalid typed selection indices`, true);
|
||||
}
|
||||
if ((payload.type === "meshSubdivide" || payload.type === "meshDeleteElements") &&
|
||||
!["VERT", "EDGE", "FACE"].includes(payload.selectionMode)) {
|
||||
throw report("INVALID_ARGUMENT", `${payload.type} contains an invalid selection mode`, true);
|
||||
}
|
||||
if (payload.type === "meshUVProject" && !["PLANAR", "CUBE", "CYLINDER", "SPHERE"].includes(payload.method)) {
|
||||
throw report("INVALID_ARGUMENT", "meshUVProject contains an unsupported projection method", true);
|
||||
}
|
||||
}
|
||||
if (payload.type === "createAction") {
|
||||
if (typeof payload.name !== "string" || payload.name.trim().length === 0 || payload.name.length > 256) throw report("ANIMATION_ACTION_INVALID", "Action name is required");
|
||||
if (payload.objectId !== undefined && typeof payload.objectId !== "string") throw report("ANIMATION_ACTION_INVALID", "Action objectId is invalid");
|
||||
@@ -293,6 +340,27 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (payload.value !== undefined && (!Array.isArray(payload.value) || !payload.value.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe value is invalid");
|
||||
for (const handle of [payload.handleLeft, payload.handleRight]) if (handle !== undefined && (!Array.isArray(handle) || handle.length !== 2 || !handle.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe handle is invalid");
|
||||
}
|
||||
if (payload.type === "setFCurveKeyframes") {
|
||||
if (typeof payload.animationId !== "string" || payload.animationId.length === 0 || typeof payload.path !== "string" || payload.path.length === 0 || !Array.isArray(payload.points) || payload.points.length === 0 || payload.points.length > 4096) throw report("ANIMATION_FCURVE_INVALID", "FCurve points are invalid");
|
||||
for (const point of payload.points) {
|
||||
if (!point || !Number.isFinite(point.frame) || !Number.isFinite(point.value)) throw report("ANIMATION_FCURVE_INVALID", "FCurve point coordinates are invalid");
|
||||
for (const handle of [point.handleLeft, point.handleRight]) if (handle !== undefined && (!Array.isArray(handle) || handle.length !== 2 || !handle.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_FCURVE_INVALID", "FCurve handle is invalid");
|
||||
}
|
||||
}
|
||||
if (payload.type === "setDriverExpression") {
|
||||
if (typeof payload.animationId !== "string" || payload.animationId.length === 0 || typeof payload.path !== "string" || payload.path.length === 0 || typeof payload.expression !== "string" || payload.expression.length > 256) throw report("ANIMATION_DRIVER_INVALID", "Driver expression is invalid");
|
||||
}
|
||||
if (payload.type === "setActionGroups") {
|
||||
if (typeof payload.animationId !== "string" || payload.animationId.length === 0 || !Array.isArray(payload.groups) || payload.groups.length > 128) throw report("ANIMATION_GROUP_INVALID", "Action groups are invalid");
|
||||
for (const group of payload.groups) if (!group || typeof group.name !== "string" || group.name.trim().length === 0 || group.name.length > 64 || !Array.isArray(group.paths) || group.paths.length > 4096 || group.paths.some((path) => typeof path !== "string" || path.length === 0 || path.length > 256)) throw report("ANIMATION_GROUP_INVALID", "Action group entries are invalid");
|
||||
}
|
||||
if (payload.type === "setKeyingSet") {
|
||||
if (typeof payload.sceneId !== "string" || payload.sceneId.length === 0 || typeof payload.name !== "string" || payload.name.trim().length === 0 || payload.name.length > 64 || !Array.isArray(payload.paths) || payload.paths.length > 4096) throw report("ANIMATION_KEYING_SET_INVALID", "Keying Set is invalid");
|
||||
if (payload.paths.some((path) => !path || typeof path.path !== "string" || path.path.length === 0 || path.path.length > 256 || (path.arrayIndex !== undefined && (!Number.isSafeInteger(path.arrayIndex) || path.arrayIndex < -1 || path.arrayIndex > 64)))) throw report("ANIMATION_KEYING_SET_INVALID", "Keying Set paths are invalid");
|
||||
}
|
||||
if (payload.type === "bakeAnimation") {
|
||||
if (typeof payload.objectId !== "string" || payload.objectId.length === 0 || !Number.isSafeInteger(payload.frameStart) || !Number.isSafeInteger(payload.frameEnd) || payload.frameStart > payload.frameEnd || payload.frameEnd - payload.frameStart > 100000 || (payload.step !== undefined && (!Number.isSafeInteger(payload.step) || payload.step < 1 || payload.step > 1000))) throw report("ANIMATION_BAKE_INVALID", "Bake range is invalid");
|
||||
}
|
||||
if (payload.type === "setTimelineMarkers") {
|
||||
if (typeof payload.dataId !== "string" || !Array.isArray(payload.markers) || payload.markers.length > 10000 || payload.markers.some((marker) => !marker || !Number.isFinite(marker.frame) || typeof marker.name !== "string" || marker.name.length > 256)) throw report("ANIMATION_MARKER_INVALID", "Timeline markers are invalid");
|
||||
}
|
||||
@@ -349,6 +417,30 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (payload.resolution !== undefined && (!Number.isSafeInteger(payload.resolution) || payload.resolution < 1 || payload.resolution > 1024)) throw report("NON_MESH_PROPERTY_INVALID", "Curve resolution must be between 1 and 1024");
|
||||
return;
|
||||
}
|
||||
case "setCurveBevel": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "CURVE") throw report("NON_MESH_DATA_UNSUPPORTED", `Curve data block is unavailable: ${payload.dataId}`);
|
||||
if (payload.bevelDepth !== undefined && (!Number.isFinite(payload.bevelDepth) || payload.bevelDepth < 0 || payload.bevelDepth > 100000)) throw report("NON_MESH_PROPERTY_INVALID", "Curve bevel depth is outside the bounded range");
|
||||
for (const value of [payload.bevelFactorStart, payload.bevelFactorEnd]) {
|
||||
if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 1)) throw report("NON_MESH_PROPERTY_INVALID", "Curve bevel factors must be between 0 and 1");
|
||||
}
|
||||
if (payload.bevelFactorStart !== undefined && payload.bevelFactorEnd !== undefined && payload.bevelFactorStart > payload.bevelFactorEnd) throw report("NON_MESH_PROPERTY_INVALID", "Curve bevel factor range is invalid");
|
||||
if (payload.bevelResolution !== undefined && (!Number.isSafeInteger(payload.bevelResolution) || payload.bevelResolution < 0 || payload.bevelResolution > 64)) throw report("NON_MESH_PROPERTY_INVALID", "Curve bevel resolution must be between 0 and 64");
|
||||
break;
|
||||
}
|
||||
case "setCurveTaper": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "CURVE") throw report("NON_MESH_DATA_UNSUPPORTED", `Curve data block is unavailable: ${payload.dataId}`);
|
||||
if (payload.taperObjectId !== null && (typeof payload.taperObjectId !== "string" || payload.taperObjectId.length === 0 || payload.taperObjectId.length > 63)) throw report("NON_MESH_PROPERTY_INVALID", "Curve taper object identity is invalid");
|
||||
if (payload.taperRadiusMode !== undefined && (!Number.isSafeInteger(payload.taperRadiusMode) || payload.taperRadiusMode < 0 || payload.taperRadiusMode > 2)) throw report("NON_MESH_PROPERTY_INVALID", "Curve taper radius mode is invalid");
|
||||
break;
|
||||
}
|
||||
case "convertCurveType": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || (data.type !== "CURVE" && data.type !== "SURFACE")) throw report("NON_MESH_DATA_UNSUPPORTED", `Curve or surface data block is unavailable: ${payload.dataId}`);
|
||||
if (payload.targetType !== "CURVE" && payload.targetType !== "SURFACE") throw report("NON_MESH_PROPERTY_INVALID", "Curve target type is invalid");
|
||||
break;
|
||||
}
|
||||
case "setCurveHandle": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "CURVE" || !data.handlePoints) throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Bezier data block is unavailable: ${payload.dataId}`);
|
||||
@@ -412,6 +504,12 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (pointCount > 1_000_000 || !Array.isArray(payload.controlPoints) || payload.controlPoints.length !== pointCount * 3 || payload.controlPoints.some((value) => !Number.isFinite(value)) || !Array.isArray(payload.pointWeights) || payload.pointWeights.length !== pointCount || payload.pointWeights.some((value) => !Number.isFinite(value) || value <= 0 || value > 1_000_000)) throw report("NON_MESH_DATA_BUDGET_EXCEEDED", "Surface control grid exceeds the finite 1M point budget");
|
||||
return;
|
||||
}
|
||||
case "setSurfaceResolution": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "SURFACE") throw report("NON_MESH_DATA_UNSUPPORTED", `Surface data block is unavailable: ${payload.dataId}`);
|
||||
if (!Number.isSafeInteger(payload.resolutionU) || payload.resolutionU < 1 || payload.resolutionU > 1024 || (payload.resolutionV !== undefined && (!Number.isSafeInteger(payload.resolutionV) || payload.resolutionV < 1 || payload.resolutionV > 1024))) throw report("NON_MESH_PROPERTY_INVALID", "Surface resolution must be between 1 and 1024");
|
||||
break;
|
||||
}
|
||||
case "setFontBody": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "FONT") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 font data block is unavailable: ${payload.dataId}`);
|
||||
@@ -518,6 +616,25 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
case "setArmatureBones":
|
||||
case "setPoseBoneTransform":
|
||||
return;
|
||||
case "armatureEditOperations": {
|
||||
if (!Array.isArray(payload.operations) || payload.operations.length === 0 || payload.operations.length > 1024) throw report("ARMATURE_EDIT_INVALID", "Armature edit operations must contain between 1 and 1024 entries");
|
||||
for (const operation of payload.operations) {
|
||||
if (!["ADD", "DELETE", "RENAME", "EXTRUDE", "DUPLICATE"].includes(operation.operation)) throw report("ARMATURE_EDIT_INVALID", "Armature edit operation is unsupported");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setBoneRoll":
|
||||
if (!Number.isFinite(payload.roll)) throw report("ARMATURE_ROLL_INVALID", "Bone roll must be finite");
|
||||
return;
|
||||
case "setBoneSymmetry":
|
||||
if (typeof payload.enabled !== "boolean") throw report("ARMATURE_SYMMETRY_INVALID", "Armature symmetry requires a boolean state");
|
||||
return;
|
||||
case "setBoneParenting":
|
||||
if (typeof payload.boneName !== "string" || (payload.parentName !== null && typeof payload.parentName !== "string") || typeof payload.connected !== "boolean") throw report("ARMATURE_PARENT_INVALID", "Bone parenting fields are invalid");
|
||||
return;
|
||||
case "setPoseConstraint":
|
||||
if (typeof payload.boneName !== "string" || typeof payload.constraintName !== "string" || (payload.constraintType !== null && !["COPY_LOCATION", "COPY_ROTATION", "COPY_SCALE", "IK", "TRACK_TO"].includes(payload.constraintType)) || (payload.influence !== undefined && (!Number.isFinite(payload.influence) || payload.influence < 0 || payload.influence > 1))) throw report("POSE_CONSTRAINT_INVALID", "Pose constraint fields are invalid");
|
||||
return;
|
||||
case "setMetaballElements": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`);
|
||||
@@ -559,7 +676,8 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (payload.type === "insertGreasePencilFrame" && payload.duration !== undefined && (!Number.isSafeInteger(payload.duration) || payload.duration < 0 || payload.duration > 1_000_000)) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil frame duration is invalid");
|
||||
return;
|
||||
}
|
||||
case "setGreasePencilStrokes": {
|
||||
case "setGreasePencilStrokes":
|
||||
case "setGreasePencilDrawing": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.frame) || payload.frame < -1_000_000 || payload.frame > 1_000_000 || !Array.isArray(payload.strokes) || payload.strokes.length > 1_000_000) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil stroke transaction is invalid");
|
||||
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil stroke base revision does not match the current SceneIR");
|
||||
let points = 0;
|
||||
@@ -573,6 +691,21 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setGreasePencilMaterial": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.frame) || payload.frame < -1_000_000 || payload.frame > 1_000_000 || !Number.isSafeInteger(payload.materialIndex) || payload.materialIndex < 0 || payload.materialIndex > 32767 || (payload.strokeIndices !== undefined && (!Array.isArray(payload.strokeIndices) || payload.strokeIndices.some((index) => !Number.isSafeInteger(index) || index < 0)))) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil material edit is invalid");
|
||||
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil material base revision does not match the current SceneIR");
|
||||
return;
|
||||
}
|
||||
case "setGreasePencilSelection": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.frame) || payload.frame < -1_000_000 || payload.frame > 1_000_000 || (payload.pointIndices !== undefined && (!Array.isArray(payload.pointIndices) || payload.pointIndices.some((index) => !Number.isSafeInteger(index) || index < 0))) || (payload.strokeIndices !== undefined && (!Array.isArray(payload.strokeIndices) || payload.strokeIndices.some((index) => !Number.isSafeInteger(index) || index < 0))) || (payload.mode !== undefined && !["REPLACE", "ADD", "SUBTRACT", "TOGGLE"].includes(payload.mode))) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil selection edit is invalid");
|
||||
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil selection base revision does not match the current SceneIR");
|
||||
return;
|
||||
}
|
||||
case "setGreasePencilInterpolate": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.sourceFrame) || !Number.isSafeInteger(payload.targetFrame) || payload.sourceFrame < -1_000_000 || payload.sourceFrame > 1_000_000 || payload.targetFrame < -1_000_000 || payload.targetFrame > 1_000_000 || payload.sourceFrame === payload.targetFrame || !Number.isFinite(payload.factor) || payload.factor < 0 || payload.factor > 1) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil interpolation edit is invalid");
|
||||
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil interpolation base revision does not match the current SceneIR");
|
||||
return;
|
||||
}
|
||||
case "setVertexColors": {
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === payload.meshId);
|
||||
const domainCount = payload.domain === "POINT" ? mesh?.vertexCount : payload.domain === "CORNER" ? mesh?.cornerCount : undefined;
|
||||
@@ -852,6 +985,22 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
for (const field of ["interlaceSwap", "sideBySideCrossed", "squeezedFrame"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Scene ${field} must be boolean`);
|
||||
return;
|
||||
}
|
||||
case "setTrackingMarkers": {
|
||||
if (typeof payload.clipId !== "string" || payload.clipId.length === 0 || typeof payload.trackId !== "string" || payload.trackId.length === 0 || !Array.isArray(payload.markers) || payload.markers.length > 1_000_000) throw report("TRACKING_SCHEMA_INVALID", "Tracking markers are invalid");
|
||||
return;
|
||||
}
|
||||
case "setTrackingSolve": {
|
||||
if (typeof payload.clipId !== "string" || payload.clipId.length === 0 || !payload.solve || typeof payload.solve !== "object") throw report("TRACKING_SCHEMA_INVALID", "Tracking solve settings are invalid");
|
||||
return;
|
||||
}
|
||||
case "setMaskSpline": {
|
||||
if (typeof payload.maskId !== "string" || payload.maskId.length === 0 || typeof payload.layerId !== "string" || payload.layerId.length === 0 || !payload.spline || typeof payload.spline !== "object" || !Array.isArray(payload.spline.points) || payload.spline.points.length < 2 || payload.spline.points.length > 1_000_000) throw report("MASK_SCHEMA_INVALID", "Mask spline is invalid");
|
||||
return;
|
||||
}
|
||||
case "setMaskPoint": {
|
||||
if (typeof payload.maskId !== "string" || payload.maskId.length === 0 || typeof payload.layerId !== "string" || payload.layerId.length === 0 || typeof payload.splineId !== "string" || payload.splineId.length === 0 || !payload.point || typeof payload.point !== "object") throw report("MASK_SCHEMA_INVALID", "Mask point is invalid");
|
||||
return;
|
||||
}
|
||||
case "setSceneStampSettings": {
|
||||
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.dataId);
|
||||
const properties = payload.properties;
|
||||
@@ -898,6 +1047,39 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
for (const name of payload.lightgroups) { if (typeof name !== "string" || name.length === 0 || new TextEncoder().encode(name).byteLength > 63 || names.has(name)) throw report("RENDER_PROPERTY_INVALID", "Scene lightgroup names must be unique and bounded"); names.add(name); }
|
||||
return;
|
||||
}
|
||||
case "clearObjectTransform": {
|
||||
if (!currentSnapshot?.nodes.some((candidate) => candidate.id === payload.objectId) ||
|
||||
!Array.isArray(payload.channels) || payload.channels.length === 0 || payload.channels.length > 3 ||
|
||||
new Set(payload.channels).size !== payload.channels.length ||
|
||||
payload.channels.some((channel) => !["LOCATION", "ROTATION", "SCALE"].includes(channel))) {
|
||||
throw report("OBJECT_EDITOR_INVALID", "Object transform clear target or channels are invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "createObjectData": {
|
||||
if (!["CAMERA", "LIGHT", "EMPTY", "ARMATURE"].includes(payload.objectType) ||
|
||||
(payload.name !== undefined && (typeof payload.name !== "string" || payload.name.length === 0 || new TextEncoder().encode(payload.name).byteLength > 63))) {
|
||||
throw report("OBJECT_EDITOR_INVALID", "Object data type or name is invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setObjectSelection": {
|
||||
if (!currentSnapshot?.nodes.some((candidate) => candidate.id === payload.objectId) ||
|
||||
typeof payload.selected !== "boolean" ||
|
||||
(payload.active !== undefined && typeof payload.active !== "boolean") || (payload.active === true && !payload.selected)) {
|
||||
throw report("OBJECT_EDITOR_INVALID", "Object selection target or state is invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setCollectionVisibility": {
|
||||
if (!currentSnapshot?.collections.some((candidate) => candidate.id === payload.collectionId) ||
|
||||
(payload.viewportVisible === undefined && payload.renderVisible === undefined) ||
|
||||
(payload.viewportVisible !== undefined && typeof payload.viewportVisible !== "boolean") ||
|
||||
(payload.renderVisible !== undefined && typeof payload.renderVisible !== "boolean")) {
|
||||
throw report("OBJECT_EDITOR_INVALID", "Collection visibility target or state is invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setObjectLightgroup": {
|
||||
const object = currentSnapshot?.nodes.find((candidate) => candidate.id === payload.objectId);
|
||||
if (!object || typeof payload.lightgroup !== "string" && payload.lightgroup !== null) throw report("RENDER_PROPERTY_INVALID", "Object lightgroup target is invalid");
|
||||
@@ -956,6 +1138,34 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
|
||||
return;
|
||||
}
|
||||
case "setSequencerStripModifiers": {
|
||||
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.sceneId);
|
||||
if (!scene || typeof payload.stripId !== "string" || payload.stripId.length === 0 || !Array.isArray(payload.modifiers) || payload.modifiers.length > 128) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer modifier target is invalid");
|
||||
for (const modifier of payload.modifiers) {
|
||||
if (!modifier || typeof modifier.id !== "string" || modifier.id.length === 0 || modifier.id.length > 128 || typeof modifier.type !== "string" || modifier.type.length === 0 || modifier.type.length > 128) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer modifier identity is invalid");
|
||||
if (modifier.enabled !== undefined && typeof modifier.enabled !== "boolean") throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer modifier enabled flag is invalid");
|
||||
if (modifier.properties !== undefined && (!modifier.properties || typeof modifier.properties !== "object" || Array.isArray(modifier.properties))) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer modifier properties are invalid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setSequencerTransitions": {
|
||||
if (!currentSnapshot?.scenes.some((candidate) => candidate.id === payload.sceneId)) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer transition scene is invalid");
|
||||
parseSequencerTimeline(payload.timeline);
|
||||
return;
|
||||
}
|
||||
case "setSequencerColorBalance": {
|
||||
if (!currentSnapshot?.scenes.some((candidate) => candidate.id === payload.sceneId) || typeof payload.stripId !== "string" || payload.stripId.length === 0) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer color balance target is invalid");
|
||||
const balance = payload.colorBalance;
|
||||
if (!balance || !Array.isArray(balance.lift) || balance.lift.length !== 3 || !Array.isArray(balance.gamma) || balance.gamma.length !== 3 || !Array.isArray(balance.gain) || balance.gain.length !== 3 || [...balance.lift, ...balance.gamma, ...balance.gain].some((value) => typeof value !== "number" || !Number.isFinite(value))) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer color balance channels are invalid");
|
||||
for (const field of ["exposure", "contrast", "saturation"] as const) if (balance[field] !== undefined && !boundedNumber(balance[field], -100, 100)) throw report("SEQUENCER_SCHEMA_INVALID", `Sequencer color balance ${field} is invalid`);
|
||||
return;
|
||||
}
|
||||
case "setCompositorNodeGraph": {
|
||||
if (!currentSnapshot?.scenes.some((candidate) => candidate.id === payload.sceneId) || !payload.graph || typeof payload.graph !== "object") throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph target is invalid");
|
||||
const graph = payload.graph as Record<string, unknown>;
|
||||
if (graph.schemaVersion !== 1 || typeof graph.id !== "string" || graph.id.length === 0 || !Array.isArray(graph.nodes) || !Array.isArray(graph.links) || graph.nodes.length > 4096 || graph.links.length > 16384) throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph schema or bounds are invalid");
|
||||
return;
|
||||
}
|
||||
case "setSequencerStrips": {
|
||||
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.sceneId);
|
||||
if (!scene) throw report("SEQUENCER_SCHEMA_INVALID", `Sequencer scene does not exist: ${payload.sceneId}`);
|
||||
@@ -1090,8 +1300,32 @@ function callUnifiedJson<T>(cmd: string, params: Record<string, unknown> = {}, t
|
||||
}
|
||||
|
||||
type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene";
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene"]);
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene", "listRNADataBlocks", "readRNAProperties", "listRNACollections"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene", "assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload", "setRNAProperty"]);
|
||||
|
||||
function validateRNAParams(command: WebEngineRouteJsonCommand, value: unknown): void {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw report("INVALID_ARGUMENT", `${command} requires an object params payload`, true);
|
||||
const params = value as Record<string, unknown>;
|
||||
const allowed = command === "listRNADataBlocks" ? ["type", "dataBlockType", "editableOnly", "limit"] : command === "setRNAProperty" ? ["type", "dataBlockType", "name", "dataBlockName", "property", "propertyIdentifier", "value", "baseRevision"] : ["type", "dataBlockType", "name", "dataBlockName", "limit"];
|
||||
if (Object.keys(params).some((key) => !allowed.includes(key))) throw report("INVALID_ARGUMENT", `${command} received an unknown parameter`, true);
|
||||
const type = params.type ?? params.dataBlockType;
|
||||
const name = params.name ?? params.dataBlockName;
|
||||
const property = params.property ?? params.propertyIdentifier;
|
||||
if (type !== undefined && (typeof type !== "string" || type.length === 0 || type.length > 96)) throw report("INVALID_ARGUMENT", `${command} requires a bounded data-block type`, true);
|
||||
if (name !== undefined && (typeof name !== "string" || name.length === 0 || name.length > 256)) throw report("INVALID_ARGUMENT", `${command} requires a bounded data-block name`, true);
|
||||
if (property !== undefined && (typeof property !== "string" || property.length === 0 || property.length > 128)) throw report("INVALID_ARGUMENT", `${command} requires a bounded RNA property identifier`, true);
|
||||
if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || (params.limit as number) < 1 || (params.limit as number) > 1024)) throw report("INVALID_ARGUMENT", `${command} limit is outside the bounded reflection budget`, true);
|
||||
if (params.editableOnly !== undefined && typeof params.editableOnly !== "boolean") throw report("INVALID_ARGUMENT", `${command} editableOnly must be boolean`, true);
|
||||
if (command === "setRNAProperty" && (property === undefined || !Object.prototype.hasOwnProperty.call(params, "value"))) throw report("INVALID_ARGUMENT", "setRNAProperty requires property and value", true);
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || (params.baseRevision as number) < 0)) throw report("REVISION_CONFLICT", "setRNAProperty baseRevision must be non-negative", true);
|
||||
const valueIsSafe = (candidate: unknown, depth = 0): boolean => {
|
||||
if (depth > 4 || candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return candidate === null || typeof candidate !== "string" || candidate.length <= 4096;
|
||||
if (typeof candidate === "number") return Number.isFinite(candidate);
|
||||
if (Array.isArray(candidate)) return candidate.length <= 64 && candidate.every((item) => valueIsSafe(item, depth + 1));
|
||||
return false;
|
||||
};
|
||||
if (command === "setRNAProperty" && !valueIsSafe(params.value)) throw report("INVALID_ARGUMENT", "setRNAProperty value must be bounded scalar JSON", true);
|
||||
}
|
||||
|
||||
function readSnapshot(): ReturnType<typeof parseSceneSnapshotIR> {
|
||||
const snapshot = parseSceneSnapshotIR(callUnifiedJson<unknown>("snapshot"));
|
||||
@@ -1585,6 +1819,15 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
|
||||
throw report("INVALID_ARGUMENT", "cmd json applyCommand requires params.command", true);
|
||||
}
|
||||
if (commandName === "invokeBlenderOperator") validateOperatorInvocation(request.command.params);
|
||||
if (["listRNADataBlocks", "readRNAProperties", "setRNAProperty", "listRNACollections"].includes(commandName)) validateRNAParams(commandName, request.command.params ?? {});
|
||||
if (["assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload"].includes(commandName)) {
|
||||
try {
|
||||
parseAssetLibraryCommand(commandName, request.command.params ?? {});
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "ASSET_MANIFEST_INVALID");
|
||||
}
|
||||
}
|
||||
if (commandName === "importScene" && (!request.command.params || typeof request.command.params.format !== "string" || typeof request.command.params.base64 !== "string")) {
|
||||
throw report("INVALID_ARGUMENT", "importScene requires format and base64 payload", true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user