Stared work on the manager side

This commit is contained in:
2025-10-23 13:05:38 +11:00
parent 443e6177ca
commit cf5edd1329
29 changed files with 327 additions and 4 deletions

View File

@@ -0,0 +1,85 @@
# ---------- Stage 1: build frontend ----------
FROM node:20-bookworm-slim AS fe
WORKDIR /fe
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# deps
COPY frontend/package.json ./
RUN pnpm install --no-frozen-lockfile
# sources
COPY frontend/index.html ./
COPY frontend/vite.config.ts ./
COPY frontend/src ./src
# build to /fe/dist
RUN pnpm run build
# ---------- Stage 2: backend + slicer ----------
FROM node:20-bookworm-slim
# OS deps: Qt libs for Orca + native build deps for better-sqlite3 + tini
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libglib2.0-0 \
libx11-6 libxext6 libxrender1 libsm6 \
libxkbcommon0 libfontconfig1 libfreetype6 libnss3 libxi6 libxrandr2 \
libxfixes3 libdrm2 libxdamage1 libxcomposite1 libwayland-client0 libxcb1 \
python3 make g++ libsqlite3-dev \
curl tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# backend deps
COPY backend/package.json ./
RUN pnpm install --no-frozen-lockfile
# backend sources
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN pnpm exec tsc
# copy built frontend from stage 1
COPY --from=fe /fe/dist /app/www
# slicer AppImage (from repo root; compose build.context must be repo root)
COPY slicer/ /app/slicer/
RUN set -eux; \
F="$(ls -1 /app/slicer | head -n1)"; \
test -n "$F"; \
chmod +x "/app/slicer/$F"; \
cd /app/slicer; \
"/app/slicer/$F" --appimage-extract; \
ln -sf /app/slicer/squashfs-root/AppRun /app/slicer/orca
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libglib2.0-0 \
libx11-6 libxext6 libxrender1 libsm6 \
libxkbcommon0 libfontconfig1 libfreetype6 libnss3 libxi6 libxrandr2 \
libxfixes3 libdrm2 libxdamage1 libxcomposite1 libwayland-client0 libxcb1 \
python3 make g++ libsqlite3-dev \
curl tini \
libgl1 libgl1-mesa-dri libglu1-mesa \
&& rm -rf /var/lib/apt/lists/*
# runtime dirs
RUN mkdir -p /app/data/uploads /app/data/outputs /app/profiles
# env
ENV QT_QPA_PLATFORM=offscreen
ENV NODE_ENV=production
ENV SLICER_CMD=/app/slicer/orca
ENV LIBGL_ALWAYS_SOFTWARE=1
# Orca 2.3.1 CLI template (plate-sliced 3MF)
ENV SLICER_ARGS="--debug 2 --arrange 1 --load-settings {MACHINE};{PROCESS} --load-filaments {FILAMENT} --slice 0 --export-3mf {OUTPUT} {INPUT}"
ENV SUPPORTS_ON=--support-material
ENV SUPPORTS_OFF=--no-support-material
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["node","dist/server.js"]

View File

@@ -0,0 +1,28 @@
{
"name": "print-webui-backend",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"better-sqlite3": "9.6.0",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"mime-types": "2.1.35",
"multer": "1.4.5-lts.1"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.9",
"@types/cors": "2.8.17",
"@types/express": "4.17.21",
"@types/mime-types": "2.1.4",
"@types/multer": "1.4.12",
"tsx": "4.19.1",
"typescript": "5.6.3"
}
}

View File

@@ -0,0 +1,46 @@
import Database from "better-sqlite3";
import { JobRecord } from "./types.js";
const dbPath = process.env.DB_PATH || "/app/data/printjobs.sqlite";
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
ext TEXT NOT NULL,
machine TEXT,
filament TEXT,
process TEXT,
profile TEXT, -- keep if you used single-profile earlier
supports TEXT NOT NULL CHECK (supports IN ('on','off')),
input_path TEXT NOT NULL,
output_path TEXT,
status TEXT NOT NULL CHECK (status IN ('queued','processing','done','error')),
error_msg TEXT,
created_at INTEGER NOT NULL,
finished_at INTEGER
);
`);
// lightweight migrations (ignore errors if columns already exist)
for (const sql of [
"ALTER TABLE jobs ADD COLUMN rot_x REAL DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN rot_y REAL DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN rot_z REAL DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN scale REAL DEFAULT 1"
]) { try { db.exec(sql); } catch {} }
export const insertJob = db.prepare(`
INSERT INTO jobs (id, filename, ext, profile, supports, input_path, output_path, status, error_msg, created_at, finished_at)
VALUES (@id, @filename, @ext, @profile, @supports, @inputPath, @outputPath, @status, @errorMsg, @createdAt, @finishedAt)
`);
export const updateStatus = db.prepare(`
UPDATE jobs SET status=@status, output_path=@outputPath, error_msg=@errorMsg, finished_at=@finishedAt WHERE id=@id
`);
export const getJob = db.prepare(`SELECT * FROM jobs WHERE id = ?`);
export const listJobs = db.prepare(`SELECT * FROM jobs ORDER BY created_at DESC LIMIT 200`);
export default db;

View File

@@ -0,0 +1,271 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import multer from "multer";
import { randomUUID } from "crypto";
import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import mime from "mime-types";
import { JobRecord } from "./types.js";
import db, { insertJob, updateStatus, getJob, listJobs } from "./db.js";
import os from "os";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(cors());
app.use(express.json());
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/app/data/uploads";
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/app/data/outputs";
const PROFILES_DIR = "/app/profiles";
const SLICER_CMD = process.env.SLICER_CMD || "/app/slicer/OrcaSlicer.AppImage";
const SLICER_ARGS = process.env.SLICER_ARGS || `--headless --load-config "{PROFILE}" {SUPPORTS} -o "{OUTPUT}" "{INPUT}"`;
const SUPPORTS_ON = process.env.SUPPORTS_ON || "--support-material";
const SUPPORTS_OFF = process.env.SUPPORTS_OFF || "--no-support-material";
await fs.mkdir(UPLOAD_DIR, { recursive: true });
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
const id = randomUUID();
const ext = (mime.extension(file.mimetype) || path.extname(file.originalname).slice(1) || "stl").toLowerCase();
cb(null, `${id}.${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB cap
fileFilter: (_req, file, cb) => {
const allowed = ["model/stl", "application/vnd.ms-3mfdocument", "model/obj", "application/octet-stream"];
if (allowed.includes(file.mimetype) || /\.(stl|3mf|obj|amf)$/i.test(file.originalname)) cb(null, true);
else cb(new Error("Unsupported file type"));
}
});
/** List available profiles (files in /profiles) */
app.get("/api/profiles", async (_req, res) => {
try {
const files = await fs.readdir(PROFILES_DIR);
const only = files.filter(f => /\.(ini|json)$/i.test(f));
res.json(only);
} catch (e: any) {
res.status(500).json({ error: e?.message || "Failed to list profiles" });
}
});
/** Create a job: upload + slice */
app.post("/api/jobs", upload.single("model"), async (req, res) => {
try {
const profileName = String(req.body.profile || "").trim();
const supports = (String(req.body.supports || "off").toLowerCase() === "on") ? "on" : "off";
if (!req.file) throw new Error("No model file uploaded");
if (!profileName) throw new Error("Profile is required");
const profilePath = path.join(PROFILES_DIR, profileName);
const profileStat = await fs.stat(profilePath).catch(() => null);
if (!profileStat?.isFile()) throw new Error("Profile not found");
// Persist job row
const id = path.parse(req.file.filename).name; // filename was <uuid>.<ext>
const ext = path.extname(req.file.filename).slice(1).toLowerCase();
const inputPath = path.join(UPLOAD_DIR, req.file.filename);
const outputDir = path.join(OUTPUT_DIR, id);
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, "result.3mf");
// read transform fields from the form (defaults)
const rotX = Number(req.body.rotX ?? 0) || 0;
const rotY = Number(req.body.rotY ?? 0) || 0;
const rotZ = Number(req.body.rotZ ?? 0) || 0;
const scale = Number(req.body.scale ?? 1) || 1;
// create a path the slicer will use as input (transformed if needed)
const transformedInput = path.join(outputDir, "transformed_input.stl");
await transformStlIfNeeded(inputPath, transformedInput, {x:rotX, y:rotY, z:rotZ}, scale);
const job: JobRecord = {
id,
filename: path.join(outputDir, "transformed_input.stl"),
ext,
profile: profilePath,
supports,
inputPath,
outputPath: null,
status: "queued",
errorMsg: null,
createdAt: Date.now(),
finishedAt: null
};
insertJob.run(job);
try {
db.prepare("UPDATE jobs SET rot_x=?, rot_y=?, rot_z=?, scale=? WHERE id=?")
.run(rotX, rotY, rotZ, scale, id);
} catch {}
// Kick slicing async
runSlicing(job, outputPath, supports, profilePath).catch(() => { /* already captured in DB */ });
res.json({ id });
} catch (e: any) {
res.status(400).json({ error: e?.message || "Failed to create job" });
}
});
/** Get one job */
app.get("/api/jobs/:id", (req, res) => {
const row = getJob.get(req.params.id) as any;
if (!row) return res.status(404).json({ error: "Not found" });
res.json(mapRow(row));
});
/** List recent jobs */
app.get("/api/jobs", (_req, res) => {
const rows = listJobs.all() as any[];
res.json(rows.map(mapRow));
});
/** Serve outputs as static files */
app.use("/outputs", express.static(OUTPUT_DIR, { fallthrough: true }));
const FRONTEND_DIR = "/app/www";
app.use(express.static(FRONTEND_DIR));
/** Health */
app.get("/api/health", (_req, res) => res.json({ ok: true }));
const port = 8080;
app.listen(port, () => console.log(`Backend listening on :${port}`));
app.get(/^(?!\/api\/|\/outputs\/).*/, (_req, res) => {
res.sendFile(path.join(FRONTEND_DIR, "index.html"));
});
/** Helpers */
function mapRow(r: any) {
return {
id: r.id,
filename: r.filename,
ext: r.ext,
profile: path.basename(r.profile),
supports: r.supports,
inputPath: r.input_path,
outputPath: r.output_path ? `/outputs/${r.id}/result.3mf` : null,
status: r.status,
errorMsg: r.error_msg,
createdAt: r.created_at,
finishedAt: r.finished_at
};
}
async function transformStlIfNeeded(srcPath: string, dstPath: string, rot: {x:number;y:number;z:number}, scale: number): Promise<void> {
const ext = path.extname(srcPath).toLowerCase();
if (ext !== ".stl" || (rot.x===0 && rot.y===0 && rot.z===0 && scale===1)) {
// no transform: just copy
await fs.copyFile(srcPath, dstPath);
return;
}
const buf = await fs.readFile(srcPath);
const isAscii = buf.slice(0,5).toString().toLowerCase() === "solid" && buf.includes(0x0a); // rough check
const rx = (rot.x*Math.PI)/180, ry = (rot.y*Math.PI)/180, rz = (rot.z*Math.PI)/180;
const sx = scale, sy = scale, sz = scale;
const apply = (x:number,y:number,z:number) => {
// scale
let X = x*sx, Y=y*sy, Z=z*sz;
// rotate Z, Y, X (intrinsic)
// Z
let x1 = X*Math.cos(rz) - Y*Math.sin(rz);
let y1 = X*Math.sin(rz) + Y*Math.cos(rz);
let z1 = Z;
// Y
let x2 = x1*Math.cos(ry) + z1*Math.sin(ry);
let y2 = y1;
let z2 = -x1*Math.sin(ry) + z1*Math.cos(ry);
// X
let x3 = x2;
let y3 = y2*Math.cos(rx) - z2*Math.sin(rx);
let z3 = y2*Math.sin(rx) + z2*Math.cos(rx);
return [x3,y3,z3] as const;
};
if (!isAscii) {
// binary STL
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const triangles = dv.getUint32(80, true);
let off = 84;
const out = Buffer.allocUnsafe(buf.byteLength);
buf.copy(out, 0, 0, 84); // header + count
for (let i=0;i<triangles;i++) {
// normal (we won't recompute normals; slicer will)
dv.getFloat32(off, true); dv.getFloat32(off+4, true); dv.getFloat32(off+8, true);
out.writeFloatLE(0, off); out.writeFloatLE(0, off+4); out.writeFloatLE(1, off+8);
off += 12;
for (let v=0; v<3; v++) {
const x = dv.getFloat32(off, true);
const y = dv.getFloat32(off+4, true);
const z = dv.getFloat32(off+8, true);
const [nx,ny,nz] = apply(x,y,z);
out.writeFloatLE(nx, off);
out.writeFloatLE(ny, off+4);
out.writeFloatLE(nz, off+8);
off += 12;
}
// attr
out.writeUInt16LE(0, off); off += 2;
}
await fs.writeFile(dstPath, out);
} else {
// basic ASCII STL (slow but fine for small files)
const txt = buf.toString("utf8");
const out = txt.replace(/^vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/gm, (_m, a, b, c) => {
const [x,y,z] = [parseFloat(a), parseFloat(b), parseFloat(c)];
const [nx,ny,nz] = apply(x,y,z);
return `vertex ${nx} ${ny} ${nz}`;
});
await fs.writeFile(dstPath, out, "utf8");
}
}
async function runSlicing(job: JobRecord, outPath: string, supports: "on"|"off", profilePath: string) {
// Update to processing
updateStatus.run({ id: job.id, status: "processing", outputPath: null, errorMsg: null, finishedAt: null });
// Build args string from env template
const supportsFlag = (supports === "on") ? SUPPORTS_ON : SUPPORTS_OFF;
const argTemplate = SLICER_ARGS
.replaceAll("{INPUT}", job.inputPath)
.replaceAll("{OUTPUT}", outPath)
.replaceAll("{PROFILE}", profilePath)
.replaceAll("{SUPPORTS}", supportsFlag);
// Split into argv carefully (simple split on space; if you need complex quoting, switch to shell:true)
const argv = argTemplate.match(/(?:[^\s"]+|"[^"]*")+/g)?.map(s => s.replace(/^"|"$/g, "")) || [];
const proc = spawn(SLICER_CMD, argv, { stdio: ["ignore","pipe","pipe"] });
let stderr = "";
proc.stderr.on("data", (d) => { stderr += d.toString(); });
proc.stdout.on("data", () => { /* could stream progress here */ });
await new Promise<void>((resolve) => proc.on("close", () => resolve()));
// Verify output exists
const ok = await fs.stat(outPath).then(st => st.isFile()).catch(() => false);
if (ok) {
updateStatus.run({ id: job.id, status: "done", outputPath: outPath, errorMsg: null, finishedAt: Date.now() });
} else {
const msg = (stderr || "Slicing failed or produced no output").slice(0, 1000);
updateStatus.run({ id: job.id, status: "error", outputPath: null, errorMsg: msg, finishedAt: Date.now() });
}
}

View File

@@ -0,0 +1,13 @@
export interface JobRecord {
id: string;
filename: string; // original filename
ext: string; // stl/3mf/obj
profile: string; // file path we used
supports: "on" | "off";
inputPath: string; // stored upload path
outputPath: string | null; // gcode path after slicing
status: "queued" | "processing" | "done" | "error";
errorMsg: string | null;
createdAt: number; // epoch ms
finishedAt: number | null;
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
env_file: .env
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./profiles:/app/profiles:ro

View File

@@ -0,0 +1,18 @@
FROM node:20-slim
WORKDIR /app
# enable pnpm
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Copy only package.json first so Docker layer caches deps
COPY package.json ./
# Install without frozen lockfile (generates a lock as needed)
RUN pnpm install --no-frozen-lockfile
# Now bring in the rest of the app
COPY vite.config.ts ./
COPY src ./src
ENV VITE_API_BASE=http://localhost:8080
EXPOSE 5173
CMD ["pnpm","run","dev","--","--host","0.0.0.0"]

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Simple Print Slicer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "print-webui-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"three": "0.170.0"
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.1",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"typescript": "5.6.3",
"vite": "5.4.8"
}
}

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useMemo, useState } from "react";
import ThreePreview from "./three-preview";
const API = import.meta.env.VITE_API_BASE || "http://localhost:8080";
type Job = { /* unchanged */ };
export default function App() {
// existing state...
const [file, setFile] = useState<File|null>(null);
const [profile, setProfile] = useState<string>("");
const [supports, setSupports] = useState<"on"|"off">("off");
// NEW: transform state
const [rotX, setRotX] = useState(0);
const [rotY, setRotY] = useState(0);
const [rotZ, setRotZ] = useState(0);
const [scale, setScale] = useState(1);
// ...profiles/jobs fetching unchanged...
const canSubmit = useMemo(() => !!file && !!profile && !busy, [file, profile, busy]);
const submit = async () => {
if (!file) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("model", file);
fd.append("profile", profile); // legacy if you still use single profile
fd.append("supports", supports);
// If you already send machine/filament/process separately, append those instead.
// NEW: transform fields
fd.append("rotX", String(rotX));
fd.append("rotY", String(rotY));
fd.append("rotZ", String(rotZ));
fd.append("scale", String(scale));
const res = await fetch(`${API}/api/jobs`, { method: "POST", body: fd });
const j = await res.json();
if (!res.ok) throw new Error(j.error || "Failed to submit");
setFile(null);
setRotX(0); setRotY(0); setRotZ(0); setScale(1);
setPoll(x => x+1);
} catch (e: any) {
alert(e?.message || "Upload failed");
} finally {
setBusy(false);
}
};
return (
<div style={{ fontFamily: "system-ui, sans-serif", color: "#ddd", background: "#0b0b0b", minHeight: "100vh" }}>
<div style={{ maxWidth: 900, margin: "0 auto", padding: 24 }}>
<h1 style={{ marginTop: 0 }}>Simple Print Slicer</h1>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "1fr 1fr" }}>
<div style={{ gridColumn: "1 / span 2", background: "#151515", padding: 16, borderRadius: 12 }}>
<label>Model file (STL/3MF/OBJ)</label>
<input type="file" accept=".stl,.3mf,.obj,.amf" onChange={e => setFile(e.target.files?.[0] || null)} />
<div style={{ height: 12 }} />
<ThreePreview file={file} rot={{x:rotX,y:rotY,z:rotZ}} scale={scale} />
</div>
<div style={{ background: "#151515", padding: 16, borderRadius: 12 }}>
{/* your profile/supports UI (unchanged) */}
<label>Supports</label>
<div>
<label><input type="radio" name="supports" checked={supports==="off"} onChange={() => setSupports("off")} /> Off</label>{" "}
<label><input type="radio" name="supports" checked={supports==="on"} onChange={() => setSupports("on")} /> On</label>
</div>
<div style={{ height: 16 }} />
{/* NEW: Rotate/Scale controls */}
<h4 style={{ margin: "12px 0 8px" }}>Transform</h4>
<div>Rotate X: <input type="number" value={rotX} onChange={e=>setRotX(Number(e.target.value))} />°</div>
<div>Rotate Y: <input type="number" value={rotY} onChange={e=>setRotY(Number(e.target.value))} />°</div>
<div>Rotate Z: <input type="number" value={rotZ} onChange={e=>setRotZ(Number(e.target.value))} />°</div>
<div>Scale: <input type="number" step="0.01" min="0.01" value={scale} onChange={e=>setScale(Number(e.target.value))} /></div>
<div style={{ height: 16 }} />
<button disabled={!canSubmit} onClick={submit} style={{ padding: "10px 16px", borderRadius: 8 }}>
{busy ? "Slicing..." : "Slice"}
</button>
</div>
{/* jobs table unchanged */}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root")!;
createRoot(container).render(<App />);

