From 443e6177cab8877fdec93c5ce9cdb9fb093a2250 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 17 Oct 2025 09:08:31 +1100 Subject: [PATCH] Inital Work, web server broke again, trying to get scale and rotation setup --- .gitignore | 63 ++++++++ backend/Dockerfile | 86 +++++++++++ backend/package.json | 28 ++++ backend/src/db.ts | 46 ++++++ backend/src/server.ts | 267 +++++++++++++++++++++++++++++++++ backend/src/types.ts | 13 ++ backend/tsconfig.json | 13 ++ docker-compose.yml | 11 ++ frontend/Dockerfile | 18 +++ frontend/index.html | 15 ++ frontend/package.json | 23 +++ frontend/src/App.tsx | 91 +++++++++++ frontend/src/main.tsx | 6 + frontend/src/three-preview.tsx | 139 +++++++++++++++++ frontend/vite.config.ts | 8 + readme.md | 15 ++ 16 files changed, 842 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/db.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/types.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/three-preview.tsx create mode 100644 frontend/vite.config.ts create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d40c4cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# ========================================================== +# Printer-Manager Project .gitignore +# ========================================================== + +# --- Node / PNPM --- +node_modules/ +pnpm-lock.yaml +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# --- Build outputs --- +dist/ +build/ +out/ +coverage/ +*.log + +# --- TypeScript cache --- +*.tsbuildinfo + +# --- Frontend (Vite / React) --- +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/.cache/ +frontend/.DS_Store + +# --- Backend --- +backend/node_modules/ +backend/dist/ +backend/.tsbuildinfo +backend/.DS_Store + +# --- Docker / environment --- +.env +.docker/ +docker-compose.override.yml + +# --- Data / runtime folders --- +data/ +profiles/ +slicer/ +# comment out the above if you actually want to version profiles: +!profiles/*.json + +# --- OS / IDE junk --- +.DS_Store +Thumbs.db +*.swp +*.swo +.idea/ +.vscode/ +*.iml + +# --- Logs / temp --- +logs/ +tmp/ +*.tmp +*.bak + +# --- Optional local overrides --- +local.* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d05fb1e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,86 @@ +# ---------- 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 \ + # 👇 add these for libGL.so.1 (Mesa) + 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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c89a36e --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..47273fb --- /dev/null +++ b/backend/src/db.ts @@ -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; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..762b7bd --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,267 @@ +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 . + 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: req.file.originalname, + ext, + profile: profilePath, + supports, + inputPath, + outputPath: null, + status: "queued", + errorMsg: null, + createdAt: Date.now(), + finishedAt: null + }; + insertJob.run(job); + + // 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.gcode` : 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 { + 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 { + 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((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() }); + } +} + diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..7771c50 --- /dev/null +++ b/backend/src/types.ts @@ -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; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..ce55831 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32624a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + backend: + build: + context: . # repo root + dockerfile: backend/Dockerfile + env_file: .env + ports: + - "8080:8080" + volumes: + - ./data:/app/data + - ./profiles:/app/profiles:ro diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d8dfc5a --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6c2b9a4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Simple Print Slicer + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d3e3a1a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8c19b09 --- /dev/null +++ b/frontend/src/App.tsx @@ -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(null); + const [profile, setProfile] = useState(""); + 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 ( +
+
+

Simple Print Slicer

+ +
+
+ + setFile(e.target.files?.[0] || null)} /> +
+ +
+ +
+ {/* your profile/supports UI (unchanged) */} + +
+ {" "} + +
+
+ {/* NEW: Rotate/Scale controls */} +

Transform

+
Rotate X: setRotX(Number(e.target.value))} />°
+
Rotate Y: setRotY(Number(e.target.value))} />°
+
Rotate Z: setRotZ(Number(e.target.value))} />°
+
Scale: setScale(Number(e.target.value))} />
+ +
+ +
+ + {/* jobs table unchanged */} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..e6241d6 --- /dev/null +++ b/frontend/src/main.tsx @@ -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(); diff --git a/frontend/src/three-preview.tsx b/frontend/src/three-preview.tsx new file mode 100644 index 0000000..64832f4 --- /dev/null +++ b/frontend/src/three-preview.tsx @@ -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 { + 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(null); + const meshRef = useRef(null); + const rendererRef = useRef(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
; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ddcb4a3 --- /dev/null +++ b/frontend/vite.config.ts @@ -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 }, +}); diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e436cb1 --- /dev/null +++ b/readme.md @@ -0,0 +1,15 @@ +## Project goals +1) Get a webui working with orcaslicer backend to let users slice with simple options and store the resulting gcode somewhere +2) Make that somewhere a nice database +3) Add the ability to select any sliced file from this database and print it +4) Add some functionality to see what printers are doing, connect to spoolman for fillament info +5) Add a print que, thinking it will pause print after finish and ordering cooldown, wait for user to remove then hit play, detects prints finished and startes the next one +6) Add some user authentication and controls +7) Make pretty + +## Warnings +Much of this code is a GPT special, thus i dont really know what im doing... sorry + +## Current status +Webpage is up, i can upload files, i cant move arround the uploaded file, rotate it, scale it or any other basics like that +Sliceing is totally broken, havnt yet fixed \ No newline at end of file