ModelViewer.tsx 49 KB

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