diff --git a/packages/fonts/README.md b/packages/fonts/README.md index c4eaa86d..dad3b75a 100644 --- a/packages/fonts/README.md +++ b/packages/fonts/README.md @@ -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 diff --git a/packages/fonts/src/composeText.test.ts b/packages/fonts/src/composeText.test.ts index 9165f4c1..23196927 100644 --- a/packages/fonts/src/composeText.test.ts +++ b/packages/fonts/src/composeText.test.ts @@ -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); }); @@ -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) => + 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) => 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 }); }); diff --git a/packages/fonts/src/composeText.ts b/packages/fonts/src/composeText.ts index 32764a9e..32cf1613 100644 --- a/packages/fonts/src/composeText.ts +++ b/packages/fonts/src/composeText.ts @@ -16,11 +16,13 @@ import { groupShapes, shade, simplifyContour, + type CubicBezier, type Contour, + type ExtrudeProfile, + type MaterialStop, type Pt, type Shape, } from "./extrude"; -import type { TextPolygonsOptions } from "./textPolygons"; /** Classic WordArt envelope shapes. */ export type WarpShape = @@ -40,40 +42,120 @@ export interface WarpOptions { amount?: number; } -export interface ComposeTextOptions extends TextPolygonsOptions { +/** A paintable face: a solid color and/or an already-rendered texture. */ +export interface Face { + /** Solid color; also the fallback if the texture fails to load. */ + color?: string; + /** + * Texture URL / data URL — a gradient, rainbow, image, or block texture + * already rendered (see `resolveFace` / `makeFillTexture`). UV-mapped across + * the whole word so the fill flows over every glyph. + */ + texture?: string; + /** Tile the texture every N world units (block look); omit to stretch one copy. */ + tile?: number; +} + +/** The back face, which can be offset for the flat WordArt drop shadow (depth 0). */ +export interface BackFace extends Face { + /** [rightward, upward] shift of the back relative to the front (world units). */ + offset?: [number, number]; +} + +/** A `Face` pinned at a position on the depth axis (`at`: 0 = front, 1 = back). */ +export interface FaceStop extends Face { + at: number; +} + +/** + * Extrusion cross-section / edge profile: + * - `"flat"` — straight slab (no edge shaping). + * - `{ edge }` — a bevel chamfer or round bullnose on the edge (mirrored + * front/back). `raised` flips a round to a convex dome. + * - `{ curve }` — a custom edge whose cross-section is a CSS cubic-bezier easing. + */ +export type Profile = + | "flat" + | { edge: "bevel" | "round"; raised?: boolean; segments?: number } + | { curve: CubicBezier; segments?: number }; + +export interface ComposeTextOptions { + /** Cap-em size in world units. Defaults to 100. */ + size?: number; + /** Extrusion depth (world units). 0 = a flat slab with no edges. Defaults to size*0.2. */ + depth?: number; + /** Bézier flattening: segments per glyph curve. Higher = smoother. Defaults to 6. */ + curveSteps?: number; + /** Extra space between glyphs (world units). */ + letterSpacing?: number; /** 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. */ + /** Non-uniform glyph stretch [x, y] (WordArt-style). Defaults to [1, 1]. */ + scale?: [number, number]; + /** Draw an underline / strikethrough 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. - */ + /** Outline simplification tolerance (world units, 0 = exact; hole-less glyphs only). */ 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 coplanar same-color cap triangles into larger polygons (fewer DOM nodes). */ merge?: boolean; - /** Back cap color. Set differently from `color` for a layered look. */ - backColor?: string; + /** Extrusion cross-section / edge profile. Defaults to "flat". */ + profile?: Profile; /** - * 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. + * Material along the depth axis. Three shapes, simplest → most general: + * - `{ front?, sides?, back? }` — the classic faces (sugar for 3 stops). Omit + * `sides` for no side band (front rounds into back), or set `sides` / `back` + * to `false` to skip that geometry entirely. + * - a single `Face` — one material for the whole solid. + * - `FaceStop[]` — N materials distributed down the axis (`at` 0→1). */ - oblique?: [number, number]; + faces?: Face | FaceStop[] | { front?: Face; sides?: Face | false; back?: BackFace | false }; + /** Outline stroke drawn as a halo around the front face. */ + outline?: { color: string; width: number }; +} + +function resolveProfile(p: Profile | undefined): { + profile: ExtrudeProfile; + roundConvex?: boolean; + profileBezier?: CubicBezier; + segments?: number; +} { + if (!p || p === "flat") return { profile: "flat" }; + if ("edge" in p) return { profile: p.edge, roundConvex: p.raised, segments: p.segments }; + return { profile: "custom", profileBezier: p.curve, segments: p.segments }; +} + +/** + * Normalize the `faces` option into sorted material stops. A face set to `false` + * (or `sides` omitted) contributes no stop — the geometry is still rendered, but + * the nearest remaining face covers it (no hole, just fewer colour bands). The + * active faces split the depth axis evenly (stop i centered at `(i+½)/k`). + */ +function resolveStops( + faces: ComposeTextOptions["faces"], + defaultFront: string, +): { stops: MaterialStop[]; backOffset?: [number, number] } { + const stop = (f: Face, at: number, color: string): MaterialStop => ({ at, color: f.color ?? color, texture: f.texture, tile: f.tile }); + if (Array.isArray(faces)) { + return { stops: faces.length ? faces.map((f) => stop(f, f.at, defaultFront)) : [{ at: 0.5, color: defaultFront }] }; + } + if (faces && ("front" in faces || "sides" in faces || "back" in faces)) { + const front = faces.front ?? {}; + const fc = front.color ?? defaultFront; + const items: { f: Face; color: string }[] = [{ f: front, color: fc }]; + if (faces.sides) items.push({ f: faces.sides, color: shade(fc, 0.72) }); + if (faces.back !== false) items.push({ f: faces.back ?? {}, color: fc }); + const k = items.length; + const stops = items.map((it, i) => stop(it.f, (i + 0.5) / k, it.color)); + return { stops, backOffset: faces.back ? faces.back.offset : undefined }; + } + if (faces) return { stops: [stop(faces as Face, 0.5, defaultFront)] }; // single Face → uniform + // Default: the classic gold front / darker sides / matching back, even thirds. + return { stops: [{ at: 1 / 6, color: defaultFront }, { at: 0.5, color: shade(defaultFront, 0.72) }, { at: 5 / 6, color: defaultFront }] }; } type WarpFn = (p: Pt) => Pt; @@ -83,15 +165,17 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText 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 [scaleX, scaleY] = options.scale ?? [1, 1]; + + const { stops, backOffset } = resolveStops(options.faces, "#d4a82a"); + + const prof = resolveProfile(options.profile); + const profileSegments = Math.max(1, Math.round(prof.segments ?? 6)); + // depth 0 → a flat slab with no side walls (and a place for the offset shadow). + const flat = depth <= 0; const scale = size / font.unitsPerEm; const barThickness = size * 0.06; @@ -150,15 +234,31 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText } }); + // Whole-word front-plane bounds, so the face fill UV-maps across every glyph. + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const s of shapes) { + for (const [x, y] of s.outer) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + const faceUvBounds = shapes.length ? { minX, minY, maxX, maxY } : undefined; + const polygons = extrudeContours(shapes, { depth, - profile, + profile: prof.profile, + roundConvex: prof.roundConvex, + profileBezier: prof.profileBezier, profileSegments, maxInset: size * 0.045, - color, - sideColor, - backColor: options.backColor, - oblique: options.oblique, + stops, + faceUvBounds, + backOffset, + layered: flat, + outlineColor: options.outline?.color, + outlineWidth: options.outline?.width, }); return options.merge ? mergePolygons(polygons) : polygons; } diff --git a/packages/fonts/src/extrude.ts b/packages/fonts/src/extrude.ts index 29553ea9..ff323cc2 100644 --- a/packages/fonts/src/extrude.ts +++ b/packages/fonts/src/extrude.ts @@ -12,44 +12,98 @@ * wound in reverse to stay outward-facing (PolyCSS hides back-faces). */ import earcut from "earcut"; -import type { Polygon, Vec3 } from "@layoutit/polycss-core"; +import type { Polygon, Vec2, 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. + * - "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. + * - "custom" — the edge profile is the CSS easing curve `profileBezier` + * (`cubic-bezier(x1,y1,x2,y2)` semantics), sampled along the depth. */ -export type ExtrudeProfile = "flat" | "round" | "bevel"; +export type ExtrudeProfile = "flat" | "round" | "bevel" | "custom"; + +/** CSS cubic-bezier control points [x1, y1, x2, y2] (P0=(0,0), P3=(1,1)). */ +export type CubicBezier = [number, number, number, number]; + +/** + * CSS `cubic-bezier(x1,y1,x2,y2)` easing → a function mapping x∈[0,1] to y. + * Solves x(t)=x by Newton's method (same approach browsers use), then reads y. + */ +export function cssCubicBezier([x1, y1, x2, y2]: CubicBezier): (x: number) => number { + const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx; + const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by; + const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t; + const sampleY = (t: number) => ((ay * t + by) * t + cy) * t; + const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx; + return (x: number) => { + const xc = Math.min(1, Math.max(0, x)); + let t = xc; + for (let i = 0; i < 8; i++) { + const err = sampleX(t) - xc; + if (Math.abs(err) < 1e-6) break; + const d = slopeX(t); + if (Math.abs(d) < 1e-6) break; + t -= err / d; + } + return sampleY(Math.min(1, Math.max(0, t))); + }; +} export interface Shape { outer: Contour; holes: Contour[]; } +/** + * A material stop on the axial (depth) axis: `at` runs 0 (front face) → 1 (back + * face). Each emitted polygon is colored/textured by the nearest stop to its + * own depth, so any number of stops can band the solid down its length. + */ +export interface MaterialStop { + at: number; + /** Solid color (and fallback if a texture fails to load). */ + color?: string; + /** Texture URL / data URL — UV-mapped across the word (caps & stretch walls). */ + texture?: string; + /** Tile the texture every N world units (block look) instead of stretching. */ + tile?: number; +} + 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; + /** Round only: flip the arc to a raised/convex bullnose (default concave). */ + roundConvex?: boolean; /** - * 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). + * Custom edge profile (profile === "custom"): a CSS cubic-bezier easing whose + * curve is the edge cross-section, sampled over `profileSegments`. */ - oblique?: [number, number]; + profileBezier?: CubicBezier; + /** Cap on inward edge inset (keeps round/bevel from pinching thin stems). */ + maxInset: number; + /** Material stops down the depth axis (≥1). Each polygon takes the nearest. */ + stops: MaterialStop[]; + /** Type-plane bounds the face UVs normalize against (the whole word). */ + faceUvBounds?: { minX: number; minY: number; maxX: number; maxY: number }; + /** Outline stroke color, drawn as a halo just behind the front face. */ + outlineColor?: string; + /** Outline stroke width in world units (only used with `outlineColor`). */ + outlineWidth?: number; + /** [rightward, upward] in-plane shift of the back cap — the flat drop shadow. */ + backOffset?: [number, number]; /** Depth offset applied to the whole shape (for layered/offset effects). */ zOffset?: number; + /** + * Flat two-layer mode: emit only the front cap + an offset back cap, with no + * connecting side walls — the classic WordArt "two flat meshes" drop shadow. + */ + layered?: boolean; } interface Ring { @@ -61,23 +115,50 @@ 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 { profile, profileSegments, maxInset } = opts; + const layered = opts.layered ?? false; const zCenter = opts.zOffset ?? 0; + // Layered mode forces a minimum front/back separation so the offset shadow + // sits behind the face even when depth is ~0. + const depth = layered ? Math.max(opts.depth, 1) : opts.depth; const frontZ = zCenter + depth / 2; const backZ = zCenter - depth / 2; - const rings = buildRings(profile, frontZ, backZ, depth, profileSegments, maxInset); + const rings = buildRings(profile, frontZ, backZ, depth, profileSegments, maxInset, opts.roundConvex ?? false, opts.profileBezier); 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 ZERO: Pt = [0, 0]; + const backOffset = opts.backOffset ?? ZERO; const place = (p: Pt, z: number, o: Pt): Vec3 => toWorld([p[0] + o[0], p[1] + o[1]], z); + // Face UV: normalize a type-plane point to the whole-word bounds (v=0 bottom, + // OBJ convention — matches PolyCSS UV expectations). + const fb = opts.faceUvBounds; + const faceW = fb ? Math.max(fb.maxX - fb.minX, 1e-6) : 1; + const faceH = fb ? Math.max(fb.maxY - fb.minY, 1e-6) : 1; + // tile > 0 → repeat the texture every `tile` world units (crisp block look); + // tile === 0 → stretch one copy across the whole word (gradient / photo). + const uvAt = (p: Pt, tile: number): Vec2 => tile > 0 + ? [(p[0] - fb!.minX) / tile, (p[1] - fb!.minY) / tile] + : [Math.min(1, Math.max(0, (p[0] - fb!.minX) / faceW)), Math.min(1, Math.max(0, (p[1] - fb!.minY) / faceH))]; + const REPEAT = { s: "repeat", t: "repeat" } as const; + const outlineWidth = opts.outlineColor ? Math.max(0, opts.outlineWidth ?? 0) : 0; + + // Material by axial position t (0 = front face, 1 = back face): nearest stop. + const stops = opts.stops.length ? [...opts.stops].sort((a, b) => a.at - b.at) : [{ at: 0.5 }]; + const materialAt = (t: number): MaterialStop => { + let best = stops[0]; + let bestD = Infinity; + // `<=` so a tie (a band exactly on a boundary, e.g. the single flat wall at + // t=0.5 between two even stops) resolves toward the deeper stop — the wall + // takes the side/back rather than the front. + for (const s of stops) { + const d = Math.abs(s.at - t); + if (d <= bestD) { bestD = d; best = s; } + } + return best; + }; + const tOf = (z: number) => (depth > 0 ? Math.min(1, Math.max(0, (frontZ - z) / depth)) : 0); + const maxRingInset = rings.reduce((m, r) => Math.max(m, r.inset), 0); for (const shape of shapes) { @@ -91,53 +172,93 @@ export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[ : 1; const si = (inset: number) => inset * insetScale; - const cap = (inset: number, z: number, flip: boolean, capColor: string) => { - const o = obliqueAt(z); + // Emit a flat cap of `contours` at depth `z`, shifted in-plane by `o`, + // painted with `mat` (UV-mapped to the word when it carries a texture). + const cap = (offset: number, z: number, o: Pt, flip: boolean, mat: MaterialStop) => { 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); + for (const [x, y] of offsetContour(contours[r], offset)) flat.push(x, y); } const tris = earcut(flat, holeIndices, 2); const vert = (i: number): Pt => [flat[i * 2], flat[i * 2 + 1]]; + const tile = mat.tile ?? 0; 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, - }); + const ordered: [Pt, Pt, Pt] = [tri[2], tri[1], tri[0]]; + const poly: Polygon = { + vertices: [place(ordered[0], z, o), place(ordered[1], z, o), place(ordered[2], z, o)], + color: mat.color ?? "#cccccc", + }; + if (mat.texture && fb) { + // Inline `texture` (not just `material`) so the mesh atlas planner — + // which reads polygon.texture — UV-maps the shared fill across the word. + poly.texture = mat.texture; + poly.material = { texture: mat.texture }; + poly.uvs = [uvAt(ordered[0], tile), uvAt(ordered[1], tile), uvAt(ordered[2], tile)]; + if (tile > 0) poly.textureWrap = REPEAT; + } + polygons.push(poly); } }; - cap(rings[0].inset, rings[0].z, false, color); - cap(rings[rings.length - 1].inset, rings[rings.length - 1].z, true, backColor); + // Outline halo: a larger silhouette in the outline color sitting just behind + // the front face, so it peeks out around every outer and counter edge. + if (outlineWidth > 0) { + cap(-outlineWidth, frontZ - 1e-3, ZERO, false, { at: 0, color: opts.outlineColor }); + } + + // Front + back caps (material at the two ends of the axis). + cap(si(rings[0].inset), rings[0].z, ZERO, false, materialAt(0)); + if (layered) { + // Flat two-layer shadow: offset back cap, no connecting walls. + cap(si(rings[rings.length - 1].inset), backZ, backOffset, true, materialAt(1)); + continue; + } + cap(si(rings[rings.length - 1].inset), rings[rings.length - 1].z, ZERO, true, materialAt(1)); 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; + const mat = materialAt(tOf((z0 + z1) / 2)); + const tile = mat.tile ?? 0; for (let i = 0, len = contour.length; i < len; i++) { const j = (i + 1) % len; - polygons.push({ + const wall: Polygon = { vertices: [ - place(curOffset[i], z1, curO), - place(curOffset[j], z1, curO), - place(prevOffset[j], z0, prevO), - place(prevOffset[i], z0, prevO), + place(curOffset[i], z1, ZERO), + place(curOffset[j], z1, ZERO), + place(prevOffset[j], z0, ZERO), + place(prevOffset[i], z0, ZERO), ], - color: sideColor, - }); + color: mat.color ?? "#cccccc", + }; + if (mat.texture) { + wall.texture = mat.texture; + wall.material = { texture: mat.texture }; + if (tile > 0 || !fb) { + // Block texture: one full tile per wall quad (voxel edge). + wall.uvs = [[0, 1], [1, 1], [1, 0], [0, 0]]; + } else { + // Stretch texture: UV-map the band to the word so it wraps the edge. + wall.uvs = [ + uvAt(curOffset[i], 0), + uvAt(curOffset[j], 0), + uvAt(prevOffset[j], 0), + uvAt(prevOffset[i], 0), + ]; + } + } + polygons.push(wall); } prevOffset = curOffset; - prevO = curO; } } } @@ -152,27 +273,37 @@ function buildRings( depth: number, seg: number, maxInset: number, + roundConvex: boolean, + profileBezier?: CubicBezier, ): 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 s = profile === "bevel" ? 1 : Math.max(2, seg); + // `insetFrac(u)` is the inset (0..1 of `edge`) at depth fraction u into the + // edge (u=0 at the face → 1 at the full-size shoulder). bevel = straight + // ramp; round = quarter arc (concave, or convex when raised); custom = the + // CSS cubic-bezier easing curve as the cross-section. + const insetFrac = profile === "bevel" + ? (u: number) => 1 - u + : profile === "custom" && profileBezier + ? ((b) => (u: number) => 1 - b(u))(cssCubicBezier(profileBezier)) + : roundConvex + ? (u: number) => 1 - Math.sin((u * Math.PI) / 2) + : (u: number) => Math.cos((u * Math.PI) / 2); const rings: Ring[] = []; for (let k = 0; k <= s; k++) { const u = k / s; - rings.push({ z: frontZ - u * edge, inset: edge * ease(u) }); + rings.push({ z: frontZ - u * edge, inset: edge * insetFrac(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) }); + rings.push({ z: backZ + edge * u, inset: edge * insetFrac(u) }); } return rings; } diff --git a/packages/fonts/src/fill.test.ts b/packages/fonts/src/fill.test.ts new file mode 100644 index 00000000..cbf4a348 --- /dev/null +++ b/packages/fonts/src/fill.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { resolveFace } from "./fill"; + +// resolveFace's solid / texture / image branches are pure (no ), so +// they're unit-tested here. The gradient / rainbow branches paint to a canvas +// and are exercised in the browser (the /wordart Playwright runs) instead. +describe("resolveFace", () => { + it("solid → just a color, no texture", () => { + expect(resolveFace({ kind: "solid", color: "#ff0000" })).toEqual({ color: "#ff0000" }); + }); + + it("texture → url + tile passed straight through (block fill)", () => { + expect(resolveFace({ kind: "texture", color: "#abcabc", url: "/t/dirt.svg", tile: 50 })).toEqual({ + color: "#abcabc", + texture: "/t/dirt.svg", + tile: 50, + }); + }); + + it("image → src becomes the texture, no tile (stretch)", () => { + const r = resolveFace({ kind: "image", src: "data:image/png;base64,AA" }); + expect(r.texture).toBe("data:image/png;base64,AA"); + expect(r.tile).toBeUndefined(); + }); + + it("an empty texture / image source yields no texture", () => { + expect(resolveFace({ kind: "texture", url: "" }).texture).toBeUndefined(); + expect(resolveFace({ kind: "image", src: "" }).texture).toBeUndefined(); + }); +}); diff --git a/packages/fonts/src/fill.ts b/packages/fonts/src/fill.ts new file mode 100644 index 00000000..9bdd89c2 --- /dev/null +++ b/packages/fonts/src/fill.ts @@ -0,0 +1,111 @@ +/** + * Browser-only helpers that paint a WordArt "master fill" onto a `` and + * return it as a data URL, plus `resolveFace` which turns a high-level fill + * spec into the pure-layer `Face` (`{ color?, texture?, tile? }`) that + * `composeText` consumes. `composeText` then UV-maps the whole word's face to + * that single texture, so a gradient / rainbow / image / block flows + * continuously across every glyph (not per-letter). + * + * Pure-layer code (`composeText`, `extrudeContours`) never imports this — it + * only receives the resulting strings — so the Node-testable path stays free of + * browser globals. + */ +import type { Face } from "./composeText"; + +/** A WordArt face fill. `solid` means "no texture, use the flat color". */ +export type FillSpec = + | { type: "solid" } + | { type: "gradient"; from: string; to: string; angle?: number } + | { type: "rainbow"; angle?: number } + | { type: "image"; src: string }; + +/** High-level per-face fill the UI works with; `resolveFace` renders it to a `Face`. */ +export type FaceFillSpec = + | { kind: "solid"; color: string } + | { kind: "gradient"; color?: string; from: string; to: string; angle?: number } + | { kind: "rainbow"; color?: string; angle?: number } + | { kind: "texture"; color?: string; url: string; tile?: number } + | { kind: "image"; color?: string; src: string }; + +/** + * Resolve a high-level face fill into the pure `Face` `composeText` takes — + * rendering gradients / rainbows to a data URL via `makeFillTexture`, and + * passing block / image URLs straight through. Keeps `composeText` browser-free. + */ +export function resolveFace(spec: FaceFillSpec): Face { + switch (spec.kind) { + case "solid": + return { color: spec.color }; + case "gradient": + return { color: spec.color, texture: makeFillTexture({ type: "gradient", from: spec.from, to: spec.to, angle: spec.angle }) }; + case "rainbow": + return { color: spec.color, texture: makeFillTexture({ type: "rainbow", angle: spec.angle }) }; + case "texture": + return { color: spec.color, texture: spec.url || undefined, tile: spec.tile }; + case "image": + return { color: spec.color, texture: spec.src || undefined }; + } +} + +const RAINBOW = [ + "#ff3b30", "#ff9500", "#ffcc00", "#34c759", + "#00c7be", "#007aff", "#5856d6", "#af52de", +]; + +function makeCanvas(w: number, h: number): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + return canvas; +} + +/** + * Paint a linear gradient covering the whole canvas at `angleDeg` (measured + * CCW from +x in word space, so 90° points up). Canvas y is down while word v + * is up, so the direction's y is negated. + */ +function paintLinear( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + angleDeg: number, + stops: Array<[number, string]>, +): void { + const a = (angleDeg * Math.PI) / 180; + const dx = Math.cos(a); + const dy = -Math.sin(a); + const cx = w / 2; + const cy = h / 2; + const half = (Math.abs(dx) * w + Math.abs(dy) * h) / 2; + const g = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half); + for (const [offset, color] of stops) g.addColorStop(offset, color); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); +} + +/** + * Build the master fill texture for a face. Returns a data URL, or `undefined` + * for `solid` (no texture). For `image`, the source is returned as-is — the + * renderer can use any `background-image` URL directly. + */ +export function makeFillTexture(spec: FillSpec): string | undefined { + if (spec.type === "solid") return undefined; + if (spec.type === "image") return spec.src || undefined; + + const size = 256; + const canvas = makeCanvas(size, size); + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + + if (spec.type === "rainbow") { + const angle = spec.angle ?? 0; + const stops = RAINBOW.map((c, i): [number, string] => [i / (RAINBOW.length - 1), c]); + paintLinear(ctx, size, size, angle, stops); + } else { + // Default 270° = straight down, so `from` is at the top of the word. + const angle = spec.angle ?? 270; + paintLinear(ctx, size, size, angle, [[0, spec.from], [1, spec.to]]); + } + + return canvas.toDataURL("image/png"); +} diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 164017d8..cc9b8c81 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -4,17 +4,22 @@ // 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. +// loadGoogleFont(), makeFillTexture(). The parts that touch +// fetch / canvas. 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 { cssCubicBezier } from "./extrude"; +export type { ExtrudeProfile, CubicBezier, MaterialStop } from "./extrude"; export { composeText } from "./composeText"; -export type { ComposeTextOptions, WarpShape, WarpOptions } from "./composeText"; +export type { ComposeTextOptions, Profile, Face, BackFace, FaceStop, WarpShape, WarpOptions } from "./composeText"; + +export { makeFillTexture, resolveFace } from "./fill"; +export type { FillSpec, FaceFillSpec } from "./fill"; export { listGoogleFonts, diff --git a/packages/fonts/src/textPolygons.ts b/packages/fonts/src/textPolygons.ts index 76d69399..351942ff 100644 --- a/packages/fonts/src/textPolygons.ts +++ b/packages/fonts/src/textPolygons.ts @@ -73,7 +73,10 @@ export function textPolygons(font: ParsedFont, text: string, options: TextPolygo profile, profileSegments, maxInset: size * 0.045, - color, - sideColor, + stops: [ + { at: 0, color }, + { at: 0.5, color: sideColor }, + { at: 1, color }, + ], }); } diff --git a/website/public/textures/wordart/brick.svg b/website/public/textures/wordart/brick.svg new file mode 100644 index 00000000..f0f19469 --- /dev/null +++ b/website/public/textures/wordart/brick.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/website/public/textures/wordart/brick2.svg b/website/public/textures/wordart/brick2.svg new file mode 100644 index 00000000..efd3d0b8 --- /dev/null +++ b/website/public/textures/wordart/brick2.svg @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/website/public/textures/wordart/cacti.svg b/website/public/textures/wordart/cacti.svg new file mode 100644 index 00000000..2406aead --- /dev/null +++ b/website/public/textures/wordart/cacti.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/public/textures/wordart/dirt.svg b/website/public/textures/wordart/dirt.svg new file mode 100644 index 00000000..7d84a644 --- /dev/null +++ b/website/public/textures/wordart/dirt.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/textures/wordart/dirt2.svg b/website/public/textures/wordart/dirt2.svg new file mode 100644 index 00000000..913590d2 --- /dev/null +++ b/website/public/textures/wordart/dirt2.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/textures/wordart/glass.svg b/website/public/textures/wordart/glass.svg new file mode 100644 index 00000000..3c21cf53 --- /dev/null +++ b/website/public/textures/wordart/glass.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/textures/wordart/grass3.svg b/website/public/textures/wordart/grass3.svg new file mode 100644 index 00000000..abd941c3 --- /dev/null +++ b/website/public/textures/wordart/grass3.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/textures/wordart/ice.svg b/website/public/textures/wordart/ice.svg new file mode 100644 index 00000000..46c2865e --- /dev/null +++ b/website/public/textures/wordart/ice.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/website/public/textures/wordart/ice3.svg b/website/public/textures/wordart/ice3.svg new file mode 100644 index 00000000..99f8a953 --- /dev/null +++ b/website/public/textures/wordart/ice3.svg @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/website/public/textures/wordart/mine.svg b/website/public/textures/wordart/mine.svg new file mode 100644 index 00000000..057b2ce6 --- /dev/null +++ b/website/public/textures/wordart/mine.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/website/public/textures/wordart/mine4.svg b/website/public/textures/wordart/mine4.svg new file mode 100644 index 00000000..ef2f710f --- /dev/null +++ b/website/public/textures/wordart/mine4.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/website/public/textures/wordart/rock.svg b/website/public/textures/wordart/rock.svg new file mode 100644 index 00000000..414a74dc --- /dev/null +++ b/website/public/textures/wordart/rock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/website/public/textures/wordart/rock3.svg b/website/public/textures/wordart/rock3.svg new file mode 100644 index 00000000..5cc1b9e1 --- /dev/null +++ b/website/public/textures/wordart/rock3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/website/public/textures/wordart/sand.svg b/website/public/textures/wordart/sand.svg new file mode 100644 index 00000000..f38e70e3 --- /dev/null +++ b/website/public/textures/wordart/sand.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/public/textures/wordart/wood.svg b/website/public/textures/wordart/wood.svg new file mode 100644 index 00000000..09658059 --- /dev/null +++ b/website/public/textures/wordart/wood.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/website/public/textures/wordart/wood3.svg b/website/public/textures/wordart/wood3.svg new file mode 100644 index 00000000..5a8d06aa --- /dev/null +++ b/website/public/textures/wordart/wood3.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx index 4367711c..fa1d1729 100644 --- a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx +++ b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx @@ -16,15 +16,54 @@ import { listGoogleFonts, loadFont, loadGoogleFont, + resolveFace, pickWeight, + type BackFace, type ExtrudeProfile, + type Face, + type FaceFillSpec, type FontEntry, type ParsedFont, + type Profile, type WarpShape, } from "@layoutit/polycss-fonts"; import "./wordart.css"; type Align = "left" | "center" | "right"; +type FillType = "solid" | "gradient" | "rainbow" | "texture" | "image"; +type FaceFill = "solid" | "texture" | "none"; +type Bezier4 = [number, number, number, number]; + +// Named CSS easings → cubic-bezier control points, for the custom edge profile. +const CSS_EASINGS: Record = { + linear: [0, 0, 1, 1], + ease: [0.25, 0.1, 0.25, 1], + "ease-in": [0.42, 0, 1, 1], + "ease-out": [0, 0, 0.58, 1], + "ease-in-out": [0.42, 0, 0.58, 1], +}; + +/** Parse a CSS easing string (`cubic-bezier(...)` or a keyword) to 4 controls. */ +function parseBezier(input: string): Bezier4 | null { + const s = input.trim().toLowerCase(); + if (CSS_EASINGS[s]) return CSS_EASINGS[s]; + const m = /cubic-bezier\(\s*([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)\s*\)/.exec(s); + if (!m) return null; + const p = [m[1], m[2], m[3], m[4]].map(Number) as Bezier4; + return p.every((n) => !Number.isNaN(n)) ? p : null; +} +const bezierToCss = (b: Bezier4) => `cubic-bezier(${b.map((n) => +n.toFixed(2)).join(", ")})`; + +// Bundled voxel-style block textures (Layoutit voxels set), served locally from +// public/textures/wordart so the atlas canvas stays same-origin (no CORS taint). +const TEXTURES: { id: string; label: string }[] = [ + { id: "dirt", label: "Dirt" }, { id: "dirt2", label: "Dirt 2" }, { id: "grass3", label: "Grass" }, + { id: "brick", label: "Brick" }, { id: "brick2", label: "Brick 2" }, { id: "wood", label: "Wood" }, + { id: "wood3", label: "Plank" }, { id: "rock", label: "Rock" }, { id: "rock3", label: "Rock 2" }, + { id: "ice", label: "Ice" }, { id: "ice3", label: "Ice 2" }, { id: "glass", label: "Glass" }, + { id: "sand", label: "Sand" }, { id: "cacti", label: "Cactus" }, { id: "mine", label: "Ore" }, { id: "mine4", label: "Ore 2" }, +]; +const texUrl = (id: string) => (id ? `/textures/wordart/${id}.svg` : ""); interface Preset { label: string; @@ -37,19 +76,48 @@ interface Preset { /** Diagonal back offset for the layered block (down-right). */ offset?: number; warp?: { shape: WarpShape; amount: number }; + /** Face fill (defaults to solid `color`). */ + fill?: FillType; + gradA?: string; + gradB?: string; + gradAngle?: number; + /** Block-texture ids for the front / sides / back faces. */ + faceTex?: string; + sideTex?: string; + backTex?: string; + outline?: { color: string; width: number }; + /** Flat two-layer drop shadow (no extrusion walls). */ + layered?: boolean; + /** CSS background for the preset tile thumbnail (defaults to `color`). */ + thumb?: string; } // 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 Gradient", profile: "bevel", depth: 26, color: "#ffd23f", sideColor: "#7c4a12", + fill: "gradient", gradA: "#ffe14d", gradB: "#ff7a1a", gradAngle: 270, thumb: "linear-gradient(#ffe14d,#ff7a1a)" }, + { label: "Grape Pop", profile: "flat", depth: 5, color: "#b14be0", sideColor: "#7a8cff", backColor: "#8aa0ff", offset: 14, layered: true, + fill: "gradient", gradA: "#c45cf0", gradB: "#7a1fb8", gradAngle: 270, thumb: "linear-gradient(#c45cf0,#7a1fb8)" }, + { label: "Chrome", profile: "bevel", depth: 22, color: "#d7dde4", sideColor: "#3a2222", + fill: "gradient", gradA: "#f4f8ff", gradB: "#9a4b4b", gradAngle: 270, thumb: "linear-gradient(#f4f8ff 45%,#9a4b4b)" }, + { label: "Rainbow", profile: "flat", depth: 10, color: "#ff5e3a", sideColor: "#7a2a55", + fill: "rainbow", gradAngle: 0, thumb: "linear-gradient(90deg,#ff3b30,#ffcc00,#34c759,#007aff,#af52de)" }, + { label: "Sky Outline", profile: "flat", depth: 8, color: "#7ec8ff", sideColor: "#2b50b0", + outline: { color: "#1838b8", width: 3 }, thumb: "#7ec8ff" }, + { label: "Grass Block", profile: "flat", depth: 18, color: "#6ab04c", sideColor: "#6b4a2b", + fill: "texture", faceTex: "grass3", sideTex: "dirt", thumb: "url(/textures/wordart/grass3.svg) center/cover" }, + { label: "Brick Wall", profile: "bevel", depth: 22, color: "#a8432a", sideColor: "#7a2f1d", + fill: "texture", faceTex: "brick", sideTex: "brick2", thumb: "url(/textures/wordart/brick.svg) center/cover" }, + { label: "Stone", profile: "flat", depth: 20, color: "#8d8d8d", sideColor: "#5a5a5a", + fill: "texture", faceTex: "rock", sideTex: "rock3", thumb: "url(/textures/wordart/rock.svg) center/cover" }, + { label: "Ice", profile: "bevel", depth: 18, color: "#b9e6ff", sideColor: "#6aa9cc", + fill: "texture", faceTex: "ice", sideTex: "ice3", thumb: "url(/textures/wordart/ice.svg) center/cover" }, { 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: "Retro Block", profile: "flat", depth: 6, color: "#ff4d6d", sideColor: "#3a0ca3", backColor: "#3a0ca3", offset: 16, layered: true, thumb: "#ff4d6d" }, { 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 }, + { label: "Ink Shadow", profile: "flat", depth: 4, color: "#e8edf2", sideColor: "#2b313b", backColor: "#2b313b", offset: 12, layered: true, thumb: "#e8edf2" }, ]; function applyCase(text: string, mode: "as-typed" | "upper" | "lower" | "title"): string { @@ -130,6 +198,11 @@ export function WordArtWorkbench() { const [scaleX, setScaleX] = useState(() => qn("sx", 100)); const [scaleY, setScaleY] = useState(() => qn("sy", 100)); const [profile, setProfile] = useState(() => qs("profile", "bevel") as ExtrudeProfile); + const [roundConvex, setRoundConvex] = useState(() => qb("rconv", false)); + const [bezier, setBezier] = useState(() => { + const p = qs("bez", "").split(",").map(Number); + return p.length === 4 && p.every((n) => !Number.isNaN(n)) ? (p as Bezier4) : [0.3, 0.9, 0.7, 0.1]; + }); const [depth, setDepth] = useState(() => qn("depth", 26)); const [letterSpacing, setLetterSpacing] = useState(() => qn("ls", 0)); const [lineHeight, setLineHeight] = useState(() => qn("lh", 1.15)); @@ -147,6 +220,21 @@ export function WordArtWorkbench() { const [warpShape, setWarpShape] = useState(() => qs("warp", "none") as WarpShape); const [warpAmount, setWarpAmount] = useState(() => qn("bend", 0.5)); const [spin, setSpin] = useState(() => qb("spin", true)); + // Face fill (solid / gradient / rainbow / image), outline, flat-layer shadow. + const [fillType, setFillType] = useState(() => qs("fill", "solid") as FillType); + const [gradA, setGradA] = useState(() => qs("ga", "#ffd23f")); + const [gradB, setGradB] = useState(() => qs("gb", "#ff5e3a")); + const [gradAngle, setGradAngle] = useState(() => qn("gang", 270)); + const [fillImage, setFillImage] = useState(""); + const [faceTex, setFaceTex] = useState(() => qs("ftex", "dirt")); + const [sideFill, setSideFill] = useState(() => qs("sfill", "solid") as FaceFill); + const [sideTex, setSideTex] = useState(() => qs("stex", "dirt")); + const [backFill, setBackFill] = useState(() => qs("bfill", "solid") as FaceFill); + const [backTex, setBackTex] = useState(() => qs("btex", "dirt")); + const [outlineOn, setOutlineOn] = useState(() => qb("ol", false)); + const [outlineColor, setOutlineColor] = useState(() => qs("olc", "#1a1a2e")); + const [outlineWidth, setOutlineWidth] = useState(() => qn("olw", 3)); + const [layered, setLayered] = useState(() => qb("layer", false)); // Camera + lighting (gallery-style) const [perspective, setPerspective] = useState(() => qb("persp", true)); const [zoomScale, setZoomScale] = useState(() => qn("zoom", 1)); @@ -211,6 +299,8 @@ export function WordArtWorkbench() { sn("sx", scaleX, 100); sn("sy", scaleY, 100); ss("profile", profile, "bevel"); + if (roundConvex) p.set("rconv", "1"); + if (profile === "custom") p.set("bez", bezier.map((n) => +n.toFixed(3)).join(",")); sn("depth", depth, 26); sn("ls", letterSpacing, 0); sn("lh", lineHeight, 1.15); @@ -235,9 +325,22 @@ export function WordArtWorkbench() { ss("lc", lightColor, "#ffffff"); sn("laz", lightAz, -25); sn("lel", lightEl, 45); + ss("fill", fillType, "solid"); + ss("ga", gradA, "#ffd23f"); + ss("gb", gradB, "#ff5e3a"); + sn("gang", gradAngle, 270); + ss("ftex", faceTex, "dirt"); + ss("sfill", sideFill, "solid"); + ss("stex", sideTex, "dirt"); + ss("bfill", backFill, "solid"); + ss("btex", backTex, "dirt"); + if (outlineOn) p.set("ol", "1"); + ss("olc", outlineColor, "#1a1a2e"); + sn("olw", outlineWidth, 3); + if (layered) p.set("layer", "1"); 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]); + }, [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, roundConvex, bezier, fillType, gradA, gradB, gradAngle, faceTex, sideFill, sideTex, backFill, backTex, outlineOn, outlineColor, outlineWidth, layered]); // Load the picked Google font whenever family / weight / style changes. useEffect(() => { @@ -257,30 +360,58 @@ export function WordArtWorkbench() { }; }, [entry, weight, italic]); + // Resolve each face's UI fill into a pure `Face` (gradients/rainbow → data URL + // via resolveFace; solid/texture pass through). One key so the memo is stable. + const TILE = 52; + const frontKey = `${fillType}:${gradA}:${gradB}:${gradAngle}:${faceTex}:${color}:${fillImage.slice(0, 40)}`; + const front = useMemo(() => { + const spec: FaceFillSpec = + fillType === "gradient" ? { kind: "gradient", color, from: gradA, to: gradB, angle: gradAngle } + : fillType === "rainbow" ? { kind: "rainbow", color, angle: gradAngle } + : fillType === "texture" ? { kind: "texture", color, url: texUrl(faceTex), tile: TILE } + : fillType === "image" ? { kind: "image", color, src: fillImage } + : { kind: "solid", color }; + return resolveFace(spec); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [frontKey]); + const polygons = useMemo(() => { if (!font) return []; + // "None" → no separate material for that face (it's covered by the nearest + // active face), but the geometry still renders — no hole. + const sides: Face | false = + sideFill === "texture" ? resolveFace({ kind: "texture", color: sideColor, url: texUrl(sideTex), tile: TILE }) + : sideFill === "solid" ? { color: sideColor } + : false; + let back: BackFace | false = + backFill === "texture" ? resolveFace({ kind: "texture", color: backColor, url: texUrl(backTex), tile: TILE }) + : backFill === "solid" ? { color: backColor } + : false; + if (back !== false && layered) back.offset = [offset || 12, -(offset || 12)]; + + const profileObj: Profile = + profile === "flat" ? "flat" + : profile === "custom" ? { curve: bezier, segments: profileSegments } + : { edge: profile, raised: roundConvex, segments: profileSegments }; + return composeText(font, applyCase(text, textCase), { size: 100, - depth, - profile, + depth: layered ? 0 : depth, // "Flat layers" = no edges (depth 0) + profile: profileObj, + scale: [scaleX / 100, scaleY / 100], 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 }, + faces: { front, sides, back }, + outline: outlineOn ? { color: outlineColor, width: outlineWidth } : undefined, }); - }, [font, text, textCase, scaleX, scaleY, depth, profile, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, merge, profileSegments, warpShape, warpAmount]); + }, [font, text, textCase, scaleX, scaleY, depth, profile, roundConvex, bezier, letterSpacing, lineHeight, align, underline, strike, sideColor, backColor, offset, curveSegments, simplify, merge, profileSegments, warpShape, warpAmount, front, fillType, backFill, backTex, sideFill, sideTex, outlineOn, outlineColor, outlineWidth, layered]); // Directional light direction from azimuth (left/right) + elevation (height), // always biased toward the front so the face stays lit. @@ -308,10 +439,27 @@ export function WordArtWorkbench() { setOffset(p.offset ?? 0); setWarpShape(p.warp?.shape ?? "none"); setWarpAmount(p.warp?.amount ?? 0.5); + setFillType(p.fill ?? "solid"); + if (p.gradA) setGradA(p.gradA); + if (p.gradB) setGradB(p.gradB); + setGradAngle(p.gradAngle ?? 270); + if (p.faceTex) setFaceTex(p.faceTex); + setSideFill(p.sideTex ? "texture" : "solid"); + if (p.sideTex) setSideTex(p.sideTex); + setBackFill(p.backTex ? "texture" : "solid"); + if (p.backTex) setBackTex(p.backTex); + setOutlineOn(!!p.outline); + if (p.outline) { setOutlineColor(p.outline.color); setOutlineWidth(p.outline.width); } + setLayered(!!p.layered); setActivePreset(p.label); } - const leftValues: LeftValues = { weight, italic, underline, strike, textCase, align, color, sideColor, backColor }; + const leftValues: LeftValues = { + weight, italic, underline, strike, textCase, align, color, sideColor, backColor, + fillType, gradA, gradB, gradAngle, image: fillImage, faceTex, + sideFill, sideTex, backFill, backTex, + outlineOn, outlineColor, outlineWidth, + }; const leftSet = (k: keyof LeftValues, v: number | string | boolean) => { switch (k) { case "weight": setWeight(v as number); break; @@ -323,11 +471,31 @@ export function WordArtWorkbench() { case "color": setColor(v as string); break; case "sideColor": setSideColor(v as string); break; case "backColor": setBackColor(v as string); break; + case "fillType": setFillType(v as FillType); break; + case "gradA": setGradA(v as string); break; + case "gradB": setGradB(v as string); break; + case "gradAngle": setGradAngle(v as number); break; + case "image": setFillImage(v as string); break; + case "faceTex": setFaceTex(v as string); break; + case "sideFill": setSideFill(v as FaceFill); break; + case "sideTex": setSideTex(v as string); break; + case "backFill": setBackFill(v as FaceFill); break; + case "backTex": setBackTex(v as string); break; + case "outlineOn": setOutlineOn(v as boolean); break; + case "outlineColor": setOutlineColor(v as string); break; + case "outlineWidth": setOutlineWidth(v as number); break; } }; + // The Profile dropdown encodes edge shape only — colors now come from the + // axial face stops, so there's no coverage to bundle in. + const profileMode = profile === "flat" ? "flat" + : profile === "custom" ? "custom" + : profile === "round" ? (roundConvex ? "roundup" : "round") + : "bevel"; const guiValues: GuiValues = { - profile, warp: warpShape, bend: warpAmount, + layered, + profileMode, warp: warpShape, bend: warpAmount, depth, letterSpacing, lineHeight, scaleX, scaleY, curveSegments, simplify, merge, profileSegments, offset, perspective, zoom: zoomScale, spin, @@ -335,7 +503,14 @@ export function WordArtWorkbench() { }; const guiSet = (k: keyof GuiValues, v: number | string | boolean) => { switch (k) { - case "profile": setProfile(v as ExtrudeProfile); break; + case "layered": setLayered(v as boolean); break; + case "profileMode": { + const base = v as string; + setProfile(base === "flat" ? "flat" : base === "custom" ? "custom" : base.startsWith("round") ? "round" : "bevel"); + setRoundConvex(base === "roundup"); + break; + } + case "warp": setWarpShape(v as WarpShape); break; case "warp": setWarpShape(v as WarpShape); break; case "bend": setWarpAmount(v as number); break; case "depth": setDepth(v as number); break; @@ -403,7 +578,7 @@ export function WordArtWorkbench() {
{PRESETS.map((p) => ( ))} @@ -416,6 +591,8 @@ export function WordArtWorkbench() { className={mobilePanel === "controls" ? "is-mobile-open" : ""} values={guiValues} set={guiSet} + bezier={bezier} + onBezier={setBezier} />