今日开源[第56期]human-atlas源码解读
源码解读 — human-atlas
仓库:https://github.com/ashemag/human-atlas
作者:ashemag(与model-x-studio同一作者,架构风格高度一致)
主语言:TypeScript(前端)/ Python(数据转换脚本)
星标 / Fork:2.8k / 700(截至 2026-09)
许可证:应用代码 MIT;解剖数据 BodyParts3D 4.0 为 CC BY 4.0(须保留署名)
在线 Demo:https://human-atlas-seven.vercel.app
解读基准:main分支,commit1c38bf3(2026-09-06,Restore Human Atlas title)
一、项目简介与作用
1.1 这是什么
Human Atlas 是一个交互式 3D 人体解剖浏览器(Web 应用)。它把日本 DBCLS 发布的 BodyParts3D 4.0 成人男性参考解剖学数据集搬到了浏览器里,做成可旋转、可缩放、可逐件点选的 3D 模型:
- 2,234 个独立可选网格(mesh)——每一个原始解剖结构都是单独可选、可高亮、可隔离的零件;
- 15 个解剖系统(骨骼、肌肉、心脏、感官、动脉、静脉、神经、呼吸、消化、泌尿、淋巴、内分泌、生殖、体被/皮肤、结缔组织);
- 3,432 个具名概念(FMA 概念)——一个概念可聚合多个网格(例如"心脏"概念可能由多个网格组成);
- 几何体在保持每个源网格完整的前提下做了简化,整包约 228 万三角形、压缩后下载约 33 MB。
1.2 作用与定位
| 维度 | 说明 |
|---|---|
| 核心作用 | 把专业解剖学数据变成"任何人打开网页就能拆开看"的科普/教学工具 |
| 目标用户 | 医学生、解剖学爱好者、科普教育者、需要 3D 人体参考的开发者 |
| 交互能力 | 轨道旋转 / 缩放 / 点选结构;按系统显隐;"爆炸视图"把全身拆成可平铺的零件清单;搜索 3432 个具名结构;隔离单件并展示详情;移动端适配 |
| 教育定位 | 作者明确声明:这是教育性探索器,不是诊断或手术工具,不涵盖所有人结构/变异 |
| AI 可调用 | 通过可选的 WebMCP 工具(find_anatomy / inspect_anatomical_structure)暴露给兼容浏览器中的 AI 智能体调用;无该能力时纯 UI 仍完整可用 |
1.3 技术栈一览
- 构建/运行:Vite 8 +
vinext(RSC on Vite 的实验性封装)+ React 19.2;web/为入口,app/为页面与 3D 逻辑。 - 3D 引擎:原生 Three.js(非 React Three Fiber),命令式管理场景、渲染循环、着色器注入。
- UI 组件:
@shadcn/react0.3 +@base-ui/react1.7(新一代 shadcn,底层由 Radix 转向 Base UI)+ Tailwind v4(@tailwindcss/postcss)。 - 几何处理脚本:Python(
convert-anatomy.py,OBJ→二进制)+ Node(optimize-anatomy.mjs用meshoptimizer四边形简化、compress-models.mjs用 gzip 压缩)。 - 部署:Vercel(
vercel.json指定 Vite 框架、dist输出);devDeps 还含 Cloudflare 插件与 Wrangler,可静态托管。 - WebMCP:
@openai/sites-vite-plugin+ 运行期document.modelContext.registerTool,供 ChatGPT 类智能体操作页面。
与
model-x-studio的关系:同一作者的"解剖版"孪生项目。两者共享同一套工程范式——几何体按系统合并成批次(减少 draw call)、用 DataTexture 在 GPU 上驱动"位移/显隐/选中"状态、用货架式(shelf)装箱算法算爆炸布局、用PointerTap类区分点击与拖拽。human-atlas把规模从 334 件放大到 2234 件,系统从 8 个增到 15 个,并额外加入了"概念搜索""WebMCP 工具""女性参考历史"等能力。
二、功能与各个模块分析
2.1 整体架构(运行期)
┌──────────────────────────────────────────────────────────────┐
│ web/main.tsx ──► React 19 客户端渲染 <Home/> (app/page.tsx) │
└──────────────────────────────────────────────────────────────┘
│ │
UI 状态机 (page.tsx) 3D 场景 (app/scene.tsx)
├─ 加载 atlas.json ├─ 原生 Three.js 渲染器
├─ 系统显隐 / 视角 / 爆炸滑块 ├─ 按系统 mergeGeometries 成批次网格
├─ 搜索 / 选择 / 隔离 ├─ 注入自定义着色器(读 DataTexture)
├─ WebMCP 工具注册 ├─ 逐块懒加载 body-*.bin(gzip)
└─ 详情面板 / 关于面板 ├─ 射线拾取(picker 网格)+ 2D 回退
└─ 爆炸布局(explosion-layout.ts 货架装箱)
│
数据层 app/anatomy.ts(类型/系统/解释文案/默认显隐)
工具层 app/pointer-tap.ts(点击vs拖拽)、app/model-download.ts(解压)
脚本层 scripts/*(转换/优化/压缩/校验)
2.2 功能清单
| # | 功能 | 入口 / 实现 |
|---|---|---|
| 1 | 轨道旋转、缩放、点选结构 | scene.tsx OrbitControls + 射线拾取 |
| 2 | 按 15 个系统显隐,提供 All / Skeleton / Organs 预设 | page.tsx 系统列表 + toggle() |
| 3 | 爆炸滑块(0–100%):装配态 → 平铺零件清单 | page.tsx Slider + scene.tsx 两阶段动画 + explosion-layout.ts |
| 4 | 搜索 3432 个具名概念(按名称或 FMA 源 id) | page.tsx Combobox + results 记忆化 |
| 5 | 选中结构 → 详情面板(系统色 + 解释文案 + 源 id + 成员列表) | page.tsx Sheet + anatomy.ts explanation() |
| 6 | 隔离单件(相机用 setViewOffset 框住结构,避开详情面板) |
scene.tsx isolate 分支 |
| 7 | 视角快捷键(¾ / 前 / 侧 / 后)、自动旋转、重置 | page.tsx view-controls |
| 8 | 移动端抽屉式系统面板、停靠栏、触摸友好 | globals.css + page.tsx mobile-only/dock |
| 9 | WebMCP 工具(AI 可搜索/聚焦结构) | agent-tools.ts |
| 10 | 几何数据转换 / 简化 / 压缩 / 校验流水线 | scripts/* |
2.3 模块职责表
| 模块 | 文件 | 职责 |
|---|---|---|
| 数据契约与文案 | app/anatomy.ts |
系统枚举、系统配色/描述、Part/Concept/Atlas/SceneState 类型、DEFAULT_VISIBLE、EXPLANATIONS 解释库、explanation() |
| 爆炸布局算法 | app/explosion-layout.ts |
纯函数 createExplosionLayout():对可见网格做货架式 2D 装箱,返回每个零件的网格单元 |
| 点击/拖拽判别 | app/pointer-tap.ts |
PointerTap 类:依据位移阈值与多指,区分"轻点选择"与"轨道/缩放/取消" |
| 模型下载解压 | app/model-download.ts |
decodeModelResponse():处理 gzip 双重解码、校验字节数 |
| AI 工具 | app/agent-tools.ts |
atlasTools() 定义两个 WebMCP 工具;registerAtlasTools() 注册到 document.modelContext |
| 3D 场景引擎 | app/scene.tsx |
渲染器/灯光/环境、DataTexture 状态、自定义着色器、分块懒加载、拾取、爆炸动画、resize/fit/隔离取景、渲染循环 |
| 页面与状态机 | app/page.tsx |
加载 atlas、UI 状态、choose/toggle/reset、搜索、详情面板、关于面板、WebMCP 注册 |
| 入口 | web/main.tsx + web/index.html |
Vite 客户端入口,挂载 <Home/> |
| 转换脚本 | scripts/convert-anatomy.py |
BodyParts3D OBJ → 二进制块(mm/Z-up→m/Y-up、法线量化、分块) |
| 简化脚本 | scripts/optimize-anatomy.mjs |
meshoptimizer 四边形简化(0.2% 相对误差),女性版焊接重合顶点 |
| 压缩脚本 | scripts/compress-models.mjs |
对二进制块做 gzip(level 9) |
| 校验脚本 | scripts/validate-atlas.mjs |
断言 2234 件/3432 概念、缓冲与索引合法、三角形数一致 |
| 交互校验 | scripts/validate-interactions.mjs |
断言多宽高比下布局不重叠、搜索/聚焦契约、PointerTap 行为 |
| 通用工具 | lib/utils.ts |
cn() = clsx + tailwind-merge |
| 移动端钩子 | hooks/use-mobile.ts |
useIsMobile()(<768px) |
| 配置 | package.json / vite.config.ts / tsconfig.json / vercel.json |
依赖、Vite 别名与构建、TS 严格模式、Vercel 部署 |
| UI 脚手架 | components/ui/*(70+ 文件) |
shadcn 自动生成的组件(Button/Badge/Slider/Sheet/Combobox 等),非业务核心,不逐行 |
| 设计系统 | app/globals.css(26 KB) |
Tailwind v4 + 自定义样式(.studio/.glass/面板/停靠栏等),非逻辑代码,不逐行 |
三、核心源码逐行注释
约定:短文件直接在原代码行尾加
//注释;scene.tsx、page.tsx两文件因单行极长,先给出原样代码,再按行号逐行解析。
components/ui/*(70+ shadcn 生成组件)与globals.css为脚手架/样式,超出"逐行业务逻辑"范围,仅在上表说明其角色。
3.1 app/anatomy.ts — 数据契约、系统表、解释文案
// 15 个解剖系统的联合类型,决定 parts[].system 的取值
export type SystemId = 'skeletal'|'muscular'|'arterial'|'venous'|'nervous'|'digestive'|'respiratory'|'urinary'|'reproductive'|'lymphatic'|'endocrine'|'integumentary'|'connective'|'sensory'|'cardiac';
// 系统元数据表:id、显示名、配色(CSS 十六进制)、教学描述
export const SYSTEMS: {id:SystemId;name:string;color:string;description:string}[] = [
{id:'skeletal',name:'Skeleton',color:'#e2d9ba',description:'Bones form the supporting framework...'}, // 骨骼:支撑框架、保护器官、造血储矿
{id:'muscular',name:'Muscles',color:'#a85b50',description:'Skeletal muscles generate movement...'}, // 肌肉:牵拉产生运动与热
{id:'cardiac',name:'Heart',color:'#b96760',description:'The heart is a muscular pump...'}, // 心脏:四腔泵血
{id:'sensory',name:'Sensory organs',color:'#b0c8ce',description:'These structures contribute to special senses...'}, // 感官:视/听/平衡
{id:'arterial',name:'Arteries',color:'#c05245',description:'The heart drives blood through...'}, // 动脉:离心的运血管道
{id:'venous',name:'Veins',color:'#527c9f',description:'Veins return blood toward the heart...'}, // 静脉:回心管道
{id:'nervous',name:'Nervous system',color:'#d8b565',description:'The brain, spinal cord...'}, // 神经:信号传导
{id:'respiratory',name:'Respiratory',color:'#b98991',description:'The airways conduct air...'}, // 呼吸:气/血交换
{id:'digestive',name:'Digestive',color:'#b8916b',description:'The digestive tract breaks down food...'}, // 消化:分解吸收
{id:'urinary',name:'Urinary',color:'#b47961',description:'The kidneys filter blood...'}, // 泌尿:滤血调平衡
{id:'lymphatic',name:'Lymphatic',color:'#879f7c',description:'Lymphatic vessels return excess...'}, // 淋巴:回流与免疫
{id:'endocrine',name:'Endocrine',color:'#c5a09a',description:'Endocrine organs release hormones...'}, // 内分泌:激素协调
{id:'reproductive',name:'Reproductive',color:'#bda098',description:'The male reproductive structures...'}, // 生殖(男性参考)
{id:'integumentary',name:'Body surface',color:'#ba9b7d',description:'The body surface provides...'}, // 体被/皮肤:外层参考
{id:'connective',name:'Connective tissue',color:'#aec3bb',description:'Cartilage, ligaments...'}, // 结缔组织:软骨/韧带
];
// 单个网格零件的元数据(由 convert 脚本产出,写入 atlas.json)
export interface Part {id:string;name:string;conceptId:string;system:SystemId;chunk:number;positions:number;normals:number;indices:number;vertexCount:number;indexCount:number;bounds:[number[],number[]]}
// 一个具名 FMA 概念(可聚合多个零件 id)
export interface Concept {id:string;name:string;elements:string[]}
// 整个图谱清单:版本、性别、来源、零件数组、概念数组、二进制块数组、总三角形数
export interface Atlas {version:string;sex?:'male';source?:string;scope?:string;parts:Part[];concepts:Concept[];chunks:{url:string;bytes:number;gzip?:string;gzipBytes?:number}[];triangles:number}
// 视角枚举
export type View = 'three-quarter'|'front'|'back'|'side';
// 场景可变状态(页面与 3D 引擎共享的"真相源")
export interface SceneState {inspectorOpen?:boolean;explode:number;visible:SystemId[];selected:string[];isolate:boolean;view:View;rotate:boolean;reset:number}
// 默认可见系统:除体被(皮肤,默认半透明隐藏)外的全部 14 个系统
export const DEFAULT_VISIBLE:SystemId[] = ['cardiac','sensory','skeletal','muscular','arterial','venous','nervous','respiratory','digestive','urinary','lymphatic','endocrine','reproductive','connective'];
// 重点器官的现成解释文案(短句教学)
export const EXPLANATIONS:Record<string,string> = {
'heart':'A muscular pump in the chest...', // 心脏
'liver':'A large organ beneath...', // 肝脏
'brain':'The central organ of...', // 脑
'stomach':'A muscular chamber...', // 胃
'spleen':'A lymphoid organ...', // 脾
'pancreas':'An abdominal organ...', // 胰
'urinary bladder':'A muscular reservoir...',// 膀胱
'trachea':'The main airway...', // 气管
'diaphragm':'A broad muscle...', // 膈
};
// 取解释:优先器官专属文案,否则回退到所属系统描述,再否则空串
export function explanation(name:string,system:SystemId){return EXPLANATIONS[name.toLowerCase()] ?? SYSTEMS.find(s=>s.id===system)?.description ?? '';}
3.2 app/explosion-layout.ts — 爆炸视图的 2D 装箱
import type {Part} from './anatomy';
export interface LayoutCell {x:number;y:number;width:number;height:number}
/** 只对可见源网格打包;每个投影包围盒各占一个单元。货架式(shelf)装箱。 */
export function createExplosionLayout(parts:Part[],aspect=1){
// 每个零件=一张"卡片":以其轴对齐包围盒宽高 + 0.04 间距,最小 0.035 防退化
const cards=parts.map(p=>({id:p.id,system:p.system,width:Math.max(.035,p.bounds[1][0]-p.bounds[0][0])+.04,height:Math.max(.035,p.bounds[1][1]-p.bounds[0][1])+.04}));
// 总面积与最宽卡片,用于推算目标总宽
const area=cards.reduce((n,c)=>n+c.width*c.height,0),maxWidth=Math.max(.3,...cards.map(c=>c.width));
// 目标宽 = max(最宽, sqrt(面积*宽高比)*1.18);宽高比越大(手机竖屏)越窄越高
const targetWidth=Math.max(maxWidth,Math.sqrt(area*Math.max(.5,Math.min(1.5,aspect)))*1.18);
// 高卡片优先(降序),同高用 id 稳定排序,保证结果可复现
cards.sort((a,b)=>b.height-a.height||a.id.localeCompare(b.id));
const cells=new Map<string,LayoutCell>();let x=0,y=0,row=0,usedWidth=0;
for(const c of cards){
if(x>0&&x+c.width>targetWidth){x=0;y+=row;row=0;} // 当前行放不下 → 换行
cells.set(c.id,{x:x+c.width/2,y:-y-c.height/2,width:c.width,height:c.height}); // 单元中心坐标(暂相对左上)
x+=c.width;usedWidth=Math.max(usedWidth,x);row=Math.max(row,c.height); // 推进 x,记录行高/已用宽
}
const height=y+row; // 总高 = 最后一行底部
cells.forEach(c=>{c.x-=usedWidth/2;c.y+=height/2;}); // 整体居中到原点
return {cells,width:usedWidth,height}; // 返回单元表与总尺寸
}
3.3 app/pointer-tap.ts — 区分"轻点"与"拖拽/多指"
/** 区分一次轻点(tap)与轨道/捏合/平移/取消的触摸序列。 */
export class PointerTap {
private active=new Map<number,{x:number;y:number;threshold:number}>(); // 每个指针 id 的起始点与阈值
private blocked=false; // 本序列是否已被判定为"非轻点"
down(id:number,x:number,y:number,threshold:number){
if(this.active.size===0)this.blocked=false; // 新序列开始,重置 blocked
this.active.set(id,{x,y,threshold});
if(this.active.size>1)this.blocked=true; // 出现第二根手指 → 多指手势,直接判定非轻点
}
move(id:number,x:number,y:number){
const start=this.active.get(id);
if(start&&Math.hypot(x-start.x,y-start.y)>start.threshold)this.blocked=true; // 位移超阈值 → 拖拽
}
up(id:number,x:number,y:number){
this.move(id,x,y); // 抬手时再校验一次位移
const tap=this.active.has(id)&&this.active.size===1&&!this.blocked; // 仅单指且未超阈值且未取消
this.active.delete(id);return tap;
}
cancel(id:number){this.active.delete(id);this.blocked=true} // 系统取消(如 pointercancel)→ 非轻点
}
3.4 app/model-download.ts — 解压与完整性校验
/** 静态托管可能把 .gz 当作"压缩响应"或"gzip 文件"两种形式。
* fetch 已按 Content-Encoding 解码;再检查载荷签名,避免重复解压。 */
export async function decodeModelResponse(response:Response,expectedBytes:number,compressed:boolean):Promise<ArrayBuffer>{
if(!response.ok)throw new Error('An anatomy file could not be loaded.');
const payload=await response.arrayBuffer();
const signature=new Uint8Array(payload,0,Math.min(2,payload.byteLength)); // 读前 2 字节
const gzip=compressed&&signature[0]===0x1f&&signature[1]===0x8b; // gzip 魔数 1f 8b
const buffer=gzip?await new Response(new Blob([payload]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer():payload; // 仅当确为 gzip 才解压
if(buffer.byteLength!==expectedBytes)throw new Error('An anatomy file was incomplete. Please reload the viewer.'); // 字节数校验防截断
return buffer;
}
3.5 app/agent-tools.ts — 暴露给 AI 的 WebMCP 工具
import type {Atlas,Concept} from './anatomy';
type Tool={name:string;description:string;inputSchema:object;annotations:{readOnlyHint:boolean};execute:(input:unknown)=>unknown};
function record(input:unknown):Record<string,unknown>{if(!input||typeof input!=='object'||Array.isArray(input))throw new Error('Expected an object.');return input as Record<string,unknown>;}
export function atlasTools(atlas:Atlas,inspect:(concept:Concept)=>void):Tool[]{return [
{name:'find_anatomy',description:'Find anatomical structures by name or source atlas identifier in this atlas.',inputSchema:{type:'object',properties:{query:{type:'string',minLength:1}},required:['query'],additionalProperties:false},annotations:{readOnlyHint:true},
execute(input){const data=record(input);if(typeof data.query!=='string'||!data.query.trim())throw new Error('A nonempty query is required.');const q=data.query.toLowerCase().trim();return atlas.concepts.filter(c=>c.name.toLowerCase().includes(q)||c.id.toLowerCase().includes(q)).slice(0,30).map(c=>({id:c.id,name:c.name,pieces:c.elements.length}));}}, // 按名称或 FMA id 模糊搜,最多 30
{name:'inspect_anatomical_structure',description:'Select an atlas concept in the 3D anatomy and open its visible detail panel.',inputSchema:{type:'object',properties:{id:{type:'string'}},required:['id'],additionalProperties:false},annotations:{readOnlyHint:false},
execute(input){const data=record(input);if(typeof data.id!=='string')throw new Error('An atlas identifier is required.');const concept=atlas.concepts.find(c=>c.id===data.id);if(!concept)throw new Error('That structure is not present in this atlas.');inspect(concept);return {id:concept.id,name:concept.name,selectedPieces:concept.elements.length};}} // 选定概念并回调 inspect(打开详情面板)
];}
export function registerAtlasTools(atlas:Atlas,inspect:(concept:Concept)=>void){
const context=(document as Document&{modelContext?:{registerTool:(tool:Tool,options:{signal:AbortSignal})=>void|Promise<void>}}).modelContext;
if(!context?.registerTool)return; // 浏览器不支持 WebMCP → 静默跳过,UI 照常
const lifecycle=new AbortController();
for(const tool of atlasTools(atlas,inspect)){try{void Promise.resolve(context.registerTool(tool,{signal:lifecycle.signal})).catch(()=>{});}catch{/* 可选能力,UI 仍可用 */}}
return()=>lifecycle.abort(); // 返回反注册(卸载时 abort)
}
3.6 app/scene.tsx — 3D 场景引擎(原样代码,见下逐行解析)
1 import {useEffect,useRef} from 'react';
2 import * as T from 'three';
3 import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
4 import {RoomEnvironment} from 'three/examples/jsm/environments/RoomEnvironment.js';
5 import {mergeGeometries} from 'three/examples/jsm/utils/BufferGeometryUtils.js';
6 import {createExplosionLayout} from './explosion-layout';
7 import {decodeModelResponse} from './model-download';
8 import {PointerTap} from './pointer-tap';
9 import {SYSTEMS,type Atlas,type SceneState} from './anatomy';
10 interface Props {atlas:Atlas;state:SceneState;onSelect:(id:string)=>void;onProgress:(n:number)=>void;onError:(s:string)=>void}
11 export default function AnatomyScene({atlas,state,onSelect,onProgress,onError}:Props){
12 const host=useRef<HTMLDivElement>(null),latest=useRef(state),select=useRef(onSelect);
13 latest.current=state;select.current=onSelect;
14 useEffect(()=>{
15 const el=host.current!;let disposed=false,frame=0,dirty=true,ready=false,lastView='',lastReset=-1,lastIsolate='',layoutKey='',amount=0;
16 let lastState:SceneState|null=null;
17 const abort=new AbortController();
18 let renderer:T.WebGLRenderer;
19 try{renderer=new T.WebGLRenderer({antialias:true,alpha:false,powerPreference:'high-performance'});}catch{onError('This browser could not start the 3D viewer. Please try a browser with WebGL enabled.');return;}
20 renderer.setPixelRatio(Math.min(devicePixelRatio,innerWidth<768?1.5:2));renderer.setClearColor('#f2f3f3');renderer.outputColorSpace=T.SRGBColorSpace;renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.12;el.appendChild(renderer.domElement);
21 renderer.domElement.setAttribute('aria-label','Interactive human anatomy. Drag to orbit, pinch or scroll to zoom, and tap a structure to inspect it.');
22 const scene=new T.Scene(),camera=new T.PerspectiveCamera(34,1,.005,100),controls=new OrbitControls(camera,renderer.domElement);
23 camera.position.set(1.4,1.05,3.6);controls.target.set(0,.85,0);controls.enableDamping=true;controls.dampingFactor=.085;controls.minDistance=.07;controls.maxDistance=40;controls.maxPolarAngle=Math.PI*.96;controls.addEventListener('change',()=>{dirty=true;});
24 const pmrem=new T.PMREMGenerator(renderer),room=new RoomEnvironment(),env=pmrem.fromScene(room,.04);scene.environment=env.texture;room.dispose();pmrem.dispose();
25 scene.add(new T.HemisphereLight(0xffffff,0xa7acb2,1.05));
26 const key=new T.DirectionalLight(0xfffaf4,2.3);key.position.set(-2,4,3);scene.add(key);
27 const rim=new T.DirectionalLight(0xe9f0ff,1.8);rim.position.set(2,2,-3);scene.add(rim);
28 const ground=new T.Mesh(new T.CircleGeometry(30,96),new T.MeshStandardMaterial({color:0xd5d9dc,roughness:1}));ground.rotation.x=-Math.PI/2;ground.position.y=-.019;scene.add(ground);
29 const platform=new T.Mesh(new T.CylinderGeometry(.68,.7,.028,100),new T.MeshStandardMaterial({color:0xeeeeec,metalness:.12,roughness:.67}));platform.position.y=-.016;scene.add(platform);
30 const ring=new T.Mesh(new T.RingGeometry(.63,.632,128),new T.MeshBasicMaterial({color:0x8c969f,transparent:true,opacity:.4,side:T.DoubleSide}));ring.rotation.x=-Math.PI/2;ring.position.y=.001;scene.add(ring);
31 const innerRing=new T.Mesh(new T.RingGeometry(.55,.551,128),new T.MeshBasicMaterial({color:0xa4aeb8,transparent:true,opacity:.16,side:T.DoubleSide}));innerRing.rotation.x=-Math.PI/2;innerRing.position.y=.001;scene.add(innerRing);
32 const width=T.MathUtils.ceilPowerOfTwo(atlas.parts.length),data=new Float32Array(width*4),partTexture=new T.DataTexture(data,width,1,T.RGBAFormat,T.FloatType);partTexture.needsUpdate=true;
33 const selectedData=new Uint8Array(width*4),selectionTexture=new T.DataTexture(selectedData,width,1);selectionTexture.needsUpdate=true;
34 const materials:T.Material[]=[],geometries:T.BufferGeometry[]=[],pickers:(T.Mesh|undefined)[]=[],centers=atlas.parts.map(p=>new T.Vector3().fromArray(p.bounds[0]).add(new T.Vector3().fromArray(p.bounds[1])).multiplyScalar(.5));
35 const offsets:T.Vector3[]=[],bounds=atlas.parts.map(p=>new T.Box3(new T.Vector3().fromArray(p.bounds[0]),new T.Vector3().fromArray(p.bounds[1])));
36 let packingWidth=1,packingHeight=1;
37 const markerPositions=new Float32Array(atlas.parts.length*3),markerGeometry=new T.BufferGeometry();markerGeometry.setAttribute('position',new T.BufferAttribute(markerPositions,3));
38 const markerMaterial=new T.PointsMaterial({color:0x64748b,size:5,sizeAttenuation:false,transparent:true,opacity:.72,depthTest:false});
39 markerMaterial.onBeforeCompile=shader=>{shader.fragmentShader=shader.fragmentShader.replace('#include <clipping_planes_fragment>','#include <clipping_planes_fragment>\nif (distance(gl_PointCoord, vec2(0.5)) > 0.5) discard;');};
40 const markers=new T.Points(markerGeometry,markerMaterial);markers.frustumCulled=false;markers.renderOrder=10;markers.visible=false;scene.add(markers);
41 const hover=document.createElement('div');hover.className='part-hover';hover.setAttribute('role','tooltip');hover.hidden=true;el.appendChild(hover);
42 type Target={index:number;x:number;y:number;left:number;right:number;top:number;bottom:number};let targets:Target[]=[];
43 const projected=new T.Vector3();
44 const findTarget=(x:number,y:number,radius:number)=>{
45 let best=-1,score=Infinity;
46 for(const t of targets){const dx=Math.max(t.left-x,0,x-t.right),dy=Math.max(t.top-y,0,y-t.bottom),distance=Math.hypot(dx,dy);if(distance>radius)continue;const candidate=distance+Math.hypot(t.x-x,t.y-y)*.025;if(candidate<score){score=candidate;best=t.index;}}
47 return best;
48 };
49 const materialFor=(system:string)=>{
50 const m=new T.MeshStandardMaterial({color:SYSTEMS.find(s=>s.id===system)?.color??'#aebbb8',metalness:.08,roughness:.53,side:T.DoubleSide,transparent:system==='integumentary',opacity:system==='integumentary'?.1:1,depthWrite:system!=='integumentary'});
51 m.onBeforeCompile=shader=>{
52 shader.uniforms.partState={value:partTexture};shader.uniforms.selectionState={value:selectionTexture};shader.uniforms.stateWidth={value:width};
53 shader.vertexShader='attribute float partIndex; uniform sampler2D partState; uniform sampler2D selectionState; uniform float stateWidth; varying float partVisible; varying float partSelected;\n'+shader.vertexShader;
54 shader.vertexShader=shader.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\nvec2 stateUv = vec2((partIndex + 0.5) / stateWidth, 0.5); vec4 state = texture2D(partState, stateUv); transformed += state.xyz; partVisible = state.w; partSelected = texture2D(selectionState, stateUv).r;');
55 shader.fragmentShader='varying float partVisible; varying float partSelected;\n'+shader.fragmentShader;
56 shader.fragmentShader=shader.fragmentShader.replace('#include <clipping_planes_fragment>','#include <clipping_planes_fragment>\nif (partVisible < 0.5) discard;');
57 shader.fragmentShader=shader.fragmentShader.replace('#include <color_fragment>','#include <color_fragment>\ndiffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.42, 0.85, 0.78), partSelected * 0.75);');
58 };materials.push(m);return m;
59 };
60 const mats=new Map(SYSTEMS.map(s=>[s.id,materialFor(s.id)]));
61 let loaded=0;
62 const loadChunk=async(ci:number)=>{
63 const chunk=atlas.chunks[ci],compressed=!!chunk.gzip&&typeof DecompressionStream!=='undefined';const response=await fetch(compressed?chunk.gzip!:chunk.url,{signal:abort.signal});const buffer=await decodeModelResponse(response,chunk.bytes,compressed);if(disposed)return;
64 const groups=new Map<string,T.BufferGeometry[]>();
65 atlas.parts.forEach((p,i)=>{
66 if(p.chunk!==ci)return;
67 const g=new T.BufferGeometry();g.setAttribute('position',new T.BufferAttribute(new Float32Array(buffer,p.positions,p.vertexCount*3),3));
68 g.setAttribute('normal',new T.BufferAttribute(new Int16Array(buffer,p.normals,p.vertexCount*3),3,true));g.setIndex(new T.BufferAttribute(new Uint32Array(buffer,p.indices,p.indexCount),1));
69 g.boundingBox=bounds[i].clone();g.computeBoundingSphere();const pick=new T.Mesh(g);pick.matrixAutoUpdate=false;pickers[i]=pick;geometries.push(g);
70 g.setAttribute('partIndex',new T.BufferAttribute(new Float32Array(p.vertexCount).fill(i),1));
71 const list=groups.get(p.system)??[];list.push(g);groups.set(p.system,list);
72 });
73 groups.forEach((gs,system)=>{const geometry=mergeGeometries(gs,false);if(!geometry)throw new Error('Could not assemble anatomy geometry.');geometries.push(geometry);const mesh=new T.Mesh(geometry,mats.get(system as never));mesh.frustumCulled=false;scene.add(mesh);});
74 lastState=null;loaded++;onProgress(Math.round(loaded/atlas.chunks.length*100));dirty=true;
75 };
76 (async()=>{try{let cursor=0;await Promise.all(Array.from({length:3},async()=>{while(cursor<atlas.chunks.length){const i=cursor++;await loadChunk(i);}}));if(!disposed){ready=true;dirty=true;}}catch(e){if(!disposed)onError(e instanceof Error?e.message:'Could not load the anatomy.');}})();
77 const fit=(view:string,extent=0)=>{
78 const aspect=camera.aspect,mobile=el.clientWidth<768,normalDistance=mobile?Math.max(4.5,1.8*el.clientHeight/Math.max(160,el.clientHeight-350)/(2*Math.tan(T.MathUtils.degToRad(camera.fov/2)))):4;
79 const reservedHeight=mobile?350:270;const availableAspect=Math.max(.35,(el.clientWidth-(mobile?40:340))/Math.max(160,el.clientHeight-reservedHeight));const atlasDistance=Math.max(packingHeight,packingWidth/availableAspect)/(2*Math.tan(T.MathUtils.degToRad(camera.fov/2)))*(el.clientHeight/Math.max(160,el.clientHeight-reservedHeight))*1.08;
80 const distance=T.MathUtils.lerp(normalDistance,Math.max(.2,atlasDistance),extent);if(extent>.8)view='front';
81 const direction=view==='front'?new T.Vector3(0,.02,1):view==='back'?new T.Vector3(0,.02,-1):view==='side'?new T.Vector3(1,.02,0):new T.Vector3(.35,.06,1).normalize();
82 controls.target.set(extent>.1&&el.clientWidth>767?-packingWidth*.12:0,extent>.1||mobile?.85:.68,0);camera.position.copy(controls.target).addScaledVector(direction,distance);controls.update();dirty=true;
83 };
84 const resize=()=>{layoutKey='';lastState=null;renderer.setPixelRatio(Math.min(devicePixelRatio,el.clientWidth<768||el.clientHeight<600?1.5:2));camera.aspect=el.clientWidth/el.clientHeight;camera.updateProjectionMatrix();renderer.setSize(el.clientWidth,el.clientHeight);fit(latest.current.view,amount);};const observer=new ResizeObserver(resize);observer.observe(el);
85 const raycaster=new T.Raycaster(),pointer=new T.Vector2(),tap=new PointerTap(),worldBox=new T.Box3(),hitPoint=new T.Vector3();
86 const down=(e:PointerEvent)=>{hover.hidden=true;tap.down(e.pointerId,e.clientX,e.clientY,e.pointerType==='touch'?12:5);};
87 const move=(e:PointerEvent)=>{tap.move(e.pointerId,e.clientX,e.clientY);if(e.buttons||amount<.5||e.pointerType==='touch'){hover.hidden=true;return;}const rect=el.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top,index=findTarget(x,y,12);hover.hidden=index<0;renderer.domElement.style.cursor=index<0?'grab':'pointer';if(index>=0){hover.textContent=atlas.parts[index].name;hover.style.left=`${Math.max(8,Math.min(x+14,el.clientWidth-260))}px`;hover.style.top=`${Math.max(8,Math.min(y+18,el.clientHeight-55))}px`;}};
88 const cancel=(e:PointerEvent)=>tap.cancel(e.pointerId);
89 const up=(e:PointerEvent)=>{
90 const validTap=tap.up(e.pointerId,e.clientX,e.clientY);if(!validTap||!ready)return;const rect=renderer.domElement.getBoundingClientRect();pointer.set((e.clientX-rect.left)/rect.width*2-1,-(e.clientY-rect.top)/rect.height*2+1);raycaster.setFromCamera(pointer,camera);
91 let nearest=Infinity,found=-1;const hasSolid=atlas.parts.some((p,i)=>p.system!=='integumentary'&&data[i*4+3]>.5);
92 pickers.forEach((mesh,i)=>{if(!mesh||data[i*4+3]<.5||(hasSolid&&atlas.parts[i].system==='integumentary'))return;worldBox.copy(bounds[i]).translate(mesh.position);if(!raycaster.ray.intersectBox(worldBox,hitPoint))return;const hits=raycaster.intersectObject(mesh,false);if(hits[0]&&hits[0].distance<nearest){nearest=hits[0].distance;found=i;}});
93 if(found<0&&amount>.45)found=findTarget(e.clientX-rect.left,e.clientY-rect.top,e.pointerType==='touch'?24:16);if(found>=0){hover.hidden=true;select.current(atlas.parts[found].id);}
94 };
95 renderer.domElement.addEventListener('pointerdown',down);renderer.domElement.addEventListener('pointermove',move);renderer.domElement.addEventListener('pointerup',up);renderer.domElement.addEventListener('pointercancel',cancel);
96 const clock=new T.Clock();let lastExtent=-1;
97 const animate=()=>{
98 if(disposed)return;frame=requestAnimationFrame(animate);const dt=Math.min(clock.getDelta(),.05),s=latest.current;
99 const changed=lastState?.visible!==s.visible||lastState?.selected!==s.selected||lastState?.isolate!==s.isolate;
99 const moving=Math.abs(amount-s.explode)>.0001;
100 if(moving){amount=T.MathUtils.damp(amount,s.explode,8,dt);dirty=true;}
101 if(changed||moving||lastExtent<0){
102 const visible=new Set(s.visible),selection=new Set(s.selected);
103 const visibleParts=atlas.parts.filter(p=>s.isolate?selection.has(p.id):visible.has(p.system)||selection.has(p.id));
104 const nextLayoutKey=visibleParts.map(p=>p.id).join(',')+':'+camera.aspect.toFixed(3);
105 if(nextLayoutKey!==layoutKey){const layout=createExplosionLayout(visibleParts,camera.aspect);packingWidth=layout.width;packingHeight=layout.height;atlas.parts.forEach((p,i)=>{const cell=layout.cells.get(p.id);offsets[i]=cell?new T.Vector3(cell.x,cell.y+.85,0):centers[i].clone();});layoutKey=nextLayoutKey;if(amount>.05&&!s.isolate)fit(s.view,Math.max(0,(amount-.3)/.7));}
106 atlas.parts.forEach((p,i)=>{
107 const c=centers[i],destination=offsets[i];let dx=0,dy=0,dz=0;
108 if(amount<=.45){const t=amount/.45;const group=SYSTEMS.findIndex(sys=>sys.id===p.system);const angle=group/SYSTEMS.length*Math.PI*2;dx=Math.sin(angle)*t*.48;dy=(c.y-.85)*t*.28;dz=Math.cos(angle)*t*.48;}
109 else {const t=(amount-.45)/.55,group=SYSTEMS.findIndex(sys=>sys.id===p.system),angle=group/SYSTEMS.length*Math.PI*2;dx=T.MathUtils.lerp(Math.sin(angle)*.48,destination.x-c.x,t);dy=T.MathUtils.lerp((c.y-.85)*.28,destination.y-c.y,t);dz=T.MathUtils.lerp(Math.cos(angle)*.48,-c.z,t);}
110 const selected=selection.has(p.id);data.set([dx,dy,dz,(s.isolate?selected:visible.has(p.system)||selected)?1:0],i*4);selectedData[i*4]=selected?255:0;
111 markerPositions.set(data[i*4+3]>.5?[c.x+dx,c.y+dy,c.z+dz]:[10000,10000,10000],i*3);const mesh=pickers[i];if(mesh){mesh.position.set(dx,dy,dz);mesh.updateMatrix();mesh.updateMatrixWorld(true);}
112 });partTexture.needsUpdate=true;selectionTexture.needsUpdate=true;markerGeometry.attributes.position.needsUpdate=true;lastState=s;lastExtent=amount;dirty=true;
113 }
114 if(s.view!==lastView||s.reset!==lastReset){fit(s.view,amount);lastView=s.view;lastReset=s.reset;}
115 if(moving&&!s.isolate)fit(amount>.5?'front':s.view,Math.max(0,(amount-.3)/.7));
116 const isolateKey=s.isolate?s.selected.join(',')+':'+s.reset+':'+s.inspectorOpen+':'+camera.aspect:'';
117 if(isolateKey!==lastIsolate||(s.isolate&&moving)){
118 if(s.isolate){const box=new T.Box3();atlas.parts.forEach((p,i)=>{if(s.selected.includes(p.id))box.union(bounds[i].clone().translate(new T.Vector3(data[i*4],data[i*4+1],data[i*4+2])));});
119 if(!box.isEmpty()){const center=box.getCenter(new T.Vector3()),size=box.getSize(new T.Vector3());const w=el.clientWidth,h=el.clientHeight,mobile=w<768,landscape=w>h&&h<=600;let left=20,right=w-20,top=mobile?175:110,bottom=h-170;if(s.inspectorOpen){if(landscape){right=w-335;top=100;bottom=h-125;}else if(mobile){const sheet=document.querySelector('.detail-sheet')?.getBoundingClientRect(),header=document.querySelector('.identity')?.getBoundingClientRect();top=(header?.bottom??94)+16;bottom=(sheet?.top??h*.58-139)-16;}else{right=w-370;left=w>1100?285:25;}}const availableWidth=Math.max(150,right-left),availableHeight=Math.max(40,bottom-top);camera.setViewOffset(w,h,w/2-(left+right)/2,h/2-(top+bottom)/2,w,h);const distance=Math.max(.07,Math.max(size.y*h/availableHeight,size.x*w/availableWidth/camera.aspect,size.z)/(2*Math.tan(T.MathUtils.degToRad(camera.fov/2)))*1.35);controls.maxDistance=Math.max(40,distance*2);controls.target.copy(center);camera.position.copy(center).add(new T.Vector3(.2,.1,1).normalize().multiplyScalar(distance));controls.update();dirty=true;}
120 }else if(lastIsolate){camera.clearViewOffset();fit(s.view,amount);}
121 lastIsolate=isolateKey;
122 }
123 controls.enableRotate=amount<.8;controls.mouseButtons.LEFT=amount<.8?T.MOUSE.ROTATE:T.MOUSE.PAN;controls.touches.ONE=amount<.8?T.TOUCH.ROTATE:T.TOUCH.PAN;ground.visible=platform.visible=ring.visible=innerRing.visible=amount<.5&&!s.isolate;markers.visible=amount>.75;controls.autoRotate=s.rotate&&!s.isolate&&amount<.4;controls.autoRotateSpeed=.65;controls.update();if(controls.autoRotate)dirty=true;
124 if(dirty){renderer.render(scene,camera);targets=[];if(amount>.45){const hasSolid=atlas.parts.some((p,i)=>p.system!=='integumentary'&&data[i*4+3]>.5);atlas.parts.forEach((p,i)=>{if(data[i*4+3]<.5||(hasSolid&&p.system==='integumentary'))return;let left=Infinity,right=-Infinity,top=Infinity,bottom=-Infinity;for(let corner=0;corner<8;corner++){projected.set(p.bounds[(corner&1)?1:0][0]+data[i*4],p.bounds[(corner&2)?1:0][1]+data[i*4+1],p.bounds[(corner&4)?1:0][2]+data[i*4+2]).project(camera);const x=(projected.x+1)*el.clientWidth/2,y=(1-projected.y)*el.clientHeight/2;left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y);}projected.copy(centers[i]).add(new T.Vector3(data[i*4],data[i*4+1],data[i*4+2])).project(camera);if(projected.z< -1||projected.z>1)return;targets.push({index:i,x:(projected.x+1)*el.clientWidth/2,y:(1-projected.y)*el.clientHeight/2,left,right,top,bottom});});}dirty=false;}
125 };animate();
126 const contextLost=(e:Event)=>{e.preventDefault();onError('The 3D session was paused by your device. Reload to continue.');};renderer.domElement.addEventListener('webglcontextlost',contextLost);
127 return()=>{disposed=true;abort.abort();cancelAnimationFrame(frame);observer.disconnect();controls.dispose();geometries.forEach(g=>g.dispose());materials.forEach(m=>m.dispose());scene.traverse(o=>{if(o instanceof T.Mesh&&!geometries.includes(o.geometry)){o.geometry.dispose();const ms=Array.isArray(o.material)?o.material:[o.material];ms.forEach(m=>m.dispose());}});env.dispose();partTexture.dispose();selectionTexture.dispose();markerGeometry.dispose();markerMaterial.dispose();hover.remove();renderer.dispose();renderer.domElement.remove();};
128 },[atlas]);
129 return <div className="scene" ref={host}/>;
130 }
逐行解析(scene.tsx)
- L1–9 导入:React 副作用钩子;原生 Three.js(
* as T);OrbitControls(轨道控制)、RoomEnvironment(环境贴图)、mergeGeometries(按系统合并几何);三个自研工具模块。 - L11–13:
AnatomyScene组件,props 含atlas(数据清单)、state(UI 共享状态)、onSelect/onProgress/onError回调。latest/select两个 ref 缓存最新 state 与回调,避免重建 effect。 - L14–17:effect 依赖
[atlas],仅在数据加载完成后挂载一次。disposed标志用于卸载时短路;abort取消进行中的 fetch。 - L18–20:创建
WebGLRenderer(抗锯齿、不透明、高性能档);失败则报错并返回。setPixelRatio移动端 1.5、桌面 2;ACES 色调映射;清屏浅灰#f2f3f3;画布挂到 DOM。 - L21:给 canvas 加
aria-label,无障碍友好。 - L22–23:透视相机(FOV 34、近 0.005 远 100,单位米);
OrbitControls目标设在人体中心 (0,0.85,0),阻尼 0.085,最大俯仰 0.96π,远近缩放区间很大。 - L24:
PMREMGenerator+RoomEnvironment生成 PBR 环境光照,生成后立刻 dispose 源场景与生成器(省内存)。 - L25–27:半球光 + 主光(暖白,强度 2.3)+ 轮廓光(冷色,1.8),构成柔和三点布光。
- L28–31:地面大圆盘、展台圆柱、两圈装饰环(外环/内环),营造"展台"质感;爆炸或隔离时隐藏(见 L123)。
- L32–33:核心性能设计①——两张 1×N 的
DataTexture:partTexture(RGBA 浮点,存每零件的位移 xyz + 可见性 w)、selectionTexture(R8,存选中 0/255)。N 取不小于零件数的 2 的幂。width是着色器查纹理用的归一化分母。 - L34–35:
centers每个零件包围盒中心;bounds每个零件 AABB。pickers为 2234 个独立拾取网格(仅用于 CPU 射线拾取,不参与显示)。 - L37–40:
markers为爆炸态下每个可见零件的"定位点"(Points),默认隐藏;自定义片元把方形点裁成圆形(gl_PointCoord距中心>0.5 则 discard)。 - L41–43:
hover工具提示 div(桌面悬停显示零件名);Target类型与targets数组:记录每个可见零件在屏幕空间的矩形包围,供 2D 命中回退。 - L44–48:
findTarget(x,y,radius):在targets中找距点击最近且不超半径的零件(矩形外距 + 0.025 倍中心距作为打分),用于爆炸态下"点 2D 投影"的回退拾取。 - L49–59:
materialFor(system):核心性能设计②——为每个系统建一个MeshStandardMaterial,并通过onBeforeCompile注入 GLSL:顶点阶段按partIndex从partTexture采样位移并叠加到transformed,同时读出partVisible/partSelected;片元阶段partVisible<0.5则discard(GPU 显隐),选中则把颜色混入青绿色vec3(0.42,0.85,0.78)。这样"位移/显隐/选中"全在 GPU 完成,CPU 每帧只更新纹理。皮肤系统半透明且不写深度。 - L60:15 个系统各一份材质,存入
matsMap。 - L62–75:
loadChunk(ci)懒加载单个二进制块:fetch(gzip 优先)→decodeModelResponse解压 → 遍历属于该块的零件,用Float32Array(buffer, 偏移, 长度)的零拷贝视图构造 position/normal(量化 Int16)/index 属性;建 picker 网格(关自动矩阵,仅用于拾取);给几何加partIndex属性;按系统分组。关键:mergeGeometries把同系统所有零件合成一个缓冲几何 → 15 个系统约 15 个 draw call,而非 2234 个。onProgress上报加载百分比。 - L76:核心性能设计③——3 个并发 worker 协程(游标
cursor自增分配块)并行加载 15 个块,全部完成置ready。 - L77–83:
fit(view,extent)根据视角与爆炸程度(extent)计算相机距离与方向:桌面正常距离 4,爆炸态按打包尺寸与可用宽高比算更远距离;移动端距离更大;爆炸>0.8 强制正面。controls.target在爆炸/移动端略偏下以露出平铺清单。 - L84:
ResizeObserver→resize:重置布局键、更新像素比/相机宽高比/画布尺寸并fit。 - L85–95:指针事件:
down记录 tap 起点并隐藏 hover;move桌面非拖拽时做 2D 命中显示 hover 提示;up用PointerTap.up判定是否真轻点,真轻点则射线拾取(L92 先盒相交再网格相交,跳过隐藏/皮肤件),未命中且爆炸>0.45 时回退findTarget2D 投影命中;命中则select.current(id)上抛选中。 - L96–112:
animate()渲染循环:amount用MathUtils.damp平滑逼近目标爆炸度。每当可见/选中/隔离变化或爆炸进行中,重算"可见零件"布局键;键变化时调用createExplosionLayout得到打包坐标并写入offsets;随后逐零件按两阶段写位移(L108:0–45% 按系统角度径向扇形散开;L109:45–100% 由扇形位置插值到打包网格位置),并把位移+可见性写入partTexture、选中写入selectionTexture、定位点写入markerGeometry。partTexture.needsUpdate=true触发 GPU 更新。 - L114–115:视角/重置变化 →
fit;爆炸进行中且非隔离 → 渐变到正面。 - L116–122:隔离取景:
isolateKey变化且处于隔离态时,合并所选零件包围盒,按"详情面板占用区域"用camera.setViewOffset只把相机视野框在剩余空间(桌面右侧/横屏右侧/移动端下方),并据包围盒尺寸算距离,让单件正好填满空白处。退出隔离则clearViewOffset复位。 - L123:爆炸>0.8 时左键改为平移(PAN)而非旋转;隐藏展台/装饰环;爆炸>0.75 显示定位点;自动旋转仅在非隔离且爆炸<0.4 时生效。
- L124:
dirty为真才渲染;渲染后(仅爆炸>0.45)重算每个可见零件的屏幕矩形targets,供下次 2D 命中回退。 - L126–127:
webglcontextlost处理;卸载清理:取消动画、断开 observer、释放所有几何/材质/纹理/渲染器,移除 canvas——无内存泄漏。
3.7 app/page.tsx — 页面与 UI 状态机(原样代码,见下逐行解析)
1 import {flushSync} from 'react-dom';
2 import {registerAtlasTools} from './agent-tools';
3 import {useEffect,useMemo,useRef,useState} from 'react';
4 import {Activity,ArrowUpRight,ChevronRight,Focus,Info,Layers3,Pause,RotateCcw,RotateCw,Search,X} from 'lucide-react';
5 import {Button} from '@/components/ui/button';
6 import {Badge} from '@/components/ui/badge';
7 import {Slider} from '@/components/ui/slider';
8 import {Switch} from '@/components/ui/switch';
9 import {Sheet,SheetContent,SheetTitle,SheetDescription} from '@/components/ui/sheet';
10 import {Combobox,ComboboxInput,ComboboxContent,ComboboxList,ComboboxItem,ComboboxEmpty} from '@/components/ui/combobox';
11 import AnatomyScene from './scene';
12 import {DEFAULT_VISIBLE,SYSTEMS,EXPLANATIONS,explanation,type Atlas,type Concept,type SceneState,type SystemId,type View} from './anatomy';
13 const initial:SceneState={explode:0,visible:DEFAULT_VISIBLE,selected:[],isolate:false,view:'three-quarter',rotate:false,reset:0};
14 export default function Home(){
15 const detailTitle=useRef<HTMLHeadingElement>(null);
16 const [atlas,setAtlas]=useState<Atlas|null>(null),[state,setState]=useState(initial),[progress,setProgress]=useState(0),[error,setError]=useState(''),[panel,setPanel]=useState<'layers'|'search'|null>(null),[details,setDetails]=useState(false),[about,setAbout]=useState(false),[query,setQuery]=useState(''),[chosen,setChosen]=useState<Concept|null>(null);
17 useEffect(()=>{const abort=new AbortController();setProgress(0);setError('');setAtlas(null);setChosen(null);setDetails(false);setState({...initial,visible:DEFAULT_VISIBLE});fetch('/models/atlas.json',{signal:abort.signal}).then(r=>{if(!r.ok)throw new Error('The anatomy catalogue could not be loaded.');return r.json();}).then(data=>setAtlas(data as Atlas)).catch(e=>{if(e.name!=='AbortError')setError(e.message);});return()=>abort.abort();},[]);
18 useEffect(()=>{const key=(e:KeyboardEvent)=>{if(e.key==='/'&&!(e.target instanceof HTMLInputElement)&&!(e.target instanceof HTMLTextAreaElement)){e.preventDefault();setPanel('search');setDetails(false);}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
19 const parts=useMemo(()=>new Map(atlas?.parts.map(p=>[p.id,p])),[atlas]);
20 const counts=useMemo(()=>Object.fromEntries(SYSTEMS.map(s=>[s.id,atlas?.parts.filter(p=>p.system===s.id).length??0])),[atlas]);
21 const activeSystems=SYSTEMS.filter(s=>counts[s.id]>0);
22 const selectedParts=state.selected.map(id=>parts.get(id)).filter(p=>!!p),selected=selectedParts[0],system=SYSTEMS.find(s=>s.id===selected?.system);
23 const visibleCount=atlas?.parts.filter(p=>state.isolate?state.selected.includes(p.id):state.visible.includes(p.system)||state.selected.includes(p.id)).length??0;
24 const results=useMemo(()=>{if(!atlas)return[];const term=query.toLowerCase().trim();if(!term)return ['heart','brain','liver','stomach','spleen','pancreas','urinary bladder','trachea'].map(name=>atlas.concepts.find(c=>c.name.toLowerCase()===name)).filter((x):x is Concept=>!!x);return atlas.concepts.filter(c=>c.name.toLowerCase().includes(term)||c.id.toLowerCase().includes(term)).sort((a,b)=>a.name.length-b.name.length).slice(0,80);},[atlas,query]);
25 const choose=(c:Concept)=>{setChosen(c);setState(s=>({...s,selected:c.elements,isolate:false,rotate:false}));setDetails(true);setPanel(null);};
26 useEffect(()=>{if(!atlas)return;return registerAtlasTools(atlas,c=>flushSync(()=>choose(c)));},[atlas]);
27 const choosePart=(id:string)=>{const p=parts.get(id);if(!p)return;setChosen({id:p.conceptId,name:p.name,elements:[id]});setState(s=>({...s,selected:[id],isolate:false,rotate:false}));setDetails(true);setPanel(null);};
28 const toggle=(id:SystemId)=>{setDetails(false);setState(s=>({...s,selected:[],isolate:false,visible:s.visible.includes(id)?s.visible.filter(x=>x!==id):[...s.visible,id]}));};
29 const reset=()=>{setState(s=>({...initial,visible:DEFAULT_VISIBLE,reset:s.reset+1}));setChosen(null);setDetails(false);setPanel(null);};
30 const openPanel=(next:'layers'|'search')=>{setDetails(false);setPanel(p=>p===next?null:next);};
31 return <main className="studio">
32 {atlas&&<AnatomyScene atlas={atlas} state={{...state,inspectorOpen:details&&selectedParts.length>0}} onSelect={choosePart} onProgress={n=>{setProgress(n);if(n===100)setError('');}} onError={setError}/>}
33 <div className="vignette"/>
34 <header className="identity"><div className="eyebrow"><span className="status-dot"/> INTERACTIVE ANATOMY</div><h1>Human Atlas<Badge variant="outline" className="edition">3D</Badge></h1><div className="identity-meta">{atlas?atlas.parts.length.toLocaleString():'2,234'} modeled pieces <span>·</span> BodyParts3D</div></header>
35 <nav className="top-actions" aria-label="Explorer panels"><Button variant="ghost" className={panel==='search'?'active':''} onClick={()=>openPanel('search')} aria-label="Search anatomy"><Search size={18}/><span>Find a structure</span><kbd>/</kbd></Button><Button variant="ghost" className="icon-button" aria-label="About this atlas" onClick={()=>{setDetails(false);setPanel(null);setAbout(true);}}><Info size={18}/></Button></nav>
36 <section className={`layers-panel glass ${panel==='layers'?'mobile-open':''}`} aria-label="Anatomical layers">
37 <div className="panel-heading"><span>Systems</span><Button variant="ghost" className="mobile-only icon-button" onClick={()=>setPanel(null)} aria-label="Close systems"><X size={18}/></Button><Badge variant="secondary" className="desktop-only small-number">{activeSystems.length}</Badge></div>
38 <div className="layer-presets"><Button variant="ghost" aria-pressed={activeSystems.every(x=>state.visible.includes(x.id))} onClick={()=>setState(s=>({...s,selected:[],isolate:false,visible:activeSystems.map(x=>x.id)}))}>All</Button><Button variant="ghost" aria-pressed={state.visible.length===1&&state.visible[0]==='skeletal'} onClick={()=>setState(s=>({...s,selected:[],isolate:false,visible:['skeletal']}))}>Skeleton</Button><Button variant="ghost" aria-pressed={state.visible.length===6&&['cardiac','respiratory','digestive','urinary','endocrine','reproductive'].every(id=>state.visible.includes(id as SystemId))} onClick={()=>setState(s=>({...s,selected:[],isolate:false,visible:['cardiac','respiratory','digestive','urinary','endocrine','reproductive']}))}>Organs</Button></div>
39 <div className="system-list">{activeSystems.map(s=><div className={`system-row ${state.visible.includes(s.id)?'enabled':''}`} key={s.id}><Button variant="ghost" className="system-name" title={`Show only ${s.name.toLowerCase()}`} onClick={()=>setState(v=>({...v,visible:[s.id],isolate:false,selected:[]}))}><span className="system-dot" style={{background:s.color}}/>{s.name}<span className="system-count">{counts[s.id]}</span></Button><Switch checked={state.visible.includes(s.id)} onCheckedChange={()=>toggle(s.id)} aria-label={`Show ${s.name.toLowerCase()}`} /></div>)}</div>
40 <div className="panel-foot"><span>{visibleCount.toLocaleString()} pieces visible</span><Button variant="ghost" onClick={()=>setState(s=>({...s,visible:[],selected:[],isolate:false}))}>Hide all</Button></div>
41 </section>
42 {panel==='search'&&<section className="search-panel glass" aria-label="Find anatomy"><div className="panel-heading"><span>Find a structure</span><Button variant="ghost" className="icon-button" onClick={()=>setPanel(null)} aria-label="Close search"><X size={18}/></Button></div><Combobox<Concept> items={results} value={null} onValueChange={value=>{if(value)choose(value);}} inputValue={query} onInputValueChange={setQuery} itemToStringLabel={c=>c.name} filter={null} open onOpenChange={open=>{if(!open)setPanel(null);}}><ComboboxInput autoFocus placeholder="Heart, femur, cranial nerve…" aria-label="Search named anatomical structures" showTrigger={false}/><ComboboxContent className="anatomy-search-results"><ComboboxEmpty>No structures match your search.</ComboboxEmpty><ComboboxList>{(c:Concept)=><ComboboxItem key={c.id} value={c}><span className="search-result-name">{c.name}</span><span className="small-number">{c.elements.length} {c.elements.length===1?'piece':'pieces'}</span></ComboboxItem>}</ComboboxList></ComboboxContent></Combobox><p className="search-note">{query?'Showing up to 80 matches. Refine your search to find smaller structures.':'Start with a major organ, or search every named structure.'}</p></section>}
43 <nav className="view-controls glass" aria-label="Camera controls">{(['three-quarter','front','side','back'] as View[]).map((v,i)=><Button variant="ghost" key={v} className={state.view===v?'active':''} aria-pressed={state.view===v} disabled={state.explode>.8&&v!=='front'} onClick={()=>setState(s=>({...s,view:v,reset:s.reset+1,rotate:false}))} title={`${v} view`} aria-label={`${v} view`}><span>{['¾','F','S','B'][i]}</span></Button>)}<i/><Button variant="ghost" disabled={state.explode>=.4} aria-label={state.rotate?'Pause rotation':'Rotate body'} title="Auto rotate" className={state.rotate?'active':''} onClick={()=>setState(s=>({...s,rotate:!s.rotate}))}>{state.rotate?<Pause size={17}/>:<RotateCw size={18}/>}</Button><Button variant="ghost" aria-label="Reset view and layers" title="Reset" onClick={reset}><RotateCcw size={17}/></Button></nav>
44 <div className="scene-caption"><span className="caption-line"/><span>{state.isolate?(chosen?.name??'SELECTED STRUCTURE'):state.explode>.95?'ANATOMICAL INVENTORY':state.explode>.05?'SEPARATED STRUCTURES':'ADULT HUMAN · MALE'}</span><span className="caption-line"/></div>
45 <div className="bottom-dock glass"><Button variant="ghost" className="mobile-only dock-layers" onClick={()=>openPanel('layers')} aria-label="Open system layers"><Layers3 size={20}/><span>Systems</span></Button><div className="explode-control"><div className="explode-label"><label id="explode-label">Explode anatomy</label><output>{Math.round(state.explode*100)}<span>%</span></output></div><Slider aria-labelledby="explode-label" min={0} max={100} step={1} value={[state.explode*100]} onValueChange={v=>setState(s=>({...s,explode:(Array.isArray(v)?v[0]:v)/100,view:(Array.isArray(v)?v[0]:v)>80?'front':s.view,rotate:false}))}/><div className="slider-endpoints"><span>Assembled</span><span>Every piece</span></div></div><Button variant="ghost" className="dock-reset" onClick={reset} aria-label="Assemble and reset"><RotateCcw size={18}/><span>Reset</span></Button></div>
46 <footer className="studio-footer"><span>{state.explode>.8?'Drag to pan':'Drag to orbit'} <b>·</b> Pinch to zoom <b>·</b> Tap to inspect</span><Button variant="ghost" onClick={()=>{setDetails(false);setPanel(null);setAbout(true);}}>Source & credits <ArrowUpRight size={12}/></Button></footer>
47 {progress<100&&!error&&<div className="loading glass" role="status"><Activity size={18}/><div><strong>Preparing the anatomy</strong><span>{progress}% · Loading {atlas?.parts.length.toLocaleString()??'2,234'} pieces</span><div className="loading-track"><i style={{width:`${progress}%`}}/></div></div></div>}
48 {error&&<div className="loading glass error" role="alert"><p>{error}</p><Button variant="ghost" onClick={()=>location.reload()}>Reload viewer</Button></div>}
49 <Sheet open={details&&selectedParts.length>0} modal={false} disablePointerDismissal onOpenChange={setDetails}><SheetContent initialFocus={detailTitle} className={`detail-sheet glass ${state.isolate?'is-isolated':''}`} showCloseButton={true}><div className="detail-header"><div className="detail-accent" style={{background:system?.color}}/><div className="eyebrow">{system?.name??'ANATOMY'}</div><SheetTitle ref={detailTitle} tabIndex={-1} className="structure-title">{chosen?.name}</SheetTitle></div><div className="detail-scroll" key={`${chosen?.id}-${state.isolate}`}><SheetDescription className="structure-description">{chosen&&selected?explanation(chosen.name,selected.system):''}</SheetDescription>{chosen&&!EXPLANATIONS[chosen.name.toLowerCase()]&&<span className="context-note">System overview · structure identified from source anatomy</span>}<div className="structure-meta"><span>Atlas reference<strong>{chosen?.id}</strong></span><span>Selected pieces<strong>{state.selected.length.toLocaleString()}</strong></span></div>{selectedParts.length>1&&<div className="member-list"><h3>Included structures</h3>{selectedParts.slice(0,50).map(p=><Button variant="ghost" key={p.id} onClick={()=>choosePart(p.id)}><span>{p.name}</span><ChevronRight size={14}/></Button>)}{selectedParts.length>50&&<p>And {selectedParts.length-50} more modeled pieces.</p>}</div>}<a className="source-link" href="https://lifesciencedb.jp/bp3d/" target="_blank" rel="noreferrer">View anatomical source <ArrowUpRight size={14}/></a></div><div className="detail-actions"><Button className={`primary-action ${state.isolate?'active':''}`} onClick={()=>setState(s=>({...s,isolate:!s.isolate,explode:0}))}><Focus size={18}/>{state.isolate?'Show surrounding anatomy':'Isolate structure'}<ChevronRight size={16}/></Button><Button variant="ghost" className="secondary-action" onClick={()=>{setState(s=>({...s,selected:[],isolate:false}));setDetails(false);}}>Clear selection</Button></div></SheetContent></Sheet>
50 <Sheet open={about} onOpenChange={setAbout}><SheetContent className="about-sheet glass"><div className="eyebrow">SOURCE & SCOPE</div><SheetTitle className="structure-title">A body, revealed.</SheetTitle><SheetDescription>Explore the adult male reference anatomy from BodyParts3D.</SheetDescription><div className="about-copy"><p><strong>Male · BodyParts3D</strong><br/>2,234 individual meshes and 3,432 named concepts from an adult male reference anatomy.</p><p>This reference does not contain every human structure or variation. Named concepts can contain multiple pieces; each source mesh is rendered once.</p><p>Colors and system groupings are designed for exploration. The geometry is simplified for the web, and short explanations provide general educational context. This is an anatomical reference, not a diagnostic or surgical tool.</p><h3>Source</h3><p>BodyParts3D, © The Database Center for Life Science licensed under CC Attribution 4.0 International.</p><a href="https://dbarchive.biosciencedbc.jp/en/bodyparts3d/lic.html" target="_blank" rel="noreferrer">Dataset license <ArrowUpRight size={14}/></a><a href="https://dbarchive.biosciencedbc.jp/en/bodyparts3d/download.html" target="_blank" rel="noreferrer">Original geometry & metadata <ArrowUpRight size={14}/></a><a href="https://academic.oup.com/nar/article/37/suppl_1/D782/1000752" target="_blank" rel="noreferrer">Read the source publication <ArrowUpRight size={14}/></a></div></SheetContent></Sheet>
51 </main>;
52 }
逐行解析(page.tsx)
- L1–12:导入 React/ReactDOM、
registerAtlasTools、图标、@/components/ui/*(shadcn 生成的 Button/Badge/Slider/Switch/Sheet/Combobox)、AnatomyScene、以及anatomy.ts的导出与类型。 - L13:
initial初始场景状态——爆炸 0、默认可见 14 系统、¾ 视角、无旋转。 - L15–16:
detailTitleref 用于详情面板聚焦;一堆useState承载 atlas / 场景状态 / 进度 / 错误 / 面板开关 / 详情 / 关于 / 搜索词 / 当前选定概念。 - L17:effect 加载
/models/atlas.json(图谱清单,指明各块 URL 与零件元数据);AbortController在卸载时取消;非AbortError才上抛错误。 - L18:键盘
/打开搜索面板(输入框/文本域聚焦时不触发)。 - L19–20:
parts把零件数组转成id→PartMap(O(1) 查找);counts统计每个系统的零件数。 - L21–22:
activeSystems仅显示有零件的系统;selectedParts/selected/system由当前选中推导。 - L23:
visibleCount计算当前可见零件数(隔离态只看选中;否则看系统可见或选中)。 - L24:
results:搜索结果记忆化。空查询时给 8 个默认大器官;否则按名称或 FMA id 模糊匹配,按名称长度升序排(短名优先),最多 80 条。 - L25:
choose(c):选中一个概念 → 写入选中元素、打开详情、关面板。 - L26:atlas 就绪后注册 WebMCP 工具,
flushSync包住choose以确保 AI 调用同步刷新 UI。 - L27:
choosePart(id):点选单个网格(来自 3D 场景或详情内成员列表)→ 以conceptId作为概念、该 id 为唯一元素,打开详情。 - L28:
toggle(id):系统开关(显隐切换)。 - L29–30:
reset复位(附带reset+1触发场景fit);openPanel切换系统/搜索面板。 - L32:渲染
AnatomyScene,传入state(inspectorOpen由详情+选中推导)、onSelect=choosePart、进度与错误回调。 - L33–34:暗角层 + 顶部标识(标题、零件数、数据来源)。
- L35:右上角"查找结构"(搜)/“关于”按钮。
- L36–41:左侧系统面板:预设 All/Skeleton/Organs、系统行(色点+名称+计数+Switch)、底部可见计数与"Hide all"。
- L42:搜索面板:
Combobox渲染results,选择即choose;空查询提示引导语。 - L43:视角控制(¾/F/S/B)、自动旋转、重置;爆炸>0.8 时禁用非正面视角。
- L44:场景标题随状态变化(ADULT HUMAN · MALE / SEPARATED / ANATOMICAL INVENTORY / 选中名)。
- L45:底部停靠栏:移动端"Systems"按钮 + 爆炸滑块(>80% 自动切正面)+ 重置。
- L46:页脚操作提示 + 来源/署名入口。
- L47–48:加载进度条 / 错误提示(可重载)。
- L49:详情
Sheet:系统色强调条、概念名、解释文案(无专属文案时标注"系统概览")、源 id、选中件数、成员列表(可点进单件)、跳转 BodyParts3D 源站、"隔离/显示周围"与"清除选择"按钮。 - L50:关于
Sheet:数据来源、范围声明、CC BY 4.0 署名与论文链接。 - L51–52:根
<main>返回。
3.8 web/main.tsx + web/index.html — 入口
// web/main.tsx
import {createRoot} from 'react-dom/client';
import Home from '../app/page'; // 复用 app/ 下的页面组件
import '../app/globals.css'; // 引入 Tailwind v4 + 自定义样式
createRoot(document.getElementById('root')!).render(<Home/>);
<!-- web/index.html:Vite 入口 HTML,挂载 #root,加载 /main.tsx(ESM) -->
<!doctype html><html lang="en" ><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/><meta name="theme-color" content="#f3f4f4"/><meta name="description" content="An interactive atlas of the human body..."/><title>Human Atlas</title><link rel="icon" href="/favicon.svg"/></head><body><div id="root"></div><script type="module" src="/main.tsx"></script></body></html>
3.9 scripts/convert-anatomy.py — OBJ → 二进制转换(带行内注释)
"""Convert official BodyParts3D 4.0 OBJ meshes without altering topology.
Usage: python3 scripts/convert-anatomy.py OBJ_DIRECTORY CONCEPT_MAP SYSTEM_MAP
源与署名见 public/ATTRIBUTION.md。坐标 mm/Z-up → m/Y-up;法线量化 signed 16-bit;按块分组。"""
import sys,json,re,struct,math
from pathlib import Path
from array import array
root=Path(__file__).resolve().parents[1]
source=Path(sys.argv[1]); metadata=json.loads(Path(sys.argv[2]).read_text()); systemdata=json.loads(Path(sys.argv[3]).read_text()) if len(sys.argv)>3 else {}
out=root/'public/models';out.mkdir(parents=True,exist_ok=True)
# 兼容研究 map 的 elements 记录或直接 id→system 映射
systems=systemdata.get('systems',systemdata.get('mapping',systemdata.get('elements',systemdata.get('meshes',systemdata))))
if isinstance(systems,list): systems={x['id']:x for x in systems}
parts=[];chunks=[];blob=bytearray();chunk=0;total_triangles=0
for element in metadata['elements']:
mesh=source/(element['id']+'.obj')
record=systemdata.get('parts',{}).get(element['id'],{})
vertices=[];normals=[];indices=[];name=element['name']
for line in mesh.read_text().splitlines():
if line.startswith('# English name : '):name=line.split(' : ',1)[1].strip() or element['name'] # 取英文标注名
elif line.startswith('v '):
x,y,z=map(float,line.split()[1:4]);vertices.extend([x*.001,z*.001+.0781112,-y*.001-.1]) # mm→m,Z-up→Y-up,并平移到舞台
elif line.startswith('vn '):
x,y,z=map(float,line.split()[1:4]);normals.extend([round(x*32767),round(z*32767),round(-y*32767)]) # 法线量化到 signed 16-bit
elif line.startswith('f '):
face=[int(s.split('/')[0])-1 for s in line.split()[1:]]
for j in range(1,len(face)-1):indices.extend([face[0],face[j],face[j+1]]) # 三角化(fan)
assert len(normals)==len(vertices),element['id']
assert len(vertices) and max(indices)<len(vertices)//3
if len(blob)>7_000_000: # 单块 >7MB 就落盘并开新块
(out/f'anatomy-{chunk}.bin').write_bytes(blob);chunks.append({'url':f'/models/anatomy-{chunk}.bin','bytes':len(blob)});blob=bytearray();chunk+=1
def append(values,fmt): # 4 字节对齐后写入,返回偏移
while len(blob)%4:blob.append(0)
offset=len(blob);blob.extend(array(fmt,values).tobytes());return offset
po=append(vertices,'f');no=append(normals,'h');io=append(indices,'I') # position(float)/normal(short)/index(uint)
bounds=[[min(vertices[i::3]) for i in range(3)],[max(vertices[i::3]) for i in range(3)]] # AABB
system=systems.get(element['id'],'connective')
if isinstance(system,dict):system=system.get('system',system.get('category','connective'))
parts.append({'id':element['id'],'name':record.get('name',name),'conceptId':record.get('conceptId',element['conceptId']),'system':system,'chunk':chunk,'positions':po,'normals':no,'indices':io,'vertexCount':len(vertices)//3,'indexCount':len(indices),'bounds':bounds})
total_triangles+=len(indices)//3
(out/f'anatomy-{chunk}.bin').write_bytes(blob);chunks.append({'url':f'/models/anatomy-{chunk}.bin','bytes':len(blob)})
manifest={'version':'BodyParts3D 4.0','parts':parts,'chunks':chunks,'triangles':total_triangles,'concepts':[{k:v for k,v in c.items() if k in ['id','name','elements']} for c in metadata['concepts']]}
(out/'atlas.json').write_text(json.dumps(manifest,separators=(',',':')))
print(json.dumps({'parts':len(parts),'concepts':len(manifest['concepts']),'triangles':total_triangles,'bytes':sum(c['bytes'] for c in chunks),'chunks':len(chunks),'systems':sorted(set(p['system'] for p in parts))},indent=2))
3.10 scripts/optimize-anatomy.mjs — 四边形简化(带行内注释)
import fs from 'node:fs';
import {MeshoptSimplifier} from 'meshoptimizer';
await MeshoptSimplifier.ready;
const name=process.argv[2]??'atlas.json',prefix=name.includes('female')?'female':'body';
const dir=new URL('../public/models/',import.meta.url),manifest=JSON.parse(fs.readFileSync(new URL(name,dir),'utf8'));
const originals=manifest.chunks.map(c=>c.url.split('/').pop());
if(manifest.optimized)throw new Error('Already optimized. Re-run the source converter first.'); // 防重复
const source=manifest.chunks.map(c=>fs.readFileSync(new URL(c.url.split('/').pop(),dir)));
let chunks=[],segments=[],bytes=0,triangles=0,maxError=0;
const flush=()=>{if(!bytes)return;const url=`/models/${prefix}-${chunks.length}.bin`;fs.writeFileSync(new URL(url.split('/').pop(),dir),Buffer.concat(segments));chunks.push({url,bytes});segments=[];bytes=0;}; // 落盘一块
const append=a=>{const padding=(4-bytes%4)%4;if(padding){segments.push(Buffer.alloc(padding));bytes+=padding;}const offset=bytes;const b=Buffer.from(a.buffer,a.byteOffset,a.byteLength);segments.push(b);bytes+=b.length;return offset;}; // 4 字节对齐追加
for(const p of manifest.parts){
const b=source[p.chunk];let pos=new Float32Array(b.buffer,b.byteOffset+p.positions,p.vertexCount*3),normal=new Int16Array(b.buffer,b.byteOffset+p.normals,p.vertexCount*3),indices=new Uint32Array(b.buffer,b.byteOffset+p.indices,p.indexCount);
if(prefix==='female'){ // 女性参考(HuBMAP)常在三角边界重复顶点 → 焊接重合点并平均法线
const map=new Map(),remap=new Uint32Array(p.vertexCount),wp=[],wn=[];
for(let i=0;i<p.vertexCount;i++){const k=`${pos[i*3]},${pos[i*3+1]},${pos[i*3+2]}`;let index=map.get(k);if(index===undefined){index=wp.length/3;map.set(k,index);wp.push(pos[i*3],pos[i*3+1],pos[i*3+2]);wn.push(0,0,0);}remap[i]=index;for(let a=0;a<3;a++)wn[index*3+a]+=normal[i*3+a];}
for(let i=0;i<wn.length;i+=3){const length=Math.hypot(wn[i],wn[i+1],wn[i+2])||1;for(let a=0;a<3;a++)wn[i+a]=Math.round(wn[i+a]/length*32767);}
pos=new Float32Array(wp);normal=new Int16Array(wn);indices=Uint32Array.from(indices,i=>remap[i]);
}
// 保留每个具名网格;窄血管与小器官更保守。每部件几何误差限 0.2%
const target=Math.max(96,Math.floor(p.indexCount*.22/3)*3); // 至少保留 96 顶点 / 至多简化到 22% 面数
const [simplified,error]=MeshoptSimplifier.simplify(indices,pos,3,Math.min(indices.length,target),.002); // 四边形误差 .002
maxError=Math.max(maxError,error);const [remap,count]=MeshoptSimplifier.compactMesh(simplified); // 压缩掉孤立顶点
const positions=new Float32Array(count*3),normals=new Int16Array(count*3);
for(let old=0;old<remap.length;old++){const n=remap[old];if(n===0xffffffff)continue;positions.set(pos.subarray(old*3,old*3+3),n*3);normals.set(normal.subarray(old*3,old*3+3),n*3);}
if(bytes>4_000_000)flush(); // 块 >4MB 落盘
p.chunk=chunks.length;p.positions=append(positions);p.normals=append(normals);p.indices=append(simplified);p.vertexCount=count;p.indexCount=simplified.length;triangles+=simplified.length/3;
}
flush();manifest.sourceTriangles=manifest.triangles;manifest.triangles=triangles;manifest.chunks=chunks;manifest.optimized={method:'meshoptimizer quadric simplification',maximumRelativeError:.002,preservedMeshes:manifest.parts.length};
fs.writeFileSync(new URL(name,dir),JSON.stringify(manifest));
for(const name of originals)fs.unlinkSync(new URL(name,dir)); // 仅删除被优化块替代的原始块
console.log(JSON.stringify({parts:manifest.parts.length,triangles,bytes:chunks.reduce((n,c)=>n+c.bytes,0),chunks:chunks.length,maxError}));
3.11 scripts/compress-models.mjs — gzip 压缩
import fs from 'node:fs';
import {gzipSync} from 'node:zlib';
const base=new URL('../public/models/',import.meta.url);
for(const name of fs.readdirSync(base).filter(n=>n==='atlas.json')){ // 仅处理 atlas.json
const path=new URL(name,base),atlas=JSON.parse(fs.readFileSync(path));
let bytes=0;
for(const c of atlas.chunks){const compressed=gzipSync(fs.readFileSync(new URL(c.url.split('/').pop(),base)),{level:9});c.gzip=c.url+'.gz';c.gzipBytes=compressed.length;fs.writeFileSync(new URL(c.gzip.split('/').pop(),base),compressed);bytes+=compressed.length;} // level 9 最高压缩
fs.writeFileSync(path,JSON.stringify(atlas));console.log(`${name}: ${(bytes/1e6).toFixed(1)} MB compressed download`);
}
3.12 scripts/validate-atlas.mjs — 数据完整性断言
import fs from 'node:fs';
import assert from 'node:assert/strict';
const filename=process.argv[2]??'atlas.json';
const base=new URL('../public/models/',import.meta.url),atlas=JSON.parse(fs.readFileSync(new URL(filename,base)));
assert.equal(atlas.parts.length,2234);assert.equal(atlas.concepts.length,3432); // 强约束零件/概念数
const ids=new Set(atlas.parts.map(p=>p.id));assert.equal(ids.size,2234); // id 唯一
const files=atlas.chunks.map(c=>{const b=fs.readFileSync(new URL(c.url.split('/').pop(),base));assert.equal(b.length,c.bytes);return b;}); // 块字节数一致
let tris=0;
for(const p of atlas.parts){
assert.ok(p.name.trim()&&p.name!=='-'&&!p.name.includes('Bounds(')); // 名称有效(排除占位名)
assert.ok(p.conceptId!=='-');assert.ok(p.system);
const b=files[p.chunk];assert.ok(p.indices+p.indexCount*4<=b.length); // 索引偏移不越界
const pos=new Float32Array(b.buffer,b.byteOffset+p.positions,p.vertexCount*3),indices=new Uint32Array(b.buffer,b.byteOffset+p.indices,p.indexCount);
assert.ok(indices.length>=3);for(const i of indices)assert.ok(i<p.vertexCount,`${p.id}: invalid vertex`); // 索引指向合法顶点
for(const value of pos)assert.ok(Number.isFinite(value)); // 坐标为有限数
tris+=p.indexCount/3;
}
for(const c of atlas.concepts){assert.ok(c.elements.length);for(const id of c.elements)assert.ok(ids.has(id),`${c.id}: missing ${id}`);} // 概念元素均存在
assert.equal(tris,atlas.triangles); // 总三角形数自洽
console.log(`Verified ${ids.size} individually indexed meshes, ${atlas.concepts.length} complete concept mappings, ${tris.toLocaleString()} triangles, and every binary buffer.`);
3.13 scripts/validate-interactions.mjs — 交互契约校验
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createExplosionLayout} from '../app/explosion-layout.ts';
import {PointerTap} from '../app/pointer-tap.ts';
import {atlasTools} from '../app/agent-tools.ts';
for (const file of ['atlas.json']) {
const atlas=JSON.parse(await readFile(new URL(`../public/models/${file}`,import.meta.url)));
const groups=[atlas.parts,...[...new Set(atlas.parts.map(p=>p.system))].map(system=>atlas.parts.filter(p=>p.system===system))]; // 全量 + 各系统分组
for(const group of groups) for(const aspect of [.46,1,1.7]) { // 手机竖屏/方形/桌面横屏三种宽高比
const layout=createExplosionLayout(group,aspect),cells=[...layout.cells.values()];
assert.equal(cells.length,group.length); // 单元数=零件数
for(let i=0;i<cells.length;i++){const a=cells[i];
assert.ok(Math.abs(a.x)+a.width/2<=layout.width/2+1e-8); // 不超出总宽
assert.ok(Math.abs(a.y)+a.height/2<=layout.height/2+1e-8); // 不超出总高
for(let j=i+1;j<cells.length;j++){const b=cells[j];
assert.ok(Math.abs(a.x-b.x)>=(a.width+b.width)/2-1e-8 || Math.abs(a.y-b.y)>=(a.height+b.height)/2-1e-8,'Exploded pieces overlap');}} // 任意两单元不重叠
}
}
let selected=null;const [find,inspect]=atlasTools(atlas,c=>{selected=c;});
const results=find.execute({query:'femur'});assert.ok(results.length>0); // 能搜到股骨
inspect.execute({id:results[0].id});const previous=selected;
assert.throws(()=>inspect.execute({id:'nonexistent-structure'})); // 不存在的结构抛错
assert.equal(selected,previous);
assert.throws(()=>find.execute({query:' '})); // 空查询抛错
console.log(`${file}: packing at desktop/mobile aspect ratios and search/inspection contracts passed.`);
}
const tap=new PointerTap();
tap.down(1,10,10,5);assert.equal(tap.up(1,12,11),true); // 小位移=轻点
tap.down(1,10,10,5);tap.move(1,40,10);assert.equal(tap.up(1,10,10),false); // 大位移=拖拽
tap.down(1,10,10,12);tap.down(2,20,20,12);assert.equal(tap.up(2,20,20),false);assert.equal(tap.up(1,10,10),false); // 多指=非轻点
tap.down(1,10,10,5);tap.cancel(1);assert.equal(tap.up(1,10,10),false); // 取消=非轻点
tap.down(1,10,10,5);assert.equal(tap.up(1,10,10),true);
assert.equal(createExplosionLayout([]).cells.size,0); // 空输入返回空布局
console.log('Tap, drag, multitouch, cancellation, and empty-view checks passed.');
3.14 配置与通用工具
lib/utils.ts:cn(...)=twMerge(clsx(...)),shadcn 标准类名合并。hooks/use-mobile.ts:useIsMobile()用matchMedia('(max-width:767px)')判断移动端;本项目主要依赖 CSS 响应式,scene.tsx直接用el.clientWidth<768判定。vite.config.ts:root:'./web',publicDir:'./public',插件react(),别名@→./,CSS 走 Tailwind v4 的 PostCSS 插件,构建输出./dist;server.watch.usePolling适配容器文件系统。vercel.json:framework:vite、buildCommand:npm run build、outputDirectory:dist、installCommand:npm ci,Vercel 一键部署。package.json:注意name:"anatomy-studio",engines.node>=22.13;依赖含vinext、@shadcn/react、@base-ui/react、three、meshoptimizer;devDeps 含@openai/sites-vite-plugin(WebMCP)、@cloudflare/vite-plugin+wrangler(可静态/CF 部署)、oxlint/oxfmt(校验与格式化)。tsconfig.json:strict:true、路径别名@/*、含vinext/types与 Cloudflare workers 类型,支持 RSC 与边缘运行时类型。
四、优点、不足与潜在应用领域
4.1 优点
- 极致的渲染性能设计:① 按系统
mergeGeometries→ 约 15 个 draw call 渲染 2234 件;② 位移/显隐/选中全部经DataTexture在 GPU 着色器内完成,CPU 每帧仅更新纹理;③ 法线量化 signed-16bit、索引 uint32 零拷贝视图、分 15 块懒加载 + 3 并发 + gzip,使 228 万三角形仅 ~33MB 下载即可流畅运行。 - 工程闭环完整:
convert → optimize → compress → validate全链路脚本,并配套validate-atlas(数据自洽)与validate-interactions(多宽高比布局不重叠、搜索/聚焦契约、PointerTap行为)双重校验,质量有保障。 - 交互打磨细致:两阶段爆炸动画(径向扇形 → 网格平铺)、
PointerTap精确区分点击/拖拽/多指/取消、桌面 hover 提示、爆炸态下 2D 投影回退拾取、相机setViewOffset让隔离件恰好避开详情面板——移动端/桌面都照顾到。 - AI 可调用且优雅降级:WebMCP 工具暴露给智能体,
registerAtlasTools在无document.modelContext时静默跳过,纯 UI 不受影响。 - 合规与诚实:明确标注 CC BY 4.0 署名、成人男性参考、教育非诊断;
ATTRIBUTION.md详述改编(坐标/单位/简化/量化)并保留源身份;女性参考作为历史资产单独说明,无隐瞒。 - 零后端、易部署:纯静态 Vite 产物,Vercel/Cloudflare/任意静态托管均可;无 API key、无账号。
4.2 不足
- 转换脚本强耦合作者本地数据:
convert-anatomy.py需要 BodyParts3D 官方 OBJ 归档 + 自制的概念/系统映射表(CONCEPT_MAP/SYSTEM_MAP),仓库未附带这些映射,他人难以复现几何重建(README 也说"可选")。optimize-anatomy.mjs里女性分支的焊接逻辑暗示历史上存在女性参考,但当前发布版已移除,重新引入需额外数据。 - 系统归类为"策展式"而非权威:
SYSTEMS配色与system归属是作者手工/半自动归类,BodyParts3D 本身未提供严格系统划分;个别结构(如"结缔组织""体被")边界主观,与临床系统分类未必一致。 - 3D 引擎命令式、维护成本高:
scene.tsx把渲染器、灯光、着色器注入、加载、拾取、动画、resize、隔离取景全部塞进一个 ~130 行的useEffect,副作用密集、耦合强,新人上手与单测都不易(仅脚本层有断言测试,浏览器交互仅靠手动验证)。 - 内部系统为示意/简化几何:为浏览器性能,每个部件用 0.2% 相对误差简化,且"体被(皮肤)"半透明仅作外层参考;细节(如神经分支、血管层级)远不及专业医学图谱,不能用于教学精确测量。
- 缺乏真实设备/多点触控测试:README 自承"物理设备性能与真实多点触控硬件未测试",仅做了 390×844、320×568、844×390 的浏览器检查。
- 性别与变异覆盖有限:仅成人男性参考,且"不涵盖所有人结构或变异";历史女性参考已下架,临床/跨性别/发育差异场景缺失。
4.3 潜在应用领域
| 领域 | 具体场景 |
|---|---|
| 医学教育 | 解剖学导论、自学平台、MOOC 交互组件;替代静态图谱,让学生"拆开看" |
| 3D 配置器 / 产品可视化 | 其"合并批次 + GPU 状态纹理 + 爆炸装箱"范式可直接迁移到汽车/机械/电子产品拆解展示 |
| 维修 / 培训仿真 | 设备爆炸视图 + 单件隔离 + 详情面板,用于售后培训、装配指引 |
| AI 智能体讲解 | WebMCP 工具使 ChatGPT 类智能体能"搜索结构并聚焦讲解",适合导览/无障碍助手 |
| WebGL 性能教学 | 作为"海量网格如何保持 60fps"的范例(DataTexture 状态驱动、Instanced/合并、量化法线) |
| 数据新闻 / 科普展览 | 嵌入文章或展厅大屏,做可交互人体科普 |
| 数字孪生 / 健康监测前端 | 作为人体参照底座,叠加器官状态、手术规划标记等(需自行补充精度与合规) |
五、总结
human-atlas 是 model-x-studio 作者把"交互式 3D 拆解"范式从汽车搬到人体解剖的成熟作品:以 BodyParts3D 4.0(CC BY 4.0,成人男性,2234 网格 / 3432 概念)为数据底座,用 Vite + React 19 + 原生 Three.js + shadcn(Base UI) + Tailwind v4 构建,通过"按系统合并几何 + GPU 状态纹理 + 货架式爆炸装箱 + 分块懒加载 + 0.2% 四边形简化"把 228 万三角形压到 ~33MB 并在浏览器流畅运行。其代码组织清晰(数据契约 / 布局算法 / 拾取判别 / 下载解压 / AI 工具 / 场景引擎 / 页面状态机 / 转换优化校验脚本各司其职),工程闭环与交互打磨都属上乘;主要短板在于几何重建对作者私有映射的依赖、系统归类的主观性、命令式 3D 引擎的可维护性,以及单一性别参考的覆盖局限。整体是一个高质量、可学习、可迁移的开源 3D 解剖教育范本。
解读基准:
main分支 commit1c38bf3(2026-09-06)。components/ui/*(70+ shadcn 生成组件)与app/globals.css(26 KB 设计系统)为脚手架/样式,未纳入逐行注释范围,仅在上文模块表中说明角色。

浙公网安备 33010602011771号