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:
@@ -15,6 +15,7 @@
|
||||
|
||||
## 移植铁律
|
||||
|
||||
- **OPFS 优先(用户铁律)。** 平台的工作过程——保存、重开、恢复、课程录制与自动测试——全部使用 OPFS;需要下载到本地时,必须由用户手动触发独立的“下载 .blend”入口,禁止在保存/编辑流程中自动下载。课程与测试不得依赖自动下载文件来重开工程。
|
||||
- **验证前置条件优先。** 验证时,先准备前置条件,之后再验证;前置条件准备也必须考虑依赖关系和先后顺序,以提高验证的准确性和效率。按依赖顺序准备构件与资源、运行环境、服务与接口、数据与场景;前置条件不满足时先补齐,不重复运行依赖它的下游验证。复用仍有效的前置检查结果,环境或依赖变化后仅重新检查受影响项;如实记录缺项、失败及验证范围。
|
||||
- **按合理的小批次推进。** 每批只处理一个明确业务缺口及其必要调用链;单次读源码≤30行、修改≤2文件且增删合计≤10行(含交接)。按实际字节控制提交提示、交接与工具输出,区分已统计内容与系统附加内容;断流/超量后缩小下一批,连续稳定交接后才有界放宽。达到预算先保留源码与continuationNotes(断点、已完成、残余、下一动作)再续接,禁止堆叠全量历史、盲目重发大包或靠删缺口缩量;不改变实现期禁止验证的规则。
|
||||
- **当前工作定位:用户明确“桌面 Blender → Web Blender 移植”基本完成,后续仅以补充具体功能缺口为主,不得重新开展整体移植或偏离主线。科学、高效率地充分复用已有源码、模块、适配接口、WASM 和应用产物,使操作者能使用全面完整的功能。**
|
||||
|
||||
66
docs/status/WEB_BLENDER_FULL_COURSE_20261113.md
Normal file
66
docs/status/WEB_BLENDER_FULL_COURSE_20261113.md
Normal file
@@ -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 或用户验收通过。
|
||||
BIN
tests/golden/UV-UI-20261113.png
Normal file
BIN
tests/golden/UV-UI-20261113.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
@@ -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