Skip to content

Commit 2db3178

Browse files
authored
Add polycss-fonts package and WordArt composer (#51)
* feat(fonts): add polycss-fonts package and /wordart WordArt composer * test(fonts): resolve polycss-core from source so CI runs before build
1 parent f495386 commit 2db3178

25 files changed

Lines changed: 2669 additions & 2 deletions

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ Monorepo layout (pnpm workspaces):
1414
| `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. |
1515
| `packages/react` | `@layoutit/polycss-react` | React components + hooks. Owns its own copy of atlas rasterisation. Depends on `core` only — **NOT on `polycss`.** |
1616
| `packages/vue` | `@layoutit/polycss-vue` | Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on `core` only. |
17+
| `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`. |
1718
| `website` | `@layoutit/polycss-website` | Astro + Starlight docs site. Not published. |
18-
| `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. |
19+
| `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. |
1920

2021
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).
2122

packages/fonts/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# @layoutit/polycss-fonts
2+
3+
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.
4+
5+
```bash
6+
pnpm add @layoutit/polycss-fonts @layoutit/polycss
7+
```
8+
9+
```ts
10+
import { loadGoogleFont, textPolygons } from "@layoutit/polycss-fonts";
11+
import { createPolyScene, createPolyOrthographicCamera } from "@layoutit/polycss";
12+
13+
const font = await loadGoogleFont({ /* FontEntry from listGoogleFonts() */ }, 700);
14+
const polygons = textPolygons(font, "Hello", { depth: 24, profile: "bevel" });
15+
16+
const scene = createPolyScene(host, { camera: createPolyOrthographicCamera({ rotX: 28, zoom: 0.06 }) });
17+
scene.add({ polygons, objectUrls: [], warnings: [], dispose() {} });
18+
```
19+
20+
## Two layers
21+
22+
**Pure** (no browser globals — runs in Node too):
23+
24+
- `parseFont(bytes)``ParsedFont` — a small, dependency-free TrueType (`glyf`) reader: sfnt tables → glyph outlines + advance widths.
25+
- `textPolygons(font, text, options)``Polygon[]` — triangulates caps (holes included), builds the depth profile, extrudes, and lays glyphs out by advance width.
26+
- `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.
27+
28+
**Browser** (uses `fetch`):
29+
30+
- `listGoogleFonts()` → every Google font (via the Fontsource API).
31+
- `googleFontUrl(entry, weight)` / `loadFont(url)` / `loadGoogleFont(entry, weight)`.
32+
33+
## `textPolygons` options
34+
35+
| Option | Default | Notes |
36+
|---|---|---|
37+
| `size` | `100` | Cap-em size in world units. |
38+
| `depth` | `size * 0.2` | Extrusion depth along world Z. |
39+
| `profile` | `"flat"` | `"flat"` slab · `"round"` bullnose · `"bevel"` chamfered edge. |
40+
| `curveSteps` | `6` | Bézier flattening — higher is smoother, more polygons. |
41+
| `letterSpacing` | `0` | Extra space between glyphs. |
42+
| `color` / `sideColor` | gold | Cap and wall colors (sideColor defaults to a darker shade). |
43+
| `profileSegments` | `6` | Ring count for round/bevel edges. |
44+
45+
## `composeText` — WordArt composer
46+
47+
`composeText` accepts every `textPolygons` option plus the layout, decoration, and warp controls below. `\n` in `text` starts a new line.
48+
49+
```ts
50+
import { composeText } from "@layoutit/polycss-fonts";
51+
52+
const polygons = composeText(font, "Poly\nCSS", {
53+
size: 100,
54+
depth: 24,
55+
align: "center",
56+
warp: { shape: "arch", amount: 0.6 },
57+
backColor: "#3a86ff", // layered: distinct back-cap color…
58+
oblique: [14, -14], // …shifted for the retro front-A / back-B leaning block
59+
});
60+
```
61+
62+
| Option | Default | Notes |
63+
|---|---|---|
64+
| `lineHeight` | `1.25` | Line advance as a multiple of `size`. |
65+
| `align` | `"center"` | `"left"` · `"center"` · `"right"`. |
66+
| `scaleX` / `scaleY` | `1` | Horizontal / vertical glyph scale (Photoshop ↔ / ↕). |
67+
| `underline` / `strike` | `false` | Decoration bars; they follow the active warp. |
68+
| `warp` || `{ shape, amount }`. `shape`: `none`, `arch`, `archDown`, `arc`, `wave`, `bulge`, `cone`, `slantUp`, `slantDown`. `amount` is `0..1`. |
69+
| `simplify` | `0` | Outline simplification tolerance (world units). Hole-less glyphs only — holed glyphs (`O`, `P`, `a`…) stay full-detail so counters never collapse. |
70+
| `merge` | `false` | Merge coplanar same-color cap triangles into larger polygons (~⅓ fewer DOM nodes). Has a CPU cost, so off by default. |
71+
| `backColor` | `color` | Back-cap color — set it apart from `color` for a layered two-tone look. |
72+
| `oblique` | `[0, 0]` | `[rightward, upward]` shift of the back cap relative to the front (world units). |
73+
74+
## Scope / limitations
75+
76+
This is a focused reader, not a full font library:
77+
78+
- **TrueType (`.ttf`, `glyf`) only.** CFF/OpenType (`.otf`, "OTTO") is rejected with a clear error. Google Fonts ship TrueType, so this covers the common case.
79+
- **Uncompressed sfnt only** — woff/woff2 are not unpacked (the Google Fonts loader fetches raw `.ttf`).
80+
- No shaping, kerning, ligatures, or variable-font axes — each character maps to one glyph plus its advance width.
81+
- Script fonts with heavily self-overlapping contours can leave minor triangulation artifacts.

packages/fonts/package.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@layoutit/polycss-fonts",
3+
"version": "0.0.0",
4+
"description": "Turn fonts + text into extruded 3D polygon meshes for PolyCSS. Framework-agnostic — returns Polygon[].",
5+
"type": "module",
6+
"main": "dist/index.cjs",
7+
"module": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"keywords": ["polycss", "font", "text", "3d", "extrude", "css", "matrix3d", "ttf", "opentype", "truetype"],
10+
"license": "MIT",
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/LayoutitStudio/polycss.git",
14+
"directory": "packages/fonts"
15+
},
16+
"bugs": {
17+
"url": "https://github.com/LayoutitStudio/polycss/issues"
18+
},
19+
"homepage": "https://github.com/LayoutitStudio/polycss#readme",
20+
"files": [
21+
"dist"
22+
],
23+
"exports": {
24+
".": {
25+
"types": "./dist/index.d.ts",
26+
"import": "./dist/index.js",
27+
"require": "./dist/index.cjs"
28+
}
29+
},
30+
"scripts": {
31+
"build": "tsup",
32+
"test": "vitest run --passWithNoTests",
33+
"test:coverage": "vitest run --coverage --passWithNoTests",
34+
"prepack": "node ../../.github/scripts/sync-package-readmes.mjs",
35+
"prepublishOnly": "npm run build"
36+
},
37+
"publishConfig": {
38+
"access": "public"
39+
},
40+
"dependencies": {
41+
"@layoutit/polycss-core": "workspace:^",
42+
"earcut": "^3.0.1"
43+
},
44+
"devDependencies": {
45+
"@types/earcut": "^3.0.0",
46+
"tsup": "^8.0.1",
47+
"typescript": "^5.3.3",
48+
"vitest": "^3.1.1",
49+
"@vitest/coverage-v8": "^3.1.1"
50+
}
51+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { resolve } from "path";
4+
import { parseFont } from "./parseFont";
5+
import { composeText } from "./composeText";
6+
7+
function loadFixture(name: string): ArrayBuffer {
8+
const buf = readFileSync(resolve(__dirname, "../test/fixtures", name));
9+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
10+
}
11+
12+
const roboto = parseFont(loadFixture("Roboto-Bold.ttf"));
13+
14+
function bounds(polys: ReturnType<typeof composeText>) {
15+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
16+
for (const p of polys) for (const [x, y] of p.vertices) {
17+
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
18+
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
19+
}
20+
return { minX, maxX, minY, maxY };
21+
}
22+
23+
describe("composeText", () => {
24+
it("renders a single line like textPolygons", () => {
25+
expect(composeText(roboto, "Poly").length).toBeGreaterThan(0);
26+
});
27+
28+
it("stacks multiple lines taller (world X = screen-down)", () => {
29+
const one = bounds(composeText(roboto, "Poly"));
30+
const three = bounds(composeText(roboto, "Poly\nCSS\nText"));
31+
expect(three.maxX - three.minX).toBeGreaterThan((one.maxX - one.minX) * 2);
32+
});
33+
34+
it("splits on \\n into independent lines", () => {
35+
const polys = composeText(roboto, "AB\nCD");
36+
expect(polys.length).toBeGreaterThan(0);
37+
});
38+
39+
it("underline and strike add decoration polygons", () => {
40+
const plain = composeText(roboto, "Hi").length;
41+
const underlined = composeText(roboto, "Hi", { underline: true }).length;
42+
const struck = composeText(roboto, "Hi", { strike: true }).length;
43+
expect(underlined).toBeGreaterThan(plain);
44+
expect(struck).toBeGreaterThan(plain);
45+
});
46+
47+
it("alignment shifts the short line's horizontal position", () => {
48+
const sumY = (polys: ReturnType<typeof composeText>) =>
49+
polys.reduce((s, p) => s + p.vertices.reduce((t, v) => t + v[1], 0), 0);
50+
const left = sumY(composeText(roboto, "wide line\nx", { align: "left" }));
51+
const right = sumY(composeText(roboto, "wide line\nx", { align: "right" }));
52+
// The short line slides right, so the total of world-Y (screen-right) grows.
53+
expect(right).toBeGreaterThan(left);
54+
});
55+
56+
it("arc warp spreads the text wider than unwarped", () => {
57+
const flat = bounds(composeText(roboto, "WordArt"));
58+
const arced = bounds(composeText(roboto, "WordArt", { warp: { shape: "arc", amount: 0.8 } }));
59+
// The arc bows letters up/down, so the vertical (world X) extent grows.
60+
expect(arced.maxX - arced.minX).toBeGreaterThan(flat.maxX - flat.minX);
61+
});
62+
63+
it("warp shapes change the geometry vs none", () => {
64+
const none = composeText(roboto, "Hi", { warp: { shape: "none" } });
65+
const wave = composeText(roboto, "Hi", { warp: { shape: "wave", amount: 0.7 } });
66+
const sum = (ps: ReturnType<typeof composeText>) =>
67+
ps.reduce((s, p) => s + p.vertices.reduce((t, v) => t + v[0] + v[1], 0), 0);
68+
expect(Math.abs(sum(none) - sum(wave))).toBeGreaterThan(1);
69+
});
70+
71+
it("larger lineHeight increases vertical extent", () => {
72+
const tight = bounds(composeText(roboto, "A\nB", { lineHeight: 1 }));
73+
const loose = bounds(composeText(roboto, "A\nB", { lineHeight: 2 }));
74+
expect(loose.maxX - loose.minX).toBeGreaterThan(tight.maxX - tight.minX);
75+
});
76+
77+
// ── regression: holes must never break ──────────────────────────────────
78+
it("never simplifies a glyph with holes, so the counter can't collapse", () => {
79+
// 'O' has a counter; its geometry must be identical at any simplify level.
80+
const exact = composeText(roboto, "O", { simplify: 0 });
81+
const coarse = composeText(roboto, "O", { simplify: 8 });
82+
expect(coarse.length).toBe(exact.length);
83+
});
84+
85+
it("still simplifies hole-less glyphs (poly reduction works)", () => {
86+
const exact = composeText(roboto, "M", { simplify: 0 });
87+
const coarse = composeText(roboto, "M", { simplify: 8 });
88+
expect(coarse.length).toBeLessThan(exact.length);
89+
});
90+
91+
it("round/bevel hold their counters too (inset never overruns the hole)", () => {
92+
for (const profile of ["round", "bevel"] as const) {
93+
expect(composeText(roboto, "o", { profile }).length).toBeGreaterThan(0);
94+
expect(composeText(roboto, "B", { profile, depth: 30 }).length).toBeGreaterThan(0);
95+
}
96+
});
97+
98+
// ── regression: scale / merge / layered ─────────────────────────────────
99+
it("horizontal scale widens the run", () => {
100+
const a = bounds(composeText(roboto, "AV"));
101+
const b = bounds(composeText(roboto, "AV", { scaleX: 2 }));
102+
expect(b.maxY - b.minY).toBeGreaterThan((a.maxY - a.minY) * 1.6);
103+
});
104+
105+
it("vertical scale heightens the glyphs", () => {
106+
const a = bounds(composeText(roboto, "A"));
107+
const b = bounds(composeText(roboto, "A", { scaleY: 2 }));
108+
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
109+
});
110+
111+
it("merge reduces the polygon count", () => {
112+
const base = composeText(roboto, "Poly", { merge: false });
113+
const merged = composeText(roboto, "Poly", { merge: true });
114+
expect(merged.length).toBeLessThan(base.length);
115+
});
116+
117+
it("layered back color + oblique recolors and offsets the back", () => {
118+
const polys = composeText(roboto, "o", { depth: 10, color: "#ff0000", backColor: "#00ff00", oblique: [12, -12] });
119+
const colors = new Set(polys.map((p) => p.color));
120+
expect(colors.has("#ff0000")).toBe(true); // front cap
121+
expect(colors.has("#00ff00")).toBe(true); // back cap
122+
});
123+
});

0 commit comments

Comments
 (0)