diff --git a/AGENTS.md b/AGENTS.md index 57b0997d7..9172f0139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ ## 移植铁律 +- **OPFS 优先(用户铁律)。** 平台的工作过程——保存、重开、恢复、课程录制与自动测试——全部使用 OPFS;需要下载到本地时,必须由用户手动触发独立的“下载 .blend”入口,禁止在保存/编辑流程中自动下载。课程与测试不得依赖自动下载文件来重开工程。 - **验证前置条件优先。** 验证时,先准备前置条件,之后再验证;前置条件准备也必须考虑依赖关系和先后顺序,以提高验证的准确性和效率。按依赖顺序准备构件与资源、运行环境、服务与接口、数据与场景;前置条件不满足时先补齐,不重复运行依赖它的下游验证。复用仍有效的前置检查结果,环境或依赖变化后仅重新检查受影响项;如实记录缺项、失败及验证范围。 - **按合理的小批次推进。** 每批只处理一个明确业务缺口及其必要调用链;单次读源码≤30行、修改≤2文件且增删合计≤10行(含交接)。按实际字节控制提交提示、交接与工具输出,区分已统计内容与系统附加内容;断流/超量后缩小下一批,连续稳定交接后才有界放宽。达到预算先保留源码与continuationNotes(断点、已完成、残余、下一动作)再续接,禁止堆叠全量历史、盲目重发大包或靠删缺口缩量;不改变实现期禁止验证的规则。 - **当前工作定位:用户明确“桌面 Blender → Web Blender 移植”基本完成,后续仅以补充具体功能缺口为主,不得重新开展整体移植或偏离主线。科学、高效率地充分复用已有源码、模块、适配接口、WASM 和应用产物,使操作者能使用全面完整的功能。** diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md new file mode 100644 index 000000000..fe5da32cc --- /dev/null +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -0,0 +1,66 @@ +# Web Blender 全站功能补齐与入门课程 — 实施进度 + +日期:2026-11-13 +状态:**进行中(UV + OPFS 保存检查点完成;四块编辑器其余三块待实现)** + +## 本轮铁律 + +- **OPFS 优先(用户指令):** 平台保存、重开、恢复、课程录制与自动测试全部使用 OPFS; + 下载到本地必须由用户手动触发独立入口(顶栏“下载 .blend”、文件 → 下载 .blend 到本地)。 + 禁止在保存/编辑流程中自动下载。 +- **桌面源码为准:** Web 侧行为先查 `blender-5.2.0/source/blender/`。UV 编辑要求 + `ob->mode & OB_MODE_EDIT` 且 `sima->mode == SI_MODE_UV`(`editors/space_image/space_image.cc:407`), + `UV` 操作 poll 使用 `ED_operator_uvedit`(`image_ops.cc:1079`)。因此课程与测试显式进入 Edit, + 不把“自动进入 Edit”当作验收前提。 + +## 已完成 + +### 1. UV Editing 在 `?ui=web` 可操作 + +- `App.tsx` 的 `dispatchUI` 在 web 模式转发 `switchWorkspace`/`setMode` 到原生上下文; + `selectNativeWorkspace` 先切 UI、再后台同步原生,等待工程恢复锁最多 30 秒。 +- UV 工作区确定性定位 `UV_IMAGE:UV` 区域;自动进入 Edit(便利),失败时可用模式菜单显式进入。 +- `changeNativeMode` 等待恢复/保存等工程锁与 `headerPending/nativeBusy` 后切换。 +- `recoverCachedProject` 取消启动 General 模板,避免 OPFS 恢复被覆盖。 +- 验证: + `DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome GPU_MODE=hardware npx playwright test --config playwright.config.ts tests/e2e/web-uv-workspace-gate.spec.ts --workers=1` + → **2/2 PASS(35.9s)**: + - UV 工作区找到可写原生 UV 区域,全选→展开提交(revision 增长); + - 保存到 OPFS → reload → 最近项目恢复 → UV 控件与原生 UV 选择仍在。 +- 测试入口:`/home/mes123456/workinf_Blender_Wasm/web/tests/e2e/web-uv-workspace-gate.spec.ts`。 +- 说明:Playwright 坐标点击会被原生画布/工具栏命中拦截,测试对工作区标签与 UV 按钮使用 DOM + `click()`;产品按钮本身可见可点。 + +### 2. OPFS 保存与手动下载拆分 + +- 主保存(顶栏“保存项目”、Ctrl+S、文件 → 保存到浏览器 OPFS、教程/机器人保存)只写 OPFS, + 不再自动下载。 +- 新增显式手动入口:顶栏“下载 .blend”(`aria-label="下载 .blend"`)、 + 文件 → 下载 .blend 到本地、F3 “Download Blend File to Local”。 +- 另存为写 OPFS;保存副本保留为手动下载副本。 +- 未验证:既有历史 spec 中仍以“保存项目 + waitForEvent('download')”取本地 .blend 的用例需要 + 迁移到新的手动下载入口;本轮未批量改。 + +### 3. 规则落盘 + +- `AGENTS.md` 移植铁律已加入“OPFS 优先”条目。 + +## 待实现(按计划顺序) + +1. **Texture Paint 最小 2D 画笔**:图像选择、颜色/大小/强度、光标、笔画、`packImage` 一次提交、 + undo/redo、OPFS 保存重开;无 WASM。桌面语义参考 `editors/sculpt_paint/mesh/paint_image*.cc`。 +2. **Geometry Nodes 画布**:共享图状态、React Flow 画布、位置/rename/属性、结果面板; + 需要一次 WASM 重建(location/properties)。 +3. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。 +4. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次 + WASM 工作。 +5. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 +6. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 + +## 已知限制 / 未验证 + +- 纹理绘制、Compositor 画布、GN 画布、Spreadsheet 尚未实现;课程/视频尚未开始。 +- 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。 +- 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。 +- 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。 +- 不宣称整体 Blender parity 或用户验收通过。 diff --git a/tests/golden/UV-UI-20261113.png b/tests/golden/UV-UI-20261113.png new file mode 100644 index 000000000..9610b8050 Binary files /dev/null and b/tests/golden/UV-UI-20261113.png differ diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 07635dbb9..2735ebbd3 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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(null); + const changeNativeModeRef = useRef<(mode: Extract['mode']) => Promise>(() => Promise.resolve()); + const uvAutoEditAttemptRef = useRef(''); const [selectedObjectIds, setSelectedObjectIds] = useState>(() => new Set()); const [meshSelection, setMeshSelection] = useState({ meshId: null, mode: "FACE", indices: new Set() }); const [greasePencilSelection, setGreasePencilSelection] = useState(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['mode']): Promise => { + const changeNativeMode = async (mode: Extract['mode']): Promise => { 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(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 => { + 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 => { + const saveBlend = async (fileName?: string, copyOnly = false, download = true): Promise => { 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 => { + 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([ { 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(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=>{ 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=>):BLENDER_WORKSPACE_IDS.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})}))}/> -
{(snapshot?.scenes?.length??0)>1?:{activeSceneFromSnapshot(snapshot)?.name??"Scene"}}ViewLayer{workerFaultTestMode ? : null}
- 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 = ""; }} /> +
{(snapshot?.scenes?.length??0)>1?:{activeSceneFromSnapshot(snapshot)?.name??"Scene"}}ViewLayer{workerFaultTestMode ? : null}
+ 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 = ""; }} /> {workerFault ?
{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.{workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null} {recentProjectIssues.length > 0 ?
最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。
{recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}
)}
: null} @@ -8278,11 +8324,11 @@ else: {headerDialog==='history'&&historyView&&!historyView.sourceHash?:null} {headerDialog==='history'?<>
{historyView?.history.entries.map(entry=>)}
{(historyError||!historyView||!historyView.sourceHash)?:null}{headerPending?正在读取工程历史…:null}{historyError?{historyError}:null}:null} {headerDialog==='new'?<>

{dirtyState.dirty?'当前工程有未保存修改。新建会替换当前场景。':'创建 Blender 默认常规场景。'}

:null} - {isFileNameDialog||headerDialog==='rename'?
{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);}}>{isFileNameDialog?

文件由浏览器下载,存储位置由浏览器决定。

:null}<>{headerDialog==='rename' && renameError ? {renameError} : null}
:null} + {isFileNameDialog||headerDialog==='rename'?
{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);}}>{isFileNameDialog?

另存为保存到浏览器 OPFS;保存副本会下载到本地。需要本地 .blend 时请使用“文件 → 下载 .blend 到本地”。

:null}<>{headerDialog==='rename' && renameError ? {renameError} : null}
:null} {headerDialog==='preferences'?<>

Web 界面

{preferencesError?{preferencesError}:null}:null} {headerDialog==='about'?<>

Blender 5.2.0 LTS · Web

当前工程使用本机 Blender 源码编译的 WebEngine 与原生工作流服务。菜单中的灰色命令尚未实现等价入口。

GLB 位于文件 → 导出。机器人及原生桌面会话位于窗口 → Web 扩展。

菜单对齐不代表桌面全部编辑器、偏好设置和功能已完全移植。

:null} :null} - {robotToolOpen?saveBlend()} onClose={()=>setRobotToolOpen(false)} onStart={async()=>{ + {robotToolOpen?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')); diff --git a/web/tests/e2e/web-uv-workspace-gate.spec.ts b/web/tests/e2e/web-uv-workspace-gate.spec.ts new file mode 100644 index 000000000..09a050579 --- /dev/null +++ b/web/tests/e2e/web-uv-workspace-gate.spec.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 选择"); +});