---
name: 3d-viewer-design
description: >
  Design rules for any 3D viewport Claude generates inside Adom — CAD,
  EDA, mechanical, PCB, component preview, schematic 3D, molecule
  viewer, any WebGL canvas. The headline rule: always use the Adom
  Babylon viewer at `gallia/viewer/` as the baseline — don't rebuild
  from scratch, extend it. Covers engine choice (Babylon, never Three),
  canonical-viewer reuse + upstream contribution workflow, background
  color (gradient, never pure black/white), HDRI + hemispheric
  lighting, PBR material fixups, ArcRotateCamera defaults including
  zoom-to-mouse AND Shift+Alt+Click orbit-center recentering
  (Fusion 360 / Onshape style), ViewCube / view presets, **world-
  origin axis helpers (always on — critical because origins are
  where every Adom 3D integration bug hides)**, measurement markers,
  and AI-drivability hooks. Read this BEFORE you pick
  `scene.clearColor`, import any 3D library, or design any camera
  interaction. Trigger words: 3D viewer, 3D viewport, scene
  background, clear color, Babylon scene, adom 3d viewer, adom
  babylon viewer, canonical viewer, CAD background, PCB viewer,
  component viewer, GLB viewer, STEP viewer, 3D preview, viewport
  design, 3D scene setup, 3d bg color, HDRI, environmentSpecular.env,
  bottom light toggle, camera limits, zoom to mouse, zoom to cursor,
  orbit center, recenter rotation, shift alt click, fusion 360 orbit,
  onshape orbit, ViewCube, view cube, view presets, axis helper,
  axes helper, origin axes, show origin, world origin, mesh origin,
  coordinate system, xyz gizmo, measurement marker, Babylon vs
  Three.js.
---

# 3D Viewer Design

The rules every 3D viewport across Adom should follow. Engineers look
at CAD/EDA 3D all day, and every wrong default silently wrecks the
experience: pure-black viewports hide dark parts, pure-white viewports
blow out highlights, brand-colored backgrounds tint every material,
one-light rigs kill half the silhouette, default camera limits
gimbal-lock the moment anyone tries to look under a chip, zoom-to-
origin teleports users away from what they were inspecting, and a
fixed orbit center makes every rotation on a large assembly feel like
flying blind. Everything below is either (a) a rule Claude keeps
getting wrong from first principles or (b) a goodie the team already
baked into the canonical Adom Babylon viewer at `gallia/viewer/` that
deserves to be the default everywhere.

---

## 1. Engine choice — always Babylon.js, never Three.js

**The rule:** every 3D viewer Claude builds for Adom uses **Babylon.js**.
No Three.js. No react-three-fiber. No `@react-three/drei`. No
three-bundle, RGBELoader, GLTFLoader-from-three, OrbitControls, or any
other Three.js module.

### Why Babylon over Three.js, even though Three.js is the default Claude reaches for

Most LLMs — including Claude — default to Three.js because Three.js
has **more training data**: more Stack Overflow answers, more GitHub
stars, more tutorials, more "how do I…" blog posts. That is the only
reason, and it is not a reason that matters for *our* use case.

- **Material system.** Babylon's PBRMaterial + environment texture
  pipeline is more correct and more tunable than Three's
  MeshStandardMaterial. HDRI reflections on gold/chrome/anodized
  aluminum look photorealistic in Babylon with a two-line setup.
- **GLB loader fidelity.** Babylon's glTF 2.0 loader handles KHR
  extensions (draco, meshopt, variants) more consistently than
  Three's GLTFLoader, which has long-standing edge cases on
  KiCad-exported GLBs.
- **First-class ArcRotateCamera.** Babylon ships an orbit camera
  with radius/alpha/beta + soft behavior limits + `zoomToMouseLocation`
  built for inspection workflows. Three's `OrbitControls` is an
  add-on with a different coordinate model.
- **Post-process pipeline.** Babylon's DefaultRenderingPipeline
  (FXAA, bloom, SSAO, tone mapping) is one flag away.
- **Scene inspector.** Babylon's built-in Inspector
  (`scene.debugLayer`) is a debugger's dream for material and lighting
  issues. Three has no equivalent shipped with the engine.
- **Consistency with the canonical viewer.** `gallia/viewer/` — see
  §2 — is Babylon. One engine ecosystem-wide means every Adom
  viewer inherits every improvement for free.

Claude will feel pulled toward Three. Resist it. If you find yourself
writing `import * as THREE from 'three'` or `new THREE.Scene()`, stop
and rewrite against `@babylonjs/core`.

**Exception:** none. Not even "quick prototypes."

---

## 2. Use the canonical Adom Babylon viewer as the baseline — extend, don't fork

**The rule:** every new Adom app that needs 3D starts from the
canonical Adom Babylon viewer at `gallia/viewer/`. Not from a blank
Babylon `new Scene()`. Not from a Babylon playground snippet. Not
from a fresh `create-babylon-app`. **From the canonical viewer.**

The canonical viewer already solves — or is the right place to solve —
every problem in this skill. If you build a new viewer from scratch
for your new app, you are either (a) re-implementing what already
exists and drifting, or (b) solving it wrong and shipping regressions
the canonical viewer would have caught.

### 2a. What the canonical viewer gives you for free

Out of the box, `gallia/viewer/` ships with:

- **Babylon engine + `environmentSpecular.env` HDRI** already bundled
  (`gallia/viewer/babylon-bundle.min.js`, `viewer/js/environmentSpecular.env`).
- **ArcRotateCamera with soft limits** (§6b) and behavior strip
  (`3d.html:293`).
- **ViewCube / view presets** — front / back / left / right / top /
  bottom / isometric buttons wired to the correct Z-up
  `alpha`/`beta` angles (see §7). Click a face, camera tweens.
- **World-origin axis helper** — R/G/B X/Y/Z gizmo at (0,0,0) visible
  by default so the user can always see which way is up and where
  the scene's origin lives (see §8). Toggleable but default-on.
- **Bottom-light toggle** for PCB under-chip inspection (§4b).
- **GLB loader with Y-up → Z-up handling** (§11) and
  `applyGlbZUpTransform(viewer, glbSource)` helper.
- **Measurement tool** with vertex snapping + auto-scaled markers (§9).
- **Cinematic camera tour** (auto-play on model load, 11-phase
  choreographed reveal).
- **AI-drivability surface** — `window.Adom3DViewer` global +
  `postMessage` protocol + console forwarding (§10).
- **Shadow ground mesh** (`shadowGround`) for soft contact shadows.
- **MCP tool integration** — callable as `av_3d_display` /
  `av_basic_3d_display` from any Claude agent.

Reuse path: embed the viewer in your app, or use the Basic3dView
entry point (`gallia/viewer/3d-viewer-standalone.ts`) as your starter,
or call the MCP tool to open the viewer in Hydrogen. Pick whichever
matches your app's surface — but start from this base.

### 2b. The upstream-first workflow for new best practices

When you discover a best practice the canonical viewer is missing —
the gradient background in §3 is the flagship current example, but
so is zoom-to-mouse (§6a), orbit-center recentering (§6c), or
anything else that lands in this skill — the workflow is:

1. **Add it to the canonical viewer first.** Land a PR in
   `gallia/viewer/` that adds the feature as a baseline default.
   Include a toolbar toggle if it's opinionated.
