From ad758c535cf040cb576e37e03145fd7f712cdb39 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 06:43:22 +0200 Subject: [PATCH 1/2] feat(glyphcss): interactive/CodePen export mounts stock effect layers by id --- AGENTS.md | 4 +- .../src/api/interactiveExport.test.ts | 108 ++++++++++++++++++ .../glyphcss/src/api/interactiveExport.ts | 75 ++++++++++-- 3 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 packages/glyphcss/src/api/interactiveExport.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2105a6fa..cf779f79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,9 +90,9 @@ Because `rasterize` is pure (geometry + camera → string), a scene can be rende - **`compileScene(opts)`** (in `glyphcss`, pure) — polygons + camera + options → the `
` string. **Byte-identical to the runtime render** for the same inputs; same defaults as `createGlyphScene`. The foundation for every adapter.
 - **`@glyphcss/compile`** — Node adapters around `compileScene`: `loadMeshFromFile`, `compileFile`, a **Vite plugin** (`import x from "./m.glb?glyph&…"` → baked `
`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
 - **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `
` with no client runtime (mirror of each other; static counterpart to `GlyphScene`).
-- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped.
+- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped. An optional `effect: { id, params, blend?, timeScale? }` mounts a live **stock** `@glyphcss/effects` layer: the snippet adds a second `import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@?deps=glyphcss@"` (same `` as the glyphcss import; `?deps` dedupes the shared glyphcss module instance), resolves the effect by id at runtime (`glyphcss` itself never imports `@glyphcss/effects` — that dependency only points one way), calls `scene.addEffectLayer(...)` after the scene/controls are wired, and — when `timeScale > 0` — appends a small `requestAnimationFrame` loop driving `params.time`.
 
-Dynamic Glyph Effect layers are runtime-only in the current slice. Static compilation and the interactive/CodePen exporters do not serialize or evaluate them yet; callers must not imply that an exported scene contains a mounted effect.
+Dynamic Glyph Effect layers are otherwise runtime-only. Static compilation still cannot serialize or evaluate them, and the interactive/CodePen exporter only supports **stock** effects by id — a custom `defineGlyphEffect` definition can't be serialized across the CDN boundary and isn't exported either.
 
 ## No per-frame DOM mutation
 
diff --git a/packages/glyphcss/src/api/interactiveExport.test.ts b/packages/glyphcss/src/api/interactiveExport.test.ts
new file mode 100644
index 00000000..4504acaf
--- /dev/null
+++ b/packages/glyphcss/src/api/interactiveExport.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from "vitest";
+import { buildGlyphInteractiveExport, glyphCodepenPrefill } from "./interactiveExport";
+import type { Polygon } from "@glyphcss/core";
+
+const TRIANGLE: Polygon[] = [
+  { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000" },
+  { vertices: [[0, 0, 1], [1, 0, 1], [0, 1, 1]], color: "#00ff00" },
+];
+
+describe("buildGlyphInteractiveExport — effect option", () => {
+  it("no effect → unchanged output (regression guard)", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, { interactions: ["orbit"] });
+    expect(r.html).not.toContain("@glyphcss/effects");
+    expect(r.html).not.toContain("getGlyphEffect");
+    expect(r.html).not.toContain("addEffectLayer");
+    expect(r.html).not.toContain("requestAnimationFrame");
+  });
+
+  it("animated effect → effects CDN import, getGlyphEffect lookup, addEffectLayer, inlined params, rAF clock", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: {
+        id: "field-synth",
+        params: { space: "surface", field1: "radial", amp1: 0.5, time: 999 },
+        blend: "replace",
+        timeScale: 2,
+      },
+    });
+    expect(r.html).toContain('import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects?deps=glyphcss";');
+    expect(r.html).toContain('getGlyphEffect("field-synth")');
+    expect(r.html).toContain("scene.addEffectLayer({ effect: getGlyphEffect(\"field-synth\"), blend: \"replace\", params:");
+    // Params are inlined without the authored `time` key — the clock owns it.
+    const paramsMatch = r.html.match(/params: (\{[^}]*\})/);
+    expect(paramsMatch).not.toBeNull();
+    const params = JSON.parse(paramsMatch![1]);
+    expect(params).toEqual({ space: "surface", field1: "radial", amp1: 0.5 });
+    expect(params.time).toBeUndefined();
+    // rAF clock drives params.time at the declared speed.
+    expect(r.html).toContain("requestAnimationFrame(_tick)");
+    expect(r.html).toContain("glyphEffect.params.time = ((now - _t0) / 1000) * 2;");
+  });
+
+  it("static effect (no timeScale) → mounts the layer but emits no rAF clock", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: [],
+      effect: { id: "matrix-rain", params: { density: 0.5 } },
+    });
+    expect(r.html).toContain('getGlyphEffect("matrix-rain")');
+    expect(r.html).toContain("scene.addEffectLayer(");
+    // Default blend is "over" (glyphcss's own addEffectLayer default) when unset.
+    expect(r.html).toContain('blend: "over"');
+    expect(r.html).not.toContain("requestAnimationFrame");
+    expect(r.html).not.toContain("_tick");
+  });
+
+  it("timeScale: 0 is treated as static — no clock emitted", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "scan", params: {}, timeScale: 0 },
+    });
+    expect(r.html).toContain("scene.addEffectLayer(");
+    expect(r.html).not.toContain("requestAnimationFrame");
+  });
+
+  it("cdnVersion pins both the glyphcss and @glyphcss/effects CDN imports, plus the deps dedupe query", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      cdnVersion: "0.1.1",
+      effect: { id: "glitch", params: {}, timeScale: 1 },
+    });
+    expect(r.html).toContain('from "https://esm.sh/glyphcss@0.1.1";');
+    expect(r.html).toContain(
+      'import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@0.1.1?deps=glyphcss@0.1.1";',
+    );
+  });
+
+  it("mounts the effect after controls in both the normal and FPV branches", () => {
+    const normal = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "wipe", params: {}, timeScale: 1 },
+    });
+    const orbitIdx = normal.html.indexOf("createGlyphOrbitControls(");
+    const effectIdx = normal.html.indexOf("scene.addEffectLayer(");
+    expect(orbitIdx).toBeGreaterThan(-1);
+    expect(effectIdx).toBeGreaterThan(orbitIdx);
+
+    const fpv = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["fpv"],
+      autoCenter: true,
+      effect: { id: "wipe", params: {}, timeScale: 1 },
+    });
+    const fpvSetupIdx = fpv.html.indexOf("fpv.setOrigin(");
+    const fpvEffectIdx = fpv.html.indexOf("scene.addEffectLayer(");
+    expect(fpvSetupIdx).toBeGreaterThan(-1);
+    expect(fpvEffectIdx).toBeGreaterThan(fpvSetupIdx);
+  });
+
+  it("glyphCodepenPrefill carries the effect-mounting script through the pen payload", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "ripple", params: {}, timeScale: 1 },
+    });
+    const pre = glyphCodepenPrefill(r, "effect-demo");
+    const data = JSON.parse(pre.data);
+    expect(data.html).toContain('getGlyphEffect("ripple")');
+    expect(data.html).toContain("requestAnimationFrame(_tick)");
+  });
+});
diff --git a/packages/glyphcss/src/api/interactiveExport.ts b/packages/glyphcss/src/api/interactiveExport.ts
index cde27e97..81f6e40b 100644
--- a/packages/glyphcss/src/api/interactiveExport.ts
+++ b/packages/glyphcss/src/api/interactiveExport.ts
@@ -37,6 +37,22 @@ export interface GlyphInteractiveExportOptions {
   cellAspect?: number;
   mode?: RenderMode;
   useColors?: boolean;
+  /**
+   * Mount a live stock `@glyphcss/effects` layer, resolved by id from the CDN
+   * at runtime. The exporter never imports `@glyphcss/effects` itself (that
+   * package depends on glyphcss, never the reverse) — `id` stays an opaque
+   * string until the snippet runs in the browser.
+   */
+  effect?: {
+    /** Stock effect id, e.g. "field-synth" / "matrix-rain". */
+    id: string;
+    /** Flat ABI params. Any `time` key is stripped — the emitted clock owns it. */
+    params: Record;
+    /** Default: "over" (glyphcss's own `addEffectLayer` default). */
+    blend?: "over" | "replace";
+    /** Clock speed. >0 emits an rAF loop driving `params.time`; 0/undefined = static. */
+    timeScale?: number;
+  };
 }
 
 export interface GlyphInteractiveExportResult {
@@ -74,8 +90,31 @@ const BASE_CSS = `html,body{margin:0;height:100%;background:#0b0d10;color:#e2e8f
 #MOUNT{width:100vw;height:100vh}
 #MOUNT pre.glyph-output{margin:0;font:13px/1 ui-monospace,Menlo,monospace;white-space:pre}`;
 
+// The clock owns `time` — strip any authored value so the rAF loop (or the
+// static single assignment) is the sole writer.
+function serializeEffectParams(params: Record): string {
+  const { time: _time, ...rest } = params;
+  return JSON.stringify(rest);
+}
+
+// Shared by the FPV and normal branches of buildScript so the effect-mount +
+// clock snippet isn't duplicated. `effect` here is the resolved, non-optional
+// export option — callers gate on `opts.effect` before calling this.
+function buildEffectBlock(effect: NonNullable): string {
+  const blend = effect.blend ?? "over";
+  const layerLine = `const glyphEffect = scene.addEffectLayer({ effect: getGlyphEffect(${JSON.stringify(effect.id)}), blend: ${JSON.stringify(blend)}, params: ${serializeEffectParams(effect.params)} });`;
+  if (!effect.timeScale) return layerLine;
+  return `${layerLine}
+const _t0 = performance.now();
+(function _tick(now) {
+  glyphEffect.params.time = ((now - _t0) / 1000) * ${round(effect.timeScale)};
+  requestAnimationFrame(_tick);
+})(performance.now());`;
+}
+
 function buildScript(opts: {
   cdn: string;
+  effectsCdn?: string;
   mountId: string;
   polysJson: string;
   rotX?: number; rotY?: number; zoom?: number;
@@ -83,6 +122,7 @@ function buildScript(opts: {
   projection?: "perspective" | "orthographic"; perspectivePx?: number;
   interactions: GlyphInteraction[];
   scale: number;
+  effect?: GlyphInteractiveExportOptions["effect"];
 }): string {
   const hasFpv = opts.interactions.includes("fpv");
   const hasPan = opts.interactions.includes("pan");
@@ -105,9 +145,12 @@ function buildScript(opts: {
   if (opts.useColors === false) sceneOpts.push("useColors: false");
   if (opts.cellAspect !== undefined) sceneOpts.push(`cellAspect: ${opts.cellAspect}`);
 
-  const head = `import { ${imports.join(", ")} } from "${opts.cdn}";
+  const importLines = [`import { ${imports.join(", ")} } from "${opts.cdn}";`];
+  if (opts.effect) importLines.push(`import { getGlyphEffect } from "${opts.effectsCdn}";`);
+  const head = `${importLines.join("\n")}
 const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] }));`;
   const mount = `document.getElementById(${JSON.stringify(opts.mountId)})`;
+  const effectBlock = opts.effect ? buildEffectBlock(opts.effect) : undefined;
 
   // FPV mirrors the gallery: perspective at rotX=90, zoom × model-scale,
   // and move/jump/gravity/eyeHeight scaled by the model so it works at any size.
@@ -123,13 +166,16 @@ const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] }))
     const fpvZoom = round((opts.zoom ?? 0.5) * s);
     const fpvPerspective = round(200 * s);
     const fpvOpts = `{ moveSpeed: ${round(1 * s)}, jumpVelocity: ${round(0.7 * s)}, gravity: ${round(1.8 * s)}, eyeHeight: ${eyeZ}, crouchHeight: ${round(0.1 * s)}, groundZ: 0, lookSensitivity: 0.15, minPitch: 5, maxPitch: 175 }`;
-    return `${head}
-const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} });
+    return [
+      head,
+      `const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} });
 const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} });
 scene.add(polygons);
 const fpv = createGlyphFirstPersonControls(scene, ${fpvOpts});
 fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]);
-// Click the scene to capture the mouse, then WASD to move, mouse to look.`;
+// Click the scene to capture the mouse, then WASD to move, mouse to look.`,
+      effectBlock,
+    ].filter(Boolean).join("\n");
   }
 
   const camParts: string[] = [];
