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