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); 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 (