← All Pull Requests

Add <adom-3d>: a click-to-load 3D view of any GLB #2

Merged opened by John Lauer 2026-10-11

Adds <adom-3d>: any GLB on a wiki page, shown in the canonical Adom 3D Viewer.

::adom-3d{src="/OWNER/SLUG/render/board.glb" poster="/OWNER/SLUG/render/board.jpg" heading="The finished board"}

Why: a README.md strips <iframe>, and none of the registered widgets can show an arbitrary model, so a board page (or any non-component page) had no way to put a live 3D view in its README. John asked for one for the Fusion 360 build of the buck (john/buck-12v5v-molecule, fusion-360/).

How it behaves:

  • Click to load (adom/wiki#1292): nothing heavy on the page path. No viewer bundle, no WebGL context and no GLB until the reader presses Load 3D model. Before that it shows the poster.
  • Disposal: scrolling well offscreen, Unload, or leaving the page calls viewer.destroy() and removes the canvas.
  • Sandbox: the frame is an opaque origin, so storage is shimmed. The model is fetched as bytes and passed to loadModelBytes. The glTF loader still hands embedded textures out as blob: URLs, which the frame's img-src blocks, so the widget gives out data: URLs instead. That workaround probably belongs in the viewer itself: 0.9.1 still produced blob URLs for GLB textures under this CSP.
  • Draco: a Draco GLB cannot decode here, because the frame's connect-src does not reach Babylon's decoder CDN. The header comment says to publish without Draco.

Tested on the live render route under the wiki's CSP: load gives a ready report with ok: true, model loaded, no CSP violations. Unload brings the poster back with zero canvases, and a reload works.

The registry entry is a separate PR on adom/widgets.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/widgets/model-3d-widget.js+++ b/widgets/model-3d-widget.js@@ -0,0 +1,125 @@+/*!+ * <adom-3d> - an interactive 3D view of any GLB on a wiki page, in the canonical Adom 3D Viewer.+ *+ * Drop-in (wiki README):+ *   ::adom-3d{src="/OWNER/SLUG/render/board.glb" poster="/OWNER/SLUG/render/board.jpg" heading="The finished board"}+ *+ * - src: the GLB (absolute path on the wiki, usually the page's own render route). Publish it WITHOUT Draco:+ *   the widget frame's connect-src does not reach Babylon's decoder CDN, so a Draco GLB cannot decode here.+ * - poster: optional still shown until the reader asks for 3D (a render or screenshot of the same model).+ * - heading: optional title over the view; caption: optional line under it.+ *+ * Nothing heavy loads on the page path: no viewer bundle, no WebGL context and no GLB until the reader clicks+ * "Load 3D model" (adom/wiki#1292). Scrolling it well offscreen, unloading it or leaving the page disposes the+ * viewer and releases the WebGL context. The model is fetched as bytes and handed to loadModelBytes, and the+ * GLB's embedded textures are given out as data: URLs, because the frame's img-src forbids blob:.+ */+(() => {+  const VIEWER = 'https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js';+  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));++  // The glTF loader turns embedded images into blob: URLs, which this frame's CSP blocks. Remember each Blob's+  // bytes and hand out an equivalent data: URL instead. Installed once, only when a reader loads a model.+  let shimmed = false;+  function shimBlobUrls() {+    if (shimmed) return; shimmed = true;+    const NativeBlob = window.Blob;+    function TrackedBlob(parts, opts) { const b = new NativeBlob(parts || [], opts); try { b.__parts = parts; b.__type = (opts && opts.type) || ''; } catch (e) {} return b; }+    TrackedBlob.prototype = NativeBlob.prototype; Object.setPrototypeOf(TrackedBlob, NativeBlob); window.Blob = TrackedBlob;+    const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);+    URL.createObjectURL = o => {+      if (!o || !o.__parts) return create(o);+      let s = '';+      for (const p of o.__parts) {+        const u = typeof p === 'string' ? new TextEncoder().encode(p) : p instanceof ArrayBuffer ? new Uint8Array(p) : ArrayBuffer.isView(p) ? new Uint8Array(p.buffer, p.byteOffset, p.byteLength) : new Uint8Array(0);+        for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000));+      }+      return 'data:' + (o.__type || 'application/octet-stream') + ';base64,' + btoa(s);+    };+    URL.revokeObjectURL = u => { if (typeof u === 'string' && u.startsWith('blob:')) revoke(u); };+  }+  // the frame is an opaque origin: storage throws, and the viewer reads its preferences on init+  function shimStorage() {+    try { window.localStorage; } catch (e) {+      let m = {};+      Object.defineProperty(window, 'localStorage', { value: { getItem: k => (k in m ? m[k] : null), setItem: (k, v) => { m[k] = String(v); }, removeItem: k => { delete m[k]; }, clear: () => { m = {}; } } });+    }+    window.process = window.process || { env: { NODE_ENV: 'production' } };+    window.global = window.global || window;+  }++  class Adom3D extends HTMLElement {+    connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      this._src = this.getAttribute('src');+      this._poster = this.getAttribute('poster');+      this._idle();+      this._io = new IntersectionObserver(es => {+        for (const e of es) if (!e.isIntersecting && this._viewer && !this._loading) this._unload('offscreen');+      }, { rootMargin: '600px 0px' });+      this._io.observe(this);+    }+    disconnectedCallback() { this._io && this._io.disconnect(); this._unload('removed'); }++    _frame(stage, foot) {+      const h = this.getAttribute('heading'), c = this.getAttribute('caption');+      this.shadowRoot.innerHTML = `<style>+        :host { display: block; font: 13px/1.5 Satoshi, -apple-system, 'Segoe UI', sans-serif; color: #e6edf3; }+        .card { background: #0d1117; border: 1px solid #30363d; border-radius: 10px; overflow: hidden; }+        .h { padding: 10px 14px; font: 600 14px/1.3 'Familjen Grotesk', -apple-system, sans-serif; border-bottom: 1px solid #30363d; }+        .stage { position: relative; aspect-ratio: 16 / 9; min-height: 320px; background: linear-gradient(180deg, #1e2a3d 0%, #1e2a3d 20%, #0a121f 100%); }+        .stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }+        .over { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }+        button { font: 600 14px/1 inherit; padding: 11px 18px; border-radius: 8px; border: 1px solid #00b8b1; background: rgba(13, 17, 23, 0.82); color: #00b8b1; cursor: pointer; transition: background .12s ease, color .12s ease, transform .06s ease; }+        button:hover { background: #00b8b1; color: #0d1117; }+        button:active { transform: translateY(1px) scale(.98); }+        button:focus-visible { outline: 2px solid #00b8b1; outline-offset: 3px; }+        button:disabled { opacity: .7; cursor: progress; }+        .foot { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-top: 1px solid #30363d; color: #8b949e; font-size: 12px; }+        .foot .sp { flex: 1; }+        .foot button { font-size: 12px; padding: 5px 10px; }+        .err { color: #f85149; }+        ::slotted([slot=v]) { position: absolute; inset: 0; }+      </style>+      <div class="card">${h ? `<div class="h">${esc(h)}</div>` : ''}<div class="stage">${stage}</div>+      <div class="foot"><span>${esc(c || '')}</span><span class="sp"></span>${foot || ''}</div></div>`;+    }+    _idle(note) {+      this._frame(`${this._poster ? `<img src="${esc(new URL(this._poster, location.href).href)}" alt="${esc(this.getAttribute('heading') || '3D model')}" loading="lazy">` : ''}+        <div class="over"><button id="go" type="button">Load 3D model</button></div>`, note ? `<span class="err">${esc(note)}</span>` : '');+      const go = this.shadowRoot.getElementById('go');+      if (!this._src) { go.disabled = true; go.textContent = 'No src: point me at a GLB'; return; }+      go.addEventListener('click', () => this._load());+    }+    async _load() {+      const go = this.shadowRoot.getElementById('go'); go.disabled = true; go.textContent = 'Loading...';+      this._loading = true;+      try {+        shimStorage(); shimBlobUrls();+        const res = await fetch(new URL(this._src, location.href).href);+        if (!res.ok) throw new Error('could not fetch the model (' + res.status + ')');+        const bytes = new Uint8Array(await res.arrayBuffer());+        await import(VIEWER);+        this._frame('<slot name="v"></slot>', '<button id="off" type="button">Unload</button>');+        this._host = document.createElement('div'); this._host.slot = 'v'; this.appendChild(this._host);+        this._viewer = window.Adom3DViewerBabylon9.init(this._host, { zUp: true, showViewCube: true });+        await this._viewer.loadModelBytes(bytes, { extension: '.glb' });+        this._viewer.frameModel();+        this.shadowRoot.getElementById('off').addEventListener('click', () => this._unload());+        this.dispatchEvent(new CustomEvent('modelready', { detail: { bytes: bytes.length }, bubbles: true, composed: true }));+      } catch (e) {+        console.warn(e); this._unload(); this._idle(String(e && e.message || e).slice(0, 160));+      } finally { this._loading = false; }+    }+    _unload(why) {+      if (this._viewer) { try { this._viewer.destroy(); } catch (e) {} this._viewer = null; }+      if (this._host) { this._host.remove(); this._host = null; }+      if (why !== 'removed' && this.shadowRoot && !this.shadowRoot.getElementById('go')) this._idle();+    }+    // for AIs and tests: what the widget is showing+    selfCheck() { return { src: this._src, loaded: !!this._viewer, report: this._viewer && this._viewer.getReadyReport ? this._viewer.getReadyReport() : null }; }+  }+  if (!customElements.get('adom-3d')) customElements.define('adom-3d', Adom3D);+})();+--- a/README.md+++ b/README.md@@ -1,111 +1,113 @@⋯ 23 unchanged lines ⋯ | `<adom-verified>` | "Verified by" trust mark + verification log; amber when a verified file changes | `verification.json` (adom/verification@1), `tools/verify_log.py` | | `<adom-models>` | Every 3D file on the page: STEP / GLB tabs, top + underside previews, what is unique, freshness | `models.json` (adom/models@1), `tools/models_json.py` | | `<adom-states>` | A component's visible states (an LED off and lit): a toggle, a picture of each state, when it applies, its STEP and GLB | `states.json` (adom/component-states@1) |+| `<adom-3d>` | Any GLB in the canonical Adom 3D Viewer, click to load: a poster until the reader asks, then orbit, view cube and zoom; unloads offscreen and on Unload, releasing the WebGL context | a non-Draco `.glb` (`src`), optional `poster`, `heading`, `caption` | | `<adom-footprint-variants>` | The footprint's variant family (heatsink vias, solder balls, rounded pads) | `variants.json`, `tools/make-variants.py` |  Each is one custom element: Shadow DOM, no dependencies, light + dark (or⋯ 79 unchanged lines ⋯ chip renders here: the component page already has its full 3D viewer. Existing shaded thumbnail files can stay cached for library/card consumers without being displayed in this widget. Legacy image-only widget usage is unchanged.+

Comments

No comments yet.

Log in to comment.