STL Model Viewer

This commit is contained in:
2025-12-25 17:55:58 +00:00
parent 8142b22b21
commit 91a9aa9c54
11 changed files with 1161 additions and 251 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ vite.config.ts.timestamp-*
.vscode/
static/uploads/

View File

@@ -10,6 +10,7 @@
{ name: "Spools", href: "/spools", icon: "mdi:cylinder" },
{ name: "Printers", href: "/printers", icon: "mdi:printer-3d" },
{ name: "Prints", href: "/prints", icon: "mdi:cube-outline" },
{ name: "Library", href: "/library", icon: "mdi:cube-scan" },
{ name: "Analytics", href: "/analytics", icon: "mdi:chart-line" },
...($page.data.user?.role === "Admin"
? [

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
interface Props {
modelPath: string;
width?: number;
height?: number;
}
let { modelPath, width = 300, height = 200 }: Props = $props();
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationId: number;
onMount(() => {
initScene();
loadModel();
animate();
});
onDestroy(() => {
if (animationId) cancelAnimationFrame(animationId);
if (renderer) renderer.dispose();
if (controls) controls.dispose();
});
function initScene() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1e2e);
// Camera
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
camera.position.set(100, 100, 100);
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// Controls - OrbitControls for intuitive 3D navigation
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.autoRotateSpeed = 1.5;
// Zoom limits
controls.minDistance = 20;
controls.maxDistance = 300;
// Pan settings
controls.enablePan = true;
controls.panSpeed = 0.8;
// Touch support
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
};
// Stop auto-rotate on interaction
controls.addEventListener("start", () => {
controls.autoRotate = false;
});
// Lights
const ambientLight = new THREE.AmbientLight(0x404040, 2);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
const backLight = new THREE.DirectionalLight(0xffffff, 0.5);
backLight.position.set(-1, -1, -1);
scene.add(backLight);
// Grid helper
const gridHelper = new THREE.GridHelper(100, 10, 0x444466, 0x333344);
scene.add(gridHelper);
}
function loadModel() {
const extension = modelPath.split(".").pop()?.toLowerCase();
if (extension === "obj") {
loadOBJ();
} else {
loadSTL();
}
}
function loadSTL() {
const loader = new STLLoader();
loader.load(
modelPath,
(geometry) => {
addGeometryToScene(geometry);
},
undefined,
(err) => {
console.error("Error loading STL:", err);
},
);
}
function loadOBJ() {
const loader = new OBJLoader();
loader.load(
modelPath,
(obj) => {
// Center the object
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
obj.position.sub(center);
// Scale to fit
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 50 / maxDim;
obj.scale.set(scale, scale, scale);
// Apply material to all meshes
const material = new THREE.MeshPhongMaterial({
color: 0x3b82f6,
specular: 0x111111,
shininess: 50,
flatShading: false,
});
obj.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = material;
}
});
obj.rotation.x = -Math.PI / 2;
scene.add(obj);
// Position camera
const distance = maxDim * scale * 2.5;
camera.position.set(distance, distance, distance);
controls.update();
},
undefined,
(err) => {
console.error("Error loading OBJ:", err);
},
);
}
function addGeometryToScene(geometry: THREE.BufferGeometry) {
// Center the geometry
geometry.computeBoundingBox();
const center = new THREE.Vector3();
geometry.boundingBox!.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
// Scale to fit
const size = new THREE.Vector3();
geometry.boundingBox!.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 50 / maxDim;
// Material
const material = new THREE.MeshPhongMaterial({
color: 0x3b82f6,
specular: 0x111111,
shininess: 50,
flatShading: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.scale.set(scale, scale, scale);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);
// Position camera based on model size
const distance = maxDim * scale * 2.5;
camera.position.set(distance, distance, distance);
controls.update();
}
function animate() {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
</script>
<div
bind:this={container}
class="model-viewer rounded-lg overflow-hidden"
style="width: {width}px; height: {height}px;"
></div>
<style>
.model-viewer {
border: 1px solid rgba(255, 255, 255, 0.1);
}
.model-viewer :global(canvas) {
display: block;
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from "$app/forms";
import { browser } from "$app/environment";
import Button from "$lib/components/ui/Button.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import Input from "$lib/components/ui/Input.svelte";
@@ -45,6 +46,63 @@
handleClose();
window.location.reload();
}
// STL Upload
let stlFile = $state<File | null>(null);
let uploadProgress = $state(0);
let uploadStatus = $state("");
function uploadSTL(): Promise<string> {
return new Promise((resolve) => {
if (!stlFile) {
resolve("");
return;
}
uploadStatus = "Uploading...";
uploadProgress = 0;
const formData = new FormData();
formData.append("stl", stlFile);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
uploadStatus = "Uploaded!";
uploadProgress = 100;
resolve(data.path);
} else {
uploadStatus = "Upload failed";
resolve("");
}
});
xhr.addEventListener("error", () => {
uploadStatus = "Upload error";
resolve("");
});
xhr.open("POST", "/api/upload-stl");
xhr.send(formData);
});
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
stlFile = input.files[0];
uploadStatus = stlFile.name;
uploadProgress = 0;
}
}
</script>
<Modal title="Edit Print Log" {open} onclose={handleClose}>
@@ -52,7 +110,17 @@
<form
method="POST"
action="?/edit"
use:enhance={({ formData }) => {
use:enhance={async ({ formData }) => {
isSubmitting = true;
// Upload STL file first if selected
if (stlFile) {
const uploadedPath = await uploadSTL();
if (uploadedPath) {
formData.set("stl_file", uploadedPath);
}
}
// Convert hours + minutes to total minutes
const hours = Number(formData.get("duration_hours") || 0);
const mins = Number(formData.get("duration_mins") || 0);
@@ -66,7 +134,6 @@
String(elapsedHours * 60 + elapsedMins),
);
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
@@ -146,6 +213,95 @@
<Input label="Print Name" name="name" value={print.name} required />
<!-- STL Viewer/Upload -->
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>
3D Model {print.stl_file ? "" : "(Optional)"}
</label>
{#if print.stl_file && browser && !stlFile}
<!-- Show existing STL viewer -->
<div
class="flex justify-center bg-slate-900 rounded-lg p-2"
>
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
<STLViewer
modelPath={print.stl_file}
width={400}
height={250}
/>
{/await}
</div>
<p class="text-xs text-slate-500 text-center">
Click below to replace with a new model
</p>
{/if}
<!-- Upload button or progress -->
{#if uploadStatus === "Uploading..." && uploadProgress > 0}
<!-- Progress Bar -->
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-slate-400">{stlFile?.name}</span>
<span class="text-blue-400">{uploadProgress}%</span>
</div>
<div
class="w-full h-2 bg-slate-800 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full transition-all duration-150"
style="width: {uploadProgress}%"
></div>
</div>
</div>
{:else}
<div class="flex items-center gap-3">
<label class="flex-1 cursor-pointer">
<div
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border border-dashed border-slate-600 hover:border-blue-500 hover:bg-blue-500/5 transition-all text-slate-400 hover:text-blue-400"
>
<Icon icon="mdi:file-upload" class="w-5 h-5" />
<span class="text-sm">
{#if uploadStatus === "Uploaded!"}
<span class="text-green-400"
>{stlFile?.name}</span
>
{:else if uploadStatus}
{uploadStatus}
{:else if print.stl_file}
Replace model file...
{:else}
Choose model file...
{/if}
</span>
</div>
<input
type="file"
accept=".stl,.obj"
class="sr-only"
onchange={handleFileSelect}
/>
</label>
{#if stlFile}
<button
type="button"
class="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
onclick={() => {
stlFile = null;
uploadProgress = 0;
uploadStatus = "";
}}
>
<Icon icon="mdi:close" class="w-5 h-5" />
</button>
{/if}
</div>
{/if}
</div>
{#if editStatus === "In Progress"}
<!-- In Progress specific fields -->
<div class="grid grid-cols-2 gap-4">

View File

@@ -15,9 +15,69 @@
let { open, printers, spools, onclose }: Props = $props();
let isSubmitting = $state(false);
let selectedStatus = $state("Success");
let stlFile = $state<File | null>(null);
let stlPath = $state("");
let uploadProgress = $state(0);
let uploadStatus = $state("");
function uploadSTL(): Promise<string> {
return new Promise((resolve) => {
if (!stlFile) {
resolve("");
return;
}
uploadStatus = "Uploading...";
uploadProgress = 0;
const formData = new FormData();
formData.append("stl", stlFile);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
uploadStatus = "Uploaded!";
uploadProgress = 100;
resolve(data.path);
} else {
uploadStatus = "Upload failed";
resolve("");
}
});
xhr.addEventListener("error", () => {
uploadStatus = "Upload error";
resolve("");
});
xhr.open("POST", "/api/upload-stl");
xhr.send(formData);
});
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
stlFile = input.files[0];
uploadStatus = stlFile.name;
uploadProgress = 0;
}
}
function handleClose() {
selectedStatus = "Success";
stlFile = null;
stlPath = "";
uploadProgress = 0;
uploadStatus = "";
onclose();
}
</script>
@@ -26,18 +86,30 @@
<form
method="POST"
action="?/log"
use:enhance={({ formData }) => {
use:enhance={async ({ formData }) => {
isSubmitting = true;
// Upload STL file first if selected
if (stlFile) {
const uploadedPath = await uploadSTL();
if (uploadedPath) {
formData.set("stl_file", uploadedPath);
}
}
// Convert hours + minutes to total minutes
const hours = Number(formData.get('duration_hours') || 0);
const mins = Number(formData.get('duration_mins') || 0);
formData.set('duration_minutes', String(hours * 60 + mins));
const hours = Number(formData.get("duration_hours") || 0);
const mins = Number(formData.get("duration_mins") || 0);
formData.set("duration_minutes", String(hours * 60 + mins));
// Convert elapsed hours + minutes to total minutes
const elapsedHours = Number(formData.get('elapsed_hours') || 0);
const elapsedMins = Number(formData.get('elapsed_mins') || 0);
formData.set('elapsed_minutes', String(elapsedHours * 60 + elapsedMins));
const elapsedHours = Number(formData.get("elapsed_hours") || 0);
const elapsedMins = Number(formData.get("elapsed_mins") || 0);
formData.set(
"elapsed_minutes",
String(elapsedHours * 60 + elapsedMins),
);
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
@@ -120,6 +192,75 @@
required
/>
<!-- 3D Model Upload -->
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>
3D Model (Optional)
</label>
{#if uploadStatus === "Uploading..." && uploadProgress > 0}
<!-- Progress Bar -->
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-slate-400">{stlFile?.name}</span>
<span class="text-blue-400">{uploadProgress}%</span>
</div>
<div
class="w-full h-2 bg-slate-800 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full transition-all duration-150"
style="width: {uploadProgress}%"
></div>
</div>
</div>
{:else}
<!-- Upload Button -->
<div class="flex items-center gap-3">
<label class="flex-1 cursor-pointer">
<div
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border border-dashed border-slate-600 hover:border-blue-500 hover:bg-blue-500/5 transition-all text-slate-400 hover:text-blue-400"
>
<Icon icon="mdi:file-upload" class="w-5 h-5" />
<span class="text-sm">
{#if uploadStatus === "Uploaded!"}
<span class="text-green-400"
>{stlFile?.name}</span
>
{:else if uploadStatus}
{uploadStatus}
{:else}
Choose model file...
{/if}
</span>
</div>
<input
type="file"
accept=".stl,.obj"
class="sr-only"
onchange={handleFileSelect}
/>
</label>
{#if stlFile}
<button
type="button"
class="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
onclick={() => {
stlFile = null;
uploadProgress = 0;
uploadStatus = "";
}}
>
<Icon icon="mdi:close" class="w-5 h-5" />
</button>
{/if}
</div>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
@@ -148,7 +289,8 @@
>
{#each spools as s}
<option value={s._id}
>{s.brand} {s.material} ({s.weight_remaining_g}g left)</option
>{s.brand}
{s.material} ({s.weight_remaining_g}g left)</option
>
{/each}
</select>
@@ -157,7 +299,9 @@
{#if selectedStatus === "In Progress"}
<!-- In Progress specific fields -->
<div class="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div
class="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20"
>
<p class="text-xs text-blue-300 mb-3">
<Icon icon="mdi:information" class="w-4 h-4 inline mr-1" />
Enter the expected total print time and how long it's been running.
@@ -165,29 +309,75 @@
<div class="space-y-3">
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider">Total Duration</label>
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>Total Duration</label
>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<input type="number" name="duration_hours" placeholder="2" min="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
<input
type="number"
name="duration_hours"
placeholder="2"
min="0"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>hr</span
>
</div>
<div class="relative">
<input type="number" name="duration_mins" placeholder="30" min="0" max="59" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
<input
type="number"
name="duration_mins"
placeholder="30"
min="0"
max="59"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>min</span
>
</div>
</div>
</div>
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider">Already Elapsed</label>
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>Already Elapsed</label
>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<input type="number" name="elapsed_hours" placeholder="0" min="0" value="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
<input
type="number"
name="elapsed_hours"
placeholder="0"
min="0"
value="0"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>hr</span
>
</div>
<div class="relative">
<input type="number" name="elapsed_mins" placeholder="0" min="0" max="59" value="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
<input
type="number"
name="elapsed_mins"
placeholder="0"
min="0"
max="59"
value="0"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>min</span
>
</div>
</div>
</div>
@@ -214,15 +404,37 @@
<div class="space-y-4">
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider">Duration</label>
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>Duration</label
>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<input type="number" name="duration_hours" placeholder="2" min="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
<input
type="number"
name="duration_hours"
placeholder="2"
min="0"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>hr</span
>
</div>
<div class="relative">
<input type="number" name="duration_mins" placeholder="30" min="0" max="59" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
<input
type="number"
name="duration_mins"
placeholder="30"
min="0"
max="59"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
/>
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
>min</span
>
</div>
</div>
</div>
@@ -246,7 +458,8 @@
{/if}
<div class="pt-4 flex justify-end gap-3">
<Button variant="ghost" onclick={handleClose} type="button">Cancel</Button
<Button variant="ghost" onclick={handleClose} type="button"
>Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting

View File

@@ -47,6 +47,9 @@ const printJobSchema = new mongoose.Schema({
type: Date
},
notes: String,
stl_file: {
type: String // Path to uploaded STL file
},
date: {
type: Date,
default: Date.now

View File

@@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
const UPLOAD_DIR = 'static/uploads/models';
const ALLOWED_EXTENSIONS = ['.stl', '.obj'];
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const formData = await request.formData();
const file = formData.get('stl') as File; // Keep 'stl' for backward compatibility
if (!file || file.size === 0) {
throw error(400, 'No file provided');
}
// Validate file type
const fileName = file.name.toLowerCase();
const extension = '.' + fileName.split('.').pop();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw error(400, 'Only STL and OBJ files are allowed');
}
// Create upload directory if it doesn't exist
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
// Generate unique filename
const timestamp = Date.now();
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
const uniqueFileName = `${locals.user.id}_${timestamp}_${sanitizedName}`;
const filePath = path.join(UPLOAD_DIR, uniqueFileName);
// Write file to disk
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Return the public path
const publicPath = `/uploads/models/${uniqueFileName}`;
return json({
success: true,
path: publicPath,
fileName: file.name,
fileType: extension.slice(1).toUpperCase()
});
};

View File

@@ -0,0 +1,22 @@
import { PrintJob } from '$lib/models/PrintJob';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
await connectDB();
// Get all prints with STL files
const printsWithSTL = await PrintJob.find({
user_id: locals.user.id,
stl_file: { $exists: true, $nin: [null, ''] }
})
.sort({ date: -1 })
.lean();
return {
models: JSON.parse(JSON.stringify(printsWithSTL))
};
};

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { browser } from "$app/environment";
import Card from "$lib/components/ui/Card.svelte";
import Icon from "@iconify/svelte";
let { data } = $props();
let models = $derived(data.models);
let selectedModel = $state<any>(null);
let showViewer = $state(false);
function openViewer(model: any) {
selectedModel = model;
showViewer = true;
}
function closeViewer() {
selectedModel = null;
showViewer = false;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
</script>
<div class="space-y-6 fade-in">
<div>
<h1 class="text-3xl font-bold text-white">Model Library</h1>
<p class="text-slate-400 mt-1">
Browse your 3D model collection ({models.length} models)
</p>
</div>
{#if models.length === 0}
<Card class="text-center py-12">
<Icon
icon="mdi:cube-off-outline"
class="w-16 h-16 text-slate-600 mx-auto mb-4"
/>
<h3 class="text-lg font-medium text-slate-300 mb-2">
No Models Yet
</h3>
<p class="text-slate-500 max-w-md mx-auto">
Upload STL files when logging prints to build your model
library. Go to <a
href="/prints"
class="text-blue-400 hover:underline">Prints</a
> to add your first model.
</p>
</Card>
{:else}
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
{#each models as model}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group cursor-pointer"
onclick={() => openViewer(model)}
>
<Card
class="overflow-hidden hover:ring-2 hover:ring-blue-500/50 transition-all duration-200"
>
<!-- Thumbnail/Preview -->
<div
class="aspect-square bg-slate-800 flex items-center justify-center relative overflow-hidden"
>
<Icon
icon="mdi:cube-scan"
class="w-20 h-20 text-slate-600 group-hover:text-blue-500 transition-colors"
/>
<div
class="absolute inset-0 bg-linear-to-t from-slate-900/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4"
>
<span
class="text-white text-sm font-medium flex items-center gap-1"
>
<Icon icon="mdi:eye" class="w-4 h-4" />
View Model
</span>
</div>
</div>
<!-- Info -->
<div class="p-4">
<h3 class="font-medium text-white truncate">
{model.name}
</h3>
<p class="text-xs text-slate-500 mt-1">
{formatDate(model.date)}
</p>
<div
class="flex items-center gap-2 mt-2 text-xs text-slate-400"
>
{#if model.status === "Success"}
<span
class="flex items-center gap-1 text-green-400"
>
<Icon
icon="mdi:check-circle"
class="w-3.5 h-3.5"
/>
Printed
</span>
{:else if model.status === "In Progress"}
<span
class="flex items-center gap-1 text-blue-400"
>
<Icon
icon="mdi:printer-3d"
class="w-3.5 h-3.5"
/>
Printing
</span>
{:else if model.status === "Fail"}
<span
class="flex items-center gap-1 text-red-400"
>
<Icon
icon="mdi:close-circle"
class="w-3.5 h-3.5"
/>
Failed
</span>
{/if}
</div>
</div>
</Card>
</div>
{/each}
</div>
{/if}
</div>
<!-- Full Screen Viewer Modal -->
{#if showViewer && selectedModel && browser}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/90 z-50 flex flex-col"
onclick={closeViewer}
>
<!-- Header -->
<div
class="flex items-center justify-between p-4 border-b border-slate-800"
onclick={(e) => e.stopPropagation()}
>
<div>
<h2 class="text-xl font-bold text-white">
{selectedModel.name}
</h2>
<p class="text-sm text-slate-400">
{formatDate(selectedModel.date)}
</p>
</div>
<button
class="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
onclick={closeViewer}
>
<Icon icon="mdi:close" class="w-6 h-6" />
</button>
</div>
<!-- Viewer -->
<div
class="flex-1 flex items-center justify-center p-4"
onclick={(e) => e.stopPropagation()}
>
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
<STLViewer
modelPath={selectedModel.stl_file}
width={Math.min(window.innerWidth - 48, 900)}
height={Math.min(window.innerHeight - 200, 600)}
/>
{/await}
</div>
<!-- Footer Info -->
<div
class="p-4 border-t border-slate-800 flex items-center justify-center gap-8 text-sm"
onclick={(e) => e.stopPropagation()}
>
{#if selectedModel.filament_used_g}
<div class="text-slate-400">
<span class="text-slate-500">Filament:</span>
{selectedModel.filament_used_g}g
</div>
{/if}
{#if selectedModel.duration_minutes}
<div class="text-slate-400">
<span class="text-slate-500">Duration:</span>
{Math.floor(selectedModel.duration_minutes / 60)}h {selectedModel.duration_minutes %
60}m
</div>
{/if}
{#if selectedModel.calculated_cost_filament}
<div class="text-slate-400">
<span class="text-slate-500">Cost:</span>
${selectedModel.calculated_cost_filament.toFixed(2)}
</div>
{/if}
</div>
</div>
{/if}
<style>
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -44,6 +44,7 @@ export const actions: Actions = {
const status = formData.get('status');
const manual_cost = formData.get('manual_cost');
const elapsed_minutes = formData.get('elapsed_minutes');
const stl_file = formData.get('stl_file');
if (!spool_id || !printer_id || !filament_used_g) {
return fail(400, { missing: true });
@@ -110,6 +111,7 @@ export const actions: Actions = {
calculated_cost_energy: Number(costEnergy.toFixed(2)),
status,
started_at: startedAt,
stl_file: stl_file || null,
date: new Date()
});
@@ -139,6 +141,7 @@ export const actions: Actions = {
const elapsed_minutes = formData.get('elapsed_minutes');
const printer_id = formData.get('printer_id');
const spool_id = formData.get('spool_id');
const stl_file = formData.get('stl_file');
if (!id || !name) {
return fail(400, { missing: true });
@@ -215,6 +218,10 @@ export const actions: Actions = {
if (spool_id) {
updateData.spool_id = spool_id;
}
// Update STL file if provided
if (stl_file) {
updateData.stl_file = stl_file;
}
await PrintJob.findOneAndUpdate(
{ _id: id, user_id: locals.user.id },

View File

@@ -75,9 +75,19 @@
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="text-white font-medium truncate">
{print.name}
</h3>
{#if print.stl_file}
<span title="Has 3D Model">
<Icon
icon="mdi:cube-scan"
class="w-4 h-4 text-blue-400 shrink-0"
/>
</span>
{/if}
</div>
<div
class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400"
>