Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

View File

@@ -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"
}
}
}
]
}

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -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>
);
}

View File

@@ -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; }

View File

@@ -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();

View 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);
}

View 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";
}

View File

@@ -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;

View File

@@ -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>;
}

View File

@@ -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,

View File

@@ -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");

View File

@@ -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();
{

View File

@@ -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",