model tree
Build and Push Docker Image / build (push) Successful in 13s

This commit is contained in:
jason
2026-04-23 13:46:54 -05:00
parent a262d4f018
commit ea1c7f6e7f
4 changed files with 162 additions and 5 deletions
+114 -3
View File
@@ -2,7 +2,7 @@ import '../style.css'
import * as THREE from 'three' import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { STLLoader } from 'three/addons/loaders/STLLoader.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 ----------------------------------------- // ---- Data injected by viewer.ejs -----------------------------------------
@@ -22,6 +22,9 @@ let camera: THREE.PerspectiveCamera
let controls: OrbitControls let controls: OrbitControls
let viewingDist: number = 200 // updated by fitCamera; used by fog toggle 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 ---------------------------------------------------------- // ---- UI helpers ----------------------------------------------------------
function setLoading(msg: string) { function setLoading(msg: string) {
@@ -244,7 +247,8 @@ async function loadStepGeometry(): Promise<void> {
const group = new THREE.Group() const group = new THREE.Group()
const isV2 = data.version >= 2 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() const geo = new THREE.BufferGeometry()
let positions: Float32Array let positions: Float32Array
@@ -273,12 +277,16 @@ async function loadStepGeometry(): Promise<void> {
geo.computeVertexNormals() 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) scene.add(group)
fitCamera(group) fitCamera(group)
addGrid(group) addGrid(group)
if (data.tree) wireTreePanel(data.tree)
} }
// ---- STL loader (client-side, Three.js built-in) ------------------------- // ---- STL loader (client-side, Three.js built-in) -------------------------
@@ -318,6 +326,109 @@ async function loadStl(): Promise<void> {
addGrid(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 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 ------------------------------------------------------ // ---- Viewer toolbar ------------------------------------------------------
function wireToolbar() { function wireToolbar() {
+10
View File
@@ -3,17 +3,25 @@ import fs from 'fs'
import path from 'path' import path from 'path'
export interface GeometryMesh { export interface GeometryMesh {
name: string
positions: string // base64-encoded Float32Array positions: string // base64-encoded Float32Array
normals: string | null // base64-encoded Float32Array normals: string | null // base64-encoded Float32Array
indices: string // base64-encoded Uint32Array indices: string // base64-encoded Uint32Array
color: [number, number, number] | null color: [number, number, number] | null
} }
export interface HierarchyNode {
name: string
meshes: number[] // indices into GeometryFile.meshes
children: HierarchyNode[]
}
export interface GeometryFile { export interface GeometryFile {
version: 2 version: 2
sourceFile: string sourceFile: string
meshCount: number meshCount: number
meshes: GeometryMesh[] 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) // 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 nor = mesh.attributes.normal ? new Float32Array(mesh.attributes.normal.array) : null
const idx = new Uint32Array(mesh.index.array) const idx = new Uint32Array(mesh.index.array)
return { return {
name: mesh.name ?? '',
positions: Buffer.from(pos.buffer, pos.byteOffset, pos.byteLength).toString('base64'), positions: Buffer.from(pos.buffer, pos.byteOffset, pos.byteLength).toString('base64'),
normals: nor ? Buffer.from(nor.buffer, nor.byteOffset, nor.byteLength).toString('base64') : null, normals: nor ? Buffer.from(nor.buffer, nor.byteOffset, nor.byteLength).toString('base64') : null,
indices: Buffer.from(idx.buffer, idx.byteOffset, idx.byteLength).toString('base64'), indices: Buffer.from(idx.buffer, idx.byteOffset, idx.byteLength).toString('base64'),
color: mesh.color ? [mesh.color[0], mesh.color[1], mesh.color[2]] : null, color: mesh.color ? [mesh.color[0], mesh.color[1], mesh.color[2]] : null,
} }
}), }),
tree: result.root,
} }
fs.writeFileSync(outputPath, JSON.stringify(geo)) fs.writeFileSync(outputPath, JSON.stringify(geo))
+7
View File
@@ -4,12 +4,19 @@ declare module 'occt-import-js' {
normal?: { array: Float32Array } normal?: { array: Float32Array }
} }
interface OcctMesh { interface OcctMesh {
name: string
attributes: MeshAttributes attributes: MeshAttributes
index: { array: Uint32Array } index: { array: Uint32Array }
color: number[] | null color: number[] | null
} }
interface HierarchyNode {
name: string
meshes: number[] // indices into ReadResult.meshes
children: HierarchyNode[]
}
interface ReadResult { interface ReadResult {
success: boolean success: boolean
root: HierarchyNode
meshes: OcctMesh[] meshes: OcctMesh[]
} }
interface OcctModule { interface OcctModule {
+29
View File
@@ -5,6 +5,8 @@
#viewer-canvas { display: block; width: 100vw; height: 100vh; } #viewer-canvas { display: block; width: 100vw; height: 100vh; }
#pdf-panel { transition: transform 0.3s ease; } #pdf-panel { transition: transform 0.3s ease; }
#pdf-panel.closed { transform: translateX(100%); } #pdf-panel.closed { transform: translateX(100%); }
#tree-panel { transition: transform 0.3s ease; }
#tree-panel.closed { transform: translateX(-100%); }
</style> </style>
<!-- 3D Canvas --> <!-- 3D Canvas -->
@@ -32,6 +34,16 @@
<!-- Top-right controls --> <!-- Top-right controls -->
<div class="fixed top-5 right-5 flex items-center gap-2 z-20"> <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 --> <!-- Fog toggle -->
<button id="fog-btn" title="Toggle atmosphere fog" <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"> 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 &nbsp;·&nbsp; 2 fingers — pan/zoom</p> <p class="text-xs text-gray-700 md:hidden">1 finger — rotate &nbsp;·&nbsp; 2 fingers — pan/zoom</p>
</div> </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 --> <!-- PDF slide-in panel -->
<% if (pdfs.length > 0) { %> <% 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"> <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">