diff --git a/AGENTS.md b/AGENTS.md index 5bef8b4e..559d7415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,9 @@ Monorepo layout (pnpm workspaces): | `packages/polycss` | `@layoutit/polycss` | Vanilla renderer + custom elements (``, 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). diff --git a/packages/fonts/README.md b/packages/fonts/README.md new file mode 100644 index 00000000..c4eaa86d --- /dev/null +++ b/packages/fonts/README.md @@ -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. diff --git a/packages/fonts/package.json b/packages/fonts/package.json new file mode 100644 index 00000000..ac7262e1 --- /dev/null +++ b/packages/fonts/package.json @@ -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" + } +} diff --git a/packages/fonts/src/composeText.test.ts b/packages/fonts/src/composeText.test.ts new file mode 100644 index 00000000..9165f4c1 --- /dev/null +++ b/packages/fonts/src/composeText.test.ts @@ -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) { + 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) => + 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) => + 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 + }); +}); diff --git a/packages/fonts/src/composeText.ts b/packages/fonts/src/composeText.ts new file mode 100644 index 00000000..32764a9e --- /dev/null +++ b/packages/fonts/src/composeText.ts @@ -0,0 +1,220 @@ +/** + * Compose styled, multi-line text into a 3D polygon mesh. + * + * Builds on the same type-plane → extrude pipeline as `textPolygons`, adding + * line breaks (`\n`), per-line alignment, line height, underline / + * strikethrough bars, and classic WordArt-style **warps** (arch / arc / wave / + * bulge / slant). The warp deforms every point in the flat type plane before + * extrusion, so the 3D walls follow the curve too. Bold/italic are chosen by + * the caller by passing the appropriate weight/style `ParsedFont`. + */ +import { mergePolygons, type Polygon } from "@layoutit/polycss-core"; +import type { ParsedFont } from "./parseFont"; +import { + dedupeContour, + extrudeContours, + groupShapes, + shade, + simplifyContour, + type Contour, + type Pt, + type Shape, +} from "./extrude"; +import type { TextPolygonsOptions } from "./textPolygons"; + +/** Classic WordArt envelope shapes. */ +export type WarpShape = + | "none" + | "arch" + | "archDown" + | "arc" + | "wave" + | "bulge" + | "cone" + | "slantUp" + | "slantDown"; + +export interface WarpOptions { + shape: WarpShape; + /** Warp strength, 0..1. Defaults to 0.5. */ + amount?: number; +} + +export interface ComposeTextOptions extends TextPolygonsOptions { + /** Line advance as a multiple of `size`. Defaults to 1.25. */ + lineHeight?: number; + /** Horizontal alignment of each line within the block. Defaults to "center". */ + align?: "left" | "center" | "right"; + /** Horizontal glyph scale (Photoshop ↔). Defaults to 1. */ + scaleX?: number; + /** Vertical glyph scale (Photoshop ↕). Defaults to 1. */ + scaleY?: number; + /** Draw an underline bar under each line. */ + underline?: boolean; + /** Draw a strikethrough bar across each line. */ + strike?: boolean; + /** WordArt envelope warp applied to the whole block. */ + warp?: WarpOptions; + /** + * Outline simplification tolerance in world units (0 = exact). Drops points + * within this distance of their neighbours — fewer polygons, blockier glyphs. + */ + simplify?: number; + /** + * Merge coplanar same-color adjacent triangles into larger convex polygons + * (fewer DOM elements). Collapses the triangulated caps; ~⅓ fewer polygons + * on flat text. Has a CPU cost, so it's off by default. + */ + merge?: boolean; + /** Back cap color. Set differently from `color` for a layered look. */ + backColor?: string; + /** + * Oblique shift of the back relative to the front ([rightward, upward], world + * units). Non-zero + a distinct `backColor` gives the retro front-A / back-B + * leaning block. + */ + oblique?: [number, number]; +} + +type WarpFn = (p: Pt) => Pt; + +export function composeText(font: ParsedFont, text: string, options: ComposeTextOptions = {}): Polygon[] { + const size = options.size ?? 100; + const depth = options.depth ?? size * 0.2; + const curveSteps = Math.max(1, Math.round(options.curveSteps ?? 6)); + const letterSpacing = options.letterSpacing ?? 0; + const color = options.color ?? "#d4a82a"; + const sideColor = options.sideColor ?? shade(color, 0.72); + const profile = options.profile ?? "flat"; + const profileSegments = Math.max(1, Math.round(options.profileSegments ?? 6)); + const lineHeight = (options.lineHeight ?? 1.25) * size; + const align = options.align ?? "center"; + const simplify = Math.max(0, options.simplify ?? 0); + const scaleX = options.scaleX ?? 1; + const scaleY = options.scaleY ?? 1; + + const scale = size / font.unitsPerEm; + const barThickness = size * 0.06; + const underlineY = -size * 0.14; + const strikeY = size * 0.26; + + const lines = text.split("\n"); + const measured = lines.map((line) => { + const glyphs = [...line].map((ch) => font.glyph(ch.codePointAt(0) ?? 0, curveSteps)); + let width = 0; + for (const g of glyphs) width += g.advanceWidth * scale * scaleX + letterSpacing; + width = Math.max(0, width - letterSpacing); + return { glyphs, width }; + }); + const blockWidth = Math.max(1, ...measured.map((m) => m.width)); + const n = measured.length; + + const warp = makeWarp(options.warp, -blockWidth / 2, blockWidth, size); + const shapes: Shape[] = []; + + measured.forEach((line, lineIndex) => { + const baselineY = ((n - 1) / 2 - lineIndex) * lineHeight - size * 0.34; + let startX = -blockWidth / 2; + if (align === "center") startX += (blockWidth - line.width) / 2; + else if (align === "right") startX += blockWidth - line.width; + + let cursor = startX; + for (const g of line.glyphs) { + if (g.contours.length) { + const placed = g.contours.map((c) => + dedupeContour(c.map(([x, y]): Pt => [x * scale * scaleX + cursor, y * scale * scaleY + baselineY])), + ); + // Group on the accurate contours, then simplify — but ONLY glyphs with + // no holes. Simplifying a holed glyph (P, o, e, a…) can move the outer + // enough that the counter collapses or the offset cap overruns it, + // filling the hole. Holed glyphs stay full-detail so holes never break. + for (const shape of groupShapes(placed)) { + const simplified: Shape = simplify > 0 && shape.holes.length === 0 + ? { outer: simplifyContour(shape.outer, simplify), holes: shape.holes } + : shape; + shapes.push(warpShape(simplified, warp)); + } + } + cursor += g.advanceWidth * scale * scaleX + letterSpacing; + } + + if (line.width > 0) { + const x0 = startX; + const x1 = startX + line.width; + if (options.underline) { + shapes.push(warpShape(barShape(x0, x1, baselineY + underlineY - barThickness, baselineY + underlineY), warp)); + } + if (options.strike) { + shapes.push(warpShape(barShape(x0, x1, baselineY + strikeY - barThickness / 2, baselineY + strikeY + barThickness / 2), warp)); + } + } + }); + + const polygons = extrudeContours(shapes, { + depth, + profile, + profileSegments, + maxInset: size * 0.045, + color, + sideColor, + backColor: options.backColor, + oblique: options.oblique, + }); + return options.merge ? mergePolygons(polygons) : polygons; +} + +function warpShape(shape: Shape, warp: WarpFn | null): Shape { + if (!warp) return shape; + return { outer: shape.outer.map(warp), holes: shape.holes.map((h) => h.map(warp)) }; +} + +/** A subdivided bar rectangle so decoration bars can curve under a warp. */ +function barShape(x0: number, x1: number, yBot: number, yTop: number, segs = 24): Shape { + const outer: Contour = []; + for (let i = 0; i <= segs; i++) outer.push([x0 + ((x1 - x0) * i) / segs, yBot]); + for (let i = segs; i >= 0; i--) outer.push([x0 + ((x1 - x0) * i) / segs, yTop]); + return { outer, holes: [] }; +} + +/** + * Build a point-warp for the type plane. `u` is the normalized position along + * the block (0 at left, 1 at right); most shapes offset or scale y by a + * function of u, while "arc" wraps the baseline around a circle (rotating the + * letters with it). Returns null for "none". + */ +function makeWarp(opts: WarpOptions | undefined, left: number, width: number, size: number): WarpFn | null { + if (!opts || opts.shape === "none") return null; + const k = Math.max(0, Math.min(1, opts.amount ?? 0.5)); + if (k === 0) return null; + const u = (x: number) => (x - left) / width; + const bump = (t: number) => 1 - (2 * t - 1) * (2 * t - 1); // 0 at ends, 1 at center + + switch (opts.shape) { + case "arch": + return (p) => [p[0], p[1] + k * size * 0.7 * bump(u(p[0]))]; + case "archDown": + return (p) => [p[0], p[1] - k * size * 0.7 * bump(u(p[0]))]; + case "wave": + return (p) => [p[0], p[1] + k * size * 0.4 * Math.sin(2 * Math.PI * u(p[0]))]; + case "bulge": + return (p) => [p[0], p[1] * (1 + k * bump(u(p[0])))]; + case "cone": + return (p) => [p[0], p[1] * (1 - k * 0.75 * u(p[0]))]; + case "slantUp": + return (p) => [p[0], p[1] + k * size * 0.6 * (u(p[0]) - 0.5)]; + case "slantDown": + return (p) => [p[0], p[1] - k * size * 0.6 * (u(p[0]) - 0.5)]; + case "arc": { + const span = Math.max(0.08, k) * Math.PI; // up to 180° + const r = width / span; + const cx = left + width / 2; + return (p) => { + const theta = (u(p[0]) - 0.5) * span; + const rad = r + p[1]; + return [cx + rad * Math.sin(theta), -r + rad * Math.cos(theta)]; + }; + } + default: + return null; + } +} diff --git a/packages/fonts/src/extrude.ts b/packages/fonts/src/extrude.ts new file mode 100644 index 00000000..29553ea9 --- /dev/null +++ b/packages/fonts/src/extrude.ts @@ -0,0 +1,420 @@ +/** + * Shared 2D→3D extrusion used by both `textPolygons` (single line) and + * `composeText` (multiline / rich / WordArt). Callers place glyph and + * decoration contours into a flat "type plane" (x → right, y → up, in world + * units) and hand them here as pre-grouped shapes; this turns each shape into + * front/back caps + side walls following the chosen depth profile. + * + * Type plane → PolyCSS world: PolyCSS maps world X → screen-down, + * world Y → screen-right, world Z → toward the viewer. So world Y = plane x, + * world X = -plane y (screen-up), and depth runs along world Z. That single + * y-negation is a reflection, so it flips winding — every emitted polygon is + * wound in reverse to stay outward-facing (PolyCSS hides back-faces). + */ +import earcut from "earcut"; +import type { Polygon, Vec3 } from "@layoutit/polycss-core"; + +export type Pt = [number, number]; +export type Contour = Pt[]; + +/** + * Cross-section of the extrusion along its depth: + * - "flat" — straight slab with vertical walls (a depth-only extrude). + * - "round" — a quarter-circle round-over on the front/back edges (bullnose). + * - "bevel" — a straight 45° chamfer on the front/back edges. + */ +export type ExtrudeProfile = "flat" | "round" | "bevel"; + +export interface Shape { + outer: Contour; + holes: Contour[]; +} + +export interface ExtrudeOptions { + depth: number; + profile: ExtrudeProfile; + profileSegments: number; + /** Cap on inward edge inset (keeps round/bevel from pinching thin stems). */ + maxInset: number; + /** Front cap color. */ + color: string; + /** Side-wall color. */ + sideColor: string; + /** Back cap color. Defaults to `color`. Set differently for a layered look. */ + backColor?: string; + /** + * Oblique in-plane shift of the back relative to the front, in world units + * ([rightward, upward]). Non-zero turns the extrude into a leaning block so + * the differently-colored back peeks out (classic retro 3D / drop shadow). + */ + oblique?: [number, number]; + /** Depth offset applied to the whole shape (for layered/offset effects). */ + zOffset?: number; +} + +interface Ring { + z: number; + inset: number; +} + +const toWorld = (p: Pt, z: number): Vec3 => [-p[1], p[0], z]; + +/** Extrude pre-grouped 2D shapes (type-plane, world units) into polygons. */ +export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[] { + const { depth, profile, profileSegments, maxInset, color, sideColor } = opts; + const backColor = opts.backColor ?? color; + const [obx, oby] = opts.oblique ?? [0, 0]; + const zCenter = opts.zOffset ?? 0; + const frontZ = zCenter + depth / 2; + const backZ = zCenter - depth / 2; + const rings = buildRings(profile, frontZ, backZ, depth, profileSegments, maxInset); + const polygons: Polygon[] = []; + + // Each ring is shifted in-plane proportional to how far back it sits, so the + // back leans away from the front by the full oblique offset. + const obliqueAt = (z: number): Pt => { + const t = depth > 0 ? (frontZ - z) / depth : 0; + return [obx * t, oby * t]; + }; + const place = (p: Pt, z: number, o: Pt): Vec3 => toWorld([p[0] + o[0], p[1] + o[1]], z); + + const maxRingInset = rings.reduce((m, r) => Math.max(m, r.inset), 0); + + for (const shape of shapes) { + const contours = [shape.outer, ...shape.holes]; + + // Clamp the round/bevel inset to this glyph's thinnest feature so the offset + // can't cross itself (the hairline strokes of high-contrast display faces + // like Abril Fatface are thinner than a fixed inset would survive). + const insetScale = maxRingInset > 1e-6 + ? Math.min(1, safeInset(contours, maxRingInset) / maxRingInset) + : 1; + const si = (inset: number) => inset * insetScale; + + const cap = (inset: number, z: number, flip: boolean, capColor: string) => { + const o = obliqueAt(z); + const flat: number[] = []; + const holeIndices: number[] = []; + for (let r = 0; r < contours.length; r++) { + if (r > 0) holeIndices.push(flat.length / 2); + for (const [x, y] of offsetContour(contours[r], si(inset))) flat.push(x, y); + } + const tris = earcut(flat, holeIndices, 2); + const vert = (i: number): Pt => [flat[i * 2], flat[i * 2 + 1]]; + for (let t = 0; t < tris.length; t += 3) { + const a = vert(tris[t]); + const b = vert(tris[t + 1]); + const c = vert(tris[t + 2]); + const tri = flip ? [a, b, c] : [a, c, b]; + polygons.push({ + vertices: [place(tri[2], z, o), place(tri[1], z, o), place(tri[0], z, o)], + color: capColor, + }); + } + }; + + cap(rings[0].inset, rings[0].z, false, color); + cap(rings[rings.length - 1].inset, rings[rings.length - 1].z, true, backColor); + + for (const contour of contours) { + let prevOffset = offsetContour(contour, si(rings[0].inset)); + let prevO = obliqueAt(rings[0].z); + for (let r = 1; r < rings.length; r++) { + const curOffset = offsetContour(contour, si(rings[r].inset)); + const curO = obliqueAt(rings[r].z); + const z0 = rings[r - 1].z; + const z1 = rings[r].z; + for (let i = 0, len = contour.length; i < len; i++) { + const j = (i + 1) % len; + polygons.push({ + vertices: [ + place(curOffset[i], z1, curO), + place(curOffset[j], z1, curO), + place(prevOffset[j], z0, prevO), + place(prevOffset[i], z0, prevO), + ], + color: sideColor, + }); + } + prevOffset = curOffset; + prevO = curO; + } + } + } + + return polygons; +} + +function buildRings( + profile: ExtrudeProfile, + frontZ: number, + backZ: number, + depth: number, + seg: number, + maxInset: number, +): Ring[] { + if (profile === "flat" || depth <= 0) { + return [{ z: frontZ, inset: 0 }, { z: backZ, inset: 0 }]; + } + const edge = Math.min(maxInset, depth / 2); + const s = profile === "round" ? Math.max(2, seg) : 1; + const ease = profile === "round" + ? (u: number) => Math.cos((u * Math.PI) / 2) + : (u: number) => 1 - u; + + const rings: Ring[] = []; + for (let k = 0; k <= s; k++) { + const u = k / s; + rings.push({ z: frontZ - u * edge, inset: edge * ease(u) }); + } + if (backZ + edge < frontZ - edge - 1e-6) { + rings.push({ z: backZ + edge, inset: 0 }); + } + for (let k = 1; k <= s; k++) { + const u = 1 - k / s; + rings.push({ z: backZ + edge * u, inset: edge * ease(u) }); + } + return rings; +} + +/** + * Largest inset that's safe for this glyph: ~40% of the smallest gap between + * any two non-adjacent contour vertices (across the outer + holes). That gap is + * roughly the thinnest stroke / counter wall, so insetting less than half of it + * keeps the offset outer and hole edges from crossing. + */ +function safeInset(contours: Contour[], desired: number): number { + // Sample each contour at its vertices AND edge midpoints, so a thin stroke + // between two long edges is found even when the glyph was flattened coarsely + // (curve=1). `e` is a fractional edge index used to skip same/adjacent edges. + const pts: { x: number; y: number; c: number; e: number; n: number }[] = []; + contours.forEach((cont, ci) => { + const n = cont.length; + for (let i = 0; i < n; i++) { + const a = cont[i]; + const b = cont[(i + 1) % n]; + pts.push({ x: a[0], y: a[1], c: ci, e: i, n }); + pts.push({ x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, c: ci, e: i + 0.5, n }); + } + }); + let minSq = Infinity; + for (let a = 0; a < pts.length; a++) { + for (let b = a + 1; b < pts.length; b++) { + if (pts[a].c === pts[b].c) { + const d = Math.abs(pts[a].e - pts[b].e); + if (d <= 1.5 || d >= pts[a].n - 1.5) continue; // skip same/adjacent edges + } + const dx = pts[a].x - pts[b].x; + const dy = pts[a].y - pts[b].y; + const sq = dx * dx + dy * dy; + if (sq < minSq) minSq = sq; + } + } + return Math.min(desired, Math.sqrt(minSq) * 0.4); +} + +function leftNormal(a: Pt, b: Pt): Pt { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + return [-dy / len, dx / len]; +} + +/** + * Miter-offset a contour inward by `dist` (clamped miter so sharp corners + * don't spike). Positive `dist` shrinks a CCW outer ring and grows a CW hole. + */ +export function offsetContour(c: Contour, dist: number): Contour { + if (dist === 0) return c; + const n = c.length; + const out: Contour = new Array(n); + for (let i = 0; i < n; i++) { + const prev = c[(i - 1 + n) % n]; + const cur = c[i]; + const next = c[(i + 1) % n]; + const n1 = leftNormal(prev, cur); + const n2 = leftNormal(cur, next); + let mx = n1[0] + n2[0]; + let my = n1[1] + n2[1]; + const ml = Math.hypot(mx, my) || 1; + mx /= ml; + my /= ml; + const cos = mx * n1[0] + my * n1[1]; + const len = dist * Math.min(1.5, 1 / Math.max(cos, 1e-3)); + out[i] = [cur[0] + mx * len, cur[1] + my * len]; + } + return out; +} + +/** Perpendicular distance from point p to the infinite line through a→b. */ +function perpDistance(p: Pt, a: Pt, b: Pt): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]); + return Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / len; +} + +/** Ramer–Douglas–Peucker on an open polyline (keeps both endpoints). */ +function rdp(points: Contour, eps: number): Contour { + if (points.length < 3) return points; + const a = points[0]; + const b = points[points.length - 1]; + let maxD = 0; + let idx = 0; + for (let i = 1; i < points.length - 1; i++) { + const d = perpDistance(points[i], a, b); + if (d > maxD) { + maxD = d; + idx = i; + } + } + if (maxD > eps) { + const left = rdp(points.slice(0, idx + 1), eps); + const right = rdp(points.slice(idx), eps); + return left.slice(0, -1).concat(right); + } + return [a, b]; +} + +/** + * Simplify a closed contour by dropping points within `tolerance` of the line + * between their neighbours — cuts cap triangles and wall quads at the cost of + * detail (higher tolerance = blockier glyphs, fewer polygons). Anchored at the + * vertex farthest from point 0 so the closed loop simplifies symmetrically. + * + * The tolerance is clamped to a fraction of the contour's own size, so small + * counters/holes (e.g. the centre of `o`, `e`, `a`) never collapse no matter + * how high the global tolerance goes — holes stay holes. + */ +export function simplifyContour(c: Contour, tolerance: number): Contour { + if (tolerance <= 0 || c.length < 5) return c; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const [x, y] of c) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const eps = Math.min(tolerance, Math.hypot(maxX - minX, maxY - minY) * 0.12); + if (eps <= 1e-3) return c; + + let far = 0; + let best = -1; + for (let i = 1; i < c.length; i++) { + const d = Math.hypot(c[i][0] - c[0][0], c[i][1] - c[0][1]); + if (d > best) { + best = d; + far = i; + } + } + const first = rdp(c.slice(0, far + 1), eps); + const second = rdp([...c.slice(far), c[0]], eps); + const merged = first.concat(second.slice(1, -1)); + return merged.length >= 3 ? merged : c; +} + +export function dedupeContour(c: Contour, eps = 0.05): Contour { + const out: Contour = []; + for (const p of c) { + const last = out[out.length - 1]; + if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) > eps) out.push(p); + } + while (out.length > 1) { + const a = out[0]; + const b = out[out.length - 1]; + if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= eps) out.pop(); + else break; + } + return out; +} + +export function signedArea(c: Contour): number { + let a = 0; + for (let i = 0, n = c.length; i < n; i++) { + const [x0, y0] = c[i]; + const [x1, y1] = c[(i + 1) % n]; + a += x0 * y1 - x1 * y0; + } + return a / 2; +} + +function pointInPolygon(p: Pt, poly: Contour): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + const hit = yi > p[1] !== yj > p[1] && + p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi; + if (hit) inside = !inside; + } + return inside; +} + +function withWinding(c: Contour, ccw: boolean): Contour { + const positive = signedArea(c) > 0; + return positive === ccw ? c : c.slice().reverse(); +} + +/** + * Group contours into filled shapes with holes by nesting depth (even depth = + * filled, odd = hole of its immediate parent), independent of font winding. + * Call this PER glyph / per decoration — never across overlapping pieces, or a + * strikethrough bar would swallow the glyphs it crosses as "holes". + */ +export function groupShapes(contours: Contour[]): Shape[] { + const valid = contours.filter((c) => c.length >= 3); + const n = valid.length; + const depth = new Array(n).fill(0); + const parent = new Array(n).fill(-1); + + for (let i = 0; i < n; i++) { + const probe = valid[i][0]; + let bestParent = -1; + let bestArea = Infinity; + for (let j = 0; j < n; j++) { + if (i === j) continue; + if (pointInPolygon(probe, valid[j])) { + depth[i]++; + const a = Math.abs(signedArea(valid[j])); + if (a < bestArea) { + bestArea = a; + bestParent = j; + } + } + } + parent[i] = bestParent; + } + + const shapes: Shape[] = []; + const indexOfShape = new Map(); + for (let i = 0; i < n; i++) { + if (depth[i] % 2 === 0) { + indexOfShape.set(i, shapes.length); + shapes.push({ outer: withWinding(valid[i], true), holes: [] }); + } + } + for (let i = 0; i < n; i++) { + if (depth[i] % 2 === 1) { + const si = indexOfShape.get(parent[i]); + if (si !== undefined) shapes[si].holes.push(withWinding(valid[i], false)); + } + } + return shapes; +} + +/** Axis-aligned rectangle as a single shape (for underline / strike bars). */ +export function rectShape(x0: number, y0: number, x1: number, y1: number): Shape { + return { outer: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], holes: [] }; +} + +/** Multiply a hex color toward black by `f` (0..1). */ +export function shade(hex: string, f: number): string { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return hex; + const n = parseInt(m[1], 16); + const r = Math.round(((n >> 16) & 255) * f); + const g = Math.round(((n >> 8) & 255) * f); + const b = Math.round((n & 255) * f); + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; +} diff --git a/packages/fonts/src/googleFonts.ts b/packages/fonts/src/googleFonts.ts new file mode 100644 index 00000000..0006ac17 --- /dev/null +++ b/packages/fonts/src/googleFonts.ts @@ -0,0 +1,68 @@ +/** + * Browser-side loading helpers. These are the only part of the package that + * touches the network (`fetch`); the parse + extrude core stays pure. + * + * Fonts come from the Fontsource API/CDN, which mirrors every Google font and + * serves plain **.ttf** with open CORS — exactly what `parseFont` needs + * (Google's default woff2 is not supported). No API key required. + */ +import { parseFont, type ParsedFont } from "./parseFont"; + +export interface FontEntry { + id: string; + family: string; + weights: number[]; + styles: string[]; + subsets: string[]; + defSubset: string; + category: string; + type: string; +} + +const FONTS_API = "https://api.fontsource.org/v1/fonts"; +const WEIGHT_PREFERENCE = [700, 400, 500, 600, 800, 300, 900, 200, 100]; + +let cache: FontEntry[] | null = null; + +/** All Google fonts that ship a normal (upright) style, sorted by family. */ +export async function listGoogleFonts(): Promise { + if (cache) return cache; + const res = await fetch(FONTS_API); + if (!res.ok) throw new Error(`font list ${res.status}`); + const all = (await res.json()) as FontEntry[]; + cache = all + .filter((f) => f.type === "google" && f.styles.includes("normal")) + .sort((a, b) => a.family.localeCompare(b.family)); + return cache; +} + +/** Pick the requested weight if available, else the closest sensible default. */ +export function pickWeight(font: FontEntry, preferred?: number): number { + if (preferred && font.weights.includes(preferred)) return preferred; + for (const w of WEIGHT_PREFERENCE) { + if (font.weights.includes(w)) return w; + } + return font.weights[0] ?? 400; +} + +export type FontStyle = "normal" | "italic"; + +/** Direct .ttf URL for a font at a given weight/style (open CORS, parseFont-ready). */ +export function googleFontUrl(font: FontEntry, weight?: number, style: FontStyle = "normal"): string { + const w = pickWeight(font, weight); + const subset = font.subsets.includes("latin") ? "latin" : font.defSubset; + const s = style === "italic" && font.styles.includes("italic") ? "italic" : "normal"; + return `https://cdn.jsdelivr.net/fontsource/fonts/${font.id}@latest/${subset}-${w}-${s}.ttf`; +} + +/** Fetch a .ttf from any URL and parse it into a `ParsedFont`. */ +export async function loadFont(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`load font ${res.status}: ${url}`); + return parseFont(await res.arrayBuffer()); +} + +/** Fetch + parse a specific Google font family/weight/style. */ +export async function loadGoogleFont(font: FontEntry, weight?: number, style: FontStyle = "normal"): Promise { + return loadFont(googleFontUrl(font, weight, style)); +} diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts new file mode 100644 index 00000000..164017d8 --- /dev/null +++ b/packages/fonts/src/index.ts @@ -0,0 +1,26 @@ +// @layoutit/polycss-fonts — fonts + text → extruded 3D Polygon[] for PolyCSS. +// +// Two layers: +// pure — parseFont(bytes), textPolygons(font, text, config). No browser +// globals; runs anywhere, returns plain Polygon[]. +// browser — listGoogleFonts(), googleFontUrl(), loadFont(url), +// loadGoogleFont(). The only part that uses fetch. + +export { parseFont } from "./parseFont"; +export type { ParsedFont, FontGlyph } from "./parseFont"; + +export { textPolygons } from "./textPolygons"; +export type { TextPolygonsOptions } from "./textPolygons"; +export type { ExtrudeProfile } from "./extrude"; + +export { composeText } from "./composeText"; +export type { ComposeTextOptions, WarpShape, WarpOptions } from "./composeText"; + +export { + listGoogleFonts, + pickWeight, + googleFontUrl, + loadFont, + loadGoogleFont, +} from "./googleFonts"; +export type { FontEntry, FontStyle } from "./googleFonts"; diff --git a/packages/fonts/src/parseFont.test.ts b/packages/fonts/src/parseFont.test.ts new file mode 100644 index 00000000..2fd8b373 --- /dev/null +++ b/packages/fonts/src/parseFont.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { parseFont } from "./parseFont"; + +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")); + +describe("parseFont", () => { + it("reads font metrics", () => { + expect(roboto.unitsPerEm).toBe(2048); + expect(roboto.ascender).toBeGreaterThan(0); + expect(roboto.descender).toBeLessThan(0); + }); + + it("outlines a simple glyph with a positive advance", () => { + const g = roboto.glyph("o".codePointAt(0)!); + // 'o' is a ring: an outer contour plus one hole. + expect(g.contours.length).toBe(2); + expect(g.advanceWidth).toBeGreaterThan(0); + for (const c of g.contours) expect(c.length).toBeGreaterThanOrEqual(3); + }); + + it("gives a blank glyph but real advance for the space character", () => { + const space = roboto.glyph(" ".codePointAt(0)!); + expect(space.contours.length).toBe(0); + expect(space.advanceWidth).toBeGreaterThan(0); + }); + + it("resolves composite glyphs (accented letters)", () => { + // 'é' is composed from 'e' + an acute accent component. + const plain = roboto.glyph("e".codePointAt(0)!); + const accented = roboto.glyph("é".codePointAt(0)!); + expect(accented.contours.length).toBeGreaterThan(plain.contours.length); + }); + + it("returns the .notdef outline (gid 0) for unmapped codepoints", () => { + const missing = roboto.glyph(0x10ffff); + expect(missing.advanceWidth).toBeGreaterThanOrEqual(0); + }); + + it("flattens curves more finely at higher curveSteps", () => { + const coarse = roboto.glyph("o".codePointAt(0)!, 1); + const fine = roboto.glyph("o".codePointAt(0)!, 12); + const count = (g: typeof coarse) => g.contours.reduce((n, c) => n + c.length, 0); + expect(count(fine)).toBeGreaterThan(count(coarse)); + }); + + it("rejects non-TrueType data", () => { + const otto = new Uint8Array([0x4f, 0x54, 0x54, 0x4f, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(() => parseFont(otto)).toThrow(/CFF|OpenType|\.otf/); + const garbage = new Uint8Array([1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(() => parseFont(garbage)).toThrow(/not a TrueType/); + }); +}); diff --git a/packages/fonts/src/parseFont.ts b/packages/fonts/src/parseFont.ts new file mode 100644 index 00000000..1de75a79 --- /dev/null +++ b/packages/fonts/src/parseFont.ts @@ -0,0 +1,377 @@ +/** + * Minimal TrueType (`glyf`) font reader — bytes → glyph outlines + metrics. + * + * A font file is an sfnt container: a table directory pointing at named binary + * tables. We read only what's needed to lay out and outline text: + * + * head → unitsPerEm + loca format maxp → glyph count + * hhea/hmtx → advance widths cmap → codepoint → glyph index + * loca → glyph offsets into glyf glyf → the outline vectors + * + * Scope is deliberately narrow — this is a small, dependency-free reader for + * the common case, not a full font library: + * - TrueType outlines only (`glyf`). CFF/OpenType (".otf", magic "OTTO") is + * a different outline format (Type2 charstrings) and is rejected with a + * clear error. Google Fonts ship TrueType, so this covers most fonts. + * - Uncompressed sfnt only — woff/woff2 wrappers are not unpacked. + * - cmap formats 4 (BMP) and 12 (full Unicode). No shaping, kerning, + * ligatures, or variable-font axes: each character maps to one glyph plus + * its advance width. + * + * TrueType glyph space: font units, y-up, origin on the baseline. + */ +import type { Vec2 } from "@layoutit/polycss-core"; + +export interface FontGlyph { + /** Closed contours as flattened polylines, font units, y-up. */ + contours: Vec2[][]; + /** Advance width in font units. */ + advanceWidth: number; +} + +export interface ParsedFont { + /** Font design units per em (the scale denominator). */ + unitsPerEm: number; + /** Typographic ascender in font units. */ + ascender: number; + /** Typographic descender in font units (usually negative). */ + descender: number; + /** Recommended extra line spacing in font units. */ + lineGap: number; + /** Outline + advance for a Unicode codepoint. Empty contours for blanks. */ + glyph(codePoint: number, curveSteps?: number): FontGlyph; +} + +const TAG_TRUETYPE = 0x00010000; +const TAG_TRUE = 0x74727565; // 'true' +const TAG_OTTO = 0x4f54544f; // 'OTTO' (CFF) + +function tag(view: DataView, offset: number): string { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3), + ); +} + +export function parseFont(data: ArrayBuffer | Uint8Array, defaultCurveSteps = 8): ParsedFont { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const sfnt = view.getUint32(0); + if (sfnt === TAG_OTTO) { + throw new Error("parseFont: CFF/OpenType (.otf) outlines are not supported — use a TrueType (.ttf) font"); + } + if (sfnt !== TAG_TRUETYPE && sfnt !== TAG_TRUE) { + throw new Error(`parseFont: not a TrueType font (sfnt 0x${sfnt.toString(16)})`); + } + + const numTables = view.getUint16(4); + const tables = new Map(); + for (let i = 0; i < numTables; i++) { + const rec = 12 + i * 16; + tables.set(tag(view, rec), { offset: view.getUint32(rec + 8), length: view.getUint32(rec + 12) }); + } + + const need = (name: string): number => { + const t = tables.get(name); + if (!t) throw new Error(`parseFont: missing required '${name}' table`); + return t.offset; + }; + + const head = need("head"); + const unitsPerEm = view.getUint16(head + 18); + const indexToLocFormat = view.getInt16(head + 50); + + const numGlyphs = view.getUint16(need("maxp") + 4); + + const hhea = need("hhea"); + const ascender = view.getInt16(hhea + 4); + const descender = view.getInt16(hhea + 6); + const lineGap = view.getInt16(hhea + 8); + const numberOfHMetrics = view.getUint16(hhea + 34); + const hmtx = need("hmtx"); + + const advanceWidth = (glyphIndex: number): number => { + const i = glyphIndex < numberOfHMetrics ? glyphIndex : numberOfHMetrics - 1; + return view.getUint16(hmtx + i * 4); + }; + + // loca: glyph offsets into glyf. Short format stores halved u16 offsets. + const loca = need("loca"); + const glyfBase = need("glyf"); + const glyphRange = (gi: number): [number, number] => { + if (indexToLocFormat === 0) { + return [glyfBase + view.getUint16(loca + gi * 2) * 2, glyfBase + view.getUint16(loca + (gi + 1) * 2) * 2]; + } + return [glyfBase + view.getUint32(loca + gi * 4), glyfBase + view.getUint32(loca + (gi + 1) * 4)]; + }; + + const lookup = buildCmap(view, need("cmap")); + + // Decode one glyph into raw contours (points + on-curve flags), resolving + // composite glyphs recursively with their 2×2 + translate transforms. + type RawContour = { pts: Vec2[]; on: boolean[] }; + const rawGlyph = (gi: number, depth = 0): RawContour[] => { + if (gi < 0 || gi >= numGlyphs || depth > 8) return []; + const [start, end] = glyphRange(gi); + if (start >= end) return []; // empty glyph (e.g. space) + + let p = start; + const numberOfContours = view.getInt16(p); + p += 2 + 8; // skip numberOfContours field + xMin/yMin/xMax/yMax + + if (numberOfContours >= 0) { + const endPts: number[] = []; + for (let c = 0; c < numberOfContours; c++) { + endPts.push(view.getUint16(p)); + p += 2; + } + const numPoints = endPts.length ? endPts[endPts.length - 1] + 1 : 0; + const instrLen = view.getUint16(p); + p += 2 + instrLen; // skip hinting instructions + + const flags = new Uint8Array(numPoints); + for (let i = 0; i < numPoints;) { + const f = view.getUint8(p++); + flags[i++] = f; + if (f & 0x08) { + let repeat = view.getUint8(p++); + while (repeat-- > 0 && i < numPoints) flags[i++] = f; + } + } + + const readCoords = (shortBit: number, sameBit: number): number[] => { + const out = new Array(numPoints); + let v = 0; + for (let i = 0; i < numPoints; i++) { + const f = flags[i]; + if (f & shortBit) { + const d = view.getUint8(p++); + v += f & sameBit ? d : -d; + } else if (!(f & sameBit)) { + v += view.getInt16(p); + p += 2; + } + out[i] = v; + } + return out; + }; + const xs = readCoords(0x02, 0x10); + const ys = readCoords(0x04, 0x20); + + const contours: RawContour[] = []; + let s = 0; + for (const e of endPts) { + const pts: Vec2[] = []; + const on: boolean[] = []; + for (let i = s; i <= e; i++) { + pts.push([xs[i], ys[i]]); + on.push((flags[i] & 0x01) !== 0); + } + if (pts.length) contours.push({ pts, on }); + s = e + 1; + } + return contours; + } + + // Composite glyph: assemble from component glyphs. + const contours: RawContour[] = []; + let more = true; + while (more) { + const flags = view.getUint16(p); + const compGi = view.getUint16(p + 2); + p += 4; + let dx = 0; + let dy = 0; + if (flags & 0x0001) { + // ARG_1_AND_2_ARE_WORDS + dx = view.getInt16(p); + dy = view.getInt16(p + 2); + p += 4; + } else { + dx = view.getInt8(p); + dy = view.getInt8(p + 1); + p += 2; + } + const f2 = (off: number) => view.getInt16(off) / 16384; + let a = 1; + let b = 0; + let c = 0; + let d = 1; + if (flags & 0x0008) { + a = d = f2(p); + p += 2; + } else if (flags & 0x0040) { + a = f2(p); + d = f2(p + 2); + p += 4; + } else if (flags & 0x0080) { + a = f2(p); + b = f2(p + 2); + c = f2(p + 4); + d = f2(p + 6); + p += 8; + } + // Only ARGS_ARE_XY_VALUES placement is handled; point-matching is rare. + const useXY = (flags & 0x0002) !== 0; + for (const ct of rawGlyph(compGi, depth + 1)) { + contours.push({ + on: ct.on, + pts: ct.pts.map(([px, py]): Vec2 => [ + a * px + c * py + (useXY ? dx : 0), + b * px + d * py + (useXY ? dy : 0), + ]), + }); + } + more = (flags & 0x0020) !== 0; // MORE_COMPONENTS + } + return contours; + }; + + const glyph = (codePoint: number, curveSteps = defaultCurveSteps): FontGlyph => { + const gi = lookup(codePoint); + const steps = Math.max(1, Math.round(curveSteps)); + const contours = rawGlyph(gi) + .map((c) => flattenContour(c.pts, c.on, steps)) + .filter((c) => c.length >= 2); + return { contours, advanceWidth: advanceWidth(gi) }; + }; + + return { unitsPerEm, ascender, descender, lineGap, glyph }; +} + +/** Build a codepoint → glyph-index lookup from the best available cmap subtable. */ +function buildCmap(view: DataView, cmap: number): (cp: number) => number { + const numSub = view.getUint16(cmap + 2); + let best = -1; + let bestScore = -1; + for (let i = 0; i < numSub; i++) { + const rec = cmap + 4 + i * 8; + const platform = view.getUint16(rec); + const encoding = view.getUint16(rec + 2); + const offset = view.getUint32(rec + 4); + const format = view.getUint16(cmap + offset); + // Prefer full-Unicode (12) over BMP (4); prefer Unicode/Windows platforms. + let score = 0; + if (format === 12) score += 4; + else if (format === 4) score += 2; + else continue; + if (platform === 3 && (encoding === 1 || encoding === 10)) score += 1; + if (platform === 0) score += 1; + if (score > bestScore) { + bestScore = score; + best = cmap + offset; + } + } + if (best < 0) throw new Error("parseFont: no supported cmap subtable (need format 4 or 12)"); + + const format = view.getUint16(best); + if (format === 12) return cmapFormat12(view, best); + return cmapFormat4(view, best); +} + +function cmapFormat4(view: DataView, sub: number): (cp: number) => number { + const segX2 = view.getUint16(sub + 6); + const segCount = segX2 / 2; + const endCodes = sub + 14; + const startCodes = endCodes + segX2 + 2; + const idDeltas = startCodes + segX2; + const idRangeOffsets = idDeltas + segX2; + return (cp: number): number => { + if (cp > 0xffff) return 0; + for (let i = 0; i < segCount; i++) { + if (view.getUint16(endCodes + i * 2) < cp) continue; + if (view.getUint16(startCodes + i * 2) > cp) return 0; + const delta = view.getInt16(idDeltas + i * 2); + const rangeOffset = view.getUint16(idRangeOffsets + i * 2); + if (rangeOffset === 0) return (cp + delta) & 0xffff; + const start = view.getUint16(startCodes + i * 2); + const addr = idRangeOffsets + i * 2 + rangeOffset + (cp - start) * 2; + const gid = view.getUint16(addr); + return gid === 0 ? 0 : (gid + delta) & 0xffff; + } + return 0; + }; +} + +function cmapFormat12(view: DataView, sub: number): (cp: number) => number { + const nGroups = view.getUint32(sub + 12); + const groups = sub + 16; + return (cp: number): number => { + for (let i = 0; i < nGroups; i++) { + const g = groups + i * 12; + const start = view.getUint32(g); + const endCode = view.getUint32(g + 4); + if (cp < start) return 0; + if (cp <= endCode) return view.getUint32(g + 8) + (cp - start); + } + return 0; + }; +} + +/** + * Flatten a TrueType quadratic contour into a polyline. Off-curve points are + * quadratic control points; two consecutive off-curve points imply an on-curve + * midpoint between them, so we expand those first, then walk on→(quad)→on. + */ +function flattenContour(pts: Vec2[], on: boolean[], steps: number): Vec2[] { + const n = pts.length; + if (n < 2) return pts.slice(); + + const ep: Vec2[] = []; + const eon: boolean[] = []; + for (let i = 0; i < n; i++) { + ep.push(pts[i]); + eon.push(on[i]); + const j = (i + 1) % n; + if (!on[i] && !on[j]) { + ep.push([(pts[i][0] + pts[j][0]) / 2, (pts[i][1] + pts[j][1]) / 2]); + eon.push(true); + } + } + + let s = eon.indexOf(true); + if (s < 0) { + // All points off-curve: synthesize an on-curve start at a midpoint. + const m = ep.length; + ep.unshift([(ep[m - 1][0] + ep[0][0]) / 2, (ep[m - 1][1] + ep[0][1]) / 2]); + eon.unshift(true); + s = 0; + } + + const m = ep.length; + const out: Vec2[] = [ep[s]]; + let cur = ep[s]; + let i = 1; + while (i <= m) { + const idx = (s + i) % m; + if (eon[idx]) { + out.push(ep[idx]); + cur = ep[idx]; + i += 1; + } else { + const ctrl = ep[idx]; + const end = ep[(s + i + 1) % m]; + for (let k = 1; k <= steps; k++) { + const t = k / steps; + const mt = 1 - t; + out.push([ + mt * mt * cur[0] + 2 * mt * t * ctrl[0] + t * t * end[0], + mt * mt * cur[1] + 2 * mt * t * ctrl[1] + t * t * end[1], + ]); + } + cur = end; + i += 2; + } + } + + // Drop the closing point that lands back on the start. + if (out.length > 1) { + const a = out[0]; + const b = out[out.length - 1]; + if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-6) out.pop(); + } + return out; +} diff --git a/packages/fonts/src/textPolygons.test.ts b/packages/fonts/src/textPolygons.test.ts new file mode 100644 index 00000000..d6008b60 --- /dev/null +++ b/packages/fonts/src/textPolygons.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { parseFont } from "./parseFont"; +import { textPolygons } from "./textPolygons"; + +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) { + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const p of polys) { + for (const [x, y, z] of p.vertices) { + minX = Math.min(minX, x); maxX = Math.max(maxX, x); + minY = Math.min(minY, y); maxY = Math.max(maxY, y); + minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z); + } + } + return { minX, maxX, minY, maxY, minZ, maxZ }; +} + +describe("textPolygons", () => { + it("produces polygons for a word", () => { + const polys = textPolygons(roboto, "Poly"); + expect(polys.length).toBeGreaterThan(0); + for (const p of polys) expect(p.vertices.length).toBeGreaterThanOrEqual(3); + }); + + it("centers the run horizontally around the origin (world Y)", () => { + const b = bounds(textPolygons(roboto, "Poly")); + expect(Math.abs(b.minY + b.maxY)).toBeLessThan(b.maxY - b.minY); // roughly symmetric + }); + + it("flat profile depth matches the requested depth on world Z", () => { + const depth = 30; + const b = bounds(textPolygons(roboto, "I", { depth, profile: "flat" })); + expect(b.maxZ - b.minZ).toBeCloseTo(depth, 5); + }); + + it("longer text produces more polygons", () => { + expect(textPolygons(roboto, "Polygon").length).toBeGreaterThan(textPolygons(roboto, "Po").length); + }); + + it("round and bevel profiles add side rings (more polys than flat)", () => { + const flat = textPolygons(roboto, "o", { profile: "flat" }).length; + const round = textPolygons(roboto, "o", { profile: "round" }).length; + const bevel = textPolygons(roboto, "o", { profile: "bevel" }).length; + expect(round).toBeGreaterThan(flat); + expect(bevel).toBeGreaterThan(flat); + }); + + it("applies cap and side colors", () => { + const polys = textPolygons(roboto, "o", { color: "#ff0000", sideColor: "#00ff00" }); + const colors = new Set(polys.map((p) => p.color)); + expect(colors.has("#ff0000")).toBe(true); + expect(colors.has("#00ff00")).toBe(true); + }); + + it("ignores whitespace-only glyphs but advances layout", () => { + const withSpace = textPolygons(roboto, "a b"); + const noSpace = textPolygons(roboto, "ab"); + // Same glyph geometry count; the space only shifts positions. + expect(withSpace.length).toBe(noSpace.length); + expect(bounds(withSpace).maxY - bounds(withSpace).minY) + .toBeGreaterThan(bounds(noSpace).maxY - bounds(noSpace).minY); + }); +}); diff --git a/packages/fonts/src/textPolygons.ts b/packages/fonts/src/textPolygons.ts new file mode 100644 index 00000000..76d69399 --- /dev/null +++ b/packages/fonts/src/textPolygons.ts @@ -0,0 +1,79 @@ +/** + * Extrude a single line of text into a 3D polygon mesh PolyCSS can render. + * For multiline / styled / WordArt composition see `composeText`. + * + * parseFont emits glyph space as font units, y-up, baseline at 0. We place + * each glyph into a flat "type plane" (x → right along the baseline, y → up), + * centered on the origin, then hand the grouped shapes to `extrudeContours`. + */ +import type { Polygon } from "@layoutit/polycss-core"; +import type { ParsedFont } from "./parseFont"; +import { + dedupeContour, + extrudeContours, + groupShapes, + shade, + type ExtrudeProfile, + type Pt, + type Shape, +} from "./extrude"; + +export type { ExtrudeProfile }; + +export interface TextPolygonsOptions { + /** Cap-em size in world units. Defaults to 100. */ + size?: number; + /** Extrusion depth along the world Z axis, in world units. Defaults to size*0.2. */ + depth?: number; + /** Bézier flattening: segments per curve. Higher = smoother, more polys. */ + curveSteps?: number; + /** Extra space between glyphs, in world units. */ + letterSpacing?: number; + /** Front/back cap color. */ + color?: string; + /** Side-wall color. Defaults to a slightly darker shade of `color`. */ + sideColor?: string; + /** Depth cross-section shape. Defaults to "flat". */ + profile?: ExtrudeProfile; + /** Rings used to sample a round/bevel profile. Higher = smoother, more polys. */ + profileSegments?: number; +} + +export function textPolygons(font: ParsedFont, text: string, options: TextPolygonsOptions = {}): Polygon[] { + const size = options.size ?? 100; + const depth = options.depth ?? size * 0.2; + const curveSteps = Math.max(1, Math.round(options.curveSteps ?? 6)); + const letterSpacing = options.letterSpacing ?? 0; + const color = options.color ?? "#d4a82a"; + const sideColor = options.sideColor ?? shade(color, 0.72); + const profile = options.profile ?? "flat"; + const profileSegments = Math.max(1, Math.round(options.profileSegments ?? 6)); + + const scale = size / font.unitsPerEm; + const chars = [...text]; + const glyphs = chars.map((ch) => font.glyph(ch.codePointAt(0) ?? 0, curveSteps)); + + let totalAdvance = 0; + for (const g of glyphs) totalAdvance += g.advanceWidth * scale + letterSpacing; + let cursor = -totalAdvance / 2; + + const shapes: Shape[] = []; + for (const g of glyphs) { + if (g.contours.length) { + const placed = g.contours.map((c) => + dedupeContour(c.map(([x, y]): Pt => [x * scale + cursor, y * scale])), + ); + shapes.push(...groupShapes(placed)); + } + cursor += g.advanceWidth * scale + letterSpacing; + } + + return extrudeContours(shapes, { + depth, + profile, + profileSegments, + maxInset: size * 0.045, + color, + sideColor, + }); +} diff --git a/packages/fonts/test/fixtures/Roboto-Bold.ttf b/packages/fonts/test/fixtures/Roboto-Bold.ttf new file mode 100644 index 00000000..43da14d8 Binary files /dev/null and b/packages/fonts/test/fixtures/Roboto-Bold.ttf differ diff --git a/packages/fonts/tsconfig.json b/packages/fonts/tsconfig.json new file mode 100644 index 00000000..1f39a844 --- /dev/null +++ b/packages/fonts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/fonts/tsup.config.ts b/packages/fonts/tsup.config.ts new file mode 100644 index 00000000..0ffa61ee --- /dev/null +++ b/packages/fonts/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + minify: true, + target: "es2020", + tsconfig: "tsconfig.json", +}); diff --git a/packages/fonts/vitest.config.ts b/packages/fonts/vitest.config.ts new file mode 100644 index 00000000..bcdad4d6 --- /dev/null +++ b/packages/fonts/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, + resolve: { + alias: { + "@layoutit/polycss-core": path.resolve(__dirname, "../core/src/index.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3535836..d71c8c7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,31 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9) + packages/fonts: + dependencies: + '@layoutit/polycss-core': + specifier: workspace:^ + version: link:../core + earcut: + specifier: ^3.0.1 + version: 3.0.2 + devDependencies: + '@types/earcut': + specifier: ^3.0.0 + version: 3.0.0 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9)) + tsup: + specifier: ^8.0.1 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9) + packages/polycss: dependencies: '@layoutit/polycss-core': @@ -216,6 +241,9 @@ importers: '@layoutit/polycss': specifier: workspace:^ version: link:../packages/polycss + '@layoutit/polycss-fonts': + specifier: workspace:^ + version: link:../packages/fonts '@layoutit/polycss-react': specifier: workspace:^ version: link:../packages/react @@ -1324,6 +1352,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1768,6 +1799,9 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4076,6 +4110,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -4628,6 +4664,8 @@ snapshots: dset@3.1.4: {} + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.329: {} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index e539b69a..7d9ab2d8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -31,6 +31,10 @@ export default defineConfig({ find: /^@layoutit\/polycss\/elements$/, replacement: repoPath('../packages/polycss/src/elements/index.ts'), }, + { + find: /^@layoutit\/polycss-fonts$/, + replacement: repoPath('../packages/fonts/src/index.ts'), + }, { find: /^@layoutit\/polycss$/, replacement: repoPath('../packages/polycss/src/index.ts'), diff --git a/website/package.json b/website/package.json index 9e71a48e..1611daf0 100644 --- a/website/package.json +++ b/website/package.json @@ -15,6 +15,7 @@ "@astrojs/sitemap": "^3.7.1", "@astrojs/starlight": "^0.38.2", "@layoutit/polycss": "workspace:^", + "@layoutit/polycss-fonts": "workspace:^", "@layoutit/polycss-react": "workspace:^", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/website/public/fonts/default.ttf b/website/public/fonts/default.ttf new file mode 100644 index 00000000..43da14d8 Binary files /dev/null and b/website/public/fonts/default.ttf differ diff --git a/website/src/components/DocsHeader.astro b/website/src/components/DocsHeader.astro index 28bc33a7..c20b8bad 100644 --- a/website/src/components/DocsHeader.astro +++ b/website/src/components/DocsHeader.astro @@ -11,6 +11,7 @@ const pathname = Astro.url.pathname.replace(/\/$/, '') || '/'; const isLanding = pathname === '/'; const isGallery = pathname.startsWith('/gallery'); const isBuilder = pathname.startsWith('/builder'); +const isWordart = pathname.startsWith('/wordart'); // Hide search dock on landing + workbench pages (gallery, builder) where // the Starlight floating search position overlaps the workbench's own // sidebar / search input. Branding + GitHub still render on workbenches — @@ -31,6 +32,7 @@ const topLinks = [ }, { href: '/gallery', label: 'Gallery', active: pathname.startsWith('/gallery') }, { href: '/builder', label: 'Builder', active: pathname.startsWith('/builder') }, + { href: '/wordart', label: 'WordArt', active: pathname.startsWith('/wordart') }, ]; --- @@ -40,7 +42,7 @@ const topLinks = [ )} - {!isLanding && !isGallery && !isBuilder && ( + {!isLanding && !isGallery && !isBuilder && !isWordart && ( diff --git a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx new file mode 100644 index 00000000..229c9cf7 --- /dev/null +++ b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx @@ -0,0 +1,709 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + BASE_TILE, + PolyMesh, + PolyOrthographicCamera, + PolyPerspectiveCamera, + PolyScene, +} from "@layoutit/polycss-react"; +import type { Vec3 } from "@layoutit/polycss-react"; +import type { Polygon } from "@layoutit/polycss-react"; +import { exportPolySceneSnapshot } from "@layoutit/polycss"; +import GUI from "lil-gui"; +import { StatsOverlay } from "../StatsOverlay"; +import { + composeText, + listGoogleFonts, + loadFont, + loadGoogleFont, + pickWeight, + type ExtrudeProfile, + type FontEntry, + type ParsedFont, + type WarpShape, +} from "@layoutit/polycss-fonts"; +import "./wordart.css"; + +type Align = "left" | "center" | "right"; + +interface Preset { + label: string; + profile: ExtrudeProfile; + depth: number; + color: string; + sideColor: string; + /** Back-face color (layered look when different + offset > 0). */ + backColor?: string; + /** Diagonal back offset for the layered block (down-right). */ + offset?: number; + warp?: { shape: WarpShape; amount: number }; +} + +// Left-rail style presets — each is a full "look": extrusion, layered front/back, +// and/or a baked-in WordArt warp (like the builder's shape tiles). +const PRESETS: Preset[] = [ + { label: "Gold Bevel", profile: "bevel", depth: 26, color: "#d4a82a", sideColor: "#7c5e16" }, + { label: "Chrome Round", profile: "round", depth: 32, color: "#cdd3da", sideColor: "#5f6772" }, + { label: "Retro Block", profile: "flat", depth: 6, color: "#ff4d6d", sideColor: "#3a0ca3", backColor: "#3a0ca3", offset: 16 }, + { label: "Comic Pop", profile: "flat", depth: 6, color: "#ffd166", sideColor: "#1d3557", backColor: "#1d3557", offset: 13 }, + { label: "Arch Gold", profile: "bevel", depth: 22, color: "#e9b949", sideColor: "#8a5a12", warp: { shape: "arch", amount: 0.6 } }, + { label: "Wave Mint", profile: "round", depth: 24, color: "#7cffb2", sideColor: "#2f8f5e", warp: { shape: "wave", amount: 0.55 } }, + { label: "Arc Crimson", profile: "flat", depth: 16, color: "#ff5470", sideColor: "#9c2740", warp: { shape: "arc", amount: 0.7 } }, + { label: "Ink Shadow", profile: "flat", depth: 4, color: "#e8edf2", sideColor: "#2b313b", backColor: "#2b313b", offset: 12 }, +]; + +function applyCase(text: string, mode: "as-typed" | "upper" | "lower" | "title"): string { + if (mode === "upper") return text.toUpperCase(); + if (mode === "lower") return text.toLowerCase(); + if (mode === "title") return text.replace(/\b\p{L}/gu, (c) => c.toUpperCase()); + return text; +} + +function readable(hex: string): string { + const m = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!m) return "#000"; + const n = parseInt(m[1], 16); + const lum = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); + return lum > 150 ? "#0b0f18" : "#ffffff"; +} + +function fitZoom(polygons: Polygon[], stageW: number, stageH: number): number { + if (!polygons.length) return 0.06; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const p of polygons) { + for (const v of p.vertices) { + if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0]; + if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1]; + if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2]; + } + } + // Flat text: Y = width (screen-right), X = height (screen-down), Z = depth. + // As the mesh turntables, width swings into depth, so fit the larger of the two. + const horizontal = Math.max(maxY - minY, maxZ - minZ); + const vertical = maxX - minX; + const fitW = (stageW * 0.7) / (Math.max(horizontal, 1) * BASE_TILE); + const fitH = (stageH * 0.68) / (Math.max(vertical, 1) * BASE_TILE); + return Math.max(0.01, Math.min(0.2, Math.min(fitW, fitH))); +} + +function codePenPayload(snapshotHtml: string, title: string): string { + const parsed = new DOMParser().parseFromString(snapshotHtml, "text/html"); + const css = Array.from(parsed.querySelectorAll("style")).map((s) => s.textContent ?? "").filter(Boolean).join("\n\n"); + const html = parsed.body.innerHTML.trim() || snapshotHtml; + return JSON.stringify({ title, html, css, editors: "100", layout: "left" }); +} + +function openCodePen(html: string, title: string): void { + const form = document.createElement("form"); + form.action = "https://codepen.io/pen/define"; + form.method = "POST"; + form.target = "_blank"; + form.setAttribute("rel", "noopener noreferrer"); + form.style.display = "none"; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "data"; + input.value = codePenPayload(html, title); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + form.remove(); +} + +// All controls persist to the URL query string so any look is a shareable link. +const URL_SEARCH = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams(); +const qs = (k: string, d: string) => URL_SEARCH.get(k) ?? d; +const qn = (k: string, d: number) => (URL_SEARCH.has(k) ? Number(URL_SEARCH.get(k)) : d); +const qb = (k: string, d: boolean) => (URL_SEARCH.has(k) ? URL_SEARCH.get(k) === "1" : d); + +export function WordArtWorkbench() { + const [font, setFont] = useState(null); + const [catalog, setCatalog] = useState([]); + const [entry, setEntry] = useState(null); + const [familyInput, setFamilyInput] = useState(() => qs("font", "")); + const [weight, setWeight] = useState(() => qn("weight", 700)); + const [italic, setItalic] = useState(() => qb("italic", false)); + const [status, setStatus] = useState(""); + + const [text, setText] = useState(() => qs("text", "Poly\nCSS")); + const [textCase, setTextCase] = useState<"as-typed" | "upper" | "lower" | "title">(() => qs("case", "as-typed") as "as-typed"); + const [scaleX, setScaleX] = useState(() => qn("sx", 100)); + const [scaleY, setScaleY] = useState(() => qn("sy", 100)); + const [profile, setProfile] = useState(() => qs("profile", "bevel") as ExtrudeProfile); + const [depth, setDepth] = useState(() => qn("depth", 26)); + const [letterSpacing, setLetterSpacing] = useState(() => qn("ls", 0)); + const [lineHeight, setLineHeight] = useState(() => qn("lh", 1.15)); + const [align, setAlign] = useState(() => qs("align", "center") as Align); + const [underline, setUnderline] = useState(() => qb("ul", false)); + const [strike, setStrike] = useState(() => qb("st", false)); + const [color, setColor] = useState(() => qs("color", "#d4a82a")); + const [sideColor, setSideColor] = useState(() => qs("side", "#7c5e16")); + const [backColor, setBackColor] = useState(() => qs("back", "#7c5e16")); + const [offset, setOffset] = useState(() => qn("offset", 0)); + const [curveSegments, setCurveSegments] = useState(() => qn("curve", 4)); + const [simplify, setSimplify] = useState(() => qn("simplify", 2)); + const [merge, setMerge] = useState(() => qb("merge", false)); + const [profileSegments, setProfileSegments] = useState(() => qn("edge", 3)); + const [warpShape, setWarpShape] = useState(() => qs("warp", "none") as WarpShape); + const [warpAmount, setWarpAmount] = useState(() => qn("bend", 0.5)); + const [spin, setSpin] = useState(() => qb("spin", true)); + // Camera + lighting (gallery-style) + const [perspective, setPerspective] = useState(() => qb("persp", true)); + const [zoomScale, setZoomScale] = useState(() => qn("zoom", 1)); + const [lightIntensity, setLightIntensity] = useState(() => qn("li", 0.95)); + const [ambient, setAmbient] = useState(() => qn("amb", 0.5)); + const [lightColor, setLightColor] = useState(() => qs("lc", "#ffffff")); + const [lightAz, setLightAz] = useState(() => qn("laz", -25)); + const [lightEl, setLightEl] = useState(() => qn("lel", 45)); + const [activePreset, setActivePreset] = useState(null); + const [exporting, setExporting] = useState(false); + + const handleCodePen = async () => { + const el = document.querySelector(".wa-stage .polycss-camera") + ?? document.querySelector(".wa-stage .polycss-scene"); + if (!el || exporting) return; + setExporting(true); + try { + const html = await exportPolySceneSnapshot(el); + openCodePen(html, `PolyCSS WordArt — ${text.replace(/\n/g, " ")}`); + } catch (e) { + setStatus(`CodePen export failed: ${(e as Error).message}`); + } finally { + setExporting(false); + } + }; + + // Default bundled font + Google catalog. If the URL named a font, select it + // once the catalog is in. + useEffect(() => { + loadFont("/fonts/default.ttf").then(setFont).catch((e) => setStatus(String(e))); + listGoogleFonts() + .then((c) => { + setCatalog(c); + const wanted = qs("font", "").trim().toLowerCase(); + if (wanted) { + const f = c.find((e) => e.family.toLowerCase() === wanted); + if (f) setEntry(f); + } + }) + .catch(() => {}); + }, []); + + // Persist every control to the URL (non-defaults only, for short links). + useEffect(() => { + const p = new URLSearchParams(); + const ss = (k: string, v: string, d: string) => { if (v !== d) p.set(k, v); }; + const sn = (k: string, v: number, d: number) => { if (v !== d) p.set(k, String(v)); }; + p.set("text", text); + if (entry) p.set("font", entry.family); + sn("weight", weight, 700); + if (italic) p.set("italic", "1"); + ss("case", textCase, "as-typed"); + sn("sx", scaleX, 100); + sn("sy", scaleY, 100); + ss("profile", profile, "bevel"); + sn("depth", depth, 26); + sn("ls", letterSpacing, 0); + sn("lh", lineHeight, 1.15); + ss("align", align, "center"); + if (underline) p.set("ul", "1"); + if (strike) p.set("st", "1"); + ss("color", color, "#d4a82a"); + ss("side", sideColor, "#7c5e16"); + ss("back", backColor, "#7c5e16"); + sn("offset", offset, 0); + sn("curve", curveSegments, 1); + sn("simplify", simplify, 2); + if (merge) p.set("merge", "1"); + sn("edge", profileSegments, 3); + ss("warp", warpShape, "none"); + sn("bend", warpAmount, 0.5); + if (!spin) p.set("spin", "0"); + if (!perspective) p.set("persp", "0"); + sn("zoom", zoomScale, 1); + sn("li", lightIntensity, 0.95); + sn("amb", ambient, 0.5); + ss("lc", lightColor, "#ffffff"); + sn("laz", lightAz, -25); + sn("lel", lightEl, 45); + const search = p.toString(); + window.history.replaceState(null, "", `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`); + }, [text, entry, weight, italic, textCase, scaleX, scaleY, profile, depth, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, merge, profileSegments, warpShape, warpAmount, spin, perspective, zoomScale, lightIntensity, ambient, lightColor, lightAz, lightEl]); + + // Load the picked Google font whenever family / weight / style changes. + useEffect(() => { + if (!entry) return; + let alive = true; + setStatus(`loading ${entry.family}…`); + loadGoogleFont(entry, weight, italic ? "italic" : "normal") + .then((f) => { + if (alive) { + setFont(f); + setStatus(`${entry.family} ${weight}${italic ? " italic" : ""}`); + } + }) + .catch((e) => alive && setStatus(`couldn't load ${entry.family}: ${e}`)); + return () => { + alive = false; + }; + }, [entry, weight, italic]); + + const polygons = useMemo(() => { + if (!font) return []; + return composeText(font, applyCase(text, textCase), { + size: 100, + depth, + profile, + letterSpacing, + lineHeight, + align, + scaleX: scaleX / 100, + scaleY: scaleY / 100, + underline, + strike, + color, + sideColor, + backColor, + oblique: offset ? [offset, -offset] : undefined, + curveSteps: curveSegments, + simplify, + merge, + profileSegments, + warp: { shape: warpShape, amount: warpAmount }, + }); + }, [font, text, textCase, scaleX, scaleY, depth, profile, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, merge, profileSegments, warpShape, warpAmount]); + + // Directional light direction from azimuth (left/right) + elevation (height), + // always biased toward the front so the face stays lit. + const lightDir = useMemo(() => { + const a = (lightAz * Math.PI) / 180; + const e = (lightEl * Math.PI) / 180; + return [Math.sin(e), Math.sin(a) * Math.cos(e), -Math.max(0.25, Math.cos(e))]; + }, [lightAz, lightEl]); + + function pickFamily(value: string) { + setFamilyInput(value); + const f = catalog.find((e) => e.family.toLowerCase() === value.trim().toLowerCase()); + if (f) { + setEntry(f); + setWeight(pickWeight(f, weight)); + } + } + + function applyPreset(p: Preset) { + setProfile(p.profile); + setDepth(p.depth); + setColor(p.color); + setSideColor(p.sideColor); + setBackColor(p.backColor ?? p.color); + setOffset(p.offset ?? 0); + setWarpShape(p.warp?.shape ?? "none"); + setWarpAmount(p.warp?.amount ?? 0.5); + setActivePreset(p.label); + } + + const leftValues: LeftValues = { weight, italic, underline, strike, textCase, align, color, sideColor, backColor }; + const leftSet = (k: keyof LeftValues, v: number | string | boolean) => { + switch (k) { + case "weight": setWeight(v as number); break; + case "italic": setItalic(v as boolean); break; + case "underline": setUnderline(v as boolean); break; + case "strike": setStrike(v as boolean); break; + case "textCase": setTextCase(v as "as-typed" | "upper" | "lower" | "title"); break; + case "align": setAlign(v as Align); break; + case "color": setColor(v as string); break; + case "sideColor": setSideColor(v as string); break; + case "backColor": setBackColor(v as string); break; + } + }; + + const guiValues: GuiValues = { + profile, warp: warpShape, bend: warpAmount, + depth, letterSpacing, lineHeight, scaleX, scaleY, + curveSegments, simplify, merge, profileSegments, offset, + perspective, zoom: zoomScale, spin, + light: lightIntensity, ambient, az: lightAz, el: lightEl, lightColor, + }; + const guiSet = (k: keyof GuiValues, v: number | string | boolean) => { + switch (k) { + case "profile": setProfile(v as ExtrudeProfile); break; + case "warp": setWarpShape(v as WarpShape); break; + case "bend": setWarpAmount(v as number); break; + case "depth": setDepth(v as number); break; + case "letterSpacing": setLetterSpacing(v as number); break; + case "lineHeight": setLineHeight(v as number); break; + case "scaleX": setScaleX(v as number); break; + case "scaleY": setScaleY(v as number); break; + case "curveSegments": setCurveSegments(v as number); break; + case "simplify": setSimplify(v as number); break; + case "merge": setMerge(v as boolean); break; + case "profileSegments": setProfileSegments(v as number); break; + case "offset": setOffset(v as number); break; + case "perspective": setPerspective(v as boolean); break; + case "zoom": setZoomScale(v as number); break; + case "spin": setSpin(v as boolean); break; + case "light": setLightIntensity(v as number); break; + case "ambient": setAmbient(v as number); break; + case "az": setLightAz(v as number); break; + case "el": setLightEl(v as number); break; + case "lightColor": setLightColor(v as string); break; + } + }; + + return ( +
+ + + +