Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion PanTS-Demo/src/components/viewer/MeshViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
isSession?: boolean;
};

export async function fetchMeshManifest(caseId: string, isSession = false): Promise<MeshManifest> {

Check failure on line 27 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check failure on line 27 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const base = isSession
? `${APP_CONSTANTS.API_ORIGIN}/api/sessions/${caseId}/mesh-manifest`
: `${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`;
Expand Down Expand Up @@ -76,7 +76,22 @@
return (
<div style={{ display: "flex", width: "100%", height: "100%" }}>
<main style={{ flex: 1, minWidth: 0 }}>
<Canvas camera={{ position: [0, 250, 650], fov: 45, near: 0.1, far: 5000 }}>
{/*
preserveDrawingBuffer is REQUIRED for the AI assistant's snapshots.
WebGL clears the drawing buffer as soon as the frame is composited, so
without it canvas.toDataURL() reads an already-cleared buffer and the
captured "3D view" is a black rectangle. data-bodymaps-3d marks the
canvas so the capture helper picks this one and never an unrelated
canvas that happens to sit in the same pane.
*/}
<Canvas
camera={{ position: [0, 250, 650], fov: 45, near: 0.1, far: 5000 }}
gl={{ preserveDrawingBuffer: true, antialias: true }}
frameloop="always"
onCreated={({ gl }) => {
gl.domElement.setAttribute("data-bodymaps-3d", "1");
}}
>
<color attach="background" args={["#050505"]} />
<ambientLight intensity={0.7} />
<directionalLight position={[300, 500, 300]} intensity={1.2} />
Expand Down
70 changes: 66 additions & 4 deletions PanTS-Demo/src/routes/VisualizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,10 @@ function VisualizationPage() {
c.height = Math.round(img.height * scale);
const ctx = c.getContext("2d");
if (!ctx) return resolve(dataUrl);
// JPEG has no alpha: paint the CT viewer's black ground first so a
// source with transparent pixels does not decode as white fringing.
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
resolve(c.toDataURL("image/jpeg", 0.85));
};
Expand All @@ -1555,14 +1559,72 @@ function VisualizationPage() {
// grid. The segmentation masks are left VISIBLE so the model can identify
// each organ by its color (paired with the mask legend). Images are
// downscaled before returning so the vision model responds quickly.
// Wait for a frame that has actually been presented. rAF never fires in a
// background tab, so cap the wait rather than hanging the capture.
const nextPresentedFrame = () =>
new Promise<void>((resolve) => {
const done = () => resolve();
const timer = window.setTimeout(done, 250);
requestAnimationFrame(() =>
requestAnimationFrame(() => {
window.clearTimeout(timer);
done();
})
);
});

// A WebGL canvas read back after its drawing buffer was cleared comes out as
// one flat color — the "black 3D screenshot". Sample a tiny copy so a dead
// capture is detected here instead of being sent to the vision model, which
// would then confidently describe an empty image.
const captureLooksBlank = (source: HTMLCanvasElement): boolean => {
try {
const probe = document.createElement("canvas");
probe.width = 32;
probe.height = 32;
const ctx = probe.getContext("2d", { willReadFrequently: true });
if (!ctx) return false;
ctx.drawImage(source, 0, 0, probe.width, probe.height);
const { data } = ctx.getImageData(0, 0, probe.width, probe.height);
let min = 255;
let max = 0;
for (let i = 0; i < data.length; i += 4) {
const luma = (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
if (luma < min) min = luma;
if (luma > max) max = luma;
}
return max - min < 4;
} catch {
return false; // unreadable canvas — assume the shot is usable
}
};

const captureAllViews = useCallback(async () => {
const shots: { name: string; dataUrl: string }[] = await captureViewportImages();
try {
const pane = document.querySelector<HTMLElement>(".render");
const canvas = pane?.querySelector<HTMLCanvasElement>("canvas");
if (canvas && canvas.width && pane && pane.offsetParent !== null) {
const url = canvas.toDataURL("image/png");
if (url && url.length > 128) shots.push({ name: "3d", dataUrl: url });
// Prefer the canvas the mesh viewer tags on creation; the positional
// lookup is only a fallback for an older render tree.
const canvas =
document.querySelector<HTMLCanvasElement>("canvas[data-bodymaps-3d]") ??
pane?.querySelector<HTMLCanvasElement>("canvas") ??
null;
const paneVisible = !pane || pane.offsetParent !== null;
if (canvas && canvas.width && paneVisible) {
await nextPresentedFrame();
let url = canvas.toDataURL("image/png");
if (captureLooksBlank(canvas)) {
// One more frame: the pane may have only just become visible.
await nextPresentedFrame();
url = canvas.toDataURL("image/png");
}
if (captureLooksBlank(canvas)) {
console.warn(
"[BodyMaps AI] 3D pane captured blank — omitting it rather than sending a black image"
);
} else if (url && url.length > 128) {
shots.push({ name: "3d", dataUrl: url });
}
}
} catch (error) {
console.warn("[BodyMaps AI] 3D capture skipped", error);
Expand Down
Loading
Loading