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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
/*!
* <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 (adom/wiki#1292): the wiki puts every widget in a click-to-load slot, so
* this frame only exists once the reader asked for it, and the model then loads straight away (one click, not
* two). Unload, scrolling it well offscreen or leaving the page disposes the
* viewer and releases the WebGL context; Load 3D model brings it back. 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:.
*
* Inline in a wiki README (registry "render": "inline") the document is the wiki page itself, so none of the
* frame's shims run: the widget embeds the wiki's own same-origin 3D viewer route instead
* (/viewer/3d/OWNER/TYPE/SLUG?model=PATH), which carries the reader's session and needs no page CSP change.
* Inline, src must be a file of a wiki page: /OWNER/SLUG/render/PATH.glb.
*/
(() => {
const VIEWER = '/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js';
// the wiki's sandboxed widget frame (/OWNER/SLUG/widget/NAME); anywhere else this script runs inline in a page
const FRAME = /^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname);
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' });
// in a wiki frame: no page margin, and nothing the viewer adds (its tip bubble) may give the frame a scrollbar
if (FRAME) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }
// the viewer shows the same Shift+Alt hint twice on first load (its pivot chip and its help tip; in a frame the
// "seen" flag cannot persist, so both return every time): keep the chip only as the live "keys held" indicator
if (FRAME && !document.getElementById('adom-3d-hint')) { const st = document.createElement('style'); st.id = 'adom-3d-hint';
st.textContent = '.viewer-container:not(.pivot-mode) .pivot-chip { display: none !important; }'; document.head.appendChild(st); }
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);
if (this._src) this._load(); // the wiki slot already was the reader's click
}
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; }
.stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }
</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;
if (!FRAME) return this._loadInline();
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; }
}
// Inline: the wiki's same-origin viewer route renders the model (its page type comes from the page API).
async _loadInline() {
try {
const u = new URL(this._src, location.href);
const m = u.origin === location.origin && decodeURIComponent(u.pathname).match(/^\/([^/]+)\/([^/]+)\/render\/(.+\.glb)$/i);
if (!m) throw new Error('src must be a .glb on a wiki page: /OWNER/SLUG/render/PATH.glb');
const res = await fetch(`/api/pages/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('could not find the page ' + m[1] + '/' + m[2] + ' (' + res.status + ')');
const type = ((await res.json()).page || {}).type;
if (!type) throw new Error('the page ' + m[1] + '/' + m[2] + ' has no type');
// the viewer route's ?model= check addresses the repo by the internal type name (hardware pages are component repos)
const view = `/viewer/3d/${encodeURIComponent(m[1])}/${encodeURIComponent(type === 'hardware' ? 'component' : type)}/${encodeURIComponent(m[2])}?model=${encodeURIComponent(m[3])}`;
this._frame(`<iframe src="${esc(view)}" title="${esc(this.getAttribute('heading') || '3D model')}" allow="fullscreen"></iframe>`,
'<button id="off" type="button">Unload</button>');
this._viewer = { destroy() {} };
this.shadowRoot.getElementById('off').addEventListener('click', () => this._unload());
this.shadowRoot.querySelector('iframe').addEventListener('load', () =>
this.dispatchEvent(new CustomEvent('modelready', { detail: { view }, bubbles: true, composed: true })), { once: 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);
})();