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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 44 additions & 19 deletions packages/fonts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,32 +44,57 @@ scene.add({ polygons, objectUrls: [], warnings: [], dispose() {} });

## `composeText` — WordArt composer

`composeText` accepts every `textPolygons` option plus the layout, decoration, and warp controls below. `\n` in `text` starts a new line.
`composeText(font, text, options)` is the full composer (`\n` starts a new line). The options group into five concerns instead of one flat bag:

```ts
import { composeText } from "@layoutit/polycss-fonts";
import { composeText, resolveFace } 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
// 1 · type & layout
size: 100, depth: 24, align: "center", scale: [1, 1],
letterSpacing: 0, lineHeight: 1.25, underline: false, strike: false,
warp: { shape: "arch", amount: 0.6 }, simplify: 0, merge: false,

// 2 · cross-section / edge profile (one union)
profile: { edge: "bevel", coverage: "front" },

// 3 · per-face material — one `Face` shape for all three
faces: {
front: resolveFace({ kind: "gradient", from: "#ffe14d", to: "#ff7a1a" }),
sides: { color: "#7c4a12" },
back: { color: "#3a86ff" },
},

// 4 · outline
outline: { color: "#1a1a2e", width: 3 },
});
```

| 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). |
| Group | Options |
|---|---|
| **Layout** | `size` · `depth` (0 = flat slab, no edges) · `curveSteps` · `letterSpacing` · `lineHeight` · `align` · `scale: [x,y]` · `underline` · `strike` · `warp` · `simplify` · `merge` |
| **`profile`** | `"flat"` · `{ edge: "bevel"\|"round", raised?, segments? }` · `{ curve: CubicBezier, segments? }` |
| **`faces`** | `{ front?, sides?, back? }` · a single `Face` · `FaceStop[]` |
| **`outline`** | `{ color, width }` — a colored halo around the front face |

- **`profile` (shape)** and **`faces` (color)** are independent functions of the same depth axis `t ∈ [0,1]` (0 = front, 1 = back). `edge` bevels/rounds the edges (`raised` flips a round to a convex dome); `curve` is a custom edge from a CSS `cubic-bezier` easing.
- **`Face`** = `{ color?, texture?, tile? }`. `texture` is an already-rendered URL/data-URL UV-mapped across the whole word; `tile` repeats it every N units (blocks) vs stretching (gradients/photos).
- **`faces` resolves to material stops down the axis** — each polygon takes the nearest stop to its depth:
- `{ front, sides, back }` → 3 stops at `{0, .5, 1}` (omit `sides` → the front rounds straight into the back, **no side band**).
- a single `Face` → one material for the whole solid.
- `FaceStop[]` (`Face & { at }`) → **N** materials distributed down the axis.
- **Flat drop shadow** — `depth: 0` + `faces.back.offset: [x, y]` with a distinct `back.color`.

### Fills — `resolveFace` & `makeFillTexture` (browser)

`composeText` is pure and takes already-rendered textures. The browser helpers turn a high-level fill into a `Face`:

```ts
resolveFace({ kind: "gradient", from: "#ffe14d", to: "#ff7a1a", angle: 270 })
// → { color?, texture: "data:image/png;…" }
```

`FaceFillSpec` (the `kind`): `"solid"` · `"gradient"` (`from`, `to`, `angle?`) · `"rainbow"` (`angle?`) · `"texture"` (`url`, `tile?`) · `"image"` (`src`). `makeFillTexture(FillSpec)` is the lower-level canvas painter if you want the data URL directly.

## Scope / limitations

Expand Down
140 changes: 131 additions & 9 deletions packages/fonts/src/composeText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,22 @@ describe("composeText", () => {
});

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);
for (const edge of ["round", "bevel"] as const) {
expect(composeText(roboto, "o", { profile: { edge } }).length).toBeGreaterThan(0);
expect(composeText(roboto, "B", { profile: { edge }, 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 }));
const b = bounds(composeText(roboto, "AV", { scale: [2, 1] }));
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 }));
const b = bounds(composeText(roboto, "A", { scale: [1, 2] }));
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
});

