From 0a47b90e210d3bc57afc5f8840045ab06daf467b Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 22 Apr 2026 17:00:42 -0500 Subject: [PATCH] operational fixes --- package-lock.json | 21 ++ package.json | 2 + src/server/routes/adminModels.ts | 54 ++++- src/server/routes/viewer.ts | 41 +--- src/server/services/thumbnailGenerator.ts | 264 ++++++++++++++++++++++ 5 files changed, 341 insertions(+), 41 deletions(-) create mode 100644 src/server/services/thumbnailGenerator.ts diff --git a/package-lock.json b/package-lock.json index a0f59fd..17298f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express-session": "^1.18.0", "multer": "^1.4.5-lts.1", "occt-import-js": "^0.0.23", + "pngjs": "^6.0.0", "slugify": "^1.6.6", "three": "^0.184.0" }, @@ -27,6 +28,7 @@ "@types/express-session": "^1.18.0", "@types/multer": "^1.4.11", "@types/node": "^22.0.0", + "@types/pngjs": "^6.0.5", "@types/three": "^0.184.0", "autoprefixer": "^10.4.19", "concurrently": "^8.2.2", @@ -1096,6 +1098,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -3035,6 +3047,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", diff --git a/package.json b/package.json index bfd2bb7..dcfb0c1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "express-session": "^1.18.0", "multer": "^1.4.5-lts.1", "occt-import-js": "^0.0.23", + "pngjs": "^6.0.0", "slugify": "^1.6.6", "three": "^0.184.0" }, @@ -28,6 +29,7 @@ "@types/express-session": "^1.18.0", "@types/multer": "^1.4.11", "@types/node": "^22.0.0", + "@types/pngjs": "^6.0.5", "@types/three": "^0.184.0", "autoprefixer": "^10.4.19", "concurrently": "^8.2.2", diff --git a/src/server/routes/adminModels.ts b/src/server/routes/adminModels.ts index c700ec9..ec85461 100644 --- a/src/server/routes/adminModels.ts +++ b/src/server/routes/adminModels.ts @@ -2,10 +2,12 @@ import { Router, Request, Response } from 'express' import multer from 'multer' import path from 'path' import fs from 'fs' +import crypto from 'crypto' import slugify from 'slugify' import { db, q, Model, Category, ModelPdf } from '../db/index' import { requireAdmin } from '../middleware/requireAdmin' -import { convertStepFile, geometryOutputPath } from '../services/stepConverter' +import { convertStepFile, geometryOutputPath, GeometryFile } from '../services/stepConverter' +import { thumbnailOutputPath, geometryToTriangles, stlBufferToTriangles, renderThumbnail } from '../services/thumbnailGenerator' 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 @@ -112,28 +114,31 @@ router.post('/admin/models', 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 + const ext = path.extname(req.file.originalname).toLowerCase().replace('.', '') as 'step' | 'stp' | 'stl' - let attempt = 0 + // Generate a random, unguessable 12-character hex slug (48 bits of entropy). + // The model name is stored separately for display — the URL reveals nothing + // about the content, making enumeration infeasible without an admin link. + let slug = crypto.randomBytes(6).toString('hex') while (q<{ id: number }>(`SELECT id FROM models WHERE slug = ?`).get(slug)) { - attempt++ - slug = `${base}-${attempt}` + slug = crypto.randomBytes(6).toString('hex') // collision is astronomically unlikely } const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/') - db.prepare(` + const insertResult = 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) + const modelId = Number(insertResult.lastInsertRowid) + const absModelPath = path.join(UPLOADS_DIR, relPath) + // Convert STEP/STP to pre-processed geometry JSON so the browser never - // needs to download the 22 MB WASM parser. + // needs to download the 22 MB WASM parser, then generate a thumbnail + // from the resulting geometry data. if (ext === 'step' || ext === 'stp') { - const absModelPath = path.join(UPLOADS_DIR, relPath) - const geoOutPath = geometryOutputPath(absModelPath) + const geoOutPath = geometryOutputPath(absModelPath) try { await convertStepFile(absModelPath, geoOutPath) } catch (convErr) { @@ -141,6 +146,33 @@ router.post('/admin/models', // will show a friendly error instead of crashing the upload. console.error('[stepConverter] conversion failed:', (convErr as Error).message) } + + // Generate thumbnail from the geometry JSON (only if conversion succeeded) + if (fs.existsSync(geoOutPath)) { + try { + const geo = JSON.parse(fs.readFileSync(geoOutPath, 'utf8')) as GeometryFile + const tris = geometryToTriangles(geo) + const thumbPath = thumbnailOutputPath(UPLOADS_DIR, modelId) + await renderThumbnail(tris, thumbPath) + const thumbRel = path.relative(UPLOADS_DIR, thumbPath).replace(/\\/g, '/') + db.prepare(`UPDATE models SET thumbnail_path = ? WHERE id = ?`).run(thumbRel, modelId) + } catch (thumbErr) { + console.error('[thumbnail] STEP generation failed:', (thumbErr as Error).message) + } + } + + } else if (ext === 'stl') { + // Parse the STL directly on the server — no intermediate JSON needed + try { + const stlBuffer = fs.readFileSync(absModelPath) + const tris = stlBufferToTriangles(stlBuffer) + const thumbPath = thumbnailOutputPath(UPLOADS_DIR, modelId) + await renderThumbnail(tris, thumbPath) + const thumbRel = path.relative(UPLOADS_DIR, thumbPath).replace(/\\/g, '/') + db.prepare(`UPDATE models SET thumbnail_path = ? WHERE id = ?`).run(thumbRel, modelId) + } catch (thumbErr) { + console.error('[thumbnail] STL generation failed:', (thumbErr as Error).message) + } } res.redirect('/admin') diff --git a/src/server/routes/viewer.ts b/src/server/routes/viewer.ts index 25768c3..2b322c1 100644 --- a/src/server/routes/viewer.ts +++ b/src/server/routes/viewer.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from 'express' import path from 'path' import fs from 'fs' -import { q, Model, ModelPdf, Category } from '../db/index' +import { q, Model, ModelPdf } from '../db/index' import { geometryOutputPath } from '../services/stepConverter' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads') @@ -44,36 +44,17 @@ router.get('/view/:slug', (req: Request, res: Response) => { }) }) -// ---- Public model index -------------------------------------------------- +// ---- Root redirect ------------------------------------------------------- +// The public model library is not browsable. The root always redirects to the +// admin area. Authenticated admins land on the dashboard; everyone else hits +// the login page (which itself redirects to /admin after a successful login). -router.get('/', (_req: Request, res: Response) => { - // Fetch categories that have at least one public model, plus their models - const categories = q(` - 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(` - SELECT * FROM models - WHERE is_public = 1 AND category_id = ? - ORDER BY created_at DESC - `).all(cat.id), - })) - - const uncategorized = q(` - SELECT * FROM models - WHERE is_public = 1 AND category_id IS NULL - ORDER BY created_at DESC - `).all() - - res.render('index', { categorised, uncategorized }) +router.get('/', (req: Request, res: Response) => { + if ((req.session as { isAdmin?: boolean }).isAdmin) { + res.redirect('/admin') + } else { + res.redirect('/admin/login') + } }) export default router diff --git a/src/server/services/thumbnailGenerator.ts b/src/server/services/thumbnailGenerator.ts new file mode 100644 index 0000000..a1621a1 --- /dev/null +++ b/src/server/services/thumbnailGenerator.ts @@ -0,0 +1,264 @@ +/** + * thumbnailGenerator.ts + * + * Pure-JS isometric thumbnail renderer. No native dependencies — works on + * any Alpine/Node image with zero additional system packages. + * + * Pipeline: + * 1. Convert source geometry (GeometryFile or binary STL) into a flat list + * of triangles with pre-computed face normals and base colours. + * 2. Project each triangle with a classic isometric transform (looking from + * the (1,1,1) direction — the same view CAD tools use by default). + * 3. Sort back-to-front (painter's algorithm) and fill each triangle with + * a Lambertian-shaded colour into an RGBA pixel buffer. + * 4. Encode the buffer as PNG via `pngjs` (pure JS) and write to disk. + * + * Output: 512×512 PNG, dark (#0a0a0f) background. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import { PNG } from 'pngjs' +import type { GeometryFile } from './stepConverter' + +// ---- Config ----------------------------------------------------------------- + +const SIZE = 512 // output image dimensions (square) +const MAX_TRIS = 150_000 // subsample cap for very-large models +const BG_R = 10, BG_G = 10, BG_B = 15 // dark background (#0a0a0f) + +// Isometric projection constants +const COS30 = Math.sqrt(3) / 2 // 0.866 +const SIN30 = 0.5 + +// Key-light direction — mirrors the Three.js directional light at (5, 8, 5) +const _LL = Math.hypot(5, 8, 5) +const LX = 5 / _LL, LY = 8 / _LL, LZ = 5 / _LL + +// Scale: fits the worst-case isometric cube in ~88 % of the canvas with padding +const SCALE = SIZE * 0.43 +const ORIGIN = SIZE / 2 + +// ---- Types ------------------------------------------------------------------ + +interface ThumbTri { + ax: number; ay: number; az: number + bx: number; by: number; bz: number + cx: number; cy: number; cz: number + nx: number; ny: number; nz: number // face normal (unit length) + r: number; g: number; b: number // base colour 0-255 +} + +// ---- Public API ------------------------------------------------------------- + +/** Returns the on-disk path for a model's auto-generated thumbnail PNG. */ +export function thumbnailOutputPath(uploadsDir: string, modelId: number | bigint): string { + return path.join(uploadsDir, 'thumbnails', `${modelId}.png`) +} + +/** Convert a pre-processed GeometryFile (from STEP/STP conversion) into triangles. */ +export function geometryToTriangles(geo: GeometryFile): ThumbTri[] { + const tris: ThumbTri[] = [] + for (const mesh of geo.meshes) { + const pos = mesh.positions + const idx = mesh.indices + const r = mesh.color ? Math.round(mesh.color[0] * 255) : 136 + const g = mesh.color ? Math.round(mesh.color[1] * 255) : 153 + const b = mesh.color ? Math.round(mesh.color[2] * 255) : 170 + for (let i = 0; i < idx.length; i += 3) { + const i0 = idx[i] * 3, i1 = idx[i + 1] * 3, i2 = idx[i + 2] * 3 + tris.push(makeTri( + pos[i0], pos[i0 + 1], pos[i0 + 2], + pos[i1], pos[i1 + 1], pos[i1 + 2], + pos[i2], pos[i2 + 1], pos[i2 + 2], + r, g, b, + )) + } + } + return tris +} + +/** + * Parse a binary STL buffer into triangles. + * ASCII STL is not supported — the function returns [] for ASCII files. + */ +export function stlBufferToTriangles(buf: Buffer): ThumbTri[] { + const tris: ThumbTri[] = [] + if (buf.length < 84) return tris + const count = buf.readUInt32LE(80) + // Validate it's actually binary STL (ASCII starts with "solid " and won't fit) + if (buf.length < 84 + count * 50) return tris + for (let i = 0; i < count; i++) { + const o = 84 + i * 50 // skip 80-byte header + 4-byte count; 50 bytes/triangle + // Bytes 0-11: STL normal (ignored — we compute it from vertices for consistency) + tris.push(makeTri( + buf.readFloatLE(o + 12), buf.readFloatLE(o + 16), buf.readFloatLE(o + 20), + buf.readFloatLE(o + 24), buf.readFloatLE(o + 28), buf.readFloatLE(o + 32), + buf.readFloatLE(o + 36), buf.readFloatLE(o + 40), buf.readFloatLE(o + 44), + 136, 153, 170, + )) + } + return tris +} + +/** + * Render a triangle list as a 512×512 isometric thumbnail PNG. + * Non-throwing — call inside a try/catch if you want failure to be silent. + */ +export async function renderThumbnail( + tris: ThumbTri[], + outputPath: string, +): Promise { + if (tris.length === 0) return + + // ---- Bounding box -------------------------------------------------------- + let minX = Infinity, maxX = -Infinity + let minY = Infinity, maxY = -Infinity + let minZ = Infinity, maxZ = -Infinity + for (const t of tris) { + minX = Math.min(minX, t.ax, t.bx, t.cx) + maxX = Math.max(maxX, t.ax, t.bx, t.cx) + minY = Math.min(minY, t.ay, t.by, t.cy) + maxY = Math.max(maxY, t.ay, t.by, t.cy) + minZ = Math.min(minZ, t.az, t.bz, t.cz) + maxZ = Math.max(maxZ, t.az, t.bz, t.cz) + } + const cX = (minX + maxX) / 2 + const cY = (minY + maxY) / 2 + const cZ = (minZ + maxZ) / 2 + const maxRange = Math.max(maxX - minX, maxY - minY, maxZ - minZ) || 1 + + // ---- Isometric projection ------------------------------------------------ + // Looking from direction (1,1,1) toward the origin. + // screenX = (nx - nz) * cos30 + // screenY = (nx + nz) * sin30 - ny + // depth = nx + ny + nz (used for painter's-algorithm sort only) + function project(wx: number, wy: number, wz: number) { + const nx = (wx - cX) / maxRange + const ny = (wy - cY) / maxRange + const nz = (wz - cZ) / maxRange + return { + sx: (nx - nz) * COS30, + sy: (nx + nz) * SIN30 - ny, + d: nx + ny + nz, + } + } + + // ---- Lambertian shading -------------------------------------------------- + function shade(nx: number, ny: number, nz: number, r: number, g: number, b: number) { + const dot = Math.max(0, nx * LX + ny * LY + nz * LZ) + const back = Math.max(0, -(nx * LX + ny * LY + nz * LZ)) * 0.08 // subtle fill + const l = 0.25 + 0.72 * dot + back + return { + r: Math.min(255, Math.round(r * l)), + g: Math.min(255, Math.round(g * l)), + b: Math.min(255, Math.round(b * l)), + } + } + + // ---- Subsample very large models ----------------------------------------- + const src = tris.length > MAX_TRIS + ? tris.filter((_, i) => i % Math.ceil(tris.length / MAX_TRIS) === 0) + : tris + + // ---- Project + sort (painter's algorithm) -------------------------------- + const projected = src.map(t => { + const pa = project(t.ax, t.ay, t.az) + const pb = project(t.bx, t.by, t.bz) + const pc = project(t.cx, t.cy, t.cz) + const col = shade(t.nx, t.ny, t.nz, t.r, t.g, t.b) + return { + x0: Math.round(ORIGIN + pa.sx * SCALE), + y0: Math.round(ORIGIN + pa.sy * SCALE), + x1: Math.round(ORIGIN + pb.sx * SCALE), + y1: Math.round(ORIGIN + pb.sy * SCALE), + x2: Math.round(ORIGIN + pc.sx * SCALE), + y2: Math.round(ORIGIN + pc.sy * SCALE), + d: pa.d + pb.d + pc.d, + r: col.r, g: col.g, b: col.b, + } + }) + projected.sort((a, b) => a.d - b.d) + + // ---- Pixel buffer (pre-filled with background) --------------------------- + const rgba = Buffer.alloc(SIZE * SIZE * 4) + for (let i = 0; i < SIZE * SIZE; i++) { + rgba[i * 4] = BG_R + rgba[i * 4 + 1] = BG_G + rgba[i * 4 + 2] = BG_B + rgba[i * 4 + 3] = 255 + } + + // ---- Rasterize ----------------------------------------------------------- + for (const tri of projected) { + rasterize(rgba, tri.x0, tri.y0, tri.x1, tri.y1, tri.x2, tri.y2, tri.r, tri.g, tri.b) + } + + // ---- Encode PNG and write to disk ---------------------------------------- + const png = new PNG({ width: SIZE, height: SIZE }) + png.data = rgba + const encoded = PNG.sync.write(png) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, encoded) +} + +// ---- Internal helpers ------------------------------------------------------- + +/** Build a ThumbTri with a computed face normal. */ +function makeTri( + ax: number, ay: number, az: number, + bx: number, by: number, bz: number, + cx: number, cy: number, cz: number, + r: number, g: number, b: number, +): ThumbTri { + const ex = bx - ax, ey = by - ay, ez = bz - az + const fx = cx - ax, fy = cy - ay, fz = cz - az + let nx = ey * fz - ez * fy + let ny = ez * fx - ex * fz + let nz = ex * fy - ey * fx + const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1 + return { ax, ay, az, bx, by, bz, cx, cy, cz, nx: nx / len, ny: ny / len, nz: nz / len, r, g, b } +} + +/** + * Fill a triangle using barycentric coverage tests. + * Paints directly into the RGBA buffer (overwrites — painter's algorithm + * guarantees correct depth ordering via the sort above). + */ +function rasterize( + rgba: Buffer, + x0: number, y0: number, + x1: number, y1: number, + x2: number, y2: number, + r: number, g: number, b: number, +) { + const minX = Math.max(0, Math.min(x0, x1, x2)) + const maxX = Math.min(SIZE - 1, Math.max(x0, x1, x2)) + const minY = Math.max(0, Math.min(y0, y1, y2)) + const maxY = Math.min(SIZE - 1, Math.max(y0, y1, y2)) + if (minX > maxX || minY > maxY) return + + // Signed area * 2 (also serves as the denominator for barycentric weights) + const D = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2) + if (D === 0) return // degenerate (zero-area) triangle + + for (let py = minY; py <= maxY; py++) { + for (let px = minX; px <= maxX; px++) { + const dx = px - x2, dy = py - y2 + const w0 = (y1 - y2) * dx + (x2 - x1) * dy + const w1 = (y2 - y0) * dx + (x0 - x2) * dy + // Point is inside the triangle when all barycentric weights have the + // same sign as D (handles both CW and CCW winding). + if (D > 0) { + if (w0 < 0 || w1 < 0 || w0 + w1 > D) continue + } else { + if (w0 > 0 || w1 > 0 || w0 + w1 < D) continue + } + const pi = (py * SIZE + px) * 4 + rgba[pi] = r + rgba[pi + 1] = g + rgba[pi + 2] = b + // alpha (rgba[pi+3]) stays 255 — set during background fill + } + } +}