@@ -145,11 +191,14 @@ fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]);
     controlLine = `createGlyphOrbitControls(scene, { drag: ${hasOrbit ? "true" : "false"}, wheel: ${hasZoom ? "true" : "false"} });`;
   }
 
-  return `${head}
-const camera = ${cameraCtor}({ ${camParts.join(", ")} });
+  return [
+    head,
+    `const camera = ${cameraCtor}({ ${camParts.join(", ")} });
 const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} });
 scene.add(polygons);
-${controlLine}`;
+${controlLine}`,
+    effectBlock,
+  ].filter(Boolean).join("\n");
 }
 
 function modelScale(polygons: Polygon[]): number {
@@ -173,15 +222,23 @@ export function buildGlyphInteractiveExport(
   const decimated = decimatePolygons(centered, { grid });
 
   const mountId = options.mountId ?? "glyph";
-  const cdn = `https://esm.sh/glyphcss${options.cdnVersion ? `@${options.cdnVersion}` : ""}`;
+  const versionSuffix = options.cdnVersion ? `@${options.cdnVersion}` : "";
+  const cdn = `https://esm.sh/glyphcss${versionSuffix}`;
+  // `?deps=glyphcss` pins esm.sh to the same glyphcss instance the main
+  // import resolves, so the effect layer's runtime type checks (which compare
+  // against glyphcss's exported classes/symbols) see one module graph.
+  const effectsCdn = options.effect
+    ? `https://esm.sh/@glyphcss/effects${versionSuffix}?deps=glyphcss${versionSuffix}`
+    : undefined;
   const script = buildScript({
-    cdn, mountId,
+    cdn, effectsCdn, mountId,
     polysJson: serializePolygons(decimated),
     rotX: options.rotX, rotY: options.rotY, zoom: options.zoom,
     projection: options.projection, perspectivePx: options.perspectivePx,
     cellAspect: options.cellAspect, mode: options.mode, useColors: options.useColors,
     interactions,
     scale: modelScale(decimated),
+    effect: options.effect,
   });
 
   const css = BASE_CSS.replace(/MOUNT/g, mountId);

From 4cdf740da620d5e49fca02d070192425f66c347a Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 18 Jul 2026 07:03:11 +0200
Subject: [PATCH 2/2] feat(website): export active effect to CodePen from
 gallery + /synth

---
 .../components/GalleryWorkbench/CodePanel.tsx | 10 +++-
 .../SynthWorkbench/SynthWorkbench.tsx         | 58 +++++++++++++++++++
 .../SynthWorkbench/synth-workbench.css        | 17 ++++++
 3 files changed, 84 insertions(+), 1 deletion(-)

diff --git a/website/src/components/GalleryWorkbench/CodePanel.tsx b/website/src/components/GalleryWorkbench/CodePanel.tsx
index c951d48b..f2db639b 100644
--- a/website/src/components/GalleryWorkbench/CodePanel.tsx
+++ b/website/src/components/GalleryWorkbench/CodePanel.tsx
@@ -589,6 +589,14 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
         mode: options.renderMode === "wireframe" ? "wireframe" : "solid",
         useColors: options.useColors,
         decimateGrid,
+        effect: effectState.effectId
+          ? {
+              id: effectState.effectId,
+              params: effectState.params,
+              blend: effectState.blend,
+              timeScale: effectState.paused ? 0 : effectState.timeScale,
+            }
+          : undefined,
       });
       postToCodepen(glyphCodepenPrefill(result, title));
     } catch (err) {
@@ -596,7 +604,7 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
     } finally {
       setExporting(false);
     }
-  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate]);
+  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate, effectState]);
 
   return (
     
         
+
setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} /> diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 088752fa..bcee7cd7 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -147,6 +147,23 @@ /* ── Center: viewport ──────────────────────────────────────────────────── */ .synth-main { flex: 1; position: relative; min-width: 0; } .synth-viewport { position: absolute; inset: 0; overflow: hidden; } +.synth-codepen-btn { + /* Bottom-left, not top-right — the floating Dock GUI anchors top-right of + `.synth-body` (the nearest `position: relative` ancestor) and would + otherwise sit on top of this button and eat its clicks. */ + position: absolute; + bottom: 12px; + left: 12px; + z-index: 5; + font: inherit; + font-size: 11px; + letter-spacing: 0.04em; + padding: 5px 12px; + border-radius: 0; + cursor: pointer; + background: rgba(7, 9, 13, 0.7); + backdrop-filter: blur(2px); +} .synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } /* ── Footer: full-width preset gallery ─────────────────────────────────── */