Advance Blender WebEngine N-015 through N-022 parity

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

View File

@@ -13,7 +13,7 @@
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "e12c4f76e9b8db6ba726fcc88a39cbf6f47ffa0b3f4bd704c03544169ff2937c",
"sha256": "acc2d6808dac4590aa3c3915495a6e24396a946b257a2d137f64ccf4c3ba6fbf",
"required": true
}
]

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -50,6 +50,7 @@ interface MeshEditSelection {
mode: MeshElementMode;
indices: Set<number>;
nonMeshKind?: NonMeshElementKind;
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
}
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: {
@@ -106,9 +107,12 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
if (renderer && snapshot && lodLevels) {
for (const [meshId, levels] of Object.entries(lodLevels)) renderer.installLODLevels(meshId, levels);
}
renderer?.setSelection(selectedObjectIds);
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
: undefined;
renderer?.setSelection(selectedObjectIds, elementSelection);
renderer?.setInteractionMode(editMode, meshSelection.mode);
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode]);
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections]);
useEffect(() => {
rendererRef.current?.setTextureAssets(textureAssets);
@@ -382,7 +386,7 @@ export function App() {
return next;
});
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined }));
};
const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
@@ -391,11 +395,20 @@ export function App() {
setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current);
}
setMeshSelection((current) => {
const preserve = additive && current.meshId === meshId && current.mode === mode && current.nonMeshKind === nonMeshKind;
const preserve = additive && current.meshId === meshId && current.mode === mode;
const next = preserve ? new Set(current.indices) : new Set<number>();
if (next.has(index)) next.delete(index);
else next.add(index);
return { meshId, mode, indices: next, nonMeshKind };
if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined };
const selections = new Map<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
if (kindIndices.has(index)) kindIndices.delete(index);
else kindIndices.add(index);
if (kindIndices.size === 0) selections.delete(nonMeshKind);
else selections.set(nonMeshKind, kindIndices);
const combined = new Set<number>();
for (const values of selections.values()) for (const value of values) combined.add(value);
return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections };
});
};
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
@@ -511,14 +524,14 @@ export function App() {
};
const setMeshSelectionMode = (mode: MeshElementMode): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set() });
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined });
};
const selectAllMeshElements = (): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId);
if (!mesh) return;
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)) });
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)), nonMeshSelections: undefined });
};
const runMeshEdit = (operation: MeshEditOperation): void => {
if (!meshSelection.meshId || meshSelection.indices.size === 0) return;
@@ -546,19 +559,19 @@ export function App() {
if (!activeNode) return;
if (uiState.context.mode === "Edit" && activeNode.dataId) {
const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId);
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id &&
(meshSelection.nonMeshKind === "HANDLE_LEFT" || meshSelection.nonMeshKind === "HANDLE_RIGHT") &&
meshSelection.indices.size === 1 && nonMesh.handlePoints) {
const pointIndex = [...meshSelection.indices][0];
const packedPointIndex = nonMesh.handlePointIndices?.indexOf(pointIndex) ?? pointIndex;
if (packedPointIndex < 0) return;
const handleOffset = packedPointIndex * 6 + (meshSelection.nonMeshKind === "HANDLE_RIGHT" ? 3 : 0);
const position: [number, number, number] = [
nonMesh.handlePoints[handleOffset], nonMesh.handlePoints[handleOffset + 1], nonMesh.handlePoints[handleOffset + 2],
];
position[axis] += amount;
void applyEditCommand({ type: "setCurveHandle", dataId: nonMesh.id, pointIndex,
side: meshSelection.nonMeshKind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position });
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
const handlePoints = nonMesh.handlePoints.slice();
const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: handlePoints.length / 6 }, (_, index) => index);
for (const [kind, selected] of meshSelection.nonMeshSelections) {
if (kind === "CONTROL_POINT") continue;
const sideOffset = kind === "HANDLE_RIGHT" ? 3 : 0;
for (const pointIndex of selected) {
const packedPointIndex = pointIndices.indexOf(pointIndex);
if (packedPointIndex < 0) continue;
handlePoints[packedPointIndex * 6 + sideOffset + axis] += amount;
}
}
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
return;
}
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);

View File

@@ -1,4 +1,4 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { LODCacheRecord } from "../../../protocol/lod";
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
@@ -127,6 +127,10 @@ export class StorageClient {
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
}
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
}
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}

