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" />