Skip to content

Commit dcb9d88

Browse files
authored
WordArt: cap-merge + adaptive segments, atomic atlas swaps, live scale (#56)
* perf(fonts): merge coplanar cap triangles into convex polygons * perf(fonts): adaptive round-edge segment count by default * refactor(wordart): drop removed merge toggle and a dead warp case * fix(scene): double-buffer atlas with pre-decoded swap to remove edit flicker * fix(scene): never blank the atlas during rebuild so geometry edits stay smooth * perf(wordart): defer the mesh render so slider drags stay responsive * feat(scene): opt-in atomicAtlas swap with onFrameReady (react + vue) * perf(wordart): live CSS-scale preview on drag, atomic swap on release * perf(wordart): scale X/Y as a live CSS transform (no recompute, no flicker)
1 parent c788a82 commit dcb9d88

9 files changed

Lines changed: 555 additions & 119 deletions

File tree

packages/fonts/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const polygons = composeText(font, "Poly\nCSS", {
5353
// 1 · type & layout
5454
size: 100, depth: 24, align: "center", scale: [1, 1],
5555
letterSpacing: 0, lineHeight: 1.25, underline: false, strike: false,
56-
warp: { shape: "arch", amount: 0.6 }, simplify: 0, merge: false,
56+
warp: { shape: "arch", amount: 0.6 }, simplify: 0,
5757

5858
// 2 · cross-section / edge profile (one union)
5959
profile: { edge: "bevel", coverage: "front" },
@@ -72,7 +72,7 @@ const polygons = composeText(font, "Poly\nCSS", {
7272

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

packages/fonts/src/composeText.test.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,29 @@ describe("composeText", () => {
108108
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
109109
});
110110

111-
it("merge reduces the polygon count", () => {
112-
const base = composeText(roboto, "Poly", { merge: false });
113-
const merged = composeText(roboto, "Poly", { merge: true });
114-
expect(merged.length).toBeLessThan(base.length);
111+
it("cap triangles merge into convex polygons (fewer nodes, no concavity)", () => {
112+
const polys = composeText(roboto, "Poly", { depth: 20 });
113+
// Merge happened: some caps are now N-gons, not bare triangles.
114+
expect(polys.some((p) => p.vertices.length > 3)).toBe(true);
115+
// Every emitted polygon must stay convex — a concave merge would render as
116+
// a self-overlapping leaf. Check each polygon in its own plane.
117+
const isConvex3d = (vs: readonly [number, number, number][]): boolean => {
118+
const n = vs.length;
119+
if (n < 3) return false;
120+
const sub = (a: number[], b: number[]) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
121+
const cross = (a: number[], b: number[]) => [
122+
a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0],
123+
];
124+
let ref: number[] | null = null;
125+
for (let i = 0; i < n; i++) {
126+
const c = cross(sub(vs[(i + 1) % n], vs[i]), sub(vs[(i + 2) % n], vs[(i + 1) % n]));
127+
if (Math.hypot(...c) < 1e-6) continue; // collinear corner
128+
if (!ref) ref = c;
129+
else if (ref[0] * c[0] + ref[1] * c[1] + ref[2] * c[2] < 0) return false; // sign flip
130+
}
131+
return true;
132+
};
133+
expect(polys.every((p) => isConvex3d(p.vertices))).toBe(true);
115134
});
116135

117136
it("flat shadow (depth 0) offsets a recolored back layer", () => {
@@ -229,13 +248,26 @@ describe("composeText", () => {
229248
});
230249

231250
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] } });
251+
// Pin equal segment counts so the comparison is apples-to-apples (the round
252+
// default is otherwise adaptive, while custom keeps a fixed default).
253+
const round = composeText(roboto, "o", { depth: 24, profile: { edge: "round", segments: 6 } });
254+
const custom = composeText(roboto, "o", { depth: 24, profile: { curve: [0.1, 0.9, 0.2, 1], segments: 6 } });
234255
const hash = (ps: ReturnType<typeof composeText>) => ps.map((p) => p.vertices.flat().join()).join("|");
235256
expect(round.length).toBe(custom.length);
236257
expect(hash(round)).not.toBe(hash(custom));
237258
});
238259

260+
it("round edges pick fewer segments by default than a forced high count", () => {
261+
// The default round segment count is adaptive (small bevel → fewer rings),
262+
// so it never exceeds — and here undercuts — an explicit high count.
263+
const auto = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round" } });
264+
const dense = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round", segments: 6 } });
265+
expect(auto.length).toBeLessThan(dense.length);
266+
// An explicit count is always honored verbatim.
267+
const exact = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round", segments: 6 } });
268+
expect(dense.length).toBe(exact.length);
269+
});
270+
239271
it("flat (depth 0) drops the side walls vs an extruded depth", () => {
240272
const walled = composeText(roboto, "o", { depth: 12, faces: { back: { color: "#00ff00" } } });
241273
const flat = composeText(roboto, "o", { depth: 0, faces: { back: { color: "#00ff00", offset: [10, -10] } } });

packages/fonts/src/composeText.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* extrusion, so the 3D walls follow the curve too. Bold/italic are chosen by
99
* the caller by passing the appropriate weight/style `ParsedFont`.
1010
*/
11-
import { mergePolygons, type Polygon } from "@layoutit/polycss-core";
11+
import type { Polygon } from "@layoutit/polycss-core";
1212
import type { ParsedFont } from "./parseFont";
1313
import {
1414
dedupeContour,
@@ -101,8 +101,6 @@ export interface ComposeTextOptions {
101101
warp?: WarpOptions;
102102
/** Outline simplification tolerance (world units, 0 = exact; hole-less glyphs only). */
103103
simplify?: number;
104-
/** Merge coplanar same-color cap triangles into larger polygons (fewer DOM nodes). */
105-
merge?: boolean;
106104
/** Extrusion cross-section / edge profile. Defaults to "flat". */
107105
profile?: Profile;
108106
/**
@@ -129,6 +127,17 @@ function resolveProfile(p: Profile | undefined): {
129127
return { profile: "custom", profileBezier: p.curve, segments: p.segments };
130128
}
131129

130+
/**
131+
* Fewest quarter-arc segments whose worst chord error (sagitta) stays under
132+
* `tol` world units, clamped to [2, 6]. For a radius-`r` quarter circle split
133+
* into N segments the sagitta is `r·(1 − cos(π/2N))`; solve that ≤ tol for N.
134+
*/
135+
function adaptiveRoundSegments(r: number, tol: number): number {
136+
if (r <= tol || r <= 1e-6) return 2;
137+
const n = Math.ceil(Math.PI / (2 * Math.acos(1 - tol / r)));
138+
return Math.min(6, Math.max(2, n));
139+
}
140+
132141
/**
133142
* Normalize the `faces` option into sorted material stops. A face set to `false`
134143
* (or `sides` omitted) contributes no stop — the geometry is still rendered, but
@@ -173,7 +182,16 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText
173182
const { stops, backOffset } = resolveStops(options.faces, "#d4a82a");
174183

175184
const prof = resolveProfile(options.profile);
176-
const profileSegments = Math.max(1, Math.round(prof.segments ?? 6));
185+
// Ring count for the round edge. An explicit `segments` is honored; otherwise
186+
// pick the fewest segments whose chord (sagitta) error stays under ~0.4% of
187+
// the cap height — the round-over radius is capped at size*0.045, so a small
188+
// bevel never needs the full default. A custom curve keeps a fixed default
189+
// since its detail isn't a function of edge size.
190+
const roundEdge = Math.min(size * 0.045, depth / 2);
191+
const adaptive = prof.profile === "round"
192+
? adaptiveRoundSegments(roundEdge, size * 0.004)
193+
: 6;
194+
const profileSegments = Math.max(1, Math.round(prof.segments ?? adaptive));
177195
// depth 0 → a flat slab with no side walls (and a place for the offset shadow).
178196
const flat = depth <= 0;
179197

@@ -260,7 +278,7 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText
260278
outlineColor: options.outline?.color,
261279
outlineWidth: options.outline?.width,
262280
});
263-
return options.merge ? mergePolygons(polygons) : polygons;
281+
return polygons;
264282
}
265283

266284
function warpShape(shape: Shape, warp: WarpFn | null): Shape {

packages/fonts/src/extrude.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,110 @@ interface Ring {
111111
inset: number;
112112
}
113113

114+
const turn = (o: Pt, a: Pt, b: Pt): number =>
115+
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
116+
117+
/** A simple polygon (no repeated vertices) whose turns never change sign. */
118+
function isConvexLoop(loop: number[], vert: (i: number) => Pt): boolean {
119+
const n = loop.length;
120+
if (n < 3) return false;
121+
if (new Set(loop).size !== n) return false; // self-touching → not simple
122+
let sign = 0;
123+
for (let i = 0; i < n; i++) {
124+
const c = turn(vert(loop[i]), vert(loop[(i + 1) % n]), vert(loop[(i + 2) % n]));
125+
if (Math.abs(c) < 1e-9) continue; // collinear vertex — ignore
126+
const s = c > 0 ? 1 : -1;
127+
if (sign === 0) sign = s;
128+
else if (s !== sign) return false;
129+
}
130+
return true;
131+
}
132+
133+
/**
134+
* Glue two index loops (same orientation) along their shared edge `a–b`,
135+
* returning the combined boundary in that same orientation, or null if the
136+
* edge isn't a clean P→Q / Q→P pair.
137+
*/
138+
function gluedLoop(P: number[], Q: number[], a: number, b: number): number[] | null {
139+
const dirIn = (loop: number[]): [number, number] | null => {
140+
const ia = loop.indexOf(a);
141+
if (ia < 0) return null;
142+
if (loop[(ia + 1) % loop.length] === b) return [a, b];
143+
const ib = loop.indexOf(b);
144+
if (ib >= 0 && loop[(ib + 1) % loop.length] === a) return [b, a];
145+
return null;
146+
};
147+
const dp = dirIn(P);
148+
const dq = dirIn(Q);
149+
if (!dp || !dq || dp[0] === dq[0]) return null; // must be opposite directions
150+
const [x, y] = dp; // P goes x→y along the shared edge
151+
const walk = (loop: number[], from: number, to: number): number[] => {
152+
const r: number[] = [];
153+
let i = loop.indexOf(from);
154+
for (let c = 0; c < loop.length; c++) {
155+
r.push(loop[i]);
156+
if (loop[i] === to) break;
157+
i = (i + 1) % loop.length;
158+
}
159+
return r;
160+
};
161+
const loop = walk(P, y, x).concat(walk(Q, x, y).slice(1, -1));
162+
return loop.length >= 3 ? loop : null;
163+
}
164+
165+
/**
166+
* Greedily merge an earcut triangle list into maximal convex polygons (a
167+
* Hertel–Mehlmann-style partition). Each emitted loop keeps earcut's traversal
168+
* order, so the cap can wind front/back exactly as it did per-triangle. The
169+
* silhouette and holes are unchanged — this only erases interior diagonals,
170+
* cutting DOM-leaf count and the coplanar same-color seams between them.
171+
*/
172+
function convexPartition(flat: number[], tris: number[]): number[][] {
173+
const vert = (i: number): Pt => [flat[i * 2], flat[i * 2 + 1]];
174+
const polys: (number[] | null)[] = [];
175+
for (let t = 0; t < tris.length; t += 3) polys.push([tris[t], tris[t + 1], tris[t + 2]]);
176+
177+
// Grow convex regions by absorbing neighbors a triangle at a time (a region
178+
// that swallows a triangle is likelier to stay convex than two quads glued
179+
// together). Each pass lets a region keep eating along its boundary; passes
180+
// repeat until one settles. Edge keys are numeric (a*stride+b) to keep the
181+
// per-pass rebuild cheap.
182+
const stride = flat.length / 2 + 1;
183+
let changed = true;
184+
while (changed) {
185+
changed = false;
186+
const edge = new Map<number, number[]>();
187+
for (let pi = 0; pi < polys.length; pi++) {
188+
const p = polys[pi];
189+
if (!p) continue;
190+
for (let i = 0; i < p.length; i++) {
191+
const a = p[i], b = p[(i + 1) % p.length];
192+
const k = a < b ? a * stride + b : b * stride + a;
193+
let l = edge.get(k);
194+
if (!l) edge.set(k, (l = []));
195+
l.push(pi);
196+
}
197+
}
198+
for (const [k, list] of edge) {
199+
if (list.length !== 2) continue;
200+
const [pi, pj] = list;
201+
// polys[pi] may have grown earlier in this pass; re-read both and re-check
202+
// that the recorded edge is still on each boundary (gluedLoop returns null
203+
// if the region already absorbed past it).
204+
const P = polys[pi], Q = polys[pj];
205+
if (!P || !Q) continue;
206+
const a = Math.floor(k / stride), b = k % stride;
207+
const merged = gluedLoop(P, Q, a, b);
208+
if (merged && isConvexLoop(merged, vert)) {
209+
polys[pi] = merged;
210+
polys[pj] = null;
211+
changed = true;
212+
}
213+
}
214+
}
215+
return polys.filter((p): p is number[] => p !== null);
216+
}
217+
114218
const toWorld = (p: Pt, z: number): Vec3 => [-p[1], p[0], z];
115219

116220
/** Extrude pre-grouped 2D shapes (type-plane, world units) into polygons. */
@@ -184,22 +288,21 @@ export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[
184288
const tris = earcut(flat, holeIndices, 2);
185289
const vert = (i: number): Pt => [flat[i * 2], flat[i * 2 + 1]];
186290
const tile = mat.tile ?? 0;
187-
for (let t = 0; t < tris.length; t += 3) {
188-
const a = vert(tris[t]);
189-
const b = vert(tris[t + 1]);
190-
const c = vert(tris[t + 2]);
191-
const tri = flip ? [a, b, c] : [a, c, b];
192-
const ordered: [Pt, Pt, Pt] = [tri[2], tri[1], tri[0]];
291+
// earcut order winds the front cap; the back cap is its reverse. A merged
292+
// convex loop keeps that order, so the same flip rule winds N-gons.
293+
for (const loop of convexPartition(flat, tris)) {
294+
const order = flip ? loop.slice().reverse() : loop;
295+
const pts = order.map(vert);
193296
const poly: Polygon = {
194-
vertices: [place(ordered[0], z, o), place(ordered[1], z, o), place(ordered[2], z, o)],
297+
vertices: pts.map((p) => place(p, z, o)),
195298
color: mat.color ?? "#cccccc",
196299
};
197300
if (mat.texture && fb) {
198301
// Inline `texture` (not just `material`) so the mesh atlas planner —
199302
// which reads polygon.texture — UV-maps the shared fill across the word.
200303
poly.texture = mat.texture;
201304
poly.material = { texture: mat.texture };
202-
poly.uvs = [uvAt(ordered[0], tile), uvAt(ordered[1], tile), uvAt(ordered[2], tile)];
305+
poly.uvs = pts.map((p) => uvAt(p, tile));
203306
if (tile > 0) poly.textureWrap = REPEAT;
204307
}
205308
polygons.push(poly);

packages/react/src/scene/PolyMesh.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ export interface PolyMeshProps extends TransformProps, InteractionProps {
112112
textureQuality?: TextureQuality;
113113
/** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */
114114
seamBleed?: PolySeamBleed;
115+
/**
116+
* Hold the whole previous frame (geometry + texture) until the next atlas is
117+
* decoded, then swap atomically — so a geometry edit never shows geometry
118+
* before its texture. Best when edits arrive as discrete commits (no
119+
* continuous drag). Defaults to false (bitmap streams in over live geometry).
120+
*/
121+
atomicAtlas?: boolean;
122+
/** Fires when the displayed atlas frame swaps to a ready one (atomic mode). */
123+
onFrameReady?: () => void;
115124
/** Per-polygon override render, or static children mounted inside the mesh wrapper. */
116125
children?: ((polygon: Polygon, index: number) => ReactNode) | ReactNode;
117126
/** Loading slot — rendered while `src` is being fetched/parsed. */
@@ -192,6 +201,8 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
192201
textureLighting,
193202
textureQuality,
194203
seamBleed,
204+
atomicAtlas,
205+
onFrameReady,
195206
castShadow,
196207
children,
197208
fallback,
@@ -604,11 +615,25 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
604615
effectiveTextureLighting,
605616
textureQuality,
606617
effectiveStrategies,
618+
atomicAtlas,
607619
);
620+
// Use the displayed plans (which lag in atomic mode) so solid leaves swap in
621+
// lockstep with the textured ones.
608622
const solidPaintDefaults = useMemo(
609-
() => !renderPolygon ? getSolidPaintDefaults(atlasPlans, effectiveTextureLighting, effectiveStrategies) : {},
610-
[renderPolygon, atlasPlans, effectiveTextureLighting, effectiveStrategies],
623+
() => !renderPolygon ? getSolidPaintDefaults(textureAtlas.plans, effectiveTextureLighting, effectiveStrategies) : {},
624+
[renderPolygon, textureAtlas.plans, effectiveTextureLighting, effectiveStrategies],
611625
);
626+
// In atomic mode the returned entries reference only changes when the frame
627+
// actually swaps (decoded), so fire onFrameReady there for preview handoff.
628+
// useLayoutEffect (not useEffect) so a consumer that resets a preview
629+
// transform does it BEFORE the swapped frame paints — otherwise the new
630+
// geometry paints one frame with the stale preview scale still applied.
631+
const onFrameReadyRef = useRef(onFrameReady);
632+
onFrameReadyRef.current = onFrameReady;
633+
useLayoutEffect(() => {
634+
if (atomicAtlas && textureAtlas.ready) onFrameReadyRef.current?.();
635+
// eslint-disable-next-line react-hooks/exhaustive-deps
636+
}, [textureAtlas.entries]);
612637
const defaultPaintVars = useMemo(
613638
() => solidPaintVars(solidPaintDefaults),
614639
[solidPaintDefaults],
@@ -862,7 +887,7 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
862887
);
863888
}
864889

865-
const plan = atlasPlans[index];
890+
const plan = textureAtlas.plans[index];
866891
if (!plan || plan.texture) return null;
867892
if (isProjectiveQuadPlan(plan)) {
868893
return (

0 commit comments

Comments
 (0)