app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
← Commit history
Merge PR #215: Add <adom-3d>: a click-to-load 3D view of any GLB
2 files changed
+125
README.md+1@@ -24,6 +24,7 @@ adom-wiki pkg install adom/component-widgets | `<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
widgets/model-3d-widget.jsadded+124@@ -0,0 +1,124 @@+/*!+ * <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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);+})();