Adom 3D Viewer

Install?

The Babylon 9.5 engine behind every component page's 3D tab on wiki.adom.inc. Versioned ESM bundle with GLB loading, view cube, layers toolbar, ground shadows, and Z-up CAD framing.

adom-wiki pkg install adom/adom-3d-viewer

Latest: v0.9.1, published

Download for your machine · v0.9.1?

Adom 3D Viewer

Contents

README

markdown

Adom 3D Viewer

The Babylon 9.5 viewer engine behind every component page's 3D tab on wiki.adom.inc. A fork of adom-inc/3d-viewer (Babylon 7.54) upgraded to Babylon 9.5 with the redesigned Inspector v9, built as a versioned ESM bundle that downstream tools vendor (the wiki's native component viewer, chiplinter, and the next wave of Adom 3D apps).

This page is the canonical home: the source lives on the Files tab (adom-wiki repo clone adom/adom-3d-viewer), and each version's built bundle is attached as a release asset you can pin.

What is in 0.4.x

  • Layers toolbar: slide-out per-layer visibility panel driven by the node naming convention (fr4_board, pad_top, silk, barrel, pin1_marker, ...).
  • Z-fighting fixes for PCB models: 10x depth precision, polygon-offset tie-breaking for nested shells, near plane at radius/50, XY-area container ordering.
  • Skybox mesh replaced by a screen-space background layer (tight far plane, faded ground rim): frameModel no longer fits a 5000-unit skybox.
  • Sandbox storage guard: localStorage/sessionStorage access is wrapped, so the viewer initializes fully (lights, IBL, shadows) inside sandboxed iframes.
  • Transparent radial-fade ground, hover tooltips, orbit-center UX, world-origin and mesh-local axis helpers, laser-etch text overlays.

Get the bundle (pin a version)

Every release attaches the built bundle as a tarball asset:

adom-wiki release download adom/adom-3d-viewer <version> adom-3d-viewer-babylon9-<version>.tar.gz
tar -xzf adom-3d-viewer-babylon9-<version>.tar.gz -C ./vendor/3d-viewer/

The extracted VERSION file records exactly which build you have. The bundle code-splits into ~60 chunks; serve them all from the same directory as the entry (adom-3d-viewer-babylon9.esm.js) and browsers fetch chunks on demand.

Embed it

<!-- Browser shim for Node-style globals that @babylonjs/inspector's React
     (Fluent UI) deps assume. Add BEFORE loading the bundle. -->
<script>
  window.process = window.process || { env: { NODE_ENV: "production" } };
  window.global = window.global || window;
</script>

<script type="module">
  await import("./vendor/3d-viewer/adom-3d-viewer-babylon9.esm.js");
  const v = window.Adom3DViewerBabylon9;
  const viewer = v.init(host, { zUp: true, showViewCube: true });
  await viewer.loadModel("chip.glb");
  viewer.frameModel();
</script>

Why ESM (not IIFE)

Babylon 9's @babylonjs/inspector has circular imports that resolve to TDZ when collapsed into a single IIFE scope, regardless of minifier. Vite's ESM output preserves module boundaries so the inspector initializes cleanly. The global is window.Adom3DViewerBabylon9 (distinct from the legacy window.Adom3DViewer), so both bundles can coexist during a migration.

API

window.Adom3DViewerBabylon9 = {
  init(host, options),         // mounts the viewer, returns ViewerInstance
  rotateYUpToZUp(scene),       // kicad-cli GLBs (Y-up) -> adom viewer (Z-up)
  addLaserEtch(scene, opts),   // bake a laser-etched chip-marking text overlay
  version: "0.5.0",            // this build, injected from package.json
  BABYLON: <full @babylonjs/core namespace>,  // re-exported for consumers
};

ViewerInstance exposes:

  • loadModel(url) / loadModelBytes(bytes, { extension? }) / clearScene() / frameModel(fillFraction?)
  • whenReady() / getReadyReport(): the ready report (see "Sandboxed frames" below): resolves once the first scene is up and reflects the current state on every later call; never rejects
  • getScene() / getEngine() / getCamera() / getShadowGenerator()
  • addContentRoot(node, opts?) / removeContentRoot(node)
  • toggleDebugLayer() / showDebugLayer() / hideDebugLayer()
  • setGroundVisible(visible) / goHome()
  • setBottomLight(on) / getBottomLightState(): underside fill light, default off
  • setProjectionMode(ortho) / getProjectionMode()
  • toggleAxes("world" | "mesh-local", force?) / getAxesState()
  • getLayers() / setLayerVisible(name, visible): layers, either declared by the content via extras.adomLayer or matched against the built-in board vocabulary (docs/CONTENT-CONVENTIONS.md). Each entry carries group for sectioning and declared to say which source it came from. Empty for GLBs with fewer than two layers
  • captureScreenshot({ width?, height? }): resolves to a PNG data URL. Renders through an offscreen target, so the result does not depend on the on-screen canvas size
  • setShadowRefreshRate(rate | null): pin the shadow map refresh rate; null restores automatic behavior. Needed for content animated from outside the viewer, which scene.animationGroups cannot see
  • destroy()

init() options: zUp, modelUrl, showViewCube, showGround, environmentUrl, environmentBytes, environment ("studio" | "none"), initialViewMode, onReady(report), onMeshHover(mesh, tip), and onGroundCreated(ground). The hover hook is called on the pick the viewer already performs: return a string to override the tooltip, null to suppress it, or nothing to leave the viewer's own text alone. It is called with (null, null) when the pointer leaves content, so a consumer-driven highlight can clear itself.

onGroundCreated is called with the ground mesh after every rebuild. The ground's geometry belongs to the viewer, because it is sized from content bounds and rebuilt whenever the model is reframed; its appearance does not, so this is where a consumer applies its own ground material. Setting the material once from outside will not survive the next reframe.

