skill / 3d-viewer-design
!

Not installable via adompkg

This skill has no published release. adompkg install kyle/3d-viewer-design will not work until a maintainer publishes a tarball with install.sh and uninstall.sh.

See the publishing docs for the package.json schema and tarball layout required to ship this skill.

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.

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 surfacewindow.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.

4. Lighting — HDRI + hemispheric is the production default

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

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:

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.

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:

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

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:

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.

    // 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°.

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.

9. Measurement / picking helpers

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

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

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

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.

13. Babylon snippet — full viewport defaults

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:

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

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.