first push
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import '../style.css'
|
||||
|
||||
// ---- Toast utility -------------------------------------------------------
|
||||
|
||||
function showToast(msg: string, durationMs = 2500) {
|
||||
const toast = document.getElementById('toast')
|
||||
const toastMsg = document.getElementById('toast-msg')
|
||||
if (!toast || !toastMsg) return
|
||||
toastMsg.textContent = msg
|
||||
toast.classList.remove('opacity-0', 'translate-y-2', 'pointer-events-none')
|
||||
toast.classList.add('opacity-100', 'translate-y-0')
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-2', 'pointer-events-none')
|
||||
toast.classList.remove('opacity-100', 'translate-y-0')
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
// ---- Copy-link buttons ---------------------------------------------------
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('.copy-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const url = btn.dataset.url ?? ''
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
showToast('Link copied to clipboard')
|
||||
} catch {
|
||||
// Fallback for non-secure contexts
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = url
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
showToast('Link copied to clipboard')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Visibility toggles --------------------------------------------------
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('.visibility-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.modelId
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/models/${id}/visibility`, { method: 'POST' })
|
||||
const data = await res.json() as { is_public: 0 | 1 }
|
||||
|
||||
const isPublic = data.is_public === 1
|
||||
const dot = btn.querySelector('span')
|
||||
|
||||
btn.dataset.isPublic = String(data.is_public)
|
||||
btn.className = btn.className.replace(
|
||||
/bg-(green|gray)-\S+|border-(green|gray)-\S+|text-(green|gray)-\S+/g, ''
|
||||
)
|
||||
btn.classList.add(
|
||||
...(isPublic
|
||||
? ['bg-green-500/10', 'border-green-500/30', 'text-green-400']
|
||||
: ['bg-gray-700/50', 'border-gray-700', 'text-gray-500'])
|
||||
)
|
||||
if (dot) {
|
||||
dot.className = `w-1.5 h-1.5 rounded-full ${isPublic ? 'bg-green-400' : 'bg-gray-500'}`
|
||||
}
|
||||
btn.childNodes.forEach(node => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
node.textContent = ` ${isPublic ? 'Public' : 'Private'}`
|
||||
}
|
||||
})
|
||||
showToast(isPublic ? 'Model set to public' : 'Model set to private')
|
||||
} catch {
|
||||
showToast('Failed to update visibility')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---- File drop zone ------------------------------------------------------
|
||||
|
||||
const dropZone = document.getElementById('drop-zone')
|
||||
const fileInput = document.getElementById('model_file') as HTMLInputElement | null
|
||||
const fileNameDisplay = document.getElementById('file-name')
|
||||
|
||||
if (dropZone && fileInput) {
|
||||
dropZone.addEventListener('dragover', e => {
|
||||
e.preventDefault()
|
||||
dropZone.classList.add('border-accent')
|
||||
})
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-accent')
|
||||
})
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault()
|
||||
dropZone.classList.remove('border-accent')
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
// Transfer to the real file input via DataTransfer
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(files[0])
|
||||
fileInput.files = dt.files
|
||||
showFileName(files[0].name)
|
||||
|
||||
// Auto-fill name field if empty
|
||||
const nameInput = document.getElementById('name') as HTMLInputElement | null
|
||||
if (nameInput && !nameInput.value) {
|
||||
nameInput.value = files[0].name.replace(/\.(step|stp|stl)$/i, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files?.[0]) showFileName(fileInput.files[0].name)
|
||||
})
|
||||
|
||||
function showFileName(name: string) {
|
||||
if (fileNameDisplay) {
|
||||
fileNameDisplay.textContent = `Selected: ${name}`
|
||||
fileNameDisplay.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inline category edit toggle ----------------------------------------
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('[class*="hover:bg"]') as HTMLElement
|
||||
const form = row?.querySelector<HTMLElement>('.edit-form')
|
||||
const actionBtns = row?.querySelector<HTMLElement>('.action-buttons')
|
||||
if (form && actionBtns) {
|
||||
form.classList.remove('hidden')
|
||||
form.classList.add('flex')
|
||||
actionBtns.classList.add('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.cancel-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('[class*="hover:bg"]') as HTMLElement
|
||||
const form = row?.querySelector<HTMLElement>('.edit-form')
|
||||
const actionBtns = row?.querySelector<HTMLElement>('.action-buttons')
|
||||
if (form && actionBtns) {
|
||||
form.classList.add('hidden')
|
||||
form.classList.remove('flex')
|
||||
actionBtns.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
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'
|
||||
|
||||
// ---- Data injected by viewer.ejs -----------------------------------------
|
||||
|
||||
declare const __STEPVIEW__: {
|
||||
modelId: number
|
||||
fileType: 'step' | 'stp' | 'stl'
|
||||
shareUrl: string
|
||||
hasPdfs: boolean
|
||||
hasGeometry: boolean // pre-processed geometry JSON exists (STEP/STP only)
|
||||
}
|
||||
|
||||
// ---- Scene state ---------------------------------------------------------
|
||||
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let scene: THREE.Scene
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let controls: OrbitControls
|
||||
|
||||
// ---- UI helpers ----------------------------------------------------------
|
||||
|
||||
function setLoading(msg: string) {
|
||||
const el = document.getElementById('loading-msg')
|
||||
if (el) el.textContent = msg
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('loading-overlay') as HTMLElement
|
||||
overlay.style.opacity = '0'
|
||||
setTimeout(() => { overlay.style.display = 'none' }, 300)
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
document.getElementById('loading-overlay')!.style.display = 'none'
|
||||
const overlay = document.getElementById('error-overlay')!
|
||||
overlay.classList.remove('hidden')
|
||||
overlay.classList.add('flex')
|
||||
const msgEl = document.getElementById('error-msg')
|
||||
if (msgEl) msgEl.textContent = msg
|
||||
}
|
||||
|
||||
function showToast(msg: string, duration = 2200) {
|
||||
const toast = document.getElementById('toast')!
|
||||
const toastMsg = document.getElementById('toast-msg')!
|
||||
toastMsg.textContent = msg
|
||||
toast.classList.remove('opacity-0', 'pointer-events-none')
|
||||
toast.classList.add('opacity-100')
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'pointer-events-none')
|
||||
toast.classList.remove('opacity-100')
|
||||
}, duration)
|
||||
}
|
||||
|
||||
// ---- Scene setup ---------------------------------------------------------
|
||||
|
||||
function buildScene(canvas: HTMLCanvasElement) {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
renderer.setClearColor(0x0a0a0f, 1)
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 1.1
|
||||
|
||||
scene = new THREE.Scene()
|
||||
|
||||
// Subtle environment fog
|
||||
scene.fog = new THREE.FogExp2(0x0a0a0f, 0.0008)
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.001, 10000)
|
||||
camera.position.set(5, 3, 8)
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.5)
|
||||
scene.add(ambient)
|
||||
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.0)
|
||||
key.position.set(10, 20, 10)
|
||||
key.castShadow = true
|
||||
key.shadow.mapSize.set(2048, 2048)
|
||||
key.shadow.camera.near = 0.1
|
||||
key.shadow.camera.far = 500
|
||||
scene.add(key)
|
||||
|
||||
const fill = new THREE.DirectionalLight(0x8899cc, 0.4)
|
||||
fill.position.set(-10, 5, -10)
|
||||
scene.add(fill)
|
||||
|
||||
const rim = new THREE.DirectionalLight(0xffffff, 0.2)
|
||||
rim.position.set(0, -5, -10)
|
||||
scene.add(rim)
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.06
|
||||
controls.minDistance = 0.001
|
||||
controls.maxDistance = 5000
|
||||
controls.panSpeed = 0.8
|
||||
controls.rotateSpeed = 0.6
|
||||
controls.zoomSpeed = 1.2
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Render loop ---------------------------------------------------------
|
||||
|
||||
function startRenderLoop() {
|
||||
const clock = new THREE.Clock()
|
||||
function tick() {
|
||||
requestAnimationFrame(tick)
|
||||
controls.update()
|
||||
renderer.render(scene, camera)
|
||||
void clock
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
// ---- Camera fit ----------------------------------------------------------
|
||||
|
||||
function fitCamera(object: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
|
||||
if (maxDim === 0) return
|
||||
|
||||
const fovRad = camera.fov * (Math.PI / 180)
|
||||
const dist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.6
|
||||
|
||||
camera.near = maxDim * 0.0005
|
||||
camera.far = maxDim * 200
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
camera.position.set(
|
||||
center.x + dist * 0.55,
|
||||
center.y + dist * 0.35,
|
||||
center.z + dist,
|
||||
)
|
||||
camera.lookAt(center)
|
||||
controls.target.copy(center)
|
||||
controls.update()
|
||||
}
|
||||
|
||||
// ---- Ground grid ---------------------------------------------------------
|
||||
|
||||
function addGrid(object: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.z) * 3
|
||||
|
||||
const grid = new THREE.GridHelper(maxDim, 20, 0x1a1a2a, 0x1a1a2a)
|
||||
grid.position.set(center.x, box.min.y - 0.001, center.z)
|
||||
scene.add(grid)
|
||||
}
|
||||
|
||||
// ---- Material factory ----------------------------------------------------
|
||||
|
||||
function makeMaterial(color: [number, number, number] | null): THREE.MeshStandardMaterial {
|
||||
const c = color
|
||||
? new THREE.Color(color[0], color[1], color[2])
|
||||
: new THREE.Color(0x8fa3b8)
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: c,
|
||||
roughness: 0.45,
|
||||
metalness: 0.25,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- STEP/STP loader (geometry JSON, pre-processed server-side) ----------
|
||||
|
||||
async function loadStepGeometry(): Promise<void> {
|
||||
setLoading('Fetching geometry…')
|
||||
const res = await fetch(`/files/geometry/${__STEPVIEW__.modelId}`)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { processing?: boolean }
|
||||
if (body.processing) {
|
||||
throw new Error('This model is still being processed. Please try again in a moment.')
|
||||
}
|
||||
throw new Error(`Could not load geometry (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
setLoading('Building 3D scene…')
|
||||
const data = await res.json() as GeometryFile
|
||||
|
||||
if (!data.meshes || data.meshes.length === 0) {
|
||||
throw new Error('Geometry file contains no meshes.')
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
|
||||
for (const mesh of data.meshes) {
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3))
|
||||
|
||||
if (mesh.normals && mesh.normals.length > 0) {
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3))
|
||||
}
|
||||
|
||||
if (mesh.indices && mesh.indices.length > 0) {
|
||||
geo.setIndex(new THREE.Uint32BufferAttribute(mesh.indices, 1))
|
||||
}
|
||||
|
||||
if (!mesh.normals || mesh.normals.length === 0) {
|
||||
geo.computeVertexNormals()
|
||||
}
|
||||
|
||||
group.add(new THREE.Mesh(geo, makeMaterial(mesh.color)))
|
||||
}
|
||||
|
||||
scene.add(group)
|
||||
fitCamera(group)
|
||||
addGrid(group)
|
||||
}
|
||||
|
||||
// ---- STL loader (client-side, Three.js built-in) -------------------------
|
||||
|
||||
async function loadStl(): Promise<void> {
|
||||
setLoading('Fetching STL file…')
|
||||
const loader = new STLLoader()
|
||||
|
||||
const geometry = await new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
loader.load(
|
||||
`/files/model/${__STEPVIEW__.modelId}`,
|
||||
resolve,
|
||||
(xhr) => {
|
||||
if (xhr.total) {
|
||||
const pct = Math.round((xhr.loaded / xhr.total) * 100)
|
||||
setLoading(`Downloading STL… ${pct}%`)
|
||||
}
|
||||
},
|
||||
reject,
|
||||
)
|
||||
})
|
||||
|
||||
setLoading('Building 3D scene…')
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
// Center geometry at origin
|
||||
geometry.computeBoundingBox()
|
||||
const center = new THREE.Vector3()
|
||||
geometry.boundingBox!.getCenter(center)
|
||||
geometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, makeMaterial(null))
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
scene.add(mesh)
|
||||
fitCamera(mesh)
|
||||
addGrid(mesh)
|
||||
}
|
||||
|
||||
// ---- Viewer toolbar ------------------------------------------------------
|
||||
|
||||
function wireToolbar() {
|
||||
// Copy-link
|
||||
const copyBtn = document.getElementById('copy-link-btn') as HTMLButtonElement | null
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(__STEPVIEW__.shareUrl)
|
||||
} catch {
|
||||
const ta = Object.assign(document.createElement('textarea'), {
|
||||
value: __STEPVIEW__.shareUrl,
|
||||
style: 'position:fixed;opacity:0',
|
||||
})
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
const orig = copyBtn.innerHTML
|
||||
copyBtn.textContent = '✓ Copied!'
|
||||
setTimeout(() => { copyBtn.innerHTML = orig }, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
// Reset camera
|
||||
const resetBtn = document.getElementById('reset-camera-btn')
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
// Re-fit camera to scene objects (exclude grid)
|
||||
const objects = scene.children.filter(c => !(c instanceof THREE.GridHelper))
|
||||
const group = new THREE.Group()
|
||||
objects.forEach(o => group.add(o.clone()))
|
||||
if (group.children.length) fitCamera(group)
|
||||
})
|
||||
}
|
||||
|
||||
// Wireframe toggle
|
||||
let wireframe = false
|
||||
const wireBtn = document.getElementById('wireframe-btn')
|
||||
if (wireBtn) {
|
||||
wireBtn.addEventListener('click', () => {
|
||||
wireframe = !wireframe
|
||||
scene.traverse(obj => {
|
||||
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
|
||||
obj.material.wireframe = wireframe
|
||||
}
|
||||
})
|
||||
wireBtn.classList.toggle('text-accent', wireframe)
|
||||
})
|
||||
}
|
||||
|
||||
// PDF panel
|
||||
const pdfToggle = document.getElementById('pdf-toggle-btn')
|
||||
const pdfPanel = document.getElementById('pdf-panel')
|
||||
const pdfClose = document.getElementById('pdf-close-btn')
|
||||
|
||||
if (pdfToggle && pdfPanel) {
|
||||
pdfToggle.addEventListener('click', () => pdfPanel.classList.toggle('closed'))
|
||||
}
|
||||
if (pdfClose && pdfPanel) {
|
||||
pdfClose.addEventListener('click', () => pdfPanel.classList.add('closed'))
|
||||
}
|
||||
|
||||
// PDF tabs
|
||||
document.querySelectorAll<HTMLButtonElement>('.pdf-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const idx = tab.dataset.tab
|
||||
document.querySelectorAll('.pdf-tab').forEach(t => {
|
||||
t.classList.remove('border-accent', 'text-white')
|
||||
t.classList.add('border-transparent', 'text-gray-500')
|
||||
})
|
||||
tab.classList.add('border-accent', 'text-white')
|
||||
tab.classList.remove('border-transparent', 'text-gray-500')
|
||||
document.querySelectorAll<HTMLElement>('.pdf-frame').forEach(f => {
|
||||
f.classList.toggle('hidden', f.dataset.frame !== idx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Boot ----------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement
|
||||
|
||||
try {
|
||||
buildScene(canvas)
|
||||
startRenderLoop()
|
||||
wireToolbar()
|
||||
|
||||
if (__STEPVIEW__.fileType === 'stl') {
|
||||
await loadStl()
|
||||
} else {
|
||||
// STEP / STP
|
||||
if (!__STEPVIEW__.hasGeometry) {
|
||||
throw new Error(
|
||||
'This model has not finished processing yet. ' +
|
||||
'Please check back shortly or contact your administrator.'
|
||||
)
|
||||
}
|
||||
await loadStepGeometry()
|
||||
}
|
||||
|
||||
hideLoading()
|
||||
} catch (err) {
|
||||
console.error('[StepView]', err)
|
||||
showError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
boot()
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import compression from 'compression'
|
||||
import session from 'express-session'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// Initialize DB (runs schema + seeds) before anything else
|
||||
import './db/index'
|
||||
|
||||
import { injectBrand } from './middleware/injectBrand'
|
||||
import { warmUpOcct } from './services/stepConverter'
|
||||
import authRoutes from './routes/auth'
|
||||
import adminModelRoutes from './routes/adminModels'
|
||||
import adminCategoryRoutes from './routes/adminCategories'
|
||||
import adminSettingsRoutes from './routes/adminSettings'
|
||||
import viewerRoutes from './routes/viewer'
|
||||
import fileRoutes from './routes/files'
|
||||
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT ?? '3000', 10)
|
||||
|
||||
// Ensure upload dirs exist
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
|
||||
for (const sub of ['models', 'pdfs', 'thumbnails', 'brand']) {
|
||||
fs.mkdirSync(path.join(UPLOADS_DIR, sub), { recursive: true })
|
||||
}
|
||||
|
||||
// Warm up WASM in background so the first STEP upload is fast
|
||||
warmUpOcct()
|
||||
|
||||
// ---- View engine ----------------------------------------------------------
|
||||
app.set('view engine', 'ejs')
|
||||
app.set('views', path.join(process.cwd(), 'views'))
|
||||
|
||||
// ---- Compression (gzip geometry JSON, etc.) ------------------------------
|
||||
app.use(compression())
|
||||
|
||||
// ---- Static files --------------------------------------------------------
|
||||
app.use(express.static(path.join(process.cwd(), 'public')))
|
||||
|
||||
// ---- Body parsing --------------------------------------------------------
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
|
||||
// ---- Sessions ------------------------------------------------------------
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.TRUST_PROXY === 'true',
|
||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
||||
},
|
||||
}))
|
||||
|
||||
// ---- Global locals -------------------------------------------------------
|
||||
app.use(injectBrand)
|
||||
|
||||
// Inject first-run warning flag when admin password is still the default
|
||||
app.use((req, res, next) => {
|
||||
const isAdminRoute = req.path.startsWith('/admin')
|
||||
const isLoggedIn = (req.session as { isAdmin?: boolean }).isAdmin
|
||||
res.locals.defaultPassword = isAdminRoute && isLoggedIn
|
||||
&& (process.env.ADMIN_PASS ?? 'changeme') === 'changeme'
|
||||
next()
|
||||
})
|
||||
|
||||
// ---- Routes --------------------------------------------------------------
|
||||
app.use(authRoutes)
|
||||
app.use(adminModelRoutes)
|
||||
app.use(adminCategoryRoutes)
|
||||
app.use(adminSettingsRoutes)
|
||||
app.use(fileRoutes)
|
||||
app.use(viewerRoutes)
|
||||
|
||||
// ---- Health check --------------------------------------------------------
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '0.1.0' }))
|
||||
|
||||
// ---- 404 -----------------------------------------------------------------
|
||||
app.use((_req, res) => {
|
||||
res.status(404).render('error', { status: 404, message: 'Page not found.' })
|
||||
})
|
||||
|
||||
// ---- Error handler -------------------------------------------------------
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err)
|
||||
res.status(500).render('error', { status: 500, message: 'An unexpected error occurred.' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`StepView running on http://localhost:${PORT}`)
|
||||
console.log(`Admin: http://localhost:${PORT}/admin`)
|
||||
if ((process.env.ADMIN_PASS ?? 'changeme') === 'changeme') {
|
||||
console.warn('⚠ WARNING: ADMIN_PASS is still set to the default "changeme". Set it in .env before exposing to a network.')
|
||||
}
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,128 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { SCHEMA_SQL, DEFAULT_SETTINGS } from './schema'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? path.join(process.cwd(), 'data')
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
|
||||
const DB_PATH = path.join(DATA_DIR, 'stepview.db')
|
||||
export const db = new DatabaseSync(DB_PATH)
|
||||
|
||||
db.exec(SCHEMA_SQL)
|
||||
|
||||
// Seed settings that don't yet exist
|
||||
const upsertSetting = db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`
|
||||
)
|
||||
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
upsertSetting.run(key, value)
|
||||
}
|
||||
|
||||
// Sync admin password hash from env on every startup
|
||||
const envPass = process.env.ADMIN_PASS ?? 'changeme'
|
||||
const newHash = bcrypt.hashSync(envPass, 12)
|
||||
db.prepare(`UPDATE settings SET value = ? WHERE key = 'admin_pass_hash'`).run(newHash)
|
||||
|
||||
// Bootstrap brand name/accent from env only if still at schema defaults
|
||||
const envBrandName = process.env.BRAND_NAME
|
||||
if (envBrandName) {
|
||||
const current = db.prepare(`SELECT value FROM settings WHERE key = 'brand_name'`).get() as { value: string } | undefined
|
||||
if (current?.value === DEFAULT_SETTINGS.brand_name) {
|
||||
db.prepare(`UPDATE settings SET value = ? WHERE key = 'brand_name'`).run(envBrandName)
|
||||
}
|
||||
}
|
||||
const envAccent = process.env.BRAND_ACCENT
|
||||
if (envAccent) {
|
||||
const current = db.prepare(`SELECT value FROM settings WHERE key = 'brand_accent'`).get() as { value: string } | undefined
|
||||
if (current?.value === DEFAULT_SETTINGS.brand_accent) {
|
||||
db.prepare(`UPDATE settings SET value = ? WHERE key = 'brand_accent'`).run(envAccent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Types ---------------------------------------------------------------
|
||||
|
||||
export interface Model {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string | null
|
||||
category_id: number | null
|
||||
file_path: string
|
||||
file_type: 'step' | 'stp' | 'stl'
|
||||
thumbnail_path: string | null
|
||||
is_public: 0 | 1
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ModelPdf {
|
||||
id: number
|
||||
model_id: number
|
||||
display_name: string
|
||||
file_path: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface BrandSettings {
|
||||
brand_name: string
|
||||
brand_tagline: string
|
||||
brand_accent: string
|
||||
brand_logo_path: string
|
||||
brand_favicon_path: string
|
||||
}
|
||||
|
||||
// ---- Typed query helpers -------------------------------------------------
|
||||
// node:sqlite returns Record<string,SQLOutputValue> which requires double-cast
|
||||
// to reach our typed interfaces. These helpers centralise that pattern.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type SqlParam = any
|
||||
|
||||
export function q<T>(sql: string) {
|
||||
const stmt = db.prepare(sql)
|
||||
return {
|
||||
all: (...params: SqlParam[]): T[] => stmt.all(...params) as unknown as T[],
|
||||
get: (...params: SqlParam[]): T | undefined => stmt.get(...params) as unknown as T | undefined,
|
||||
run: (...params: SqlParam[]) => stmt.run(...params),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers -------------------------------------------------------------
|
||||
|
||||
export function getAllSettings(): Record<string, string> {
|
||||
const rows = q<{ key: string; value: string }>(`SELECT key, value FROM settings`).all()
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||
}
|
||||
|
||||
export function getSetting(key: string): string {
|
||||
const row = q<{ value: string }>(`SELECT value FROM settings WHERE key = ?`).get(key)
|
||||
return row?.value ?? ''
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
).run(key, value)
|
||||
}
|
||||
|
||||
export function getBrandSettings(): BrandSettings {
|
||||
const s = getAllSettings()
|
||||
return {
|
||||
brand_name: s.brand_name ?? 'StepView',
|
||||
brand_tagline: s.brand_tagline ?? '3D Model Viewer',
|
||||
brand_accent: s.brand_accent ?? '#3b82f6',
|
||||
brand_logo_path: s.brand_logo_path ?? '',
|
||||
brand_favicon_path: s.brand_favicon_path ?? '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const SCHEMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL CHECK(file_type IN ('step', 'stp', 'stl')),
|
||||
thumbnail_path TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pdfs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_models_slug ON models(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_category_id ON models(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_is_public ON models(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_pdfs_model ON model_pdfs(model_id);
|
||||
`
|
||||
|
||||
export const DEFAULT_SETTINGS: Record<string, string> = {
|
||||
brand_name: 'StepView',
|
||||
brand_tagline: '3D Model Viewer',
|
||||
brand_accent: '#3b82f6',
|
||||
brand_logo_path: '',
|
||||
brand_favicon_path: '',
|
||||
admin_pass_hash: '',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { getBrandSettings, BrandSettings } from '../db/index'
|
||||
|
||||
// Cache brand settings for 60 s so every request doesn't hit SQLite.
|
||||
let cache: BrandSettings | null = null
|
||||
let cacheExpiresAt = 0
|
||||
|
||||
export function injectBrand(_req: Request, res: Response, next: NextFunction): void {
|
||||
const now = Date.now()
|
||||
if (!cache || now > cacheExpiresAt) {
|
||||
cache = getBrandSettings()
|
||||
cacheExpiresAt = now + 60_000
|
||||
}
|
||||
res.locals.brand = cache
|
||||
next()
|
||||
}
|
||||
|
||||
// Call this whenever brand settings are updated so the next request picks
|
||||
// up the changes immediately rather than waiting for the cache to expire.
|
||||
export function invalidateBrandCache(): void {
|
||||
cache = null
|
||||
cacheExpiresAt = 0
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
|
||||
// Applied to all file-serving routes. Prevents browsers from triggering a
|
||||
// Save As dialog and strips Accept-Ranges so partial downloads are not possible.
|
||||
export function noDownload(_req: Request, res: Response, next: NextFunction): void {
|
||||
res.removeHeader('Content-Disposition')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('Accept-Ranges', 'none')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if ((req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const isApi = req.path.startsWith('/api/')
|
||||
if (isApi) {
|
||||
res.status(401).json({ error: 'Unauthorized' })
|
||||
} else {
|
||||
res.redirect(`/admin/login?next=${encodeURIComponent(req.originalUrl)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import slugify from 'slugify'
|
||||
import { db, q, Category } from '../db/index'
|
||||
import { requireAdmin } from '../middleware/requireAdmin'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/admin/categories', requireAdmin, (_req: Request, res: Response) => {
|
||||
const categories = q<Category & { model_count: number }>(`
|
||||
SELECT c.*, COUNT(m.id) AS model_count
|
||||
FROM categories c
|
||||
LEFT JOIN models m ON m.category_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sort_order, c.name
|
||||
`).all()
|
||||
res.render('admin/categories', { categories, error: null })
|
||||
})
|
||||
|
||||
router.post('/admin/categories', requireAdmin, (req: Request, res: Response) => {
|
||||
const { name, description } = req.body as { name: string; description?: string }
|
||||
if (!name?.trim()) {
|
||||
const categories = q<Category & { model_count: number }>(`
|
||||
SELECT *, 0 AS model_count FROM categories ORDER BY sort_order, name
|
||||
`).all()
|
||||
res.render('admin/categories', { categories, error: 'Category name is required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const base = slugify(name.trim(), { lower: true, strict: true })
|
||||
let slug = base
|
||||
let attempt = 0
|
||||
while (q<{ id: number }>(`SELECT id FROM categories WHERE slug = ?`).get(slug)) {
|
||||
attempt++
|
||||
slug = `${base}-${attempt}`
|
||||
}
|
||||
|
||||
const maxOrder = q<{ m: number | null }>(`SELECT MAX(sort_order) AS m FROM categories`).get()?.m ?? -1
|
||||
db.prepare(`INSERT INTO categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)`).run(
|
||||
name.trim(), slug, description?.trim() || null, maxOrder + 1
|
||||
)
|
||||
res.redirect('/admin/categories')
|
||||
})
|
||||
|
||||
router.post('/admin/categories/:id/edit', requireAdmin, (req: Request, res: Response) => {
|
||||
const { name, description } = req.body as { name: string; description?: string }
|
||||
db.prepare(`UPDATE categories SET name = ?, description = ? WHERE id = ?`).run(
|
||||
name.trim(), description?.trim() || null, req.params.id
|
||||
)
|
||||
res.redirect('/admin/categories')
|
||||
})
|
||||
|
||||
router.post('/admin/categories/:id/delete', requireAdmin, (req: Request, res: Response) => {
|
||||
db.prepare(`DELETE FROM categories WHERE id = ?`).run(req.params.id)
|
||||
res.redirect('/admin/categories')
|
||||
})
|
||||
|
||||
router.post('/api/admin/categories/reorder', requireAdmin, (req: Request, res: Response) => {
|
||||
const { ids } = req.body as { ids: number[] }
|
||||
if (!Array.isArray(ids)) { res.status(400).json({ error: 'ids must be an array' }); return }
|
||||
|
||||
const update = db.prepare(`UPDATE categories SET sort_order = ? WHERE id = ?`)
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
ids.forEach((id, index) => update.run(index, id))
|
||||
db.exec('COMMIT')
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import slugify from 'slugify'
|
||||
import { db, q, Model, Category, ModelPdf } from '../db/index'
|
||||
import { requireAdmin } from '../middleware/requireAdmin'
|
||||
import { convertStepFile, geometryOutputPath } from '../services/stepConverter'
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
|
||||
const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_MB ?? '500', 10) * 1024 * 1024
|
||||
|
||||
const ALLOWED_MODEL_EXTS = new Set(['.step', '.stp', '.stl'])
|
||||
const ALLOWED_PDF_EXTS = new Set(['.pdf'])
|
||||
|
||||
const modelStorage = multer.diskStorage({
|
||||
destination: path.join(UPLOADS_DIR, 'models'),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
const base = slugify(path.basename(file.originalname, ext), { lower: true, strict: true })
|
||||
cb(null, `${Date.now()}-${base}${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
const pdfStorage = multer.diskStorage({
|
||||
destination: path.join(UPLOADS_DIR, 'pdfs'),
|
||||
filename: (_req, file, cb) => {
|
||||
const base = slugify(path.basename(file.originalname, '.pdf'), { lower: true, strict: true })
|
||||
cb(null, `${Date.now()}-${base}.pdf`)
|
||||
},
|
||||
})
|
||||
|
||||
function modelFileFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (ALLOWED_MODEL_EXTS.has(ext)) cb(null, true)
|
||||
else cb(new Error(`Unsupported file type: ${ext}`))
|
||||
}
|
||||
|
||||
function pdfFileFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (ALLOWED_PDF_EXTS.has(ext)) cb(null, true)
|
||||
else cb(new Error('Only PDF files are allowed'))
|
||||
}
|
||||
|
||||
const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter, limits: { fileSize: MAX_FILE_BYTES } })
|
||||
const uploadPdf = multer({ storage: pdfStorage, fileFilter: pdfFileFilter, limits: { fileSize: MAX_FILE_BYTES } })
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ---- Dashboard -----------------------------------------------------------
|
||||
|
||||
router.get('/admin', requireAdmin, (req: Request, res: Response) => {
|
||||
// Use `search` as the query param name to avoid collision with the `q`
|
||||
// helper imported from db/index.
|
||||
const { search, category_id, visibility } = req.query as Record<string, string>
|
||||
|
||||
let sql = `
|
||||
SELECT m.*, c.name AS category_name,
|
||||
(SELECT COUNT(*) FROM model_pdfs WHERE model_id = m.id) AS pdf_count
|
||||
FROM models m
|
||||
LEFT JOIN categories c ON c.id = m.category_id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: string[] = []
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (m.name LIKE ? OR m.description LIKE ?)`
|
||||
params.push(`%${search}%`, `%${search}%`)
|
||||
}
|
||||
if (category_id) {
|
||||
sql += ` AND m.category_id = ?`
|
||||
params.push(category_id)
|
||||
}
|
||||
if (visibility === 'public') sql += ` AND m.is_public = 1`
|
||||
if (visibility === 'private') sql += ` AND m.is_public = 0`
|
||||
sql += ` ORDER BY m.created_at DESC`
|
||||
|
||||
const models = q<Model & { category_name: string | null; pdf_count: number }>(sql).all(...params)
|
||||
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
||||
const totalCount = (q<{ n: number }>(`SELECT COUNT(*) AS n FROM models`).get())?.n ?? 0
|
||||
const publicCount = (q<{ n: number }>(`SELECT COUNT(*) AS n FROM models WHERE is_public = 1`).get())?.n ?? 0
|
||||
|
||||
res.render('admin/dashboard', {
|
||||
models,
|
||||
categories,
|
||||
totalCount,
|
||||
publicCount,
|
||||
filters: { search, category_id, visibility },
|
||||
baseUrl: process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Upload form ---------------------------------------------------------
|
||||
|
||||
router.get('/admin/upload', requireAdmin, (_req: Request, res: Response) => {
|
||||
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
||||
res.render('admin/upload', { categories, error: null })
|
||||
})
|
||||
|
||||
router.post('/admin/models',
|
||||
requireAdmin,
|
||||
uploadModel.single('model_file'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
||||
res.render('admin/upload', { categories, error: 'A model file is required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const { name, description, category_id, is_public } = req.body as {
|
||||
name: string; description?: string; category_id?: string; is_public?: string
|
||||
}
|
||||
|
||||
const ext = path.extname(req.file.originalname).toLowerCase().replace('.', '') as 'step' | 'stp' | 'stl'
|
||||
const base = slugify(name, { lower: true, strict: true })
|
||||
let slug = base
|
||||
|
||||
let attempt = 0
|
||||
while (q<{ id: number }>(`SELECT id FROM models WHERE slug = ?`).get(slug)) {
|
||||
attempt++
|
||||
slug = `${base}-${attempt}`
|
||||
}
|
||||
|
||||
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/')
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO models (slug, name, description, category_id, file_path, file_type, is_public)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(slug, name.trim(), description?.trim() || null, category_id ? parseInt(category_id, 10) : null, relPath, ext, is_public === 'on' ? 1 : 0)
|
||||
|
||||
// Convert STEP/STP to pre-processed geometry JSON so the browser never
|
||||
// needs to download the 22 MB WASM parser.
|
||||
if (ext === 'step' || ext === 'stp') {
|
||||
const absModelPath = path.join(UPLOADS_DIR, relPath)
|
||||
const geoOutPath = geometryOutputPath(absModelPath)
|
||||
try {
|
||||
await convertStepFile(absModelPath, geoOutPath)
|
||||
} catch (convErr) {
|
||||
// Conversion failure is non-fatal — the model is saved; the viewer
|
||||
// will show a friendly error instead of crashing the upload.
|
||||
console.error('[stepConverter] conversion failed:', (convErr as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect('/admin')
|
||||
} catch (err) {
|
||||
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
||||
res.render('admin/upload', { categories, error: (err as Error).message })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Attach PDF ----------------------------------------------------------
|
||||
|
||||
router.post('/admin/models/:id/pdf',
|
||||
requireAdmin,
|
||||
uploadPdf.single('pdf_file'),
|
||||
(req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT id FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model || !req.file) { res.redirect('/admin'); return }
|
||||
|
||||
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/')
|
||||
const displayName = (req.body.display_name as string | undefined)?.trim() || req.file.originalname
|
||||
const maxOrder = q<{ m: number | null }>(`SELECT MAX(sort_order) AS m FROM model_pdfs WHERE model_id = ?`).get(model.id)?.m ?? -1
|
||||
|
||||
db.prepare(`INSERT INTO model_pdfs (model_id, display_name, file_path, sort_order) VALUES (?, ?, ?, ?)`).run(model.id, displayName, relPath, maxOrder + 1)
|
||||
res.redirect(`/admin/models/${model.id}/edit`)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Toggle visibility (JSON) --------------------------------------------
|
||||
|
||||
router.post('/api/admin/models/:id/visibility', requireAdmin, (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT id, is_public FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.status(404).json({ error: 'Not found' }); return }
|
||||
|
||||
const newValue = model.is_public === 1 ? 0 : 1
|
||||
db.prepare(`UPDATE models SET is_public = ?, updated_at = datetime('now') WHERE id = ?`).run(newValue, model.id)
|
||||
res.json({ is_public: newValue })
|
||||
})
|
||||
|
||||
// ---- Delete model --------------------------------------------------------
|
||||
|
||||
router.post('/admin/models/:id/delete', requireAdmin, (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.redirect('/admin'); return }
|
||||
|
||||
const modelFile = path.join(UPLOADS_DIR, model.file_path)
|
||||
if (fs.existsSync(modelFile)) fs.unlinkSync(modelFile)
|
||||
|
||||
const pdfs = q<{ file_path: string }>(`SELECT file_path FROM model_pdfs WHERE model_id = ?`).all(model.id)
|
||||
for (const pdf of pdfs) {
|
||||
const pdfFile = path.join(UPLOADS_DIR, pdf.file_path)
|
||||
if (fs.existsSync(pdfFile)) fs.unlinkSync(pdfFile)
|
||||
}
|
||||
|
||||
if (model.thumbnail_path) {
|
||||
const thumb = path.join(UPLOADS_DIR, model.thumbnail_path)
|
||||
if (fs.existsSync(thumb)) fs.unlinkSync(thumb)
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM models WHERE id = ?`).run(model.id)
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
// ---- Edit model ----------------------------------------------------------
|
||||
|
||||
router.get('/admin/models/:id/edit', requireAdmin, (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.redirect('/admin'); return }
|
||||
|
||||
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
||||
const pdfs = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE model_id = ? ORDER BY sort_order`).all(model.id)
|
||||
res.render('admin/edit', {
|
||||
model, categories, pdfs, error: null,
|
||||
baseUrl: process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/admin/models/:id/edit', requireAdmin, (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.redirect('/admin'); return }
|
||||
|
||||
const { name, description, category_id, is_public } = req.body as {
|
||||
name: string; description?: string; category_id?: string; is_public?: string
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE models SET name = ?, description = ?, category_id = ?, is_public = ?, updated_at = datetime('now') WHERE id = ?`).run(
|
||||
name.trim(), description?.trim() || null, category_id ? parseInt(category_id, 10) : null, is_public === 'on' ? 1 : 0, model.id
|
||||
)
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
// ---- Delete PDF ----------------------------------------------------------
|
||||
|
||||
router.post('/admin/pdfs/:id/delete', requireAdmin, (req: Request, res: Response) => {
|
||||
const pdf = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE id = ?`).get(req.params.id)
|
||||
if (!pdf) { res.redirect('/admin'); return }
|
||||
|
||||
const pdfFile = path.join(UPLOADS_DIR, pdf.file_path)
|
||||
if (fs.existsSync(pdfFile)) fs.unlinkSync(pdfFile)
|
||||
|
||||
db.prepare(`DELETE FROM model_pdfs WHERE id = ?`).run(pdf.id)
|
||||
res.redirect(`/admin/models/${pdf.model_id}/edit`)
|
||||
})
|
||||
|
||||
// ---- JSON list -----------------------------------------------------------
|
||||
|
||||
router.get('/api/admin/models', requireAdmin, (_req: Request, res: Response) => {
|
||||
const models = q<Model & { category_name: string | null }>(`
|
||||
SELECT m.*, c.name AS category_name
|
||||
FROM models m LEFT JOIN categories c ON c.id = m.category_id
|
||||
ORDER BY m.created_at DESC
|
||||
`).all()
|
||||
res.json(models)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getAllSettings, setSetting } from '../db/index'
|
||||
import { requireAdmin } from '../middleware/requireAdmin'
|
||||
import { invalidateBrandCache } from '../middleware/injectBrand'
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
|
||||
|
||||
const logoStorage = multer.diskStorage({
|
||||
destination: path.join(UPLOADS_DIR, 'brand'),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
cb(null, `logo${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: path.join(UPLOADS_DIR, 'brand'),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
cb(null, `favicon${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.svg', '.webp', '.ico'])
|
||||
|
||||
function imageFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (ALLOWED_IMAGE_EXTS.has(ext)) cb(null, true)
|
||||
else cb(new Error('Only image files are allowed for logo/favicon'))
|
||||
}
|
||||
|
||||
const uploadLogo = multer({ storage: logoStorage, fileFilter: imageFilter, limits: { fileSize: 2 * 1024 * 1024 } })
|
||||
const uploadFavicon = multer({ storage: faviconStorage, fileFilter: imageFilter, limits: { fileSize: 512 * 1024 } })
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/admin/settings', requireAdmin, (_req: Request, res: Response) => {
|
||||
const settings = getAllSettings()
|
||||
res.render('admin/settings', { settings, saved: false, error: null })
|
||||
})
|
||||
|
||||
router.post('/admin/settings', requireAdmin, (req: Request, res: Response) => {
|
||||
const allowed = ['brand_name', 'brand_tagline', 'brand_accent'] as const
|
||||
for (const key of allowed) {
|
||||
const val = (req.body as Record<string, string>)[key]?.trim()
|
||||
if (val !== undefined) setSetting(key, val)
|
||||
}
|
||||
invalidateBrandCache()
|
||||
const settings = getAllSettings()
|
||||
res.render('admin/settings', { settings, saved: true, error: null })
|
||||
})
|
||||
|
||||
router.post('/admin/settings/logo', requireAdmin, uploadLogo.single('logo'), (_req: Request, res: Response) => {
|
||||
if (_req.file) {
|
||||
setSetting('brand_logo_path', `brand/${_req.file.filename}`)
|
||||
invalidateBrandCache()
|
||||
}
|
||||
res.redirect('/admin/settings')
|
||||
})
|
||||
|
||||
router.post('/admin/settings/favicon', requireAdmin, uploadFavicon.single('favicon'), (req: Request, res: Response) => {
|
||||
if (req.file) {
|
||||
setSetting('brand_favicon_path', `brand/${req.file.filename}`)
|
||||
invalidateBrandCache()
|
||||
}
|
||||
res.redirect('/admin/settings')
|
||||
})
|
||||
|
||||
router.post('/admin/settings/logo/delete', requireAdmin, (_req: Request, res: Response) => {
|
||||
for (const ext of ['.png', '.jpg', '.jpeg', '.svg', '.webp']) {
|
||||
const f = path.join(UPLOADS_DIR, 'brand', `logo${ext}`)
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f)
|
||||
}
|
||||
setSetting('brand_logo_path', '')
|
||||
invalidateBrandCache()
|
||||
res.redirect('/admin/settings')
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getSetting } from '../db/index'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/admin/login', (req: Request, res: Response) => {
|
||||
if ((req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.redirect('/admin')
|
||||
return
|
||||
}
|
||||
res.render('admin/login', {
|
||||
error: null,
|
||||
next: req.query.next ?? '/admin',
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/admin/login', async (req: Request, res: Response) => {
|
||||
const { username, password } = req.body as { username: string; password: string }
|
||||
const expectedUser = process.env.ADMIN_USER ?? 'admin'
|
||||
const storedHash = getSetting('admin_pass_hash')
|
||||
|
||||
const userMatch = username === expectedUser
|
||||
const passMatch = storedHash ? await bcrypt.compare(password, storedHash) : false
|
||||
|
||||
if (userMatch && passMatch) {
|
||||
;(req.session as { isAdmin?: boolean }).isAdmin = true
|
||||
const next = (req.body.next as string | undefined) ?? '/admin'
|
||||
// Guard against open redirect
|
||||
const safeNext = next.startsWith('/') ? next : '/admin'
|
||||
res.redirect(safeNext)
|
||||
return
|
||||
}
|
||||
|
||||
res.render('admin/login', {
|
||||
error: 'Invalid username or password.',
|
||||
next: req.body.next ?? '/admin',
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/admin/logout', (req: Request, res: Response) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/admin/login')
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { q, Model, ModelPdf, getSetting } from '../db/index'
|
||||
import { noDownload } from '../middleware/noDownload'
|
||||
import { geometryOutputPath } from '../services/stepConverter'
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
|
||||
|
||||
const router = Router()
|
||||
|
||||
function safeResolve(relPath: string): string | null {
|
||||
const abs = path.resolve(UPLOADS_DIR, relPath)
|
||||
if (!abs.startsWith(path.resolve(UPLOADS_DIR))) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
router.get('/files/model/:id', noDownload, (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.status(404).end(); return }
|
||||
|
||||
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.status(403).end(); return
|
||||
}
|
||||
|
||||
const abs = safeResolve(model.file_path)
|
||||
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
|
||||
|
||||
const ext = path.extname(model.file_path).toLowerCase()
|
||||
const mime = ext === '.stl' ? 'model/stl' : 'application/octet-stream'
|
||||
res.setHeader('Content-Type', mime)
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
})
|
||||
|
||||
router.get('/files/pdf/:modelId/:pdfId', noDownload, (req: Request, res: Response) => {
|
||||
const model = q<{ is_public: 0 | 1 }>(`SELECT is_public FROM models WHERE id = ?`).get(req.params.modelId)
|
||||
if (!model) { res.status(404).end(); return }
|
||||
|
||||
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.status(403).end(); return
|
||||
}
|
||||
|
||||
const pdf = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE id = ? AND model_id = ?`).get(req.params.pdfId, req.params.modelId)
|
||||
if (!pdf) { res.status(404).end(); return }
|
||||
|
||||
const abs = safeResolve(pdf.file_path)
|
||||
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
})
|
||||
|
||||
router.get('/brand/logo', (_req: Request, res: Response) => {
|
||||
const relPath = getSetting('brand_logo_path')
|
||||
if (!relPath) { res.status(404).end(); return }
|
||||
|
||||
const abs = safeResolve(relPath)
|
||||
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
|
||||
|
||||
const ext = path.extname(abs).toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon',
|
||||
}
|
||||
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600')
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
})
|
||||
|
||||
router.get('/brand/favicon', (_req: Request, res: Response) => {
|
||||
const relPath = getSetting('brand_favicon_path')
|
||||
if (!relPath) { res.status(404).end(); return }
|
||||
|
||||
const abs = safeResolve(relPath)
|
||||
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
|
||||
|
||||
res.setHeader('Content-Type', 'image/x-icon')
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
})
|
||||
|
||||
// Pre-processed geometry JSON for STEP/STP files (served with gzip by compression middleware)
|
||||
router.get('/files/geometry/:id', (req: Request, res: Response) => {
|
||||
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
||||
if (!model) { res.status(404).json({ error: 'Not found' }); return }
|
||||
|
||||
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.status(403).json({ error: 'Forbidden' }); return
|
||||
}
|
||||
|
||||
const absModelPath = safeResolve(model.file_path)
|
||||
if (!absModelPath) { res.status(404).json({ error: 'Model file not found' }); return }
|
||||
|
||||
const geoPath = geometryOutputPath(absModelPath)
|
||||
if (!fs.existsSync(geoPath)) {
|
||||
res.status(404).json({ error: 'Geometry not yet processed', processing: true })
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600')
|
||||
fs.createReadStream(geoPath).pipe(res)
|
||||
})
|
||||
|
||||
router.get('/files/thumbnail/:id', (req: Request, res: Response) => {
|
||||
const model = q<Pick<Model, 'thumbnail_path' | 'is_public'>>(
|
||||
`SELECT thumbnail_path, is_public FROM models WHERE id = ?`
|
||||
).get(req.params.id)
|
||||
|
||||
if (!model?.thumbnail_path) { res.status(404).end(); return }
|
||||
|
||||
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.status(403).end(); return
|
||||
}
|
||||
|
||||
const abs = safeResolve(model.thumbnail_path)
|
||||
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
|
||||
|
||||
res.setHeader('Content-Type', 'image/png')
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600')
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { q, Model, ModelPdf, Category } from '../db/index'
|
||||
import { geometryOutputPath } from '../services/stepConverter'
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/view/:slug', (req: Request, res: Response) => {
|
||||
const model = q<Model & { category_name: string | null }>(`
|
||||
SELECT m.*, c.name AS category_name
|
||||
FROM models m
|
||||
LEFT JOIN categories c ON c.id = m.category_id
|
||||
WHERE m.slug = ?
|
||||
`).get(req.params.slug)
|
||||
|
||||
if (!model) {
|
||||
res.status(404).render('error', { status: 404, message: 'Model not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
|
||||
res.status(403).render('error', { status: 403, message: 'This model is not publicly available.' })
|
||||
return
|
||||
}
|
||||
|
||||
const pdfs = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE model_id = ? ORDER BY sort_order`).all(model.id)
|
||||
const baseUrl = process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`
|
||||
|
||||
// Determine whether pre-processed geometry is available for STEP/STP
|
||||
let hasGeometry = false
|
||||
if (model.file_type === 'step' || model.file_type === 'stp') {
|
||||
const absPath = path.resolve(UPLOADS_DIR, model.file_path)
|
||||
hasGeometry = fs.existsSync(geometryOutputPath(absPath))
|
||||
}
|
||||
|
||||
res.render('viewer', {
|
||||
model,
|
||||
pdfs,
|
||||
shareUrl: `${baseUrl}/view/${model.slug}`,
|
||||
hasGeometry,
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Public model index --------------------------------------------------
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
// Fetch categories that have at least one public model, plus their models
|
||||
const categories = q<Category & { model_count: number }>(`
|
||||
SELECT c.*, COUNT(m.id) AS model_count
|
||||
FROM categories c
|
||||
INNER JOIN models m ON m.category_id = c.id AND m.is_public = 1
|
||||
GROUP BY c.id
|
||||
HAVING model_count > 0
|
||||
ORDER BY c.sort_order, c.name
|
||||
`).all()
|
||||
|
||||
// For each category, fetch its models
|
||||
const categorised = categories.map(cat => ({
|
||||
category: cat,
|
||||
models: q<Model>(`
|
||||
SELECT * FROM models
|
||||
WHERE is_public = 1 AND category_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(cat.id),
|
||||
}))
|
||||
|
||||
const uncategorized = q<Model>(`
|
||||
SELECT * FROM models
|
||||
WHERE is_public = 1 AND category_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`).all()
|
||||
|
||||
res.render('index', { categorised, uncategorized })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,68 @@
|
||||
import occtimport from 'occt-import-js'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface GeometryMesh {
|
||||
positions: number[]
|
||||
normals: number[] | null
|
||||
indices: number[]
|
||||
color: [number, number, number] | null
|
||||
}
|
||||
|
||||
export interface GeometryFile {
|
||||
version: 1
|
||||
sourceFile: string
|
||||
meshCount: number
|
||||
meshes: GeometryMesh[]
|
||||
}
|
||||
|
||||
// Singleton — WASM initializes once per Node process (~3 s on first call, instant after)
|
||||
let _occt: Awaited<ReturnType<typeof occtimport>> | null = null
|
||||
let _init: Promise<Awaited<ReturnType<typeof occtimport>>> | null = null
|
||||
|
||||
async function getOcct() {
|
||||
if (_occt) return _occt
|
||||
if (!_init) _init = occtimport().then(m => { _occt = m; return m })
|
||||
return _init
|
||||
}
|
||||
|
||||
// Warm up the WASM module in the background so the first upload doesn't pay
|
||||
// the initialization cost during the HTTP request.
|
||||
export function warmUpOcct(): void {
|
||||
getOcct().catch(() => {/* non-fatal */})
|
||||
}
|
||||
|
||||
export async function convertStepFile(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
): Promise<{ meshCount: number }> {
|
||||
const occt = await getOcct()
|
||||
const buffer = fs.readFileSync(inputPath)
|
||||
const result = occt.ReadStepFile(new Uint8Array(buffer), null)
|
||||
|
||||
if (!result.success || result.meshes.length === 0) {
|
||||
throw new Error('STEP/STP parse failed — file may be corrupt or unsupported.')
|
||||
}
|
||||
|
||||
const geo: GeometryFile = {
|
||||
version: 1,
|
||||
sourceFile: path.basename(inputPath),
|
||||
meshCount: result.meshes.length,
|
||||
meshes: result.meshes.map(mesh => ({
|
||||
positions: Array.from(mesh.attributes.position.array),
|
||||
normals: mesh.attributes.normal ? Array.from(mesh.attributes.normal.array) : null,
|
||||
indices: Array.from(mesh.index.array),
|
||||
color: mesh.color ? [mesh.color[0], mesh.color[1], mesh.color[2]] : null,
|
||||
})),
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(geo))
|
||||
return { meshCount: result.meshes.length }
|
||||
}
|
||||
|
||||
/** Returns the expected geometry output path for a given model file path. */
|
||||
export function geometryOutputPath(modelFilePath: string): string {
|
||||
const dir = path.dirname(modelFilePath)
|
||||
const base = path.basename(modelFilePath, path.extname(modelFilePath))
|
||||
return path.join(dir, `${base}.geometry.json`)
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'occt-import-js' {
|
||||
interface MeshAttributes {
|
||||
position: { array: Float32Array }
|
||||
normal?: { array: Float32Array }
|
||||
}
|
||||
interface OcctMesh {
|
||||
attributes: MeshAttributes
|
||||
index: { array: Uint32Array }
|
||||
color: number[] | null
|
||||
}
|
||||
interface ReadResult {
|
||||
success: boolean
|
||||
meshes: OcctMesh[]
|
||||
}
|
||||
interface OcctModule {
|
||||
ReadStepFile(buffer: Uint8Array, params: null): ReadResult
|
||||
}
|
||||
function occtimport(options?: { locateFile?: (f: string) => string }): Promise<OcctModule>
|
||||
export default occtimport
|
||||
}
|
||||
Reference in New Issue
Block a user