View File

@@ -380,3 +380,26 @@ export async function readContentAsset(projectId: string, sha256: string, storag
const file = await directory.getFileHandle(sha256);
return (await file.getFile()).arrayBuffer();
}
export async function readContentAssetRange(
projectId: string,
sha256: string,
byteOffset: number,
byteLength: number,
expectedTotalBytes: number,
storage?: StorageManager,
): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
validateSha256(sha256);
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0 || !Number.isSafeInteger(byteLength) || byteLength <= 0 ||
!Number.isSafeInteger(expectedTotalBytes) || expectedTotalBytes <= 0 || byteOffset > expectedTotalBytes - byteLength) {
throw new Error("Content-addressed asset range is invalid");
}
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const directory = await ensureDirectory(root, `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
const file = await (await directory.getFileHandle(sha256)).getFile();
if (file.size !== expectedTotalBytes) throw new Error("Content-addressed asset size mismatch");
return file.slice(byteOffset, byteOffset + byteLength).arrayBuffer();
}

View File

@@ -7,9 +7,14 @@ import {
LineBasicMaterial,
type Object3D,
} from "../vendor/three/three.module.js";
import type { GreasePencilDataIR, GreasePencilFrameIR } from "../../../protocol/grease-pencil";
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
import type { SceneNodeIR } from "../../../protocol/scene-ir";
interface DrawingPreview {
drawing: GreasePencilDrawingIR;
onion: "NONE" | "PREVIOUS" | "NEXT";
}
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
let selected: GreasePencilFrameIR | undefined;
for (const candidate of frames) {
@@ -18,45 +23,75 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
return selected;
}
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
const current = activeFrame(layer.frames, frame);
if (!current) return [];
const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }];
if (!layer.onionSkinning) return result;
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame);
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" });
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" });
return result;
}
function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
let count = 0;
for (const stroke of preview.drawing.strokes) {
if (!stroke.points || stroke.points.length < 2) continue;
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
const positions = new Float32Array(pointCount * 3);
let red = 0;
let green = 0;
let blue = 0;
let opacity = 0;
for (let index = 0; index < pointCount; index++) {
const point = stroke.points[index % stroke.points.length];
positions[index * 3] = point.position[0];
positions[index * 3 + 1] = point.position[2];
positions[index * 3 + 2] = -point.position[1];
}
for (const point of stroke.points) {
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
red += color[0];
green += color[1];
blue += color[2];
opacity += point.opacity * color[3];
}
const divisor = stroke.points.length;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
const material = new LineBasicMaterial({
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
transparent: true,
depthWrite: preview.onion === "NONE",
});
const line = new Line(geometry, material);
line.userData.greasePencilOnion = preview.onion;
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
group.add(line);
count++;
}
return count;
}
export function createGreasePencilObject(data: GreasePencilDataIR, frame: number): Object3D | null {
if (data.geometryStatus !== "available") return null;
const group = new Group();
let onionDrawingCount = 0;
let currentDrawingCount = 0;
for (const layer of data.layers) {
if (!layer.visible || layer.opacity <= 0) continue;
const drawing = activeFrame(layer.frames, frame)?.drawing;
if (!drawing) continue;
for (const stroke of drawing.strokes) {
if (!stroke.points || stroke.points.length < 2) continue;
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
const positions = new Float32Array(pointCount * 3);
let red = 0;
let green = 0;
let blue = 0;
let opacity = 0;
for (let index = 0; index < pointCount; index++) {
const point = stroke.points[index % stroke.points.length];
positions[index * 3] = point.position[0];
positions[index * 3 + 1] = point.position[2];
positions[index * 3 + 2] = -point.position[1];
}
for (const point of stroke.points) {
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
red += color[0];
green += color[1];
blue += color[2];
opacity += point.opacity * color[3];
}
const divisor = stroke.points.length;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const material = new LineBasicMaterial({
color: new Color(red / divisor, green / divisor, blue / divisor),
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor)),
transparent: true,
});
group.add(new Line(geometry, material));
for (const preview of layerDrawings(layer, frame)) {
const added = addDrawing(group, layer, preview);
if (preview.onion === "NONE") currentDrawingCount += added;
else onionDrawingCount += added;
}
}
group.userData.greasePencilCurrentStrokeCount = currentDrawingCount;
group.userData.greasePencilOnionStrokeCount = onionDrawingCount;
return group.children.length > 0 ? group : null;
}

View File

@@ -17,6 +17,7 @@ import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
function blenderPosition(x: number, y: number, z: number): [number, number, number] {
return [x, z, -y];
@@ -80,7 +81,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
controlPositions.set(blenderPosition(points[index], points[index + 1], points[index + 2]), index);
}
controlGeometry.setAttribute("position", new Float32BufferAttribute(controlPositions, 3));
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true }));
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true, vertexColors: true }));
controls.userData.nonMeshDataId = data.id;
controls.userData.nonMeshPointIndexMap = Array.from({ length: points.length / 3 }, (_, index) => index);
controls.userData.nonMeshPointKindMap = Array.from({ length: points.length / 3 }, () => "CONTROL_POINT" as NonMeshElementKind);
@@ -92,7 +93,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
group.add(lines);
const pointGeometry = new BufferGeometry();
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true }));
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true, vertexColors: true }));
handles.userData.nonMeshDataId = data.id;
handles.userData.nonMeshPointIndexMap = handleIndexMap;
handles.userData.nonMeshPointKindMap = handleKindMap;
@@ -172,3 +173,25 @@ export function applyNonMeshTransform(object: Object3D, node: SceneNodeIR): void
child.userData.blenderId = node.id;
});
}
export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshElementSelection): void {
root.traverse((object) => {
const dataId = object.userData.nonMeshDataId;
const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined;
const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
if (typeof dataId !== "string" || !indexMap || !kindMap || !(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
const selectedByKind = selection.get(dataId);
const colors = new Float32Array(indexMap.length * 3);
for (let index = 0; index < indexMap.length; index++) {
const selected = selectedByKind?.get(kindMap[index])?.has(indexMap[index]) ?? false;
const color = selected ? [1, 0.4, 0.1] : kindMap[index] === "CONTROL_POINT" ? [0.4, 0.72, 1] : [0.6, 0.48, 1];
colors[index * 3] = color[0];
colors[index * 3 + 1] = color[1];
colors[index * 3 + 2] = color[2];
}
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
object.material.color.set(0xffffff);
object.material.vertexColors = true;
object.material.needsUpdate = true;
});
}

View File

@@ -9,7 +9,7 @@ export type OffscreenViewportRequest =
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
| { type: "textureAssets"; assets: GPUTextureAsset[] }
| { type: "resize"; width: number; height: number; pixelRatio: number }
| { type: "selection"; objectIds: string[] }
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }> }
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
| { type: "pick"; x: number; y: number; additive: boolean }
@@ -18,7 +18,7 @@ export type OffscreenViewportRequest =
export type OffscreenViewportResponse =
| { type: "ready" }
| { type: "frame"; visiblePixels: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
| { type: "selected"; objectId: string; additive: boolean }
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }

View File

@@ -12,7 +12,7 @@ import type { NonMeshElementKind } from "./nonmesh";
export interface ViewportBackend {
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
setSelection(objectIds: ReadonlySet<string>): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
dispose(): void;
@@ -147,8 +147,9 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
}
setSelection(objectIds: ReadonlySet<string>): void {
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
@@ -209,6 +210,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
}
else if (message.type === "textureStatus") {
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";

View File

@@ -0,0 +1,62 @@
import {
Matrix3,
Mesh,
Triangle,
Vector2,
Vector3,
type Intersection,
} from "../vendor/three/three.module.js";
import type { PaintHitIR } from "../../../protocol/paint";
export interface PaintRaycastHitIR extends PaintHitIR {
objectId: string;
dataId: string;
}
function finiteTuple(values: readonly number[]): boolean {
return values.every(Number.isFinite);
}
export function paintHitFromIntersection(intersection: Intersection, pressure = 1): PaintRaycastHitIR | null {
const faceIndex = intersection.faceIndex;
if (!(intersection.object instanceof Mesh) || faceIndex === undefined || faceIndex === null || faceIndex < 0 || pressure < 0 || pressure > 1) return null;
const object = intersection.object;
const positionAttribute = object.geometry.getAttribute("position");
const indexAttribute = object.geometry.getIndex();
if (!positionAttribute) return null;
const corner = faceIndex * 3;
const vertexA = indexAttribute ? indexAttribute.getX(corner) : corner;
const vertexB = indexAttribute ? indexAttribute.getX(corner + 1) : corner + 1;
const vertexC = indexAttribute ? indexAttribute.getX(corner + 2) : corner + 2;
if ([vertexA, vertexB, vertexC].some((vertex) => vertex < 0 || vertex >= positionAttribute.count)) return null;
const a = new Vector3().fromBufferAttribute(positionAttribute, vertexA);
const b = new Vector3().fromBufferAttribute(positionAttribute, vertexB);
const c = new Vector3().fromBufferAttribute(positionAttribute, vertexC);
const localPoint = object.worldToLocal(intersection.point.clone());
const barycentric = Triangle.getBarycoord(localPoint, a, b, c, new Vector3());
if (!barycentric || !finiteTuple(barycentric.toArray())) return null;
const localNormal = intersection.face?.normal?.clone() ?? new Triangle(a, b, c).getNormal(new Vector3());
const worldNormal = localNormal.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld)).normalize();
const sourceFaces = object.userData.triangleFaceIndices as number[] | undefined;
const result: PaintRaycastHitIR = {
objectId: String(object.userData.blenderId ?? ""),
dataId: String(object.userData.meshId ?? ""),
position: intersection.point.toArray(),
normal: worldNormal.toArray(),
faceIndex: sourceFaces?.[faceIndex] ?? faceIndex,
barycentric: barycentric.toArray(),
pressure,
};
if (!result.objectId || !result.dataId) return null;
const uv = object.geometry.getAttribute("uv");
if (uv) {
const uvA = new Vector2().fromBufferAttribute(uv, vertexA);
const uvB = new Vector2().fromBufferAttribute(uv, vertexB);
const uvC = new Vector2().fromBufferAttribute(uv, vertexC);
result.uv = [
uvA.x * barycentric.x + uvB.x * barycentric.y + uvC.x * barycentric.z,
uvA.y * barycentric.x + uvB.y * barycentric.y + uvC.y * barycentric.z,
];
}
return result;
}

View File

@@ -40,7 +40,7 @@ import {
import { GPUTextureStore } from "./texture-assets";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import { applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
@@ -279,6 +279,7 @@ export class ViewportRenderer {
const dataById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let previewCount = 0;
let blockedCount = 0;
let onionStrokeCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = dataById.get(node.dataId);
@@ -291,10 +292,12 @@ export class ViewportRenderer {
applyGreasePencilTransform(object, node);
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
onionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
previewCount++;
}
this.canvas.dataset.greasePencilCount = String(previewCount);
this.canvas.dataset.greasePencilBlockedCount = String(blockedCount);
this.canvas.dataset.greasePencilOnionStrokeCount = String(onionStrokeCount);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
@@ -350,7 +353,7 @@ export class ViewportRenderer {
this.currentSnapshot = next;
}
setSelection(objectIds: ReadonlySet<string>): void {
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
const visitedInstances = new Set<InstancedMesh>();
for (const [objectId, object] of this.objectByBlenderId) {
if (object instanceof InstancedMesh) {
@@ -370,6 +373,7 @@ export class ViewportRenderer {
setPBRMaterialSelected(material, objectIds.has(objectId));
}
}
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,15 +1,65 @@
import { gateSelectionInteraction, parseRaycastSelectionHit, recordSelection, SELECTION_HISTORY_BUDGET, stepSelectionHistory } from "../../../protocol/selection-history";
import {
gateSelectionInteraction,
parseRaycastSelectionHit,
parseSelectionHistory,
patchSelectionRanges,
recordSelection,
SELECTION_HISTORY_BUDGET,
stepSelectionHistory,
} from "../../../protocol/selection-history";
const empty = { activeObjectId: null, objectIds: [], targets: [] };
const selected = {
activeObjectId: "object:1",
objectIds: ["object:1", "object:2"],
targets: [
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", indices: [3, 1] },
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", indices: [2] },
],
};
const base = { schemaVersion: 2, revision: 0, cursor: 0, entries: [empty] };
const empty = { activeObjectId: null, objectIds: [], meshId: null, elementMode: "FACE", elementIndices: [] };
const selected = { activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "VERT", elementIndices: [3, 1] };
const base = { schemaVersion: 1, revision: 0, cursor: 0, entries: [empty] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const next = recordSelection(base, 0, { ...selected, nonMeshKind: "HANDLE_LEFT" }); const undone = stepSelectionHistory(next, 1, "UNDO"); const redone = stepSelectionHistory(undone, 2, "REDO"); result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].elementIndices, redone.entries[redone.cursor].nonMeshKind]; } catch (error) { result.history = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 4, selected); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 0, { ...selected, elementIndices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); } catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
try { result.handleHit = parseRaycastSelectionHit({ sourceRevision: 3, dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3).nonMeshKind; } catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
try {
const next = recordSelection(base, 0, selected);
const undone = stepSelectionHistory(next, 1, "UNDO");
const redone = stepSelectionHistory(undone, 2, "REDO");
result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].targets.map((target) => [target.dataId, target.indices, target.nonMeshKind])];
}
catch (error) { result.history = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 4, selected); }
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
try {
recordSelection(base, 0, { ...selected, targets: [{ ...selected.targets[0], indices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }] });
}
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); }
catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
try {
const hit = parseRaycastSelectionHit({ sourceRevision: 3, objectId: "object:1", dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3);
result.handleHit = [hit.objectId, hit.nonMeshKind];
}
catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
try {
const patched = patchSelectionRanges(selected, [
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 2, end: 4, selected: true },
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 3, end: 3, selected: false },
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", start: 2, end: 2, selected: false },
]);
result.rangePatch = patched.targets.map((target) => [target.dataId, target.indices]);
}
catch (error) { result.rangePatch = error instanceof Error ? error.message : String(error); }
try {
const migrated = parseSelectionHistory({
schemaVersion: 1,
revision: 0,
cursor: 0,
entries: [{ activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "FACE", elementIndices: [4] }],
});
result.migrated = [migrated.schemaVersion, migrated.entries[0].targets[0].dataId];
}
catch (error) { result.migrated = error instanceof Error ? error.message : String(error); }
result.gates = [gateSelectionInteraction("RAYCAST").status, gateSelectionInteraction("HISTORY").status, gateSelectionInteraction("GIZMO").status];
self.postMessage(result);
};

View File

@@ -1,9 +1,9 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
import { parseSimulationCacheManifest, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
@@ -724,6 +724,36 @@ async function readSimulationCache(projectId: string, cacheKey: string): Promise
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
}
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
projectLayout(projectId);
const row = await readSimulationRow(projectId, cacheKey);
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
}
const selected = selectSimulationCacheFrame(manifest, frame);
const asset = await readAssetRow(projectId, manifest.cacheSha256);
if (!asset || asset.bytes !== manifest.byteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
}
const data = asset.buffer ?
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
await verifySimulationCacheFrame(manifest, frame, data);
return {
projectId,
cacheKey,
persisted: true,
manifest,
path: row.path,
frame,
byteOffset: selected.byteOffset,
byteLength: selected.byteLength,
data,
};
}
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
projectLayout(projectId);
const db = await openDatabase();
@@ -773,6 +803,7 @@ scope.onmessage = async (event) => {
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
else throw new Error("Unknown storage command");
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);

View File

@@ -35,7 +35,7 @@ import {
setPBRMaterialSelected,
} from "../three-adapter/pbr";
import { GPUTextureStore } from "../three-adapter/texture-assets";
import { applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
const workerScope = self as unknown as {
@@ -276,6 +276,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
const greasePencilsById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let greasePencilCount = 0;
let greasePencilBlockedCount = 0;
let greasePencilOnionStrokeCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = greasePencilsById.get(node.dataId);
@@ -288,9 +289,10 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
applyGreasePencilTransform(object, node);
root.add(object);
objectById.set(node.id, object);
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
greasePencilCount++;
}
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount });
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
@@ -311,7 +313,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
render();
}
function setSelection(ids: string[]): void {
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void {
const selected = new Set(ids);
const visited = new Set<Object3D>();
for (const [id, object] of objectById) {
@@ -329,6 +331,15 @@ function setSelection(ids: string[]): void {
}
}
}
const selection = new Map<string, Map<NonMeshElementKind, Set<number>>>();
for (const element of elements) {
const kinds = selection.get(element.dataId) ?? new Map<NonMeshElementKind, Set<number>>();
const indices = kinds.get(element.kind) ?? new Set<number>();
indices.add(element.index);
kinds.set(element.kind, indices);
selection.set(element.dataId, kinds);
}
if (root) applyNonMeshElementSelection(root, selection);
render();
}
@@ -411,7 +422,7 @@ workerScope.onmessage = (event): void => {
else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
else if (message.type === "selection") setSelection(message.objectIds);
else if (message.type === "selection") setSelection(message.objectIds, message.elements);
else if (message.type === "interaction") {
editMode = message.editMode;
selectionMode = message.selectionMode;

View File

@@ -366,6 +366,30 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
for (const field of ["useTemperature", "castsShadow"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Light ${field} must be boolean`);
return;
}
case "setCameraProperties": {
const camera = currentSnapshot?.cameras.find((candidate) => candidate.id === payload.dataId);
const properties = payload.properties;
if (!camera || !validPropertyKeys(properties, ["projection", "lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit", "shift", "near", "far", "orthoScale", "depthOfField"])) throw report("RENDER_PROPERTY_INVALID", "Camera target or properties are invalid");
if (properties.projection !== undefined && !["PERSPECTIVE", "ORTHOGRAPHIC"].includes(properties.projection)) throw report("RENDER_PROPERTY_INVALID", "Camera projection is invalid");
for (const [field, minimum, maximum] of [["lensMm", 0.1, 10_000], ["sensorWidthMm", 0.1, 10_000], ["sensorHeightMm", 0.1, 10_000], ["near", 0.0001, 1e9], ["far", 0.0002, 1e12], ["orthoScale", 0.0001, 1e9]] as const) {
const value = properties[field];
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera ${field} is outside the bounded range`);
}
if (properties.near !== undefined && properties.far !== undefined && properties.far <= properties.near) throw report("RENDER_PROPERTY_INVALID", "Camera far clip must exceed near clip");
if (properties.sensorFit !== undefined && ![0, 1, 2].includes(properties.sensorFit)) throw report("RENDER_PROPERTY_INVALID", "Camera sensorFit is invalid");
if (properties.shift !== undefined && (!Array.isArray(properties.shift) || properties.shift.length !== 2 || properties.shift.some((value) => !boundedNumber(value, -1000, 1000)))) throw report("RENDER_PROPERTY_INVALID", "Camera shift is invalid");
if (properties.depthOfField !== undefined) {
const dof = properties.depthOfField;
if (!validPropertyKeys(dof, ["enabled", "focusDistance", "apertureFStop", "apertureBlades", "apertureRotation", "apertureRatio"])) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField is invalid");
if (dof.enabled !== undefined && typeof dof.enabled !== "boolean") throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.enabled must be boolean");
for (const [field, minimum, maximum] of [["focusDistance", 0, 1e9], ["apertureFStop", 0.01, 1000], ["apertureRotation", -Math.PI * 2, Math.PI * 2], ["apertureRatio", 0.01, 100]] as const) {
const value = dof[field];
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera depthOfField.${field} is outside the bounded range`);
}
if (dof.apertureBlades !== undefined && (!Number.isSafeInteger(dof.apertureBlades) || dof.apertureBlades < 0 || dof.apertureBlades > 64)) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.apertureBlades is outside the bounded range");
}
return;
}
case "setWorldProperties": {
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === payload.dataId);
const properties = payload.properties;

