Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Monorepo layout (pnpm workspaces):
| `packages/polycss` | `@layoutit/polycss` | Vanilla renderer + custom elements (`<poly-scene>`, etc.). Owns DOM emission, CSS injection, its own copy of atlas rasterisation. Depends on `core` only. |
| `packages/react` | `@layoutit/polycss-react` | React components + hooks. Owns its own copy of atlas rasterisation. Depends on `core` only — **NOT on `polycss`.** |
| `packages/vue` | `@layoutit/polycss-vue` | Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on `core` only. |
| `packages/fonts` | `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Hand-written TrueType (`glyf`) reader + extruder (flat/round/bevel profiles) + Google Fonts loader. Framework-agnostic (returns `Polygon[]`, no React/Vue mirror needed). Depends on `core` + `earcut`. |
| `website` | `@layoutit/polycss-website` | Astro + Starlight docs site. Not published. |
| `examples/{html,vanilla,react,vue}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer. Workspace members so they resolve to local `workspace:^` packages. Not published. |
| `examples/{html,vanilla,react,vue,fontcss}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer (`fontcss` demos `@layoutit/polycss-fonts`). Workspace members so they resolve to local `workspace:^` packages. Not published. |

Public API is **mirrored** across React and Vue. Adding a hook on one side without adding the matching composable on the other is not acceptable (see "Cross-package discipline" below).

Expand Down
81 changes: 81 additions & 0 deletions packages/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# @layoutit/polycss-fonts

Turn **fonts + text into extruded 3D polygon meshes** for [PolyCSS](https://github.com/LayoutitStudio/polycss). Framework-agnostic: it returns plain `Polygon[]`, so the same call works in the vanilla, React, and Vue renderers — no per-framework wrappers.

```bash
pnpm add @layoutit/polycss-fonts @layoutit/polycss
```

```ts
import { loadGoogleFont, textPolygons } from "@layoutit/polycss-fonts";
import { createPolyScene, createPolyOrthographicCamera } from "@layoutit/polycss";

const font = await loadGoogleFont({ /* FontEntry from listGoogleFonts() */ }, 700);
const polygons = textPolygons(font, "Hello", { depth: 24, profile: "bevel" });

const scene = createPolyScene(host, { camera: createPolyOrthographicCamera({ rotX: 28, zoom: 0.06 }) });
scene.add({ polygons, objectUrls: [], warnings: [], dispose() {} });
```

## Two layers

**Pure** (no browser globals — runs in Node too):

- `parseFont(bytes)` → `ParsedFont` — a small, dependency-free TrueType (`glyf`) reader: sfnt tables → glyph outlines + advance widths.
- `textPolygons(font, text, options)` → `Polygon[]` — triangulates caps (holes included), builds the depth profile, extrudes, and lays glyphs out by advance width.
- `composeText(font, text, options)` → `Polygon[]` — the full WordArt composer on top of `textPolygons`: multi-line text, alignment, line height, glyph scale, underline / strike bars, envelope warps, and a layered two-color look.

**Browser** (uses `fetch`):

- `listGoogleFonts()` → every Google font (via the Fontsource API).
- `googleFontUrl(entry, weight)` / `loadFont(url)` / `loadGoogleFont(entry, weight)`.

## `textPolygons` options

| Option | Default | Notes |
|---|---|---|
| `size` | `100` | Cap-em size in world units. |
| `depth` | `size * 0.2` | Extrusion depth along world Z. |
| `profile` | `"flat"` | `"flat"` slab · `"round"` bullnose · `"bevel"` chamfered edge. |
| `curveSteps` | `6` | Bézier flattening — higher is smoother, more polygons. |
| `letterSpacing` | `0` | Extra space between glyphs. |
| `color` / `sideColor` | gold | Cap and wall colors (sideColor defaults to a darker shade). |
| `profileSegments` | `6` | Ring count for round/bevel edges. |

## `composeText` — WordArt composer

`composeText` accepts every `textPolygons` option plus the layout, decoration, and warp controls below. `\n` in `text` starts a new line.

```ts
import { composeText } from "@layoutit/polycss-fonts";

