app
Adom 3D Viewer
Public Made by Adomby adom
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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { SpotLight } from '@babylonjs/core/Lights/spotLight';
import { ShadowGenerator } from '@babylonjs/core/Lights/Shadows/shadowGenerator';
import { CubeTexture } from '@babylonjs/core/Materials/Textures/cubeTexture';
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { Layer } from '@babylonjs/core/Layers/layer';
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
import type { Engine, Scene } from '@babylonjs/core';
import { ViewCubeBuilder, type ViewCubeUICallbacks } from './ViewCubeBuilder';
import { AdomStatics } from '../AdomStatics';
import { STUDIO_ENV_BASE64 } from '../assets/studioEnv';
import { initAxisHelpers } from './AxisHelpers';
export interface SceneBuilderOptions {
zUp: boolean;
showViewCube: boolean;
environmentUrl?: string;
viewCubeCallbacks?: ViewCubeUICallbacks;
}
export class SceneBuilder {
static init(engine: Engine, options: SceneBuilderOptions = { zUp: true, showViewCube: true }): Promise<void> {
if (!AdomStatics.scene) {
console.error("SceneBuilder Error: AdomStatics.scene is null.");
return Promise.reject(new Error("Scene is undefined in AdomStatics."));
}
const scene = AdomStatics.scene;
const { zUp, showViewCube, environmentUrl, viewCubeCallbacks } = options;
return new Promise<void>((resolve, reject) => {
try {
const rootNode = new TransformNode('ViewerRoot', scene);
const hemDir = zUp ? new Vector3(0, 0, 1) : new Vector3(0, 1, 0);
const hemisphericLight = new HemisphericLight('light', hemDir, scene);
hemisphericLight.intensity = 0.2;
hemisphericLight.parent = rootNode;
hemisphericLight.diffuse = new Color3(1, 1, 1);
hemisphericLight.groundColor = new Color3(0.65, 0.65, 0.65);
// Bottom fill light — illuminates the underside of parts so a
// user inspecting BGA balls / QFN pads / through-hole leg
// bottoms can see them clearly. Default OFF (intensity 0)
// because top-down lighting + ambient is the cleaner default
// for the typical "look at chip from above" use case;
// toggled on via ThreeDViewer.setBottomLight() when the user
// wants to inspect the bottom face. The direction vector
// points UP (toward +Z in Z-up, +Y in Y-up) so the upper
// hemisphere of the light wraps around the model from below.
const bottomDir = zUp ? new Vector3(0, 0, -1) : new Vector3(0, -1, 0);
const bottomLight = new HemisphericLight('bottomLight', bottomDir, scene);
bottomLight.intensity = 0; // toggled to ~0.6 by setBottomLight(true)
bottomLight.parent = rootNode;
bottomLight.diffuse = new Color3(1, 1, 1);
bottomLight.groundColor = new Color3(0.5, 0.5, 0.5);
AdomStatics.bottomLight = bottomLight;
// SpotLight — sole direct light, matching Hydrogen.
// Cone naturally darkens ground edges. Repositioned by
// frameModel() proportionally to the loaded model's size.
const spotPos = zUp
? new Vector3(600, -300, 1500)
: new Vector3(600, 1500, -300);
const spotDir = new Vector3(0, 0, 0).subtract(spotPos).normalize();
const spotLight = new SpotLight('spotLight', spotPos, spotDir, Math.PI * 0.7, 50, scene);
spotLight.intensity = 2_000_000;
spotLight.parent = rootNode;
AdomStatics.spotLight = spotLight;
const shadowGenerator = new ShadowGenerator(8192, spotLight);
scene.shadowsEnabled = true;
shadowGenerator.usePercentageCloserFiltering = true;
shadowGenerator.filteringQuality = ShadowGenerator.QUALITY_HIGH;
shadowGenerator.bias = 0.0002 / 1000;
shadowGenerator.normalBias = 0.08 / 1000;
shadowGenerator.darkness = -1;
shadowGenerator.forceBackFacesOnly = true;
shadowGenerator.frustumEdgeFalloff = 0;
AdomStatics.shadowGenerator = shadowGenerator;
// Catch incompatible shadow-filter combos no matter when a
// consumer sets them (pages flip flags long after load).
scene.onBeforeRenderObservable.add(() => {
AdomStatics.enforceShadowFilterCompat();
});
// Studio environment map for PBR reflections + skybox.
// Bundled as base64 so consumers don't need to copy assets.
// environmentUrl prop lets them override with their own .env file.
if (environmentUrl) {
scene.environmentTexture = CubeTexture.CreateFromPrefilteredData(environmentUrl, scene);
} else {
const raw = atob(STUDIO_ENV_BASE64.split(',')[1]);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
scene.environmentTexture = new CubeTexture(blobUrl, scene, null, false, null, null, null, undefined, true, '.env');
}
// 3d-viewer-design skill §3b — neutral steel-blue gradient.
// Top ≈ #5a6b7e (0.353, 0.420, 0.494) ~37% luma
// Bot ≈ #2a3340 (0.165, 0.200, 0.251) ~20% luma
// Both ends deliberately brighter than near-black so dark IC
// bodies stay visible in any vertical position.
//
// Rendered as a SCREEN-SPACE background layer, not a skybox
// mesh. The old 5000-unit skybox was world geometry: on real
// GPUs its far corners fell into the last sliver of depth
// precision (near planes here can be ~0.0003 for meter-scale
// parts) and got far-plane clipped into hard triangular seams
// that swept across the background while orbiting (adom/wiki
// #45). A background layer has no depth at all: it cannot
// clip, seam, or z-fight, and the gradient is identical at
// every camera angle. PBR reflections on the model still come
// from scene.environmentTexture, which is untouched.
const SKILL_BG_TOP = '#5a6b7e';
const SKILL_BG_BOTTOM = new Color3(0.165, 0.200, 0.251);
const bgTex = new DynamicTexture('bgGradientTex', { width: 4, height: 512 }, scene, false);
const bgCtx = bgTex.getContext();
const grad = bgCtx.createLinearGradient(0, 0, 0, 512);
grad.addColorStop(0, SKILL_BG_TOP);
grad.addColorStop(1, SKILL_BG_BOTTOM.toHexString());
bgCtx.fillStyle = grad;
bgCtx.fillRect(0, 0, 4, 512);
bgTex.update();
const bgLayer = new Layer('bgGradient', null, scene, true);
bgLayer.texture = bgTex;
scene.environmentIntensity = 1.0;
scene.clearColor = new Color4(SKILL_BG_BOTTOM.r, SKILL_BG_BOTTOM.g, SKILL_BG_BOTTOM.b, 1);
if (showViewCube && AdomStatics.camera) {
ViewCubeBuilder.createViewCube(scene, AdomStatics.camera, engine, viewCubeCallbacks);
}
// 3d-viewer-design §8 axis helpers — non-negotiable.
// §8a world-origin axes (default ON, toggleable via API)
// §8c screen-space corner triad (always on)
if (AdomStatics.camera) {
initAxisHelpers(scene, AdomStatics.camera, { worldDefaultOn: true });
}
scene.onPointerObservable.add(() => { /* Ensure pointer events */ });
const canvas = engine.getRenderingCanvas();
if (canvas) {
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
}
resolve();
} catch (error) {
console.error("SceneBuilder Error during setup:", error);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
}