Add Chromium-only Blender WebEngine parity work
This commit is contained in:
13
web/app/index.html
Normal file
13
web/app/index.html
Normal 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>
|
||||
20
web/app/public/engine-manifest.json
Normal file
20
web/app/public/engine-manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
16
web/app/public/vendor/blender/web_engine.js
vendored
Normal file
16
web/app/public/vendor/blender/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Executable file
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
967
web/app/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
web/app/src/app/app-shell.css
Normal file
85
web/app/src/app/app-shell.css
Normal 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; }
|
||||
}
|
||||
121
web/app/src/engine-client/EngineClient.ts
Normal file
121
web/app/src/engine-client/EngineClient.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
210
web/app/src/engine-client/WebEngineClient.ts
Normal file
210
web/app/src/engine-client/WebEngineClient.ts
Normal 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
12
web/app/src/main.tsx
Normal 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>,
|
||||
);
|
||||
54
web/app/src/platform/capabilities.ts
Normal file
54
web/app/src/platform/capabilities.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
155
web/app/src/storage/StorageClient.ts
Normal file
155
web/app/src/storage/StorageClient.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
web/app/src/storage/autosave.ts
Normal file
21
web/app/src/storage/autosave.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
34
web/app/src/storage/migrations.ts
Normal file
34
web/app/src/storage/migrations.ts
Normal 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() });
|
||||
}
|
||||
}
|
||||
382
web/app/src/storage/opfs-files.ts
Normal file
382
web/app/src/storage/opfs-files.ts
Normal 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();
|
||||
}
|
||||
1
web/app/src/three-adapter/geometry-delta.ts
Normal file
1
web/app/src/three-adapter/geometry-delta.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { applyMeshGeometryDelta, diffMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
|
||||
75
web/app/src/three-adapter/grease-pencil.ts
Normal file
75
web/app/src/three-adapter/grease-pencil.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
122
web/app/src/three-adapter/lod.ts
Normal file
122
web/app/src/three-adapter/lod.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
174
web/app/src/three-adapter/nonmesh.ts
Normal file
174
web/app/src/three-adapter/nonmesh.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
25
web/app/src/three-adapter/offscreen-viewport-protocol.ts
Normal file
25
web/app/src/three-adapter/offscreen-viewport-protocol.ts
Normal 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 };
|
||||
232
web/app/src/three-adapter/offscreen-viewport.ts
Normal file
232
web/app/src/three-adapter/offscreen-viewport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
130
web/app/src/three-adapter/pbr.ts
Normal file
130
web/app/src/three-adapter/pbr.ts
Normal 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;
|
||||
}
|
||||
145
web/app/src/three-adapter/texture-assets.ts
Normal file
145
web/app/src/three-adapter/texture-assets.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
678
web/app/src/three-adapter/viewport.ts
Normal file
678
web/app/src/three-adapter/viewport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
65
web/app/src/vendor/blender/web_engine.d.ts
vendored
Normal file
65
web/app/src/vendor/blender/web_engine.d.ts
vendored
Normal 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;
|
||||
16
web/app/src/vendor/blender/web_engine.js
vendored
Normal file
16
web/app/src/vendor/blender/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Executable file
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
21
web/app/src/vendor/three/LICENSE
vendored
Normal 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.
|
||||
295
web/app/src/vendor/three/addons/controls/OrbitControls.d.ts
vendored
Normal file
295
web/app/src/vendor/three/addons/controls/OrbitControls.d.ts
vendored
Normal 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 };
|
||||
1963
web/app/src/vendor/three/addons/controls/OrbitControls.js
vendored
Normal file
1963
web/app/src/vendor/three/addons/controls/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
60007
web/app/src/vendor/three/three.core.js
vendored
Normal file
60007
web/app/src/vendor/three/three.core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/app/src/vendor/three/three.module.d.ts
vendored
Normal file
1
web/app/src/vendor/three/three.module.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "three";
|
||||
19610
web/app/src/vendor/three/three.module.js
vendored
Normal file
19610
web/app/src/vendor/three/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
web/app/src/workers/asset-library-io-test.worker.ts
Normal file
21
web/app/src/workers/asset-library-io-test.worker.ts
Normal 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);
|
||||
};
|
||||
17
web/app/src/workers/budget-aggregation-test.worker.ts
Normal file
17
web/app/src/workers/budget-aggregation-test.worker.ts
Normal 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 {};
|
||||
15
web/app/src/workers/budget-test.worker.ts
Normal file
15
web/app/src/workers/budget-test.worker.ts
Normal 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" });
|
||||
}
|
||||
};
|
||||
58
web/app/src/workers/compositor-test.worker.ts
Normal file
58
web/app/src/workers/compositor-test.worker.ts
Normal 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);
|
||||
};
|
||||
31
web/app/src/workers/deformation-test.worker.ts
Normal file
31
web/app/src/workers/deformation-test.worker.ts
Normal 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 {};
|
||||
15
web/app/src/workers/editor-workflow-test.worker.ts
Normal file
15
web/app/src/workers/editor-workflow-test.worker.ts
Normal 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);
|
||||
};
|
||||
111
web/app/src/workers/engine.worker.ts
Normal file
111
web/app/src/workers/engine.worker.ts
Normal 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,
|
||||
}]));
|
||||
}
|
||||
};
|
||||
65
web/app/src/workers/glb-test.worker.ts
Normal file
65
web/app/src/workers/glb-test.worker.ts
Normal 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 {};
|
||||
74
web/app/src/workers/grease-pencil-schema-test.worker.ts
Normal file
74
web/app/src/workers/grease-pencil-schema-test.worker.ts
Normal 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());
|
||||
};
|
||||
23
web/app/src/workers/mesh-cache-test.worker.ts
Normal file
23
web/app/src/workers/mesh-cache-test.worker.ts
Normal 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" });
|
||||
}
|
||||
};
|
||||
53
web/app/src/workers/modifier-graph-test.worker.ts
Normal file
53
web/app/src/workers/modifier-graph-test.worker.ts
Normal 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 {};
|
||||
59
web/app/src/workers/nonmesh-binary-malformed-test.worker.ts
Normal file
59
web/app/src/workers/nonmesh-binary-malformed-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
16
web/app/src/workers/nonmesh-binary-test.worker.ts
Normal file
16
web/app/src/workers/nonmesh-binary-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
22
web/app/src/workers/paint-schema-test.worker.ts
Normal file
22
web/app/src/workers/paint-schema-test.worker.ts
Normal 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);
|
||||
};
|
||||
|
||||
65
web/app/src/workers/physics-simulation-test.worker.ts
Normal file
65
web/app/src/workers/physics-simulation-test.worker.ts
Normal 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);
|
||||
};
|
||||
15
web/app/src/workers/release-gate-test.worker.ts
Normal file
15
web/app/src/workers/release-gate-test.worker.ts
Normal 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);
|
||||
};
|
||||
18
web/app/src/workers/scripting-platform-test.worker.ts
Normal file
18
web/app/src/workers/scripting-platform-test.worker.ts
Normal 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);
|
||||
};
|
||||
15
web/app/src/workers/selection-history-test.worker.ts
Normal file
15
web/app/src/workers/selection-history-test.worker.ts
Normal 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);
|
||||
};
|
||||
45
web/app/src/workers/sequencer-test.worker.ts
Normal file
45
web/app/src/workers/sequencer-test.worker.ts
Normal 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);
|
||||
};
|
||||
33
web/app/src/workers/simplify-test.worker.ts
Normal file
33
web/app/src/workers/simplify-test.worker.ts
Normal 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" });
|
||||
}
|
||||
};
|
||||
15
web/app/src/workers/skin-test.worker.ts
Normal file
15
web/app/src/workers/skin-test.worker.ts
Normal 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 {};
|
||||
789
web/app/src/workers/storage.worker.ts
Normal file
789
web/app/src/workers/storage.worker.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
30
web/app/src/workers/tracking-mask-test.worker.ts
Normal file
30
web/app/src/workers/tracking-mask-test.worker.ts
Normal 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);
|
||||
};
|
||||
27
web/app/src/workers/vdb-test.worker.ts
Normal file
27
web/app/src/workers/vdb-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
441
web/app/src/workers/viewport-render.worker.ts
Normal file
441
web/app/src/workers/viewport-render.worker.ts
Normal 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" });
|
||||
}
|
||||
};
|
||||
1200
web/app/src/workers/web-engine.worker.ts
Normal file
1200
web/app/src/workers/web-engine.worker.ts
Normal file
File diff suppressed because it is too large
Load Diff
43
web/app/vite.config.ts
Normal file
43
web/app/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user