This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user