ModelViewer.tsx 40 KB

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