Stared work on the manager side
This commit is contained in:
85
Slicer Project/backend/Dockerfile
Normal file
85
Slicer Project/backend/Dockerfile
Normal 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"]
|
||||
28
Slicer Project/backend/package.json
Normal file
28
Slicer Project/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
Slicer Project/backend/src/db.ts
Normal file
46
Slicer Project/backend/src/db.ts
Normal 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;
|
||||
271
Slicer Project/backend/src/server.ts
Normal file
271
Slicer Project/backend/src/server.ts
Normal 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() });
|
||||
}
|
||||
}
|
||||
|
||||
13
Slicer Project/backend/src/types.ts
Normal file
13
Slicer Project/backend/src/types.ts
Normal 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;
|
||||
}
|
||||
13
Slicer Project/backend/tsconfig.json
Normal file
13
Slicer Project/backend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
Slicer Project/docker-compose.yml
Normal file
11
Slicer Project/docker-compose.yml
Normal 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
|
||||
18
Slicer Project/frontend/Dockerfile
Normal file
18
Slicer Project/frontend/Dockerfile
Normal 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"]
|
||||
15
Slicer Project/frontend/index.html
Normal file
15
Slicer Project/frontend/index.html
Normal 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>
|
||||
23
Slicer Project/frontend/package.json
Normal file
23
Slicer Project/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
91
Slicer Project/frontend/src/App.tsx
Normal file
91
Slicer Project/frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
Slicer Project/frontend/src/main.tsx
Normal file
6
Slicer Project/frontend/src/main.tsx
Normal 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 />);
|
||||
139
Slicer Project/frontend/src/three-preview.tsx
Normal file
139
Slicer Project/frontend/src/three-preview.tsx
Normal 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 }} />;
|
||||
}
|
||||
8
Slicer Project/frontend/vite.config.ts
Normal file
8
Slicer Project/frontend/vite.config.ts
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user