2. **Update this skill** to reflect that the canonical viewer now
   supports it (move from "known migration debt" to "you get this
   for free").
3. **Delete workarounds** from downstream apps. If a downstream app
   copy-pasted a hack to fix the missing behavior, remove it — the
   app should inherit from the canonical viewer now.
4. **Roll out via the Adom update pipeline.** All consumers get the
   new default automatically on their next `adom update` or workspace
   refresh. No per-app migration required.

### 2c. Do NOT do any of these

- **Do not fork the viewer into your app's repo.** Forks drift. A
  feature added to the canonical viewer never makes it into your
  fork, and a bug fixed in your fork never makes it back.
- **Do not write a parallel mini-viewer** to "just show a GLB
  quickly." `Basic3dView` already exists for that use case
  (`basic-3d-viewer` skill). Use it.
- **Do not ship app-local workarounds for viewer bugs.** File them
  against `gallia/viewer/` and fix them there.
- **Do not re-implement ViewCube, measurement, HDRI loading, or
  bottom-light-toggle per-app.** Inherit from the canonical viewer.

### 2d. Current canonical-viewer debt — improve it, don't route around it

The canonical viewer predates some of the rules in this skill. As
of 2026-04-24:

- Clear color is flat near-black `Color3(0.05, 0.06, 0.08)`
  (`fp-to-3d.js:~1642`) — should be the gradient in §3. **Fix
  upstream per §2b.**
- `cam.zoomToMouseLocation` is not explicitly set on all camera
  construction paths — audit and set it on every ArcRotateCamera
  (§6a).
- Orbit-center recentering (§6c) doesn't exist yet — add as a new
  feature to the canonical viewer, not as a per-app hack.
- Mesh-local axis helpers (§8b) and the screen-space corner triad
  (§8c) don't exist yet — audit and add upstream. The world-origin
  helper (§8a) may or may not be present; verify and make it
  default-on if missing.

These are the next PRs for `gallia/viewer/`. None of them is more
than a ~20–40-line change.

### 2e. How to extend — the 5-tier layering decision matrix

"Extend, don't fork" (§2c) is the rule. **Layering** is the
implementation. There are five tiers, ordered from most-embedded
(preferred) to least-embedded (rare, last resort). Pick the highest
tier that works for your app.

#### Tier 1 — Pure embed in a Hydrogen webview or AV panel *(90%+ of apps)*

You write zero viewer code. Your app opens the canonical viewer in a
Hydrogen tab (or the AV panel), passes a GLB path + a few config
knobs, and drives it via `postMessage`. Every future improvement to
the canonical viewer (brightened gradient, axis helpers, new
toolbar buttons) shows up in your app automatically on the next
`adom update`. **Default to this tier.** Apps like shotlog,
parts-search, library-review, chipfit, 3d-component-creator, and
adom-tsci all belong here. If you're tempted to leave this tier,
prove it to yourself first by listing exactly what the canonical
viewer can't do and asking whether adding it upstream (§2b) would
serve everyone.

#### Tier 2 — MCP tool call *(simplest path)*

`av_3d_display({ glb_path, title, ... })` or
`av_basic_3d_display({ glb_path, title })` from the Claude side. No
browser code in your app at all. Use this when your "app" is
fundamentally an AI pipeline that ends in "show this to the user" —
a one-shot display, a generated preview, a datasheet render. Same
inherited behavior as Tier 1.

#### Tier 3 — Import `Basic3dView` into your own canvas host

`gallia/viewer/3d-viewer-standalone.ts` exposes
`window.Adom3DViewer.init(canvasEl, opts)`. You host the canvas in
your own DOM (for layout reasons — you want the viewer inside your
app's workspace tab instead of a separate panel), but Babylon scene
setup, HDRI, camera defaults, GLB loader, Y-up→Z-up transform, axis
helpers, pivot sphere, and bottom-light toggle are all inherited.
**This is the sweet spot when you need a 3D tab inside your app's
workspace rather than a separate panel.**

#### Tier 4 — Canonical viewer + extension hooks (plugin-style)

When you need one or two custom pieces — an app-specific toolbar
button, a custom `scene.onBeforeRender` observer, a scene-graph
annotation your app owns, a net-highlight overlay for PCB routing —
you add the *extension points* upstream first (§2b workflow), then
use them from your app. The canonical viewer becomes an extensible
host, not a closed box. If it doesn't expose the hook you need,
**land the hook upstream before your app ships.** Don't let missing
hooks push you to Tier 5 unless there's genuinely no way to
generalize them.

#### Tier 5 — Roll your own, but still Babylon, still inherit helpers *(last resort)*

Very rare. When justified, you still import `@babylonjs/core`, still
call `applyGlbZUpTransform`, `applyAdomViewportDefaults`,
`applyAdomCameraDefaults`, the axis helper functions, the
pivot-sphere wiring, the CSS gradient. Every §1–§15 rule still
applies. You're rolling your own *scene orchestration*, not your
own rendering stack.

### 2f. Three-question decision flow

1. **Is your app's 3D "view this model / scene data"?** → **Tier 1
   or 2 embed.** Stop here. If you're thinking "but I need a custom
   layout," re-ask #1 — most custom-layout needs are "I want my own
   panel chrome around a standard viewer," which is an embed with a
   styled wrapper, not a custom viewer.
2. **Does your app need the canvas inside its own DOM / workspace
   tab?** → **Tier 3 (Basic3dView import).** You still inherit
   everything; you just get to pick the canvas parent.
3. **Do you need custom scene behavior the canonical viewer can't be
   taught?** → **Tier 4.** Add the hook upstream, then use it. Only
   jump to Tier 5 if you're certain the behavior doesn't generalize.

### 2g. Signals you should NOT roll your own

- *"The canonical viewer doesn't have feature X"* → add X upstream
  (§2b). If it generalizes, everyone benefits; if it doesn't, it
  probably shouldn't be in a shared viewer anyway.
- *"I need a custom toolbar."* → the canonical viewer's toolbar can
  be extended. Add the extension point upstream if it isn't there.
- *"I need different default materials."* → set them via
  `tweak_scene` postMessage (§10b) or on init. Not a rolling reason.
- *"I want my own layout around the canvas."* → Tier 3 import lets
  you own the layout without forking the scene.
- *"Three.js has a library that does X."* → see §1. No.

### 2h. Signals you might legitimately roll your own

- **Render-to-texture pipelines** — ML training data generation,
  simulation frames streaming, deterministic render farms. The
  canonical viewer is a UI; you need a renderer-as-service.
- **Hybrid 2D+3D canvases** with custom layout math where 3D is
  incidental (e.g. a schematic tool where 3D is a hover preview).
- **Realtime streaming / headless rendering** — WebRTC pipeline,
  cloud-rendered output, server-side render.
- **Fundamentally different rendering model** — ray tracing demo,
  Monte Carlo renderer, shader-education tool, game engine.
- **Your app's primary purpose IS 3D** in a way PCB/CAD viewing
  isn't a use case (physics sim, generative art tool). Here the
  canonical viewer is just the wrong primitive.

Even in these cases, §1 (Babylon) and the §2a helper-function
imports still apply. Don't abandon the pattern library — just the
specific entry point.

### 2i. One-line summary

**Embed if you can, import if you must, roll if you're sure — and
never leave the canonical viewer's pattern library behind,
regardless of tier.**

---

## 3. Background color — the one every Claude gets wrong

### 3a. Never pure black, never pure white, never a saturated brand color

- **Pure black (`#000`)**: dark materials (black plastic, solder mask,
  anodized aluminum, dark silicon) vanish into the void. Ambient
  occlusion cannot read — shadows cannot be darker than the background.
- **Near-black (`rgb(0.05, 0.06, 0.08)`)**: current canonical viewer
  default — almost as bad as pure black. Migration target per §2d.
- **Pure white (`#fff`)**: white silkscreen, white plastic, chrome
  blow out. Retinal fatigue after 10 minutes. Clashes with Adom's
  dark UI chrome.
- **Saturated brand color (e.g. Adom teal `#00B8B1`)**: tints every
  material through environment reflection. A silver chip on teal
  gets a teal rim and stops looking like silver.
- **Flat mid-gray (`#808080`)**: fine but dead. No "up/down" cue.

### 3b. The Adom default: subtle vertical gradient

Both ends of the gradient are **deliberately brighter than the dark
chrome tokens**, because two prior attempts (v1.0 reused `#21262d`
at the top, v1.1 brightened the top to `#3e4a5c` but kept the bottom
at `#0d1117`) *both* shipped viewers that lost black chips against
the background. The v1.1 mistake was assuming "dark parts stay in
the upper half of the frame" — but a single chip centered in the
viewport at default framing lands in the *lower* half, where the
near-black bottom made it invisible all over again. Field feedback
(2026-04-24, second pass) forced brightening *both* ends.

| Position | Color | Rationale |
|----------|-------|-----------|
| Top (~20% of viewport) | **`#5a6b7e`** | Medium steel blue-gray, ~37% luminance. Black-chip-on-sky reads with strong contrast. |
| Bottom (~80%)          | **`#2a3340`** | Darker steel, ~20% luminance. Black chip at ~10% luminance still shows a clear ~10-point step; chrome / silver / white materials pop against this end. |

Vertical, lighter-top-to-darker-bottom. Five things happen at once:

1. **Black models stay visible everywhere in the frame** — including
   in the lower half where they land by default. This is the reason
   the bottom was bumped from `#0d1117` to `#2a3340`. Do **not**
   darken the bottom back.
2. **White / chrome models stay visible.** Even at `#5a6b7e` the top
   is still clearly darker than white silkscreen / silver pins / gold
   contacts — they still pop.
3. **Implicit horizon.** Lighter top reads "sky", darker bottom "ground".
4. **Neutral enough for any material.** Both ends are cool-toned
   steel, so colored parts (green PCBs, red LEDs, blue capacitors)
   aren't fighting a strong-tinted background.
5. **Still on-brand for Adom's dark theme.** We're not at "CAD light
   gray" levels (Fusion 360 tops out around `#a8b0b8`) — this is
   still a dark viewport, just not a dark-materials-disappear one.

**Trade-off: loses perfect chrome-continuity with `--bg`.** v1.1's
bottom `#0d1117` matched the `--bg` panel-chrome token so the
viewport faded seamlessly into surrounding UI. v1.2 gives up that
perfect continuity — there's a small but visible tonal step where
the canvas meets the panel border. **Black-chip visibility wins.**
If a specific app needs perfect chrome continuity more than
black-chip visibility, document the exception per-app and use
`#3e4a5c` → `#0d1117`.

**Acceptance test, non-negotiable.** Load a black matte IC body
(e.g. a TQFP64) centered at default framing. The chip body must
read clearly at every vertical position as you orbit the model. If
the body disappears in any zone, the gradient is regressed — bump
the bottom color, don't work around it.

### 3c. Adom-branded touches go *on top of* the neutral gradient

- **Soft teal ground contact shadow** beneath the model —
  `rgba(0, 184, 177, 0.04)` center fading to transparent.
- **Teal rim light** (§4) picks out silhouettes without affecting
  base color.
- **Accent-colored gizmo / HUD** uses `#00b8b0` teal for active state,
  `#30363d` for resting.

Do *not* tint the background teal, do *not* add a teal wash, do *not*
use `#003C3F` as the base.

---

## 4. Lighting — HDRI + hemispheric is the production default

### 4a. Primary rig: HDRI + hemispheric (what the canonical viewer uses)

```js
scene.environmentTexture = CubeTexture.CreateFromPrefilteredData(
  '/js/environmentSpecular.env', scene);
scene.environmentIntensity = 0.8;

const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);
hemi.intensity = 0.6;
hemi.diffuse     = Color3.FromHexString('#e6edf3');
hemi.groundColor = new Color3(0.2, 0.18, 0.15);
```

HDRI handles specular reflections + ambient bounce; hemispheric
adds global fill so matte plastics don't crush to black. Reuse
Adom's bundled neutral studio HDRI at
`gallia/viewer/viewer/js/environmentSpecular.env`. Don't use outdoor
or dramatic HDRIs — they fight the neutral gradient.

### 4b. Bottom-light toggle — the PCB-inspection goodie

The canonical viewer ships a toolbar toggle that boosts the
hemispheric's `groundColor` to warm (`3d.html:1112`), simulating
bench-bounce illumination for under-chip inspection:

```js
function setBottomLight(enabled) {
  if (enabled) {
    hemi.groundColor = new Color3(0.7, 0.65, 0.55);
    hemi.intensity   = Math.max(hemi.intensity, 1.5);
  } else {
    hemi.groundColor = new Color3(0.2, 0.18, 0.15);
    hemi.intensity   = 0.6;
  }
}
```

Every PCB viewer MUST have this toggle exposed. Every mechanical /
component viewer SHOULD. Costs nothing, solves the "I can't see
what's under the chip" complaint permanently.

### 4c. Fallback rig: three-light studio (only when HDRI unavailable)

| Light | Direction (Z-up) | Color | Intensity |
|-------|------------------|-------|-----------|
| **Key** (top-front) | `(0.4, 0.5, 0.8)` norm | `#ffffff` | 1.0 |
| **Fill** (opposite, below) | `(-0.5, -0.4, 0.2)` norm | `#e6edf3` | 0.4 |
| **Rim** (back, teal-tinted) | `(0.0, -1.0, 0.3)` norm | `#00b8b0` | 0.25 |

Plus ambient at `0.15`. **Never ship one directional light.**

---

## 5. Materials — PBR by default, don't nuke what the GLB gave you

### 5a. Default material class is `PBRMaterial`, always

**The rule:** when you create a material in an Adom 3D viewer, it is
a `BABYLON.PBRMaterial`. Not `StandardMaterial`. Not `MultiMaterial`.
Not `BackgroundMaterial`. **PBR.**

Why: Babylon is built around PBR. The `environmentSpecular.env` HDRI
pipeline from §4a only lights PBR materials correctly — reflections,
metallic response, and the BRDF lookup texture all live on PBR. Drop
a `StandardMaterial` into an HDRI-lit scene and you get flat, dead
lighting regardless of environment intensity. Chrome looks like grey
paint, gold looks like tan plastic, anodized aluminum looks like cardboard.

Boilerplate for a new material:

```js
import { PBRMaterial, Color3, Texture } from '@babylonjs/core';

const mat = new PBRMaterial('partBody', scene);
mat.albedoColor       = Color3.FromHexString('#1a1a1a'); // matte black IC body
mat.metallic          = 0;           // plastic
mat.roughness         = 0.45;        // slightly glossy, not mirror
mat.environmentBRDFTexture = scene.environmentBRDFTexture; // inherit HDRI BRDF
```

For metals (pins, contacts, pads):

```js
const pinMat = new PBRMaterial('pinMetal', scene);
pinMat.albedoColor = Color3.FromHexString('#c8ccd1'); // tin-lead-ish
pinMat.metallic    = 1.0;
pinMat.roughness   = 0.25;
```

For gold (connector contacts, ENIG pads):

```js
const gold = new PBRMaterial('gold', scene);
gold.albedoColor = Color3.FromHexString('#c9a04c');
gold.metallic    = 1.0;
gold.roughness   = 0.22;
```

**StandardMaterial only belongs in two places:** the laser-etch
overlay technique (§5c, where `emissiveColor + zOffset` is the
point and PBR would over-complicate), and legacy code paths you
haven't migrated yet.

### 5b. Don't nuke what the GLB gave you

Adom viewers load GLB files from tscircuit, KiCad, Fusion 360,
Blender, and user uploads. Each authoring tool bakes different
material assumptions. **Do not reset incoming materials.** Detect
and tweak:

```js
scene.meshes.forEach(mesh => {
  const mat = mesh.material;
  if (!mat) return;

  if (mat.getClassName() === 'PBRMaterial') {
    // Keep PBR — just ensure env reflections apply.
    mat.environmentBRDFTexture = scene.environmentBRDFTexture;
  } else if (mat.getClassName() === 'StandardMaterial') {
    // Convert to PBR so it picks up HDRI reflections consistently.
    // See `3d.html:389–454` for the full conversion pattern
    // (copy albedoColor from diffuseColor, guess metallic/roughness
    //  from shininess, preserve texture slots).
  }
});
```

Loaders that write `StandardMaterial` by default (older Blender
exports, some KiCad export paths) should be upgraded to PBR on
load — the HDRI scene lighting depends on it.

### 5c. Laser etch / silkscreen overlay technique

```js
const tex = new DynamicTexture('etch', { width: 512, height: 512 }, scene, true);
ctx.fillStyle = 'rgba(210, 212, 215, 0.8)'; // silverish, NOT pure white
const mat = new StandardMaterial('etchMat', scene);
mat.diffuseTexture  = tex;
mat.emissiveColor   = new Color3(0.82, 0.83, 0.84); // self-illuminates
mat.zOffset         = -2;                           // no z-fight with body
```

---

## 6. Camera — zoom-to-mouse, soft limits, and Fusion/Onshape-style orbit-center recentering

### 6a. Zoom-to-mouse is non-negotiable

Every professional CAD/EDA tool (Fusion 360, SolidWorks, Onshape,
KiCad, Altium) zooms toward the cursor, not toward the camera target.
Default Babylon ArcRotateCamera zooms toward the orbit target —
which means every scroll-in teleports the user away from whatever
they were inspecting. Flip the switch:

```js
cam.zoomToMouseLocation = true; // Babylon >= 5.0, the magic line
```

One-line change, single biggest usability win on the viewer.

### 6b. Soft camera limits — strip behaviors first

```js
while (cam.behaviors && cam.behaviors.length > 0)
  cam.removeBehavior(cam.behaviors[0]);
cam.useFramingBehavior = false;      // framing fights manual camera state
cam.zoomToMouseLocation = true;      // §6a
cam.lowerRadiusLimit = 0.1;          // zoom to 0.1 mm detail
cam.upperRadiusLimit = 200;          // zoom out to full assembly
cam.lowerBetaLimit   = 0.01;         // near-top-down, no gimbal lock
cam.upperBetaLimit   = Math.PI - 0.01; // rotate under-chip, no flip
cam.wheelPrecision   = 50;
cam.pinchPrecision   = 200;
cam.panningSensibility = 1000;
cam.minZ = 0.01;                     // never clip 0.1 — eats solder mask
cam.maxZ = 1000;
```

### 6c. Orbit-center recentering — Shift+Alt+Click (the massive UX upgrade)

Default ArcRotateCamera orbits around a fixed `target`. On a small
PCB that's fine. On a large assembly (full board, enclosure, multi-
board system) it's brutal: the user wants to inspect a connector in
the corner, rotates the camera, and the connector swings off-screen
because the orbit center is in the middle of the board. They pan,
lose orientation, pan more, give up.

Professional CAD tools solve this by letting the user **re-declare
the orbit center on demand**. Fusion 360: middle-click to set pivot.
Onshape: Alt+Click to recenter. Adom uses **Shift+Alt+Click** —
distinct from any existing Babylon gesture, easy to chord, doesn't
interfere with normal left-drag orbit / right-drag pan / scroll
zoom. After a recenter, the camera tweens its `target` to the new
point over ~200ms while keeping `alpha` / `beta` / `radius`
identical — the user's viewpoint is preserved, but subsequent
rotations orbit around what they just clicked.

The feature is free on small boards (user never feels the need to
recenter) and transformative on large ones. Always ship it.

**Implementation:**

```js
import { Animation, CubicEase, EasingFunction, Vector3, Plane } from '@babylonjs/core';

function attachOrbitCenterRecenter(scene, cam, canvas) {
  canvas.addEventListener('pointerdown', (e) => {
    // Shift+Alt+LeftClick only — leaves all other gestures untouched
    if (e.button !== 0 || !e.shiftKey || !e.altKey) return;
    e.preventDefault();

    const pick = scene.pick(scene.pointerX, scene.pointerY);
    let newTarget;

    if (pick.hit && pick.pickedPoint) {
      // Common case: cursor is over a mesh — recenter on the surface point.
      newTarget = pick.pickedPoint.clone();
    } else {
      // Fallback: cursor is over empty space. Project the pick ray onto a
      // sensible fallback plane so the recenter still works.
      //
      // Priority order for the fallback plane:
      //   1) Horizontal plane through the current target (Z = cam.target.z).
      //      Keeps the user near where they were already looking.
      //   2) Ground plane Z=0 (last resort).
      const ray = scene.createPickingRay(scene.pointerX, scene.pointerY,
                                          null, cam);
      for (const planeZ of [cam.target.z, 0]) {
        // Ray-plane intersection with plane Z = planeZ
        const t = (planeZ - ray.origin.z) / ray.direction.z;
        if (t > 0 && t < 10000) {
          newTarget = ray.origin.add(ray.direction.scale(t));
          break;
        }
      }
      if (!newTarget) return; // ray is parallel to both planes — bail
    }

    // Tween cam.target over 200ms. alpha/beta/radius stay identical,
    // so the user's viewpoint is preserved — only the pivot moves.
    const ease = new CubicEase();
    ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);
    Animation.CreateAndStartAnimation(
      'orbitRecenter', cam, 'target',
      60,            // fps
      12,            // 12 frames @ 60fps = 200ms
      cam.target.clone(), newTarget,
      Animation.ANIMATIONLOOPMODE_CONSTANT,
      ease
    );
  });
}
```

**UX polish bits that matter:**

- **Show the teal pivot sphere during EVERY left-drag rotate, not
  just after a recenter.** This is the single most important part
  of making Shift+Alt+Click discoverable. The instant the user
  starts left-dragging, a small teal sphere (`#00b8b0`, ~1% of
  scene extent, semi-transparent α≈0.7) appears at `cam.target`.
  Fades out ~400 ms after pointer-up. Over time — after the user
  has seen the sphere appear-during-drag dozens of times — they
  form an unshakeable mental link: **teal sphere = rotation center**.
  Once that association is built, they intuitively understand what
  Shift+Alt+Click does the first time they try it: "oh, that chord
  moves the teal sphere." Without this persistent visual training,
  Shift+Alt+Click is a hidden power-user gesture nobody ever finds,
  and users get frustrated ("why does this thing keep swinging
  off-screen when I rotate?"). *Not showing the sphere during
  drag-rotate is working against the user's training.*

  ```js
  // Pseudocode wiring — attach to the same canvas as §6c
  let pivotSphere = null;
  function showPivot() {
    if (!pivotSphere) {
      pivotSphere = MeshBuilder.CreateSphere('pivot',
        { diameter: sceneExtent * 0.01 }, scene);
      const m = new StandardMaterial('pivotMat', scene);
      m.emissiveColor = Color3.FromHexString('#00b8b0');
      m.alpha = 0.7;
      m.disableLighting = true;
      pivotSphere.material = m;
      pivotSphere.isPickable = false;
      pivotSphere.renderingGroupId = 2; // draw on top
    }
    pivotSphere.position.copyFrom(cam.target);
    pivotSphere.setEnabled(true);
  }
  function hidePivot(delayMs = 400) {
    setTimeout(() => pivotSphere && pivotSphere.setEnabled(false), delayMs);
  }
  canvas.addEventListener('pointerdown', e => {
    if (e.button === 0 && !e.shiftKey && !e.altKey) showPivot();
  });
  canvas.addEventListener('pointerup', () => hidePivot(400));
  // Also update pivotSphere.position each frame while rotating so it
  // tracks cam.target exactly (Babylon: scene.onBeforeRenderObservable).
  ```

- **Use the SAME teal sphere for the Shift+Alt+Click recenter
  flash.** Don't introduce a different marker — it's the same
  object, just lingering ~600 ms after the recenter tween completes
  instead of the 400 ms fade used during drag. Consistent visual
  language: one sphere, one meaning. The recenter gesture literally
  looks like "move the teal sphere to here," which is exactly the
  mental model we want.
- **Sphere size scales with scene extent.** `sceneExtent * 0.01`
  keeps it readable without dominating — on a 30 mm PCB that's a
  0.3 mm dot, on a 200 mm assembly that's a 2 mm dot.
- **Keep the keybind discoverable.** Toolbar tooltip on the
  orbit/rotate button: "Left-drag to orbit (teal sphere shows the
  rotation center). Shift+Alt+Click to move the rotation center to
  what you clicked."
- **Do not recenter on empty space without a fallback plane.** If
  the ray doesn't hit anything AND the fallback planes don't
  intersect (ray parallel to Z), just silently do nothing — do not
  teleport the target to `Vector3.Zero()` or some default, that's
  more disorienting than not recentering.
- **Keep normal orbit untouched.** Left-drag without the chord still
  orbits around the current (possibly recentered) target. Users
  learn the chord; they don't have to use it.

Ship this in the canonical viewer first (§2b), delete it from any
downstream workaround.

### 6d. Defaults

- **Initial view**: isometric (`alpha: π/4, beta: π/3` on Z-up) — see
  §8f for why this specific sign on alpha (operator-side octant).
- **Initial radius**: bounding sphere × 1.3. Model fills ~70% of
  viewport.
- **FOV**: 35–40°.

---

## 6e. The DIY trap — "I'll just build my own viewer" costs more than it saves

This is for you, Claude. Future you. Me. Every time this skill gets
updated it's because someone re-implemented the canonical viewer from
scratch and then had to be told — feature by painful feature — about
every rule in this document. Gradient background. Bottom-light toggle.
Zoom-to-mouse. Shift+Alt+Click recenter. Pivot sphere during drag.
Axis helpers. Depth-testing on those helpers. Corner triad with labels.
The Adom/CNC coordinate convention (§8f). View-preset `alpha`/`beta`
table. None of it is obvious up-front. All of it already exists —
correct, tested, consistent — in `gallia/viewer/`.

The rationalization Claude will make: "the canonical viewer is heavy
and my app's use case is narrow, so copy-pasting Babylon init is
faster." Then the user spends three to five rounds pointing out that
black chips vanish into the background, the chip is on-edge because
the Z-up transform was skipped, the rotation pivot floats above solid
geometry, the Top view is rotated 180° from the CNC convention, and
the iso view looks in from the wrong octant. Each round costs a bump,
a rebuild, a wiki re-publish, and the user's time. The perceived
"savings" from skipping §2 are −10× over the next week.

**Rule:** if you catch yourself about to write `new BABYLON.Scene()`
or `new BABYLON.Engine()` in a new Adom app, stop. Start from
`gallia/viewer/` per §2. If the canonical viewer is genuinely missing
the feature you need, take the upstream-first path (§2b) — land the
fix in `gallia/viewer/` first, then delete the downstream workaround.
Do not rationalize a parallel implementation because it feels quicker.

Documented 2026-04-24 after step2glb's preview viewer hit every single
one of the above pitfalls one by one through four point releases.

---

## 7. ViewCube / view presets

The canonical viewer ships a ViewCube-style panel: clickable faces
for front / back / left / right / top / bottom, plus a dedicated
isometric button. Clicking a face tweens the camera to the preset
`alpha` / `beta` over ~300 ms with a cubic ease. See
`gallia/viewer/viewer/3d.html` for the reference implementation and
the `basic-3d-viewer` skill for the full Z-up preset angle table.

Rules for ViewCube in any new Adom viewer:

- **Reuse the canonical viewer's ViewCube.** Don't redraw one in your
  app. Embed the viewer, or if you must roll your own, use identical
  preset angles so view memory transfers between apps.
- **Always include "isometric"** as a separate button — users expect
  it next to the six orthogonal faces, not buried in a menu.
- **Tween, don't snap.** 300 ms cubic-ease tween between presets. A
  hard snap disorients users who briefly lose track of which way is
  up.
- **Respect the current orbit center.** ViewCube sets `alpha`/`beta`
  only — leave `cam.target` alone. The user's recentered pivot from
  §6c should survive a view-preset click.
- **Monochrome icon** for the ViewCube button, per the `brand` skill's
  icon rule. Active face highlighted in teal `#00b8b0`.

---

## 8. Axis helpers — always show where the origin is (non-negotiable)

Origins are where every Adom 3D integration bug hides. A chip's GLB
was exported with origin at its centroid, but the footprint's origin
is at pad-1. A Fusion 360 export used Y-up, a KiCad export used
Z-up, and the chip lands sideways. A molecule's 3D model baked the
origin 3.2 mm above the board surface, and now nothing stacks
correctly. **Without a visible origin, neither the user nor the AI
can spot the misalignment** — you just see parts floating in the
wrong place, with no way to tell whether the chip is wrong, the
footprint is wrong, or the Z-up transform skipped a mesh.

Showing where (0, 0, 0) is — and where each loaded mesh's local
origin is — is how you turn a "something's off, I can't tell what"
bug into a "oh the chip's local +Z is pointing sideways" fix in
one glance. This is mandatory in every Adom 3D viewer.

**The rule:** every Adom 3D viewer ships with a world-origin axis
helper visible by default. It is a toolbar toggle ("Show axes"),
**not** a hidden feature you only enable when debugging.

### 8a. World-origin axis helper — default ON

At world origin (0, 0, 0), draw an axis gizmo:

- **X axis red**, **Y axis green**, **Z axis blue** — standard RGB
  convention. Every 3D tool on earth uses R/G/B for X/Y/Z. **Do not
  brand-color these** (no Adom teal, no purple). Same rationale as
  "don't tint the background teal" (§3a): conventions that let users
  from any CAD/DCC tool instantly read the scene outrank brand
  consistency here.
- **Length** = 15% of current scene bounding-sphere radius. Scales
  with the model — a fixed length either dominates small scenes
  (chip preview) or disappears in large ones (full assembly).
- **Labels** `X` / `Y` / `Z` at each tip, billboarded so they always
  face the camera, in `#e6edf3` (primary text color).
- **Position**: world (0, 0, 0). Even after a Shift+Alt+Click recenter
  (§6c), the helper stays at world origin — that is the whole point.

Babylon implementation:

```js
import { AxesViewer } from '@babylonjs/core';

function addWorldAxesHelper(scene, sceneExtent) {
  const axes = new AxesViewer(scene, sceneExtent * 0.15);
  // AxesViewer positions at (0,0,0) world by default and uses R/G/B.
  scene.__worldAxes = axes;
  return axes;
}

function toggleWorldAxes(scene, visible) {
  const axes = scene.__worldAxes;
  if (!axes) return;
  [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => m.setEnabled(visible));
}
```

### 8b. Mesh-local axis helpers — toggleable, default OFF

For every loaded GLB root (a chip, a footprint, a molecule, a
sub-assembly), the user needs a way to reveal that mesh's *local*
origin on demand. Nine out of ten misalignment bugs come from the
local origin not being where anyone thought it was — centroid vs
pad-1, top-of-body vs seat-plane, bounding-box-corner vs ref-des
anchor.

Toolbar button: **"Show mesh origins"**. When on, each loaded root
mesh gets a smaller axis helper parented to it, so the helper moves
and rotates with the mesh:

```js
function addMeshOriginAxes(rootMesh, scene) {
  const r = rootMesh.getBoundingInfo().boundingSphere.radius;
  const axes = new AxesViewer(scene, r * 0.4);
  // Parent so the helper follows the mesh — shows the LOCAL origin.
  [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => m.parent = rootMesh);
  rootMesh.__localAxes = axes;
  return axes;
}
```

This is when the bugs become obvious. User toggles "Show mesh
origins", sees a chip's local +Z pointing sideways → Y-up→Z-up
conversion got skipped on that loader path (§11). Sees a footprint's
origin sitting at the bounding-box corner instead of pad 1 → bad
export from the library tool. Sees a chip hovering 3.2 mm above the
board because its local origin is at the *seat plane* of the
package body instead of the bottom of the pins → wrong convention
from the CAD author. None of these are obvious without the helper,
and all of them look identical ("chip is just floating wrong")
without it.

### 8c. Screen-space corner triad — always on

Fusion 360, Blender, SolidWorks, Onshape, and every serious DCC tool
pin a small three-axis widget in a screen corner that rotates with
the camera but stays fixed to the viewport, so the user always knows
which way is up regardless of orbit state. It complements §8a — the
world helper shows where origin is in *scene* space, the corner
triad shows where the axes point in *screen* space.

Implement via a Babylon `UtilityLayerRenderer` with a secondary
ArcRotateCamera that mirrors the main camera's `alpha` and `beta`
(but uses a fixed radius). Pin the layer's viewport to the
bottom-left ~8% of the screen. Label `X` / `Y` / `Z`, same R/G/B
colors as §8a. No scene-space position — it lives in overlay space.
Lightweight, never interferes with interaction, saves constant
"which way is up?" moments. Default ON, no toggle.

### 8d. Why this is mandatory, not a "nice to have"

- **Y-up vs Z-up conflicts.** World-origin helper immediately shows
  which axis is "up" so the user can verify the Z-up transform (§11)
  landed. A mis-rotated board looks identical to a correctly-rotated
  one in isolation — until you stack something on it.
- **Chip origins vary wildly.** STEP exports from Fusion 360 often
  put origin at centroid; KiCad's `.kicad_mod` origin is at pin 1;
  tscircuit bakes at different points depending on footprint.
  Mesh-local helpers make the difference visible on load.
- **Footprint origins vary too.** Some libraries anchor at pad 1,
  some at bbox centroid, some at a ref-des anchor. When a chip's
  3D model doesn't line up with its footprint pads, first check
  the two origins — and you can only check them if they're drawn.
- **AI-assisted debugging only works when the user can describe
  what they see.** "Something's off" is unhelpful; "the chip's local
  +Z arrow is pointing toward me instead of up" is a sentence the
  AI can act on. Axis helpers turn vague misalignment into
  describable geometry.
- **This is a free upgrade.** Showing the origin costs nothing — no
  perf impact, no visual clutter at 15% of scene extent, toggleable
  if it ever does get in the way. No downside, massive debugging
  upside. Default ON.

### 8e. Keybinds + AI-drivability

- Keyboard: `A` toggles world axes. `Shift+A` toggles mesh-local
  axes. Corner triad stays on (no toggle needed).
- `window.Adom3DViewer.toggleAxes(target, enabled)` where `target`
  is `'world'` | `'mesh-local'` | `'corner-triad'`.
- `postMessage`: `{ type: 'toggle_axes', target, enabled }` (see §10b).
- Toolbar tooltip on "Show axes": "Always show the world origin.
  Useful when debugging alignment of chips, footprints, and
  molecules whose local origins may differ."

### 8f. Adom/CNC coordinate convention + view-preset angle table

Adom is a CNC-forward ecosystem. **The coordinate convention is fixed,
is not optional, and every Adom 3D viewer follows it.** The reason this
has its own section: view-preset math is tied to the convention, and
anyone picking preset angles without this in front of them will get
Top or Iso rotated by 90° or 180° and waste the user's time.

**The convention:**

| Axis | Direction | Mnemonic |
|------|-----------|----------|
| **+X** | East (operator's right as they face the machine) | "X goes to your right hand" |
| **+Y** | North (away from the operator, toward the back of the machine) | "Y goes away" |
| **+Z** | Up (out of the table surface, toward the ceiling) | "Z is up" |

The operator stands at -Y and faces +Y. Every view preset below is
derived from that mental model:

| Preset | Camera position | Screen-right | Screen-up | Mental model |
|--------|-----------------|--------------|-----------|--------------|
| **Top** | `(0, 0, +r)` | +X | +Y | Looking straight down at the machine bed |
| **Bottom** | `(0, 0, -r)` | +X | -Y | Looking up through the bed (mirror of Top) |
| **Front** | `(0, -r, 0)` | +X | +Z | Operator's natural view — walking up to the machine |
| **Back** | `(0, +r, 0)` | -X | +Z | Looking at the machine from behind |
| **Right** | `(+r, 0, 0)` | -Y | +Z | Standing to the east of the machine |
| **Left** | `(-r, 0, 0)` | +Y | +Z | Standing to the west of the machine |
| **Iso** (home) | `(+r, -r, +r)` (operator-side, above) | — | — | Operator's 3/4 view from their own side of the machine |

**Babylon `alpha`/`beta` that produce those positions** — for
`ArcRotateCamera` with `upVector = (0, 0, 1)`:

```js
position.x = target.x + r * sin(β) * cos(α)
position.y = target.y - r * sin(β) * sin(α)     // ← note the MINUS
position.z = target.z + r * cos(β)
```

**The sign on alpha is reversed vs standard spherical coordinates.** A
confident-looking `-Math.PI/2` will actually put the camera at **+Y**,
not -Y. Every new viewer's first-attempt preset table gets at least
one preset rotated 180° because of this. Verify every preset you
compute with a `console.log(cam.position.asArray())` before shipping.

The canonical preset table (Z-up, right-handed Babylon with the alpha
convention above):

```js
const VIEW_PRESETS = {
  top:    { alpha:  Math.PI / 2, beta: 0.01           },
  bottom: { alpha:  Math.PI / 2, beta: Math.PI - 0.01 },
  front:  { alpha:  Math.PI / 2, beta: Math.PI / 2    },
  back:   { alpha: -Math.PI / 2, beta: Math.PI / 2    },
  right:  { alpha:  0,           beta: Math.PI / 2    },
  left:   { alpha: -Math.PI,     beta: Math.PI / 2    },
  iso:    { alpha:  Math.PI / 4, beta: Math.PI / 3    },
};
```

**Iso home view gotcha.** The "obvious" iso angle — `alpha: -π/4` —
lands the camera in the (+X, +Y, +Z) octant, which is *behind* the
operator. That's Blender's / VRay's / Maya's default iso, but it's
wrong for CNC. The correct Adom iso is `alpha: +π/4`, which puts the
camera in (+X, -Y, +Z) — on the operator's own side of the machine.

**Acceptance test.** Snap to Top, eyeball the corner triad (§8c):
- `Y` label MUST be at the top of the triad.
- `X` label MUST be on the right.
- `Z` MUST be the dot at the center (coming toward the viewer).

If any of the three is wrong, your preset table is wrong. Do not ship.

### 8g. Why this convention is non-negotiable

CAD/DCC tools outside CNC use wildly inconsistent conventions — Blender
is Z-up but its default iso looks from the back; Maya is Y-up with Y
pointing out of the monitor; SolidWorks is Y-up; Fusion 360 lets each
user pick. Adom standardizes on CNC-operator conventions specifically
so every tool in the ecosystem — chipfit, adom-tsci, basic-3d-viewer,
InstaPCB preview, step2glb, and any future 3D surface — matches the
physical machine the user will actually be operating. A board seen in
step2glb's Top view is the same orientation the user will see when
they put that board on the CNC bed. A component seen in chipfit's Iso
view shows pin 1 in the same corner as when the operator approaches
the machine from the front. Consistency across viewers is how a board
goes from the screen to the bed without the user mentally rotating
anything. Break the convention in your viewer and every downstream
user has to re-learn your orientation.

This section exists because someone will be tempted to use Blender's
default iso "because it shows more of the model." Don't. Pick a
different default camera distance or angle elevation if you want more
material visible; keep the alpha in the operator-side octant.

---

## 9. Measurement / picking helpers

### 9a. Snap to vertex, not to ray-hit

```js
function snapToVertex(pickResult) {
  const mesh = pickResult.pickedMesh;
  let worldVerts = measureVertexCache.get(mesh.uniqueId);
  if (!worldVerts) {
    const local = mesh.getVerticesData(VertexBuffer.PositionKind);
    const m = mesh.getWorldMatrix();
    worldVerts = [];
    for (let i = 0; i < local.length; i += 3) {
      worldVerts.push(Vector3.TransformCoordinates(
        new Vector3(local[i], local[i+1], local[i+2]), m));
    }
    measureVertexCache.set(mesh.uniqueId, worldVerts);
  }
  return worldVerts.reduce((best, v) =>
    Vector3.DistanceSquared(v, pickResult.pickedPoint) <
    Vector3.DistanceSquared(best, pickResult.pickedPoint) ? v : best);
}
```

### 9b. Auto-scale markers to scene extent

```js
const size = Math.max(Math.min(sceneExtent * 0.03, 0.001), 0.0003);
// 3% of scene diagonal, clamped to 0.3–1 mm
```

---

## 10. AI-drivability — every 3D viewer is AI-controllable

### 10a. Window-global API

```js
window.Adom3DViewer = {
  init, setCamera, setBottomLight, recenterOrbit, tweakScene,
  measure, snapToVertex, dumpMeshes, testPbr,
  BABYLON: { Vector3, Color3, StandardMaterial, PBRMaterial, ... }
};
```

### 10b. `postMessage` protocol

```js
window.addEventListener('message', (e) => {
  const { type } = e.data;
  if (type === 'set_camera')       { /* { alpha, beta, radius, targetX/Y/Z } */ }
  if (type === 'set_bottom_light') { /* { enabled } */ }
  if (type === 'recenter_orbit')   { /* { x, y, z } — same tween as §6c */ }
  if (type === 'view_preset')      { /* { preset: 'iso'|'front'|... } */ }
  if (type === 'toggle_axes')      { /* { target: 'world'|'mesh-local'|'corner-triad', enabled } — see §8 */ }
  if (type === 'tweak_scene')      { /* { envIntensity, hemiIntensity } */ }
  if (type === 'measure')          { /* { pointA, pointB } */ }
});
```

### 10c. Console forwarding

Forward `console.log/warn/error` via `postMessage` so Claude can read
scene-state from CLI without opening devtools.

### Rule

Every toolbar button + every keyboard shortcut must have a
corresponding entry in at least one of these three channels.
Shift+Alt+Click (§6c)? There's a `recenter_orbit` message. ViewCube
face click (§7)? There's a `view_preset` message. Every UI affordance
round-trips through an AI-drivable surface.

---

## 11. Y-up → Z-up coordinate handling

The Adom ecosystem is Z-up. GLB spec is Y-up. The canonical viewer's
`applyGlbZUpTransform(viewer, glbSource)` handles this on every
loader path. See the `basic-3d-viewer` skill for the recipe.

---

## 12. Presets & escape hatches

| Preset | Top | Bottom | When to use |
|--------|-----|--------|-------------|
| **Studio** (default) | `#5a6b7e` | `#2a3340` | 99% of use cases |
| **Dark flat** | `#0d1117` | `#0d1117` | Dark-bg docs screenshots |
| **Light flat** | `#e6edf3` | `#e6edf3` | Print, light-theme exports |

Persisted per app; never force-reset on reload.

---

## 13. Babylon snippet — full viewport defaults

```js
import { Scene, Color3, Color4, Vector3, CubeTexture,
         HemisphericLight } from '@babylonjs/core';

export function applyAdomViewportDefaults(scene, engine) {
  // Background — transparent clearColor, CSS gradient on canvas parent
  scene.clearColor = new Color4(0x0d/255, 0x11/255, 0x17/255, 0);
  engine.getRenderingCanvas().parentElement.style.background =
    'linear-gradient(180deg, #5a6b7e 0%, #2a3340 80%)';

  // Lighting — HDRI + hemispheric (§4a)
  scene.environmentTexture = CubeTexture.CreateFromPrefilteredData(
    '/js/environmentSpecular.env', scene);
  scene.environmentIntensity = 0.8;

  const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);
  hemi.intensity = 0.6;
  hemi.diffuse     = Color3.FromHexString('#e6edf3');
  hemi.groundColor = new Color3(0.2, 0.18, 0.15);

  return { hemi }; // wire to setBottomLight (§4b)
}

export function applyAdomCameraDefaults(cam, scene, canvas) {
  // Strip behaviors first (§6b)
  while (cam.behaviors && cam.behaviors.length > 0)
    cam.removeBehavior(cam.behaviors[0]);
  cam.useFramingBehavior = false;

  cam.zoomToMouseLocation = true;          // §6a
  cam.lowerRadiusLimit = 0.1;
  cam.upperRadiusLimit = 200;
  cam.lowerBetaLimit   = 0.01;
  cam.upperBetaLimit   = Math.PI - 0.01;
  cam.wheelPrecision   = 50;
  cam.pinchPrecision   = 200;
  cam.panningSensibility = 1000;
  cam.minZ = 0.01;
  cam.maxZ = 1000;

  attachOrbitCenterRecenter(scene, cam, canvas); // §6c — Shift+Alt+Click
}
```

CSS fallback for the gradient:

```css
.viewer-3d-canvas-wrapper {
  background: linear-gradient(180deg, #5a6b7e 0%, #2a3340 80%);
}
.viewer-3d-canvas-wrapper canvas { background: transparent; }
```

---

## 14. Pitfalls checklist

- [ ] Engine is **Babylon.js**. No `import * as THREE from 'three'`.
- [ ] Starts from the canonical Adom Babylon viewer at
      `gallia/viewer/`, not from a blank Babylon scene.
- [ ] New best-practices were added to the canonical viewer first
      (§2b), not as downstream hacks.
- [ ] Background is vertical gradient **`#5a6b7e` → `#2a3340`** (v1.3
      field-fix pass — both ends brightened after v1.1's "brighten
      only the top" still lost black chips in the lower half), not
      pure black / white / brand-colored / v1.1's `#3e4a5c` → `#0d1117`
      / v1.0's `#21262d` → `#0d1117`.
- [ ] **Black, white, AND chrome models all clearly visible in the
      default view** (test all three — this is THE acceptance test).
- [ ] HDRI environment loaded; bottom-light toggle in toolbar for
      any PCB viewer.
- [ ] Default material class for any new material is **`PBRMaterial`**,
      not `StandardMaterial` — HDRI lighting depends on it (§5a).
- [ ] **World-origin axis helper visible by default** (R/G/B X/Y/Z,
      15% of scene extent, §8a). Toolbar toggle "Show axes" present.
- [ ] **Mesh-local axis toggle** ("Show mesh origins") present (§8b).
- [ ] **Screen-space corner triad** pinned bottom-left, always on (§8c).
- [ ] **`cam.zoomToMouseLocation = true`** is set.
- [ ] **Teal pivot sphere visible during EVERY left-drag rotate**
      (§6c), not just after recenter. Same sphere used for the
      Shift+Alt+Click recenter flash (consistent visual language).
- [ ] **Shift+Alt+Click orbit-center recentering** wired up, with
      surface-projection fallback + brief teal pivot-marker flash.
- [ ] ViewCube / view presets reuse the canonical viewer's
      component and tween (not snap) between presets.
- [ ] Camera behaviors stripped before limits applied; soft limits
      set; zoom / pinch / pan precision tuned.
- [ ] Incoming GLB materials detected (PBR vs Standard). Standard
      converted to PBR on load (§5b). Env BRDF texture attached to
      every PBR material.
- [ ] No hard grid by default. Soft shadow catcher under the model.
- [ ] Default camera fills ~70% of viewport with the model.
- [ ] Studio / Dark flat / Light flat bg presets in toolbar,
      persisted per-app.
- [ ] AI-drivability: every toolbar button + every keyboard chord
      (Shift+Alt+Click, every ViewCube face, every axis toggle, `A`
      and `Shift+A` shortcuts) reachable via `window.*` API,
      `postMessage`, or console-forwarded command.
- [ ] Screenshot the viewer with a black chip, a white chip, and a
      chrome chip side-by-side. All three read cleanly? If any
      disappears against the background, §3b is regressed — bump
      the top gradient color, do not work around it.

---

## 15. Why this skill exists — frozen in time

Came up 2026-04-24 while discussing viewer UX. The observation:
Claude makes many ad-hoc 3D viewers, each re-inventing the
background, lighting, camera setup, and — worst — whether to use
Babylon or Three. Without a shared rule, some ship with pure black
(dark parts invisible), some with brand-teal backgrounds (every
material tinted), most default to Three (fighting the canonical
viewer's Babylon ecosystem), almost none set `zoomToMouseLocation`,
and zero ship Fusion/Onshape-style orbit-center recentering. This
skill consolidates the "one right answer" — Babylon, start from the
canonical viewer, gradient bg, HDRI + hemispheric, bottom-light
toggle, zoom-to-mouse, Shift+Alt+Click recenter, ViewCube reuse,
soft camera limits, vertex snapping, PBR fixups, AI-drivability —
so every new viewer starts on-brand, engineer-friendly, and
consistent with the canonical viewer's DNA.

### Changelog

- **1.3.0 (2026-04-24)** — Second-pass gradient fix + absorbed
  upstream learnings from step2glb's preview viewer.
  - **Brightened both ends of the gradient.** Top `#3e4a5c` →
    `#5a6b7e`, bottom `#0d1117` → `#2a3340`. v1.1 had only
    brightened the top on the assumption that black chips sit in
    the upper half; field test with a TQFP64 showed the chip body
    lands in the *lower* half at default framing, where the
    near-black v1.1 bottom made it invisible again. Fix: bring
    both ends into the "dark theme but not near-black" band.
    Trade-off: we lose perfect `--bg` chrome-continuity; visibility
    wins. §3b now carries the non-negotiable acceptance test:
    "load a black TQFP64, orbit, verify visibility at every
    vertical position."
  - **Landed §6e "The DIY trap"** (from the step2glb thread) —
    direct Claude-to-Claude warning about re-implementing the
    canonical viewer from scratch, with real incident evidence.
  - **Landed §8f + §8g (from the step2glb thread)** — the
    Adom/CNC coordinate convention (operator at -Y facing +Y) and
    the canonical `alpha`/`beta` view-preset table, with the
    sign-reversal gotcha (`cam.position.y = target.y - r*sin(β)*sin(α)`
    — the MINUS sign puts the Back view at -Math.PI/2, not the
    obvious +Math.PI/2). Plus the "obvious iso" warning
    (Blender/Maya's default `-π/4` puts the camera behind the
    operator — wrong for CNC).
- **1.2.0 (2026-04-24)** — Added §2e–§2i **5-tier layering decision
  matrix**. "Extend, don't fork" was the rule since v1.0 but had no
  guidance on *how* to extend in practice — Claudes consistently
  asked "what does layering look like tier-by-tier?" The new section
  covers Tier 1 pure embed (default, 90%+ of apps), Tier 2 MCP call,
  Tier 3 Basic3dView import (canvas in your own DOM), Tier 4
  extension hooks upstream, Tier 5 roll-your-own (last resort, still
  Babylon + still inheriting helpers). Plus a three-question
  decision flow, signal lists for "don't roll your own" vs
  "legitimate reasons to roll your own," and the one-liner: "Embed
  if you can, import if you must, roll if you're sure."
- **1.1.0 (2026-04-24)** — Field feedback from the first shipped
  viewer using this skill forced two fixes and two additions:
  - **Brighter background top** (`#3e4a5c` instead of `#21262d`) —
    black IC bodies were still invisible against the old near-black
    top. §3b.
  - **Explicit PBR-default rule** (§5a) — the prior version implied
    PBR but didn't call it out as the mandatory default material
    class. Shipped viewers were creating StandardMaterial by habit,
    breaking HDRI lighting on chrome / gold / anodized parts.
  - **Axis helpers (§8)** — entire new section. World-origin helper
    default-on, mesh-local helpers toggleable, screen-space corner
    triad always on. Rationale: origins are where every Adom 3D
    integration bug hides (chip vs footprint, Y-up vs Z-up, centroid
    vs pad-1), and the user + AI can only debug misalignment if
    they can see where each origin actually lives.
  - **Pivot sphere during every drag-rotate (§6c)** — the teal
    sphere now appears the instant the user starts left-dragging,
    not just after a Shift+Alt+Click recenter. Trains the user to
    associate the teal sphere with "rotation center," which makes
    Shift+Alt+Click intuitively discoverable instead of a hidden
    gesture nobody finds.
- **1.0.0 (2026-04-24)** — Initial publish.

When rules here are wrong — they will be, eventually — update this
file AND land the fix upstream in `gallia/viewer/`. Every 3D viewer
reads from here.
