import '../style.css' import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { STLLoader } from 'three/addons/loaders/STLLoader.js' import type { GeometryFile, HierarchyNode } from '../../server/services/stepConverter' // ---- Data injected by viewer.ejs ----------------------------------------- declare const __STEPVIEW__: { modelId: number fileType: 'step' | 'stp' | 'stl' shareUrl: string hasPdfs: boolean hasGeometry: boolean // pre-processed geometry JSON exists (STEP/STP only) } // ---- Scene state --------------------------------------------------------- let renderer: THREE.WebGLRenderer let scene: THREE.Scene let camera: THREE.PerspectiveCamera let controls: OrbitControls let viewingDist: number = 200 // updated by fitCamera; used by fog toggle // Map from geometry-JSON mesh index → Three.js Mesh, used by the tree panel const meshObjects = new Map() // ---- UI helpers ---------------------------------------------------------- function setLoading(msg: string) { const el = document.getElementById('loading-msg') if (el) el.textContent = msg } function hideLoading() { const overlay = document.getElementById('loading-overlay') as HTMLElement overlay.style.opacity = '0' setTimeout(() => { overlay.style.display = 'none' }, 300) } function showError(msg: string) { document.getElementById('loading-overlay')!.style.display = 'none' const overlay = document.getElementById('error-overlay')! overlay.classList.remove('hidden') overlay.classList.add('flex') const msgEl = document.getElementById('error-msg') if (msgEl) msgEl.textContent = msg } function showToast(msg: string, duration = 2200) { const toast = document.getElementById('toast')! const toastMsg = document.getElementById('toast-msg')! toastMsg.textContent = msg toast.classList.remove('opacity-0', 'pointer-events-none') toast.classList.add('opacity-100') setTimeout(() => { toast.classList.add('opacity-0', 'pointer-events-none') toast.classList.remove('opacity-100') }, duration) } // ---- Scene setup --------------------------------------------------------- function buildScene(canvas: HTMLCanvasElement) { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.setSize(window.innerWidth, window.innerHeight) renderer.setClearColor(0x0a0a0f, 1) renderer.shadowMap.enabled = true renderer.shadowMap.type = THREE.PCFSoftShadowMap renderer.outputColorSpace = THREE.SRGBColorSpace renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 1.1 scene = new THREE.Scene() camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.001, 10000) camera.position.set(5, 3, 8) // Lighting — no fog: FogExp2 with a fixed density darkens large models // whose camera distance exceeds ~200 units, making them appear black. const ambient = new THREE.AmbientLight(0xffffff, 0.9) scene.add(ambient) const key = new THREE.DirectionalLight(0xffffff, 1.8) key.position.set(1, 2, 1.5) key.castShadow = true key.shadow.mapSize.set(2048, 2048) key.shadow.camera.near = 0.1 key.shadow.camera.far = 500 scene.add(key) const fill = new THREE.DirectionalLight(0x8899cc, 0.4) fill.position.set(-2, 0.5, -1) scene.add(fill) const rim = new THREE.DirectionalLight(0xffffff, 0.25) rim.position.set(0, -1, -2) scene.add(rim) controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true controls.dampingFactor = 0.06 controls.minDistance = 0.001 controls.maxDistance = 5000 controls.panSpeed = 0.8 controls.rotateSpeed = 0.6 controls.zoomSpeed = 1.2 window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) }) } // ---- Render loop --------------------------------------------------------- function startRenderLoop() { const clock = new THREE.Clock() function tick() { requestAnimationFrame(tick) controls.update() renderer.render(scene, camera) void clock } tick() } // ---- Camera fit ---------------------------------------------------------- function fitCamera(object: THREE.Object3D) { const box = new THREE.Box3().setFromObject(object) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) if (maxDim === 0) return const fovRad = camera.fov * (Math.PI / 180) const dist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.6 viewingDist = dist camera.near = maxDim * 0.0005 camera.far = maxDim * 200 camera.updateProjectionMatrix() camera.position.set( center.x + dist * 0.55, center.y + dist * 0.35, center.z + dist, ) camera.lookAt(center) controls.target.copy(center) controls.update() } // ---- Ground grid --------------------------------------------------------- function addGrid(object: THREE.Object3D) { const box = new THREE.Box3().setFromObject(object) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.z) * 3 const grid = new THREE.GridHelper(maxDim, 20, 0x1a1a2a, 0x1a1a2a) grid.position.set(center.x, box.min.y - 0.001, center.z) scene.add(grid) } // ---- Material factory ---------------------------------------------------- function makeMaterial(color: [number, number, number] | null): THREE.MeshStandardMaterial { const c = color ? new THREE.Color(color[0], color[1], color[2]) : new THREE.Color(0x8fa3b8) return new THREE.MeshStandardMaterial({ color: c, roughness: 0.45, metalness: 0.25, side: THREE.DoubleSide, }) } // ---- Binary decode helpers (geometry v2) --------------------------------- function b64ToFloat32(b64: string): Float32Array { const bin = atob(b64) const buf = new ArrayBuffer(bin.length) const u8 = new Uint8Array(buf) for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i) return new Float32Array(buf) } function b64ToUint32(b64: string): Uint32Array { const bin = atob(b64) const buf = new ArrayBuffer(bin.length) const u8 = new Uint8Array(buf) for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i) return new Uint32Array(buf) } // ---- STEP/STP loader (geometry JSON, pre-processed server-side) ---------- async function loadStepGeometry(): Promise { setLoading('Fetching geometry…') const res = await fetch(`/files/geometry/${__STEPVIEW__.modelId}`) if (!res.ok) { const body = await res.json().catch(() => ({})) as { processing?: boolean } if (body.processing) { throw new Error('This model is still being processed. Please try again in a moment.') } throw new Error(`Could not load geometry (HTTP ${res.status})`) } // Stream the response body so there is visible activity during large downloads let text: string if (res.body) { const reader = res.body.getReader() const chunks: Uint8Array[] = [] let loaded = 0 while (true) { const { done, value } = await reader.read() if (done) break chunks.push(value) loaded += value.byteLength setLoading(`Downloading geometry… ${Math.round(loaded / 1024)} KB`) } setLoading('Parsing geometry…') text = new TextDecoder().decode( chunks.reduce((acc, c) => { const m = new Uint8Array(acc.length + c.length); m.set(acc); m.set(c, acc.length); return m }, new Uint8Array(0)) ) } else { text = await res.text() } // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = JSON.parse(text) as GeometryFile & { version: number; meshes: any[] } if (!data.meshes || data.meshes.length === 0) { throw new Error('Geometry file contains no meshes.') } setLoading('Building 3D scene…') const group = new THREE.Group() const isV2 = data.version >= 2 for (let i = 0; i < data.meshes.length; i++) { const mesh = data.meshes[i] const geo = new THREE.BufferGeometry() let positions: Float32Array let normals: Float32Array | null let indices: Uint32Array | null if (isV2) { positions = b64ToFloat32(mesh.positions) normals = mesh.normals ? b64ToFloat32(mesh.normals) : null indices = mesh.indices ? b64ToUint32(mesh.indices) : null } else { // Legacy v1: plain number arrays positions = new Float32Array(mesh.positions as number[]) normals = mesh.normals ? new Float32Array(mesh.normals as number[]) : null indices = mesh.indices ? new Uint32Array(mesh.indices as number[]) : null } geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) if (normals && normals.length > 0) { geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) } if (indices && indices.length > 0) { geo.setIndex(new THREE.Uint32BufferAttribute(indices, 1)) } if (!normals || normals.length === 0) { geo.computeVertexNormals() } const threeMesh = new THREE.Mesh(geo, makeMaterial(mesh.color)) meshObjects.set(i, threeMesh) group.add(threeMesh) } scene.add(group) fitCamera(group) addGrid(group) // Diagnostics — always log tree state so browser console can confirm what arrived console.log('[StepView] geometry version:', data.version) console.log('[StepView] mesh count:', data.meshes.length) console.log('[StepView] data.tree:', data.tree) const treeContent = document.getElementById('tree-content') if (!treeContent) return // not a STEP model or panel removed if (!data.tree) { treeContent.innerHTML = '

