stage 8-complete
Build and Push Docker Image / build (push) Successful in 1m4s

This commit is contained in:
jason
2026-04-21 14:21:53 -05:00
parent 76308b8aa3
commit bb452a59ae
13 changed files with 281 additions and 44 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
"Bash(npx tsc *)",
"Bash(npx next *)",
"Bash(npm run *)",
"Bash(node scripts/copy-viewer-assets.mjs)"
"Bash(node scripts/copy-viewer-assets.mjs)",
"Bash(npx prisma *)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)"
]
}
}
+3 -2
View File
@@ -4,7 +4,7 @@ A single-container, self-hosted Manufacturing Resource Planning (MRP) app built
## Status
Steps 1 6 of the build plan are in this repo:
Shipped (see [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md)): steps 1, 2, 3, 4, 5, 6, 8.
- **1.** Scaffold + auth (admin email/password, operator name/4-digit PIN with 12 h device session, PIN lockout, audited sessions).
- **2.** Admin CRUD (users, machines, operation templates, projects / assemblies / parts) with content-addressed STEP / PDF / DXF / SVG file uploads.
@@ -12,8 +12,9 @@ Steps 1 6 of the build plan are in this repo:
- **4.** Operator scan flow — phone scan resolves `/op/scan/<token>`, single-claim enforced at DB level, Start / Pause / Done with inline QC for steps that require it, TimeLog rows for every claim.
- **5.** PDF traveler generation — per-operation card + per-part cover sheet with the full operation list and file manifest. Printed via `pdf-lib` (no native deps).
- **6.** Fasteners + purchase orders — per-project BOM of fasteners with unresolved-need rollups, PO lifecycle (`draft → sent → partial → received`, or `cancelled`), per-line receipt entry with auto-advance, and vendor-ready PDF downloads.
- **8.** In-browser STEP viewer — OpenCascade (WASM, via `occt-import-js`) parses STEP/STP, `three.js` renders with OrbitControls. Thumbnails are captured from the first rendered frame, uploaded to the content-addressed file store, and displayed on the assembly parts list. No native deps, no server-side GL.
Planned (not yet shipped): dashboard → STEP viewer → dedicated QC operations → OpenAPI docs + backups. See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md).
Planned (not yet shipped): dashboard (step 7) → dedicated QC operations (9) → OpenAPI docs + backups (10).
## Core concepts
@@ -39,6 +39,7 @@ interface PartRow {
hasStep: boolean;
hasDrawing: boolean;
hasCut: boolean;
thumbnailFileId: string | null;
operationCount: number;
}
@@ -100,6 +101,7 @@ export default function AssemblyDetailClient({
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium w-[90px]">Preview</th>
<th className="px-4 py-2 font-medium">Code</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Material</th>
@@ -112,6 +114,26 @@ export default function AssemblyDetailClient({
<tbody>
{parts.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
{p.thumbnailFileId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/v1/files/${p.thumbnailFileId}/download`}
alt={`${p.code} preview`}
width={72}
height={54}
className="rounded border border-slate-200 bg-slate-50 object-cover w-[72px] h-[54px]"
/>
) : p.hasStep ? (
<div className="rounded border border-dashed border-slate-300 bg-slate-50 w-[72px] h-[54px] flex items-center justify-center text-[10px] text-slate-400">
open to render
</div>
) : (
<div className="rounded border border-slate-200 bg-slate-50 w-[72px] h-[54px] flex items-center justify-center text-[10px] text-slate-400">
no STEP
</div>
)}
</td>
<td className="px-4 py-3 font-mono text-slate-700">{p.code}</td>
<td className="px-4 py-3 font-medium">{p.name}</td>
<td className="px-4 py-3 text-slate-600">{p.material ?? "—"}</td>
@@ -136,7 +158,7 @@ export default function AssemblyDetailClient({
))}
{parts.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
No parts yet.
</td>
</tr>
@@ -21,6 +21,7 @@ export default async function AdminAssemblyDetailPage({
stepFile: { select: { id: true } },
drawingFile: { select: { id: true } },
cutFile: { select: { id: true } },
thumbnailFile: { select: { id: true } },
},
},
},
@@ -46,6 +47,7 @@ export default async function AdminAssemblyDetailPage({
hasStep: !!p.stepFile,
hasDrawing: !!p.drawingFile,
hasCut: !!p.cutFile,
thumbnailFileId: p.thumbnailFile?.id ?? null,
operationCount: p._count.operations,
}))}
/>
@@ -47,6 +47,7 @@ interface PartInfo {
stepFile: FileView | null;
drawingFile: FileView | null;
cutFile: FileView | null;
thumbnailFileId: string | null;
}
export interface OperationRow {
@@ -208,7 +209,13 @@ export default function PartDetailClient({
</section>
{part.stepFile ? (
<StepViewerSection fileId={part.stepFile.id} fileName={part.stepFile.originalName} />
<StepViewerSection
partId={part.id}
fileId={part.stepFile.id}
fileName={part.stepFile.originalName}
hasThumbnail={!!part.thumbnailFileId}
onThumbnailSaved={() => router.refresh()}
/>
) : null}
<OperationsSection
@@ -676,19 +683,73 @@ function OperationModal({
// -------- 3D viewer ------------------------------------------------------
function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: string }) {
function StepViewerSection({
partId,
fileId,
fileName,
hasThumbnail,
onThumbnailSaved,
}: {
partId: string;
fileId: string;
fileName: string;
hasThumbnail: boolean;
onThumbnailSaved: () => void;
}) {
const [open, setOpen] = useState(false);
const [edges, setEdges] = useState(true);
const [savingThumb, setSavingThumb] = useState(false);
const [thumbMessage, setThumbMessage] = useState<string | null>(null);
// Bump this to force a remount of the viewer (and therefore a fresh
// first-frame capture). Used by "Regenerate thumbnail".
const [captureNonce, setCaptureNonce] = useState(0);
// One-shot guard so onFirstFrame from the render loop only fires once per
// viewer mount. Reset whenever captureNonce advances.
const capturedRef = useRef(false);
// The viewer fetches via `/api/v1/files/:id/download`, which streams the
// stored STEP bytes with correct auth (admin session cookie is already set).
const url = `/api/v1/files/${fileId}/download`;
// Capture + upload the first-frame thumbnail. Runs at most once per open.
async function handleFirstFrame(blob: Blob) {
if (capturedRef.current) return;
capturedRef.current = true;
setSavingThumb(true);
setThumbMessage(null);
try {
const form = new FormData();
form.set("file", new File([blob], `part-${partId}.jpg`, { type: "image/jpeg" }));
const res = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
method: "POST",
body: form,
});
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ thumbnailFileId: res.file.id }),
});
setThumbMessage("Thumbnail saved.");
onThumbnailSaved();
} catch (err) {
setThumbMessage(
err instanceof ApiClientError ? `Thumbnail save failed: ${err.message}` : "Thumbnail save failed",
);
} finally {
setSavingThumb(false);
}
}
// Only request a new thumbnail if there isn't one already (or the user hit
// "Regenerate"), to avoid overwriting a good capture with a worse one every
// time someone opens the viewer.
const shouldCapture = open && (!hasThumbnail || captureNonce > 0) && !capturedRef.current;
return (
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">3D viewer</h2>
<div className="flex items-center gap-2">
{open ? (
<>
<label className="flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
@@ -697,6 +758,21 @@ function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: str
/>
Show edges
</label>
{hasThumbnail ? (
<Button
variant="ghost"
size="sm"
disabled={savingThumb}
onClick={() => {
capturedRef.current = false;
setThumbMessage("Regenerating on next frame…");
setCaptureNonce((n) => n + 1);
}}
>
{savingThumb ? "Saving…" : "Regenerate thumbnail"}
</Button>
) : null}
</>
) : null}
<Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show 3D"}
@@ -706,13 +782,21 @@ function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: str
{open ? (
<Card>
<div className="p-3">
<div className="mb-2 text-xs text-slate-500 truncate" title={fileName}>
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-500">
<span className="truncate" title={fileName}>
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
</span>
{thumbMessage ? <span className="text-slate-500">{thumbMessage}</span> : null}
</div>
<StepViewer key={`${fileId}-${edges ? "e" : "ne"}`} url={url} showEdges={edges} />
<StepViewer
key={`${fileId}-${edges ? "e" : "ne"}-${captureNonce}`}
url={url}
showEdges={edges}
onFirstFrame={shouldCapture ? handleFirstFrame : undefined}
/>
<p className="mt-2 text-[11px] text-slate-500">
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happens
in your browser nothing is uploaded.
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happen in
your browser; the first view also uploads a ~480×360 JPEG thumbnail for the parts list.
</p>
</div>
</Card>
@@ -25,6 +25,7 @@ export default async function AdminPartDetailPage({
stepFile: true,
drawingFile: true,
cutFile: true,
thumbnailFile: true,
operations: {
orderBy: { sequence: "asc" },
include: {
@@ -79,6 +80,7 @@ export default async function AdminPartDetailPage({
stepFile: fileView(part.stepFile),
drawingFile: fileView(part.drawingFile),
cutFile: fileView(part.cutFile),
thumbnailFileId: part.thumbnailFileId,
}}
operations={part.operations.map((op) => ({
id: op.id,
+1
View File
@@ -51,6 +51,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
...(body.stepFileId !== undefined ? { stepFileId: body.stepFileId } : {}),
...(body.drawingFileId !== undefined ? { drawingFileId: body.drawingFileId } : {}),
...(body.cutFileId !== undefined ? { cutFileId: body.cutFileId } : {}),
...(body.thumbnailFileId !== undefined ? { thumbnailFileId: body.thumbnailFileId } : {}),
},
});
await audit({
+92 -2
View File
@@ -21,6 +21,12 @@ interface Props {
className?: string;
/** Show mesh edges for readability. Defaults to true. Disable on very complex assemblies. */
showEdges?: boolean;
/**
* Called once, after the first successful render, with a downsampled JPEG
* thumbnail of the 3D view (~480x360). Host is responsible for uploading
* it. Errors in the host callback are swallowed — the viewer still runs.
*/
onFirstFrame?: (blob: Blob) => void | Promise<void>;
}
type ViewerState =
@@ -29,10 +35,17 @@ type ViewerState =
| { kind: "ready"; triangleCount: number; meshCount: number }
| { kind: "error"; message: string };
export default function StepViewer({ url, className, showEdges = true }: Props) {
export default function StepViewer({ url, className, showEdges = true, onFirstFrame }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<ViewerState>({ kind: "idle" });
// Hold the latest callback in a ref so updates don't re-trigger the setup
// effect (which would reload the STEP file every render).
const onFirstFrameRef = useRef(onFirstFrame);
useEffect(() => {
onFirstFrameRef.current = onFirstFrame;
}, [onFirstFrame]);
useEffect(() => {
const hostNullable = containerRef.current;
if (!hostNullable) return;
@@ -89,7 +102,13 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
const width = host.clientWidth || 600;
const height = host.clientHeight || 400;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
// `preserveDrawingBuffer` so we can capture a thumbnail via toBlob().
// Slight perf cost; acceptable for the one-at-a-time viewer use case.
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(width, height, false);
renderer.setClearColor(0xf8fafc); // slate-50
@@ -150,10 +169,34 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
// Render loop.
let rafId = 0;
let capturedFirstFrame = false;
const render = () => {
controls.update();
renderer.render(scene, camera);
rafId = requestAnimationFrame(render);
// After the first real paint, grab a thumbnail if the host wants
// one. We wait one extra frame so antialiasing and OrbitControls
// have settled. Reads through a ref so callback identity changes
// don't reload the model.
const cb = onFirstFrameRef.current;
if (!capturedFirstFrame && cb) {
capturedFirstFrame = true;
// Defer a tick so the paint actually commits before we read back.
requestAnimationFrame(() => {
try {
captureThumbnail(renderer.domElement, 480, 360).then((blob) => {
if (!cancelled && blob) {
Promise.resolve(cb(blob)).catch(() => {
/* host failure must not break the viewer */
});
}
});
} catch {
/* ignore thumbnail capture errors */
}
});
}
};
render();
@@ -240,6 +283,53 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
import type * as THREENS from "three";
import type { OcctMesh } from "occt-import-js";
/**
* Downsample the viewer canvas to a fixed size and return a JPEG blob.
* JPEG instead of PNG because thumbnails don't need transparency and JPEG
* cuts the byte count roughly 4× on typical 3D renders.
*/
function captureThumbnail(
source: HTMLCanvasElement,
width: number,
height: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
try {
const off = document.createElement("canvas");
off.width = width;
off.height = height;
const ctx = off.getContext("2d");
if (!ctx) return resolve(null);
// Letterbox if aspect ratios differ — fill with slate-50 to match the
// viewer background so thumbnails look continuous with the live view.
ctx.fillStyle = "#f8fafc";
ctx.fillRect(0, 0, width, height);
const srcAspect = source.width / source.height;
const dstAspect = width / height;
let dw = width;
let dh = height;
if (srcAspect > dstAspect) {
dh = Math.round(width / srcAspect);
} else {
dw = Math.round(height * srcAspect);
}
const dx = Math.round((width - dw) / 2);
const dy = Math.round((height - dh) / 2);
ctx.drawImage(source, dx, dy, dw, dh);
off.toBlob(
(blob) => resolve(blob),
"image/jpeg",
0.85,
);
} catch {
resolve(null);
}
});
}
interface BuiltMesh {
mesh: THREENS.Mesh;
edges: THREENS.Group | null;
+1 -1
View File
@@ -11,7 +11,7 @@ The roadmap agreed at project kickoff. Each step is committed separately so the
| 5 | PDF generation: per-operation card + per-part cover sheet | **done** |
| 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | **done** |
| 7 | Admin dashboard (WIP, overdue, plan-vs-actual) + audit log viewer | planned |
| 8 | In-browser STEP viewer + server-side thumbnails | planned |
| 8 | In-browser STEP viewer + server-side thumbnails | **done** |
| 9 | QC records (inline checkboxes + dedicated QC operation type) | planned |
| 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned |
+1
View File
@@ -196,6 +196,7 @@ export const UpdatePartSchema = z
stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(),
thumbnailFileId: z.string().min(1).nullable().optional(),
})
.strict();
@@ -0,0 +1,29 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"assemblyId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"material" TEXT,
"qty" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"stepFileId" TEXT,
"drawingFileId" TEXT,
"cutFileId" TEXT,
"thumbnailFileId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_assemblyId_fkey" FOREIGN KEY ("assemblyId") REFERENCES "Assembly" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Part_stepFileId_fkey" FOREIGN KEY ("stepFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_drawingFileId_fkey" FOREIGN KEY ("drawingFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_cutFileId_fkey" FOREIGN KEY ("cutFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_thumbnailFileId_fkey" FOREIGN KEY ("thumbnailFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("assemblyId", "code", "createdAt", "cutFileId", "drawingFileId", "id", "material", "name", "notes", "qty", "stepFileId", "updatedAt") SELECT "assemblyId", "code", "createdAt", "cutFileId", "drawingFileId", "id", "material", "name", "notes", "qty", "stepFileId", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_assemblyId_code_key" ON "Part"("assemblyId", "code");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+3
View File
@@ -138,6 +138,7 @@ model Part {
stepFileId String?
drawingFileId String?
cutFileId String?
thumbnailFileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -145,6 +146,7 @@ model Part {
stepFile FileAsset? @relation("PartStep", fields: [stepFileId], references: [id], onDelete: SetNull)
drawingFile FileAsset? @relation("PartDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull)
cutFile FileAsset? @relation("PartCut", fields: [cutFileId], references: [id], onDelete: SetNull)
thumbnailFile FileAsset? @relation("PartThumbnail", fields: [thumbnailFileId], references: [id], onDelete: SetNull)
operations Operation[]
@@unique([assemblyId, code])
@@ -287,6 +289,7 @@ model FileAsset {
partStep Part[] @relation("PartStep")
partDrawing Part[] @relation("PartDrawing")
partCut Part[] @relation("PartCut")
partThumbnail Part[] @relation("PartThumbnail")
poPdfs PurchaseOrder[] @relation("PoPdf")
}
+1 -1
View File
File diff suppressed because one or more lines are too long