Advance M7 workflows and release operations
This commit is contained in:
@@ -1,20 +1,54 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"schemaVersion": 2,
|
||||
"protocolVersion": 1,
|
||||
"engineVersion": "blender-wasm-0.1.0",
|
||||
"releaseId": "blender-wasm-0.1.0-rc.1",
|
||||
"engineVersion": "blender-wasm-0.1.0-rc.1",
|
||||
"engine": "blender-wasm",
|
||||
"memory": {
|
||||
"initialPages": 256,
|
||||
"maximumPages": 32768,
|
||||
"shared": false
|
||||
},
|
||||
"wasm": [
|
||||
"variants": [
|
||||
{
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
|
||||
"required": true
|
||||
"id": "single",
|
||||
"memory": {
|
||||
"initialPages": 256,
|
||||
"maximumPages": 32768,
|
||||
"shared": false
|
||||
},
|
||||
"resources": {
|
||||
"js": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/single/web_engine.js",
|
||||
"sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca"
|
||||
},
|
||||
"wasm": {
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/single/web_engine.wasm",
|
||||
"sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pthread",
|
||||
"memory": {
|
||||
"initialPages": 256,
|
||||
"maximumPages": 32768,
|
||||
"shared": true
|
||||
},
|
||||
"resources": {
|
||||
"js": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/pthread/web_engine.js",
|
||||
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
|
||||
},
|
||||
"wasm": {
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/pthread/web_engine.wasm",
|
||||
"sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b"
|
||||
},
|
||||
"pthreadWorker": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/pthread/web_engine.js",
|
||||
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
Binary file not shown.
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { WebEngineClient } from "../engine-client/WebEngineClient";
|
||||
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
|
||||
import type { ProgressEvent } from "../../../protocol/progress";
|
||||
@@ -32,6 +33,11 @@ import {
|
||||
} from "../volume/nanovdb-viewport";
|
||||
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
|
||||
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
|
||||
import { createInitialUserActionStates, reduceUserActionStates, type UserActionIdentity, type UserActionKind } from "../../../protocol/user-action-state";
|
||||
import { acquireProjectAction, createProjectActionMutexState, releaseProjectAction, type ProjectActionConflict, type ProjectActionIdentity } from "../../../protocol/project-action-mutex";
|
||||
import { DEFAULT_FILE_READ_YIELD_BYTES, FileByteReadError, readFileBytes, type FileReadPhase } from "../../../protocol/file-byte-reader";
|
||||
import { advanceSaveTransaction, beginSaveTransaction, commitSaveTransaction, createSaveTransactionState, failSaveTransaction, type SaveTransactionState } from "../../../protocol/save-transaction";
|
||||
import { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } from "../../../protocol/dirty-state";
|
||||
import "./app-shell.css";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
@@ -48,6 +54,26 @@ async function sha256Hex(data: ArrayBuffer): Promise<string> {
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const LAST_PROJECT_STORAGE_KEY = "blender-web:last-project-id";
|
||||
|
||||
function loadLastProjectId(): string {
|
||||
try {
|
||||
return localStorage.getItem(LAST_PROJECT_STORAGE_KEY) || "untitled";
|
||||
}
|
||||
catch {
|
||||
return "untitled";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberLastProjectId(projectId: string): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_PROJECT_STORAGE_KEY, projectId);
|
||||
}
|
||||
catch {
|
||||
// Project recovery still works in the current session when localStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
interface AreaProps {
|
||||
className?: string;
|
||||
editor: EditorType;
|
||||
@@ -366,7 +392,7 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
|
||||
{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>;
|
||||
return <div key={node.id} role="button" tabIndex={0} data-node-id={node.id} data-data-id={node.dataId ?? ""} 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>
|
||||
))}
|
||||
@@ -645,7 +671,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
|
||||
export function App() {
|
||||
const [uiState, setUIState] = useState(createDefaultWebWorkspaceState);
|
||||
const [frame, setFrame] = useState(1);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [dirtyState, setDirtyState] = useState(() => createDirtyState());
|
||||
const [engineStatus, setEngineStatus] = useState("WASM: starting");
|
||||
const [wasmStatus, setWasmStatus] = useState("WASM ABI: starting");
|
||||
const [storageStatus, setStorageStatus] = useState("Storage: starting");
|
||||
@@ -660,15 +686,65 @@ export function App() {
|
||||
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 [openReadEvidence, setOpenReadEvidence] = useState<{ actionId: string | null; phase: FileReadPhase | "IDLE"; bytesRead: number; totalBytes: number; progressEvents: number }>({ actionId: null, phase: "IDLE", bytesRead: 0, totalBytes: 0, progressEvents: 0 });
|
||||
const [openCleanupEvidence, setOpenCleanupEvidence] = useState({ readerLiveReaders: 0, readerInputBytes: 0, readerStagingFiles: 0, engineActiveRequests: 0, engineInputBytes: 0, engineNativeHandles: 0, engineStagingFiles: 0, lastStage: "IDLE" });
|
||||
const [saveTransaction, setSaveTransaction] = useState<SaveTransactionState>(() => createSaveTransactionState({ revision: 0, sha256: null }));
|
||||
const [userActions, dispatchUserAction] = useReducer(reduceUserActionStates, createInitialUserActionStates());
|
||||
const [projectActionConflict, setProjectActionConflict] = useState<ProjectActionConflict | 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 projectIdRef = useRef(loadLastProjectId());
|
||||
const projectRevisionRef = useRef(0);
|
||||
const committedProjectRevisionRef = useRef(0);
|
||||
const committedProjectHashRef = useRef<string | null>(null);
|
||||
const userActionSequenceRef = useRef(0);
|
||||
const projectActionMutexRef = useRef(createProjectActionMutexState());
|
||||
const openAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const workspace = uiState.context.workspaceId;
|
||||
const saved = !dirtyState.dirty;
|
||||
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
|
||||
const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command));
|
||||
const nextUserActionIdentity = <Kind extends UserActionKind>(kind: Kind): UserActionIdentity & { kind: Kind } => (
|
||||
{ kind, actionId: `${kind}:${++userActionSequenceRef.current}` }
|
||||
);
|
||||
const startUserAction = (identity: UserActionIdentity): void => {
|
||||
dispatchUserAction({ type: "START", identity });
|
||||
};
|
||||
const beginUserAction = (kind: UserActionKind): UserActionIdentity => {
|
||||
const identity = nextUserActionIdentity(kind);
|
||||
startUserAction(identity);
|
||||
return identity;
|
||||
};
|
||||
const succeedUserAction = (identity: UserActionIdentity): void => {
|
||||
dispatchUserAction({ type: "SUCCEED", identity });
|
||||
};
|
||||
const failUserAction = (identity: UserActionIdentity, errorCode: string): void => {
|
||||
dispatchUserAction({ type: "FAIL", identity, errorCode });
|
||||
};
|
||||
const cancelUserAction = (identity: UserActionIdentity, errorCode: string): void => {
|
||||
dispatchUserAction({ type: "CANCEL", identity, errorCode });
|
||||
};
|
||||
const acquireProjectActionLock = (identity: ProjectActionIdentity, reportConflict = true): boolean => {
|
||||
const result = acquireProjectAction(projectActionMutexRef.current, identity);
|
||||
if (!result.granted) {
|
||||
if (reportConflict) {
|
||||
setProjectActionConflict(result.conflict);
|
||||
setEngineStatus(`Action: blocked (${result.conflict.code}: ${result.conflict.reason})`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
projectActionMutexRef.current = result.state;
|
||||
setProjectActionConflict(null);
|
||||
return true;
|
||||
};
|
||||
const releaseProjectActionLock = (identity: ProjectActionIdentity): void => {
|
||||
const result = releaseProjectAction(projectActionMutexRef.current, identity);
|
||||
if (result.released) projectActionMutexRef.current = result.state;
|
||||
else setEngineStatus(`Action: lock failure (${result.errorCode})`);
|
||||
};
|
||||
const selectObject = (id: string, additive = false): void => {
|
||||
setSelectedObjectIds((current) => {
|
||||
if (!additive) return new Set([id]);
|
||||
@@ -767,11 +843,11 @@ export function App() {
|
||||
// Cache invalidation is best effort and never blocks an engine command.
|
||||
}
|
||||
};
|
||||
const applyEditCommand = async (command: WebEngineEditCommand): Promise<void> => {
|
||||
const applyEditCommand = async (command: WebEngineEditCommand): Promise<boolean> => {
|
||||
const client = webClientRef.current;
|
||||
if (!client || !snapshot) {
|
||||
if (command.type === "setFrame") setFrame(command.frame);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (command.type === "previewDecimateMesh") {
|
||||
@@ -781,9 +857,11 @@ export function App() {
|
||||
setEngineStatus(result.simplify
|
||||
? `Engine: Preview ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles`
|
||||
: "Engine: Preview ready");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const result = await client.applyCommand(command);
|
||||
const logicalRevision = Math.max(projectRevisionRef.current + 1, result.snapshot.revision);
|
||||
projectRevisionRef.current = logicalRevision;
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setSnapshot(result.snapshot);
|
||||
@@ -794,7 +872,26 @@ export function App() {
|
||||
if (command.type === "meshEdit" || command.type === "separateMeshFaces" || command.type === "joinObjects") {
|
||||
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
|
||||
}
|
||||
setSaved(false);
|
||||
if (command.type === "undo" || command.type === "redo") {
|
||||
let matchesCommittedContent = false;
|
||||
try {
|
||||
const historyBlend = await client.saveBlend();
|
||||
matchesCommittedContent = await sha256Hex(historyBlend) === committedProjectHashRef.current;
|
||||
}
|
||||
catch {
|
||||
// A history serialization failure is conservative: the project remains dirty.
|
||||
}
|
||||
setDirtyState((current) => {
|
||||
const accepted = acceptHistoryTransaction(current, logicalRevision, matchesCommittedContent);
|
||||
return accepted.ok ? accepted.state : current;
|
||||
});
|
||||
}
|
||||
else {
|
||||
setDirtyState((current) => {
|
||||
const accepted = acceptMainTransaction(current, logicalRevision);
|
||||
return accepted.ok ? accepted.state : current;
|
||||
});
|
||||
}
|
||||
void invalidateCachedLODs(projectIdRef.current, result.snapshot.revision);
|
||||
setEngineStatus(result.simplify
|
||||
? `Engine: Decimate ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles (r${result.snapshot.revision})`
|
||||
@@ -803,12 +900,12 @@ export function App() {
|
||||
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);
|
||||
const operationId = `op-${logicalRevision}-${crypto.randomUUID()}`;
|
||||
await storage.appendOperation(operationId, projectIdRef.current, logicalRevision, 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));
|
||||
await storage.saveSnapshot(projectIdRef.current, logicalRevision, blend.slice(0));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -816,8 +913,10 @@ export function App() {
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
setEngineStatus(`Engine: command failed (${errorMessage(error)})`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -858,18 +957,30 @@ export function App() {
|
||||
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}) });
|
||||
};
|
||||
const importImage = async (file: File): Promise<void> => {
|
||||
const identity = beginUserAction("IMPORT");
|
||||
let failureCode = "IMPORT_DECODE_FAILED";
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
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",
|
||||
failureCode = "IMPORT_MAIN_TRANSACTION_FAILED";
|
||||
const applied = await 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();
|
||||
if (!applied) {
|
||||
failUserAction(identity, failureCode);
|
||||
return;
|
||||
}
|
||||
succeedUserAction(identity);
|
||||
}
|
||||
catch (error) {
|
||||
failUserAction(identity, failureCode);
|
||||
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
finally {
|
||||
bitmap?.close();
|
||||
}
|
||||
};
|
||||
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
@@ -1085,7 +1196,9 @@ export function App() {
|
||||
let mounted = true;
|
||||
void loadWebEngineManifest()
|
||||
.then(async (manifest) => {
|
||||
const requiredResource = manifest.wasm.find((resource) => resource.required);
|
||||
const requiredResource = "variants" in manifest
|
||||
? manifest.variants.find((variant) => variant.id === "single")?.resources.wasm
|
||||
: manifest.wasm.find((resource) => resource.required);
|
||||
if (requiredResource) await verifyWasmResource(requiredResource);
|
||||
if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`);
|
||||
})
|
||||
@@ -1171,15 +1284,66 @@ export function App() {
|
||||
};
|
||||
|
||||
const openBlendFile = async (file: File): Promise<void> => {
|
||||
const identity = nextUserActionIdentity("OPEN");
|
||||
if (!acquireProjectActionLock(identity)) return;
|
||||
startUserAction(identity);
|
||||
const controller = new AbortController();
|
||||
openAbortControllerRef.current = controller;
|
||||
const client = webClientRef.current;
|
||||
if (!client) return;
|
||||
setOpenProgress({ requestId: "ui", operation: "blend.open", phase: "started", fraction: 0, message: `打开 ${file.name}` });
|
||||
let failureCode = "OPEN_FILE_READ_FAILED";
|
||||
try {
|
||||
const input = await file.arrayBuffer();
|
||||
if (!client) {
|
||||
failUserAction(identity, "OPEN_ENGINE_UNAVAILABLE");
|
||||
return;
|
||||
}
|
||||
const input = await readFileBytes(file, {
|
||||
signal: controller.signal,
|
||||
yieldControl: () => new Promise((resolve) => requestAnimationFrame(() => resolve())),
|
||||
onResourceState: (state) => setOpenCleanupEvidence((current) => ({
|
||||
...current,
|
||||
readerLiveReaders: state.liveReaders,
|
||||
readerInputBytes: state.liveInputBytes,
|
||||
readerStagingFiles: state.liveStagingFiles,
|
||||
})),
|
||||
onProgress: (item) => {
|
||||
const update = (): void => {
|
||||
setOpenReadEvidence((current) => ({
|
||||
actionId: identity.actionId,
|
||||
phase: item.phase,
|
||||
bytesRead: item.bytesRead,
|
||||
totalBytes: item.totalBytes,
|
||||
progressEvents: current.actionId === identity.actionId ? current.progressEvents + 1 : 1,
|
||||
}));
|
||||
setOpenProgress({
|
||||
requestId: identity.actionId,
|
||||
operation: "blend.open.read",
|
||||
phase: item.phase === "STARTED" ? "started" : item.phase === "COMPLETED" ? "completed" : item.phase === "CANCELLED" ? "cancelled" : "progress",
|
||||
fraction: item.fraction,
|
||||
bytesRead: item.bytesRead,
|
||||
totalBytes: item.totalBytes,
|
||||
message: `读取 ${file.name} ${item.bytesRead}/${item.totalBytes} B`,
|
||||
});
|
||||
};
|
||||
if (item.phase !== "READING" || item.bytesRead % DEFAULT_FILE_READ_YIELD_BYTES === 0) flushSync(update);
|
||||
else update();
|
||||
},
|
||||
});
|
||||
if (controller.signal.aborted) throw new FileByteReadError("FILE_READ_CANCELLED", "cancelled before engine open");
|
||||
const inputSha256 = await sha256Hex(input);
|
||||
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);
|
||||
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) });
|
||||
rememberLastProjectId(projectId);
|
||||
failureCode = "OPEN_ENGINE_FAILED";
|
||||
const result = await client.openBlend(input, (progress) => {
|
||||
setOpenCleanupEvidence((current) => ({ ...current, lastStage: progress.stage ?? current.lastStage }));
|
||||
setOpenProgress({ ...progress, requestId: identity.actionId });
|
||||
}, controller.signal);
|
||||
projectRevisionRef.current = result.snapshot.revision;
|
||||
committedProjectRevisionRef.current = 0;
|
||||
committedProjectHashRef.current = inputSha256;
|
||||
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: inputSha256 }));
|
||||
setDirtyState(createDirtyState(result.snapshot.revision));
|
||||
setVolumeProject({ projectId, sourceBlendSha256: inputSha256 });
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setGPUTextureAssets([]);
|
||||
@@ -1188,32 +1352,104 @@ export function App() {
|
||||
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);
|
||||
succeedUserAction(identity);
|
||||
} catch (error) {
|
||||
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
if (controller.signal.aborted || (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED")) {
|
||||
cancelUserAction(identity, "OPEN_CANCELLED");
|
||||
if (client) {
|
||||
try {
|
||||
const resources = await client.openResourceStatus();
|
||||
setOpenCleanupEvidence((current) => ({
|
||||
...current,
|
||||
engineActiveRequests: resources.activeRequests,
|
||||
engineInputBytes: resources.liveInputBytes,
|
||||
engineNativeHandles: resources.liveNativeHandles,
|
||||
engineStagingFiles: resources.liveStagingFiles,
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
// The visible cancellation remains valid; Worker recovery is handled separately.
|
||||
}
|
||||
}
|
||||
setEngineStatus("Engine: .blend open cancelled");
|
||||
}
|
||||
else {
|
||||
failUserAction(identity, error instanceof FileByteReadError ? error.code : failureCode);
|
||||
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
} finally {
|
||||
setOpenProgress(null);
|
||||
if (openAbortControllerRef.current === controller) openAbortControllerRef.current = null;
|
||||
releaseProjectActionLock(identity);
|
||||
}
|
||||
};
|
||||
const cancelOpenFileRead = (): void => {
|
||||
openAbortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
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);
|
||||
const revision = Math.max(projectRevisionRef.current, committedProjectRevisionRef.current, snapshot.revision);
|
||||
const started = beginSaveTransaction(createSaveTransactionState({
|
||||
revision: committedProjectRevisionRef.current,
|
||||
sha256: committedProjectHashRef.current,
|
||||
}), revision);
|
||||
if (!started.ok) throw new Error(started.errorCode);
|
||||
let transaction = started.state;
|
||||
setSaveTransaction(transaction);
|
||||
try {
|
||||
const data = await client.saveBlend();
|
||||
const sha256 = await sha256Hex(data);
|
||||
const staged = advanceSaveTransaction(transaction, "OPFS_STAGE", sha256);
|
||||
if (!staged.ok) throw new Error(staged.errorCode);
|
||||
transaction = staged.state;
|
||||
setSaveTransaction(transaction);
|
||||
|
||||
const storage = storageClientRef.current;
|
||||
let persisted = { revision, sha256 };
|
||||
if (storage) {
|
||||
const result = await storage.saveProject(projectIdRef.current, revision, data.slice(0));
|
||||
persisted = { revision: result.revision, sha256: result.sha256 };
|
||||
}
|
||||
const sceneCommitted = advanceSaveTransaction(transaction, "SCENE_COMMIT");
|
||||
if (!sceneCommitted.ok) throw new Error(sceneCommitted.errorCode);
|
||||
transaction = sceneCommitted.state;
|
||||
const metadataCommitted = advanceSaveTransaction(transaction, "METADATA_COMMIT");
|
||||
if (!metadataCommitted.ok) throw new Error(metadataCommitted.errorCode);
|
||||
transaction = metadataCommitted.state;
|
||||
const committed = commitSaveTransaction(transaction, persisted);
|
||||
if (!committed.ok) throw new Error(committed.errorCode);
|
||||
transaction = committed.state;
|
||||
setSaveTransaction(transaction);
|
||||
committedProjectRevisionRef.current = persisted.revision;
|
||||
committedProjectHashRef.current = persisted.sha256;
|
||||
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: persisted.sha256 });
|
||||
setDirtyState((current) => {
|
||||
const accepted = acceptMainSave(current, persisted.revision);
|
||||
return accepted.ok ? accepted.state : current;
|
||||
});
|
||||
|
||||
if (storage) {
|
||||
try {
|
||||
await storage.saveSnapshot(projectIdRef.current, revision, data.slice(0));
|
||||
await storage.pruneOperations(projectIdRef.current, revision);
|
||||
}
|
||||
catch (error) {
|
||||
setStorageStatus(`Storage: post-commit maintenance failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
catch (error) {
|
||||
setSaveTransaction(failSaveTransaction(transaction, transaction.stage === "SERIALIZE" ? "SAVE_SERIALIZE_INTERRUPTED" : "SAVE_STORAGE_INTERRUPTED"));
|
||||
throw error;
|
||||
}
|
||||
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) });
|
||||
setSaved(true);
|
||||
return data;
|
||||
};
|
||||
|
||||
const recoverCachedProject = async (): Promise<void> => {
|
||||
@@ -1222,6 +1458,7 @@ export function App() {
|
||||
if (!client || !storage) return;
|
||||
try {
|
||||
const projectId = projectIdRef.current;
|
||||
rememberLastProjectId(projectId);
|
||||
let baseRevision = 0;
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
@@ -1237,8 +1474,9 @@ export function App() {
|
||||
baseRevision = saved.revision;
|
||||
buffer = saved.buffer;
|
||||
}
|
||||
const baseSha256 = await sha256Hex(buffer);
|
||||
let opened = await client.openBlend(buffer);
|
||||
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) });
|
||||
setVolumeProject({ projectId, sourceBlendSha256: baseSha256 });
|
||||
const replay = await storage.listOperations(projectId, baseRevision);
|
||||
for (const operation of replay.operations) {
|
||||
const payload = operation.payload as WebEngineEditCommand;
|
||||
@@ -1247,6 +1485,14 @@ export function App() {
|
||||
}
|
||||
opened = await client.applyCommand(payload);
|
||||
}
|
||||
committedProjectRevisionRef.current = baseRevision;
|
||||
committedProjectHashRef.current = baseSha256;
|
||||
setSaveTransaction(createSaveTransactionState({ revision: baseRevision, sha256: baseSha256 }));
|
||||
projectRevisionRef.current = replay.operations.reduce(
|
||||
(revision, operation) => Math.max(revision + 1, operation.revision),
|
||||
baseRevision,
|
||||
);
|
||||
setDirtyState(recoverDirtyState(projectRevisionRef.current, baseRevision));
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setGPUTextureAssets([]);
|
||||
@@ -1255,7 +1501,6 @@ export function App() {
|
||||
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);
|
||||
@@ -1267,48 +1512,115 @@ export function App() {
|
||||
};
|
||||
|
||||
const saveBlend = async (): Promise<void> => {
|
||||
const saveIdentity = nextUserActionIdentity("SAVE");
|
||||
if (!acquireProjectActionLock(saveIdentity)) return;
|
||||
startUserAction(saveIdentity);
|
||||
let data: ArrayBuffer | null = null;
|
||||
try {
|
||||
const persisted = await persistProject();
|
||||
if (!persisted) {
|
||||
failUserAction(saveIdentity, "SAVE_PROJECT_UNAVAILABLE");
|
||||
}
|
||||
else {
|
||||
data = persisted;
|
||||
succeedUserAction(saveIdentity);
|
||||
}
|
||||
} catch (error) {
|
||||
failUserAction(saveIdentity, "SAVE_FAILED");
|
||||
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
finally {
|
||||
releaseProjectActionLock(saveIdentity);
|
||||
}
|
||||
if (!data) return;
|
||||
|
||||
const saveAsIdentity = beginUserAction("SAVE_AS");
|
||||
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);
|
||||
succeedUserAction(saveAsIdentity);
|
||||
} catch (error) {
|
||||
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
failUserAction(saveAsIdentity, "SAVE_AS_DOWNLOAD_FAILED");
|
||||
setEngineStatus(`Engine: .blend download failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
};
|
||||
const closeProject = (): void => {
|
||||
if (!snapshot) return;
|
||||
const identity: ProjectActionIdentity = { kind: "CLOSE", actionId: `CLOSE:${++userActionSequenceRef.current}` };
|
||||
if (!acquireProjectActionLock(identity)) return;
|
||||
try {
|
||||
autosaveRef.current?.cancel();
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setGPUTextureAssets([]);
|
||||
setVolumeProject(null);
|
||||
setSnapshot(null);
|
||||
setSelectedObjectIds(new Set());
|
||||
setMeshSelection({ meshId: null, mode: "FACE", indices: new Set() });
|
||||
setGeometryBuffers([]);
|
||||
setNonMeshGeometryBuffers([]);
|
||||
setFrame(1);
|
||||
setDirtyState(createDirtyState());
|
||||
projectRevisionRef.current = 0;
|
||||
committedProjectRevisionRef.current = 0;
|
||||
committedProjectHashRef.current = null;
|
||||
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: null }));
|
||||
commandCountRef.current = 0;
|
||||
setOpenProgress(null);
|
||||
setEngineStatus("Engine: ready, open a .blend file");
|
||||
}
|
||||
finally {
|
||||
releaseProjectActionLock(identity);
|
||||
}
|
||||
};
|
||||
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;
|
||||
const identity = beginUserAction("EXPORT");
|
||||
if (!snapshot) {
|
||||
failUserAction(identity, "EXPORT_PROJECT_UNAVAILABLE");
|
||||
setEngineStatus("GLB: blocked (no open project)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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.
|
||||
}
|
||||
}
|
||||
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);
|
||||
succeedUserAction(identity);
|
||||
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
|
||||
}
|
||||
else {
|
||||
failUserAction(identity, "EXPORT_BLOCKED");
|
||||
setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
|
||||
}
|
||||
}
|
||||
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)`);
|
||||
catch (error) {
|
||||
failUserAction(identity, "EXPORT_FAILED");
|
||||
setEngineStatus(`GLB: export failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
else setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1317,11 +1629,16 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
autosaveRef.current?.schedule(async () => {
|
||||
const identity: ProjectActionIdentity = { kind: "SAVE", actionId: `AUTOSAVE:${++userActionSequenceRef.current}` };
|
||||
if (!acquireProjectActionLock(identity, false)) return;
|
||||
try {
|
||||
await persistProject();
|
||||
} catch (error) {
|
||||
setEngineStatus(`Engine: autosave failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
}
|
||||
finally {
|
||||
releaseProjectActionLock(identity);
|
||||
}
|
||||
});
|
||||
}, [saved, snapshot]);
|
||||
|
||||
@@ -1329,6 +1646,7 @@ export function App() {
|
||||
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 };
|
||||
const canCancelOpenRead = userActions.OPEN.status === "RUNNING" && (openProgress?.operation === "blend.open.read" || openProgress?.cancellable === true);
|
||||
const operatorCommands: OperatorCommand[] = [
|
||||
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
|
||||
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
|
||||
@@ -1338,21 +1656,41 @@ export function App() {
|
||||
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
|
||||
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },
|
||||
{ id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } },
|
||||
{ id: "file.close", label: "Close Project", keywords: "file", execute: closeProject },
|
||||
] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
|
||||
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}
|
||||
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
|
||||
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
|
||||
data-user-action-save-status={userActions.SAVE.status} data-user-action-save-id={userActions.SAVE.identity?.actionId} data-user-action-save-error={userActions.SAVE.errorCode ?? undefined}
|
||||
data-user-action-save-as-status={userActions.SAVE_AS.status} data-user-action-save-as-id={userActions.SAVE_AS.identity?.actionId} data-user-action-save-as-error={userActions.SAVE_AS.errorCode ?? undefined}
|
||||
data-user-action-export-status={userActions.EXPORT.status} data-user-action-export-id={userActions.EXPORT.identity?.actionId} data-user-action-export-error={userActions.EXPORT.errorCode ?? undefined}
|
||||
data-project-action-conflict={projectActionConflict?.code} data-project-action-conflict-reason={projectActionConflict?.reason}
|
||||
data-project-action-conflict-requested={projectActionConflict?.requested.kind} data-project-action-conflict-owner={projectActionConflict?.owner.kind}
|
||||
data-project-action-conflict-request-id={projectActionConflict?.requested.actionId} data-project-action-conflict-owner-id={projectActionConflict?.owner.actionId}
|
||||
data-open-read-action-id={openReadEvidence.actionId ?? undefined} data-open-read-phase={openReadEvidence.phase}
|
||||
data-open-read-bytes={openReadEvidence.bytesRead} data-open-read-total={openReadEvidence.totalBytes} data-open-read-events={openReadEvidence.progressEvents}
|
||||
data-open-cleanup-reader-count={openCleanupEvidence.readerLiveReaders} data-open-cleanup-reader-bytes={openCleanupEvidence.readerInputBytes}
|
||||
data-open-cleanup-reader-staging={openCleanupEvidence.readerStagingFiles} data-open-cleanup-engine-requests={openCleanupEvidence.engineActiveRequests}
|
||||
data-open-cleanup-engine-bytes={openCleanupEvidence.engineInputBytes} data-open-cleanup-engine-handles={openCleanupEvidence.engineNativeHandles}
|
||||
data-open-cleanup-engine-staging={openCleanupEvidence.engineStagingFiles} data-open-cleanup-stage={openCleanupEvidence.lastStage}
|
||||
data-save-transaction-status={saveTransaction.status} data-save-transaction-stage={saveTransaction.stage ?? undefined}
|
||||
data-save-committed-revision={saveTransaction.committed.revision} data-save-committed-hash={saveTransaction.committed.sha256 ?? undefined}
|
||||
data-save-candidate-revision={saveTransaction.candidate?.revision} data-save-candidate-hash={saveTransaction.candidate?.sha256 ?? undefined}
|
||||
data-save-transaction-error={saveTransaction.errorCode ?? undefined} data-dirty={dirtyState.dirty}
|
||||
data-current-main-revision={dirtyState.currentMainRevision} data-committed-main-revision={dirtyState.committedMainRevision}>
|
||||
<header className="topbar">
|
||||
<div className="brand"><span className="brand-mark" aria-hidden="true">◈</span><span>Web Blender Modeler V1</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>
|
||||
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}>打开</button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}>关闭</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={() => void 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.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
|
||||
<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.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></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} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} 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>
|
||||
@@ -1360,7 +1698,7 @@ export function App() {
|
||||
<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 commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
|
||||
<footer className="status-bar"><span>Web Blender Modeler V1</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>
|
||||
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.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; }
|
||||
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
|
||||
.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; }
|
||||
@@ -62,6 +63,7 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.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; }
|
||||
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
|
||||
.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; }
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
WebEngineRequest,
|
||||
WebEngineResponse,
|
||||
WebEngineResult,
|
||||
WebEngineOpenResourceStatus,
|
||||
WebEngineStatus,
|
||||
} from "../../../protocol/web-engine";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -25,6 +26,7 @@ interface PendingRequest {
|
||||
reject: (error: ErrorReport) => void;
|
||||
onProgress?: (progress: ProgressEvent) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface WebEngineClientOptions {
|
||||
@@ -61,11 +63,11 @@ export class WebEngineClient {
|
||||
return (await this.request({ type: "init" })).status;
|
||||
}
|
||||
|
||||
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
|
||||
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise<BlendOpenResult> {
|
||||
if (buffer.byteLength === 0) {
|
||||
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
|
||||
}
|
||||
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
|
||||
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress, signal);
|
||||
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
|
||||
this.geometryBuffers = result.geometryBuffers ?? [];
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
|
||||
@@ -146,6 +148,12 @@ export class WebEngineClient {
|
||||
return result.blend;
|
||||
}
|
||||
|
||||
async openResourceStatus(): Promise<WebEngineOpenResourceStatus> {
|
||||
const result = await this.request({ type: "openResourceStatus" });
|
||||
if (!result.openResources) throw this.report("INVALID_ARGUMENT", "WebEngine open resource status missing", true);
|
||||
return result.openResources;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
|
||||
this.worker?.terminate();
|
||||
@@ -169,16 +177,29 @@ export class WebEngineClient {
|
||||
command: WebEngineRequest["command"],
|
||||
transfer: Transferable[] = [],
|
||||
onProgress?: (progress: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): 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.get(requestId)?.cleanup?.();
|
||||
this.pending.delete(requestId);
|
||||
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(requestId, { resolve, reject, onProgress, timer });
|
||||
const abort = (): void => {
|
||||
worker.postMessage({
|
||||
requestId: `web-engine-cancel-${++this.requestCounter}`,
|
||||
command: { type: "cancelOpen", targetRequestId: requestId },
|
||||
} satisfies WebEngineRequest);
|
||||
};
|
||||
const cleanup = signal ? () => signal.removeEventListener("abort", abort) : undefined;
|
||||
this.pending.set(requestId, { resolve, reject, onProgress, timer, cleanup });
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
}
|
||||
worker.postMessage(request, transfer);
|
||||
});
|
||||
}
|
||||
@@ -192,6 +213,7 @@ export class WebEngineClient {
|
||||
}
|
||||
this.pending.delete(response.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.cleanup?.();
|
||||
if (response.ok) pending.resolve(response.result);
|
||||
else pending.reject(response.error);
|
||||
}
|
||||
@@ -199,6 +221,7 @@ export class WebEngineClient {
|
||||
private failPending(error: ErrorReport): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.cleanup?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
|
||||
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { WebEngineVariantV2 } from "../../../protocol/manifest";
|
||||
import type {
|
||||
EngineVariantResourceState,
|
||||
EngineVariantSession,
|
||||
} from "./engine-variant-bootstrap";
|
||||
import { EngineVariantLoadError } from "./engine-variant-bootstrap";
|
||||
|
||||
interface EmscriptenPThreadRuntime {
|
||||
unusedWorkers: Worker[];
|
||||
runningWorkers: Worker[];
|
||||
terminateAllThreads(): void;
|
||||
}
|
||||
|
||||
interface BrowserVariantModule {
|
||||
HEAPU8: Uint8Array;
|
||||
_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_open_blend(handle: number, data: number, length: number): number;
|
||||
PThread?: EmscriptenPThreadRuntime;
|
||||
}
|
||||
|
||||
type BrowserVariantFactory = (options: { wasmBinary: ArrayBuffer }) => Promise<BrowserVariantModule>;
|
||||
|
||||
async function verifiedBytes(url: string, expectedSha256: string, controllers: Set<AbortController>): Promise<ArrayBuffer> {
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new EngineVariantLoadError(
|
||||
"ENGINE_VARIANT_RESOURCE_REQUEST_FAILED",
|
||||
`${url} returned ${response.status}`,
|
||||
);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const actual = [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (actual !== expectedSha256) {
|
||||
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", url);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
finally {
|
||||
controllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserEngineVariantSession implements EngineVariantSession<ArrayBuffer> {
|
||||
readonly variant: WebEngineVariantV2;
|
||||
private readonly module: BrowserVariantModule;
|
||||
private readonly pendingRequests: Set<AbortController>;
|
||||
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
private handle: number;
|
||||
|
||||
constructor(
|
||||
variant: WebEngineVariantV2,
|
||||
module: BrowserVariantModule,
|
||||
handle: number,
|
||||
pendingRequests: Set<AbortController>,
|
||||
) {
|
||||
this.variant = variant;
|
||||
this.module = module;
|
||||
this.handle = handle;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
async openProject(project: ArrayBuffer): Promise<void> {
|
||||
if (this.handle <= 0) throw new Error("ENGINE_VARIANT_SESSION_DISPOSED");
|
||||
const pointer = this.module._malloc(project.byteLength);
|
||||
if (project.byteLength > 0 && pointer <= 0) throw new Error("ENGINE_VARIANT_PROJECT_ALLOCATION_FAILED");
|
||||
try {
|
||||
if (project.byteLength > 0) this.module.HEAPU8.set(new Uint8Array(project), pointer);
|
||||
const result = this.module._web_engine_open_blend(this.handle, pointer, project.byteLength);
|
||||
if (result !== 0) throw new Error(`ENGINE_VARIANT_PROJECT_OPEN_FAILED: ${result}`);
|
||||
}
|
||||
finally {
|
||||
if (pointer > 0) this.module._free(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
resourceState(): EngineVariantResourceState {
|
||||
const pthread = this.module.PThread;
|
||||
return {
|
||||
handles: this.handle > 0 ? this.module._web_engine_get_live_handles() : 0,
|
||||
workers: pthread ? pthread.unusedWorkers.length + pthread.runningWorkers.length : 0,
|
||||
timers: this.timers.size,
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
};
|
||||
}
|
||||
|
||||
testOnlySeedTrackedResources(): void {
|
||||
this.pendingRequests.add(new AbortController());
|
||||
const timer = setTimeout(() => this.timers.delete(timer), 60_000);
|
||||
this.timers.add(timer);
|
||||
}
|
||||
|
||||
async dispose(): Promise<EngineVariantResourceState> {
|
||||
for (const controller of this.pendingRequests) controller.abort();
|
||||
this.pendingRequests.clear();
|
||||
for (const timer of this.timers) clearTimeout(timer);
|
||||
this.timers.clear();
|
||||
if (this.handle > 0) {
|
||||
this.module._web_engine_destroy(this.handle);
|
||||
this.handle = 0;
|
||||
}
|
||||
this.module.PThread?.terminateAllThreads();
|
||||
return this.resourceState();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBrowserEngineVariantSession(
|
||||
variant: WebEngineVariantV2,
|
||||
): Promise<EngineVariantSession<ArrayBuffer>> {
|
||||
const controllers = new Set<AbortController>();
|
||||
const verifiedResources = new Map<string, ArrayBuffer>();
|
||||
const verify = async (resource: { url: string; sha256: string }): Promise<ArrayBuffer> => {
|
||||
const existing = verifiedResources.get(resource.url);
|
||||
if (existing) return existing;
|
||||
const bytes = await verifiedBytes(resource.url, resource.sha256, controllers);
|
||||
verifiedResources.set(resource.url, bytes);
|
||||
return bytes;
|
||||
};
|
||||
await verify(variant.resources.js);
|
||||
const wasmBinary = await verify(variant.resources.wasm);
|
||||
if (variant.id === "pthread") await verify(variant.resources.pthreadWorker);
|
||||
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as { default: BrowserVariantFactory };
|
||||
const module = await imported.default({ wasmBinary });
|
||||
const handle = module._web_engine_create();
|
||||
if (handle <= 0) {
|
||||
module.PThread?.terminateAllThreads();
|
||||
throw new Error("ENGINE_VARIANT_HANDLE_CREATION_FAILED");
|
||||
}
|
||||
return new BrowserEngineVariantSession(variant, module, handle, controllers);
|
||||
}
|
||||
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
bindWebEngineRelease,
|
||||
selectWebEngineVariant,
|
||||
type WasmThreadingCapabilities,
|
||||
type WebEngineReleaseBinding,
|
||||
type WebEngineVariantPolicy,
|
||||
} from "../../../protocol/engine-variant";
|
||||
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import type { WebEngineManifestV2, WebEngineVariantV2 } from "../../../protocol/manifest";
|
||||
|
||||
export interface EngineVariantResourceState {
|
||||
handles: number;
|
||||
workers: number;
|
||||
timers: number;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface EngineVariantSession<Project = ArrayBuffer> {
|
||||
readonly variant: WebEngineVariantV2;
|
||||
openProject(project: Project): Promise<void>;
|
||||
dispose(): Promise<EngineVariantResourceState>;
|
||||
resourceState(): EngineVariantResourceState;
|
||||
testOnlySeedTrackedResources?(): void;
|
||||
}
|
||||
|
||||
export interface EngineVariantFallbackReason {
|
||||
code: "PTHREAD_INITIALIZATION_FAILED";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapResult {
|
||||
status: "READY";
|
||||
selected: "single" | "pthread";
|
||||
attempted: Array<"single" | "pthread">;
|
||||
fallbackReason: EngineVariantFallbackReason | null;
|
||||
pthreadGate: CapabilityGateResult;
|
||||
openCount: number;
|
||||
failedAttemptCleanup: EngineVariantResourceState | null;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapOutcome<Project = ArrayBuffer> {
|
||||
result: EngineVariantBootstrapResult;
|
||||
session: EngineVariantSession<Project>;
|
||||
}
|
||||
|
||||
export type WebEngineRefreshRequired = Extract<WebEngineReleaseBinding, { status: "REFRESH_REQUIRED" }>;
|
||||
|
||||
export type WebEngineReleaseBootstrapOutcome<Project = ArrayBuffer> =
|
||||
| EngineVariantBootstrapOutcome<Project>
|
||||
| { result: WebEngineRefreshRequired; session: null };
|
||||
|
||||
export interface EngineVariantBootstrapDependencies<Project = ArrayBuffer> {
|
||||
initialize(variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>>;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapTestHooks {
|
||||
failPthreadAfterInitialize?: boolean;
|
||||
seedTrackedPthreadResources?: boolean;
|
||||
}
|
||||
|
||||
export class EngineVariantLoadError extends Error {
|
||||
readonly code: "ENGINE_VARIANT_RESOURCE_REQUEST_FAILED" | "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH";
|
||||
|
||||
constructor(code: EngineVariantLoadError["code"], message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "EngineVariantLoadError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class EngineVariantBootstrapError extends Error {
|
||||
readonly code: "ENGINE_VARIANT_INTEGRITY_FAILED";
|
||||
readonly attempted: Array<"single" | "pthread">;
|
||||
|
||||
constructor(attempted: Array<"single" | "pthread">, cause: EngineVariantLoadError) {
|
||||
super(`${cause.code}: ${cause.message}`, { cause });
|
||||
this.name = "EngineVariantBootstrapError";
|
||||
this.code = "ENGINE_VARIANT_INTEGRITY_FAILED";
|
||||
this.attempted = [...attempted];
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : "pthread engine initialization failed";
|
||||
}
|
||||
|
||||
function rejectIntegrityFailure(
|
||||
error: unknown,
|
||||
attempted: Array<"single" | "pthread">,
|
||||
): void {
|
||||
if (error instanceof EngineVariantLoadError && error.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH") {
|
||||
throw new EngineVariantBootstrapError(attempted, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapSelectedWebEngineVariant<Project = ArrayBuffer>(
|
||||
manifest: WebEngineManifestV2,
|
||||
policy: WebEngineVariantPolicy,
|
||||
capabilities: WasmThreadingCapabilities,
|
||||
dependencies: EngineVariantBootstrapDependencies<Project>,
|
||||
pendingProject?: Project,
|
||||
testHooks?: EngineVariantBootstrapTestHooks,
|
||||
): Promise<EngineVariantBootstrapOutcome<Project>> {
|
||||
const selection = selectWebEngineVariant(manifest, policy, capabilities);
|
||||
if (!selection.selectedVariant) throw new Error("PTHREAD_REQUIRED_CAPABILITY_BLOCKED");
|
||||
|
||||
const attempted: Array<"single" | "pthread"> = [];
|
||||
let failedAttemptCleanup: EngineVariantResourceState | null = null;
|
||||
let fallbackReason: EngineVariantFallbackReason | null = null;
|
||||
let session: EngineVariantSession<Project> | null = null;
|
||||
|
||||
const initialize = async (variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>> => {
|
||||
attempted.push(variant.id);
|
||||
return dependencies.initialize(variant);
|
||||
};
|
||||
|
||||
try {
|
||||
session = await initialize(selection.selectedVariant);
|
||||
if (selection.selectedVariant.id === "pthread") {
|
||||
if (testHooks?.seedTrackedPthreadResources) session.testOnlySeedTrackedResources?.();
|
||||
if (testHooks?.failPthreadAfterInitialize) throw new Error("TEST_PTHREAD_INITIALIZATION_FAILURE");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const failedSession = session;
|
||||
if (failedSession) failedAttemptCleanup = await failedSession.dispose();
|
||||
rejectIntegrityFailure(error, attempted);
|
||||
if (policy !== "AUTO" || selection.selectedVariant.id !== "pthread") throw error;
|
||||
fallbackReason = { code: "PTHREAD_INITIALIZATION_FAILED", message: failureMessage(error) };
|
||||
const single = manifest.variants.find((variant) => variant.id === "single");
|
||||
if (!single) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING", { cause: error });
|
||||
try {
|
||||
session = await initialize(single);
|
||||
}
|
||||
catch (fallbackError) {
|
||||
rejectIntegrityFailure(fallbackError, attempted);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) throw new Error("ENGINE_VARIANT_INITIALIZATION_FAILED");
|
||||
let openCount = 0;
|
||||
if (pendingProject !== undefined) {
|
||||
try {
|
||||
await session.openProject(pendingProject);
|
||||
openCount = 1;
|
||||
}
|
||||
catch (error) {
|
||||
await session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
status: "READY",
|
||||
selected: session.variant.id,
|
||||
attempted,
|
||||
fallbackReason,
|
||||
pthreadGate: selection.pthreadGate,
|
||||
openCount,
|
||||
failedAttemptCleanup,
|
||||
},
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bootstrapWebEngineRelease<Project = ArrayBuffer>(
|
||||
expectedReleaseId: string,
|
||||
manifest: WebEngineManifestV2,
|
||||
policy: WebEngineVariantPolicy,
|
||||
capabilities: WasmThreadingCapabilities,
|
||||
dependencies: EngineVariantBootstrapDependencies<Project>,
|
||||
pendingProject?: Project,
|
||||
testHooks?: EngineVariantBootstrapTestHooks,
|
||||
): Promise<WebEngineReleaseBootstrapOutcome<Project>> {
|
||||
const binding = bindWebEngineRelease(expectedReleaseId, manifest);
|
||||
if (binding.status === "REFRESH_REQUIRED") {
|
||||
return { result: binding, session: null };
|
||||
}
|
||||
return bootstrapSelectedWebEngineVariant(
|
||||
binding.manifest,
|
||||
policy,
|
||||
capabilities,
|
||||
dependencies,
|
||||
pendingProject,
|
||||
testHooks,
|
||||
);
|
||||
}
|
||||
|
||||
export function engineVariantStatusLabel(result: EngineVariantBootstrapResult): string {
|
||||
if (result.selected === "pthread") return "Runtime: pthread";
|
||||
return result.fallbackReason
|
||||
? `Runtime: single (pthread fallback: ${result.fallbackReason.code})`
|
||||
: "Runtime: single";
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
|
||||
export interface WasmThreadingCapabilities {
|
||||
crossOriginIsolated: boolean;
|
||||
sharedArrayBuffer: boolean;
|
||||
worker: boolean;
|
||||
}
|
||||
import {
|
||||
type WasmThreadingCapabilities,
|
||||
} from "../../../protocol/engine-variant";
|
||||
export { gateWasmThreadingCapability, selectWebEngineVariant } from "../../../protocol/engine-variant";
|
||||
export type { WasmThreadingCapabilities } from "../../../protocol/engine-variant";
|
||||
|
||||
export interface BrowserCapabilities extends WasmThreadingCapabilities {
|
||||
webgl2: boolean;
|
||||
@@ -17,34 +15,6 @@ export interface BrowserCapabilities extends WasmThreadingCapabilities {
|
||||
indexedDb: boolean;
|
||||
}
|
||||
|
||||
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
|
||||
const issues = [];
|
||||
if (!capabilities.crossOriginIsolated) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"Cross-origin isolation is required for the pthread WASM engine",
|
||||
"crossOriginIsolated",
|
||||
));
|
||||
}
|
||||
if (!capabilities.sharedArrayBuffer) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"SharedArrayBuffer is required for the pthread WASM engine",
|
||||
"sharedArrayBuffer",
|
||||
));
|
||||
}
|
||||
if (!capabilities.worker) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"Worker is required for the pthread WASM engine",
|
||||
"worker",
|
||||
));
|
||||
}
|
||||
return issues.length > 0
|
||||
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
|
||||
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
|
||||
}
|
||||
|
||||
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
|
||||
if (typeof WebAssembly === "undefined") return false;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export class StorageClient {
|
||||
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> {
|
||||
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
|
||||
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 const OPFS_PROJECT_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface OpfsProjectLayout {
|
||||
projectId: string;
|
||||
@@ -52,7 +53,7 @@ type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDir
|
||||
export type ProjectSaveFault = "after-stage" | "after-scene-commit";
|
||||
|
||||
export interface ProjectBlendManifest {
|
||||
schemaVersion: 1;
|
||||
schemaVersion: typeof OPFS_PROJECT_SCHEMA_VERSION;
|
||||
projectId: string;
|
||||
revision: number;
|
||||
bytes: number;
|
||||
@@ -140,7 +141,7 @@ async function verifyFile(directory: FileSystemDirectoryHandle, name: string, by
|
||||
}
|
||||
|
||||
function validateManifest(value: ProjectBlendManifest | undefined, projectId: string): ProjectBlendManifest | undefined {
|
||||
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
|
||||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || 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") {
|
||||
@@ -150,7 +151,7 @@ function validateManifest(value: ProjectBlendManifest | undefined, projectId: st
|
||||
}
|
||||
|
||||
function validateJournal(value: ProjectBlendJournal | undefined, projectId: string): ProjectBlendJournal {
|
||||
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
|
||||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || 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" ||
|
||||
@@ -176,7 +177,7 @@ async function finalizeBlendCommit(
|
||||
journal: ProjectBlendJournal,
|
||||
): Promise<ProjectBlendManifest> {
|
||||
const manifest: ProjectBlendManifest = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
|
||||
projectId: journal.projectId,
|
||||
revision: journal.revision,
|
||||
bytes: journal.bytes,
|
||||
@@ -266,7 +267,7 @@ export async function writeProjectBlend(
|
||||
const stageName = `scene.blend.${crypto.randomUUID()}.stage`;
|
||||
const sha256 = await sha256Hex(data);
|
||||
const journal: ProjectBlendJournal = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
|
||||
projectId,
|
||||
revision,
|
||||
bytes: data.byteLength,
|
||||
|
||||
@@ -199,12 +199,12 @@ async function ensureProject(projectId: string): Promise<StorageProjectResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "quota"): Promise<StorageSaveResult> {
|
||||
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "before-metadata-commit" | "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);
|
||||
const existing = faultAt ? undefined : await readProjectRow(projectId);
|
||||
const existing = await readProjectRow(projectId);
|
||||
if (existing && existing.revision > revision) {
|
||||
throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`);
|
||||
}
|
||||
@@ -232,8 +232,24 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
|
||||
};
|
||||
}
|
||||
if (useOpfs) {
|
||||
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
|
||||
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
|
||||
const previousBuffer = faultAt && existing ? await readProjectBlend(projectId) : undefined;
|
||||
try {
|
||||
const opfsFault = faultAt === "after-stage" || faultAt === "after-scene-commit" ? faultAt : undefined;
|
||||
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, opfsFault);
|
||||
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
|
||||
if (faultAt === "before-metadata-commit") {
|
||||
throw new Error("PROJECT_SAVE_FAULT_INJECTED: before-metadata-commit");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (faultAt && existing && previousBuffer) {
|
||||
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
|
||||
if (rollback.manifest.sha256 !== existing.sha256) {
|
||||
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
else if (faultAt) {
|
||||
throw new Error("PROJECT_SAVE_FAULT_UNAVAILABLE: IndexedDB commits are already transactional");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -95,6 +95,21 @@ let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
|
||||
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
|
||||
let sourceBlendBuffer: ArrayBuffer | null = null;
|
||||
let sourceBlendRevision = -1;
|
||||
const cancelledOpenRequests = new Set<string>();
|
||||
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
|
||||
|
||||
function openResourceStatus(): WebEngineOpenResourceStatus {
|
||||
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
|
||||
activeRequests: status.activeRequests + 1,
|
||||
liveInputBytes: status.liveInputBytes + item.liveInputBytes,
|
||||
liveNativeHandles: status.liveNativeHandles + item.liveNativeHandles,
|
||||
liveStagingFiles: status.liveStagingFiles + item.liveStagingFiles,
|
||||
}), { activeRequests: 0, liveInputBytes: 0, liveNativeHandles: 0, liveStagingFiles: 0 });
|
||||
}
|
||||
|
||||
function throwIfOpenCancelled(requestId: string): void {
|
||||
if (cancelledOpenRequests.has(requestId)) throw report("OPEN_CANCELLED", "Blend open cancelled before Main commit");
|
||||
}
|
||||
|
||||
function status(): WebEngineStatus {
|
||||
return {
|
||||
@@ -470,6 +485,15 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
|
||||
return report(mappedCode, message);
|
||||
}
|
||||
|
||||
function nativeErrorFor(target: WasmModule, fallbackCode: ErrorReport["code"]): ErrorReport {
|
||||
const code = target._web_engine_last_error_code() ?? 0;
|
||||
const message = target._web_engine_last_error_message() !== 0
|
||||
? target.UTF8ToString(target._web_engine_last_error_message())
|
||||
: "WebEngine native call failed";
|
||||
const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : fallbackCode;
|
||||
return report(mappedCode, message);
|
||||
}
|
||||
|
||||
async function initialize(): Promise<WebEngineStatus> {
|
||||
if (module) return status();
|
||||
if (!initializationPromise) {
|
||||
@@ -921,8 +945,8 @@ async function evaluateIsolatedDecimate(
|
||||
}
|
||||
}
|
||||
|
||||
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string): void {
|
||||
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message } });
|
||||
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string, stage?: string, cancellable?: boolean): void {
|
||||
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
|
||||
}
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
@@ -936,27 +960,74 @@ scope.onmessage = async (event) => {
|
||||
result = baseResult();
|
||||
break;
|
||||
case "openBlend": {
|
||||
progress(request.requestId, "started", 0, "Reading .blend");
|
||||
await initialize();
|
||||
const sourceCopy = request.command.buffer.slice(0);
|
||||
const input = copyIntoWasm(request.command.buffer);
|
||||
const resource = { liveInputBytes: request.command.buffer.byteLength, liveNativeHandles: 0, liveStagingFiles: 0 };
|
||||
activeOpenResources.set(request.requestId, resource);
|
||||
let candidateModule: WasmModule | null = null;
|
||||
let candidateHandle = 0;
|
||||
try {
|
||||
if (!module || module._web_engine_open_blend(handle, input.pointer, input.length) !== 0) {
|
||||
throw nativeError("BLEND_READ_FAILED");
|
||||
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
|
||||
await initialize();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
|
||||
candidateModule = await wasmFactory({ wasmBinary: wasmBinary.slice(0) });
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
candidateHandle = candidateModule._web_engine_create();
|
||||
if (candidateHandle <= 0) throw report("WASM_INIT_FAILED", "WebEngine isolated open handle creation failed");
|
||||
resource.liveNativeHandles = 1;
|
||||
const pointer = candidateModule._malloc(request.command.buffer.byteLength);
|
||||
if (!pointer) throw report("WASM_OUT_OF_MEMORY", "WebEngine could not allocate isolated blend input");
|
||||
try {
|
||||
candidateModule.HEAPU8.set(new Uint8Array(request.command.buffer), pointer);
|
||||
if (candidateModule._web_engine_open_blend(candidateHandle, pointer, request.command.buffer.byteLength) !== 0) {
|
||||
throw nativeErrorFor(candidateModule, "BLEND_READ_FAILED");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
candidateModule._free(pointer);
|
||||
resource.liveInputBytes = 0;
|
||||
}
|
||||
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
|
||||
const previousModule = module;
|
||||
const previousHandle = handle;
|
||||
module = candidateModule;
|
||||
handle = candidateHandle;
|
||||
try {
|
||||
progress(request.requestId, "progress", 0.8, "Building SceneIR", "COMMITTING", false);
|
||||
const scene = await readSnapshotResult();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
const sourceCopy = request.command.buffer.slice(0);
|
||||
currentSnapshot = scene.snapshot;
|
||||
sourceBlendBuffer = sourceCopy;
|
||||
sourceBlendRevision = scene.snapshot.revision;
|
||||
result = { status: status(), ...publishFullScene(scene) };
|
||||
}
|
||||
catch (error) {
|
||||
module = previousModule;
|
||||
handle = previousHandle;
|
||||
throw error;
|
||||
}
|
||||
previousModule?._web_engine_destroy(previousHandle);
|
||||
candidateHandle = 0;
|
||||
resource.liveNativeHandles = 0;
|
||||
progress(request.requestId, "completed", 1, "SceneIR ready", "COMPLETED", false);
|
||||
}
|
||||
finally {
|
||||
module?._free(input.pointer);
|
||||
if (candidateModule && candidateHandle > 0) candidateModule._web_engine_destroy(candidateHandle);
|
||||
activeOpenResources.delete(request.requestId);
|
||||
cancelledOpenRequests.delete(request.requestId);
|
||||
}
|
||||
progress(request.requestId, "progress", 0.8, "Building SceneIR");
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
sourceBlendBuffer = sourceCopy;
|
||||
sourceBlendRevision = scene.snapshot.revision;
|
||||
result = { status: status(), ...publishFullScene(scene) };
|
||||
progress(request.requestId, "completed", 1, "SceneIR ready");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "cancelOpen":
|
||||
if (activeOpenResources.has(request.command.targetRequestId)) cancelledOpenRequests.add(request.command.targetRequestId);
|
||||
result = { status: status(), openResources: openResourceStatus() };
|
||||
break;
|
||||
case "openResourceStatus":
|
||||
result = { status: status(), openResources: openResourceStatus() };
|
||||
break;
|
||||
case "snapshot":
|
||||
await initialize();
|
||||
{
|
||||
|
||||
@@ -11,6 +11,9 @@ const deploymentContract = JSON.parse(
|
||||
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
|
||||
) as { responseHeaders: { allResponses: Record<string, string> } };
|
||||
const isolationHeaders = deploymentContract.responseHeaders.allResponses;
|
||||
const runtimeEnvironment = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
|
||||
const unisolatedSingleThreadTest = runtimeEnvironment?.WEB_TEST_UNISOLATED_SINGLE_THREAD === "1";
|
||||
const developmentHeaders = unisolatedSingleThreadTest ? {} : isolationHeaders;
|
||||
|
||||
function preserveIsolationHeaders(): Plugin {
|
||||
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {
|
||||
@@ -26,8 +29,31 @@ function preserveIsolationHeaders(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function serveEngineVariantModuleImports(): Plugin {
|
||||
const vendorRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "public/vendor/blender");
|
||||
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string | number) => void }, next: () => void) => void) => void } }) => {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const url = new URL((request as { url?: string }).url ?? "/", "http://vite.local");
|
||||
const match = url.pathname.match(/^\/vendor\/blender\/(single|pthread)\/web_engine\.js$/);
|
||||
if (!match || !url.searchParams.has("import")) { next(); return; }
|
||||
const filePath = path.join(vendorRoot, match[1], "web_engine.js");
|
||||
if (!fs.existsSync(filePath)) { next(); return; }
|
||||
const stat = fs.statSync(filePath);
|
||||
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
response.setHeader("Content-Length", stat.size);
|
||||
for (const [name, value] of Object.entries(developmentHeaders)) response.setHeader(name, value);
|
||||
fs.createReadStream(filePath).pipe(response as never);
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: "serve-engine-variant-module-imports",
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
|
||||
function localVDBFixture(): Plugin {
|
||||
const resourceRoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb";
|
||||
const resourceRoot = runtimeEnvironment?.VDB_RESOURCE_ROOT ?? path.join(runtimeEnvironment?.HOME ?? "", "resource-library/blender-web-vdb");
|
||||
const files = new Map([
|
||||
["/__vdb_fixture__/manifest", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
|
||||
["/__vdb_fixture__/report", { path: path.join(resourceRoot, "reports/generated-smoke-conversion.json"), type: "application/json" }],
|
||||
@@ -70,18 +96,19 @@ function localVDBFixture(): Plugin {
|
||||
|
||||
export default defineConfig({
|
||||
root: "app",
|
||||
plugins: [preserveIsolationHeaders(), localVDBFixture(), react()],
|
||||
plugins: [
|
||||
serveEngineVariantModuleImports(),
|
||||
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
|
||||
localVDBFixture(),
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
headers: {
|
||||
...isolationHeaders,
|
||||
},
|
||||
headers: developmentHeaders,
|
||||
},
|
||||
preview: {
|
||||
headers: {
|
||||
...isolationHeaders,
|
||||
},
|
||||
headers: developmentHeaders,
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
|
||||
Reference in New Issue
Block a user