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
+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;