今日开源[第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.mjsvalidate-model.mjsvalidate-touch.mjs 校验 334 槽位不重叠、整车尺寸、触控手势
资源检视/渲染 scripts/inspect-source.pyinspect-visibility.pyrender-model.py 调试用:导出网格清单、可见性、离线渲染预览图
构建/部署 package.jsonvercel.jsonvite.config.tsvite.vercel.config.tsnext.config.tstsconfig.json.openai/hosting.json.oxlintrc.json.oxfmtrc.json 依赖、脚本、RSC/Cloudflare/Vercel 配置、lint/format 配置

2.3 核心交互流程

  1. page.tsx 挂载 <VehicleScene>,传入 selected/explode/labels/autoRotate/isolated/focusedMesh
  2. VehicleSceneuseEffect 中创建渲染器、场景、相机、灯光、展示台,并用 GLTFLoader 异步加载 model-x.glb
  3. 加载完成后,遍历 GLB 中带 component 标记的对象,按 part 挂到 8 个系统中;同时调用 createExplosionLayout() 计算每片爆炸目标位置,并为每片生成可点击的 mesh-marker 按钮。
  4. 每帧 frame():根据 explode 百分比用阻尼插值平滑过渡,更新每片位置、阴影、标签投影、隔离聚焦;仅在"有变化"时才 renderer.render()(按需渲染,省电)。
  5. 用户拖动爆炸滑块 → setExplodelatest.current.explode 变化 → frame 平滑过渡整车。
  6. 点击某片或某系统 → onSelect/onInspectpage 打开详情面板,可再"隔离"该件。

三、核心源码逐行解读(带注释)

说明:本项目的业务核心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.jsonframework=vitebuildCommand=npm run build:verceloutputDirectory=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.jsonstrictpaths@/* → ./*、包含 RSC/Cloudflare 类型。
  • lib/utils.tscn() = clsx + tailwind-merge。
  • hooks/use-mobile.tsuseIsMobile() 监听 ≤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 优点

  1. 交互体验打磨细致:手写按需渲染(无变化不 render)、阻尼插值爆炸、自动取景(相机随爆炸程度平滑拉远)、prefers-reduced-motion 尊重、触屏阈值区分点击/手势——细节到位,移动端体验也做了安全区适配。
  2. 工程化闭环完整:从 Blender 转换、语义化清单、到 Node 端几何/布局/触控三套校验,形成"可复现 + 可验证"的资源工程,不是"丢一个 glb 上去就完事"。
  3. 性能意识强:玻璃透射改为不透明、阴影按需更新且限频、标记点云用 Points 而非每片一个 DOM、标签投影仅更新 transform 并节流——在 334 片规模下仍能保持流畅。
  4. 设计克制统一:暗色玻璃拟态 + 单一强调橙红,视觉与品牌(Tesla 铭牌)协调,专业感强。
  5. AI 可调用(前瞻性):通过 modelContext.registerTool 注册 explore_vehicle_component,让 WebMCP 智能体驱动讲解,顺应"智能体操作网页"的趋势,且做了 feature-detect 优雅降级。
  6. 合规与诚实:显著标注"非 OEM 零件、示意性内部系统、不与特斯拉关联、署名来源、不提供下载",降低版权与误导风险。

4.2 不足

  1. 资源转换强耦合作者本地环境convert-model.py 硬编码 /private/tmp/model-x-bpy/private/tmp/tesla-model-x-cgimoon.blend,且需 Blender bpy他人无法直接复现;GLB 已入库(17.5MB)算是缓解,但"改模型重导出"门槛高。
  2. 部件分类是启发式、非权威convert-model.py 按材质名/包围盒位置猜测系统归属与命名,作者自述"非核实的 Tesla 服务零件"。教学可用,但作为"零件目录"不严谨。
  3. 3D 引擎非声明式、可维护性一般vehicle-scene.tsx 近 200 行单函数、大量命令式 Three.js 状态(引擎对象、帧循环、聚焦状态散落各变量),虽性能佳但阅读与扩展成本高;改用 react-three-fiber + 状态管理会更易维护。
  4. 内部系统为程序化示意几何:电池/电驱/悬架是用圆角盒+圆柱拼的"示意",与真实结构差距大,且爆炸布局对这三组有特殊可见性处理——易让学习者误以为"这就是真实结构"。
  5. 测试覆盖有限:仅 3 个 .mjs 校验(几何/布局/触控),无组件单测、无浏览器端交互/视觉回归;VALIDATION.md 也承认"Browser interaction QA was not run"。
  6. 构建链路复杂:同时存在 vinext(Cloudflare)、Vercel(vite)、OpenAI Sites 插件三条路径,配置分散(vite.config.ts/vite.vercel.config.ts/vercel.json/.openai/hosting.json),新人上手成本不低。
  7. WebMCP 工具未实际运行验证:注册用了 feature-detect,但执行"在支持的浏览器上下文"未被验证(见 VALIDATION.md)。

4.3 潜在应用领域

  1. 汽车/机械教学与科普:STEM 课堂、博物馆互动展项、车企车主讲堂——把"电动车由哪些系统组成"讲清楚。
  2. 产品/配置可视化基座:可改造为"任意 SKU 的 3D 拆解页"(手机、家电、工业设备),爆炸滑块 + 单件说明是通用范式。
  3. 电商/售前 3D 配置器:在"看外观"之外叠加"看内部",提升高客单价耐用品(车、工程机械)的转化率。
  4. 维修/培训手册:配合真实零件号数据库替换启发式分类,即可做"点击零件 → 显示维修步骤/力矩/配件号"的技师培训系统。
  5. 智能体驱动的可视化讲解explore_vehicle_component 工具可被客服/导购智能体调用,实现"你问、它转、它拆、它说"的自然语言交互。
  6. 模型资产流水线模板convert-model.py + 校验脚本是一套"商业车模 → 合规 Web 资产"的可借鉴范式(材质替换、松散拆分、语义清单、几何校验)。
  7. WebGL 性能教学案例:按需渲染、阻尼插值、标记点云、阴影限频等手法,适合作为 Three.js 性能优化范例。

五、总结

model-x-studio 是一个完成度相当高的交互式 3D 车辆解剖工作室:它以 Next.js/Vite + 原生 Three.js 为底座,把一辆版权车模转换成合规的 Web 资产,并用"8 大系统 + 334 片爆炸 + 单件隔离 + 平滑相机"的组合,做出了流畅且专业的浏览体验。代码在工程化(转换→清单→校验)、性能(按需渲染/限频/点云)、可访问性(ARIA、减少动效、移动端安全区)和前瞻性(WebMCP 工具)上都有亮点。

短板主要集中在可复现性(转换脚本强耦合本地)分类权威性与内部示意几何的准确性、以及3D 引擎的可维护性上。整体而言,它是一个优秀的"教育科普型 3D 产品页"范本,其爆炸布局算法、按需渲染与资源校验思路,尤其值得在类似"3D 拆解/配置器"项目中借鉴。

注:本文所引源码取自仓库 main 分支(SHA 7544959),以解读时为准。

posted @ 2026-09-10 10:10  zhang-yd  阅读(5)  评论(0)    收藏  举报