| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277 |
- import { useEffect, useRef, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import * as THREE from 'three';
- import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
- import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
- import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
- import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
- import JSZip from 'jszip';
- import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
- import { Button } from './Button';
- import { getAuthToken } from '../api/client';
- import type { StepWorkerMesh, StepWorkerResponse } from '../workers/stepPreview.worker';
- // STEP triangulation runs in a dedicated worker (#2976): OpenCascade-as-WASM
- // takes seconds on a real assembly and its embind glue needs an eval-relaxed
- // CSP that only the worker's own script response carries (see the worker
- // file and security_headers_middleware in backend/app/main.py). The worker —
- // and with it the ~7 MB wasm — loads on the first STEP preview and is then
- // kept warm for the page's lifetime.
- let stepWorker: Worker | null = null;
- let stepRequestId = 0;
- const stepPendingRequests = new Map<
- number,
- { resolve: (meshes: StepWorkerMesh[]) => void; reject: (err: Error) => void }
- >();
- function failAllStepRequests(message: string): void {
- for (const pending of stepPendingRequests.values()) {
- pending.reject(new Error(message));
- }
- stepPendingRequests.clear();
- }
- function getStepWorker(): Worker {
- if (!stepWorker) {
- const worker = new Worker(new URL('../workers/stepPreview.worker.ts', import.meta.url), { type: 'module' });
- worker.onmessage = (event: MessageEvent<StepWorkerResponse>) => {
- const pending = stepPendingRequests.get(event.data.id);
- if (!pending) return;
- stepPendingRequests.delete(event.data.id);
- if (event.data.ok) {
- pending.resolve(event.data.meshes);
- } else {
- pending.reject(new Error(event.data.reason));
- }
- };
- // A crashed or unloadable worker is dropped so the next preview attempt
- // starts a fresh one instead of reusing a dead instance.
- worker.onerror = () => {
- failAllStepRequests('error');
- worker.terminate();
- if (stepWorker === worker) stepWorker = null;
- };
- stepWorker = worker;
- }
- return stepWorker;
- }
- function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
- return new Promise((resolve, reject) => {
- const id = ++stepRequestId;
- stepPendingRequests.set(id, { resolve, reject });
- getStepWorker().postMessage({ id, buffer }, [buffer]);
- });
- }
- // How far the user may dolly out, as a multiple of the framed distance.
- const MAX_ZOOM_OUT = 8;
- /**
- * Frame the camera on a bounding box.
- *
- * The previous heuristic was `maxDim * 1.8`, which ignores both the camera's
- * field of view and the viewport's aspect ratio. In a tall, narrow panel the
- * horizontal field of view is much narrower than the vertical one, so that
- * distance pushed the model into the middle of the frame with a screenful of
- * empty space above it. Solving the distance from the bounding *sphere*
- * against both fields of view fills the frame at any viewport shape.
- */
- function fitCameraToBox(
- camera: THREE.PerspectiveCamera,
- controls: OrbitControls,
- box: THREE.Box3,
- plateDiagonal: number,
- padding = 1.15,
- ): void {
- const size = box.getSize(new THREE.Vector3());
- const center = box.getCenter(new THREE.Vector3());
- // Circumscribed sphere: conservative, so the model never crops on rotation.
- const radius = Math.max(size.length() / 2, 0.001);
- const vFov = THREE.MathUtils.degToRad(camera.fov);
- const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
- const distance = padding * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
- // Keep the established three-quarter view; only the distance changes.
- const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
- camera.position.copy(center).addScaledVector(direction, distance);
- // Zoom range relative to the framed distance. Without the upper bound the
- // wheel dollied straight through the far plane: five or six notches out and
- // the model, plate and grid all vanished, which reads as "zoom is broken".
- controls.minDistance = Math.max(radius * 0.05, 0.05);
- controls.maxDistance = distance * MAX_ZOOM_OUT;
- // Clip planes scaled to the subject, so a small model doesn't z-fight and a
- // large one isn't sliced by the far plane. The far plane has to cover the
- // whole dolly range plus the plate, whose diagonal dwarfs a small model.
- camera.near = Math.max(distance / 1000, 0.01);
- camera.far = controls.maxDistance + radius * 4 + plateDiagonal;
- camera.updateProjectionMatrix();
- controls.target.copy(center);
- controls.update();
- }
- interface BuildVolume {
- x: number;
- y: number;
- z: number;
- }
- // Module-level so the default keeps its identity: an inline default object is
- // new on every render and, as an effect dependency, would rebuild the whole
- // scene each time the component re-renders.
- const DEFAULT_BUILD_VOLUME: BuildVolume = { x: 256, y: 256, z: 256 };
- interface ModelViewerProps {
- url: string;
- fileType?: string;
- buildVolume?: BuildVolume;
- filamentColors?: string[];
- selectedPlateId?: number | null;
- className?: string;
- /** Called once with a 256px PNG of the first render — used by the file
- * manager to persist a thumbnail for formats the server cannot render
- * itself (STEP, #2976). */
- onSnapshot?: (blob: Blob) => void;
- }
- // Triangulated STEP shape as posted back by the step preview worker.
- type StepMeshData = StepWorkerMesh;
- interface MeshData {
- vertices: number[];
- triangles: number[];
- extruder: number; // Per-mesh extruder index for coloring
- }
- interface ObjectData {
- id: string;
- meshes: MeshData[];
- defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
- plateId?: number | null;
- }
- interface BuildItem {
- objectId: string;
- transform: THREE.Matrix4;
- extruder?: number; // Can override object's extruder
- plateId?: number | null;
- }
- interface Parsed3MFData {
- objects: Map<string, ObjectData>;
- buildItems: BuildItem[];
- plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
- plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
- }
- // Yield to the browser event loop so the main thread can repaint, process
- // user input (especially the modal's close button), and avoid the
- // "page unresponsive" dialog while we crunch through large 3MFs in
- // straight-line JS. setTimeout(_, 0) is sufficient — we don't need rAF
- // here, the goal is just to surrender control so queued tasks run.
- function nextTick(): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, 0));
- }
- // Yield once per N iterations of a hot loop. Picked so each batch is
- // ~5-10 ms of work on a typical desktop — fine-grained enough to keep
- // frames flowing, coarse enough not to drown the loop in setTimeout
- // dispatch overhead. Adjust if profiling shows otherwise.
- const YIELD_EVERY_N_VERTICES = 20000;
- const YIELD_EVERY_N_TRIANGLES = 20000;
- // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
- function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
- const matrix = new THREE.Matrix4();
- if (!transformStr) {
- return matrix; // Identity matrix
- }
- // 3MF transform is a 3x4 affine matrix in row-major order:
- // "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
- // Where (m30, m31, m32) is the translation vector
- const values = transformStr.trim().split(/\s+/).map(parseFloat);
- if (values.length >= 12) {
- // Three.js Matrix4.set takes row-major order arguments:
- // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
- // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
- matrix.set(
- values[0], values[1], values[2], values[9], // m00, m01, m02, tx
- values[3], values[4], values[5], values[10], // m10, m11, m12, ty
- values[6], values[7], values[8], values[11], // m20, m21, m22, tz
- 0, 0, 0, 1
- );
- }
- return matrix;
- }
- // Alias for backwards compatibility
- const parseTransform = parseTransform3MF;
- async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise<MeshData[]> {
- const meshes: MeshData[] = [];
- const meshElements = doc.getElementsByTagName('mesh');
- for (let j = 0; j < meshElements.length; j++) {
- const meshEl = meshElements[j];
- const vertices: number[] = [];
- const triangles: number[] = [];
- const vertexElements = meshEl.getElementsByTagName('vertex');
- for (let k = 0; k < vertexElements.length; k++) {
- const v = vertexElements[k];
- vertices.push(
- parseFloat(v.getAttribute('x') || '0'),
- parseFloat(v.getAttribute('y') || '0'),
- parseFloat(v.getAttribute('z') || '0')
- );
- if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) {
- await nextTick();
- }
- }
- const triangleElements = meshEl.getElementsByTagName('triangle');
- for (let k = 0; k < triangleElements.length; k++) {
- const t = triangleElements[k];
- triangles.push(
- parseInt(t.getAttribute('v1') || '0'),
- parseInt(t.getAttribute('v2') || '0'),
- parseInt(t.getAttribute('v3') || '0')
- );
- if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) {
- await nextTick();
- }
- }
- if (vertices.length > 0 && triangles.length > 0) {
- meshes.push({ vertices, triangles, extruder: defaultExtruder });
- }
- }
- return meshes;
- }
- function parsePlateIdFromAttributes(element: Element): number | null {
- const plateAttribute = Array.from(element.attributes).find((attr) => {
- const name = attr.name.toLowerCase();
- return (
- name === 'plate_id' ||
- name === 'plater_id' ||
- name === 'plateid' ||
- name === 'platerid' ||
- name.endsWith(':plate_id') ||
- name.endsWith(':plater_id')
- );
- });
- if (!plateAttribute?.value) return null;
- const parsed = Number.parseInt(plateAttribute.value, 10);
- return Number.isFinite(parsed) ? parsed : null;
- }
- async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
- let zip: JSZip;
- try {
- zip = await JSZip.loadAsync(arrayBuffer);
- } catch {
- throw new Error('Unsupported file format');
- }
- const objects = new Map<string, ObjectData>();
- const buildItems: BuildItem[] = [];
- const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
- const plateOffsets = new Map<number, { offsetX: number; offsetY: number }>();
- const parser = new DOMParser();
- // Helper to load and parse a model file from the zip
- async function loadModelFile(path: string): Promise<Document | null> {
- // Normalize path (remove leading slash)
- const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
- const file = zip.files[normalizedPath];
- if (!file) return null;
- const content = await file.async('string');
- return parser.parseFromString(content, 'application/xml');
- }
- // Parse model_settings.config to get extruder assignments
- // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
- const extruderMapById = new Map<string, number>();
- const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
- const objectNameById = new Map<string, string>();
- const plateAssignmentsByObjectId = new Map<string, number>();
- const modelSettingsFile = zip.files['Metadata/model_settings.config'];
- if (modelSettingsFile) {
- try {
- const content = await modelSettingsFile.async('string');
- const doc = parser.parseFromString(content, 'application/xml');
- const objectElements = doc.getElementsByTagName('object');
- for (let i = 0; i < objectElements.length; i++) {
- const objEl = objectElements[i];
- const objectId = objEl.getAttribute('id');
- if (!objectId) continue;
- // Find object-level extruder + name
- const directMetadata = Array.from(objEl.children).filter(
- (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
- );
- if (directMetadata.length > 0) {
- const extruderVal = directMetadata[0].getAttribute('value');
- if (extruderVal) {
- extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
- }
- }
- const nameMetadata = Array.from(objEl.children).find(
- (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
- );
- const objectName = nameMetadata?.getAttribute('value');
- if (objectName) {
- objectNameById.set(objectId, objectName);
- }
- // Find part-level extruders
- const partElements = objEl.getElementsByTagName('part');
- for (let j = 0; j < partElements.length; j++) {
- const partEl = partElements[j];
- const partId = partEl.getAttribute('id');
- if (!partId) continue;
- // Look for extruder in part's direct children
- const partMetadata = Array.from(partEl.children).filter(
- (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
- );
- if (partMetadata.length > 0) {
- const extruderVal = partMetadata[0].getAttribute('value');
- if (extruderVal) {
- partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));
- }
- }
- }
- }
- // Parse plate -> object assignments
- const plateElements = doc.getElementsByTagName('plate');
- for (let i = 0; i < plateElements.length; i++) {
- const plateEl = plateElements[i];
- let plateId: number | null = null;
- const metadataElements = plateEl.getElementsByTagName('metadata');
- let plateOffsetX = 0;
- let plateOffsetY = 0;
- for (let j = 0; j < metadataElements.length; j++) {
- const metaEl = metadataElements[j];
- const key = metaEl.getAttribute('key');
- if (key === 'plater_id' || key === 'plate_id') {
- const value = metaEl.getAttribute('value');
- if (value) {
- const parsed = Number.parseInt(value, 10);
- if (Number.isFinite(parsed)) {
- plateId = parsed;
- }
- }
- } else if (key === 'pos_x') {
- const value = metaEl.getAttribute('value');
- const parsed = value ? Number.parseFloat(value) : Number.NaN;
- if (Number.isFinite(parsed)) {
- plateOffsetX = parsed;
- }
- } else if (key === 'pos_y') {
- const value = metaEl.getAttribute('value');
- const parsed = value ? Number.parseFloat(value) : Number.NaN;
- if (Number.isFinite(parsed)) {
- plateOffsetY = parsed;
- }
- }
- }
- if (plateId == null) continue;
- if (plateOffsetX !== 0 || plateOffsetY !== 0) {
- plateOffsets.set(plateId, { offsetX: plateOffsetX, offsetY: plateOffsetY });
- }
- const modelInstances = plateEl.getElementsByTagName('model_instance');
- for (let j = 0; j < modelInstances.length; j++) {
- const instanceEl = modelInstances[j];
- const instanceMetadata = instanceEl.getElementsByTagName('metadata');
- for (let k = 0; k < instanceMetadata.length; k++) {
- const metaEl = instanceMetadata[k];
- if (metaEl.getAttribute('key') === 'object_id') {
- const value = metaEl.getAttribute('value');
- if (value) {
- plateAssignmentsByObjectId.set(value, plateId);
- }
- }
- }
- }
- }
- } catch {
- // Silently ignore model_settings.config parsing errors
- }
- }
- // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
- const plateAssignmentsByName = new Map<string, number>();
- const plateJsonNames = Object.keys(zip.files).filter(
- (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
- );
- for (const name of plateJsonNames) {
- const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
- if (!match) continue;
- const plateIndex = Number.parseInt(match[1], 10);
- if (!Number.isFinite(plateIndex)) continue;
- try {
- const payload = await zip.files[name].async('string');
- const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
- const objectsList = json.bbox_objects ?? [];
- for (const entry of objectsList) {
- if (entry?.name) {
- plateAssignmentsByName.set(entry.name, plateIndex);
- }
- }
- if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
- const [minX, minY, maxX, maxY] = json.bbox_all;
- if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
- plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
- }
- }
- } catch {
- // Ignore plate json parsing errors
- }
- }
- // Find the main 3D model file
- const mainModelPath = Object.keys(zip.files).find(
- (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
- );
- if (!mainModelPath) {
- // Fallback: try to find any .model file
- const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
- if (anyModelPath) {
- const doc = await loadModelFile(anyModelPath);
- if (doc) {
- const meshes = await parseMeshFromDoc(doc, 0);
- if (meshes.length > 0) {
- objects.set('1', { id: '1', meshes, defaultExtruder: 0 });
- }
- }
- }
- return { objects, buildItems, plateBounds, plateOffsets };
- }
- const mainDoc = await loadModelFile(mainModelPath);
- if (!mainDoc) return { objects, buildItems, plateBounds, plateOffsets };
- // Parse objects - Bambu Studio uses components to reference external files
- const objectElements = mainDoc.getElementsByTagName('object');
- for (let i = 0; i < objectElements.length; i++) {
- // Yield once per top-level object so the modal stays interactive
- // throughout the parse (#1412). Inner vertex/triangle/component
- // loops yield on their own. See nextTick() comment near the top.
- if (i > 0) {
- await nextTick();
- }
- const objEl = objectElements[i];
- const objectId = objEl.getAttribute('id');
- if (!objectId) continue;
- const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
- // Get default extruder from model_settings.config map, falling back to attribute or default
- let defaultExtruder = extruderMapById.get(objectId) ?? -1;
- if (defaultExtruder < 0) {
- const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1';
- defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
- }
- const meshes: MeshData[] = [];
- // Check for direct mesh in this object
- const objMeshElements = objEl.getElementsByTagName('mesh');
- for (let j = 0; j < objMeshElements.length; j++) {
- const meshEl = objMeshElements[j];
- const vertices: number[] = [];
- const triangles: number[] = [];
- const vertexElements = meshEl.getElementsByTagName('vertex');
- for (let k = 0; k < vertexElements.length; k++) {
- const v = vertexElements[k];
- vertices.push(
- parseFloat(v.getAttribute('x') || '0'),
- parseFloat(v.getAttribute('y') || '0'),
- parseFloat(v.getAttribute('z') || '0')
- );
- if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) {
- await nextTick();
- }
- }
- const triangleElements = meshEl.getElementsByTagName('triangle');
- for (let k = 0; k < triangleElements.length; k++) {
- const t = triangleElements[k];
- triangles.push(
- parseInt(t.getAttribute('v1') || '0'),
- parseInt(t.getAttribute('v2') || '0'),
- parseInt(t.getAttribute('v3') || '0')
- );
- if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) {
- await nextTick();
- }
- }
- if (vertices.length > 0 && triangles.length > 0) {
- meshes.push({ vertices, triangles, extruder: defaultExtruder });
- }
- }
- // Check for component references (Bambu Studio style)
- const componentElements = objEl.getElementsByTagName('component');
- for (let j = 0; j < componentElements.length; j++) {
- // Yield before each component — each one triggers another async file
- // load + DOM parse + vertex/triangle iteration. Multi-color "parted"
- // statues from MakerWorld can have dozens of components; without
- // this yield the whole chain runs as one long synchronous burst
- // between awaits and freezes the modal close button (#1412).
- await nextTick();
- const compEl = componentElements[j];
- // p:path attribute contains the external file reference
- const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
- // objectid in component corresponds to part id in model_settings
- const compObjectId = compEl.getAttribute('objectid');
- if (extPath) {
- const extDoc = await loadModelFile(extPath);
- if (extDoc) {
- // Look up per-part extruder, falling back to object's default
- const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
- const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder;
- const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
- // Apply component transform if present
- const compTransformStr = compEl.getAttribute('transform');
- const compTransform = parseTransform(compTransformStr);
- for (const mesh of extMeshes) {
- if (compTransformStr) {
- // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
- const transformedVertices: number[] = [];
- for (let k = 0; k < mesh.vertices.length; k += 3) {
- const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
- v.applyMatrix4(compTransform);
- transformedVertices.push(v.x, v.y, v.z);
- }
- meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });
- } else {
- meshes.push(mesh);
- }
- }
- }
- }
- }
- if (meshes.length > 0) {
- objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
- }
- }
- // Parse build items (placement on build plate)
- const buildElements = mainDoc.getElementsByTagName('build');
- if (buildElements.length > 0) {
- const itemElements = buildElements[0].getElementsByTagName('item');
- for (let i = 0; i < itemElements.length; i++) {
- const itemEl = itemElements[i];
- const objectId = itemEl.getAttribute('objectid');
- if (!objectId) continue;
- const transform = parseTransform(itemEl.getAttribute('transform'));
- const itemPlateId = parsePlateIdFromAttributes(itemEl);
- const objectPlateId = objects.get(objectId)?.plateId ?? null;
- const objectName = objectNameById.get(objectId);
- const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
- buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
- }
- }
- return { objects, buildItems, plateBounds, plateOffsets };
- }
- function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
- const geometry = new THREE.BufferGeometry();
- // Convert from 3MF Z-up to Three.js Y-up coordinate system
- // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
- const positions = new Float32Array(mesh.vertices.length);
- for (let i = 0; i < mesh.vertices.length; i += 3) {
- positions[i] = mesh.vertices[i]; // X stays X
- positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
- positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
- }
- geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- geometry.setIndex(mesh.triangles);
- // Compute normals
- geometry.computeVertexNormals();
- return geometry;
- }
- function disposeGroup(group: THREE.Group) {
- group.traverse((child) => {
- if (child instanceof THREE.Mesh) {
- child.geometry.dispose();
- if (Array.isArray(child.material)) {
- for (const material of child.material) {
- material.dispose();
- }
- } else {
- child.material.dispose();
- }
- }
- });
- }
- function buildModelGroup(
- parsedData: Parsed3MFData,
- selectedPlateId: number | null,
- filamentColors?: string[],
- ): THREE.Group {
- const { objects, buildItems } = parsedData;
- const group = new THREE.Group();
- // Create materials for each extruder color
- const getMaterial = (extruder: number): THREE.MeshStandardMaterial => {
- const defaultColor = '#00ae42';
- const colorStr = filamentColors?.[extruder] || defaultColor;
- // Convert hex color string to THREE.js color
- const color = new THREE.Color(colorStr);
- // Matte plastic against the scene's environment map. Phong lit only by
- // direct lights gave every same-facing surface an identical colour, which
- // is what flattened models into silhouettes. Roughness is high because
- // FDM prints are not glossy, but not 1.0 -- a little specular is what
- // makes layer-scale surface detail legible.
- return new THREE.MeshStandardMaterial({
- color,
- roughness: 0.62,
- metalness: 0.0,
- envMapIntensity: 0.55,
- flatShading: false,
- });
- };
- // Group geometries by extruder index (using per-mesh extruder)
- const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
- const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
- const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
- ? buildItems
- : buildItems.filter((item) => item.plateId === selectedPlateId);
- const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
- // If we have build items, use them for positioning
- if (activeBuildItems.length > 0) {
- for (const item of activeBuildItems) {
- const objectData = objects.get(item.objectId);
- if (!objectData) continue;
- for (const meshData of objectData.meshes) {
- // Use mesh's extruder, or item override, or object default
- const extruder = item.extruder ?? meshData.extruder;
- // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
- const transformedVertices: number[] = [];
- for (let k = 0; k < meshData.vertices.length; k += 3) {
- const v = new THREE.Vector3(
- meshData.vertices[k],
- meshData.vertices[k + 1],
- meshData.vertices[k + 2]
- );
- v.applyMatrix4(item.transform);
- transformedVertices.push(v.x, v.y, v.z);
- }
- // Now create geometry with coordinate conversion
- const geometry = createGeometryFromMesh({
- vertices: transformedVertices,
- triangles: meshData.triangles,
- extruder: extruder,
- });
- if (!geometriesByExtruder.has(extruder)) {
- geometriesByExtruder.set(extruder, []);
- }
- geometriesByExtruder.get(extruder)!.push(geometry);
- }
- }
- } else {
- // Fallback: just add all objects without transforms
- for (const objectData of objects.values()) {
- for (const meshData of objectData.meshes) {
- // Use per-mesh extruder
- const extruder = meshData.extruder;
- const geometry = createGeometryFromMesh(meshData);
- if (!geometriesByExtruder.has(extruder)) {
- geometriesByExtruder.set(extruder, []);
- }
- geometriesByExtruder.get(extruder)!.push(geometry);
- }
- }
- }
- // Create meshes for each extruder group
- for (const [extruder, geometries] of geometriesByExtruder) {
- if (geometries.length === 0) continue;
- const mergedGeometry = geometries.length === 1
- ? geometries[0]
- : mergeGeometries(geometries, false);
- if (mergedGeometry) {
- const material = getMaterial(extruder);
- const mesh = new THREE.Mesh(mergedGeometry, material);
- mesh.castShadow = true;
- group.add(mesh);
- }
- // Dispose individual geometries if merged
- if (geometries.length > 1) {
- for (const geom of geometries) {
- geom.dispose();
- }
- }
- }
- return group;
- }
- // One mesh per STEP shape so per-part colours survive; parts without a colour
- // fall back to the same filament colour the STL path uses.
- function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THREE.Group {
- const group = new THREE.Group();
- const fallback = filamentColors?.[0] || '#00ae42';
- for (const meshData of meshes) {
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
- if (meshData.normals) {
- geometry.setAttribute('normal', new THREE.BufferAttribute(meshData.normals, 3));
- }
- if (meshData.indices) {
- geometry.setIndex(new THREE.BufferAttribute(meshData.indices, 1));
- }
- if (!meshData.normals) {
- geometry.computeVertexNormals();
- }
- // STEP is Z-up like STL; three.js is Y-up.
- geometry.rotateX(-Math.PI / 2);
- const color = meshData.color
- ? new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2])
- : new THREE.Color(fallback);
- const material = new THREE.MeshStandardMaterial({
- color,
- roughness: 0.62,
- metalness: 0.0,
- envMapIntensity: 0.55,
- });
- const mesh = new THREE.Mesh(geometry, material);
- mesh.castShadow = true;
- group.add(mesh);
- }
- return group;
- }
- export function ModelViewer({
- url,
- fileType,
- buildVolume = DEFAULT_BUILD_VOLUME,
- filamentColors,
- selectedPlateId = null,
- className = '',
- onSnapshot,
- }: ModelViewerProps) {
- const { t } = useTranslation();
- const containerRef = useRef<HTMLDivElement>(null);
- const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
- const sceneRef = useRef<THREE.Scene | null>(null);
- const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
- // Held so the environment map and its generator can be released on unmount;
- // a PMREM render target is GPU memory the garbage collector cannot reclaim.
- const pmremRef = useRef<THREE.PMREMGenerator | null>(null);
- const environmentRef = useRef<THREE.Texture | null>(null);
- const keyLightRef = useRef<THREE.DirectionalLight | null>(null);
- const shadowCatcherRef = useRef<THREE.Mesh | null>(null);
- const controlsRef = useRef<OrbitControls | null>(null);
- const modelGroupRef = useRef<THREE.Group | null>(null);
- const plateRef = useRef<THREE.Mesh | null>(null);
- const gridRef = useRef<THREE.GridHelper | null>(null);
- // The last framed bounds, so Reset returns to the framed view rather than
- // a fixed camera pose that ignores where the model was placed.
- const fitBoxRef = useRef<THREE.Box3 | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
- const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
- const [stepMeshes, setStepMeshes] = useState<StepMeshData[] | null>(null);
- // Seconds spent converting a STEP file. A large export can take over a
- // minute in the OpenCascade worker, and a bare spinner for that long reads
- // as "nothing is happening" - so STEP loads say what they are doing and
- // show that time is passing.
- const [stepLoadSeconds, setStepLoadSeconds] = useState(0);
- const loadingType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
- const isStepFile = loadingType === 'step' || loadingType === 'stp';
- useEffect(() => {
- if (!loading || !isStepFile) return;
- setStepLoadSeconds(0);
- const startedAt = Date.now();
- const timer = window.setInterval(() => {
- setStepLoadSeconds(Math.floor((Date.now() - startedAt) / 1000));
- }, 1000);
- return () => window.clearInterval(timer);
- }, [loading, isStepFile]);
- // Snapshot is a one-shot per loaded url; the callback lives in a ref so its
- // identity never retriggers the (expensive) scene effects.
- const snapshotSentRef = useRef(false);
- const onSnapshotRef = useRef(onSnapshot);
- useEffect(() => {
- onSnapshotRef.current = onSnapshot;
- });
- useEffect(() => {
- if (!containerRef.current) return;
- const container = containerRef.current;
- const width = container.clientWidth;
- const height = container.clientHeight;
- // Scene
- const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x1a1a1a);
- sceneRef.current = scene;
- // Camera
- const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
- camera.position.set(150, 150, 150);
- cameraRef.current = camera;
- // Renderer
- const renderer = new THREE.WebGLRenderer({ antialias: true });
- renderer.setSize(width, height);
- // Cap the device pixel ratio: a 3x phone screen quadruples the fragment
- // load for no visible gain on a model this simple.
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- // Filmic tone mapping keeps the bright side of a saturated filament colour
- // from clipping to white, which is what made every model read as flat paint.
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
- // Deliberately below 1.0: RoomEnvironment is a bright white box, and
- // anything at or above unity clipped the lit side of a saturated
- // filament colour to white, draining the hue out of the model.
- renderer.toneMappingExposure = 0.85;
- renderer.shadowMap.enabled = true;
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
- container.appendChild(renderer.domElement);
- rendererRef.current = renderer;
- // Controls
- const controls = new OrbitControls(camera, renderer.domElement);
- controls.enableDamping = true;
- controls.dampingFactor = 0.05;
- controlsRef.current = controls;
- // Image-based lighting. A generated room gives the model a real light
- // environment -- soft gradients across curved surfaces, a hint of
- // reflection -- which is the single biggest difference between this and a
- // desktop slicer's viewport. Two directional lights on flat ambient could
- // never produce that; every surface facing the same way got the same
- // colour, so the model read as a flat silhouette.
- const pmrem = new THREE.PMREMGenerator(renderer);
- const environment = pmrem.fromScene(new RoomEnvironment(), 0.04);
- scene.environment = environment.texture;
- pmremRef.current = pmrem;
- environmentRef.current = environment.texture;
- // One key light on top, purely for the contact shadow and a highlight
- // direction; the environment supplies the fill.
- // Mostly overhead. An oblique key threw a long shadow across the whole
- // bed; a print sitting on a plate wants a contact shadow beneath it.
- const keyLight = new THREE.DirectionalLight(0xffffff, 0.75);
- keyLight.position.set(60, 260, 90);
- keyLight.castShadow = true;
- keyLight.shadow.mapSize.set(2048, 2048);
- keyLight.shadow.bias = -0.0005;
- keyLight.shadow.normalBias = 0.02;
- // Three's default shadow camera is a +/-5 unit box; on a 256mm bed the
- // model falls entirely outside it and no shadow is drawn at all.
- const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
- keyLight.shadow.camera.left = -shadowExtent;
- keyLight.shadow.camera.right = shadowExtent;
- keyLight.shadow.camera.top = shadowExtent;
- keyLight.shadow.camera.bottom = -shadowExtent;
- keyLight.shadow.camera.near = 1;
- keyLight.shadow.camera.far = shadowExtent * 6;
- keyLight.shadow.camera.updateProjectionMatrix();
- scene.add(keyLight);
- keyLightRef.current = keyLight;
- // Grid - use the larger dimension for the grid size
- const gridSize = Math.max(buildVolume.x, buildVolume.y);
- const gridDivisions = Math.ceil(gridSize / 16);
- const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
- scene.add(gridHelper);
- gridRef.current = gridHelper;
- // Build plate indicator
- const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
- const plateMaterial = new THREE.MeshBasicMaterial({
- color: 0x00ae42,
- transparent: true,
- opacity: 0.15,
- side: THREE.DoubleSide,
- });
- const plate = new THREE.Mesh(plateGeometry, plateMaterial);
- plate.rotation.x = -Math.PI / 2;
- plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
- scene.add(plate);
- plateRef.current = plate;
- // Dedicated shadow catcher just above the plate. The plate itself is an
- // unlit MeshBasicMaterial and cannot receive shadows; ShadowMaterial draws
- // nothing but the shadow, so the tinted plate shows through unchanged.
- // Without a contact shadow the model reads as pasted onto the background
- // rather than resting on the bed.
- const shadowCatcher = new THREE.Mesh(
- new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
- new THREE.ShadowMaterial({ opacity: 0.22 }),
- );
- shadowCatcher.rotation.x = -Math.PI / 2;
- shadowCatcher.position.y = -0.49;
- shadowCatcher.receiveShadow = true;
- scene.add(shadowCatcher);
- shadowCatcherRef.current = shadowCatcher;
- // Animation loop - keep it simple for reliability
- let animationId: number;
- const animate = () => {
- animationId = requestAnimationFrame(animate);
- controls.update();
- renderer.render(scene, camera);
- };
- animate();
- setLoading(true);
- setError(null);
- setParsedData(null);
- setStlGeometry(null);
- setStepMeshes(null);
- snapshotSentRef.current = false;
- const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
- // Build auth headers for fetch
- const headers: HeadersInit = {};
- const token = getAuthToken();
- if (token) {
- headers['Authorization'] = `Bearer ${token}`;
- }
- if (normalizedType === 'stl') {
- fetch(url, { headers })
- .then((res) => {
- if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
- return res.arrayBuffer();
- })
- .then((buffer) => {
- const loader = new STLLoader();
- const geometry = loader.parse(buffer);
- geometry.computeVertexNormals();
- geometry.rotateX(-Math.PI / 2);
- setStlGeometry(geometry);
- })
- .catch((err) => {
- setError(err.message);
- setLoading(false);
- });
- } else if (normalizedType === '3mf') {
- fetch(url, { headers })
- .then((res) => {
- if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
- return res.arrayBuffer();
- })
- .then(parse3MF)
- .then((parsed) => {
- if (parsed.objects.size === 0) {
- throw new Error(t('modelViewer.errors.noMeshes'));
- }
- setParsedData(parsed);
- })
- .catch((err) => {
- setError(err.message);
- setLoading(false);
- });
- } else if (normalizedType === 'step' || normalizedType === 'stp') {
- // STEP has no three.js loader; triangulation happens in the dedicated
- // OpenCascade worker (#2976), off the UI thread.
- fetch(url, { headers })
- .then((res) => {
- if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
- return res.arrayBuffer();
- })
- .then(parseStepInWorker)
- .then(setStepMeshes)
- .catch((err: Error) => {
- // Worker rejections carry machine reasons; everything else already
- // is a translated message from the fetch step above.
- const message = err.message === 'no-meshes'
- ? t('modelViewer.errors.noMeshes')
- : err.message === 'error'
- ? t('modelViewer.errors.failedToLoad')
- : err.message;
- setError(message);
- setLoading(false);
- });
- } else {
- setError(t('modelViewer.errors.unsupportedFormat'));
- setLoading(false);
- }
- // Handle resize (window + container)
- const handleResize = () => {
- if (!container) return;
- const w = container.clientWidth;
- const h = container.clientHeight;
- if (w === 0 || h === 0) return;
- camera.aspect = w / h;
- camera.updateProjectionMatrix();
- renderer.setSize(w, h);
- };
- window.addEventListener('resize', handleResize);
- // Entering or leaving fullscreen resizes the container too; the observer
- // catches that, but only after layout — resizing on the event itself
- // avoids one frame drawn at the old aspect.
- document.addEventListener('fullscreenchange', handleResize);
- const resizeObserver = new ResizeObserver(() => {
- handleResize();
- });
- resizeObserver.observe(container);
- return () => {
- window.removeEventListener('resize', handleResize);
- document.removeEventListener('fullscreenchange', handleResize);
- resizeObserver.disconnect();
- cancelAnimationFrame(animationId);
- controls.dispose();
- // The environment map is a render target; disposing the renderer alone
- // leaves it allocated on the GPU, and this viewer is opened and closed
- // repeatedly from the file manager.
- environmentRef.current?.dispose();
- environmentRef.current = null;
- pmremRef.current?.dispose();
- pmremRef.current = null;
- scene.environment = null;
- renderer.dispose();
- container.removeChild(renderer.domElement);
- modelGroupRef.current = null;
- plateRef.current = null;
- gridRef.current = null;
- keyLightRef.current = null;
- shadowCatcherRef.current = null;
- };
- }, [url, buildVolume, fileType, t]);
- useEffect(() => {
- if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
- if (!parsedData && !stlGeometry && !stepMeshes) return;
- if (modelGroupRef.current) {
- sceneRef.current.remove(modelGroupRef.current);
- disposeGroup(modelGroupRef.current);
- }
- // STL and STEP are plain single models with no plate/build-item layout;
- // they share the same centre-on-plate placement below.
- const isPlainModel = !!stlGeometry || !!stepMeshes;
- const group = stlGeometry
- ? (() => {
- const materialColor = filamentColors?.[0] || '#00ae42';
- const material = new THREE.MeshStandardMaterial({
- color: new THREE.Color(materialColor),
- roughness: 0.62,
- metalness: 0.0,
- envMapIntensity: 0.55,
- });
- const mesh = new THREE.Mesh(stlGeometry, material);
- mesh.castShadow = true;
- const stlGroup = new THREE.Group();
- stlGroup.add(mesh);
- return stlGroup;
- })()
- : stepMeshes
- ? buildStepGroup(stepMeshes, filamentColors)
- : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
- modelGroupRef.current = group;
- sceneRef.current.add(group);
- // Get bounding box to position model
- const box = new THREE.Box3().setFromObject(group);
- const center = box.getCenter(new THREE.Vector3());
- // Always place models on the build plate (Y=0)
- group.position.y = -box.min.y;
- const selectedPlateBounds = (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
- ? parsedData!.plateBounds.get(selectedPlateId)
- : undefined;
- const selectedPlateOffset = (!isPlainModel && selectedPlateId != null)
- ? parsedData!.plateOffsets.get(selectedPlateId)
- : undefined;
- const shouldCenterOnPlate = isPlainModel
- || parsedData!.buildItems.length === 0
- || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
- const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
- const centerOffsetZ = shouldCenterOnPlate ? -center.z : 0;
- let plateOffsetX = 0;
- let plateOffsetZ = 0;
- if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
- const plateBox = new THREE.Box3().setFromObject(group);
- plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
- plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
- }
- const plateCenterX = buildVolume.x / 2;
- const plateCenterZ = buildVolume.y / 2;
- if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
- group.position.x = centerOffsetX - plateOffsetX;
- group.position.z = centerOffsetZ - plateOffsetZ;
- } else if (!isPlainModel && selectedPlateId != null && selectedPlateOffset) {
- group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
- group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
- } else if (shouldCenterOnPlate) {
- group.position.x = centerOffsetX + plateCenterX;
- group.position.z = centerOffsetZ + plateCenterZ;
- } else {
- group.position.x = centerOffsetX;
- group.position.z = centerOffsetZ;
- }
- if (plateRef.current) {
- plateRef.current.position.x = plateCenterX;
- plateRef.current.position.z = plateCenterZ;
- }
- if (gridRef.current) {
- gridRef.current.position.x = plateCenterX;
- gridRef.current.position.z = plateCenterZ;
- }
- // Follows the plate, or the shadow lands on empty space beside the bed.
- if (shadowCatcherRef.current) {
- shadowCatcherRef.current.position.x = plateCenterX;
- shadowCatcherRef.current.position.z = plateCenterZ;
- }
- // Recalculate bounding box after positioning
- const finalBox = new THREE.Box3().setFromObject(group);
- // Adjust camera to fit model
- fitBoxRef.current = finalBox;
- fitCameraToBox(cameraRef.current, controlsRef.current, finalBox, Math.hypot(buildVolume.x, buildVolume.y));
- setLoading(false);
- // One-shot snapshot of the first framed render (#2976). Rendering
- // explicitly right before reading the canvas keeps the WebGL buffer
- // valid without preserveDrawingBuffer.
- if (onSnapshotRef.current && !snapshotSentRef.current) {
- snapshotSentRef.current = true;
- requestAnimationFrame(() => {
- const renderer = rendererRef.current;
- const scene = sceneRef.current;
- const camera = cameraRef.current;
- if (!renderer || !scene || !camera) return;
- try {
- renderer.render(scene, camera);
- const source = renderer.domElement;
- const size = 256;
- const side = Math.min(source.width, source.height);
- const target = document.createElement('canvas');
- target.width = size;
- target.height = size;
- const ctx = target.getContext('2d');
- if (!ctx) return;
- ctx.drawImage(source, (source.width - side) / 2, (source.height - side) / 2, side, side, 0, 0, size, size);
- target.toBlob((blob) => {
- if (blob) onSnapshotRef.current?.(blob);
- }, 'image/png');
- } catch {
- // Snapshot is best-effort; the preview itself already rendered.
- }
- });
- }
- }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
- const resetView = () => {
- const camera = cameraRef.current;
- const controls = controlsRef.current;
- if (!camera || !controls) return;
- if (fitBoxRef.current) {
- fitCameraToBox(camera, controls, fitBoxRef.current, Math.hypot(buildVolume.x, buildVolume.y));
- } else {
- camera.position.set(150, 150, 150);
- controls.target.set(0, 50, 0);
- controls.update();
- }
- };
- // Dolly along the view axis, like the wheel. Scaling the camera position
- // itself measured from the world origin, not from the orbit target at the
- // plate centre, so each press also slid the model sideways.
- const zoom = (factor: number) => {
- const camera = cameraRef.current;
- const controls = controlsRef.current;
- if (!camera || !controls) return;
- const offset = camera.position.clone().sub(controls.target);
- const distance = Math.min(controls.maxDistance, Math.max(controls.minDistance, offset.length() * factor));
- camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
- controls.update();
- };
- return (
- <div className={`relative ${className}`}>
- <div ref={containerRef} className="w-full h-full min-h-[400px]" />
- {loading && (
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-bambu-dark/80 px-6 text-center">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- {isStepFile && (
- <div role="status" className="space-y-1">
- <p className="text-sm text-white tabular-nums">
- {t('modelViewer.stepConverting', { seconds: stepLoadSeconds })}
- </p>
- <p className="text-xs text-bambu-gray">{t('modelViewer.stepSlowHint')}</p>
- </div>
- )}
- </div>
- )}
- {error && (
- <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
- <p className="text-red-400">{error}</p>
- </div>
- )}
- {!loading && !error && (
- <div className="absolute bottom-4 right-4 flex gap-2">
- <Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
- <ZoomIn className="w-4 h-4" />
- </Button>
- <Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
- <ZoomOut className="w-4 h-4" />
- </Button>
- <Button variant="secondary" size="sm" onClick={resetView}>
- <RotateCcw className="w-4 h-4" />
- </Button>
- </div>
- )}
- </div>
- );
- }
|