No tree data — open the admin panel, edit this model, and click Retry Processing to rebuild geometry with tree support.

' return } const nodeCount = countNodes(data.tree) console.log('[StepView] tree node count:', nodeCount) if (nodeCount === 0) { treeContent.innerHTML = '

This file has no named components in its hierarchy.

' return } wireTreePanel(data.tree) } function countNodes(node: HierarchyNode): number { return 1 + node.children.reduce((acc, c) => acc + countNodes(c), 0) } // ---- STL loader (client-side, Three.js built-in) ------------------------- async function loadStl(): Promise { setLoading('Fetching STL file…') const loader = new STLLoader() const geometry = await new Promise((resolve, reject) => { loader.load( `/files/model/${__STEPVIEW__.modelId}`, resolve, (xhr) => { if (xhr.total) { const pct = Math.round((xhr.loaded / xhr.total) * 100) setLoading(`Downloading STL… ${pct}%`) } }, reject, ) }) setLoading('Building 3D scene…') geometry.computeVertexNormals() // Center geometry at origin geometry.computeBoundingBox() const center = new THREE.Vector3() geometry.boundingBox!.getCenter(center) geometry.translate(-center.x, -center.y, -center.z) const mesh = new THREE.Mesh(geometry, makeMaterial(null)) mesh.castShadow = true mesh.receiveShadow = true scene.add(mesh) fitCamera(mesh) addGrid(mesh) } // ---- Model tree ---------------------------------------------------------- function setNodeVisible(node: HierarchyNode, visible: boolean) { for (const idx of node.meshes) { const m = meshObjects.get(idx) if (m) m.visible = visible } for (const child of node.children) setNodeVisible(child, visible) } function buildTreeNode(node: HierarchyNode, depth: number): HTMLElement { const label = node.name.trim() || 'Solid' const hasChildren = node.children.length > 0 const wrapper = document.createElement('div') const row = document.createElement('div') row.className = 'flex items-center gap-1 py-1 rounded-lg hover:bg-white/5 group select-none' row.style.paddingLeft = `${6 + depth * 14}px` row.style.paddingRight = '6px' // Chevron const chevron = document.createElement('span') chevron.className = `w-3.5 h-3.5 shrink-0 text-gray-600 transition-transform duration-150 ${hasChildren ? 'cursor-pointer' : 'invisible'}` chevron.innerHTML = `` // Eye toggle const EYE_ON = `` const EYE_OFF = `` const eyeBtn = document.createElement('button') eyeBtn.className = 'w-3.5 h-3.5 shrink-0 text-gray-500 hover:text-white transition-colors opacity-0 group-hover:opacity-100' eyeBtn.title = 'Toggle visibility' eyeBtn.innerHTML = EYE_ON let visible = true eyeBtn.addEventListener('click', (e) => { e.stopPropagation() visible = !visible eyeBtn.innerHTML = visible ? EYE_ON : EYE_OFF eyeBtn.style.opacity = visible ? '' : '1' eyeBtn.style.color = visible ? '' : 'rgb(248 113 113)' nameEl.style.opacity = visible ? '' : '0.3' setNodeVisible(node, visible) }) // Name const nameEl = document.createElement('span') nameEl.className = 'text-xs text-gray-300 truncate flex-1' nameEl.textContent = label row.append(chevron, eyeBtn, nameEl) wrapper.append(row) if (hasChildren) { const childrenEl = document.createElement('div') for (const child of node.children) { childrenEl.append(buildTreeNode(child, depth + 1)) } let expanded = true chevron.style.transform = 'rotate(90deg)' const toggle = () => { expanded = !expanded chevron.style.transform = expanded ? 'rotate(90deg)' : '' childrenEl.style.display = expanded ? '' : 'none' } chevron.addEventListener('click', (e) => { e.stopPropagation(); toggle() }) row.addEventListener('click', toggle) wrapper.append(childrenEl) } return wrapper } function wireTreePanel(root: HierarchyNode) { const content = document.getElementById('tree-content') if (!content) return // Skip an unnamed root wrapper — go straight to its children const topNodes = (!root.name && root.children.length > 0) ? root.children : [root] content.innerHTML = '' for (const node of topNodes) { content.append(buildTreeNode(node, 0)) } } // ---- Viewer toolbar ------------------------------------------------------ function wireToolbar() { // Model tree panel toggle (always wired; content populated after geometry loads) const treePanel = document.getElementById('tree-panel') const treeBtn = document.getElementById('tree-toggle-btn') const treeClose = document.getElementById('tree-close-btn') if (treePanel) { treeBtn?.addEventListener('click', () => { const closing = !treePanel.classList.contains('closed') treePanel.classList.toggle('closed') treeBtn.classList.toggle('text-accent', !closing) }) treeClose?.addEventListener('click', () => { treePanel.classList.add('closed') treeBtn?.classList.remove('text-accent') }) } // Copy-link const copyBtn = document.getElementById('copy-link-btn') as HTMLButtonElement | null if (copyBtn) { copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(__STEPVIEW__.shareUrl) } catch { const ta = Object.assign(document.createElement('textarea'), { value: __STEPVIEW__.shareUrl, style: 'position:fixed;opacity:0', }) document.body.appendChild(ta) ta.select() document.execCommand('copy') document.body.removeChild(ta) } const orig = copyBtn.innerHTML copyBtn.textContent = '✓ Copied!' setTimeout(() => { copyBtn.innerHTML = orig }, 2000) }) } // Reset camera const resetBtn = document.getElementById('reset-camera-btn') if (resetBtn) { resetBtn.addEventListener('click', () => { // Re-fit camera to scene objects (exclude grid) const objects = scene.children.filter(c => !(c instanceof THREE.GridHelper)) const group = new THREE.Group() objects.forEach(o => group.add(o.clone())) if (group.children.length) fitCamera(group) }) } // Fog toggle — off by default; density scaled to the loaded model's viewing distance let fogOn = false const fogBtn = document.getElementById('fog-btn') if (fogBtn) { fogBtn.addEventListener('click', () => { fogOn = !fogOn scene.fog = fogOn ? new THREE.FogExp2(0x0a0a0f, 0.5 / viewingDist) : null fogBtn.classList.toggle('text-accent', fogOn) }) } // Wireframe toggle let wireframe = false const wireBtn = document.getElementById('wireframe-btn') if (wireBtn) { wireBtn.addEventListener('click', () => { wireframe = !wireframe scene.traverse(obj => { if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) { obj.material.wireframe = wireframe } }) wireBtn.classList.toggle('text-accent', wireframe) }) } // PDF panel const pdfToggle = document.getElementById('pdf-toggle-btn') const pdfPanel = document.getElementById('pdf-panel') const pdfClose = document.getElementById('pdf-close-btn') if (pdfToggle && pdfPanel) { pdfToggle.addEventListener('click', () => pdfPanel.classList.toggle('closed')) } if (pdfClose && pdfPanel) { pdfClose.addEventListener('click', () => pdfPanel.classList.add('closed')) } // PDF tabs document.querySelectorAll('.pdf-tab').forEach(tab => { tab.addEventListener('click', () => { const idx = tab.dataset.tab document.querySelectorAll('.pdf-tab').forEach(t => { t.classList.remove('border-accent', 'text-white') t.classList.add('border-transparent', 'text-gray-500') }) tab.classList.add('border-accent', 'text-white') tab.classList.remove('border-transparent', 'text-gray-500') document.querySelectorAll('.pdf-frame').forEach(f => { f.classList.toggle('hidden', f.dataset.frame !== idx) }) }) }) } // ---- Boot ---------------------------------------------------------------- async function boot() { const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement try { buildScene(canvas) startRenderLoop() wireToolbar() if (__STEPVIEW__.fileType === 'stl') { await loadStl() } else { // STEP / STP if (!__STEPVIEW__.hasGeometry) { throw new Error( 'This model has not finished processing yet. ' + 'Please check back shortly or contact your administrator.' ) } await loadStepGeometry() } hideLoading() } catch (err) { console.error('[StepView]', err) showError(err instanceof Error ? err.message : String(err)) } } boot() export {}