operational fixes
Build and Push Docker Image / build (push) Successful in 26s

This commit is contained in:
jason
2026-04-22 17:00:42 -05:00
parent b5318abe0a
commit 0a47b90e21
5 changed files with 341 additions and 41 deletions
+21
View File
@@ -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",
+2
View File
@@ -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",
+43 -11
View File
@@ -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')
+11 -30
View File
@@ -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<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 })
router.get('/', (req: Request, res: Response) => {
if ((req.session as { isAdmin?: boolean }).isAdmin) {
res.redirect('/admin')
} else {
res.redirect('/admin/login')
}
})
export default router
+264
View File
@@ -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<void> {
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
}
}
}