diff --git a/src/client/viewer/main.ts b/src/client/viewer/main.ts index d8703eb..abf9247 100644 --- a/src/client/viewer/main.ts +++ b/src/client/viewer/main.ts @@ -2,7 +2,7 @@ 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 } from '../../server/services/stepConverter' +import type { GeometryFile, HierarchyNode } from '../../server/services/stepConverter' // ---- Data injected by viewer.ejs ----------------------------------------- @@ -22,6 +22,9 @@ 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) { @@ -244,7 +247,8 @@ async function loadStepGeometry(): Promise { const group = new THREE.Group() const isV2 = data.version >= 2 - for (const mesh of data.meshes) { + for (let i = 0; i < data.meshes.length; i++) { + const mesh = data.meshes[i] const geo = new THREE.BufferGeometry() let positions: Float32Array @@ -273,12 +277,16 @@ async function loadStepGeometry(): Promise { geo.computeVertexNormals() } - group.add(new THREE.Mesh(geo, makeMaterial(mesh.color))) + const threeMesh = new THREE.Mesh(geo, makeMaterial(mesh.color)) + meshObjects.set(i, threeMesh) + group.add(threeMesh) } scene.add(group) fitCamera(group) addGrid(group) + + if (data.tree) wireTreePanel(data.tree) } // ---- STL loader (client-side, Three.js built-in) ------------------------- @@ -318,6 +326,109 @@ async function loadStl(): Promise { 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 single-child 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)) + } + + // Wire tree toggle button + const treePanel = document.getElementById('tree-panel')! + const toggleBtn = document.getElementById('tree-toggle-btn') + const closeBtn = document.getElementById('tree-close-btn') + + const openPanel = () => { treePanel.classList.remove('closed'); toggleBtn?.classList.add('text-accent') } + const closePanel = () => { treePanel.classList.add('closed'); toggleBtn?.classList.remove('text-accent') } + + toggleBtn?.addEventListener('click', () => + treePanel.classList.contains('closed') ? openPanel() : closePanel() + ) + closeBtn?.addEventListener('click', closePanel) +} + // ---- Viewer toolbar ------------------------------------------------------ function wireToolbar() { diff --git a/src/server/services/stepConverter.ts b/src/server/services/stepConverter.ts index 133d330..90c41a7 100644 --- a/src/server/services/stepConverter.ts +++ b/src/server/services/stepConverter.ts @@ -3,17 +3,25 @@ import fs from 'fs' import path from 'path' export interface GeometryMesh { + name: string positions: string // base64-encoded Float32Array normals: string | null // base64-encoded Float32Array indices: string // base64-encoded Uint32Array color: [number, number, number] | null } +export interface HierarchyNode { + name: string + meshes: number[] // indices into GeometryFile.meshes + children: HierarchyNode[] +} + export interface GeometryFile { version: 2 sourceFile: string meshCount: number meshes: GeometryMesh[] + tree?: HierarchyNode // absent on files converted before tree support } // Singleton — WASM initializes once per Node process (~3 s on first call, instant after) @@ -59,12 +67,14 @@ export async function convertStepFile( const nor = mesh.attributes.normal ? new Float32Array(mesh.attributes.normal.array) : null const idx = new Uint32Array(mesh.index.array) return { + name: mesh.name ?? '', positions: Buffer.from(pos.buffer, pos.byteOffset, pos.byteLength).toString('base64'), normals: nor ? Buffer.from(nor.buffer, nor.byteOffset, nor.byteLength).toString('base64') : null, indices: Buffer.from(idx.buffer, idx.byteOffset, idx.byteLength).toString('base64'), color: mesh.color ? [mesh.color[0], mesh.color[1], mesh.color[2]] : null, } }), + tree: result.root, } fs.writeFileSync(outputPath, JSON.stringify(geo)) diff --git a/src/server/types/occt-import-js.d.ts b/src/server/types/occt-import-js.d.ts index ed10844..68f1adc 100644 --- a/src/server/types/occt-import-js.d.ts +++ b/src/server/types/occt-import-js.d.ts @@ -4,12 +4,19 @@ declare module 'occt-import-js' { normal?: { array: Float32Array } } interface OcctMesh { + name: string attributes: MeshAttributes index: { array: Uint32Array } color: number[] | null } + interface HierarchyNode { + name: string + meshes: number[] // indices into ReadResult.meshes + children: HierarchyNode[] + } interface ReadResult { success: boolean + root: HierarchyNode meshes: OcctMesh[] } interface OcctModule { diff --git a/views/viewer.ejs b/views/viewer.ejs index 803a8f3..2928647 100644 --- a/views/viewer.ejs +++ b/views/viewer.ejs @@ -3,8 +3,10 @@ @@ -32,6 +34,16 @@
+ + <% if (model.file_type === 'step' || model.file_type === 'stp') { %> + + <% } %> +
+ +<% if (model.file_type === 'step' || model.file_type === 'stp') { %> +
+
+

Model Tree

+ +
+
+

Awaiting model load…

+
+
+<% } %> + <% if (pdfs.length > 0) { %>