462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"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 };
|
||
}
|