Files
stepview/src/client/viewer/main.ts
T
jason e31023505f
Build and Push Docker Image / build (push) Successful in 13s
model tree fix 2
2026-04-23 14:15:10 -05:00

587 lines
20 KiB
TypeScript

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<number, THREE.Mesh>()
// ---- 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<void> {
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 = '<p class="text-xs text-gray-500 px-4 py-3">No tree data — open the admin panel, edit this model, and click <strong class="text-gray-400">Retry Processing</strong> to rebuild geometry with tree support.</p>'
return
}
const nodeCount = countNodes(data.tree)
console.log('[StepView] tree node count:', nodeCount)
if (nodeCount === 0) {
treeContent.innerHTML = '<p class="text-xs text-gray-500 px-4 py-3">This file has no named components in its hierarchy.</p>'
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<void> {
setLoading('Fetching STL file…')
const loader = new STLLoader()
const geometry = await new Promise<THREE.BufferGeometry>((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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>`
// Eye toggle
const EYE_ON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`
const EYE_OFF = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/></svg>`
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<HTMLButtonElement>('.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<HTMLElement>('.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 {}