const polygons = composeText(font, "Poly\nCSS", {
size: 100,
depth: 24,
align: "center",
warp: { shape: "arch", amount: 0.6 },
backColor: "#3a86ff", // layered: distinct back-cap color…
oblique: [14, -14], // …shifted for the retro front-A / back-B leaning block
});
```

| Option | Default | Notes |
|---|---|---|
| `lineHeight` | `1.25` | Line advance as a multiple of `size`. |
| `align` | `"center"` | `"left"` · `"center"` · `"right"`. |
| `scaleX` / `scaleY` | `1` | Horizontal / vertical glyph scale (Photoshop ↔ / ↕). |
| `underline` / `strike` | `false` | Decoration bars; they follow the active warp. |
| `warp` | — | `{ shape, amount }`. `shape`: `none`, `arch`, `archDown`, `arc`, `wave`, `bulge`, `cone`, `slantUp`, `slantDown`. `amount` is `0..1`. |
| `simplify` | `0` | Outline simplification tolerance (world units). Hole-less glyphs only — holed glyphs (`O`, `P`, `a`…) stay full-detail so counters never collapse. |
| `merge` | `false` | Merge coplanar same-color cap triangles into larger polygons (~⅓ fewer DOM nodes). Has a CPU cost, so off by default. |
| `backColor` | `color` | Back-cap color — set it apart from `color` for a layered two-tone look. |
| `oblique` | `[0, 0]` | `[rightward, upward]` shift of the back cap relative to the front (world units). |

## Scope / limitations

This is a focused reader, not a full font library:

- **TrueType (`.ttf`, `glyf`) only.** CFF/OpenType (`.otf`, "OTTO") is rejected with a clear error. Google Fonts ship TrueType, so this covers the common case.
- **Uncompressed sfnt only** — woff/woff2 are not unpacked (the Google Fonts loader fetches raw `.ttf`).
- No shaping, kerning, ligatures, or variable-font axes — each character maps to one glyph plus its advance width.
- Script fonts with heavily self-overlapping contours can leave minor triangulation artifacts.
51 changes: 51 additions & 0 deletions packages/fonts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@layoutit/polycss-fonts",
"version": "0.0.0",
"description": "Turn fonts + text into extruded 3D polygon meshes for PolyCSS. Framework-agnostic — returns Polygon[].",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": ["polycss", "font", "text", "3d", "extrude", "css", "matrix3d", "ttf", "opentype", "truetype"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/LayoutitStudio/polycss.git",
"directory": "packages/fonts"
},
"bugs": {
"url": "https://github.com/LayoutitStudio/polycss/issues"
},
"homepage": "https://github.com/LayoutitStudio/polycss#readme",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"prepack": "node ../../.github/scripts/sync-package-readmes.mjs",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@layoutit/polycss-core": "workspace:^",
"earcut": "^3.0.1"
},
"devDependencies": {
"@types/earcut": "^3.0.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^3.1.1",
"@vitest/coverage-v8": "^3.1.1"
}
}
123 changes: 123 additions & 0 deletions packages/fonts/src/composeText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { parseFont } from "./parseFont";
import { composeText } from "./composeText";

function loadFixture(name: string): ArrayBuffer {
const buf = readFileSync(resolve(__dirname, "../test/fixtures", name));
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}

const roboto = parseFont(loadFixture("Roboto-Bold.ttf"));

function bounds(polys: ReturnType<typeof composeText>) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of polys) for (const [x, y] of p.vertices) {
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
return { minX, maxX, minY, maxY };
}

describe("composeText", () => {
it("renders a single line like textPolygons", () => {
expect(composeText(roboto, "Poly").length).toBeGreaterThan(0);
});

it("stacks multiple lines taller (world X = screen-down)", () => {
const one = bounds(composeText(roboto, "Poly"));
const three = bounds(composeText(roboto, "Poly\nCSS\nText"));
expect(three.maxX - three.minX).toBeGreaterThan((one.maxX - one.minX) * 2);
});

it("splits on \\n into independent lines", () => {
const polys = composeText(roboto, "AB\nCD");
expect(polys.length).toBeGreaterThan(0);
});

it("underline and strike add decoration polygons", () => {
const plain = composeText(roboto, "Hi").length;
const underlined = composeText(roboto, "Hi", { underline: true }).length;
const struck = composeText(roboto, "Hi", { strike: true }).length;
expect(underlined).toBeGreaterThan(plain);
expect(struck).toBeGreaterThan(plain);
});

it("alignment shifts the short line's horizontal position", () => {
const sumY = (polys: ReturnType<typeof composeText>) =>
polys.reduce((s, p) => s + p.vertices.reduce((t, v) => t + v[1], 0), 0);
const left = sumY(composeText(roboto, "wide line\nx", { align: "left" }));
const right = sumY(composeText(roboto, "wide line\nx", { align: "right" }));
// The short line slides right, so the total of world-Y (screen-right) grows.
expect(right).toBeGreaterThan(left);
});

it("arc warp spreads the text wider than unwarped", () => {
const flat = bounds(composeText(roboto, "WordArt"));
const arced = bounds(composeText(roboto, "WordArt", { warp: { shape: "arc", amount: 0.8 } }));
// The arc bows letters up/down, so the vertical (world X) extent grows.
expect(arced.maxX - arced.minX).toBeGreaterThan(flat.maxX - flat.minX);
});

it("warp shapes change the geometry vs none", () => {
const none = composeText(roboto, "Hi", { warp: { shape: "none" } });
const wave = composeText(roboto, "Hi", { warp: { shape: "wave", amount: 0.7 } });
const sum = (ps: ReturnType<typeof composeText>) =>
ps.reduce((s, p) => s + p.vertices.reduce((t, v) => t + v[0] + v[1], 0), 0);
expect(Math.abs(sum(none) - sum(wave))).toBeGreaterThan(1);
});

it("larger lineHeight increases vertical extent", () => {
const tight = bounds(composeText(roboto, "A\nB", { lineHeight: 1 }));
const loose = bounds(composeText(roboto, "A\nB", { lineHeight: 2 }));
expect(loose.maxX - loose.minX).toBeGreaterThan(tight.maxX - tight.minX);
});

// ── regression: holes must never break ──────────────────────────────────
it("never simplifies a glyph with holes, so the counter can't collapse", () => {
// 'O' has a counter; its geometry must be identical at any simplify level.
const exact = composeText(roboto, "O", { simplify: 0 });
const coarse = composeText(roboto, "O", { simplify: 8 });
expect(coarse.length).toBe(exact.length);
});

it("still simplifies hole-less glyphs (poly reduction works)", () => {
const exact = composeText(roboto, "M", { simplify: 0 });
const coarse = composeText(roboto, "M", { simplify: 8 });
expect(coarse.length).toBeLessThan(exact.length);
});

it("round/bevel hold their counters too (inset never overruns the hole)", () => {
for (const profile of ["round", "bevel"] as const) {
expect(composeText(roboto, "o", { profile }).length).toBeGreaterThan(0);
expect(composeText(roboto, "B", { profile, depth: 30 }).length).toBeGreaterThan(0);
}
});

// ── regression: scale / merge / layered ─────────────────────────────────
it("horizontal scale widens the run", () => {
const a = bounds(composeText(roboto, "AV"));
const b = bounds(composeText(roboto, "AV", { scaleX: 2 }));
expect(b.maxY - b.minY).toBeGreaterThan((a.maxY - a.minY) * 1.6);
});

it("vertical scale heightens the glyphs", () => {
const a = bounds(composeText(roboto, "A"));
const b = bounds(composeText(roboto, "A", { scaleY: 2 }));
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
});

it("merge reduces the polygon count", () => {
const base = composeText(roboto, "Poly", { merge: false });
const merged = composeText(roboto, "Poly", { merge: true });
expect(merged.length).toBeLessThan(base.length);
});

it("layered back color + oblique recolors and offsets the back", () => {
const polys = composeText(roboto, "o", { depth: 10, color: "#ff0000", backColor: "#00ff00", oblique: [12, -12] });
const colors = new Set(polys.map((p) => p.color));
expect(colors.has("#ff0000")).toBe(true); // front cap
expect(colors.has("#00ff00")).toBe(true); // back cap
});
});
Loading
Loading