feat(manual): add the illustrated Web Blender HTML user manual

A single-page 28-chapter manual generated from manual.json covers the
11 workspaces, all React editors, OPFS save/reopen, explicit local
downloads, import/export routes, preferences/keymap and the capability
matrix. It reuses verified course screenshots and adds 14 fresh
Chromium captures for shell, storage, area, camera, preferences, IO and
troubleshooting chapters.

Manual-driven fixes: Save As now states OPFS-only, GLTF import is
restored, quick toolbar honors its preference, startup workspace is
applied, Properties tabs jump to sections, Outliner fallback search
filters, and the editor-header ⋮ opens the area menu. Help and the
tutorial landing link the new manual; the PDF remains as an archive.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 11:58:40 +08:00
parent aa8cc1313e
commit b932db7642
37 changed files with 1996 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -0,0 +1,239 @@
{
"schemaVersion": 1,
"generatedAt": "2026-11-13T03:58:17.817Z",
"version": "1.0.0",
"lastVerified": "2026-11-13",
"sourceCommit": "aa8cc1313",
"images": [
{
"src": "/tutorial/full/steps/M01-interface.jpg",
"file": "/tutorial/full/steps/M01-interface.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M01-file-menu.jpg",
"file": "/tutorial/full/steps/M01-file-menu.jpg",
"exists": true
},
{
"src": "/manuals/images/ch01-first-save.jpg",
"file": "/manuals/images/ch01-first-save.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M02-operator-search.jpg",
"file": "/tutorial/full/steps/M02-operator-search.jpg",
"exists": true
},
{
"src": "/manuals/images/ch03-recent.jpg",
"file": "/manuals/images/ch03-recent.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M14-opfs-save.jpg",
"file": "/tutorial/full/steps/M14-opfs-save.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M14-reopen.jpg",
"file": "/tutorial/full/steps/M14-reopen.jpg",
"exists": true
},
{
"src": "/manuals/images/ch05-recovery.jpg",
"file": "/manuals/images/ch05-recovery.jpg",
"exists": true
},
{
"src": "/manuals/images/ch06-area-menu.jpg",
"file": "/manuals/images/ch06-area-menu.jpg",
"exists": true
},
{
"src": "/manuals/images/ch06-area-split.jpg",
"file": "/manuals/images/ch06-area-split.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M02-layout.jpg",
"file": "/tutorial/full/steps/M02-layout.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M03-mesh-selection.jpg",
"file": "/tutorial/full/steps/M03-mesh-selection.jpg",
"exists": true
},
{
"src": "/manuals/images/ch07-camera-view.jpg",
"file": "/manuals/images/ch07-camera-view.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M02-selection.jpg",
"file": "/tutorial/full/steps/M02-selection.jpg",
"exists": true
},
{
"src": "/manuals/images/ch08-outliner.jpg",
"file": "/manuals/images/ch08-outliner.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M04-properties.jpg",
"file": "/tutorial/full/steps/M04-properties.jpg",
"exists": true
},
{
"src": "/manuals/images/ch09-properties-tabs.jpg",
"file": "/manuals/images/ch09-properties-tabs.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M04-modifier-stack.jpg",
"file": "/tutorial/full/steps/M04-modifier-stack.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M03-workspace.jpg",
"file": "/tutorial/full/steps/M03-workspace.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M03-edit-mode.jpg",
"file": "/tutorial/full/steps/M03-edit-mode.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M06-uv-workspace.jpg",
"file": "/tutorial/full/steps/M06-uv-workspace.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M06-unwrap.jpg",
"file": "/tutorial/full/steps/M06-unwrap.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M05-shader-workspace.jpg",
"file": "/tutorial/full/steps/M05-shader-workspace.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M05-asset-library.jpg",
"file": "/tutorial/full/steps/M05-asset-library.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M07-painted-stroke.jpg",
"file": "/tutorial/full/steps/M07-painted-stroke.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M07-texture-paint.jpg",
"file": "/tutorial/full/steps/M07-texture-paint.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M12-geometry-nodes.jpg",
"file": "/tutorial/full/steps/M12-geometry-nodes.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M12-node-added.jpg",
"file": "/tutorial/full/steps/M12-node-added.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M13-vertices.jpg",
"file": "/tutorial/full/steps/M13-vertices.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M10-animation-workspace.jpg",
"file": "/tutorial/full/steps/M10-animation-workspace.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M10-keyframe.jpg",
"file": "/tutorial/full/steps/M10-keyframe.jpg",
"exists": true
},
{
"src": "/manuals/images/ch21-physics.jpg",
"file": "/manuals/images/ch21-physics.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M08-render-workspace.jpg",
"file": "/tutorial/full/steps/M08-render-workspace.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M08-pbr-panel.jpg",
"file": "/tutorial/full/steps/M08-pbr-panel.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M09-render-result.jpg",
"file": "/tutorial/full/steps/M09-render-result.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M11-compositor.jpg",
"file": "/tutorial/full/steps/M11-compositor.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M11-executed.jpg",
"file": "/tutorial/full/steps/M11-executed.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M11-nodes-added.jpg",
"file": "/tutorial/full/steps/M11-nodes-added.jpg",
"exists": true
},
{
"src": "/manuals/images/ch25-import-menu.jpg",
"file": "/manuals/images/ch25-import-menu.jpg",
"exists": true
},
{
"src": "/manuals/images/ch25-export-menu.jpg",
"file": "/manuals/images/ch25-export-menu.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M13-filtered.jpg",
"file": "/tutorial/full/steps/M13-filtered.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M15-scripting.jpg",
"file": "/tutorial/full/steps/M15-scripting.jpg",
"exists": true
},
{
"src": "/tutorial/full/steps/M15-console.jpg",
"file": "/tutorial/full/steps/M15-console.jpg",
"exists": true
},
{
"src": "/manuals/images/ch27-preferences.jpg",
"file": "/manuals/images/ch27-preferences.jpg",
"exists": true
},
{
"src": "/manuals/images/ch27-keymap.jpg",
"file": "/manuals/images/ch27-keymap.jpg",
"exists": true
},
{
"src": "/manuals/images/ch28-help.jpg",
"file": "/manuals/images/ch28-help.jpg",
"exists": true
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,145 @@
{
"schemaVersion": 1,
"chapters": [
{
"id": "ch01",
"title": "快速开始:首次加载、打开工程与第一次保存",
"summary": "从浏览器打开 Web Blender,等待 WASM 就绪,载入练习工程并把工程保存到 OPFS。"
},
{
"id": "ch02",
"title": "界面总览:工作区、编辑器、状态栏与命令搜索",
"summary": "认识 11 个工作区、区域、状态栏和 F3 操作搜索,知道在哪里找命令与反馈。"
},
{
"id": "ch03",
"title": "文件与工程:新建、打开、最近项目、另存为与保存副本",
"summary": "掌握当前 Web 版的工程文件入口,理解另存为写 OPFS、保存副本才下载本地副本。"
},
{
"id": "ch04",
"title": "浏览器存储:OPFS 保存、重开与手动本地下载",
"summary": "理解 OPFS 工程布局、最近项目索引、恢复点与手动下载的边界。"
},
{
"id": "ch05",
"title": "撤销、自动恢复点、恢复与诊断",
"summary": "使用撤销/重做、浏览器自动恢复点、最近项目修复和诊断报告处理错误。"
},
{
"id": "ch06",
"title": "工作区与 Area 布局:切换、⋮ 菜单、分割与持久化",
"summary": "用区域右上角选择器切换编辑器,用 ⋮ 菜单分割/合并/交换/最大化区域,并保存布局。"
},
{
"id": "ch07",
"title": "3D 视口:导航、选择、变换与相机视图",
"summary": "使用鼠标与键盘导航视口,选择对象/网格元素,执行变换并进入相机视图或锁定相机。"
},
{
"id": "ch08",
"title": "Outliner:层级、选择、可见性、重命名与拖放",
"summary": "在大纲视图中选择对象、展开层级、切换可见性、重命名、remap 数据和拖放父子/集合。"
},
{
"id": "ch09",
"title": "Properties:对象、修改器、材质与场景属性",
"summary": "使用右侧属性面板查看和编辑变换、层级、修改器、材质、相机、灯光、世界、渲染和物理设置。"
},
{
"id": "ch10",
"title": "对象模式:创建、复制、重命名、父子与 Join",
"summary": "创建基础体、复制/链接复制、重命名、删除、父子关系和 Join 合并。"
},
{
"id": "ch11",
"title": "网格编辑与雕刻范围",
"summary": "进入编辑模式执行选择/挤出/倒角/环切/细分,并在雕刻模式使用有限的笔刷与重构。"
},
{
"id": "ch12",
"title": "修改器栈:添加、排序、参数、可见性与应用",
"summary": "在 Modifier Stack 中添加 Bevel/Boolean/Mirror/Array 等修改器并管理堆栈。"
},
{
"id": "ch13",
"title": "非网格对象:曲线、曲面、文本、字体、融球与 Grease Pencil",
"summary": "从添加菜单创建曲线/文本/融球/Grease Pencil,编辑控制点或文本,导入本地字体。"
},
{
"id": "ch14",
"title": "UV 编辑:选择、展开、打包、变换与缝合",
"summary": "进入可写 UV 区域,全选/变换/展开/打包/缝合 UV,并保存重开。"
},
{
"id": "ch15",
"title": "材质与 Shader Editor:Principled、图像与预览",
"summary": "创建/分配材质,在节点画布编辑 Principled 与图像节点,预览并打包外部贴图。"
},
{
"id": "ch16",
"title": "PBR/RE 资产库、Append/Link 与 packed 图像",
"summary": "从 PBR/RE 资产库加载模型/材质,理解 Append/Link 与 packed 资源。"
},
{
"id": "ch17",
"title": "Texture Paint:画笔、撤销/重做、Make Local 与 OPFS",
"summary": "在 packed 图像上绘制,一次笔画一次提交,撤销/重做并保存重开;库链接图先本地化。"
},
{
"id": "ch18",
"title": "Geometry Nodes 与 Spreadsheet",
"summary": "在画布中添加 allowlist 节点、连线、编辑 socket、提交图,并在 Spreadsheet 检查数据。"
},
{
"id": "ch19",
"title": "Timeline、Dope Sheet 与关键帧动画",
"summary": "跳帧、插入/删除 Loc/Rot/Scale 关键帧、播放并管理 Action。"
},
{
"id": "ch20",
"title": "Graph Editor、NLA 与驱动器",
"summary": "在 Graph Editor 查看/拖动关键帧、切换插值;用 NLA 表单管理轨道/条带和驱动器表达式。"
},
{
"id": "ch21",
"title": "物理、粒子、点缓存与模拟限制",
"summary": "查看并编辑 Soft Body/Rigid Body/Cloth/Fluid/Particle 设置,理解 Web 播放与求解限制。"
},
{
"id": "ch22",
"title": "相机、灯光、世界与渲染属性",
"summary": "编辑相机镜头/DoF、灯光类型/能量、世界环境、色彩管理和渲染输出。"
},
{
"id": "ch23",
"title": "PBR 渲染、Render Result 与手动 PNG 下载",
"summary": "设置分辨率/相机/曝光/位移,执行浏览器 PBR 渲染,检查结果并手动下载 PNG。"
},
{
"id": "ch24",
"title": "Compositor:节点画布、执行与保存",
"summary": "创建 RGB/Exposure/Blur/Mix/Alpha Over/Transform/Viewer/Composite 节点,执行 CPU 合成并提交。"
},
{
"id": "ch25",
"title": "导入导出矩阵:GLB/GLTF/OBJ/STL/PLY、原生导出与 CSV",
"summary": "按可用性导入/导出模型与数据,明确浏览器本地、原生/F3、不可用三种路径。"
},
{
"id": "ch26",
"title": "脚本:Text Editor、Python Console 与 Info",
"summary": "编辑/运行文本脚本,使用原生 Python 控制台与 Info 状态,理解脚本策略。"
},
{
"id": "ch27",
"title": "Preferences、Keymap、快捷栏与启动工作区",
"summary": "使用分栏偏好设置、OPFS 保存、可改键位、快捷栏显隐和启动工作区。"
},
{
"id": "ch28",
"title": "故障排查、能力边界与离线/版本说明",
"summary": "按状态栏/诊断/支持矩阵定位问题,理解 OPFS、浏览器、原生服务和格式限制。"
}
]
}

View File

@@ -0,0 +1,19 @@
:root{color-scheme:dark;--bg:#191b1a;--panel:#232624;--panel-2:#2b2f2c;--line:#414743;--text:#e7eae7;--muted:#a8b0a9;--accent:#79a98a;--accent-2:#d6b36a;--danger:#c9836f}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.75 "Noto Sans SC",system-ui,-apple-system,"Segoe UI",sans-serif}a{color:#9bc7a9}
.manual-topbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 14px;background:#202321f2;border-bottom:1px solid var(--line);backdrop-filter:blur(6px)}
.manual-brand{font-weight:700;color:#eef3ee;text-decoration:none}.manual-menu-toggle{display:none;width:30px;height:28px;background:#343a35;color:#eee;border-radius:4px}
.manual-search{display:flex;align-items:center;gap:5px;flex:1 1 240px;max-width:520px;color:var(--muted);font-size:12px}.manual-search input{flex:1;min-width:0;padding:5px 8px;color:var(--text);background:#151716;border:1px solid #4d554f;border-radius:4px}
.manual-search-status{color:var(--accent-2);font-size:12px}.manual-topbar nav{display:flex;gap:12px;margin-left:auto;font-size:12px}.manual-topbar nav a{color:var(--muted);text-decoration:none}
.manual-layout{display:grid;grid-template-columns:300px minmax(0,1fr);align-items:start}.manual-sidebar{position:sticky;top:49px;max-height:calc(100vh - 49px);overflow:auto;padding:14px 10px;border-right:1px solid var(--line);background:#1e211f}
.manual-nav-group{margin-bottom:14px}.manual-nav-group>strong{display:block;margin:0 0 4px 7px;color:var(--accent-2);font-size:11px;letter-spacing:.08em}.manual-nav a{display:flex;gap:7px;padding:5px 8px;border-radius:4px;color:#cbd2cc;text-decoration:none;font-size:12px;line-height:1.35}.manual-nav a:hover{background:#2b302c}.manual-nav a span{flex:none;color:#8b9b8f;font-family:monospace}.manual-nav a[hidden]{display:none}.manual-nav a.active{background:#355343;color:#eaf3ec}
.manual-main{min-width:0;padding:24px clamp(16px,4vw,52px) 70px;overflow-wrap:anywhere}.manual-main p,.manual-main li{overflow-wrap:anywhere;word-break:break-word}.manual-hero{max-width:1050px;margin:0 auto 26px;padding:26px clamp(16px,3vw,30px);background:linear-gradient(135deg,#24302a,#242625 65%,#2f2a20);border:1px solid var(--line);border-radius:8px}.manual-kicker{color:var(--accent-2);font-size:12px;letter-spacing:.06em}.manual-hero h1{margin:6px 0 10px;font-size:clamp(28px,4vw,44px)}.manual-policy{margin-top:14px;padding:10px 14px;border-left:3px solid var(--accent-2);background:#292a25;font-size:13px}.manual-policy strong{color:#e6d1a4}.manual-policy ul{margin:4px 0 0;padding-left:18px}
.manual-chapter,.manual-appendix{max-width:1050px;margin:0 auto 28px;padding:22px clamp(16px,3vw,30px);background:var(--panel);border:1px solid var(--line);border-radius:8px}.manual-chapter h2,.manual-appendix h2{margin:4px 0 8px;line-height:1.3}.manual-chapter-kicker{margin:0;color:var(--accent-2);font-size:11px;letter-spacing:.06em}.manual-summary{color:#cdd4ce}.manual-meta{color:var(--muted);font-size:12px}
.manual-block{margin:16px 0}.manual-block h3{margin:0 0 5px;font-size:14px;color:#cfe0d4}.manual-block ul{margin:0;padding-left:20px}.manual-limit{border-left:3px solid var(--danger);padding-left:12px}
.manual-steps{margin:18px 0 0;padding:0;list-style:none;counter-reset:step}.manual-steps>li{position:relative;margin:0 0 24px;padding:0 0 0 38px}.manual-steps>li::before{counter-increment:step;content:counter(step);position:absolute;left:0;top:0;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#355343;color:#e8f2ec;font-size:12px;font-weight:700}.manual-steps h3{margin:2px 0 6px;font-size:14px;color:#e6ece7}.manual-steps p{margin:0 0 8px}
figure{margin:10px 0 0}.manual-steps img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:6px;background:#111}figcaption{margin-top:5px;color:#8f9a92;font-size:11px}
.manual-callout{margin:8px 0;padding:8px 11px;background:#2c302a;border-left:3px solid var(--accent);border-radius:3px}.manual-callout.limit{border-left-color:var(--danger)}.manual-callout.warning{border-left-color:var(--accent-2)}
.manual-chapter-nav{display:flex;justify-content:space-between;margin-top:20px;padding-top:10px;border-top:1px solid var(--line);font-size:12px}.manual-chapter-nav a{color:var(--accent);text-decoration:none}
.manual-table-wrap{width:100%;max-width:100%;overflow-x:auto}.manual-table-wrap table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:12px}.manual-table-wrap td,.manual-table-wrap th{padding:6px 8px;text-align:left;border:1px solid #454c47;vertical-align:top;overflow-wrap:anywhere;word-break:break-word}.manual-table-wrap th{background:#313732}.status{padding:1px 6px;border-radius:8px;font-size:10px;white-space:nowrap}.status.usable{background:#2f513d;color:#d8f0de}.status.partial{background:#514a2d;color:#f1e1ae}.status.native-only{background:#3b4554;color:#d7e3f5}.status.unsupported{background:#4c3331;color:#f1d4ce}
.manual-footer{max-width:1050px;margin:20px auto 0;padding-top:14px;border-top:1px solid var(--line);color:#8f9a92;font-size:11px}
@media(max-width:900px){.manual-topbar nav{display:none}.manual-menu-toggle{display:block}.manual-layout{grid-template-columns:1fr}.manual-sidebar{position:fixed;left:0;top:49px;bottom:0;z-index:25;width:280px;transform:translateX(-100%);transition:transform .15s}.manual-sidebar.open{transform:none;box-shadow:20px 0 40px #0008}.manual-main{padding-top:14px}}
@media print{.manual-topbar,.manual-sidebar,.manual-menu-toggle,.manual-chapter-nav,.manual-search-status{display:none!important}.manual-layout{display:block}.manual-main{padding:0}.manual-chapter,.manual-appendix{break-inside:avoid;border:0;padding:0;margin:0 0 18px}.manual-hero{break-after:page}figure img{max-height:420px;object-fit:contain} }

View File

@@ -0,0 +1,38 @@
(() => {
const sidebar = document.querySelector('.manual-sidebar');
const toggle = document.querySelector('.manual-menu-toggle');
const search = document.querySelector('.manual-search input');
const status = document.querySelector('.manual-search-status');
const links = Array.from(document.querySelectorAll('.manual-nav a[data-nav-id]'));
const articles = Array.from(document.querySelectorAll('.manual-chapter'));
const apply = () => {
const query = (search?.value || '').trim().toLowerCase();
let matches = 0;
for (const link of links) {
const article = document.querySelector('#' + CSS.escape(link.dataset.navId));
const text = (article?.dataset.search || link.textContent || '').toLowerCase();
const hit = !query || text.includes(query) || (link.textContent || '').toLowerCase().includes(query);
link.hidden = !hit;
if (hit) matches += 1;
}
for (const article of articles) {
const text = (article.dataset.search || '').toLowerCase();
article.hidden = Boolean(query) && !text.includes(query);
}
if (status) status.textContent = query ? matches + ' 个匹配' : '';
};
search?.addEventListener('input', apply);
toggle?.addEventListener('click', () => sidebar?.classList.toggle('open'));
window.addEventListener('keydown', (event) => {
if (event.key === '/' && document.activeElement !== search) { event.preventDefault(); search?.focus(); }
if (event.key === 'Escape' && search) { search.value = ''; apply(); search.blur(); }
});
const observer = 'IntersectionObserver' in window ? new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = entry.target.id;
for (const link of links) link.classList.toggle('active', link.dataset.navId === id);
}
}, { rootMargin: '-30% 0px -60% 0px' }) : null;
for (const article of articles) observer?.observe(article);
})();

View File

@@ -214,7 +214,7 @@ function sceneCameraPose(snapshot: SceneSnapshotIR | null): BlenderViewportCamer
}
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
IO_FORMAT_UI_REGISTRY.importRoutes = IO_FORMAT_UI_REGISTRY.importRoutes.filter((route) => ["OBJ", "STL", "PLY", "GLB"].includes(route.format));
IO_FORMAT_UI_REGISTRY.importRoutes = IO_FORMAT_UI_REGISTRY.importRoutes.filter((route) => ["OBJ", "STL", "PLY", "GLB", "GLTF"].includes(route.format));
const NATIVE_FILE_EXPORT_EXTENSIONS = new Map<string,string>([['wm.obj_export','.obj'],['wm.stl_export','.stl'],['wm.ply_export','.ply'],['wm.usd_export','.usdc'],['wm.alembic_export','.abc']]);
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness(ioFormatRuntimeReceiptFreshnessJSON);
const IO_FORMAT_RUNTIME_RECEIPT_EXPECTED = ioFormatRuntimeReceiptFreshnessExpectedJSON as unknown as IOFormatReceiptFreshnessExpectedIR;
@@ -1339,7 +1339,7 @@ function Area({ className = "", editor, children, areaId }: AreaProps) {
<span>{editor}</span>
</button>
<span className="header-spacer" />
<button className="icon-button" type="button" aria-label={`${editor} 菜单`}>⋮</button>
<DesktopMenu className="area-menu" label="⋮" items={areaMenuItems} />
</header>
<div className="area-content">{useDefaultContent ? newAreaContent : context!.render(activeEditor, areaKey)}</div>
</section>
@@ -1816,6 +1816,7 @@ function Outliner({ snapshot, areaId, onSelect, onToggleVisibility, onRefresh, o
const selectedIds = snapshot ? selectionFromSnapshot(snapshot) : new Set<string>();
const collectionRows = snapshot?.collections ?? [];
const [collapsed,setCollapsed]=useState<Set<string>>(new Set());
const [fallbackSearch,setFallbackSearch]=useState("");
const workflow = snapshot?.editorWorkflow;
const workspace = workflow?.workspaces.find(item => item.id === workflow.context.workspaceId);
const nativeArea = workspace?.areas.find(item => item.editor === 'OUTLINER' &&
@@ -2107,11 +2108,28 @@ function Outliner({ snapshot, areaId, onSelect, onToggleVisibility, onRefresh, o
</form>
</DesktopDialog> : null}
</div>;
const fallbackQuery = fallbackSearch.trim().toLowerCase();
const fallbackAllowed = (() => {
if (!fallbackQuery) return null;
const allowed = new Set<string>();
const children = new Map<string, string[]>();
for (const node of snapshot?.nodes ?? []) if (node.parentId) children.set(node.parentId, [...(children.get(node.parentId) ?? []), node.id]);
for (const node of snapshot?.nodes ?? []) {
if (!node.name.toLowerCase().includes(fallbackQuery)) continue;
allowed.add(node.id);
let parent = node.parentId;
while (parent) { allowed.add(parent); parent = nodeById.get(parent)?.parentId ?? null; }
const stack = [...(children.get(node.id) ?? [])];
while (stack.length) { const id = stack.pop()!; allowed.add(id); stack.push(...(children.get(id) ?? [])); }
}
return allowed;
})();
const visibleCollections = fallbackAllowed ? collectionRows.filter((collection) => collection.objectIds.some((id) => fallbackAllowed.has(id))) : collectionRows;
const rowsFor=(objectIds:string[])=>{
const members=new Set(objectIds),children=new Map<string,string[]>(),rows:{id:string;depth:number;hasChildren:boolean}[]=[],visited=new Set<string>();
for(const id of objectIds){const parent=nodeById.get(id)?.parentId;if(parent&&members.has(parent))children.set(parent,[...(children.get(parent)??[]),id]);}
const visit=(id:string,depth:number,hidden=false)=>{if(visited.has(id))return;visited.add(id);const childIds=children.get(id)??[];
if(!hidden)rows.push({id,depth,hasChildren:childIds.length>0});
if(!hidden&&(!fallbackAllowed||fallbackAllowed.has(id)))rows.push({id,depth,hasChildren:childIds.length>0});
for(const child of childIds)visit(child,depth+1,hidden||collapsed.has(id));
};
for(const id of objectIds){const parent=nodeById.get(id)?.parentId;if(!parent||!members.has(parent))visit(id,0);}
@@ -2120,9 +2138,9 @@ function Outliner({ snapshot, areaId, onSelect, onToggleVisibility, onRefresh, o
};
return (
<div className="outliner-content">
<div className="outliner-tools"><input aria-label="搜索场景" placeholder="搜索场景" /><button type="button" aria-label="筛选">⌄</button>{onRefresh ? <button type="button" aria-label="刷新大纲树" onClick={() => onRefresh()}>↻</button> : null}{onSelectAll ? <><button type="button" aria-label="全选大纲对象" onClick={() => onSelectAll('SELECT')}>全选</button><button type="button" aria-label="取消选择大纲对象" onClick={() => onSelectAll('DESELECT')}>清除</button><button type="button" aria-label="反选大纲对象" onClick={() => onSelectAll('INVERT')}>反选</button><button type="button" aria-label="切换大纲选择" onClick={() => onSelectAll('TOGGLE')}>切换</button></> : null}</div>
<div className="outliner-tools">{!tree ? <><input aria-label="搜索场景" placeholder="搜索场景" value={fallbackSearch} onChange={(event) => setFallbackSearch(event.target.value)} />{fallbackQuery ? <button type="button" aria-label="清除大纲搜索" onClick={() => setFallbackSearch("")}>×</button> : null}</> : null}<button type="button" aria-label="筛选">⌄</button>{onRefresh ? <button type="button" aria-label="刷新大纲树" onClick={() => onRefresh()}>↻</button> : null}{onSelectAll ? <><button type="button" aria-label="全选大纲对象" onClick={() => onSelectAll('SELECT')}>全选</button><button type="button" aria-label="取消选择大纲对象" onClick={() => onSelectAll('DESELECT')}>清除</button><button type="button" aria-label="反选大纲对象" onClick={() => onSelectAll('INVERT')}>反选</button><button type="button" aria-label="切换大纲选择" onClick={() => onSelectAll('TOGGLE')}>切换</button></> : null}</div>
<div className="outliner-tree" role="tree" aria-label="场景层级" aria-multiselectable="true">
{collectionRows.length === 0 ? <div className="tree-row collection" role="treeitem" aria-expanded="true"><span>▾</span><strong>Scene Collection</strong></div> : collectionRows.map((collection, index) => (
{visibleCollections.length === 0 ? <div className="tree-row collection" role="treeitem" aria-expanded="true"><span>▾</span><strong>{fallbackQuery ? "没有匹配的对象" : "Scene Collection"}</strong></div> : visibleCollections.map((collection, index) => (
<div key={collection.id}>
<div className={index === 0 ? "tree-row collection" : "tree-row"} role="treeitem" aria-expanded="true"><span>▾</span><span className="tree-icon">◈</span><strong>{collection.name}</strong></div>
<div role="group">
@@ -2637,6 +2655,13 @@ function Properties({ selectedEdgeIndices, nonMeshGeometryBuffers, client, snaps
onActiveMaterialSlotChange: (slot: number) => void;
}) {
const greasePencilPointSelection = greasePencilSelection?.selectedPoints ?? [];
const [propertyTab, setPropertyTab] = useState<"object" | "modifier" | "material">("object");
const jumpToPropertySection = (tab: "object" | "modifier" | "material", event: React.MouseEvent<HTMLButtonElement>): void => {
setPropertyTab(tab);
const content = event.currentTarget.closest(".properties-content");
const selector = tab === "object" ? '[data-testid="object-transform-readout"]' : tab === "modifier" ? ".modifier-panel" : '[data-testid="material-slots-section"]';
content?.querySelector<HTMLElement>(selector)?.scrollIntoView({ block: "start", behavior: "smooth" });
};
const [mode, setMode] = useState<SimplifyMode>("COLLAPSE");
const [ratio, setRatio] = useState(0.5);
const [targetKind, setTargetKind] = useState<'RATIO' | 'TRIANGLES'>('RATIO');
@@ -3276,9 +3301,9 @@ function Properties({ selectedEdgeIndices, nonMeshGeometryBuffers, client, snaps
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" 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>
<button className={`property-tab${propertyTab === "object" ? " active" : ""}`} type="button" role="tab" aria-selected={propertyTab === "object"} title="对象属性" onClick={(event) => jumpToPropertySection("object", event)}><span aria-hidden="true">▣</span><span className="property-tab-label">对象</span></button>
<button className={`property-tab${propertyTab === "modifier" ? " active" : ""}`} type="button" role="tab" aria-selected={propertyTab === "modifier"} title="修改器属性" onClick={(event) => jumpToPropertySection("modifier", event)}><span aria-hidden="true">⌕</span><span className="property-tab-label">修改器</span></button>
<button className={`property-tab${propertyTab === "material" ? " active" : ""}`} type="button" role="tab" aria-selected={propertyTab === "material"} title="材质属性" onClick={(event) => jumpToPropertySection("material", event)}><span aria-hidden="true">●</span><span className="property-tab-label">材质</span></button>
</div>
<div className="property-section" data-testid="object-transform-readout"><h3>Transform</h3>
<label>位置 <output data-testid="object-location">{activeNode ? activeNode.transform.translation.map(value => value.toFixed(3)).join(", ") : "--"}</output></label>
@@ -3631,7 +3656,7 @@ function Properties({ selectedEdgeIndices, nonMeshGeometryBuffers, client, snaps
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><label>Selection mask <input aria-label="Paint selection mask" type="range" min="0" max="1" step="0.01" value={paintSelectionMask} onChange={(event) => setPaintSelectionMask(Number(event.target.value))} /><output>{paintSelectionMask.toFixed(2)}</output></label><label>Normalize weights <input aria-label="Paint normalize weights" type="checkbox" checked={paintNormalize} onChange={(event) => setPaintNormalize(event.target.checked)} /></label><label>Limit influences <select aria-label="Paint influence limit" value={paintLimit} onChange={(event) => setPaintLimit(Number(event.target.value))}>{[1, 2, 3, 4, 8].map((limit) => <option key={limit} value={limit}>{limit}</option>)}</select></label><label>Mirror X/Y/Z <input aria-label="Paint mirror weights" type="checkbox" checked={paintMirror} onChange={(event) => setPaintMirror(event.target.checked)} /></label>{paintMirror ? <label>Mirror axis <select aria-label="Paint mirror axis" value={paintMirrorAxis} onChange={(event) => setPaintMirrorAxis(Number(event.target.value) as 0 | 1 | 2)}><option value={0}>X</option><option value={1}>Y</option><option value={2}>Z</option></select></label> : null}<div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: paintNormalize, limit: paintLimit, mirror: paintMirror, ...(paintMirror ? { mirrorAxis: paintMirrorAxis, mirrorTolerance: 1e-4 } : {}) })}>Apply Weight</button><button type="button" disabled={selectedVertexIndices.length === 0 || paintSelectionMask <= 0} onClick={blendPaintColor}>Blend Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup || paintSelectionMask <= 0} onClick={blendPaintWeight}>Blend Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output><output data-testid="paint-color-attribute">{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}</output><output data-testid="paint-vertex-group">{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}</output></div> : null}
{activeMesh ? <div className="property-section" data-testid="sculpt-editor" data-command="sculptStroke,setSculptMeshAttributes,invokeBlenderOperator"><h3>Sculpt</h3><label>Brush <select aria-label="Sculpt brush" value={sculptBrush} onChange={(event) => setSculptBrush(event.target.value as SculptBrush)}><option value="DRAW">Draw</option><option value="INFLATE">Inflate</option><option value="GRAB">Grab</option><option value="SMOOTH">Smooth</option></select></label><label>Radius <input aria-label="Sculpt radius" type="number" min="0.001" step="0.01" value={sculptRadius} onChange={(event) => setSculptRadius(Math.max(0.001, Number(event.target.value) || 0.001))} /></label><label>Strength <input aria-label="Sculpt strength" type="range" min="-1" max="1" step="0.01" value={sculptStrength} onChange={(event) => setSculptStrength(Number(event.target.value))} /><output>{sculptStrength.toFixed(2)}</output></label><label>Pressure <input aria-label="Sculpt pressure" type="range" min="0" max="1" step="0.01" value={sculptPressure} onChange={(event) => setSculptPressure(Number(event.target.value))} /><output>{sculptPressure.toFixed(2)}</output></label><label>Symmetry X/Y/Z <span className="property-actions">{([0, 1, 2] as const).map((axis) => <label key={axis}><input aria-label={`Sculpt symmetry ${axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}`} type="checkbox" checked={sculptSymmetry[axis]} onChange={(event) => setSculptSymmetry((current) => current.map((value, index) => index === axis ? event.target.checked : value) as [boolean, boolean, boolean])} />{axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}</label>)}</span></label><label>Mirror object space <input aria-label="Sculpt mirror object space" type="checkbox" checked={sculptMirrorObjectSpace} onChange={(event) => setSculptMirrorObjectSpace(event.target.checked)} /></label><div className="property-actions"><button type="button" onClick={applySculptStroke}>Apply Sculpt Stroke ({sculptCursorSamples.length || 3} samples)</button></div><label>Mask <input aria-label="Sculpt mask" type="range" min="0" max="1" step="0.01" value={sculptMaskValue} onChange={(event) => setSculptMaskValue(Number(event.target.value))} /><output>{sculptMaskValue.toFixed(2)}</output></label><label>Active face set <input aria-label="Sculpt active face set" type="number" min="0" step="1" value={sculptActiveFaceSet} onChange={(event) => setSculptActiveFaceSet(Math.max(0, Math.floor(Number(event.target.value) || 0)))} /></label><div className="property-actions"><button type="button" disabled={activeMesh.vertexCount <= 0} onClick={applySculptMeshAttributes}>Apply Sculpt Attributes</button></div><label>Remesh voxel size <input aria-label="Sculpt remesh voxel size" type="number" min="0.001" step="0.001" value={sculptRemeshVoxelSize} onChange={(event) => setSculptRemeshVoxelSize(Math.max(0.001, Number(event.target.value) || 0.001))} /></label><div className="property-actions"><button type="button" disabled={!client || sculptRemeshBusy} onClick={() => { void applySculptRemesh(); }}>Voxel Remesh</button>
{sculptRemeshError && <p role="alert">{sculptRemeshError}</p>}</div><output>{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces</output></div> : null}
{activeMesh ? <div className="property-section">
{activeMesh ? <div className="property-section" data-testid="material-slots-section">
<h3>Material Slots</h3>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => { const material = snapshot?.materials.find((item) => item.id === id); return <span key={`${id}:${index}`}><input type="radio" name="active-material-slot" aria-label={`激活材质槽 ${index + 1}`} checked={index === activeMaterialSlot} onChange={() => onActiveMaterialSlotChange(index)} />{index + 1}. <select aria-label={`材质槽 ${index + 1}`} value={id} disabled={materialSlotsReadOnly || !activeNode} onChange={(event) => activeNode && onCommand({ type: "assignMaterialSlot", objectId: activeNode.id, materialId: event.target.value, slotIndex: index })}>{snapshot?.materials.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}</select>{material?.sourceLibrary ? ` · ${material.sourceLibrary}` : ""}</span>; })}</div>
{materialSlotsReadOnly ? <p role="status">链接对象或网格的材质槽只读。</p> : null}
@@ -4104,12 +4129,15 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
const [areaLayouts,setAreaLayouts]=useState<Record<string,AreaIR[]>>({});
const settingsRef = useRef<WebSettingsFile>(initialWebSettings ?? { schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString(), preferences: structuredClone(DEFAULT_WEB_PREFERENCES), areaLayouts: {}, areaEditorOverrides: {}, cameraLocks: {} });
const settingsLoadedRef = useRef(false);
const [settingsHydrated, setSettingsHydrated] = useState(false);
const startupWorkspaceAppliedRef = useRef(false);
useEffect(() => {
let cancelled = false;
void loadWebSettings().then(({ settings }) => {
if (cancelled) return;
settingsRef.current = settings;
settingsLoadedRef.current = true;
setSettingsHydrated(true);
setWebPreferences(settings.preferences);
setQuickTools(settings.preferences.interface.quickTools);
setShowStatus(settings.preferences.interface.showStatus);
@@ -4380,6 +4408,15 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
}
setUIState((state) => reduceUICommand(state, command));
};
useEffect(() => {
if (startupWorkspaceAppliedRef.current || !settingsHydrated || !settingsLoadedRef.current || !snapshot) return;
startupWorkspaceAppliedRef.current = true;
const parameters = new URLSearchParams(window.location.search);
if (parameters.get("tutorial") || parameters.get("workspace")) return;
const target = webPreferences.interface.startupWorkspace as WorkspaceId;
if (!BLENDER_WORKSPACE_IDS.includes(target)) return;
if (uiState.context.workspaceId !== target) dispatchUI({ type: "switchWorkspace", workspaceId: target });
}, [snapshot, settingsHydrated, webPreferences.interface.startupWorkspace, uiState.context.workspaceId, dispatchUI]);
useEffect(() => {
if (!snapshot?.editorWorkflow || snapshot.editorWorkflowStatus !== "AVAILABLE") {
setEditorWorkflow(null);
@@ -8034,7 +8071,7 @@ else:
menuAction('完整桌面工作区',()=>{void openFullDesktop();},!hasProject),separator,menuAction('下一个工作区',()=>cycleWorkspace(1)),menuAction('上一个工作区',()=>cycleWorkspace(-1)),{label:'编辑器',children:NATIVE_EDITORS.map(item=>menuAction(item.label,()=>openNativeEditor(item.id),!hasProject))},{label:'显示状态栏',checked:showStatus,action:()=>setShowStatus(!showStatus)},separator,
menuAction('保存视口截图',()=>{const canvas=document.querySelector<HTMLCanvasElement>('canvas.viewport-canvas');if(!canvas)return;canvas.toBlob(blob=>{if(!blob)return;const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='viewport.png';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);});},workspace==='Rendering'||new URLSearchParams(location.search).get('offscreen')==='1'),
{label:'Web 扩展',children:[menuAction('六轴机器人工具',()=>setRobotToolOpen(true)),menuAction('原生桌面会话',()=>setInteractiveDesktop({context:nativeContext()}),!hasProject),{label:'显示快捷操作栏',checked:quickTools,action:()=>setQuickTools(!quickTools)}]}],
'帮助':[menuAction('使用手册',()=>{window.open('/manuals/WEB_Blender_Function_User_Guide.pdf','_blank','noopener');}),menuAction('菜单操作对照',()=>{window.open('/manuals/WEB_Blender_Desktop_Menus.pdf','_blank','noopener');}),menuAction('导航操作说明',()=>{window.open('/manuals/WEB_Blender_Viewport_Navigation.pdf','_blank','noopener');}),menuAction('机器人工具说明',()=>{window.open('/manuals/WEB_Blender_Robot_Tool_User_Manual.pdf','_blank','noopener');}),menuAction('零件建模教学',()=>{window.open('/?tutorial=parts','_blank','noopener');}),menuAction('二级减速器教学',()=>{window.open('/?tutorial=reducer','_blank','noopener');}),menuAction('PBR 渲染教学',()=>{window.open('/?ui=web&tutorial=pbr','_blank','noopener');}),menuAction('PBR 渲染图文/视频教案',()=>{window.open('/tutorial/pbr/render-lesson.html','_blank','noopener');}),separator,
'帮助':[menuAction('使用手册(网页版)',()=>{window.open('/manuals/index.html','_blank','noopener');}),menuAction('PDF 手册(旧版下载)',()=>{window.open('/manuals/WEB_Blender_Function_User_Guide.pdf','_blank','noopener');}),menuAction('菜单操作对照',()=>{window.open('/manuals/WEB_Blender_Desktop_Menus.pdf','_blank','noopener');}),menuAction('导航操作说明',()=>{window.open('/manuals/WEB_Blender_Viewport_Navigation.pdf','_blank','noopener');}),menuAction('机器人工具说明',()=>{window.open('/manuals/WEB_Blender_Robot_Tool_User_Manual.pdf','_blank','noopener');}),menuAction('零件建模教学',()=>{window.open('/?tutorial=parts','_blank','noopener');}),menuAction('二级减速器教学',()=>{window.open('/?tutorial=reducer','_blank','noopener');}),menuAction('PBR 渲染教学',()=>{window.open('/?ui=web&tutorial=pbr','_blank','noopener');}),menuAction('PBR 渲染图文/视频教案',()=>{window.open('/tutorial/pbr/render-lesson.html','_blank','noopener');}),separator,
menuAction('导出诊断报告',exportDiagnosticReport,diagnostics.length===0),menuAction('关于 Web Blender',()=>setHeaderDialog('about'))]
};
const primitiveItems:DesktopMenuItem[]=([['CUBE','立方体'],['PLANE','平面'],['UV_SPHERE','UV 球体'],['ICO_SPHERE','棱角球'],['CYLINDER','柱体'],['CONE','锥体'],['TORUS','环体']] as [MeshPrimitive,string][]).map(([type,label])=>menuAction(label,()=>{void applyEditCommand(coreSceneCommands.createPrimitive(type));},!hasProject||uiState.context.mode!=='Object'));
@@ -8695,7 +8732,7 @@ else:
{headerDialog?<DesktopDialog title={{new:'新建工程','save-as':'另存为','save-copy':'保存副本',rename:'重命名',about:'关于 Web Blender',preferences:'偏好设置',history:'撤销历史',export:'导出模型','obj-import':'导入 OBJ 与材质'}[headerDialog]} onClose={()=>{if(!headerPending)setHeaderDialog(null);}}>
{headerDialog==='obj-import'?<OBJImportPanel initialFile={objImportFile} disabled={headerBusy || projectActionLocked || Boolean(localInvokePending) || localInvokeActive || nativeModalActive} onPrepare={prepareOBJImport}/>:null}
{headerDialog==='save-copy'&&<p>下载当前工程的副本,并继续编辑当前项目;未保存标记将保留。</p>}
{headerDialog==='save-as'&&<p>另存为会保留原项目,创建并切换到新的浏览器项目,同时下载 .blend 文件。后续保存使用新项目和文件名。</p>}
{headerDialog==='save-as'&&<p>另存为会保留原项目,创建并切换到新的浏览器 OPFS 项目,不会自动下载文件。后续保存使用新项目和文件名;需要本地副本时请使用“文件 → 下载 .blend 到本地”。</p>}
{headerDialog==='export'?<NativeExportPanel key={exportFormat} initialFormat={exportFormat} client={webClientRef.current} snapshot={snapshot} busy={nativeBusy} output={nativeOutput} files={nativeFiles} onRun={runNativeWorkflow} onCancel={()=>nativeAbortRef.current?.abort()}/>:null}
{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}

View File

@@ -1,4 +1,4 @@
.desktop-menu-trigger{display:inline-flex;flex:none;position:relative}.desktop-menu-trigger>button{min-height:24px;padding:3px 7px;background:transparent;color:#d3d3d3;white-space:nowrap;border-radius:2px}.desktop-menu-trigger>button[aria-expanded=true]{background:#454545}.desktop-menu-panel{position:fixed;z-index:1500;width:280px;max-width:calc(100vw - 8px);padding:5px;background:#282b2a;color:#e0e3e1;border:1px solid #656b67;box-shadow:0 7px 20px #0007;border-radius:4px;overflow:auto}.desktop-menu-panel>button{display:flex;position:relative;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:7px;width:100%;min-height:28px;text-align:left;padding:5px 9px;color:inherit;background:transparent;border:0;border-radius:2px;font-size:12px}.desktop-menu-panel>button:hover:enabled,.desktop-menu-panel>button:focus-visible{background:#44644f;color:white;outline:1px solid #bdd7c4}.desktop-menu-panel>button:disabled{color:#949c96;cursor:default}.desktop-menu-panel small{display:block;flex-basis:100%;font-size:10px;color:#a7b1a9;white-space:normal;line-height:1.4}.desktop-menu-shortcut{color:#b2beb5;font-size:10px}.desktop-menu-panel [role=separator]{height:1px;background:#49514b;margin:5px 3px}.desktop-menu-back{border-bottom:1px solid #657268!important;margin-bottom:4px}.desktop-header-menus{display:flex;align-items:center;min-width:0;gap:1px}.desktop-selection-controls{display:flex;align-items:center;gap:3px;margin-left:8px}.desktop-header-label{color:#cbd4ce;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:11px}.desktop-dialog-backdrop{position:fixed;inset:0;z-index:1600;background:#0006;display:flex;align-items:center;justify-content:center;padding:14px}.desktop-dialog{background:#292d2a;border:1px solid #87948b;border-radius:5px;width:440px;max-width:100%;max-height:90vh;overflow:auto;padding:14px;color:#e3e8e4}.desktop-dialog header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.desktop-dialog h2{font-size:16px;margin:0}.desktop-dialog button{padding:6px 11px;color:inherit;background:#49534c;border:1px solid #7c8b81;border-radius:3px}.desktop-dialog header button{padding:2px;width:27px;height:27px}.desktop-dialog input:not([type=checkbox]){width:100%;padding:7px;background:#1e211f;border:1px solid #6d7c72;color:inherit}.desktop-dialog p{font-size:12px;line-height:1.7}.desktop-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:15px}.desktop-dialog label{display:flex;gap:8px;align-items:center;margin:12px 0}.workspace-toolbar{overflow:visible}.desktop-header-actions{display:flex;min-width:0;align-items:center;overflow-x:auto;scrollbar-width:none}.legacy-quick-toolbar{display:none}.blender-app:not([data-native-canvas=true]) .legacy-quick-toolbar,.blender-app[data-quick-tools=true] .legacy-quick-toolbar{display:flex;position:absolute;top:58px;left:0;right:0;z-index:10;background:#303532;overflow-x:auto;gap:4px;padding:4px}.legacy-quick-toolbar>button{white-space:nowrap;min-height:27px;padding:3px 7px;background:#414942}.blender-app[data-hide-status=true] .status-bar{display:none}.blender-app[data-hide-status=true]{grid-template-rows:28px 30px minmax(0,1fr) 0}.workspace-menu-control{flex:none}.workspace-tabs{min-width:90px}.workspace-tabs .workspace-menu-control{position:sticky;right:0;background:#222}.viewport-header-status{margin-left:auto;display:flex;align-items:center;gap:12px;font-size:11px;white-space:nowrap;padding-right:8px}.topbar-actions .extension-robot{color:#bbd4c3}
.desktop-menu-trigger{display:inline-flex;flex:none;position:relative}.desktop-menu-trigger>button{min-height:24px;padding:3px 7px;background:transparent;color:#d3d3d3;white-space:nowrap;border-radius:2px}.desktop-menu-trigger>button[aria-expanded=true]{background:#454545}.desktop-menu-panel{position:fixed;z-index:1500;width:280px;max-width:calc(100vw - 8px);padding:5px;background:#282b2a;color:#e0e3e1;border:1px solid #656b67;box-shadow:0 7px 20px #0007;border-radius:4px;overflow:auto}.desktop-menu-panel>button{display:flex;position:relative;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:7px;width:100%;min-height:28px;text-align:left;padding:5px 9px;color:inherit;background:transparent;border:0;border-radius:2px;font-size:12px}.desktop-menu-panel>button:hover:enabled,.desktop-menu-panel>button:focus-visible{background:#44644f;color:white;outline:1px solid #bdd7c4}.desktop-menu-panel>button:disabled{color:#949c96;cursor:default}.desktop-menu-panel small{display:block;flex-basis:100%;font-size:10px;color:#a7b1a9;white-space:normal;line-height:1.4}.desktop-menu-shortcut{color:#b2beb5;font-size:10px}.desktop-menu-panel [role=separator]{height:1px;background:#49514b;margin:5px 3px}.desktop-menu-back{border-bottom:1px solid #657268!important;margin-bottom:4px}.desktop-header-menus{display:flex;align-items:center;min-width:0;gap:1px}.desktop-selection-controls{display:flex;align-items:center;gap:3px;margin-left:8px}.desktop-header-label{color:#cbd4ce;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:11px}.desktop-dialog-backdrop{position:fixed;inset:0;z-index:1600;background:#0006;display:flex;align-items:center;justify-content:center;padding:14px}.desktop-dialog{background:#292d2a;border:1px solid #87948b;border-radius:5px;width:440px;max-width:100%;max-height:90vh;overflow:auto;padding:14px;color:#e3e8e4}.desktop-dialog header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.desktop-dialog h2{font-size:16px;margin:0}.desktop-dialog button{padding:6px 11px;color:inherit;background:#49534c;border:1px solid #7c8b81;border-radius:3px}.desktop-dialog header button{padding:2px;width:27px;height:27px}.desktop-dialog input:not([type=checkbox]){width:100%;padding:7px;background:#1e211f;border:1px solid #6d7c72;color:inherit}.desktop-dialog p{font-size:12px;line-height:1.7}.desktop-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:15px}.desktop-dialog label{display:flex;gap:8px;align-items:center;margin:12px 0}.workspace-toolbar{overflow:visible}.desktop-header-actions{display:flex;min-width:0;align-items:center;overflow-x:auto;scrollbar-width:none}.legacy-quick-toolbar{display:none}.blender-app[data-quick-tools=true] .legacy-quick-toolbar{display:flex;position:absolute;top:58px;left:0;right:0;z-index:10;background:#303532;overflow-x:auto;gap:4px;padding:4px}.legacy-quick-toolbar>button{white-space:nowrap;min-height:27px;padding:3px 7px;background:#414942}.blender-app[data-hide-status=true] .status-bar{display:none}.blender-app[data-hide-status=true]{grid-template-rows:28px 30px minmax(0,1fr) 0}.workspace-menu-control{flex:none}.workspace-tabs{min-width:90px}.workspace-tabs .workspace-menu-control{position:sticky;right:0;background:#222}.viewport-header-status{margin-left:auto;display:flex;align-items:center;gap:12px;font-size:11px;white-space:nowrap;padding-right:8px}.topbar-actions .extension-robot{color:#bbd4c3}
@media(max-width:700px){.desktop-header-label{display:none}.viewport-header-status{gap:3px;font-size:10px}.desktop-selection-controls{margin-left:0}.desktop-header-menus{max-width:100%;overflow-x:auto;scrollbar-width:none}}
@media(max-width:900px){.topbar .menu-bar{display:flex}.topbar .menu-bar button{padding-inline:5px}.topbar-actions>select,.topbar-actions>.topbar-context,.topbar-actions>.extension-robot{display:none}.workspace-tabs .workspace-tab:nth-child(n+4){display:initial}.workspace-tabs .workspace-tab{flex:none}.workspace-tabs{min-width:40px}.workspace-menu-control>button{font-size:0;width:26px;padding:3px}.workspace-menu-control>button::after{content:'▾';font-size:14px}}
@media(max-width:550px){.topbar-actions{display:none}.workspace-tabs{min-width:48px}.brand{padding:0}.topbar .menu-bar button{padding-inline:4px;font-size:11px}}

View File

@@ -38,7 +38,7 @@ export function TutorialLanding({ course }: { course: TutorialCourse }) {
return <main className="parts-tutorial-landing" data-course={course.id}>
<header className="parts-landing-top">
<a className="parts-landing-brand" href="/" aria-label="返回 Web Blender"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span>Blender Web</span></a>
<nav aria-label="教程导航"><a href="/manuals/WEB_Blender_Function_User_Guide.pdf" target="_blank" rel="noopener">使用手册</a><a href="/tutorial/full/index.html" target="_blank" rel="noopener">全站入门课程</a>{course.lessonUrl ? <a href={course.lessonUrl} target="_blank" rel="noopener">渲染图文/视频教案</a> : null}<a href="/" className="parts-landing-return">直接打开 Blender</a></nav>
<nav aria-label="教程导航"><a href="/manuals/index.html" target="_blank" rel="noopener">使用手册</a><a href="/manuals/WEB_Blender_Function_User_Guide.pdf" target="_blank" rel="noopener">PDF 版</a><a href="/tutorial/full/index.html" target="_blank" rel="noopener">全站入门课程</a>{course.lessonUrl ? <a href={course.lessonUrl} target="_blank" rel="noopener">渲染图文/视频教案</a> : null}<a href="/" className="parts-landing-return">直接打开 Blender</a></nav>
</header>
<section className="parts-landing-hero">
<div>

View File

@@ -84,7 +84,7 @@ export const DEFAULT_WEB_PREFERENCES: WebPreferencesIR = {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
interface: {
showStatus: true,
quickTools: false,
quickTools: true,
showAreaSwitcher: true,
showObjectInfo: true,
showViewName: true,

View File

@@ -0,0 +1,45 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1600, height: 900 } });
test("HTML manual has chapters, figures, working search and no overflow", async ({ page }) => {
test.setTimeout(240_000);
await page.goto("/manuals/index.html", { waitUntil: "load" });
await expect(page.locator(".manual-chapter")).toHaveCount(28);
await expect(page.locator(".manual-nav a[data-nav-id]")).toHaveCount(32);
await expect(page.getByLabel("搜索手册", { exact: true })).toBeVisible();
await expect(page.locator("figure img").first()).toBeVisible();
const firstImage = await page.locator("figure img").first().evaluate((element) => ({ complete: (element as HTMLImageElement).complete, width: (element as HTMLImageElement).naturalWidth }));
expect(firstImage.complete).toBe(true);
expect(firstImage.width).toBeGreaterThan(0);
await page.getByLabel("搜索手册", { exact: true }).fill("相机");
await expect(page.locator(".manual-search-status")).toContainText("匹配");
await expect.poll(async () => page.locator(".manual-nav a[data-nav-id][hidden]").count()).toBeGreaterThan(0);
await page.getByLabel("搜索手册", { exact: true }).fill("");
await expect(page.locator(".manual-search-status")).toHaveText("");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
test("manual is responsive and reachable from Help", async ({ page, context }) => {
test.setTimeout(240_000);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/manuals/index.html", { waitUntil: "load" });
await expect(page.locator(".manual-menu-toggle")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 1600, height: 900 });
await page.goto("/?ui=web", { waitUntil: "domcontentloaded" });
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.getByRole("button", { name: "帮助", exact: true }).click();
const entry = page.getByRole("menuitem", { name: "使用手册(网页版)", exact: true });
await expect(entry).toBeVisible();
const popupPromise = context.waitForEvent("page", { timeout: 30_000 });
await entry.click();
const popup = await popupPromise;
await popup.waitForLoadState("domcontentloaded");
expect(new URL(popup.url()).pathname).toBe("/manuals/index.html");
await expect(popup.locator(".manual-chapter")).toHaveCount(28);
});

View File

@@ -16,7 +16,7 @@ async function openStudio(page: import("@playwright/test").Page): Promise<void>
}
async function openAreaMenu(page: import("@playwright/test").Page, area: import("@playwright/test").Locator): Promise<void> {
await area.locator(".area-menu button").evaluate((element) => (element as HTMLElement).click());
await area.locator(".area-type-switch .area-menu button").evaluate((element) => (element as HTMLElement).click());
await page.getByRole("menu").last().waitFor({ timeout: 10_000 });
}

View File

@@ -77,4 +77,10 @@ test("Camera to View writes the camera and Camera to View lock moves it", async
const otherCanvas = page.locator(".camera-view-area canvas.viewport-canvas").first();
if (await otherCanvas.count()) await expect(otherCanvas).not.toHaveAttribute("data-camera-locked", "true");
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
const reloaded = page.locator(".viewport-area canvas.viewport-canvas").first();
await reloaded.waitFor({ timeout: 60_000 });
await expect.poll(async () => reloaded.getAttribute("data-camera-locked"), { timeout: 30_000 }).toBe("true");
});

View File

@@ -0,0 +1,49 @@
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");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudio(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 expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
await page.waitForTimeout(700);
}
test("Properties tabs jump to object, modifier and material sections", async ({ page }) => {
test.setTimeout(240_000);
await openStudio(page);
const properties = page.locator(".properties-area");
const modifierTab = properties.locator('button[title="修改器属性"]');
await modifierTab.evaluate((element) => (element as HTMLElement).click());
await expect(modifierTab).toHaveAttribute("aria-selected", "true");
await expect.poll(async () => properties.locator(".modifier-panel").evaluate((element) => {
const box = element.getBoundingClientRect();
const area = element.closest(".properties-area")!.getBoundingClientRect();
return box.top >= area.top - 2 && box.top < area.bottom;
})).toBe(true);
const materialTab = properties.locator('button[title="材质属性"]');
await materialTab.evaluate((element) => (element as HTMLElement).click());
await expect(properties.getByTestId("material-slots-section")).toBeVisible();
await expect(materialTab).toHaveAttribute("aria-selected", "true");
});
test("Editor header ⋮ opens the same area menu as the floating switcher", async ({ page }) => {
test.setTimeout(240_000);
await openStudio(page);
await page.locator(".workspace-tabs button", { hasText: "Scripting" }).evaluate((element) => (element as HTMLElement).click());
await page.waitForTimeout(600);
const headerMenu = page.locator(".viewport-area .editor-header .area-menu button").first();
await expect(headerMenu).toBeVisible({ timeout: 30_000 });
await headerMenu.evaluate((element) => (element as HTMLElement).click());
await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-area-layout", "custom");
await page.locator(".workspace-grid > .area-frame .area-type-switch .area-menu button").first().evaluate((element) => (element as HTMLElement).click());
await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-area-layout", "default");
});

View File

@@ -63,3 +63,20 @@ test("Keymap conflict is rejected and unsupported preference rows are marked", a
await page.getByRole("button", { name: "视口", exact: true }).click();
await expect(page.locator('.preference-row[data-setting="viewport.gizmoSize"]')).toHaveAttribute("data-unsupported", "true");
});
test("Quick toolbar and startup workspace preferences take effect", async ({ page }) => {
test.setTimeout(240_000);
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect(page.locator(".legacy-quick-toolbar")).toBeVisible();
await openPreferences(page);
await page.getByLabel("显示 Web 快捷操作栏", { exact: true }).uncheck();
await page.getByLabel("启动工作区", { exact: true }).selectOption("Animation");
await page.getByRole("button", { name: "保存偏好", exact: true }).click();
await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click();
await expect(page.locator(".legacy-quick-toolbar")).toBeHidden();
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator("main.blender-app").getAttribute("data-workspace"), { timeout: 60_000 }).toBe("Animation");
await expect(page.locator(".legacy-quick-toolbar")).toBeHidden();
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1440, height: 900 } });
async function openSaveAs(page: import("@playwright/test").Page): Promise<void> {
await page.getByRole("button", { name: "文件", exact: true }).click();
await page.getByRole("menuitem", { name: "另存为", exact: true }).click();
await page.getByLabel("另存为文件名", { exact: true }).waitFor({ timeout: 30_000 });
}
test("Save As writes OPFS only and local .blend requires the explicit download action", async ({ page }) => {
test.setTimeout(240_000);
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect(page.getByTestId("scene-stats")).toBeVisible({ timeout: 120_000 });
await openSaveAs(page);
await expect(page.getByText("不会自动下载文件", { exact: false })).toBeVisible();
await page.getByLabel("另存为文件名", { exact: true }).fill("manual-save-as.blend");
const unexpectedDownload = page.waitForEvent("download", { timeout: 2_000 }).then(() => true).catch(() => false);
await page.getByRole("button", { name: "确认", exact: true }).click();
expect(await unexpectedDownload).toBe(false);
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED", { timeout: 60_000 });
const download = page.waitForEvent("download", { timeout: 60_000 });
await page.locator('button[aria-label="下载 .blend"]').evaluate((element) => (element as HTMLElement).click());
expect((await download).suggestedFilename()).toMatch(/\.blend$/);
});