feat(jobs): in-browser STL / PLY / OBJ scan viewer

Clinics upload intraoral scans as raw STL/PLY/OBJ and labs need to see
what was captured before accepting the case. Built a tooth-level 3D
preview that runs entirely in the browser — no server-side rendering,
no extra service, just three.js + react-three-fiber driven off the
existing download proxy.

Component (src/components/stl-viewer.tsx)
  - Detects format from the filename (.stl / .ply / .obj).
  - fetch() → ArrayBuffer → STLLoader / PLYLoader / OBJLoader.parse.
  - OBJ comes back as a Group; merge the child meshes into a single
    BufferGeometry (positions concat) so all three formats render
    through the same mesh path.
  - Scene: dark background, ambient + two directional lights, a
    light-grey standard material, drei <Bounds> for auto-fit framing,
    OrbitControls with damping. A 'Sığdır' button in the corner re-fits
    the camera by remounting Bounds.
  - Cleanup: geometry.dispose() on unmount, fetch cancellation guard,
    inline error/loading states.

Wiring (job-files-panel.tsx)
  - VIEWABLE_RE = /\.(stl|ply|obj)$/i. Only those rows get the new
    eye-icon button.
  - STLViewer is loaded via next/dynamic with ssr:false so three.js
    (~500KB minified) only enters the bundle when the dialog actually
    opens. Mounting is also gated on viewerOpen, so closing the dialog
    frees the WebGL context.
  - Dialog is a tall (85vh) wide (max-w-5xl) shell with the filename
    + size in the header and the canvas filling the rest.

Deps added: three, @react-three/fiber, @react-three/drei (+ types).
This commit is contained in:
kovakmedya
2026-05-22 01:51:05 +03:00
parent 0e4033aa3f
commit 9e78d506ae
4 changed files with 701 additions and 2 deletions
@@ -2,7 +2,8 @@
import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
import dynamic from "next/dynamic";
import { Download, Eye, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -24,6 +25,15 @@ import {
} from "@/lib/appwrite/job-file-types";
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
// three.js + react-three is ~500KB minified; only load it when the user
// actually opens the viewer dialog.
const STLViewer = dynamic(
() => import("@/components/stl-viewer").then((m) => m.STLViewer),
{ ssr: false, loading: () => null },
);
const VIEWABLE_RE = /\.(stl|ply|obj)$/i;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -243,6 +253,8 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
const isViewable = VIEWABLE_RE.test(file.name);
useEffect(() => {
if (state.ok) {
@@ -280,6 +292,28 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
<Badge variant="outline" className="hidden sm:inline-flex">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
</Badge>
{isViewable && (
<Dialog open={viewerOpen} onOpenChange={setViewerOpen}>
<Button
size="sm"
variant="outline"
onClick={() => setViewerOpen(true)}
>
<Eye className="size-4" />
</Button>
<DialogContent className="h-[85vh] max-w-5xl gap-0 p-0">
<DialogHeader className="border-b px-4 py-3">
<DialogTitle className="truncate text-base">{file.name}</DialogTitle>
<DialogDescription className="text-xs">
{formatSize(file.size)} · Sürükleyerek döndürün, kaydırarak yaklaşın.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1">
{viewerOpen && <STLViewer url={file.url} filename={file.name} />}
</div>
</DialogContent>
</Dialog>
)}
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}>
<Download className="size-4" />
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { Bounds, OrbitControls } from "@react-three/drei";
import { Loader2, RotateCcw } from "lucide-react";
import * as THREE from "three";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
import { Button } from "@/components/ui/button";
type SupportedFormat = "stl" | "ply" | "obj";
function detectFormat(filename: string): SupportedFormat | null {
const lower = filename.toLowerCase();
if (lower.endsWith(".stl")) return "stl";
if (lower.endsWith(".ply")) return "ply";
if (lower.endsWith(".obj")) return "obj";
return null;
}
/**
* Loads a 3D scan from the given URL into a BufferGeometry. OBJ files come
* back from the loader as a THREE.Group, so we merge their child meshes'
* geometries into a single BufferGeometry for a uniform render path.
*/
async function loadGeometry(
url: string,
format: SupportedFormat,
): Promise<THREE.BufferGeometry> {
const res = await fetch(url);
if (!res.ok) throw new Error(`İndirilemedi (HTTP ${res.status})`);
const buffer = await res.arrayBuffer();
if (format === "stl") {
const g = new STLLoader().parse(buffer);
g.computeVertexNormals();
return g;
}
if (format === "ply") {
const g = new PLYLoader().parse(buffer);
g.computeVertexNormals();
return g;
}
// OBJ — text format, parse needs a string and yields a Group of meshes.
const text = new TextDecoder().decode(buffer);
const group = new OBJLoader().parse(text);
const geometries: THREE.BufferGeometry[] = [];
group.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const g = (obj as THREE.Mesh).geometry as THREE.BufferGeometry;
if (g) geometries.push(g);
}
});
if (geometries.length === 0) throw new Error("OBJ içinde mesh bulunamadı.");
// For a single mesh we can avoid the merge dependency entirely.
if (geometries.length === 1) {
const g = geometries[0].clone();
g.computeVertexNormals();
return g;
}
// Cheap concat: positions only. Good enough for previewing scans.
const positions: number[] = [];
for (const g of geometries) {
const pos = g.getAttribute("position");
if (!pos) continue;
for (let i = 0; i < pos.count * 3; i++) {
positions.push((pos.array as ArrayLike<number>)[i]);
}
}
const merged = new THREE.BufferGeometry();
merged.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
merged.computeVertexNormals();
return merged;
}
export function STLViewer({
url,
filename,
}: {
url: string;
filename: string;
}) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
const [error, setError] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
const format = detectFormat(filename);
useEffect(() => {
if (!format) {
setError("Bu dosya türü görüntüleyici tarafından desteklenmiyor.");
return;
}
let cancelled = false;
setGeometry(null);
setError(null);
loadGeometry(url, format)
.then((g) => {
if (cancelled) {
g.dispose();
return;
}
setGeometry(g);
})
.catch((e: unknown) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Yüklenemedi.");
});
return () => {
cancelled = true;
};
}, [url, format]);
// Dispose old geometry when it gets replaced or the component unmounts.
useEffect(() => {
return () => {
geometry?.dispose();
};
}, [geometry]);
if (error) {
return (
<div className="bg-muted/40 text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
{error}
</div>
);
}
if (!geometry) {
return (
<div className="bg-muted/40 text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-sm">
<Loader2 className="size-4 animate-spin" />
Tarama yükleniyor...
</div>
);
}
return (
<div className="relative h-full w-full">
<Canvas camera={{ position: [0, 0, 100], fov: 45, near: 0.1, far: 5000 }} dpr={[1, 2]}>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.45} />
<directionalLight position={[10, 10, 10]} intensity={0.7} />
<directionalLight position={[-10, -10, -10]} intensity={0.35} />
<Bounds key={resetKey} fit clip observe margin={1.25}>
<mesh geometry={geometry}>
<meshStandardMaterial
color="#e2e8f0"
roughness={0.55}
metalness={0.05}
/>
</mesh>
</Bounds>
<OrbitControls makeDefault enableDamping dampingFactor={0.1} />
</Canvas>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setResetKey((k) => k + 1)}
className="absolute right-3 top-3 bg-background/80 backdrop-blur"
>
<RotateCcw className="size-4" />
Sığdır
</Button>
</div>
);
}
export default STLViewer;