import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import * as THREE from 'three'; // @ts-ignore import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; // @ts-ignore import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; @Component({ selector: 'app-stl-viewer', standalone: true, imports: [CommonModule], template: `
@if (loading) {
Loading 3D Model...
}
`, styles: [` .viewer-container { width: 100%; height: 300px; background: var(--color-neutral-50); border-radius: var(--radius-lg); border: 1px solid var(--color-border); overflow: hidden; position: relative; } .loading-overlay { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.8); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; z-index: 10; color: var(--color-text-muted); } .spinner { width: 32px; height: 32px; border: 3px solid var(--color-neutral-200); border-top-color: var(--color-brand); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } `] }) export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { @Input() file: File | null = null; @ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef; private scene!: THREE.Scene; private camera!: THREE.PerspectiveCamera; private renderer!: THREE.WebGLRenderer; private controls!: OrbitControls; private animationId: number | null = null; private currentMesh: THREE.Mesh | null = null; loading = false; ngOnInit() { this.initThree(); } ngOnChanges(changes: SimpleChanges) { if (changes['file'] && this.file) { this.loadFile(this.file); } } ngOnDestroy() { if (this.animationId) cancelAnimationFrame(this.animationId); if (this.renderer) this.renderer.dispose(); } private initThree() { const width = this.rendererContainer.nativeElement.clientWidth; const height = this.rendererContainer.nativeElement.clientHeight; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50 // Lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(1, 1, 1); this.scene.add(directionalLight); // Camera this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); this.camera.position.z = 100; // Renderer this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(width, height); this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); // Controls this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.animate(); // Handle resize const resizeObserver = new ResizeObserver(() => { if (!this.rendererContainer) return; const w = this.rendererContainer.nativeElement.clientWidth; const h = this.rendererContainer.nativeElement.clientHeight; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); }); resizeObserver.observe(this.rendererContainer.nativeElement); } private loadFile(file: File) { this.loading = true; const reader = new FileReader(); reader.onload = (event) => { try { const loader = new STLLoader(); const geometry = loader.parse(event.target?.result as ArrayBuffer); if (this.currentMesh) { this.scene.remove(this.currentMesh); this.currentMesh.geometry.dispose(); } const material = new THREE.MeshPhongMaterial({ color: 0xFACF0A, // Brand color specular: 0x111111, shininess: 200 }); this.currentMesh = new THREE.Mesh(geometry, material); // Center geometry geometry.computeBoundingBox(); geometry.center(); // Rotate to stand upright (usually necessary for STLs) this.currentMesh.rotation.x = -Math.PI / 2; this.scene.add(this.currentMesh); // Adjust camera to fit object const boundingBox = geometry.boundingBox!; const size = new THREE.Vector3(); boundingBox.getSize(size); const maxDim = Math.max(size.x, size.y, size.z); const fov = this.camera.fov * (Math.PI / 180); let cameraZ = Math.abs(maxDim / 2 * Math.tan(fov * 2)); // Basic fit cameraZ *= 2.5; // Zoom out a bit this.camera.position.z = cameraZ; this.camera.updateProjectionMatrix(); } catch (err) { console.error('Error loading STL:', err); } finally { this.loading = false; } }; reader.readAsArrayBuffer(file); } private animate() { this.animationId = requestAnimationFrame(() => this.animate()); if (this.controls) this.controls.update(); if (this.renderer && this.scene && this.camera) { this.renderer.render(this.scene, this.camera); } } }