This commit is contained in:
+114
-3
@@ -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() {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Vendored
+7
@@ -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 {
|
||||||
|
|||||||
+31
-2
@@ -3,8 +3,10 @@
|
|||||||
<style>
|
<style>
|
||||||
body { overflow: hidden; }
|
body { overflow: hidden; }
|
||||||
#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 · 2 fingers — pan/zoom</p>
|
<p class="text-xs text-gray-700 md:hidden">1 finger — rotate · 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user