feat: ✨ lazily-loaded Gerber viewer demo in the blog, served from R2
Embed KiCad's gerbview WASM in the graphics-to-WebGL blog post as a
click-to-load demo that opens the tiny_tapeout board entirely in-browser.
- GerberDemo.astro: lazy iframe embed + cross-origin-isolation reload guard
+ WebGL2/SharedArrayBuffer feature-detect with a poster fallback
- public/gerber-demo/{index.html,boot.js}: self-contained gerbview boot
harness (config seed, MEMFS board preload, argv auto-open)
- board/ tiny_tapeout layers + poster.png committed; wasm/ glue JS committed
(served same-origin — pthread worker can't be cross-origin), while
gerbview.wasm + kicad-resources.bin are git-ignored and served from
Cloudflare R2 (assets.pcbjam.com)
- COOP/COEP require-corp scoped to the post + /gerber-demo routes
(dev: astro.config.mjs + middleware.ts; prod: vercel.json)
- post converted to MDX (@astrojs/mdx) so it can embed the component
- scripts/: sync-demo-wasm.sh, r2-deploy.sh, r2-cors.json
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dca8e41390
commit
57f1f53d26
36 changed files with 242845 additions and 9 deletions
291
site/src/components/GerberDemo.astro
Normal file
291
site/src/components/GerberDemo.astro
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
---
|
||||
/**
|
||||
* Interactive, lazily-loaded KiCad Gerber viewer embedded in a blog post.
|
||||
*
|
||||
* Nothing heavy loads on page view: the ~22 MB (gzipped) WASM is fetched only
|
||||
* when the reader activates the viewer, which mounts an <iframe> pointing at the
|
||||
* self-contained boot page (public/gerber-demo/index.html).
|
||||
*
|
||||
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
|
||||
* and WebGL2. We feature-detect and degrade gracefully: capable browsers get the
|
||||
* live viewer; everything else gets a static poster + a short note. COOP/COEP are
|
||||
* applied to this route (dev: astro.config.mjs; prod: vercel.json); because
|
||||
* Astro's <ClientRouter/> can deliver this page via a soft SPA swap (which is
|
||||
* NOT cross-origin isolated), the client script does one guarded reload so the
|
||||
* route headers take effect.
|
||||
*/
|
||||
const poster = "/gerber-demo/poster.png"; // captured screenshot; optional fallback art
|
||||
const caption =
|
||||
"KiCad's Gerber viewer (gerbview), compiled to WebAssembly, running the tiny_tapeout demo board entirely in your browser.";
|
||||
---
|
||||
|
||||
<figure class="gerber-demo not-prose" data-state="idle">
|
||||
<div class="gerber-demo__frame">
|
||||
<!-- Poster / launch surface. The href is a real fallback: with JS off (or if
|
||||
we choose to), it opens the standalone demo page. JS intercepts it to
|
||||
embed inline. -->
|
||||
<a
|
||||
class="gerber-demo__launch"
|
||||
href="/gerber-demo/index.html"
|
||||
style={`background-image:url(${poster})`}
|
||||
>
|
||||
<span class="gerber-demo__play" aria-hidden="true">▶</span>
|
||||
<span class="gerber-demo__cta">Launch Gerber viewer</span>
|
||||
<span class="gerber-demo__sub">~22 MB · loads on demand · runs locally in your browser</span>
|
||||
</a>
|
||||
|
||||
<!-- Shown only when the browser can't run the live viewer. -->
|
||||
<div class="gerber-demo__fallback" hidden>
|
||||
<p class="gerber-demo__fallback-title">Live demo unavailable in this browser</p>
|
||||
<p class="gerber-demo__sub">
|
||||
The in-browser viewer needs a recent desktop <strong>Chrome</strong>,
|
||||
<strong>Firefox</strong>, or <strong>Safari 16+</strong> (it relies on
|
||||
SharedArrayBuffer + WebGL2). Here's a screenshot of what it renders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status line while the WASM downloads/boots. -->
|
||||
<div class="gerber-demo__status" hidden>
|
||||
<span class="gerber-demo__spinner" aria-hidden="true"></span>
|
||||
<span class="gerber-demo__status-text">Loading the viewer…</span>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption class="gerber-demo__caption">{caption}</figcaption>
|
||||
</figure>
|
||||
|
||||
<script>
|
||||
const BOOT_SRC = "/gerber-demo/index.html";
|
||||
const RELOAD_KEY = "gerber-demo-coi-reload";
|
||||
|
||||
function hasWebGL2(): boolean {
|
||||
try {
|
||||
return !!document.createElement("canvas").getContext("webgl2");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function launch(root: HTMLElement): void {
|
||||
if (root.dataset.state === "loading" || root.dataset.state === "ready") return;
|
||||
root.dataset.state = "loading";
|
||||
|
||||
const status = root.querySelector<HTMLElement>(".gerber-demo__status");
|
||||
if (status) status.hidden = false;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = BOOT_SRC;
|
||||
iframe.title = "KiCad Gerber viewer";
|
||||
// Delegate cross-origin isolation to the (same-origin) iframe so it can use
|
||||
// SharedArrayBuffer / pthreads.
|
||||
iframe.allow = "cross-origin-isolated; fullscreen";
|
||||
iframe.setAttribute("allowfullscreen", "");
|
||||
iframe.className = "gerber-demo__iframe";
|
||||
// Size via INLINE styles, not the scoped stylesheet: this element is created
|
||||
// in JS so it lacks Astro's data-astro-cid scope attribute, and the scoped
|
||||
// `.gerber-demo__iframe` rule would not match it (it would fall back to the
|
||||
// 300x150 iframe default, shrinking the whole viewer).
|
||||
iframe.style.cssText =
|
||||
"position:absolute;inset:0;width:100%;height:100%;border:0;display:block;";
|
||||
iframe.addEventListener("load", () => {
|
||||
root.dataset.state = "ready";
|
||||
if (status) status.hidden = true;
|
||||
});
|
||||
|
||||
const frame = root.querySelector(".gerber-demo__frame");
|
||||
frame?.appendChild(iframe);
|
||||
}
|
||||
|
||||
function showFallback(root: HTMLElement): void {
|
||||
root.dataset.state = "unsupported";
|
||||
const launchEl = root.querySelector<HTMLElement>(".gerber-demo__launch");
|
||||
const fallback = root.querySelector<HTMLElement>(".gerber-demo__fallback");
|
||||
// Keep the poster (it's the <a> background); hide the play affordance, show note.
|
||||
launchEl?.querySelector(".gerber-demo__play")?.setAttribute("hidden", "");
|
||||
launchEl?.querySelector(".gerber-demo__cta")?.setAttribute("hidden", "");
|
||||
launchEl?.querySelector(".gerber-demo__sub")?.setAttribute("hidden", "");
|
||||
if (launchEl) {
|
||||
launchEl.removeAttribute("href"); // not clickable
|
||||
launchEl.style.cursor = "default";
|
||||
}
|
||||
if (fallback) fallback.hidden = false;
|
||||
}
|
||||
|
||||
function initDemo(): void {
|
||||
const root = document.querySelector<HTMLElement>(".gerber-demo");
|
||||
if (!root) return; // page without the demo
|
||||
|
||||
const isolated = window.crossOriginIsolated === true;
|
||||
|
||||
// Soft SPA navigation lands here NOT cross-origin isolated; force one real
|
||||
// load so the route's COOP/COEP headers apply. Guard against reload loops.
|
||||
if (!isolated) {
|
||||
if (!sessionStorage.getItem(RELOAD_KEY)) {
|
||||
sessionStorage.setItem(RELOAD_KEY, "1");
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
// Already retried and still not isolated → genuinely unsupported (or no
|
||||
// headers, e.g. a preview without vercel.json). Fall back.
|
||||
} else {
|
||||
sessionStorage.removeItem(RELOAD_KEY);
|
||||
}
|
||||
|
||||
const supported =
|
||||
isolated && typeof SharedArrayBuffer !== "undefined" && hasWebGL2();
|
||||
|
||||
if (!supported) {
|
||||
showFallback(root);
|
||||
return;
|
||||
}
|
||||
|
||||
const launchEl = root.querySelector<HTMLElement>(".gerber-demo__launch");
|
||||
launchEl?.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // intercept the href; embed inline instead
|
||||
launch(root);
|
||||
});
|
||||
}
|
||||
|
||||
// Run on first load and on every SPA navigation (ClientRouter).
|
||||
document.addEventListener("astro:page-load", initDemo);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gerber-demo {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.gerber-demo__frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
min-height: 360px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
/* Launch surface (poster + CTA). */
|
||||
.gerber-demo__launch {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--bg-soft);
|
||||
transition: filter var(--dur) var(--ease);
|
||||
}
|
||||
.gerber-demo__launch::before {
|
||||
/* Dim the poster so the CTA reads clearly. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(11, 16, 32, 0.45),
|
||||
rgba(11, 16, 32, 0.7)
|
||||
);
|
||||
}
|
||||
.gerber-demo__launch:hover {
|
||||
filter: brightness(1.06);
|
||||
text-decoration: none;
|
||||
}
|
||||
.gerber-demo__play {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-600);
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
padding-left: 4px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.gerber-demo__cta {
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.gerber-demo__sub {
|
||||
position: relative;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
max-width: 32rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.gerber-demo__iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.gerber-demo__status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg);
|
||||
background: rgba(11, 16, 32, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.gerber-demo__spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 999px;
|
||||
animation: gerber-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes gerber-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.gerber-demo__fallback {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(11, 16, 32, 0.9);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.gerber-demo__fallback-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gerber-demo__fallback .gerber-demo__sub {
|
||||
position: static;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.gerber-demo__caption {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gerber-demo__spinner {
|
||||
animation: none;
|
||||
}
|
||||
.gerber-demo__launch {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,7 +4,7 @@ import { glob } from 'astro/loaders';
|
|||
// Content Layer API (Astro 5+). Markdown files live in src/content/blog/.
|
||||
// NOTE: this config file is src/content.config.ts — NOT src/content/config.ts.
|
||||
const blog = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
title: "Porting KiCad's Graphics to WebGL with Claude"
|
||||
description: "Coming soon."
|
||||
pubDate: 2026-06-07
|
||||
---
|
||||
|
||||
Coming soon.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
title: "Porting KiCad's Graphics to WebGL with Claude"
|
||||
description: "Coming soon."
|
||||
pubDate: 2026-06-07
|
||||
---
|
||||
|
||||
import GerberDemo from '../../components/GerberDemo.astro';
|
||||
|
||||
{/* TODO(author): intro — the gerbview port and the graphics-abstraction-layer → WebGL work. */}
|
||||
|
||||
Coming soon.
|
||||
|
||||
## See it run: a Gerber viewer in your browser
|
||||
|
||||
The viewer below is KiCad's `gerbview` compiled to WebAssembly. Click to load it
|
||||
(it streams ~22 MB on demand) and it renders a real multi-layer board —
|
||||
the [tiny_tapeout](https://tinytapeout.com/) demo — entirely client-side, with no
|
||||
server doing the rasterization.
|
||||
|
||||
<GerberDemo />
|
||||
|
||||
{/* TODO(author): the rest of the article. */}
|
||||
23
site/src/middleware.ts
Normal file
23
site/src/middleware.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { defineMiddleware } from 'astro:middleware';
|
||||
|
||||
/**
|
||||
* Dev-only cross-origin isolation for Astro-rendered pages.
|
||||
*
|
||||
* Vite's `server.headers` (astro.config.mjs) already cover static assets in
|
||||
* public/ (the boot page + WASM), but Astro's dev page renderer writes its own
|
||||
* response headers and drops them — so the blog post itself wouldn't be
|
||||
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
|
||||
* would be unavailable. Set them here for `npm run dev`.
|
||||
*
|
||||
* Production is static (output: 'static'); these headers come from vercel.json
|
||||
* scoped to the post + /gerber-demo routes, so we no-op outside dev to keep the
|
||||
* prerender/build output untouched.
|
||||
*/
|
||||
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||
const response = await next();
|
||||
if (import.meta.env.DEV) {
|
||||
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
}
|
||||
return response;
|
||||
});
|
||||
Loading…
Reference in a new issue