Files
mrp-qrcode/components/StepViewer.tsx
T
jason bb452a59ae
Build and Push Docker Image / build (push) Successful in 1m4s
stage 8-complete
2026-04-21 14:21:53 -05:00

462 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// In-browser STEP viewer. Uses `occt-import-js` (OpenCascade compiled to
// WASM) to triangulate STEP/STP into mesh buffers, then renders them with
// three.js and an OrbitControls camera.
//
// The WASM blob is served from /vendor/occt-import-js.wasm — copied there
// by scripts/copy-viewer-assets.mjs during predev/prebuild.
//
// This component is strictly client-side: both `three` and `occt-import-js`
// reach for `window`/`WebGL`, so we never import them at module top-level in
// server components. All imports inside here are fine because the file is
// marked "use client" and only ever mounted below a client boundary.
import { useEffect, useRef, useState } from "react";
interface Props {
/** Fully qualified URL that returns the STEP bytes (e.g. /api/v1/files/:id/download). */
url: string;
/** Optional class name for the outer wrapper. */
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 =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "ready"; triangleCount: number; meshCount: number }
| { kind: "error"; message: string };
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;
// Capture in a non-nullable local so the async closure below retains the
// narrowing (TS flow analysis doesn't carry narrowing across async fns).
const host: HTMLDivElement = hostNullable;
let cancelled = false;
let cleanup: (() => void) | null = null;
async function run() {
setState({ kind: "loading", message: "Initialising viewer…" });
try {
// Dynamically import the heavy bits — keeps initial JS small and
// means nothing in here ever runs on the server.
const [{ default: occtFactory }, THREE, { OrbitControls }] =
await Promise.all([
import("occt-import-js"),
import("three"),
import("three/examples/jsm/controls/OrbitControls.js"),
]);
if (cancelled) return;
setState({ kind: "loading", message: "Fetching model…" });
const response = await fetch(url, { cache: "force-cache" });
if (!response.ok) {
throw new Error(`Download failed (${response.status})`);
}
const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
setState({ kind: "loading", message: "Parsing STEP file…" });
const occt = await occtFactory({
locateFile: (file) => `/vendor/${file}`,
});
if (cancelled) return;
const fileBuffer = new Uint8Array(arrayBuffer);
const result = occt.ReadStepFile(fileBuffer, null);
if (!result.success || result.meshes.length === 0) {
throw new Error(
result.success
? "No geometry found in this STEP file."
: "OpenCascade could not parse this STEP file.",
);
}
if (cancelled) return;
setState({ kind: "loading", message: "Building scene…" });
// --- three.js scene -----------------------------------------------
const width = host.clientWidth || 600;
const height = host.clientHeight || 400;
// `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
host.appendChild(renderer.domElement);
renderer.domElement.style.display = "block";
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
renderer.domElement.style.touchAction = "none";
const scene = new THREE.Scene();
const ambient = new THREE.AmbientLight(0xffffff, 0.55);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 0.9);
key.position.set(1, 1.5, 1);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.35);
fill.position.set(-1, -0.5, -1);
scene.add(fill);
const group = new THREE.Group();
let totalTris = 0;
const disposables: Array<{ dispose: () => void }> = [];
for (const m of result.meshes) {
const built = buildMesh(THREE, m, showEdges);
if (!built) continue;
group.add(built.mesh);
if (built.edges) group.add(built.edges);
totalTris += built.triangleCount;
disposables.push(...built.disposables);
}
scene.add(group);
// Center and frame.
const bbox = new THREE.Box3().setFromObject(group);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
bbox.getSize(size);
bbox.getCenter(center);
group.position.sub(center); // recentre at origin
const camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 1e7);
const radius = Math.max(size.x, size.y, size.z, 1);
const dist = radius * 2.4;
camera.position.set(dist, dist * 0.8, dist);
camera.lookAt(0, 0, 0);
camera.near = radius / 1000;
camera.far = radius * 1000;
camera.updateProjectionMatrix();
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
controls.update();
// 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();
// Resize.
const ro = new ResizeObserver(() => {
const w = host.clientWidth;
const h = host.clientHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
});
ro.observe(host);
cleanup = () => {
cancelAnimationFrame(rafId);
ro.disconnect();
controls.dispose();
for (const d of disposables) {
try {
d.dispose();
} catch {
/* ignore */
}
}
renderer.dispose();
if (renderer.domElement.parentNode === host) {
host.removeChild(renderer.domElement);
}
};
if (cancelled) {
cleanup();
cleanup = null;
return;
}
setState({
kind: "ready",
triangleCount: totalTris,
meshCount: result.meshes.length,
});
} catch (err) {
if (cancelled) return;
const message = err instanceof Error ? err.message : "Unknown viewer error";
setState({ kind: "error", message });
}
}
run();
return () => {
cancelled = true;
if (cleanup) cleanup();
};
// Reload whenever the target file changes.
}, [url, showEdges]);
return (
<div className={className ?? "relative w-full h-[480px] rounded-lg border border-slate-200 bg-slate-50 overflow-hidden"}>
<div ref={containerRef} className="absolute inset-0" />
{state.kind !== "ready" ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-md bg-white/80 backdrop-blur px-3 py-2 text-sm text-slate-600 border border-slate-200">
{state.kind === "error" ? (
<span className="text-red-600">Viewer error: {state.message}</span>
) : state.kind === "loading" ? (
<span>{state.message}</span>
) : (
<span>Preparing viewer</span>
)}
</div>
</div>
) : (
<div className="absolute bottom-2 right-2 rounded-md bg-white/80 backdrop-blur px-2 py-1 text-xs text-slate-500 border border-slate-200 pointer-events-none">
{state.meshCount} mesh{state.meshCount === 1 ? "" : "es"} · {state.triangleCount.toLocaleString()} tris
</div>
)}
</div>
);
}
// --------------------------------------------------------------------------
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;
triangleCount: number;
disposables: Array<{ dispose: () => void }>;
}
/**
* Adapted from occt-import-js/examples/three_viewer.html. Handles the
* per-BRep-face colouring scheme OCCT emits: a single indexed buffer
* geometry with one material per face group.
*/
function buildMesh(
THREE: typeof THREENS,
source: OcctMesh,
showEdges: boolean,
): BuiltMesh | null {
const positionArr = source.attributes.position?.array;
const indexArr = source.index?.array;
if (!positionArr || !indexArr || positionArr.length === 0 || indexArr.length === 0) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(
positionArr instanceof Float32Array ? positionArr : Float32Array.from(positionArr as ArrayLike<number>),
3,
),
);
if (source.attributes.normal?.array) {
const normalArr = source.attributes.normal.array;
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute(
normalArr instanceof Float32Array ? normalArr : Float32Array.from(normalArr as ArrayLike<number>),
3,
),
);
} else {
// Fall back to computed normals if the importer didn't supply any.
}
geometry.name = source.name ?? "occt-mesh";
const indexTyped =
indexArr instanceof Uint32Array ? indexArr : Uint32Array.from(indexArr as ArrayLike<number>);
geometry.setIndex(new THREE.BufferAttribute(indexTyped, 1));
if (!source.attributes.normal?.array) {
geometry.computeVertexNormals();
}
const disposables: Array<{ dispose: () => void }> = [geometry];
const defaultColor = source.color
? new THREE.Color(source.color[0], source.color[1], source.color[2])
: new THREE.Color(0xb0b8c1); // slate-400-ish
const defaultMaterial = new THREE.MeshPhongMaterial({
color: defaultColor,
specular: 0x101010,
shininess: 30,
flatShading: false,
});
disposables.push(defaultMaterial);
const materials: THREENS.Material[] = [defaultMaterial];
const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x334155 });
disposables.push(outlineMaterial);
const edges = showEdges ? new THREE.Group() : null;
const triangleCount = indexTyped.length / 3;
if (source.brep_faces && source.brep_faces.length > 0) {
for (const face of source.brep_faces) {
const c = face.color
? new THREE.Color(face.color[0], face.color[1], face.color[2])
: defaultMaterial.color;
const mat = new THREE.MeshPhongMaterial({ color: c, specular: 0x101010, shininess: 30 });
materials.push(mat);
disposables.push(mat);
}
let triangleIndex = 0;
let faceGroupIndex = 0;
while (triangleIndex < triangleCount) {
const firstIndex = triangleIndex;
let lastIndex: number;
let materialIndex: number;
if (faceGroupIndex >= source.brep_faces.length) {
lastIndex = triangleCount;
materialIndex = 0;
} else if (triangleIndex < source.brep_faces[faceGroupIndex].first) {
lastIndex = source.brep_faces[faceGroupIndex].first;
materialIndex = 0;
} else {
lastIndex = source.brep_faces[faceGroupIndex].last + 1;
materialIndex = faceGroupIndex + 1;
faceGroupIndex++;
}
geometry.addGroup(firstIndex * 3, (lastIndex - firstIndex) * 3, materialIndex);
if (edges) {
const inner = new THREE.BufferGeometry();
inner.setAttribute("position", geometry.attributes.position);
if (geometry.attributes.normal) {
inner.setAttribute("normal", geometry.attributes.normal);
}
inner.setIndex(new THREE.BufferAttribute(indexTyped.slice(firstIndex * 3, lastIndex * 3), 1));
const edgesGeom = new THREE.EdgesGeometry(inner, 30);
edges.add(new THREE.LineSegments(edgesGeom, outlineMaterial));
disposables.push(inner, edgesGeom);
}
triangleIndex = lastIndex;
}
} else if (edges) {
const edgesGeom = new THREE.EdgesGeometry(geometry, 30);
edges.add(new THREE.LineSegments(edgesGeom, outlineMaterial));
disposables.push(edgesGeom);
}
const mesh = new THREE.Mesh(
geometry,
materials.length > 1 ? materials : materials[0],
);
mesh.name = source.name ?? "occt-mesh";
if (edges) edges.renderOrder = mesh.renderOrder + 1;
return { mesh, edges, triangleCount, disposables };
}