Expand All @@ -114,10 +114,132 @@ describe("composeText", () => {
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] });
it("flat shadow (depth 0) offsets a recolored back layer", () => {
const polys = composeText(roboto, "o", { depth: 0, faces: { front: { color: "#ff0000" }, back: { color: "#00ff00", offset: [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
expect(colors.has("#ff0000")).toBe(true); // front
expect(colors.has("#00ff00")).toBe(true); // offset back
});

// ── regression: WordArt fills / outline / flat-layer shadow ──────────────
it("a face texture UV-maps the front cap across the whole word", () => {
const tex = "data:image/png;base64,AAAA";
const polys = composeText(roboto, "Hi", { faces: { front: { texture: tex } } });
const faces = polys.filter((p) => p.texture === tex);
expect(faces.length).toBeGreaterThan(0);
// Every textured face carries one UV per vertex…
expect(faces.every((p) => p.uvs?.length === p.vertices.length)).toBe(true);
// …and the UVs span the whole word (reach both extremes of 0..1).
const us = faces.flatMap((p) => p.uvs!.map((uv) => uv[0]));
expect(Math.min(...us)).toBeLessThan(0.05);
expect(Math.max(...us)).toBeGreaterThan(0.95);
// Walls stay untextured.
expect(polys.some((p) => !p.texture)).toBe(true);
});

it("solid (no faceTexture) leaves the face untextured", () => {
const polys = composeText(roboto, "Hi");
expect(polys.every((p) => !p.texture && !p.uvs)).toBe(true);
});

it("outline adds a halo silhouette in the outline color", () => {
const plain = composeText(roboto, "o").length;
const polys = composeText(roboto, "o", { outline: { color: "#123456", width: 3 } });
expect(polys.length).toBeGreaterThan(plain);
expect(polys.some((p) => p.color === "#123456")).toBe(true);
});

it("textures each face independently (front / sides / back)", () => {
const polys = composeText(roboto, "Hi", {
depth: 20,
faces: {
front: { texture: "/t/dirt.svg" },
sides: { texture: "/t/wood.svg" },
back: { texture: "/t/brick.svg" },
},
});
const urls = new Set(polys.map((p) => p.texture).filter(Boolean));
expect(urls.has("/t/dirt.svg")).toBe(true); // front cap
expect(urls.has("/t/brick.svg")).toBe(true); // back cap
expect(urls.has("/t/wood.svg")).toBe(true); // side walls
});

it("tiling repeats the texture (UV > 1 + repeat wrap) vs stretch", () => {
const stretched = composeText(roboto, "WWWW", { faces: { front: { texture: "/t/dirt.svg" } } });
const tiled = composeText(roboto, "WWWW", { faces: { front: { texture: "/t/dirt.svg", tile: 20 } } });
const maxU = (ps: ReturnType<typeof composeText>) =>
Math.max(...ps.filter((p) => p.texture).flatMap((p) => p.uvs!.map((uv) => uv[0])));
expect(maxU(stretched)).toBeLessThanOrEqual(1.0001); // normalized to word
expect(maxU(tiled)).toBeGreaterThan(1.5); // repeats across the word
expect(tiled.find((p) => p.texture)?.textureWrap?.s).toBe("repeat");
});

it("axial faces band the solid by depth (front cap / body / back cap)", () => {
const polys = composeText(roboto, "o", {
depth: 30,
faces: { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } },
});
const front = polys.filter((p) => p.color === "#ff0000");
const side = polys.filter((p) => p.color === "#00ff00");
const back = polys.filter((p) => p.color === "#0000ff");
expect(front.length).toBeGreaterThan(0); // front cap (t≈0)
expect(side.length).toBeGreaterThan(0); // body walls (t≈0.5)
expect(back.length).toBeGreaterThan(0); // back cap (t≈1)
// The front cap sits at the most-forward z; the back cap at the most-back.
const frontZ = Math.max(...front.flatMap((p) => p.vertices.map((v) => v[2])));
const backZ = Math.min(...back.flatMap((p) => p.vertices.map((v) => v[2])));
expect(frontZ).toBeGreaterThan(backZ);
});

it("omitting `sides` makes the front meet the back (no side band)", () => {
const withSide = composeText(roboto, "o", { depth: 30, faces: { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } } });
const noSide = composeText(roboto, "o", { depth: 30, faces: { front: { color: "#ff0000" }, back: { color: "#0000ff" } } });
expect(withSide.some((p) => p.color === "#00ff00")).toBe(true); // has a side band
expect(noSide.some((p) => p.color === "#00ff00")).toBe(false); // none
// Same geometry, but the side band's polys are now front/back instead.
expect(noSide.length).toBe(withSide.length);
expect(noSide.filter((p) => p.color !== "#00ff00").length).toBeGreaterThan(withSide.filter((p) => p.color !== "#00ff00").length);
});

it("a face set to `false` is covered by its neighbour (no hole, no own color)", () => {
const faces = { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } };
const full = composeText(roboto, "o", { depth: 20, faces });
const noBack = composeText(roboto, "o", { depth: 20, faces: { ...faces, back: false } });
// Geometry is intact (same polygon count → no hole at the back)…
expect(noBack.length).toBe(full.length);
// …the back has no color of its own…
expect(noBack.some((p) => p.color === "#0000ff")).toBe(false);
// …and the back cap is covered by the nearest active face (the side).
expect(noBack.filter((p) => p.color === "#00ff00").length).toBeGreaterThan(full.filter((p) => p.color === "#00ff00").length);
});

it("an N-stop array distributes materials down the axis", () => {
const polys = composeText(roboto, "I", {
depth: 40,
faces: [
{ at: 0, color: "#111111" },
{ at: 0.5, color: "#777777" },
{ at: 1, color: "#eeeeee" },
],
});
const colors = new Set(polys.map((p) => p.color));
expect(colors.has("#111111")).toBe(true);
expect(colors.has("#777777")).toBe(true);
expect(colors.has("#eeeeee")).toBe(true);
});

it("custom cubic-bezier profile differs from a round edge", () => {
const round = composeText(roboto, "o", { depth: 24, profile: { edge: "round" } });
const custom = composeText(roboto, "o", { depth: 24, profile: { curve: [0.1, 0.9, 0.2, 1] } });
const hash = (ps: ReturnType<typeof composeText>) => ps.map((p) => p.vertices.flat().join()).join("|");
expect(round.length).toBe(custom.length);
expect(hash(round)).not.toBe(hash(custom));
});

it("flat (depth 0) drops the side walls vs an extruded depth", () => {
const walled = composeText(roboto, "o", { depth: 12, faces: { back: { color: "#00ff00" } } });
const flat = composeText(roboto, "o", { depth: 0, faces: { back: { color: "#00ff00", offset: [10, -10] } } });
expect(flat.length).toBeLessThan(walled.length);
expect(flat.some((p) => p.color === "#00ff00")).toBe(true); // shadow layer kept
});
});
Loading
Loading