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