ModelViewer.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import { useEffect, useRef, useState } from 'react';
  2. import * as THREE from 'three';
  3. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
  4. import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
  5. import JSZip from 'jszip';
  6. import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
  7. import { Button } from './Button';
  8. interface BuildVolume {
  9. x: number;
  10. y: number;
  11. z: number;
  12. }
  13. interface ModelViewerProps {
  14. url: string;
  15. buildVolume?: BuildVolume;
  16. className?: string;
  17. }
  18. interface MeshData {
  19. vertices: number[];
  20. triangles: number[];
  21. }
  22. interface ObjectData {
  23. id: string;
  24. meshes: MeshData[];
  25. }
  26. interface BuildItem {
  27. objectId: string;
  28. transform: THREE.Matrix4;
  29. }
  30. // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
  31. function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
  32. const matrix = new THREE.Matrix4();
  33. if (!transformStr) {
  34. return matrix; // Identity matrix
  35. }
  36. // 3MF transform is a 3x4 affine matrix in row-major order:
  37. // "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
  38. // Where (m30, m31, m32) is the translation vector
  39. const values = transformStr.trim().split(/\s+/).map(parseFloat);
  40. if (values.length >= 12) {
  41. // Three.js Matrix4.set takes row-major order arguments:
  42. // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
  43. // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
  44. matrix.set(
  45. values[0], values[1], values[2], values[9], // m00, m01, m02, tx
  46. values[3], values[4], values[5], values[10], // m10, m11, m12, ty
  47. values[6], values[7], values[8], values[11], // m20, m21, m22, tz
  48. 0, 0, 0, 1
  49. );
  50. }
  51. return matrix;
  52. }
  53. // Alias for backwards compatibility
  54. const parseTransform = parseTransform3MF;
  55. async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
  56. const meshes: MeshData[] = [];
  57. const meshElements = doc.getElementsByTagName('mesh');
  58. for (let j = 0; j < meshElements.length; j++) {
  59. const meshEl = meshElements[j];
  60. const vertices: number[] = [];
  61. const triangles: number[] = [];
  62. const vertexElements = meshEl.getElementsByTagName('vertex');
  63. for (let k = 0; k < vertexElements.length; k++) {
  64. const v = vertexElements[k];
  65. vertices.push(
  66. parseFloat(v.getAttribute('x') || '0'),
  67. parseFloat(v.getAttribute('y') || '0'),
  68. parseFloat(v.getAttribute('z') || '0')
  69. );
  70. }
  71. const triangleElements = meshEl.getElementsByTagName('triangle');
  72. for (let k = 0; k < triangleElements.length; k++) {
  73. const t = triangleElements[k];
  74. triangles.push(
  75. parseInt(t.getAttribute('v1') || '0'),
  76. parseInt(t.getAttribute('v2') || '0'),
  77. parseInt(t.getAttribute('v3') || '0')
  78. );
  79. }
  80. if (vertices.length > 0 && triangles.length > 0) {
  81. meshes.push({ vertices, triangles });
  82. }
  83. }
  84. return meshes;
  85. }
  86. async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
  87. const zip = await JSZip.loadAsync(arrayBuffer);
  88. const objects = new Map<string, ObjectData>();
  89. const buildItems: BuildItem[] = [];
  90. const parser = new DOMParser();
  91. // Helper to load and parse a model file from the zip
  92. async function loadModelFile(path: string): Promise<Document | null> {
  93. // Normalize path (remove leading slash)
  94. const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
  95. const file = zip.files[normalizedPath];
  96. if (!file) return null;
  97. const content = await file.async('string');
  98. return parser.parseFromString(content, 'application/xml');
  99. }
  100. // Find the main 3D model file
  101. const mainModelPath = Object.keys(zip.files).find(
  102. (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
  103. );
  104. if (!mainModelPath) {
  105. // Fallback: try to find any .model file
  106. const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
  107. if (anyModelPath) {
  108. const doc = await loadModelFile(anyModelPath);
  109. if (doc) {
  110. const meshes = await parseMeshFromDoc(doc);
  111. if (meshes.length > 0) {
  112. objects.set('1', { id: '1', meshes });
  113. }
  114. }
  115. }
  116. return { objects, buildItems };
  117. }
  118. const mainDoc = await loadModelFile(mainModelPath);
  119. if (!mainDoc) return { objects, buildItems };
  120. // Parse objects - Bambu Studio uses components to reference external files
  121. const objectElements = mainDoc.getElementsByTagName('object');
  122. for (let i = 0; i < objectElements.length; i++) {
  123. const objEl = objectElements[i];
  124. const objectId = objEl.getAttribute('id');
  125. if (!objectId) continue;
  126. const meshes: MeshData[] = [];
  127. // Check for direct mesh in this object
  128. const objMeshElements = objEl.getElementsByTagName('mesh');
  129. for (let j = 0; j < objMeshElements.length; j++) {
  130. const meshEl = objMeshElements[j];
  131. const vertices: number[] = [];
  132. const triangles: number[] = [];
  133. const vertexElements = meshEl.getElementsByTagName('vertex');
  134. for (let k = 0; k < vertexElements.length; k++) {
  135. const v = vertexElements[k];
  136. vertices.push(
  137. parseFloat(v.getAttribute('x') || '0'),
  138. parseFloat(v.getAttribute('y') || '0'),
  139. parseFloat(v.getAttribute('z') || '0')
  140. );
  141. }
  142. const triangleElements = meshEl.getElementsByTagName('triangle');
  143. for (let k = 0; k < triangleElements.length; k++) {
  144. const t = triangleElements[k];
  145. triangles.push(
  146. parseInt(t.getAttribute('v1') || '0'),
  147. parseInt(t.getAttribute('v2') || '0'),
  148. parseInt(t.getAttribute('v3') || '0')
  149. );
  150. }
  151. if (vertices.length > 0 && triangles.length > 0) {
  152. meshes.push({ vertices, triangles });
  153. }
  154. }
  155. // Check for component references (Bambu Studio style)
  156. const componentElements = objEl.getElementsByTagName('component');
  157. for (let j = 0; j < componentElements.length; j++) {
  158. const compEl = componentElements[j];
  159. // p:path attribute contains the external file reference
  160. const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
  161. if (extPath) {
  162. const extDoc = await loadModelFile(extPath);
  163. if (extDoc) {
  164. const extMeshes = await parseMeshFromDoc(extDoc);
  165. // Apply component transform if present
  166. const compTransformStr = compEl.getAttribute('transform');
  167. const compTransform = parseTransform(compTransformStr);
  168. for (const mesh of extMeshes) {
  169. if (compTransformStr) {
  170. // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
  171. const transformedVertices: number[] = [];
  172. for (let k = 0; k < mesh.vertices.length; k += 3) {
  173. const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
  174. v.applyMatrix4(compTransform);
  175. transformedVertices.push(v.x, v.y, v.z);
  176. }
  177. meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
  178. } else {
  179. meshes.push(mesh);
  180. }
  181. }
  182. }
  183. }
  184. }
  185. if (meshes.length > 0) {
  186. objects.set(objectId, { id: objectId, meshes });
  187. }
  188. }
  189. // Parse build items (placement on build plate)
  190. const buildElements = mainDoc.getElementsByTagName('build');
  191. if (buildElements.length > 0) {
  192. const itemElements = buildElements[0].getElementsByTagName('item');
  193. for (let i = 0; i < itemElements.length; i++) {
  194. const itemEl = itemElements[i];
  195. const objectId = itemEl.getAttribute('objectid');
  196. if (!objectId) continue;
  197. const transform = parseTransform(itemEl.getAttribute('transform'));
  198. buildItems.push({ objectId, transform });
  199. }
  200. }
  201. return { objects, buildItems };
  202. }
  203. function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
  204. const geometry = new THREE.BufferGeometry();
  205. // Convert from 3MF Z-up to Three.js Y-up coordinate system
  206. // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
  207. const positions = new Float32Array(mesh.vertices.length);
  208. for (let i = 0; i < mesh.vertices.length; i += 3) {
  209. positions[i] = mesh.vertices[i]; // X stays X
  210. positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
  211. positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
  212. }
  213. geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  214. geometry.setIndex(mesh.triangles);
  215. // Compute normals
  216. geometry.computeVertexNormals();
  217. return geometry;
  218. }
  219. export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
  220. const containerRef = useRef<HTMLDivElement>(null);
  221. const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
  222. const sceneRef = useRef<THREE.Scene | null>(null);
  223. const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
  224. const controlsRef = useRef<OrbitControls | null>(null);
  225. const [loading, setLoading] = useState(true);
  226. const [error, setError] = useState<string | null>(null);
  227. useEffect(() => {
  228. if (!containerRef.current) return;
  229. const container = containerRef.current;
  230. const width = container.clientWidth;
  231. const height = container.clientHeight;
  232. // Scene
  233. const scene = new THREE.Scene();
  234. scene.background = new THREE.Color(0x1a1a1a);
  235. sceneRef.current = scene;
  236. // Camera
  237. const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
  238. camera.position.set(150, 150, 150);
  239. cameraRef.current = camera;
  240. // Renderer
  241. const renderer = new THREE.WebGLRenderer({ antialias: true });
  242. renderer.setSize(width, height);
  243. renderer.setPixelRatio(window.devicePixelRatio);
  244. container.appendChild(renderer.domElement);
  245. rendererRef.current = renderer;
  246. // Controls
  247. const controls = new OrbitControls(camera, renderer.domElement);
  248. controls.enableDamping = true;
  249. controls.dampingFactor = 0.05;
  250. controlsRef.current = controls;
  251. // Lights
  252. const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
  253. scene.add(ambientLight);
  254. const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
  255. directionalLight.position.set(100, 100, 100);
  256. scene.add(directionalLight);
  257. const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
  258. directionalLight2.position.set(-100, 50, -100);
  259. scene.add(directionalLight2);
  260. // Grid - use the larger dimension for the grid size
  261. const gridSize = Math.max(buildVolume.x, buildVolume.y);
  262. const gridDivisions = Math.ceil(gridSize / 16);
  263. const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
  264. scene.add(gridHelper);
  265. // Build plate indicator
  266. const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
  267. const plateMaterial = new THREE.MeshBasicMaterial({
  268. color: 0x00ae42,
  269. transparent: true,
  270. opacity: 0.15,
  271. side: THREE.DoubleSide,
  272. });
  273. const plate = new THREE.Mesh(plateGeometry, plateMaterial);
  274. plate.rotation.x = -Math.PI / 2;
  275. plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
  276. scene.add(plate);
  277. // Animation loop - keep it simple for reliability
  278. let animationId: number;
  279. const animate = () => {
  280. animationId = requestAnimationFrame(animate);
  281. controls.update();
  282. renderer.render(scene, camera);
  283. };
  284. animate();
  285. // Load 3MF
  286. fetch(url)
  287. .then((res) => {
  288. if (!res.ok) throw new Error('Failed to load file');
  289. return res.arrayBuffer();
  290. })
  291. .then(parse3MF)
  292. .then(({ objects, buildItems }) => {
  293. if (objects.size === 0) {
  294. throw new Error('No meshes found in 3MF file');
  295. }
  296. const material = new THREE.MeshPhongMaterial({
  297. color: 0x00ae42,
  298. shininess: 30,
  299. flatShading: false,
  300. });
  301. const group = new THREE.Group();
  302. const allGeometries: THREE.BufferGeometry[] = [];
  303. // If we have build items, use them for positioning
  304. if (buildItems.length > 0) {
  305. for (const item of buildItems) {
  306. const objectData = objects.get(item.objectId);
  307. if (!objectData) continue;
  308. for (const meshData of objectData.meshes) {
  309. // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
  310. const transformedVertices: number[] = [];
  311. for (let k = 0; k < meshData.vertices.length; k += 3) {
  312. const v = new THREE.Vector3(
  313. meshData.vertices[k],
  314. meshData.vertices[k + 1],
  315. meshData.vertices[k + 2]
  316. );
  317. v.applyMatrix4(item.transform);
  318. transformedVertices.push(v.x, v.y, v.z);
  319. }
  320. // Now create geometry with coordinate conversion
  321. const geometry = createGeometryFromMesh({
  322. vertices: transformedVertices,
  323. triangles: meshData.triangles,
  324. });
  325. allGeometries.push(geometry);
  326. }
  327. }
  328. } else {
  329. // Fallback: just add all objects without transforms
  330. for (const objectData of objects.values()) {
  331. for (const meshData of objectData.meshes) {
  332. const geometry = createGeometryFromMesh(meshData);
  333. allGeometries.push(geometry);
  334. }
  335. }
  336. }
  337. // Merge all geometries into one for better performance
  338. if (allGeometries.length > 0) {
  339. const mergedGeometry = allGeometries.length === 1
  340. ? allGeometries[0]
  341. : mergeGeometries(allGeometries, false);
  342. if (mergedGeometry) {
  343. const mesh = new THREE.Mesh(mergedGeometry, material);
  344. group.add(mesh);
  345. }
  346. // Dispose individual geometries if merged
  347. if (allGeometries.length > 1) {
  348. for (const geom of allGeometries) {
  349. geom.dispose();
  350. }
  351. }
  352. }
  353. // Get bounding box to position model
  354. const box = new THREE.Box3().setFromObject(group);
  355. const center = box.getCenter(new THREE.Vector3());
  356. // Always place models on the build plate (Y=0)
  357. group.position.y = -box.min.y;
  358. // For models without build transforms, also center X/Z
  359. if (buildItems.length === 0) {
  360. group.position.x = -center.x;
  361. group.position.z = -center.z;
  362. }
  363. scene.add(group);
  364. // Recalculate bounding box after positioning
  365. const finalBox = new THREE.Box3().setFromObject(group);
  366. const finalCenter = finalBox.getCenter(new THREE.Vector3());
  367. const finalSize = finalBox.getSize(new THREE.Vector3());
  368. // Adjust camera to fit model
  369. const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
  370. const cameraDistance = maxDim * 1.8;
  371. camera.position.set(
  372. finalCenter.x + cameraDistance * 0.7,
  373. finalCenter.y + cameraDistance * 0.5,
  374. finalCenter.z + cameraDistance * 0.7
  375. );
  376. controls.target.copy(finalCenter);
  377. controls.update();
  378. setLoading(false);
  379. })
  380. .catch((err) => {
  381. setError(err.message);
  382. setLoading(false);
  383. });
  384. // Handle resize
  385. const handleResize = () => {
  386. if (!container) return;
  387. const w = container.clientWidth;
  388. const h = container.clientHeight;
  389. camera.aspect = w / h;
  390. camera.updateProjectionMatrix();
  391. renderer.setSize(w, h);
  392. };
  393. window.addEventListener('resize', handleResize);
  394. return () => {
  395. window.removeEventListener('resize', handleResize);
  396. cancelAnimationFrame(animationId);
  397. controls.dispose();
  398. renderer.dispose();
  399. container.removeChild(renderer.domElement);
  400. };
  401. }, [url, buildVolume]);
  402. const resetView = () => {
  403. if (cameraRef.current && controlsRef.current) {
  404. cameraRef.current.position.set(150, 150, 150);
  405. controlsRef.current.target.set(0, 50, 0);
  406. controlsRef.current.update();
  407. }
  408. };
  409. const zoom = (factor: number) => {
  410. if (cameraRef.current) {
  411. cameraRef.current.position.multiplyScalar(factor);
  412. }
  413. };
  414. return (
  415. <div className={`relative ${className}`}>
  416. <div ref={containerRef} className="w-full h-full min-h-[400px]" />
  417. {loading && (
  418. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  419. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  420. </div>
  421. )}
  422. {error && (
  423. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  424. <p className="text-red-400">{error}</p>
  425. </div>
  426. )}
  427. {!loading && !error && (
  428. <div className="absolute bottom-4 right-4 flex gap-2">
  429. <Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
  430. <ZoomIn className="w-4 h-4" />
  431. </Button>
  432. <Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
  433. <ZoomOut className="w-4 h-4" />
  434. </Button>
  435. <Button variant="secondary" size="sm" onClick={resetView}>
  436. <RotateCcw className="w-4 h-4" />
  437. </Button>
  438. </div>
  439. )}
  440. </div>
  441. );
  442. }