feat(web): enable UV editing in web UI and use OPFS save/reopen
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

- Forward workspace and mode to the native context in ?ui=web.
- Locate the writable native UV area; enter Edit explicitly and wait for
  project recovery locks before switching mode.
- Save projects to OPFS by default; add an explicit manual .blend download.
- Add UV E2E coverage and implementation status.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 04:40:13 +08:00
parent a27aa46d7c
commit 7267db7cb4
5 changed files with 222 additions and 19 deletions

View File

@@ -3992,6 +3992,8 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
useEffect(() => () => { nativeAbortRef.current?.abort(); localInvokeAbortRef.current?.abort(); }, []);
useEffect(() => () => { for (const file of nativeFiles) URL.revokeObjectURL(file.url); }, [nativeFiles]);
const [editorWorkflow, setEditorWorkflow] = useState<EditorWorkflowIR | null>(null);
const changeNativeModeRef = useRef<(mode: Extract<UICommand, { type: 'setMode' }>['mode']) => Promise<void>>(() => Promise.resolve());
const uvAutoEditAttemptRef = useRef('');
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(() => new Set());
const [meshSelection, setMeshSelection] = useState<MeshEditSelection>({ meshId: null, mode: "FACE", indices: new Set() });
const [greasePencilSelection, setGreasePencilSelection] = useState<GreasePencilSelectionStateIR | null>(null);
@@ -4101,15 +4103,28 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
URL.revokeObjectURL(url);
};
const dispatchUI = (command: UICommand) => {
if (localOnly && command.type === 'setMode') {
void changeNativeMode(command.mode);
if (command.type === 'setMode' && editorWorkflow) {
void changeNativeModeRef.current(command.mode);
setUIState((state) => reduceUICommand(state, command));
return;
}
if(command.type==='switchWorkspace'&&command.workspaceId==='UV Editing')uvAutoEditAttemptRef.current='';
if(command.type==='switchWorkspace'&&command.workspaceId!==uiState.context.workspaceId){notifyNativeModalCancel();if(!discardTextDraft())return;}
if (localOnly && command.type === 'switchWorkspace' && editorWorkflow) {
if (command.type === 'switchWorkspace' && editorWorkflow) {
const target = editorWorkflow.workspaces.find(item => item.name === command.workspaceId);
if (!target?.writable || !target.screenId) { setEngineStatus('Workspace context unavailable'); return; }
if (target.id !== editorWorkflow.context.workspaceId) {
if (localOnly && (!target?.writable || !target.screenId)) { setEngineStatus('Workspace context unavailable'); return; }
if (target?.writable && target.screenId && target.id !== editorWorkflow.context.workspaceId) {
if (!localOnly) {
// Keep the React workspace responsive even when the native context
// cannot follow (for example a read-only area); the editor surface
// reports its own unavailable state.
setUIState(state => reduceUICommand(state, command));
void applyEditCommand({type:'editEditorContext',baseRevision:snapshot!.revision,
edit:{action:'SWITCH_WORKSPACE',workspaceId:target.id,screenId:target.screenId}},
undefined,
message => setEngineStatus(`工作区切换失败:${message}`));
return;
}
void applyEditCommand({type:'editEditorContext',baseRevision:snapshot!.revision,
edit:{action:'SWITCH_WORKSPACE',workspaceId:target.id,screenId:target.screenId}},
() => setUIState(state => reduceUICommand(state, command)),
@@ -4729,12 +4744,13 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
setEngineStatus(`原生操作失败(上下文未确认):${recordDiagnostic("ENGINE", "COMMAND_FAILED", reason, { command: command.operator, recovery: "context" })}`);
}});
},[localOnly,nativeCanvasReady,localInvokePending]);
const changeNativeMode = (mode: Extract<UICommand, {type: 'setMode'}>['mode']): Promise<void> => {
const changeNativeMode = async (mode: Extract<UICommand, {type: 'setMode'}>['mode']): Promise<void> => {
const client = webClientRef.current;
if (!client || !snapshot) {
setEngineStatus('请先打开工程再切换模式。');
return Promise.resolve();
}
for(let attempt=0;attempt<300&&(projectActionMutexRef.current.owner||headerPending||nativeBusy||modeChangePendingRef.current||pythonState?.status==='running'||pythonState?.status==='stopping');attempt++)await new Promise<void>(resolve=>setTimeout(resolve,100));
if (modeChangePendingRef.current || headerPending || nativeBusy ||
pythonState?.status === 'running' || pythonState?.status === 'stopping' ||
projectActionMutexRef.current.owner) {
@@ -4812,6 +4828,18 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
editCommandTailRef.current = result;
return result;
};
changeNativeModeRef.current = changeNativeMode;
useEffect(() => {
if (localOnly || workspace !== 'UV Editing') { uvAutoEditAttemptRef.current = ''; return; }
if (!snapshot || !editorWorkflow) return;
const uv = editorWorkflow.workspaces.find(item => item.name === 'UV Editing');
if (!uv?.writable || !uv.screenId || uv.id !== editorWorkflow.context.workspaceId) return;
if (editorWorkflow.context.mode === 'EDIT') return;
const attempt = `${projectIdRef.current}:${snapshot.activeObjectId ?? ''}:${uv.id}`;
if (uvAutoEditAttemptRef.current === attempt) return;
uvAutoEditAttemptRef.current = attempt;
void changeNativeModeRef.current('Edit');
}, [localOnly, workspace, snapshot?.activeObjectId, editorWorkflow]);
timelinePumpRef.current = async (isCurrent) => {
const client = webClientRef.current;
if (!localOnly || !client || !snapshot) return false;
@@ -6601,6 +6629,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
};
const recoverCachedProject = async (targetProjectId = projectIdRef.current, displayName?: string, mode: 'open' | 'restore' | 'restart' = 'restore', snapshotRevision?: number, snapshotSha256?: string): Promise<boolean> => {
startupTemplateCancelled.current = true;
const client = webClientRef.current;
const storage = storageClientRef.current;
let sourceProjectId = projectIdRef.current;
@@ -6847,7 +6876,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
if (textDraftDirty.current) throw new Error('请先应用或放弃文本编辑器中尚未应用的修改,再保存副本。');
return client.withNativeFramesPaused(async () => { const bytes = await client.saveBlend(); if (client !== webClientRef.current || project !== projectIdRef.current) throw new Error('保存副本期间工程已改变'); return bytes; });
};
const saveBlend = async (fileName?: string, copyOnly = false): Promise<void> => {
const saveBlend = async (fileName?: string, copyOnly = false, download = true): Promise<void> => {
const displayName = (fileName ?? projectDisplayNameRef.current).trim() || 'blender-web.blend';
const downloadName = /\.blend$/i.test(displayName) ? displayName : `${displayName}.blend`;
const saveIdentity = nextUserActionIdentity(fileName === undefined ? "SAVE" : "SAVE_AS");
@@ -6870,6 +6899,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
setEngineStatus(recordDiagnostic("ENGINE", "BLEND_SAVE_FAILED", error));
}
if (!data) return;
if (!download) { succeedUserAction(saveIdentity); return; }
try {
const url = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
@@ -6886,7 +6916,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
}
} finally { releaseProjectActionLock(saveLockIdentity); if (copyOnly) setCopySaveEpoch(epoch => epoch + 1); }
};
saveBlendRef.current = saveBlend;
const saveProjectOpfs = async (fileName?: string): Promise<void> => {
await saveBlend(fileName, false, false);
};
saveBlendRef.current = saveProjectOpfs;
/* Course entry persists the default scene under a deterministic OPFS id so
* two tutorials never share a snapshot chain. It reuses the save transaction
* without creating a browser download. */
@@ -7125,7 +7158,8 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
};
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
{ id: "openBlendFile", label: "Open Blend File", keywords: "browser file open", execute: () => fileInputRef.current?.click() },
{ id: "saveBlend", label: "Save Blend File", keywords: "browser file save", execute: () => { void saveBlend(); } },
{ id: "saveBlend", label: "Save Blend File (OPFS)", keywords: "browser file save opfs", execute: () => { void saveProjectOpfs(); } },
{ id: "downloadBlend", label: "Download Blend File to Local", keywords: "browser file download blend local", execute: () => { void saveBlend(); } },
{ id: "saveRecoveryCheckpoint", label: "Save Browser Checkpoint", keywords: "browser autosave recovery checkpoint", execute: () => { void saveAutosaveCheckpoint(); } },
{ id: "recoverCachedProject", label: "Recover Browser Checkpoint", keywords: "browser restore autosave recovery", execute: showAutosaveRecovery },
{ id: "saveNativeAutoSave", label: "Save Native Autosave File", keywords: "native wm.save_auto_save", execute: () => requestNativeAutosaveOperator('wm.save_auto_save') },
@@ -7680,7 +7714,8 @@ else:
menuAction('打开',()=>fileInputRef.current?.click(),false,'Ctrl O'),
{label:'最近打开',children:recentProjects.length?recentProjects.map(project=>menuAction(project.displayName,()=>{void recoverCachedProject(project.projectId,project.displayName,'open');})): [unavailable('没有最近项目','保存项目后显示')]},
{label:'恢复',children:[menuAction('恢复自动保存文件',()=>requestNativeAutosaveOperator('wm.recover_auto_save')),menuAction('恢复浏览器恢复点',showAutosaveRecovery),menuAction('恢复上一次会话',()=>requestNativeAutosaveOperator('wm.recover_last_session'))]},separator,
menuAction('保存',()=>{void saveBlend();},!hasProject,'Ctrl S'),
menuAction('保存到浏览器 OPFS',()=>{void saveProjectOpfs();},!hasProject,'Ctrl S'),
menuAction('下载 .blend 到本地',()=>{void saveBlend();},!hasProject),
menuAction('另存为',()=>{setHeaderValue(projectDisplayNameRef.current);setHeaderDialog('save-as');},!hasProject,'Shift Ctrl S'),
menuAction('保存副本',()=>{setHeaderValue('blender-web-copy.blend');setHeaderDialog('save-copy');},!hasProject),
separator,
@@ -7871,10 +7906,21 @@ else:
};
const selectNativeWorkspace=async(id:string)=>{
const target=editorWorkflow?.workspaces.find(item=>item.id===id);
if(!target?.writable||!target.screenId||!snapshot||!discardTextDraft())return;
await applyEditCommand({type:'editEditorContext',baseRevision:snapshot.revision,edit:{action:'SWITCH_WORKSPACE',workspaceId:id,screenId:target.screenId}}, () => {
if (BLENDER_WORKSPACE_IDS.includes(target.name as typeof BLENDER_WORKSPACE_IDS[number])) setUIState(state=>reduceUICommand(state,{type:'switchWorkspace',workspaceId:target.name as typeof BLENDER_WORKSPACE_IDS[number]}));
}, message => setEngineStatus(`工作区切换失败:${message}`));
const uiWorkspace=target&&BLENDER_WORKSPACE_IDS.includes(target.name as typeof BLENDER_WORKSPACE_IDS[number])?target.name as typeof BLENDER_WORKSPACE_IDS[number]:null;
if(uiWorkspace==='UV Editing')uvAutoEditAttemptRef.current='';
if(!localOnly&&uiWorkspace)setUIState(state=>reduceUICommand(state,{type:'switchWorkspace',workspaceId:uiWorkspace}));
if(!target){setEngineStatus('工作区切换失败:找不到目标工作区');return;}
if(!target.writable){setEngineStatus('工作区切换失败:目标工作区只读');return;}
if(!target.screenId){setEngineStatus('工作区切换失败:目标工作区没有可写 screen');return;}
if(!snapshot){setEngineStatus('工作区切换失败:工程尚未就绪');return;}
if(!discardTextDraft())return;
for(let attempt=0;attempt<300&&projectActionMutexRef.current.owner;attempt++)await new Promise<void>(resolve=>setTimeout(resolve,100));
if(projectActionMutexRef.current.owner){setEngineStatus('工程正在处理其他操作,请稍后再切换工作区。');return;}
let feedback='';
const applied=await applyEditCommand({type:'editEditorContext',baseRevision:snapshot.revision,edit:{action:'SWITCH_WORKSPACE',workspaceId:id,screenId:target.screenId}}, () => {
if (uiWorkspace) setUIState(state=>reduceUICommand(state,{type:'switchWorkspace',workspaceId:uiWorkspace}));
}, message => { feedback=message; setEngineStatus(`工作区切换失败:${message}`); });
if(!applied&&!localOnly&&!feedback)setEngineStatus('工作区原生上下文未切换;当前编辑器可能只读。');
};
const editNativeArea=async(area:EditorAreaIR,action:'SET_ACTIVE_AREA'|'SET_TEXT'|'SET_IMAGE',dataId?:string|null):Promise<boolean>=>{
try {
@@ -8010,7 +8056,7 @@ else:
const tutorialApi: TutorialApi = {
applyCommand: (command) => applyEditCommand(command),
runPython: runTutorialPython,
save: (fileName) => saveBlend(fileName),
save: (fileName) => saveProjectOpfs(fileName),
exportGLB: reportGLBExport,
saveCheckpoint: async () => {
const client = webClientRef.current;
@@ -8083,8 +8129,8 @@ else:
{editorWorkflow?editorWorkflow.workspaces.map(item=><button key={item.id} className={item.id===nativeWorkspace?.id?'workspace-tab active':'workspace-tab'} type="button" aria-current={item.id===nativeWorkspace?.id?'page':undefined} disabled={!item.writable} onClick={()=>void selectNativeWorkspace(item.id)}>{item.name}</button>):BLENDER_WORKSPACE_IDS.map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
<DesktopMenu className="workspace-menu-control" label="工作区列表" items={localOnly&&editorWorkflow?editorWorkflow.workspaces.map(item=>({label:item.name,checked:item.id===nativeWorkspace?.id,disabled:!item.writable,action:()=>void selectNativeWorkspace(item.id)})):BLENDER_WORKSPACE_IDS.map(id=>({label:id,checked:id===workspace,action:()=>dispatchUI({type:'switchWorkspace',workspaceId:id})}))}/>
</nav>
<div className="topbar-actions"><label title="恢复自动保存时允许执行 Blender 文件脚本"><input type="checkbox" aria-label="允许恢复文件脚本" checked={nativeRecoveryUseScripts} onChange={event=>setNativeRecoveryUseScripts(event.target.checked)}/>允许恢复脚本</label><button type="button" className="extension-robot" aria-label="六轴机器人工具" title="Web 扩展:六轴机器人" onClick={()=>setRobotToolOpen(true)}>Robot</button>{(snapshot?.scenes?.length??0)>1?<select className="topbar-context" aria-label="当前场景" title="当前场景" value={snapshot?.sceneId??''} onChange={(event)=>{const sceneId=event.target.value;if(sceneId)void applyEditCommand({type:"setScene",sceneId});}}>{(snapshot?.scenes??[]).map((scene)=><option key={scene.id} value={scene.id}>{scene.name}</option>)}</select>:<span className="topbar-context" title="当前场景">{activeSceneFromSnapshot(snapshot)?.name??"Scene"}</span>}<span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName, 'open'); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存项目" onClick={() => void saveBlend()}>▣</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { startupTemplateCancelled.current = true; const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} />
<div className="topbar-actions"><label title="恢复自动保存时允许执行 Blender 文件脚本"><input type="checkbox" aria-label="允许恢复文件脚本" checked={nativeRecoveryUseScripts} onChange={event=>setNativeRecoveryUseScripts(event.target.checked)}/>允许恢复脚本</label><button type="button" className="extension-robot" aria-label="六轴机器人工具" title="Web 扩展:六轴机器人" onClick={()=>setRobotToolOpen(true)}>Robot</button>{(snapshot?.scenes?.length??0)>1?<select className="topbar-context" aria-label="当前场景" title="当前场景" value={snapshot?.sceneId??''} onChange={(event)=>{const sceneId=event.target.value;if(sceneId)void applyEditCommand({type:"setScene",sceneId});}}>{(snapshot?.scenes??[]).map((scene)=><option key={scene.id} value={scene.id}>{scene.name}</option>)}</select>:<span className="topbar-context" title="当前场景">{activeSceneFromSnapshot(snapshot)?.name??"Scene"}</span>}<span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName, 'open'); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存到浏览器 OPFS" onClick={() => void saveProjectOpfs()}>▣</button><button type="button" aria-label="下载 .blend" title="手动下载 .blend 到本地" onClick={() => void saveBlend()}>⤓</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { startupTemplateCancelled.current = true; uvAutoEditAttemptRef.current = ''; const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} />
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING" || projectActionLocked}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
@@ -8278,11 +8324,11 @@ else:
{headerDialog==='history'&&historyView&&!historyView.sourceHash?<button type="button" disabled={headerBusy||historyView.history.cursor<=0} onClick={()=>{setHeaderDialog(null);void applyEditCommand({type:'undo'});}}>撤销上一步(恢复)</button>:null}
{headerDialog==='history'?<><div className="desktop-history-list" aria-busy={headerPending}>{historyView?.history.entries.map(entry=><button key={entry.index} aria-current={entry.index===historyView.history.cursor?'step':undefined} disabled={headerPending||Boolean(historyError)||!historyView.sourceHash||entry.index===historyView.history.cursor} onClick={()=>void jumpHistory(entry.index)}><span>{entry.index+1}. {entry.label}</span>{entry.index===historyView.history.cursor?<strong>当前</strong>:null}</button>)}</div>{(historyError||!historyView||!historyView.sourceHash)?<button disabled={headerPending} onClick={()=>void openHistory()}>重新读取历史</button>:null}{headerPending?<output>正在读取工程历史…</output>:null}{historyError?<output role="alert">{historyError}</output>:null}</>:null}
{headerDialog==='new'?<><p>{dirtyState.dirty?'当前工程有未保存修改。新建会替换当前场景。':'创建 Blender 默认常规场景。'}</p><div className="desktop-dialog-actions"><button onClick={()=>setHeaderDialog(null)}>取消</button><button onClick={()=>{setHeaderDialog(null);void newProject();}}>新建常规工程</button></div></>:null}
{isFileNameDialog||headerDialog==='rename'?<form onSubmit={event=>{event.preventDefault();const value=headerValue.trim();if(!value||headerPending)return;if(isFileNameDialog){setHeaderDialog(null);void saveBlend(value.toLowerCase().endsWith('.blend')?value:value+'.blend',headerDialog==='save-copy');}else void submitObjectRename(value);}}><label>{isFileNameDialog?'文件名':'对象名称'}<input aria-label={headerDialog==='save-copy'?'副本文件名':isFileNameDialog?'另存为文件名':'新对象名称'} required disabled={headerPending} maxLength={isFileNameDialog?128:63} value={headerValue} onChange={event=>setHeaderValue(event.target.value)}/></label>{isFileNameDialog?<p>文件由浏览器下载,存储位置由浏览器决定。</p>:null}<>{headerDialog==='rename' && renameError ? <output role="alert">{renameError}</output> : null}</><div className="desktop-dialog-actions"><button type="button" disabled={headerPending} onClick={()=>setHeaderDialog(null)}>取消</button><button type="submit" disabled={headerPending || (headerDialog==='rename' && !canRenameObject)}>{headerPending ? '正在重命名…' : '确认'}</button></div></form>:null}
{isFileNameDialog||headerDialog==='rename'?<form onSubmit={event=>{event.preventDefault();const value=headerValue.trim();if(!value||headerPending)return;if(isFileNameDialog){const fileName=value.toLowerCase().endsWith('.blend')?value:value+'.blend';const copy=headerDialog==='save-copy';setHeaderDialog(null);void(copy?saveBlend(fileName,true):saveProjectOpfs(fileName));}else void submitObjectRename(value);}}><label>{isFileNameDialog?'文件名':'对象名称'}<input aria-label={headerDialog==='save-copy'?'副本文件名':isFileNameDialog?'另存为文件名':'新对象名称'} required disabled={headerPending} maxLength={isFileNameDialog?128:63} value={headerValue} onChange={event=>setHeaderValue(event.target.value)}/></label>{isFileNameDialog?<p>另存为保存到浏览器 OPFS;保存副本会下载到本地。需要本地 .blend 时请使用“文件 → 下载 .blend 到本地”。</p>:null}<>{headerDialog==='rename' && renameError ? <output role="alert">{renameError}</output> : null}</><div className="desktop-dialog-actions"><button type="button" disabled={headerPending} onClick={()=>setHeaderDialog(null)}>取消</button><button type="submit" disabled={headerPending || (headerDialog==='rename' && !canRenameObject)}>{headerPending ? '正在重命名…' : '确认'}</button></div></form>:null}
{headerDialog==='preferences'?<><h3>Web 界面</h3><label><input type="checkbox" checked={showStatus} onChange={event=>setShowStatus(event.target.checked)}/>显示状态栏</label><label><input type="checkbox" checked={quickTools} onChange={event=>setQuickTools(event.target.checked)}/>显示 Web 快捷操作栏</label>{preferencesError?<output role="alert">{preferencesError}</output>:null}<button disabled={headerPending} onClick={()=>{void (async()=>{if(!hasProject)await newProject();if(!webClientRef.current)return;setHeaderDialog(null);openNativeEditor('PREFERENCES');})();}}>Blender 偏好设置</button></>:null}
{headerDialog==='about'?<><h3>Blender 5.2.0 LTS · Web</h3><p>当前工程使用本机 Blender 源码编译的 WebEngine 与原生工作流服务。菜单中的灰色命令尚未实现等价入口。</p><p>GLB 位于文件 → 导出。机器人及原生桌面会话位于窗口 → Web 扩展。</p><p>菜单对齐不代表桌面全部编辑器、偏好设置和功能已完全移植。</p></>:null}
</DesktopDialog>:null}
{robotToolOpen?<RobotTool client={webClientRef.current} snapshot={snapshot} onApplied={applyNativeScene} onSave={()=>saveBlend()} onClose={()=>setRobotToolOpen(false)} onStart={async()=>{
{robotToolOpen?<RobotTool client={webClientRef.current} snapshot={snapshot} onApplied={applyNativeScene} onSave={()=>saveProjectOpfs()} onClose={()=>setRobotToolOpen(false)} onStart={async()=>{
if(snapshot)return;
const response=await fetch('/robot-assets/abb-irb120/start-scene.blend');if(!response.ok)throw new Error('机器人起始工程缺失');
const opened = await openBlendFile(new File([await response.arrayBuffer()],'robot-project.blend'));

View File

@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
const GLB = path.resolve(HERE, "../../../re/猴头.glb");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudioAndMonkey(page: import("@playwright/test").Page): Promise<void> {
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 });
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
}
async function ensureEditMode(page: import("@playwright/test").Page): Promise<void> {
const status = page.locator('[data-testid="engine-status"]');
if ((await status.innerText()).includes("模式:Edit")) return;
try {
await expect(status).toContainText("模式:Edit", { timeout: 5_000 });
return;
}
catch { /* fall through to the explicit mode menu */ }
await page.locator(".mode-chip button").first().evaluate((element) => (element as HTMLElement).click());
const editMode = page.getByRole("menuitemcheckbox", { name: /编辑模式/ });
await editMode.waitFor({ timeout: 15_000 });
await editMode.evaluate((element) => (element as HTMLElement).click());
await expect(status).toContainText("模式:Edit", { timeout: 60_000 });
await page.keyboard.press("Escape");
}
async function openUVWorkspace(page: import("@playwright/test").Page): Promise<void> {
// The native desktop canvas can sit above the workspace tab at the same
// coordinates in headed Chromium; invoke the tab's DOM click directly.
await page.locator(".workspace-tabs button", { hasText: "UV Editing" }).evaluate((element) => (element as HTMLElement).click());
await expect(page.locator(".wasm-uv-editor")).toBeVisible({ timeout: 60_000 });
await expect(page.locator(".uv-controls")).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => page.locator('[data-testid="editor-workflow-context"]').innerText(), { timeout: 60_000 })
.toContain("UV Editing");
// Desktop Blender's UV editor requires OB_MODE_EDIT (space_image.cc);
// enter Edit explicitly rather than depending on a startup timing quirk.
await ensureEditMode(page);
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("原生 UV 选择");
}
async function selectAllAndUnwrap(page: import("@playwright/test").Page): Promise<void> {
await page.locator(".wasm-uv-editor button", { hasText: "全选 UV" }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("UV 选择已提交");
await page.locator(".wasm-uv-editor button", { hasText: "展开所选 UV" }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("展开 UV已提交");
}
test("UV Editing finds a writable native UV area and commits an unwrap", async ({ page }) => {
test.setTimeout(300_000);
await openStudioAndMonkey(page);
await openUVWorkspace(page);
await expect(page.locator('[data-testid="engine-status"]')).toContainText("模式:Edit", { timeout: 60_000 });
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await selectAllAndUnwrap(page);
const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
expect(after).toBeGreaterThan(before);
});
test("UV workspace survives save and reopen", async ({ page }) => {
test.setTimeout(360_000);
await openStudioAndMonkey(page);
await openUVWorkspace(page);
await selectAllAndUnwrap(page);
// Save through the OPFS project transaction (the download is a side effect
// of the same commit), then reload and reopen the recent OPFS project.
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
await openUVWorkspace(page);
await expect(page.locator(".uv-controls")).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("原生 UV 选择");
});