This commit is contained in:
Generated
+21
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user