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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/fonts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const polygons = composeText(font, "Poly\nCSS", {
// 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,
warp: { shape: "arch", amount: 0.6 }, simplify: 0,

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

| Group | Options |
|---|---|
| **Layout** | `size` · `depth` (0 = flat slab, no edges) · `curveSteps` · `letterSpacing` · `lineHeight` · `align` · `scale: [x,y]` · `underline` · `strike` · `warp` · `simplify` · `merge` |
| **Layout** | `size` · `depth` (0 = flat slab, no edges) · `curveSteps` · `letterSpacing` · `lineHeight` · `align` · `scale: [x,y]` · `underline` · `strike` · `warp` · `simplify` |
| **`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 |
Expand Down
44 changes: 38 additions & 6 deletions packages/fonts/src/composeText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,29 @@ describe("composeText", () => {
expect(b.maxX - b.minX).toBeGreaterThan((a.maxX - a.minX) * 1.6);
});

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

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

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

it("round edges pick fewer segments by default than a forced high count", () => {
// The default round segment count is adaptive (small bevel → fewer rings),
// so it never exceeds — and here undercuts — an explicit high count.
const auto = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round" } });
const dense = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round", segments: 6 } });
expect(auto.length).toBeLessThan(dense.length);
// An explicit count is always honored verbatim.
const exact = composeText(roboto, "WordArt", { depth: 20, profile: { edge: "round", segments: 6 } });
expect(dense.length).toBe(exact.length);
});

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] } } });
Expand Down
28 changes: 23 additions & 5 deletions packages/fonts/src/composeText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* extrusion, so the 3D walls follow the curve too. Bold/italic are chosen by
* the caller by passing the appropriate weight/style `ParsedFont`.
*/
import { mergePolygons, type Polygon } from "@layoutit/polycss-core";
import type { Polygon } from "@layoutit/polycss-core";
import type { ParsedFont } from "./parseFont";
import {
dedupeContour,
Expand Down Expand Up @@ -101,8 +101,6 @@ export interface ComposeTextOptions {
warp?: WarpOptions;
/** Outline simplification tolerance (world units, 0 = exact; hole-less glyphs only). */
simplify?: number;
/** Merge coplanar same-color cap triangles into larger polygons (fewer DOM nodes). */
merge?: boolean;
/** Extrusion cross-section / edge profile. Defaults to "flat". */
profile?: Profile;
/**
Expand All @@ -129,6 +127,17 @@ function resolveProfile(p: Profile | undefined): {
return { profile: "custom", profileBezier: p.curve, segments: p.segments };
}

/**
* Fewest quarter-arc segments whose worst chord error (sagitta) stays under
* `tol` world units, clamped to [2, 6]. For a radius-`r` quarter circle split
* into N segments the sagitta is `r·(1 − cos(π/2N))`; solve that ≤ tol for N.
*/
function adaptiveRoundSegments(r: number, tol: number): number {
if (r <= tol || r <= 1e-6) return 2;
const n = Math.ceil(Math.PI / (2 * Math.acos(1 - tol / r)));
return Math.min(6, Math.max(2, n));
}

/**
* 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
Expand Down Expand Up @@ -173,7 +182,16 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText
const { stops, backOffset } = resolveStops(options.faces, "#d4a82a");

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

Expand Down Expand Up @@ -260,7 +278,7 @@ export function composeText(font: ParsedFont, text: string, options: ComposeText
outlineColor: options.outline?.color,
outlineWidth: options.outline?.width,
});
return options.merge ? mergePolygons(polygons) : polygons;
return polygons;
}

function warpShape(shape: Shape, warp: WarpFn | null): Shape {
Expand Down
119 changes: 111 additions & 8 deletions packages/fonts/src/extrude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,110 @@ interface Ring {
inset: number;
}

const turn = (o: Pt, a: Pt, b: Pt): number =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);

/** A simple polygon (no repeated vertices) whose turns never change sign. */
function isConvexLoop(loop: number[], vert: (i: number) => Pt): boolean {
const n = loop.length;
if (n < 3) return false;
if (new Set(loop).size !== n) return false; // self-touching → not simple
let sign = 0;
for (let i = 0; i < n; i++) {
const c = turn(vert(loop[i]), vert(loop[(i + 1) % n]), vert(loop[(i + 2) % n]));
if (Math.abs(c) < 1e-9) continue; // collinear vertex — ignore
const s = c > 0 ? 1 : -1;
if (sign === 0) sign = s;
else if (s !== sign) return false;
}
return true;
}

/**
* Glue two index loops (same orientation) along their shared edge `a–b`,
* returning the combined boundary in that same orientation, or null if the
* edge isn't a clean P→Q / Q→P pair.
*/
function gluedLoop(P: number[], Q: number[], a: number, b: number): number[] | null {
const dirIn = (loop: number[]): [number, number] | null => {
const ia = loop.indexOf(a);
if (ia < 0) return null;
if (loop[(ia + 1) % loop.length] === b) return [a, b];
const ib = loop.indexOf(b);
if (ib >= 0 && loop[(ib + 1) % loop.length] === a) return [b, a];
return null;
};
const dp = dirIn(P);
const dq = dirIn(Q);
if (!dp || !dq || dp[0] === dq[0]) return null; // must be opposite directions
const [x, y] = dp; // P goes x→y along the shared edge
const walk = (loop: number[], from: number, to: number): number[] => {
const r: number[] = [];
let i = loop.indexOf(from);
for (let c = 0; c < loop.length; c++) {
r.push(loop[i]);
if (loop[i] === to) break;
i = (i + 1) % loop.length;
}
return r;
};
const loop = walk(P, y, x).concat(walk(Q, x, y).slice(1, -1));
return loop.length >= 3 ? loop : null;
}

/**
* Greedily merge an earcut triangle list into maximal convex polygons (a
* Hertel–Mehlmann-style partition). Each emitted loop keeps earcut's traversal
* order, so the cap can wind front/back exactly as it did per-triangle. The
* silhouette and holes are unchanged — this only erases interior diagonals,
* cutting DOM-leaf count and the coplanar same-color seams between them.
*/
function convexPartition(flat: number[], tris: number[]): number[][] {
const vert = (i: number): Pt => [flat[i * 2], flat[i * 2 + 1]];
const polys: (number[] | null)[] = [];
for (let t = 0; t < tris.length; t += 3) polys.push([tris[t], tris[t + 1], tris[t + 2]]);

// Grow convex regions by absorbing neighbors a triangle at a time (a region
// that swallows a triangle is likelier to stay convex than two quads glued
// together). Each pass lets a region keep eating along its boundary; passes
// repeat until one settles. Edge keys are numeric (a*stride+b) to keep the
// per-pass rebuild cheap.
const stride = flat.length / 2 + 1;
let changed = true;
while (changed) {
changed = false;
const edge = new Map<number, number[]>();
for (let pi = 0; pi < polys.length; pi++) {
const p = polys[pi];
if (!p) continue;
for (let i = 0; i < p.length; i++) {
const a = p[i], b = p[(i + 1) % p.length];
const k = a < b ? a * stride + b : b * stride + a;
let l = edge.get(k);
if (!l) edge.set(k, (l = []));
l.push(pi);
}
}
for (const [k, list] of edge) {
if (list.length !== 2) continue;
const [pi, pj] = list;
// polys[pi] may have grown earlier in this pass; re-read both and re-check
// that the recorded edge is still on each boundary (gluedLoop returns null
// if the region already absorbed past it).
const P = polys[pi], Q = polys[pj];
if (!P || !Q) continue;
const a = Math.floor(k / stride), b = k % stride;
const merged = gluedLoop(P, Q, a, b);
if (merged && isConvexLoop(merged, vert)) {
polys[pi] = merged;
polys[pj] = null;
changed = true;
}
}
}
return polys.filter((p): p is number[] => p !== null);
}

const toWorld = (p: Pt, z: number): Vec3 => [-p[1], p[0], z];

/** Extrude pre-grouped 2D shapes (type-plane, world units) into polygons. */
Expand Down Expand Up @@ -184,22 +288,21 @@ export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[
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];
const ordered: [Pt, Pt, Pt] = [tri[2], tri[1], tri[0]];
// earcut order winds the front cap; the back cap is its reverse. A merged
// convex loop keeps that order, so the same flip rule winds N-gons.
for (const loop of convexPartition(flat, tris)) {
const order = flip ? loop.slice().reverse() : loop;
const pts = order.map(vert);
const poly: Polygon = {
vertices: [place(ordered[0], z, o), place(ordered[1], z, o), place(ordered[2], z, o)],
vertices: pts.map((p) => place(p, 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)];
poly.uvs = pts.map((p) => uvAt(p, tile));
if (tile > 0) poly.textureWrap = REPEAT;
}
polygons.push(poly);
Expand Down
31 changes: 28 additions & 3 deletions packages/react/src/scene/PolyMesh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export interface PolyMeshProps extends TransformProps, InteractionProps {
textureQuality?: TextureQuality;
/** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */
seamBleed?: PolySeamBleed;
/**
* Hold the whole previous frame (geometry + texture) until the next atlas is
* decoded, then swap atomically — so a geometry edit never shows geometry
* before its texture. Best when edits arrive as discrete commits (no
* continuous drag). Defaults to false (bitmap streams in over live geometry).
*/
atomicAtlas?: boolean;
/** Fires when the displayed atlas frame swaps to a ready one (atomic mode). */
onFrameReady?: () => void;
/** Per-polygon override render, or static children mounted inside the mesh wrapper. */
children?: ((polygon: Polygon, index: number) => ReactNode) | ReactNode;
/** Loading slot — rendered while `src` is being fetched/parsed. */
Expand Down Expand Up @@ -192,6 +201,8 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
textureLighting,
textureQuality,
seamBleed,
atomicAtlas,
onFrameReady,
castShadow,
children,
fallback,
Expand Down Expand Up @@ -604,11 +615,25 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
effectiveTextureLighting,
textureQuality,
effectiveStrategies,
atomicAtlas,
);
// Use the displayed plans (which lag in atomic mode) so solid leaves swap in
// lockstep with the textured ones.
const solidPaintDefaults = useMemo(
() => !renderPolygon ? getSolidPaintDefaults(atlasPlans, effectiveTextureLighting, effectiveStrategies) : {},
[renderPolygon, atlasPlans, effectiveTextureLighting, effectiveStrategies],
() => !renderPolygon ? getSolidPaintDefaults(textureAtlas.plans, effectiveTextureLighting, effectiveStrategies) : {},
[renderPolygon, textureAtlas.plans, effectiveTextureLighting, effectiveStrategies],
);
// In atomic mode the returned entries reference only changes when the frame
// actually swaps (decoded), so fire onFrameReady there for preview handoff.
// useLayoutEffect (not useEffect) so a consumer that resets a preview
// transform does it BEFORE the swapped frame paints — otherwise the new
// geometry paints one frame with the stale preview scale still applied.
const onFrameReadyRef = useRef(onFrameReady);
onFrameReadyRef.current = onFrameReady;
useLayoutEffect(() => {
if (atomicAtlas && textureAtlas.ready) onFrameReadyRef.current?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textureAtlas.entries]);
const defaultPaintVars = useMemo(
() => solidPaintVars(solidPaintDefaults),
[solidPaintDefaults],
Expand Down Expand Up @@ -862,7 +887,7 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
);
}

const plan = atlasPlans[index];
const plan = textureAtlas.plans[index];
if (!plan || plan.texture) return null;
if (isProjectiveQuadPlan(plan)) {
return (
Expand Down
Loading
Loading