diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index 978cdb434..3f2842363 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -231,9 +231,29 @@ - 线上验证(冷缓存):课程页 15 模块/15 模块视频,M01/M15 视频 200;应用 166 s 进入 SceneIR, 4 个区域切换器、区域 ⋮ 菜单可见;服务 active。 +### 15. 完整 HTML 用户手册与手册驱动的缺口修复 + +- 新单页手册 `web/app/public/manuals/index.html`(`manual.css`/`manual.js`/`manual-search.json`/`images/`), + 28 章 + 6 部分 + 附录 A/B/C/D;侧栏目录、全文搜索、章节锚点、上一/下一章、打印样式;无内联脚本/样式/远程 URL。 +- 源数据 `user-manual/WEB_Blender_Web_Manual_20261113/manual.json` 与 `tools/web/manual/feature-matrix.json` + (31 项可用/部分/仅原生/不可用能力矩阵);生成器/检查器/截图脚本: + `tools/web/manual/{generate-manual.mjs,check-manual.mjs,capture-manual.mjs}`。 +- 截图:新增 14 张真实 Chromium 截图(保存 OPFS、最近项目、恢复点、Area ⋮/分割、相机视图、Outliner、 + Properties 标签、物理、导入/导出菜单、Preferences/键位、帮助),复用课程/PBR 截图 33 张。 +- 手册驱动修复:另存为文案、恢复 `.gltf` 导入、快捷栏开关、启动工作区、Properties 标签跳转、 + Outliner fallback 搜索、区域头 ⋮ 菜单;新增/扩展测试: + `user-manual-smoke`, `web-save-download-semantics`, `web-manual-ui-gaps`, `web-preferences`, `web-camera-view`。 +- 验证:`node tools/web/manual/generate-manual.mjs` PASS(28 章、14 新图,无缺失); + `node tools/web/manual/check-manual.mjs` PASS(28 章、31 能力、7 gap、无过时下载表述); + 组合 E2E **12/12 PASS(1.1m)**。 +- 入口:Help 第一项“使用手册(网页版)” → `/manuals/index.html`,保留 PDF 旧版下载; + `TutorialLanding` 同步加入网页版/PDF 两个入口;手册页顶部提供打开 Web Blender、全站课程、PDF 旧版链接。 +- 待发布到线上 release。 + ## 待实现 -1. Preferences 仍只覆盖高价值设置;主题/系统/完整键位配置未实现(已在 UI 标注不支持及原因)。 +1. 发布包含 HTML 手册与手册驱动缺口修复的新 release,并线上验证 Help 入口/搜索/图片。 +2. Preferences 仍只覆盖高价值设置;主题/系统/完整键位配置未实现(已在 UI 标注不支持及原因)。 ## 已知限制 / 未验证 diff --git a/tests/golden/USER-MANUAL-001/capture-report.json b/tests/golden/USER-MANUAL-001/capture-report.json new file mode 100644 index 000000000..9e5f9e9d9 --- /dev/null +++ b/tests/golden/USER-MANUAL-001/capture-report.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "status": "PASS", + "generatedAt": "2026-11-13T03:55:08.851Z", + "gpuMode": "hardware", + "images": [ + { + "name": "ch01-first-save", + "file": "web/app/public/manuals/images/ch01-first-save.jpg" + }, + { + "name": "ch03-recent", + "file": "web/app/public/manuals/images/ch03-recent.jpg" + }, + { + "name": "ch05-recovery", + "file": "web/app/public/manuals/images/ch05-recovery.jpg" + }, + { + "name": "ch06-area-menu", + "file": "web/app/public/manuals/images/ch06-area-menu.jpg" + }, + { + "name": "ch06-area-split", + "file": "web/app/public/manuals/images/ch06-area-split.jpg" + }, + { + "name": "ch07-camera-view", + "file": "web/app/public/manuals/images/ch07-camera-view.jpg" + }, + { + "name": "ch08-outliner", + "file": "web/app/public/manuals/images/ch08-outliner.jpg" + }, + { + "name": "ch09-properties-tabs", + "file": "web/app/public/manuals/images/ch09-properties-tabs.jpg" + }, + { + "name": "ch21-physics", + "file": "web/app/public/manuals/images/ch21-physics.jpg" + }, + { + "name": "ch25-import-menu", + "file": "web/app/public/manuals/images/ch25-import-menu.jpg" + }, + { + "name": "ch25-export-menu", + "file": "web/app/public/manuals/images/ch25-export-menu.jpg" + }, + { + "name": "ch27-preferences", + "file": "web/app/public/manuals/images/ch27-preferences.jpg" + }, + { + "name": "ch27-keymap", + "file": "web/app/public/manuals/images/ch27-keymap.jpg" + }, + { + "name": "ch28-help", + "file": "web/app/public/manuals/images/ch28-help.jpg" + } + ], + "failures": [] +} diff --git a/tests/golden/USER-MANUAL-001/check-report.json b/tests/golden/USER-MANUAL-001/check-report.json new file mode 100644 index 000000000..fc5374679 --- /dev/null +++ b/tests/golden/USER-MANUAL-001/check-report.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "status": "PASS", + "chapters": 28, + "parts": 6, + "capabilities": 31, + "gaps": 7, + "errors": [] +} diff --git a/tools/web/manual/capture-manual.mjs b/tools/web/manual/capture-manual.mjs new file mode 100644 index 000000000..e140e72a6 --- /dev/null +++ b/tools/web/manual/capture-manual.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * Capture the screenshots referenced by the Web Blender user manual. + * + * Usage: + * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ + * node tools/web/manual/capture-manual.mjs --port 5184 --gpu hardware + */ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "../../.."); +const WEB = path.join(REPO, "web"); +const requireFromWeb = createRequire(path.join(WEB, "package.json")); +const { chromium } = requireFromWeb("playwright"); +const OUT = path.join(WEB, "app/public/manuals/images"); +const STUDIO = path.join(WEB, "app/public/tutorial/pbr/studio.blend"); +const GLB = path.join(REPO, "re/猴头.glb"); +const REPORT = path.join(REPO, "tests/golden/USER-MANUAL-001/capture-report.json"); + +const args = process.argv.slice(2); +const flag = (name, fallback) => { const index = args.indexOf(`--${name}`); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; +const port = Number.parseInt(flag("port", "5184"), 10) || 5184; +const baseUrl = `http://127.0.0.1:${port}`; +const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware"); +const hardwareArgs = ["--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu"]; +const swiftshaderArgs = ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"]; + +async function waitForServer() { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { const response = await fetch(baseUrl); if (response.status < 500) return; } catch { /* retry */ } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + throw new Error(`server unavailable: ${baseUrl}`); +} + +const domClick = (locator) => locator.evaluate((element) => element.click()); + +async function main() { + await waitForServer(); + fs.mkdirSync(OUT, { recursive: true }); + fs.mkdirSync(path.dirname(REPORT), { recursive: true }); + const browser = await chromium.launch({ headless: false, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs }); + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); + const images = []; + const failures = []; + const shot = async (name) => { + const file = path.join(OUT, `${name}.jpg`); + await page.screenshot({ path: file, type: "jpeg", quality: 82 }); + images.push({ name, file: path.relative(REPO, file) }); + console.log(`captured ${name}`); + }; + const closeMenu = async () => { await page.keyboard.press("Escape"); await page.waitForTimeout(200); }; + + try { + await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" }); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); + await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + await page.waitForTimeout(900); + + await domClick(page.locator('button[aria-label="保存项目"]')); + await page.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("data-user-action-save-status") === "SUCCEEDED", null, { timeout: 60_000 }); + await shot("ch01-first-save"); + + await page.getByRole("button", { name: "文件", exact: true }).click(); + await page.getByRole("menuitem", { name: "最近打开", exact: true }).click(); + await page.waitForTimeout(400); + await shot("ch03-recent"); + await closeMenu(); + + await page.getByRole("button", { name: "文件", exact: true }).click(); + await page.getByRole("menuitem", { name: "恢复", exact: true }).click(); + await page.getByRole("menuitem", { name: "恢复浏览器恢复点", exact: true }).click(); + await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).waitFor({ timeout: 30_000 }); + await shot("ch05-recovery"); + await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).getByRole("button", { name: "取消", exact: true }).click(); + await page.waitForTimeout(300); + + const properties = page.locator(".properties-area"); + await properties.locator(".area-type-switch .area-menu button").evaluate((element) => element.click()); + await page.getByRole("menu").last().waitFor({ timeout: 10_000 }); + await shot("ch06-area-menu"); + await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click(); + await page.waitForTimeout(500); + await shot("ch06-area-split"); + await page.locator(".workspace-grid > .area-frame .area-type-switch .area-menu button").first().evaluate((element) => element.click()); + await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click(); + await page.waitForTimeout(400); + + const canvas = page.locator(".viewport-area canvas.viewport-canvas").first(); + await canvas.focus(); + await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click()); + await page.waitForTimeout(700); + await shot("ch07-camera-view"); + await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click()); + await page.waitForTimeout(300); + + const row = page.locator(".tree-row.child, [role=treeitem]").first(); + if (await row.count()) await row.evaluate((element) => element.click()); + await page.waitForTimeout(400); + await shot("ch08-outliner"); + + await properties.locator('button[title="修改器属性"]').evaluate((element) => element.click()); + await page.waitForTimeout(400); + await shot("ch09-properties-tabs"); + + await page.getByText("Physics", { exact: true }).first().scrollIntoViewIfNeeded().catch(() => undefined); + await page.waitForTimeout(400); + await shot("ch21-physics"); + + await page.getByRole("button", { name: "文件", exact: true }).click(); + await page.getByRole("menuitem", { name: "导入", exact: true }).click(); + await page.waitForTimeout(300); + await shot("ch25-import-menu"); + await closeMenu(); + await page.getByRole("button", { name: "文件", exact: true }).click(); + await page.getByRole("menuitem", { name: "导出", exact: true }).click(); + await page.waitForTimeout(300); + await shot("ch25-export-menu"); + await closeMenu(); + + await page.getByRole("button", { name: "编辑", exact: true }).click(); + await page.getByRole("menuitem", { name: "偏好设置", exact: true }).click(); + await page.getByTestId("preferences-window").waitFor({ timeout: 30_000 }); + await shot("ch27-preferences"); + await page.getByRole("button", { name: "键位", exact: true }).click(); + await page.waitForTimeout(300); + await shot("ch27-keymap"); + await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click(); + + await page.getByRole("button", { name: "帮助", exact: true }).click(); + await page.waitForTimeout(300); + await shot("ch28-help"); + await closeMenu(); + } + catch (error) { + failures.push(error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error)); + await page.screenshot({ path: path.join(OUT, "capture-failure.jpg"), type: "jpeg", quality: 82 }).catch(() => undefined); + } + finally { + await browser.close().catch(() => undefined); + const report = { schemaVersion: 1, status: failures.length ? "FAIL" : "PASS", generatedAt: new Date().toISOString(), gpuMode, images, failures }; + fs.writeFileSync(REPORT, JSON.stringify(report, null, 2) + "\n", "utf8"); + console.log(JSON.stringify({ status: report.status, images: images.length, failures: failures.length, report: REPORT }, null, 2)); + if (failures.length) process.exitCode = 1; + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/tools/web/manual/check-manual.mjs b/tools/web/manual/check-manual.mjs new file mode 100644 index 000000000..7bb49cd5a --- /dev/null +++ b/tools/web/manual/check-manual.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Validate the user manual source and generated single-page HTML. + * + * Usage: + * node tools/web/manual/check-manual.mjs + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "../../.."); +const SOURCE = path.join(REPO, "user-manual/WEB_Blender_Web_Manual_20261113/manual.json"); +const MATRIX = path.join(HERE, "feature-matrix.json"); +const OUT = path.join(REPO, "web/app/public/manuals"); +const PUBLIC = path.join(REPO, "web/app/public"); +const REPORT = path.join(REPO, "tests/golden/USER-MANUAL-001/check-report.json"); +const errors = []; + +const manual = JSON.parse(fs.readFileSync(SOURCE, "utf8")); +const matrix = JSON.parse(fs.readFileSync(MATRIX, "utf8")); +const htmlPath = path.join(OUT, "index.html"); +const html = fs.existsSync(htmlPath) ? fs.readFileSync(htmlPath, "utf8") : ""; +if (!html) errors.push("missing manuals/index.html"); +if (manual.chapters.length < 25 || manual.chapters.length > 32) errors.push(`chapter count ${manual.chapters.length} outside 25-32`); +if (new Set(manual.chapters.map((chapter) => chapter.id)).size !== manual.chapters.length) errors.push("duplicate chapter ids"); + +const chapterIds = new Set(manual.chapters.map((chapter) => chapter.id)); +for (const chapter of manual.chapters) { + if (!chapter.title || !chapter.summary) errors.push(`${chapter.id} missing title/summary`); + if (!Array.isArray(chapter.steps) || chapter.steps.length < 3) errors.push(`${chapter.id} needs at least 3 steps`); + if (!chapter.steps.some((step) => step.image)) errors.push(`${chapter.id} has no screenshot`); + for (const prerequisite of chapter.prerequisites ?? []) if (!chapterIds.has(prerequisite)) errors.push(`${chapter.id} prerequisite ${prerequisite} missing`); + for (const step of chapter.steps) { + if (!step.title || !step.text) errors.push(`${chapter.id} step missing title/text`); + if (!step.image) continue; + const file = step.image.startsWith("/") ? path.join(PUBLIC, step.image.slice(1)) : path.join(OUT, step.image); + if (!fs.existsSync(file)) errors.push(`${chapter.id} missing image ${step.image}`); + } +} +const workspaces = ["Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading", "Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting"]; +for (const workspace of workspaces) { + if (!manual.chapters.some((chapter) => (chapter.workspaces ?? []).includes(workspace))) errors.push(`workspace not covered: ${workspace}`); +} +const editorKeywords = ["3D Viewport", "Outliner", "Properties", "Timeline", "Dope Sheet", "Graph Editor", "Image Editor", "Shader Editor", "Compositor", "Geometry Nodes", "Spreadsheet", "Text Editor", "Python Console", "Info"]; +const corpus = JSON.stringify(manual); +for (const keyword of editorKeywords) if (!corpus.includes(keyword)) errors.push(`editor not covered: ${keyword}`); + +for (const part of manual.parts ?? []) { + for (const id of part.chapterIds) if (!chapterIds.has(id)) errors.push(`part ${part.id} references missing chapter ${id}`); +} +if (!manual.storagePolicy?.rules?.some((rule) => rule.includes("OPFS"))) errors.push("storage policy does not mention OPFS"); +if (!manual.storagePolicy?.rules?.some((rule) => rule.includes("下载"))) errors.push("storage policy does not mention manual download"); +const stale = [/同时下载 \.blend 文件/]; +for (const pattern of stale) if (pattern.test(corpus)) errors.push(`stale save/download claim: ${pattern}`); +if (!manual.gaps?.length) errors.push("manual gap register is empty"); +for (const gap of manual.gaps ?? []) { + if (gap.status === "FIXED") { + for (const file of gap.files ?? []) if (!fs.existsSync(path.join(REPO, file))) errors.push(`gap ${gap.id} missing source ${file}`); + for (const test of gap.tests ?? []) if (!fs.existsSync(path.join(REPO, test))) errors.push(`gap ${gap.id} missing test ${test}`); + } +} + +const validStatuses = new Set(Object.keys(matrix.legend)); +for (const capability of matrix.capabilities ?? []) { + if (!validStatuses.has(capability.status)) errors.push(`invalid matrix status ${capability.status}`); + if (capability.chapter && !chapterIds.has(capability.chapter)) errors.push(`matrix chapter missing ${capability.chapter}`); +} +const areas = new Set((matrix.capabilities ?? []).map((capability) => capability.area)); +for (const area of ["Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading", "Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting", "Files", "IO", "Settings"]) if (!areas.has(area)) errors.push(`matrix area missing ${area}`); + +if (html) { + const chapters = (html.match(/class="manual-chapter"/g) ?? []).length; + if (chapters !== manual.chapters.length) errors.push(`html chapters ${chapters} != ${manual.chapters.length}`); + if (!/
]*\bsrc=)[^>]*>/i.test(html)) errors.push("inline script block"); + if (/\son[a-z]+\s*=/i.test(html)) errors.push("inline event handler"); + if (/https?:\/\//i.test(html)) errors.push("remote URL in manual"); + if (!/ + + +
+ + Web Blender 用户手册 + + + +
+
+ +
+
+

UM-${escapeHtml(manual.version)} · ${escapeHtml(manual.lastVerified)} · ${escapeHtml(manual.desktopVersion)}

+

${escapeHtml(manual.title)}

+

${escapeHtml(manual.intro)}

+
存储规则
    ${manual.storagePolicy.rules.map((rule) => `
  • ${escapeHtml(rule)}
  • `).join("")}
+
+ ${chaptersHtml} + ${support} + ${appendixHtml} +
手册版本 ${escapeHtml(manual.version)} · 验证日期 ${escapeHtml(manual.lastVerified)} · 源码 ${escapeHtml(manual.sourceCommit)} · 不宣称完整桌面 Blender parity。
+
+
+ + +`; + +const css = `: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} } +`; + +const js = `(() => { + 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); +})(); +`; + +fs.mkdirSync(OUT, { recursive: true }); +fs.mkdirSync(path.join(OUT, "images"), { recursive: true }); +fs.writeFileSync(path.join(OUT, "index.html"), html, "utf8"); +fs.writeFileSync(path.join(OUT, "manual.css"), css, "utf8"); +fs.writeFileSync(path.join(OUT, "manual.js"), js, "utf8"); +fs.writeFileSync(path.join(OUT, "manual-search.json"), JSON.stringify({ schemaVersion: 1, chapters: manual.chapters.map((chapter) => ({ id: chapter.id, title: chapter.title, summary: chapter.summary })) }, null, 2) + "\n", "utf8"); +const manifest = { schemaVersion: 1, generatedAt: new Date().toISOString(), version: manual.version, lastVerified: manual.lastVerified, sourceCommit: manual.sourceCommit, images: [...new Set(manual.chapters.flatMap((chapter) => chapter.steps.map((step) => step.image).filter(Boolean)))].map((src) => ({ src, file: src.startsWith("/") ? src : `/${src}`, exists: existing.has(src) || fs.existsSync(src.startsWith("/") ? path.join(PUBLIC, src.slice(1)) : path.join(OUT, src)) })) }; +fs.writeFileSync(path.join(OUT, "images", "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8"); +console.log(JSON.stringify({ status: missing.length ? "MISSING_ASSETS" : "PASS", chapters: manual.chapters.length, appendices: manual.appendices.length, missing, output: path.join(OUT, "index.html") }, null, 2)); +if (missing.length) process.exitCode = 1; diff --git a/user-manual/WEB_Blender_Web_Manual_20261113/manual.json b/user-manual/WEB_Blender_Web_Manual_20261113/manual.json new file mode 100644 index 000000000..c38eb3ac4 --- /dev/null +++ b/user-manual/WEB_Blender_Web_Manual_20261113/manual.json @@ -0,0 +1,546 @@ +{ + "schemaVersion": 1, + "id": "web-blender-manual", + "locale": "zh-CN", + "title": "Web Blender 用户手册", + "version": "1.0.0", + "lastVerified": "2026-11-13", + "sourceCommit": "aa8cc1313", + "desktopVersion": "Blender 5.2.0 LTS", + "intro": "本手册对标当前 Web Blender(?ui=web)的实际功能,按真实界面和真实操作步骤编写。工程保存、重开和恢复使用浏览器 OPFS;需要本地文件时使用独立的“下载”动作。手册不宣称完整桌面 Blender parity,未实现项在支持矩阵和限制章节中如实列出。", + "storagePolicy": { + "platformSave": "OPFS", + "rules": [ + "顶栏“保存项目”、Ctrl+S、文件 → 保存到浏览器 OPFS、另存为、自动恢复点只写浏览器存储。", + "需要本地 .blend 时,手动使用顶栏“下载 .blend”或文件 → 下载 .blend 到本地。", + "PNG、GLB、CSV、诊断报告等下载都是独立的手动动作。", + "清除站点数据会删除 OPFS 工程、最近项目索引和偏好设置。" + ] + }, + "parts": [ + { "id": "part-a", "title": "A 入门与数据", "chapterIds": ["ch01", "ch02", "ch03", "ch04", "ch05"] }, + { "id": "part-b", "title": "B 布局与建模", "chapterIds": ["ch06", "ch07", "ch08", "ch09", "ch10", "ch11", "ch12"] }, + { "id": "part-c", "title": "C 曲线、UV、材质与纹理", "chapterIds": ["ch13", "ch14", "ch15", "ch16", "ch17"] }, + { "id": "part-d", "title": "D 程序化、动画与模拟", "chapterIds": ["ch18", "ch19", "ch20", "ch21"] }, + { "id": "part-e", "title": "E 渲染、合成与交换", "chapterIds": ["ch22", "ch23", "ch24", "ch25"] }, + { "id": "part-f", "title": "F 脚本、设置与运维", "chapterIds": ["ch26", "ch27", "ch28"] } + ], + "chapters": [ + { + "id": "ch01", "number": 1, "part": "part-a", "title": "快速开始:首次加载、打开工程与第一次保存", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "从浏览器打开 Web Blender,等待 WASM 就绪,载入练习工程并把工程保存到 OPFS。", + "objectives": ["识别 WASM 首次加载状态", "打开 .blend 并确认 SceneIR", "完成一次 OPFS 保存"], + "prerequisites": [], + "desktopReference": "File > Open / Save", + "steps": [ + { "title": "打开 Web 界面", "text": "在 Chromium 访问 /?ui=web,等待底栏显示 WASM 就绪与 Engine: SceneIR。远端首次加载需要下载约 79 MB wasm,页面会显示进度。", "image": "/tutorial/full/steps/M01-interface.jpg", "alt": "Web Blender 顶栏、工作区标签、视口、大纲与属性面板" }, + { "title": "载入练习工程", "text": "点击顶栏“打开 .blend”,选择课程工程 studio.blend(绝对路径:/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/studio.blend),等待对象数变为 6。", "image": "/tutorial/full/steps/M01-file-menu.jpg", "alt": "文件菜单与打开 .blend 入口" }, + { "title": "保存到 OPFS", "text": "点击顶栏“保存项目”。状态栏显示 SUCCEEDED 后,工程已写入浏览器 OPFS;此动作不会下载文件。", "image": "/manuals/images/ch01-first-save.jpg", "alt": "保存项目按钮与保存成功状态" } + ], + "checks": ["底栏 Engine 显示 SceneIR", "对象数与工程文件一致", "保存状态为 SUCCEEDED"], + "tips": ["用 F3 搜索“打开”“保存到浏览器 OPFS”也可以执行同一入口。"], + "limits": ["本地文件不会自动出现;需要时使用“下载 .blend”。"], + "links": [{ "label": "全站课程 M01", "href": "/tutorial/full/index.html#M01" }] + }, + { + "id": "ch02", "number": 2, "part": "part-a", "title": "界面总览:工作区、编辑器、状态栏与命令搜索", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading", "Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting"], + "summary": "认识 11 个工作区、区域、状态栏和 F3 操作搜索,知道在哪里找命令与反馈。", + "objectives": ["切换工作区", "识别状态栏反馈", "用 F3 搜索并执行命令"], + "prerequisites": ["ch01"], + "desktopReference": "Workspaces / F3 Search", + "steps": [ + { "title": "认识工作区", "text": "顶部工作区标签包括 Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting;每个工作区有固定的默认区域布局。", "image": "/tutorial/full/steps/M01-interface.jpg", "alt": "顶部工作区标签与整体界面" }, + { "title": "使用状态栏", "text": "底栏显示活动对象、对象/顶点/面数量、WASM 与 Engine 状态、存储预算和版本;操作失败时先看 Engine 状态。", "image": "/manuals/images/ch01-first-save.jpg", "alt": "底部状态栏和保存状态" }, + { "title": "F3 命令搜索", "text": "按 F3,输入命令名(如“移动”“Export GLB”),选择带执行位置标签的条目。原生命令可能显示 DESKTOP/SERVER_NATIVE;浏览器等价命令显示 BROWSER。", "image": "/tutorial/full/steps/M02-operator-search.jpg", "alt": "F3 操作搜索面板与命令结果" } + ], + "checks": ["11 个工作区标签齐全", "F3 能搜索到命令", "状态栏能看到 Engine/存储状态"], + "tips": ["F3 搜索是找隐藏入口的首选,不必先猜菜单位置。"], + "limits": ["灰色菜单项或带 unavailable 标记的命令尚未实现等价入口。"], + "links": [] + }, + { + "id": "ch03", "number": 3, "part": "part-a", "title": "文件与工程:新建、打开、最近项目、另存为与保存副本", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "掌握当前 Web 版的工程文件入口,理解另存为写 OPFS、保存副本才下载本地副本。", + "objectives": ["新建/打开工程", "使用最近项目", "区分另存为与保存副本"], + "prerequisites": ["ch01"], + "desktopReference": "File > New/Open/Save As/Save Copy", + "steps": [ + { "title": "新建与打开", "text": "文件 → 新建 → 常规创建默认场景;文件 → 打开或顶栏“打开 .blend”载入工程。打开期间状态栏显示读取进度,可取消。", "image": "/tutorial/full/steps/M01-file-menu.jpg", "alt": "文件菜单中的新建与打开入口" }, + { "title": "最近项目", "text": "顶栏“最近项目”下拉或文件 → 最近打开列出显式保存过的工程;条目损坏时会提示“项目内容缺失/校验失败”,可移除引用。", "image": "/manuals/images/ch03-recent.jpg", "alt": "最近项目菜单与工程条目" }, + { "title": "另存为与保存副本", "text": "另存为在 OPFS 创建并切换到新工程,不下载;保存副本下载本地 .blend,且不改变当前工程。需要本地工程时也可用顶栏“下载 .blend”。", "image": "/manuals/images/ch01-first-save.jpg", "alt": "顶栏保存与下载入口" } + ], + "checks": ["最近项目能重开已保存工程", "另存为后工程名/项目切换", "保存副本产生下载文件"], + "tips": ["同名 .blend 会映射到同一个 OPFS 项目;需要隔离时使用另存为。"], + "limits": ["“另存为会下载”是过时说明;当前只写 OPFS。"], + "links": [] + }, + { + "id": "ch04", "number": 4, "part": "part-a", "title": "浏览器存储:OPFS 保存、重开与手动本地下载", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "理解 OPFS 工程布局、最近项目索引、恢复点与手动下载的边界。", + "objectives": ["说明 OPFS/IndexedDB 分工", "保存并重开工程", "手动下载 .blend"], + "prerequisites": ["ch01", "ch03"], + "desktopReference": "File > Save / Save As", + "steps": [ + { "title": "保存到 OPFS", "text": "保存项目写入 projects//scene.blend 及元数据;快照、资源与恢复点也在同一项目目录下。工程不会出现在浏览器下载目录。", "image": "/tutorial/full/steps/M14-opfs-save.jpg", "alt": "保存到 OPFS 后的状态栏反馈" }, + { "title": "刷新并重开", "text": "刷新页面后,在最近项目中选择刚保存的工程;若询问是否保存当前修改,选择“不保存,继续”。对象、材质、UV/节点等 Main 数据应恢复。", "image": "/tutorial/full/steps/M14-reopen.jpg", "alt": "刷新后从最近项目重开工程" }, + { "title": "手动下载 .blend", "text": "顶栏“下载 .blend”或文件 → 下载 .blend 到本地会先把工程提交到 OPFS,再下载 <工程名>.blend。外部未打包资源不会自动包含。", "image": "/manuals/images/ch01-first-save.jpg", "alt": "手动下载 .blend 的顶栏入口" } + ], + "checks": ["重开后对象数一致", "本地下载只在手动动作后出现", "状态栏显示 OPFS 保存成功"], + "tips": ["课程与测试的重开流程都使用最近项目/OPFS,不依赖自动下载。"], + "limits": ["清除站点数据会删除 OPFS 工程与最近项目索引;本地 .blend 是持久备份。"], + "links": [{ "label": "存储与恢复课程 M14", "href": "/tutorial/full/index.html#M14" }] + }, + { + "id": "ch05", "number": 5, "part": "part-a", "title": "撤销、自动恢复点、恢复与诊断", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "使用撤销/重做、浏览器自动恢复点、最近项目修复和诊断报告处理错误。", + "objectives": ["撤销/重做 Main 编辑", "恢复自动恢复点", "导出诊断报告"], + "prerequisites": ["ch04"], + "desktopReference": "Edit > Undo / File > Recover Auto Save", + "steps": [ + { "title": "撤销与重做", "text": "编辑 → 撤销/重做,或 Ctrl+Z/Ctrl+Y;撤销历史列出可回退的 Main 版本。工作区切换不会产生 Main 撤销步。", "image": "/manuals/images/ch05-recovery.jpg", "alt": "自动恢复点对话框" }, + { "title": "恢复自动恢复点", "text": "文件 → 恢复 → 恢复浏览器恢复点,选择项目和恢复点后点“恢复选中版本”。自动恢复点不标记工程已保存,也不更新最近项目。", "image": "/manuals/images/ch05-recovery.jpg", "alt": "选择项目和恢复点的恢复对话框" }, + { "title": "诊断与修复", "text": "最近项目损坏时可按条目移除引用;失败操作可点击顶栏“!”或帮助 → 导出诊断报告,生成 blender-web-diagnostics.json。", "image": "/manuals/images/ch05-recovery.jpg", "alt": "恢复与诊断入口所在对话框" } + ], + "checks": ["撤销后数据回到上一版本", "恢复点能载入指定 revision", "诊断 JSON 可下载"], + "tips": ["恢复点只保留最近数份;重要节点前先保存 OPFS 工程。"], + "limits": ["恢复点是浏览器存储,不替代本地备份;原生 autosave 是另一套入口。"], + "links": [] + }, + { + "id": "ch06", "number": 6, "part": "part-b", "title": "工作区与 Area 布局:切换、⋮ 菜单、分割与持久化", "level": "入门", + "workspace": "Layout", "workspaces": ["Layout", "Modeling", "Rendering", "Geometry Nodes"], + "summary": "用区域右上角选择器切换编辑器,用 ⋮ 菜单分割/合并/交换/最大化区域,并保存布局。", + "objectives": ["切换区域编辑器", "分割和合并区域", "重开后恢复布局"], + "prerequisites": ["ch02"], + "desktopReference": "Area > Split/Join/Swap, editor type menu", + "steps": [ + { "title": "切换编辑器", "text": "每个区域右上角有编辑器选择器,可切换为 3D Viewport、Outliner、Properties、Spreadsheet、节点编辑器、Image Editor、Text Editor、Python Console、Timeline/Graph 等。", "image": "/manuals/images/ch06-area-menu.jpg", "alt": "区域右上角编辑器选择器与 ⋮ 菜单" }, + { "title": "分割与合并", "text": "打开区域 ⋮ → 水平/垂直分割;合并/停靠选择相邻方向;交换编辑器、复制区域、最大化/聚焦、关闭和重置布局也在同一菜单。首次操作会把当前工作区切换为自定义布局。", "image": "/manuals/images/ch06-area-split.jpg", "alt": "分割后的双属性区域与区域菜单" }, + { "title": "布局持久化", "text": "布局、编辑器覆盖和每视口相机锁定写入 OPFS settings/web-interface.json;刷新页面后恢复。选择“重置工作区布局”回到默认 CSS 布局。", "image": "/manuals/images/ch06-area-split.jpg", "alt": "自定义区域布局" } + ], + "checks": ["区域可切换编辑器", "分割/合并后区域数量正确", "刷新后布局保留", "重置恢复默认"], + "tips": ["区域头部的 ⋮ 与右上角浮动 ⋮ 打开同一个菜单。"], + "limits": ["复制区域用拆分承载,不创建新浏览器窗口;暂无拖拽分隔条 resize。"], + "links": [{ "label": "区域布局测试路径", "href": "/tutorial/full/index.html" }] + }, + { + "id": "ch07", "number": 7, "part": "part-b", "title": "3D 视口:导航、选择、变换与相机视图", "level": "基础", + "workspace": "Layout", "workspaces": ["Layout", "Modeling", "Animation"], + "summary": "使用鼠标与键盘导航视口,选择对象/网格元素,执行变换并进入相机视图或锁定相机。", + "objectives": ["旋转/平移/缩放视口", "选择对象与网格元素", "切换相机视图并锁定"], + "prerequisites": ["ch06"], + "desktopReference": "View3D navigation, Numpad0 Camera View, lock_camera", + "steps": [ + { "title": "导航", "text": "中键拖动旋转,Shift+中键平移,滚轮缩放;小键盘 1/3/7 切换轴视图,5 切换透视/正交,Home 显示全部,Numpad . 框显所选。", "image": "/tutorial/full/steps/M02-layout.jpg", "alt": "Layout 工作区 3D 视口与导航工具栏" }, + { "title": "选择与变换", "text": "左键选择对象,Ctrl/Shift 追加/范围选择;G/R/S 进入移动/旋转/缩放。编辑模式支持顶点/边/面选择(快捷栏 1/2/3)。", "image": "/tutorial/full/steps/M03-mesh-selection.jpg", "alt": "编辑模式网格元素选择" }, + { "title": "相机视图与锁定", "text": "按 Numpad0 或工具栏“相机视图”对齐场景相机;再次触发恢复上一视图。未锁定时 orbit 被拒绝,pan/zoom 只做取景;“锁定相机”(Camera to View)允许导航并把相机 pose 写回对象,Ctrl+Alt+Numpad0 对齐相机到当前视图。", "image": "/manuals/images/ch07-camera-view.jpg", "alt": "相机视图按钮与锁定按钮状态" } + ], + "checks": ["轴视图与透视切换正常", "编辑模式选择模式可切换", "Numpad0 进入/退出相机视图", "锁定后导航改变相机并可撤销"], + "tips": ["相机锁定按视口保存,刷新后保留。"], + "limits": ["未实现 object-as-camera、自动关键帧/约束/父级 lock 分支。"], + "links": [{ "label": "相机视图测试", "href": "/tutorial/full/index.html" }] + }, + { + "id": "ch08", "number": 8, "part": "part-b", "title": "Outliner:层级、选择、可见性、重命名与拖放", "level": "基础", + "workspace": "Layout", "workspaces": ["Layout", "Scripting"], + "summary": "在大纲视图中选择对象、展开层级、切换可见性、重命名、remap 数据和拖放父子/集合。", + "objectives": ["选择与展开层级", "重命名/可见性", "拖放建立父子或移动集合"], + "prerequisites": ["ch07"], + "desktopReference": "Outliner select/parent/collection drop, remap", + "steps": [ + { "title": "选择与层级", "text": "单击对象行选中;Ctrl 追加,Shift 范围选择;展开按钮或左右方向键展开/折叠,Home/End 与上下键导航。", "image": "/tutorial/full/steps/M02-selection.jpg", "alt": "大纲视图对象选择与层级" }, + { "title": "重命名与可见性", "text": "双击名称或 F2 行内重命名;眼睛按钮切换可见性。链接库对象/数据不可重命名或 remap。", "image": "/manuals/images/ch08-outliner.jpg", "alt": "大纲视图对象行、可见性与重命名" }, + { "title": "拖放与 Remap", "text": "对象拖到对象行建立父子(Shift 允许,Alt 保持变换);拖到集合移动/链接(Ctrl 链接);右键/Shift+F10 打开数据菜单执行 Rename/Remap Users。", "image": "/manuals/images/ch08-outliner.jpg", "alt": "大纲视图拖放与数据菜单入口" } + ], + "checks": ["选择与范围选择正确", "重命名进入 Main", "拖放后父子/集合关系正确"], + "tips": ["大纲头部提供全选/清除/反选/切换与刷新。"], + "limits": ["原生 outliner 上下文不可用时使用简化树;搜索过滤只在简化树可用。"], + "links": [] + }, + { + "id": "ch09", "number": 9, "part": "part-b", "title": "Properties:对象、修改器、材质与场景属性", "level": "基础", + "workspace": "Layout", "workspaces": ["Layout", "Modeling", "Rendering"], + "summary": "使用右侧属性面板查看和编辑变换、层级、修改器、材质、相机、灯光、世界、渲染和物理设置。", + "objectives": ["找到属性分组", "用标签跳转", "编辑并提交属性"], + "prerequisites": ["ch07", "ch08"], + "desktopReference": "Properties editor", + "steps": [ + { "title": "属性分组", "text": "属性面板按 Object & Hierarchy、Modifier Stack、Material Slots、Camera/Light/World、Scene/Render、Physics、View Layer 等分组纵向排列;修改后写入 Main 并可撤销。", "image": "/tutorial/full/steps/M04-properties.jpg", "alt": "属性面板中的变换与对象分组" }, + { "title": "对象/修改器/材质标签", "text": "左侧三个标签分别跳转到对象变换、Modifier Stack、Material Slots。标签会更新选中状态并滚动到目标段落。", "image": "/manuals/images/ch09-properties-tabs.jpg", "alt": "属性面板三个跳转标签与修改器段落" }, + { "title": "编辑与只读状态", "text": "数值/下拉/按钮直接提交;链接对象、相机、集合和材质显示只读提示。相机属性要求单用户数据块。", "image": "/tutorial/full/steps/M04-modifier-stack.jpg", "alt": "修改器栈与参数编辑" } + ], + "checks": ["标签能跳转并选中", "修改属性后视口更新", "只读链接数据不能编辑"], + "tips": ["F3 搜索属性名可快速定位命令,例如 Modifier/Decimate。"], + "limits": ["不是完整 RNA 属性浏览器;部分场景/渲染字段以离散按钮呈现。"], + "links": [] + }, + { + "id": "ch10", "number": 10, "part": "part-b", "title": "对象模式:创建、复制、重命名、父子与 Join", "level": "基础", + "workspace": "Modeling", "workspaces": ["Layout", "Modeling"], + "summary": "创建基础体、复制/链接复制、重命名、删除、父子关系和 Join 合并。", + "objectives": ["创建对象", "复制与清理", "建立父子/Join"], + "prerequisites": ["ch07"], + "desktopReference": "Object mode operators", + "steps": [ + { "title": "创建对象", "text": "Modeling 快捷栏的 Add CUBE/PLANE/UV SPHERE/MONKEY 或菜单“添加”创建对象;也可 F3 使用原生 primitive 操作。", "image": "/tutorial/full/steps/M03-workspace.jpg", "alt": "Modeling 工作区与对象添加快捷栏" }, + { "title": "复制、重命名、删除", "text": "Shift+D 复制,Alt+D 链接复制,F2 重命名,Delete 删除;删除与取消都可撤销。", "image": "/tutorial/full/steps/M03-workspace.jpg", "alt": "对象模式快捷工具" }, + { "title": "父子与 Join", "text": "选中多个对象后 Parent/Unparent 设置或清除父级;Join 合并为一个对象。父级操作可保留或清除变换。", "image": "/tutorial/full/steps/M03-workspace.jpg", "alt": "对象模式下的父级与合并按钮" } + ], + "checks": ["新对象出现在 Outliner", "复制/删除后对象数正确", "Join 后活动对象网格包含其他对象"], + "tips": ["活动对象决定 Join 后的名称和数据。"], + "limits": ["部分高级对象/集合操作仅在菜单或 F3 中提供。"], + "links": [] + }, + { + "id": "ch11", "number": 11, "part": "part-b", "title": "网格编辑与雕刻范围", "level": "基础", + "workspace": "Modeling", "workspaces": ["Modeling", "Sculpting"], + "summary": "进入编辑模式执行选择/挤出/倒角/环切/细分,并在雕刻模式使用有限的笔刷与重构。", + "objectives": ["进入编辑模式", "执行常用拓扑操作", "使用雕刻笔刷/面组"], + "prerequisites": ["ch10"], + "desktopReference": "Mesh edit operators, sculpt brushes", + "steps": [ + { "title": "进入编辑模式", "text": "Modeling 工作区点击模式按钮选择编辑模式;快捷栏切换 Vertex/Edge/Face,Select All 后执行 MERGE/DISSOLVE/EXTRUDE/INSET/BEVEL/LOOP_CUT/SUBDIVIDE 等。", "image": "/tutorial/full/steps/M03-edit-mode.jpg", "alt": "编辑模式下的网格与元素选择" }, + { "title": "常用操作", "text": "面模式可 TRIANGULATE/Recalculate Normals;边模式可 BRIDGE_LOOPS;操作失败时先确认选择模式与元素数量。", "image": "/tutorial/full/steps/M03-mesh-selection.jpg", "alt": "网格元素全选与拓扑操作按钮" }, + { "title": "雕刻范围", "text": "Sculpting 工作区属性面板提供 Draw/Inflate/Grab/Smooth、半径/强度/压力/对称、遮罩、面组和 Voxel Remesh;笔刷从属性面板应用到当前网格,不是连续笔触。", "image": "/tutorial/full/steps/M04-properties.jpg", "alt": "属性面板中的雕刻与网格设置" } + ], + "checks": ["编辑模式可切换选择模式", "拓扑操作后网格更新", "雕刻参数/遮罩写入属性"], + "tips": ["破坏性操作后可用撤销恢复;高面数操作注意存储/性能。"], + "limits": ["无完整建模工具箱、框选/套索网格选择与连续雕刻笔触;高级操作依赖菜单/F3。"], + "links": [{ "label": "建模课程 M03", "href": "/tutorial/full/index.html#M03" }] + }, + { + "id": "ch12", "number": 12, "part": "part-b", "title": "修改器栈:添加、排序、参数、可见性与应用", "level": "基础", + "workspace": "Modeling", "workspaces": ["Modeling", "Layout"], + "summary": "在 Modifier Stack 中添加 Bevel/Boolean/Mirror/Array 等修改器并管理堆栈。", + "objectives": ["添加修改器", "调整参数/顺序", "应用或移除"], + "prerequisites": ["ch09"], + "desktopReference": "Modifier stack", + "steps": [ + { "title": "添加修改器", "text": "Properties → Modifier Stack 输入修改器类型并 Add;列表显示名称、类型、参数和 V/R/E/C 可见性。", "image": "/tutorial/full/steps/M04-modifier-stack.jpg", "alt": "Modifier Stack 与修改器参数" }, + { "title": "排序与参数", "text": "用 ↑/↓、Move to First/Last 调整求值顺序;编辑参数后视口更新。Decimate 提供 Collapse/Un-Subdivide/Dissolve Planar、比例/目标三角面与预览/应用。", "image": "/tutorial/full/steps/M04-modifier-stack.jpg", "alt": "修改器排序和 Decimate 参数" }, + { "title": "应用、复制、移除", "text": "Apply 写入网格并移除修改器;可存为形态键、复制到选中、按名称移除;R 列控制渲染可见性。", "image": "/tutorial/full/steps/M04-properties.jpg", "alt": "属性面板中的修改器与对象设置" } + ], + "checks": ["修改器按顺序求值", "参数变化视口更新", "Apply 后网格变化"], + "tips": ["Boolean 需要封闭网格;Mirror/Array 依赖原点与间距。"], + "limits": ["仅支持已接线的修改器类型;参数以类型元数据生成。"], + "links": [{ "label": "修改器课程 M04", "href": "/tutorial/full/index.html#M04" }] + }, + { + "id": "ch13", "number": 13, "part": "part-c", "title": "非网格对象:曲线、曲面、文本、字体、融球与 Grease Pencil", "level": "基础", + "workspace": "Modeling", "workspaces": ["Modeling", "Layout"], + "summary": "从添加菜单创建曲线/文本/融球/Grease Pencil,编辑控制点或文本,导入本地字体。", + "objectives": ["创建非网格对象", "编辑曲线/文本", "导入 TTF/OTF/PFB"], + "prerequisites": ["ch10"], + "desktopReference": "Curve/Text/Font/Grease Pencil", + "steps": [ + { "title": "创建非网格", "text": "添加菜单包含 Bezier/NURBS 曲线、曲面、文本、骨架、融球、体积、晶格、点云、毛发、Grease Pencil 与力场。", "image": "/tutorial/full/steps/M03-workspace.jpg", "alt": "Modeling 工作区与添加入口" }, + { "title": "曲线/文本编辑", "text": "编辑模式下曲线菜单提供挤出、复制、循环、手柄类型、样条类型、分离、细分、平滑、反向等;文本属性可编辑正文与布局。", "image": "/tutorial/full/steps/M03-edit-mode.jpg", "alt": "编辑模式与对象编辑界面" }, + { "title": "导入字体", "text": "文本属性 → Import TTF/OTF/PFB 选择本地字体(绝对路径任选);字体写入 OPFS 资源并打包进 Main,可设置 Regular/Bold/Italic 链接。", "image": "/tutorial/full/steps/M04-properties.jpg", "alt": "属性面板中的字体/数据设置" } + ], + "checks": ["非网格对象出现在 Outliner", "曲线控制点可编辑", "字体导入后文本外观变化"], + "tips": ["字体文件会进入当前工程资源;保存 OPFS 后重开仍可用。"], + "limits": ["非网格数据的高级编辑受当前 Web 能力矩阵限制;部分操作仅原生/F3。"], + "links": [] + }, + { + "id": "ch14", "number": 14, "part": "part-c", "title": "UV 编辑:选择、展开、打包、变换与缝合", "level": "进阶", + "workspace": "UV Editing", "workspaces": ["UV Editing"], + "summary": "进入可写 UV 区域,全选/变换/展开/打包/缝合 UV,并保存重开。", + "objectives": ["进入 UV 编辑", "展开并打包", "保存重开 UV"], + "prerequisites": ["ch11"], + "desktopReference": "UV Editor, unwrap/pack/stitch", + "steps": [ + { "title": "进入可写 UV 区域", "text": "选中网格,切换到 UV Editing;桌面 Blender 要求 OB_MODE_EDIT,点击“编辑模式”进入。左侧 UV 编辑器显示原生 UV 选择。", "image": "/tutorial/full/steps/M06-uv-workspace.jpg", "alt": "UV Editing 工作区与原生 UV 编辑器" }, + { "title": "选择与展开", "text": "点击“全选 UV”,等待“UV 选择已提交”,再点“展开所选 UV”;可选择角度/共形/最小拉伸、边距、UDIM 目标与自定义区域。", "image": "/tutorial/full/steps/M06-unwrap.jpg", "alt": "展开后的 UV 岛与选择状态" }, + { "title": "打包、变换、缝合", "text": "“打包 UV 岛”设置间距;变换控件执行移动/旋转/缩放;缝合控件使用缝合模式/距离。保存 OPFS 后重开,UV 与选择仍保留。", "image": "/tutorial/full/steps/M06-unwrap.jpg", "alt": "UV 展开结果与控件" } + ], + "checks": ["UV 选择提交成功", "展开/打包改变 UV 布局", "保存重开后 UV 不变"], + "tips": ["展开前标记缝合线可得到更整齐的 UV 岛。"], + "limits": ["编辑依赖可写原生 UV 区域;无完整 UV sculpt/高级工具。"], + "links": [{ "label": "UV 课程 M06", "href": "/tutorial/full/index.html#M06" }] + }, + { + "id": "ch15", "number": 15, "part": "part-c", "title": "材质与 Shader Editor:Principled、图像与预览", "level": "进阶", + "workspace": "Shading", "workspaces": ["Shading"], + "summary": "创建/分配材质,在节点画布编辑 Principled 与图像节点,预览并打包外部贴图。", + "objectives": ["分配材质", "编辑节点/连接", "设置图像节点"], + "prerequisites": ["ch09"], + "desktopReference": "Principled BSDF, Image Texture, Material Preview", + "steps": [ + { "title": "选择材质", "text": "Shading 工作区上方选择材质;Properties → Material Slots 添加/分配材质。链接材质显示只读。", "image": "/tutorial/full/steps/M05-shader-workspace.jpg", "alt": "Shading 工作区、材质槽与节点编辑器" }, + { "title": "节点画布", "text": "启用默认节点生成 Principled + Material Output;添加节点、拖拽节点、连接 socket、重命名、删除、编辑 socket 值和属性。支持 RGB/Value/Mix/Math/Mapping/坐标/UV/分离颜色/对象信息/图像/法线/凹凸/Principled/Output 14 类。", "image": "/tutorial/full/steps/M05-shader-workspace.jpg", "alt": "Shader Editor 节点与材质预览" }, + { "title": "图像与预览", "text": "Image Texture 选择 packed 图像或色彩空间;外部图片通过“外部贴图”入口导入 OPFS 后打包;右侧材质球显示预览,可保存缩略图。", "image": "/tutorial/full/steps/M05-asset-library.jpg", "alt": "材质预览与资产库材质卡片" } + ], + "checks": ["材质槽分配成功", "节点连接后预览变化", "图像节点使用正确贴图"], + "tips": ["Principled 的 Base Color/Roughness/Metallic 会直接影响 PBR 渲染。"], + "limits": ["仅 14 类节点;未知节点会显示不支持并阻止图验证。"], + "links": [{ "label": "材质课程 M05", "href": "/tutorial/full/index.html#M05" }] + }, + { + "id": "ch16", "number": 16, "part": "part-c", "title": "PBR/RE 资产库、Append/Link 与 packed 图像", "level": "进阶", + "workspace": "Shading", "workspaces": ["Shading", "Rendering"], + "summary": "从 PBR/RE 资产库加载模型/材质,理解 Append/Link 与 packed 资源。", + "objectives": ["筛选/搜索资产", "双击添加/分配", "修复资产库"], + "prerequisites": ["ch15"], + "desktopReference": "Asset library, Append/Link, packed images", + "steps": [ + { "title": "选择资产源", "text": "Shading 左下 PBR 资产库可选择 pbr-library、re-pbr 或 HQ 源,按类型/目录/来源筛选,搜索资产名。", "image": "/tutorial/full/steps/M05-asset-library.jpg", "alt": "PBR 资产库搜索、筛选与资产卡片" }, + { "title": "添加或分配", "text": "双击模型添加到场景,双击材质分配到当前材质槽;也可用“添加到场景”“分配到材质槽”。资产库元数据与预览存 OPFS/IndexedDB。", "image": "/tutorial/full/steps/M05-asset-library.jpg", "alt": "RE PBR 材质卡片与分配结果" }, + { "title": "packed 与修复", "text": "材质贴图以 packed 图像进入工程,Properties → Resources 显示 packed 资源;缺失时点“修复资产库”重建。库链接图像绘制前需在 Texture Paint “设为本地”。", "image": "/tutorial/full/steps/M07-painted-stroke.jpg", "alt": "纹理绘制中的 packed 图像与绘制结果" } + ], + "checks": ["资产卡可见/可筛选", "双击材质改变材质槽", "packed 图在重开后仍存在"], + "tips": ["RE 5000PBR 有 6 张材质;HQ 源提供 2048 贴图。"], + "limits": ["库链接对象/材质只读;仅图像有 Make Local 绘制路径。"], + "links": [{ "label": "PBR 课程 M05", "href": "/tutorial/full/index.html#M05" }] + }, + { + "id": "ch17", "number": 17, "part": "part-c", "title": "Texture Paint:画笔、撤销/重做、Make Local 与 OPFS", "level": "进阶", + "workspace": "Texture Paint", "workspaces": ["Texture Paint"], + "summary": "在 packed 图像上绘制,一次笔画一次提交,撤销/重做并保存重开;库链接图先本地化。", + "objectives": ["选择图像", "绘制/擦除", "撤销/重做与持久化"], + "prerequisites": ["ch15", "ch16"], + "desktopReference": "Texture Paint 2D image brush", + "steps": [ + { "title": "选择纹理图像", "text": "Texture Paint 顶部“纹理图像”只列出 packed 图像;没有图像时先在 Shading 分配带 Base Color 图的材质。", "image": "/tutorial/full/steps/M07-texture-paint.jpg", "alt": "Texture Paint 图像选择与画布" }, + { "title": "绘制笔画", "text": "选择绘制/擦除、颜色、大小、强度,在画布按住拖动;松开后显示“笔画已提交”,一次笔画一个 Main revision。", "image": "/tutorial/full/steps/M07-painted-stroke.jpg", "alt": "在贴图上绘制白色笔画后的结果" }, + { "title": "本地化与恢复", "text": "库链接图像先选择“库链接图像”并点“设为本地”,再绘制;可撤销/重做笔画,保存 OPFS 后重开像素仍保留。", "image": "/tutorial/full/steps/M07-painted-stroke.jpg", "alt": "纹理绘制撤销/重做与保存状态" } + ], + "checks": ["画布像素变化", "撤销恢复原图", "重开后笔画仍在"], + "tips": ["图像越大 PNG 编码越慢;课程使用 1024×1024。"], + "limits": ["仅二维 packed 图像;不做 UDIM/投影/4K 生产级绘制。"], + "links": [{ "label": "纹理绘制课程 M07", "href": "/tutorial/full/index.html#M07" }] + }, + { + "id": "ch18", "number": 18, "part": "part-d", "title": "Geometry Nodes 与 Spreadsheet", "level": "进阶", + "workspace": "Geometry Nodes", "workspaces": ["Geometry Nodes"], + "summary": "在画布中添加 allowlist 节点、连线、编辑 socket、提交图,并在 Spreadsheet 检查数据。", + "objectives": ["添加/连线节点", "提交 GN 图", "检查 Spreadsheet 数据"], + "prerequisites": ["ch11"], + "desktopReference": "Geometry Nodes node tree, Spreadsheet", + "steps": [ + { "title": "打开 GN 画布", "text": "选中网格,切换到 Geometry Nodes;画布显示当前图,检查器显示节点/socket 属性。", "image": "/tutorial/full/steps/M12-geometry-nodes.jpg", "alt": "Geometry Nodes 画布与检查器" }, + { "title": "添加与连线", "text": "选择 allowlist 节点(Group Input/Output、Transform、Set Position、Join/Separate Geometry、Realize Instances、Store Named Attribute、输入/比较/数学、Object/Collection/Image Info 等),拖拽端口连线,修改 socket 默认值。", "image": "/tutorial/full/steps/M12-node-added.jpg", "alt": "添加 Transform 节点并连接后的 GN 画布" }, + { "title": "提交与检查", "text": "点击“提交图”写入原生节点树;下方 Spreadsheet 切换对象/顶点/边/面/UV/属性,过滤/列/CSV。", "image": "/tutorial/full/steps/M13-vertices.jpg", "alt": "Spreadsheet 顶点表与过滤" } + ], + "checks": ["节点/连线/属性提交成功", "几何随图更新", "Spreadsheet 行数据正确"], + "tips": ["保存重开会保留节点、连线与位置。"], + "limits": ["仅 16 类 allowlist 节点;无 Simulation Zone;Spreadsheet 只读当前网格缓冲。"], + "links": [{ "label": "GN 课程 M12", "href": "/tutorial/full/index.html#M12" }] + }, + { + "id": "ch19", "number": 19, "part": "part-d", "title": "Timeline、Dope Sheet 与关键帧动画", "level": "基础", + "workspace": "Animation", "workspaces": ["Animation"], + "summary": "跳帧、插入/删除 Loc/Rot/Scale 关键帧、播放并管理 Action。", + "objectives": ["跳帧与播放", "插入关键帧", "管理 Action/插值"], + "prerequisites": ["ch07"], + "desktopReference": "Timeline, Dope Sheet, Actions", + "steps": [ + { "title": "时间线", "text": "Animation 底部 Timeline 可跳到首/末帧、上一/下一帧、播放/暂停;当前帧输入框可直接跳转。", "image": "/tutorial/full/steps/M10-animation-workspace.jpg", "alt": "Animation 工作区时间线与 Dope Sheet" }, + { "title": "关键帧", "text": "选中对象,在目标帧点击 Loc/Rot/Scale 插入关键帧,Del Key 删除当前帧;Dope Sheet 显示关键点与当前帧。", "image": "/tutorial/full/steps/M10-keyframe.jpg", "alt": "Dope Sheet 关键帧与时间线" }, + { "title": "Action 与插值", "text": "Action name/New Action/Remove Action/活动 Action 管理动画;F-Curve 插值可选 Constant/Linear/Bezier;Fake User 防止 Action 被清理。", "image": "/tutorial/full/steps/M10-keyframe.jpg", "alt": "Action 与关键帧插值控件" } + ], + "checks": ["关键帧出现在当前帧", "播放推进帧号", "Action 切换生效"], + "tips": ["Web 播放是本地帧步进,不是原生动画求值/音频播放。"], + "limits": ["无完整原生播放/音频;部分多通道编辑器只暴露首条曲线。"], + "links": [{ "label": "动画课程 M10", "href": "/tutorial/full/index.html#M10" }] + }, + { + "id": "ch20", "number": 20, "part": "part-d", "title": "Graph Editor、NLA 与驱动器", "level": "进阶", + "workspace": "Animation", "workspaces": ["Animation"], + "summary": "在 Graph Editor 查看/拖动关键帧、切换插值;用 NLA 表单管理轨道/条带和驱动器表达式。", + "objectives": ["Graph 查看/拖动关键帧", "插入/删除关键帧", "NLA/驱动器入口"], + "prerequisites": ["ch19"], + "desktopReference": "Graph Editor, NLA, Drivers", + "steps": [ + { "title": "Graph 曲线", "text": "Animation 工作区 Graph Editor 列出通道,显示曲线与关键点;勾选通道显隐,滚轮缩放,按钮框显全部。", "image": "/tutorial/full/steps/M10-keyframe.jpg", "alt": "Animation 工作区中的 Graph Editor 区域" }, + { "title": "编辑关键帧", "text": "点击关键点选中并拖动;插入/删除关键帧;插值选择 Bezier/Linear/Constant。拖动提交一条 F-Curve 命令。", "image": "/tutorial/full/steps/M10-keyframe.jpg", "alt": "Graph Editor 关键帧与插值" }, + { "title": "NLA 与驱动器", "text": "Timeline 的 NLA 区可选择 Track/Strip,编辑起止、重复、Blend In/Out、Extrapolation 并提交;驱动器表达式/关键帧编辑器在动画属性区。", "image": "/tutorial/full/steps/M10-animation-workspace.jpg", "alt": "NLA 与动画属性区域" } + ], + "checks": ["Graph 曲线显示正确", "拖动改变关键帧", "NLA 提交生效"], + "tips": ["Graph 是简化 SVG 编辑器;复杂曲线/驱动器使用原生/F3。"], + "limits": ["NLA 是表单式而非完整通道编辑器;驱动器变量/目标未完整暴露。"], + "links": [] + }, + { + "id": "ch21", "number": 21, "part": "part-d", "title": "物理、粒子、点缓存与模拟限制", "level": "进阶", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "查看并编辑 Soft Body/Rigid Body/Cloth/Fluid/Particle 设置,理解 Web 播放与求解限制。", + "objectives": ["找到物理设置", "编辑参数", "理解播放/求解边界"], + "prerequisites": ["ch09"], + "desktopReference": "Physics properties, point cache", + "steps": [ + { "title": "物理工作台", "text": "Properties → Physics 选择 Rigid Body/Soft Body/Cloth/Fluid/Particle,编辑质量、摩擦、弹性、碰撞、缓存等参数。", "image": "/manuals/images/ch21-physics.jpg", "alt": "属性面板中的物理设置与缓存选项" }, + { "title": "点缓存", "text": "Point Cache 可 Bake All/Free Bake All;在 localOnly/native 路径可执行缓存操作,Web 路径受能力门控。", "image": "/tutorial/full/steps/M04-properties.jpg", "alt": "属性面板与物理/缓存入口" }, + { "title": "模拟播放", "text": "物理求解依赖已校验的桌面/服务器实现;Web 本地播放未接通时状态会返回 PHYSICS_CACHE_PLAYBACK_UNAVAILABLE/PHYSICS_SOLVER_UNAVAILABLE。", "image": "/manuals/images/ch21-physics.jpg", "alt": "物理设置和运行限制提示" } + ], + "checks": ["物理类型可切换", "参数进入 Main", "不支持时明确显示能力码"], + "tips": ["需要真实求解时使用原生会话/服务器渲染路径。"], + "limits": ["Web 本地模拟播放/求解未接通;仅参数编辑与缓存入口。"], + "links": [] + }, + { + "id": "ch22", "number": 22, "part": "part-e", "title": "相机、灯光、世界与渲染属性", "level": "进阶", + "workspace": "Rendering", "workspaces": ["Rendering", "Layout"], + "summary": "编辑相机镜头/DoF、灯光类型/能量、世界环境、色彩管理和渲染输出。", + "objectives": ["编辑相机/灯光", "设置世界/色彩", "配置分辨率与输出"], + "prerequisites": ["ch09"], + "desktopReference": "Camera/Light/World/Render properties", + "steps": [ + { "title": "相机", "text": "选中相机,Properties 编辑投影、焦距、near/far、全景/鱼眼与景深(焦点对象/距离/F-stop)。", "image": "/tutorial/full/steps/M08-render-workspace.jpg", "alt": "Rendering 工作区与相机/渲染面板" }, + { "title": "灯光与世界", "text": "灯光属性编辑类型、形状、diffuse/specular/transmission/volume 因子、衰减/阴影;世界属性编辑曝光、环境强度、AO 与探针分辨率。", "image": "/tutorial/full/steps/M08-pbr-panel.jpg", "alt": "PBR 渲染面板与灯光/世界参数" }, + { "title": "输出设置", "text": "Scene Render/Output 设置分辨率、百分比、帧范围、输出格式;色彩管理选择 display/view transform/look/exposure/gamma。", "image": "/tutorial/full/steps/M08-pbr-panel.jpg", "alt": "分辨率、相机与视图设置" } + ], + "checks": ["相机/灯光修改影响渲染", "世界/色彩设置保存", "分辨率与输出格式正确"], + "tips": ["PBR 练习面板提供场景相机对齐与常用参数预设。"], + "limits": ["部分场景/渲染字段为离散按钮;完整 RNA 字段未全部暴露。"], + "links": [] + }, + { + "id": "ch23", "number": 23, "part": "part-e", "title": "PBR 渲染、Render Result 与手动 PNG 下载", "level": "进阶", + "workspace": "Rendering", "workspaces": ["Rendering"], + "summary": "设置分辨率/相机/曝光/位移,执行浏览器 PBR 渲染,检查结果并手动下载 PNG。", + "objectives": ["执行一次 PBR 渲染", "检查结果/OPFS", "手动下载 PNG"], + "prerequisites": ["ch15", "ch16", "ch22"], + "desktopReference": "Render > Render Image", + "steps": [ + { "title": "渲染参数", "text": "Rendering 的 PBR 面板设置视角预设、位移强度、视图(Final/Base Color/Normal/Roughness)、曝光、分辨率 256/512/1024/2048、相机 Viewport/Scene。", "image": "/tutorial/full/steps/M08-pbr-panel.jpg", "alt": "PBR 渲染参数面板" }, + { "title": "执行渲染", "text": "点击“渲染 PBR”,等待纹理/环境就绪并输出结果;结果显示尺寸、字节数和 OPFS hash。浏览器渲染器是 three.js WebGL2 physical-v1,不是 Cycles/EEVEE。", "image": "/tutorial/full/steps/M09-render-result.jpg", "alt": "PBR 渲染结果与 OPFS 信息" }, + { "title": "手动下载/原生渲染", "text": "点击“下载 PNG”保存本地 pbr-.png;Rendering 页面还提供可选的“桌面 EEVEE 渲染”,需原生服务,产物链接需手动点击下载。", "image": "/tutorial/full/steps/M09-render-result.jpg", "alt": "下载 PNG 与渲染结果区域" } + ], + "checks": ["渲染结果尺寸符合设置", "结果写入 OPFS", "PNG 仅在点击下载后保存"], + "tips": ["深色材质先切 Base Color/Roughness 诊断,避免只凭整体亮度判断。"], + "limits": ["浏览器 PBR 为简化渲染器;原生渲染依赖服务器/桌面服务。"], + "links": [{ "label": "渲染课程 M08/M09", "href": "/tutorial/full/index.html#M08" }] + }, + { + "id": "ch24", "number": 24, "part": "part-e", "title": "Compositor:节点画布、执行与保存", "level": "进阶", + "workspace": "Compositing", "workspaces": ["Compositing"], + "summary": "创建 RGB/Exposure/Blur/Mix/Alpha Over/Transform/Viewer/Composite 节点,执行 CPU 合成并提交。", + "objectives": ["添加/连线节点", "执行合成/Viewer", "保存重开图"], + "prerequisites": ["ch23"], + "desktopReference": "Compositor node tree, Composite/Viewer", + "steps": [ + { "title": "节点画布", "text": "Compositing 工作区画布默认包含 Composite;添加节点类型,拖拽端口连接,右侧检查器编辑属性/颜色/图像来源。", "image": "/tutorial/full/steps/M11-compositor.jpg", "alt": "Compositor 画布与默认 Composite 节点" }, + { "title": "执行合成", "text": "点击“执行合成”,CPU 求值器显示 Viewer 或 Composite 结果;RGB/Exposure/Invert/Blur/Mix/Alpha Over/Transform 等可用,IMAGE 使用 packed 图,Render Layer 使用最近 PBR 渲染。", "image": "/tutorial/full/steps/M11-executed.jpg", "alt": "Compositor 执行结果预览" }, + { "title": "提交与限制", "text": "点击“提交图”写入原生 CompositorNodeTree;不支持的节点会阻止提交并显示“当前图含无法持久化的节点”。保存 OPFS 后重开保留节点/连线/属性/资源。", "image": "/tutorial/full/steps/M11-nodes-added.jpg", "alt": "添加 RGB/Exposure 节点并连线" } + ], + "checks": ["节点/连线/属性提交", "执行结果显示", "重开后图保留"], + "tips": ["先执行再提交,便于发现资源/节点限制。"], + "limits": ["9 类节点;WebGPU allowlist 4 类;Alpha Over factor 不持久化,MIX 仅 RGBA。"], + "links": [{ "label": "Compositor 课程 M11", "href": "/tutorial/full/index.html#M11" }] + }, + { + "id": "ch25", "number": 25, "part": "part-e", "title": "导入导出矩阵:GLB/GLTF/OBJ/STL/PLY、原生导出与 CSV", "level": "进阶", + "workspace": "Layout", "workspaces": ["Layout", "Rendering", "Geometry Nodes"], + "summary": "按可用性导入/导出模型与数据,明确浏览器本地、原生/F3、不可用三种路径。", + "objectives": ["导入本地模型", "导出 GLB/CSV", "识别不可用格式"], + "prerequisites": ["ch03"], + "desktopReference": "File > Import/Export", + "steps": [ + { "title": "导入模型/图像/字体", "text": "顶栏“打开 .blend”文件选择器接受 .blend、.gltf、.glb、.obj、.stl、.ply;材质图片、VFont 在对应属性面板导入;资源写入 OPFS。", "image": "/manuals/images/ch25-import-menu.jpg", "alt": "文件菜单导入子菜单与格式" }, + { "title": "导出", "text": "顶栏“导出 GLB”生成 blender-web.glb;文件 → 导出提供 GLB/GLTF/FBX/OBJ/STL/PLY/USD/ALEMBIC 的原生面板(需服务);浏览器本地仅 GLB/OBJ 可用,其余显示“浏览器导出尚未移植”。", "image": "/manuals/images/ch25-export-menu.jpg", "alt": "文件菜单导出子菜单与格式状态" }, + { "title": "CSV/诊断", "text": "Spreadsheet“导出 CSV”导出当前过滤/可见列;帮助/顶栏导出诊断 JSON。所有下载均为显式手动动作。", "image": "/tutorial/full/steps/M13-filtered.jpg", "alt": "Spreadsheet 过滤与 CSV 导出入口" } + ], + "checks": ["导入后对象数增加", "GLB/CSV 仅在点击导出后下载", "不可用格式不被承诺"], + "tips": ["GLTF 外部 .bin/纹理不在单文件导入范围;优先使用 GLB。"], + "limits": ["USD/Alembic/FBX 当前依赖原生服务;浏览器本地导出仅部分格式。"], + "links": [] + }, + { + "id": "ch26", "number": 26, "part": "part-f", "title": "脚本:Text Editor、Python Console 与 Info", "level": "进阶", + "workspace": "Scripting", "workspaces": ["Scripting"], + "summary": "编辑/运行文本脚本,使用原生 Python 控制台与 Info 状态,理解脚本策略。", + "objectives": ["编辑/应用文本", "运行 Python", "查看 Info/诊断"], + "prerequisites": ["ch04"], + "desktopReference": "Text Editor, Python Console, Info", + "steps": [ + { "title": "Text Editor", "text": "Scripting 上方 Text Editor 选择/创建文本,打开 .py/.txt(≤1 MiB),编辑后必须 Apply 才写入 Main;可下载或运行原生 Python。", "image": "/tutorial/full/steps/M15-scripting.jpg", "alt": "Scripting 工作区文本编辑器与控制台" }, + { "title": "Python Console", "text": "Web 模式使用原生会话面板连接/执行/撤销 Main/应用到场景;输出与命令历史可见。脚本受信任/权限/沙箱策略约束。", "image": "/tutorial/full/steps/M15-console.jpg", "alt": "Python Console 与 Info 区域" }, + { "title": "Info 与策略", "text": "Info 显示 Engine 状态与活动场景,不是完整 Blender 操作日志;敏感脚本需显式允许/恢复脚本策略。", "image": "/tutorial/full/steps/M15-console.jpg", "alt": "Info 状态与脚本运行结果" } + ], + "checks": ["文本 Apply 后执行", "控制台执行结果可见", "未 Apply 草稿阻止保存"], + "tips": ["运行前保存 OPFS 工程;脚本可修改 Main。"], + "limits": ["Web 控制台依赖原生会话;Info 不是操作历史;脚本能力受策略/沙箱限制。"], + "links": [{ "label": "脚本课程 M15", "href": "/tutorial/full/index.html#M15" }] + }, + { + "id": "ch27", "number": 27, "part": "part-f", "title": "Preferences、Keymap、快捷栏与启动工作区", "level": "基础", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "使用分栏偏好设置、OPFS 保存、可改键位、快捷栏显隐和启动工作区。", + "objectives": ["修改并保存偏好", "改键/冲突处理", "控制快捷栏与启动工作区"], + "prerequisites": ["ch04"], + "desktopReference": "Edit > Preferences, Keymap", + "steps": [ + { "title": "偏好分栏", "text": "编辑 → 偏好设置打开 Web 偏好窗口;界面/视口/动画/输入导航/键位/保存加载/文件/主题/系统分栏,支持项真实生效,不支持项显示“Web 不支持”和桌面来源。", "image": "/manuals/images/ch27-preferences.jpg", "alt": "Web 偏好设置分栏与设置项" }, + { "title": "键位", "text": "键位页列出 17 个顶层动作;点“录制”按组合键,冲突会被拒绝并提示;保存后写入 OPFS,旧键位失效。", "image": "/manuals/images/ch27-keymap.jpg", "alt": "键位表、录制按钮与冲突提示" }, + { "title": "快捷栏/启动工作区", "text": "界面页控制状态栏、快捷操作栏、区域切换器、启动工作区;保存偏好后刷新,快捷栏显隐与启动工作区生效。", "image": "/manuals/images/ch27-preferences.jpg", "alt": "界面偏好中的快捷栏与启动工作区设置" } + ], + "checks": ["偏好刷新后保持", "键位冲突被拒绝", "快捷栏可隐藏", "启动工作区生效"], + "tips": ["OPFS 是偏好权威存储,localStorage 只是镜像。"], + "limits": ["主题/系统/完整键位配置未实现;部分视口/自动保存项标注不支持。"], + "links": [] + }, + { + "id": "ch28", "number": 28, "part": "part-f", "title": "故障排查、能力边界与离线/版本说明", "level": "进阶", + "workspace": "Layout", "workspaces": ["Layout"], + "summary": "按状态栏/诊断/支持矩阵定位问题,理解 OPFS、浏览器、原生服务和格式限制。", + "objectives": ["定位常见失败", "导出诊断", "查阅限制"], + "prerequisites": ["ch04"], + "desktopReference": "Troubleshooting / limitations", + "steps": [ + { "title": "先看状态栏", "text": "失败时查看 Engine 状态、存储状态和诊断计数;打开操作失败会显示读取/取消阶段,Worker 停止可点“重启并恢复”。", "image": "/manuals/images/ch28-help.jpg", "alt": "帮助菜单与诊断/手册入口" }, + { "title": "恢复与备份", "text": "优先从最近项目/自动恢复点重开;损坏引用可移除。重要工程另存并定期手动下载 .blend;清除站点数据会删除 OPFS。", "image": "/tutorial/full/steps/M14-reopen.jpg", "alt": "最近项目恢复入口" }, + { "title": "能力边界", "text": "支持矩阵区分可用/部分/仅原生/不可用。明确限制包括:本地模拟求解、部分 IO 格式、完整主题/键位、UDIM/投影纹理、Compositor/Shader/GN 节点目录。不要把手册当桌面 parity 证书。", "image": "/manuals/images/ch28-help.jpg", "alt": "帮助菜单与支持信息" } + ], + "checks": ["能从状态栏定位失败阶段", "能恢复或导出诊断", "限制与支持矩阵一致"], + "tips": ["首次远端加载 154–193 秒属正常,等待进度完成再操作。"], + "limits": ["本手册只验证 Chromium + RTX 4060;其他浏览器未验证。"], + "links": [{ "label": "支持矩阵附录", "href": "#appendix-support" }] + } + ], + "appendices": [ + { "id": "appendix-shortcuts", "title": "附录 A:常用快捷键与 F3 命令", "rows": [ + ["Ctrl+S", "保存到浏览器 OPFS(不下载)"], + ["Ctrl+O", "打开 .blend"], + ["Ctrl+Z / Ctrl+Y", "撤销 / 重做"], + ["F3", "操作搜索"], + ["F2", "重命名活动项/Outliner 行"], + ["G / R / S", "移动 / 旋转 / 缩放"], + ["Shift+D / Alt+D", "复制 / 链接复制"], + ["Numpad0", "相机视图切换"], + ["Ctrl+Alt+Numpad0", "对齐相机到视图"], + ["Home / Numpad .", "显示全部 / 框显所选"], + ["Numpad 1/3/7, 5", "轴视图 / 透视正交"] + ] }, + { "id": "appendix-paths", "title": "附录 C:OPFS 与本地文件位置", "rows": [ + ["projects//scene.blend", "工程主文件(OPFS)"], + ["projects//snapshots/.blend", "恢复点快照"], + ["projects//assets/sha256/...", "打包/内容资源"], + ["settings/web-interface.json", "偏好、键位、布局、相机锁定"], + ["最近项目", "IndexedDB blender-web-metadata/setting/recent-projects:v1"], + ["手动下载", "顶栏“下载 .blend”、PNG/GLB/CSV 导出"] + ] }, + { "id": "appendix-glossary", "title": "附录 D:术语与缩略语", "rows": [ + ["OPFS", "Origin Private File System,浏览器私有文件系统"], + ["SceneIR", "WebEngine 输出的场景只读描述"], + ["Main", "原生 Blender 主数据库,编辑与撤销的真实来源"], + ["Packed 图像", "数据打包在工程内的图像,可在纹理绘制中编辑"], + ["Make Local", "把库链接图像复制为本地可编辑数据"], + ["Camera to View", "锁定相机视图时导航会写回相机对象"] + ] } + ], + "gaps": [ + { "id": "save-as-copy", "status": "FIXED", "summary": "另存为文案错误地称会下载 .blend", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/web-save-download-semantics.spec.ts"], "chapterIds": ["ch03"] }, + { "id": "gltf-import", "status": "FIXED", "summary": ".gltf 被 UI 过滤但原生导入支持", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/io-format-ui-gate.spec.ts"], "chapterIds": ["ch25"] }, + { "id": "quick-toolbar", "status": "FIXED", "summary": "快捷操作栏开关不生效", "files": ["web/app/src/app/desktop-menus.css", "web/protocol/web-preferences.ts"], "tests": ["web/tests/e2e/web-preferences.spec.ts"], "chapterIds": ["ch27"] }, + { "id": "properties-tabs", "status": "FIXED", "summary": "Properties 三个标签无响应", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/web-manual-ui-gaps.spec.ts"], "chapterIds": ["ch09"] }, + { "id": "editor-header-menu", "status": "FIXED", "summary": "区域头 ⋮ 无响应", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/web-manual-ui-gaps.spec.ts"], "chapterIds": ["ch06"] }, + { "id": "outliner-search", "status": "FIXED", "summary": "Outliner fallback 搜索框空转", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/web-manual-ui-gaps.spec.ts"], "chapterIds": ["ch08"] }, + { "id": "startup-workspace", "status": "FIXED", "summary": "启动工作区偏好无效果", "files": ["web/app/src/app/App.tsx"], "tests": ["web/tests/e2e/web-preferences.spec.ts"], "chapterIds": ["ch27"] } + ] +} diff --git a/web/app/public/manuals/images/ch01-first-save.jpg b/web/app/public/manuals/images/ch01-first-save.jpg new file mode 100644 index 000000000..64ccb9717 Binary files /dev/null and b/web/app/public/manuals/images/ch01-first-save.jpg differ diff --git a/web/app/public/manuals/images/ch03-recent.jpg b/web/app/public/manuals/images/ch03-recent.jpg new file mode 100644 index 000000000..3abe82253 Binary files /dev/null and b/web/app/public/manuals/images/ch03-recent.jpg differ diff --git a/web/app/public/manuals/images/ch05-recovery.jpg b/web/app/public/manuals/images/ch05-recovery.jpg new file mode 100644 index 000000000..216af68ca Binary files /dev/null and b/web/app/public/manuals/images/ch05-recovery.jpg differ diff --git a/web/app/public/manuals/images/ch06-area-menu.jpg b/web/app/public/manuals/images/ch06-area-menu.jpg new file mode 100644 index 000000000..d784a91b6 Binary files /dev/null and b/web/app/public/manuals/images/ch06-area-menu.jpg differ diff --git a/web/app/public/manuals/images/ch06-area-split.jpg b/web/app/public/manuals/images/ch06-area-split.jpg new file mode 100644 index 000000000..129e16dd1 Binary files /dev/null and b/web/app/public/manuals/images/ch06-area-split.jpg differ diff --git a/web/app/public/manuals/images/ch07-camera-view.jpg b/web/app/public/manuals/images/ch07-camera-view.jpg new file mode 100644 index 000000000..9acfe43eb Binary files /dev/null and b/web/app/public/manuals/images/ch07-camera-view.jpg differ diff --git a/web/app/public/manuals/images/ch08-outliner.jpg b/web/app/public/manuals/images/ch08-outliner.jpg new file mode 100644 index 000000000..3333a8ece Binary files /dev/null and b/web/app/public/manuals/images/ch08-outliner.jpg differ diff --git a/web/app/public/manuals/images/ch09-properties-tabs.jpg b/web/app/public/manuals/images/ch09-properties-tabs.jpg new file mode 100644 index 000000000..5efc2c234 Binary files /dev/null and b/web/app/public/manuals/images/ch09-properties-tabs.jpg differ diff --git a/web/app/public/manuals/images/ch21-physics.jpg b/web/app/public/manuals/images/ch21-physics.jpg new file mode 100644 index 000000000..ff2e03757 Binary files /dev/null and b/web/app/public/manuals/images/ch21-physics.jpg differ diff --git a/web/app/public/manuals/images/ch25-export-menu.jpg b/web/app/public/manuals/images/ch25-export-menu.jpg new file mode 100644 index 000000000..7c4aa5457 Binary files /dev/null and b/web/app/public/manuals/images/ch25-export-menu.jpg differ diff --git a/web/app/public/manuals/images/ch25-import-menu.jpg b/web/app/public/manuals/images/ch25-import-menu.jpg new file mode 100644 index 000000000..eb49c4b06 Binary files /dev/null and b/web/app/public/manuals/images/ch25-import-menu.jpg differ diff --git a/web/app/public/manuals/images/ch27-keymap.jpg b/web/app/public/manuals/images/ch27-keymap.jpg new file mode 100644 index 000000000..9bf4d0d35 Binary files /dev/null and b/web/app/public/manuals/images/ch27-keymap.jpg differ diff --git a/web/app/public/manuals/images/ch27-preferences.jpg b/web/app/public/manuals/images/ch27-preferences.jpg new file mode 100644 index 000000000..994a724a0 Binary files /dev/null and b/web/app/public/manuals/images/ch27-preferences.jpg differ diff --git a/web/app/public/manuals/images/ch28-help.jpg b/web/app/public/manuals/images/ch28-help.jpg new file mode 100644 index 000000000..08b6aedca Binary files /dev/null and b/web/app/public/manuals/images/ch28-help.jpg differ diff --git a/web/app/public/manuals/images/manifest.json b/web/app/public/manuals/images/manifest.json new file mode 100644 index 000000000..83affee44 --- /dev/null +++ b/web/app/public/manuals/images/manifest.json @@ -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 + } + ] +} diff --git a/web/app/public/manuals/index.html b/web/app/public/manuals/index.html new file mode 100644 index 000000000..779c28c63 --- /dev/null +++ b/web/app/public/manuals/index.html @@ -0,0 +1,233 @@ + + + + + + Web Blender 用户手册 + + + + +
+ + Web Blender 用户手册 + + + +
+
+ +
+
+

UM-1.0.0 · 2026-11-13 · Blender 5.2.0 LTS

+

Web Blender 用户手册

+

本手册对标当前 Web Blender(?ui=web)的实际功能,按真实界面和真实操作步骤编写。工程保存、重开和恢复使用浏览器 OPFS;需要本地文件时使用独立的“下载”动作。手册不宣称完整桌面 Blender parity,未实现项在支持矩阵和限制章节中如实列出。

+
存储规则
  • 顶栏“保存项目”、Ctrl+S、文件 → 保存到浏览器 OPFS、另存为、自动恢复点只写浏览器存储。
  • 需要本地 .blend 时,手动使用顶栏“下载 .blend”或文件 → 下载 .blend 到本地。
  • PNG、GLB、CSV、诊断报告等下载都是独立的手动动作。
  • 清除站点数据会删除 OPFS 工程、最近项目索引和偏好设置。
+
+
+

第 1 章 · 入门 · Layout

快速开始:首次加载、打开工程与第一次保存

从浏览器打开 Web Blender,等待 WASM 就绪,载入练习工程并把工程保存到 OPFS。

+

本章目标

  • 识别 WASM 首次加载状态
  • 打开 .blend 并确认 SceneIR
  • 完成一次 OPFS 保存
+
  1. 1. 打开 Web 界面

    在 Chromium 访问 /?ui=web,等待底栏显示 WASM 就绪与 Engine: SceneIR。远端首次加载需要下载约 79 MB wasm,页面会显示进度。

    Web Blender 顶栏、工作区标签、视口、大纲与属性面板
    图 1.1 打开 Web 界面
  2. 2. 载入练习工程

    点击顶栏“打开 .blend”,选择课程工程 studio.blend(绝对路径:/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/studio.blend),等待对象数变为 6。

    文件菜单与打开 .blend 入口
    图 1.2 载入练习工程
  3. 3. 保存到 OPFS

    点击顶栏“保存项目”。状态栏显示 SUCCEEDED 后,工程已写入浏览器 OPFS;此动作不会下载文件。

    保存项目按钮与保存成功状态
    图 1.3 保存到 OPFS
+

完成检查

  • 底栏 Engine 显示 SceneIR
  • 对象数与工程文件一致
  • 保存状态为 SUCCEEDED

提示

  • 用 F3 搜索“打开”“保存到浏览器 OPFS”也可以执行同一入口。

限制 / 不支持

  • 本地文件不会自动出现;需要时使用“下载 .blend”。

相关链接

+ +
+
+

第 2 章 · 入门 · Layout

界面总览:工作区、编辑器、状态栏与命令搜索

认识 11 个工作区、区域、状态栏和 F3 操作搜索,知道在哪里找命令与反馈。

前置:第 1 章

+

本章目标

  • 切换工作区
  • 识别状态栏反馈
  • 用 F3 搜索并执行命令
+
  1. 1. 认识工作区

    顶部工作区标签包括 Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting;每个工作区有固定的默认区域布局。

    顶部工作区标签与整体界面
    图 2.1 认识工作区
  2. 2. 使用状态栏

    底栏显示活动对象、对象/顶点/面数量、WASM 与 Engine 状态、存储预算和版本;操作失败时先看 Engine 状态。

    底部状态栏和保存状态
    图 2.2 使用状态栏
  3. 3. F3 命令搜索

    按 F3,输入命令名(如“移动”“Export GLB”),选择带执行位置标签的条目。原生命令可能显示 DESKTOP/SERVER_NATIVE;浏览器等价命令显示 BROWSER。

    F3 操作搜索面板与命令结果
    图 2.3 F3 命令搜索
+

完成检查

  • 11 个工作区标签齐全
  • F3 能搜索到命令
  • 状态栏能看到 Engine/存储状态

提示

  • F3 搜索是找隐藏入口的首选,不必先猜菜单位置。

限制 / 不支持

  • 灰色菜单项或带 unavailable 标记的命令尚未实现等价入口。
+ +
+
+

第 3 章 · 入门 · Layout

文件与工程:新建、打开、最近项目、另存为与保存副本

掌握当前 Web 版的工程文件入口,理解另存为写 OPFS、保存副本才下载本地副本。

前置:第 1 章

+

本章目标

  • 新建/打开工程
  • 使用最近项目
  • 区分另存为与保存副本
+
  1. 1. 新建与打开

    文件 → 新建 → 常规创建默认场景;文件 → 打开或顶栏“打开 .blend”载入工程。打开期间状态栏显示读取进度,可取消。

    文件菜单中的新建与打开入口
    图 3.1 新建与打开
  2. 2. 最近项目

    顶栏“最近项目”下拉或文件 → 最近打开列出显式保存过的工程;条目损坏时会提示“项目内容缺失/校验失败”,可移除引用。

    最近项目菜单与工程条目
    图 3.2 最近项目
  3. 3. 另存为与保存副本

    另存为在 OPFS 创建并切换到新工程,不下载;保存副本下载本地 .blend,且不改变当前工程。需要本地工程时也可用顶栏“下载 .blend”。

    顶栏保存与下载入口
    图 3.3 另存为与保存副本
+

完成检查

  • 最近项目能重开已保存工程
  • 另存为后工程名/项目切换
  • 保存副本产生下载文件

提示

  • 同名 .blend 会映射到同一个 OPFS 项目;需要隔离时使用另存为。

限制 / 不支持

  • “另存为会下载”是过时说明;当前只写 OPFS。
+ +
+
+

第 4 章 · 入门 · Layout

浏览器存储:OPFS 保存、重开与手动本地下载

理解 OPFS 工程布局、最近项目索引、恢复点与手动下载的边界。

前置:第 1 章、第 3 章

+

本章目标

  • 说明 OPFS/IndexedDB 分工
  • 保存并重开工程
  • 手动下载 .blend
+
  1. 1. 保存到 OPFS

    保存项目写入 projects/<projectId>/scene.blend 及元数据;快照、资源与恢复点也在同一项目目录下。工程不会出现在浏览器下载目录。

    保存到 OPFS 后的状态栏反馈
    图 4.1 保存到 OPFS
  2. 2. 刷新并重开

    刷新页面后,在最近项目中选择刚保存的工程;若询问是否保存当前修改,选择“不保存,继续”。对象、材质、UV/节点等 Main 数据应恢复。

    刷新后从最近项目重开工程
    图 4.2 刷新并重开
  3. 3. 手动下载 .blend

    顶栏“下载 .blend”或文件 → 下载 .blend 到本地会先把工程提交到 OPFS,再下载 <工程名>.blend。外部未打包资源不会自动包含。

    手动下载 .blend 的顶栏入口
    图 4.3 手动下载 .blend
+

完成检查

  • 重开后对象数一致
  • 本地下载只在手动动作后出现
  • 状态栏显示 OPFS 保存成功

提示

  • 课程与测试的重开流程都使用最近项目/OPFS,不依赖自动下载。

限制 / 不支持

  • 清除站点数据会删除 OPFS 工程与最近项目索引;本地 .blend 是持久备份。
+ +
+
+

第 5 章 · 入门 · Layout

撤销、自动恢复点、恢复与诊断

使用撤销/重做、浏览器自动恢复点、最近项目修复和诊断报告处理错误。

前置:第 4 章

+

本章目标

  • 撤销/重做 Main 编辑
  • 恢复自动恢复点
  • 导出诊断报告
+
  1. 1. 撤销与重做

    编辑 → 撤销/重做,或 Ctrl+Z/Ctrl+Y;撤销历史列出可回退的 Main 版本。工作区切换不会产生 Main 撤销步。

    自动恢复点对话框
    图 5.1 撤销与重做
  2. 2. 恢复自动恢复点

    文件 → 恢复 → 恢复浏览器恢复点,选择项目和恢复点后点“恢复选中版本”。自动恢复点不标记工程已保存,也不更新最近项目。

    选择项目和恢复点的恢复对话框
    图 5.2 恢复自动恢复点
  3. 3. 诊断与修复

    最近项目损坏时可按条目移除引用;失败操作可点击顶栏“!”或帮助 → 导出诊断报告,生成 blender-web-diagnostics.json。

    恢复与诊断入口所在对话框
    图 5.3 诊断与修复
+

完成检查

  • 撤销后数据回到上一版本
  • 恢复点能载入指定 revision
  • 诊断 JSON 可下载

提示

  • 恢复点只保留最近数份;重要节点前先保存 OPFS 工程。

限制 / 不支持

  • 恢复点是浏览器存储,不替代本地备份;原生 autosave 是另一套入口。
+ +
+
+

第 6 章 · 入门 · Layout

工作区与 Area 布局:切换、⋮ 菜单、分割与持久化

用区域右上角选择器切换编辑器,用 ⋮ 菜单分割/合并/交换/最大化区域,并保存布局。

前置:第 2 章

+

本章目标

  • 切换区域编辑器
  • 分割和合并区域
  • 重开后恢复布局
+
  1. 1. 切换编辑器

    每个区域右上角有编辑器选择器,可切换为 3D Viewport、Outliner、Properties、Spreadsheet、节点编辑器、Image Editor、Text Editor、Python Console、Timeline/Graph 等。

    区域右上角编辑器选择器与 ⋮ 菜单
    图 6.1 切换编辑器
  2. 2. 分割与合并

    打开区域 ⋮ → 水平/垂直分割;合并/停靠选择相邻方向;交换编辑器、复制区域、最大化/聚焦、关闭和重置布局也在同一菜单。首次操作会把当前工作区切换为自定义布局。

    分割后的双属性区域与区域菜单
    图 6.2 分割与合并
  3. 3. 布局持久化

    布局、编辑器覆盖和每视口相机锁定写入 OPFS settings/web-interface.json;刷新页面后恢复。选择“重置工作区布局”回到默认 CSS 布局。

    自定义区域布局
    图 6.3 布局持久化
+

完成检查

  • 区域可切换编辑器
  • 分割/合并后区域数量正确
  • 刷新后布局保留
  • 重置恢复默认

提示

  • 区域头部的 ⋮ 与右上角浮动 ⋮ 打开同一个菜单。

限制 / 不支持

  • 复制区域用拆分承载,不创建新浏览器窗口;暂无拖拽分隔条 resize。
+ +
+
+

第 7 章 · 基础 · Layout

3D 视口:导航、选择、变换与相机视图

使用鼠标与键盘导航视口,选择对象/网格元素,执行变换并进入相机视图或锁定相机。

前置:第 6 章

+

本章目标

  • 旋转/平移/缩放视口
  • 选择对象与网格元素
  • 切换相机视图并锁定
+
  1. 1. 导航

    中键拖动旋转,Shift+中键平移,滚轮缩放;小键盘 1/3/7 切换轴视图,5 切换透视/正交,Home 显示全部,Numpad . 框显所选。

    Layout 工作区 3D 视口与导航工具栏
    图 7.1 导航
  2. 2. 选择与变换

    左键选择对象,Ctrl/Shift 追加/范围选择;G/R/S 进入移动/旋转/缩放。编辑模式支持顶点/边/面选择(快捷栏 1/2/3)。

    编辑模式网格元素选择
    图 7.2 选择与变换
  3. 3. 相机视图与锁定

    按 Numpad0 或工具栏“相机视图”对齐场景相机;再次触发恢复上一视图。未锁定时 orbit 被拒绝,pan/zoom 只做取景;“锁定相机”(Camera to View)允许导航并把相机 pose 写回对象,Ctrl+Alt+Numpad0 对齐相机到当前视图。

    相机视图按钮与锁定按钮状态
    图 7.3 相机视图与锁定
+

完成检查

  • 轴视图与透视切换正常
  • 编辑模式选择模式可切换
  • Numpad0 进入/退出相机视图
  • 锁定后导航改变相机并可撤销

提示

  • 相机锁定按视口保存,刷新后保留。

限制 / 不支持

  • 未实现 object-as-camera、自动关键帧/约束/父级 lock 分支。

相关链接

+ +
+
+

第 8 章 · 基础 · Layout

Outliner:层级、选择、可见性、重命名与拖放

在大纲视图中选择对象、展开层级、切换可见性、重命名、remap 数据和拖放父子/集合。

前置:第 7 章

+

本章目标

  • 选择与展开层级
  • 重命名/可见性
  • 拖放建立父子或移动集合
+
  1. 1. 选择与层级

    单击对象行选中;Ctrl 追加,Shift 范围选择;展开按钮或左右方向键展开/折叠,Home/End 与上下键导航。

    大纲视图对象选择与层级
    图 8.1 选择与层级
  2. 2. 重命名与可见性

    双击名称或 F2 行内重命名;眼睛按钮切换可见性。链接库对象/数据不可重命名或 remap。

    大纲视图对象行、可见性与重命名
    图 8.2 重命名与可见性
  3. 3. 拖放与 Remap

    对象拖到对象行建立父子(Shift 允许,Alt 保持变换);拖到集合移动/链接(Ctrl 链接);右键/Shift+F10 打开数据菜单执行 Rename/Remap Users。

    大纲视图拖放与数据菜单入口
    图 8.3 拖放与 Remap
+

完成检查

  • 选择与范围选择正确
  • 重命名进入 Main
  • 拖放后父子/集合关系正确

提示

  • 大纲头部提供全选/清除/反选/切换与刷新。

限制 / 不支持

  • 原生 outliner 上下文不可用时使用简化树;搜索过滤只在简化树可用。
+ +
+
+

第 9 章 · 基础 · Layout

Properties:对象、修改器、材质与场景属性

使用右侧属性面板查看和编辑变换、层级、修改器、材质、相机、灯光、世界、渲染和物理设置。

前置:第 7 章、第 8 章

+

本章目标

  • 找到属性分组
  • 用标签跳转
  • 编辑并提交属性
+
  1. 1. 属性分组

    属性面板按 Object & Hierarchy、Modifier Stack、Material Slots、Camera/Light/World、Scene/Render、Physics、View Layer 等分组纵向排列;修改后写入 Main 并可撤销。

    属性面板中的变换与对象分组
    图 9.1 属性分组
  2. 2. 对象/修改器/材质标签

    左侧三个标签分别跳转到对象变换、Modifier Stack、Material Slots。标签会更新选中状态并滚动到目标段落。

    属性面板三个跳转标签与修改器段落
    图 9.2 对象/修改器/材质标签
  3. 3. 编辑与只读状态

    数值/下拉/按钮直接提交;链接对象、相机、集合和材质显示只读提示。相机属性要求单用户数据块。

    修改器栈与参数编辑
    图 9.3 编辑与只读状态
+

完成检查

  • 标签能跳转并选中
  • 修改属性后视口更新
  • 只读链接数据不能编辑

提示

  • F3 搜索属性名可快速定位命令,例如 Modifier/Decimate。

限制 / 不支持

  • 不是完整 RNA 属性浏览器;部分场景/渲染字段以离散按钮呈现。
+ +
+
+

第 10 章 · 基础 · Modeling

对象模式:创建、复制、重命名、父子与 Join

创建基础体、复制/链接复制、重命名、删除、父子关系和 Join 合并。

前置:第 7 章

+

本章目标

  • 创建对象
  • 复制与清理
  • 建立父子/Join
+
  1. 1. 创建对象

    Modeling 快捷栏的 Add CUBE/PLANE/UV SPHERE/MONKEY 或菜单“添加”创建对象;也可 F3 使用原生 primitive 操作。

    Modeling 工作区与对象添加快捷栏
    图 10.1 创建对象
  2. 2. 复制、重命名、删除

    Shift+D 复制,Alt+D 链接复制,F2 重命名,Delete 删除;删除与取消都可撤销。

    对象模式快捷工具
    图 10.2 复制、重命名、删除
  3. 3. 父子与 Join

    选中多个对象后 Parent/Unparent 设置或清除父级;Join 合并为一个对象。父级操作可保留或清除变换。

    对象模式下的父级与合并按钮
    图 10.3 父子与 Join
+

完成检查

  • 新对象出现在 Outliner
  • 复制/删除后对象数正确
  • Join 后活动对象网格包含其他对象

提示

  • 活动对象决定 Join 后的名称和数据。

限制 / 不支持

  • 部分高级对象/集合操作仅在菜单或 F3 中提供。
+ +
+
+

第 11 章 · 基础 · Modeling

网格编辑与雕刻范围

进入编辑模式执行选择/挤出/倒角/环切/细分,并在雕刻模式使用有限的笔刷与重构。

前置:第 10 章

+

本章目标

  • 进入编辑模式
  • 执行常用拓扑操作
  • 使用雕刻笔刷/面组
+
  1. 1. 进入编辑模式

    Modeling 工作区点击模式按钮选择编辑模式;快捷栏切换 Vertex/Edge/Face,Select All 后执行 MERGE/DISSOLVE/EXTRUDE/INSET/BEVEL/LOOP_CUT/SUBDIVIDE 等。

    编辑模式下的网格与元素选择
    图 11.1 进入编辑模式
  2. 2. 常用操作

    面模式可 TRIANGULATE/Recalculate Normals;边模式可 BRIDGE_LOOPS;操作失败时先确认选择模式与元素数量。

    网格元素全选与拓扑操作按钮
    图 11.2 常用操作
  3. 3. 雕刻范围

    Sculpting 工作区属性面板提供 Draw/Inflate/Grab/Smooth、半径/强度/压力/对称、遮罩、面组和 Voxel Remesh;笔刷从属性面板应用到当前网格,不是连续笔触。

    属性面板中的雕刻与网格设置
    图 11.3 雕刻范围
+

完成检查

  • 编辑模式可切换选择模式
  • 拓扑操作后网格更新
  • 雕刻参数/遮罩写入属性

提示

  • 破坏性操作后可用撤销恢复;高面数操作注意存储/性能。

限制 / 不支持

  • 无完整建模工具箱、框选/套索网格选择与连续雕刻笔触;高级操作依赖菜单/F3。

相关链接

+ +
+
+

第 12 章 · 基础 · Modeling

修改器栈:添加、排序、参数、可见性与应用

在 Modifier Stack 中添加 Bevel/Boolean/Mirror/Array 等修改器并管理堆栈。

前置:第 9 章

+

本章目标

  • 添加修改器
  • 调整参数/顺序
  • 应用或移除
+
  1. 1. 添加修改器

    Properties → Modifier Stack 输入修改器类型并 Add;列表显示名称、类型、参数和 V/R/E/C 可见性。

    Modifier Stack 与修改器参数
    图 12.1 添加修改器
  2. 2. 排序与参数

    用 ↑/↓、Move to First/Last 调整求值顺序;编辑参数后视口更新。Decimate 提供 Collapse/Un-Subdivide/Dissolve Planar、比例/目标三角面与预览/应用。

    修改器排序和 Decimate 参数
    图 12.2 排序与参数
  3. 3. 应用、复制、移除

    Apply 写入网格并移除修改器;可存为形态键、复制到选中、按名称移除;R 列控制渲染可见性。

    属性面板中的修改器与对象设置
    图 12.3 应用、复制、移除
+

完成检查

  • 修改器按顺序求值
  • 参数变化视口更新
  • Apply 后网格变化

提示

  • Boolean 需要封闭网格;Mirror/Array 依赖原点与间距。

限制 / 不支持

  • 仅支持已接线的修改器类型;参数以类型元数据生成。
+ +
+
+

第 13 章 · 基础 · Modeling

非网格对象:曲线、曲面、文本、字体、融球与 Grease Pencil

从添加菜单创建曲线/文本/融球/Grease Pencil,编辑控制点或文本,导入本地字体。

前置:第 10 章

+

本章目标

  • 创建非网格对象
  • 编辑曲线/文本
  • 导入 TTF/OTF/PFB
+
  1. 1. 创建非网格

    添加菜单包含 Bezier/NURBS 曲线、曲面、文本、骨架、融球、体积、晶格、点云、毛发、Grease Pencil 与力场。

    Modeling 工作区与添加入口
    图 13.1 创建非网格
  2. 2. 曲线/文本编辑

    编辑模式下曲线菜单提供挤出、复制、循环、手柄类型、样条类型、分离、细分、平滑、反向等;文本属性可编辑正文与布局。

    编辑模式与对象编辑界面
    图 13.2 曲线/文本编辑
  3. 3. 导入字体

    文本属性 → Import TTF/OTF/PFB 选择本地字体(绝对路径任选);字体写入 OPFS 资源并打包进 Main,可设置 Regular/Bold/Italic 链接。

    属性面板中的字体/数据设置
    图 13.3 导入字体
+

完成检查

  • 非网格对象出现在 Outliner
  • 曲线控制点可编辑
  • 字体导入后文本外观变化

提示

  • 字体文件会进入当前工程资源;保存 OPFS 后重开仍可用。

限制 / 不支持

  • 非网格数据的高级编辑受当前 Web 能力矩阵限制;部分操作仅原生/F3。
+ +
+
+

第 14 章 · 进阶 · UV Editing

UV 编辑:选择、展开、打包、变换与缝合

进入可写 UV 区域,全选/变换/展开/打包/缝合 UV,并保存重开。

前置:第 11 章

+

本章目标

  • 进入 UV 编辑
  • 展开并打包
  • 保存重开 UV
+
  1. 1. 进入可写 UV 区域

    选中网格,切换到 UV Editing;桌面 Blender 要求 OB_MODE_EDIT,点击“编辑模式”进入。左侧 UV 编辑器显示原生 UV 选择。

    UV Editing 工作区与原生 UV 编辑器
    图 14.1 进入可写 UV 区域
  2. 2. 选择与展开

    点击“全选 UV”,等待“UV 选择已提交”,再点“展开所选 UV”;可选择角度/共形/最小拉伸、边距、UDIM 目标与自定义区域。

    展开后的 UV 岛与选择状态
    图 14.2 选择与展开
  3. 3. 打包、变换、缝合

    “打包 UV 岛”设置间距;变换控件执行移动/旋转/缩放;缝合控件使用缝合模式/距离。保存 OPFS 后重开,UV 与选择仍保留。

    UV 展开结果与控件
    图 14.3 打包、变换、缝合
+

完成检查

  • UV 选择提交成功
  • 展开/打包改变 UV 布局
  • 保存重开后 UV 不变

提示

  • 展开前标记缝合线可得到更整齐的 UV 岛。

限制 / 不支持

  • 编辑依赖可写原生 UV 区域;无完整 UV sculpt/高级工具。

相关链接

+ +
+
+

第 15 章 · 进阶 · Shading

材质与 Shader Editor:Principled、图像与预览

创建/分配材质,在节点画布编辑 Principled 与图像节点,预览并打包外部贴图。

前置:第 9 章

+

本章目标

  • 分配材质
  • 编辑节点/连接
  • 设置图像节点
+
  1. 1. 选择材质

    Shading 工作区上方选择材质;Properties → Material Slots 添加/分配材质。链接材质显示只读。

    Shading 工作区、材质槽与节点编辑器
    图 15.1 选择材质
  2. 2. 节点画布

    启用默认节点生成 Principled + Material Output;添加节点、拖拽节点、连接 socket、重命名、删除、编辑 socket 值和属性。支持 RGB/Value/Mix/Math/Mapping/坐标/UV/分离颜色/对象信息/图像/法线/凹凸/Principled/Output 14 类。

    Shader Editor 节点与材质预览
    图 15.2 节点画布
  3. 3. 图像与预览

    Image Texture 选择 packed 图像或色彩空间;外部图片通过“外部贴图”入口导入 OPFS 后打包;右侧材质球显示预览,可保存缩略图。

    材质预览与资产库材质卡片
    图 15.3 图像与预览
+

完成检查

  • 材质槽分配成功
  • 节点连接后预览变化
  • 图像节点使用正确贴图

提示

  • Principled 的 Base Color/Roughness/Metallic 会直接影响 PBR 渲染。

限制 / 不支持

  • 仅 14 类节点;未知节点会显示不支持并阻止图验证。

相关链接

+ +
+
+

第 16 章 · 进阶 · Shading

PBR/RE 资产库、Append/Link 与 packed 图像

从 PBR/RE 资产库加载模型/材质,理解 Append/Link 与 packed 资源。

前置:第 15 章

+

本章目标

  • 筛选/搜索资产
  • 双击添加/分配
  • 修复资产库
+
  1. 1. 选择资产源

    Shading 左下 PBR 资产库可选择 pbr-library、re-pbr 或 HQ 源,按类型/目录/来源筛选,搜索资产名。

    PBR 资产库搜索、筛选与资产卡片
    图 16.1 选择资产源
  2. 2. 添加或分配

    双击模型添加到场景,双击材质分配到当前材质槽;也可用“添加到场景”“分配到材质槽”。资产库元数据与预览存 OPFS/IndexedDB。

    RE PBR 材质卡片与分配结果
    图 16.2 添加或分配
  3. 3. packed 与修复

    材质贴图以 packed 图像进入工程,Properties → Resources 显示 packed 资源;缺失时点“修复资产库”重建。库链接图像绘制前需在 Texture Paint “设为本地”。

    纹理绘制中的 packed 图像与绘制结果
    图 16.3 packed 与修复
+

完成检查

  • 资产卡可见/可筛选
  • 双击材质改变材质槽
  • packed 图在重开后仍存在

提示

  • RE 5000PBR 有 6 张材质;HQ 源提供 2048 贴图。

限制 / 不支持

  • 库链接对象/材质只读;仅图像有 Make Local 绘制路径。

相关链接

+ +
+
+

第 17 章 · 进阶 · Texture Paint

Texture Paint:画笔、撤销/重做、Make Local 与 OPFS

在 packed 图像上绘制,一次笔画一次提交,撤销/重做并保存重开;库链接图先本地化。

前置:第 15 章、第 16 章

+

本章目标

  • 选择图像
  • 绘制/擦除
  • 撤销/重做与持久化
+
  1. 1. 选择纹理图像

    Texture Paint 顶部“纹理图像”只列出 packed 图像;没有图像时先在 Shading 分配带 Base Color 图的材质。

    Texture Paint 图像选择与画布
    图 17.1 选择纹理图像
  2. 2. 绘制笔画

    选择绘制/擦除、颜色、大小、强度,在画布按住拖动;松开后显示“笔画已提交”,一次笔画一个 Main revision。

    在贴图上绘制白色笔画后的结果
    图 17.2 绘制笔画
  3. 3. 本地化与恢复

    库链接图像先选择“库链接图像”并点“设为本地”,再绘制;可撤销/重做笔画,保存 OPFS 后重开像素仍保留。

    纹理绘制撤销/重做与保存状态
    图 17.3 本地化与恢复
+

完成检查

  • 画布像素变化
  • 撤销恢复原图
  • 重开后笔画仍在

提示

  • 图像越大 PNG 编码越慢;课程使用 1024×1024。

限制 / 不支持

  • 仅二维 packed 图像;不做 UDIM/投影/4K 生产级绘制。
+ +
+
+

第 18 章 · 进阶 · Geometry Nodes

Geometry Nodes 与 Spreadsheet

在画布中添加 allowlist 节点、连线、编辑 socket、提交图,并在 Spreadsheet 检查数据。

前置:第 11 章

+

本章目标

  • 添加/连线节点
  • 提交 GN 图
  • 检查 Spreadsheet 数据
+
  1. 1. 打开 GN 画布

    选中网格,切换到 Geometry Nodes;画布显示当前图,检查器显示节点/socket 属性。

    Geometry Nodes 画布与检查器
    图 18.1 打开 GN 画布
  2. 2. 添加与连线

    选择 allowlist 节点(Group Input/Output、Transform、Set Position、Join/Separate Geometry、Realize Instances、Store Named Attribute、输入/比较/数学、Object/Collection/Image Info 等),拖拽端口连线,修改 socket 默认值。

    添加 Transform 节点并连接后的 GN 画布
    图 18.2 添加与连线
  3. 3. 提交与检查

    点击“提交图”写入原生节点树;下方 Spreadsheet 切换对象/顶点/边/面/UV/属性,过滤/列/CSV。

    Spreadsheet 顶点表与过滤
    图 18.3 提交与检查
+

完成检查

  • 节点/连线/属性提交成功
  • 几何随图更新
  • Spreadsheet 行数据正确

提示

  • 保存重开会保留节点、连线与位置。

限制 / 不支持

  • 仅 16 类 allowlist 节点;无 Simulation Zone;Spreadsheet 只读当前网格缓冲。

相关链接

+ +
+
+

第 19 章 · 基础 · Animation

Timeline、Dope Sheet 与关键帧动画

跳帧、插入/删除 Loc/Rot/Scale 关键帧、播放并管理 Action。

前置:第 7 章

+

本章目标

  • 跳帧与播放
  • 插入关键帧
  • 管理 Action/插值
+
  1. 1. 时间线

    Animation 底部 Timeline 可跳到首/末帧、上一/下一帧、播放/暂停;当前帧输入框可直接跳转。

    Animation 工作区时间线与 Dope Sheet
    图 19.1 时间线
  2. 2. 关键帧

    选中对象,在目标帧点击 Loc/Rot/Scale 插入关键帧,Del Key 删除当前帧;Dope Sheet 显示关键点与当前帧。

    Dope Sheet 关键帧与时间线
    图 19.2 关键帧
  3. 3. Action 与插值

    Action name/New Action/Remove Action/活动 Action 管理动画;F-Curve 插值可选 Constant/Linear/Bezier;Fake User 防止 Action 被清理。

    Action 与关键帧插值控件
    图 19.3 Action 与插值
+

完成检查

  • 关键帧出现在当前帧
  • 播放推进帧号
  • Action 切换生效

提示

  • Web 播放是本地帧步进,不是原生动画求值/音频播放。

限制 / 不支持

  • 无完整原生播放/音频;部分多通道编辑器只暴露首条曲线。

相关链接

+ +
+
+

第 20 章 · 进阶 · Animation

Graph Editor、NLA 与驱动器

在 Graph Editor 查看/拖动关键帧、切换插值;用 NLA 表单管理轨道/条带和驱动器表达式。

前置:第 19 章

+

本章目标

  • Graph 查看/拖动关键帧
  • 插入/删除关键帧
  • NLA/驱动器入口
+
  1. 1. Graph 曲线

    Animation 工作区 Graph Editor 列出通道,显示曲线与关键点;勾选通道显隐,滚轮缩放,按钮框显全部。

    Animation 工作区中的 Graph Editor 区域
    图 20.1 Graph 曲线
  2. 2. 编辑关键帧

    点击关键点选中并拖动;插入/删除关键帧;插值选择 Bezier/Linear/Constant。拖动提交一条 F-Curve 命令。

    Graph Editor 关键帧与插值
    图 20.2 编辑关键帧
  3. 3. NLA 与驱动器

    Timeline 的 NLA 区可选择 Track/Strip,编辑起止、重复、Blend In/Out、Extrapolation 并提交;驱动器表达式/关键帧编辑器在动画属性区。

    NLA 与动画属性区域
    图 20.3 NLA 与驱动器
+

完成检查

  • Graph 曲线显示正确
  • 拖动改变关键帧
  • NLA 提交生效

提示

  • Graph 是简化 SVG 编辑器;复杂曲线/驱动器使用原生/F3。

限制 / 不支持

  • NLA 是表单式而非完整通道编辑器;驱动器变量/目标未完整暴露。
+ +
+
+

第 21 章 · 进阶 · Layout

物理、粒子、点缓存与模拟限制

查看并编辑 Soft Body/Rigid Body/Cloth/Fluid/Particle 设置,理解 Web 播放与求解限制。

前置:第 9 章

+

本章目标

  • 找到物理设置
  • 编辑参数
  • 理解播放/求解边界
+
  1. 1. 物理工作台

    Properties → Physics 选择 Rigid Body/Soft Body/Cloth/Fluid/Particle,编辑质量、摩擦、弹性、碰撞、缓存等参数。

    属性面板中的物理设置与缓存选项
    图 21.1 物理工作台
  2. 2. 点缓存

    Point Cache 可 Bake All/Free Bake All;在 localOnly/native 路径可执行缓存操作,Web 路径受能力门控。

    属性面板与物理/缓存入口
    图 21.2 点缓存
  3. 3. 模拟播放

    物理求解依赖已校验的桌面/服务器实现;Web 本地播放未接通时状态会返回 PHYSICS_CACHE_PLAYBACK_UNAVAILABLE/PHYSICS_SOLVER_UNAVAILABLE。

    物理设置和运行限制提示
    图 21.3 模拟播放
+

完成检查

  • 物理类型可切换
  • 参数进入 Main
  • 不支持时明确显示能力码

提示

  • 需要真实求解时使用原生会话/服务器渲染路径。

限制 / 不支持

  • Web 本地模拟播放/求解未接通;仅参数编辑与缓存入口。
+ +
+
+

第 22 章 · 进阶 · Rendering

相机、灯光、世界与渲染属性

编辑相机镜头/DoF、灯光类型/能量、世界环境、色彩管理和渲染输出。

前置:第 9 章

+

本章目标

  • 编辑相机/灯光
  • 设置世界/色彩
  • 配置分辨率与输出
+
  1. 1. 相机

    选中相机,Properties 编辑投影、焦距、near/far、全景/鱼眼与景深(焦点对象/距离/F-stop)。

    Rendering 工作区与相机/渲染面板
    图 22.1 相机
  2. 2. 灯光与世界

    灯光属性编辑类型、形状、diffuse/specular/transmission/volume 因子、衰减/阴影;世界属性编辑曝光、环境强度、AO 与探针分辨率。

    PBR 渲染面板与灯光/世界参数
    图 22.2 灯光与世界
  3. 3. 输出设置

    Scene Render/Output 设置分辨率、百分比、帧范围、输出格式;色彩管理选择 display/view transform/look/exposure/gamma。

    分辨率、相机与视图设置
    图 22.3 输出设置
+

完成检查

  • 相机/灯光修改影响渲染
  • 世界/色彩设置保存
  • 分辨率与输出格式正确

提示

  • PBR 练习面板提供场景相机对齐与常用参数预设。

限制 / 不支持

  • 部分场景/渲染字段为离散按钮;完整 RNA 字段未全部暴露。
+ +
+
+

第 23 章 · 进阶 · Rendering

PBR 渲染、Render Result 与手动 PNG 下载

设置分辨率/相机/曝光/位移,执行浏览器 PBR 渲染,检查结果并手动下载 PNG。

前置:第 15 章、第 16 章、第 22 章

+

本章目标

  • 执行一次 PBR 渲染
  • 检查结果/OPFS
  • 手动下载 PNG
+
  1. 1. 渲染参数

    Rendering 的 PBR 面板设置视角预设、位移强度、视图(Final/Base Color/Normal/Roughness)、曝光、分辨率 256/512/1024/2048、相机 Viewport/Scene。

    PBR 渲染参数面板
    图 23.1 渲染参数
  2. 2. 执行渲染

    点击“渲染 PBR”,等待纹理/环境就绪并输出结果;结果显示尺寸、字节数和 OPFS hash。浏览器渲染器是 three.js WebGL2 physical-v1,不是 Cycles/EEVEE。

    PBR 渲染结果与 OPFS 信息
    图 23.2 执行渲染
  3. 3. 手动下载/原生渲染

    点击“下载 PNG”保存本地 pbr-<width>.png;Rendering 页面还提供可选的“桌面 EEVEE 渲染”,需原生服务,产物链接需手动点击下载。

    下载 PNG 与渲染结果区域
    图 23.3 手动下载/原生渲染
+

完成检查

  • 渲染结果尺寸符合设置
  • 结果写入 OPFS
  • PNG 仅在点击下载后保存

提示

  • 深色材质先切 Base Color/Roughness 诊断,避免只凭整体亮度判断。

限制 / 不支持

  • 浏览器 PBR 为简化渲染器;原生渲染依赖服务器/桌面服务。
+ +
+
+

第 24 章 · 进阶 · Compositing

Compositor:节点画布、执行与保存

创建 RGB/Exposure/Blur/Mix/Alpha Over/Transform/Viewer/Composite 节点,执行 CPU 合成并提交。

前置:第 23 章

+

本章目标

  • 添加/连线节点
  • 执行合成/Viewer
  • 保存重开图
+
  1. 1. 节点画布

    Compositing 工作区画布默认包含 Composite;添加节点类型,拖拽端口连接,右侧检查器编辑属性/颜色/图像来源。

    Compositor 画布与默认 Composite 节点
    图 24.1 节点画布
  2. 2. 执行合成

    点击“执行合成”,CPU 求值器显示 Viewer 或 Composite 结果;RGB/Exposure/Invert/Blur/Mix/Alpha Over/Transform 等可用,IMAGE 使用 packed 图,Render Layer 使用最近 PBR 渲染。

    Compositor 执行结果预览
    图 24.2 执行合成
  3. 3. 提交与限制

    点击“提交图”写入原生 CompositorNodeTree;不支持的节点会阻止提交并显示“当前图含无法持久化的节点”。保存 OPFS 后重开保留节点/连线/属性/资源。

    添加 RGB/Exposure 节点并连线
    图 24.3 提交与限制
+

完成检查

  • 节点/连线/属性提交
  • 执行结果显示
  • 重开后图保留

提示

  • 先执行再提交,便于发现资源/节点限制。

限制 / 不支持

  • 9 类节点;WebGPU allowlist 4 类;Alpha Over factor 不持久化,MIX 仅 RGBA。
+ +
+
+

第 25 章 · 进阶 · Layout

导入导出矩阵:GLB/GLTF/OBJ/STL/PLY、原生导出与 CSV

按可用性导入/导出模型与数据,明确浏览器本地、原生/F3、不可用三种路径。

前置:第 3 章

+

本章目标

  • 导入本地模型
  • 导出 GLB/CSV
  • 识别不可用格式
+
  1. 1. 导入模型/图像/字体

    顶栏“打开 .blend”文件选择器接受 .blend、.gltf、.glb、.obj、.stl、.ply;材质图片、VFont 在对应属性面板导入;资源写入 OPFS。

    文件菜单导入子菜单与格式
    图 25.1 导入模型/图像/字体
  2. 2. 导出

    顶栏“导出 GLB”生成 blender-web.glb;文件 → 导出提供 GLB/GLTF/FBX/OBJ/STL/PLY/USD/ALEMBIC 的原生面板(需服务);浏览器本地仅 GLB/OBJ 可用,其余显示“浏览器导出尚未移植”。

    文件菜单导出子菜单与格式状态
    图 25.2 导出
  3. 3. CSV/诊断

    Spreadsheet“导出 CSV”导出当前过滤/可见列;帮助/顶栏导出诊断 JSON。所有下载均为显式手动动作。

    Spreadsheet 过滤与 CSV 导出入口
    图 25.3 CSV/诊断
+

完成检查

  • 导入后对象数增加
  • GLB/CSV 仅在点击导出后下载
  • 不可用格式不被承诺

提示

  • GLTF 外部 .bin/纹理不在单文件导入范围;优先使用 GLB。

限制 / 不支持

  • USD/Alembic/FBX 当前依赖原生服务;浏览器本地导出仅部分格式。
+ +
+
+

第 26 章 · 进阶 · Scripting

脚本:Text Editor、Python Console 与 Info

编辑/运行文本脚本,使用原生 Python 控制台与 Info 状态,理解脚本策略。

前置:第 4 章

+

本章目标

  • 编辑/应用文本
  • 运行 Python
  • 查看 Info/诊断
+
  1. 1. Text Editor

    Scripting 上方 Text Editor 选择/创建文本,打开 .py/.txt(≤1 MiB),编辑后必须 Apply 才写入 Main;可下载或运行原生 Python。

    Scripting 工作区文本编辑器与控制台
    图 26.1 Text Editor
  2. 2. Python Console

    Web 模式使用原生会话面板连接/执行/撤销 Main/应用到场景;输出与命令历史可见。脚本受信任/权限/沙箱策略约束。

    Python Console 与 Info 区域
    图 26.2 Python Console
  3. 3. Info 与策略

    Info 显示 Engine 状态与活动场景,不是完整 Blender 操作日志;敏感脚本需显式允许/恢复脚本策略。

    Info 状态与脚本运行结果
    图 26.3 Info 与策略
+

完成检查

  • 文本 Apply 后执行
  • 控制台执行结果可见
  • 未 Apply 草稿阻止保存

提示

  • 运行前保存 OPFS 工程;脚本可修改 Main。

限制 / 不支持

  • Web 控制台依赖原生会话;Info 不是操作历史;脚本能力受策略/沙箱限制。

相关链接

+ +
+
+

第 27 章 · 基础 · Layout

Preferences、Keymap、快捷栏与启动工作区

使用分栏偏好设置、OPFS 保存、可改键位、快捷栏显隐和启动工作区。

前置:第 4 章

+

本章目标

  • 修改并保存偏好
  • 改键/冲突处理
  • 控制快捷栏与启动工作区
+
  1. 1. 偏好分栏

    编辑 → 偏好设置打开 Web 偏好窗口;界面/视口/动画/输入导航/键位/保存加载/文件/主题/系统分栏,支持项真实生效,不支持项显示“Web 不支持”和桌面来源。

    Web 偏好设置分栏与设置项
    图 27.1 偏好分栏
  2. 2. 键位

    键位页列出 17 个顶层动作;点“录制”按组合键,冲突会被拒绝并提示;保存后写入 OPFS,旧键位失效。

    键位表、录制按钮与冲突提示
    图 27.2 键位
  3. 3. 快捷栏/启动工作区

    界面页控制状态栏、快捷操作栏、区域切换器、启动工作区;保存偏好后刷新,快捷栏显隐与启动工作区生效。

    界面偏好中的快捷栏与启动工作区设置
    图 27.3 快捷栏/启动工作区
+

完成检查

  • 偏好刷新后保持
  • 键位冲突被拒绝
  • 快捷栏可隐藏
  • 启动工作区生效

提示

  • OPFS 是偏好权威存储,localStorage 只是镜像。

限制 / 不支持

  • 主题/系统/完整键位配置未实现;部分视口/自动保存项标注不支持。
+ +
+
+

第 28 章 · 进阶 · Layout

故障排查、能力边界与离线/版本说明

按状态栏/诊断/支持矩阵定位问题,理解 OPFS、浏览器、原生服务和格式限制。

前置:第 4 章

+

本章目标

  • 定位常见失败
  • 导出诊断
  • 查阅限制
+
  1. 1. 先看状态栏

    失败时查看 Engine 状态、存储状态和诊断计数;打开操作失败会显示读取/取消阶段,Worker 停止可点“重启并恢复”。

    帮助菜单与诊断/手册入口
    图 28.1 先看状态栏
  2. 2. 恢复与备份

    优先从最近项目/自动恢复点重开;损坏引用可移除。重要工程另存并定期手动下载 .blend;清除站点数据会删除 OPFS。

    最近项目恢复入口
    图 28.2 恢复与备份
  3. 3. 能力边界

    支持矩阵区分可用/部分/仅原生/不可用。明确限制包括:本地模拟求解、部分 IO 格式、完整主题/键位、UDIM/投影纹理、Compositor/Shader/GN 节点目录。不要把手册当桌面 parity 证书。

    帮助菜单与支持信息
    图 28.3 能力边界
+

完成检查

  • 能从状态栏定位失败阶段
  • 能恢复或导出诊断
  • 限制与支持矩阵一致

提示

  • 首次远端加载 154–193 秒属正常,等待进度完成再操作。

限制 / 不支持

  • 本手册只验证 Chromium + RTX 4060;其他浏览器未验证。

相关链接

+ +
+

附录 B:能力支持矩阵

可在当前 Web UI 完成;可用但有明确范围/限制;需要原生桌面/服务器会话;当前 Web 版不支持。

区域功能状态说明章节
Layout3D 视口导航/选择/变换/相机视图可在当前 Web UI 完成ch07
LayoutOutliner 选择/重命名/拖放/可见性可在当前 Web UI 完成ch08
LayoutProperties 对象/修改器/材质/场景可在当前 Web UI 完成ch09
LayoutTimeline 跳帧/关键帧可在当前 Web UI 完成ch19
Modeling网格编辑/快捷拓扑操作可用但有明确范围/限制无完整建模工具箱/框选ch11
SculptingDraw/Inflate/Grab/Smooth、遮罩/面组/Remesh 入口可用但有明确范围/限制笔刷从属性应用,非连续雕刻ch11
UV Editing选择/变换/展开/打包/缝合可用但有明确范围/限制需要可写原生 UV 区域ch14
Texture Paint2D packed 图像绘制/撤销/重做/Make Local可用但有明确范围/限制不支持 UDIM/投影/4K 生产级ch17
Shading材质/Principled/14 类 Shader 节点/预览可用但有明确范围/限制仅 allowlist 节点ch15
ShadingPBR/RE 资产库/Append/Link/packed 图可在当前 Web UI 完成ch16
AnimationTimeline/Dope Sheet/关键帧/Action可在当前 Web UI 完成ch19
AnimationGraph Editor/NLA/驱动器可用但有明确范围/限制Graph 简化 SVG;NLA 表单ch20
AnimationWeb 播放/音频可用但有明确范围/限制本地帧步进,非原生求值ch19
RenderingPBR 渲染/诊断视图/PNG 手动下载可在当前 Web UI 完成ch23
Rendering原生 EEVEE/Cycles 渲染需要原生桌面/服务器会话需要原生/服务器后端ch23
Compositing9 类节点/连线/执行/Viewer/提交可用但有明确范围/限制节点目录受限;资源/属性有限制ch24
Geometry Nodes16 类 allowlist 节点/连线/提交可用但有明确范围/限制无 Simulation Zone/任意节点ch18
Geometry NodesSpreadsheet 对象/顶点/边/面/UV/属性/CSV可用但有明确范围/限制只读、250k 行上限ch18
ScriptingText Editor/Python Console/Info可用但有明确范围/限制Web 控制台依赖原生会话ch26
Files新建/打开/最近项目/恢复/撤销可在当前 Web UI 完成ch03
Files保存到 OPFS/下载 .blend/另存为/副本可在当前 Web UI 完成ch04
Files自动恢复点/诊断报告可在当前 Web UI 完成ch05
IOGLTF/GLB/OBJ/STL/PLY 导入可用但有明确范围/限制GLTF 仅嵌入资源;OBJ 不含 MTL 文件夹流程ch25
IO浏览器 GLB/OBJ 导出可用但有明确范围/限制GLB 本地;OBJ 本地/原生ch25
IOUSD/Alembic/FBX 导出需要原生桌面/服务器会话运行时 receipt 可能未注册ch25
IO图像/VFont/URDF 导入可在当前 Web UI 完成ch25
SettingsPreferences/Keymap/区域布局/相机锁定持久化可在当前 Web UI 完成ch27
Settings主题/系统/Add-ons/完整键位当前 Web 版不支持ch27
Simulation物理参数/缓存入口可用但有明确范围/限制本地播放/求解未接通ch21
SequencerStrip 表单/原生媒体传输可用但有明确范围/限制Web 最终导出依赖服务器;无本地媒体选择器ch25
CameraNumpad0/Camera to View/锁定持久化可用但有明确范围/限制无 object-as-camera/自动关键帧/约束分支ch07
+

附录 A:常用快捷键与 F3 命令

Ctrl+S保存到浏览器 OPFS(不下载)
Ctrl+O打开 .blend
Ctrl+Z / Ctrl+Y撤销 / 重做
F3操作搜索
F2重命名活动项/Outliner 行
G / R / S移动 / 旋转 / 缩放
Shift+D / Alt+D复制 / 链接复制
Numpad0相机视图切换
Ctrl+Alt+Numpad0对齐相机到视图
Home / Numpad .显示全部 / 框显所选
Numpad 1/3/7, 5轴视图 / 透视正交
+

附录 C:OPFS 与本地文件位置

projects/<id>/scene.blend工程主文件(OPFS)
projects/<id>/snapshots/<revision>.blend恢复点快照
projects/<id>/assets/sha256/...打包/内容资源
settings/web-interface.json偏好、键位、布局、相机锁定
最近项目IndexedDB blender-web-metadata/setting/recent-projects:v1
手动下载顶栏“下载 .blend”、PNG/GLB/CSV 导出
+

附录 D:术语与缩略语

OPFSOrigin Private File System,浏览器私有文件系统
SceneIRWebEngine 输出的场景只读描述
Main原生 Blender 主数据库,编辑与撤销的真实来源
Packed 图像数据打包在工程内的图像,可在纹理绘制中编辑
Make Local把库链接图像复制为本地可编辑数据
Camera to View锁定相机视图时导航会写回相机对象
+
手册版本 1.0.0 · 验证日期 2026-11-13 · 源码 aa8cc1313 · 不宣称完整桌面 Blender parity。
+
+
+ + diff --git a/web/app/public/manuals/manual-search.json b/web/app/public/manuals/manual-search.json new file mode 100644 index 000000000..5edff3c75 --- /dev/null +++ b/web/app/public/manuals/manual-search.json @@ -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、浏览器、原生服务和格式限制。" + } + ] +} diff --git a/web/app/public/manuals/manual.css b/web/app/public/manuals/manual.css new file mode 100644 index 000000000..729487c91 --- /dev/null +++ b/web/app/public/manuals/manual.css @@ -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} } diff --git a/web/app/public/manuals/manual.js b/web/app/public/manuals/manual.js new file mode 100644 index 000000000..0263d33a3 --- /dev/null +++ b/web/app/public/manuals/manual.js @@ -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); +})(); diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 152cdbebb..c382c935f 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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([['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) { {editor} - +
{useDefaultContent ? newAreaContent : context!.render(activeEditor, areaKey)}
@@ -1816,6 +1816,7 @@ function Outliner({ snapshot, areaId, onSelect, onToggleVisibility, onRefresh, o const selectedIds = snapshot ? selectionFromSnapshot(snapshot) : new Set(); const collectionRows = snapshot?.collections ?? []; const [collapsed,setCollapsed]=useState>(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 : null} ; + const fallbackQuery = fallbackSearch.trim().toLowerCase(); + const fallbackAllowed = (() => { + if (!fallbackQuery) return null; + const allowed = new Set(); + const children = new Map(); + 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(),rows:{id:string;depth:number;hasChildren:boolean}[]=[],visited=new Set(); 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 (
-
{onRefresh ? : null}{onSelectAll ? <> : null}
+
{!tree ? <> setFallbackSearch(event.target.value)} />{fallbackQuery ? : null} : null}{onRefresh ? : null}{onSelectAll ? <> : null}
- {collectionRows.length === 0 ?
▾Scene Collection
: collectionRows.map((collection, index) => ( + {visibleCollections.length === 0 ?
▾{fallbackQuery ? "没有匹配的对象" : "Scene Collection"}
: visibleCollections.map((collection, index) => (
▾◈{collection.name}
@@ -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): 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(selector)?.scrollIntoView({ block: "start", behavior: "smooth" }); + }; const [mode, setMode] = useState("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 (
- - - + + +

Transform

@@ -3631,7 +3656,7 @@ function Properties({ selectedEdgeIndices, nonMeshGeometryBuffers, client, snaps {activeMesh && activeNode ?

Paint

{paintMirror ? : null}
{selectedVertexIndices.length} selected vertices{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}
: null} {activeMesh ?

Sculpt

{sculptRemeshError &&

{sculptRemeshError}

}
{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces
: null} - {activeMesh ?
+ {activeMesh ?

Material Slots

{activeMesh.materialSlotIds?.map((id, index) => { const material = snapshot?.materials.find((item) => item.id === id); return onActiveMaterialSlotChange(index)} />{index + 1}. {material?.sourceLibrary ? ` · ${material.sourceLibrary}` : ""}; })}
{materialSlotsReadOnly ?

链接对象或网格的材质槽只读。

: null} @@ -4104,12 +4129,15 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab const [areaLayouts,setAreaLayouts]=useState>({}); const settingsRef = useRef(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('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?{if(!headerPending)setHeaderDialog(null);}}> {headerDialog==='obj-import'?:null} {headerDialog==='save-copy'&&

下载当前工程的副本,并继续编辑当前项目;未保存标记将保留。

} - {headerDialog==='save-as'&&

另存为会保留原项目,创建并切换到新的浏览器项目,同时下载 .blend 文件。后续保存使用新项目和文件名。

} + {headerDialog==='save-as'&&

另存为会保留原项目,创建并切换到新的浏览器 OPFS 项目,不会自动下载文件。后续保存使用新项目和文件名;需要本地副本时请使用“文件 → 下载 .blend 到本地”。

} {headerDialog==='export'?nativeAbortRef.current?.abort()}/>:null} {headerDialog==='history'&&historyView&&!historyView.sourceHash?:null} {headerDialog==='history'?<>
{historyView?.history.entries.map(entry=>)}
{(historyError||!historyView||!historyView.sourceHash)?:null}{headerPending?正在读取工程历史…:null}{historyError?{historyError}:null}:null} diff --git a/web/app/src/app/desktop-menus.css b/web/app/src/app/desktop-menus.css index 1453bdd77..7a11cfdfc 100644 --- a/web/app/src/app/desktop-menus.css +++ b/web/app/src/app/desktop-menus.css @@ -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}} diff --git a/web/app/src/tutorial/TutorialLanding.tsx b/web/app/src/tutorial/TutorialLanding.tsx index 37be92aab..9f29d344b 100644 --- a/web/app/src/tutorial/TutorialLanding.tsx +++ b/web/app/src/tutorial/TutorialLanding.tsx @@ -38,7 +38,7 @@ export function TutorialLanding({ course }: { course: TutorialCourse }) { return
- +
diff --git a/web/protocol/web-preferences.ts b/web/protocol/web-preferences.ts index 177c3aac0..9d0e5bf39 100644 --- a/web/protocol/web-preferences.ts +++ b/web/protocol/web-preferences.ts @@ -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, diff --git a/web/tests/e2e/user-manual-smoke.spec.ts b/web/tests/e2e/user-manual-smoke.spec.ts new file mode 100644 index 000000000..57822d20f --- /dev/null +++ b/web/tests/e2e/user-manual-smoke.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/web-area-layout.spec.ts b/web/tests/e2e/web-area-layout.spec.ts index 7beffd125..2426ba84d 100644 --- a/web/tests/e2e/web-area-layout.spec.ts +++ b/web/tests/e2e/web-area-layout.spec.ts @@ -16,7 +16,7 @@ async function openStudio(page: import("@playwright/test").Page): Promise } async function openAreaMenu(page: import("@playwright/test").Page, area: import("@playwright/test").Locator): Promise { - 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 }); } diff --git a/web/tests/e2e/web-camera-view.spec.ts b/web/tests/e2e/web-camera-view.spec.ts index 2be09e1af..fd2317e28 100644 --- a/web/tests/e2e/web-camera-view.spec.ts +++ b/web/tests/e2e/web-camera-view.spec.ts @@ -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"); }); diff --git a/web/tests/e2e/web-manual-ui-gaps.spec.ts b/web/tests/e2e/web-manual-ui-gaps.spec.ts new file mode 100644 index 000000000..3e6acb67b --- /dev/null +++ b/web/tests/e2e/web-manual-ui-gaps.spec.ts @@ -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 { + 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"); +}); diff --git a/web/tests/e2e/web-preferences.spec.ts b/web/tests/e2e/web-preferences.spec.ts index c43604e0a..df555543b 100644 --- a/web/tests/e2e/web-preferences.spec.ts +++ b/web/tests/e2e/web-preferences.spec.ts @@ -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(); +}); diff --git a/web/tests/e2e/web-save-download-semantics.spec.ts b/web/tests/e2e/web-save-download-semantics.spec.ts new file mode 100644 index 000000000..ba864a893 --- /dev/null +++ b/web/tests/e2e/web-save-download-semantics.spec.ts @@ -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 { + 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$/); +});