@@ -15,6 +15,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||||
|
import StepViewerPanel from "@/components/StepViewerPanel";
|
||||||
|
|
||||||
interface FileView {
|
interface FileView {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -153,6 +154,16 @@ export default function AssemblyDetailClient({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{assembly.stepFile ? (
|
||||||
|
<StepViewerPanel
|
||||||
|
className="mb-6"
|
||||||
|
title="Assembly 3D"
|
||||||
|
fileId={assembly.stepFile.id}
|
||||||
|
fileName={assembly.stepFile.originalName}
|
||||||
|
height={480}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import StepViewerPanel from "@/components/StepViewerPanel";
|
||||||
|
|
||||||
type ScanFile = { id: string; originalName: string; kind: string };
|
type ScanFile = { id: string; originalName: string; kind: string };
|
||||||
|
|
||||||
@@ -77,16 +78,17 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
const totalUnits = op.part.assembly.qty * op.part.qty;
|
const totalUnits = op.part.assembly.qty * op.part.qty;
|
||||||
|
|
||||||
// Flat list of attached files (part first, then assembly). Rendered as big
|
// Flat list of attached files (part first, then assembly). Rendered as big
|
||||||
// tap targets so the operator can pull the drawing / STEP right from the
|
// tap targets so the operator can pull the drawing / cut file right from
|
||||||
// scan page without bouncing to the admin UI.
|
// the scan page without bouncing to the admin UI.
|
||||||
|
//
|
||||||
|
// STEP files are intentionally excluded — we render them inline via
|
||||||
|
// StepViewerPanel below instead, so the operator never has to download a
|
||||||
|
// .stp to the phone.
|
||||||
const quickFiles: Array<{ label: string; file: ScanFile; scope: "part" | "assembly" }> = [];
|
const quickFiles: Array<{ label: string; file: ScanFile; scope: "part" | "assembly" }> = [];
|
||||||
if (op.part.drawingFile) quickFiles.push({ label: "Drawing", file: op.part.drawingFile, scope: "part" });
|
if (op.part.drawingFile) quickFiles.push({ label: "Drawing", file: op.part.drawingFile, scope: "part" });
|
||||||
if (op.part.stepFile) quickFiles.push({ label: "3D / STEP", file: op.part.stepFile, scope: "part" });
|
|
||||||
if (op.part.cutFile) quickFiles.push({ label: "Cut file", file: op.part.cutFile, scope: "part" });
|
if (op.part.cutFile) quickFiles.push({ label: "Cut file", file: op.part.cutFile, scope: "part" });
|
||||||
if (op.part.assembly.drawingFile)
|
if (op.part.assembly.drawingFile)
|
||||||
quickFiles.push({ label: "Assembly drawing", file: op.part.assembly.drawingFile, scope: "assembly" });
|
quickFiles.push({ label: "Assembly drawing", file: op.part.assembly.drawingFile, scope: "assembly" });
|
||||||
if (op.part.assembly.stepFile)
|
|
||||||
quickFiles.push({ label: "Assembly 3D", file: op.part.assembly.stepFile, scope: "assembly" });
|
|
||||||
if (op.part.assembly.cutFile)
|
if (op.part.assembly.cutFile)
|
||||||
quickFiles.push({ label: "Assembly cut", file: op.part.assembly.cutFile, scope: "assembly" });
|
quickFiles.push({ label: "Assembly cut", file: op.part.assembly.cutFile, scope: "assembly" });
|
||||||
|
|
||||||
@@ -246,6 +248,28 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{op.part.stepFile ? (
|
||||||
|
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||||
|
<StepViewerPanel
|
||||||
|
title="Part 3D"
|
||||||
|
fileId={op.part.stepFile.id}
|
||||||
|
fileName={op.part.stepFile.originalName}
|
||||||
|
height={320}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{op.part.assembly.stepFile ? (
|
||||||
|
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||||
|
<StepViewerPanel
|
||||||
|
title="Assembly 3D"
|
||||||
|
fileId={op.part.assembly.stepFile.id}
|
||||||
|
fileName={op.part.assembly.stepFile.originalName}
|
||||||
|
height={320}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{op.instructions ? (
|
{op.instructions ? (
|
||||||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
|
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Lightweight wrapper around <StepViewer>. Used anywhere we want a
|
||||||
|
// "Show 3D" toggle that reveals the in-browser STEP viewer on demand:
|
||||||
|
//
|
||||||
|
// - Assembly detail page (admin)
|
||||||
|
// - Operator scan page (phone)
|
||||||
|
//
|
||||||
|
// Keeps the heavy occt / three.js / WASM out of the initial bundle via
|
||||||
|
// next/dynamic + ssr:false, and only starts loading when the user taps
|
||||||
|
// the toggle.
|
||||||
|
//
|
||||||
|
// Intentionally does NOT capture thumbnails — the part detail page has
|
||||||
|
// its own richer variant for that. Here we just render the model.
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const StepViewer = dynamic(() => import("@/components/StepViewer"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="relative w-full h-[320px] rounded-lg border border-slate-200 bg-slate-50 flex items-center justify-center text-sm text-slate-500">
|
||||||
|
Loading viewer…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** FileAsset id whose bytes will be streamed from /api/v1/files/:id/download. */
|
||||||
|
fileId: string;
|
||||||
|
/** Human-friendly file name shown in the header. */
|
||||||
|
fileName: string;
|
||||||
|
/** Panel heading, e.g. "3D viewer" or "Assembly 3D". */
|
||||||
|
title?: string;
|
||||||
|
/** If true, the panel renders already open. Defaults to false — saves the
|
||||||
|
* ~2 MB WASM download until the operator actually asks for it, which matters
|
||||||
|
* on a phone. */
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
/** Height of the viewer canvas. Defaults to 360 (good for phones). Admin
|
||||||
|
* pages can bump this to 480. */
|
||||||
|
height?: number;
|
||||||
|
/** Extra tailwind classes for the outer <section>. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepViewerPanel({
|
||||||
|
fileId,
|
||||||
|
fileName,
|
||||||
|
title = "3D viewer",
|
||||||
|
defaultOpen = false,
|
||||||
|
height = 360,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const [edges, setEdges] = useState(true);
|
||||||
|
const url = `/api/v1/files/${fileId}/download`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={className}>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900">{title}</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{open ? (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={edges}
|
||||||
|
onChange={(e) => setEdges(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show edges
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`rounded-md text-sm font-medium px-3 py-1.5 ${
|
||||||
|
open
|
||||||
|
? "bg-white border border-slate-300 text-slate-700"
|
||||||
|
: "bg-slate-900 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{open ? "Hide" : "Show 3D"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||||
|
<div className="mb-2 text-[11px] text-slate-500 truncate" title={fileName}>
|
||||||
|
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
|
||||||
|
</div>
|
||||||
|
<div style={{ height }} className="relative w-full rounded-lg border border-slate-200 bg-slate-50 overflow-hidden">
|
||||||
|
<StepViewer
|
||||||
|
key={`${fileId}-${edges ? "e" : "ne"}`}
|
||||||
|
url={url}
|
||||||
|
showEdges={edges}
|
||||||
|
className="absolute inset-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-slate-500">
|
||||||
|
Drag to rotate · pinch / scroll to zoom · two-finger drag to pan. Rendered in
|
||||||
|
your browser — the STEP file isn't downloaded to your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Interactive 3D preview. Tap <span className="font-medium">Show 3D</span> to load
|
||||||
|
(~2 MB of viewer code fetched on first use).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user