Sandboxed frames (wiki widgets and readmes)

The wiki's widget and readme frames are opaque origins under a strict CSP (connect-src 'self' data:, img-src data: https:, no blob:), and every localStorage access there throws. Since 0.7.0 the viewer is built for that (adom/adom-3d-viewer#5):

Orbit

The camera orbits freely through both poles (0.7.1). Dragging through a flat top or bottom view keeps rotating in the same direction; there is no stop near the underside and no beta limit. Presets (front, back, the view cube) still land upright. npm run test:orbit drives real pointer drags across both poles in the Y-up and Z-up frames.

The ? button: controls card and the Shift+Alt tip

Every viewer draws a small ? in its bottom-right corner (0.7.3). The card it opens leads with the move that makes large boards and scaffolds navigable: Shift+Alt+click a point on the model to set the center of rotation (a glowing dot marks it; orbit and zoom then work around that spot). The rest of the controls follow: drag to orbit, right-drag or Ctrl+drag to pan, scroll to zoom toward the cursor, the cube faces with Home and Fit, hold Space to spin. The ? key and Escape toggle the card. The first time a model is ready in a browser, a tip bubble says the Shift+Alt line once and is then remembered (ephemeral storage in sandboxed frames). init(host, { showHelp: false }) hides all of it for hosts that draw their own help (0.7.4: a host container that is already positioned, such as position: absolute; inset: 0, keeps its positioning; 0.7.3 forced it to relative, and the canvas collapsed to 150 px after the host hid and re-showed the viewer); viewer.showHelp() and viewer.hideHelp() drive the card. npm run test:help covers it.

View cube: click on release, drag to orbit

A view-cube face navigates on mouse RELEASE (0.7.2, adom/adom-3d-viewer#4), not on press: pressing on the cube and dragging orbits the model exactly like dragging on the model, the way Fusion's cube behaves. A release within 4 px of the press, still over the same face, is the click that animates the camera to that face. npm run test:viewcube checks all three cases (drag on the cube matches a drag on the model, a clean click snaps on release, a wander does not snap).

  • The studio environment map loads from memory (Babylon's buffer option), and images (environment mip levels, GLB-embedded textures) decode as ImageBitmaps, so no blob: URL is ever fetched. environmentBytes injects your own .env bytes the same way; environment: "none" skips the map.
  • Preferences fall back to document-local memory when storage is denied; the report says storage: "ephemeral".
  • loadModelBytes(bytes) takes a model the host already fetched or verified, so the frame's CSP never sees a model URL.
  • The loading overlay always comes down, model failure included, and whenReady() resolves with a report:
const viewer = Adom3DViewerBabylon9.init(host, { modelUrl: "/adom/part/render/part.glb" });
const report = await viewer.whenReady();
// { ok, engine: { webglVersion, contextLost }, environment: { source, ready, error },
//   camera: { ready, mode }, storage: "persistent" | "ephemeral",
//   loadingOverlay: "hidden" | "visible", model: { requested, loaded, meshes, vertices, error },
//   cspViolations: [{ directive, blocked }], hints: [{ code, level, message, action }], version }

ok is true only when the scene rendered, the overlay is gone, the environment is ready (or deliberately absent), no CSP violation was recorded and any requested model loaded with geometry. Otherwise the hints name the fix: CSP_BLOCKED, MODEL_LOAD_FAILED, MODEL_EMPTY, ENVIRONMENT_FAILED, ENVIRONMENT_PENDING, STORAGE_EPHEMERAL (info), LOADING_OVERLAY_STUCK, WEBGL_CONTEXT_LOST.

One thing the frame's host must do: an opaque origin fetches module scripts in CORS mode, so the bundle must be served with Access-Control-Allow-Origin (the wiki's /static/vendor/ route does).

npm run test:sandbox proves all of it in headless Chrome: an https server carrying the wiki's exact widget CSP, an sandbox="allow-scripts" iframe, a ready report that says ok, painted chip pixels, then three destroy/recreate rounds alternating URL and bytes transport. Reads puppeteer-core and a Chrome from ~/.cache/puppeteer (or PUPPETEER_EXECUTABLE_PATH).

Scale

Millimeter-scale models are supported. The near plane is radius/50 recomputed per frame, and light height, intensity, panning speed and radius limits are all derived from model size, so a 5 mm part and a 500 mm assembly both frame and light correctly without a unit setting.

The full @babylonjs/core namespace is exposed at Adom3DViewerBabylon9.BABYLON so consumers do not need their own import side. chiplinter uses V.BABYLON.MeshBuilder.CreateDisc, V.BABYLON.PBRMaterial, V.BABYLON.Tools.CreateScreenshotUsingRenderTarget, and friends directly.

Build from source

adom-wiki repo clone adom/adom-3d-viewer
cd adom-3d-viewer
npm install
npx vite build --config vite.standalone.config.ts   # -> dist-standalone/

npm run build (plain vite build) produces the npm-consumable dist/ library instead; dist-standalone/ is the self-contained browser bundle the wiki and release assets ship.

Consumers

  • wiki.adom.inc component pages: git-wiki vendors dist-standalone/ into static/vendor/adom-3d-viewer-babylon9 and pins it via VIEWER_BUNDLE_VER.
  • adom-chiplinter: vendors the release tarball into vendor/3d-viewer/.
  • Downstream viewer work is tracked on adom/wiki issues (#48, #53, #60).

Development

Source of record is this page's repo. GitHub adom-inc/adom-3d-viewer-babylon9 is the working mirror where CI and day-to-day commits happen; each released version is ported here (source push + package publish + bundle release asset).