Skip to content

Commit ba64e4f

Browse files
authored
WordArt rich materials: per-face fills, textures, and edge-profile curves (#54)
* feat(fonts): WordArt face fills, outline stroke, and flat-layer shadow * feat(fonts): axial face materials, edge profiles, and a grouped composeText API * chore(website): bundle voxel block textures for /wordart * feat(website): per-face fills, block textures, and an edge-curve editor in /wordart
1 parent c43e0ad commit ba64e4f

26 files changed

Lines changed: 1299 additions & 148 deletions

packages/fonts/README.md

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,32 +44,57 @@ scene.add({ polygons, objectUrls: [], warnings: [], dispose() {} });
4444

4545
## `composeText` — WordArt composer
4646

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

4949
```ts
50-
import { composeText } from "@layoutit/polycss-fonts";
50+
import { composeText, resolveFace } from "@layoutit/polycss-fonts";
5151

5252
const polygons = composeText(font, "Poly\nCSS", {
53-
size: 100,
54-
depth: 24,
55-
align: "center",
56-
warp: { shape: "arch", amount: 0.6 },
57-
backColor: "#3a86ff", // layered: distinct back-cap color…
58-
oblique: [14, -14], // …shifted for the retro front-A / back-B leaning block
53+
// 1 · type & layout
54+
size: 100, depth: 24, align: "center", scale: [1, 1],
55+
letterSpacing: 0, lineHeight: 1.25, underline: false, strike: false,
56+
warp: { shape: "arch", amount: 0.6 }, simplify: 0, merge: false,
57+
58+
// 2 · cross-section / edge profile (one union)
59+
profile: { edge: "bevel", coverage: "front" },
60+
61+
// 3 · per-face material — one `Face` shape for all three
62+
faces: {
63+
front: resolveFace({ kind: "gradient", from: "#ffe14d", to: "#ff7a1a" }),
64+
sides: { color: "#7c4a12" },
65+
back: { color: "#3a86ff" },
66+
},
67+
68+
// 4 · outline
69+
outline: { color: "#1a1a2e", width: 3 },
5970
});
6071
```
6172

62-
| Option | Default | Notes |
63-
|---|---|---|
64-
| `lineHeight` | `1.25` | Line advance as a multiple of `size`. |
65-
| `align` | `"center"` | `"left"` · `"center"` · `"right"`. |
66-
| `scaleX` / `scaleY` | `1` | Horizontal / vertical glyph scale (Photoshop ↔ / ↕). |
67-
| `underline` / `strike` | `false` | Decoration bars; they follow the active warp. |
68-
| `warp` || `{ shape, amount }`. `shape`: `none`, `arch`, `archDown`, `arc`, `wave`, `bulge`, `cone`, `slantUp`, `slantDown`. `amount` is `0..1`. |
69-
| `simplify` | `0` | Outline simplification tolerance (world units). Hole-less glyphs only — holed glyphs (`O`, `P`, `a`…) stay full-detail so counters never collapse. |
70-
| `merge` | `false` | Merge coplanar same-color cap triangles into larger polygons (~⅓ fewer DOM nodes). Has a CPU cost, so off by default. |
71-
| `backColor` | `color` | Back-cap color — set it apart from `color` for a layered two-tone look. |
72-
| `oblique` | `[0, 0]` | `[rightward, upward]` shift of the back cap relative to the front (world units). |
73+
| Group | Options |
74+
|---|---|
75+
| **Layout** | `size` · `depth` (0 = flat slab, no edges) · `curveSteps` · `letterSpacing` · `lineHeight` · `align` · `scale: [x,y]` · `underline` · `strike` · `warp` · `simplify` · `merge` |
76+
| **`profile`** | `"flat"` · `{ edge: "bevel"\|"round", raised?, segments? }` · `{ curve: CubicBezier, segments? }` |
77+
| **`faces`** | `{ front?, sides?, back? }` · a single `Face` · `FaceStop[]` |
78+
| **`outline`** | `{ color, width }` — a colored halo around the front face |
79+
80+
- **`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.
81+
- **`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).
82+
- **`faces` resolves to material stops down the axis** — each polygon takes the nearest stop to its depth:
83+
- `{ front, sides, back }` → 3 stops at `{0, .5, 1}` (omit `sides` → the front rounds straight into the back, **no side band**).
84+
- a single `Face` → one material for the whole solid.
85+
- `FaceStop[]` (`Face & { at }`) → **N** materials distributed down the axis.
86+
- **Flat drop shadow**`depth: 0` + `faces.back.offset: [x, y]` with a distinct `back.color`.
87+
88+
### Fills — `resolveFace` & `makeFillTexture` (browser)
89+
90+
`composeText` is pure and takes already-rendered textures. The browser helpers turn a high-level fill into a `Face`:
91+
92+
```ts
93+
resolveFace({ kind: "gradient", from: "#ffe14d", to: "#ff7a1a", angle: 270 })
94+
// → { color?, texture: "data:image/png;…" }
95+
```
96+
97+
`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.
7398

7499
## Scope / limitations
75100

packages/fonts/src/composeText.test.ts

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,22 @@ describe("composeText", () => {
8989
});
9090

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

9898
// ── regression: scale / merge / layered ─────────────────────────────────
9999
it("horizontal scale widens the run", () => {
100100
const a = bounds(composeText(roboto, "AV"));
101-
const b = bounds(composeText(roboto, "AV", { scaleX: 2 }));
101+
const b = bounds(composeText(roboto, "AV", { scale: [2, 1] }));
102102
expect(b.maxY - b.minY).toBeGreaterThan((a.maxY - a.minY) * 1.6);
103103
});
104104

105105
it("vertical scale heightens the glyphs", () => {
106106
const a = bounds(composeText(roboto, "A"));
107-
const b = bounds(composeText(roboto, "A", { scaleY: 2 }));
107+
const b = bounds(composeText(roboto, "A", { scale: [1, 2] }));
108108
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
109109
});
110110

@@ -114,10 +114,132 @@ describe("composeText", () => {
114114
expect(merged.length).toBeLessThan(base.length);
115115
});
116116

117-
it("layered back color + oblique recolors and offsets the back", () => {
118-
const polys = composeText(roboto, "o", { depth: 10, color: "#ff0000", backColor: "#00ff00", oblique: [12, -12] });
117+
it("flat shadow (depth 0) offsets a recolored back layer", () => {
118+
const polys = composeText(roboto, "o", { depth: 0, faces: { front: { color: "#ff0000" }, back: { color: "#00ff00", offset: [12, -12] } } });
119119
const colors = new Set(polys.map((p) => p.color));
120-
expect(colors.has("#ff0000")).toBe(true); // front cap
121-
expect(colors.has("#00ff00")).toBe(true); // back cap
120+
expect(colors.has("#ff0000")).toBe(true); // front
121+
expect(colors.has("#00ff00")).toBe(true); // offset back
122+
});
123+
124+
// ── regression: WordArt fills / outline / flat-layer shadow ──────────────
125+
it("a face texture UV-maps the front cap across the whole word", () => {
126+
const tex = "data:image/png;base64,AAAA";
127+
const polys = composeText(roboto, "Hi", { faces: { front: { texture: tex } } });
128+
const faces = polys.filter((p) => p.texture === tex);
129+
expect(faces.length).toBeGreaterThan(0);
130+
// Every textured face carries one UV per vertex…
131+
expect(faces.every((p) => p.uvs?.length === p.vertices.length)).toBe(true);
132+
// …and the UVs span the whole word (reach both extremes of 0..1).
133+
const us = faces.flatMap((p) => p.uvs!.map((uv) => uv[0]));
134+
expect(Math.min(...us)).toBeLessThan(0.05);
135+
expect(Math.max(...us)).toBeGreaterThan(0.95);
136+
// Walls stay untextured.
137+
expect(polys.some((p) => !p.texture)).toBe(true);
138+
});
139+
140+
it("solid (no faceTexture) leaves the face untextured", () => {
141+
const polys = composeText(roboto, "Hi");
142+
expect(polys.every((p) => !p.texture && !p.uvs)).toBe(true);
143+
});
144+
145+
it("outline adds a halo silhouette in the outline color", () => {
146+
const plain = composeText(roboto, "o").length;
147+
const polys = composeText(roboto, "o", { outline: { color: "#123456", width: 3 } });
148+
expect(polys.length).toBeGreaterThan(plain);
149+
expect(polys.some((p) => p.color === "#123456")).toBe(true);
150+
});
151+
152+
it("textures each face independently (front / sides / back)", () => {
153+
const polys = composeText(roboto, "Hi", {
154+
depth: 20,
155+
faces: {
156+
front: { texture: "/t/dirt.svg" },
157+
sides: { texture: "/t/wood.svg" },
158+
back: { texture: "/t/brick.svg" },
159+
},
160+
});
161+
const urls = new Set(polys.map((p) => p.texture).filter(Boolean));
162+
expect(urls.has("/t/dirt.svg")).toBe(true); // front cap
163+
expect(urls.has("/t/brick.svg")).toBe(true); // back cap
164+
expect(urls.has("/t/wood.svg")).toBe(true); // side walls
165+
});
166+
167+
it("tiling repeats the texture (UV > 1 + repeat wrap) vs stretch", () => {
168+
const stretched = composeText(roboto, "WWWW", { faces: { front: { texture: "/t/dirt.svg" } } });
169+
const tiled = composeText(roboto, "WWWW", { faces: { front: { texture: "/t/dirt.svg", tile: 20 } } });
170+
const maxU = (ps: ReturnType<typeof composeText>) =>
171+
Math.max(...ps.filter((p) => p.texture).flatMap((p) => p.uvs!.map((uv) => uv[0])));
172+
expect(maxU(stretched)).toBeLessThanOrEqual(1.0001); // normalized to word
173+
expect(maxU(tiled)).toBeGreaterThan(1.5); // repeats across the word
174+
expect(tiled.find((p) => p.texture)?.textureWrap?.s).toBe("repeat");
175+
});
176+
177+
it("axial faces band the solid by depth (front cap / body / back cap)", () => {
178+
const polys = composeText(roboto, "o", {
179+
depth: 30,
180+
faces: { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } },
181+
});
182+
const front = polys.filter((p) => p.color === "#ff0000");
183+
const side = polys.filter((p) => p.color === "#00ff00");
184+
const back = polys.filter((p) => p.color === "#0000ff");
185+
expect(front.length).toBeGreaterThan(0); // front cap (t≈0)
186+
expect(side.length).toBeGreaterThan(0); // body walls (t≈0.5)
187+
expect(back.length).toBeGreaterThan(0); // back cap (t≈1)
188+
// The front cap sits at the most-forward z; the back cap at the most-back.
189+
const frontZ = Math.max(...front.flatMap((p) => p.vertices.map((v) => v[2])));
190+
const backZ = Math.min(...back.flatMap((p) => p.vertices.map((v) => v[2])));
191+
expect(frontZ).toBeGreaterThan(backZ);
192+
});
193+
194+
it("omitting `sides` makes the front meet the back (no side band)", () => {
195+
const withSide = composeText(roboto, "o", { depth: 30, faces: { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } } });
196+
const noSide = composeText(roboto, "o", { depth: 30, faces: { front: { color: "#ff0000" }, back: { color: "#0000ff" } } });
197+
expect(withSide.some((p) => p.color === "#00ff00")).toBe(true); // has a side band
198+
expect(noSide.some((p) => p.color === "#00ff00")).toBe(false); // none
199+
// Same geometry, but the side band's polys are now front/back instead.
200+
expect(noSide.length).toBe(withSide.length);
201+
expect(noSide.filter((p) => p.color !== "#00ff00").length).toBeGreaterThan(withSide.filter((p) => p.color !== "#00ff00").length);
202+
});
203+
204+
it("a face set to `false` is covered by its neighbour (no hole, no own color)", () => {
205+
const faces = { front: { color: "#ff0000" }, sides: { color: "#00ff00" }, back: { color: "#0000ff" } };
206+
const full = composeText(roboto, "o", { depth: 20, faces });
207+
const noBack = composeText(roboto, "o", { depth: 20, faces: { ...faces, back: false } });
208+
// Geometry is intact (same polygon count → no hole at the back)…
209+
expect(noBack.length).toBe(full.length);
210+
// …the back has no color of its own…
211+
expect(noBack.some((p) => p.color === "#0000ff")).toBe(false);
212+
// …and the back cap is covered by the nearest active face (the side).
213+
expect(noBack.filter((p) => p.color === "#00ff00").length).toBeGreaterThan(full.filter((p) => p.color === "#00ff00").length);
214+
});
215+
216+
it("an N-stop array distributes materials down the axis", () => {
217+
const polys = composeText(roboto, "I", {
218+
depth: 40,
219+
faces: [
220+
{ at: 0, color: "#111111" },
221+
{ at: 0.5, color: "#777777" },
222+
{ at: 1, color: "#eeeeee" },
223+
],
224+
});
225+
const colors = new Set(polys.map((p) => p.color));
226+
expect(colors.has("#111111")).toBe(true);
227+
expect(colors.has("#777777")).toBe(true);
228+
expect(colors.has("#eeeeee")).toBe(true);
229+
});
230+
231+
it("custom cubic-bezier profile differs from a round edge", () => {
232+
const round = composeText(roboto, "o", { depth: 24, profile: { edge: "round" } });
233+
const custom = composeText(roboto, "o", { depth: 24, profile: { curve: [0.1, 0.9, 0.2, 1] } });
234+
const hash = (ps: ReturnType<typeof composeText>) => ps.map((p) => p.vertices.flat().join()).join("|");
235+
expect(round.length).toBe(custom.length);
236+
expect(hash(round)).not.toBe(hash(custom));
237+
});
238+
239+
it("flat (depth 0) drops the side walls vs an extruded depth", () => {
240+
const walled = composeText(roboto, "o", { depth: 12, faces: { back: { color: "#00ff00" } } });
241+
const flat = composeText(roboto, "o", { depth: 0, faces: { back: { color: "#00ff00", offset: [10, -10] } } });
242+
expect(flat.length).toBeLessThan(walled.length);
243+
expect(flat.some((p) => p.color === "#00ff00")).toBe(true); // shadow layer kept
122244
});
123245
});

0 commit comments

Comments
 (0)