← Commit history

add wiki-readme-3d: interactive 3D viewers inside readme.html despite the sandbox CSP (Adom Babylon bundle extension, File loads, repo-PNG image channel, texture shim, render loop/lights/camera) - proven on adom/bq25792-charger

John Lauer ·89df901074 ·3mo ago ·parent 0b45a38
3 files changed +152−1
SKILL.md+2
@@ -92,6 +92,7 @@ important enough to state up front: | **wiki-publish-safely** | The operational pitfalls as hard rules; the verify-in-pup gate. | | **wiki-readme** | How to make a great README — `README.md` vs `readme.html`, sections, embeds, images. | | **wiki-readme-html** | Build a custom `readme.html` — sandbox limits, shadowing, self-containment, theming, verify loop. |+| **wiki-readme-3d** | Put a WORKING interactive 3D viewer in a readme.html despite the sandbox CSP — extend the Adom Babylon bundle, File-object GLB loads, the repo-PNG image channel for full-quality models, texture shim, render loop/lights/camera. | | **wiki-visibility** | Public vs private source, public releases, the private readme, open vs closed pages. | | **wiki-repo-styles** | The archetype taxonomy (Page / Skillpack / Family) and how to choose. | | **wiki-component** | Publish a component page — chip / molecule / board; the tabbed chip CAD-hub README. |@@ -110,6 +111,7 @@ important enough to state up front: - "Make a hero for this page" / "the hero disappeared" → wiki-hero - "Write a README for this page" → wiki-readme - "I want a custom HTML readme / tabs / designed layout" → wiki-readme-html+- "I want interactive 3D / a model viewer / a GLB toggle in the readme" → wiki-readme-3d - "Keep the source private but let people install it" → wiki-visibility - "Bundle these skills into one package" → wiki-skillpack - "Add a child plugin to adom-usb" → wiki-family
package.json+2−1
@@ -1,7 +1,7 @@ {   "slug": "adom-wiki-skillpack",   "title": "Adom Wiki Skill Pack",-  "version": "0.7.4",+  "version": "0.7.5",   "type": "skill",   "hero": {     "path": "screenshots/hero.png"@@ -59,6 +59,7 @@     "skills/wiki-publish-safely/SKILL.md",     "skills/wiki-readme/SKILL.md",     "skills/wiki-readme-html/SKILL.md",+    "skills/wiki-readme-3d/SKILL.md",     "skills/wiki-visibility/SKILL.md",     "skills/wiki-repo-styles/SKILL.md",     "skills/wiki-component/SKILL.md",
skills/wiki-readme-3d/SKILL.mdadded+148
@@ -0,0 +1,148 @@+---+name: wiki-readme-3d+description: >+  Put a WORKING interactive 3D viewer (or any data-hungry JS app) inside an Adom Wiki readme.html,+  despite the readme sandbox CSP that blocks ALL fetch (connect-src none), all wasm, and blob:+  images. Covers the complete proven recipe: extend the wiki's own Adom Babylon viewer bundle (never+  roll your own), load GLBs with zero network via File objects, smuggle full-quality models through+  the img-src channel (GLB bytes packed into lossless repo PNGs read back via CORS canvas), the+  blob-to-data-URI texture shim, and the five things the bundle only does inside its own loadModel+  (render loop, lights, camera framing, clip planes, scene clearing). Every rule here failed live+  first on adom/bq25792-charger (2026-07-15). Trigger words: 3d in readme, readme viewer, readme.html+  3d, babylon in readme, model toggle wiki page, readme csp, connect-src none, readme too large,+  interactive readme, glb in readme, exploded view toggle, custom wiki 3d viewer.+---++# Interactive 3D inside a wiki readme.html (the proven recipe)++Built and verified live on `adom/bq25792-charger` (exploded/assembled stackup toggle). The readme+runs in `<iframe sandbox="allow-scripts allow-downloads">` with CSP:+`default-src 'none'; script-src 'unsafe-inline' https:; style-src 'unsafe-inline' https:;+img-src data: https:; connect-src 'none'; ...` - so: scripts and styles load from https, images+load from https/data, and **nothing else moves: no fetch/XHR (not even data: URIs), no wasm+('wasm-unsafe-eval' absent), no blob: images, no workers.** Plus the `/readme` endpoint refuses+files over **~4-5 MB** ("README too large to render"). Everything below exists to win inside that+box.++## Rule 0: extend the wiki's own viewer - never roll your own++The bundle the wiki itself uses is at+`/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js` (+ `style.css`), loadable+from the readme (script-src https:). It sets `window.Adom3DViewerBabylon9` and `window.BABYLON`.+Wiring, copied from the wiki's own `/viewer/3d/...` page:++```js+const viewer = Adom3DViewerBabylon9.init(el, { zUp:true, showViewCube:true, showGround:true });+// public API: loadModel(url), clearScene(), frameModel(), getScene(), getEngine(), getCamera(), toggleAxes()+```++`viewer.loadModel(url)` fetches -> dead under connect-src 'none'. So append into ITS scene yourself:++```js+const f = new File([bytes], "model.glb", { type: "model/gltf-binary" });+await BABYLON.SceneLoader.AppendAsync("", f, viewer.getScene(), null, ".glb");+```++A **File** rides FileReader (not fetch) - CSP-exempt. The explicit `".glb"` plugin hint is required+(data:/blob: URL strings all fail; the wrapper also chokes on non-strings - `lastIndexOf is not a+function`).++## Getting the model BYTES with no network: the PNG image channel++Embedding base64 in the HTML works but hits the ~4-5 MB readme cap fast. The loophole that removes+the cap AND keeps full quality: **img-src allows https:, `/blob/` serves PNG inline with+`access-control-allow-origin: *`, and a CORS-clean canvas is readable.** So pack the GLB bytes into+a lossless PNG in the page repo and decode in the readme:++- Pack (server side): 4-byte LE length header + raw GLB bytes into **RGB channels only** (alpha+  stays 255 - canvas 2D premultiplies alpha, so bytes stored under alpha<255 corrupt). Width 2048,+  height as needed, PIL `Image.frombytes('RGB', ...)`, save PNG (PNG is lossless).+- Decode (readme): `new Image()` with `crossOrigin="anonymous"`, draw to canvas, `getImageData`,+  take bytes 0-2 of every pixel, read the length header, slice. Then the File/AppendAsync load above.++Result on the BQ page: readme.html fell from 1.89 MB (embedded, quality-crushed) to **13 KB**, with+**zero-simplification** models served from the repo.++## Models must be QUANTIZED, never draco (and never gpu-instanced)++- **Draco cannot decode in the sandbox** (wasm blocked, workers blocked, eval blocked). Use+  `gltf-transform optimize --compress quantize` - `KHR_mesh_quantization` is core Babylon/three+  support, zero decoder, and faster first paint than draco anywhere.+- Measured (BQ board, from ~20 MB raw): draco 956 KB | quantize-only (full quality) 4.36 MB |+  quantize+simplify 0.1% 2.4 MB | quantize+simplify 1% 845 KB. **1% simplification is visibly bad+  on PCB detail (John rejected it)** - with the PNG channel there is no size pressure, so ship+  quantize-only full quality.+- **`--instance false` is mandatory**: the optimizer's `EXT_mesh_gpu_instancing` renders as ONE+  giant mis-scaled instance in this Babylon build (a single machine pin filled the screen).++## Textures: the blob-to-data-URI shim++Babylon hands glTF textures to `<img>` via `URL.createObjectURL(blob)` -> blocked (img-src has no+blob:). Even textures stored as data: URIs inside the glTF get blob-ized. Shim once, before loading:++```js+const orig = URL.createObjectURL.bind(URL);+URL.createObjectURL = (b) => (b && b.__dataUri) || orig(b);+const OrigBlob = window.Blob;+window.Blob = function(parts, opts){+  const bl = new OrigBlob(parts, opts);+  try {+    if (parts && parts.length===1 && (parts[0] instanceof Uint8Array || parts[0] instanceof ArrayBuffer)) {+      const u8 = parts[0] instanceof Uint8Array ? parts[0] : new Uint8Array(parts[0]);+      if (u8.length < 3000000) {+        let s=""; for (let i=0;i<u8.length;i+=0x8000) s+=String.fromCharCode.apply(null,u8.subarray(i,i+0x8000));+        bl.__dataUri = "data:"+((opts&&opts.type)||"application/octet-stream")+";base64,"+btoa(s);+      }+    }+  } catch(e){}+  return bl;+};+window.Blob.prototype = OrigBlob.prototype;+```++img-src data: is allowed, so the stamped data: URI renders. (Also convert the GLB's bufferView+images to data: URIs in its glTF JSON - belt and suspenders.)++## The five things the bundle only does inside its own loadModel (do them yourself)++Bypassing `loadModel` means the viewer is mounted but inert. After every append:++1. **Start the render loop** (else the canvas is pure black at "60fps"):+   `eng.stopRenderLoop(); eng.runRenderLoop(()=>scene.render());`+2. **Add lights** (PBR renders as a black silhouette with zero lights and no environment):+   hemispheric + key + fill `DirectionalLight`s is enough.+3. **Frame the camera on `scene.activeCamera`** - `viewer.getCamera()` can be a DIFFERENT object;+   set target/radius on `scene.activeCamera`, inside a `setTimeout(...,200)` so nothing internally+   reframes over you. Use `scene.getWorldExtends()` (full scene - quantized models carry their scale+   in node matrices, per-mesh bounds lie).+4. **Scale the clip planes**: default `minZ` 0.1 CLIPS a small board entirely. Set+   `minZ = diag*0.005`, `maxZ = diag*200`, `wheelPrecision = 3/diag`.+5. **Track and dispose your own appends on toggle** - `viewer.clearScene()` does not know about+   externally appended meshes (they accumulate): snapshot `scene.meshes.length` before append, keep+   the slice, `mesh.dispose(false,true)` each before the next load.++## Verification (headless puppeteer, since pup/CDP screenshots of nested WebGL lie)++- `page.evaluate`: read your status element + `EngineStore.Instances[0].scenes[0].meshes.length`.+- Pixel truth: `readPixels` INSIDE `scene.onAfterRenderObservable` (outside the frame, non-+  preserveDrawingBuffer canvases read black). If `onAfterRender` never fires, the render loop+  isn't running - see item 1.+- Note a nonblack-pixel test can be fooled by the clear color - verify the MODEL visually via an+  OS-level window capture (`desktop_screenshot_window`) on a real desktop.++## Dead ends (do not retry)++- `viewer.loadModel("/blob/...")`, `fetch()`, XHR, `fetch("data:...")` - connect-src 'none' blocks all.+- data:/blob: URL strings into loadModel or AppendAsync - loader fetches them; blocked.+- Draco/meshopt GLBs - wasm + workers + eval all blocked.+- Nesting the wiki's `/viewer/3d/...` page in an iframe (frame-src https: allows it, and the scene+  runs at 60fps) - but the nested canvas composites BLACK on screen. Unresolved; avoid.+- Embedding >4-5 MB in readme.html - "README too large to render".+- Serving JS/HTML from `/blob` - `application/octet-stream` + `content-disposition: attachment` ++  nosniff; scripts will not execute from there.++## Platform asks that obsolete parts of this skill (file/track on adom/wiki)++`connect-src 'self'` on `/readme` would replace the PNG channel with a plain fetch; `img-src blob:`+would remove the texture shim; correct content-types on `/blob` (html/js/svg/csv) would allow real+runtime assets. Until then, this recipe is the way.