ModelViewer.tsx 34 KB

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