diff --git a/src/three-components/FootprinterModel.tsx b/src/three-components/FootprinterModel.tsx index 3af2a97f..475c6d9a 100644 --- a/src/three-components/FootprinterModel.tsx +++ b/src/three-components/FootprinterModel.tsx @@ -33,6 +33,18 @@ export const FootprinterModel = ({ if (!footprint) return null const { geometries } = getJscadModelForFootprint(footprint, jscadModeling) + // jscad-electronics answers "I have no body for this" with an empty model + // rather than an error, and an empty group renders as nothing at all -- the + // component silently vanishes from the board with no way to tell it apart + // from a part that was never placed. Throwing puts it in front of the + // ThreeErrorBoundary the viewers already wrap every model in, which draws + // the red error cube and shows this message on hover. + if (geometries.flat(Infinity).length === 0) { + throw new Error( + `No 3D model for footprint "${footprint}" (jscad-electronics returned no geometry)`, + ) + } + const group = new THREE.Group() for (const geomInfo of geometries.flat(Infinity) as any[]) { diff --git a/tests/__snapshots__/convert-3d-view-to-svg-top-view.diff.png b/tests/__snapshots__/convert-3d-view-to-svg-top-view.diff.png new file mode 100644 index 00000000..fd1f3144 Binary files /dev/null and b/tests/__snapshots__/convert-3d-view-to-svg-top-view.diff.png differ diff --git a/tests/__snapshots__/convert-3d-view-to-svg-with-multiple-elements.diff.png b/tests/__snapshots__/convert-3d-view-to-svg-with-multiple-elements.diff.png new file mode 100644 index 00000000..884768dc Binary files /dev/null and b/tests/__snapshots__/convert-3d-view-to-svg-with-multiple-elements.diff.png differ diff --git a/tests/footprinter-empty-model-errors.test.tsx b/tests/footprinter-empty-model-errors.test.tsx new file mode 100644 index 00000000..50fe2a4e --- /dev/null +++ b/tests/footprinter-empty-model-errors.test.tsx @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test" +import * as jscadModeling from "@jscad/modeling" +import { getJscadModelForFootprint } from "jscad-electronics/vanilla" +import { JSDOM } from "jsdom" +import { act } from "react" +import { createRoot } from "react-dom/client" +import * as THREE from "three" + +const { FootprinterModel } = await import( + "../src/three-components/FootprinterModel" +) +const { ThreeErrorBoundary } = await import( + "../src/three-components/ThreeErrorBoundary" +) +const { ThreeContext } = await import("../src/react-three/ThreeContext") +const { HoverContext } = await import("../src/react-three/HoverContext") + +/** A footprint jscad-electronics has no body for. */ +const FOOTPRINT_WITH_NO_MODEL = "res_p0.8656mm_pw0.5657mm_ph0.54mm" + +/** + * Half of what makes the viewer-side change necessary is upstream silence, so + * it is asserted rather than assumed: if jscad-electronics ever starts throwing + * for this, or starts returning a body, this test says so instead of leaving a + * guard in place for a condition that no longer occurs. + */ +test("jscad-electronics reports a missing body as an empty model, not an error", () => { + const { geometries } = getJscadModelForFootprint( + FOOTPRINT_WITH_NO_MODEL, + jscadModeling as any, + ) + + expect(geometries.flat(Number.POSITIVE_INFINITY)).toHaveLength(0) +}) + +/** + * Renders one `FootprinterModel` inside the same error boundary the viewers + * wrap every model in, and reports what the boundary caught and what reached + * the scene. Measured before unmount, since unmounting detaches the group. + */ +const renderInBoundary = async (footprint: string) => { + const dom = new JSDOM('
') + const previousWindow = globalThis.window + const previousDocument = globalThis.document + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }) + + let caughtError: Error | undefined + const rootObject = new THREE.Object3D() + const reactRoot = createRoot(dom.window.document.getElementById("root")!) + let objectsInScene = 0 + + try { + await act(async () => { + reactRoot.render( +