今日开源[第55期]model-x-studio源码解读
源码解读:model-x-studio
项目地址:https://github.com/ashemag/model-x-studio
解读日期:2026-09-10
项目性质:交互式 3D 特斯拉 Model X 拆解工作室(Web 应用 + Blender 转 glTF 资源流水线)
一、项目简介与作用
1.1 项目简介
model-x-studio 是一个面向浏览器、可交互的 3D 特斯拉 Model X 解剖/拆解工作室。它把一辆由第三方作者(cgi Moon,来自 BlendKit)制作的预改款 Model X 车模,转换为 Web 友好的 glTF(GLB)格式,并在网页里用 Three.js / React Three Fiber 渲染。应用提供一个圆形展示平台、按系统划分的部件说明面板、单件隔离(高亮+独显)能力,以及一条覆盖全部 334 个网格碎片(mesh pieces)的渐进式爆炸滑块(explode slider)。
项目定位是一份独立的教育性(educational)作品,并非特斯拉官方零件目录,也不与特斯拉有任何官方关联。README 与界面中都明确标注:334 片是"由艺术家手工拆分的网格岛屿(artist-authored mesh islands)",而非经过核实的特斯拉 OEM 维修零件编号;电池、电驱、悬架三套内部系统被显式标记为"示意性(illustrative)几何"。
1.2 项目作用
- 教学/科普:以可旋转、可拆解的方式直观展示一辆纯电 SUV 的外部、座舱、电池、电驱、悬架、车轮等系统及其相互关系。
- 交互演示:用户可拖拽旋转、滚轮/双指缩放、点选某个系统查看"它是什么 / 它怎么工作"两段说明,并用滑块把整车从"装配态"平滑过渡到"完全爆炸态"。
- 单件探究:在爆炸到一定程度后可显示每件碎片的编号标签,并可将某一件或某一系统单独隔离出来聚焦观察。
- AI 可调用:页面注册了一个
modelContext.registerTool的浏览器内工具(explore_vehicle_component),允许支持 WebMCP 的智能体直接"选中部件 + 设置爆炸程度 + 是否隔离",说明它也是为"智能体驱动的人车讲解"场景准备的。 - 资源工程示范:附带完整的 Blender→glTF 转换脚本与几何/布局校验脚本,展示如何把版权受限的商业车模合规地(Royalty Free、署名、不提供下载)嵌入到教育应用中。
1.3 技术栈
| 维度 | 选型 |
|---|---|
| 框架 | Next.js 风格架构(实际用 vinext,即 Vite + RSC 的 Next 兼容层),React 19 |
| 3D | three ^0.159 + 原生 WebGLRenderer(非 react-three-fiber,作者手写 R3F 风格的渲染循环) |
| 组件库 | shadcn(style: base-nova)基于 @base-ui/react + @shadcn/react 生成 |
| 样式 | Tailwind CSS v4 + 自定义 CSS 变量 + tw-animate-css |
| 构建 | 开发用 vinext;Vercel 用 vite build --config vite.vercel.config.ts;另含 Cloudflare/Vinext 产物 |
| 资源 | Blender bpy 脚本转 GLB;Node 端用 GLTFLoader 做几何校验 |
| 工具链 | oxlint + oxfmt(OpenJS 系),TypeScript 5.9 |
二、项目功能与模块分析
2.1 整体架构
┌──────────────────────────── 浏览器(客户端) ────────────────────────────┐
│ │
│ app/layout.tsx 根布局(<html lang=en class=dark>) │
│ app/page.tsx 主页面:UI 状态机 + 浮层面板 + 工具注册 │
│ app/vehicle-scene.tsx 3D 引擎:手写 Three.js 渲染循环、拾取、爆炸动画 │
│ app/parts.ts 部件目录数据 + 单件说明字典 │
│ app/explosion-layout.ts 爆炸布局算法(把334片铺成不重叠的网格) │
│ app/pointer-tap.ts 指针手势判定(区分点击/拖拽/缩放/多指) │
│ app/globals.css 设计系统(暗色玻璃拟态浮层、移动端安全区适配) │
│ │
│ components/ui/* shadcn 生成的 70+ 通用 UI 组件(Select/Slider/...) │
│ lib/utils.ts cn() 合并类名 hooks/use-mobile.ts 移动端断点 │
│ │
│ 静态资源:public/models/model-x.glb(17.5MB) + model-x-manifest.json │
└─────────────────────────────────────────────────────────────────────────────┘
▲ 资源来自 ▼ 构建产物送往
┌── Blender 资源流水线(scripts/) ──┐ ┌── 部署(Vercel / Cloudflare) ──┐
│ convert-model.py Blend→GLB+清单 │ │ vercel.json / vite*.config.ts │
│ validate-*.mjs 几何/布局/触控校验 │ │ vercel-app/ Vite 入口 │
└───────────────────────────────────┘ └─────────────────────────────────┘
2.2 模块清单与职责
| 模块 | 文件 | 职责 |
|---|---|---|
| 入口/布局 | app/layout.tsx |
定义 <html>、metadata、viewport,引入全局样式 |
| 主页面/状态机 | app/page.tsx |
所有 UI 状态(selected/explode/isolated 等)、浮层面板渲染、WebMCP 工具注册 |
| 3D 场景引擎 | app/vehicle-scene.tsx |
WebGL 渲染器、相机/OrbitControls、灯光与环境、车模加载与分组、爆炸动画、requestAnimationFrame 循环、射线拾取、HTML 标签投影 |
| 部件数据 | app/parts.ts |
8 大系统的元信息(名称/分类/描述/原理/参数/官方文档链接)+ describePiece() 单件说明字典 |
| 爆炸布局算法 | app/explosion-layout.ts |
计算 334 片在"全爆炸"状态下的目标位置(装箱铺排,保证不重叠且都在视野内) |
| 手势判定 | app/pointer-tap.ts |
区分"点击选择"与"拖拽/缩放/多指手势",避免误触 |
| 设计系统 | app/globals.css |
暗色主题变量、玻璃拟态浮层、响应式断点、手机安全区适配 |
| UI 组件库 | components/ui/* |
shadcn 生成的 70+ 基础组件(非作者业务代码,属脚手架) |
| 工具函数 | lib/utils.ts |
cn():clsx + tailwind-merge 合并 className |
| 移动端 Hook | hooks/use-mobile.ts |
useIsMobile() 监听 ≤767px 断点 |
| 资源转换 | scripts/convert-model.py |
用 Blender 把源 .blend 转成 GLB 并生成 model-x-manifest.json |
| 资源校验 | scripts/validate-explosion.mjs、validate-model.mjs、validate-touch.mjs |
校验 334 槽位不重叠、整车尺寸、触控手势 |
| 资源检视/渲染 | scripts/inspect-source.py、inspect-visibility.py、render-model.py |
调试用:导出网格清单、可见性、离线渲染预览图 |
| 构建/部署 | package.json、vercel.json、vite.config.ts、vite.vercel.config.ts、next.config.ts、tsconfig.json、.openai/hosting.json、.oxlintrc.json、.oxfmtrc.json |
依赖、脚本、RSC/Cloudflare/Vercel 配置、lint/format 配置 |
2.3 核心交互流程
page.tsx挂载<VehicleScene>,传入selected/explode/labels/autoRotate/isolated/focusedMesh。VehicleScene在useEffect中创建渲染器、场景、相机、灯光、展示台,并用GLTFLoader异步加载model-x.glb。- 加载完成后,遍历 GLB 中带
component标记的对象,按part挂到 8 个系统中;同时调用createExplosionLayout()计算每片爆炸目标位置,并为每片生成可点击的mesh-marker按钮。 - 每帧
frame():根据explode百分比用阻尼插值平滑过渡,更新每片位置、阴影、标签投影、隔离聚焦;仅在"有变化"时才renderer.render()(按需渲染,省电)。 - 用户拖动爆炸滑块 →
setExplode→latest.current.explode变化 →frame平滑过渡整车。 - 点击某片或某系统 →
onSelect/onInspect→page打开详情面板,可再"隔离"该件。
三、核心源码逐行解读(带注释)
说明:本项目的业务核心是
app/下的 6 个自定义文件与scripts/流水线。components/ui/*共 70+ 个文件为 shadcn 自动生成的基础组件脚手架(Select/Slider/Tabs/Tooltip 等),非作者手写业务逻辑,本文不逐一注释,仅在 2.2 表中说明其角色。
3.1 app/layout.tsx(根布局)
import type { Metadata } from 'next';
import './globals.css';
// 页面级 SEO 元数据:标题与描述
export const metadata: Metadata = { title: 'Model X — Anatomy of Electric', description: 'Take a closer look. An interactive, exploded 3D study of the systems inside Tesla Model X.' };
// viewport:移动端适配,viewport-fit=cover 用于刘海屏安全区
export const viewport = {width:'device-width',initialScale:1,viewportFit:'cover'};
// 根布局组件:只包一层 <html lang="en" class="dark">,dark 类驱动 CSS 变量暗色主题
export default function RootLayout({children}:{children:React.ReactNode}) { return <html lang="en" className="dark"><body>{children}</body></html> }
3.2 app/parts.ts(部件目录 + 单件说明)
// 8 大系统的标识联合类型
export type PartId = 'body'|'glass'|'doors'|'cabin'|'battery'|'drive'|'suspension'|'wheels';
// 特斯拉车主手册(Model X)基地址,用于每个系统的"官方文档"外链
const manual = 'https://www.tesla.com/ownersmanual/modelx/en_us/';
// 部件数组:每个元素含 id/name/category/tag/description/principle/specs/source
export const parts: {id:PartId;name:string;category:string;tag:string;description:string;principle:string;specs:[string,string][];source:string}[] = [
// body:车身与外部结构,双面溃缩区、前后储物
{id:'body',name:'Body & structure',category:'Exterior',tag:'THE PROTECTIVE SHELL',description:'The body brings the vehicle together: ...',principle:'The structure carries loads around the cabin, ...',specs:[['Role','Structure & protection'],['Location','Vehicle exterior']],source:manual+'GUID-7A32EC01-...html'},
// glass:全景玻璃(前风挡上扫至前排上方)
{id:'glass',name:'Panoramic glass',category:'Exterior',tag:'A WIDER VIEW',...},
// doors:鹰翼门(双铰链向上折叠)
{id:'doors',name:'Doors & falcon wings',category:'Exterior',tag:'ENGINEERING THAT OPENS UP',...},
// cabin:座舱(位于电池之上,多座椅布局)
{id:'cabin',name:'Passenger cabin',category:'Interior',tag:'BUILT AROUND PEOPLE',...},
// battery:高压电池(液冷锂电,位于座舱下)——标记为示意
{id:'battery',name:'High-voltage battery',category:'Energy',tag:'THE ENERGY FOUNDATION',...},
// drive:电驱单元(前+后双电机,单速减速器)——示意
{id:'drive',name:'Electric drive units',category:'Powertrain',tag:'ENERGY INTO MOTION',...},
// suspension:自适应空气悬架(前双叉臂+后多连杆)——示意
{id:'suspension',name:'Adaptive suspension',category:'Chassis',tag:'CONNECTED TO THE ROAD',...},
// wheels:车轮与制动(通风盘+ABS+能量回收)
{id:'wheels',name:'Wheels & brakes',category:'Chassis',tag:'WHERE MOTION MEETS GRIP',...},
];
// 单件说明函数:根据 manifest 里的 label 给出该碎片的科普解释
export function describePiece(label:string):string {
const name=label.split(' · ')[0]; // label 形如 "Hood · left",取第一部分作为键
const explanations:Record<string,string>={ // 已知碎片的解释字典(约 20 条)
'Hood':'The hood closes the front luggage compartment. ...',
'Front bumper':'The front bumper cover forms the nose of the vehicle ...',
'Falcon wing door panel':'This panel belongs to the rear door assembly. ...',
// ……(其余 Hood/Trim/Glazing/Lighting/Emblem/Mirror/Tire/Brake disc 等)
};
// 命中字典返回对应解释;否则返回通用兜底说明(强调"非核实的 OEM 零件")
return explanations[name]||'This is a separate piece of the source model. Its label describes the visible geometry; an exact Tesla service-part identity is not supplied.';
}
3.3 app/explosion-layout.ts(爆炸布局算法)
import * as THREE from 'three';
// 单片输入类型:id、所属 part、包围盒
export type ExplodablePiece = {id:string;part:string;bounds:THREE.Box3};
// 概览相机方向(归一化),由车身初始相机位推导
export const overviewDirection=new THREE.Vector3(-5.7,2.1,6.3).normalize();
// 以概览方向为基准构造"右/上"两个正交基,用于把 3D 包围盒投影到 2D 平面做装箱
const right=new THREE.Vector3().crossVectors(new THREE.Vector3(0,1,0),overviewDirection).normalize();
const up=new THREE.Vector3().crossVectors(overviewDirection,right).normalize();
export const layoutCenter=new THREE.Vector3(0,3,0); // 爆炸后整体中心(抬高到 y=3)
// 把每片按其包围盒在 right/up 平面上的投影,铺成互不重叠的卡片网格
export function createExplosionLayout(pieces:ExplodablePiece()) {
const cards=pieces.map(piece=>{
const {min,max}=piece.bounds;let left=Infinity,bottom=Infinity,rightEdge=-Infinity,top=-Infinity;
// 遍历包围盒 8 个角,投影到 right/up 平面,求该片在该平面的 2D 包围框
for(const x of [min.x,max.x])for(const y of [min.y,max.y])for(const z of [min.z,max.z]){
const corner=new THREE.Vector3(x,y,z),u=corner.dot(right),v=corner.dot(up);
left=Math.min(left,u);rightEdge=Math.max(rightEdge,u);bottom=Math.min(bottom,v);top=Math.max(top,v);
}
// 给每片留出最小尺寸与间距,避免相邻碎片贴太近;记录片中心
return {...piece,width:Math.max(.36,rightEdge-left)+.22,height:Math.max(.3,top-bottom)+.22,center:piece.bounds.getCenter(new THREE.Vector3())};
// 排序:先按系统分组,再按高度降序(大块先放),最后按 id——保证铺排稳定
}).sort((a,b)=>a.part.localeCompare(b.part)||b.height-a.height||a.id.localeCompare(b.id));
const area=cards.reduce((sum,c)=>sum+c.width*c.height,0); // 总面积
const width=Math.max(12,Math.sqrt(area*1.6)); // 估算画布宽(面积开方*系数)
let x=0,y=0,rowHeight=0;
// 行优先装箱:放不下就换行(典型 shelf packing)
const slots=cards.map(card=>{
if(x&&x+card.width>width){x=0;y+=rowHeight;rowHeight=0} // 换行
const slot={...card,x:x+card.width/2,y:y+card.height/2};x+=card.width;rowHeight=Math.max(rowHeight,card.height);return slot;
});
const height=y+rowHeight; // 总高
// 把每片的 2D 槽位换算回 3D 平移向量:translation = 槽位中心 - 该片原始中心
const result=new Map(slots.map(slot=>{
const center=layoutCenter.clone().addScaledVector(right,slot.x-width/2).addScaledVector(up,height/2-slot.y);
return [slot.id,{translation:center.clone().sub(slot.center),center,width:slot.width,height:slot.height,u:slot.x,v:slot.y}];
}));
return {pieces:result,width,height}; // 返回 {单片平移映射, 画布宽高}
}
要点:该算法并不在 3D 空间里"炸开",而是把每片投影到一个与相机视线垂直的平面,做 2D 不重叠铺排(shelf packing),再换算成 3D 平移。好处是保证全爆炸时 334 片都在相机视野内且互不遮挡,由 validate-explosion.mjs 严格校验。
3.4 app/pointer-tap.ts(指针手势判定)
/** 区分"点击"与"拖拽/缩放/平移/多指手势"。 */
export class PointerTap {
private active=new Map<number,{x:number;y:number;threshold:number}>(); // 每根手指的按下起点
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} // 手势被系统中断 → 非点击
}
要点:用"阈值 + 单指 + 未取消"三条件判定 tap,使旋转/缩放车模时不会误选部件。
3.5 app/vehicle-scene.tsx(3D 引擎,核心,约 193 行)
'use client';
import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react';
import * as THREE from 'three';
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
import {RoundedBoxGeometry} from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {RoomEnvironment} from 'three/examples/jsm/environments/RoomEnvironment.js';
import {createExplosionLayout,layoutCenter,overviewDirection} from './explosion-layout';
import {PointerTap} from './pointer-tap';
import {parts,type PartId} from './parts';
// 对父组件暴露的命令式句柄:放大/缩小/重置视角
export type SceneHandle={zoom:(factor:number)=>void;reset:()=>void};
type Props={focusedMesh:string;onInspect:(id:string)=>void;selected:PartId;explode:number;labels:boolean;autoRotate:boolean;isolated:boolean;onSelect:(id:PartId)=>void};
// 8 个系统的"装配态整体偏移":爆炸时整组往该方向移动(body 上移、battery 下移等)
const offsets:Record<PartId,[number,number,number]>={body:[0,.6,0],glass:[0,2,0],doors:[0,1.1,0],cabin:[0,.7,0],battery:[0,-.85,0],drive:[0,-.18,0],suspension:[0,.08,0],wheels:[0,0,0]};
// 8 个系统在屏幕上的标签锚点(HTML 标签投影的目标世界坐标)
const anchors:Record<PartId,[number,number,number]>={body:[-1.9,1.02,.2],glass:[.15,1.94,0],doors:[.25,1.44,1.05],cabin:[.05,1.05,-.4],battery:[.1,.24,1.02],drive:[-1.54,.57,.2],suspension:[1.57,.83,-.83],wheels:[1.53,.46,1.1]};
// 用 forwardRef 暴露命令式句柄;props 始终通过 latest.current 读取最新值(避免闭包陈旧)
const VehicleScene=forwardRef<SceneHandle,Props>(function VehicleScene(props,ref){
const host=useRef<HTMLDivElement>(null);const latest=useRef(props);latest.current=props;
const engine=useRef<{camera:THREE.PerspectiveCamera;controls:OrbitControls;reset:()=>void;interrupt:()=>void}|null>(null);
const [error,setError]=useState<string|null>(null);const [ready,setReady]=useState(false);
// 暴露给父组件的 zoom/reset:zoom 通过"相机位置相对 target 缩放"实现
useImperativeHandle(ref,()=>({zoom(f){const e=engine.current;if(e){e.interrupt();e.camera.position.sub(e.controls.target).multiplyScalar(f).add(e.controls.target)}},reset(){const e=engine.current;if(e){e.reset()}}}),[]);
useEffect(()=>{
setReady(false);setError(null);
const el=host.current!; let renderer:THREE.WebGLRenderer;
// 创建 WebGL 渲染器;失败则提示"浏览器无法启动 3D 视图"
try{renderer=new THREE.WebGLRenderer({antialias:true,alpha:true,powerPreference:'high-performance'})}catch{setError('Your browser could not start the 3D view.');return}
// 像素比:触屏更低(1.25)省电,桌面 1.5;透明清屏;ACES 色调映射
renderer.setPixelRatio(Math.min(window.devicePixelRatio,window.matchMedia('(pointer: coarse)').matches?1.25:1.5));
renderer.setClearColor(0x000000,0);renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=.95;
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false; // 阴影按需更新
el.appendChild(renderer.domElement);
// 场景:近黑背景 + 雾(远处淡出);透视相机 FOV=37
const scene=new THREE.Scene();scene.background=new THREE.Color('#050607');scene.fog=new THREE.Fog('#050607',16,55);
const camera=new THREE.PerspectiveCamera(37,1,.05,500);camera.position.set(-5.7,2.9,6.3);
// OrbitControls:阻尼、距离限制、极角限制、自动旋转速度
const controls=new OrbitControls(camera,renderer.domElement);controls.target.set(0,.8,0);controls.enableDamping=true;controls.dampingFactor=.065;controls.minDistance=5;controls.maxDistance=180;controls.maxPolarAngle=Math.PI*.49;controls.minPolarAngle=.18;controls.enablePan=true;controls.autoRotateSpeed=.65;
engine.current={camera,controls,reset:()=>{fitView(true);invalidated=true},interrupt:()=>{framingTime=0}};
// PMREM + RoomEnvironment 生成 IBL 环境贴图(金属/玻璃的反射来源)
const pmrem=new THREE.PMREMGenerator(renderer);const room=new RoomEnvironment();const env=pmrem.fromScene(room,.04);scene.environment=env.texture;
// 灯光:半球光(补天光) + 主方向光(投影) + 边缘光 + 暖色点光(地面辉光)
scene.add(new THREE.HemisphereLight(0xd8e9ff,0x444448,.8));
const key=new THREE.DirectionalLight(0xffffff,2.5);key.position.set(-4,8,4);scene.add(key);key.castShadow=true;key.shadow.mapSize.set(1024,1024);key.shadow.camera.left=-7;key.shadow.camera.right=7;key.shadow.camera.top=7;key.shadow.camera.bottom=-7;key.shadow.bias=-.001;
const rim=new THREE.DirectionalLight(0xa6c7eb,1.8);rim.position.set(3,4,-5);scene.add(rim);
const glow=new THREE.PointLight(0xe0c4a6,2,10);glow.position.set(1,0,4);scene.add(glow);
// 材质工厂:默认金属 .3 / 粗糙 .32;预置 dark/silver/orange 三种
const mat=(color:string,metal=.3,rough=.32)=>new THREE.MeshStandardMaterial({color,metalness:metal,roughness:rough});
const dark=mat('#13181d',.28,.36),silver=mat('#94a2ae',.85,.25),orange=mat('#f97645',.55,.32);
// 按 part 建 8 个 Group 并加入场景(车模加载后把对应节点 attach 进来)
const groups={} as Record<PartId,THREE.Group>;parts.forEach(p=>{const g=new THREE.Group();g.name=p.id;g.userData.part=p.id;groups[p.id]=g;scene.add(g)});
// 小工具:mesh/box(圆角盒)/cyl(圆柱,横置)/tube(管道) 向某 group 添加几何体
function mesh(g:THREE.Group,geometry:THREE.BufferGeometry,m:THREE.Material,pos:[number,number,number]=[0,0,0]){const o=new THREE.Mesh(geometry,m.clone());o.position.set(...pos);o.castShadow=true;o.receiveShadow=true;o.userData.part=g.userData.part;g.add(o);return o}
function box(g:THREE.Group,s:[number,number,number],p:[number,number,number],m:THREE.Material,r=.04){return mesh(g,new RoundedBoxGeometry(...s,3,r),m,p)}
function cyl(g:THREE.Group,r:number,len:number,p:[number,number,number],m:THREE.Material){const o=mesh(g,new THREE.CylinderGeometry(r,r,len,36),m,p);o.rotation.x=Math.PI/2;return o}
function tube(g:THREE.Group,points:THREE.Vector3[],r:number,m:THREE.Material){return mesh(g,new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points),24,r,8,false),m)}
// —— 示意性内部系统(程序化生成,非真实零件)——
// 电池壳 + 可见电芯模块 + 橙色高压母线
box(groups.battery,[2.95,.20,1.70],[0,.32,0],silver,.065);box(groups.battery,[2.8,.08,1.58],[0,.45,0],dark,.025);
for(let x=0;x<8;x++)for(let z=0;z<4;z++)box(groups.battery,[.30,.07,.33],[-1.22+x*.35,.51,-.585+z*.39],mat('#6d827e',.65,.38),.018);
[-1,1].forEach(s=>box(groups.battery,[2.8,.035,.035],[0,.56,s*.77],orange,.009));
// 双电机 + 半轴 + 带肋逆变器壳
[-1.56,1.56].forEach(x=>{cyl(groups.drive,.21,.73,[x,.59,0],silver);cyl(groups.drive,.095,1.82,[x,.55,0],dark);box(groups.drive,[.46,.17,.49],[x,.82,0],silver);for(let j=0;j<7;j++)box(groups.drive,[.022,.04,.43],[x-.19+j*.065,.922,0],dark,.004);tube(groups.drive,[new THREE.Vector3(x,.75,.4),new THREE.Vector3(x*.8,.57,.58),new THREE.Vector3(x*.77,.44,.66)],.026,orange)});
// 空气弹簧 + 悬架摆臂(对齐车轮)
[-1.55,1.57].forEach(x=>[-1,1].forEach(side=>{const strut=cyl(groups.suspension,.09,.41,[x,.78,side*.73],silver);strut.rotation.x=0;for(let j=0;j<5;j++){const ring=mesh(groups.suspension,new THREE.TorusGeometry(.11,.028,8,24),dark,[x,.68+j*.055,side*.73]);ring.rotation.x=Math.PI/2;}[-.18,.18].forEach(dx=>tube(groups.suspension,[new THREE.Vector3(x+dx,.48,side*.37),new THREE.Vector3(x,.47,side*.96)],.026,silver))}));
// —— 以下 exterior/wheels/interior 是创作者真实导入的网格 ——
const readyRef={current:false}; let cancelled=false;
// pieces:每片状态(节点/原位/本组扩散/part/id/包围盒/中心/全爆炸平移/材质数组)
const pieces:{node:THREE.Object3D;home:THREE.Vector3;spread:THREE.Vector3;part:PartId;id:string;bounds:THREE.Box3;center:THREE.Vector3;fullSpread:THREE.Vector3;materials:THREE.MeshStandardMaterial[]}[]=[];
let layout:ReturnType<typeof createExplosionLayout>|null=null;
const pieceLabels:{b:HTMLButtonElement;id:string;part:PartId;center:THREE.Vector3;spread:THREE.Vector3;fullSpread:THREE.Vector3}[]=[];
// 卸载时释放几何体/材质,避免显存泄漏
const disposeObject=(root:THREE.Object3D)=>root.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();(Array.isArray(o.material)?o.material:[o.material]).forEach(m=>m.dispose())}});
// 加载 GLB(?v=334 用于缓存失效控制)
new GLTFLoader().load('/models/model-x.glb?v=334',gltf=>{
if(cancelled){disposeObject(gltf.scene);return} // 组件已卸载则丢弃
Object.values(groups).forEach(g=>g.position.set(0,0,0));scene.updateMatrixWorld(true);
const model=gltf.scene;model.rotation.y=-Math.PI/2;scene.add(model);model.updateMatrixWorld(true); // 车模绕 Y 转 -90° 对齐坐标轴
const nodes:THREE.Object3D[]=[];model.traverse(o=>{if(o.userData.component)nodes.push(o)}); // 收集带 component 标记的对象
nodes.forEach(node=>{
const id=node.userData.part as PartId;if(!groups[id])return;
const component=node.userData.component as string;groups[id].attach(node); // 把节点从 model 移到对应 part 的 Group
const bounds=new THREE.Box3().setFromObject(node);const center=bounds.getCenter(new THREE.Vector3());
const side=Math.sign(center.z)||1; // 左右侧符号(z 正负)
// 本组"微扩散"方向:车身按 x 展开,车轮/门/玻璃按 z 左右展开
const spread=new THREE.Vector3(id==='body'?center.x*.17:0,0,id==='wheels'?side*.95:id==='doors'?side*.9:id==='glass'?side*.12:0);
pieces.push({node,home:node.position.clone(),spread,part:id,id:component,bounds,center,fullSpread:new THREE.Vector3(),materials:[]});
// 深拷贝材质(避免共享),并把透射玻璃改为不透明金属玻璃;记录基础自发光用于高亮
node.traverse(o=>{if(o instanceof THREE.Mesh){o.userData.part=id;o.userData.component=component;o.castShadow=true;o.receiveShadow=true;
const materials=Array.isArray(o.material)?o.material:[o.material];o.material=Array.isArray(o.material)?materials.map(m=>m.clone()):materials[0].clone();
(Array.isArray(o.material)?o.material:[o.material]).forEach(m=>{if(m instanceof THREE.MeshStandardMaterial){if(m instanceof THREE.MeshPhysicalMaterial&&m.transmission>0){m.transmission=0;m.metalness=.25;m.roughness=.18} // 玻璃剔除透射以保证性能/可视
pieces[pieces.length-1].materials.push(m);m.envMapIntensity=1.3;m.userData.baseEmission=m.emissive.clone();m.userData.baseIntensity=m.emissiveIntensity;}});
}});
});
layout=createExplosionLayout(pieces); // 计算全爆炸布局
pieces.forEach((piece,i)=>{
piece.fullSpread.copy(layout!.pieces.get(piece.id)!.translation); // 记录该片的整体平移
// 为每片创建 DOM 按钮(编号标记),点击 → onSelect + onInspect
const b=document.createElement('button');b.className='mesh-marker';b.textContent=String(i+1);b.title=piece.node.userData.label||'Modeled piece';b.setAttribute('aria-label',`Inspect piece ${i+1}: ${b.title}`);
b.addEventListener('click',()=>{latest.current.onSelect(piece.part);latest.current.onInspect(piece.id)});el.appendChild(b);
pieceLabels.push({b,id:piece.id,part:piece.part,center:piece.center,spread:piece.spread,fullSpread:piece.fullSpread});
});
const positions=new Float32Array(pieces.length*3);markerGeometry.setAttribute('position',new THREE.BufferAttribute(positions,3));
scene.remove(model);readyRef.current=true;setReady(true);fitView(true);invalidated=true;renderer.shadowMap.needsUpdate=true; // 加载完成
},undefined,()=>{if(!cancelled)setError('The detailed car could not load. Reload to try again.')}); // 失败回调
// 标记点云(全爆炸时出现的小圆点,用 Points 渲染,避免每片一个 DOM)
const markerGeometry=new THREE.BufferGeometry();
const markerMaterial=new THREE.PointsMaterial({color:0xf6bc99,size:4,sizeAttenuation:false,depthWrite:false,depthTest:false,transparent:true,opacity:.75});
const markers=new THREE.Points(markerGeometry,markerMaterial);markers.visible=false;markers.frustumCulled=false;markers.renderOrder=10;scene.add(markers);
// 展示台 + 地面 + 网格:低多边形舞台,无需额外渲染 pass
const stage=new THREE.Group();scene.add(stage);
const stageMaterial=new THREE.MeshStandardMaterial({color:0x454f5b,metalness:.35,roughness:.48,transparent:true});
const plinth=new THREE.Mesh(new THREE.CylinderGeometry(3.55,3.6,.13,96),stageMaterial);plinth.position.y=-.12;plinth.receiveShadow=true;stage.add(plinth); // 圆盘基座
const rimMaterial=new THREE.MeshStandardMaterial({color:0xa6b9c9,metalness:.7,roughness:.35,transparent:true});
for(const radius of [3.36,3.51]){const ring=new THREE.Mesh(new THREE.TorusGeometry(radius,.007,5,128),rimMaterial);ring.rotation.x=-Math.PI/2;ring.position.y=-.05;stage.add(ring)} // 两道金属圈
const ground=new THREE.Mesh(new THREE.PlaneGeometry(200,200),new THREE.MeshStandardMaterial({color:0x343d49,roughness:.85,metalness:.12}));ground.rotation.x=-Math.PI/2;ground.position.y=-.19;ground.receiveShadow=true;scene.add(ground);
const grid=new THREE.GridHelper(100,100,0x657182,0x566272);grid.position.y=-.185;(grid.material as THREE.Material).transparent=true;(grid.material as THREE.Material).opacity=.13;scene.add(grid); // 淡网格地面
// 8 个系统标签 DOM 按钮(hover/选中时显示名称)
const labelNodes=parts.map((p,i)=>{const b=document.createElement('button');b.className='scene-label';b.setAttribute('aria-label','Inspect '+p.name);b.innerHTML='<span>'+String(i+1).padStart(2,'0')+'</span><strong>'+p.name+'</strong>';b.addEventListener('click',()=>latest.current.onSelect(p.id));el.appendChild(b);return {b,id:p.id}});
let viewWidth=1,viewHeight=1;
// 尺寸自适应:监听容器尺寸,更新渲染器/相机;标记需重投影
const resize=()=>{const w=el.clientWidth,h=el.clientHeight;viewWidth=w;viewHeight=h;renderer.setSize(w,h);camera.aspect=w/h;camera.updateProjectionMatrix();if(readyRef.current){invalidated=true;framingTime=.8}};
const observer=new ResizeObserver(resize);observer.observe(el);resize();
// 射线拾取 + 指针手势
const taps=new PointerTap();const raycaster=new THREE.Raycaster();const pointer=new THREE.Vector2();
const onDown=(e:PointerEvent)=>{taps.down(e.pointerId,e.clientX,e.clientY,e.pointerType==='touch'?10:5)};
const onMove=(e:PointerEvent)=>{taps.move(e.pointerId,e.clientX,e.clientY)};
const onCancel=(e:PointerEvent)=>{taps.cancel(e.pointerId)};
const onUp=(e:PointerEvent)=>{
if(!taps.up(e.pointerId,e.clientX,e.clientY))return; // 非点击则交给 OrbitControls
const r=renderer.domElement.getBoundingClientRect();pointer.set((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1);raycaster.setFromCamera(pointer,camera);
// 先按"标记点云"命中(爆炸态下用屏幕距离最近判定,触屏阈值更大)
if(markers.visible){let nearest=-1,nearestDistance=e.pointerType==='touch'?324:64;pieces.forEach((piece,i)=>{vector.copy(piece.center).add(piece.node.position).sub(piece.home).add(groups[piece.part].position).project(camera);const dx=(vector.x-pointer.x)*viewWidth/2,dy=(vector.y-pointer.y)*viewHeight/2,d=dx*dx+dy*dy;if(vector.z<1&&d<nearestDistance){nearest=i;nearestDistance=d}});if(nearest>=0){latest.current.onSelect(pieces[nearest].part);latest.current.onInspect(pieces[nearest].id);return}}
// 否则用射线打整车轮廓,过滤不可见节点,命中取 userData.part/component
const hits=raycaster.intersectObjects(Object.values(groups),true).filter(h=>{let o:THREE.Object3D|null=h.object;while(o){if(!o.visible)return false;o=o.parent}return true});
if(hits[0]){latest.current.onSelect(hits[0].object.userData.part);latest.current.onInspect(hits[0].object.userData.component||'')}
};
renderer.domElement.addEventListener('pointerdown',onDown);renderer.domElement.addEventListener('pointermove',onMove);renderer.domElement.addEventListener('pointercancel',onCancel);renderer.domElement.addEventListener('pointerup',onUp);
const lost=(e:Event)=>{e.preventDefault();setError('The graphics connection was interrupted. Please reload the view.')};renderer.domElement.addEventListener('webglcontextlost',lost); // WebGL 上下文丢失提示
// 动画循环状态变量
let raf=0;let amount=latest.current.explode/100;const vector=new THREE.Vector3();let last=performance.now();
let focusKey='';let previousExplosion=latest.current.explode;let framingTime=0;
let invalidated=true,previousProps:Props|null=null,lastLabels=0,lastShadow=0;
let labelsPending=false;let lastHighlighted='';const cameraPosition=new THREE.Vector3(),cameraQuaternion=new THREE.Quaternion();
const homeTarget=new THREE.Vector3(0,.8,0),framingDirection=overviewDirection.clone();
// fitView:根据爆炸程度平滑重设相机 target/position;immediate 时直接跳到目标
function fitView(immediate=false,dt=1/60){
if(latest.current.isolated)return; // 隔离态由聚焦逻辑控制,不自动 fit
const f=THREE.MathUtils.smoothstep(immediate?latest.current.explode/100:amount,.4,1); // 爆炸>40% 才开始拉远
const target=homeTarget.clone().lerp(layoutCenter,f);
const tangent=Math.tan(THREE.MathUtils.degToRad(camera.fov/2));
const fullDistance=layout?Math.max(layout.height/(2*tangent),layout.width/(2*tangent*camera.aspect))*1.18+3:9; // 全爆炸所需距离
const assembledDistance=Math.max(10.5,7.5/camera.aspect);
const distance=THREE.MathUtils.lerp(assembledDistance,fullDistance,f);
const direction=immediate?overviewDirection:framingDirection.clone().lerp(overviewDirection,f).normalize();
const blend=immediate?1:1-Math.exp(-8*dt); // 指数平滑
controls.target.lerp(target,blend);camera.position.lerp(target.addScaledVector(direction,distance),blend);
}
const stopFraming=()=>{framingTime=0};controls.addEventListener('start',stopFraming); // 用户手动操作则停止自动取景
const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches; // 尊重"减少动效"偏好
// —— 每帧渲染 ——
function frame(now:number){raf=requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.05);last=now;if(document.hidden)return; // 后台标签页不渲染
const p=latest.current,propsChanged=!previousProps||p.selected!==previousProps.selected||p.focusedMesh!==previousProps.focusedMesh||p.isolated!==previousProps.isolated||p.labels!==previousProps.labels||p.autoRotate!==previousProps.autoRotate;
if(p.explode!==previousExplosion){previousExplosion=p.explode;framingTime=1.5;framingDirection.copy(camera.position).sub(controls.target).normalize()} // 爆炸值变化→启动 1.5s 自动取景
const oldAmount=amount;amount=reduced?p.explode/100:THREE.MathUtils.damp(amount,p.explode/100,7,dt);if(Math.abs(amount-p.explode/100)<.0001)amount=p.explode/100; // 阻尼插值到目标爆炸度
const moving=oldAmount!==amount,geometryChanged=moving||invalidated||propsChanged;
const individual=THREE.MathUtils.smoothstep(amount,.4,1); // "单片铺开"系数(爆炸>40% 后启用)
if(scene.fog instanceof THREE.Fog){scene.fog.near=16+individual*384;scene.fog.far=55+individual*445;} // 爆炸时雾推远,整体淡出
framingTime=Math.max(0,framingTime-dt);if(framingTime>0)fitView(false,dt);
controls.autoRotate=p.autoRotate&&!reduced;controls.update(); // OrbitControls 更新(阻尼/自动旋转)
const cameraChanged=camera.position.distanceToSquared(cameraPosition)>1e-10||1-Math.abs(camera.quaternion.dot(cameraQuaternion))>1e-10;
if(!geometryChanged&&!cameraChanged&&!(labelsPending&&now-lastLabels>50))return; // 啥都没变→跳过本帧(按需渲染)
labelsPending=true;
if(geometryChanged){
// 地面/网格随爆炸下沉、淡出;展示台仅在装配态可见
ground.position.y=-.19-.85*amount-individual*(layout?.height||0)*.6;grid.position.y=ground.position.y+.005;
stage.visible=amount<.18&&!p.isolated;stageMaterial.opacity=1-THREE.MathUtils.smoothstep(amount,.02,.18);rimMaterial.opacity=stageMaterial.opacity;
renderer.shadowMap.enabled=individual<.05&&!p.isolated;ground.visible=individual<.2&&!p.isolated;grid.visible=individual<.2&&!p.isolated;
// 8 个系统整组按 offsets 偏移并控制可见性(隔离时仅显 selected)
parts.forEach(({id})=>{const g=groups[id],o=offsets[id];g.position.set(o[0]*amount*(1-individual),o[1]*amount*(1-individual),o[2]*amount*(1-individual));g.visible=!p.isolated||p.selected===id;
if(['battery','drive','suspension'].includes(id))g.visible=g.visible&&(amount>.08||p.isolated)&&(individual<.98||p.isolated);
});
// 每片位置 = 原位 + 本组微扩散*amount + 整体铺开*fullSpread*individual;可见性按隔离/聚焦
const positions=markerGeometry.getAttribute('position') as THREE.BufferAttribute|undefined;
pieces.forEach((piece,i)=>{
piece.node.position.copy(piece.home).addScaledVector(piece.spread,amount*(1-individual)).addScaledVector(piece.fullSpread,individual);
piece.node.visible=!p.isolated||!p.focusedMesh||p.focusedMesh===piece.id;
if(positions){vector.copy(piece.center).add(piece.node.position).sub(piece.home).add(groups[piece.part].position);positions.setXYZ(i,vector.x,vector.y,vector.z)} // 更新标记点云坐标
});
if(positions)positions.needsUpdate=true;
markers.visible=individual>.45&&!p.isolated&&!p.labels; // 标记点云在全爆炸+显示标签关闭时可见
markerMaterial.opacity=THREE.MathUtils.smoothstep(individual,.45,.9)*.75;
// 高亮:聚焦片自发光设为橙红;其余恢复基础自发光
if(p.focusedMesh!==lastHighlighted||invalidated){
for(const piece of pieces)if(piece.id===lastHighlighted||piece.id===p.focusedMesh){
for(const m of piece.materials){m.emissive.copy(m.userData.baseEmission);m.emissiveIntensity=m.userData.baseIntensity;if(piece.id===p.focusedMesh){m.emissive.set('#c1532f');m.emissiveIntensity=.23}}
}
lastHighlighted=p.focusedMesh;
}
if(renderer.shadowMap.enabled&&(!moving||now-lastShadow>80)){renderer.shadowMap.needsUpdate=true;lastShadow=now} // 阴影限频更新
}
// 隔离态聚焦:把相机 target 移到目标包围盒中心,并按尺寸拉近距离
const nextFocus=p.isolated?(p.focusedMesh||p.selected):'';
if(readyRef.current&&nextFocus!==focusKey){
focusKey=nextFocus;
if(nextFocus){
const target=p.focusedMesh?pieces.find(x=>x.id===p.focusedMesh)?.node:groups[p.selected];
if(target){scene.updateMatrixWorld(true);const bounds=new THREE.Box3().setFromObject(target);const center=bounds.getCenter(new THREE.Vector3());const extent=bounds.getSize(new THREE.Vector3()).length();const direction=camera.position.clone().sub(controls.target).normalize();controls.minDistance=.15;controls.target.copy(center);camera.position.copy(center).addScaledVector(direction,Math.max(.4,extent*1.8));}
}else{controls.minDistance=.15;fitView(true)}
controls.update();
}
if(p.isolated&&readyRef.current&&geometryChanged){ // 隔离+几何变化时保持目标居中
const target=p.focusedMesh?pieces.find(x=>x.id===p.focusedMesh)?.node:groups[p.selected];
if(target){scene.updateMatrixWorld(true);const center=new THREE.Box3().setFromObject(target).getCenter(new THREE.Vector3());const movement=center.clone().sub(controls.target);camera.position.add(movement);controls.target.copy(center);controls.update()}
}
// 仅更新 transform 的标签投影(避免每帧大量 layout 写);~50ms 节流
if(now-lastLabels>50||propsChanged||invalidated){
lastLabels=now;labelsPending=false;
labelNodes.forEach(({b,id})=>{const show=individual<.5&&readyRef.current&&p.labels&&(!['battery','drive','suspension'].includes(id)||amount>.08||p.isolated)&&(!p.isolated||p.selected===id);
if(b.hidden===show)b.hidden=!show;if(!show)return;
const a=anchors[id];vector.set(...a).add(groups[id].position).project(camera);b.style.display=vector.z<1?'flex':'none';b.classList.toggle('chosen',id===p.selected);
b.style.transform=`translate3d(${(vector.x*.5+.5)*viewWidth}px,${(-vector.y*.5+.5)*viewHeight}px,0) translate(-12px,-50%)`; // 屏幕投影定位
});
pieceLabels.forEach(({b,id,part,center,spread,fullSpread})=>{ // 单件编号标签
const show=individual>.45&&(p.labels||id===p.focusedMesh)&&(!p.isolated||p.selected===part)&&(!p.isolated||!p.focusedMesh||p.focusedMesh===id);
if(b.hidden===show)b.hidden=!show;if(!show)return;
vector.copy(center).addScaledVector(spread,amount*(1-individual)).addScaledVector(fullSpread,individual).add(groups[part].position).project(camera);
b.style.display=vector.z<1&&Math.abs(vector.x)<1&&Math.abs(vector.y)<1?'grid':'none';b.classList.toggle('chosen',p.focusedMesh===id);b.classList.add('numbered');
b.style.transform=`translate3d(${(vector.x*.5+.5)*viewWidth}px,${(-vector.y*.5+.5)*viewHeight}px,0) translate(-50%,-50%)`;
});
}
cameraPosition.copy(camera.position);cameraQuaternion.copy(camera.quaternion);previousProps=p;invalidated=false;
renderer.render(scene,camera); // 真正绘制
}raf=requestAnimationFrame(frame);
// 卸载清理:取消 RAF、断开 observer、移除监听、释放所有几何体/材质/环境贴图/渲染器
return()=>{cancelled=true;cancelAnimationFrame(raf);observer.disconnect();controls.removeEventListener('start',stopFraming);controls.dispose();markerGeometry.dispose();markerMaterial.dispose();engine.current=null;labelNodes.forEach(x=>x.b.remove());pieceLabels.forEach(x=>x.b.remove());renderer.domElement.removeEventListener('pointerdown',onDown);renderer.domElement.removeEventListener('pointermove',onMove);renderer.domElement.removeEventListener('pointercancel',onCancel);renderer.domElement.removeEventListener('pointerup',onUp);renderer.domElement.removeEventListener('webglcontextlost',lost);scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();const ms=Array.isArray(o.material)?o.material:[o.material];ms.forEach(m=>m.dispose())}});env.dispose();pmrem.dispose();room.dispose();renderer.dispose();renderer.domElement.remove();};
},[]);
// 仅渲染挂载点 + loading/error 覆盖层(错误时提供"Reload"按钮)
return <><div ref={host} className="canvas-host" aria-label="Rotatable exploded 3D vehicle model"/>{!ready&&!error&&<div className="scene-loading"><span/>Loading the detailed Model X…</div>}{error&&<div className="scene-error"><h3>The 3D view needs a moment.</h3><p>{error}</p><button onClick={()=>location.reload()}>Reload the view</button></div>}</>;
});
export default VehicleScene;
3.6 app/page.tsx(主页面/状态机,约 80 行)
'use client';
import {flushSync} from 'react-dom'; // 同步刷新(工具调用需立即更新 UI)
import {useState, useRef, useEffect} from 'react';
import {...} from 'lucide-react'; // 图标
import {Select,...} from '@/components/ui/select'; // shadcn 组件
import {Slider} from '@/components/ui/slider';
import {Switch} from '@/components/ui/switch';
import {Tabs,TabsList,TabsTrigger} from '@/components/ui/tabs';
import {parts,describePiece, type PartId} from './parts';
import VehicleScene, {type SceneHandle} from './vehicle-scene';
export default function Home(){
const [selected,setSelected]=useState<PartId>('body'); // 当前选中系统
const [canFullscreen,setCanFullscreen]=useState(false); // 浏览器是否支持全屏
const [compact,setCompact]=useState(false);const [toolsOpen,setToolsOpen]=useState(false); // 移动端:紧凑/工具展开
const [componentsOpen,setComponentsOpen]=useState(false);const [detailOpen,setDetailOpen]=useState(false); // 部件列表/详情面板开关
const [explode,setExplode]=useState(0); const [labels,setLabels]=useState(false); const [rotate,setRotate]=useState(false); const [isolated,setIsolated]=useState(false); const [help,setHelp]=useState(false);
// 响应式:根据媒体查询判断 compact(小屏竖屏)并默认开/关各面板
useEffect(()=>{setCanFullscreen(Boolean(document.fullscreenEnabled));const query=window.matchMedia('(max-width: 700px), (max-height: 500px)');const update=()=>{setCompact(query.matches);setComponentsOpen(!query.matches);setToolsOpen(false)};update();query.addEventListener('change',update);return()=>query.removeEventListener('change',update)},[]);
const [focusedMesh,setFocusedMesh]=useState(''); // 聚焦的单片 id
const [catalog,setCatalog]=useState<{id:string;part:PartId;label:string}[]>([]); // 334 片清单(异步拉 manifest)
useEffect(()=>{fetch('/models/model-x-manifest.json').then(r=>r.json()).then(m=>setCatalog((m as {objects:{id:string;part:PartId;label:string}[]}).objects)).catch(()=>{});},[]);
const [tab,setTab]=useState('overview'); const scene=useRef<SceneHandle|null>(null); const root=useRef<HTMLDivElement>(null);
// WebMCP 工具注册:让智能体能"选中部件/设爆炸度/隔离"
useEffect(()=>{
const context=(document as Document & {modelContext?:{registerTool:(tool:unknown,options:{signal:AbortSignal})=>unknown}}).modelContext;
if(!context?.registerTool)return; // 不支持则跳过(feature-detect)
const lifecycle=new AbortController();
try { Promise.resolve(context.registerTool({name:'explore_vehicle_component',description:'Select a Model X component, set its exploded view and optionally isolate it in the 3D study.',inputSchema:{type:'object',properties:{component:{type:'string',enum:parts.map(p=>p.id)},explosion:{type:'number',minimum:0,maximum:100},isolate:{type:'boolean'}},required:['component'],additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute(input:unknown){
const v=input as {component:PartId;explosion?:number;isolate?:boolean};
// 入参校验:component 必须合法,explosion∈[0,100],isolate 为布尔;否则抛错
if(!v||!parts.some(p=>p.id===v.component)||(v.explosion!==undefined&&(typeof v.explosion!=='number'||!Number.isFinite(v.explosion)||v.explosion<0||v.explosion>100))||(v.isolate!==undefined&&typeof v.isolate!=='boolean'))throw new Error('Choose a valid component and an explosion value between 0 and 100.');
flushSync(()=>{setSelected(v.component);setFocusedMesh('');setDetailOpen(true);setTab('overview');if(window.matchMedia('(max-width: 700px), (max-height: 500px)').matches){setComponentsOpen(false);setHelp(false)}if(v.explosion!==undefined)setExplode(v.explosion);if(v.isolate!==undefined)setIsolated(v.isolate)}); // 同步提交状态
return {component:v.component,description:parts.find(p=>p.id===v.component)!.description};
}},{signal:lifecycle.signal})).catch(()=>{}); }catch{}
return()=>lifecycle.abort();
},[]);
const part=parts.find(p=>p.id===selected)!; const piece=catalog.find(p=>p.id===focusedMesh); // 当前系统/聚焦片
// 选中某系统:清聚焦、开详情、紧凑屏收起其他面板
function select(id:PartId){setFocusedMesh('');setSelected(id);setTab('overview');setDetailOpen(true);if(compact){setComponentsOpen(false);setHelp(false);setToolsOpen(false)}}
function toggleComponents(){setComponentsOpen(!componentsOpen);if(compact){setDetailOpen(false);setHelp(false);setToolsOpen(false)}}
function toggleHelp(){setHelp(!help);if(compact){setComponentsOpen(false);setDetailOpen(false);setToolsOpen(false)}}
return <main className="studio" ref={root}>
<section className="stage-view" aria-label="Interactive Model X studio">
{/* 3D 场景:传入全部交互状态与命令式 ref */}
<VehicleScene focusedMesh={focusedMesh} onInspect={setFocusedMesh} ref={scene} selected={selected} explode={explode} labels={labels} autoRotate={rotate} isolated={isolated} onSelect={select}/>
</section>
<div className="model-plaque"><span>T E S L A</span><h1>MODEL X</h1></div> {/* 左上角铭牌 */}
{/* 部件列表浮层 */}
{componentsOpen&&<aside className="components-panel floating-panel" aria-label="Components">
<div className="panel-heading"><h2>Components</h2><button className="icon-button" onClick={()=>setComponentsOpen(false)} aria-label="Hide components"><X size={14}/></button></div>
<div className="parts-list">{parts.map((p,i)=><button key={p.id} onClick={()=>select(p.id)} className={'part-row '+(p.id===selected&&detailOpen?'selected':'')} aria-pressed={p.id===selected&&detailOpen}><span className="part-number">{String(i+1).padStart(2,'0')}</span><span>{p.name}</span><ChevronRight size={13}/></button>)}</div>
</aside>}
{/* 右侧工具条:部件/缩放/重置/旋转/全屏/关于 */}
<nav className="view-tools floating-panel" data-expanded={toolsOpen} aria-label="View controls">
<button className={'tools-components '+(componentsOpen?'active':'')} title="Components" onClick={toggleComponents} aria-label="Toggle components" aria-pressed={componentsOpen}><Layers3 size={18}/></button>
<span/>
<button className="tools-extra" title="Zoom in" onClick={()=>scene.current?.zoom(.85)} aria-label="Zoom in"><Plus size={18}/></button> {/* 放大:factor<1 拉近 */}
<button className="tools-extra" title="Zoom out" onClick={()=>scene.current?.zoom(1.18)} aria-label="Zoom out"><Minus size={18}/></button>
<button className="tools-reset" title="Reset view" onClick={()=>{setRotate(false);scene.current?.reset()}} aria-label="Reset view"><RotateCcw size={17}/></button>
<button className={'tools-extra '+(rotate?'active':'')} title="Auto rotate" onClick={()=>setRotate(!rotate)} aria-label="Toggle auto rotation" aria-pressed={rotate}><Rotate3d size={18}/></button>
<span/>
{canFullscreen&&<button className="tools-extra" title="Fullscreen" onClick={()=>{if(document.fullscreenElement)document.exitFullscreen();else root.current?.requestFullscreen?.()}} aria-label="Toggle fullscreen"><Maximize2 size={17}/></button>}
<button className="tools-extra" title="About this model" onClick={toggleHelp} aria-label="About this model" aria-expanded={help}><CircleHelp size={17}/></button>
<button className="tools-more" title="More view controls" onClick={()=>setToolsOpen(!toolsOpen)} aria-label="More view controls" aria-expanded={toolsOpen}><MoreHorizontal size={20}/></button>
</nav>}
{/* 详情面板:概述/原理两 Tab + 参数表 + 单件选择 + 隔离按钮 + 官方文档链接 */}
{detailOpen&&<aside className="detail-panel floating-panel" aria-label="Component details">
<div className="panel-heading"><span>{part.category}{['battery','drive','suspension'].includes(selected)&&<span className="illustrative-badge">Illustrative</span>}</span><button className="icon-button" onClick={()=>setDetailOpen(false)} aria-label="Close details"><X size={16}/></button></div>
<div className="detail" aria-live="polite">
<h2>{piece?piece.label:part.name}</h2>
<Tabs value={tab} onValueChange={v=>setTab(String(v))}><TabsList variant="line" className="detail-tabs"><TabsTrigger value="overview">Overview</TabsTrigger><TabsTrigger value="working">How it works</TabsTrigger></TabsList></Tabs>
<p className="detail-copy">{piece&&tab==='overview'?describePiece(piece.label):tab==='overview'?part.description:part.principle}</p> {/* 单件优先用 describePiece;否则系统用 description/principle */}
<dl className="specs">{part.specs.map(([a,b])=><div key={a}><dt>{a}</dt><dd>{b}</dd></div>)}</dl>
{catalog.some(p=>p.part===selected)&&<div className="piece-picker"><span>Individual pieces</span><Select value={focusedMesh||'all'} onValueChange={value=>setFocusedMesh(value==='all'?'':String(value))}><SelectTrigger aria-label="Choose an individual mesh piece"><SelectValue>{piece?piece.label:`All ${catalog.filter(p=>p.part===selected).length} pieces`}</SelectValue></SelectTrigger><SelectContent alignItemWithTrigger={false}>{[{id:'all',label:'All pieces in this system'},...catalog.filter(p=>p.part===selected)].map((p,i)=><SelectItem key={p.id} value={p.id}>{i?`${String(i).padStart(2,'0')} · `:''}{p.label}</SelectItem>)}</SelectContent></Select></div>}
<button className={'isolate-button '+(isolated?'is-active':'')} onClick={()=>setIsolated(!isolated)}>{isolated?<Layers3 size={15}/>:<Crosshair size={15}/>} {isolated?'Show everything':focusedMesh?'Isolate piece':'Isolate component'}</button>
<a className="source-link" href={part.source} target="_blank" rel="noreferrer">Tesla documentation <ArrowUpRight size={12}/></a>
</div>
</aside>}
{/* 底部爆炸控制坞:组装/滑块/全拆/标签开关 */}
<div className="explode-dock floating-panel" aria-label="Assembly controls">
<button className={'assembly-button '+(explode===0?'active':'')} title="Assemble" onClick={()=>{setExplode(0);setIsolated(false)}} aria-label="Assemble vehicle"><Box size={18}/><span>Assemble</span></button>
<div className="explode-control"><div className="slider-caption"><label id="explode-label">Explode</label><output>{explode===100?`${catalog.length} pieces`:`${explode}%`}</output></div><Slider aria-labelledby="explode-label" value={[explode]} onValueChange={v=>setExplode(Array.isArray(v)?v[0]:v)} min={0} max={100}/></div>
<button className={'assembly-button '+(explode===100?'active':'')} title="Separate all pieces" onClick={()=>{setExplode(100);setIsolated(false)}} aria-label="Separate all pieces"><Expand size={18}/><span>All parts</span></button>
<div className="dock-divider"/><label className="labels-toggle"><Switch checked={labels} onCheckedChange={setLabels} aria-label="Show labels"/><span>Labels</span></label>
</div>}
{/* 关于面板:操作提示 + 版权/限制声明 */}
{help&&<aside className="about-panel floating-panel" aria-label="About this model"><div className="panel-heading"><h2>About the model</h2><button className="icon-button" onClick={()=>setHelp(false)} aria-label="Close model information"><X size={15}/></button></div><p>Drag to orbit. Pinch or scroll to zoom. Select a component for details; use the slider to separate the car.</p><p>Pre-refresh Model X by <a href="https://www.blendkit.com/asset-gallery-detail/983e8f94-5a56-44a4-94d9-eed5e4cdcd6c/" target="_blank" rel="noreferrer">cgi Moon</a>. The {catalog.length||334} mesh pieces are modeled geometry, not a complete Tesla parts catalog. Battery, drive and suspension are illustrative. Not affiliated with Tesla.</p></aside>}
</main>
}
3.7 scripts/convert-model.py(Blender 资源转换,关键脚本)
import sys, json, os
sys.path.insert(0,'/private/tmp/model-x-bpy') # 注入含 bpy 的虚拟环境路径
import bpy
from mathutils import Vector
bpy.ops.wm.open_mainfile(filepath='/private/tmp/tesla-model-x-cgimoon.blend',use_scripts=False) # 打开源工程(禁用脚本执行,安全)
bpy.context.view_layer.update()
worlds={o.name:o.matrix_world.copy() for o in bpy.data.objects} # 备份世界矩阵(保留父子关系)
for o in bpy.data.objects:
o.parent=None;o.matrix_world=worlds[o.name] # 暂时解除父子,保留世界变换
bpy.context.view_layer.update()
for o in list(bpy.data.objects):
if o.type!='MESH' or not len(o.data.polygons): continue # 跳过空/非网格
for mod in list(o.modifiers):
if mod.type=='SUBSURF': mod.levels=2 if o.name in ['Plane.028','Cube.004'] else 1;mod.render_levels=mod.levels # 细分级别
if mod.type=='NODES': o.modifiers.remove(mod) # 移除几何节点修饰(不可烘焙)
# 把源材质替换为可导出的 Principled BSDF(源用 Cycles 自定义节点组,浏览器跑不了)
for m in bpy.data.materials:
name=m.name.lower(); m.use_nodes=True; m.node_tree.nodes.clear()
out=m.node_tree.nodes.new('ShaderNodeOutputMaterial');p=m.node_tree.nodes.new('ShaderNodeBsdfPrincipled');m.node_tree.links.new(p.outputs['BSDF'],out.inputs['Surface'])
color=(.025,.03,.037,1);metal=.0;rough=.4
# 按材质名推断颜色/金属度/粗糙度(车漆/铬/玻璃/灯/内饰/胎/红尾灯/黄/白发光…)
if 'carpaint' in name: color=(.22,.29,.38,1);metal=.55;rough=.29;p.inputs['Coat Weight'].default_value=.7;p.inputs['Coat Roughness'].default_value=.16
elif any(s in name for s in ['chrome','rims','disk','nuts','logo','mirror','border plate']): color=(.55,.59,.64,1);metal=.92;rough=.2
elif name.startswith('windows'): color=(.012,.022,.035,1);metal=.35;rough=.105
elif 'headlights' in name or 'frontbumperlightsshield' in name: color=(.3,.4,.5,1);rough=.15;p.inputs['Transmission Weight'].default_value=.8
elif 'interior' in name: color=(.15,.13,.115,1);rough=.7
elif 'tyre' in name: color=(.016,.018,.021,1);rough=.72
elif 'redback' in name or 'backlightsshield' in name: color=(.36,.009,.013,1);rough=.22
elif 'yellow' in name:color=(.8,.22,.01,1)
elif 'white' in name: color=(.8,.88,1,1);p.inputs['Emission Color'].default_value=(.65,.8,1,1);p.inputs['Emission Strength'].default_value=1.1
p.inputs['Base Color'].default_value=color;p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
# 先求修饰后几何,再按"松散块(LOOSE)"拆分真实断开的源几何
bpy.context.view_layer.update()
deps=bpy.context.evaluated_depsgraph_get()
snapshots={o.name:bpy.data.meshes.new_from_object(o.evaluated_get(deps),depsgraph=deps) for o in bpy.context.scene.objects if o.type=='MESH'}
for o in list(bpy.context.scene.objects):
if o.type!='MESH' or not len(o.data.polygons):continue
name=o.name; data=snapshots[name]
o.modifiers.clear();o.data=data
bpy.context.view_layer.objects.active=o;o.select_set(True)
for other in bpy.context.selected_objects:
if other!=o:other.select_set(False)
bpy.ops.object.transform_apply(location=True,rotation=True,scale=True) # 应用变换
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.separate(type='LOOSE');bpy.ops.object.mode_set(mode='OBJECT') # 按松散几何拆成多块
for piece in bpy.context.selected_objects:piece['source_object']=name # 记录来源对象名
bpy.ops.object.select_all(action='DESELECT')
for o in list(bpy.data.objects):
if o.type!='MESH' or not len(o.data.polygons):bpy.data.objects.remove(o,do_unlink=True) # 清掉无面对象
bpy.context.view_layer.update()
entries=[]
for i,o in enumerate(list(bpy.context.scene.objects)):
if o.type!='MESH' or not len(o.data.polygons):continue
pts=[o.matrix_world@Vector(v) for v in o.bound_box];lo=Vector([min(p[a] for p in pts) for a in range(3)]);hi=Vector([max(p[a] for p in pts) for a in range(3)]);c=(lo+hi)/2;size=hi-lo # 世界包围盒
used={p.material_index for p in o.data.polygons};mats=' '.join(m.name.lower() for n,m in enumerate(o.data.materials) if m and n in used);source=o.get('source_object',o.name)
group='body';label='Exterior detail'
# 按材质/来源/位置启发式分类到 8 大系统并命名(车轮/座舱/玻璃/车身各部位/灯/标/镜/铬/饰条…)
if any(s in mats for s in ['tyre','rims','tiresnuts','disk']):group='wheels',label='Wheel and brake detail'
elif source=='interiors':group='cabin';label='Interior trim and seating'
elif 'windows' in mats and 'carpaint' not in mats and 'border' not in mats:group='glass';label='Glazing'
elif source=='Plane.028':
if 'windows.' in mats or mats=='windows':group='glass';label='Windshield' if c.y<0 and abs(c.x)<.2 else 'Rear glass' if c.y>1.5 else 'Side or roof glass'
elif abs(c.x)>.5 and -.95<c.y<1.4 and size.y>.35 and size.z>.3:group='doors';label='Front door panel' if c.y<.3 else 'Falcon wing door panel'
elif c.y<-1.3 and size.x>1 and c.z>.75:label='Hood'
elif c.y<-1.8 and size.x>1:label='Front bumper'
elif c.y>1.8 and size.x>1:label='Liftgate panel' if c.z>.75 else 'Rear bumper'
elif abs(c.x)>.7 and size.z>.4:label='Fender / quarter panel'
else:label='Body trim and seals'
elif any(s in mats for s in ['lights','headlight']):label='Rear lighting element' if c.y>0 else 'Front lighting element'
elif 'logo' in mats:label='Tesla emblem'
elif 'mirror' in mats:label='Side mirror'
elif 'chrome' in mats:label='Chrome trim'
elif 'blackplastic' in mats:label='Lower trim and liner'
if group=='wheels':
label='Tire and tread' if 'tyre' in mats else 'Brake disc' if 'disk' in mats else 'Wheel fastener' if 'nuts' in mats else 'Alloy wheel detail'
if abs(c.x)>.3:label+=(' · left' if c.x>0 else ' · right') # 左右
if group=='wheels':label+=(' · front' if c.y<0 else ' · rear') # 前后
o.name=f'{group}_{i:04d}';o['part']=group;o['label']=label;o['component']=o.name # 写入 GLB 自定义属性
entries.append({'id':o.name,'part':group,'label':label,'source':source,'center':list(c),'size':list(size),'faces':len(o.data.polygons)})
os.makedirs('public/models',exist_ok=True)
bpy.ops.export_scene.gltf(filepath=os.path.abspath('public/models/model-x.glb'),export_format='GLB',export_extras=True,export_cameras=False,export_lights=False,export_yup=True) # 导出 GLB(含 extras)
open('public/models/model-x-manifest.json','w').write(json.dumps({'creator':'cgi Moon','modelYear':'Pre-refresh; exact year unverified','source':'https://www.blendkit.com/...','objects':entries},indent=2)) # 写清单
print('EXPORTED',len(entries),'pieces',sum(e['faces'] for e in entries),'faces')
print('BOUNDS',[(o.name,[round(v,2) for v in o.dimensions]) for o in bpy.context.scene.objects if max(o.dimensions)>5.5])
要点:该脚本把"版权车模 + 商业 Cycles 材质"转换成"浏览器可渲染的 GLB + 语义化清单"。分类逻辑是启发式(按材质名/包围盒位置猜测),作者明确声明这些不是 OEM 零件号;脚本依赖本地 /private/tmp/... 路径与 Blender bpy,需在作者机器上运行,不适合直接复现。
3.8 校验脚本(scripts/*.mjs)
validate-explosion.mjs:用GLTFLoader.parseAsync在 Node 加载 GLB,取出带component的对象 → 调createExplosionLayout→ 断言334个互不重叠的投影槽位,并在 3 种视口比例(0.7/1.3/2)下断言所有片都在相机视野内。这是几何正确性的硬校验。validate-model.mjs:校验 GLB 网格 id 数 = manifest 条目数、类别合法、整车尺寸落在合理范围(宽 1.9–2.4m、高 1.5–1.9m、长 4.9–5.3m),并打印统计(网格片数、绘制网格数、尺寸、字节)。validate-touch.mjs:单测PointerTap——点按、拖回起点不选、双指不选、取消不选、取消后恢复、鼠标更小阈值,全部用node:assert断言。
3.9 其余脚本与配置(简述)
inspect-source.py/inspect-visibility.py:调试用,导出源.blend的网格/材质/可见性清单。render-model.py:用 Blender Cycles 离线渲染一张预览 PNG(用于人工核对转换效果)。package.json:定义 4 条脚本——dev/build(vinext,面向 Cloudflare)、build:vercel(vite)、lint/format(oxlint/oxfmt)。engines要求 Node ≥22.13。vercel.json:framework=vite、buildCommand=npm run build:vercel、outputDirectory=dist/vercel。vite.vercel.config.ts:以vercel-app/为 root,复用@别名与public/,输出到dist/vercel(Vercel 入口复用同一 React 页面/样式/组件/Three 场景/本地模型)。vite.config.ts:本地预览用的全配置(含 Cloudflare 插件、D1/R2 绑定桩、Tailwind/PostCSS)。next.config.ts:空配置(保留以兼容 OpenAI Sites 工具链)。tsconfig.json:strict、paths中@/* → ./*、包含 RSC/Cloudflare 类型。lib/utils.ts:cn()= clsx + tailwind-merge。hooks/use-mobile.ts:useIsMobile()监听 ≤767px。components/ui/*:shadcn(base-nova 风格,基于 Base UI)自动生成的 70+ 基础组件(Select/Slider/Switch/Tabs/Tooltip/Card/Dialog…),属脚手架,非业务代码。app/globals.css:设计系统——暗色 CSS 变量、floating-panel玻璃拟态、studio全屏舞台、scene-label/mesh-marker投影标签、explode-dock控制坞,以及 1050/700/500px 三档响应式与手机安全区(env(safe-area-inset-*))适配。
四、优点、不足与潜在应用领域
4.1 优点
- 交互体验打磨细致:手写按需渲染(无变化不
render)、阻尼插值爆炸、自动取景(相机随爆炸程度平滑拉远)、prefers-reduced-motion尊重、触屏阈值区分点击/手势——细节到位,移动端体验也做了安全区适配。 - 工程化闭环完整:从 Blender 转换、语义化清单、到 Node 端几何/布局/触控三套校验,形成"可复现 + 可验证"的资源工程,不是"丢一个 glb 上去就完事"。
- 性能意识强:玻璃透射改为不透明、阴影按需更新且限频、标记点云用
Points而非每片一个 DOM、标签投影仅更新transform并节流——在 334 片规模下仍能保持流畅。 - 设计克制统一:暗色玻璃拟态 + 单一强调橙红,视觉与品牌(Tesla 铭牌)协调,专业感强。
- AI 可调用(前瞻性):通过
modelContext.registerTool注册explore_vehicle_component,让 WebMCP 智能体驱动讲解,顺应"智能体操作网页"的趋势,且做了 feature-detect 优雅降级。 - 合规与诚实:显著标注"非 OEM 零件、示意性内部系统、不与特斯拉关联、署名来源、不提供下载",降低版权与误导风险。
4.2 不足
- 资源转换强耦合作者本地环境:
convert-model.py硬编码/private/tmp/model-x-bpy与/private/tmp/tesla-model-x-cgimoon.blend,且需 Blenderbpy,他人无法直接复现;GLB 已入库(17.5MB)算是缓解,但"改模型重导出"门槛高。 - 部件分类是启发式、非权威:
convert-model.py按材质名/包围盒位置猜测系统归属与命名,作者自述"非核实的 Tesla 服务零件"。教学可用,但作为"零件目录"不严谨。 - 3D 引擎非声明式、可维护性一般:
vehicle-scene.tsx近 200 行单函数、大量命令式 Three.js 状态(引擎对象、帧循环、聚焦状态散落各变量),虽性能佳但阅读与扩展成本高;改用 react-three-fiber + 状态管理会更易维护。 - 内部系统为程序化示意几何:电池/电驱/悬架是用圆角盒+圆柱拼的"示意",与真实结构差距大,且爆炸布局对这三组有特殊可见性处理——易让学习者误以为"这就是真实结构"。
- 测试覆盖有限:仅 3 个
.mjs校验(几何/布局/触控),无组件单测、无浏览器端交互/视觉回归;VALIDATION.md 也承认"Browser interaction QA was not run"。 - 构建链路复杂:同时存在 vinext(Cloudflare)、Vercel(vite)、OpenAI Sites 插件三条路径,配置分散(
vite.config.ts/vite.vercel.config.ts/vercel.json/.openai/hosting.json),新人上手成本不低。 - WebMCP 工具未实际运行验证:注册用了 feature-detect,但执行"在支持的浏览器上下文"未被验证(见 VALIDATION.md)。
4.3 潜在应用领域
- 汽车/机械教学与科普:STEM 课堂、博物馆互动展项、车企车主讲堂——把"电动车由哪些系统组成"讲清楚。
- 产品/配置可视化基座:可改造为"任意 SKU 的 3D 拆解页"(手机、家电、工业设备),爆炸滑块 + 单件说明是通用范式。
- 电商/售前 3D 配置器:在"看外观"之外叠加"看内部",提升高客单价耐用品(车、工程机械)的转化率。
- 维修/培训手册:配合真实零件号数据库替换启发式分类,即可做"点击零件 → 显示维修步骤/力矩/配件号"的技师培训系统。
- 智能体驱动的可视化讲解:
explore_vehicle_component工具可被客服/导购智能体调用,实现"你问、它转、它拆、它说"的自然语言交互。 - 模型资产流水线模板:
convert-model.py+ 校验脚本是一套"商业车模 → 合规 Web 资产"的可借鉴范式(材质替换、松散拆分、语义清单、几何校验)。 - WebGL 性能教学案例:按需渲染、阻尼插值、标记点云、阴影限频等手法,适合作为 Three.js 性能优化范例。
五、总结
model-x-studio 是一个完成度相当高的交互式 3D 车辆解剖工作室:它以 Next.js/Vite + 原生 Three.js 为底座,把一辆版权车模转换成合规的 Web 资产,并用"8 大系统 + 334 片爆炸 + 单件隔离 + 平滑相机"的组合,做出了流畅且专业的浏览体验。代码在工程化(转换→清单→校验)、性能(按需渲染/限频/点云)、可访问性(ARIA、减少动效、移动端安全区)和前瞻性(WebMCP 工具)上都有亮点。
短板主要集中在可复现性(转换脚本强耦合本地)、分类权威性与内部示意几何的准确性、以及3D 引擎的可维护性上。整体而言,它是一个优秀的"教育科普型 3D 产品页"范本,其爆炸布局算法、按需渲染与资源校验思路,尤其值得在类似"3D 拆解/配置器"项目中借鉴。
注:本文所引源码取自仓库
main分支(SHA 7544959),以解读时为准。

浙公网安备 33010602011771号