chore: checkpoint web Blender feature-gap work, docs and tests
- update native web_engine sources and rebuilt wasm artifacts - add task cards, status notes and tooling scripts - add web e2e specs, tutorial UI sources and tutorial assets - sync golden task manifests and audit records Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
89
web/app/src/tutorial/TutorialLanding.tsx
Normal file
89
web/app/src/tutorial/TutorialLanding.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { TutorialCourse } from "./course";
|
||||
import { TutorialTreeView } from "./TutorialTreeView";
|
||||
import { readTutorialProgressFromOpfs } from "./parts/progress-store";
|
||||
import { clearTutorialProgress, readTutorialProgress } from "./parts/usePartsTutorialProgress";
|
||||
|
||||
function PartIcon({ kind }: { kind: string }) {
|
||||
const common = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||||
if (kind === "base") return <svg viewBox="0 0 64 64" aria-hidden="true"><path {...common} d="M10 40 32 28l22 12-22 12Z" /><path {...common} d="M10 40v8l22 12 22-12v-8" /><circle cx="22" cy="45" r="2.4" {...common} /><circle cx="42" cy="45" r="2.4" {...common} /></svg>;
|
||||
if (kind === "shaft") return <svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="32" cy="14" rx="11" ry="4" {...common} /><path {...common} d="M21 14v36a11 4 0 0 0 22 0V14" /><path {...common} d="M21 26h22M21 36h22" /></svg>;
|
||||
if (kind === "ring") return <svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="32" cy="34" rx="20" ry="12" {...common} /><ellipse cx="32" cy="34" rx="12" ry="7" {...common} /></svg>;
|
||||
if (kind === "washer") return <svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="32" cy="38" rx="20" ry="9" {...common} /><path {...common} d="M12 38v-8a20 9 0 0 1 40 0v8" /><ellipse cx="32" cy="30" rx="8" ry="4" {...common} /></svg>;
|
||||
if (kind === "bolt") return <svg viewBox="0 0 64 64" aria-hidden="true"><path {...common} d="M22 12h20l4 8-4 8H22l-4-8Z" /><path {...common} d="M27 28h10v24l-5 5-5-5Z" /><path {...common} d="M27 36h10M27 42h10M27 48h10" /></svg>;
|
||||
return <svg viewBox="0 0 64 64" aria-hidden="true"><path {...common} d="M8 44h20V20h12l8 10v14h8" /><path {...common} d="M16 44v8M48 44v8" /><circle cx="26" cy="18" r="2" {...common} /></svg>;
|
||||
}
|
||||
|
||||
export function TutorialLanding({ course }: { course: TutorialCourse }) {
|
||||
const initial = useMemo(() => readTutorialProgress(course), [course]);
|
||||
const [progress, setProgress] = useState(initial);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readTutorialProgressFromOpfs(course.opfsFile, course.steps).then((stored) => {
|
||||
if (cancelled || !stored) return;
|
||||
setProgress((current) => !current || Date.parse(stored.updatedAt) > Date.parse(current.updatedAt) ? stored : current);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [course]);
|
||||
const currentIndex = progress ? Math.max(0, course.steps.findIndex((step) => step.id === progress.currentStepId)) : 0;
|
||||
const completedRequired = progress
|
||||
? course.requiredSteps.filter((step) => progress.completedStepIds.includes(step.id)).length
|
||||
: 0;
|
||||
const chapters = Array.from(new Set(course.steps.map((step) => step.chapter)));
|
||||
const start = () => window.location.assign(course.startUrl);
|
||||
const reset = () => {
|
||||
clearTutorialProgress(course);
|
||||
setProgress(null);
|
||||
};
|
||||
return <main className="parts-tutorial-landing" data-course={course.id}>
|
||||
<header className="parts-landing-top">
|
||||
<a className="parts-landing-brand" href="/" aria-label="返回 Web Blender"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span>Blender Web</span></a>
|
||||
<nav aria-label="教程导航"><a href="/manuals/WEB_Blender_Function_User_Guide.pdf" target="_blank" rel="noopener">使用手册</a><a href="/" className="parts-landing-return">直接打开 Blender</a></nav>
|
||||
</header>
|
||||
<section className="parts-landing-hero">
|
||||
<div>
|
||||
<p className="parts-eyebrow">{course.eyebrow}</p>
|
||||
<h1>{course.heroTitle}<br /><span>{course.heroAccent}</span></h1>
|
||||
<p className="parts-lead">{course.lead}</p>
|
||||
<div className="parts-cta">
|
||||
<button type="button" className="parts-primary" onClick={start}>{progress ? "继续学习" : "开始学习"} <span aria-hidden="true">→</span></button>
|
||||
{progress ? <button type="button" className="parts-secondary" onClick={reset}>重置进度</button> : null}
|
||||
</div>
|
||||
{progress ? <p className="parts-resume" role="status">已完成 {completedRequired}/{course.requiredSteps.length} 个步骤 · 当前:{course.steps[currentIndex]?.title ?? "开始"}(第 {currentIndex + 1} 步)</p> : <p className="parts-resume">教程使用浏览器本地 WASM 引擎;工程保存在此浏览器中。</p>}
|
||||
</div>
|
||||
<div className="parts-hero-art" aria-hidden="true">
|
||||
<div className="parts-orbit orbit-a" />
|
||||
<div className="parts-orbit orbit-b" />
|
||||
<div className="parts-art-grid">{course.parts.slice(0, 4).map((part) => <div key={part.name} className="parts-art-piece"><PartIcon kind={part.icon} /><span>{part.name}</span></div>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="parts-section" aria-labelledby="parts-parts-title">
|
||||
<div className="parts-section-head"><h2 id="parts-parts-title">你会完成的零件</h2><p>{course.partsLead}</p></div>
|
||||
<div className="parts-card-grid">{course.parts.map((part) => <article key={part.name} className="parts-card"><PartIcon kind={part.icon} /><h3>{part.name}</h3><p>{part.note}</p></article>)}</div>
|
||||
</section>
|
||||
<section className="parts-section" aria-labelledby="parts-gallery-title">
|
||||
<div className="parts-section-head"><h2 id="parts-gallery-title">课堂实录</h2><p>{course.galleryLead}</p></div>
|
||||
<div className="parts-gallery-grid">{course.gallery.map((item) => <figure className="parts-gallery-item" key={item.step}>
|
||||
<img src={`${course.assetDir}/steps/${item.step}.jpg`} alt={`${item.title} 的实际编辑器截图`} loading="lazy" width="1440" height="900" />
|
||||
<figcaption><strong>{item.title}</strong><span>{item.note}</span></figcaption>
|
||||
</figure>)}</div>
|
||||
</section>
|
||||
<section className="parts-section parts-two-col" aria-labelledby="parts-skills-title">
|
||||
<div><h2 id="parts-skills-title">你将学会</h2><ul className="parts-skill-list">{course.skills.map((skill) => <li key={skill}>{skill}</li>)}</ul></div>
|
||||
<div><h2>课程结构</h2><TutorialTreeView ariaLabel={`${course.title}课程结构`} className="parts-course-tree" nodes={chapters.map((chapter) => {
|
||||
const chapterSteps = course.steps.filter((step) => step.chapter === chapter);
|
||||
return { id: `chapter:${chapter}`, label: chapter, meta: `${chapterSteps.length} 步`, children: chapterSteps.map((step) => ({ id: `step:${step.id}`, label: step.title })) };
|
||||
})} /></div>
|
||||
</section>
|
||||
<section className="parts-section parts-callout" aria-labelledby="parts-how-title">
|
||||
<h2 id="parts-how-title">边做边学,而不是看录像</h2>
|
||||
<div className="parts-how-grid">
|
||||
<div><strong>1. 你来做</strong><p>侧栏给出菜单路径、快捷键与尺寸;所有操作都在真实 Web Blender 中完成。</p></div>
|
||||
<div><strong>2. 实时检查</strong><p>React 侧栏读取引擎的 SceneIR,检查对象、修改器、集合与材质,而不是只比对截图。</p></div>
|
||||
<div><strong>3. 卡住就演示</strong><p>“演示本步”会用同一套 Blender 原生接口完成当前步骤,然后你可以在场景里继续改。</p></div>
|
||||
</div>
|
||||
<button type="button" className="parts-primary parts-bottom-cta" onClick={start}>{progress ? course.bottomCtaContinue : course.bottomCta} <span aria-hidden="true">→</span></button>
|
||||
</section>
|
||||
<footer className="parts-landing-footer"><span>Blender 5.2.0 LTS · WebAssembly 引擎</span><span>教程进度与工程保存在本机浏览器的 OPFS 持久存储中,不会上传。</span></footer>
|
||||
</main>;
|
||||
}
|
||||
219
web/app/src/tutorial/TutorialOverlay.tsx
Normal file
219
web/app/src/tutorial/TutorialOverlay.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TutorialCourse } from "./course";
|
||||
import { sceneHierarchyTree } from "./scene-tree";
|
||||
import { TutorialTreeView } from "./TutorialTreeView";
|
||||
import { useTutorialProgress } from "./parts/usePartsTutorialProgress";
|
||||
import type { StepCheck, TutorialAction, TutorialApi, TutorialContext, TutorialStep } from "./parts/types";
|
||||
|
||||
function CheckRow({ item }: { item: StepCheck }) {
|
||||
return <li className={item.ok ? "tutorial-check ok" : "tutorial-check"} data-check-id={item.id} data-ok={item.ok ? "true" : "false"}>
|
||||
<span aria-hidden="true">{item.ok ? "✓" : "○"}</span>
|
||||
<div><span>{item.label}</span>{!item.ok && item.hint ? <small>{item.hint}</small> : null}</div>
|
||||
</li>;
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/* A React remount must not re-open the last checkpoint over newer unsaved
|
||||
* edits in the same page session; a real reload resets this module state. */
|
||||
const preparedCourseIds = new Set<string>();
|
||||
|
||||
export function TutorialOverlay({ course, api, context }: { course: TutorialCourse; api: TutorialApi; context: TutorialContext }) {
|
||||
const { progress, hydrated, setCurrentStep, completeStep, recordProject, recordSavedProject, reset, completedRequired } = useTutorialProgress(course);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showSteps, setShowSteps] = useState(false);
|
||||
const [demoRunning, setDemoRunning] = useState(false);
|
||||
const [demoError, setDemoError] = useState<string | null>(null);
|
||||
const [resumeState, setResumeState] = useState<"idle" | "restoring" | "failed">("idle");
|
||||
const [projectState, setProjectState] = useState<"idle" | "preparing" | "ready" | "failed">("idle");
|
||||
const projectReadyRef = useRef<Promise<boolean> | null>(null);
|
||||
const checkpointInFlightRef = useRef(false);
|
||||
const index = Math.max(0, course.steps.findIndex((step) => step.id === progress.currentStepId));
|
||||
const step = course.steps[index] ?? course.steps[0];
|
||||
const checks = useMemo(() => step.validate(context), [step, context]);
|
||||
const hierarchyNodes = useMemo(() => step.hierarchy ? sceneHierarchyTree(context.snapshot) : [], [step.hierarchy, context.snapshot]);
|
||||
const passed = !step.guide && checks.length > 0 ? checks.every((item) => item.ok) : true;
|
||||
const requiredTotal = course.requiredSteps.length;
|
||||
const completed = progress.completedStepIds.includes(step.id);
|
||||
const nextStep = course.steps[index + 1];
|
||||
const previousStep = course.steps[index - 1];
|
||||
const surfaceReady = context.ready && !context.busy;
|
||||
/* The course project must be recovered or claimed before any advance can
|
||||
* record a project id; otherwise the intro click can race the OPFS write. */
|
||||
const projectPreparing = projectState === "idle" || projectState === "preparing";
|
||||
const ready = surfaceReady && !projectPreparing;
|
||||
const canDemo = Boolean(step.demo?.length) && ready && !demoRunning && (!step.canDemo || step.canDemo(context));
|
||||
|
||||
/* Resume reads the learner's scene back from the OPFS project written by the
|
||||
* app's autosave/save path, then a course without progress claims its own
|
||||
* deterministic project id. Both steps share one promise so a fresh scene can
|
||||
* never overwrite a saved course project before recovery finishes. The
|
||||
* startup template reuses its project id across sessions, so id equality does
|
||||
* not prove the current scene is the saved one; resume must always restore. */
|
||||
useEffect(() => {
|
||||
if (!surfaceReady || !hydrated || projectReadyRef.current) return;
|
||||
if (preparedCourseIds.has(course.id)) {
|
||||
projectReadyRef.current = Promise.resolve(true);
|
||||
setProjectState("ready");
|
||||
return;
|
||||
}
|
||||
const prepare = async (): Promise<boolean> => {
|
||||
setProjectState("preparing");
|
||||
if (progress.projectId && progress.completedStepIds.length > 0) {
|
||||
setResumeState("restoring");
|
||||
const recovered = await api.recoverProject(progress.projectId, progress.projectDisplayName ?? course.defaultProjectName).catch(() => false);
|
||||
setResumeState(recovered ? "idle" : "failed");
|
||||
setProjectState(recovered ? "ready" : "failed");
|
||||
if (recovered) preparedCourseIds.add(course.id);
|
||||
return recovered;
|
||||
}
|
||||
if (course.defaultProjectId) {
|
||||
const ensured = await api.ensureCourseProject(course.defaultProjectId, course.defaultProjectName).catch(() => false);
|
||||
setProjectState(ensured ? "ready" : "failed");
|
||||
if (ensured) preparedCourseIds.add(course.id);
|
||||
return ensured;
|
||||
}
|
||||
preparedCourseIds.add(course.id);
|
||||
setProjectState("ready");
|
||||
return true;
|
||||
};
|
||||
projectReadyRef.current = prepare();
|
||||
}, [surfaceReady, hydrated, progress.projectId, progress.projectDisplayName, progress.completedStepIds.length, course.defaultProjectId, course.defaultProjectName, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.id !== course.saveStepId || !context.saved || !context.projectId) return;
|
||||
if (progress.savedProjectId === context.projectId && progress.savedDisplayName === course.defaultProjectName) return;
|
||||
recordSavedProject(context.projectId, course.defaultProjectName);
|
||||
}, [course, step.id, context.saved, context.projectId, progress.savedProjectId, progress.savedDisplayName, recordSavedProject]);
|
||||
|
||||
const go = (target: TutorialStep | undefined) => {
|
||||
if (!target) return;
|
||||
setDemoError(null);
|
||||
setCurrentStep(target.id);
|
||||
};
|
||||
const advance = async () => {
|
||||
if (!passed) return;
|
||||
let prepared = projectReadyRef.current ? await projectReadyRef.current : true;
|
||||
if (!prepared && course.defaultProjectId && !progress.projectId) {
|
||||
prepared = await api.ensureCourseProject(course.defaultProjectId, course.defaultProjectName).catch(() => false);
|
||||
if (prepared) { projectReadyRef.current = Promise.resolve(true); setProjectState("ready"); }
|
||||
else setProjectState("failed");
|
||||
}
|
||||
completeStep(step.id);
|
||||
if (prepared && context.projectId) recordProject(context.projectId, context.projectDisplayName ?? course.defaultProjectName);
|
||||
// Durability is background work; the save step persists the project itself.
|
||||
// Serialize checkpoints so a heavy scene never queues an unbounded chain.
|
||||
const checkpointEvery = course.checkpointEvery ?? 1;
|
||||
if (prepared && nextStep?.id !== course.saveStepId && index % checkpointEvery === 0 && !checkpointInFlightRef.current) {
|
||||
checkpointInFlightRef.current = true;
|
||||
void api.saveCheckpoint().finally(() => { checkpointInFlightRef.current = false; });
|
||||
}
|
||||
go(nextStep);
|
||||
};
|
||||
const runDemo = async () => {
|
||||
if (!step.demo?.length || demoRunning) return;
|
||||
setDemoRunning(true);
|
||||
setDemoError(null);
|
||||
try {
|
||||
for (const action of step.demo) {
|
||||
const ok = await runAction(action, api);
|
||||
if (!ok) throw new Error("演示未完成,请检查引擎状态后重试。");
|
||||
}
|
||||
} catch (error) {
|
||||
setDemoError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setDemoRunning(false);
|
||||
}
|
||||
};
|
||||
if (collapsed) {
|
||||
return <button type="button" className="tutorial-reopen" aria-label={course.reopenAria} onClick={() => setCollapsed(false)}>
|
||||
<span aria-hidden="true">?</span><span>教程</span>
|
||||
<small>{completedRequired}/{requiredTotal}</small>
|
||||
</button>;
|
||||
}
|
||||
const isSummary = step.id === course.summaryStepId;
|
||||
return <aside className="parts-tutorial-overlay" aria-label={course.overlayTitle} data-course={course.id} data-tutorial-step={step.id} data-tutorial-ready={ready ? "true" : "false"} data-tutorial-project={projectState}>
|
||||
<header className="tutorial-head">
|
||||
<div>
|
||||
<p className="tutorial-kicker">{isSummary ? "课程完成" : step.chapter}</p>
|
||||
<h2>{step.title}</h2>
|
||||
</div>
|
||||
<button type="button" className="tutorial-icon-button" aria-label="收起教程侧栏" title="收起教程" onClick={() => setCollapsed(true)}>×</button>
|
||||
</header>
|
||||
<progress className="tutorial-progress" max={requiredTotal} value={completedRequired} aria-label="教程完成进度" />
|
||||
<p className="tutorial-progress-label">第 {index + 1}/{course.steps.length} 步 · 已完成 {completedRequired}/{requiredTotal}</p>
|
||||
<div className="tutorial-body">
|
||||
{!surfaceReady ? <div className="tutorial-loading" role="status" data-tutorial-ready="false">
|
||||
<span aria-hidden="true">◌</span> 正在打开默认工程,场景就绪后即可演示和检查。
|
||||
</div> : null}
|
||||
{projectPreparing && surfaceReady ? <div className="tutorial-loading" role="status" data-tutorial-ready="false" data-tutorial-project="preparing">
|
||||
<span aria-hidden="true">◌</span> 正在准备课程工程(OPFS)…
|
||||
</div> : null}
|
||||
{resumeState === "restoring" ? <div className="tutorial-loading" role="status" data-tutorial-resume="restoring">
|
||||
<span aria-hidden="true">◌</span> 正在从 OPFS 恢复上次的学习工程…
|
||||
</div> : null}
|
||||
{resumeState === "failed" ? <div className="tutorial-warning" role="alert" data-tutorial-resume="failed">
|
||||
<strong>未找到上次的学习工程</strong>
|
||||
<p>OPFS 中的工程可能已被清理。当前默认场景仍可继续练习;勾选检查通过后即可推进。</p>
|
||||
<button type="button" className="tutorial-link-button" onClick={() => { setResumeState("idle"); reset(); }}>清空旧进度并重新开始</button>
|
||||
</div> : null}
|
||||
{projectState === "failed" && resumeState !== "failed" ? <div className="tutorial-warning" role="alert" data-tutorial-project="failed">
|
||||
<strong>课程工程未能写入 OPFS</strong>
|
||||
<p>当前场景仍可继续练习;请检查浏览器存储权限后点击“下一步”重试保存记录。</p>
|
||||
</div> : null}
|
||||
<p className="tutorial-summary">{step.summary}</p>
|
||||
<figure className="tutorial-reference">
|
||||
<img src={`${course.assetDir}/steps/${step.id}-view.jpg`} alt={`${step.title} 完成后的视口效果`} loading="lazy" />
|
||||
<figcaption>目标效果 · 有头 Chromium 实拍</figcaption>
|
||||
</figure>
|
||||
{step.hierarchy ? <section className="tutorial-hierarchy">
|
||||
<h3>实时装配树</h3>
|
||||
<TutorialTreeView ariaLabel="场景装配层级" nodes={hierarchyNodes} defaultExpandAll />
|
||||
</section> : null}
|
||||
<ol className="tutorial-instructions">{step.instructions.map((instruction, itemIndex) => <li key={`${step.id}:${itemIndex}`}><span>{instruction.text}</span>{instruction.detail ? <small>{instruction.detail}</small> : null}</li>)}</ol>
|
||||
{step.hints?.length ? <details className="tutorial-hints"><summary>提示与技巧</summary><ul>{step.hints.map((hint) => <li key={hint}>{hint}</li>)}</ul></details> : null}
|
||||
{!step.guide ? <>
|
||||
<h3 className="tutorial-check-title">完成检查</h3>
|
||||
<ul className="tutorial-checks" aria-live="polite">{checks.map((item) => <CheckRow key={item.id} item={item} />)}</ul>
|
||||
</> : null}
|
||||
{isSummary ? <div className="tutorial-complete">
|
||||
<h3>零件清单</h3>
|
||||
<ul className="tutorial-part-list">{course.summaryParts.map((part) => <li key={part}>{part}</li>)}</ul>
|
||||
<h3>你练习过的能力</h3>
|
||||
<ul className="tutorial-skill-list">{course.skills.map((skill) => <li key={skill}>{skill}</li>)}</ul>
|
||||
<button type="button" className="tutorial-primary" onClick={() => setCollapsed(true)}>继续自由练习</button>
|
||||
<button type="button" className="tutorial-link-button" onClick={() => { reset(); setCurrentStep(course.steps[0].id); }}>重新开始教程</button>
|
||||
</div> : null}
|
||||
{demoError ? <p className="tutorial-error" role="alert">{demoError}</p> : null}
|
||||
</div>
|
||||
<footer className="tutorial-footer">
|
||||
<button type="button" className="tutorial-secondary" disabled={!previousStep} onClick={() => go(previousStep)}>上一步</button>
|
||||
{step.demo?.length ? <button type="button" className="tutorial-demo" data-demo-state={demoRunning ? "running" : canDemo ? "ready" : "blocked"} disabled={!canDemo} title={canDemo ? "用 Blender 原生接口演示当前步骤" : ready ? "请先完成前面的步骤" : "正在打开默认工程"} onClick={() => void runDemo()}>{demoRunning ? "演示中…" : "演示本步"}</button> : null}
|
||||
{nextStep ? <button type="button" className="tutorial-primary" disabled={!passed || !context.ready || projectPreparing || demoRunning} onClick={() => void advance()}>{completed && !isSummary ? "下一节" : "下一步"}<span aria-hidden="true">→</span></button> : null}
|
||||
</footer>
|
||||
<div className="tutorial-footer-links">
|
||||
<button type="button" className="tutorial-link-button" onClick={() => setShowSteps((value) => !value)}>{showSteps ? "隐藏目录" : "查看课程目录"}</button>
|
||||
<button type="button" className="tutorial-link-button" onClick={() => { reset(); setShowSteps(false); }}>重置进度</button>
|
||||
</div>
|
||||
{showSteps ? <nav className="tutorial-steps" aria-label="课程目录">
|
||||
{course.steps.map((item, itemIndex) => <button key={item.id} type="button" className={item.id === step.id ? "current" : progress.completedStepIds.includes(item.id) ? "done" : ""} onClick={() => go(item)}>
|
||||
<span>{String(itemIndex + 1).padStart(2, "0")}</span>
|
||||
<span>{item.title}</span>
|
||||
{progress.completedStepIds.includes(item.id) ? <span aria-hidden="true">✓</span> : null}
|
||||
</button>)}
|
||||
</nav> : null}
|
||||
</aside>;
|
||||
}
|
||||
|
||||
export async function runAction(action: TutorialAction, api: TutorialApi): Promise<boolean> {
|
||||
if (action.kind === "python") return api.runPython(action.source);
|
||||
if (action.kind === "command") return api.applyCommand(action.command);
|
||||
if (action.kind === "join") return api.joinObjects(action.names, action.activeName);
|
||||
if (action.kind === "parent") return api.setParents(action.names, action.activeName);
|
||||
if (action.kind === "save") { await api.save(action.fileName); return true; }
|
||||
if (action.kind === "export") { await api.exportGLB(); return true; }
|
||||
await delay(action.ms);
|
||||
return true;
|
||||
}
|
||||
98
web/app/src/tutorial/TutorialTreeView.tsx
Normal file
98
web/app/src/tutorial/TutorialTreeView.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
|
||||
export interface TutorialTreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
meta?: string;
|
||||
status?: "done" | "current";
|
||||
children?: readonly TutorialTreeNode[];
|
||||
}
|
||||
|
||||
interface VisibleNode {
|
||||
node: TutorialTreeNode;
|
||||
level: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
function flattenVisible(nodes: readonly TutorialTreeNode[], expanded: ReadonlySet<string>, level = 1, parentId: string | null = null, output: VisibleNode[] = []): VisibleNode[] {
|
||||
for (const node of nodes) {
|
||||
output.push({ node, level, parentId });
|
||||
if (node.children?.length && expanded.has(node.id)) flattenVisible(node.children, expanded, level + 1, node.id, output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parentIds(nodes: readonly TutorialTreeNode[], output = new Set<string>()): Set<string> {
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) { output.add(node.id); parentIds(node.children, output); }
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Accessible React tree used by the course outline and the live assembly tree.
|
||||
* It supports the standard tree keyboard model (arrows, Home/End, Enter/Space)
|
||||
* and is the tutorial's only tree control; no native widget is involved. */
|
||||
export function TutorialTreeView({ nodes, ariaLabel, className, defaultExpandAll = true, onActivate }: {
|
||||
nodes: readonly TutorialTreeNode[];
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
defaultExpandAll?: boolean;
|
||||
onActivate?: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => defaultExpandAll ? parentIds(nodes) : new Set());
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const itemRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
const visible = useMemo(() => flattenVisible(nodes, expanded), [nodes, expanded]);
|
||||
const focusNode = (id: string | null) => {
|
||||
if (!id) return;
|
||||
setActiveId(id);
|
||||
itemRefs.current.get(id)?.focus();
|
||||
};
|
||||
const toggle = (node: TutorialTreeNode) => {
|
||||
if (!node.children?.length) { onActivate?.(node.id); return; }
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(node.id)) next.delete(node.id);
|
||||
else next.add(node.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleKey = (event: KeyboardEvent<HTMLDivElement>, item: VisibleNode) => {
|
||||
const index = visible.findIndex((candidate) => candidate.node.id === item.node.id);
|
||||
const node = item.node;
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); focusNode(visible[Math.min(visible.length - 1, index + 1)]?.node.id ?? null); }
|
||||
else if (event.key === "ArrowUp") { event.preventDefault(); focusNode(visible[Math.max(0, index - 1)]?.node.id ?? null); }
|
||||
else if (event.key === "Home") { event.preventDefault(); focusNode(visible[0]?.node.id ?? null); }
|
||||
else if (event.key === "End") { event.preventDefault(); focusNode(visible[visible.length - 1]?.node.id ?? null); }
|
||||
else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
if (node.children?.length && !expanded.has(node.id)) setExpanded((current) => new Set(current).add(node.id));
|
||||
else if (node.children?.length) focusNode(node.children[0]?.id ?? null);
|
||||
}
|
||||
else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
if (node.children?.length && expanded.has(node.id)) setExpanded((current) => { const next = new Set(current); next.delete(node.id); return next; });
|
||||
else focusNode(item.parentId);
|
||||
}
|
||||
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(node); }
|
||||
};
|
||||
const tabbableId = activeId && visible.some((item) => item.node.id === activeId) ? activeId : visible[0]?.node.id;
|
||||
return <div className={`tutorial-tree${className ? ` ${className}` : ""}`} role="tree" aria-label={ariaLabel}>
|
||||
{visible.map((item) => {
|
||||
const node = item.node;
|
||||
const hasChildren = Boolean(node.children?.length);
|
||||
return <div key={node.id} role="treeitem" aria-level={item.level} aria-expanded={hasChildren ? expanded.has(node.id) : undefined}
|
||||
aria-selected={activeId === node.id} tabIndex={tabbableId === node.id ? 0 : -1}
|
||||
ref={(element) => { if (element) itemRefs.current.set(node.id, element); else itemRefs.current.delete(node.id); }}
|
||||
className={`tutorial-tree-row${node.status ? ` ${node.status}` : ""}`}
|
||||
onFocus={() => setActiveId(node.id)}
|
||||
onClick={() => toggle(node)}
|
||||
onKeyDown={(event) => handleKey(event, item)}>
|
||||
<span className="tutorial-tree-caret" aria-hidden="true">{hasChildren ? (expanded.has(node.id) ? "▾" : "▸") : "·"}</span>
|
||||
<span className="tutorial-tree-label">{node.label}</span>
|
||||
{node.status === "done" ? <span className="tutorial-tree-mark" aria-hidden="true">✓</span> : null}
|
||||
{node.meta ? <span className="tutorial-tree-meta">{node.meta}</span> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
171
web/app/src/tutorial/course.ts
Normal file
171
web/app/src/tutorial/course.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { PARTS_TUTORIAL_REQUIRED_STEPS, PARTS_TUTORIAL_STEPS, PARTS_TUTORIAL_STORAGE_KEY } from "./parts/curriculum";
|
||||
import { REDUCER_TUTORIAL_REQUIRED_STEPS, REDUCER_TUTORIAL_STEPS, REDUCER_TUTORIAL_STORAGE_KEY } from "./reducer/curriculum";
|
||||
import type { TutorialStep } from "./parts/types";
|
||||
|
||||
export type CourseId = "parts" | "reducer";
|
||||
|
||||
export interface TutorialCourseCard {
|
||||
name: string;
|
||||
note: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface TutorialGalleryItem {
|
||||
step: string;
|
||||
title: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Everything a course needs that was previously hardcoded in the parts UI. */
|
||||
export interface TutorialCourse {
|
||||
id: CourseId;
|
||||
title: string;
|
||||
heading: string;
|
||||
steps: readonly TutorialStep[];
|
||||
requiredSteps: readonly TutorialStep[];
|
||||
storageKey: string;
|
||||
opfsFile: string;
|
||||
assetDir: string;
|
||||
startUrl: string;
|
||||
saveStepId: string;
|
||||
summaryStepId: string;
|
||||
/** Checkpoint every N advances; heavy courses can checkpoint less often. */
|
||||
checkpointEvery?: number;
|
||||
/** Set only when the course must own a deterministic OPFS project id. */
|
||||
defaultProjectId?: string;
|
||||
defaultProjectName: string;
|
||||
pythonFileName: string;
|
||||
overlayTitle: string;
|
||||
reopenAria: string;
|
||||
heroTitle: string;
|
||||
heroAccent: string;
|
||||
eyebrow: string;
|
||||
lead: string;
|
||||
partsLead: string;
|
||||
galleryLead: string;
|
||||
bottomCta: string;
|
||||
bottomCtaContinue: string;
|
||||
parts: readonly TutorialCourseCard[];
|
||||
summaryParts: readonly string[];
|
||||
gallery: readonly TutorialGalleryItem[];
|
||||
skills: readonly string[];
|
||||
}
|
||||
|
||||
const PARTS_CARDS: readonly TutorialCourseCard[] = [
|
||||
{ name: "底座", note: "立方体 + 倒角 + 布尔孔", icon: "base" },
|
||||
{ name: "轴", note: "圆柱 + 编辑模式 + 平滑", icon: "shaft" },
|
||||
{ name: "密封圈", note: "贝塞尔曲线 + 倒角深度", icon: "ring" },
|
||||
{ name: "垫圈", note: "布尔差集 + 阵列", icon: "washer" },
|
||||
{ name: "螺栓", note: "六角头 + 合并 + 文字 M8", icon: "bolt" },
|
||||
{ name: "支架", note: "实体化 + 镜像 + 细分", icon: "bracket" },
|
||||
];
|
||||
|
||||
const PARTS_GALLERY: readonly TutorialGalleryItem[] = [
|
||||
{ step: "base-holes", title: "底座 · 布尔打孔", note: "四个圆柱合并成刀具,再用 Boolean 差集切出安装孔。" },
|
||||
{ step: "base-detail", title: "底座 · 编辑模式沉台", note: "在顶面内插并向下挤出,做出装配用的沉台。" },
|
||||
{ step: "shaft-create", title: "轴 · 圆柱与倒角", note: "圆柱参数化创建,加 Bevel 修改器并平滑着色。" },
|
||||
{ step: "gasket-curve", title: "密封圈 · 曲线建模", note: "贝塞尔圆环设置 Bevel Depth,再转换为网格。" },
|
||||
{ step: "washer-boolean", title: "垫圈 · 布尔内孔", note: "外圆柱减去内圆柱,再用 Array 叠放三片。" },
|
||||
{ step: "bolt-shaft", title: "螺栓 · 头杆合并", note: "六角头与螺栓杆 Join 成单个对象,再加倒角。" },
|
||||
{ step: "bracket-subsurf", title: "支架 · 镜像与细分", note: "Solidify 加厚度,Mirror 对称,Subsurf 圆润边缘。" },
|
||||
{ step: "assembly-hierarchy", title: "装配 · 集合与父子", note: "零件归入机械零件集合,并以 Empty 作为装配总成。" },
|
||||
];
|
||||
|
||||
const PARTS_SKILLS: readonly string[] = [
|
||||
"创建与变换:基础体、曲线、文字、Empty,移动/旋转/缩放与数值输入",
|
||||
"编辑模式:内插、挤出、倒角、选择与法线",
|
||||
"修改器与布尔:Bevel、Boolean、Mirror、Array、Solidify、Subsurf",
|
||||
"层级、材质与导出:集合、父子、材质颜色、保存 .blend、导出 GLB",
|
||||
];
|
||||
|
||||
export const COURSES = {
|
||||
parts: {
|
||||
id: "parts",
|
||||
title: "机械零件套装",
|
||||
heading: "从零制作机械零件套装",
|
||||
steps: PARTS_TUTORIAL_STEPS,
|
||||
requiredSteps: PARTS_TUTORIAL_REQUIRED_STEPS,
|
||||
storageKey: PARTS_TUTORIAL_STORAGE_KEY,
|
||||
opfsFile: "parts-progress.json",
|
||||
assetDir: "/tutorial/parts",
|
||||
startUrl: "/?tutorial=parts&start=1",
|
||||
saveStepId: "save-export",
|
||||
summaryStepId: "summary",
|
||||
checkpointEvery: 1,
|
||||
defaultProjectName: "机械零件套装.blend",
|
||||
pythonFileName: "PartsTutorial.py",
|
||||
overlayTitle: "零件建模互动教程",
|
||||
reopenAria: "展开零件建模教程",
|
||||
heroTitle: "从零制作",
|
||||
heroAccent: "机械零件套装",
|
||||
eyebrow: "互动教学 · 初学者 · 约 60–90 分钟",
|
||||
lead: "在真实运行的 Web Blender 里动手完成一套零件:底座、轴、密封圈、垫圈、螺栓与支架。每个步骤都会实时检查你的场景,卡住时可以一键演示。",
|
||||
partsLead: "从基础体到布尔、曲线与文字,覆盖零件创建的主要手法。",
|
||||
galleryLead: "以下画面由有头 Chromium 实际执行每一步“演示本步”后截取,不是示意图。",
|
||||
bottomCta: "进入教程",
|
||||
bottomCtaContinue: "继续上次进度",
|
||||
parts: PARTS_CARDS,
|
||||
summaryParts: ["底座", "轴", "密封圈", "垫圈", "螺栓", "螺栓标记", "支架"],
|
||||
gallery: PARTS_GALLERY,
|
||||
skills: PARTS_SKILLS,
|
||||
},
|
||||
reducer: {
|
||||
id: "reducer",
|
||||
title: "二级减速器",
|
||||
heading: "从零制作二级减速器",
|
||||
steps: REDUCER_TUTORIAL_STEPS,
|
||||
requiredSteps: REDUCER_TUTORIAL_REQUIRED_STEPS,
|
||||
storageKey: REDUCER_TUTORIAL_STORAGE_KEY,
|
||||
opfsFile: "reducer-progress.json",
|
||||
assetDir: "/tutorial/reducer",
|
||||
startUrl: "/?tutorial=reducer&start=1",
|
||||
saveStepId: "save-export",
|
||||
summaryStepId: "summary",
|
||||
checkpointEvery: 3,
|
||||
defaultProjectId: "tutorial-reducer",
|
||||
defaultProjectName: "二级减速器.blend",
|
||||
pythonFileName: "ReducerTutorial.py",
|
||||
overlayTitle: "二级减速器互动教程",
|
||||
reopenAria: "展开二级减速器教程",
|
||||
heroTitle: "从零制作",
|
||||
heroAccent: "二级减速器",
|
||||
eyebrow: "互动教学 · 初学者 · 约 90–120 分钟",
|
||||
lead: "在真实 Web Blender 中完成一台二级减速器:箱体、箱盖、三根轴、四个齿轮、六套轴承与端盖,最后用集合和 Empty 建立完整装配层级。每一步都会实时检查你的场景。",
|
||||
partsLead: "从箱体到齿轮,覆盖实体建模、布尔、阵列与标准件的完整流程。",
|
||||
galleryLead: "以下画面由有头 Chromium 实际执行每一步“演示本步”后截取,不是示意图。",
|
||||
bottomCta: "进入减速器教程",
|
||||
bottomCtaContinue: "继续上次进度",
|
||||
parts: [
|
||||
{ name: "箱体", note: "立方体 + Solidify + 布尔轴承孔", icon: "base" },
|
||||
{ name: "箱盖", note: "内插止口 + Mirror 螺栓孔", icon: "bracket" },
|
||||
{ name: "齿轮", note: "Array 对象偏移 + Boolean 切齿", icon: "bolt" },
|
||||
{ name: "轴", note: "圆柱 + 轴颈 + 布尔键槽", icon: "shaft" },
|
||||
{ name: "轴承", note: "Torus + 滚球 + Join", icon: "ring" },
|
||||
{ name: "附件", note: "通气帽 / 油标 / 放油塞 / 铭牌", icon: "washer" },
|
||||
],
|
||||
summaryParts: ["箱体", "箱盖", "输入轴", "中间轴", "输出轴", "齿轮1–4", "轴承1–6", "端盖1–6", "键1–4", "法兰螺栓", "通气帽", "油标", "放油塞", "铭牌"],
|
||||
gallery: [
|
||||
{ step: "housing-bores", title: "箱体 · 布尔轴承孔", note: "六个圆柱 Join 成孔刀,再用 Boolean 差集切穿两端壁。" },
|
||||
{ step: "gear1-cutter", title: "齿轮 · 阵列齿槽刀", note: "齿槽刀原点放在齿轮中心,Empty 旋转 360/17,Array 用对象偏移复制。" },
|
||||
{ step: "gear2", title: "齿轮组 · 参数化齿数", note: "同一套阵列+布尔流程生成 z=34 的大齿轮。" },
|
||||
{ step: "bearings-set", title: "轴承 · Torus 与滚球", note: "内外套圈、滚球合并后复制到三根轴两端。" },
|
||||
{ step: "shaft-assemblies", title: "装配 · 轴组件 Empty", note: "每根轴的零件 parent 到对应 Empty,实时装配树同步更新。" },
|
||||
{ step: "collections", title: "层级 · 集合归类", note: "箱体组件、传动组件、附件三个子集合。" },
|
||||
{ step: "materials-assign", title: "材质 · 四种表面", note: "铸铁、钢、黄铜、蓝漆分配到不同零件。" },
|
||||
{ step: "save-export", title: "交付 · 保存与 GLB", note: "工程写入 OPFS,并导出 GLB 供外部使用。" },
|
||||
],
|
||||
skills: [
|
||||
"零件创建与变换:立方体、圆柱、Torus、Text、Empty,应用缩放与旋转",
|
||||
"编辑模式:删面、内插、挤出、倒角与平滑着色",
|
||||
"修改器与布尔:Solidify、Bevel、Boolean 并集/差集、Array 对象偏移、Mirror、Subsurf",
|
||||
"传动件建模:轴颈与键槽、齿轮齿槽阵列、轴承套圈与滚珠",
|
||||
"装配与交付:集合、Empty 父子层级、实时装配树、材质、链接复制、保存与 GLB 导出",
|
||||
],
|
||||
},
|
||||
} satisfies Record<CourseId, TutorialCourse>;
|
||||
|
||||
export type AvailableCourseId = keyof typeof COURSES;
|
||||
|
||||
export function isAvailableCourseId(value: string | null): value is AvailableCourseId {
|
||||
return value !== null && Object.prototype.hasOwnProperty.call(COURSES, value);
|
||||
}
|
||||
6
web/app/src/tutorial/parts/PartsTutorialLanding.tsx
Normal file
6
web/app/src/tutorial/parts/PartsTutorialLanding.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { COURSES } from "../course";
|
||||
import { TutorialLanding } from "../TutorialLanding";
|
||||
|
||||
export function PartsTutorialLanding() {
|
||||
return <TutorialLanding course={COURSES.parts} />;
|
||||
}
|
||||
7
web/app/src/tutorial/parts/PartsTutorialOverlay.tsx
Normal file
7
web/app/src/tutorial/parts/PartsTutorialOverlay.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { COURSES } from "../course";
|
||||
import { TutorialOverlay } from "../TutorialOverlay";
|
||||
import type { TutorialApi, TutorialContext } from "./types";
|
||||
|
||||
export function PartsTutorialOverlay({ api, context }: { api: TutorialApi; context: TutorialContext }) {
|
||||
return <TutorialOverlay course={COURSES.parts} api={api} context={context} />;
|
||||
}
|
||||
690
web/app/src/tutorial/parts/curriculum.ts
Normal file
690
web/app/src/tutorial/parts/curriculum.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
import type { TutorialStep } from "./types";
|
||||
import {
|
||||
approx, check, collectionByName, collectionContains, findNode, meshDimensions, meshOf, materialByName,
|
||||
materialColorApprox, materialSlotIds, modifierCheck, modifierEnum, modifierNumber, modifierObject, modifierOf,
|
||||
nameMatches, nodeCheck, nodeScale, vectorApprox,
|
||||
} from "./validators";
|
||||
|
||||
const P = (source: string): string => source.trim();
|
||||
|
||||
/* Shared Python prelude used by the step demos. */
|
||||
const prelude = `
|
||||
import bpy
|
||||
def activate(obj):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
def named(name):
|
||||
return bpy.data.objects.get(name)
|
||||
def ensure_named(obj, name):
|
||||
if obj is not None:
|
||||
obj.name = name
|
||||
return obj
|
||||
def mode_object():
|
||||
if bpy.context.object and bpy.context.object.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
`;
|
||||
|
||||
const BASE_STEPS: TutorialStep[] = [
|
||||
{
|
||||
id: "intro",
|
||||
chapter: "开始",
|
||||
title: "欢迎与界面速览",
|
||||
summary: "这套课程会用真实 Web Blender 制作一套机械零件:底座、轴、密封圈、垫圈、螺栓和支架。每一步都先由你动手,侧栏会实时检查结果。",
|
||||
guide: true,
|
||||
instructions: [
|
||||
{ text: "左侧是真实的 Web Blender 编辑器;右侧是本教程。", detail: "你也可以点击工具栏的“Blender 桌面”切换到原生画布;本课程默认使用 Web 工具界面。" },
|
||||
{ text: "每一步有“演示本步”按钮;卡住时可以点它查看软件如何完成该步。" },
|
||||
{ text: "完成后点击“下一步”。侧栏会记住进度;也可以随时重置。" },
|
||||
],
|
||||
validate: () => [],
|
||||
},
|
||||
{
|
||||
id: "base-rename",
|
||||
chapter: "零件一 · 底座",
|
||||
title: "重命名并定出底座尺寸",
|
||||
summary: "先从一个立方体开始。把它命名为“底座”,并把它压成一块安装底板。",
|
||||
instructions: [
|
||||
{ text: "在 Outliner 中双击默认的 Cube(或选中后按 F2),重命名为“底座”。" },
|
||||
{ text: "在右侧属性面板的 Object > Transform 中设置缩放 X=2、Y=1.2、Z=0.15。" },
|
||||
{ text: "把位置 Z 设为 0.15,让底板贴在地面上。" },
|
||||
],
|
||||
hints: ["也可以按 S 然后依次输入 X 2、Y 1.2、Z 0.15 来缩放。", "数值字段支持直接输入并按 Enter。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
base = named('底座') or named('Cube')
|
||||
ensure_named(base, '底座')
|
||||
base.scale = (2.0, 1.2, 0.15)
|
||||
base.location = (0.0, 0.0, 0.15)
|
||||
activate(base)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const node = findNode(snapshot, "底座");
|
||||
return [
|
||||
check("base-node", "对象「底座」存在", Boolean(node)),
|
||||
check("base-scale", "缩放约为 (2, 1.2, 0.15)", vectorApprox(nodeScale(node), [2, 1.2, 0.15], 0.02), "在属性面板输入缩放值"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "base-apply-scale",
|
||||
chapter: "零件一 · 底座",
|
||||
title: "应用缩放,固定几何",
|
||||
summary: "应用缩放会把当前尺寸写进网格数据,后面的倒角和布尔运算才稳定。",
|
||||
instructions: [
|
||||
{ text: "选中底座。" },
|
||||
{ text: "打开物体菜单,选择“应用变换 → 缩放”。" },
|
||||
{ text: "检查属性面板中的缩放回到 (1, 1, 1),而外形尺寸保持不变。" },
|
||||
],
|
||||
hints: ["Ctrl+A 也可以打开应用变换菜单。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
base = named('底座')
|
||||
activate(base)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const node = findNode(snapshot, "底座");
|
||||
const dims = meshDimensions(meshOf(snapshot, node));
|
||||
return [
|
||||
check("apply-scale", "缩放已应用为 (1, 1, 1)", vectorApprox(nodeScale(node), [1, 1, 1], 0.001)),
|
||||
check("apply-dims", "底座外形约为 4 × 2.4 × 0.3", Boolean(dims) && vectorApprox(dims, [4, 2.4, 0.3], 0.03), "应用缩放后再检查尺寸"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "base-bevel",
|
||||
chapter: "零件一 · 底座",
|
||||
title: "倒角修改器:给边缘加圆角",
|
||||
summary: "用非破坏性的 Bevel 修改器把锋利边缘变成圆角;参数随时可改。",
|
||||
instructions: [
|
||||
{ text: "选中底座,打开右侧的修改器面板。" },
|
||||
{ text: "添加 Bevel(倒角)修改器。" },
|
||||
{ text: "设置 Width(宽度)= 0.03,Segments(段数)= 3。" },
|
||||
],
|
||||
hints: ["如果找不到参数,展开修改器条目即可看到 Width 与 Segments。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
base = named('底座')
|
||||
activate(base)
|
||||
mod = base.modifiers.get('底座倒角') or base.modifiers.new('底座倒角', 'BEVEL')
|
||||
mod.width = 0.03
|
||||
mod.segments = 3
|
||||
mod.limit_method = 'ANGLE'
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "底座"), "BEVEL");
|
||||
return [
|
||||
modifierCheck(snapshot, "底座", "BEVEL"),
|
||||
check("bevel-width", "倒角宽度约为 0.03", approx(modifierNumber(modifier, "width"), 0.03, 0.005), "在修改器中输入 Width"),
|
||||
check("bevel-segments", "倒角段数为 3", modifierNumber(modifier, "segments") === 3, "把 Segments 设为 3"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "base-holes",
|
||||
chapter: "零件一 · 底座",
|
||||
title: "布尔运算:切出四个安装孔",
|
||||
summary: "制作四个圆柱作为刀具,用 Boolean 差集在底座上切孔。",
|
||||
instructions: [
|
||||
{ text: "添加 4 个圆柱(半径 0.18、深度 0.6),分别放在底座的四个角。" },
|
||||
{ text: "把 4 个圆柱选中,最后选中一个并执行 Join(Ctrl+J),合并成“开孔刀具”。" },
|
||||
{ text: "选中底座,添加 Boolean 修改器,模式选 Difference(差集),Object 选择“开孔刀具”。" },
|
||||
{ text: "在视口中隐藏刀具对象,观察底座上的孔。" },
|
||||
],
|
||||
hints: ["刀具要完全穿过底板,孔才会通。", "Boolean 的 Object 下拉里选择“开孔刀具”。"],
|
||||
demo: [
|
||||
{ kind: "python", source: P(`${prelude}
|
||||
base = named('底座')
|
||||
mode_object()
|
||||
for index, (x, y) in enumerate(((1.4, 0.8), (-1.4, 0.8), (-1.4, -0.8), (1.4, -0.8)), start=1):
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=32, radius=0.18, depth=0.6, location=(x, y, 0.15))
|
||||
ensure_named(bpy.context.active_object, '孔刀%d' % index)
|
||||
activate(base)
|
||||
`) },
|
||||
{ kind: "join", names: ["孔刀1", "孔刀2", "孔刀3", "孔刀4"], activeName: "孔刀1" },
|
||||
{ kind: "python", source: P(`${prelude}
|
||||
cutter = ensure_named(named('孔刀1'), '开孔刀具')
|
||||
base = named('底座')
|
||||
mod = base.modifiers.get('安装孔') or base.modifiers.new('安装孔', 'BOOLEAN')
|
||||
mod.operation = 'DIFFERENCE'
|
||||
mod.solver = 'EXACT'
|
||||
mod.object = cutter
|
||||
cutter.hide_set(True)
|
||||
activate(base)
|
||||
`) },
|
||||
],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "底座"), "BOOLEAN");
|
||||
const cutter = findNode(snapshot, "开孔刀具");
|
||||
const operation = modifierEnum(modifier, "operation");
|
||||
const target = modifierObject(modifier, "object");
|
||||
return [
|
||||
modifierCheck(snapshot, "底座", "BOOLEAN"),
|
||||
check("boolean-difference", "布尔模式为 Difference(差集)", operation === "DIFFERENCE", "在修改器中选择 Difference"),
|
||||
check("boolean-object", "布尔对象指向「开孔刀具」", Boolean(cutter) && (target === cutter!.id || target === cutter!.name || target === `object:${cutter!.name}`), "在 Object 下拉中选择开孔刀具"),
|
||||
nodeCheck(snapshot, "开孔刀具", "MESH"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "base-detail",
|
||||
chapter: "零件一 · 底座",
|
||||
title: "编辑模式:给底座做沉台",
|
||||
summary: "进入 Edit Mode,选中顶面做内插与下沉,给底座加出一个装饰沉台。",
|
||||
instructions: [
|
||||
{ text: "选中底座,按 Tab 进入 Edit Mode。" },
|
||||
{ text: "切换为面选择(3),点击选中顶面。" },
|
||||
{ text: "按 I 内插面(厚度约 0.25),再按 E 向下挤出(约 -0.05)。" },
|
||||
{ text: "按 Tab 返回 Object Mode。" },
|
||||
],
|
||||
hints: ["顶面是 Z 最大的一面。", "内插后不要移动鼠标,直接输入数值再回车更快。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
import bmesh
|
||||
base = named('底座')
|
||||
activate(base)
|
||||
bpy.context.tool_settings.mesh_select_mode = (False, False, True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bm = bmesh.from_edit_mesh(base.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
for face in bm.faces:
|
||||
face.select_set(False)
|
||||
top = max(bm.faces, key=lambda face: face.calc_center_median().z)
|
||||
top.select_set(True)
|
||||
bmesh.update_edit_mesh(base.data)
|
||||
bpy.ops.mesh.inset(thickness=0.25, depth=0.0)
|
||||
bpy.ops.mesh.extrude_region()
|
||||
bpy.ops.transform.translate(value=(0.0, 0.0, -0.05))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const mesh = meshOf(snapshot, findNode(snapshot, "底座"));
|
||||
return [
|
||||
check("base-edit-faces", "底座网格面数已增加(沉台已生成)", Boolean(mesh) && (mesh!.faceCount ?? 0) > 10, "在编辑模式中对顶面执行内插/挤出"),
|
||||
check("base-still-mesh", "底座仍是网格对象", findNode(snapshot, "底座")?.type === "MESH"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shaft-create",
|
||||
chapter: "零件二 · 轴",
|
||||
title: "创建轴并倒角",
|
||||
summary: "用圆柱做出轴,再给它加 Bevel 和平滑着色。",
|
||||
instructions: [
|
||||
{ text: "添加圆柱:半径 0.15、深度 1.8、位置 (0, 0, 1.2)。" },
|
||||
{ text: "重命名为“轴”。" },
|
||||
{ text: "添加 Bevel 修改器(宽度 0.01、段数 2),并执行“平滑着色”。" },
|
||||
],
|
||||
hints: ["添加圆柱后可在“调整上一步操作”面板中精确输入半径和深度。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=32, radius=0.15, depth=1.8, location=(0.0, 0.0, 1.2))
|
||||
shaft = ensure_named(bpy.context.active_object, '轴')
|
||||
mod = shaft.modifiers.get('轴倒角') or shaft.modifiers.new('轴倒角', 'BEVEL')
|
||||
mod.width = 0.01
|
||||
mod.segments = 2
|
||||
activate(shaft)
|
||||
bpy.ops.object.shade_smooth()
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const node = findNode(snapshot, "轴");
|
||||
return [
|
||||
nodeCheck(snapshot, "轴", "MESH"),
|
||||
modifierCheck(snapshot, "轴", "BEVEL"),
|
||||
check("shaft-width", "轴倒角宽度约为 0.01", approx(modifierNumber(modifierOf(snapshot, node, "BEVEL"), "width"), 0.01, 0.003)),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shaft-edit",
|
||||
chapter: "零件二 · 轴",
|
||||
title: "编辑模式:轴端倒角",
|
||||
summary: "对轴的顶面做一次内插/倒角,做出装配用的轴肩。",
|
||||
instructions: [
|
||||
{ text: "选中轴,Tab 进入 Edit Mode。" },
|
||||
{ text: "面选择,选中顶面。" },
|
||||
{ text: "用 I 内插约 0.05,再按 E 向上挤出约 0.08。" },
|
||||
{ text: "Tab 返回 Object Mode。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
import bmesh
|
||||
shaft = named('轴')
|
||||
activate(shaft)
|
||||
bpy.context.tool_settings.mesh_select_mode = (False, False, True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bm = bmesh.from_edit_mesh(shaft.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
for face in bm.faces:
|
||||
face.select_set(False)
|
||||
top = max(bm.faces, key=lambda face: face.calc_center_median().z)
|
||||
top.select_set(True)
|
||||
bmesh.update_edit_mesh(shaft.data)
|
||||
bpy.ops.mesh.inset(thickness=0.05, depth=0.0)
|
||||
bpy.ops.mesh.extrude_region()
|
||||
bpy.ops.transform.translate(value=(0.0, 0.0, 0.08))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const mesh = meshOf(snapshot, findNode(snapshot, "轴"));
|
||||
return [
|
||||
check("shaft-faces", "轴的面数已增加(轴肩已生成)", Boolean(mesh) && (mesh!.faceCount ?? 0) > 40, "在编辑模式中对顶面内插和挤出"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gasket-curve",
|
||||
chapter: "零件三 · 密封圈",
|
||||
title: "用曲线做密封圈",
|
||||
summary: "曲线加上 Bevel Depth 会变成有厚度的管状体,再转换为网格。",
|
||||
instructions: [
|
||||
{ text: "添加 → 曲线 → 贝塞尔圆环,半径约 0.4,放在 (1.6, 0, 0.34)。" },
|
||||
{ text: "在曲线属性的“几何数据”中把 Bevel Depth 设为 0.03、Resolution 设为 2。" },
|
||||
{ text: "重命名为“密封圈”,然后物体 → 转换 → 网格。" },
|
||||
],
|
||||
hints: ["曲线属性里也可以使用“Set Bevel”快捷按钮。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.curve.primitive_bezier_circle_add(radius=0.4, location=(1.6, 0.0, 0.34))
|
||||
curve = bpy.context.active_object
|
||||
curve.data.bevel_depth = 0.03
|
||||
curve.data.bevel_resolution = 2
|
||||
ensure_named(curve, '密封圈')
|
||||
activate(curve)
|
||||
bpy.ops.object.convert(target='MESH')
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const node = findNode(snapshot, "密封圈");
|
||||
const mesh = meshOf(snapshot, node);
|
||||
return [
|
||||
nodeCheck(snapshot, "密封圈", "MESH"),
|
||||
check("gasket-faces", "密封圈已有管状网格", Boolean(mesh) && (mesh!.faceCount ?? 0) > 0, "曲线需要设置 Bevel Depth 并转换为网格"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "washer-boolean",
|
||||
chapter: "零件四 · 垫圈",
|
||||
title: "布尔差集:垫圈内孔",
|
||||
summary: "外圆柱减去内圆柱,得到一个带孔的垫圈。",
|
||||
instructions: [
|
||||
{ text: "添加外圆柱:半径 0.35、深度 0.08、位置 (1.6, 1.2, 0.34),命名“垫圈”。" },
|
||||
{ text: "添加内圆柱:半径 0.18、深度 0.3、同一位置,命名“垫圈刀具”。" },
|
||||
{ text: "给垫圈添加 Boolean 修改器,Difference,Object 选择“垫圈刀具”;隐藏刀具。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=48, radius=0.35, depth=0.08, location=(1.6, 1.2, 0.34))
|
||||
washer = ensure_named(bpy.context.active_object, '垫圈')
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=48, radius=0.18, depth=0.3, location=(1.6, 1.2, 0.34))
|
||||
cutter = ensure_named(bpy.context.active_object, '垫圈刀具')
|
||||
mod = washer.modifiers.get('内孔') or washer.modifiers.new('内孔', 'BOOLEAN')
|
||||
mod.operation = 'DIFFERENCE'
|
||||
mod.solver = 'EXACT'
|
||||
mod.object = cutter
|
||||
cutter.hide_set(True)
|
||||
activate(washer)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "垫圈"), "BOOLEAN");
|
||||
const cutter = findNode(snapshot, "垫圈刀具");
|
||||
return [
|
||||
nodeCheck(snapshot, "垫圈", "MESH"),
|
||||
modifierCheck(snapshot, "垫圈", "BOOLEAN"),
|
||||
check("washer-difference", "垫圈布尔为 Difference", modifierEnum(modifier, "operation") === "DIFFERENCE"),
|
||||
check("washer-object", "布尔对象指向「垫圈刀具」", Boolean(cutter) && Boolean(modifierObject(modifier, "object"))),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "washer-array",
|
||||
chapter: "零件四 · 垫圈",
|
||||
title: "阵列修改器:叠放垫圈",
|
||||
summary: "Array 修改器把垫圈沿 Z 复制 3 层,形成一个垫圈组。",
|
||||
instructions: [
|
||||
{ text: "选中垫圈,添加 Array(阵列)修改器。" },
|
||||
{ text: "Count(数量)设为 3。" },
|
||||
{ text: "关闭相对偏移的 X/Y,只保留 Z 偏移;让三片垫圈沿 Z 叠放。" },
|
||||
],
|
||||
hints: ["Array 的 Relative Offset 以对象自身尺寸为单位。"],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
washer = named('垫圈')
|
||||
activate(washer)
|
||||
mod = washer.modifiers.get('阵列') or washer.modifiers.new('阵列', 'ARRAY')
|
||||
mod.count = 3
|
||||
mod.use_relative_offset = True
|
||||
mod.relative_offset_displace = (0.0, 0.0, 1.5)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "垫圈"), "ARRAY");
|
||||
return [
|
||||
modifierCheck(snapshot, "垫圈", "ARRAY"),
|
||||
check("array-count", "阵列数量为 3", modifierNumber(modifier, "count") === 3, "把 Count 改为 3"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bolt-head",
|
||||
chapter: "零件五 · 螺栓",
|
||||
title: "六角螺栓头",
|
||||
summary: "用 vertices=6 的圆柱做六角头,再进入编辑模式做出倒角台。",
|
||||
instructions: [
|
||||
{ text: "添加圆柱,把顶点数设为 6、半径 0.22、深度 0.16,位置 (1.6, -1.2, 1.28)。" },
|
||||
{ text: "重命名为“螺栓头”。" },
|
||||
{ text: "Tab 进入 Edit Mode,选中顶面,用 I 内插 0.04,再向下挤出 0.03。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
import bmesh
|
||||
mode_object()
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=6, radius=0.22, depth=0.16, location=(1.6, -1.2, 1.28))
|
||||
head = ensure_named(bpy.context.active_object, '螺栓头')
|
||||
activate(head)
|
||||
bpy.context.tool_settings.mesh_select_mode = (False, False, True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bm = bmesh.from_edit_mesh(head.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
for face in bm.faces:
|
||||
face.select_set(False)
|
||||
top = max(bm.faces, key=lambda face: face.calc_center_median().z)
|
||||
top.select_set(True)
|
||||
bmesh.update_edit_mesh(head.data)
|
||||
bpy.ops.mesh.inset(thickness=0.04, depth=0.0)
|
||||
bpy.ops.mesh.extrude_region()
|
||||
bpy.ops.transform.translate(value=(0.0, 0.0, -0.03))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const mesh = meshOf(snapshot, findNode(snapshot, "螺栓头"));
|
||||
return [
|
||||
nodeCheck(snapshot, "螺栓头", "MESH"),
|
||||
check("bolt-head-faces", "六角头已做出内插/挤出细节", Boolean(mesh) && (mesh!.faceCount ?? 0) > 10),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bolt-shaft",
|
||||
chapter: "零件五 · 螺栓",
|
||||
title: "螺栓杆与合并",
|
||||
summary: "做出螺栓杆,把头与杆 Join 成一个对象,再加倒角。",
|
||||
instructions: [
|
||||
{ text: "添加圆柱:半径 0.09、深度 0.9、位置 (1.6, -1.2, 0.75),命名“螺栓杆”。" },
|
||||
{ text: "同时选中“螺栓头”和“螺栓杆”,活动对象为螺栓头,执行 Join(Ctrl+J)。" },
|
||||
{ text: "把合并后的对象重命名为“螺栓”,添加 Bevel 修改器(0.01、2 段)。" },
|
||||
],
|
||||
demo: [
|
||||
{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=32, radius=0.09, depth=0.9, location=(1.6, -1.2, 0.75))
|
||||
ensure_named(bpy.context.active_object, '螺栓杆')
|
||||
`) },
|
||||
{ kind: "join", names: ["螺栓头", "螺栓杆"], activeName: "螺栓头" },
|
||||
{ kind: "python", source: P(`${prelude}
|
||||
bolt = ensure_named(named('螺栓头'), '螺栓')
|
||||
mod = bolt.modifiers.get('螺栓倒角') or bolt.modifiers.new('螺栓倒角', 'BEVEL')
|
||||
mod.width = 0.01
|
||||
mod.segments = 2
|
||||
activate(bolt)
|
||||
`) },
|
||||
],
|
||||
validate: ({ snapshot }) => {
|
||||
const node = findNode(snapshot, "螺栓");
|
||||
const mesh = meshOf(snapshot, node);
|
||||
return [
|
||||
nodeCheck(snapshot, "螺栓", "MESH"),
|
||||
check("bolt-merged", "螺栓头与杆已合并(面数大于单独头部)", Boolean(mesh) && (mesh!.faceCount ?? 0) > 20),
|
||||
modifierCheck(snapshot, "螺栓", "BEVEL"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bolt-label",
|
||||
chapter: "零件五 · 螺栓",
|
||||
title: "文字标记 M8",
|
||||
summary: "添加文字对象,写上规格 M8,再转换成网格并作为螺栓的子对象。",
|
||||
instructions: [
|
||||
{ text: "添加 → 文字,位置放在螺栓头顶部。" },
|
||||
{ text: "在文字数据中把 Body 改为 M8,大小约 0.14,居中对齐。" },
|
||||
{ text: "转换为网格,重命名“螺栓标记”。" },
|
||||
{ text: "选中螺栓标记,再选中螺栓作为活动对象,执行“设置父级”。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.object.text_add(location=(1.6, -1.2, 1.38))
|
||||
text = bpy.context.active_object
|
||||
text.data.body = 'M8'
|
||||
text.data.size = 0.14
|
||||
text.data.align_x = 'CENTER'
|
||||
text.data.align_y = 'CENTER'
|
||||
activate(text)
|
||||
bpy.ops.object.convert(target='MESH')
|
||||
label = ensure_named(bpy.context.active_object, '螺栓标记')
|
||||
bolt = named('螺栓')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
label.select_set(True)
|
||||
bolt.select_set(True)
|
||||
bpy.context.view_layer.objects.active = bolt
|
||||
bpy.ops.object.parent_set(type='OBJECT', keep_transform=True)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const label = findNode(snapshot, "螺栓标记");
|
||||
const bolt = findNode(snapshot, "螺栓");
|
||||
const mesh = meshOf(snapshot, label);
|
||||
return [
|
||||
nodeCheck(snapshot, "螺栓标记", "MESH"),
|
||||
check("label-mesh", "文字已转换为网格", Boolean(mesh) && (mesh!.faceCount ?? 0) > 0),
|
||||
check("label-parent", "螺栓标记的父级是螺栓", Boolean(label && bolt && label.parentId === bolt!.id), "选中标记后以螺栓为活动对象设置父级"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bracket-solidify",
|
||||
chapter: "零件六 · 支架",
|
||||
title: "支架:实体化与镜像",
|
||||
summary: "用平面/立方体做支架,Solidify 给它厚度,Mirror 复制到另一侧。",
|
||||
instructions: [
|
||||
{ text: "添加立方体,缩放约 (0.8, 0.4, 0.05),放在 (-1.8, 0, 0.6)。" },
|
||||
{ text: "应用位置/旋转/缩放,让原点回到世界中心,Mirror 才能以中心对称。" },
|
||||
{ text: "添加 Solidify(厚度 0.05)和 Mirror(X 轴)修改器。" },
|
||||
{ text: "重命名为“支架”。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
mode_object()
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(-1.8, 0.0, 0.6))
|
||||
bracket = bpy.context.active_object
|
||||
bracket.name = '支架'
|
||||
bracket.scale = (0.8, 0.4, 0.05)
|
||||
activate(bracket)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
solid = bracket.modifiers.get('实体化') or bracket.modifiers.new('实体化', 'SOLIDIFY')
|
||||
solid.thickness = 0.05
|
||||
mirror = bracket.modifiers.get('镜像') or bracket.modifiers.new('镜像', 'MIRROR')
|
||||
mirror.use_axis[0] = True
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "支架"), "SOLIDIFY");
|
||||
return [
|
||||
nodeCheck(snapshot, "支架", "MESH"),
|
||||
modifierCheck(snapshot, "支架", "SOLIDIFY"),
|
||||
modifierCheck(snapshot, "支架", "MIRROR"),
|
||||
check("solidify-thickness", "实体化厚度约为 0.05", approx(modifierNumber(modifier, "thickness"), 0.05, 0.01)),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bracket-subsurf",
|
||||
chapter: "零件六 · 支架",
|
||||
title: "细分曲面:圆润支架",
|
||||
summary: "Subdivision Surface 把支架的硬边变成平滑曲面;层级 2 即可。",
|
||||
instructions: [
|
||||
{ text: "选中支架,添加 Subdivision Surface(表面细分)修改器。" },
|
||||
{ text: "把 Levels Viewport 与 Render 都设为 2。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
bracket = named('支架')
|
||||
activate(bracket)
|
||||
mod = bracket.modifiers.get('细分') or bracket.modifiers.new('细分', 'SUBSURF')
|
||||
mod.levels = 2
|
||||
mod.render_levels = 2
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const modifier = modifierOf(snapshot, findNode(snapshot, "支架"), "SUBSURF");
|
||||
return [
|
||||
modifierCheck(snapshot, "支架", "SUBSURF"),
|
||||
check("subsurf-levels", "细分层级为 2", modifierNumber(modifier, "levels") === 2, "把 Levels Viewport 设为 2"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "assembly-hierarchy",
|
||||
chapter: "装配与交付",
|
||||
title: "集合与父子层级",
|
||||
summary: "把所有零件放进“机械零件”集合,并用一个 Empty 作为装配总成。",
|
||||
instructions: [
|
||||
{ text: "在 Outliner 中点击“新建集合”,命名为“机械零件”。" },
|
||||
{ text: "把底座、轴、密封圈、垫圈、螺栓、螺栓标记、支架移动到该集合。" },
|
||||
{ text: "添加空物体(Empty),命名为“装配总成”,放在世界原点。" },
|
||||
{ text: "选中所有零件,以“装配总成”为活动对象执行“设置父级”。" },
|
||||
],
|
||||
hints: ["Outliner 中可以直接拖拽对象到集合。", "刀具对象可以留在主集合中,不必放进机械零件集合。"],
|
||||
demo: [
|
||||
{ kind: "python", source: P(`${prelude}
|
||||
collection = bpy.data.collections.get('机械零件') or bpy.data.collections.new('机械零件')
|
||||
if bpy.context.scene.collection.children.get('机械零件') is None:
|
||||
bpy.context.scene.collection.children.link(collection)
|
||||
part_names = ('底座', '轴', '密封圈', '垫圈', '螺栓', '螺栓标记', '支架')
|
||||
for name in part_names:
|
||||
obj = named(name)
|
||||
if obj is None:
|
||||
continue
|
||||
for child in list(obj.users_collection):
|
||||
child.objects.unlink(obj)
|
||||
collection.objects.link(obj)
|
||||
bpy.ops.object.empty_add(type='PLAIN_AXES', location=(0.0, 0.0, 0.0))
|
||||
root = ensure_named(bpy.context.active_object, '装配总成')
|
||||
activate(root)
|
||||
`) },
|
||||
{ kind: "parent", names: ["底座", "轴", "密封圈", "垫圈", "螺栓", "螺栓标记", "支架"], activeName: "装配总成" },
|
||||
],
|
||||
validate: ({ snapshot }) => {
|
||||
const collection = collectionByName(snapshot, "机械零件");
|
||||
const root = findNode(snapshot, "装配总成");
|
||||
const parts = ["底座", "轴", "密封圈", "垫圈", "螺栓", "螺栓标记", "支架"];
|
||||
const unparented = root ? parts.filter((name) => findNode(snapshot, name)?.parentId !== root.id) : parts;
|
||||
return [
|
||||
check("collection-exists", "集合「机械零件」存在", Boolean(collection)),
|
||||
check("collection-members", "集合包含全部零件", Boolean(collection) && parts.every((name) => {
|
||||
const node = findNode(snapshot, name);
|
||||
return Boolean(node) && collectionContains(collection, node);
|
||||
}), "把零件移动到机械零件集合"),
|
||||
nodeCheck(snapshot, "装配总成", "EMPTY"),
|
||||
check("parts-parented", "零件父级为「装配总成」", Boolean(root) && unparented.length === 0,
|
||||
root ? `仍未设置父级:${unparented.join("、")}` : "选中零件后以装配总成为活动对象设置父级"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "materials",
|
||||
chapter: "装配与交付",
|
||||
title: "材质与颜色",
|
||||
summary: "建立钢、黄铜、深灰三种材质,并分配给不同零件。",
|
||||
instructions: [
|
||||
{ text: "在材质属性中新建“钢”,Base Color 设为浅灰(约 0.62, 0.66, 0.70),Metallic 偏高。" },
|
||||
{ text: "新建“黄铜”,Base Color 设为 (0.72, 0.55, 0.20)。" },
|
||||
{ text: "新建“深灰”,Base Color 设为 (0.20, 0.21, 0.22)。" },
|
||||
{ text: "为底座/轴/螺栓分配钢,为垫圈/支架/标记分配黄铜,为密封圈分配深灰。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
def material(name, color, metallic):
|
||||
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get('Principled BSDF')
|
||||
if bsdf is not None:
|
||||
bsdf.inputs['Base Color'].default_value = color
|
||||
bsdf.inputs['Metallic'].default_value = metallic
|
||||
mat.diffuse_color = color
|
||||
return mat
|
||||
steel = material('钢', (0.62, 0.66, 0.70, 1.0), 0.9)
|
||||
brass = material('黄铜', (0.72, 0.55, 0.20, 1.0), 0.8)
|
||||
graphite = material('深灰', (0.20, 0.21, 0.22, 1.0), 0.2)
|
||||
assignments = {'底座': steel, '轴': steel, '螺栓': steel, '垫圈': brass, '支架': brass, '螺栓标记': brass, '密封圈': graphite}
|
||||
for name, mat in assignments.items():
|
||||
obj = named(name)
|
||||
if obj is None or obj.type != 'MESH':
|
||||
continue
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(mat)
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const steel = materialByName(snapshot, "钢");
|
||||
const brass = materialByName(snapshot, "黄铜");
|
||||
const graphite = materialByName(snapshot, "深灰");
|
||||
const hasMaterial = (name: string, materialId: string) => {
|
||||
const node = findNode(snapshot, name);
|
||||
return materialSlotIds(snapshot, node).includes(materialId);
|
||||
};
|
||||
return [
|
||||
check("mat-steel", "材质「钢」存在且颜色接近浅灰", materialColorApprox(steel, [0.62, 0.66, 0.70], 0.08)),
|
||||
check("mat-brass", "材质「黄铜」存在且颜色接近黄铜色", materialColorApprox(brass, [0.72, 0.55, 0.20], 0.08)),
|
||||
check("mat-graphite", "材质「深灰」存在且颜色接近深灰", materialColorApprox(graphite, [0.20, 0.21, 0.22], 0.08)),
|
||||
check("mat-assign", "主要零件已分配材质", Boolean(steel && brass && graphite) &&
|
||||
hasMaterial("底座", steel!.id) && hasMaterial("垫圈", brass!.id) && hasMaterial("密封圈", graphite!.id), "为零件添加材质槽并选择材质"),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "duplicate-set",
|
||||
chapter: "装配与交付",
|
||||
title: "复制零件,扩展套装",
|
||||
summary: "复制螺栓,放到另一侧,练习对象复制与位置调整。",
|
||||
instructions: [
|
||||
{ text: "选中螺栓,按 Shift+D 复制。" },
|
||||
{ text: "把副本移动到 Y = -2.0 附近(或使用属性面板输入位置)。" },
|
||||
{ text: "把副本重命名为“螺栓”(会自动得到 .001 后缀)。" },
|
||||
],
|
||||
demo: [{ kind: "python", source: P(`${prelude}
|
||||
bolt = named('螺栓')
|
||||
activate(bolt)
|
||||
bpy.ops.object.duplicate(linked=False)
|
||||
copy = bpy.context.active_object
|
||||
copy.location.y -= 0.9
|
||||
copy.name = '螺栓'
|
||||
`) }],
|
||||
validate: ({ snapshot }) => {
|
||||
const copies = snapshot?.nodes.filter((node) => nameMatches(node.name, "螺栓")) ?? [];
|
||||
return [
|
||||
check("bolt-copies", "场景中至少有两个螺栓", copies.length >= 2, "选中螺栓后 Shift+D 复制"),
|
||||
check("bolt-copy-moved", "副本位置与原件不同", copies.length >= 2 && copies.some((node) => Math.abs(node.transform.translation[1] - copies[0].transform.translation[1]) > 0.3)),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "save-export",
|
||||
chapter: "装配与交付",
|
||||
title: "保存工程并导出 GLB",
|
||||
summary: "把工程保存为“机械零件套装.blend”,并导出 GLB 供外部使用。",
|
||||
instructions: [
|
||||
{ text: "文件 → 保存,文件名使用“机械零件套装.blend”。" },
|
||||
{ text: "工具栏或文件菜单 → 导出 GLB。" },
|
||||
{ text: "确认浏览器完成下载;保存成功后,本步会自动勾选。" },
|
||||
],
|
||||
hints: ["如果之前没有保存过,保存会下载一份 .blend 副本并写入浏览器的项目存储。"],
|
||||
demo: [{ kind: "save", fileName: "机械零件套装.blend" }, { kind: "export" }, { kind: "wait", ms: 1200 }],
|
||||
validate: ({ saved, exported }) => [
|
||||
check("save", "工程已保存", saved, "使用文件 → 保存"),
|
||||
check("export", "GLB 已导出", exported, "使用导出 GLB 并等待下载"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
chapter: "完成",
|
||||
title: "完成总结",
|
||||
summary: "你已经完成了一套机械零件:底座、轴、密封圈、垫圈、螺栓、文字标记和支架。",
|
||||
guide: true,
|
||||
instructions: [
|
||||
{ text: "回看零件清单:底座、轴、密封圈、垫圈、螺栓、螺栓标记、支架(以及复制的螺栓)。" },
|
||||
{ text: "你练习了:对象创建与变换、编辑模式内插/挤出、Bevel/Boolean/Mirror/Array/Solidify/Subsurf、集合与父子、材质、保存与 GLB 导出。" },
|
||||
{ text: "继续练习:给底座添加倒角阵列、尝试用齿轮布尔做出传动件,或打开帮助手册查看更多菜单。" },
|
||||
],
|
||||
validate: () => [],
|
||||
},
|
||||
];
|
||||
|
||||
export const PARTS_TUTORIAL_STEPS: TutorialStep[] = BASE_STEPS;
|
||||
export const PARTS_TUTORIAL_REQUIRED_STEPS = PARTS_TUTORIAL_STEPS.filter((step) => !step.guide);
|
||||
export const PARTS_TUTORIAL_STORAGE_KEY = "web-blender.parts-tutorial.v1";
|
||||
209
web/app/src/tutorial/parts/parts-tutorial.css
Normal file
209
web/app/src/tutorial/parts/parts-tutorial.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/* Parts tutorial: landing page and in-editor guided panel. */
|
||||
|
||||
.parts-tutorial-landing {
|
||||
--parts-bg: #151617;
|
||||
--parts-panel: #1d1f21;
|
||||
--parts-panel-2: #24272a;
|
||||
--parts-line: #34383c;
|
||||
--parts-text: #e8e9ea;
|
||||
--parts-muted: #a6abb0;
|
||||
--parts-accent: #f28c28;
|
||||
--parts-accent-2: #6f9fce;
|
||||
--parts-green: #7ac77a;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(900px 420px at 80% -120px, rgba(111, 159, 206, 0.18), transparent 70%),
|
||||
radial-gradient(700px 380px at 10% 10%, rgba(242, 140, 40, 0.09), transparent 70%),
|
||||
var(--parts-bg);
|
||||
color: var(--parts-text);
|
||||
font-family: "Noto Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.parts-tutorial-landing a { color: inherit; }
|
||||
.parts-landing-top {
|
||||
max-width: 1180px; margin: 0 auto; padding: 22px 32px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
}
|
||||
.parts-landing-brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 700; letter-spacing: .02em; }
|
||||
.parts-landing-brand .brand-mark { position: relative; width: 22px; height: 22px; display: inline-block; background: #202124; border-radius: 6px; }
|
||||
.parts-landing-brand .brand-mark::before { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: conic-gradient(from 210deg, transparent 0 42%, var(--parts-accent) 42% 56%, transparent 56% 100%); }
|
||||
.parts-landing-brand .brand-mark::after { content: ""; position: absolute; width: 8px; height: 8px; border-radius: 50%; background: var(--parts-accent-2); right: 3px; top: 7px; }
|
||||
.parts-landing-top nav { display: flex; align-items: center; gap: 18px; font-size: 13px; color: var(--parts-muted); }
|
||||
.parts-landing-top nav a { text-decoration: none; }
|
||||
.parts-landing-top nav a:hover { color: var(--parts-text); }
|
||||
.parts-landing-return { border: 1px solid var(--parts-line); padding: 7px 12px; border-radius: 8px; color: var(--parts-text) !important; }
|
||||
|
||||
.parts-landing-hero {
|
||||
max-width: 1180px; margin: 0 auto; padding: 54px 32px 72px;
|
||||
display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(320px, .95fr); gap: 56px; align-items: center;
|
||||
}
|
||||
.parts-eyebrow { margin: 0 0 18px; color: var(--parts-accent); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.parts-landing-hero h1 { margin: 0; font-size: clamp(38px, 5vw, 66px); line-height: 1.02; letter-spacing: -.03em; font-weight: 800; }
|
||||
.parts-landing-hero h1 span { color: var(--parts-accent); }
|
||||
.parts-lead { margin: 22px 0 0; max-width: 620px; color: var(--parts-muted); font-size: 16px; line-height: 1.75; }
|
||||
.parts-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
|
||||
.parts-primary, .parts-secondary {
|
||||
border-radius: 10px; border: 1px solid transparent; padding: 13px 20px; font: inherit; font-weight: 700; cursor: pointer;
|
||||
display: inline-flex; gap: 10px; align-items: center; justify-content: center;
|
||||
}
|
||||
.parts-primary { background: var(--parts-accent); color: #17130d; }
|
||||
.parts-primary:hover { filter: brightness(1.08); }
|
||||
.parts-secondary { background: transparent; color: var(--parts-text); border-color: var(--parts-line); }
|
||||
.parts-secondary:hover { border-color: #596066; }
|
||||
.parts-resume { margin: 16px 0 0; color: var(--parts-muted); font-size: 13px; }
|
||||
.parts-hero-art { position: relative; min-height: 360px; display: grid; place-items: center; }
|
||||
.parts-hero-art .parts-orbit { position: absolute; border: 1px solid rgba(111, 159, 206, .28); border-radius: 50%; }
|
||||
.parts-hero-art .orbit-a { width: 330px; height: 330px; transform: rotate(-18deg); }
|
||||
.parts-hero-art .orbit-b { width: 240px; height: 240px; border-color: rgba(242, 140, 40, .25); transform: rotate(24deg); }
|
||||
.parts-art-grid { position: relative; display: grid; grid-template-columns: repeat(2, 132px); gap: 14px; }
|
||||
.parts-art-piece {
|
||||
background: linear-gradient(160deg, #25292c, #1b1d1f); border: 1px solid var(--parts-line); border-radius: 16px;
|
||||
padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 10px; color: var(--parts-accent-2);
|
||||
}
|
||||
.parts-art-piece:nth-child(2) { color: var(--parts-accent); }
|
||||
.parts-art-piece:nth-child(3) { color: #d8b36a; }
|
||||
.parts-art-piece:nth-child(4) { color: #9bb7d4; }
|
||||
.parts-art-piece svg { width: 74px; height: 74px; }
|
||||
.parts-art-piece span { color: var(--parts-text); font-size: 13px; }
|
||||
|
||||
.parts-section { max-width: 1180px; margin: 0 auto; padding: 64px 32px; border-top: 1px solid rgba(255,255,255,.06); }
|
||||
.parts-section-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
|
||||
.parts-section h2 { margin: 0; font-size: 28px; letter-spacing: -.01em; }
|
||||
.parts-section-head p { margin: 0; color: var(--parts-muted); font-size: 14px; }
|
||||
.parts-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.parts-card { background: var(--parts-panel); border: 1px solid var(--parts-line); border-radius: 14px; padding: 22px; color: var(--parts-accent-2); }
|
||||
.parts-card svg { width: 56px; height: 56px; }
|
||||
.parts-card h3 { margin: 14px 0 6px; color: var(--parts-text); font-size: 17px; }
|
||||
.parts-card p { margin: 0; color: var(--parts-muted); font-size: 13px; line-height: 1.6; }
|
||||
.parts-gallery-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.parts-gallery-item { margin: 0; overflow: hidden; background: var(--parts-panel); border: 1px solid var(--parts-line); border-radius: 14px; }
|
||||
.parts-gallery-item img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; background: #0f1112; border-bottom: 1px solid var(--parts-line); }
|
||||
.parts-gallery-item figcaption { display: grid; gap: 5px; padding: 14px 16px 16px; }
|
||||
.parts-gallery-item strong { font-size: 14px; }
|
||||
.parts-gallery-item span { color: var(--parts-muted); font-size: 12.5px; line-height: 1.6; }
|
||||
.parts-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
|
||||
.parts-skill-list, .parts-chapter-list { list-style: none; margin: 20px 0 0; padding: 0; display: grid; gap: 12px; }
|
||||
.parts-skill-list li { position: relative; padding-left: 24px; color: var(--parts-muted); line-height: 1.65; font-size: 14px; }
|
||||
.parts-skill-list li::before { content: "✓"; position: absolute; left: 0; color: var(--parts-green); }
|
||||
.parts-chapter-list li { display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start; }
|
||||
.parts-chapter-list li > span { color: var(--parts-accent); font-variant-numeric: tabular-nums; font-size: 12px; padding-top: 3px; }
|
||||
.parts-chapter-list strong { display: block; font-size: 14px; }
|
||||
.parts-chapter-list p { margin: 4px 0 0; color: var(--parts-muted); font-size: 12px; line-height: 1.6; }
|
||||
.parts-callout { text-align: left; }
|
||||
.parts-how-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 22px; }
|
||||
.parts-how-grid > div { background: var(--parts-panel); border: 1px solid var(--parts-line); border-radius: 14px; padding: 20px; }
|
||||
.parts-how-grid strong { display: block; margin-bottom: 8px; }
|
||||
.parts-how-grid p { margin: 0; color: var(--parts-muted); font-size: 13px; line-height: 1.65; }
|
||||
.parts-bottom-cta { margin-top: 26px; }
|
||||
.parts-landing-footer { max-width: 1180px; margin: 0 auto; padding: 22px 32px 38px; display: flex; justify-content: space-between; gap: 20px; color: #7d8287; font-size: 12px; }
|
||||
|
||||
/* In-editor overlay */
|
||||
.parts-tutorial-overlay {
|
||||
position: fixed; left: 236px; right: auto; top: 66px; bottom: 30px; width: min(402px, 32vw); z-index: 60;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
background: rgba(25, 27, 29, .97); border: 1px solid #3d4247; border-radius: 12px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, .5); color: #e8e9ea;
|
||||
font-family: "Noto Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 13px;
|
||||
}
|
||||
.tutorial-loading { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 9px 11px; border: 1px solid #34495c; border-radius: 8px; background: #1c2833; color: #a9c9e6; line-height: 1.5; font-size: 12px; }
|
||||
.tutorial-loading span { color: #f28c28; }
|
||||
.tutorial-head { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 16px 18px 10px; }
|
||||
.tutorial-kicker { margin: 0 0 5px; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: #f28c28; }
|
||||
.tutorial-head h2 { margin: 0; font-size: 18px; line-height: 1.25; }
|
||||
.tutorial-icon-button { border: 0; background: transparent; color: #a6abb0; font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
|
||||
.tutorial-icon-button:hover { background: #2c3034; color: #fff; }
|
||||
.tutorial-progress { appearance: none; -webkit-appearance: none; display: block; width: calc(100% - 36px); margin: 0 18px; height: 6px; border: 0; border-radius: 999px; overflow: hidden; background: #303438; }
|
||||
.tutorial-progress::-webkit-progress-bar { background: #303438; border-radius: 999px; }
|
||||
.tutorial-progress::-webkit-progress-value { background: linear-gradient(90deg, #f28c28, #f2b45c); border-radius: 999px; }
|
||||
.tutorial-progress::-moz-progress-bar { background: linear-gradient(90deg, #f28c28, #f2b45c); border-radius: 999px; }
|
||||
.tutorial-progress-label { margin: 8px 18px 0; color: #8f959a; font-size: 11px; }
|
||||
.tutorial-body { flex: 1; overflow-y: auto; padding: 14px 18px 18px; }
|
||||
.tutorial-summary { margin: 0 0 14px; color: #c6cacd; line-height: 1.65; }
|
||||
.tutorial-reference { margin: 0 0 12px; border: 1px solid #34383c; border-radius: 8px; overflow: hidden; background: #17191a; }
|
||||
.tutorial-reference img { display: block; width: 100%; height: auto; max-height: 150px; object-fit: cover; object-position: center; }
|
||||
.tutorial-reference figcaption { padding: 5px 8px; color: #8f959a; font-size: 10.5px; }
|
||||
.tutorial-instructions { margin: 0 0 14px; padding-left: 20px; display: grid; gap: 9px; color: #dfe1e3; line-height: 1.55; }
|
||||
.tutorial-instructions small { display: block; margin-top: 3px; color: #8f959a; font-size: 11.5px; line-height: 1.5; }
|
||||
.tutorial-hints { margin: 0 0 14px; border: 1px solid #34383c; border-radius: 8px; padding: 8px 10px; color: #a6abb0; }
|
||||
.tutorial-hints summary { cursor: pointer; color: #c6cacd; }
|
||||
.tutorial-hints ul { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 6px; line-height: 1.5; }
|
||||
.tutorial-check-title { margin: 16px 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: #8f959a; }
|
||||
.tutorial-checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 7px; }
|
||||
.tutorial-check { display: grid; grid-template-columns: 20px 1fr; gap: 6px; align-items: start; color: #cfd2d4; line-height: 1.45; }
|
||||
.tutorial-check > span:first-child { color: #8f959a; text-align: center; }
|
||||
.tutorial-check.ok > span:first-child { color: #7ac77a; }
|
||||
.tutorial-check small { display: block; margin-top: 2px; color: #c89054; font-size: 11px; }
|
||||
.tutorial-warning { border: 1px solid #8a5a2a; background: #2a2118; color: #f0c48e; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; }
|
||||
.tutorial-warning p { margin: 5px 0 8px; line-height: 1.5; font-size: 12px; }
|
||||
.tutorial-error { margin: 12px 0 0; border: 1px solid #7e3a3a; background: #2b1b1b; color: #ffb4b4; border-radius: 8px; padding: 8px 10px; font-size: 12px; }
|
||||
.tutorial-complete { margin-top: 12px; }
|
||||
.tutorial-complete h3 { margin: 16px 0 8px; font-size: 13px; color: #f28c28; }
|
||||
.tutorial-part-list, .tutorial-skill-list { margin: 0; padding-left: 18px; display: grid; gap: 5px; color: #c6cacd; line-height: 1.5; }
|
||||
.tutorial-footer { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid #34383c; background: #1c1e20; }
|
||||
.tutorial-footer button { flex: 1; min-width: 0; }
|
||||
.tutorial-primary, .tutorial-secondary, .tutorial-demo {
|
||||
border-radius: 8px; padding: 9px 12px; font: inherit; font-weight: 600; cursor: pointer; border: 1px solid transparent;
|
||||
}
|
||||
.tutorial-primary { background: #f28c28; color: #17130d; }
|
||||
.tutorial-primary:disabled { background: #4a3b2a; color: #8b8178; cursor: not-allowed; }
|
||||
.tutorial-secondary { background: #2a2d30; color: #dfe1e3; border-color: #3d4247; }
|
||||
.tutorial-secondary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.tutorial-demo { background: #24384a; color: #a9c9e6; border-color: #33536e; }
|
||||
.tutorial-demo:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tutorial-footer-links { display: flex; justify-content: space-between; padding: 0 14px 12px; gap: 8px; }
|
||||
.tutorial-link-button { border: 0; background: transparent; color: #8fb6d8; font: inherit; font-size: 12px; cursor: pointer; padding: 3px 0; text-align: left; }
|
||||
.tutorial-link-button:hover { color: #cbe2f4; text-decoration: underline; }
|
||||
.tutorial-steps { border-top: 1px solid #34383c; max-height: 220px; overflow-y: auto; padding: 8px; display: grid; gap: 2px; }
|
||||
.tutorial-steps button { display: grid; grid-template-columns: 26px 1fr 18px; gap: 8px; align-items: center; text-align: left; padding: 7px 8px; border: 0; border-radius: 6px; background: transparent; color: #c6cacd; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.tutorial-steps button:hover { background: #262a2d; }
|
||||
.tutorial-steps button.current { background: #33302a; color: #f5d3ad; }
|
||||
.tutorial-steps button.done > span:last-child { color: #7ac77a; }
|
||||
.tutorial-steps button > span:first-child { color: #a6abb0; font-variant-numeric: tabular-nums; }
|
||||
.tutorial-reopen {
|
||||
position: fixed; left: 236px; bottom: 34px; z-index: 60; display: inline-flex; align-items: center; gap: 7px;
|
||||
background: #1d1f21; color: #f28c28; border: 1px solid #3d4247; border-radius: 999px; padding: 9px 14px;
|
||||
font: inherit; font-weight: 700; font-size: 12px; cursor: pointer; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.tutorial-reopen small { color: #a6abb0; font-weight: 500; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.parts-landing-hero { grid-template-columns: 1fr; gap: 28px; }
|
||||
.parts-hero-art { min-height: 260px; }
|
||||
.parts-card-grid, .parts-how-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.parts-landing-top, .parts-landing-hero, .parts-section, .parts-landing-footer { padding-left: 18px; padding-right: 18px; }
|
||||
.parts-section-head, .parts-two-col { grid-template-columns: 1fr; display: grid; gap: 18px; }
|
||||
.parts-card-grid, .parts-how-grid, .parts-gallery-grid { grid-template-columns: 1fr; }
|
||||
.parts-art-grid { grid-template-columns: repeat(2, 116px); }
|
||||
.parts-tutorial-overlay { left: 10px; right: 10px; top: auto; bottom: 10px; width: auto; max-height: 62vh; }
|
||||
.tutorial-footer { padding-bottom: 10px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tutorial-progress > span { transition: none; }
|
||||
}
|
||||
|
||||
/* React tree controls (course outline and live assembly hierarchy). */
|
||||
.tutorial-tree { margin: 16px 0 0; padding: 0; }
|
||||
.tutorial-tree-row { display: flex; align-items: flex-start; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; color: inherit; line-height: 1.5; outline-offset: -2px; }
|
||||
.tutorial-tree-row:hover { background: rgba(242, 140, 40, .08); }
|
||||
.tutorial-tree-row:focus-visible { outline: 2px solid #f28c28; }
|
||||
.tutorial-tree-row[aria-level="2"] { margin-left: 16px; }
|
||||
.tutorial-tree-row[aria-level="3"] { margin-left: 32px; }
|
||||
.tutorial-tree-row[aria-level="4"] { margin-left: 48px; }
|
||||
.tutorial-tree-row[aria-level="5"] { margin-left: 64px; }
|
||||
.tutorial-tree-row[aria-level="6"] { margin-left: 80px; }
|
||||
.tutorial-tree-row[aria-level="7"] { margin-left: 96px; }
|
||||
.tutorial-tree-row[aria-level="8"] { margin-left: 112px; }
|
||||
.tutorial-tree-caret { width: 14px; flex: none; color: var(--parts-muted, #8a8f94); font-size: 11px; padding-top: 2px; }
|
||||
.tutorial-tree-label { flex: 1; min-width: 0; }
|
||||
.tutorial-tree-meta { color: var(--parts-muted, #a6abb0); font-size: 11px; white-space: nowrap; padding-top: 1px; }
|
||||
.tutorial-tree-mark { color: #7ac77a; }
|
||||
.tutorial-tree-row.current { background: #33302a; color: #f5d3ad; }
|
||||
.tutorial-hierarchy { margin-top: 14px; border-top: 1px solid #34383c; padding-top: 10px; }
|
||||
.tutorial-hierarchy h3 { margin: 0; font-size: 12px; color: #c6cacd; }
|
||||
.tutorial-hierarchy .tutorial-tree { margin-top: 6px; max-height: 190px; overflow-y: auto; font-size: 12px; }
|
||||
.tutorial-hierarchy .tutorial-tree-row { padding: 4px 6px; }
|
||||
.tutorial-hierarchy .tutorial-tree-meta { color: #a6abb0; }
|
||||
.parts-course-tree { margin-top: 14px; }
|
||||
105
web/app/src/tutorial/parts/progress-store.ts
Normal file
105
web/app/src/tutorial/parts/progress-store.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { PARTS_TUTORIAL_STEPS } from "./curriculum";
|
||||
import type { PartsTutorialProgress, TutorialStep } from "./types";
|
||||
|
||||
/** OPFS keeps the tutorial record even when localStorage is evicted or cleared
|
||||
* by another tab; localStorage remains the synchronous first-paint mirror. */
|
||||
const OPFS_DIRECTORY = "tutorial";
|
||||
const PARTS_OPFS_FILE = "parts-progress.json";
|
||||
|
||||
export const PARTS_TUTORIAL_PROGRESS_VERSION = 1;
|
||||
export const TUTORIAL_PROGRESS_VERSION = 1;
|
||||
|
||||
export function emptyTutorialProgress(firstStepId: string): PartsTutorialProgress {
|
||||
return {
|
||||
version: 1,
|
||||
currentStepId: firstStepId,
|
||||
completedStepIds: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyPartsTutorialProgress(): PartsTutorialProgress {
|
||||
return emptyTutorialProgress(PARTS_TUTORIAL_STEPS[0]?.id ?? "intro");
|
||||
}
|
||||
|
||||
export function normalizeTutorialProgress(value: unknown, steps: readonly TutorialStep[]): PartsTutorialProgress | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<PartsTutorialProgress>;
|
||||
if (candidate.version !== 1 || typeof candidate.currentStepId !== "string" || !Array.isArray(candidate.completedStepIds)) return null;
|
||||
const known = new Set(steps.map((step) => step.id));
|
||||
const completed = [...new Set(candidate.completedStepIds.filter((id): id is string => typeof id === "string" && known.has(id)))];
|
||||
const firstStepId = steps[0]?.id ?? "intro";
|
||||
return {
|
||||
version: 1,
|
||||
currentStepId: known.has(candidate.currentStepId) ? candidate.currentStepId : firstStepId,
|
||||
completedStepIds: completed,
|
||||
...(typeof candidate.projectId === "string" ? { projectId: candidate.projectId } : {}),
|
||||
...(typeof candidate.projectDisplayName === "string" ? { projectDisplayName: candidate.projectDisplayName } : {}),
|
||||
...(typeof candidate.savedProjectId === "string" ? { savedProjectId: candidate.savedProjectId } : {}),
|
||||
...(typeof candidate.savedDisplayName === "string" ? { savedDisplayName: candidate.savedDisplayName } : {}),
|
||||
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePartsTutorialProgress(value: unknown): PartsTutorialProgress | null {
|
||||
return normalizeTutorialProgress(value, PARTS_TUTORIAL_STEPS);
|
||||
}
|
||||
|
||||
async function tutorialDirectory(create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
||||
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
|
||||
if (!storage?.getDirectory) return null;
|
||||
try {
|
||||
const root = await storage.getDirectory();
|
||||
return await root.getDirectoryHandle(OPFS_DIRECTORY, { create });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTutorialProgressFromOpfs(file: string, steps: readonly TutorialStep[]): Promise<PartsTutorialProgress | null> {
|
||||
const directory = await tutorialDirectory(false);
|
||||
if (!directory) return null;
|
||||
try {
|
||||
const fileHandle = await directory.getFileHandle(file);
|
||||
const text = await (await fileHandle.getFile()).text();
|
||||
return normalizeTutorialProgress(JSON.parse(text), steps);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTutorialProgressToOpfs(file: string, progress: PartsTutorialProgress): Promise<boolean> {
|
||||
const directory = await tutorialDirectory(true);
|
||||
if (!directory) return false;
|
||||
try {
|
||||
const fileHandle = await directory.getFileHandle(file, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(progress, null, 1));
|
||||
await writable.close();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTutorialProgressFromOpfs(file: string): Promise<void> {
|
||||
const directory = await tutorialDirectory(false);
|
||||
if (!directory) return;
|
||||
try {
|
||||
await directory.removeEntry(file);
|
||||
} catch {
|
||||
/* A missing record means there is nothing to clear. */
|
||||
}
|
||||
}
|
||||
|
||||
export function readPartsTutorialProgressFromOpfs(): Promise<PartsTutorialProgress | null> {
|
||||
return readTutorialProgressFromOpfs(PARTS_OPFS_FILE, PARTS_TUTORIAL_STEPS);
|
||||
}
|
||||
|
||||
export function writePartsTutorialProgressToOpfs(progress: PartsTutorialProgress): Promise<boolean> {
|
||||
return writeTutorialProgressToOpfs(PARTS_OPFS_FILE, progress);
|
||||
}
|
||||
|
||||
export function clearPartsTutorialProgressFromOpfs(): Promise<void> {
|
||||
return clearTutorialProgressFromOpfs(PARTS_OPFS_FILE);
|
||||
}
|
||||
84
web/app/src/tutorial/parts/types.ts
Normal file
84
web/app/src/tutorial/parts/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { SceneSnapshotIR } from "../../../../protocol/scene-ir";
|
||||
import type { WebEngineEditCommand } from "../../../../protocol/web-engine";
|
||||
|
||||
export interface TutorialContext {
|
||||
snapshot: SceneSnapshotIR | null;
|
||||
/** The default project has finished opening and the native Main owns a scene. */
|
||||
ready: boolean;
|
||||
/** A project action (open/save/autosave) or native modal currently owns the engine. */
|
||||
busy: boolean;
|
||||
selectedObjectIds: ReadonlySet<string>;
|
||||
mode: string;
|
||||
dirty: boolean;
|
||||
saved: boolean;
|
||||
exported: boolean;
|
||||
projectId: string | null;
|
||||
projectDisplayName: string | null;
|
||||
}
|
||||
|
||||
export interface StepCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface TutorialInstruction {
|
||||
text: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export type TutorialAction =
|
||||
| { kind: "command"; command: WebEngineEditCommand }
|
||||
/** Joins named objects through the same typed Main command as Object > Join. */
|
||||
| { kind: "join"; names: string[]; activeName: string }
|
||||
/** Parents named objects through the same typed Main command as Object > Parent. */
|
||||
| { kind: "parent"; names: string[]; activeName: string }
|
||||
| { kind: "python"; source: string }
|
||||
| { kind: "save"; fileName?: string }
|
||||
| { kind: "export" }
|
||||
| { kind: "wait"; ms: number };
|
||||
|
||||
export interface TutorialStep {
|
||||
id: string;
|
||||
chapter: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
instructions: TutorialInstruction[];
|
||||
hints?: string[];
|
||||
/** Guide cards (0 and the final summary) do not require validation. */
|
||||
guide?: boolean;
|
||||
/** Shows the live SceneIR hierarchy tree (collections and object parents). */
|
||||
hierarchy?: boolean;
|
||||
/** Demonstrates the step by driving the real Main; disabled until canDemo passes. */
|
||||
demo?: TutorialAction[];
|
||||
canDemo?: (context: TutorialContext) => boolean;
|
||||
validate: (context: TutorialContext) => StepCheck[];
|
||||
}
|
||||
|
||||
export interface TutorialApi {
|
||||
applyCommand: (command: WebEngineEditCommand) => Promise<boolean>;
|
||||
runPython: (source: string) => Promise<boolean>;
|
||||
save: (fileName?: string) => Promise<void>;
|
||||
exportGLB: () => Promise<void>;
|
||||
/** Writes the current Main into OPFS recovery storage without marking the project saved. */
|
||||
saveCheckpoint: () => Promise<boolean>;
|
||||
joinObjects: (names: string[], activeName: string) => Promise<boolean>;
|
||||
setParents: (names: string[], activeName: string) => Promise<boolean>;
|
||||
recoverProject: (projectId: string, displayName: string) => Promise<boolean>;
|
||||
/** Persists the current scene under a course-owned OPFS project id without a download. */
|
||||
ensureCourseProject: (projectId: string, displayName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PartsTutorialProgress {
|
||||
version: 1;
|
||||
currentStepId: string;
|
||||
completedStepIds: string[];
|
||||
/** OPFS project that holds the learner's scene for resume. */
|
||||
projectId?: string;
|
||||
projectDisplayName?: string;
|
||||
savedProjectId?: string;
|
||||
savedDisplayName?: string;
|
||||
completedAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
127
web/app/src/tutorial/parts/usePartsTutorialProgress.ts
Normal file
127
web/app/src/tutorial/parts/usePartsTutorialProgress.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { COURSES, type TutorialCourse } from "../course";
|
||||
import {
|
||||
clearTutorialProgressFromOpfs,
|
||||
emptyTutorialProgress,
|
||||
normalizeTutorialProgress,
|
||||
readTutorialProgressFromOpfs,
|
||||
writeTutorialProgressToOpfs,
|
||||
} from "./progress-store";
|
||||
import type { PartsTutorialProgress } from "./types";
|
||||
|
||||
export function readTutorialProgress(course: TutorialCourse): PartsTutorialProgress | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(course.storageKey);
|
||||
return raw ? normalizeTutorialProgress(JSON.parse(raw), course.steps) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistLocal(course: TutorialCourse, progress: PartsTutorialProgress): void {
|
||||
try {
|
||||
window.localStorage.setItem(course.storageKey, JSON.stringify(progress));
|
||||
} catch {
|
||||
/* Storage can be disabled; OPFS and the in-memory session still keep progress. */
|
||||
}
|
||||
}
|
||||
|
||||
/* OPFS writes are serialized so a slow earlier commit can never overwrite a
|
||||
* newer step record, including across courses. */
|
||||
let opfsWriteTail: Promise<unknown> = Promise.resolve();
|
||||
function persistOpfs(course: TutorialCourse, progress: PartsTutorialProgress): void {
|
||||
opfsWriteTail = opfsWriteTail.then(() => writeTutorialProgressToOpfs(course.opfsFile, progress), () => undefined);
|
||||
}
|
||||
|
||||
export function clearTutorialProgress(course: TutorialCourse): void {
|
||||
try {
|
||||
window.localStorage.removeItem(course.storageKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void clearTutorialProgressFromOpfs(course.opfsFile);
|
||||
}
|
||||
|
||||
export function readPartsTutorialProgress(): PartsTutorialProgress | null {
|
||||
return readTutorialProgress(COURSES.parts);
|
||||
}
|
||||
|
||||
export function clearPartsTutorialProgress(): void {
|
||||
clearTutorialProgress(COURSES.parts);
|
||||
}
|
||||
|
||||
export function useTutorialProgress(course: TutorialCourse) {
|
||||
const [initial] = useState(() => readTutorialProgress(course));
|
||||
const [progress, setProgress] = useState<PartsTutorialProgress>(() => initial ?? emptyTutorialProgress(course.steps[0]?.id ?? "intro"));
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const progressRef = useRef(progress);
|
||||
progressRef.current = progress;
|
||||
/* OPFS is authoritative when it is newer than the localStorage mirror; a
|
||||
* record that only exists in OPFS (localStorage evicted) is adopted as is. */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readTutorialProgressFromOpfs(course.opfsFile, course.steps).then((stored) => {
|
||||
if (cancelled) return;
|
||||
if (!stored) { persistOpfs(course, progressRef.current); return; }
|
||||
const storedTime = Number.isFinite(Date.parse(stored.updatedAt)) ? Date.parse(stored.updatedAt) : 0;
|
||||
const localTime = initial && Number.isFinite(Date.parse(initial.updatedAt)) ? Date.parse(initial.updatedAt) : 0;
|
||||
if (!initial || storedTime > localTime) setProgress(stored);
|
||||
else if (localTime > storedTime) persistOpfs(course, initial);
|
||||
}).catch(() => undefined).finally(() => { if (!cancelled) setHydrated(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [course, initial]);
|
||||
const persist = useCallback((next: PartsTutorialProgress) => {
|
||||
persistLocal(course, next);
|
||||
persistOpfs(course, next);
|
||||
}, [course]);
|
||||
const update = useCallback((next: PartsTutorialProgress) => {
|
||||
const stamped = { ...next, updatedAt: new Date().toISOString() };
|
||||
setProgress(stamped);
|
||||
persist(stamped);
|
||||
}, [persist]);
|
||||
const setCurrentStep = useCallback((stepId: string) => {
|
||||
setProgress((current) => {
|
||||
const next = { ...current, currentStepId: stepId, updatedAt: new Date().toISOString() };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, [persist]);
|
||||
const recordProject = useCallback((projectId: string, displayName: string) => {
|
||||
setProgress((current) => {
|
||||
if (current.projectId === projectId && current.projectDisplayName === displayName) return current;
|
||||
const next = { ...current, projectId, projectDisplayName: displayName, updatedAt: new Date().toISOString() };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, [persist]);
|
||||
const completeStep = useCallback((stepId: string) => {
|
||||
setProgress((current) => {
|
||||
if (current.completedStepIds.includes(stepId)) return current;
|
||||
const completedStepIds = [...current.completedStepIds, stepId];
|
||||
const allRequired = course.requiredSteps.every((step) => completedStepIds.includes(step.id));
|
||||
const next = { ...current, completedStepIds, ...(allRequired && !current.completedAt ? { completedAt: new Date().toISOString() } : {}), updatedAt: new Date().toISOString() };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, [course, persist]);
|
||||
const recordSavedProject = useCallback((projectId: string, displayName: string) => {
|
||||
setProgress((current) => {
|
||||
const next = { ...current, savedProjectId: projectId, savedDisplayName: displayName, projectId, projectDisplayName: displayName, updatedAt: new Date().toISOString() };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, [persist]);
|
||||
const reset = useCallback(() => {
|
||||
clearTutorialProgress(course);
|
||||
update(emptyTutorialProgress(course.steps[0]?.id ?? "intro"));
|
||||
}, [course, update]);
|
||||
const completedRequired = useMemo(
|
||||
() => course.requiredSteps.filter((step) => progress.completedStepIds.includes(step.id)).length,
|
||||
[course, progress.completedStepIds],
|
||||
);
|
||||
return { progress, hydrated, update, setCurrentStep, completeStep, recordProject, recordSavedProject, reset, completedRequired };
|
||||
}
|
||||
|
||||
export function usePartsTutorialProgress() {
|
||||
return useTutorialProgress(COURSES.parts);
|
||||
}
|
||||
120
web/app/src/tutorial/parts/validators.ts
Normal file
120
web/app/src/tutorial/parts/validators.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { CollectionIR, MaterialIR, MeshSummaryIR, ModifierIR, SceneNodeIR, SceneSnapshotIR } from "../../../../protocol/scene-ir";
|
||||
import type { StepCheck } from "./types";
|
||||
|
||||
const NAME_SUFFIX = /(\.\d{3})+$/;
|
||||
|
||||
export function nameMatches(actual: string, expected: string): boolean {
|
||||
if (actual === expected) return true;
|
||||
if (!actual.startsWith(expected)) return false;
|
||||
return NAME_SUFFIX.test(actual.slice(expected.length));
|
||||
}
|
||||
|
||||
export function findNode(snapshot: SceneSnapshotIR | null, expected: string): SceneNodeIR | undefined {
|
||||
if (!snapshot) return undefined;
|
||||
return snapshot.nodes.find((node) => node.name === expected) ??
|
||||
snapshot.nodes.find((node) => nameMatches(node.name, expected));
|
||||
}
|
||||
|
||||
export function findNodes(snapshot: SceneSnapshotIR | null, expected: string): SceneNodeIR[] {
|
||||
if (!snapshot) return [];
|
||||
return snapshot.nodes.filter((node) => nameMatches(node.name, expected));
|
||||
}
|
||||
|
||||
export function meshOf(snapshot: SceneSnapshotIR | null, node: SceneNodeIR | undefined): MeshSummaryIR | undefined {
|
||||
if (!snapshot || !node || !node.dataId) return undefined;
|
||||
return snapshot.meshes.find((mesh) => mesh.id === node.dataId);
|
||||
}
|
||||
|
||||
export function modifierStack(snapshot: SceneSnapshotIR | null, node: SceneNodeIR | undefined): ModifierIR[] {
|
||||
return meshOf(snapshot, node)?.modifierStack ?? [];
|
||||
}
|
||||
|
||||
export function modifierOf(snapshot: SceneSnapshotIR | null, node: SceneNodeIR | undefined, type: string): ModifierIR | undefined {
|
||||
return modifierStack(snapshot, node).find((modifier) => modifier.type.toUpperCase() === type.toUpperCase());
|
||||
}
|
||||
|
||||
export function modifierParameter(modifier: ModifierIR | undefined, key: string): unknown {
|
||||
if (!modifier) return undefined;
|
||||
const editable = modifier.editableParameters?.find((parameter) => parameter.key === key);
|
||||
return editable ? editable.value : modifier.parameters?.[key];
|
||||
}
|
||||
|
||||
export function modifierNumber(modifier: ModifierIR | undefined, key: string): number | undefined {
|
||||
const value = modifierParameter(modifier, key);
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function modifierEnum(modifier: ModifierIR | undefined, key: string): string | undefined {
|
||||
const value = modifierParameter(modifier, key);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function modifierObject(modifier: ModifierIR | undefined, key = "object"): string | undefined {
|
||||
const value = modifierParameter(modifier, key);
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object" && "value" in value && typeof (value as { value?: unknown }).value === "string") {
|
||||
return (value as { value: string }).value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function approx(actual: number | undefined, expected: number, tolerance = 0.01): boolean {
|
||||
return typeof actual === "number" && Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
|
||||
}
|
||||
|
||||
export function vectorApprox(actual: readonly number[] | undefined, expected: readonly number[], tolerance = 0.01): boolean {
|
||||
return Array.isArray(actual) && actual.length >= expected.length &&
|
||||
expected.every((value, index) => approx(actual[index], value, tolerance));
|
||||
}
|
||||
|
||||
export function nodeScale(node: SceneNodeIR | undefined): readonly number[] | undefined {
|
||||
return node?.transform?.scale;
|
||||
}
|
||||
|
||||
export function nodeTranslation(node: SceneNodeIR | undefined): readonly number[] | undefined {
|
||||
return node?.transform?.translation;
|
||||
}
|
||||
|
||||
export function meshDimensions(mesh: MeshSummaryIR | undefined): [number, number, number] | undefined {
|
||||
if (!mesh?.bounds) return undefined;
|
||||
return [0, 1, 2].map((axis) => mesh.bounds!.max[axis] - mesh.bounds!.min[axis]) as [number, number, number];
|
||||
}
|
||||
|
||||
export function collectionByName(snapshot: SceneSnapshotIR | null, name: string): CollectionIR | undefined {
|
||||
return snapshot?.collections.find((collection) => nameMatches(collection.name, name));
|
||||
}
|
||||
|
||||
export function collectionContains(collection: CollectionIR | undefined, node: SceneNodeIR | undefined): boolean {
|
||||
return Boolean(collection && node && collection.objectIds.includes(node.id));
|
||||
}
|
||||
|
||||
export function materialByName(snapshot: SceneSnapshotIR | null, name: string): MaterialIR | undefined {
|
||||
return snapshot?.materials.find((material) => nameMatches(material.name, name));
|
||||
}
|
||||
|
||||
export function materialSlotIds(snapshot: SceneSnapshotIR | null, node: SceneNodeIR | undefined): string[] {
|
||||
return meshOf(snapshot, node)?.materialSlotIds ?? [];
|
||||
}
|
||||
|
||||
export function materialColorApprox(material: MaterialIR | undefined, expected: readonly number[], tolerance = 0.05): boolean {
|
||||
return Boolean(material) && material!.baseColor.length >= 3 &&
|
||||
expected.every((value, index) => approx(material!.baseColor[index], value, tolerance));
|
||||
}
|
||||
|
||||
export function check(id: string, label: string, ok: boolean, hint?: string): StepCheck {
|
||||
return { id, label, ok, ...(ok ? {} : hint ? { hint } : {}) };
|
||||
}
|
||||
|
||||
export function nodeCheck(snapshot: SceneSnapshotIR | null, name: string, type?: string): StepCheck {
|
||||
const node = findNode(snapshot, name);
|
||||
return check(`node:${name}`, `场景中存在对象「${name}」${type ? `(${type})` : ""}`,
|
||||
Boolean(node) && (!type || node!.type === type),
|
||||
node ? `当前类型为 ${node.type}` : "请先在场景中创建该对象并命名");
|
||||
}
|
||||
|
||||
export function modifierCheck(snapshot: SceneSnapshotIR | null, nodeName: string, type: string): StepCheck {
|
||||
const node = findNode(snapshot, nodeName);
|
||||
const modifier = modifierOf(snapshot, node, type);
|
||||
return check(`modifier:${nodeName}:${type}`, `「${nodeName}」拥有 ${type} 修改器`,
|
||||
Boolean(modifier), node ? "请在属性面板的修改器栈中添加" : "请先创建该对象");
|
||||
}
|
||||
1236
web/app/src/tutorial/reducer/curriculum.ts
Normal file
1236
web/app/src/tutorial/reducer/curriculum.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
web/app/src/tutorial/scene-tree.ts
Normal file
51
web/app/src/tutorial/scene-tree.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import type { TutorialTreeNode } from "./TutorialTreeView";
|
||||
|
||||
/** Builds the live Outliner-like tree the tutorial shows while teaching
|
||||
* collections and parenting. It reads SceneIR only; no native widget. */
|
||||
export function sceneHierarchyTree(snapshot: SceneSnapshotIR | null): TutorialTreeNode[] {
|
||||
if (!snapshot) return [];
|
||||
const nodes = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
||||
const collections = new Map(snapshot.collections.map((collection) => [collection.id, collection]));
|
||||
const rootCollection = snapshot.collections.find((collection) => collection.isMaster) ?? snapshot.collections[0];
|
||||
const buildObject = (collectionId: string, nodeId: string, ancestors: ReadonlySet<string>): TutorialTreeNode | null => {
|
||||
const node = nodes.get(nodeId);
|
||||
if (!node || ancestors.has(node.id) || node.visible === false) return null;
|
||||
const nextAncestors = new Set(ancestors).add(node.id);
|
||||
const children = snapshot.nodes
|
||||
.filter((candidate) => candidate.parentId === node.id)
|
||||
.map((candidate) => buildObject(collectionId, candidate.id, nextAncestors))
|
||||
.filter((child): child is TutorialTreeNode => Boolean(child));
|
||||
return {
|
||||
id: `object:${collectionId}:${node.id}`,
|
||||
label: node.name,
|
||||
meta: node.type,
|
||||
...(children.length ? { children } : {}),
|
||||
};
|
||||
};
|
||||
const buildCollection = (collectionId: string, ancestors: ReadonlySet<string>): TutorialTreeNode | null => {
|
||||
const collection = collections.get(collectionId);
|
||||
if (!collection || ancestors.has(collection.id)) return null;
|
||||
const nextAncestors = new Set(ancestors).add(collection.id);
|
||||
const objectIds = new Set(collection.objectIds);
|
||||
const objectRoots = collection.objectIds
|
||||
.filter((id) => {
|
||||
const node = nodes.get(id);
|
||||
return Boolean(node && (!node.parentId || !objectIds.has(node.parentId)));
|
||||
})
|
||||
.map((id) => buildObject(collection.id, id, new Set()))
|
||||
.filter((child): child is TutorialTreeNode => Boolean(child));
|
||||
const childCollections = collection.childCollectionIds
|
||||
.map((id) => buildCollection(id, nextAncestors))
|
||||
.filter((child): child is TutorialTreeNode => Boolean(child));
|
||||
const children = [...objectRoots, ...childCollections];
|
||||
if (children.length === 0) return null;
|
||||
return {
|
||||
id: `collection:${collection.id}`,
|
||||
label: collection.name,
|
||||
meta: "集合",
|
||||
children,
|
||||
};
|
||||
};
|
||||
return rootCollection ? [buildCollection(rootCollection.id, new Set())].filter((node): node is TutorialTreeNode => Boolean(node)) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user