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", "express-session": "^1.18.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"occt-import-js": "^0.0.23", "occt-import-js": "^0.0.23",
"pngjs": "^6.0.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"three": "^0.184.0" "three": "^0.184.0"
}, },
@@ -27,6 +28,7 @@
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
@@ -1096,6 +1098,16 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -3035,6 +3047,15 @@
"node": ">= 6" "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": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+2
View File
@@ -17,6 +17,7 @@
"express-session": "^1.18.0", "express-session": "^1.18.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"occt-import-js": "^0.0.23", "occt-import-js": "^0.0.23",
"pngjs": "^6.0.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"three": "^0.184.0" "three": "^0.184.0"
}, },
@@ -28,6 +29,7 @@
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
+42 -10
View File
@@ -2,10 +2,12 @@ import { Router, Request, Response } from 'express'
import multer from 'multer' import multer from 'multer'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import crypto from 'crypto'
import slugify from 'slugify' import slugify from 'slugify'
import { db, q, Model, Category, ModelPdf } from '../db/index' import { db, q, Model, Category, ModelPdf } from '../db/index'
import { requireAdmin } from '../middleware/requireAdmin' 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 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 MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_MB ?? '500', 10) * 1024 * 1024
@@ -113,26 +115,29 @@ router.post('/admin/models',
} }
const ext = path.extname(req.file.originalname).toLowerCase().replace('.', '') as 'step' | 'stp' | 'stl' 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 // 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)) { while (q<{ id: number }>(`SELECT id FROM models WHERE slug = ?`).get(slug)) {
attempt++ slug = crypto.randomBytes(6).toString('hex') // collision is astronomically unlikely
slug = `${base}-${attempt}`
} }
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/') 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) INSERT INTO models (slug, name, description, category_id, file_path, file_type, is_public)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(slug, name.trim(), description?.trim() || null, category_id ? parseInt(category_id, 10) : null, relPath, ext, is_public === 'on' ? 1 : 0) `).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 const modelId = Number(insertResult.lastInsertRowid)
// needs to download the 22 MB WASM parser.
if (ext === 'step' || ext === 'stp') {
const absModelPath = path.join(UPLOADS_DIR, relPath) 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, then generate a thumbnail
// from the resulting geometry data.
if (ext === 'step' || ext === 'stp') {
const geoOutPath = geometryOutputPath(absModelPath) const geoOutPath = geometryOutputPath(absModelPath)
try { try {
await convertStepFile(absModelPath, geoOutPath) await convertStepFile(absModelPath, geoOutPath)
@@ -141,6 +146,33 @@ router.post('/admin/models',
// will show a friendly error instead of crashing the upload. // will show a friendly error instead of crashing the upload.
console.error('[stepConverter] conversion failed:', (convErr as Error).message) 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') res.redirect('/admin')
+11 -30
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express' import { Router, Request, Response } from 'express'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { q, Model, ModelPdf, Category } from '../db/index' import { q, Model, ModelPdf } from '../db/index'
import { geometryOutputPath } from '../services/stepConverter' import { geometryOutputPath } from '../services/stepConverter'
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads') 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) => { router.get('/', (req: Request, res: Response) => {
// Fetch categories that have at least one public model, plus their models if ((req.session as { isAdmin?: boolean }).isAdmin) {
const categories = q<Category & { model_count: number }>(` res.redirect('/admin')
SELECT c.*, COUNT(m.id) AS model_count } else {
FROM categories c res.redirect('/admin/login')
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 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
}
}
}