{
  "schema_version": 1,
  "type": "skill",
  "slug": "3d-viewer-design",
  "title": "3D Viewer Design",
  "brief": "Design rules for any Adom 3D viewport — always Babylon, 5-tier layering on the canonical viewer, v1.3 brightened gradient both ends (#5a6b7e → #2a3340, field-proven to keep black chips visible everywh",
  "version": "1.3.0",
  "tags": [],
  "license": "MIT",
  "discovery_triggers": [
    "3d viewer",
    "3d viewport",
    "babylon scene",
    "scene background",
    "clearColor",
    "clear color",
    "ArcRotateCamera",
    "zoom to mouse",
    "zoom to cursor",
    "orbit center",
    "recenter rotation",
    "shift alt click",
    "fusion 360 orbit",
    "onshape orbit",
    "viewcube",
    "view cube",
    "view presets",
    "view preset angles",
    "HDRI",
    "environmentSpecular",
    "bottom light toggle",
    "under chip",
    "PBR material",
    "babylon vs three",
    "three.js vs babylon",
    "adom 3d viewer",
    "adom babylon viewer",
    "canonical viewer",
    "GLB viewer",
    "STEP viewer",
    "component viewer",
    "CAD background",
    "3d bg color",
    "measurement marker",
    "axis helper",
    "axes helper",
    "origin axes",
    "show origin",
    "world origin",
    "mesh origin",
    "coordinate system",
    "xyz gizmo",
    "chip origin",
    "footprint origin",
    "y-up z-up",
    "pivot sphere",
    "rotation center visualization",
    "black chip invisible",
    "PBR default",
    "PBRMaterial",
    "standard material vs pbr",
    "extend vs roll your own",
    "layer on canonical viewer",
    "embed 3d viewer",
    "basic3dview import",
    "viewer tier",
    "which 3d viewer tier",
    "fork vs extend viewer",
    "plugin hook 3d viewer",
    "roll your own 3d viewer",
    "cnc coordinate convention",
    "alpha beta view preset",
    "iso view angle",
    "top view alpha beta",
    "operator facing +y",
    "DIY trap 3d viewer",
    "step2glb preview viewer",
    "brightened gradient v1.3"
  ],
  "discovery_pitch": "Design rules for any 3D viewport in Adom — always Babylon (never Three.js), 5-tier layering on the canonical Adom viewer, v1.3 brightened gradient both ends (#5a6b7e top → #2a3340 bottom — field-proven to keep black chips visible at every vertical position), PBR-default materials, HDRI + hemispheric lighting, zoom-to-mouse, Shift+Alt+Click orbit recentering with a teal pivot sphere visible during every drag-rotate, ViewCube, always-on world-origin axis helpers, and the Adom/CNC coordinate + view-preset convention.",
  "sample_prompts": [
    {
      "label": "Background",
      "prompt": "What's the best background color for a 3D viewer in Adom?"
    },
    {
      "label": "Babylon vs Three",
      "prompt": "Should I use Babylon or Three.js for this 3D viewer?"
    },
    {
      "label": "Which tier",
      "prompt": "I need a 3D viewer for my new app — embed, import Basic3dView, or roll my own?"
    },
    {
      "label": "Zoom to mouse",
      "prompt": "Set up zoom-to-mouse on my Babylon ArcRotateCamera"
    },
    {
      "label": "Orbit recenter",
      "prompt": "Add Fusion 360 / Onshape style orbit-center recentering (Shift+Alt+Click) to my 3D viewer"
    },
    {
      "label": "Axis helpers",
      "prompt": "Add always-on world-origin axis helpers and toggleable mesh-local axes to my 3D viewer"
    },
    {
      "label": "View presets",
      "prompt": "Set up the canonical Adom/CNC view-preset angles (Top/Front/Iso) on my ArcRotateCamera"
    },
    {
      "label": "PCB bottom light",
      "prompt": "Add a bottom-light toggle so I can see under chips on my PCB viewer"
    }
  ],
  "source_path": "SKILL.md",
  "readme": "# 3D Viewer Design\n\nThe rules every 3D viewport across Adom should follow. Engineers look\nat CAD/EDA 3D all day, and every wrong default silently wrecks the\nexperience: pure-black viewports hide dark parts, pure-white viewports\nblow out highlights, brand-colored backgrounds tint every material,\none-light rigs kill half the silhouette, default camera limits\ngimbal-lock the moment anyone tries to look under a chip, zoom-to-\norigin teleports users away from what they were inspecting, and a\nfixed orbit center makes every rotation on a large assembly feel like\nflying blind. Everything below is either (a) a rule Claude keeps\ngetting wrong from first principles or (b) a goodie the team already\nbaked into the canonical Adom Babylon viewer at `gallia/viewer/` that\ndeserves to be the default everywhere.\n\n\n## 2. Use the canonical Adom Babylon viewer as the baseline — extend, don't fork\n\n**The rule:** every new Adom app that needs 3D starts from the\ncanonical Adom Babylon viewer at `gallia/viewer/`. Not from a blank\nBabylon `new Scene()`. Not from a Babylon playground snippet. Not\nfrom a fresh `create-babylon-app`. **From the canonical viewer.**\n\nThe canonical viewer already solves — or is the right place to solve —\nevery problem in this skill. If you build a new viewer from scratch\nfor your new app, you are either (a) re-implementing what already\nexists and drifting, or (b) solving it wrong and shipping regressions\nthe canonical viewer would have caught.\n\n### 2a. What the canonical viewer gives you for free\n\nOut of the box, `gallia/viewer/` ships with:\n\n- **Babylon engine + `environmentSpecular.env` HDRI** already bundled\n  (`gallia/viewer/babylon-bundle.min.js`, `viewer/js/environmentSpecular.env`).\n- **ArcRotateCamera with soft limits** (§6b) and behavior strip\n  (`3d.html:293`).\n- **ViewCube / view presets** — front / back / left / right / top /\n  bottom / isometric buttons wired to the correct Z-up\n  `alpha`/`beta` angles (see §7). Click a face, camera tweens.\n- **World-origin axis helper** — R/G/B X/Y/Z gizmo at (0,0,0) visible\n  by default so the user can always see which way is up and where\n  the scene's origin lives (see §8). Toggleable but default-on.\n- **Bottom-light toggle** for PCB under-chip inspection (§4b).\n- **GLB loader with Y-up → Z-up handling** (§11) and\n  `applyGlbZUpTransform(viewer, glbSource)` helper.\n- **Measurement tool** with vertex snapping + auto-scaled markers (§9).\n- **Cinematic camera tour** (auto-play on model load, 11-phase\n  choreographed reveal).\n- **AI-drivability surface** — `window.Adom3DViewer` global +\n  `postMessage` protocol + console forwarding (§10).\n- **Shadow ground mesh** (`shadowGround`) for soft contact shadows.\n- **MCP tool integration** — callable as `av_3d_display` /\n  `av_basic_3d_display` from any Claude agent.\n\nReuse path: embed the viewer in your app, or use the Basic3dView\nentry point (`gallia/viewer/3d-viewer-standalone.ts`) as your starter,\nor call the MCP tool to open the viewer in Hydrogen. Pick whichever\nmatches your app's surface — but start from this base.\n\n### 2b. The upstream-first workflow for new best practices\n\nWhen you discover a best practice the canonical viewer is missing —\nthe gradient background in §3 is the flagship current example, but\nso is zoom-to-mouse (§6a), orbit-center recentering (§6c), or\nanything else that lands in this skill — the workflow is:\n\n1. **Add it to the canonical viewer first.** Land a PR in\n   `gallia/viewer/` that adds the feature as a baseline default.\n   Include a toolbar toggle if it's opinionated.\n2. **Update this skill** to reflect that the canonical viewer now\n   supports it (move from \"known migration debt\" to \"you get this\n   for free\").\n3. **Delete workarounds** from downstream apps. If a downstream app\n   copy-pasted a hack to fix the missing behavior, remove it — the\n   app should inherit from the canonical viewer now.\n4. **Roll out via the Adom update pipeline.** All consumers get the\n   new default automatically on their next `adom update` or workspace\n   refresh. No per-app migration required.\n\n### 2c. Do NOT do any of these\n\n- **Do not fork the viewer into your app's repo.** Forks drift. A\n  feature added to the canonical viewer never makes it into your\n  fork, and a bug fixed in your fork never makes it back.\n- **Do not write a parallel mini-viewer** to \"just show a GLB\n  quickly.\" `Basic3dView` already exists for that use case\n  (`basic-3d-viewer` skill). Use it.\n- **Do not ship app-local workarounds for viewer bugs.** File them\n  against `gallia/viewer/` and fix them there.\n- **Do not re-implement ViewCube, measurement, HDRI loading, or\n  bottom-light-toggle per-app.** Inherit from the canonical viewer.\n\n### 2d. Current canonical-viewer debt — improve it, don't route around it\n\nThe canonical viewer predates some of the rules in this skill. As\nof 2026-04-24:\n\n- Clear color is flat near-black `Color3(0.05, 0.06, 0.08)`\n  (`fp-to-3d.js:~1642`) — should be the gradient in §3. **Fix\n  upstream per §2b.**\n- `cam.zoomToMouseLocation` is not explicitly set on all camera\n  construction paths — audit and set it on every ArcRotateCamera\n  (§6a).\n- Orbit-center recentering (§6c) doesn't exist yet — add as a new\n  feature to the canonical viewer, not as a per-app hack.\n- Mesh-local axis helpers (§8b) and the screen-space corner triad\n  (§8c) don't exist yet — audit and add upstream. The world-origin\n  helper (§8a) may or may not be present; verify and make it\n  default-on if missing.\n\nThese are the next PRs for `gallia/viewer/`. None of them is more\nthan a ~20–40-line change.\n\n### 2e. How to extend — the 5-tier layering decision matrix\n\n\"Extend, don't fork\" (§2c) is the rule. **Layering** is the\nimplementation. There are five tiers, ordered from most-embedded\n(preferred) to least-embedded (rare, last resort). Pick the highest\ntier that works for your app.\n\n#### Tier 1 — Pure embed in a Hydrogen webview or AV panel *(90%+ of apps)*\n\nYou write zero viewer code. Your app opens the canonical viewer in a\nHydrogen tab (or the AV panel), passes a GLB path + a few config\nknobs, and drives it via `postMessage`. Every future improvement to\nthe canonical viewer (brightened gradient, axis helpers, new\ntoolbar buttons) shows up in your app automatically on the next\n`adom update`. **Default to this tier.** Apps like shotlog,\nparts-search, library-review, chipfit, 3d-component-creator, and\nadom-tsci all belong here. If you're tempted to leave this tier,\nprove it to yourself first by listing exactly what the canonical\nviewer can't do and asking whether adding it upstream (§2b) would\nserve everyone.\n\n#### Tier 2 — MCP tool call *(simplest path)*\n\n`av_3d_display({ glb_path, title, ... })` or\n`av_basic_3d_display({ glb_path, title })` from the Claude side. No\nbrowser code in your app at all. Use this when your \"app\" is\nfundamentally an AI pipeline that ends in \"show this to the user\" —\na one-shot display, a generated preview, a datasheet render. Same\ninherited behavior as Tier 1.\n\n#### Tier 3 — Import `Basic3dView` into your own canvas host\n\n`gallia/viewer/3d-viewer-standalone.ts` exposes\n`window.Adom3DViewer.init(canvasEl, opts)`. You host the canvas in\nyour own DOM (for layout reasons — you want the viewer inside your\napp's workspace tab instead of a separate panel), but Babylon scene\nsetup, HDRI, camera defaults, GLB loader, Y-up→Z-up transform, axis\nhelpers, pivot sphere, and bottom-light toggle are all inherited.\n**This is the sweet spot when you need a 3D tab inside your app's\nworkspace rather than a separate panel.**\n\n#### Tier 4 — Canonical viewer + extension hooks (plugin-style)\n\nWhen you need one or two custom pieces — an app-specific toolbar\nbutton, a custom `scene.onBeforeRender` observer, a scene-graph\nannotation your app owns, a net-highlight overlay for PCB routing —\nyou add the *extension points* upstream first (§2b workflow), then\nuse them from your app. The canonical viewer becomes an extensible\nhost, not a closed box. If it doesn't expose the hook you need,\n**land the hook upstream before your app ships.** Don't let missing\nhooks push you to Tier 5 unless there's genuinely no way to\ngeneralize them.\n\n#### Tier 5 — Roll your own, but still Babylon, still inherit helpers *(last resort)*\n\nVery rare. When justified, you still import `@babylonjs/core`, still\ncall `applyGlbZUpTransform`, `applyAdomViewportDefaults`,\n`applyAdomCameraDefaults`, the axis helper functions, the\npivot-sphere wiring, the CSS gradient. Every §1–§15 rule still\napplies. You're rolling your own *scene orchestration*, not your\nown rendering stack.\n\n### 2f. Three-question decision flow\n\n1. **Is your app's 3D \"view this model / scene data\"?** → **Tier 1\n   or 2 embed.** Stop here. If you're thinking \"but I need a custom\n   layout,\" re-ask #1 — most custom-layout needs are \"I want my own\n   panel chrome around a standard viewer,\" which is an embed with a\n   styled wrapper, not a custom viewer.\n2. **Does your app need the canvas inside its own DOM / workspace\n   tab?** → **Tier 3 (Basic3dView import).** You still inherit\n   everything; you just get to pick the canvas parent.\n3. **Do you need custom scene behavior the canonical viewer can't be\n   taught?** → **Tier 4.** Add the hook upstream, then use it. Only\n   jump to Tier 5 if you're certain the behavior doesn't generalize.\n\n### 2g. Signals you should NOT roll your own\n\n- *\"The canonical viewer doesn't have feature X\"* → add X upstream\n  (§2b). If it generalizes, everyone benefits; if it doesn't, it\n  probably shouldn't be in a shared viewer anyway.\n- *\"I need a custom toolbar.\"* → the canonical viewer's toolbar can\n  be extended. Add the extension point upstream if it isn't there.\n- *\"I need different default materials.\"* → set them via\n  `tweak_scene` postMessage (§10b) or on init. Not a rolling reason.\n- *\"I want my own layout around the canvas.\"* → Tier 3 import lets\n  you own the layout without forking the scene.\n- *\"Three.js has a library that does X.\"* → see §1. No.\n\n### 2h. Signals you might legitimately roll your own\n\n- **Render-to-texture pipelines** — ML training data generation,\n  simulation frames streaming, deterministic render farms. The\n  canonical viewer is a UI; you need a renderer-as-service.\n- **Hybrid 2D+3D canvases** with custom layout math where 3D is\n  incidental (e.g. a schematic tool where 3D is a hover preview).\n- **Realtime streaming / headless rendering** — WebRTC pipeline,\n  cloud-rendered output, server-side render.\n- **Fundamentally different rendering model** — ray tracing demo,\n  Monte Carlo renderer, shader-education tool, game engine.\n- **Your app's primary purpose IS 3D** in a way PCB/CAD viewing\n  isn't a use case (physics sim, generative art tool). Here the\n  canonical viewer is just the wrong primitive.\n\nEven in these cases, §1 (Babylon) and the §2a helper-function\nimports still apply. Don't abandon the pattern library — just the\nspecific entry point.\n\n### 2i. One-line summary\n\n**Embed if you can, import if you must, roll if you're sure — and\nnever leave the canonical viewer's pattern library behind,\nregardless of tier.**\n\n\n## 4. Lighting — HDRI + hemispheric is the production default\n\n### 4a. Primary rig: HDRI + hemispheric (what the canonical viewer uses)\n\n```js\nscene.environmentTexture = CubeTexture.CreateFromPrefilteredData(\n  '/js/environmentSpecular.env', scene);\nscene.environmentIntensity = 0.8;\n\nconst hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);\nhemi.intensity = 0.6;\nhemi.diffuse     = Color3.FromHexString('#e6edf3');\nhemi.groundColor = new Color3(0.2, 0.18, 0.15);\n```\n\nHDRI handles specular reflections + ambient bounce; hemispheric\nadds global fill so matte plastics don't crush to black. Reuse\nAdom's bundled neutral studio HDRI at\n`gallia/viewer/viewer/js/environmentSpecular.env`. Don't use outdoor\nor dramatic HDRIs — they fight the neutral gradient.\n\n### 4b. Bottom-light toggle — the PCB-inspection goodie\n\nThe canonical viewer ships a toolbar toggle that boosts the\nhemispheric's `groundColor` to warm (`3d.html:1112`), simulating\nbench-bounce illumination for under-chip inspection:\n\n```js\nfunction setBottomLight(enabled) {\n  if (enabled) {\n    hemi.groundColor = new Color3(0.7, 0.65, 0.55);\n    hemi.intensity   = Math.max(hemi.intensity, 1.5);\n  } else {\n    hemi.groundColor = new Color3(0.2, 0.18, 0.15);\n    hemi.intensity   = 0.6;\n  }\n}\n```\n\nEvery PCB viewer MUST have this toggle exposed. Every mechanical /\ncomponent viewer SHOULD. Costs nothing, solves the \"I can't see\nwhat's under the chip\" complaint permanently.\n\n### 4c. Fallback rig: three-light studio (only when HDRI unavailable)\n\n| Light | Direction (Z-up) | Color | Intensity |\n|-------|------------------|-------|-----------|\n| **Key** (top-front) | `(0.4, 0.5, 0.8)` norm | `#ffffff` | 1.0 |\n| **Fill** (opposite, below) | `(-0.5, -0.4, 0.2)` norm | `#e6edf3` | 0.4 |\n| **Rim** (back, teal-tinted) | `(0.0, -1.0, 0.3)` norm | `#00b8b0` | 0.25 |\n\nPlus ambient at `0.15`. **Never ship one directional light.**\n\n\n## 6. Camera — zoom-to-mouse, soft limits, and Fusion/Onshape-style orbit-center recentering\n\n### 6a. Zoom-to-mouse is non-negotiable\n\nEvery professional CAD/EDA tool (Fusion 360, SolidWorks, Onshape,\nKiCad, Altium) zooms toward the cursor, not toward the camera target.\nDefault Babylon ArcRotateCamera zooms toward the orbit target —\nwhich means every scroll-in teleports the user away from whatever\nthey were inspecting. Flip the switch:\n\n```js\ncam.zoomToMouseLocation = true; // Babylon >= 5.0, the magic line\n```\n\nOne-line change, single biggest usability win on the viewer.\n\n### 6b. Soft camera limits — strip behaviors first\n\n```js\nwhile (cam.behaviors && cam.behaviors.length > 0)\n  cam.removeBehavior(cam.behaviors[0]);\ncam.useFramingBehavior = false;      // framing fights manual camera state\ncam.zoomToMouseLocation = true;      // §6a\ncam.lowerRadiusLimit = 0.1;          // zoom to 0.1 mm detail\ncam.upperRadiusLimit = 200;          // zoom out to full assembly\ncam.lowerBetaLimit   = 0.01;         // near-top-down, no gimbal lock\ncam.upperBetaLimit   = Math.PI - 0.01; // rotate under-chip, no flip\ncam.wheelPrecision   = 50;\ncam.pinchPrecision   = 200;\ncam.panningSensibility = 1000;\ncam.minZ = 0.01;                     // never clip 0.1 — eats solder mask\ncam.maxZ = 1000;\n```\n\n### 6c. Orbit-center recentering — Shift+Alt+Click (the massive UX upgrade)\n\nDefault ArcRotateCamera orbits around a fixed `target`. On a small\nPCB that's fine. On a large assembly (full board, enclosure, multi-\nboard system) it's brutal: the user wants to inspect a connector in\nthe corner, rotates the camera, and the connector swings off-screen\nbecause the orbit center is in the middle of the board. They pan,\nlose orientation, pan more, give up.\n\nProfessional CAD tools solve this by letting the user **re-declare\nthe orbit center on demand**. Fusion 360: middle-click to set pivot.\nOnshape: Alt+Click to recenter. Adom uses **Shift+Alt+Click** —\ndistinct from any existing Babylon gesture, easy to chord, doesn't\ninterfere with normal left-drag orbit / right-drag pan / scroll\nzoom. After a recenter, the camera tweens its `target` to the new\npoint over ~200ms while keeping `alpha` / `beta` / `radius`\nidentical — the user's viewpoint is preserved, but subsequent\nrotations orbit around what they just clicked.\n\nThe feature is free on small boards (user never feels the need to\nrecenter) and transformative on large ones. Always ship it.\n\n**Implementation:**\n\n```js\nimport { Animation, CubicEase, EasingFunction, Vector3, Plane } from '@babylonjs/core';\n\nfunction attachOrbitCenterRecenter(scene, cam, canvas) {\n  canvas.addEventListener('pointerdown', (e) => {\n    // Shift+Alt+LeftClick only — leaves all other gestures untouched\n    if (e.button !== 0 || !e.shiftKey || !e.altKey) return;\n    e.preventDefault();\n\n    const pick = scene.pick(scene.pointerX, scene.pointerY);\n    let newTarget;\n\n    if (pick.hit && pick.pickedPoint) {\n      // Common case: cursor is over a mesh — recenter on the surface point.\n      newTarget = pick.pickedPoint.clone();\n    } else {\n      // Fallback: cursor is over empty space. Project the pick ray onto a\n      // sensible fallback plane so the recenter still works.\n      //\n      // Priority order for the fallback plane:\n      //   1) Horizontal plane through the current target (Z = cam.target.z).\n      //      Keeps the user near where they were already looking.\n      //   2) Ground plane Z=0 (last resort).\n      const ray = scene.createPickingRay(scene.pointerX, scene.pointerY,\n                                          null, cam);\n      for (const planeZ of [cam.target.z, 0]) {\n        // Ray-plane intersection with plane Z = planeZ\n        const t = (planeZ - ray.origin.z) / ray.direction.z;\n        if (t > 0 && t < 10000) {\n          newTarget = ray.origin.add(ray.direction.scale(t));\n          break;\n        }\n      }\n      if (!newTarget) return; // ray is parallel to both planes — bail\n    }\n\n    // Tween cam.target over 200ms. alpha/beta/radius stay identical,\n    // so the user's viewpoint is preserved — only the pivot moves.\n    const ease = new CubicEase();\n    ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);\n    Animation.CreateAndStartAnimation(\n      'orbitRecenter', cam, 'target',\n      60,            // fps\n      12,            // 12 frames @ 60fps = 200ms\n      cam.target.clone(), newTarget,\n      Animation.ANIMATIONLOOPMODE_CONSTANT,\n      ease\n    );\n  });\n}\n```\n\n**UX polish bits that matter:**\n\n- **Show the teal pivot sphere during EVERY left-drag rotate, not\n  just after a recenter.** This is the single most important part\n  of making Shift+Alt+Click discoverable. The instant the user\n  starts left-dragging, a small teal sphere (`#00b8b0`, ~1% of\n  scene extent, semi-transparent α≈0.7) appears at `cam.target`.\n  Fades out ~400 ms after pointer-up. Over time — after the user\n  has seen the sphere appear-during-drag dozens of times — they\n  form an unshakeable mental link: **teal sphere = rotation center**.\n  Once that association is built, they intuitively understand what\n  Shift+Alt+Click does the first time they try it: \"oh, that chord\n  moves the teal sphere.\" Without this persistent visual training,\n  Shift+Alt+Click is a hidden power-user gesture nobody ever finds,\n  and users get frustrated (\"why does this thing keep swinging\n  off-screen when I rotate?\"). *Not showing the sphere during\n  drag-rotate is working against the user's training.*\n\n  ```js\n  // Pseudocode wiring — attach to the same canvas as §6c\n  let pivotSphere = null;\n  function showPivot() {\n    if (!pivotSphere) {\n      pivotSphere = MeshBuilder.CreateSphere('pivot',\n        { diameter: sceneExtent * 0.01 }, scene);\n      const m = new StandardMaterial('pivotMat', scene);\n      m.emissiveColor = Color3.FromHexString('#00b8b0');\n      m.alpha = 0.7;\n      m.disableLighting = true;\n      pivotSphere.material = m;\n      pivotSphere.isPickable = false;\n      pivotSphere.renderingGroupId = 2; // draw on top\n    }\n    pivotSphere.position.copyFrom(cam.target);\n    pivotSphere.setEnabled(true);\n  }\n  function hidePivot(delayMs = 400) {\n    setTimeout(() => pivotSphere && pivotSphere.setEnabled(false), delayMs);\n  }\n  canvas.addEventListener('pointerdown', e => {\n    if (e.button === 0 && !e.shiftKey && !e.altKey) showPivot();\n  });\n  canvas.addEventListener('pointerup', () => hidePivot(400));\n  // Also update pivotSphere.position each frame while rotating so it\n  // tracks cam.target exactly (Babylon: scene.onBeforeRenderObservable).\n  ```\n\n- **Use the SAME teal sphere for the Shift+Alt+Click recenter\n  flash.** Don't introduce a different marker — it's the same\n  object, just lingering ~600 ms after the recenter tween completes\n  instead of the 400 ms fade used during drag. Consistent visual\n  language: one sphere, one meaning. The recenter gesture literally\n  looks like \"move the teal sphere to here,\" which is exactly the\n  mental model we want.\n- **Sphere size scales with scene extent.** `sceneExtent * 0.01`\n  keeps it readable without dominating — on a 30 mm PCB that's a\n  0.3 mm dot, on a 200 mm assembly that's a 2 mm dot.\n- **Keep the keybind discoverable.** Toolbar tooltip on the\n  orbit/rotate button: \"Left-drag to orbit (teal sphere shows the\n  rotation center). Shift+Alt+Click to move the rotation center to\n  what you clicked.\"\n- **Do not recenter on empty space without a fallback plane.** If\n  the ray doesn't hit anything AND the fallback planes don't\n  intersect (ray parallel to Z), just silently do nothing — do not\n  teleport the target to `Vector3.Zero()` or some default, that's\n  more disorienting than not recentering.\n- **Keep normal orbit untouched.** Left-drag without the chord still\n  orbits around the current (possibly recentered) target. Users\n  learn the chord; they don't have to use it.\n\nShip this in the canonical viewer first (§2b), delete it from any\ndownstream workaround.\n\n### 6d. Defaults\n\n- **Initial view**: isometric (`alpha: π/4, beta: π/3` on Z-up) — see\n  §8f for why this specific sign on alpha (operator-side octant).\n- **Initial radius**: bounding sphere × 1.3. Model fills ~70% of\n  viewport.\n- **FOV**: 35–40°.\n\n\n## 7. ViewCube / view presets\n\nThe canonical viewer ships a ViewCube-style panel: clickable faces\nfor front / back / left / right / top / bottom, plus a dedicated\nisometric button. Clicking a face tweens the camera to the preset\n`alpha` / `beta` over ~300 ms with a cubic ease. See\n`gallia/viewer/viewer/3d.html` for the reference implementation and\nthe `basic-3d-viewer` skill for the full Z-up preset angle table.\n\nRules for ViewCube in any new Adom viewer:\n\n- **Reuse the canonical viewer's ViewCube.** Don't redraw one in your\n  app. Embed the viewer, or if you must roll your own, use identical\n  preset angles so view memory transfers between apps.\n- **Always include \"isometric\"** as a separate button — users expect\n  it next to the six orthogonal faces, not buried in a menu.\n- **Tween, don't snap.** 300 ms cubic-ease tween between presets. A\n  hard snap disorients users who briefly lose track of which way is\n  up.\n- **Respect the current orbit center.** ViewCube sets `alpha`/`beta`\n  only — leave `cam.target` alone. The user's recentered pivot from\n  §6c should survive a view-preset click.\n- **Monochrome icon** for the ViewCube button, per the `brand` skill's\n  icon rule. Active face highlighted in teal `#00b8b0`.\n\n\n## 9. Measurement / picking helpers\n\n### 9a. Snap to vertex, not to ray-hit\n\n```js\nfunction snapToVertex(pickResult) {\n  const mesh = pickResult.pickedMesh;\n  let worldVerts = measureVertexCache.get(mesh.uniqueId);\n  if (!worldVerts) {\n    const local = mesh.getVerticesData(VertexBuffer.PositionKind);\n    const m = mesh.getWorldMatrix();\n    worldVerts = [];\n    for (let i = 0; i < local.length; i += 3) {\n      worldVerts.push(Vector3.TransformCoordinates(\n        new Vector3(local[i], local[i+1], local[i+2]), m));\n    }\n    measureVertexCache.set(mesh.uniqueId, worldVerts);\n  }\n  return worldVerts.reduce((best, v) =>\n    Vector3.DistanceSquared(v, pickResult.pickedPoint) <\n    Vector3.DistanceSquared(best, pickResult.pickedPoint) ? v : best);\n}\n```\n\n### 9b. Auto-scale markers to scene extent\n\n```js\nconst size = Math.max(Math.min(sceneExtent * 0.03, 0.001), 0.0003);\n// 3% of scene diagonal, clamped to 0.3–1 mm\n```\n\n\n## 11. Y-up → Z-up coordinate handling\n\nThe Adom ecosystem is Z-up. GLB spec is Y-up. The canonical viewer's\n`applyGlbZUpTransform(viewer, glbSource)` handles this on every\nloader path. See the `basic-3d-viewer` skill for the recipe.\n\n\n## 13. Babylon snippet — full viewport defaults\n\n```js\nimport { Scene, Color3, Color4, Vector3, CubeTexture,\n         HemisphericLight } from '@babylonjs/core';\n\nexport function applyAdomViewportDefaults(scene, engine) {\n  // Background — transparent clearColor, CSS gradient on canvas parent\n  scene.clearColor = new Color4(0x0d/255, 0x11/255, 0x17/255, 0);\n  engine.getRenderingCanvas().parentElement.style.background =\n    'linear-gradient(180deg, #5a6b7e 0%, #2a3340 80%)';\n\n  // Lighting — HDRI + hemispheric (§4a)\n  scene.environmentTexture = CubeTexture.CreateFromPrefilteredData(\n    '/js/environmentSpecular.env', scene);\n  scene.environmentIntensity = 0.8;\n\n  const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);\n  hemi.intensity = 0.6;\n  hemi.diffuse     = Color3.FromHexString('#e6edf3');\n  hemi.groundColor = new Color3(0.2, 0.18, 0.15);\n\n  return { hemi }; // wire to setBottomLight (§4b)\n}\n\nexport function applyAdomCameraDefaults(cam, scene, canvas) {\n  // Strip behaviors first (§6b)\n  while (cam.behaviors && cam.behaviors.length > 0)\n    cam.removeBehavior(cam.behaviors[0]);\n  cam.useFramingBehavior = false;\n\n  cam.zoomToMouseLocation = true;          // §6a\n  cam.lowerRadiusLimit = 0.1;\n  cam.upperRadiusLimit = 200;\n  cam.lowerBetaLimit   = 0.01;\n  cam.upperBetaLimit   = Math.PI - 0.01;\n  cam.wheelPrecision   = 50;\n  cam.pinchPrecision   = 200;\n  cam.panningSensibility = 1000;\n  cam.minZ = 0.01;\n  cam.maxZ = 1000;\n\n  attachOrbitCenterRecenter(scene, cam, canvas); // §6c — Shift+Alt+Click\n}\n```\n\nCSS fallback for the gradient:\n\n```css\n.viewer-3d-canvas-wrapper {\n  background: linear-gradient(180deg, #5a6b7e 0%, #2a3340 80%);\n}\n.viewer-3d-canvas-wrapper canvas { background: transparent; }\n```\n\n\n## 15. Why this skill exists — frozen in time\n\nCame up 2026-04-24 while discussing viewer UX. The observation:\nClaude makes many ad-hoc 3D viewers, each re-inventing the\nbackground, lighting, camera setup, and — worst — whether to use\nBabylon or Three. Without a shared rule, some ship with pure black\n(dark parts invisible), some with brand-teal backgrounds (every\nmaterial tinted), most default to Three (fighting the canonical\nviewer's Babylon ecosystem), almost none set `zoomToMouseLocation`,\nand zero ship Fusion/Onshape-style orbit-center recentering. This\nskill consolidates the \"one right answer\" — Babylon, start from the\ncanonical viewer, gradient bg, HDRI + hemispheric, bottom-light\ntoggle, zoom-to-mouse, Shift+Alt+Click recenter, ViewCube reuse,\nsoft camera limits, vertex snapping, PBR fixups, AI-drivability —\nso every new viewer starts on-brand, engineer-friendly, and\nconsistent with the canonical viewer's DNA.\n\n### Changelog\n\n- **1.3.0 (2026-04-24)** — Second-pass gradient fix + absorbed\n  upstream learnings from step2glb's preview viewer.\n  - **Brightened both ends of the gradient.** Top `#3e4a5c` →\n    `#5a6b7e`, bottom `#0d1117` → `#2a3340`. v1.1 had only\n    brightened the top on the assumption that black chips sit in\n    the upper half; field test with a TQFP64 showed the chip body\n    lands in the *lower* half at default framing, where the\n    near-black v1.1 bottom made it invisible again. Fix: bring\n    both ends into the \"dark theme but not near-black\" band.\n    Trade-off: we lose perfect `--bg` chrome-continuity; visibility\n    wins. §3b now carries the non-negotiable acceptance test:\n    \"load a black TQFP64, orbit, verify visibility at every\n    vertical position.\"\n  - **Landed §6e \"The DIY trap\"** (from the step2glb thread) —\n    direct Claude-to-Claude warning about re-implementing the\n    canonical viewer from scratch, with real incident evidence.\n  - **Landed §8f + §8g (from the step2glb thread)** — the\n    Adom/CNC coordinate convention (operator at -Y facing +Y) and\n    the canonical `alpha`/`beta` view-preset table, with the\n    sign-reversal gotcha (`cam.position.y = target.y - r*sin(β)*sin(α)`\n    — the MINUS sign puts the Back view at -Math.PI/2, not the\n    obvious +Math.PI/2). Plus the \"obvious iso\" warning\n    (Blender/Maya's default `-π/4` puts the camera behind the\n    operator — wrong for CNC).\n- **1.2.0 (2026-04-24)** — Added §2e–§2i **5-tier layering decision\n  matrix**. \"Extend, don't fork\" was the rule since v1.0 but had no\n  guidance on *how* to extend in practice — Claudes consistently\n  asked \"what does layering look like tier-by-tier?\" The new section\n  covers Tier 1 pure embed (default, 90%+ of apps), Tier 2 MCP call,\n  Tier 3 Basic3dView import (canvas in your own DOM), Tier 4\n  extension hooks upstream, Tier 5 roll-your-own (last resort, still\n  Babylon + still inheriting helpers). Plus a three-question\n  decision flow, signal lists for \"don't roll your own\" vs\n  \"legitimate reasons to roll your own,\" and the one-liner: \"Embed\n  if you can, import if you must, roll if you're sure.\"\n- **1.1.0 (2026-04-24)** — Field feedback from the first shipped\n  viewer using this skill forced two fixes and two additions:\n  - **Brighter background top** (`#3e4a5c` instead of `#21262d`) —\n    black IC bodies were still invisible against the old near-black\n    top. §3b.\n  - **Explicit PBR-default rule** (§5a) — the prior version implied\n    PBR but didn't call it out as the mandatory default material\n    class. Shipped viewers were creating StandardMaterial by habit,\n    breaking HDRI lighting on chrome / gold / anodized parts.\n  - **Axis helpers (§8)** — entire new section. World-origin helper\n    default-on, mesh-local helpers toggleable, screen-space corner\n    triad always on. Rationale: origins are where every Adom 3D\n    integration bug hides (chip vs footprint, Y-up vs Z-up, centroid\n    vs pad-1), and the user + AI can only debug misalignment if\n    they can see where each origin actually lives.\n  - **Pivot sphere during every drag-rotate (§6c)** — the teal\n    sphere now appears the instant the user starts left-dragging,\n    not just after a Shift+Alt+Click recenter. Trains the user to\n    associate the teal sphere with \"rotation center,\" which makes\n    Shift+Alt+Click intuitively discoverable instead of a hidden\n    gesture nobody finds.\n- **1.0.0 (2026-04-24)** — Initial publish.\n\nWhen rules here are wrong — they will be, eventually — update this\nfile AND land the fix upstream in `gallia/viewer/`. Every 3D viewer\nreads from here.\n",
  "author": {
    "id": "695820315b5f1e4db2fcf602",
    "name": "Kyle Bergstedt",
    "email": "[email protected]"
  },
  "visibility": {
    "public": true
  },
  "hero": null,
  "metadata": {},
  "created_at": "2026-05-28T05:29:47.238Z",
  "updated_at": "2026-05-28T05:29:47.238Z",
  "sub_skills": [],
  "parent_app": null
}