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:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user