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() {
|
||||
|
||||
Reference in New Issue
Block a user