View File

@@ -30,8 +30,11 @@
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
"test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs",
"test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs",
"test:sequencer": "playwright test --config playwright.config.ts -g \"N-021 sequencer\"",
"test:sequencer-main-reader": "node ../tools/web/check-sequencer-main-reader.mjs",
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,7 @@ export type WebEngineEditCommand =
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }
| { type: "setCameraProperties"; dataId: string; properties: { projection?: "PERSPECTIVE" | "ORTHOGRAPHIC"; lensMm?: number; sensorWidthMm?: number; sensorHeightMm?: number; sensorFit?: 0 | 1 | 2; shift?: [number, number]; near?: number; far?: number; orthoScale?: number; depthOfField?: { enabled?: boolean; focusDistance?: number; apertureFStop?: number; apertureBlades?: number; apertureRotation?: number; apertureRatio?: number } } }
| { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } }
| { type: "setMetaballElements"; dataId: string; elements: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }> }
| { type: "sculptStroke"; stroke: SculptStrokeIR }

View File

@@ -425,6 +425,37 @@ test("enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema b
expect(result.budget).toBe("accepted-blocked-budget");
});
test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts");
const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] });
const object = createGreasePencilObject({
id: "grease-pencil:onion",
name: "Onion",
geometryStatus: "available",
layerCount: 1,
frameCount: 3,
strokeCount: 3,
pointCount: 6,
layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
{ frame: 1, drawing: drawing("drawing:1", -2) },
{ frame: 5, drawing: drawing("drawing:5", 0) },
{ frame: 9, drawing: drawing("drawing:9", 2) },
] }],
}, 5);
if (!object) return null;
return {
onion: object.userData.greasePencilOnionStrokeCount,
current: object.userData.greasePencilCurrentStrokeCount,
kinds: object.children.map((child) => child.userData.greasePencilOnion),
opacities: object.children.map((child) => "material" in child && !Array.isArray(child.material) ? (child.material as { opacity?: number }).opacity : undefined),
};
});
expect(result).toEqual({ onion: 2, current: 1, kinds: ["PREVIOUS", "NONE", "NEXT"], opacities: [0.28, 1, 0.28] });
});
for (const offscreen of [false, true]) {
test(`renders the N-016 Grease Pencil current-frame strokes in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
await page.goto(offscreen ? "/?offscreen=1" : "/");
@@ -465,6 +496,33 @@ test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
});
test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const three = await import("/src/vendor/three/three.module.js");
const { paintHitFromIntersection } = await import("/src/three-adapter/paint-hit.ts");
const geometry = new three.BufferGeometry();
geometry.setAttribute("position", new three.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3));
geometry.setAttribute("uv", new three.Float32BufferAttribute([0, 0, 1, 0, 0, 1], 2));
geometry.setIndex([0, 1, 2]);
const mesh = new three.Mesh(geometry, new three.MeshBasicMaterial());
mesh.userData.blenderId = "object:paint";
mesh.userData.meshId = "mesh:paint";
mesh.userData.triangleFaceIndices = [17];
mesh.updateMatrixWorld(true);
const raycaster = new three.Raycaster(new three.Vector3(0.25, 0.25, 1), new three.Vector3(0, 0, -1));
const intersection = raycaster.intersectObject(mesh)[0];
return intersection ? paintHitFromIntersection(intersection, 0.75) : null;
});
expect(result?.objectId).toBe("object:paint");
expect(result?.dataId).toBe("mesh:paint");
expect(result?.faceIndex).toBe(17);
expect(result?.pressure).toBe(0.75);
expect(result?.barycentric).toEqual(expect.arrayContaining([0.5, 0.25, 0.25]));
expect(result?.uv).toEqual([0.25, 0.25]);
expect(result?.normal).toEqual([0, 0, 1]);
});
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -642,11 +700,16 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.history).toEqual([3, 1, [1, 3], "HANDLE_LEFT"]);
expect(result.history).toEqual([3, 1, [
["curve:1", [1, 3], "HANDLE_LEFT"],
["curve:2", [2], "HANDLE_RIGHT"],
]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.budget).toContain("SELECTION_HISTORY_BUDGET_EXCEEDED");
expect(result.raycast).toContain("RAYCAST_HIT_INVALID");
expect(result.handleHit).toBe("HANDLE_RIGHT");
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
expect(result.migrated).toEqual([2, "mesh:1"]);
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
});
@@ -895,6 +958,14 @@ test("persists and revalidates content-addressed Simulation caches across Worker
const restarted = new StorageClient();
const listed = await restarted.listSimulationCaches(projectId);
const read = await restarted.readSimulationCache(projectId, stored.cacheKey);
const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2);
let missingFrameCode = "";
try {
await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3);
}
catch (error) {
missingFrameCode = String((error as Error & { code?: string }).code ?? "");
}
let corruptCode = "";
try {
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
@@ -908,6 +979,10 @@ test("persists and revalidates content-addressed Simulation caches across Worker
path: stored.path,
listed: listed.caches.map((cache) => cache.cacheKey),
bytes: Array.from(new Uint8Array(read.data)),
frame: frameRead.frame,
frameOffset: frameRead.byteOffset,
frameBytes: Array.from(new Uint8Array(frameRead.data)),
missingFrameCode,
corruptCode,
};
});
@@ -915,6 +990,10 @@ test("persists and revalidates content-addressed Simulation caches across Worker
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
expect(result.listed).toContain(result.cacheKey);
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
expect(result.frame).toBe(2);
expect(result.frameOffset).toBe(3);
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING");
expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
});