feat(web): enable UV editing in web UI and use OPFS save/reopen
- 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:
@@ -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'));
|
||||
|
||||
90
web/tests/e2e/web-uv-workspace-gate.spec.ts
Normal file
90
web/tests/e2e/web-uv-workspace-gate.spec.ts
Normal 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 选择");
|
||||
});
|
||||
Reference in New Issue
Block a user