Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` 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 `<pre>`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
- **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `<pre>` 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@<ver>?deps=glyphcss@<ver>"` (same `<ver>` 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

Expand Down
108 changes: 108 additions & 0 deletions packages/glyphcss/src/api/interactiveExport.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
75 changes: 66 additions & 9 deletions packages/glyphcss/src/api/interactiveExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number | string | boolean>;
/** 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 {
Expand Down Expand Up @@ -74,15 +90,39 @@ 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, number | string | boolean>): 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<GlyphInteractiveExportOptions["effect"]>): 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;
cellAspect?: number; mode?: string; useColors?: boolean;
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");
Expand All @@ -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.
Expand All @@ -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[] = [];
Expand All @@ -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 {
Expand All @@ -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<ver>` 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);
Expand Down
10 changes: 9 additions & 1 deletion website/src/components/GalleryWorkbench/CodePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,14 +589,22 @@ 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) {
console.error("glyphcss: CodePen export failed", err);
} finally {
setExporting(false);
}
}, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate]);
}, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate, effectState]);

return (
<aside id={id} className={`gw-code-panel${collapsed ? " gw-code-panel--collapsed" : ""}${className ? ` ${className}` : ""}`}>
Expand Down
58 changes: 58 additions & 0 deletions website/src/components/SynthWorkbench/SynthWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
createGlyphOrbitControls,
injectGlyphBaseStyles,
resolveGeometry,
buildGlyphInteractiveExport,
glyphCodepenPrefill,
type GlyphGeometryName,
type GlyphSceneHandle,
} from "glyphcss";
Expand All @@ -16,6 +18,23 @@ import { useColor, useFolder, useOption, useSlider, useText, useToggle } from ".
import "../GalleryWorkbench/gallery-workbench.css";
import "./synth-workbench.css";

/** POST a CodePen prefill payload (opens a new pen in a new tab). Mirrors
* `CodePanel.tsx`'s `postToCodepen` — small enough to duplicate rather than share. */
function postToCodepen(prefill: { action: string; data: string }): void {
const form = document.createElement("form");
form.method = "POST";
form.action = prefill.action;
form.target = "_blank";
const input = document.createElement("input");
input.type = "hidden";
input.name = "data";
input.value = prefill.data;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
form.remove();
}

type ParamValue = number | string | boolean;
type Params = Record<string, ParamValue>;
type Polys = ReturnType<typeof resolveGeometry>;
Expand Down Expand Up @@ -379,6 +398,36 @@ export default function SynthWorkbench() {

const presets = useMemo(() => (fieldSynth.presets ?? []) as readonly GlyphEffectPreset<never>[], []);

// Export the current patch (shape + field-synth params + camera framing) as a
// live, interactive CodePen — same geometry, same mount call the page itself
// uses (`blend: "replace"`, matching the `addEffectLayer` call above).
const [exporting, setExporting] = useState(false);
const handleCodepen = useCallback(async () => {
setExporting(true);
try {
const polys = shapePolys(shape);
const camera = cameraRef.current;
const result = buildGlyphInteractiveExport(polys, {
interactions: ["orbit", "zoom"],
rotX: camera?.rotX ?? (isFlat(shape) ? 0 : 58),
rotY: camera?.rotY ?? (isFlat(shape) ? 0 : 32),
zoom: camera?.zoom ?? 46,
projection: "orthographic",
mode: "solid",
useColors: true,
effect: {
id: fieldSynth.id,
params: paramsRef.current,
blend: "replace",
timeScale: pausedRef.current ? 0 : tsRef.current,
},
});
postToCodepen(glyphCodepenPrefill(result, "glyphcss field synth"));
} finally {
setExporting(false);
}
}, [shape]);

return (
<div className="synth-shell dn-root">
<div className="synth-body">
Expand All @@ -396,6 +445,15 @@ export default function SynthWorkbench() {
</aside>
<main className="synth-main">
<div className="synth-viewport" ref={hostRef} />
<button
type="button"
className="synth-codepen-btn gw-code-panel__action gw-code-panel__action--codepen"
onClick={handleCodepen}
disabled={exporting}
title="Export the current patch as a live, interactive CodePen"
>
{exporting ? "Exporting…" : "CodePen"}
</button>
</main>
<Dock id="synth-controls-panel">
<SynthDock shape={shape} onShape={setShape} timeScale={timeScale} onTimeScale={setTimeScale} paused={paused} onPaused={setPaused} density={density} onDensity={setDensity} lighting={lighting} onLight={(partial) => setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} />
Expand Down
Loading
Loading