View File

@@ -0,0 +1,139 @@
import React, { useEffect, useRef } from "react";
import * as THREE from "three";
type Props = {
file: File | null;
rot: { x: number; y: number; z: number }; // degrees
scale: number; // 1.0 = 100%
};
async function parseSTL(file: File): Promise<THREE.BufferGeometry> {
const buf = await file.arrayBuffer();
const dv = new DataView(buf);
const isASCII = new TextDecoder().decode(new Uint8Array(buf.slice(0,5))).toLowerCase() === "solid";
if (isASCII) {
const txt = new TextDecoder().decode(new Uint8Array(buf));
const verts: number[] = [];
for (const ln of txt.split("\n")) {
const m = ln.trim().match(/^vertex\s+([\d\.\-eE]+)\s+([\d\.\-eE]+)\s+([\d\.\-eE]+)/);
if (m) verts.push(+m[1], +m[2], +m[3]);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(verts, 3));
g.computeVertexNormals();
return g;
} else {
const triangles = dv.getUint32(80, true);
const verts = new Float32Array(triangles * 9);
let off = 84;
for (let i=0;i<triangles;i++) {
off += 12; // normal
for (let v=0; v<9; v+=3) {
verts[i*9+v+0] = dv.getFloat32(off, true); off+=4;
verts[i*9+v+1] = dv.getFloat32(off, true); off+=4;
verts[i*9+v+2] = dv.getFloat32(off, true); off+=4;
}
off += 2; // attr
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(verts, 3));
g.computeVertexNormals();
return g;
}
}
export default function ThreePreview({ file, rot, scale }: Props) {
const ref = useRef<HTMLDivElement>(null);
const meshRef = useRef<THREE.Mesh | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
useEffect(() => {
if (!ref.current) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
const width = ref.current.clientWidth;
const height = 320;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
ref.current.appendChild(renderer.domElement);
rendererRef.current = renderer;
const ambient = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambient);
const dir = new THREE.DirectionalLight(0xffffff, 0.8); dir.position.set(1,1,1); scene.add(dir);
const grid = new THREE.GridHelper(300, 30);
scene.add(grid);
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
const load = async () => {
if (!file) return;
if (!/\.(stl|3mf|obj|amf)$/i.test(file.name)) return;
// MVP preview: STL only
const ext = file.name.toLowerCase().split(".").pop();
let mesh: THREE.Mesh | null = null;
if (ext === "stl") {
const geom = await parseSTL(file);
const mat = new THREE.MeshStandardMaterial({ metalness: 0.1, roughness: 0.6 });
mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
meshRef.current = mesh;
// center & frame
geom.computeBoundingBox();
const bb = geom.boundingBox!;
const size = new THREE.Vector3().subVectors(bb.max, bb.min);
const center = new THREE.Vector3().addVectors(bb.min, bb.max).multiplyScalar(0.5);
mesh.position.sub(center);
const dist = Math.max(size.x, size.y, size.z) * 2.2 + 40;
camera.position.set(dist, dist, dist);
camera.lookAt(0, 0, 0);
} else {
// non-STL: no preview yet
}
};
load();
return () => {
renderer.dispose();
if (renderer.domElement && renderer.domElement.parentNode === ref.current) {
ref.current.removeChild(renderer.domElement);
}
};
}, [file]);
// apply transform when rot/scale change
useEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
const rx = THREE.MathUtils.degToRad(rot.x);
const ry = THREE.MathUtils.degToRad(rot.y);
const rz = THREE.MathUtils.degToRad(rot.z);
mesh.rotation.set(rx, ry, rz);
mesh.scale.setScalar(scale);
}, [rot, scale]);
// keep canvas width responsive
useEffect(() => {
const onResize = () => {
const r = rendererRef.current;
const el = ref.current;
if (!r || !el) return;
r.setSize(el.clientWidth, 320);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return <div ref={ref} style={{ width: "100%", height: 320, background: "#111", borderRadius: 12 }} />;
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-ignore no types needed for this import-less usage
export default defineConfig({
plugins: [react()],
server: { port: 5173, host: true },
});