This commit is contained in:
+114
-3
@@ -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<number, THREE.Mesh>()
|
||||
|
||||
// ---- UI helpers ----------------------------------------------------------
|
||||
|
||||
function setLoading(msg: string) {
|
||||
@@ -244,7 +247,8 @@ async function loadStepGeometry(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 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() {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Vendored
+7
@@ -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 {
|
||||
|
||||
+31
-2
@@ -3,8 +3,10 @@
|
||||
<style>
|
||||
body { overflow: hidden; }
|
||||
#viewer-canvas { display: block; width: 100vw; height: 100vh; }
|
||||
#pdf-panel { transition: transform 0.3s ease; }
|
||||
#pdf-panel.closed { transform: translateX(100%); }
|
||||
#pdf-panel { transition: transform 0.3s ease; }
|
||||
#pdf-panel.closed { transform: translateX(100%); }
|
||||
#tree-panel { transition: transform 0.3s ease; }
|
||||
#tree-panel.closed { transform: translateX(-100%); }
|
||||
</style>
|
||||
|
||||
<!-- 3D Canvas -->
|
||||
@@ -32,6 +34,16 @@
|
||||
<!-- Top-right controls -->
|
||||
<div class="fixed top-5 right-5 flex items-center gap-2 z-20">
|
||||
|
||||
<!-- Model tree toggle (STEP/STP only) -->
|
||||
<% if (model.file_type === 'step' || model.file_type === 'stp') { %>
|
||||
<button id="tree-toggle-btn" title="Toggle model tree"
|
||||
class="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 bg-surface-900/90 backdrop-blur-sm border border-gray-700 hover:border-gray-600 text-gray-400 hover:text-white rounded-xl transition-all">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<% } %>
|
||||
|
||||
<!-- Fog toggle -->
|
||||
<button id="fog-btn" title="Toggle atmosphere fog"
|
||||
class="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 bg-surface-900/90 backdrop-blur-sm border border-gray-700 hover:border-gray-600 text-gray-400 hover:text-white rounded-xl transition-all">
|
||||
@@ -98,6 +110,23 @@
|
||||
<p class="text-xs text-gray-700 md:hidden">1 finger — rotate · 2 fingers — pan/zoom</p>
|
||||
</div>
|
||||
|
||||
<!-- Model tree panel (left side, STEP/STP only) -->
|
||||
<% if (model.file_type === 'step' || model.file_type === 'stp') { %>
|
||||
<div id="tree-panel" class="fixed top-0 left-0 h-full w-64 bg-surface-900/95 backdrop-blur-sm border-r border-gray-800 z-20 closed flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-800 shrink-0">
|
||||
<h2 class="text-sm font-semibold text-white">Model Tree</h2>
|
||||
<button id="tree-close-btn" class="p-1.5 rounded-lg text-gray-500 hover:text-white hover:bg-surface-800 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="tree-content" class="flex-1 overflow-y-auto py-2">
|
||||
<p class="text-xs text-gray-600 px-4 py-2">Awaiting model load…</p>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- PDF slide-in panel -->
|
||||
<% if (pdfs.length > 0) { %>
|
||||
<div id="pdf-panel" class="fixed top-0 right-0 h-full w-full max-w-md bg-surface-900 border-l border-gray-800 z-20 closed flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user