ModelViewer.tsx 46 KB

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