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