Inital Work, web server broke again, trying to get scale and rotation setup
This commit is contained in:
18
frontend/Dockerfile
Normal file
18
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
frontend/index.html
Normal file
15
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
frontend/package.json
Normal file
23
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
frontend/src/App.tsx
Normal file
91
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
frontend/src/main.tsx
Normal file
6
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
frontend/src/three-preview.tsx
Normal file
139
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
frontend/vite.config.ts
Normal file
8
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