Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

13
web/app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202124" />
<title>Blender Web Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"protocolVersion": 1,
"engineVersion": "blender-wasm-0.1.0",
"engine": "blender-wasm",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"wasm": [
{
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "e12c4f76e9b8db6ba726fcc88a39cbf6f47ffa0b3f4bd704c03544169ff2937c",
"required": true
}
]
}

File diff suppressed because one or more lines are too long

BIN
web/app/public/vendor/blender/web_engine.wasm vendored Executable file

Binary file not shown.

967
web/app/src/app/App.tsx Normal file
View File

@@ -0,0 +1,967 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { WebEngineClient } from "../engine-client/WebEngineClient";
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
import type { ProgressEvent } from "../../../protocol/progress";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { collectGPUTextureAssetRequests, createGPUTextureAsset, RenderAssetValidationError } from "../../../protocol/render-assets";
import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SimplifyAttributePolicy, SimplifyDelimit, SimplifyMode, SimplifyProfile } from "../../../protocol/simplify";
import { StorageClient } from "../storage/StorageClient";
import { AutosaveScheduler } from "../storage/autosave";
import { ViewportRenderer } from "../three-adapter/viewport";
import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport";
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
import { buildLODCacheKey } from "../three-adapter/lod";
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
import type { LODCacheRecord } from "../../../protocol/lod";
import { modifierStackHash } from "../../../protocol/modifier";
import { exportGLB } from "../../../protocol/glb-export";
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import "./app-shell.css";
interface AreaProps {
className?: string;
editor: EditorType;
children: React.ReactNode;
}
function Area({ className = "", editor, children }: AreaProps) {
return (
<section className={`area-frame ${className}`} data-editor={editor}>
<header className="editor-header">
<button className="editor-selector" type="button" aria-label={`切换 ${editor} 编辑器`}>
<span className="editor-icon" aria-hidden="true">{editor === "3D Viewport" ? "◇" : editor === "Outliner" ? "☷" : editor === "Properties" ? "⚙" : "▥"}</span>
<span>{editor}</span>
</button>
<span className="header-spacer" />
<button className="icon-button" type="button" aria-label={`${editor} 菜单`}></button>
</header>
<div className="area-content">{children}</div>
</section>
);
}
interface MeshEditSelection {
meshId: string | null;
mode: MeshElementMode;
indices: Set<number>;
nonMeshKind?: NonMeshElementKind;
}
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: {
snapshot: SceneSnapshotIR | null;
geometryBuffers: MeshGeometryBuffer[];
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
textureAssets: GPUTextureAsset[];
lodLevels: Record<string, WebEngineLODLevelResult[]> | null;
selectedObjectIds: ReadonlySet<string>;
editMode: boolean;
meshSelection: MeshEditSelection;
onSelect: (id: string, additive: boolean) => void;
onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<ViewportBackend | null>(null);
const [viewportError, setViewportError] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate");
useEffect(() => {
if (!canvasRef.current) return;
try {
const offscreenRequested = new URLSearchParams(window.location.search).get("offscreen") === "1";
const canvas = canvasRef.current;
const renderer = offscreenRequested && supportsOffscreenViewport(canvas)
? acquireOffscreenViewportRenderer(canvas, onSelect, onElementSelect)
: new ViewportRenderer(canvas, onSelect, onElementSelect);
rendererRef.current = renderer;
return () => {
rendererRef.current = null;
if (renderer instanceof OffscreenViewportRenderer) releaseOffscreenViewportRenderer(canvas, renderer);
else renderer.dispose();
};
} catch (error) {
const message = error instanceof Error ? error.message : "无法创建 WebGL 上下文";
setViewportError(message);
return undefined;
}
}, []);
useEffect(() => {
const renderer = rendererRef.current;
renderer?.setSnapshot(snapshot ?? {
schemaVersion: 1,
revision: 0,
sceneId: "empty",
source: { kind: "mock" },
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
activeObjectId: null,
frame: { current: 1, start: 1, end: 250 },
}, geometryBuffers, nonMeshGeometryBuffers);
if (renderer && snapshot && lodLevels) {
for (const [meshId, levels] of Object.entries(lodLevels)) renderer.installLODLevels(meshId, levels);
}
renderer?.setSelection(selectedObjectIds);
renderer?.setInteractionMode(editMode, meshSelection.mode);
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode]);
useEffect(() => {
rendererRef.current?.setTextureAssets(textureAssets);
}, [textureAssets]);
return (
<div className="viewport-placeholder" role="img" aria-label="Three.js 视口占位区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="axis-gizmo" aria-hidden="true"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span></div>
{viewportError || !snapshot || snapshot.nodes.length === 0 ? <div className="viewport-message">
<strong>Blender Web Viewport</strong>
{viewportError ? <span>WebGL {viewportError}</span> : <span>Three.js WebGL2 </span>}
</div> : null}
<div className="viewport-toolbar" aria-label="视口工具">
<button type="button" className="tool-button active" aria-label="选择工具"></button>
<button type="button" className={`tool-button${activeTool === "translate" ? " active" : ""}`} aria-label="移动工具" title="移动" onClick={() => setActiveTool("translate")}></button>
<button type="button" className={`tool-button${activeTool === "rotate" ? " active" : ""}`} aria-label="旋转工具" title="旋转" onClick={() => setActiveTool("rotate")}></button>
<button type="button" className={`tool-button${activeTool === "scale" ? " active" : ""}`} aria-label="缩放工具" title="缩放" onClick={() => setActiveTool("scale")}></button>
</div>
{snapshot?.activeObjectId ? <div className="transform-gizmo" aria-label="变换 Gizmo">{([0, 1, 2] as const).map((axis) => <button key={axis} type="button" className={`gizmo-axis axis-${"xyz"[axis]}`} aria-label={`${"XYZ"[axis]} 轴变换手柄`} onPointerDown={(event) => {
const start = { x: event.clientX, y: event.clientY };
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const target = event.currentTarget;
const finish = (up: PointerEvent): void => {
target.removeEventListener("pointerup", finish);
target.removeEventListener("pointercancel", finish);
const distance = (up.clientX - start.x) - (up.clientY - start.y);
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis);
};
target.addEventListener("pointerup", finish);
target.addEventListener("pointercancel", finish);
}}>{"XYZ"[axis]}</button>)}</div> : null}
<aside className="viewport-sidebar" aria-label="视口侧栏">
<span>{editMode ? `${meshSelection.mode} ${meshSelection.indices.size}` : "Transform"}</span>
<span>View</span>
<span>Item</span>
</aside>
</div>
);
}
function Outliner({ snapshot, onSelect, onToggleVisibility }: {
snapshot: SceneSnapshotIR | null;
onSelect: (id: string, additive: boolean) => void;
onToggleVisibility: (id: string, visible: boolean) => void;
}) {
const nodeById = new Map((snapshot?.nodes ?? []).map((node) => [node.id, node]));
const collectionRows = snapshot?.collections ?? [];
return (
<div className="outliner-content">
<div className="outliner-tools"><input aria-label="搜索场景" placeholder="搜索场景" /><button type="button" aria-label="筛选"></button></div>
{collectionRows.length === 0 ? <div className="tree-row collection"><span></span><strong>Scene Collection</strong></div> : collectionRows.map((collection, index) => (
<div key={collection.id}>
<div className={index === 0 ? "tree-row collection" : "tree-row"}><span></span><span className="tree-icon"></span><strong>{collection.name}</strong></div>
{collection.objectIds.map((objectId) => {
const node = nodeById.get(objectId);
if (!node) return null;
return <div key={node.id} role="button" tabIndex={0} onClick={(event) => onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}><span>·</span><span className={`tree-icon ${node.type.toLowerCase()}`}></span><span>{node.name}</span><button type="button" className="tree-action" aria-label={`${node.visible ? "隐藏" : "显示"} ${node.name}`} onClick={(event) => { event.stopPropagation(); onToggleVisibility(node.id, !node.visible); }}>{node.visible ? "◉" : "○"}</button></div>;
})}
</div>
))}
</div>
);
}
function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
snapshot: SceneSnapshotIR | null;
selectedFaceIndices: number[];
onCommand: (command: WebEngineEditCommand) => void;
onImportImage: (file: File) => void;
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
onPreviewDecimate: (profile: SimplifyProfile, meshId: string) => void;
onGenerateLOD: (meshId: string, triangleCount: number) => void;
onSetModifierEnabled: (meshId: string, modifierUuid: string, enabled: boolean) => void;
previewActive: boolean;
onCancelPreview: () => void;
}) {
const [mode, setMode] = useState<SimplifyMode>("COLLAPSE");
const [ratio, setRatio] = useState(0.5);
const [iterations, setIterations] = useState(1);
const [angleDegrees, setAngleDegrees] = useState(5);
const [triangulate, setTriangulate] = useState(false);
const [useSymmetry, setUseSymmetry] = useState(false);
const [symmetryAxis, setSymmetryAxis] = useState<0 | 1 | 2>(0);
const [useDissolveBoundaries, setUseDissolveBoundaries] = useState(false);
const [delimit, setDelimit] = useState<SimplifyDelimit[]>([]);
const [attributePolicy, setAttributePolicy] = useState<SimplifyAttributePolicy>("PRESERVE");
const [materialColor, setMaterialColor] = useState("#cc8844");
const [materialRoughness, setMaterialRoughness] = useState(0.4);
const [materialMetallic, setMaterialMetallic] = useState(0);
const [materialIOR, setMaterialIOR] = useState(1.45);
const [materialSpecular, setMaterialSpecular] = useState(0.5);
const [materialTransmission, setMaterialTransmission] = useState(0);
const [materialCoat, setMaterialCoat] = useState(0);
const [materialCoatRoughness, setMaterialCoatRoughness] = useState(0.03);
const [materialEmissionStrength, setMaterialEmissionStrength] = useState(1);
const [renameValue, setRenameValue] = useState("");
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]);
useEffect(() => {
if (!activeMaterial) return;
setMaterialColor(`#${activeMaterial.baseColor.slice(0, 3).map((component) => Math.round(Math.max(0, Math.min(1, component)) * 255).toString(16).padStart(2, "0")).join("")}`);
setMaterialRoughness(activeMaterial.roughness);
setMaterialMetallic(activeMaterial.metallic);
setMaterialIOR(activeMaterial.ior);
setMaterialSpecular(activeMaterial.specularIORLevel ?? 0.5);
setMaterialTransmission(activeMaterial.transmissionWeight ?? 0);
setMaterialCoat(activeMaterial.coatWeight ?? 0);
setMaterialCoatRoughness(activeMaterial.coatRoughness ?? 0.03);
setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1);
}, [activeMaterial]);
useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]);
const toggleDelimit = (value: SimplifyDelimit): void => {
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
};
const createProfile = (): { profile: SimplifyProfile; meshId: string } | null => {
if (!activeMesh || !snapshot) return null;
if (mode === "COLLAPSE") {
return { meshId: activeMesh.id, profile: {
schemaVersion: 1,
sourceMeshRevision: snapshot.revision,
attributePolicy,
mode,
ratio,
triangulate,
useSymmetry,
...(useSymmetry ? { symmetryAxis, symmetryTolerance: 1e-4 } : {}),
} };
}
else if (mode === "UNSUBDIV") {
return { meshId: activeMesh.id, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy, mode, iterations } };
}
return { meshId: activeMesh.id, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy, mode, angleLimit: angleDegrees * Math.PI / 180, useDissolveBoundaries, delimit } };
};
const apply = (): void => {
const command = createProfile();
if (command) onApplyDecimate(command.profile, command.meshId);
};
const preview = (): void => {
const command = createProfile();
if (command) onPreviewDecimate(command.profile, command.meshId);
};
return (
<div className="properties-content">
<div className="property-tabs" role="tablist" aria-label="属性标签">
<button className="property-tab active" type="button" role="tab" aria-selected="true"></button>
<button className="property-tab" type="button" role="tab" aria-selected="false"></button>
<button className="property-tab" type="button" role="tab" aria-selected="false"></button>
</div>
<div className="property-section"><h3>Transform</h3><label> <output>0.000, 0.000, 0.000</output></label><label> <output>0°, 0°, 0°</output></label><label> <output>1.000, 1.000, 1.000</output></label></div>
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label> <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}></button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}></button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}></button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input type="checkbox" defaultChecked /></label></div>
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label> UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}> UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
{activeMesh ? <div className="property-section">
<h3>Material Slots</h3>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
<div className="property-actions"><button type="button" onClick={() => activeNode && onCommand({ type: "addMaterialSlot", objectId: activeNode.id, name: "Material" })}></button><button type="button" disabled={!activeNode || !activeMesh.materialSlotIds?.length} onClick={() => activeNode && onCommand({ type: "removeMaterialSlot", objectId: activeNode.id, slotIndex: Math.max(0, (activeMesh.materialSlotIds?.length ?? 1) - 1) })}></button><button type="button" disabled={selectedFaceIndices.length === 0 || !activeMesh.materialSlotIds?.length} onClick={() => onCommand({ type: "assignMaterialFaces", meshId: activeMesh.id, faceIndices: selectedFaceIndices, slotIndex: 0 })}></button></div>
{activeMaterial ? <>
<label>Base Color <input aria-label="材质基础色" type="color" value={materialColor} onChange={(event) => setMaterialColor(event.target.value)} /></label>
<label>Roughness <input aria-label="材质粗糙度" type="range" min="0" max="1" step="0.01" value={materialRoughness} onChange={(event) => setMaterialRoughness(Number(event.target.value))} /><output>{materialRoughness.toFixed(2)}</output></label>
<label>Metallic <input aria-label="材质金属度" type="range" min="0" max="1" step="0.01" value={materialMetallic} onChange={(event) => setMaterialMetallic(Number(event.target.value))} /><output>{materialMetallic.toFixed(2)}</output></label>
<label>IOR <input aria-label="材质 IOR" type="range" min="1" max="2.333" step="0.01" value={materialIOR} onChange={(event) => setMaterialIOR(Number(event.target.value))} /><output>{materialIOR.toFixed(2)}</output></label>
<label>Specular IOR <input aria-label="材质镜面 IOR" type="range" min="0" max="1" step="0.01" value={materialSpecular} onChange={(event) => setMaterialSpecular(Number(event.target.value))} /><output>{materialSpecular.toFixed(2)}</output></label>
<label>Transmission <input aria-label="材质透射" type="range" min="0" max="1" step="0.01" value={materialTransmission} onChange={(event) => setMaterialTransmission(Number(event.target.value))} /><output>{materialTransmission.toFixed(2)}</output></label>
<label>Coat <input aria-label="材质涂层" type="range" min="0" max="1" step="0.01" value={materialCoat} onChange={(event) => setMaterialCoat(Number(event.target.value))} /><output>{materialCoat.toFixed(2)}</output></label>
<label>Coat Roughness <input aria-label="材质涂层粗糙度" type="range" min="0" max="1" step="0.01" value={materialCoatRoughness} onChange={(event) => setMaterialCoatRoughness(Number(event.target.value))} /><output>{materialCoatRoughness.toFixed(2)}</output></label>
<label>Emission Strength <input aria-label="材质自发光强度" type="number" min="0" max="1000000" step="0.1" value={materialEmissionStrength} onChange={(event) => setMaterialEmissionStrength(Math.min(1_000_000, Math.max(0, Number(event.target.value))))} /></label>
<div className="property-actions"><button type="button" onClick={() => {
const rgb = [1, 3, 5].map((offset) => Number.parseInt(materialColor.slice(offset, offset + 2), 16) / 255) as [number, number, number];
onCommand({ type: "setMaterialPrincipled", materialId: activeMaterial.id, baseColor: [...rgb, activeMaterial.baseColor[3]], roughness: materialRoughness, metallic: materialMetallic, emissionColor: activeMaterial.emissionColor, alpha: activeMaterial.alpha, ior: materialIOR, specularIORLevel: materialSpecular, transmissionWeight: materialTransmission, coatWeight: materialCoat, coatRoughness: materialCoatRoughness, emissionStrength: materialEmissionStrength });
}}> Principled</button><label className="file-button"><input type="file" accept="image/png,image/jpeg" onChange={(event) => { const file = event.target.files?.[0]; if (file) onImportImage(file); event.target.value = ""; }} /></label></div>
<label> <select aria-label="材质图片" value="" onChange={(event) => event.target.value && onCommand({ type: "setMaterialImageNode", materialId: activeMaterial.id, imageId: event.target.value, usage: "BASE_COLOR", uvMap: activeMesh.activeUVMap ?? undefined })}><option value=""> Base Color...</option>{snapshot?.images.map((image) => <option key={image.id} value={image.id}>{image.name}</option>)}</select></label>
<label>线 <select aria-label="材质法线图片" value="" onChange={(event) => event.target.value && onCommand({ type: "setMaterialImageNode", materialId: activeMaterial.id, imageId: event.target.value, usage: "NORMAL", uvMap: activeMesh.activeUVMap ?? undefined })}><option value=""> Normal...</option>{snapshot?.images.map((image) => <option key={image.id} value={image.id}>{image.name}</option>)}</select></label>
</> : null}
</div> : null}
{activeNode?.constraints?.length ? <div className="property-section"><h3>Constraints</h3>{activeNode.constraints.map((constraint) => <label key={constraint.name}>{constraint.name}<input type="range" min="0" max="1" step="0.05" value={constraint.influence} onChange={(event) => onCommand({ type: "setConstraint", objectId: activeNode.id, constraintName: constraint.name, enabled: constraint.enabled, influence: Number(event.target.value) })} /><input aria-label={`启用约束 ${constraint.name}`} type="checkbox" checked={constraint.enabled} onChange={(event) => onCommand({ type: "setConstraint", objectId: activeNode.id, constraintName: constraint.name, enabled: event.target.checked, influence: constraint.influence })} /></label>)}</div> : null}
{activeMesh?.modifierStack?.length ? <div className="property-section"><h3>Modifier Stack</h3>{activeMesh.modifierStack.map((modifier) => <div className="modifier-row" key={modifier.uuid}><span>{modifier.name}</span>{(["showViewport", "showRender", "showEditMode", "showOnCage"] as const).map((field, index) => <label key={field}>{["V", "R", "E", "C"][index]}<input type="checkbox" checked={Boolean(modifier[field])} onChange={(event) => { if (field === "showViewport") onSetModifierEnabled(activeMesh.id, modifier.uuid, event.target.checked); else onCommand({ type: "setModifierVisibility", meshId: activeMesh.id, modifierUuid: modifier.uuid, showViewport: modifier.showViewport, showRender: modifier.showRender, showEditMode: Boolean(modifier.showEditMode), showOnCage: Boolean(modifier.showOnCage), [field]: event.target.checked }); }} aria-label={`${["Viewport", "Render", "Edit", "Cage"][index]} ${modifier.name}`} /></label>)}</div>)}</div> : null}
<div className="property-section modifier-panel">
<h3>Decimate</h3>
<label> <select aria-label="Decimate 模式" value={mode} onChange={(event) => setMode(event.target.value as SimplifyMode)}><option value="COLLAPSE">Collapse</option><option value="UNSUBDIV">Un-Subdivide</option><option value="DISSOLVE_PLANAR">Dissolve Planar</option></select></label>
{mode === "COLLAPSE" ? <label> <input aria-label="Decimate 比例" type="range" min="0.05" max="1" step="0.05" value={ratio} onChange={(event) => setRatio(Number(event.target.value))} /><output>{ratio.toFixed(2)}</output></label> : null}
{mode === "COLLAPSE" ? <label> <input aria-label="Decimate 三角化" type="checkbox" checked={triangulate} onChange={(event) => setTriangulate(event.target.checked)} /></label> : null}
{mode === "COLLAPSE" ? <label> <input aria-label="Decimate 对称" type="checkbox" checked={useSymmetry} onChange={(event) => setUseSymmetry(event.target.checked)} /></label> : null}
{mode === "COLLAPSE" && useSymmetry ? <label> <select aria-label="Decimate 对称轴" value={symmetryAxis} onChange={(event) => setSymmetryAxis(Number(event.target.value) as 0 | 1 | 2)}><option value={0}>X</option><option value={1}>Y</option><option value={2}>Z</option></select></label> : null}
{mode === "UNSUBDIV" ? <label> <input aria-label="Un-Subdivide 迭代" type="number" min="1" max="32" step="1" value={iterations} onChange={(event) => setIterations(Math.min(32, Math.max(1, Number(event.target.value))))} /></label> : null}
{mode === "DISSOLVE_PLANAR" ? <label> <input aria-label="Dissolve 角度" type="range" min="0" max="180" step="1" value={angleDegrees} onChange={(event) => setAngleDegrees(Number(event.target.value))} /><output>{angleDegrees}°</output></label> : null}
{mode === "DISSOLVE_PLANAR" ? <label> <input aria-label="Dissolve 边界" type="checkbox" checked={useDissolveBoundaries} onChange={(event) => setUseDissolveBoundaries(event.target.checked)} /></label> : null}
{mode === "DISSOLVE_PLANAR" ? <fieldset className="delimit-options"><legend></legend>{(["NORMAL", "MATERIAL", "SEAM", "SHARP", "UV"] as SimplifyDelimit[]).map((value) => <label key={value}><input type="checkbox" checked={delimit.includes(value)} onChange={() => toggleDelimit(value)} />{value}</label>)}</fieldset> : null}
<label> <select aria-label="Decimate 属性策略" value={attributePolicy} onChange={(event) => setAttributePolicy(event.target.value as SimplifyAttributePolicy)}><option value="PRESERVE">Preserve</option><option value="RECOMPUTE_NORMALS">Recompute Normals</option><option value="DROP">Drop</option></select></label>
<div className="modifier-summary"><span>{activeMesh ? `${activeMesh.name}: ${activeMesh.triangleCount} triangles` : "选择网格对象"}</span><button type="button" aria-label="预览 Decimate" disabled={!activeMesh} onClick={preview}></button><button type="button" aria-label="应用 Decimate" disabled={!activeMesh} onClick={apply}></button><button type="button" aria-label="生成 LOD" disabled={!activeMesh} onClick={() => activeMesh && onGenerateLOD(activeMesh.id, activeMesh.triangleCount ?? 0)}>LOD</button>{previewActive ? <button type="button" aria-label="取消 Decimate 预览" onClick={onCancelPreview}></button> : null}</div>
</div>
</div>
);
}
function OperatorSearch({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const operators = ["Add Cube", "Apply Transform", "Frame Selected", "Save Project"];
const matches = operators.filter((operator) => operator.toLowerCase().includes(query.toLowerCase()));
return (
<div className="operator-search" role="dialog" aria-label="Operator Search">
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") onClose(); }} placeholder="Search operators" aria-label="搜索操作" />
<div className="operator-results">{matches.map((operator) => <button key={operator} type="button" onClick={onClose}>{operator}</button>)}</div>
</div>
);
}
function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { snapshot: SceneSnapshotIR | null; frame: number; start: number; end: number; onFrameChange: (value: number) => void; onCommand: (command: WebEngineEditCommand) => void }) {
const [playing, setPlaying] = useState(false);
useEffect(() => {
if (!playing) return undefined;
const timer = window.setInterval(() => {
if (frame >= end) {
setPlaying(false);
return;
}
onFrameChange(Math.min(end, frame + 1));
}, 1000 / 24);
return () => window.clearInterval(timer);
}, [end, frame, onFrameChange, playing]);
const mid = Math.round(start + (end - start) * 0.2);
const second = Math.round(start + (end - start) * 0.4);
const third = Math.round(start + (end - start) * 0.6);
const fourth = Math.round(start + (end - start) * 0.8);
const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId);
const keyframes = [...new Set(animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])].sort((left, right) => left - right);
return (
<div className="timeline-content">
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}></button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "LOCATION", interpolation: "BEZIER" })}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "ROTATION_EULER", interpolation: "BEZIER" })}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "SCALE", interpolation: "BEZIER" })}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand({ type: "deleteObjectKeyframe", objectId: snapshot.activeObjectId!, frame })}>Del Key</button></> : null}</div>
<input className="frame-slider" type="range" min={start} max={end} value={Math.min(end, Math.max(start, frame))} onChange={(event) => onFrameChange(Number(event.target.value))} aria-label="当前帧" />
<div className="timeline-scale"><span>{start}</span><span>{mid}</span><span>{second}</span><span>{third}</span><span>{fourth}</span><span>{end}</span></div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`关键帧 ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : null}</div>
</div>
);
}
export function App() {
const [uiState, setUIState] = useState(createDefaultWebWorkspaceState);
const [frame, setFrame] = useState(1);
const [saved, setSaved] = useState(true);
const [engineStatus, setEngineStatus] = useState("WASM: starting");
const [wasmStatus, setWasmStatus] = useState("WASM ABI: starting");
const [storageStatus, setStorageStatus] = useState("Storage: starting");
const [manifestStatus, setManifestStatus] = useState("Manifest: checking");
const [snapshot, setSnapshot] = useState<SceneSnapshotIR | null>(null);
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(() => new Set());
const [meshSelection, setMeshSelection] = useState<MeshEditSelection>({ meshId: null, mode: "FACE", indices: new Set() });
const [geometryBuffers, setGeometryBuffers] = useState<MeshGeometryBuffer[]>([]);
const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState<NonMeshGeometryChunk[]>([]);
const [gpuTextureAssets, setGPUTextureAssets] = useState<GPUTextureAsset[]>([]);
const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
const webClientRef = useRef<WebEngineClient | null>(null);
const storageClientRef = useRef<StorageClient | null>(null);
const autosaveRef = useRef<AutosaveScheduler | null>(null);
const commandCountRef = useRef(0);
const projectIdRef = useRef("untitled");
const fileInputRef = useRef<HTMLInputElement>(null);
const workspace = uiState.context.workspaceId;
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command));
const selectObject = (id: string, additive = false): void => {
setSelectedObjectIds((current) => {
if (!additive) return new Set([id]);
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
};
const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
if (owner) {
setSelectedObjectIds((current) => additive ? new Set([...current, owner.id]) : new Set([owner.id]));
setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current);
}
setMeshSelection((current) => {
const preserve = additive && current.meshId === meshId && current.mode === mode && current.nonMeshKind === nonMeshKind;
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 };
});
};
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
const storage = storageClientRef.current;
if (!storage) return;
try {
const listed = await storage.listLODManifests(projectId);
const restored: Record<string, WebEngineLODLevelResult[]> = {};
const objectIds = new Set(scene.nodes.filter((node) => node.dataId).map((node) => node.id));
const candidates = listed.manifests
.filter((manifest) => manifest.sourceMeshRevision === scene.revision && objectIds.has(manifest.objectId))
.sort((left, right) => (right.generatedAt ?? "").localeCompare(left.generatedAt ?? ""));
for (const manifest of candidates) {
if (restored[manifest.meshId]) continue;
try {
const cached = await storage.readLOD(projectId, manifest.cacheKey);
const levels = decodeLODGeometry(cached.data);
if (levels.length === 0 || levels.some((level) => level.geometryBuffers.some((geometry) => !geometry.meshId.startsWith(`${manifest.meshId}:lod:`)))) continue;
restored[manifest.meshId] = levels;
}
catch {
// A missing or corrupt cache is ignored; native generation remains available.
}
}
if (projectIdRef.current !== projectId) return;
setLodLevels(Object.keys(restored).length > 0 ? restored : null);
if (Object.keys(restored).length > 0) setEngineStatus(`Engine: SceneIR r${scene.revision} (${Object.keys(restored).length} cached LOD mesh)`);
}
catch {
// Storage can be unavailable in browsers without OPFS; the scene remains usable.
}
};
const invalidateCachedLODs = async (projectId: string, revision: number): Promise<void> => {
const storage = storageClientRef.current;
if (!storage) return;
try {
const listed = await storage.listLODManifests(projectId);
await Promise.all(listed.manifests.filter((manifest) => manifest.sourceMeshRevision !== revision).map(async (manifest) => {
try {
await storage.deleteLOD(projectId, manifest.cacheKey);
}
catch {
// A stale row can be pruned later without blocking the edit.
}
}));
}
catch {
// Cache invalidation is best effort and never blocks an engine command.
}
};
const applyEditCommand = async (command: WebEngineEditCommand): Promise<void> => {
const client = webClientRef.current;
if (!client || !snapshot) {
if (command.type === "setFrame") setFrame(command.frame);
return;
}
try {
if (command.type === "previewDecimateMesh") {
const result = await client.previewCommand(command);
setPreview({ snapshot: result.snapshot, geometryBuffers: result.geometryBuffers, nonMeshGeometryBuffers: result.nonMeshGeometryBuffers ?? [] });
setLodLevels(null);
setEngineStatus(result.simplify
? `Engine: Preview ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles`
: "Engine: Preview ready");
return;
}
const result = await client.applyCommand(command);
setPreview(null);
setLodLevels(null);
setSnapshot(result.snapshot);
setSelectedObjectIds(new Set(result.snapshot.activeObjectId ? [result.snapshot.activeObjectId] : []));
setGeometryBuffers(result.geometryBuffers);
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
setFrame(result.snapshot.frame.current);
if (command.type === "meshEdit" || command.type === "separateMeshFaces" || command.type === "joinObjects") {
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
}
setSaved(false);
void invalidateCachedLODs(projectIdRef.current, result.snapshot.revision);
setEngineStatus(result.simplify
? `Engine: Decimate ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles (r${result.snapshot.revision})`
: `Engine: SceneIR r${result.snapshot.revision} (${result.snapshot.nodes.length} objects)`);
if (command.type !== "undo" && command.type !== "redo") {
const storage = storageClientRef.current;
if (storage) {
try {
const operationId = `op-${result.snapshot.revision}-${crypto.randomUUID()}`;
await storage.appendOperation(operationId, projectIdRef.current, result.snapshot.revision, command);
commandCountRef.current += 1;
if (commandCountRef.current % 10 === 0) {
const blend = await client.saveBlend();
await storage.saveSnapshot(projectIdRef.current, result.snapshot.revision, blend.slice(0));
}
}
catch (error) {
setStorageStatus(`Storage: operation log failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
}
}
} catch (error) {
setEngineStatus(`Engine: command failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const applyDecimate = (profile: SimplifyProfile, meshId: string): void => {
void applyEditCommand({ type: "decimateMesh", meshId, profile });
};
const previewDecimate = (profile: SimplifyProfile, meshId: string): void => {
void applyEditCommand({ type: "previewDecimateMesh", meshId, profile });
};
const setModifierEnabled = (meshId: string, modifierUuid: string, enabled: boolean): void => {
void applyEditCommand({ type: "setModifierEnabled", meshId, modifierUuid, enabled });
};
const setMeshSelectionMode = (mode: MeshElementMode): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set() });
};
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)) });
};
const runMeshEdit = (operation: MeshEditOperation): void => {
if (!meshSelection.meshId || meshSelection.indices.size === 0) return;
void applyEditCommand({ type: "meshEdit", meshId: meshSelection.meshId, operation, selectionMode: meshSelection.mode,
elementIndices: [...meshSelection.indices], ...(operation === "EXTRUDE" ? { offset: [0, 0, 0.25] as [number, number, number] } : {}),
...(["INSET", "BEVEL"].includes(operation) ? { amount: 0.1 } : {}),
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}) });
};
const importImage = async (file: File): Promise<void> => {
try {
const bitmap = await createImageBitmap(file);
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
void applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
mimeType: file.type === "image/jpeg" ? "image/jpeg" : "image/png", width: bitmap.width, height: bitmap.height, base64: btoa(binary) });
bitmap.close();
}
catch (error) {
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
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 });
return;
}
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);
if (mesh && tool === "translate") {
const offset: [number, number, number] = [0, 0, 0];
offset[axis] = amount;
const vertexIndices = meshSelection.meshId === mesh.id && meshSelection.mode === "VERT" ? [...meshSelection.indices] : [];
if (vertexIndices.length > 0) void applyEditCommand({ type: "translateMeshVertices", meshId: mesh.id, vertexIndices, offset });
}
return;
}
const translation: [number, number, number] = [...activeNode.transform.translation];
const rotationEuler: [number, number, number] = [...activeNode.transform.rotationEuler];
const scale: [number, number, number] = [...activeNode.transform.scale];
if (tool === "translate") translation[axis] += amount;
if (tool === "rotate") rotationEuler[axis] += amount * Math.PI;
if (tool === "scale") scale[axis] *= Math.max(0.01, 1 + amount);
void applyEditCommand({ type: "setObjectTransform", objectId: activeNode.id, translation, rotationEuler, scale });
};
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select")) return;
const activeId = snapshot?.activeObjectId;
if (event.key === "Tab") {
event.preventDefault();
dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" });
}
else if ((event.key === "Delete" || event.key === "Backspace") && activeId) {
event.preventDefault();
void applyEditCommand({ type: "deleteObject", objectId: activeId });
}
else if (event.altKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand({ type: "duplicateObject", objectId: activeId, offset: [0.5, 0.5, 0], linked: true });
}
else if (event.shiftKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand({ type: "duplicateObject", objectId: activeId, offset: [0.25, 0.25, 0] });
}
else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
void applyEditCommand({ type: event.shiftKey ? "redo" : "undo" });
}
else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") {
event.preventDefault();
void applyEditCommand({ type: "redo" });
}
else if (["g", "r", "s"].includes(event.key.toLowerCase())) {
event.preventDefault();
transformActive(event.key.toLowerCase() === "g" ? "translate" : event.key.toLowerCase() === "r" ? "rotate" : "scale");
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [snapshot, uiState.context.mode]);
const generateLOD = async (meshId: string, triangleCount: number): Promise<void> => {
const client = webClientRef.current;
if (!client || !snapshot || triangleCount <= 0) return;
const budgets = [...new Set([triangleCount, Math.max(1, Math.floor(triangleCount * 0.5)), Math.max(1, Math.floor(triangleCount * 0.25))])];
const levels = budgets.map((triangleBudget) => ({
triangleBudget,
profile: {
schemaVersion: 1 as const,
sourceMeshRevision: snapshot.revision,
attributePolicy: "PRESERVE" as const,
mode: "COLLAPSE" as const,
ratio: triangleBudget / triangleCount,
triangulate: false,
useSymmetry: false,
},
}));
try {
const result = await client.generateLOD({ meshId, sourceMeshRevision: snapshot.revision, levels });
const profileHash = JSON.stringify(levels.map((level) => level.profile));
const sourceMesh = snapshot.meshes.find((mesh) => mesh.id === meshId);
const objectId = snapshot.nodes.find((node) => node.dataId === meshId)?.id ?? meshId;
const stackHash = modifierStackHash(sourceMesh?.modifierStack);
const cacheKey = buildLODCacheKey({ objectId, meshRevision: snapshot.revision, modifierStackHash: stackHash, profileHash, poseOrRestState: "REST" });
const encoded = encodeLODGeometry(result.lod.levels);
const byteLength = encoded.byteLength;
const manifest: LODCacheRecord = {
...result.lod.manifest,
cacheKey,
objectId,
modifierStackHash: stackHash,
profileHash,
poseOrRestState: "REST",
generatedAt: new Date().toISOString(),
byteLength,
};
const storage = storageClientRef.current;
let displayLevels = result.lod.levels;
let cacheReadWarning = "";
if (storage) {
await storage.saveLOD(projectIdRef.current, cacheKey, encoded.slice(0));
await storage.putLODManifest(projectIdRef.current, manifest);
try {
const cached = await storage.readLOD(projectIdRef.current, cacheKey);
displayLevels = decodeLODGeometry(cached.data);
} catch (error) {
cacheReadWarning = `; cache read failed, using generated geometry${error instanceof Error ? ` (${error.message})` : ""}`;
}
}
setLodLevels((current) => ({ ...(current ?? {}), [meshId]: displayLevels }));
setEngineStatus(`LOD: ${displayLevels.map((level) => `${level.outputTriangleCount}t`).join(" / ")} (${byteLength} bytes)${cacheReadWarning}`);
}
catch (error) {
setEngineStatus(`Engine: LOD generation failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
useEffect(() => {
const client = new WebEngineClient();
webClientRef.current = client;
let mounted = true;
void client.init()
.then((result) => {
if (mounted) {
setWasmStatus(result.ready ? `WASM ABI: ready (${result.liveHandles})` : "WASM ABI: unavailable");
setEngineStatus("Engine: ready, open a .blend file");
}
})
.catch((error: unknown) => {
if (mounted) {
setWasmStatus("WASM ABI: unavailable");
setEngineStatus(`Engine: unavailable${error instanceof Error ? ` (${error.message})` : ""}`);
}
});
return () => {
mounted = false;
webClientRef.current = null;
client.terminate();
};
}, []);
useEffect(() => {
let mounted = true;
void loadWebEngineManifest()
.then(async (manifest) => {
const requiredResource = manifest.wasm.find((resource) => resource.required);
if (requiredResource) await verifyWasmResource(requiredResource);
if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`);
})
.catch(() => {
if (mounted) setManifestStatus("Manifest: rejected");
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
const client = new StorageClient();
storageClientRef.current = client;
void client.smoke().then((result) => {
setStorageStatus(result.opfsAvailable ? "Storage: IndexedDB + OPFS" : "Storage: IndexedDB");
}).catch(() => {
setStorageStatus("Storage: unavailable");
});
return () => {
storageClientRef.current = null;
client.terminate();
};
}, []);
useEffect(() => {
const scheduler = new AutosaveScheduler();
autosaveRef.current = scheduler;
return () => {
autosaveRef.current = null;
scheduler.dispose();
};
}, []);
const cachePackedAssets = async (projectId: string, scene: SceneSnapshotIR): Promise<GPUTextureAsset[]> => {
const engine = webClientRef.current;
const storage = storageClientRef.current;
if (!engine || !storage) return [];
let requests: ReturnType<typeof collectGPUTextureAssetRequests>;
try {
requests = collectGPUTextureAssetRequests(scene);
}
catch (error) {
setEngineStatus(`PBR asset gate: ${error instanceof RenderAssetValidationError ? error.code : "GPU_TEXTURE_INVALID"}`);
return [];
}
const requestsByAssetId = new Map<string, typeof requests>();
for (const request of requests) requestsByAssetId.set(request.assetId, [...(requestsByAssetId.get(request.assetId) ?? []), request]);
const assets = scene.images.flatMap((image) => image.tiles?.length
? image.tiles.filter((tile) => tile.packed).map((tile) => ({ assetId: tile.assetId, mimeType: tile.mimeType, sourcePath: tile.sourcePath }))
: image.packed ? [{ assetId: image.assetId, mimeType: image.mimeType ?? "application/octet-stream", sourcePath: image.sourcePath }] : []);
const gpuAssets: GPUTextureAsset[] = [];
for (const asset of assets) {
try {
const requested = await engine.requestAsset(asset.assetId);
if (!requested.data) continue;
let sourcePath: string | undefined;
try {
sourcePath = asset.sourcePath ? normalizeProjectAssetPath(asset.sourcePath) : undefined;
}
catch {
sourcePath = undefined;
}
const storageData = requested.data.slice(0);
await storage.putAsset(projectId, storageData, asset.mimeType, sourcePath);
for (const request of requestsByAssetId.get(asset.assetId) ?? []) {
const width = request.width || requested.width || 0;
const height = request.height || requested.height || 0;
if (!width || !height) continue;
try {
gpuAssets.push(await createGPUTextureAsset({ ...request, width, height, mimeType: requested.mimeType ?? request.mimeType }, requested.data.slice(0)));
}
catch (error) {
if (error instanceof RenderAssetValidationError) setEngineStatus(`PBR asset blocked: ${error.code}`);
}
}
}
catch {
// Asset caching is recoverable and must not block opening the scene.
}
}
return gpuAssets;
};
const openBlendFile = async (file: File): Promise<void> => {
const client = webClientRef.current;
if (!client) return;
setOpenProgress({ requestId: "ui", operation: "blend.open", phase: "started", fraction: 0, message: `打开 ${file.name}` });
try {
const input = await file.arrayBuffer();
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
projectIdRef.current = projectId;
const result = await client.openBlend(input, setOpenProgress);
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
setSnapshot(result.snapshot);
setSelectedObjectIds(new Set(result.snapshot.activeObjectId ? [result.snapshot.activeObjectId] : []));
setGeometryBuffers(result.geometryBuffers);
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
setFrame(result.snapshot.frame.current);
setSaved(true);
setEngineStatus(`Engine: SceneIR r${result.snapshot.revision} (${result.snapshot.nodes.length} objects)`);
void cachePackedAssets(projectId, result.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
});
void restoreCachedLODs(projectId, result.snapshot);
} catch (error) {
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
} finally {
setOpenProgress(null);
}
};
const persistProject = async (): Promise<ArrayBuffer | null> => {
const client = webClientRef.current;
if (!client || !snapshot) return null;
const data = await client.saveBlend();
const storage = storageClientRef.current;
if (storage) {
await storage.saveProject(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
}
setSaved(true);
return data;
};
const recoverCachedProject = async (): Promise<void> => {
const client = webClientRef.current;
const storage = storageClientRef.current;
if (!client || !storage) return;
try {
const projectId = projectIdRef.current;
let baseRevision = 0;
let buffer: ArrayBuffer;
try {
const project = await storage.readProject(projectId);
baseRevision = project.revision;
buffer = project.buffer;
}
catch {
const listed = await storage.listSnapshots(projectId);
const latest = listed.snapshots[0];
if (!latest) throw new Error("no committed project or retained snapshot");
const saved = await storage.readSnapshot(projectId, latest.revision);
baseRevision = saved.revision;
buffer = saved.buffer;
}
let opened = await client.openBlend(buffer);
const replay = await storage.listOperations(projectId, baseRevision);
for (const operation of replay.operations) {
const payload = operation.payload as WebEngineEditCommand;
if (payload.type === "undo" || payload.type === "redo" || payload.type === "previewDecimateMesh") {
throw new Error(`operation ${operation.id} is not replayable`);
}
opened = await client.applyCommand(payload);
}
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
setSnapshot(opened.snapshot);
setGeometryBuffers(opened.geometryBuffers);
setNonMeshGeometryBuffers(opened.nonMeshGeometryBuffers ?? []);
setFrame(opened.snapshot.frame.current);
setSelectedObjectIds(new Set(opened.snapshot.activeObjectId ? [opened.snapshot.activeObjectId] : []));
setSaved(replay.operations.length === 0);
setEngineStatus(`Recovery: ${replay.operations.length} operation(s), ${replay.quarantined} quarantined`);
void cachePackedAssets(projectId, opened.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
});
}
catch (error) {
setEngineStatus(`Recovery: failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const saveBlend = async (): Promise<void> => {
try {
const data = await persistProject();
if (!data) return;
const url = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.blend";
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const reportGLBExport = async (): Promise<void> => {
if (!snapshot) return;
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
try {
const evaluated = await webClientRef.current.evaluateDepsgraph();
const mapped = mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, evaluated.depsgraph);
exportSnapshot = mapped.snapshot;
exportGeometryBuffers = mapped.geometryBuffers;
}
catch {
// The exporter will emit a machine-readable evaluation-required error.
}
}
const result = exportGLB(exportSnapshot, exportGeometryBuffers, [], nonMeshGeometryBuffers);
const report = result.report;
const errorCount = report.warnings.filter((warning) => warning.severity === "error").length;
const warningCount = report.warnings.length - errorCount;
if (result.glb) {
const url = URL.createObjectURL(new Blob([result.glb], { type: "model/gltf-binary" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.glb";
anchor.click();
URL.revokeObjectURL(url);
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
}
else setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
};
useEffect(() => {
if (saved || !snapshot) {
autosaveRef.current?.cancel();
return;
}
autosaveRef.current?.schedule(async () => {
try {
await persistProject();
} catch (error) {
setEngineStatus(`Engine: autosave failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
});
}, [saved, snapshot]);
const objectCount = snapshot?.nodes.length ?? 0;
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0;
const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 };
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Blender Web</span></div>
<nav className="menu-bar" aria-label="主菜单"><button type="button" onClick={() => fileInputRef.current?.click()}></button><button type="button"></button><button type="button"></button><button type="button"></button><button type="button"></button></nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
</nav>
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={reportGLBExport}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
</header>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Blender Web 0.1.0</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);
}

View File

@@ -0,0 +1,85 @@
:root {
color: #e5e7eb;
background: #18191c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 13px;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
button, input { font: inherit; }
button { color: inherit; border: 0; cursor: pointer; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; }
.topbar { gap: 14px; }
.brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; }
.brand-mark { color: #e37a2c; font-size: 18px; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.workspace-tabs { align-self: stretch; gap: 0; }
.workspace-tab { min-width: 82px; border-bottom: 2px solid transparent; border-radius: 0; }
.workspace-tab.active { color: #fff; background: #313338; border-bottom-color: #e37a2c; }
.topbar-actions { margin-left: auto; }
.workspace-toolbar { min-width: 0; overflow-x: auto; color: #aeb2bb; border-bottom-color: #111214; white-space: nowrap; scrollbar-width: thin; }
.mode-chip { padding: 3px 8px; color: #dedfe2; background: #35373c; border-radius: 3px; }
.segmented { display: inline-flex; flex: none; border: 1px solid #4a4d54; border-radius: 3px; overflow: hidden; }.segmented button { border-radius: 0; border-right: 1px solid #4a4d54; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #a55325; }
.toolbar-spacer, .header-spacer, .status-spacer { flex: 1; }
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; grid-template-rows: minmax(0, 1fr) 164px; min-height: 0; gap: 1px; background: #111214; }
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #24262a; }
.editor-header { display: flex; align-items: center; min-height: 28px; padding: 0 6px; background: #303238; border-bottom: 1px solid #17181b; }
.editor-selector, .icon-button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; background: transparent; color: #e1e3e7; border-radius: 3px; }
.editor-selector:hover, .icon-button:hover { background: #41434a; }
.editor-icon { color: #e37a2c; font-size: 14px; }
.icon-button { color: #a9adb5; }
.area-content { position: relative; flex: 1; min-height: 0; overflow: auto; }
.viewport-area { grid-column: 1; grid-row: 1; }
.outliner-area { grid-column: 2; grid-row: 1; }
.properties-area { grid-column: 2; grid-row: 2; }
.timeline-area { grid-column: 1; grid-row: 2; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; }
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
.viewport-message strong { color: #f3f4f6; font-size: 15px; }
.viewport-message span { color: #969ba4; }
.axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
.gizmo-axis.axis-x { left: 60px; top: 32px; color: #e35b55; }.gizmo-axis.axis-y { left: 32px; top: 4px; color: #65bb70; }.gizmo-axis.axis-z { left: 32px; top: 60px; color: #5d8ee8; }
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }
.outliner-content, .properties-content { padding: 8px; color: #c8cbd0; }
.outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; }
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { margin-left: auto; min-width: 22px; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
.property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
@media (max-width: 800px) {
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
.topbar::-webkit-scrollbar { display: none; }
.topbar > *, .topbar-actions button { flex: none; white-space: nowrap; }
.topbar-actions { margin-left: 0; }
.menu-bar { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 150px; }
.outliner-area, .properties-area { display: none; }
.viewport-area, .timeline-area { grid-column: 1; }
.viewport-area { grid-row: 1; }
.timeline-area { grid-row: 2; }
.workspace-tab { min-width: 64px; }
.status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; }
.timeline-content { padding-inline: 8px; }
.timeline-controls { gap: 2px; }
.timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; }
.frame-number { min-width: 38px; margin-left: 4px; padding-inline: 5px; }
}

View File

@@ -0,0 +1,121 @@
import type {
EngineCapabilities,
EngineRequest,
EngineResponse,
EngineCommand,
} from "../../../protocol/engine";
import type { ErrorReport } from "../../../protocol/error";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
interface PendingRequest {
resolve: (response: EngineResponse) => void;
reject: (reason: ErrorReport) => void;
timer: ReturnType<typeof setTimeout>;
}
export interface EngineClientOptions {
timeoutMs?: number;
workerFactory?: () => Worker;
}
const defaultWorkerFactory = () =>
new Worker(new URL("../workers/engine.worker.ts", import.meta.url), { type: "module" });
export class EngineClient {
private readonly timeoutMs: number;
private readonly workerFactory: () => Worker;
private worker: Worker | null = null;
private requestCounter = 0;
private pending = new Map<string, PendingRequest>();
private _revision = 0;
constructor(options: EngineClientOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 10_000;
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
}
get revision(): number {
return this._revision;
}
start(): void {
if (this.worker) return;
const worker = this.workerFactory();
worker.onmessage = (event: MessageEvent<EngineResponse>) => this.handleResponse(event.data);
worker.onerror = () => this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 发生异常", true));
this.worker = worker;
}
async init(protocolVersion = 1): Promise<EngineCapabilities> {
const response = await this.request({ type: "init", protocolVersion });
if (!response.capabilities) throw this.report("WASM_INIT_FAILED", "EngineWorker 未返回能力信息", false);
return response.capabilities;
}
async getSceneSnapshot(): Promise<SceneSnapshotIR> {
const response = await this.request({ type: "getSceneSnapshot", sinceRevision: this._revision });
if (!response.snapshot) throw this.report("INVALID_ARGUMENT", "EngineWorker 未返回 SceneIR", false);
return response.snapshot;
}
async setFrame(frame: number): Promise<void> {
await this.request({ type: "setFrame", frame });
}
async setObjectVisibility(objectId: string, visible: boolean): Promise<void> {
await this.request({ type: "setObjectVisibility", objectId, visible });
}
restart(): void {
this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 已重启", true));
this.worker?.terminate();
this.worker = null;
this._revision = 0;
this.start();
}
terminate(): void {
this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 已关闭", true));
this.worker?.terminate();
this.worker = null;
}
private request(command: EngineCommand): Promise<EngineResponse> {
this.start();
const worker = this.worker;
if (!worker) return Promise.reject(this.report("WASM_INIT_FAILED", "无法创建 EngineWorker", false));
const requestId = `engine-${++this.requestCounter}`;
const message: EngineRequest = { requestId, expectedRevision: this._revision, command };
return new Promise<EngineResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(this.report("WORKER_TERMINATED", `请求超时: ${command.type}`, true));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, timer });
worker.postMessage(message);
});
}
private handleResponse(response: EngineResponse): void {
const pending = this.pending.get(response.requestId);
if (!pending) return;
this.pending.delete(response.requestId);
clearTimeout(pending.timer);
this._revision = response.revision;
if (response.ok) pending.resolve(response);
else pending.reject(response.reports?.[0] ?? this.report("INVALID_ARGUMENT", "EngineWorker 请求失败", true));
}
private failPending(report: ErrorReport): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(report);
}
this.pending.clear();
}
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
return { code, severity: "error", message, recoverable };
}
}

View File

@@ -0,0 +1,210 @@
import type { ErrorReport } from "../../../protocol/error";
import type { ProgressEvent } from "../../../protocol/progress";
import type {
AssetRequestResult,
MeshGeometryBuffer,
WebEngineEditCommand,
WebEngineLODResult,
WebEngineRequest,
WebEngineResponse,
WebEngineResult,
WebEngineStatus,
} from "../../../protocol/web-engine";
import type { LODGenerationRequest } from "../../../protocol/lod";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { SceneDelta } from "../../../protocol/scene-delta";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SimplifyResult } from "../../../protocol/simplify";
import type { DepsgraphEvaluationIR } from "../../../protocol/depsgraph";
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta";
interface PendingRequest {
resolve: (result: WebEngineResult) => void;
reject: (error: ErrorReport) => void;
onProgress?: (progress: ProgressEvent) => void;
timer: ReturnType<typeof setTimeout>;
}
export interface WebEngineClientOptions {
timeoutMs?: number;
workerFactory?: () => Worker;
}
const defaultWorkerFactory = () =>
new Worker(new URL("../workers/web-engine.worker.ts", import.meta.url), { type: "module" });
export interface BlendOpenResult {
status: WebEngineStatus;
snapshot: SceneSnapshotIR;
geometryBuffers: MeshGeometryBuffer[];
nonMeshGeometryBuffers?: NonMeshGeometryChunk[];
simplify?: SimplifyResult;
}
export class WebEngineClient {
private readonly timeoutMs: number;
private readonly workerFactory: () => Worker;
private worker: Worker | null = null;
private requestCounter = 0;
private pending = new Map<string, PendingRequest>();
private geometryBuffers: MeshGeometryBuffer[] = [];
private nonMeshGeometryBuffers: NonMeshGeometryChunk[] = [];
constructor(options: WebEngineClientOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 30_000;
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
}
async init(): Promise<WebEngineStatus> {
return (await this.request({ type: "init" })).status;
}
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
if (buffer.byteLength === 0) {
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
}
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
this.geometryBuffers = result.geometryBuffers ?? [];
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
}
async snapshot(): Promise<BlendOpenResult> {
const result = await this.request({ type: "snapshot" });
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneIR", true);
this.geometryBuffers = result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
}
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {
const result = await this.request({ type: "delta" });
if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true);
return { status: result.status, delta: result.delta };
}
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta }> {
const result = await this.request({ type: "applyCommand", payload });
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true);
this.geometryBuffers = result.geometryDelta
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
: result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return {
status: result.status,
snapshot: result.snapshot,
geometryBuffers: [...this.geometryBuffers],
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
simplify: result.simplify,
};
}
async previewCommand(payload: Extract<WebEngineEditCommand, { type: "previewDecimateMesh" }>): Promise<BlendOpenResult> {
const result = await this.request({ type: "applyCommand", payload });
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回预览结果", true);
return { status: result.status, snapshot: result.snapshot, geometryBuffers: result.geometryBuffers ?? [], nonMeshGeometryBuffers: result.nonMeshGeometryBuffers ?? [], simplify: result.simplify };
}
async generateLOD(payload: LODGenerationRequest): Promise<{ status: WebEngineStatus; lod: WebEngineLODResult }> {
const result = await this.request({ type: "generateLOD", payload });
if (!result.lod) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 LOD 结果", true);
return { status: result.status, lod: result.lod };
}
async requestAsset(assetId: string): Promise<AssetRequestResult> {
if (!assetId) throw this.report("INVALID_ARGUMENT", "Asset ID 不能为空", true);
const result = await this.request({ type: "requestAsset", assetId });
if (!result.asset) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回资产结果", true);
return result.asset;
}
async queryRenderCapability(request: RenderCapabilityRequest): Promise<CapabilityGateResult> {
const result = await this.request({ type: "queryRenderCapability", request });
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine 未返回渲染能力门", true);
return result.capabilityGate;
}
async queryNonMeshCapability(dataId: string): Promise<CapabilityGateResult> {
const result = await this.request({ type: "queryNonMeshCapability", dataId });
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine did not return the non-mesh capability gate", true);
return result.capabilityGate;
}
async evaluateDepsgraph(): Promise<{ status: WebEngineStatus; depsgraph: DepsgraphEvaluationIR }> {
const result = await this.request({ type: "evaluateDepsgraph" });
if (!result.depsgraph) throw this.report("CAPABILITY_MISSING", "WebEngine 未返回 Blender Depsgraph 结果", true);
return { status: result.status, depsgraph: result.depsgraph };
}
async saveBlend(): Promise<ArrayBuffer> {
const result = await this.request({ type: "saveBlend" });
if (!result.blend) throw this.report("BLEND_WRITE_FAILED", "WebEngine 未返回 .blend 数据", true);
return result.blend;
}
terminate(): void {
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
this.worker?.terminate();
this.worker = null;
this.geometryBuffers = [];
this.nonMeshGeometryBuffers = [];
}
private start(): Worker {
if (this.worker) return this.worker;
const worker = this.workerFactory();
worker.onmessage = (event: MessageEvent<WebEngineResponse>) => this.handleResponse(event.data);
worker.onerror = (event) => {
this.failPending(this.report("WORKER_TERMINATED", event.message || "WebEngineWorker 发生异常", true));
};
this.worker = worker;
return worker;
}
private request(
command: WebEngineRequest["command"],
transfer: Transferable[] = [],
onProgress?: (progress: ProgressEvent) => void,
): Promise<WebEngineResult> {
const worker = this.start();
const requestId = `web-engine-${++this.requestCounter}`;
const request = { requestId, command } as WebEngineRequest;
return new Promise<WebEngineResult>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, onProgress, timer });
worker.postMessage(request, transfer);
});
}
private handleResponse(response: WebEngineResponse): void {
const pending = this.pending.get(response.requestId);
if (!pending) return;
if (response.kind === "progress") {
pending.onProgress?.(response.progress);
return;
}
this.pending.delete(response.requestId);
clearTimeout(pending.timer);
if (response.ok) pending.resolve(response.result);
else pending.reject(response.error);
}
private failPending(error: ErrorReport): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
return { code, severity: "error", message, recoverable };
}
}

12
web/app/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/App";
const root = document.getElementById("root");
if (!root) throw new Error("Missing application root");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,54 @@
export interface BrowserCapabilities {
webgl2: boolean;
offscreenCanvas: boolean;
opfs: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
wasm: boolean;
wasmSimd: boolean;
wasmThreads: boolean;
storageEstimate: boolean;
indexedDb: boolean;
}
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
if (typeof WebAssembly === "undefined") return false;
const modules: Record<typeof feature, Uint8Array> = {
simd: new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60,
0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x0a, 0x0b, 0x01, 0x09, 0x00, 0xfd,
0x00, 0x00, 0x0b,
]),
threads: new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60,
0x00, 0x00, 0x05, 0x04, 0x01, 0x01, 0x01, 0x00, 0x0a, 0x04, 0x01, 0x02,
0x00, 0x0b,
]),
};
try {
return WebAssembly.validate(modules[feature] as BufferSource);
} catch {
return false;
}
}
export function detectBrowserCapabilities(): BrowserCapabilities {
const canvas = typeof document === "undefined" ? undefined : document.createElement("canvas");
const webgl2 = Boolean(canvas?.getContext("webgl2"));
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
return {
webgl2,
offscreenCanvas: typeof OffscreenCanvas !== "undefined",
opfs: Boolean(storage?.getDirectory),
sharedArrayBuffer: typeof SharedArrayBuffer !== "undefined",
worker: typeof Worker !== "undefined",
wasm: typeof WebAssembly !== "undefined",
wasmSimd: supportsWasmFeature("simd"),
wasmThreads: supportsWasmFeature("threads"),
storageEstimate: Boolean(storage?.estimate),
indexedDb: typeof indexedDB !== "undefined",
};
}

View File

@@ -0,0 +1,155 @@
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 { LODCacheRecord } from "../../../protocol/lod";
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
export class StorageClient {
private readonly worker: Worker;
private counter = 0;
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number }>();
constructor() {
this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<StorageResponse>) => {
const pending = this.pending.get(event.data.requestId);
if (!pending) return;
this.pending.delete(event.data.requestId);
window.clearTimeout(pending.timeout);
if (event.data.ok && event.data.result) pending.resolve(event.data.result);
else {
const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string };
error.code = event.data.errorCode;
pending.reject(error);
}
};
this.worker.onerror = (event) => {
const error = new Error(event.message || "StorageWorker error");
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
};
}
smoke(): Promise<StorageSmokeResult> {
return this.request({ type: "smoke" }) as Promise<StorageSmokeResult>;
}
info(): Promise<StorageInfoResult> {
return this.request({ type: "info" }) as Promise<StorageInfoResult>;
}
ensureProject(projectId: string): Promise<StorageProjectResult> {
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
}
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "quota"): Promise<StorageSaveResult> {
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
}
recoverProject(projectId: string): Promise<StorageRecoveryResult> {
return this.request({ type: "recoverProject", projectId }) as Promise<StorageRecoveryResult>;
}
readProject(projectId: string): Promise<StorageProjectReadResult> {
return this.request({ type: "readProject", projectId }) as Promise<StorageProjectReadResult>;
}
appendOperation(id: string, projectId: string, revision: number, payload: unknown, inversePayload?: unknown): Promise<StorageOperationResult> {
return this.request({ type: "appendOperation", id, projectId, revision, payload, inversePayload }) as Promise<StorageOperationResult>;
}
listOperations(projectId: string, afterRevision: number): Promise<StorageOperationListResult> {
return this.request({ type: "listOperations", projectId, afterRevision }) as Promise<StorageOperationListResult>;
}
pruneOperations(projectId: string, throughRevision: number): Promise<StorageOperationPruneResult> {
return this.request({ type: "pruneOperations", projectId, throughRevision }) as Promise<StorageOperationPruneResult>;
}
saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise<StorageSnapshotResult> {
return this.request({ type: "saveSnapshot", projectId, revision, buffer, maxCount, maxBytes }, [buffer]) as Promise<StorageSnapshotResult>;
}
listSnapshots(projectId: string): Promise<StorageSnapshotListResult> {
return this.request({ type: "listSnapshots", projectId }) as Promise<StorageSnapshotListResult>;
}
readSnapshot(projectId: string, revision: number): Promise<StorageSnapshotReadResult> {
return this.request({ type: "readSnapshot", projectId, revision }) as Promise<StorageSnapshotReadResult>;
}
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult> {
return this.request({ type: "putAsset", projectId, data, mimeType, sourcePath }, [data]) as Promise<StorageAssetPutResult>;
}
readAsset(projectId: string, sha256: string): Promise<StorageAssetReadResult> {
return this.request({ type: "readAsset", projectId, sha256 }) as Promise<StorageAssetReadResult>;
}
listAssets(projectId: string): Promise<StorageAssetListResult> {
return this.request({ type: "listAssets", projectId }) as Promise<StorageAssetListResult>;
}
saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise<StorageLODResult>;
}
putLODManifest(projectId: string, manifest: LODCacheRecord): Promise<StorageLODManifestResult> {
return this.request({ type: "putLODManifest", projectId, manifest }) as Promise<StorageLODManifestResult>;
}
getLODManifest(projectId: string, cacheKey: string): Promise<StorageLODManifestResult> {
return this.request({ type: "getLODManifest", projectId, cacheKey }) as Promise<StorageLODManifestResult>;
}
listLODManifests(projectId: string): Promise<StorageLODManifestListResult> {
return this.request({ type: "listLODManifests", projectId }) as Promise<StorageLODManifestListResult>;
}
readLOD(projectId: string, cacheKey: string): Promise<StorageLODReadResult> {
return this.request({ type: "readLOD", projectId, cacheKey }) as Promise<StorageLODReadResult>;
}
deleteLOD(projectId: string, cacheKey: string): Promise<StorageLODManifestResult> {
return this.request({ type: "deleteLOD", projectId, cacheKey }) as Promise<StorageLODManifestResult>;
}
pruneLOD(projectId: string, maxBytes: number): Promise<StorageLODPruneResult> {
return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise<StorageLODPruneResult>;
}
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise<StorageSimulationCacheResult> {
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise<StorageSimulationCacheResult>;
}
readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
}
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
const requestId = `storage-${++this.counter}`;
const request: StorageRequest = { requestId, command };
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pending.delete(requestId);
reject(new Error("StorageWorker timeout"));
}, 10_000);
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout });
this.worker.postMessage(request, transfer);
});
}
terminate(): void {
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(new Error("StorageClient terminated"));
}
this.pending.clear();
this.worker.terminate();
}
}

View File

@@ -0,0 +1,21 @@
export class AutosaveScheduler {
private timer: number | null = null;
schedule(callback: () => void | Promise<void>, delayMs = 1500): void {
this.cancel();
this.timer = window.setTimeout(() => {
this.timer = null;
void callback();
}, delayMs);
}
cancel(): void {
if (this.timer === null) return;
window.clearTimeout(this.timer);
this.timer = null;
}
dispose(): void {
this.cancel();
}
}

View File

@@ -0,0 +1,34 @@
export const STORAGE_DATABASE_NAME = "blender-web-metadata";
export const STORAGE_SCHEMA_VERSION = 6;
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const;
export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void {
if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) {
db.createObjectStore("smoke", { keyPath: "id" });
}
if (oldVersion < 2) {
if (!db.objectStoreNames.contains("project")) db.createObjectStore("project", { keyPath: "id" });
if (!db.objectStoreNames.contains("asset")) db.createObjectStore("asset", { keyPath: "id" });
if (!db.objectStoreNames.contains("snapshot")) db.createObjectStore("snapshot", { keyPath: "id" });
if (!db.objectStoreNames.contains("operation_log")) db.createObjectStore("operation_log", { keyPath: "id" });
if (!db.objectStoreNames.contains("setting")) db.createObjectStore("setting", { keyPath: "id" });
if (!db.objectStoreNames.contains("migration")) db.createObjectStore("migration", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-2", version: 2, appliedAt: new Date().toISOString() });
}
if (oldVersion < 3) {
if (!db.objectStoreNames.contains("lod_manifest")) db.createObjectStore("lod_manifest", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-3", version: 3, appliedAt: new Date().toISOString() });
}
if (oldVersion < 4) {
transaction.objectStore("migration").put({ id: "schema-4", version: 4, appliedAt: new Date().toISOString() });
}
if (oldVersion < 5) {
if (!db.objectStoreNames.contains("operation_quarantine")) db.createObjectStore("operation_quarantine", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-5", version: 5, appliedAt: new Date().toISOString() });
}
if (oldVersion < 6) {
if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() });
}
}

View File

@@ -0,0 +1,382 @@
const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
export interface OpfsProjectLayout {
projectId: string;
projectPath: string;
scenePath: string;
snapshotsPath: string;
assetsPath: string;
thumbsPath: string;
tmpPath: string;
cachePath: string;
lodPath: string;
}
export function validateProjectId(projectId: string): string {
if (!PROJECT_ID_PATTERN.test(projectId)) throw new Error("Invalid project id");
return projectId;
}
export function validateFileName(fileName: string): string {
if (!FILE_NAME_PATTERN.test(fileName) || fileName === "." || fileName === "..") {
throw new Error("Invalid OPFS file name");
}
return fileName;
}
export function validateSha256(sha256: string): string {
if (!SHA256_PATTERN.test(sha256)) throw new Error("Invalid SHA-256 digest");
return sha256;
}
export function projectLayout(projectId: string): OpfsProjectLayout {
validateProjectId(projectId);
const projectPath = `projects/${projectId}`;
return {
projectId,
projectPath,
scenePath: `${projectPath}/scene.blend`,
snapshotsPath: `${projectPath}/snapshots`,
assetsPath: `${projectPath}/assets`,
thumbsPath: `${projectPath}/thumbs`,
tmpPath: `${projectPath}/tmp`,
cachePath: `${projectPath}/cache`,
lodPath: `${projectPath}/cache/lod`,
};
}
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
export type ProjectSaveFault = "after-stage" | "after-scene-commit";
export interface ProjectBlendManifest {
schemaVersion: 1;
projectId: string;
revision: number;
bytes: number;
sha256: string;
committedAt: string;
}
interface ProjectBlendJournal extends Omit<ProjectBlendManifest, "committedAt"> {
stageName: string;
createdAt: string;
}
export interface ProjectBlendCommitResult {
layout: OpfsProjectLayout;
manifest: ProjectBlendManifest;
recovered: boolean;
}
export interface ProjectBlendRecoveryResult {
layout: OpfsProjectLayout;
status: "clean" | "recovered" | "missing";
manifest?: ProjectBlendManifest;
}
export function opfsAvailable(storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): boolean {
return Boolean(storage && "getDirectory" in storage);
}
async function ensureDirectory(root: FileSystemDirectoryHandle, path: string): Promise<FileSystemDirectoryHandle> {
let current = root;
for (const segment of path.split("/")) {
if (!segment || segment === "." || segment === "..") throw new Error("Invalid OPFS path");
current = await current.getDirectoryHandle(segment, { create: true });
}
return current;
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
async function writeFile(directory: FileSystemDirectoryHandle, name: string, data: ArrayBuffer | string): Promise<void> {
validateFileName(name);
const handle = await directory.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
await writable.write(data);
await writable.close();
}
async function readFile(directory: FileSystemDirectoryHandle, name: string): Promise<ArrayBuffer> {
validateFileName(name);
return (await (await directory.getFileHandle(name)).getFile()).arrayBuffer();
}
async function readJsonFile<T>(directory: FileSystemDirectoryHandle, name: string): Promise<T | undefined> {
try {
const bytes = await readFile(directory, name);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return undefined;
throw error;
}
}
async function removeFile(directory: FileSystemDirectoryHandle, name: string): Promise<void> {
try {
await directory.removeEntry(name);
}
catch (error) {
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
}
}
async function verifyFile(directory: FileSystemDirectoryHandle, name: string, bytes: number, sha256: string): Promise<boolean> {
try {
const data = await readFile(directory, name);
return data.byteLength === bytes && await sha256Hex(data) === sha256;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return false;
throw error;
}
}
function validateManifest(value: ProjectBlendManifest | undefined, projectId: string): ProjectBlendManifest | undefined {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.committedAt !== "string") {
return undefined;
}
return value;
}
function validateJournal(value: ProjectBlendJournal | undefined, projectId: string): ProjectBlendJournal {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.createdAt !== "string" ||
!/^scene\.blend\.[A-Za-z0-9-]{1,64}\.stage$/.test(value.stageName)) {
throw new Error("PROJECT_RECOVERY_JOURNAL_INVALID: save journal is malformed");
}
return value;
}
async function commitStagedBlend(project: FileSystemDirectoryHandle, stageName: string): Promise<void> {
const stage = await project.getFileHandle(stageName);
const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
if (move) {
await move.call(stage, "scene.blend");
return;
}
await writeFile(project, "scene.blend", await (await stage.getFile()).arrayBuffer());
}
async function finalizeBlendCommit(
project: FileSystemDirectoryHandle,
tmp: FileSystemDirectoryHandle,
journal: ProjectBlendJournal,
): Promise<ProjectBlendManifest> {
const manifest: ProjectBlendManifest = {
schemaVersion: 1,
projectId: journal.projectId,
revision: journal.revision,
bytes: journal.bytes,
sha256: journal.sha256,
committedAt: new Date().toISOString(),
};
await writeFile(project, "scene.blend.meta.json", JSON.stringify(manifest));
await removeFile(project, journal.stageName);
await removeFile(tmp, "scene.blend.journal.json");
return manifest;
}
export async function ensureProjectLayout(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise<OpfsProjectLayout> {
const layout = projectLayout(projectId);
const manager = storage as OpfsStorage | undefined;
if (!manager?.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
await ensureDirectory(root, layout.snapshotsPath);
await ensureDirectory(root, layout.assetsPath);
await ensureDirectory(root, layout.thumbsPath);
await ensureDirectory(root, layout.tmpPath);
await ensureDirectory(root, layout.lodPath);
return layout;
}
export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise<OpfsProjectLayout> {
const layout = await ensureProjectLayout(projectId, storage);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
validateFileName(`${cacheKey}.mesh`);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const lod = await ensureDirectory(root, layout.lodPath);
const temporaryName = `${cacheKey}.mesh.${crypto.randomUUID()}.tmp`;
const temporary = await lod.getFileHandle(temporaryName, { create: true });
const writable = await temporary.createWritable();
await writable.write(data);
await writable.close();
const target = await lod.getFileHandle(`${cacheKey}.mesh`, { create: true });
const targetWritable = await target.createWritable();
await targetWritable.write(await (await temporary.getFile()).arrayBuffer());
await targetWritable.close();
await lod.removeEntry(temporaryName);
return layout;
}
export async function readLodCache(projectId: string, cacheKey: string, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
validateFileName(`${cacheKey}.mesh`);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const lod = await ensureDirectory(root, layout.lodPath);
const file = await lod.getFileHandle(`${cacheKey}.mesh`);
return (await file.getFile()).arrayBuffer();
}
export async function deleteLodCache(projectId: string, cacheKey: string, storage?: StorageManager): Promise<void> {
const layout = projectLayout(projectId);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const lod = await ensureDirectory(root, layout.lodPath);
try {
await lod.removeEntry(`${cacheKey}.mesh`);
}
catch (error) {
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
}
}
export async function writeProjectBlend(
projectId: string,
revision: number,
data: ArrayBuffer,
storage?: StorageManager,
faultAt?: ProjectSaveFault,
): Promise<ProjectBlendCommitResult> {
if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid project revision");
if (data.byteLength === 0) throw new Error("Project blend buffer is empty");
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const project = await ensureDirectory(root, layout.projectPath);
const tmp = await ensureDirectory(root, layout.tmpPath);
const stageName = `scene.blend.${crypto.randomUUID()}.stage`;
const sha256 = await sha256Hex(data);
const journal: ProjectBlendJournal = {
schemaVersion: 1,
projectId,
revision,
bytes: data.byteLength,
sha256,
stageName,
createdAt: new Date().toISOString(),
};
await writeFile(project, stageName, data);
if (!await verifyFile(project, stageName, journal.bytes, journal.sha256)) {
throw new Error("PROJECT_SAVE_STAGE_VERIFY_FAILED: staged blend does not match its digest");
}
await writeFile(tmp, "scene.blend.journal.json", JSON.stringify(journal));
if (faultAt === "after-stage") {
throw new Error("PROJECT_SAVE_FAULT_INJECTED: after-stage");
}
await commitStagedBlend(project, stageName);
if (!await verifyFile(project, "scene.blend", journal.bytes, journal.sha256)) {
throw new Error("PROJECT_SAVE_COMMIT_VERIFY_FAILED: committed blend does not match its digest");
}
if (faultAt === "after-scene-commit") {
throw new Error("PROJECT_SAVE_FAULT_INJECTED: after-scene-commit");
}
const manifest = await finalizeBlendCommit(project, tmp, journal);
return { layout, manifest, recovered: false };
}
export async function readProjectBlend(projectId: string, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const project = await ensureDirectory(root, layout.projectPath);
const file = await project.getFileHandle("scene.blend");
return (await file.getFile()).arrayBuffer();
}
export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise<ProjectBlendRecoveryResult> {
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const project = await ensureDirectory(root, layout.projectPath);
const tmp = await ensureDirectory(root, layout.tmpPath);
const rawJournal = await readJsonFile<ProjectBlendJournal>(tmp, "scene.blend.journal.json");
if (!rawJournal) {
const rawManifest = await readJsonFile<ProjectBlendManifest>(project, "scene.blend.meta.json");
const manifest = validateManifest(rawManifest, projectId);
if (!manifest) return { layout, status: "missing" };
if (!await verifyFile(project, "scene.blend", manifest.bytes, manifest.sha256)) {
throw new Error("PROJECT_RECOVERY_INTEGRITY_FAILED: scene.blend does not match its manifest");
}
return { layout, status: "clean", manifest };
}
const journal = validateJournal(rawJournal, projectId);
if (!await verifyFile(project, "scene.blend", journal.bytes, journal.sha256)) {
if (!await verifyFile(project, journal.stageName, journal.bytes, journal.sha256)) {
throw new Error("PROJECT_RECOVERY_INCOMPLETE: neither committed nor staged blend matches the journal");
}
await commitStagedBlend(project, journal.stageName);
}
if (!await verifyFile(project, "scene.blend", journal.bytes, journal.sha256)) {
throw new Error("PROJECT_RECOVERY_COMMIT_FAILED: recovered blend failed integrity verification");
}
const manifest = await finalizeBlendCommit(project, tmp, journal);
return { layout, status: "recovered", manifest };
}
export async function writeContentAsset(projectId: string, sha256: string, data: ArrayBuffer, storage?: StorageManager): Promise<{ layout: OpfsProjectLayout; path: string; deduplicated: boolean }> {
const layout = await ensureProjectLayout(projectId, storage);
validateSha256(sha256);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const directoryPath = `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`;
const directory = await ensureDirectory(root, directoryPath);
const path = `${directoryPath}/${sha256}`;
try {
const existing = await directory.getFileHandle(sha256);
const bytes = await (await existing.getFile()).arrayBuffer();
if (bytes.byteLength !== data.byteLength) throw new Error("Content-addressed asset size mismatch");
return { layout, path, deduplicated: true };
}
catch (error) {
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
}
const temporaryName = `${sha256}.${crypto.randomUUID()}.tmp`;
const temporary = await directory.getFileHandle(temporaryName, { create: true });
const writable = await temporary.createWritable();
await writable.write(data);
await writable.close();
const move = (temporary as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
if (move) {
await move.call(temporary, sha256);
}
else {
const target = await directory.getFileHandle(sha256, { create: true });
const targetWritable = await target.createWritable();
await targetWritable.write(await (await temporary.getFile()).arrayBuffer());
await targetWritable.close();
await directory.removeEntry(temporaryName);
}
return { layout, path, deduplicated: false };
}
export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
validateSha256(sha256);
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 directory.getFileHandle(sha256);
return (await file.getFile()).arrayBuffer();
}

View File

@@ -0,0 +1 @@
export { applyMeshGeometryDelta, diffMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";

View File

@@ -0,0 +1,75 @@
import {
BufferGeometry,
Color,
Float32BufferAttribute,
Group,
Line,
LineBasicMaterial,
type Object3D,
} from "../vendor/three/three.module.js";
import type { GreasePencilDataIR, GreasePencilFrameIR } from "../../../protocol/grease-pencil";
import type { SceneNodeIR } from "../../../protocol/scene-ir";
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
let selected: GreasePencilFrameIR | undefined;
for (const candidate of frames) {
if (candidate.frame <= frame && (!selected || candidate.frame > selected.frame)) selected = candidate;
}
return selected;
}
export function createGreasePencilObject(data: GreasePencilDataIR, frame: number): Object3D | null {
if (data.geometryStatus !== "available") return null;
const group = new Group();
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));
}
}
return group.children.length > 0 ? group : null;
}
export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;
object.position.set(x, z, -y);
object.rotation.set(rx, rz, -ry);
object.scale.set(...node.transform.scale);
object.name = node.name;
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
child.userData.greasePencilDataId = node.dataId;
});
}

View File

@@ -0,0 +1,122 @@
import { Object3D, PerspectiveCamera, OrthographicCamera, Vector3, type Camera } from "../vendor/three/three.module.js";
import { buildLODCacheKey } from "../../../protocol/lod";
export { buildLODCacheKey };
export interface LODSelectionPolicy {
/** Descending minimum projected pixel heights for level 0, 1, ... */
screenHeightThresholds: readonly number[];
/** Fractional dead-band around a threshold, preventing rapid toggling. */
hysteresis?: number;
}
export interface LODSelectionResult {
level: number;
projectedPixelHeight: number;
}
function validateThresholds(thresholds: readonly number[]): void {
if (thresholds.length === 0 || thresholds.some((value) => !Number.isFinite(value) || value <= 0)) {
throw new Error("LOD screenHeightThresholds must contain positive finite values");
}
for (let index = 1; index < thresholds.length; index++) {
if (thresholds[index] >= thresholds[index - 1]) throw new Error("LOD thresholds must strictly decrease");
}
}
export function selectLODLevel(projectedPixelHeight: number, policy: LODSelectionPolicy, currentLevel?: number): LODSelectionResult {
validateThresholds(policy.screenHeightThresholds);
if (!Number.isFinite(projectedPixelHeight) || projectedPixelHeight < 0) throw new Error("projectedPixelHeight must be finite and non-negative");
const hysteresis = policy.hysteresis ?? 0.08;
if (!Number.isFinite(hysteresis) || hysteresis < 0 || hysteresis >= 0.5) throw new Error("LOD hysteresis must be in [0, 0.5)");
let target = policy.screenHeightThresholds.findIndex((threshold) => projectedPixelHeight >= threshold);
if (target < 0) target = policy.screenHeightThresholds.length - 1;
if (currentLevel === undefined) return { level: target, projectedPixelHeight };
const current = Math.max(0, Math.min(policy.screenHeightThresholds.length - 1, Math.trunc(currentLevel)));
if (target === current || policy.screenHeightThresholds.length === 1) return { level: current, projectedPixelHeight };
if (target > current) {
const boundary = policy.screenHeightThresholds[current];
if (projectedPixelHeight >= boundary * (1 - hysteresis)) return { level: current, projectedPixelHeight };
}
else {
const boundary = policy.screenHeightThresholds[target];
if (projectedPixelHeight < boundary * (1 + hysteresis)) return { level: current, projectedPixelHeight };
}
return { level: target, projectedPixelHeight };
}
export function projectedPixelHeight(radius: number, distance: number, camera: Camera, viewportHeight: number): number {
if (!Number.isFinite(radius) || radius < 0 || !Number.isFinite(distance) || distance <= 0 || viewportHeight <= 0) return 0;
if ((camera as OrthographicCamera).isOrthographicCamera) {
const orthographic = camera as OrthographicCamera;
return (radius * 2 * viewportHeight) / Math.max(0.0001, orthographic.top - orthographic.bottom);
}
const perspective = camera as PerspectiveCamera;
const focalPixels = viewportHeight / (2 * Math.tan((perspective.fov * Math.PI) / 360));
return (radius * 2 * focalPixels) / distance;
}
export interface ThreeLODLevel {
object: Object3D;
screenHeightThreshold: number;
}
interface Entry {
levels: ThreeLODLevel[];
radius: number;
currentLevel?: number;
enabled: boolean;
}
/** Selects already-generated native LOD objects without rebuilding the scene. */
export class ThreeLODAdapter {
private readonly entries = new Map<string, Entry>();
register(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
if (!meshId || levels.length === 0) throw new Error("LOD meshId and levels are required");
const normalized = [...levels];
const thresholds = normalized.map((level) => level.screenHeightThreshold);
validateThresholds(thresholds);
this.entries.set(meshId, { levels: normalized, radius: Math.max(0, radius), enabled: true });
normalized.forEach((level, index) => { level.object.visible = index === 0; });
}
unregister(meshId: string): void {
this.entries.delete(meshId);
}
setEnabled(meshId: string, enabled: boolean): void {
const entry = this.entries.get(meshId);
if (!entry) return;
entry.enabled = enabled;
if (!enabled) entry.levels.forEach((level) => { level.object.visible = false; });
}
update(camera: Camera, viewportHeight: number, hysteresis = 0.08): Map<string, LODSelectionResult> {
const result = new Map<string, LODSelectionResult>();
const cameraPosition = new Vector3();
camera.getWorldPosition(cameraPosition);
for (const [meshId, entry] of this.entries) {
if (!entry.enabled) {
entry.levels.forEach((level) => { level.object.visible = false; });
continue;
}
const center = new Vector3();
entry.levels[0].object.getWorldPosition(center);
const distance = Math.max(0.0001, cameraPosition.distanceTo(center));
const projected = projectedPixelHeight(entry.radius, distance, camera, viewportHeight);
const selection = selectLODLevel(projected, {
screenHeightThresholds: entry.levels.map((level) => level.screenHeightThreshold),
hysteresis,
}, entry.currentLevel);
entry.currentLevel = selection.level;
entry.levels.forEach((level, index) => { level.object.visible = index === selection.level; });
result.set(meshId, selection);
}
return result;
}
clear(): void {
this.entries.clear();
}
}

View File

@@ -0,0 +1,174 @@
import {
BufferGeometry,
Color,
Float32BufferAttribute,
Group,
Line,
LineBasicMaterial,
LineSegments,
Mesh,
MeshPhysicalMaterial,
Points,
PointsMaterial,
SphereGeometry,
type Object3D,
} from "../vendor/three/three.module.js";
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
function blenderPosition(x: number, y: number, z: number): [number, number, number] {
return [x, z, -y];
}
function createControlPointGeometry(points: ArrayLike<number>, start: number, end: number, closed: boolean): BufferGeometry | null {
if (end - start < 2) return null;
const pointCount = end - start + (closed ? 1 : 0);
const positions = new Float32Array(pointCount * 3);
for (let local = 0; local < pointCount; local++) {
const index = closed && local === end - start ? start : start + local;
const point = index * 3;
const target = local * 3;
positions[target] = points[point];
positions[target + 1] = points[point + 2];
positions[target + 2] = -points[point + 1];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
return geometry;
}
function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = data.controlPoints ?? [], splineOffsets?: ArrayLike<number>): Object3D | null {
if (points.length < 6) return null;
const group = new Group();
const offsets = splineOffsets && splineOffsets.length > 1 ? splineOffsets : data.splineOffsets && data.splineOffsets.length > 1 ? data.splineOffsets : [0, points.length / 3];
const material = new LineBasicMaterial({ color: new Color(0x67b7ff), transparent: true, opacity: 0.95 });
const handlePoints = data.handlePoints;
const handlePointIndices = data.handlePointIndices ?? (handlePoints?.length === (points.length / 3) * 6 ? Array.from({ length: points.length / 3 }, (_, index) => index) : []);
const hasHandles = data.type === "CURVE" && Boolean(handlePoints && handlePointIndices.length > 0 && handlePoints.length === handlePointIndices.length * 6);
const handleLines: number[] = [];
const handlePositions: number[] = [];
const handleIndexMap: number[] = [];
const handleKindMap: NonMeshElementKind[] = [];
if (hasHandles) {
for (let handleIndex = 0; handleIndex < handlePointIndices.length; handleIndex++) {
const pointIndex = handlePointIndices[handleIndex];
const point = pointIndex * 3;
const handle = handleIndex * 6;
const anchor = blenderPosition(points[point], points[point + 1], points[point + 2]);
const left = blenderPosition(handlePoints![handle], handlePoints![handle + 1], handlePoints![handle + 2]);
const right = blenderPosition(handlePoints![handle + 3], handlePoints![handle + 4], handlePoints![handle + 5]);
handleLines.push(anchor[0], anchor[1], anchor[2], left[0], left[1], left[2], anchor[0], anchor[1], anchor[2], right[0], right[1], right[2]);
handlePositions.push(left[0], left[1], left[2], right[0], right[1], right[2]);
handleIndexMap.push(pointIndex, pointIndex);
handleKindMap.push("HANDLE_LEFT", "HANDLE_RIGHT");
}
}
for (let index = 0; index < offsets.length - 1; index++) {
const start = Math.max(0, Math.floor(offsets[index]));
const end = Math.min(points.length / 3, Math.floor(offsets[index + 1]));
const closed = data.cyclicU?.[index] ?? false;
const geometry = createControlPointGeometry(points, start, end, closed);
if (!geometry) continue;
const line = new Line(geometry, material.clone());
group.add(line);
}
const controlGeometry = new BufferGeometry();
const controlPositions = new Float32Array(points.length);
for (let index = 0; index < points.length; index += 3) {
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 }));
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);
group.add(controls);
if (hasHandles) {
const lineGeometry = new BufferGeometry();
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 }));
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 }));
handles.userData.nonMeshDataId = data.id;
handles.userData.nonMeshPointIndexMap = handleIndexMap;
handles.userData.nonMeshPointKindMap = handleKindMap;
group.add(handles);
}
return group.children.length > 0 ? group : null;
}
function createPointPreview(data: NonMeshDataIR, points: ArrayLike<number> = data.controlPoints ?? []): Object3D | null {
if (points.length < 3) return null;
const positions = new Float32Array(points.length);
for (let index = 0; index < points.length; index += 3) {
positions[index] = points[index];
positions[index + 1] = points[index + 2];
positions[index + 2] = -points[index + 1];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const pointObject = new Points(geometry, new PointsMaterial({ color: new Color(0xffc36b), size: 0.08, sizeAttenuation: true }));
pointObject.userData.nonMeshDataId = data.id;
pointObject.userData.nonMeshPointOffset = 0;
return pointObject;
}
function createMetaballPreview(data: NonMeshDataIR): Object3D | null {
if (!data.elements || data.elements.length === 0) return null;
const group = new Group();
for (const element of data.elements) {
const [x, y, z] = blenderPosition(...element.position);
const sphere = new Mesh(
new SphereGeometry(Math.max(0.001, element.radius), 16, 10),
new MeshPhysicalMaterial({ color: 0x6eb7ff, roughness: 0.36, metalness: 0.02 }),
);
sphere.position.set(x, y, z);
sphere.scale.set(...(element.scale.map((value) => value > 0 ? value : 1) as [number, number, number]));
sphere.castShadow = true;
sphere.receiveShadow = true;
sphere.userData.nonMeshDataId = data.id;
group.add(sphere);
}
return group;
}
function binaryPreviewData(data: NonMeshDataIR, chunks: readonly NonMeshGeometryChunk[]): { points: Float32Array; offsets?: Uint32Array } | null {
if (data.geometryStatus !== "binary") return null;
const matching = chunks.filter((chunk) => chunk.dataId === data.id).sort((left, right) => left.chunkIndex - right.chunkIndex);
if (matching.length === 0) return null;
const first = matching[0];
if (matching.some((chunk, index) => chunk.chunkIndex !== index || chunk.totalPointCount !== first.totalPointCount)) return null;
const points = new Float32Array(first.totalPointCount * 3);
for (const chunk of matching) points.set(new Float32Array(chunk.positions), chunk.pointOffset * 3);
return { points, offsets: first.curveOffsets ? new Uint32Array(first.curveOffsets) : undefined };
}
export function createNonMeshObject(data: NonMeshDataIR, chunks: readonly NonMeshGeometryChunk[] = []): Object3D | null {
const binary = binaryPreviewData(data, chunks);
if (data.geometryStatus !== "available" && !binary) return null;
const points = binary?.points ?? data.controlPoints;
const offsets = binary?.offsets;
if (data.type === "METABALL") return createMetaballPreview(data);
if (data.type === "POINT_CLOUD" || data.type === "CURVES" || data.type === "HAIR") return points ? createPointPreview(data, points) : null;
if (data.type === "CURVE" || data.type === "SURFACE" || data.type === "FONT") return points ? createCurvePreview(data, points, offsets) : null;
return null;
}
export function applyNonMeshTransform(object: Object3D, node: SceneNodeIR): void {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;
object.position.set(x, z, -y);
object.rotation.set(rx, rz, -ry);
object.scale.set(...node.transform.scale);
object.name = node.name;
object.userData.sceneNodeId = node.id;
object.userData.blenderId = node.id;
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
});
}

View File

@@ -0,0 +1,25 @@
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import type { NonMeshElementKind } from "./nonmesh";
export type OffscreenViewportRequest =
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
| { 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: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
| { type: "pick"; x: number; y: number; additive: boolean }
| { type: "dispose" };
export type OffscreenViewportResponse =
| { type: "ready" }
| { type: "frame"; visiblePixels: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: 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 }
| { type: "error"; message: string };

View File

@@ -0,0 +1,232 @@
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { cloneMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
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;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
dispose(): void;
}
export function supportsOffscreenViewport(canvas: HTMLCanvasElement): boolean {
return typeof canvas.transferControlToOffscreen === "function" && typeof Worker !== "undefined";
}
interface SharedOffscreenBackend {
renderer: OffscreenViewportRenderer;
references: number;
disposeTimer?: number;
}
const sharedBackends = new WeakMap<HTMLCanvasElement, SharedOffscreenBackend>();
export function acquireOffscreenViewportRenderer(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
): OffscreenViewportRenderer {
const existing = sharedBackends.get(canvas);
if (existing) {
if (existing.disposeTimer !== undefined) window.clearTimeout(existing.disposeTimer);
existing.disposeTimer = undefined;
existing.references += 1;
return existing.renderer;
}
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect);
sharedBackends.set(canvas, { renderer, references: 1 });
return renderer;
}
export function releaseOffscreenViewportRenderer(canvas: HTMLCanvasElement, renderer: OffscreenViewportRenderer): void {
const existing = sharedBackends.get(canvas);
if (!existing || existing.renderer !== renderer) return;
existing.references = Math.max(0, existing.references - 1);
if (existing.references > 0) return;
existing.disposeTimer = window.setTimeout(() => {
if (existing.references > 0) return;
existing.renderer.dispose();
sharedBackends.delete(canvas);
}, 0);
}
export class OffscreenViewportRenderer implements ViewportBackend {
private readonly canvas: HTMLCanvasElement;
private readonly worker: Worker;
private readonly resizeObserver: ResizeObserver;
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
) {
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
const offscreen = canvas.transferControlToOffscreen();
const request: OffscreenViewportRequest = {
type: "init",
canvas: offscreen,
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
};
this.worker.postMessage(request, [offscreen]);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
canvas.addEventListener("pointerdown", this.pointerDown);
canvas.addEventListener("pointermove", this.pointerMove);
canvas.addEventListener("pointerup", this.pointerUp);
canvas.addEventListener("pointercancel", this.pointerUp);
canvas.addEventListener("wheel", this.wheel, { passive: false });
canvas.dataset.rendererBackend = "offscreen-worker";
canvas.dataset.pbrProfile = PBR_PROFILE;
canvas.dataset.toneMapping = PBR_TONE_MAPPING;
canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (snapshot === this.lastSnapshot && geometryBuffers === this.lastGeometryBuffers && nonMeshGeometryBuffers === this.lastNonMeshGeometryBuffers) return;
this.lastSnapshot = snapshot;
const udim = snapshot.images.find((image) => image.tiles && image.tiles.length > 0);
if (udim) {
const gate = gateUDIMImage(udim);
this.canvas.dataset.udimGate = gate.status.toLowerCase();
this.canvas.dataset.udimGateCode = gate.issues[0]?.code ?? "";
}
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
if (world?.environmentImageId) {
const gate = gateEnvironmentImage(snapshot.images.find((image) => image.id === world.environmentImageId));
this.canvas.dataset.iblGate = gate.status.toLowerCase();
this.canvas.dataset.iblGateCode = gate.issues[0]?.code ?? "";
}
this.lastGeometryBuffers = geometryBuffers;
this.lastNonMeshGeometryBuffers = nonMeshGeometryBuffers;
const cloned = cloneMeshGeometryBuffers(geometryBuffers);
const nonMeshCloned = nonMeshGeometryBuffers.map((chunk) => ({
...chunk,
positions: chunk.positions.slice(0),
radii: chunk.radii?.slice(0),
curveOffsets: chunk.curveOffsets?.slice(0),
attributes: chunk.attributes.map((attribute) => ({ ...attribute, data: attribute.data.slice(0) })),
}));
const transfer: Transferable[] = [];
for (const payload of cloned) for (const value of Object.values(payload)) if (value instanceof ArrayBuffer) transfer.push(value);
transfer.push(...nonMeshChunkTransferables(nonMeshCloned));
this.worker.postMessage({ type: "snapshot", snapshot, geometryBuffers: cloned, nonMeshGeometryBuffers: nonMeshCloned } satisfies OffscreenViewportRequest, transfer);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (assets.length === 0) {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
return;
}
const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) }));
const transfer: Transferable[] = cloned.map((asset) => asset.data);
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
}
setSelection(objectIds: ReadonlySet<string>): void {
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
}
installLODLevels(): void {
// The main-thread renderer remains the adaptive LOD owner; the worker path
// renders the source instanced mesh and retains native frustum culling.
}
private resize(): void {
this.worker.postMessage({
type: "resize",
width: Math.max(1, this.canvas.clientWidth),
height: Math.max(1, this.canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
} satisfies OffscreenViewportRequest);
}
private pointerDown = (event: PointerEvent): void => {
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
this.canvas.setPointerCapture(event.pointerId);
};
private pointerMove = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId || (event.buttons & 1) === 0) return;
const deltaX = event.clientX - this.pointer.x;
const deltaY = event.clientY - this.pointer.y;
if (Math.abs(deltaX) + Math.abs(deltaY) > 1) this.pointer.moved = true;
this.pointer.x = event.clientX;
this.pointer.y = event.clientY;
this.worker.postMessage({ type: "orbit", deltaX, deltaY, zoom: 0 } satisfies OffscreenViewportRequest);
};
private pointerUp = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId) return;
if (!this.pointer.moved) {
const bounds = this.canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey } satisfies OffscreenViewportRequest);
}
this.pointer = null;
};
private wheel = (event: WheelEvent): void => {
event.preventDefault();
this.worker.postMessage({ type: "orbit", deltaX: 0, deltaY: 0, zoom: event.deltaY } satisfies OffscreenViewportRequest);
};
private handleMessage(message: OffscreenViewportResponse): void {
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
else if (message.type === "snapshotStatus") {
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
}
else if (message.type === "textureStatus") {
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(message.loaded);
this.canvas.dataset.textureBytes = String(message.bytes);
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
}
else if (message.type === "error") this.canvas.dataset.rendererError = message.message;
}
dispose(): void {
this.resizeObserver.disconnect();
this.canvas.removeEventListener("pointerdown", this.pointerDown);
this.canvas.removeEventListener("pointermove", this.pointerMove);
this.canvas.removeEventListener("pointerup", this.pointerUp);
this.canvas.removeEventListener("pointercancel", this.pointerUp);
this.canvas.removeEventListener("wheel", this.wheel);
this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest);
this.worker.terminate();
}
}

View File

@@ -0,0 +1,130 @@
import {
ACESFilmicToneMapping,
Color,
DirectionalLight,
DoubleSide,
MeshPhysicalMaterial,
Object3D,
PCFShadowMap,
PointLight,
RectAreaLight,
SRGBColorSpace,
SpotLight,
Vector3,
type Light,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
export const PBR_PROFILE = "physical-v1";
export const PBR_TONE_MAPPING = "aces";
export const PBR_SHADOW_PROFILE = "pcf-1024";
function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback;
}
export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): void {
renderer.outputColorSpace = SRGBColorSpace;
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 2 ** clamp(exposure, -8, 8, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = PCFShadowMap;
}
export function createPBRMaterial(definition?: MaterialIR, active = false): MeshPhysicalMaterial {
const baseColor = definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
const emission = definition?.emissionColor ?? [0, 0, 0, 1];
const alpha = clamp(definition?.alpha ?? baseColor[3], 0, 1, 1);
const transmission = clamp(definition?.transmissionWeight, 0, 1, 0);
const material = new MeshPhysicalMaterial({
color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]),
roughness: clamp(definition?.roughness, 0, 1, 0.45),
metalness: clamp(definition?.metallic, 0, 1, 0.05),
ior: clamp(definition?.ior, 1, 2.333, 1.45),
// Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0.
specularIntensity: clamp((definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
clearcoat: clamp(definition?.coatWeight, 0, 1, 0),
clearcoatRoughness: clamp(definition?.coatRoughness, 0, 1, 0.03),
transmission,
emissive: new Color().setRGB(emission[0], emission[1], emission[2]),
emissiveIntensity: clamp(definition?.emissionStrength, 0, 1_000_000, 1),
opacity: alpha,
transparent: alpha < 0.999,
depthWrite: alpha >= 0.999,
vertexColors: true,
side: DoubleSide,
});
material.userData.baseEmissive = material.emissive.getHex();
material.userData.baseEmissiveIntensity = material.emissiveIntensity;
material.userData.pbrProfile = PBR_PROFILE;
return material;
}
export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void {
const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0;
const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1;
material.emissive.set(selected ? 0x4a1f08 : baseEmissive);
material.emissiveIntensity = selected ? Math.max(0.65, baseIntensity) : baseIntensity;
}
export function blenderLightIntensity(definition: LightIR): number {
return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10;
}
export function createPBRLight(definition: LightIR): Light {
const color = new Color().setRGB(...definition.color);
const intensity = blenderLightIntensity(definition);
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :
definition.lightType === 4 ? new RectAreaLight(color, intensity, definition.areaSize, definition.areaSizeY) :
new PointLight(color, intensity, 0, 2);
light.userData.blenderCastsShadowDefinition = definition.castsShadow ?? true;
light.userData.blenderExposure = definition.exposure ?? 0;
light.userData.blenderTemperature = definition.temperature ?? 6500;
light.userData.blenderUsesTemperature = definition.useTemperature ?? false;
return light;
}
export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Object3D): void {
const [x, y, z] = node.transform.translation;
light.position.set(x, z, -y);
light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
if (light instanceof PointLight) {
light.distance = 0;
light.decay = 2;
}
light.userData.blenderCastsShadow = definitionCastsShadow(light);
if ((light instanceof DirectionalLight || light instanceof SpotLight) && light.userData.blenderCastsShadow) {
const target = new Object3D();
const forward = new Vector3(0, -1, 0).applyEuler(light.rotation);
target.position.copy(light.position).add(forward);
light.target = target;
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;
light.shadow.camera.far = 100;
if (light instanceof DirectionalLight) {
light.shadow.camera.left = -20;
light.shadow.camera.right = 20;
light.shadow.camera.top = 20;
light.shadow.camera.bottom = -20;
}
parent.add(target);
}
else if (light instanceof PointLight && light.userData.blenderCastsShadow) {
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;
light.shadow.camera.far = 100;
}
}
function definitionCastsShadow(light: Light): boolean {
return typeof light.userData.blenderCastsShadowDefinition === "boolean" ?
light.userData.blenderCastsShadowDefinition : true;
}

View File

@@ -0,0 +1,145 @@
import {
EquirectangularReflectionMapping,
NoColorSpace,
PMREMGenerator,
SRGBColorSpace,
Texture,
type MeshPhysicalMaterial,
type Object3D,
type Scene,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { MaterialIR, SceneSnapshotIR, WorldIR } from "../../../protocol/scene-ir";
import { RenderAssetValidationError, validateGPUTextureAsset, type GPUTextureAsset, type GPUTextureColorSpace, type GPUTextureUsage } from "../../../protocol/render-assets";
export interface TextureUploadStatus {
loaded: number;
rejected: number;
bytes: number;
errors: string[];
errorCodes: string[];
}
function key(imageId: string, usage: GPUTextureUsage): string {
return `${imageId}:${usage}`;
}
function colorSpaceFor(usage: GPUTextureUsage, declared: GPUTextureColorSpace): string {
if (declared === "SRGB" || usage === "BASE_COLOR" || usage === "EMISSIVE") return SRGBColorSpace;
return NoColorSpace;
}
async function decodeTexture(asset: GPUTextureAsset): Promise<Texture> {
if (typeof createImageBitmap !== "function") throw new Error("createImageBitmap is unavailable");
const blob = new Blob([asset.data], { type: asset.mimeType });
const bitmap = await createImageBitmap(blob);
const texture = new Texture(bitmap);
// ImageBitmap pixels are already oriented for the WebGL upload path.
texture.flipY = false;
texture.colorSpace = colorSpaceFor(asset.usage, asset.colorSpace);
texture.name = `${asset.imageId}:${asset.usage}${asset.tileNumber ? `:${asset.tileNumber}` : ""}`;
texture.needsUpdate = true;
return texture;
}
export class GPUTextureStore {
private readonly textures = new Map<string, Texture>();
private readonly assets = new Map<string, GPUTextureAsset>();
private readonly udimTileCounts = new Map<string, number>();
private revision = 0;
getRevision(): number {
return this.revision;
}
get(imageId: string, usage: GPUTextureUsage): Texture | undefined {
return this.textures.get(key(imageId, usage)) ?? this.textures.get(key(imageId, "BASE_COLOR"));
}
getAsset(imageId: string, usage: GPUTextureUsage): GPUTextureAsset | undefined {
return this.assets.get(key(imageId, usage));
}
async upload(assets: readonly GPUTextureAsset[]): Promise<TextureUploadStatus> {
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [] };
const incomingUDIMCounts = new Map<string, number>();
for (const asset of assets) if (asset.usage === "UDIM_TILE") incomingUDIMCounts.set(asset.imageId, (incomingUDIMCounts.get(asset.imageId) ?? 0) + 1);
for (const asset of assets) {
try {
await validateGPUTextureAsset(asset);
if (asset.usage === "UDIM_TILE" && (incomingUDIMCounts.get(asset.imageId) ?? 0) > 1) {
throw new Error(`UDIM multi-tile sampling is blocked for ${asset.imageId}`);
}
const texture = await decodeTexture(asset);
const lookupUsage = asset.usage === "UDIM_TILE" ? "BASE_COLOR" : asset.usage;
const old = this.textures.get(key(asset.imageId, lookupUsage));
old?.dispose();
this.textures.set(key(asset.imageId, lookupUsage), texture);
this.assets.set(key(asset.imageId, lookupUsage), asset);
if (asset.usage === "UDIM_TILE") this.udimTileCounts.set(asset.imageId, (this.udimTileCounts.get(asset.imageId) ?? 0) + 1);
status.loaded += 1;
status.bytes += asset.byteLength;
}
catch (error) {
status.rejected += 1;
status.errors.push(error instanceof Error ? error.message : "Texture upload failed");
status.errorCodes.push(error instanceof RenderAssetValidationError ? error.code : "GPU_TEXTURE_DECODE_FAILED");
}
}
this.revision += 1;
return status;
}
applyMaterial(material: MeshPhysicalMaterial, definition?: MaterialIR): void {
if (!definition) return;
const baseImageId = definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
if (baseImageId) {
const texture = this.get(baseImageId, "BASE_COLOR");
if (texture) material.map = texture;
}
const normalImageId = definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
if (normalImageId) {
const texture = this.get(normalImageId, "NORMAL");
if (texture) material.normalMap = texture;
}
if (material.map || material.normalMap) material.needsUpdate = true;
}
applySnapshotMaterials(root: Object3D, snapshot: SceneSnapshotIR): void {
const materialById = new Map(snapshot.materials.map((material) => [material.id, material]));
root.traverse((object) => {
const mesh = object as { material?: unknown };
const materialIds = object.userData.materialSlotIds as string[] | undefined;
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
materials.forEach((material, index) => {
if (!material || typeof material !== "object" || !("isMeshPhysicalMaterial" in material)) return;
this.applyMaterial(material as MeshPhysicalMaterial, materialById.get(materialIds?.[index] ?? ""));
});
});
}
async applyEnvironment(scene: Scene, renderer: WebGLRenderer, world: WorldIR | undefined, background = true): Promise<boolean> {
if (!world?.environmentImageId) return false;
const source = this.get(world.environmentImageId, "ENVIRONMENT");
if (!source) return false;
source.mapping = EquirectangularReflectionMapping;
source.needsUpdate = true;
const pmrem = new PMREMGenerator(renderer);
const target = pmrem.fromEquirectangular(source);
pmrem.dispose();
const oldTarget = scene.userData.pbrIBLTarget as { dispose?: () => void } | undefined;
oldTarget?.dispose?.();
scene.userData.pbrIBLTarget = target;
scene.environment = target.texture;
scene.environmentIntensity = Math.max(0, world.environmentStrength ?? 1);
if (background && (world.backgroundVisible ?? true)) scene.background = source;
return true;
}
dispose(): void {
for (const texture of this.textures.values()) texture.dispose();
this.textures.clear();
this.assets.clear();
this.udimTileCounts.clear();
}
}

View File

@@ -0,0 +1,678 @@
import {
BufferGeometry,
BoxGeometry,
Color,
DirectionalLight,
GridHelper,
Group,
InstancedMesh,
Matrix4,
Mesh,
HemisphereLight,
MeshPhysicalMaterial,
Raycaster,
PerspectiveCamera,
Scene,
type Object3D,
WebGLRenderer,
Float32BufferAttribute,
Uint32BufferAttribute,
Euler,
Quaternion,
Vector2,
Vector3,
} from "../vendor/three/three.module.js";
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
import { applySceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
import {
configurePBRLight,
configurePBRRenderer,
createPBRLight,
createPBRMaterial,
PBR_PROFILE,
PBR_SHADOW_PROFILE,
PBR_TONE_MAPPING,
setPBRMaterialSelected,
} from "./pbr";
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 type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const node of snapshot.nodes) {
if (node.type !== "MESH" || !node.visible || !node.dataId) continue;
const ids = groups.get(node.dataId) ?? [];
ids.push(node.id);
groups.set(node.dataId, ids);
}
for (const [meshId, ids] of groups) if (ids.length < minimumSize) groups.delete(meshId);
return groups;
}
/** Three.js owns the browser viewport; Blender's Z-up coordinates are adapted at the boundary. */
export class ViewportRenderer {
readonly renderer: WebGLRenderer;
readonly scene: Scene;
readonly camera: PerspectiveCamera;
readonly controls: OrbitControls;
private readonly canvas: HTMLCanvasElement;
private readonly importedRoot = new Group();
private readonly importedLights = new Group();
private readonly resizeObserver: ResizeObserver;
private animationFrame = 0;
private disposed = false;
private currentSnapshot: SceneSnapshotIR | null = null;
private readonly objectByBlenderId = new Map<string, Object3D>();
private readonly instanceIndexByBlenderId = new Map<string, number>();
private readonly lodAdapter = new ThreeLODAdapter();
private readonly textureStore = new GPUTextureStore();
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private editMode = false;
private selectionMode: MeshElementMode = "FACE";
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
) {
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setClearColor(new Color("#25272b"));
this.canvas.dataset.rendererBackend = "webgl-pbr";
this.canvas.dataset.pbrProfile = PBR_PROFILE;
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
this.camera.position.set(4.5, -4.5, 3.5);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 0, 0);
this.controls.enableDamping = true;
this.scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55));
const keyLight = new DirectionalLight(0xffffff, 2.5);
keyLight.position.set(4, -5, 8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.bias = -0.0005;
keyLight.shadow.normalBias = 0.03;
this.scene.add(keyLight, keyLight.target);
this.scene.add(new GridHelper(20, 20, 0x60656e, 0x383b42));
this.scene.add(this.importedRoot);
this.scene.add(this.importedLights);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.canvas.addEventListener("click", this.handleClick);
this.resize();
this.renderLoop();
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
this.currentSnapshot = snapshot;
const udim = snapshot.images.find((image) => image.tiles && image.tiles.length > 0);
if (udim) {
const gate = gateUDIMImage(udim);
this.canvas.dataset.udimGate = gate.status.toLowerCase();
this.canvas.dataset.udimGateCode = gate.issues[0]?.code ?? "";
}
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
if (world?.environmentImageId) {
const gate = gateEnvironmentImage(snapshot.images.find((image) => image.id === world.environmentImageId));
this.canvas.dataset.iblGate = gate.status.toLowerCase();
this.canvas.dataset.iblGateCode = gate.issues[0]?.code ?? "";
}
this.objectByBlenderId.clear();
this.instanceIndexByBlenderId.clear();
this.lodAdapter.clear();
this.clearImportedScene();
this.applyWorld(snapshot);
this.applyCamera(snapshot);
this.populateLights(snapshot);
this.populateNonMesh(snapshot, nonMeshGeometryBuffers);
this.populateGreasePencils(snapshot);
const meshes = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
const binaryGeometry = new Map(geometryBuffers.map((payload) => [payload.meshId, payload]));
for (const node of snapshot.nodes) {
if (node.type !== "MESH" || !node.visible || !node.dataId) continue;
const summary = meshes.get(node.dataId);
if (!summary) continue;
const materialById = new Map(snapshot.materials.map((material) => [material.id, material]));
const fallbackMaterial = this.createMaterial(undefined, node.id === snapshot.activeObjectId);
const materials = (summary.materialSlotIds ?? [])
.map((id) => materialById.get(id))
.map((material) => this.createMaterial(material, node.id === snapshot.activeObjectId));
if (materials.length === 0) materials.push(fallbackMaterial);
let geometry: BufferGeometry = new BoxGeometry(2, 2, 2);
const payload = binaryGeometry.get(summary.id);
const positionsData = payload ? new Float32Array(payload.positions) : summary.positions;
const indicesData = payload ? new Uint32Array(payload.indices) : summary.indices;
const normalsData = payload?.normals ? new Float32Array(payload.normals) : summary.normals;
const triangleCornerData = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices;
const triangleFaceData = payload?.triangleFaceIndices ? new Uint32Array(payload.triangleFaceIndices) : summary.triangleFaceIndices;
const uvData = payload?.uvs ? new Float32Array(payload.uvs) : summary.uvs;
const colorData = payload?.colors ? new Float32Array(payload.colors) : summary.colors;
const triangleMaterialData = payload?.triangleMaterialIndices ? new Uint32Array(payload.triangleMaterialIndices) : summary.triangleMaterialIndices;
if ((summary.geometryStatus === "available" || summary.geometryStatus === "binary") && positionsData && indicesData) {
geometry = new BufferGeometry();
const hasCornerAttributes = Boolean(triangleCornerData && (uvData || colorData));
if (hasCornerAttributes && triangleCornerData) {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
for (let index = 0; index < indicesData.length; index++) {
const vertex = indicesData[index] * 3;
positions.push(positionsData[vertex], positionsData[vertex + 2], -positionsData[vertex + 1]);
if (normalsData) {
normals.push(normalsData[vertex], normalsData[vertex + 2], -normalsData[vertex + 1]);
}
const corner = triangleCornerData[index];
if (uvData) uvs.push(uvData[corner * 2], uvData[corner * 2 + 1]);
if (colorData) colors.push(colorData[corner * 4], colorData[corner * 4 + 1], colorData[corner * 4 + 2], colorData[corner * 4 + 3]);
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normals.length > 0) geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
if (uvs.length > 0) geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
if (colors.length > 0) geometry.setAttribute("color", new Float32BufferAttribute(colors, 4));
} else {
const positions = new Array<number>(positionsData.length);
for (let index = 0; index < positionsData.length; index += 3) {
// Blender Z-up/right-handed -> Three.js Y-up/right-handed.
positions[index] = positionsData[index];
positions[index + 1] = positionsData[index + 2];
positions[index + 2] = -positionsData[index + 1];
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normalsData) {
const normals = new Array<number>(normalsData.length);
for (let index = 0; index < normalsData.length; index += 3) {
normals[index] = normalsData[index];
normals[index + 1] = normalsData[index + 2];
normals[index + 2] = -normalsData[index + 1];
}
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
geometry.setIndex(new Uint32BufferAttribute(indicesData, 1));
if (!normalsData) geometry.computeVertexNormals();
}
if (triangleMaterialData && materials.length > 1) {
for (let triangle = 0; triangle < triangleMaterialData.length; triangle++) {
const materialIndex = Math.min(triangleMaterialData[triangle], materials.length - 1);
geometry.addGroup(triangle * 3, 3, materialIndex);
}
}
}
const mesh = new Mesh(geometry, materials.length === 1 ? materials[0] : materials);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = node.name;
// Summary-only meshes use a bounded proxy until their transferable geometry is available.
const [x, y, z] = node.transform.translation;
mesh.position.set(x, z, -y);
mesh.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
mesh.scale.set(...node.transform.scale);
mesh.userData.sceneNodeId = node.id;
mesh.userData.blenderId = node.id;
mesh.userData.meshId = summary.id;
mesh.userData.materialSlotIds = summary.materialSlotIds ?? [];
mesh.userData.revision = snapshot.revision;
mesh.userData.vertexCount = summary.vertexCount;
mesh.userData.sourcePositions = positionsData ? Array.from(positionsData) : undefined;
mesh.userData.sourceIndices = indicesData ? Array.from(indicesData) : undefined;
mesh.userData.triangleFaceIndices = triangleFaceData ? Array.from(triangleFaceData) : undefined;
mesh.userData.edgeVertexIndices = payload?.edgeVertexIndices
? Array.from(new Uint32Array(payload.edgeVertexIndices))
: summary.edgeVertexIndices;
this.importedRoot.add(mesh);
this.objectByBlenderId.set(node.id, mesh);
}
this.coalesceMeshInstances(snapshot);
if (this.importedRoot.children.length > 0) {
this.controls.target.set(0, 0, 0);
}
}
private populateNonMesh(snapshot: SceneSnapshotIR, nonMeshGeometryBuffers: readonly NonMeshGeometryChunk[]): void {
const dataById = new Map((snapshot.nonMeshData ?? []).map((data) => [data.id, data]));
let previewCount = 0;
let blockedCount = 0;
for (const node of snapshot.nodes) {
if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue;
const data = dataById.get(node.dataId);
if (!data) continue;
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
if (!object) {
blockedCount++;
continue;
}
applyNonMeshTransform(object, node);
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
child.userData.nonMeshDataId = data.id;
});
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
previewCount++;
}
this.canvas.dataset.nonMeshCount = String(previewCount);
this.canvas.dataset.nonMeshBlockedCount = String(blockedCount);
}
private populateGreasePencils(snapshot: SceneSnapshotIR): void {
const dataById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let previewCount = 0;
let blockedCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = dataById.get(node.dataId);
if (!data) continue;
const object = createGreasePencilObject(data, snapshot.frame.current);
if (!object) {
blockedCount++;
continue;
}
applyGreasePencilTransform(object, node);
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
previewCount++;
}
this.canvas.dataset.greasePencilCount = String(previewCount);
this.canvas.dataset.greasePencilBlockedCount = String(blockedCount);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (assets.length === 0) {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
return;
}
void this.textureStore.upload(assets).then((status) => {
this.canvas.dataset.textureStatus = status.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(status.loaded);
this.canvas.dataset.textureBytes = String(status.bytes);
this.canvas.dataset.textureErrorCode = status.errorCodes[0] ?? "";
if (!this.currentSnapshot) return;
this.textureStore.applySnapshotMaterials(this.importedRoot, this.currentSnapshot);
const worldId = this.currentSnapshot.scenes[0]?.worldId;
const world = this.currentSnapshot.worlds.find((candidate) => candidate.id === worldId) ?? this.currentSnapshot.worlds[0];
void this.textureStore.applyEnvironment(this.scene, this.renderer, world, true).then((ready) => {
this.canvas.dataset.iblStatus = ready ? "ready" : world?.environmentImageId ? "blocked" : "none";
});
});
}
applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
const next = applySceneDelta(this.currentSnapshot, delta);
const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length ||
delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations);
if (hasLifecycleChanges) {
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
return;
}
for (const change of delta.nodes?.updated ?? []) {
const object = this.objectByBlenderId.get(change.id);
if (!object) continue;
const instanceIndex = this.instanceIndexByBlenderId.get(change.id);
if (object instanceof InstancedMesh && instanceIndex !== undefined) {
const node = next.nodes.find((candidate) => candidate.id === change.id);
if (node) object.setMatrixAt(instanceIndex, this.instanceMatrix(node.transform, node.visible));
object.instanceMatrix.needsUpdate = true;
}
else if (change.visible !== undefined) object.visible = change.visible;
if (change.visible !== undefined && typeof object.userData.meshId === "string") this.lodAdapter.setEnabled(object.userData.meshId, change.visible);
if (change.transform && !(object instanceof InstancedMesh)) {
const [x, y, z] = change.transform.translation;
object.position.set(x, z, -y);
object.rotation.set(change.transform.rotationEuler[0], change.transform.rotationEuler[2], -change.transform.rotationEuler[1]);
object.scale.set(...change.transform.scale);
}
object.userData.revision = next.revision;
}
this.currentSnapshot = next;
}
setSelection(objectIds: ReadonlySet<string>): void {
const visitedInstances = new Set<InstancedMesh>();
for (const [objectId, object] of this.objectByBlenderId) {
if (object instanceof InstancedMesh) {
if (visitedInstances.has(object)) continue;
visitedInstances.add(object);
const ids = object.userData.instanceNodeIds as string[];
for (let index = 0; index < ids.length; index++) {
object.setColorAt(index, new Color(objectIds.has(ids[index]) ? 0xf08a45 : 0xffffff));
}
if (object.instanceColor) object.instanceColor.needsUpdate = true;
continue;
}
if (!(object instanceof Mesh)) continue;
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
if (!(material instanceof MeshPhysicalMaterial)) continue;
setPBRMaterialSelected(material, objectIds.has(objectId));
}
}
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.editMode = editMode;
this.selectionMode = selectionMode;
}
registerLOD(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
this.lodAdapter.register(meshId, levels, radius);
}
unregisterLOD(meshId: string): void {
this.lodAdapter.unregister(meshId);
}
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void {
const source = [...this.objectByBlenderId.values()].find((object) => object.userData.meshId === meshId);
if (!(source instanceof Mesh) || source instanceof InstancedMesh || levels.length === 0) return;
const created: ThreeLODLevel[] = [];
const sourceMaterials = Array.isArray(source.material) ? source.material : [source.material];
source.geometry.computeBoundingSphere();
const radius = source.geometry.boundingSphere?.radius ?? 1;
for (const level of levels) {
const payload = level.geometryBuffers.find((geometry) => geometry.meshId === level.meshId) ?? level.geometryBuffers[0];
if (!payload) continue;
const mesh = new Mesh(this.createLODGeometry(payload, sourceMaterials.length), sourceMaterials);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = `${source.name} LOD ${level.level}`;
mesh.position.copy(source.position);
mesh.rotation.copy(source.rotation);
mesh.scale.copy(source.scale);
mesh.userData.sceneNodeId = source.userData.sceneNodeId;
mesh.userData.blenderId = source.userData.blenderId;
mesh.userData.meshId = meshId;
this.importedRoot.add(mesh);
created.push({ object: mesh, screenHeightThreshold: Math.max(24, 768 / 2 ** level.level) });
}
if (created.length === 0) return;
source.visible = false;
this.lodAdapter.register(meshId, created, radius);
}
updateLODSelection(hysteresis = 0.08): Map<string, LODSelectionResult> {
return this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight), hysteresis);
}
private createLODGeometry(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
const positionsData = new Float32Array(payload.positions);
const indicesData = new Uint32Array(payload.indices);
const normalsData = payload.normals ? new Float32Array(payload.normals) : undefined;
const cornerData = payload.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : undefined;
const uvData = payload.uvs ? new Float32Array(payload.uvs) : undefined;
const colorData = payload.colors ? new Float32Array(payload.colors) : undefined;
const materialData = payload.triangleMaterialIndices ? new Uint32Array(payload.triangleMaterialIndices) : undefined;
const geometry = new BufferGeometry();
if (cornerData && (uvData || colorData)) {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
for (let index = 0; index < indicesData.length; index++) {
const vertex = indicesData[index] * 3;
positions.push(positionsData[vertex], positionsData[vertex + 2], -positionsData[vertex + 1]);
if (normalsData) normals.push(normalsData[vertex], normalsData[vertex + 2], -normalsData[vertex + 1]);
const corner = cornerData[index];
if (uvData) uvs.push(uvData[corner * 2], uvData[corner * 2 + 1]);
if (colorData) colors.push(colorData[corner * 4], colorData[corner * 4 + 1], colorData[corner * 4 + 2], colorData[corner * 4 + 3]);
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normals.length > 0) geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
if (uvs.length > 0) geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
if (colors.length > 0) geometry.setAttribute("color", new Float32BufferAttribute(colors, 4));
}
else {
const positions = new Array<number>(positionsData.length);
for (let index = 0; index < positionsData.length; index += 3) {
positions[index] = positionsData[index];
positions[index + 1] = positionsData[index + 2];
positions[index + 2] = -positionsData[index + 1];
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normalsData) {
const normals = new Array<number>(normalsData.length);
for (let index = 0; index < normalsData.length; index += 3) {
normals[index] = normalsData[index];
normals[index + 1] = normalsData[index + 2];
normals[index + 2] = -normalsData[index + 1];
}
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
geometry.setIndex(new Uint32BufferAttribute(indicesData, 1));
if (!normalsData) geometry.computeVertexNormals();
}
if (materialData && materialCount > 1) {
for (let triangle = 0; triangle < materialData.length; triangle++) geometry.addGroup(triangle * 3, 3, Math.min(materialData[triangle], materialCount - 1));
}
return geometry;
}
private instanceMatrix(transform: SceneSnapshotIR["nodes"][number]["transform"], visible = true): Matrix4 {
const [x, y, z] = transform.translation;
const [rx, ry, rz] = transform.rotationEuler;
const scale = visible ? new Vector3(...transform.scale) : new Vector3(0, 0, 0);
return new Matrix4().compose(
new Vector3(x, z, -y),
new Quaternion().setFromEuler(new Euler(rx, rz, -ry)),
scale,
);
}
private coalesceMeshInstances(snapshot: SceneSnapshotIR): void {
const instanceGroups = collectMeshInstanceGroups(snapshot);
for (const [meshId, nodeIds] of instanceGroups) {
const meshes = nodeIds.map((id) => this.objectByBlenderId.get(id)).filter((object): object is Mesh => object instanceof Mesh && !(object instanceof InstancedMesh));
if (meshes.length !== nodeIds.length || meshes.length < 2) continue;
const first = meshes[0];
const instances = new InstancedMesh(first.geometry, first.material, meshes.length);
instances.castShadow = true;
instances.receiveShadow = true;
instances.name = `${first.name} (${meshes.length} instances)`;
instances.frustumCulled = true;
instances.userData = { ...first.userData, blenderId: undefined, instanceNodeIds: [...nodeIds], meshId };
for (let index = 0; index < nodeIds.length; index++) {
const node = snapshot.nodes.find((candidate) => candidate.id === nodeIds[index]);
if (!node) continue;
instances.setMatrixAt(index, this.instanceMatrix(node.transform, node.visible));
instances.setColorAt(index, new Color(0xffffff));
this.objectByBlenderId.set(node.id, instances);
this.instanceIndexByBlenderId.set(node.id, index);
}
instances.instanceMatrix.needsUpdate = true;
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
for (let index = 0; index < meshes.length; index++) {
const mesh = meshes[index];
this.importedRoot.remove(mesh);
if (index === 0) continue;
mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) material.dispose();
}
this.importedRoot.add(instances);
}
this.canvas.dataset.instanceGroups = String(instanceGroups.size);
}
private clearImportedScene(): void {
while (this.importedRoot.children.length > 0) {
const child = this.importedRoot.children.pop();
if (!child) continue;
child.traverse((object) => {
const mesh = object as Mesh;
if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
if (material && typeof material.dispose === "function") material.dispose();
}
});
}
while (this.importedLights.children.length > 0) {
const child = this.importedLights.children.pop();
if (child && "dispose" in child && typeof child.dispose === "function") child.dispose();
}
}
private createMaterial(definition: MaterialIR | undefined, active: boolean): MeshPhysicalMaterial {
return createPBRMaterial(definition, active);
}
private applyWorld(snapshot: SceneSnapshotIR): void {
const sceneDefinition = snapshot.scenes.find((scene) => scene.id === snapshot.sceneId) ?? snapshot.scenes[0];
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
this.scene.background = world ? new Color().setRGB(...world.color) : new Color("#25272b");
configurePBRRenderer(this.renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
this.canvas.dataset.viewTransform = sceneDefinition?.colorManagement?.viewTransform ?? "";
this.canvas.dataset.viewLook = sceneDefinition?.colorManagement?.look ?? "";
this.canvas.dataset.mist = world?.mist?.enabled ? "metadata-only" : "disabled";
}
private applyCamera(snapshot: SceneSnapshotIR): void {
const cameraObjectId = snapshot.scenes[0]?.cameraObjectId;
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
const definition = snapshot.cameras.find((camera) => camera.id === cameraNode?.dataId);
if (!cameraNode || !definition) return;
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
this.camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
this.camera.near = Math.max(0.0001, definition.near);
this.camera.far = Math.max(this.camera.near + 0.001, definition.far);
this.camera.filmGauge = sensor;
this.camera.filmOffset = definition.shift[0] * sensor;
this.camera.updateProjectionMatrix();
}
private populateLights(snapshot: SceneSnapshotIR): void {
const lights = new Map(snapshot.lights.map((light) => [light.id, light]));
for (const node of snapshot.nodes) {
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
const definition = lights.get(node.dataId);
if (!definition) continue;
const light = createPBRLight(definition);
configurePBRLight(light, node, this.importedLights);
light.name = node.name;
light.userData.sceneNodeId = node.id;
light.userData.blenderId = node.id;
light.userData.revision = snapshot.revision;
this.importedLights.add(light);
this.objectByBlenderId.set(node.id, light);
}
}
private resize(): void {
const width = Math.max(1, this.canvas.clientWidth);
const height = Math.max(1, this.canvas.clientHeight);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
private handleClick = (event: MouseEvent): void => {
const bounds = this.canvas.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
this.pointer.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
this.raycaster.setFromCamera(this.pointer, this.camera);
const hit = this.raycaster.intersectObjects(this.importedRoot.children, true)
.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
if (!hit) return;
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
const nonMeshDataId = hit.object.userData.nonMeshDataId;
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
const pointIndex = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index);
const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kindMap?.[hit.index] ?? "CONTROL_POINT");
return;
}
const meshId = hit.object.userData.meshId;
const triangle = hit.faceIndex ?? -1;
const sourceIndices = hit.object.userData.sourceIndices as number[] | undefined;
if (this.editMode && typeof meshId === "string" && triangle >= 0 && sourceIndices) {
const triangleVertices = sourceIndices.slice(triangle * 3, triangle * 3 + 3);
let selectedIndex = -1;
if (this.selectionMode === "FACE") {
const triangleFaces = hit.object.userData.triangleFaceIndices as number[] | undefined;
selectedIndex = triangleFaces?.[triangle] ?? triangle;
}
else if (this.selectionMode === "VERT") {
const positions = hit.object.userData.sourcePositions as number[] | undefined;
if (positions) {
const localHit = hit.object.worldToLocal(hit.point.clone());
const local = [localHit.x, -localHit.z, localHit.y];
selectedIndex = triangleVertices.reduce((best, vertex) => {
if (best < 0) return vertex;
const distance = (positions[vertex * 3] - local[0]) ** 2 + (positions[vertex * 3 + 1] - local[1]) ** 2 + (positions[vertex * 3 + 2] - local[2]) ** 2;
const bestDistance = (positions[best * 3] - local[0]) ** 2 + (positions[best * 3 + 1] - local[1]) ** 2 + (positions[best * 3 + 2] - local[2]) ** 2;
return distance < bestDistance ? vertex : best;
}, -1);
}
}
else {
const edges = hit.object.userData.edgeVertexIndices as number[] | undefined;
if (edges) {
const candidates = [[triangleVertices[0], triangleVertices[1]], [triangleVertices[1], triangleVertices[2]], [triangleVertices[2], triangleVertices[0]]];
selectedIndex = candidates.reduce((found, pair) => {
if (found >= 0) return found;
const low = Math.min(pair[0], pair[1]);
const high = Math.max(pair[0], pair[1]);
for (let edge = 0; edge < edges.length / 2; edge++) {
if (Math.min(edges[edge * 2], edges[edge * 2 + 1]) === low && Math.max(edges[edge * 2], edges[edge * 2 + 1]) === high) return edge;
}
return -1;
}, -1);
}
}
if (selectedIndex >= 0) this.onElementSelect?.(meshId, this.selectionMode, selectedIndex, additive);
return;
}
const instanceIds = hit.object.userData.instanceNodeIds as string[] | undefined;
const objectId = instanceIds && hit.instanceId !== undefined ? instanceIds[hit.instanceId] : hit.object.userData.blenderId;
if (typeof objectId === "string") this.onSelect?.(objectId, additive);
};
private renderLoop = (): void => {
if (this.disposed) return;
this.controls.update();
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
this.renderer.render(this.scene, this.camera);
this.animationFrame = window.requestAnimationFrame(this.renderLoop);
};
dispose(): void {
this.disposed = true;
window.cancelAnimationFrame(this.animationFrame);
this.resizeObserver.disconnect();
this.canvas.removeEventListener("click", this.handleClick);
this.controls.dispose();
this.lodAdapter.clear();
this.clearImportedScene();
this.textureStore.dispose();
this.renderer.dispose();
}
}

View File

@@ -0,0 +1,65 @@
interface WebEngineWasmModule {
_malloc: (size: number) => number;
_free: (pointer: number) => void;
_web_engine_create: () => number;
_web_engine_destroy: (handle: number) => void;
_web_engine_get_live_handles: () => number;
_web_engine_get_allocated_bytes: () => number;
_web_engine_open_blend: (handle: number, data: number, length: number) => number;
_web_engine_apply_command: (handle: number, data: number, length: number) => number;
_web_engine_undo: (handle: number) => number;
_web_engine_redo: (handle: number) => number;
_web_engine_get_scene_snapshot: (handle: number, data: number, length: number) => number;
_web_engine_get_scene_metadata: (handle: number, data: number, length: number) => number;
_web_engine_get_scene_geometry: (handle: number, data: number, length: number) => number;
_web_engine_get_scene_delta: (handle: number, data: number, length: number) => number;
_web_engine_get_packed_asset: (handle: number, assetId: number, assetIdLength: number, data: number, length: number) => number;
_web_engine_evaluate_depsgraph: (handle: number, data: number, length: number) => number;
_web_engine_save_blend: (handle: number, data: number, length: number) => number;
_web_engine_decimate_apply: (
positions: number,
vertexCount: number,
indices: number,
triangleCount: number,
mode: number,
ratio: number,
iterations: number,
angleLimit: number,
delimit: number,
useDissolveBoundaries: number,
triangulate: number,
symmetryAxis: number,
symmetryEpsilon: number,
vertexWeights: number,
vertexGroupFactor: number,
vertexGroupInvert: number,
uvs: number,
colors: number,
materialIndices: number,
positionsOut: number,
positionsCapacity: number,
positionsCount: number,
indicesOut: number,
indicesCapacity: number,
indicesCount: number,
uvsOut: number,
uvsCapacity: number,
uvsCount: number,
colorsOut: number,
colorsCapacity: number,
colorsCount: number,
materialIndicesOut: number,
materialIndicesCapacity: number,
materialIndicesCount: number,
faceCount: number,
) => number;
_web_engine_free_buffer: (data: number) => void;
_web_engine_last_error_code: () => number;
_web_engine_last_error_message: () => number;
UTF8ToString: (pointer: number) => string;
HEAPU8: Uint8Array;
HEAPU32: Uint32Array;
}
declare const factory: (options: { wasmBinary: ArrayBuffer }) => Promise<WebEngineWasmModule>;
export default factory;

File diff suppressed because one or more lines are too long

BIN
web/app/src/vendor/blender/web_engine.wasm vendored Executable file

Binary file not shown.

21
web/app/src/vendor/three/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,295 @@
import { Camera, Controls, MOUSE, TOUCH, Vector3 } from "three";
export interface OrbitControlsEventMap {
/**
* Fires when the camera has been transformed by the controls.
*/
change: {};
/**
* Fires when an interaction was initiated.
*/
start: {};
/**
* Fires when an interaction has finished.
*/
end: {};
}
/**
* Orbit controls allow the camera to orbit around a target.
*/
declare class OrbitControls<TCamera extends Camera = Camera> extends Controls<OrbitControlsEventMap, TCamera> {
/**
* The focus point of the controls, the {@link .object} orbits around this. It can be updated manually at any point
* to change the focus of the controls.
*/
target: Vector3;
/**
* The focus point of the {@link .minTargetRadius} and {@link .maxTargetRadius} limits. It can be updated manually
* at any point to change the center of interest for the {@link .target}.
*/
cursor: Vector3;
/**
* How far you can dolly in ( {@link PerspectiveCamera} only ). Default is 0.
*/
minDistance: number;
/**
* How far you can dolly out ( {@link PerspectiveCamera} only ). Default is Infinity.
*/
maxDistance: number;
/**
* How far you can zoom in ( {@link OrthographicCamera} only ). Default is 0.
*/
minZoom: number;
/**
* How far you can zoom out ( {@link OrthographicCamera} only ). Default is Infinity.
*/
maxZoom: number;
/**
* How close you can get the target to the 3D {@link .cursor}. Default is 0.
*/
minTargetRadius: number;
/**
* How far you can move the target from the 3D {@link .cursor}. Default is Infinity.
*/
maxTargetRadius: number;
/**
* How far you can orbit vertically, lower limit. Range is 0 to Math.PI radians, and default is 0.
*/
minPolarAngle: number;
/**
* How far you can orbit vertically, upper limit. Range is 0 to Math.PI radians, and default is Math.PI.
*/
maxPolarAngle: number;
/**
* How far you can orbit horizontally, lower limit. If set, the interval [ min, max ] must be a sub-interval of
* [ - 2 PI, 2 PI ], with ( max - min < 2 PI ). Default is Infinity.
*/
minAzimuthAngle: number;
/**
* How far you can orbit horizontally, upper limit. If set, the interval [ min, max ] must be a sub-interval of
* [ - 2 PI, 2 PI ], with ( max - min < 2 PI ). Default is Infinity.
*/
maxAzimuthAngle: number;
/**
* Set to true to enable damping (inertia), which can be used to give a sense of weight to the controls. Default is
* false.
* Note that if this is enabled, you must call {@link .update}() in your animation loop.
*/
enableDamping: boolean;
/**
* The damping inertia used if .enableDamping is set to true. Default is `0.05`.
* Note that for this to work, you must call {@link .update}() in your animation loop.
*/
dampingFactor: number;
/**
* Enable or disable zooming (dollying) of the camera.
*/
enableZoom: boolean;
/**
* Speed of zooming / dollying. Default is 1.
*/
zoomSpeed: number;
/**
* Enable or disable horizontal and vertical rotation of the camera. Default is true.
* Note that it is possible to disable a single axis by setting the min and max of the
* [polar angle]{@link .minPolarAngle} or [azimuth angle]{@link .minAzimuthAngle} to the same value, which will
* cause the vertical or horizontal rotation to be fixed at that value.
*/
enableRotate: boolean;
/**
* Speed of rotation. Default is 1.
*/
rotateSpeed: number;
/**
* How fast to rotate the camera when the keyboard is used. Default is 1.
*/
keyRotateSpeed: number;
/**
* Enable or disable camera panning. Default is true.
*/
enablePan: boolean;
/**
* Speed of panning. Default is 1.
*/
panSpeed: number;
/**
* Defines how the camera's position is translated when panning. If true, the camera pans in screen space.
* Otherwise, the camera pans in the plane orthogonal to the camera's up direction. Default is `true`.
*/
screenSpacePanning: boolean;
/**
* How fast to pan the camera when the keyboard is used. Default is 7.0 pixels per keypress.
*/
keyPanSpeed: number;
/**
* Setting this property to `true` allows to zoom to the cursor's position. Default is `false`.
*/
zoomToCursor: boolean;
/**
* Set to true to automatically rotate around the target.
* Note that if this is enabled, you must call {@link .update}() in your animation loop. If you want the auto-rotate speed
* to be independent of the frame rate (the refresh rate of the display), you must pass the time `deltaTime`, in
* seconds, to {@link .update}().
*/
autoRotate: boolean;
/**
* How fast to rotate around the target if {@link .autoRotate} is true. Default is 2.0, which equates to 30 seconds
* per orbit at 60fps.
* Note that if {@link .autoRotate} is enabled, you must call {@link .update}() in your animation loop.
*/
autoRotateSpeed: number;
/**
* This object contains references to the keycodes for controlling camera panning. Default is the 4 arrow keys.
*/
keys: { LEFT: string; UP: string; RIGHT: string; BOTTOM: string };
/**
* This object contains references to the mouse actions used by the controls.
*/
mouseButtons: {
LEFT?: MOUSE | null | undefined;
MIDDLE?: MOUSE | null | undefined;
RIGHT?: MOUSE | null | undefined;
};
/**
* This object contains references to the touch actions used by the controls.
*/
touches: { ONE?: TOUCH | null | undefined; TWO?: TOUCH | null | undefined };
/**
* Used internally by the {@link .saveState} and {@link .reset} methods.
*/
target0: Vector3;
/**
* Used internally by the {@link .saveState} and {@link .reset} methods.
*/
position0: Vector3;
/**
* Used internally by the {@link .saveState} and {@link .reset} methods.
*/
zoom0: number;
/**
* @param object The camera to be controlled. The camera must not be a child of another object, unless that object
* is the scene itself.
* @param domElement The HTML element used for event listeners. (optional)
*/
constructor(object: TCamera, domElement?: HTMLElement | SVGElement | null);
set cursorStyle(type: "auto" | "grab");
get cursorStyle(): "auto" | "grab";
/**
* Get the current vertical rotation, in radians.
*/
getPolarAngle(): number;
/**
* Get the current horizontal rotation, in radians.
*/
getAzimuthalAngle(): number;
/**
* Returns the distance from the camera to the target.
*/
getDistance(): number;
/**
* Adds key event listeners to the given DOM element. `window` is a recommended argument for using this method.
* @param domElement
*/
listenToKeyEvents(domElement: HTMLElement | Window): void;
/**
* Removes the key event listener previously defined with {@link .listenToKeyEvents}().
*/
stopListenToKeyEvents(): void;
/**
* Save the current state of the controls. This can later be recovered with {@link .reset}.
*/
saveState(): void;
/**
* Reset the controls to their state from either the last time the {@link .saveState} was called, or the initial
* state.
*/
reset(): void;
/**
* Programmatically pan the camera.
*
* @param {number} deltaX - The horizontal pan amount in pixels.
* @param {number} deltaY - The vertical pan amount in pixels.
*/
pan(deltaX: number, deltaY: number): void;
/**
* Programmatically dolly in (zoom in for perspective camera).
*
* @param {number} dollyScale - The dolly scale factor.
*/
dollyIn(dollyScale: number): void;
/**
* Programmatically dolly out (zoom out for perspective camera).
*
* @param {number} dollyScale - The dolly scale factor.
*/
dollyOut(dollyScale: number): void;
/**
* Programmatically rotate the camera left (around the vertical axis).
*
* @param {number} angle - The rotation angle in radians.
*/
rotateLeft(angle: number): void;
/**
* Programmatically rotate the camera up (around the horizontal axis).
*
* @param {number} angle - The rotation angle in radians.
*/
rotateUp(angle: number): void;
/**
* Update the controls. Must be called after any manual changes to the camera's transform, or in the update loop if
* {@link .autoRotate} or {@link .enableDamping} are set. `deltaTime`, in seconds, is optional, and is only required
* if you want the auto-rotate speed to be independent of the frame rate (the refresh rate of the display).
*/
update(deltaTime?: number | null): boolean;
}
export { OrbitControls };

File diff suppressed because it is too large Load Diff

60007
web/app/src/vendor/three/three.core.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export * from "three";

19610
web/app/src/vendor/three/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
import { ASSET_LIBRARY_BUDGET, assetStorageCapabilities, gateIORequest, gateLibraryMutation, libraryLoadOrder, parseAssetLibraryManifest, parseIORequest, verifyAssetSource } from "../../../protocol/asset-library-io";
const shaA = "a".repeat(64); const shaB = "b".repeat(64);
const asset = { id: "asset:1", name: "Cube", kind: "OBJECT", catalogId: "catalog:models", tags: ["model"], author: "Web", license: "CC0-1.0", sourceSha256: shaA, sourcePath: "assets/cube.blend" };
const base = { schemaVersion: 1, revision: 2, catalogs: [{ id: "catalog:root", name: "Root", parentId: null }, { id: "catalog:models", name: "Models", parentId: "catalog:root" }], assets: [asset], libraries: [{ id: "library:a", name: "A", sourcePath: "libraries/a.blend", sourceSha256: shaA, dependencyIds: ["library:b"], readOnly: true }, { id: "library:b", name: "B", sourcePath: "libraries/b.blend", sourceSha256: shaB, dependencyIds: [], readOnly: true }] };
const glbRequest = { format: "GLB", operation: "EXPORT", externalUris: [], archiveEntries: [] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const manifest = parseAssetLibraryManifest(base); verifyAssetSource(manifest.assets[0], shaA); result.valid = [manifest.assets[0].license, libraryLoadOrder(manifest)]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try { verifyAssetSource(parseAssetLibraryManifest(base).assets[0], shaB); } catch (error) { result.hash = error instanceof Error ? error.message : String(error); }
try { parseAssetLibraryManifest({ ...base, assets: [{ ...asset, license: "" }] }); } catch (error) { result.license = error instanceof Error ? error.message : String(error); }
try { parseAssetLibraryManifest({ ...base, libraries: [{ ...base.libraries[0], dependencyIds: ["library:a"] }, base.libraries[1]] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try { parseIORequest({ ...glbRequest, archiveEntries: [{ path: "safe.bin", compressedBytes: 1, uncompressedBytes: ASSET_LIBRARY_BUDGET.maxCompressionRatio + 1 }] }); } catch (error) { result.archive = error instanceof Error ? error.message : String(error); }
try { parseIORequest({ ...glbRequest, externalUris: ["https://example.com/file.bin"] }); } catch (error) { result.uri = error instanceof Error ? error.message : String(error); }
result.glb = gateIORequest(glbRequest).status;
result.obj = gateIORequest({ ...glbRequest, format: "OBJ", operation: "IMPORT" }).issues[0]?.code;
result.library = gateLibraryMutation("APPEND").status;
result.storage = assetStorageCapabilities();
self.postMessage(result);
};

View File

@@ -0,0 +1,17 @@
import { aggregateLightweightUsage, evaluateLightweightBudgets } from "../../../protocol/budget";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ objects: Parameters<typeof aggregateLightweightUsage>[0]; lod: Parameters<typeof aggregateLightweightUsage>[1]; scopes: Parameters<typeof evaluateLightweightBudgets>[0] }>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event: MessageEvent<{ objects: Parameters<typeof aggregateLightweightUsage>[0]; lod: Parameters<typeof aggregateLightweightUsage>[1]; scopes: Parameters<typeof evaluateLightweightBudgets>[0] }>) => {
try {
const usage = aggregateLightweightUsage(event.data.objects, event.data.lod);
scope.postMessage({ ok: true, report: evaluateLightweightBudgets(event.data.scopes, usage) });
} catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "budget aggregation failed" });
}
};
export {};

View File

@@ -0,0 +1,15 @@
import { evaluateLightweightBudget } from "../../../protocol/budget";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ budget: unknown; usage: Parameters<typeof evaluateLightweightBudget>[1] }>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event) => {
try {
scope.postMessage({ ok: true, report: evaluateLightweightBudget(event.data.budget, event.data.usage) });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "budget validation failed" });
}
};

View File

@@ -0,0 +1,58 @@
import { executeCompositorGraph, gateCompositorGraph, parseCompositorGraph } from "../../../protocol/compositor";
const node = (id: string, type: string, properties: Record<string, unknown> = {}) => ({ id, type, name: id, properties });
const valid = {
schemaVersion: 1,
id: "compositor:test",
name: "Test",
outputNodeId: "out",
resources: [],
nodes: [
node("red", "CONSTANT_COLOR", { color: [0.25, 0, 0, 1] }),
node("exposure", "EXPOSURE", { exposure: 1 }),
node("blue", "CONSTANT_COLOR", { color: [0, 0, 1, 0.5] }),
node("mix", "MIX", { factor: 0.5 }),
node("blur", "BLUR", { radius: 1 }),
node("viewer", "VIEWER"),
node("out", "COMPOSITE"),
],
links: [
{ fromNodeId: "red", fromSocket: "Image", toNodeId: "exposure", toSocket: "Image" },
{ fromNodeId: "exposure", fromSocket: "Image", toNodeId: "mix", toSocket: "A" },
{ fromNodeId: "blue", fromSocket: "Image", toNodeId: "mix", toSocket: "B" },
{ fromNodeId: "mix", fromSocket: "Image", toNodeId: "blur", toSocket: "Image" },
{ fromNodeId: "blur", fromSocket: "Image", toNodeId: "viewer", toSocket: "Image" },
{ fromNodeId: "viewer", fromSocket: "Image", toNodeId: "out", toSocket: "Image" },
],
};
self.onmessage = () => {
const result: Record<string, unknown> = {};
try {
const parsed = parseCompositorGraph(valid);
const execution = executeCompositorGraph(parsed, new Map(), { width: 2, height: 2 });
result.valid = Array.from(execution.composite.data.slice(0, 4));
result.nodes = execution.evaluatedNodeIds;
result.viewer = execution.viewers.has("viewer");
result.gate = gateCompositorGraph(parsed, new Set()).status;
}
catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try {
parseCompositorGraph({ ...valid, nodes: [node("a", "INVERT"), node("b", "INVERT"), node("out", "COMPOSITE")], links: [
{ fromNodeId: "a", fromSocket: "Image", toNodeId: "b", toSocket: "Image" },
{ fromNodeId: "b", fromSocket: "Image", toNodeId: "a", toSocket: "Image" },
{ fromNodeId: "b", fromSocket: "Image", toNodeId: "out", toSocket: "Image" },
] });
}
catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
const unsupported = { ...valid, nodes: [
{ ...node("cryptomatte", "UNSUPPORTED"), blenderType: "CompositorNodeCryptomatteV2" },
node("out", "COMPOSITE"),
], links: [{ fromNodeId: "cryptomatte", fromSocket: "Image", toNodeId: "out", toSocket: "Image" }] };
result.unsupported = gateCompositorGraph(unsupported, new Set()).issues[0]?.code;
try { executeCompositorGraph(valid, new Map(), { width: 8_192, height: 8_192 }); }
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { executeCompositorGraph(valid, new Map(), { width: 2, height: 2, cancelled: () => true }); }
catch (error) { result.cancelled = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -0,0 +1,31 @@
import { evaluateDeformedMesh } from "../../../protocol/deformation";
import type { MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
interface DeformationTestRequest {
snapshot: SceneSnapshotIR;
meshId: string;
geometryBuffers?: Array<Pick<MeshGeometryBuffer, "meshId" | "positions">>;
}
const scope = self as unknown as {
onmessage: ((event: MessageEvent<DeformationTestRequest>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event) => {
try {
const geometry = event.data.geometryBuffers?.find((candidate) => candidate.meshId === event.data.meshId);
const snapshot = geometry ? {
...event.data.snapshot,
meshes: event.data.snapshot.meshes.map((mesh) => mesh.id === event.data.meshId ?
{ ...mesh, positions: Array.from(new Float32Array(geometry.positions)) } : mesh),
} : event.data.snapshot;
scope.postMessage({ ok: true, positions: evaluateDeformedMesh(snapshot, event.data.meshId) });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "deformation evaluation failed" });
}
};
export {};

View File

@@ -0,0 +1,15 @@
import { applyEditorWorkflowEdit, EDITOR_WORKFLOW_BUDGET, gateEditorOperation, parseEditorWorkflow } from "../../../protocol/editor-workflow";
const regions = [{ id: "header", kind: "HEADER", visible: true }, { id: "main", kind: "MAIN", visible: true }];
const area = (id: string, editor: string, x: number, y: number, width: number, height: number) => ({ id, editor, regions, rect: { x, y, width, height }, maximized: false });
const base = { schemaVersion: 1, workspaces: [{ id: "Layout", name: "Layout", activeAreaId: "viewport", revision: 0, areas: [area("viewport", "VIEW_3D", 0, 0, 0.8, 0.8), area("properties", "PROPERTIES", 0.8, 0, 0.2, 0.8)] }, { id: "Animation", name: "Animation", activeAreaId: "timeline", revision: 0, areas: [area("timeline", "TIMELINE", 0, 0, 1, 1)] }], context: { workspaceId: "Layout", activeAreaId: "viewport", activeEditor: "VIEW_3D", mode: "OBJECT", activeObjectId: "object:1", selection: ["object:1"], viewLayer: "ViewLayer", pinnedData: null, revision: 0 }, keymaps: [{ id: "key:delete", key: "X", modifiers: [], command: "object.delete", enabled: true }] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const parsed = parseEditorWorkflow(base); const switched = applyEditorWorkflowEdit(parsed, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); const selected = applyEditorWorkflowEdit(switched, { type: "SET_SELECTION", revision: 1, selectedIds: ["object:2"], activeObjectId: "object:2" }); result.edit = [selected.context.revision, selected.context.activeEditor, selected.context.selection]; } catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
try { applyEditorWorkflowEdit(base, { type: "SET_ACTIVE_AREA", revision: 8, areaId: "viewport" }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
try { parseEditorWorkflow({ ...base, workspaces: [{ ...base.workspaces[0], areas: [area("a", "VIEW_3D", 0, 0, 0.6, 1), area("b", "OUTLINER", 0.5, 0, 0.5, 1)] }] }); } catch (error) { result.overlap = error instanceof Error ? error.message : String(error); }
try { parseEditorWorkflow({ ...base, context: { ...base.context, selection: new Array(EDITOR_WORKFLOW_BUDGET.maxSelection + 1).fill("x") } }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
result.view = gateEditorOperation("READ_ONLY_VIEW").status; result.writer = gateEditorOperation("WRITER").issues[0]?.code; result.gizmo = gateEditorOperation("GIZMO").issues[0]?.code;
self.postMessage(result);
};

View File

@@ -0,0 +1,111 @@
import type { EngineRequest, EngineResponse } from "../../../protocol/engine";
import type { SceneNodeIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
interface WorkerScope {
onmessage: ((event: MessageEvent<EngineRequest>) => void) | null;
postMessage(message: EngineResponse): void;
}
const scope = self as unknown as WorkerScope;
let revision = 0;
let frame = 1;
const nodes: SceneNodeIR[] = [
{
id: "mock-basic-cube",
name: "BasicCube",
type: "MESH",
parentId: null,
dataId: "mock-cube-mesh",
visible: true,
selectable: true,
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
transform: {
translation: [0, 0, 0],
rotationEuler: [0, 0, 0],
scale: [1, 1, 1],
rotationMode: 1,
},
},
];
function snapshot(): SceneSnapshotIR {
return {
schemaVersion: 1,
revision,
sceneId: "mock-scene",
source: { kind: "mock" },
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
nodes: nodes.map((node) => ({ ...node })),
meshes: [{ id: "mock-cube-mesh", name: "Cube", vertexCount: 8, edgeCount: 12, faceCount: 6, cornerCount: 24, triangleCount: 12, geometryStatus: "summary-only" }],
materials: [],
cameras: [],
lights: [],
worlds: [],
images: [],
animations: [],
collections: [{ id: "mock-collection", name: "Collection", objectIds: ["mock-basic-cube"], childCollectionIds: [] }],
scenes: [{ id: "mock-scene", name: "Scene", rootCollectionId: "mock-collection" }],
activeObjectId: "mock-basic-cube",
frame: { current: frame, start: 1, end: 250 },
};
}
function response(requestId: string, ok: boolean, reports?: EngineResponse["reports"]): EngineResponse {
return { requestId, ok, revision, reports };
}
scope.onmessage = (event) => {
const request = event.data;
try {
if (request.expectedRevision !== revision && request.command.type !== "init") {
scope.postMessage(response(request.requestId, false, [{
code: "REVISION_CONFLICT",
severity: "error",
message: `revision mismatch: expected ${request.expectedRevision}, current ${revision}`,
recoverable: true,
}]));
return;
}
switch (request.command.type) {
case "init":
scope.postMessage({
...response(request.requestId, true),
capabilities: { engine: "mock", protocolVersion: 1, supportsBlend: false, supportsMeshEdit: false },
});
return;
case "getSceneSnapshot":
scope.postMessage({ ...response(request.requestId, true), snapshot: snapshot() });
return;
case "setFrame":
frame = Math.max(1, Math.round(request.command.frame));
revision += 1;
scope.postMessage(response(request.requestId, true));
return;
case "setObjectVisibility": {
const command = request.command;
const node = nodes.find((item) => item.id === command.objectId);
if (!node) {
scope.postMessage(response(request.requestId, false, [{ code: "INVALID_ARGUMENT", severity: "error", message: "object not found", recoverable: true }]));
return;
}
node.visible = command.visible;
revision += 1;
scope.postMessage(response(request.requestId, true));
return;
}
case "shutdown":
scope.postMessage(response(request.requestId, true));
self.close();
return;
}
} catch (error) {
scope.postMessage(response(request.requestId, false, [{
code: "INVALID_ARGUMENT",
severity: "error",
message: error instanceof Error ? error.message : "unknown EngineWorker error",
recoverable: true,
}]));
}
};

View File

@@ -0,0 +1,65 @@
import { exportGLB } from "../../../protocol/glb-export";
import { compareGLBToSceneIR, importGLBSemantics } from "../../../protocol/glb-import";
const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; postMessage(message: unknown): void };
scope.onmessage = (event) => {
try {
const result = exportGLB(event.data.snapshot, event.data.geometryBuffers, event.data.assetBuffers);
const magic = result.glb ? new DataView(result.glb).getUint32(0, true) : undefined;
const version = result.glb ? new DataView(result.glb).getUint32(4, true) : undefined;
let firstMorphDelta: number[] | undefined;
let attributes: string[] | undefined;
let pbrSummary: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number } | undefined;
let gltfSummary: {
imageCount: number;
skinCount: number;
animationCount: number;
imageByteLength: number;
imageMimeType?: string;
imageSignature: number[];
skinJointCount: number;
animationChannelCount: number;
inverseBindType?: string;
} | undefined;
let roundTrip: { compatible: boolean; mismatches: string[] } | undefined;
if (result.glb) {
const imported = importGLBSemantics(result.glb);
roundTrip = compareGLBToSceneIR(event.data.snapshot, imported, event.data.assetBuffers ?? []);
const view = new DataView(result.glb);
const jsonLength = view.getUint32(12, true);
const json = JSON.parse(new TextDecoder().decode(new Uint8Array(result.glb, 20, jsonLength)));
const targetAccessor = json.meshes?.[0]?.primitives?.[0]?.targets?.[0]?.POSITION;
attributes = Object.keys(json.meshes?.[0]?.primitives?.[0]?.attributes ?? {});
const pbr = json.materials?.[0]?.pbrMetallicRoughness;
if (pbr) pbrSummary = { baseColorFactor: pbr.baseColorFactor, roughnessFactor: pbr.roughnessFactor, metallicFactor: pbr.metallicFactor };
const imageView = json.images?.[0] ? json.bufferViews?.[json.images[0].bufferView] : undefined;
const imageOffset = 20 + jsonLength + 8 + (imageView?.byteOffset ?? 0);
const imageSignature = imageView ? Array.from(new Uint8Array(result.glb, imageOffset, Math.min(8, imageView.byteLength))) : [];
const inverseBindAccessor = json.skins?.[0] ? json.accessors?.[json.skins[0].inverseBindMatrices] : undefined;
gltfSummary = {
imageCount: json.images?.length ?? 0,
skinCount: json.skins?.length ?? 0,
animationCount: json.animations?.length ?? 0,
imageByteLength: imageView?.byteLength ?? 0,
imageMimeType: json.images?.[0]?.mimeType,
imageSignature,
skinJointCount: json.skins?.[0]?.joints?.length ?? 0,
animationChannelCount: json.animations?.[0]?.channels?.length ?? 0,
inverseBindType: inverseBindAccessor?.type,
};
const accessor = Number.isInteger(targetAccessor) ? json.accessors?.[targetAccessor] : undefined;
const bufferView = accessor ? json.bufferViews?.[accessor.bufferView] : undefined;
if (accessor && bufferView && accessor.count > 0) {
const byteOffset = 20 + jsonLength + 8 + (bufferView.byteOffset ?? 0);
firstMorphDelta = Array.from(new Float32Array(result.glb, byteOffset, 3));
}
}
scope.postMessage({ ok: true, report: result.report, byteLength: result.glb?.byteLength ?? 0, magic, version, firstMorphDelta, attributes, pbrSummary, gltfSummary, roundTrip });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "GLB warning analysis failed" });
}
};
export {};

View File

@@ -0,0 +1,74 @@
import { GREASE_PENCIL_BUDGET, parseGreasePencilData } from "../../../protocol/grease-pencil";
const point = (x: number) => ({ position: [x, 0, 0], radius: 0.05, opacity: 1 });
const valid = {
id: "gp:SchemaFixture",
name: "SchemaFixture",
geometryStatus: "available",
layerCount: 1,
frameCount: 1,
strokeCount: 1,
pointCount: 2,
layers: [{
id: "layer:Lines",
name: "Lines",
visible: true,
locked: false,
opacity: 1,
frames: [{
frame: 1,
drawing: {
id: "drawing:1",
strokeCount: 1,
pointCount: 2,
strokes: [{ id: "stroke:1", cyclic: false, pointCount: 2, points: [point(0), point(1)] }],
},
}],
}],
};
function result(): Record<string, unknown> {
let validStatus: string;
let malformed = "";
try {
validStatus = parseGreasePencilData(valid).geometryStatus;
}
catch (error) {
validStatus = error instanceof Error ? error.message : String(error);
}
try {
parseGreasePencilData({ ...valid, pointCount: 3 });
}
catch (error) {
malformed = error instanceof Error ? error.message : String(error);
}
const budget = (() => {
try {
parseGreasePencilData({
...valid,
geometryStatus: "blocked",
pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1,
layers: [{
...valid.layers[0],
frames: [{
...valid.layers[0].frames[0],
drawing: { ...valid.layers[0].frames[0].drawing, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, strokes: [{ cyclic: false, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1 }] },
}],
}],
strokeCount: 1,
frameCount: 1,
errorCode: "GREASE_PENCIL_BUDGET_EXCEEDED",
});
return "accepted-blocked-budget";
}
catch (error) {
return error instanceof Error ? error.message : String(error);
}
})();
return { validStatus, malformed, budget };
}
self.onmessage = () => {
self.postMessage(result());
};

View File

@@ -0,0 +1,23 @@
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ levels: Parameters<typeof encodeLODGeometry>[0] }>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
};
scope.onmessage = (event) => {
try {
const encoded = encodeLODGeometry(event.data.levels);
const decoded = decodeLODGeometry(encoded);
const transfer: Transferable[] = [];
for (const level of decoded) {
for (const geometry of level.geometryBuffers) {
for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
}
}
scope.postMessage({ ok: true, byteLength: encoded.byteLength, levels: decoded }, transfer);
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "mesh cache round trip failed" });
}
};

View File

@@ -0,0 +1,53 @@
import { evaluateModifierDependencyGraph } from "../../../protocol/modifier-graph";
const scope = self as unknown as {
onmessage: ((event: MessageEvent) => void) | null;
postMessage(message: unknown): void;
};
const modifier = (uuid: string, status: "EVALUATED" | "METADATA_ONLY" | "BLOCKED", dependsOn?: string[]) => ({
uuid,
type: "DECIMATE",
name: uuid,
enabled: true,
showViewport: true,
showRender: true,
parameters: {},
evaluationStatus: status,
dependsOn,
});
scope.onmessage = () => {
try {
const report = evaluateModifierDependencyGraph({
meshes: [{
id: "mesh:ordered",
name: "Ordered",
vertexCount: 0,
edgeCount: 0,
faceCount: 0,
cornerCount: 0,
geometryStatus: "summary-only",
modifierStack: [modifier("mod:source", "EVALUATED"), modifier("mod:decimate", "METADATA_ONLY")],
}],
});
const cycle = evaluateModifierDependencyGraph({
meshes: [{
id: "mesh:cycle",
name: "Cycle",
vertexCount: 0,
edgeCount: 0,
faceCount: 0,
cornerCount: 0,
geometryStatus: "summary-only",
modifierStack: [modifier("mod:a", "EVALUATED", ["mod:b"]), modifier("mod:b", "EVALUATED")],
}],
});
scope.postMessage({ ok: true, report, cycle });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "modifier graph evaluation failed" });
}
};
export {};

View File

@@ -0,0 +1,59 @@
import {
chunkNonMeshGeometry,
chunkNonMeshScene,
reassembleNonMeshGeometry,
validateNonMeshGeometryChunks,
} from "../../../protocol/nonmesh-binary";
const scope = self as unknown as {
onmessage: ((event: MessageEvent) => void) | null;
postMessage: (value: unknown) => void;
};
async function rejected(task: () => Promise<unknown>): Promise<string> {
try {
await task();
return "accepted";
}
catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
scope.onmessage = (): void => {
const positions = Float32Array.from({ length: 30 }, (_, index) => index * 0.1);
void (async () => {
const valid = await chunkNonMeshGeometry({ dataId: "test:malformed", positions });
const badHash = { ...valid[0], sha256: "0".repeat(64) };
const badLength = { ...valid[0], positions: valid[0].positions.slice(0, valid[0].positions.byteLength - 4) };
const overflowMetadata = { ...valid[0], pointCount: Number.MAX_SAFE_INTEGER, totalPointCount: Number.MAX_SAFE_INTEGER };
const invalidAttributes = { ...valid[0], attributes: {} };
const attributeOverflow = { ...valid[0], attributes: [{
name: "overflow", domain: "POINT", dataType: "FLOAT3", components: 4, storage: "FLOAT32",
elementOffset: 0, elementCount: Number.MAX_SAFE_INTEGER, data: new ArrayBuffer(0),
}] };
const negativeOffset = { ...valid[0], pointOffset: -1 };
const multi = await chunkNonMeshGeometry({ dataId: "test:missing-attribute", positions: new Float32Array(12), attributes: [{ name: "weight", domain: "POINT", dataType: "FLOAT", components: 1, values: new Float32Array(4) }] }, { maxChunkPoints: 2 });
const missingAttribute = [{ ...multi[0] }, { ...multi[1], attributes: [] }];
const hundredThousand = new Float32Array(100_000 * 3);
const oversized = new Float32Array((1_000_001) * 3);
const result = {
hash: await rejected(() => validateNonMeshGeometryChunks([badHash])),
length: await rejected(() => validateNonMeshGeometryChunks([badLength])),
integerOverflow: await rejected(() => validateNonMeshGeometryChunks([overflowMetadata])),
invalidAttributes: await rejected(() => validateNonMeshGeometryChunks([invalidAttributes])),
attributeOverflow: await rejected(() => validateNonMeshGeometryChunks([attributeOverflow])),
negativeOffset: await rejected(() => validateNonMeshGeometryChunks([negativeOffset])),
missingAttribute: (() => { try { reassembleNonMeshGeometry("test:missing-attribute", missingAttribute); return "accepted"; } catch (error) { return error instanceof Error ? error.message : String(error); } })(),
duplicateId: await rejected(() => chunkNonMeshScene([
{ dataId: "test:duplicate", positions: new Float32Array(3) },
{ dataId: "test:duplicate", positions: new Float32Array(3) },
])),
hundredThousandBudget: await rejected(() => chunkNonMeshGeometry(
{ dataId: "test:hundred-thousand", positions: hundredThousand }, { maxPoints: 99_999 },
)),
pointBudget: await rejected(() => chunkNonMeshGeometry({ dataId: "test:oversized", positions: oversized })),
};
scope.postMessage(result);
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,16 @@
import { benchmarkNonMeshTransfer, NON_MESH_MAX_POINTS } from "../../../protocol/nonmesh-binary";
const scope = self as unknown as { onmessage: ((event: MessageEvent<{ pointCount?: number }>) => void) | null; postMessage: (value: unknown) => void };
scope.onmessage = (event): void => {
const pointCount = event.data.pointCount ?? NON_MESH_MAX_POINTS;
const positions = new Float32Array(pointCount * 3);
const radii = new Float32Array(pointCount);
for (let index = 0; index < pointCount; index++) {
positions[index * 3] = index % 1024;
positions[index * 3 + 1] = Math.floor(index / 1024) % 1024;
positions[index * 3 + 2] = index * 0.0001;
radii[index] = 0.01;
}
void benchmarkNonMeshTransfer({ dataId: "test:one-million-points", positions, radii }).then((gate) => scope.postMessage(gate)).catch((error) => scope.postMessage({ status: "BLOCKED", code: "NON_MESH_BINARY_INVALID", error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,22 @@
import { PAINT_BUDGET, parsePaintStroke, parseWeightPatch } from "../../../protocol/paint";
const base = {
schemaVersion: 1,
mode: "VERTEX_COLOR",
objectId: "object:Paint",
revision: 3,
radius: 12,
strength: 0.5,
samples: [{ position: [0, 0, 0], normal: [0, 0, 1], faceIndex: 0, barycentric: [0.2, 0.3, 0.5], uv: [0.25, 0.75], pressure: 1 }],
color: [1, 0.25, 0, 1],
};
self.onmessage = () => {
const result: Record<string, string> = {};
try { result.valid = parsePaintStroke(base).mode; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, samples: [{ position: [0, 0, 0], barycentric: [0.1, 0.1, 0.1] }] }); } catch (error) { result.hit = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, mode: "WEIGHT", vertexGroup: "Group", samples: new Array(PAINT_BUDGET.maxSamples + 1).fill(base.samples[0]) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { result.weight = parseWeightPatch({ schemaVersion: 1, objectId: "object:Paint", revision: 3, vertexGroup: "Group", indices: [0, 1], values: [0.25, 0.75], normalize: true }).vertexGroup; } catch (error) { result.weight = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -0,0 +1,65 @@
import {
PHYSICS_FAMILIES,
PHYSICS_SIMULATION_BUDGET,
gatePhysicsExecution,
parsePhysicsSimulationManifest,
physicsCapabilityInventory,
selectPhysicsCacheFrame,
} from "../../../protocol/physics-simulation";
const hash = "a".repeat(64);
const base = {
id: "physics:cloth",
family: "CLOTH",
ownerObjectId: "object:Cloth",
settingsHash: hash,
settings: { quality: 5, usePressure: false },
dependencyIds: ["object:Collision"],
cache: {
cacheKey: "cloth-cache-1-2",
source: "BLENDER_DESKTOP_BAKE",
sourceBlendSha256: hash,
settingsHash: hash,
inputHash: hash,
cacheSha256: hash,
frameStart: 1,
frameEnd: 2,
cachedFrames: [1, 2],
status: "COMPLETE",
},
};
self.onmessage = () => {
const result: Record<string, unknown> = {};
try {
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
result.valid = `${parsed.systems[0].family}:${selectPhysicsCacheFrame(parsed.systems[0], 2).frame}`;
}
catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try {
parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [
{ ...base, id: "physics:a", cache: undefined, dependencyIds: ["physics:b"] },
{ ...base, id: "physics:b", cache: undefined, dependencyIds: ["physics:a"] },
] });
}
catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try {
parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [{
...base,
cache: undefined,
dependencyIds: new Array(PHYSICS_SIMULATION_BUDGET.maxDependenciesPerSystem + 1).fill("object:TooMany"),
}] });
}
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try {
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
selectPhysicsCacheFrame(parsed.systems[0], 3);
}
catch (error) { result.missingFrame = error instanceof Error ? error.message : String(error); }
result.families = physicsCapabilityInventory().map((entry) => entry.family);
result.inventoryBlocked = physicsCapabilityInventory().every((entry) => entry.localSolver === "BLOCKED" && entry.cachePlayback === "BLOCKED");
result.solver = gatePhysicsExecution("FLUID", "LOCAL_SOLVER").issues[0]?.code;
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
result.familyCount = PHYSICS_FAMILIES.length;
self.postMessage(result);
};

View File

@@ -0,0 +1,15 @@
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies });
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false } };
const base = { schemaVersion: 2, source: "docs/status/parity-ledger.json", generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
try { parseReleaseManifest({ ...base, schemaVersion: 1 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -0,0 +1,18 @@
import { gateScriptExecution, gateServerScriptJob, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform";
const sha = "a".repeat(64); const signature = "b".repeat(128);
const script = { id: "script:clean", name: "Clean", entryPath: "scripts/clean.py", sourceSha256: sha, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false };
const base = { schemaVersion: 1, scripts: [script] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { result.valid = parseScriptingManifest(base).scripts[0].entryPath; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
result.exec = gateScriptExecution(base, "script:clean", new Set(["key:trusted"])).issues[0]?.code;
result.server = gateServerScriptJob({ scriptId: "script:clean", sourceSha256: sha, inputBlendSha256: "c".repeat(64), status: "QUEUED" }, base, "c".repeat(64)).issues[0]?.code;
try { parseScriptingManifest({ ...base, scripts: [{ ...script, entryPath: "../escape.py" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); }
try { parseScriptingManifest({ ...base, scripts: [{ ...script, autorun: true }] }); } catch (error) { result.policy = error instanceof Error ? error.message : String(error); }
try { parseScriptingManifest({ ...base, scripts: new Array(SCRIPTING_BUDGET.maxScripts + 1).fill(script) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { parseScriptingManifest({ ...base, scripts: [{ ...script, signature: "bad" }] }); } catch (error) { result.signature = error instanceof Error ? error.message : String(error); }
result.platform = platformCapabilities();
self.postMessage(result);
};

View File

@@ -0,0 +1,15 @@
import { gateSelectionInteraction, parseRaycastSelectionHit, recordSelection, SELECTION_HISTORY_BUDGET, stepSelectionHistory } from "../../../protocol/selection-history";
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); }
result.gates = [gateSelectionInteraction("RAYCAST").status, gateSelectionInteraction("HISTORY").status, gateSelectionInteraction("GIZMO").status];
self.postMessage(result);
};

View File

@@ -0,0 +1,45 @@
import {
SEQUENCER_BUDGET,
applySequencerEdit,
gateSequencerCodec,
parseSequencerTimeline,
sequencerRuntimeCapabilities,
sequencerSourceFrame,
} from "../../../protocol/sequencer";
const movie = {
id: "strip:Movie", name: "Movie", type: "MOVIE", channel: 2, frameStart: 10, frameEnd: 20,
sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false,
sourcePath: "media/shot.mp4", mimeType: "video/mp4",
};
const base = { schemaVersion: 1, id: "sequence:Test", revision: 3, frameStart: 1, frameEnd: 100,
fpsNumerator: 24, fpsDenominator: 1, strips: [movie] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try {
const timeline = parseSequencerTimeline(base);
result.valid = sequencerSourceFrame(timeline.strips[0], 15);
const moved = applySequencerEdit(timeline, { type: "MOVE", revision: 3, stripId: movie.id, frameDelta: 5, channel: 3 });
const split = applySequencerEdit(moved, { type: "SPLIT", revision: 4, stripId: movie.id, frame: 20, rightStripId: "strip:MovieRight" });
result.edit = [split.revision, split.strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd, strip.sourceStart, strip.sourceEnd])];
try { applySequencerEdit(timeline, { type: "MOVE", revision: 2, stripId: movie.id, frameDelta: 1 }); }
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
}
catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try {
parseSequencerTimeline({ ...base, strips: [{ ...movie, sourcePath: "../outside.mp4" }] });
}
catch (error) { result.path = error instanceof Error ? error.message : String(error); }
try {
parseSequencerTimeline({ ...base, strips: [{ ...movie, id: "strip:Meta", type: "META", sourcePath: undefined, sourceId: undefined, childStripIds: ["strip:Meta"] }] });
}
catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try {
parseSequencerTimeline({ ...base, strips: new Array(SEQUENCER_BUDGET.maxStrips + 1).fill(movie) });
}
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
result.runtime = sequencerRuntimeCapabilities();
self.postMessage(result);
};

View File

@@ -0,0 +1,33 @@
import { parseLODManifest, parseSimplifyProfile, SimplifyValidationError } from "../../../protocol/simplify";
const scope = self as unknown as {
onmessage: ((event: MessageEvent) => void) | null;
postMessage: (value: unknown) => void;
};
scope.onmessage = (event) => {
try {
const input = event.data as { profile: unknown; manifest: unknown; invalidProfile: unknown };
const profile = parseSimplifyProfile(input.profile);
const manifest = parseLODManifest(input.manifest);
let errorCode = "";
try {
parseSimplifyProfile(input.invalidProfile);
} catch (error) {
errorCode = error instanceof SimplifyValidationError ? error.code : "unknown";
}
scope.postMessage({
ok: true,
profile: {
mode: profile.mode,
ratio: profile.mode === "COLLAPSE" ? profile.ratio : undefined,
attributePolicy: profile.attributePolicy,
skinMaxInfluences: profile.skinPolicy?.maxInfluences,
},
levels: manifest.levels.length,
errorCode,
});
} catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "simplify protocol failed" });
}
};

View File

@@ -0,0 +1,15 @@
import { evaluateSkinSimplifyGate } from "../../../protocol/skin";
const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; postMessage(message: unknown): void };
scope.onmessage = (event) => {
try {
const result = evaluateSkinSimplifyGate(event.data.vertexCount, event.data.skin, event.data.shapeKeys, event.data.policy);
scope.postMessage({ ok: true, result });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "skin validation failed" });
}
};
export {};

View File

@@ -0,0 +1,789 @@
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 { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
import { parseSimulationCacheManifest, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, 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";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
postMessage(message: StorageResponse, transfer?: Transferable[]): void;
};
const projectTransactions = new Map<string, Promise<void>>();
let opfsUsable: boolean | undefined;
async function useOpfsForProject(projectId: string): Promise<boolean> {
if (opfsUsable !== undefined) return opfsUsable;
const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator;
if (!workerNavigator?.storage || typeof (workerNavigator.storage as StorageManager & { getDirectory?: unknown }).getDirectory !== "function") {
opfsUsable = false;
return false;
}
try {
await ensureProjectLayout(projectId);
opfsUsable = true;
}
catch {
opfsUsable = false;
}
return opfsUsable;
}
async function withProjectTransaction<T>(projectId: string, operation: () => Promise<T>): Promise<T> {
projectLayout(projectId);
const previous = projectTransactions.get(projectId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const tail = result.then(() => undefined, () => undefined);
projectTransactions.set(projectId, tail);
try {
return await result;
}
finally {
if (projectTransactions.get(projectId) === tail) projectTransactions.delete(projectId);
}
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION);
request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed"));
});
}
function storeNames(db: IDBDatabase): string[] {
return [...STORAGE_STORES].filter((name) => db.objectStoreNames.contains(name));
}
async function info(): Promise<StorageInfoResult> {
const db = await openDatabase();
const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator;
const result = {
backend: "indexeddb" as const,
opfsAvailable: Boolean(workerNavigator?.storage && "getDirectory" in workerNavigator.storage),
schemaVersion: STORAGE_SCHEMA_VERSION,
stores: storeNames(db),
};
db.close();
return result;
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
});
}
interface LODManifestRow {
id: string;
projectId: string;
cacheKey: string;
manifest?: LODCacheRecord;
bytes?: number;
lastAccessAt?: string;
}
interface SimulationManifestRow {
id: string;
projectId: string;
cacheKey: string;
manifest: SimulationCacheManifestIR;
path: string;
createdAt: string;
}
interface ProjectRow {
id: string;
revision: number;
bytes: number;
sha256: string;
updatedAt: string;
scenePath: string;
backend: "opfs" | "indexeddb";
buffer?: ArrayBuffer;
}
async function readProjectRow(projectId: string): Promise<ProjectRow | undefined> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("project", "readonly");
const row = await new Promise<ProjectRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("project").get(projectId);
request.onsuccess = () => resolve(request.result as ProjectRow | undefined);
request.onerror = () => reject(request.error ?? new Error("Project metadata lookup failed"));
});
await transactionComplete(transaction);
db.close();
return row;
}
async function writeProjectRow(row: ProjectRow): Promise<void> {
const db = await openDatabase();
const transaction = db.transaction("project", "readwrite");
transaction.objectStore("project").put(row);
await transactionComplete(transaction);
db.close();
}
async function readLODRow(projectId: string, cacheKey: string): Promise<LODManifestRow | undefined> {
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readonly");
const row = await new Promise<LODManifestRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("lod_manifest").get(`${projectId}:${cacheKey}`);
request.onsuccess = () => resolve(request.result as LODManifestRow | undefined);
request.onerror = () => reject(request.error ?? new Error("LOD manifest lookup failed"));
});
await transactionComplete(transaction);
db.close();
return row;
}
async function readSimulationRow(projectId: string, cacheKey: string): Promise<SimulationManifestRow | undefined> {
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readonly");
const row = await new Promise<SimulationManifestRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("simulation_manifest").get(`${projectId}:${cacheKey}`);
request.onsuccess = () => resolve(request.result as SimulationManifestRow | undefined);
request.onerror = () => reject(request.error ?? new Error("Simulation manifest lookup failed"));
});
await transactionComplete(transaction);
db.close();
return row;
}
async function smoke() {
const db = await openDatabase();
const write = db.transaction("smoke", "readwrite");
write.objectStore("smoke").put({ id: 1, value: "ready" });
await transactionComplete(write);
const read = db.transaction("smoke", "readonly");
const value = await new Promise<{ id: number; value: string } | undefined>((resolve, reject) => {
const request = read.objectStore("smoke").get(1);
request.onsuccess = () => resolve(request.result as { id: number; value: string } | undefined);
request.onerror = () => reject(request.error ?? new Error("IndexedDB read failed"));
});
await transactionComplete(read);
const stores = storeNames(db);
db.close();
const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator;
return {
backend: "indexeddb" as const,
opfsAvailable: Boolean(workerNavigator?.storage && "getDirectory" in workerNavigator.storage),
rowCount: value?.value === "ready" ? 1 : 0,
persisted: value?.value === "ready",
schemaVersion: STORAGE_SCHEMA_VERSION,
stores,
};
}
async function ensureProject(projectId: string): Promise<StorageProjectResult> {
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
return {
projectId: layout.projectId,
scenePath: layout.scenePath,
directories: [layout.snapshotsPath, layout.assetsPath, layout.thumbsPath, layout.tmpPath],
};
}
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "quota"): Promise<StorageSaveResult> {
if (faultAt === "quota") throw new Error("QuotaExceededError: injected OPFS quota exhaustion");
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
const sha256 = await sha256Hex(buffer);
if (useOpfs) {
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
}
else if (faultAt) {
throw new Error("PROJECT_SAVE_FAULT_UNAVAILABLE: IndexedDB commits are already transactional");
}
await writeProjectRow({
id: projectId,
revision,
bytes: buffer.byteLength,
sha256,
updatedAt: new Date().toISOString(),
scenePath: layout.scenePath,
backend: useOpfs ? "opfs" : "indexeddb",
buffer: useOpfs ? undefined : buffer.slice(0),
});
return {
projectId,
bytes: buffer.byteLength,
revision,
persisted: true,
backend: useOpfs ? "opfs" : "indexeddb",
scenePath: layout.scenePath,
sha256,
};
}
async function recoverProject(projectId: string): Promise<StorageRecoveryResult> {
const useOpfs = await useOpfsForProject(projectId);
if (!useOpfs) {
const row = await readProjectRow(projectId);
return row ? {
projectId,
status: "clean",
recovered: false,
backend: "indexeddb",
revision: row.revision,
bytes: row.bytes,
sha256: row.sha256,
} : { projectId, status: "missing", recovered: false, backend: "indexeddb" };
}
const recovery = await recoverProjectBlend(projectId);
if (!recovery.manifest) {
return { projectId, status: "missing", recovered: false, backend: "opfs" };
}
const { manifest } = recovery;
await writeProjectRow({
id: projectId,
revision: manifest.revision,
bytes: manifest.bytes,
sha256: manifest.sha256,
updatedAt: manifest.committedAt,
scenePath: recovery.layout.scenePath,
backend: "opfs",
});
return {
projectId,
status: recovery.status,
recovered: recovery.status === "recovered",
backend: "opfs",
revision: manifest.revision,
bytes: manifest.bytes,
sha256: manifest.sha256,
};
}
async function readProject(projectId: string): Promise<StorageProjectReadResult> {
const recovery = await recoverProject(projectId);
if (recovery.status === "missing" || recovery.revision === undefined ||
recovery.bytes === undefined || recovery.sha256 === undefined) {
throw new Error("Project has no committed blend");
}
const row = await readProjectRow(projectId);
const buffer = recovery.backend === "opfs" ? await readProjectBlend(projectId) : row?.buffer?.slice(0);
if (!buffer || buffer.byteLength !== recovery.bytes || await sha256Hex(buffer) !== recovery.sha256) {
throw new Error("Project blend integrity check failed");
}
return {
projectId,
revision: recovery.revision,
bytes: recovery.bytes,
sha256: recovery.sha256,
backend: recovery.backend,
recovered: recovery.recovered,
buffer,
};
}
async function appendOperation(id: string, projectId: string, revision: number, payload: unknown, inversePayload?: unknown): Promise<StorageOperationResult> {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error("Invalid operation id");
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("operation_log", "readwrite");
transaction.objectStore("operation_log").put({
id,
projectId,
revision,
payload,
inversePayload,
createdAt: new Date().toISOString(),
});
await transactionComplete(transaction);
db.close();
return { id, projectId, revision, persisted: true };
}
function validOperation(row: unknown, projectId: string): row is StorageOperationRecord {
if (!row || typeof row !== "object") return false;
const value = row as Record<string, unknown>;
const payload = value.payload as Record<string, unknown> | undefined;
return typeof value.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value.id) &&
value.projectId === projectId && Number.isInteger(value.revision) && Number(value.revision) >= 0 &&
typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) &&
Boolean(payload && typeof payload.type === "string");
}
async function listOperations(projectId: string, afterRevision: number): Promise<StorageOperationListResult> {
projectLayout(projectId);
if (!Number.isInteger(afterRevision) || afterRevision < 0) throw new Error("afterRevision must be a non-negative integer");
const db = await openDatabase();
const read = db.transaction("operation_log", "readonly");
const rows = await new Promise<unknown[]>((resolve, reject) => {
const request = read.objectStore("operation_log").getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("Operation log scan failed"));
});
await transactionComplete(read);
const invalid = rows.filter((row) => (row as { projectId?: unknown })?.projectId === projectId && !validOperation(row, projectId));
if (invalid.length > 0) {
const transaction = db.transaction(["operation_log", "operation_quarantine"], "readwrite");
for (const row of invalid) {
const id = String((row as { id?: unknown }).id ?? `invalid-${crypto.randomUUID()}`);
transaction.objectStore("operation_log").delete(id);
transaction.objectStore("operation_quarantine").put({ id, projectId, row, reason: "OPERATION_LOG_INVALID", quarantinedAt: new Date().toISOString() });
}
await transactionComplete(transaction);
}
db.close();
const operations = rows.filter((row): row is StorageOperationRecord => validOperation(row, projectId) && row.revision > afterRevision)
.sort((left, right) => left.revision - right.revision || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
for (let index = 1; index < operations.length; index++) {
if (operations[index].revision <= operations[index - 1].revision) throw new Error("OPERATION_LOG_REVISION_CONFLICT");
}
return { projectId, afterRevision, operations, quarantined: invalid.length };
}
async function pruneOperations(projectId: string, throughRevision: number): Promise<StorageOperationPruneResult> {
projectLayout(projectId);
if (!Number.isInteger(throughRevision) || throughRevision < 0) throw new Error("throughRevision must be a non-negative integer");
const db = await openDatabase();
const transaction = db.transaction("operation_log", "readwrite");
const store = transaction.objectStore("operation_log");
const rows = await new Promise<StorageOperationRecord[]>((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result as StorageOperationRecord[]);
request.onerror = () => reject(request.error ?? new Error("Operation log scan failed"));
});
const remove = rows.filter((row) => row.projectId === projectId && Number.isInteger(row.revision) && row.revision <= throughRevision);
remove.forEach((row) => store.delete(row.id));
await transactionComplete(transaction);
db.close();
return { projectId, throughRevision, removed: remove.length };
}
interface SnapshotRow extends StorageSnapshotResult { id: string; buffer: ArrayBuffer }
async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise<StorageSnapshotResult> {
projectLayout(projectId);
if (!Number.isInteger(revision) || revision < 0 || buffer.byteLength === 0 || !Number.isInteger(maxCount) || maxCount < 1 || !Number.isInteger(maxBytes) || maxBytes < buffer.byteLength) {
throw new Error("Invalid snapshot retention request");
}
const sha256 = await sha256Hex(buffer);
const db = await openDatabase();
const transaction = db.transaction("snapshot", "readwrite");
const store = transaction.objectStore("snapshot");
const rows = await new Promise<SnapshotRow[]>((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed"));
});
const createdAt = new Date().toISOString();
const row: SnapshotRow = { id: `${projectId}:${revision}`, projectId, revision, bytes: buffer.byteLength, sha256, createdAt, persisted: true, buffer: buffer.slice(0) };
store.put(row);
const retained = [...rows.filter((candidate) => candidate.revision !== revision), row].sort((left, right) => right.revision - left.revision);
let total = 0;
for (let index = 0; index < retained.length; index++) {
total += retained[index].bytes;
if (index >= maxCount || total > maxBytes) store.delete(retained[index].id);
}
await transactionComplete(transaction);
db.close();
return { projectId, revision, bytes: row.bytes, sha256, createdAt, persisted: true };
}
async function listSnapshots(projectId: string): Promise<StorageSnapshotListResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("snapshot", "readonly");
const rows = await new Promise<SnapshotRow[]>((resolve, reject) => {
const request = transaction.objectStore("snapshot").getAll();
request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed"));
});
await transactionComplete(transaction);
db.close();
return { projectId, snapshots: rows.sort((left, right) => right.revision - left.revision).map((row) => ({ projectId, revision: row.revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt })) };
}
async function readSnapshot(projectId: string, revision: number): Promise<StorageSnapshotReadResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("snapshot", "readonly");
const row = await new Promise<SnapshotRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("snapshot").get(`${projectId}:${revision}`);
request.onsuccess = () => resolve(request.result as SnapshotRow | undefined);
request.onerror = () => reject(request.error ?? new Error("Snapshot read failed"));
});
await transactionComplete(transaction);
db.close();
if (!row || row.buffer.byteLength !== row.bytes || await sha256Hex(row.buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: row.buffer.slice(0) };
}
interface AssetRow extends StorageAssetRecord {
buffer?: ArrayBuffer;
}
function assetRecord(row: AssetRow): StorageAssetRecord {
return {
assetId: row.assetId,
projectId: row.projectId,
sha256: row.sha256,
bytes: row.bytes,
mimeType: row.mimeType,
sourcePath: row.sourcePath,
path: row.path,
createdAt: row.createdAt,
lastAccessAt: row.lastAccessAt,
};
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
async function readAssetRow(projectId: string, sha256: string): Promise<AssetRow | undefined> {
const db = await openDatabase();
const transaction = db.transaction("asset", "readonly");
const row = await new Promise<AssetRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("asset").get(`${projectId}:${sha256}`);
request.onsuccess = () => resolve(request.result as AssetRow | undefined);
request.onerror = () => reject(request.error ?? new Error("Asset metadata lookup failed"));
});
await transactionComplete(transaction);
db.close();
return row;
}
async function putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult> {
projectLayout(projectId);
if (data.byteLength === 0) throw new Error("Asset data is empty");
if (!/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mimeType)) throw new Error("Invalid asset MIME type");
const normalizedSourcePath = sourcePath === undefined ? undefined : normalizeProjectAssetPath(sourcePath);
const sha256 = await sha256Hex(data);
const useOpfs = await useOpfsForProject(projectId);
const existing = await readAssetRow(projectId, sha256);
let path = `indexeddb:${projectId}:${sha256}`;
let deduplicated = Boolean(existing);
if (useOpfs) {
const written = await writeContentAsset(projectId, sha256, data);
path = written.path;
deduplicated = written.deduplicated;
const verified = await readContentAsset(projectId, sha256);
if (verified.byteLength !== data.byteLength || await sha256Hex(verified) !== sha256) {
throw new Error("Content-addressed asset verification failed");
}
}
const now = new Date().toISOString();
const row: AssetRow = {
assetId: `sha256:${sha256}`,
projectId,
sha256,
bytes: data.byteLength,
mimeType,
sourcePath: normalizedSourcePath,
path,
createdAt: existing?.createdAt ?? now,
lastAccessAt: now,
buffer: useOpfs ? undefined : data.slice(0),
};
const db = await openDatabase();
const transaction = db.transaction("asset", "readwrite");
transaction.objectStore("asset").put({ ...row, id: `${projectId}:${sha256}` });
await transactionComplete(transaction);
db.close();
return { ...assetRecord(row), persisted: true, deduplicated };
}
async function readAsset(projectId: string, sha256: string): Promise<StorageAssetReadResult> {
projectLayout(projectId);
validateSha256(sha256);
const row = await readAssetRow(projectId, sha256);
if (!row) throw new Error("Content-addressed asset metadata is missing");
const data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
if (data.byteLength !== row.bytes || await sha256Hex(data) !== sha256) {
throw new Error("Content-addressed asset integrity check failed");
}
const lastAccessAt = new Date().toISOString();
const db = await openDatabase();
const transaction = db.transaction("asset", "readwrite");
transaction.objectStore("asset").put({ ...row, id: `${projectId}:${sha256}`, lastAccessAt });
await transactionComplete(transaction);
db.close();
return { asset: { ...assetRecord(row), lastAccessAt }, data };
}
async function listAssets(projectId: string): Promise<StorageAssetListResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("asset", "readonly");
const rows = await new Promise<AssetRow[]>((resolve, reject) => {
const request = transaction.objectStore("asset").getAll();
request.onsuccess = () => resolve((request.result as AssetRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Asset metadata scan failed"));
});
await transactionComplete(transaction);
db.close();
return {
projectId,
assets: rows.map(assetRecord).sort((left, right) => left.sha256.localeCompare(right.sha256)),
};
}
async function saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
const layout = await writeLodCache(projectId, cacheKey, data);
const existing = await readLODRow(projectId, cacheKey);
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readwrite");
const store = transaction.objectStore("lod_manifest");
store.put({
...(existing ?? {}),
id: `${projectId}:${cacheKey}`,
projectId,
cacheKey,
bytes: data.byteLength,
lastAccessAt: new Date().toISOString(),
});
await transactionComplete(transaction);
db.close();
return { projectId, cacheKey, bytes: data.byteLength, persisted: true, path: `${layout.lodPath}/${cacheKey}.mesh` };
}
async function putLODManifest(projectId: string, manifest: LODCacheRecord): Promise<StorageLODManifestResult> {
manifest = parseLODCacheRecord(manifest);
projectLayout(projectId);
const existing = await readLODRow(projectId, manifest.cacheKey);
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readwrite");
const store = transaction.objectStore("lod_manifest");
const lastAccessAt = new Date().toISOString();
store.put({
...(existing ?? {}),
id: `${projectId}:${manifest.cacheKey}`,
projectId,
cacheKey: manifest.cacheKey,
bytes: manifest.byteLength ?? existing?.bytes,
lastAccessAt,
manifest: { ...manifest, byteLength: manifest.byteLength ?? existing?.bytes, lastAccessAt },
});
await transactionComplete(transaction);
db.close();
return { projectId, cacheKey: manifest.cacheKey, persisted: true };
}
async function getLODManifest(projectId: string, cacheKey: string): Promise<StorageLODManifestResult> {
projectLayout(projectId);
const row = await readLODRow(projectId, cacheKey);
let accessedAt = row?.lastAccessAt;
if (row?.manifest) {
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readwrite");
const store = transaction.objectStore("lod_manifest");
const lastAccessAt = new Date().toISOString();
accessedAt = lastAccessAt;
store.put({ ...row, lastAccessAt, manifest: { ...row.manifest, byteLength: row.manifest.byteLength ?? row.bytes, lastAccessAt } });
await transactionComplete(transaction);
db.close();
}
return { projectId, cacheKey, persisted: Boolean(row?.manifest), manifest: row?.manifest ? { ...row.manifest, byteLength: row.manifest.byteLength ?? row.bytes, lastAccessAt: accessedAt } : undefined };
}
async function readLOD(projectId: string, cacheKey: string): Promise<StorageLODReadResult> {
const data = await readLodCache(projectId, cacheKey);
await getLODManifest(projectId, cacheKey);
return { projectId, cacheKey, data, bytes: data.byteLength };
}
async function listLODManifests(projectId: string): Promise<StorageLODManifestListResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readonly");
const rows = await new Promise<LODManifestRow[]>((resolve, reject) => {
const request = transaction.objectStore("lod_manifest").getAll();
request.onsuccess = () => resolve((request.result as LODManifestRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("LOD manifest scan failed"));
});
await transactionComplete(transaction);
db.close();
const manifests: LODCacheRecord[] = [];
for (const row of rows) {
if (!row.manifest) continue;
try {
manifests.push(parseLODCacheRecord({
...row.manifest,
byteLength: row.manifest.byteLength ?? row.bytes,
lastAccessAt: row.lastAccessAt ?? row.manifest.lastAccessAt,
}));
}
catch {
// Invalid rows remain isolated and can be removed by the prune path.
}
}
return { projectId, manifests };
}
async function deleteLOD(projectId: string, cacheKey: string): Promise<StorageLODManifestResult> {
await deleteLodCache(projectId, cacheKey);
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("lod_manifest", "readwrite");
transaction.objectStore("lod_manifest").delete(`${projectId}:${cacheKey}`);
await transactionComplete(transaction);
db.close();
return { projectId, cacheKey, persisted: true };
}
async function pruneLOD(projectId: string, maxBytes: number): Promise<StorageLODPruneResult> {
if (!Number.isInteger(maxBytes) || maxBytes < 0) throw new Error("maxBytes must be a non-negative integer");
projectLayout(projectId);
const db = await openDatabase();
const readTransaction = db.transaction("lod_manifest", "readonly");
const rows = await new Promise<Array<{ id: string; cacheKey: string; bytes?: number; lastAccessAt?: string }> >((resolve, reject) => {
const request = readTransaction.objectStore("lod_manifest").getAll();
request.onsuccess = () => resolve((request.result as Array<{ id: string; projectId: string; cacheKey: string; bytes?: number; lastAccessAt?: string }>).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("LOD manifest scan failed"));
});
await transactionComplete(readTransaction);
let total = rows.reduce((sum, row) => sum + (row.bytes ?? 0), 0);
const remove = rows.slice().sort((left, right) => (left.lastAccessAt ?? "").localeCompare(right.lastAccessAt ?? ""));
const removed: string[] = [];
for (const row of remove) {
if (total <= maxBytes) break;
await deleteLodCache(projectId, row.cacheKey);
total -= row.bytes ?? 0;
removed.push(row.cacheKey);
}
if (removed.length > 0) {
const writeTransaction = db.transaction("lod_manifest", "readwrite");
const store = writeTransaction.objectStore("lod_manifest");
removed.forEach((cacheKey) => store.delete(`${projectId}:${cacheKey}`));
await transactionComplete(writeTransaction);
}
db.close();
return { projectId, maxBytes, removed: removed.length, bytes: rows.filter((row) => removed.includes(row.cacheKey)).reduce((sum, row) => sum + (row.bytes ?? 0), 0), cacheKeys: removed };
}
async function putSimulationCache(
projectId: string,
manifestValue: SimulationCacheManifestIR,
data: ArrayBuffer,
): Promise<StorageSimulationCacheResult> {
projectLayout(projectId);
const manifest = await verifySimulationCache(manifestValue, data);
const project = await readProjectRow(projectId);
if (!project) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
}
if (project.sha256 !== manifest.sourceBlendSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
}
const cacheKey = simulationCacheKey(manifest);
const existing = await readSimulationRow(projectId, cacheKey);
if (existing && existing.manifest.cacheSha256 !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache key already references a different payload");
}
const asset = await putAsset(
projectId,
data,
"application/x-blender-simulation-cache",
`simulation/${cacheKey}.bin`,
);
if (asset.sha256 !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
}
const row: SimulationManifestRow = {
id: `${projectId}:${cacheKey}`,
projectId,
cacheKey,
manifest,
path: asset.path,
createdAt: existing?.createdAt ?? new Date().toISOString(),
};
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readwrite");
transaction.objectStore("simulation_manifest").put(row);
await transactionComplete(transaction);
db.close();
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
}
async function readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
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 asset = await readAsset(projectId, manifest.cacheSha256);
await verifySimulationCache(manifest, asset.data);
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
}
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readonly");
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
const request = transaction.objectStore("simulation_manifest").getAll();
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Simulation manifest scan failed"));
});
await transactionComplete(transaction);
db.close();
const caches = rows.map((row) => {
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== row.cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
}
return { cacheKey: row.cacheKey, manifest, path: row.path, createdAt: row.createdAt };
}).sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
return { projectId, caches };
}
scope.onmessage = async (event) => {
try {
let result;
const command = event.data.command;
if (command.type === "smoke") result = await smoke();
else if (command.type === "info") result = await info();
else if (command.type === "ensureProject") result = await withProjectTransaction(command.projectId, () => ensureProject(command.projectId));
else if (command.type === "saveProject") result = await withProjectTransaction(command.projectId, () => saveProject(command.projectId, command.revision, command.buffer, command.faultAt));
else if (command.type === "recoverProject") result = await withProjectTransaction(command.projectId, () => recoverProject(command.projectId));
else if (command.type === "readProject") result = await withProjectTransaction(command.projectId, () => readProject(command.projectId));
else if (command.type === "appendOperation") result = await appendOperation(command.id, command.projectId, command.revision, command.payload, command.inversePayload);
else if (command.type === "listOperations") result = await listOperations(command.projectId, command.afterRevision);
else if (command.type === "pruneOperations") result = await pruneOperations(command.projectId, command.throughRevision);
else if (command.type === "saveSnapshot") result = await withProjectTransaction(command.projectId, () => saveSnapshot(command.projectId, command.revision, command.buffer, command.maxCount, command.maxBytes));
else if (command.type === "listSnapshots") result = await listSnapshots(command.projectId);
else if (command.type === "readSnapshot") result = await readSnapshot(command.projectId, command.revision);
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath);
else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256);
else if (command.type === "listAssets") result = await listAssets(command.projectId);
else if (command.type === "saveLOD") result = await saveLOD(command.projectId, command.cacheKey, command.data);
else if (command.type === "putLODManifest") result = await putLODManifest(command.projectId, command.manifest);
else if (command.type === "getLODManifest") result = await getLODManifest(command.projectId, command.cacheKey);
else if (command.type === "listLODManifests") result = await listLODManifests(command.projectId);
else if (command.type === "readLOD") result = await readLOD(command.projectId, command.cacheKey);
else if (command.type === "deleteLOD") result = await deleteLOD(command.projectId, command.cacheKey);
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 === "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]);
else if (result && "buffer" in result && result.buffer instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.buffer]);
else scope.postMessage({ requestId: event.data.requestId, ok: true, result });
} catch (error) {
scope.postMessage({
requestId: event.data.requestId,
ok: false,
error: error instanceof Error ? error.message : "IndexedDB initialization failed",
errorCode: error && typeof error === "object" && "code" in error ? (error as { code: StorageResponse["errorCode"] }).code : undefined,
});
}
};

View File

@@ -0,0 +1,30 @@
import {
TRACKING_MASK_BUDGET,
applyTrackingMaskEdit,
gateTrackingOperation,
parseTrackingMaskProject,
type MaskPointIR,
type TrackingMarkerIR,
} from "../../../protocol/tracking-mask";
const marker: TrackingMarkerIR = { frame: 1, position: [0.5, 0.5], patternMin: [-0.1, -0.1], patternMax: [0.1, 0.1], searchMin: [-0.2, -0.2], searchMax: [0.2, 0.2], keyframe: true, muted: false, selected: true };
const point: MaskPointIR = { id: "point:1", co: [0.2, 0.3], handleLeft: [0.1, 0.3], handleRight: [0.3, 0.3], handleType: "ALIGNED", feather: 0.1, selected: true };
const base = { schemaVersion: 1, revision: 4, clips: [{ id: "clip:1", name: "Shot", sourcePath: "media/shot.mp4", sourceSha256: "a".repeat(64), width: 1920, height: 1080, frameStart: 1, frameEnd: 100, fpsNumerator: 24, fpsDenominator: 1, tracks: [{ id: "track:1", name: "Track", selected: false, locked: false, markers: [marker] }], planeTracks: [] }], masks: [{ id: "mask:1", name: "Mask", layers: [{ id: "layer:1", name: "Layer", visible: true, locked: false, opacity: 1, splines: [{ id: "spline:1", cyclic: false, fill: true, points: [point, { ...point, id: "point:2", co: [0.7, 0.8] }] }] }] }], bindings: [{ id: "binding:1", target: "COMPOSITOR", ownerId: "node:1", clipId: "clip:1", maskId: "mask:1" }] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try {
const parsed = parseTrackingMaskProject(base);
const markerEdit = applyTrackingMaskEdit(parsed, { type: "SET_MARKER", revision: 4, clipId: "clip:1", trackId: "track:1", marker: { ...marker, frame: 10, position: [0.6, 0.4], keyframe: false } });
const pointEdit = applyTrackingMaskEdit(markerEdit, { type: "SET_MASK_POINT", revision: 5, maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", point: { ...point, co: [0.4, 0.6], feather: 0.25 } });
result.edit = [pointEdit.revision, pointEdit.clips[0].tracks[0].markers.map((item) => item.frame), pointEdit.masks[0].layers[0].splines[0].points[0].co];
try { applyTrackingMaskEdit(parsed, { type: "SET_TRACK_SELECTION", revision: 3, clipId: "clip:1", trackId: "track:1", selected: true }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
} catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
try { parseTrackingMaskProject({ ...base, clips: [{ ...base.clips[0], sourcePath: "../shot.mp4" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); }
try { parseTrackingMaskProject({ ...base, bindings: [{ ...base.bindings[0], clipId: "clip:missing" }] }); } catch (error) { result.binding = error instanceof Error ? error.message : String(error); }
try { parseTrackingMaskProject({ ...base, clips: new Array(TRACKING_MASK_BUDGET.maxClips + 1).fill(base.clips[0]) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
result.browserGate = gateTrackingOperation("BROWSER_TRACKING").issues[0]?.code;
result.verifiedGate = gateTrackingOperation("BROWSER_TRACKING", "VERIFIED").status;
result.solveGate = gateTrackingOperation("CAMERA_SOLVE").status;
self.postMessage(result);
};

View File

@@ -0,0 +1,27 @@
import { decodeVDBResource, type VDBResourceManifest } from "../../../protocol/volume-vdb";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
scope.onmessage = (): void => {
void (async () => {
const data = Uint8Array.from([0x76, 0x64, 0x62, 0x01]);
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
const manifest: VDBResourceManifest = { projectId: "vdb-test", sourcePath: "//cache/smoke.vdb", byteLength: data.byteLength, sha256: digest, grids: [{ name: "density", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 }] };
const decoded = await decodeVDBResource({ ...manifest, data: data.buffer }, async (request, signal) => {
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
return { metadata: request, decodedByteLength: request.data.byteLength };
}, new AbortController().signal);
let outsideProject = "";
try {
await decodeVDBResource({ ...manifest, sourcePath: "../../outside.vdb", data: data.buffer }, undefined, new AbortController().signal);
}
catch (error) { outsideProject = error instanceof Error ? error.message : String(error); }
const controller = new AbortController();
const cancelled = decodeVDBResource({ ...manifest, data: data.buffer }, async (_request, signal) => new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve({ metadata: manifest, decodedByteLength: data.byteLength }), 20);
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("cancelled", "AbortError")); }, { once: true });
}), controller.signal).then(() => false).catch((error) => error instanceof DOMException && error.name === "AbortError");
setTimeout(() => controller.abort(), 1);
scope.postMessage({ decodedByteLength: decoded.decodedByteLength, outsideProject, cancelled: await cancelled });
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,441 @@
import {
BufferGeometry,
Color,
DirectionalLight,
Euler,
Float32BufferAttribute,
GridHelper,
Group,
HemisphereLight,
InstancedMesh,
Matrix4,
Mesh,
MeshPhysicalMaterial,
PerspectiveCamera,
Quaternion,
Raycaster,
Scene,
Uint32BufferAttribute,
Vector2,
Vector3,
WebGLRenderer,
type Object3D,
} from "../vendor/three/three.module.js";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../three-adapter/offscreen-viewport-protocol";
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
import {
configurePBRLight,
configurePBRRenderer,
createPBRLight,
createPBRMaterial,
setPBRMaterialSelected,
} from "../three-adapter/pbr";
import { GPUTextureStore } from "../three-adapter/texture-assets";
import { applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
const workerScope = self as unknown as {
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
postMessage(message: OffscreenViewportResponse): void;
};
let renderer: WebGLRenderer | null = null;
let scene: Scene | null = null;
let camera: PerspectiveCamera | null = null;
let root: Group | null = null;
let importedLights: Group | null = null;
let width = 1;
let height = 1;
let yaw = -Math.PI / 4;
let pitch = 0.55;
let distance = 7;
let editMode = false;
let selectionMode: MeshElementMode = "FACE";
let currentSnapshot: SceneSnapshotIR | null = null;
const textureStore = new GPUTextureStore();
const raycaster = new Raycaster();
const objectById = new Map<string, Object3D>();
function post(message: OffscreenViewportResponse): void {
workerScope.postMessage(message);
}
function render(): void {
if (!renderer || !scene || !camera) return;
camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
const gl = renderer.getContext();
const sampleWidth = Math.min(16, gl.drawingBufferWidth);
const sampleHeight = Math.min(16, gl.drawingBufferHeight);
const pixels = new Uint8Array(sampleWidth * sampleHeight * 4);
gl.readPixels(
Math.max(0, Math.floor((gl.drawingBufferWidth - sampleWidth) / 2)),
Math.max(0, Math.floor((gl.drawingBufferHeight - sampleHeight) / 2)),
sampleWidth,
sampleHeight,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels,
);
let visiblePixels = 0;
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) visiblePixels += 1;
}
post({ type: "frame", visiblePixels });
}
function resize(nextWidth: number, nextHeight: number, pixelRatio: number): void {
width = Math.max(1, nextWidth);
height = Math.max(1, nextHeight);
if (!renderer || !camera) return;
renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
render();
}
function matrixFor(node: SceneSnapshotIR["nodes"][number]): Matrix4 {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;
return new Matrix4().compose(
new Vector3(x, z, -y),
new Quaternion().setFromEuler(new Euler(rx, rz, -ry)),
new Vector3(...node.transform.scale),
);
}
function geometryFrom(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
const sourcePositions = new Float32Array(payload.positions);
const sourceIndices = new Uint32Array(payload.indices);
const sourceNormals = payload.normals ? new Float32Array(payload.normals) : undefined;
const cornerIndices = payload.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : undefined;
const sourceUVs = payload.uvs ? new Float32Array(payload.uvs) : undefined;
const sourceColors = payload.colors ? new Float32Array(payload.colors) : undefined;
const geometry = new BufferGeometry();
if (cornerIndices && (sourceUVs || sourceColors)) {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
for (let index = 0; index < sourceIndices.length; index++) {
const vertex = sourceIndices[index] * 3;
const corner = cornerIndices[index];
positions.push(sourcePositions[vertex], sourcePositions[vertex + 2], -sourcePositions[vertex + 1]);
if (sourceNormals) normals.push(sourceNormals[vertex], sourceNormals[vertex + 2], -sourceNormals[vertex + 1]);
if (sourceUVs) uvs.push(sourceUVs[corner * 2], sourceUVs[corner * 2 + 1]);
if (sourceColors) colors.push(sourceColors[corner * 4], sourceColors[corner * 4 + 1], sourceColors[corner * 4 + 2], sourceColors[corner * 4 + 3]);
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normals.length > 0) geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
if (uvs.length > 0) geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
if (colors.length > 0) geometry.setAttribute("color", new Float32BufferAttribute(colors, 4));
}
else {
const positions = new Float32Array(sourcePositions.length);
for (let index = 0; index < sourcePositions.length; index += 3) {
positions[index] = sourcePositions[index];
positions[index + 1] = sourcePositions[index + 2];
positions[index + 2] = -sourcePositions[index + 1];
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setIndex(new Uint32BufferAttribute(sourceIndices, 1));
if (sourceNormals) {
const normals = new Float32Array(sourceNormals.length);
for (let index = 0; index < sourceNormals.length; index += 3) {
normals[index] = sourceNormals[index];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = -sourceNormals[index + 1];
}
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
}
if (!sourceNormals) geometry.computeVertexNormals();
if (payload.triangleMaterialIndices && materialCount > 1) {
const triangleMaterials = new Uint32Array(payload.triangleMaterialIndices);
for (let triangle = 0; triangle < triangleMaterials.length; triangle++) {
geometry.addGroup(triangle * 3, 3, Math.min(triangleMaterials[triangle], materialCount - 1));
}
}
return geometry;
}
function clearRoot(): void {
if (!root) return;
for (const child of [...root.children]) {
root.remove(child);
child.traverse((object) => {
const mesh = object as Mesh;
mesh.geometry?.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) material?.dispose();
});
}
objectById.clear();
}
function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
void textureStore.upload(assets).then((status) => {
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === currentSnapshot?.scenes[0]?.worldId) ?? currentSnapshot?.worlds[0];
if (scene && renderer) void textureStore.applyEnvironment(scene, renderer, world, true);
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes });
render();
}).catch((error) => post({ type: "error", message: error instanceof Error ? error.message : "Texture upload failed" }));
}
function clearLights(): void {
if (!importedLights) return;
for (const child of [...importedLights.children]) {
importedLights.remove(child);
if ("dispose" in child && typeof child.dispose === "function") child.dispose();
}
}
function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], nonMeshGeometryBuffers: NonMeshGeometryChunk[]): void {
if (!root || !scene) return;
currentSnapshot = snapshot;
clearRoot();
clearLights();
const geometryById = new Map(buffers.map((buffer) => [buffer.meshId, buffer]));
const summaryById = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
const materialById = new Map(snapshot.materials.map((material) => [material.id, material]));
const nodesByMesh = new Map<string, SceneSnapshotIR["nodes"]>();
for (const node of snapshot.nodes) {
if (node.type !== "MESH" || !node.visible || !node.dataId || !geometryById.has(node.dataId)) continue;
const nodes = nodesByMesh.get(node.dataId) ?? [];
nodes.push(node);
nodesByMesh.set(node.dataId, nodes);
}
for (const [meshId, nodes] of nodesByMesh) {
const payload = geometryById.get(meshId);
if (!payload) continue;
const summary = summaryById.get(meshId);
const materials = (summary?.materialSlotIds ?? []).map((id) => createPBRMaterial(materialById.get(id)));
if (materials.length === 0) materials.push(createPBRMaterial());
const geometry = geometryFrom(payload, materials.length);
const material = materials.length === 1 ? materials[0] : materials;
const common = {
meshId,
sourcePositions: Array.from(new Float32Array(payload.positions)),
sourceIndices: Array.from(new Uint32Array(payload.indices)),
triangleFaceIndices: payload.triangleFaceIndices ? Array.from(new Uint32Array(payload.triangleFaceIndices)) : undefined,
edgeVertexIndices: payload.edgeVertexIndices ? Array.from(new Uint32Array(payload.edgeVertexIndices)) : undefined,
};
if (nodes.length === 1) {
const mesh = new Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.applyMatrix4(matrixFor(nodes[0]));
mesh.userData = { ...common, blenderId: nodes[0].id };
mesh.userData.materialSlotIds = summary?.materialSlotIds ?? [];
root.add(mesh);
objectById.set(nodes[0].id, mesh);
}
else {
const instances = new InstancedMesh(geometry, material, nodes.length);
instances.castShadow = true;
instances.receiveShadow = true;
instances.frustumCulled = true;
instances.userData = { ...common, instanceNodeIds: nodes.map((node) => node.id) };
instances.userData.materialSlotIds = summary?.materialSlotIds ?? [];
nodes.forEach((node, index) => {
instances.setMatrixAt(index, matrixFor(node));
instances.setColorAt(index, new Color(0xffffff));
objectById.set(node.id, instances);
});
root.add(instances);
}
}
const dataById = new Map((snapshot.nonMeshData ?? []).map((data) => [data.id, data]));
let nonMeshCount = 0;
let nonMeshBlockedCount = 0;
for (const node of snapshot.nodes) {
if (node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA" || !node.visible || !node.dataId) continue;
const data = dataById.get(node.dataId);
if (!data) continue;
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
if (!object) {
nonMeshBlockedCount++;
continue;
}
applyNonMeshTransform(object, node);
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
child.userData.nonMeshDataId = data.id;
});
root.add(object);
objectById.set(node.id, object);
nonMeshCount++;
}
const greasePencilsById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let greasePencilCount = 0;
let greasePencilBlockedCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = greasePencilsById.get(node.dataId);
if (!data) continue;
const object = createGreasePencilObject(data, snapshot.frame.current);
if (!object) {
greasePencilBlockedCount++;
continue;
}
applyGreasePencilTransform(object, node);
root.add(object);
objectById.set(node.id, object);
greasePencilCount++;
}
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount });
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);
if (renderer) configurePBRRenderer(renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
if (importedLights) {
const lights = new Map(snapshot.lights.map((definition) => [definition.id, definition]));
for (const node of snapshot.nodes) {
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
const definition = lights.get(node.dataId);
if (!definition) continue;
const light = createPBRLight(definition);
configurePBRLight(light, node, importedLights);
light.name = node.name;
importedLights.add(light);
objectById.set(node.id, light);
}
}
render();
}
function setSelection(ids: string[]): void {
const selected = new Set(ids);
const visited = new Set<Object3D>();
for (const [id, object] of objectById) {
if (visited.has(object)) continue;
visited.add(object);
if (object instanceof InstancedMesh) {
const instanceIds = object.userData.instanceNodeIds as string[];
instanceIds.forEach((instanceId, index) => object.setColorAt(index, new Color(selected.has(instanceId) ? 0xf08a45 : 0xffffff)));
if (object.instanceColor) object.instanceColor.needsUpdate = true;
}
else if (object instanceof Mesh) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
if (material instanceof MeshPhysicalMaterial) setPBRMaterialSelected(material, selected.has(id));
}
}
}
render();
}
function pick(x: number, y: number, additive: boolean): void {
if (!root || !camera) return;
raycaster.setFromCamera(new Vector2(x, y), camera);
const hit = raycaster.intersectObjects(root.children, true)[0];
if (!hit) return;
const nonMeshDataId = hit.object.userData.nonMeshDataId;
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
const index = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index);
const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
post({ type: "elementSelected", meshId: nonMeshDataId, mode: "VERT", index, additive, nonMeshKind: kindMap?.[hit.index] ?? "CONTROL_POINT" });
return;
}
const instanceIds = hit.object.userData.instanceNodeIds as string[] | undefined;
const objectId = instanceIds && typeof hit.instanceId === "number" ? instanceIds[hit.instanceId] : hit.object.userData.blenderId;
const meshId = hit.object.userData.meshId;
const sourceIndices = hit.object.userData.sourceIndices as number[] | undefined;
if (editMode && typeof meshId === "string" && typeof hit.faceIndex === "number" && sourceIndices) {
const triangleFaces = hit.object.userData.triangleFaceIndices as number[] | undefined;
const triangleVertices = sourceIndices.slice(hit.faceIndex * 3, hit.faceIndex * 3 + 3);
let index = -1;
if (selectionMode === "FACE") index = triangleFaces?.[hit.faceIndex] ?? hit.faceIndex;
else if (selectionMode === "VERT") {
const positions = hit.object.userData.sourcePositions as number[] | undefined;
if (positions && triangleVertices.length === 3) {
const localHit = hit.object.worldToLocal(hit.point.clone());
const local = [localHit.x, -localHit.z, localHit.y];
index = triangleVertices.reduce((best, vertex) => {
if (best < 0) return vertex;
const distance = (positions[vertex * 3] - local[0]) ** 2 + (positions[vertex * 3 + 1] - local[1]) ** 2 + (positions[vertex * 3 + 2] - local[2]) ** 2;
const bestDistance = (positions[best * 3] - local[0]) ** 2 + (positions[best * 3 + 1] - local[1]) ** 2 + (positions[best * 3 + 2] - local[2]) ** 2;
return distance < bestDistance ? vertex : best;
}, -1);
}
}
else {
const edges = hit.object.userData.edgeVertexIndices as number[] | undefined;
if (edges && triangleVertices.length === 3) {
const candidates = [[triangleVertices[0], triangleVertices[1]], [triangleVertices[1], triangleVertices[2]], [triangleVertices[2], triangleVertices[0]]];
index = candidates.reduce((found, pair) => {
if (found >= 0) return found;
const low = Math.min(pair[0], pair[1]);
const high = Math.max(pair[0], pair[1]);
for (let edge = 0; edge < edges.length / 2; edge++) {
if (Math.min(edges[edge * 2], edges[edge * 2 + 1]) === low && Math.max(edges[edge * 2], edges[edge * 2 + 1]) === high) return edge;
}
return -1;
}, -1);
}
}
if (index >= 0) post({ type: "elementSelected", meshId, mode: selectionMode, index, additive });
}
else if (typeof objectId === "string") post({ type: "selected", objectId, additive });
}
workerScope.onmessage = (event): void => {
try {
const message = event.data;
if (message.type === "init") {
renderer = new WebGLRenderer({ canvas: message.canvas, antialias: true, preserveDrawingBuffer: true });
configurePBRRenderer(renderer);
scene = new Scene();
camera = new PerspectiveCamera(45, 1, 0.01, 1000);
root = new Group();
importedLights = new Group();
scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55));
const light = new DirectionalLight(0xffffff, 2.5);
light.position.set(4, -5, 8);
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
scene.add(light, light.target, new GridHelper(20, 20, 0x60656e, 0x383b42), root, importedLights);
resize(message.width, message.height, message.pixelRatio);
post({ type: "ready" });
}
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 === "interaction") {
editMode = message.editMode;
selectionMode = message.selectionMode;
}
else if (message.type === "orbit") {
yaw -= message.deltaX * 0.008;
pitch = Math.max(-1.45, Math.min(1.45, pitch + message.deltaY * 0.008));
distance = Math.max(0.2, Math.min(500, distance * Math.exp(message.zoom * 0.001)));
render();
}
else if (message.type === "pick") pick(message.x, message.y, message.additive);
else if (message.type === "dispose") {
clearRoot();
clearLights();
renderer?.dispose();
textureStore.dispose();
renderer = null;
scene = null;
camera = null;
root = null;
importedLights = null;
}
}
catch (error) {
post({ type: "error", message: error instanceof Error ? error.message : "Offscreen viewport failed" });
}
};

File diff suppressed because it is too large Load Diff

43
web/app/vite.config.ts Normal file
View File

@@ -0,0 +1,43 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
};
function preserveIsolationHeaders(): Plugin {
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {
server.middlewares.use((_request, response, next) => {
for (const [name, value] of Object.entries(isolationHeaders)) response.setHeader(name, value);
next();
});
};
return {
name: "preserve-isolation-headers",
configureServer: install,
configurePreviewServer: install,
};
}
export default defineConfig({
root: "app",
plugins: [preserveIsolationHeaders(), react()],
server: {
port: 5173,
strictPort: false,
headers: {
...isolationHeaders,
},
},
preview: {
headers: {
...isolationHeaders,
},
},
build: {
outDir: "../dist",
emptyOutDir: true,
},
});