ModelViewer.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  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 { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
  6. import JSZip from 'jszip';
  7. import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
  8. import { Button } from './Button';
  9. interface BuildVolume {
  10. x: number;
  11. y: number;
  12. z: number;
  13. }
  14. interface ModelViewerProps {
  15. url: string;
  16. fileType?: string;
  17. buildVolume?: BuildVolume;
  18. filamentColors?: string[];
  19. selectedPlateId?: number | null;
  20. className?: string;
  21. }
  22. interface MeshData {
  23. vertices: number[];
  24. triangles: number[];
  25. extruder: number; // Per-mesh extruder index for coloring
  26. }
  27. interface ObjectData {
  28. id: string;
  29. meshes: MeshData[];
  30. defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
  31. plateId?: number | null;
  32. }
  33. interface BuildItem {
  34. objectId: string;
  35. transform: THREE.Matrix4;
  36. extruder?: number; // Can override object's extruder
  37. plateId?: number | null;
  38. }
  39. interface Parsed3MFData {
  40. objects: Map<string, ObjectData>;
  41. buildItems: BuildItem[];
  42. plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
  43. }
  44. // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
  45. function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
  46. const matrix = new THREE.Matrix4();
  47. if (!transformStr) {
  48. return matrix; // Identity matrix
  49. }
  50. // 3MF transform is a 3x4 affine matrix in row-major order:
  51. // "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
  52. // Where (m30, m31, m32) is the translation vector
  53. const values = transformStr.trim().split(/\s+/).map(parseFloat);
  54. if (values.length >= 12) {
  55. // Three.js Matrix4.set takes row-major order arguments:
  56. // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
  57. // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
  58. matrix.set(
  59. values[0], values[1], values[2], values[9], // m00, m01, m02, tx
  60. values[3], values[4], values[5], values[10], // m10, m11, m12, ty
  61. values[6], values[7], values[8], values[11], // m20, m21, m22, tz
  62. 0, 0, 0, 1
  63. );
  64. }
  65. return matrix;
  66. }
  67. // Alias for backwards compatibility
  68. const parseTransform = parseTransform3MF;
  69. async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise<MeshData[]> {
  70. const meshes: MeshData[] = [];
  71. const meshElements = doc.getElementsByTagName('mesh');
  72. for (let j = 0; j < meshElements.length; j++) {
  73. const meshEl = meshElements[j];
  74. const vertices: number[] = [];
  75. const triangles: number[] = [];
  76. const vertexElements = meshEl.getElementsByTagName('vertex');
  77. for (let k = 0; k < vertexElements.length; k++) {
  78. const v = vertexElements[k];
  79. vertices.push(
  80. parseFloat(v.getAttribute('x') || '0'),
  81. parseFloat(v.getAttribute('y') || '0'),
  82. parseFloat(v.getAttribute('z') || '0')
  83. );
  84. }
  85. const triangleElements = meshEl.getElementsByTagName('triangle');
  86. for (let k = 0; k < triangleElements.length; k++) {
  87. const t = triangleElements[k];
  88. triangles.push(
  89. parseInt(t.getAttribute('v1') || '0'),
  90. parseInt(t.getAttribute('v2') || '0'),
  91. parseInt(t.getAttribute('v3') || '0')
  92. );
  93. }
  94. if (vertices.length > 0 && triangles.length > 0) {
  95. meshes.push({ vertices, triangles, extruder: defaultExtruder });
  96. }
  97. }
  98. return meshes;
  99. }
  100. function parsePlateIdFromAttributes(element: Element): number | null {
  101. const plateAttribute = Array.from(element.attributes).find((attr) => {
  102. const name = attr.name.toLowerCase();
  103. return (
  104. name === 'plate_id' ||
  105. name === 'plater_id' ||
  106. name === 'plateid' ||
  107. name === 'platerid' ||
  108. name.endsWith(':plate_id') ||
  109. name.endsWith(':plater_id')
  110. );
  111. });
  112. if (!plateAttribute?.value) return null;
  113. const parsed = Number.parseInt(plateAttribute.value, 10);
  114. return Number.isFinite(parsed) ? parsed : null;
  115. }
  116. async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
  117. let zip: JSZip;
  118. try {
  119. zip = await JSZip.loadAsync(arrayBuffer);
  120. } catch {
  121. throw new Error('Unsupported file format');
  122. }
  123. const objects = new Map<string, ObjectData>();
  124. const buildItems: BuildItem[] = [];
  125. const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
  126. const parser = new DOMParser();
  127. // Helper to load and parse a model file from the zip
  128. async function loadModelFile(path: string): Promise<Document | null> {
  129. // Normalize path (remove leading slash)
  130. const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
  131. const file = zip.files[normalizedPath];
  132. if (!file) return null;
  133. const content = await file.async('string');
  134. return parser.parseFromString(content, 'application/xml');
  135. }
  136. // Parse model_settings.config to get extruder assignments
  137. // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
  138. const extruderMapById = new Map<string, number>();
  139. const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
  140. const objectNameById = new Map<string, string>();
  141. const plateAssignmentsByObjectId = new Map<string, number>();
  142. const modelSettingsFile = zip.files['Metadata/model_settings.config'];
  143. if (modelSettingsFile) {
  144. try {
  145. const content = await modelSettingsFile.async('string');
  146. const doc = parser.parseFromString(content, 'application/xml');
  147. const objectElements = doc.getElementsByTagName('object');
  148. for (let i = 0; i < objectElements.length; i++) {
  149. const objEl = objectElements[i];
  150. const objectId = objEl.getAttribute('id');
  151. if (!objectId) continue;
  152. // Find object-level extruder + name
  153. const directMetadata = Array.from(objEl.children).filter(
  154. (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
  155. );
  156. if (directMetadata.length > 0) {
  157. const extruderVal = directMetadata[0].getAttribute('value');
  158. if (extruderVal) {
  159. extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
  160. }
  161. }
  162. const nameMetadata = Array.from(objEl.children).find(
  163. (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
  164. );
  165. const objectName = nameMetadata?.getAttribute('value');
  166. if (objectName) {
  167. objectNameById.set(objectId, objectName);
  168. }
  169. // Find part-level extruders
  170. const partElements = objEl.getElementsByTagName('part');
  171. for (let j = 0; j < partElements.length; j++) {
  172. const partEl = partElements[j];
  173. const partId = partEl.getAttribute('id');
  174. if (!partId) continue;
  175. // Look for extruder in part's direct children
  176. const partMetadata = Array.from(partEl.children).filter(
  177. (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
  178. );
  179. if (partMetadata.length > 0) {
  180. const extruderVal = partMetadata[0].getAttribute('value');
  181. if (extruderVal) {
  182. partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));
  183. }
  184. }
  185. }
  186. }
  187. // Parse plate -> object assignments
  188. const plateElements = doc.getElementsByTagName('plate');
  189. for (let i = 0; i < plateElements.length; i++) {
  190. const plateEl = plateElements[i];
  191. let plateId: number | null = null;
  192. const metadataElements = plateEl.getElementsByTagName('metadata');
  193. for (let j = 0; j < metadataElements.length; j++) {
  194. const metaEl = metadataElements[j];
  195. const key = metaEl.getAttribute('key');
  196. if (key === 'plater_id' || key === 'plate_id') {
  197. const value = metaEl.getAttribute('value');
  198. if (value) {
  199. const parsed = Number.parseInt(value, 10);
  200. if (Number.isFinite(parsed)) {
  201. plateId = parsed;
  202. }
  203. }
  204. }
  205. }
  206. if (plateId == null) continue;
  207. const modelInstances = plateEl.getElementsByTagName('model_instance');
  208. for (let j = 0; j < modelInstances.length; j++) {
  209. const instanceEl = modelInstances[j];
  210. const instanceMetadata = instanceEl.getElementsByTagName('metadata');
  211. for (let k = 0; k < instanceMetadata.length; k++) {
  212. const metaEl = instanceMetadata[k];
  213. if (metaEl.getAttribute('key') === 'object_id') {
  214. const value = metaEl.getAttribute('value');
  215. if (value) {
  216. plateAssignmentsByObjectId.set(value, plateId);
  217. }
  218. }
  219. }
  220. }
  221. }
  222. } catch {
  223. // Silently ignore model_settings.config parsing errors
  224. }
  225. }
  226. // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
  227. const plateAssignmentsByName = new Map<string, number>();
  228. const plateJsonNames = Object.keys(zip.files).filter(
  229. (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
  230. );
  231. for (const name of plateJsonNames) {
  232. const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
  233. if (!match) continue;
  234. const plateIndex = Number.parseInt(match[1], 10);
  235. if (!Number.isFinite(plateIndex)) continue;
  236. try {
  237. const payload = await zip.files[name].async('string');
  238. const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
  239. const objectsList = json.bbox_objects ?? [];
  240. for (const entry of objectsList) {
  241. if (entry?.name) {
  242. plateAssignmentsByName.set(entry.name, plateIndex);
  243. }
  244. }
  245. if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
  246. const [minX, minY, maxX, maxY] = json.bbox_all;
  247. if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
  248. plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
  249. }
  250. }
  251. } catch {
  252. // Ignore plate json parsing errors
  253. }
  254. }
  255. // Find the main 3D model file
  256. const mainModelPath = Object.keys(zip.files).find(
  257. (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
  258. );
  259. if (!mainModelPath) {
  260. // Fallback: try to find any .model file
  261. const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
  262. if (anyModelPath) {
  263. const doc = await loadModelFile(anyModelPath);
  264. if (doc) {
  265. const meshes = await parseMeshFromDoc(doc, 0);
  266. if (meshes.length > 0) {
  267. objects.set('1', { id: '1', meshes, defaultExtruder: 0 });
  268. }
  269. }
  270. }
  271. return { objects, buildItems, plateBounds };
  272. }
  273. const mainDoc = await loadModelFile(mainModelPath);
  274. if (!mainDoc) return { objects, buildItems, plateBounds };
  275. // Parse objects - Bambu Studio uses components to reference external files
  276. const objectElements = mainDoc.getElementsByTagName('object');
  277. for (let i = 0; i < objectElements.length; i++) {
  278. const objEl = objectElements[i];
  279. const objectId = objEl.getAttribute('id');
  280. if (!objectId) continue;
  281. const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
  282. // Get default extruder from model_settings.config map, falling back to attribute or default
  283. let defaultExtruder = extruderMapById.get(objectId) ?? -1;
  284. if (defaultExtruder < 0) {
  285. const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1';
  286. defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
  287. }
  288. const meshes: MeshData[] = [];
  289. // Check for direct mesh in this object
  290. const objMeshElements = objEl.getElementsByTagName('mesh');
  291. for (let j = 0; j < objMeshElements.length; j++) {
  292. const meshEl = objMeshElements[j];
  293. const vertices: number[] = [];
  294. const triangles: number[] = [];
  295. const vertexElements = meshEl.getElementsByTagName('vertex');
  296. for (let k = 0; k < vertexElements.length; k++) {
  297. const v = vertexElements[k];
  298. vertices.push(
  299. parseFloat(v.getAttribute('x') || '0'),
  300. parseFloat(v.getAttribute('y') || '0'),
  301. parseFloat(v.getAttribute('z') || '0')
  302. );
  303. }
  304. const triangleElements = meshEl.getElementsByTagName('triangle');
  305. for (let k = 0; k < triangleElements.length; k++) {
  306. const t = triangleElements[k];
  307. triangles.push(
  308. parseInt(t.getAttribute('v1') || '0'),
  309. parseInt(t.getAttribute('v2') || '0'),
  310. parseInt(t.getAttribute('v3') || '0')
  311. );
  312. }
  313. if (vertices.length > 0 && triangles.length > 0) {
  314. meshes.push({ vertices, triangles, extruder: defaultExtruder });
  315. }
  316. }
  317. // Check for component references (Bambu Studio style)
  318. const componentElements = objEl.getElementsByTagName('component');
  319. for (let j = 0; j < componentElements.length; j++) {
  320. const compEl = componentElements[j];
  321. // p:path attribute contains the external file reference
  322. const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
  323. // objectid in component corresponds to part id in model_settings
  324. const compObjectId = compEl.getAttribute('objectid');
  325. if (extPath) {
  326. const extDoc = await loadModelFile(extPath);
  327. if (extDoc) {
  328. // Look up per-part extruder, falling back to object's default
  329. const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
  330. const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder;
  331. const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
  332. // Apply component transform if present
  333. const compTransformStr = compEl.getAttribute('transform');
  334. const compTransform = parseTransform(compTransformStr);
  335. for (const mesh of extMeshes) {
  336. if (compTransformStr) {
  337. // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
  338. const transformedVertices: number[] = [];
  339. for (let k = 0; k < mesh.vertices.length; k += 3) {
  340. const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
  341. v.applyMatrix4(compTransform);
  342. transformedVertices.push(v.x, v.y, v.z);
  343. }
  344. meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });
  345. } else {
  346. meshes.push(mesh);
  347. }
  348. }
  349. }
  350. }
  351. }
  352. if (meshes.length > 0) {
  353. objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
  354. }
  355. }
  356. // Parse build items (placement on build plate)
  357. const buildElements = mainDoc.getElementsByTagName('build');
  358. if (buildElements.length > 0) {
  359. const itemElements = buildElements[0].getElementsByTagName('item');
  360. for (let i = 0; i < itemElements.length; i++) {
  361. const itemEl = itemElements[i];
  362. const objectId = itemEl.getAttribute('objectid');
  363. if (!objectId) continue;
  364. const transform = parseTransform(itemEl.getAttribute('transform'));
  365. const itemPlateId = parsePlateIdFromAttributes(itemEl);
  366. const objectPlateId = objects.get(objectId)?.plateId ?? null;
  367. const objectName = objectNameById.get(objectId);
  368. const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
  369. buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
  370. }
  371. }
  372. return { objects, buildItems, plateBounds };
  373. }
  374. function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
  375. const geometry = new THREE.BufferGeometry();
  376. // Convert from 3MF Z-up to Three.js Y-up coordinate system
  377. // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
  378. const positions = new Float32Array(mesh.vertices.length);
  379. for (let i = 0; i < mesh.vertices.length; i += 3) {
  380. positions[i] = mesh.vertices[i]; // X stays X
  381. positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
  382. positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
  383. }
  384. geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  385. geometry.setIndex(mesh.triangles);
  386. // Compute normals
  387. geometry.computeVertexNormals();
  388. return geometry;
  389. }
  390. function disposeGroup(group: THREE.Group) {
  391. group.traverse((child) => {
  392. if (child instanceof THREE.Mesh) {
  393. child.geometry.dispose();
  394. if (Array.isArray(child.material)) {
  395. for (const material of child.material) {
  396. material.dispose();
  397. }
  398. } else {
  399. child.material.dispose();
  400. }
  401. }
  402. });
  403. }
  404. function buildModelGroup(
  405. parsedData: Parsed3MFData,
  406. selectedPlateId: number | null,
  407. filamentColors?: string[],
  408. ): THREE.Group {
  409. const { objects, buildItems } = parsedData;
  410. const group = new THREE.Group();
  411. // Create materials for each extruder color
  412. const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
  413. const defaultColor = '#00ae42';
  414. const colorStr = filamentColors?.[extruder] || defaultColor;
  415. // Convert hex color string to THREE.js color
  416. const color = new THREE.Color(colorStr);
  417. return new THREE.MeshPhongMaterial({
  418. color,
  419. shininess: 30,
  420. flatShading: false,
  421. });
  422. };
  423. // Group geometries by extruder index (using per-mesh extruder)
  424. const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
  425. const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
  426. const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
  427. ? buildItems
  428. : buildItems.filter((item) => item.plateId === selectedPlateId);
  429. const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
  430. // If we have build items, use them for positioning
  431. if (activeBuildItems.length > 0) {
  432. for (const item of activeBuildItems) {
  433. const objectData = objects.get(item.objectId);
  434. if (!objectData) continue;
  435. for (const meshData of objectData.meshes) {
  436. // Use mesh's extruder, or item override, or object default
  437. const extruder = item.extruder ?? meshData.extruder;
  438. // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
  439. const transformedVertices: number[] = [];
  440. for (let k = 0; k < meshData.vertices.length; k += 3) {
  441. const v = new THREE.Vector3(
  442. meshData.vertices[k],
  443. meshData.vertices[k + 1],
  444. meshData.vertices[k + 2]
  445. );
  446. v.applyMatrix4(item.transform);
  447. transformedVertices.push(v.x, v.y, v.z);
  448. }
  449. // Now create geometry with coordinate conversion
  450. const geometry = createGeometryFromMesh({
  451. vertices: transformedVertices,
  452. triangles: meshData.triangles,
  453. extruder: extruder,
  454. });
  455. if (!geometriesByExtruder.has(extruder)) {
  456. geometriesByExtruder.set(extruder, []);
  457. }
  458. geometriesByExtruder.get(extruder)!.push(geometry);
  459. }
  460. }
  461. } else {
  462. // Fallback: just add all objects without transforms
  463. for (const objectData of objects.values()) {
  464. for (const meshData of objectData.meshes) {
  465. // Use per-mesh extruder
  466. const extruder = meshData.extruder;
  467. const geometry = createGeometryFromMesh(meshData);
  468. if (!geometriesByExtruder.has(extruder)) {
  469. geometriesByExtruder.set(extruder, []);
  470. }
  471. geometriesByExtruder.get(extruder)!.push(geometry);
  472. }
  473. }
  474. }
  475. // Create meshes for each extruder group
  476. for (const [extruder, geometries] of geometriesByExtruder) {
  477. if (geometries.length === 0) continue;
  478. const mergedGeometry = geometries.length === 1
  479. ? geometries[0]
  480. : mergeGeometries(geometries, false);
  481. if (mergedGeometry) {
  482. const material = getMaterial(extruder);
  483. const mesh = new THREE.Mesh(mergedGeometry, material);
  484. group.add(mesh);
  485. }
  486. // Dispose individual geometries if merged
  487. if (geometries.length > 1) {
  488. for (const geom of geometries) {
  489. geom.dispose();
  490. }
  491. }
  492. }
  493. return group;
  494. }
  495. export function ModelViewer({
  496. url,
  497. fileType,
  498. buildVolume = { x: 256, y: 256, z: 256 },
  499. filamentColors,
  500. selectedPlateId = null,
  501. className = '',
  502. }: ModelViewerProps) {
  503. const containerRef = useRef<HTMLDivElement>(null);
  504. const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
  505. const sceneRef = useRef<THREE.Scene | null>(null);
  506. const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
  507. const controlsRef = useRef<OrbitControls | null>(null);
  508. const modelGroupRef = useRef<THREE.Group | null>(null);
  509. const plateRef = useRef<THREE.Mesh | null>(null);
  510. const gridRef = useRef<THREE.GridHelper | null>(null);
  511. const [loading, setLoading] = useState(true);
  512. const [error, setError] = useState<string | null>(null);
  513. const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
  514. const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
  515. useEffect(() => {
  516. if (!containerRef.current) return;
  517. const container = containerRef.current;
  518. const width = container.clientWidth;
  519. const height = container.clientHeight;
  520. // Scene
  521. const scene = new THREE.Scene();
  522. scene.background = new THREE.Color(0x1a1a1a);
  523. sceneRef.current = scene;
  524. // Camera
  525. const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
  526. camera.position.set(150, 150, 150);
  527. cameraRef.current = camera;
  528. // Renderer
  529. const renderer = new THREE.WebGLRenderer({ antialias: true });
  530. renderer.setSize(width, height);
  531. renderer.setPixelRatio(window.devicePixelRatio);
  532. container.appendChild(renderer.domElement);
  533. rendererRef.current = renderer;
  534. // Controls
  535. const controls = new OrbitControls(camera, renderer.domElement);
  536. controls.enableDamping = true;
  537. controls.dampingFactor = 0.05;
  538. controlsRef.current = controls;
  539. // Lights
  540. const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
  541. scene.add(ambientLight);
  542. const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
  543. directionalLight.position.set(100, 100, 100);
  544. scene.add(directionalLight);
  545. const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
  546. directionalLight2.position.set(-100, 50, -100);
  547. scene.add(directionalLight2);
  548. // Grid - use the larger dimension for the grid size
  549. const gridSize = Math.max(buildVolume.x, buildVolume.y);
  550. const gridDivisions = Math.ceil(gridSize / 16);
  551. const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
  552. scene.add(gridHelper);
  553. gridRef.current = gridHelper;
  554. // Build plate indicator
  555. const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
  556. const plateMaterial = new THREE.MeshBasicMaterial({
  557. color: 0x00ae42,
  558. transparent: true,
  559. opacity: 0.15,
  560. side: THREE.DoubleSide,
  561. });
  562. const plate = new THREE.Mesh(plateGeometry, plateMaterial);
  563. plate.rotation.x = -Math.PI / 2;
  564. plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
  565. scene.add(plate);
  566. plateRef.current = plate;
  567. // Animation loop - keep it simple for reliability
  568. let animationId: number;
  569. const animate = () => {
  570. animationId = requestAnimationFrame(animate);
  571. controls.update();
  572. renderer.render(scene, camera);
  573. };
  574. animate();
  575. setLoading(true);
  576. setError(null);
  577. setParsedData(null);
  578. setStlGeometry(null);
  579. const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
  580. if (normalizedType === 'stl') {
  581. fetch(url)
  582. .then((res) => {
  583. if (!res.ok) throw new Error('Failed to load file');
  584. return res.arrayBuffer();
  585. })
  586. .then((buffer) => {
  587. const loader = new STLLoader();
  588. const geometry = loader.parse(buffer);
  589. geometry.computeVertexNormals();
  590. geometry.rotateX(-Math.PI / 2);
  591. setStlGeometry(geometry);
  592. })
  593. .catch((err) => {
  594. setError(err.message);
  595. setLoading(false);
  596. });
  597. } else if (normalizedType === '3mf') {
  598. fetch(url)
  599. .then((res) => {
  600. if (!res.ok) throw new Error('Failed to load file');
  601. return res.arrayBuffer();
  602. })
  603. .then(parse3MF)
  604. .then((parsed) => {
  605. if (parsed.objects.size === 0) {
  606. throw new Error('No meshes found in 3MF file');
  607. }
  608. setParsedData(parsed);
  609. })
  610. .catch((err) => {
  611. setError(err.message);
  612. setLoading(false);
  613. });
  614. } else {
  615. setError('Unsupported file format');
  616. setLoading(false);
  617. }
  618. // Handle resize
  619. const handleResize = () => {
  620. if (!container) return;
  621. const w = container.clientWidth;
  622. const h = container.clientHeight;
  623. camera.aspect = w / h;
  624. camera.updateProjectionMatrix();
  625. renderer.setSize(w, h);
  626. };
  627. window.addEventListener('resize', handleResize);
  628. return () => {
  629. window.removeEventListener('resize', handleResize);
  630. cancelAnimationFrame(animationId);
  631. controls.dispose();
  632. renderer.dispose();
  633. container.removeChild(renderer.domElement);
  634. modelGroupRef.current = null;
  635. plateRef.current = null;
  636. gridRef.current = null;
  637. };
  638. }, [url, buildVolume, fileType]);
  639. useEffect(() => {
  640. if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
  641. if (!parsedData && !stlGeometry) return;
  642. if (modelGroupRef.current) {
  643. sceneRef.current.remove(modelGroupRef.current);
  644. disposeGroup(modelGroupRef.current);
  645. }
  646. const isStlModel = !!stlGeometry;
  647. const group = isStlModel
  648. ? (() => {
  649. const materialColor = filamentColors?.[0] || '#00ae42';
  650. const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
  651. const mesh = new THREE.Mesh(stlGeometry!, material);
  652. const stlGroup = new THREE.Group();
  653. stlGroup.add(mesh);
  654. return stlGroup;
  655. })()
  656. : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
  657. modelGroupRef.current = group;
  658. sceneRef.current.add(group);
  659. // Get bounding box to position model
  660. const box = new THREE.Box3().setFromObject(group);
  661. const center = box.getCenter(new THREE.Vector3());
  662. // Always place models on the build plate (Y=0)
  663. group.position.y = -box.min.y;
  664. // For a selected plate, center the plate contents on the build plate
  665. const shouldRecenter = isStlModel || parsedData!.buildItems.length === 0;
  666. const centerOffsetX = shouldRecenter ? -center.x : 0;
  667. const centerOffsetZ = shouldRecenter ? -center.z : 0;
  668. let plateOffsetX = 0;
  669. let plateOffsetZ = 0;
  670. if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
  671. const plateBox = new THREE.Box3().setFromObject(group);
  672. const bounds = parsedData!.plateBounds.get(selectedPlateId);
  673. if (bounds) {
  674. plateOffsetX = plateBox.min.x - bounds.minX;
  675. plateOffsetZ = plateBox.min.z - bounds.minY;
  676. } else {
  677. const epsilon = 1e-6;
  678. plateOffsetX = Math.floor((plateBox.min.x + epsilon) / buildVolume.x) * buildVolume.x;
  679. plateOffsetZ = Math.floor((plateBox.min.z + epsilon) / buildVolume.y) * buildVolume.y;
  680. }
  681. }
  682. const plateCenterX = buildVolume.x / 2;
  683. const plateCenterZ = buildVolume.y / 2;
  684. if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
  685. group.position.x = centerOffsetX - plateOffsetX;
  686. group.position.z = centerOffsetZ - plateOffsetZ;
  687. } else if (isStlModel) {
  688. group.position.x = centerOffsetX + plateCenterX;
  689. group.position.z = centerOffsetZ + plateCenterZ;
  690. } else {
  691. group.position.x = centerOffsetX;
  692. group.position.z = centerOffsetZ;
  693. }
  694. if (plateRef.current) {
  695. plateRef.current.position.x = plateCenterX;
  696. plateRef.current.position.z = plateCenterZ;
  697. }
  698. if (gridRef.current) {
  699. gridRef.current.position.x = plateCenterX;
  700. gridRef.current.position.z = plateCenterZ;
  701. }
  702. // Recalculate bounding box after positioning
  703. const finalBox = new THREE.Box3().setFromObject(group);
  704. const finalCenter = finalBox.getCenter(new THREE.Vector3());
  705. const finalSize = finalBox.getSize(new THREE.Vector3());
  706. // Adjust camera to fit model
  707. const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
  708. const cameraDistance = maxDim * 1.8;
  709. cameraRef.current.position.set(
  710. finalCenter.x + cameraDistance * 0.7,
  711. finalCenter.y + cameraDistance * 0.5,
  712. finalCenter.z + cameraDistance * 0.7
  713. );
  714. controlsRef.current.target.copy(finalCenter);
  715. controlsRef.current.update();
  716. setLoading(false);
  717. }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
  718. const resetView = () => {
  719. if (cameraRef.current && controlsRef.current) {
  720. cameraRef.current.position.set(150, 150, 150);
  721. controlsRef.current.target.set(0, 50, 0);
  722. controlsRef.current.update();
  723. }
  724. };
  725. const zoom = (factor: number) => {
  726. if (cameraRef.current) {
  727. cameraRef.current.position.multiplyScalar(factor);
  728. }
  729. };
  730. return (
  731. <div className={`relative ${className}`}>
  732. <div ref={containerRef} className="w-full h-full min-h-[400px]" />
  733. {loading && (
  734. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  735. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  736. </div>
  737. )}
  738. {error && (
  739. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  740. <p className="text-red-400">{error}</p>
  741. </div>
  742. )}
  743. {!loading && !error && (
  744. <div className="absolute bottom-4 right-4 flex gap-2">
  745. <Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
  746. <ZoomIn className="w-4 h-4" />
  747. </Button>
  748. <Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
  749. <ZoomOut className="w-4 h-4" />
  750. </Button>
  751. <Button variant="secondary" size="sm" onClick={resetView}>
  752. <RotateCcw className="w-4 h-4" />
  753. </Button>
  754. </div>
  755. )}
  756. </div>
  757. );
  758. }