Align web editor with Blender 5.2 interface
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 18:11:57 +08:00
parent 70721187c0
commit 98cebd381a
6 changed files with 154 additions and 83 deletions

View File

@@ -514,7 +514,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8",
"observedSha256": "41279de5f12071d914a185c0813f537b8db85ae229f9ce61724fa36cac7cdf31",
"observedSha256": "a9c6e52bbb062506b42f78c9b71876544e2ea38305679228d41849c105db76ac",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{
@@ -578,7 +578,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4",
"observedSha256": "41279de5f12071d914a185c0813f537b8db85ae229f9ce61724fa36cac7cdf31",
"observedSha256": "a9c6e52bbb062506b42f78c9b71876544e2ea38305679228d41849c105db76ac",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{

View File

@@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202124" />
<meta name="theme-color" content="#181818" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Web Blender Modeler V1</title>
<title>Blender 5.2.0 LTS - Web</title>
<link rel="preload" as="fetch" type="application/wasm" crossorigin href="/vendor/blender/single/web_engine.wasm" />
<link rel="stylesheet" href="/src/app/app-shell.css" />
</head>

View File

@@ -82,7 +82,7 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand } from "../../../protocol/ui-schema";
import { parseEditorWorkflow, type EditorWorkflowIR } from "../../../protocol/editor-workflow";
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import";
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
@@ -138,7 +138,7 @@ function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult
["媒体", budget.mediaBytes],
["VDB", budget.vdbBytes],
] as const : [];
return <section className="storage-budget-panel" data-testid="storage-budget-panel" data-project-id={budget?.projectId ?? ""} data-total-bytes={budget?.totalBytes ?? 0} aria-label="存储预算"><strong>存储预算</strong>{values.map(([label, bytes]) => <span key={label} data-category={label} data-bytes={bytes}>{label} {formatStorageBytes(bytes)}</span>)}<output>合计 {formatStorageBytes(budget?.totalBytes ?? 0)}</output><button type="button" data-testid="cleanup-project-assets" aria-label="清理项目孤儿资源" disabled={!budget?.projectId || budget.projectId === "untitled"} onClick={onCleanup}>清理孤儿资源</button></section>;
return <section className="storage-budget-panel" data-testid="storage-budget-panel" data-project-id={budget?.projectId ?? ""} data-total-bytes={budget?.totalBytes ?? 0} aria-label="存储预算"><strong>Storage</strong>{values.map(([label, bytes]) => <span key={label} data-category={label} data-bytes={bytes}>{label} {formatStorageBytes(bytes)}</span>)}<output>{formatStorageBytes(budget?.totalBytes ?? 0)}</output><button type="button" data-testid="cleanup-project-assets" aria-label="清理项目孤儿资源" title="清理项目孤儿资源" disabled={!budget?.projectId || budget.projectId === "untitled"} onClick={onCleanup}>⌫</button></section>;
}
interface OperatorPaletteCommand {
@@ -744,10 +744,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<div className="viewport-placeholder" role="region" aria-label="Three.js 视口区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="axis-gizmo" aria-hidden="true"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span></div>
{viewportError || !snapshot || snapshot.nodes.length === 0 ? <div className="viewport-message">
<strong>3D Viewport</strong>
{viewportError ? <span>WebGL 不可用,已保留场景编辑界面:{viewportError}</span> : <span>Three.js WebGL2 适配器</span>}
</div> : null}
{viewportError ? <div className="viewport-message"><strong>3D Viewport</strong><span>WebGL 不可用,已保留场景编辑界面:{viewportError}</span></div> : null}
{editMode && activeTool === "marquee" && marqueeDrawing ? <div className="grease-pencil-marquee-surface" data-testid="grease-pencil-marquee-surface" data-drawing-id={marqueeDrawing.drawingId} onPointerDown={beginMarquee} onPointerMove={moveMarquee} onPointerUp={finishMarquee} onPointerCancel={finishMarquee}>{marqueeBox ? <span className="grease-pencil-marquee-box" style={{ left: `${marqueeBox.left * 100}%`, top: `${marqueeBox.top * 100}%`, width: `${(marqueeBox.right - marqueeBox.left) * 100}%`, height: `${(marqueeBox.bottom - marqueeBox.top) * 100}%` }} /> : null}</div> : null}
<div className="viewport-toolbar" aria-label="视口工具">
<button type="button" className={`tool-button${activeTool === "marquee" ? " active" : ""}`} aria-label="Grease Pencil 框选工具" title="框选" disabled={!editMode || !marqueeDrawing} onClick={() => setActiveTool("marquee")}>□</button>
@@ -1461,9 +1458,9 @@ function Properties({ client, snapshot, editorWorkflow, selectedFaceIndices, sel
return (
<div className="properties-content">
<div className="property-tabs" role="tablist" aria-label="属性标签">
<button className="property-tab active" type="button" role="tab" aria-selected="true">对象</button>
<button className="property-tab" type="button" role="tab" aria-selected="false">修改器</button>
<button className="property-tab" type="button" role="tab" aria-selected="false">材质</button>
<button className="property-tab active" type="button" role="tab" aria-selected="true" title="对象属性"><span aria-hidden="true">▣</span><span className="property-tab-label">对象</span></button>
<button className="property-tab" type="button" role="tab" aria-selected="false" title="修改器属性"><span aria-hidden="true">⌕</span><span className="property-tab-label">修改器</span></button>
<button className="property-tab" type="button" role="tab" aria-selected="false" title="材质属性"><span aria-hidden="true">●</span><span className="property-tab-label">材质</span></button>
</div>
<div className="property-section"><h3>Transform</h3><label>位置 <output>0.000, 0.000, 0.000</output></label><label>旋转 <output>0°, 0°, 0°</output></label><label>缩放 <output>1.000, 1.000, 1.000</output></label></div>
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label>名称 <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}>重命名</button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}>应用变换</button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}>原点到几何体</button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">选择...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
@@ -3463,7 +3460,7 @@ export function App() {
...(snapshot?.activeObjectId ? [{ id: "selectObject", label: "Select Active Object", keywords: "browser selection outliner", execute: () => selectObject(snapshot.activeObjectId!, false) }] : []),
{ id: "focusViewport", label: "Focus 3D Viewport", keywords: "browser view zoom pan orbit", execute: () => dispatchUI({ type: "setMode", mode: "Object" }) },
{ id: "focusEditor", label: "Focus Current Editor", keywords: "browser editor area region", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode }) },
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
...BLENDER_WORKSPACE_IDS.filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([
["CUBE", "Cube"], ["PLANE", "Plane"], ["UV_SPHERE", "UV Sphere"], ["ICO_SPHERE", "Ico Sphere"],
@@ -3513,17 +3510,17 @@ export function App() {
data-grease-pencil-selection-drawing-id={greasePencilSelection?.drawing.drawingId ?? ""}
data-diagnostic-count={diagnostics.length} data-last-diagnostic-code={diagnostics.at(-1)?.code}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true">◈</span><span>Web Blender Modeler V1</span></div>
<div className="brand" title="Blender 5.2.0 LTS Web"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span className="brand-name">Blender</span></div>
<nav className="menu-bar" aria-label="主菜单">{(["文件", "编辑", "渲染", "窗口", "帮助"] as const).map((menu) => <span key={menu} className="menu-entry"><button ref={(element) => { menuTriggerRefs.current[menu] = element; }} type="button" aria-haspopup="menu" aria-expanded={uiState.openMenu === menu} onClick={() => dispatchUI({ type: "toggleMenu", menu })}>{menu}</button>{uiState.openMenu === menu ? <MenuPopover menu={menu} labels={menu === "文件" ? ["打开", "保存", "关闭"] : menu === "编辑" ? ["撤销", "重做"] : menu === "渲染" ? ["导出 GLB"] : ["关闭菜单"]} onClose={closeMenu} onSelect={(label) => { closeMenu(); if (label === "打开") fileInputRef.current?.click(); else if (label === "保存") void saveBlend(); else if (label === "关闭") closeProject(); else if (label === "撤销") void applyEditCommand({ type: "undo" }); else if (label === "重做") void applyEditCommand({ type: "redo" }); else if (label === "导出 GLB") void reportGLBExport(); }} /> : null}</span>)}</nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</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>)}
<button className="workspace-add" type="button" aria-label="添加工作区" title="添加工作区">+</button>
</nav>
<div className="topbar-actions"><select aria-label="最近项目" 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); event.currentTarget.value = ""; }}><option value="">最近项目 ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}>打开</button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}>关闭</button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}>恢复</button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}>保存</button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>诊断</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" 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>
<div className="topbar-actions"><span className="topbar-context" title="当前场景">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); 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) { 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) void importSceneFile(file, route.format); else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { 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"}>{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}
<StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} />
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
@@ -3532,7 +3529,7 @@ export function App() {
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, route) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; const routeLabel = (route as unknown as { identifier?: string; id?: string }).identifier ?? (route as unknown as { id?: string }).id ?? "unknown"; setEngineStatus(`Route: ${routeLabel}`); }} /> : null}
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span className="status-hint">LMB Select · MMB Rotate View · Wheel Zoom</span><span data-testid="scene-stats">Collection | Cube · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span className="runtime-status">{manifestStatus}</span><span className="runtime-status">{wasmStatus}</span><span className="runtime-status" data-testid="engine-status">{engineStatus}</span><span className="runtime-status">{storageStatus}</span><StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} /><span className="blender-version">5.2.0 LTS</span></footer>
</main>
);
}

