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,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 },
});