View File

@@ -1,102 +1,129 @@
:root {
color: #e5e7eb;
background: #18191c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 13px;
color: #d9d9d9;
background: #181818;
font-family: "Noto Sans", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 12px;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
button, input { font: inherit; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; background: #181818; }
button, input, select, textarea { font: inherit; letter-spacing: 0; }
button { color: inherit; border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #77b9f4; outline-offset: -2px; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 34px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; }
.topbar { gap: 14px; }
.brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; }
.brand-mark { color: #e37a2c; font-size: 18px; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }.menu-entry { position: relative; display: inline-flex; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
.workspace-tabs { align-self: stretch; gap: 0; }
.workspace-tab { min-width: 82px; border-bottom: 2px solid transparent; border-radius: 0; }
.workspace-tab.active { color: #fff; background: #313338; border-bottom-color: #e37a2c; }
.topbar-actions { margin-left: auto; }
.workspace-toolbar { min-width: 0; overflow-x: auto; color: #aeb2bb; border-bottom-color: #111214; white-space: nowrap; scrollbar-width: thin; }
.mode-chip { padding: 3px 8px; color: #dedfe2; background: #35373c; border-radius: 3px; }
.segmented { display: inline-flex; flex: none; border: 1px solid #4a4d54; border-radius: 3px; overflow: hidden; }.segmented button { border-radius: 0; border-right: 1px solid #4a4d54; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #a55325; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 28px 30px minmax(0, 1fr) 20px; width: 100%; height: 100vh; min-height: 480px; background: #202020; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; min-width: 0; background: #1d1d1d; border-bottom: 1px solid #0f0f0f; }
.topbar { gap: 2px; padding: 0 5px; overflow: hidden; box-shadow: inset 0 -1px #303030; }
.brand { display: flex; align-items: center; flex: none; gap: 5px; height: 100%; padding: 0 4px 0 2px; color: #f2f2f2; font-weight: 600; }
.brand-name { font-size: 11px; }
.brand-mark { position: relative; display: block; width: 21px; height: 14px; }
.brand-mark::before { position: absolute; left: 1px; top: 4px; width: 13px; height: 7px; background: #ea7600; border-radius: 60% 35% 60% 35%; transform: rotate(-12deg); content: ""; }
.brand-mark::after { position: absolute; left: 9px; top: 3px; width: 8px; height: 8px; border: 2px solid #ea7600; border-radius: 50%; background: #477eaf; content: ""; }
.brand-orbit { position: absolute; left: 0; top: 1px; width: 10px; height: 3px; border-top: 2px solid #ea7600; transform: rotate(17deg); }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; min-width: 0; gap: 1px; }.menu-entry { position: relative; display: inline-flex; flex: none; }
.menu-bar { flex: none; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-add, .workspace-toolbar button { min-height: 24px; padding: 3px 7px; color: #c9c9c9; background: transparent; border-radius: 2px; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-add:hover, .workspace-toolbar button:hover { color: #fff; background: #3a3a3a; }
.topbar-actions button:disabled { color: #606060; background: transparent; cursor: default; }
.workspace-tabs { flex: 1 1 auto; align-self: stretch; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
.workspace-tabs::-webkit-scrollbar, .topbar::-webkit-scrollbar, .workspace-toolbar::-webkit-scrollbar { display: none; }
.workspace-tab { flex: none; min-width: auto; padding-inline: 9px; border: 0; border-radius: 0; font-size: 10px; }
.workspace-tab.active { color: #fff; background: #303030; box-shadow: inset 0 -2px #4c94c8; }
.workspace-add { flex: none; width: 24px; padding: 0; font-size: 16px; }
.topbar-actions { flex: none; margin-left: auto; }
.topbar-actions button { min-width: 24px; }
.topbar-actions select { width: 78px; height: 22px; color: #cfcfcf; background: #292929; border: 1px solid #444; border-radius: 2px; }
.topbar-context { padding: 2px 6px; color: #bdbdbd; border-left: 1px solid #383838; white-space: nowrap; }
.workspace-toolbar { gap: 2px; padding: 0 6px; overflow-x: auto; color: #bababa; background: #292929; white-space: nowrap; scrollbar-width: none; }
.workspace-toolbar::before { flex: none; color: #e67e22; font-size: 14px; content: "◇"; }
.workspace-toolbar > span:first-child { display: none; }
.mode-chip { min-width: 86px; padding: 2px 8px; color: #e3e3e3; background: #343434 !important; border: 1px solid #484848; }
.segmented { display: inline-flex; flex: none; height: 22px; border: 1px solid #4b4b4b; border-radius: 2px; overflow: hidden; }.segmented button { min-height: 20px; border-radius: 0; border-right: 1px solid #4b4b4b; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #4775a3; }
.toolbar-spacer, .header-spacer, .status-spacer { flex: 1; }
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; grid-template-rows: minmax(0, 1fr) 164px; min-height: 0; gap: 1px; background: #111214; }
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #24262a; }
.editor-header { display: flex; align-items: center; min-height: 28px; padding: 0 6px; background: #303238; border-bottom: 1px solid #17181b; }
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: minmax(180px, 1fr) minmax(220px, 2fr) 108px; min-height: 0; gap: 1px; background: #0f0f0f; }
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #252525; }
.editor-header { display: flex; align-items: center; flex: none; min-height: 27px; padding: 0 4px; background: #292929; border-bottom: 1px solid #151515; box-shadow: inset 0 -1px #333; }
.editor-selector, .icon-button { display: inline-flex; align-items: center; gap: 6px; min-width: 24px; min-height: 24px; padding: 4px 6px; background: transparent; color: #e1e3e7; border-radius: 3px; }
.editor-selector:hover, .icon-button:hover { background: #41434a; }
.editor-icon { color: #e37a2c; font-size: 14px; }
.icon-button { color: #a9adb5; }
.editor-selector:hover, .icon-button:hover { background: #414141; }
.editor-icon { color: #e67e22; font-size: 14px; }
.icon-button { color: #aaa; }
.area-content { position: relative; flex: 1; min-height: 0; overflow: auto; }
.viewport-area { grid-column: 1; grid-row: 1; }
.viewport-area { grid-column: 1; grid-row: 1 / 3; }
.outliner-area { grid-column: 2; grid-row: 1; }
.properties-area { grid-column: 2; grid-row: 2; }
.timeline-area { grid-column: 1; grid-row: 2; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
.properties-area { grid-column: 2; grid-row: 2 / 4; }
.timeline-area { grid-column: 1; grid-row: 3; }
.viewport-area > .editor-header, .timeline-area > .editor-header { display: none; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background-color: #393939; background-image: linear-gradient(#414141 1px, transparent 1px), linear-gradient(90deg, #414141 1px, transparent 1px); background-size: 32px 32px; }
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.grease-pencil-marquee-surface { position: absolute; inset: 0; z-index: 1; cursor: crosshair; touch-action: none; }
.grease-pencil-marquee-box { position: absolute; border: 1px solid #7fc4ff; background: #4c9ed126; box-shadow: 0 0 0 1px #17212aaa; pointer-events: none; }
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; }
.viewport-placeholder::after { position: absolute; inset: 50% 0 0; border-top: 1px solid #505050; content: ""; pointer-events: none; }
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
.viewport-message strong { color: #f3f4f6; font-size: 15px; }
.viewport-message span { color: #969ba4; }
.axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
.axis-gizmo { position: absolute; top: 13px; right: 16px; z-index: 2; width: 54px; height: 54px; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; min-width: 18px; padding: 2px; text-align: center; border-radius: 50%; background: #353535; box-shadow: 0 0 0 1px #202020; }.axis-x { right: -3px; top: 23px; color: #ed6a6a; }.axis-y { left: 21px; top: -4px; color: #6fc47d; }.axis-z { left: 3px; bottom: 4px; color: #6e9ee8; }
.viewport-toolbar { position: absolute; top: 12px; left: 7px; z-index: 2; display: grid; gap: 2px; padding: 3px; background: #292929e8; border: 1px solid #4a4a4a; border-radius: 3px; box-shadow: 0 2px 8px #0005; }.tool-button { width: 28px; height: 28px; background: transparent; color: #ccc; border-radius: 2px; }.tool-button:hover, .tool-button.active { color: #fff; background: #4775a3; }
.tool-button:disabled { color: #666a72; cursor: default; }.tool-button:disabled:hover { background: transparent; }
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
.transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; }
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
.gizmo-axis.axis-x { left: 60px; top: 32px; color: #e35b55; }.gizmo-axis.axis-y { left: 32px; top: 4px; color: #65bb70; }.gizmo-axis.axis-z { left: 32px; top: 60px; color: #5d8ee8; }
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }
.outliner-content, .properties-content { padding: 8px; color: #c8cbd0; }
.outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; }
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
.property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.viewport-sidebar { position: absolute; top: 72px; right: 0; z-index: 2; display: flex; gap: 1px; padding: 0; color: #c2c2c2; background: #292929e8; border: 1px solid #484848; border-right: 0; border-radius: 3px 0 0 3px; }.viewport-sidebar span { padding: 8px 5px; writing-mode: vertical-rl; }
.outliner-content { padding: 3px 2px; color: #d0d0d0; }
.properties-content { position: relative; display: flex; flex-direction: column; min-height: 100%; padding: 4px 7px 12px 40px; color: #d0d0d0; background: #303030; }
.outliner-tools { display: flex; gap: 3px; margin: 0 2px 4px; }.outliner-tools input { min-width: 0; height: 23px; flex: 1; padding: 3px 7px; color: #e4e4e4; background: #181818; border: 1px solid #454545; border-radius: 3px; outline: none; }.outliner-tools button { width: 27px; background: #383838; border-radius: 2px; }
.tree-row { display: flex; align-items: center; gap: 5px; min-height: 24px; padding: 1px 4px; border-radius: 0; }.tree-row:hover { background: #353535; }.tree-row.child { padding-left: 26px; }.tree-row.selected { color: #fff; background: #4775a3; }.tree-icon { color: #d59b44; }.tree-icon.mesh { color: #6999c8; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 1px 3px; color: #a6a6a6; background: transparent; border: 0; border-radius: 2px; }.tree-action:hover { color: #fff; background: #4a4a4a; }
.property-tabs { position: absolute; left: 0; top: 0; bottom: 0; display: flex; flex-direction: column; gap: 1px; width: 34px; margin: 0; padding: 4px 3px; background: #242424; border-right: 1px solid #151515; }.property-tab { display: grid; place-items: center; width: 27px; height: 27px; padding: 0; color: #aaa; background: transparent; border-radius: 2px; }.property-tab:hover { color: #fff; background: #404040; }.property-tab.active { color: #fff; background: #4775a3; }.property-tab-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }.property-section { padding: 7px 3px 9px; border-bottom: 1px solid #454545; }.property-section h3 { margin: 0 0 7px; padding: 4px 6px; color: #eee; background: #383838; border-radius: 2px; font-size: 11px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 23px; color: #b5b5b5; }.property-section output { display: block; color: #ddd; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e0e0; background: #252525; border: 1px solid #505050; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #4775a3; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #8e8e8e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #4775a3; border: 1px solid #6193c3; border-radius: 2px; }.modifier-summary button:disabled { color: #777; background: #303030; border-color: #454545; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.properties-content > [data-testid="scene-context-workbench"], .properties-content > [data-testid="editor-workflow-workbench"], .properties-content > [data-testid="capability-family-workbench"] { order: 90; }
.properties-content > [data-testid="feature-route-coverage"], .properties-content > [data-testid="release-status-workbench"] { order: 100; }
.property-section > button { min-height: 24px; margin: 2px; padding: 3px 7px; color: #e2e2e2; background: #454545; border: 1px solid #575757; border-radius: 2px; }
.property-section > button:hover { background: #555; }
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; min-height: 24px; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.dope-sheet input { min-width: 24px; min-height: 32px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
.timeline-content { display: grid; grid-template-rows: 27px 24px 15px 28px; height: auto; min-height: 100%; padding: 0; background: #292929; }.timeline-controls { display: flex; align-items: center; justify-content: center; gap: 1px; padding: 1px 5px; overflow-x: auto; border-bottom: 1px solid #151515; scrollbar-width: none; }.timeline-controls button { min-width: 25px; height: 24px; padding: 0 5px; color: #d0d0d0; background: transparent; border-radius: 2px; white-space: nowrap; }.timeline-controls button:hover { background: #484848; }.frame-number { min-width: 48px; height: 22px; margin-left: 8px; padding: 3px 7px; text-align: center; color: #fff; background: #464646; border: 1px solid #545454; border-radius: 2px; }.frame-slider { width: 100%; min-height: 24px; margin: 0; accent-color: #4775a3; }.timeline-scale { display: flex; justify-content: space-between; padding: 0 7px; color: #b6b6b6; background: #303030; font-size: 10px; }.dope-sheet { display: grid; grid-template-columns: 90px minmax(120px, 1fr) 78px; align-items: center; gap: 5px; padding: 2px 5px; border-top: 1px solid #444; }.channel-name { overflow: hidden; color: #c0c0c0; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 17px; background: #1f1f1f; border: 1px solid #3d3d3d; }.key-dot { position: absolute; top: 4px; width: 8px; height: 8px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292929; border: 1px solid #484848; }
.dope-sheet input { min-width: 80px; min-height: 24px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
.status-bar { gap: 10px; min-width: 0; min-height: 20px; padding: 0 6px; overflow: hidden; color: #aaa; background: #181818; font-size: 10px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }.status-hint { color: #888; }.runtime-status:not([data-testid="engine-status"]) { display: none; }.blender-version { color: #7fa8cc; }
.storage-budget-panel { display: flex; align-items: center; flex: none; gap: 5px; min-width: 0; height: 100%; padding: 0 4px; color: #aaa; background: transparent; border: 0; white-space: nowrap; }.storage-budget-panel strong { color: #aaa; font-size: 10px; font-weight: 500; }.storage-budget-panel span { display: none; color: #aaa; font-variant-numeric: tabular-nums; }.storage-budget-panel output { color: #c9c9c9; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; width: 19px; height: 18px; padding: 0; color: #ccc; background: #303030; border: 1px solid #444; border-radius: 2px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
.worker-fault-banner { position: fixed; top: 76px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #a55325; border: 1px solid #d17a42; border-radius: 3px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
.recent-project-repair-banner { position: fixed; top: 76px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 3px; }.recent-project-repair-item button:hover { background: #92703a; }
.worker-fault-banner { position: fixed; top: 62px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 3px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #8d4b28; border: 1px solid #c6753b; border-radius: 2px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
.recent-project-repair-banner { position: fixed; top: 62px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 3px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 2px; }.recent-project-repair-item button:hover { background: #92703a; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.operator-search { position: fixed; top: 64px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 7px; transform: translateX(-50%); background: #292929; border: 1px solid #5a5a5a; border-radius: 3px; box-shadow: 0 12px 30px #0008; }
.main-menu-popover { position: absolute; top: 100%; left: 0; z-index: 14; display: grid; min-width: 128px; padding: 4px; background: #303238; border: 1px solid #545860; border-radius: 3px; box-shadow: 0 8px 20px #0008; }.main-menu-popover button { padding: 6px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 2px; white-space: nowrap; }.main-menu-popover button:hover, .main-menu-popover button:focus-visible { color: #fff; background: #a55325; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
@media (max-width: 900px) {
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
.topbar::-webkit-scrollbar { display: none; }
.topbar > *, .topbar-actions button { flex: none; white-space: nowrap; }
.topbar-actions { margin-left: 0; }
.blender-app { grid-template-rows: 28px 30px minmax(0, 1fr) 20px; }
.topbar { min-width: 0; overflow: hidden; }
.topbar > *, .topbar-actions button { white-space: nowrap; }
.brand-name, .topbar-context, .topbar-actions button:nth-of-type(2), .topbar-actions button:nth-of-type(3), .topbar-actions button:nth-of-type(5), .topbar-actions button:nth-of-type(6), .topbar-actions button:nth-of-type(7), .topbar-actions button:nth-of-type(8) { display: none; }
.topbar-actions { flex: none; margin-left: 0; }
.topbar-actions select { width: 62px; }
.menu-bar { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 150px; }
.workspace-tabs .workspace-tab:nth-child(n+4) { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 108px; }
.outliner-area, .properties-area { display: none; }
.viewport-area, .timeline-area { grid-column: 1; }
.viewport-area { grid-row: 1; }
.timeline-area { grid-row: 2; }
.workspace-tab { min-width: 64px; }
.status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; }
.status-bar { overflow-x: auto; scrollbar-width: thin; }
.timeline-content { padding-inline: 8px; }
.workspace-tab { min-width: auto; padding-inline: 8px; }
.status-hint, .status-bar > [data-testid="scene-stats"], .storage-budget-panel strong, .storage-budget-panel output, .blender-version { display: none; }
.status-bar { gap: 5px; overflow: hidden; }
.runtime-status[data-testid="engine-status"] { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.timeline-controls { gap: 2px; }
.timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; }
.frame-number { min-width: 38px; margin-left: 4px; padding-inline: 5px; }
}
@media (min-width: 901px) and (max-width: 1280px) {
.brand-name, .topbar-context { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr) 290px; }
.workspace-tab { padding-inline: 7px; }
}

View File

@@ -1,4 +1,18 @@
export type WorkspaceId = "Layout" | "Modeling" | "Animation";
export const BLENDER_WORKSPACE_IDS = [
"Layout",
"Modeling",
"Sculpting",
"UV Editing",
"Texture Paint",
"Shading",
"Animation",
"Rendering",
"Compositing",
"Geometry Nodes",
"Scripting",
] as const;
export type WorkspaceId = typeof BLENDER_WORKSPACE_IDS[number];
export type EditorType = "3D Viewport" | "Outliner" | "Properties" | "Timeline";
export type BlenderMode = "Object" | "Edit" | "Pose";
@@ -75,11 +89,7 @@ function workspace(id: WorkspaceId): WorkspaceIR {
}
export function createDefaultWebWorkspaceState(): WebWorkspaceState {
const workspaces = {
Layout: workspace("Layout"),
Modeling: workspace("Modeling"),
Animation: workspace("Animation"),
} satisfies Record<WorkspaceId, WorkspaceIR>;
const workspaces = Object.fromEntries(BLENDER_WORKSPACE_IDS.map((id) => [id, workspace(id)])) as Record<WorkspaceId, WorkspaceIR>;
return {
workspaces,
context: {

View File

@@ -47,4 +47,41 @@ test.describe("M7-14 responsive layout", () => {
expect(report.rootScrollWidth, JSON.stringify(report)).toBeLessThanOrEqual(report.viewportWidth);
});
}
test("desktop mirrors Blender 5.2 Layout workspace regions", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
await expect(page.locator(".workspace-tab")).toHaveText([
"Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading",
"Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting",
]);
const geometry = await page.evaluate(() => {
const rect = (selector: string) => {
const bounds = document.querySelector<HTMLElement>(selector)?.getBoundingClientRect();
if (!bounds) throw new Error(`Missing ${selector}`);
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, bottom: bounds.bottom };
};
const propertyTabs = document.querySelector<HTMLElement>(".property-tabs");
return {
viewport: rect(".viewport-area"),
outliner: rect(".outliner-area"),
properties: rect(".properties-area"),
timeline: rect(".timeline-area"),
status: rect(".status-bar"),
propertyTabsDirection: propertyTabs ? getComputedStyle(propertyTabs).flexDirection : "missing",
};
});
expect(geometry.viewport.width / 1440).toBeGreaterThan(0.75);
expect(geometry.outliner.x).toBeCloseTo(geometry.viewport.width + 1, 0);
expect(geometry.outliner.height).toBeLessThan(geometry.properties.height);
expect(geometry.properties.bottom).toBeCloseTo(geometry.status.y, 0);
expect(geometry.timeline.x).toBe(0);
expect(geometry.timeline.width).toBeCloseTo(geometry.viewport.width, 0);
expect(geometry.timeline.height).toBeGreaterThanOrEqual(96);
expect(geometry.timeline.height).toBeLessThanOrEqual(120);
expect(geometry.propertyTabsDirection).toBe("column");
});
});