diff --git a/packages/fonts/README.md b/packages/fonts/README.md index dad3b75a..156cad70 100644 --- a/packages/fonts/README.md +++ b/packages/fonts/README.md @@ -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" }, @@ -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 | diff --git a/packages/fonts/src/composeText.test.ts b/packages/fonts/src/composeText.test.ts index 23196927..ae94ea3c 100644 --- a/packages/fonts/src/composeText.test.ts +++ b/packages/fonts/src/composeText.test.ts @@ -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", () => { @@ -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) => 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] } } }); diff --git a/packages/fonts/src/composeText.ts b/packages/fonts/src/composeText.ts index 32cf1613..c3314fc3 100644 --- a/packages/fonts/src/composeText.ts +++ b/packages/fonts/src/composeText.ts @@ -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, @@ -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; /** @@ -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 @@ -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; @@ -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 { diff --git a/packages/fonts/src/extrude.ts b/packages/fonts/src/extrude.ts index ff323cc2..f2bbee44 100644 --- a/packages/fonts/src/extrude.ts +++ b/packages/fonts/src/extrude.ts @@ -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(); + 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. */ @@ -184,14 +288,13 @@ 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) { @@ -199,7 +302,7 @@ export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[ // 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); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index a551d7e4..3f9bd56e 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -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. */ @@ -192,6 +201,8 @@ export const PolyMesh = forwardRef(function PolyM textureLighting, textureQuality, seamBleed, + atomicAtlas, + onFrameReady, castShadow, children, fallback, @@ -604,11 +615,25 @@ export const PolyMesh = forwardRef(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], @@ -862,7 +887,7 @@ export const PolyMesh = forwardRef(function PolyM ); } - const plan = atlasPlans[index]; + const plan = textureAtlas.plans[index]; if (!plan || plan.texture) return null; if (isProjectiveQuadPlan(plan)) { return ( diff --git a/packages/react/src/scene/atlas/useTextureAtlas.ts b/packages/react/src/scene/atlas/useTextureAtlas.ts index 0d8949b5..5ae24497 100644 --- a/packages/react/src/scene/atlas/useTextureAtlas.ts +++ b/packages/react/src/scene/atlas/useTextureAtlas.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { TextureAtlasPlan, PackedTextureAtlasEntry, @@ -12,22 +12,51 @@ import { filterAtlasPlans } from "./filterPlans"; import { packTextureAtlasPlansWithScale } from "./packing"; import { buildAtlasPages } from "./buildAtlasPages"; -// TextureAtlasResult exposed by useTextureAtlas. +// TextureAtlasResult exposed by useTextureAtlas. `plans` is the plan list whose +// atlas is currently displayed — in `atomic` mode it lags `entries`/`pages` as +// one frame so solid + textured leaves always swap together. export interface TextureAtlasResult { + plans: Array; entries: Array; pages: TextureAtlasPage[]; ready: boolean; } +interface AtlasFrame { + plans: Array; + entries: Array; + pages: TextureAtlasPage[]; +} + function pageShells(pages: readonly { width: number; height: number }[]): TextureAtlasPage[] { return pages.map((page) => ({ width: page.width, height: page.height, url: null })); } -function textureAtlasPagesEqual(a: readonly TextureAtlasPage[], b: readonly TextureAtlasPage[]): boolean { - return a.length === b.length && a.every((page, index) => { - const other = b[index]; - return page.width === other.width && page.height === other.height && page.url === other.url; - }); +function blobUrlsOf(pages: readonly TextureAtlasPage[]): string[] { + return pages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : []); +} + +// Force the browser to decode the new atlas bitmaps before they're swapped onto +// mounted leaves. A freshly created Blob URL isn't decoded until its first +// paint; copying it onto a live element decodes lazily on the next frame — +// exactly the visible blank. `Image.decode()` does that work upfront. +function decodeBlobUrls(urls: string[]): Promise { + if (urls.length === 0 || typeof Image === "undefined") return Promise.resolve(); + return Promise.all(urls.map((url) => { + const img = new Image(); + img.src = url; + const decoded = img.decode?.(); + return decoded ? decoded.catch(() => {}) : Promise.resolve(); + })).then(() => undefined); +} + +// Revoke after the browser has had a frame to paint with the replacement URL, +// so the old bitmap is never freed while it's still on screen. +function deferRevoke(urls: string[]): void { + if (urls.length === 0) return; + const run = (): void => { for (const url of urls) URL.revokeObjectURL(url); }; + if (typeof requestAnimationFrame === "function") requestAnimationFrame(run); + else setTimeout(run, 0); } // --------------------------------------------------------------------------- @@ -39,6 +68,12 @@ export function useTextureAtlas( textureLighting: PolyTextureLightingMode, textureQualityInput?: TextureQuality, strategies?: PolyRenderStrategiesOption, + // Atomic mode: hold the entire previous frame (geometry + bitmap) until the + // next atlas is rasterised AND decoded, then swap all at once. Use it when + // geometry changes arrive as discrete commits (no continuous drag), so an + // edit never shows geometry before its texture. Default (false) streams the + // bitmap in while geometry updates live — better for continuous drags. + atomic = false, ): TextureAtlasResult { const disabled = useMemo( () => new Set((strategies?.disable ?? []) as PolyRenderStrategy[]), @@ -65,44 +100,108 @@ export function useTextureAtlas( [atlasPlans, textureQualityInput], ); - const [pages, setPages] = useState( - () => pageShells(packed.pages), - ); + // Streaming-mode page state (default). + const [pages, setPages] = useState(() => pageShells(packed.pages)); + // Atomic-mode whole-frame state. + const [frame, setFrame] = useState(() => ({ + plans, + entries: packed.entries, + pages: pageShells(packed.pages), + })); + // Blob URLs currently on screen — revoked a frame after they're replaced. + const shownUrls = useRef([]); + const seqRef = useRef(0); + const mountedRef = useRef(true); useEffect(() => { - let cancelled = false; - let urls: string[] = []; - const nextPageShells = pageShells(packed.pages); - setPages((prev) => textureAtlasPagesEqual(prev, nextPageShells) ? prev : nextPageShells); + mountedRef.current = true; + return () => { + mountedRef.current = false; + for (const url of shownUrls.current) URL.revokeObjectURL(url); + shownUrls.current = []; + }; + }, []); + + useEffect(() => { + if (atomic) { + const seq = ++seqRef.current; + const snapPlans = plans; + const snapEntries = packed.entries; + if (packed.pages.length === 0 || typeof document === "undefined") { + deferRevoke(shownUrls.current); + shownUrls.current = []; + setFrame({ plans: snapPlans, entries: snapEntries, pages: pageShells(packed.pages) }); + return; + } + // Cancel as soon as a newer edit arrives (seqRef advances): the stale + // build aborts and is dropped, so an intermediate baked texture never + // swaps in. Only the latest build reaches the swap. + const stale = (): boolean => seq !== seqRef.current; + let built: string[] = []; + buildAtlasPages(packed.pages, textureLighting, document, atlasScale, stale) + .then(async (nextPages) => { + built = blobUrlsOf(nextPages); + await decodeBlobUrls(built); + if (!mountedRef.current || stale()) { + deferRevoke(built); + return; + } + const prev = shownUrls.current; + shownUrls.current = built; + built = []; + deferRevoke(prev); + setFrame({ plans: snapPlans, entries: snapEntries, pages: nextPages }); + }) + .catch(() => {}); + return; + } - if (packed.pages.length === 0 || typeof document === "undefined") { + // --- streaming mode (default): geometry live, bitmap double-buffered --- + let cancelled = false; + if (packed.pages.length === 0) { + deferRevoke(shownUrls.current); + shownUrls.current = []; + setPages((prev) => prev.length === 0 ? prev : []); return () => {}; } + if (typeof document === "undefined") return () => {}; + setPages((prev) => prev.some((page) => page.url) ? prev : pageShells(packed.pages)); + + let built: string[] = []; buildAtlasPages(packed.pages, textureLighting, document, atlasScale, () => cancelled) - .then((nextPages) => { + .then(async (nextPages) => { + built = blobUrlsOf(nextPages); + await decodeBlobUrls(built); if (cancelled) { - for (const page of nextPages) { - if (page.url?.startsWith("blob:")) URL.revokeObjectURL(page.url); - } + deferRevoke(built); return; } - urls = nextPages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : []); - setPages((prev) => textureAtlasPagesEqual(prev, nextPages) ? prev : nextPages); + const stale = shownUrls.current; + shownUrls.current = built; + built = []; + deferRevoke(stale); + setPages(nextPages); }) - .catch(() => { - if (!cancelled) { - setPages((prev) => textureAtlasPagesEqual(prev, nextPageShells) ? prev : nextPageShells); - } - }); + .catch(() => {}); return () => { cancelled = true; - for (const url of urls) URL.revokeObjectURL(url); + deferRevoke(built); }; - }, [packed, textureLighting, atlasScale]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [packed, textureLighting, atlasScale, atomic]); + if (atomic) { + return { + plans: frame.plans, + entries: frame.entries, + pages: frame.pages, + ready: frame.pages.length === 0 || frame.pages.every((page) => !!page.url), + }; + } return { + plans, entries: packed.entries, pages, ready: pages.length === 0 || pages.every((page) => !!page.url), diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 632c8849..0bee8ee9 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -170,6 +170,8 @@ export const PolyMesh = defineComponent({ textureLighting: { type: String as PropType, default: undefined }, textureQuality: { type: [Number, String] as PropType, default: undefined }, seamBleed: { type: [Number, String] as PropType, default: undefined }, + atomicAtlas: { type: Boolean as PropType, default: false }, + onFrameReady: { type: Function as PropType<() => void>, default: undefined }, castShadow: { type: Boolean as PropType, default: false }, meshResolution: { type: String as PropType, default: undefined }, parseOptions: { type: Object as PropType, default: undefined }, @@ -312,9 +314,19 @@ export const PolyMesh = defineComponent({ ); }); const atlasTextureQuality = computed(() => props.textureQuality); - const textureAtlas = useTextureAtlas(textureAtlasPlans, atlasTextureLighting, atlasTextureQuality, atlasStrategies); + const atomicAtlas = computed(() => props.atomicAtlas); + const textureAtlas = useTextureAtlas(textureAtlasPlans, atlasTextureLighting, atlasTextureQuality, atlasStrategies, atomicAtlas); + // Use the displayed plans (which lag in atomic mode) so solid leaves swap in + // lockstep with the textured ones. const solidPaintDefaults = computed(() => - atlasAutoRender ? getSolidPaintDefaults(textureAtlasPlans.value, atlasTextureLighting.value, atlasStrategies.value) : {}, + atlasAutoRender ? getSolidPaintDefaults(textureAtlas.plans.value, atlasTextureLighting.value, atlasStrategies.value) : {}, + ); + // Fire onFrameReady when the displayed atlas frame swaps (atomic mode) — used + // by consumers to hand off a preview transform without a one-frame overshoot. + watch( + () => textureAtlas.entries.value, + () => { if (props.atomicAtlas && textureAtlas.ready.value) props.onFrameReady?.(); }, + { flush: "sync" }, ); const defaultPaintVars = computed(() => solidPaintVars(solidPaintDefaults.value)); @@ -786,7 +798,7 @@ export const PolyMesh = defineComponent({ solidPaintDefaults: solidPaintDefaults.value, }); } - const plan = textureAtlasPlans.value[index]; + const plan = textureAtlas.plans.value[index]; if (!plan || plan.texture) return null; if (isProjectiveQuadPlan(plan)) { return renderTextureProjectiveSolidPoly({ diff --git a/packages/vue/src/scene/atlas/useTextureAtlas.ts b/packages/vue/src/scene/atlas/useTextureAtlas.ts index a19665d0..83e5dfb6 100644 --- a/packages/vue/src/scene/atlas/useTextureAtlas.ts +++ b/packages/vue/src/scene/atlas/useTextureAtlas.ts @@ -20,10 +20,13 @@ import { filterAtlasPlans } from "./filterPlans"; import { packTextureAtlasPlansWithScale } from "./packing"; import { buildAtlasPages } from "./buildAtlasPages"; -// TextureAtlasResult exposed by useTextureAtlas. +// TextureAtlasResult exposed by useTextureAtlas. `plans` is the plan list whose +// atlas is currently displayed — in atomic mode it lags `entries`/`pages` as one +// frame so solid + textured leaves always swap together. export interface TextureAtlasResult { + plans: ComputedRef>; entries: ComputedRef>; - pages: Ref; + pages: ComputedRef; ready: ComputedRef; useFullRectSolid: ComputedRef; useProjectiveQuad: ComputedRef; @@ -31,6 +34,12 @@ export interface TextureAtlasResult { useBorderShape: ComputedRef; } +interface AtlasFrame { + plans: Array; + entries: Array; + pages: TextureAtlasPage[]; +} + // --------------------------------------------------------------------------- // useTextureAtlas — Vue composable that packs plans into atlas pages with blob URLs // --------------------------------------------------------------------------- @@ -41,11 +50,45 @@ function revokeUrls(urls: string[]): void { } } +function blobUrlsOf(pages: readonly TextureAtlasPage[]): string[] { + return pages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : []); +} + +function shellsOf(pages: readonly { width: number; height: number }[]): TextureAtlasPage[] { + return pages.map((page) => ({ width: page.width, height: page.height, url: null })); +} + +// Force the browser to decode the new atlas bitmaps before they're swapped onto +// mounted leaves. A freshly created Blob URL isn't decoded until its first +// paint; copying it onto a live element decodes lazily on the next frame — +// exactly the visible blank. `Image.decode()` does that work upfront. +function decodeBlobUrls(urls: string[]): Promise { + if (urls.length === 0 || typeof Image === "undefined") return Promise.resolve(); + return Promise.all(urls.map((url) => { + const img = new Image(); + img.src = url; + const decoded = img.decode?.(); + return decoded ? decoded.catch(() => {}) : Promise.resolve(); + })).then(() => undefined); +} + +// Revoke after the browser has had a frame to paint with the replacement URL, +// so the old bitmap is never freed while it's still on screen. +function deferRevoke(urls: string[]): void { + if (urls.length === 0) return; + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => revokeUrls(urls)); + else setTimeout(() => revokeUrls(urls), 0); +} + export function useTextureAtlas( plans: ComputedRef>, textureLighting: ComputedRef, textureQuality: ComputedRef = computed(() => undefined), strategies: ComputedRef = computed(() => undefined), + // Atomic mode: hold the whole previous frame (geometry + bitmap) until the + // next atlas is rasterised AND decoded, then swap all at once. Default (false) + // streams the bitmap in while geometry updates live. + atomic: ComputedRef = computed(() => false), ): TextureAtlasResult { const disabled = computed(() => new Set((strategies.value?.disable ?? []) as PolyRenderStrategy[])); const useFullRectSolid = computed(() => !disabled.value.has("b")); @@ -72,65 +115,111 @@ export function useTextureAtlas( ); }); - const pages = ref( - atlasState.value.packed.pages.map((page) => ({ width: page.width, height: page.height, url: null })), - ); + // Streaming-mode page state (default). + const pages = ref(shellsOf(atlasState.value.packed.pages)); + // Atomic-mode whole-frame state. + const frame = ref({ + plans: plans.value, + entries: atlasState.value.packed.entries, + pages: shellsOf(atlasState.value.packed.pages), + }); + // Blob URLs currently shown on screen — revoked a frame after they're replaced. let activeUrls: string[] = []; + let seq = 0; + let disposed = false; watch( - () => [atlasState.value, textureLighting.value] as const, - ([nextAtlasState, nextTextureLighting], _prev, onCleanup) => { + () => [atlasState.value, textureLighting.value, atomic.value] as const, + ([nextAtlasState, nextTextureLighting, nextAtomic], _prev, onCleanup) => { const { packed: nextPacked, atlasScale: nextAtlasScale } = nextAtlasState; - let cancelled = false; - revokeUrls(activeUrls); - activeUrls = []; - pages.value = nextPacked.pages.map((page) => ({ - width: page.width, - height: page.height, - url: null, - })); + if (nextAtomic) { + const mySeq = ++seq; + const snapPlans = plans.value; + const snapEntries = nextPacked.entries; + if (nextPacked.pages.length === 0 || typeof document === "undefined") { + deferRevoke(activeUrls); + activeUrls = []; + frame.value = { plans: snapPlans, entries: snapEntries, pages: shellsOf(nextPacked.pages) }; + return; + } + // Cancel as soon as a newer edit arrives: only the latest build swaps, + // so an intermediate baked texture never flashes in. + const stale = (): boolean => disposed || mySeq !== seq; + let built: string[] = []; + buildAtlasPages(nextPacked.pages, nextTextureLighting, document, nextAtlasScale, stale) + .then(async (nextPages) => { + built = blobUrlsOf(nextPages); + await decodeBlobUrls(built); + if (stale()) { + deferRevoke(built); + return; + } + const prev = activeUrls; + activeUrls = built; + built = []; + deferRevoke(prev); + frame.value = { plans: snapPlans, entries: snapEntries, pages: nextPages }; + }) + .catch(() => {}); + return; + } + + // --- streaming mode (default): geometry live, bitmap double-buffered --- + let cancelled = false; + let built: string[] = []; onCleanup(() => { cancelled = true; - revokeUrls(activeUrls); - activeUrls = []; + deferRevoke(built); }); - if (nextPacked.pages.length === 0 || typeof document === "undefined") return; + if (nextPacked.pages.length === 0) { + deferRevoke(activeUrls); + activeUrls = []; + if (pages.value.length !== 0) pages.value = []; + return; + } + if (typeof document === "undefined") return; + + if (!pages.value.some((page) => page.url)) { + pages.value = shellsOf(nextPacked.pages); + } buildAtlasPages(nextPacked.pages, nextTextureLighting, document, nextAtlasScale, () => cancelled) - .then((nextPages) => { + .then(async (nextPages) => { + built = blobUrlsOf(nextPages); + await decodeBlobUrls(built); if (cancelled) { - revokeUrls(nextPages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : [])); + deferRevoke(built); return; } - activeUrls = nextPages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : []); + const prev = activeUrls; + activeUrls = built; + built = []; + deferRevoke(prev); pages.value = nextPages; }) - .catch(() => { - if (!cancelled) { - pages.value = nextPacked.pages.map((page) => ({ - width: page.width, - height: page.height, - url: null, - })); - } - }); + .catch(() => {}); }, { immediate: true }, ); if (getCurrentScope()) { onScopeDispose(() => { + disposed = true; revokeUrls(activeUrls); activeUrls = []; }); } return { - entries: computed(() => atlasState.value.packed.entries), - pages, - ready: computed(() => pages.value.length === 0 || pages.value.every((page) => !!page.url)), + plans: computed(() => atomic.value ? frame.value.plans : plans.value), + entries: computed(() => atomic.value ? frame.value.entries : atlasState.value.packed.entries), + pages: computed(() => atomic.value ? frame.value.pages : pages.value), + ready: computed(() => { + const p = atomic.value ? frame.value.pages : pages.value; + return p.length === 0 || p.every((page) => !!page.url); + }), useFullRectSolid, useProjectiveQuad, useStableTriangle, diff --git a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx index fa1d1729..11727fd6 100644 --- a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx +++ b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx @@ -135,7 +135,7 @@ function readable(hex: string): string { return lum > 150 ? "#0b0f18" : "#ffffff"; } -function fitZoom(polygons: Polygon[], stageW: number, stageH: number): number { +function fitZoom(polygons: Polygon[], stageW: number, stageH: number, scaleX = 1, scaleY = 1): number { if (!polygons.length) return 0.06; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; for (const p of polygons) { @@ -146,9 +146,13 @@ function fitZoom(polygons: Polygon[], stageW: number, stageH: number): number { } } // Flat text: Y = width (screen-right), X = height (screen-down), Z = depth. - // As the mesh turntables, width swings into depth, so fit the larger of the two. - const horizontal = Math.max(maxY - minY, maxZ - minZ); - const vertical = maxX - minX; + // As the mesh turntables, width swings into depth, so fit the larger of the + // two. Scale X/Y are applied as a CSS transform on the mesh (not baked), so + // multiply the base bounds by them here to frame the *scaled* word — the + // camera then compensates the wrapper scale, keeping the word framed and the + // texture ~base resolution. + const horizontal = Math.max((maxY - minY) * scaleX, maxZ - minZ); + const vertical = (maxX - minX) * scaleY; const fitW = (stageW * 0.7) / (Math.max(horizontal, 1) * BASE_TILE); const fitH = (stageH * 0.68) / (Math.max(vertical, 1) * BASE_TILE); return Math.max(0.01, Math.min(0.2, Math.min(fitW, fitH))); @@ -215,7 +219,6 @@ export function WordArtWorkbench() { const [offset, setOffset] = useState(() => qn("offset", 0)); const [curveSegments, setCurveSegments] = useState(() => qn("curve", 4)); const [simplify, setSimplify] = useState(() => qn("simplify", 2)); - const [merge, setMerge] = useState(() => qb("merge", false)); const [profileSegments, setProfileSegments] = useState(() => qn("edge", 3)); const [warpShape, setWarpShape] = useState(() => qs("warp", "none") as WarpShape); const [warpAmount, setWarpAmount] = useState(() => qn("bend", 0.5)); @@ -313,7 +316,6 @@ export function WordArtWorkbench() { sn("offset", offset, 0); sn("curve", curveSegments, 1); sn("simplify", simplify, 2); - if (merge) p.set("merge", "1"); sn("edge", profileSegments, 3); ss("warp", warpShape, "none"); sn("bend", warpAmount, 0.5); @@ -340,7 +342,7 @@ export function WordArtWorkbench() { 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, roundConvex, bezier, fillType, gradA, gradB, gradAngle, faceTex, sideFill, sideTex, backFill, backTex, outlineOn, outlineColor, outlineWidth, layered]); + }, [text, entry, weight, italic, textCase, scaleX, scaleY, profile, depth, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, 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(() => { @@ -398,7 +400,8 @@ export function WordArtWorkbench() { size: 100, depth: layered ? 0 : depth, // "Flat layers" = no edges (depth 0) profile: profileObj, - scale: [scaleX / 100, scaleY / 100], + // Scale X/Y are NOT baked here — they're a live CSS transform on the mesh + // wrapper (so they stretch the whole block uniformly and need no recompute). letterSpacing, lineHeight, align, @@ -406,12 +409,17 @@ export function WordArtWorkbench() { strike, curveSteps: curveSegments, simplify, - merge, warp: { shape: warpShape, amount: warpAmount }, faces: { front, sides, back }, outline: outlineOn ? { color: outlineColor, width: outlineWidth } : undefined, }); - }, [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]); + }, [font, text, textCase, depth, profile, roundConvex, bezier, letterSpacing, lineHeight, align, underline, strike, sideColor, backColor, offset, curveSegments, simplify, profileSegments, warpShape, warpAmount, front, fillType, backFill, backTex, sideFill, sideTex, outlineOn, outlineColor, outlineWidth, layered]); + + // Live "dynamic-mode" preview for the affine sliders (scale X/Y, depth): while + // dragging, we don't recompute geometry — we set a CSS scale3d on the mesh + // wrapper (one var per axis, like dynamic lighting) and bake the real geometry + // only on release. {1,1,1} = identity (nothing previewing). + const [preview, setPreview] = useState<{ sx: number; sy: number; sz: number }>({ sx: 1, sy: 1, sz: 1 }); // Directional light direction from azimuth (left/right) + elevation (height), // always biased toward the front so the face stays lit. @@ -497,7 +505,7 @@ export function WordArtWorkbench() { layered, profileMode, warp: warpShape, bend: warpAmount, depth, letterSpacing, lineHeight, scaleX, scaleY, - curveSegments, simplify, merge, profileSegments, offset, + curveSegments, simplify, profileSegments, offset, perspective, zoom: zoomScale, spin, light: lightIntensity, ambient, az: lightAz, el: lightEl, lightColor, }; @@ -511,7 +519,6 @@ export function WordArtWorkbench() { 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; case "letterSpacing": setLetterSpacing(v as number); break; @@ -520,7 +527,6 @@ export function WordArtWorkbench() { case "scaleY": setScaleY(v as number); break; case "curveSegments": setCurveSegments(v as number); break; case "simplify": setSimplify(v as number); break; - case "merge": setMerge(v as boolean); break; case "profileSegments": setProfileSegments(v as number); break; case "offset": setOffset(v as number); break; case "perspective": setPerspective(v as boolean); break; @@ -539,6 +545,10 @@ export function WordArtWorkbench() { setPreview((p) => (p.sx === 1 && p.sy === 1 && p.sz === 1) ? p : { sx: 1, sy: 1, sz: 1 })} + scaleXFrac={scaleX / 100} + scaleYFrac={scaleY / 100} zoomScale={zoomScale} setZoomScale={setZoomScale} perspective={perspective} @@ -591,6 +601,7 @@ export function WordArtWorkbench() { className={mobilePanel === "controls" ? "is-mobile-open" : ""} values={guiValues} set={guiSet} + onPreviewAxis={(axis, ratio) => setPreview((p) => ({ ...p, [axis]: ratio }))} bezier={bezier} onBezier={setBezier} /> @@ -625,6 +636,10 @@ export function WordArtWorkbench() { interface StageProps { polygons: Polygon[]; + preview: { sx: number; sy: number; sz: number }; + onFrameReady: () => void; + scaleXFrac: number; + scaleYFrac: number; zoomScale: number; setZoomScale: (updater: (prev: number) => number) => void; perspective: boolean; @@ -641,7 +656,7 @@ interface StageProps { * in this small component means only the camera/scene/mesh re-render per frame, * not the parent's controls + 2000-option font datalist (which tanked FPS). */ -function Stage({ polygons, zoomScale, setZoomScale, perspective, lightDir, lightIntensity, lightColor, ambient, spin, status }: StageProps) { +function Stage({ polygons, preview, onFrameReady, scaleXFrac, scaleYFrac, zoomScale, setZoomScale, perspective, lightDir, lightIntensity, lightColor, ambient, spin, status }: StageProps) { const stageRef = useRef(null); const wrapRef = useRef(null); const [stage, setStage] = useState({ w: 900, h: 600 }); @@ -652,11 +667,24 @@ function Stage({ polygons, zoomScale, setZoomScale, perspective, lightDir, light const draggingRef = useRef(false); const lastPtr = useRef({ x: 0, y: 0 }); + // The wrapper transform is set imperatively (the spin raf rewrites it every + // frame), so the live scale preview has to be folded in here rather than via + // React style — otherwise applyRotation would clobber it. scale3d is + // innermost (applied to the geometry) then rotate/tilt. + const previewRef = useRef(preview); + previewRef.current = preview; + // Live scale read from a ref so the spin raf's captured applyRotation still + // sees the latest value (otherwise scaling while spinning would be clobbered). + const scaleRef = useRef({ x: scaleXFrac, y: scaleYFrac }); + scaleRef.current = { x: scaleXFrac, y: scaleYFrac }; const applyRotation = () => { const el = wrapRef.current; - if (el) el.style.transform = `rotateX(${tiltRef.current}deg) rotateY(${spinRef.current}deg)`; + const p = previewRef.current; + const s = scaleRef.current; + // Scale X/Y are live CSS; depth uses the preview sz. + if (el) el.style.transform = `rotateX(${tiltRef.current}deg) rotateY(${spinRef.current}deg) scale3d(${s.x}, ${s.y}, ${p.sz})`; }; - useLayoutEffect(applyRotation); // re-assert after any re-render (e.g. new geometry) + useLayoutEffect(applyRotation); // re-assert after any re-render (preview / new geometry) useEffect(() => { const el = stageRef.current; @@ -710,7 +738,7 @@ function Stage({ polygons, zoomScale, setZoomScale, perspective, lightDir, light setZoomScale((z) => Math.max(0.1, Math.min(6, z * factor))); }; - const zoom = fitZoom(polygons, stage.w, stage.h) * zoomScale; + const zoom = fitZoom(polygons, stage.w, stage.h, scaleXFrac, scaleYFrac) * zoomScale; const Cam = perspective ? PolyPerspectiveCamera : PolyOrthographicCamera; return ( @@ -731,7 +759,7 @@ function Stage({ polygons, zoomScale, setZoomScale, perspective, lightDir, light ambientLight={{ intensity: ambient }} >
- +
@@ -746,7 +774,7 @@ interface GuiValues { layered: boolean; profileMode: string; warp: string; bend: number; depth: number; letterSpacing: number; lineHeight: number; scaleX: number; scaleY: number; - curveSegments: number; simplify: number; merge: boolean; profileSegments: number; offset: number; + curveSegments: number; simplify: number; profileSegments: number; offset: number; perspective: boolean; zoom: number; spin: boolean; light: number; ambient: number; az: number; el: number; lightColor: string; } @@ -841,8 +869,18 @@ function mountBezierEditor(parent: HTMLElement, getB: () => Bezier4, setB: (b: B * are identical, not a CSS approximation. lil-gui is imperative, so we mount it * once and bridge its onChange → React, and React state → updateDisplay(). */ -function GuiPanel({ id, className = "", values, set, bezier, onBezier }: { id?: string; className?: string; values: GuiValues; set: (k: keyof GuiValues, v: number | string | boolean) => void; bezier: Bezier4; onBezier: (b: Bezier4) => void }) { +function GuiPanel({ id, className = "", values, set, onPreviewAxis, bezier, onBezier }: { id?: string; className?: string; values: GuiValues; set: (k: keyof GuiValues, v: number | string | boolean) => void; onPreviewAxis: (axis: "sx" | "sy" | "sz", ratio: number) => void; bezier: Bezier4; onBezier: (b: Bezier4) => void }) { const hostRef = useRef(null); + // Current committed values, read inside the (mount-once) GUI callbacks so the + // live-preview ratio is taken against the value at drag start. + const valuesRef = useRef(values); + valuesRef.current = values; + const onPreviewAxisRef = useRef(onPreviewAxis); + onPreviewAxisRef.current = onPreviewAxis; + // True while an affine slider is mid-drag (preview mode). The values→GUI + // write-back below is skipped then, so it can't reset the control the user is + // dragging back to the (not-yet-committed) value. + const previewDragRef = useRef(false); const cfgRef = useRef({ ...values }); const ctrlRef = useRef>>({}); const bezierRef = useRef(bezier); @@ -855,6 +893,21 @@ function GuiPanel({ id, className = "", values, set, bezier, onBezier }: { id?: const gui = new GUI({ container: hostRef.current!, title: "Settings", width: 300 }); const on = (k: keyof GuiValues) => (v: number | string | boolean) => set(k, v); + // Tier-1 "dynamic mode": while dragging an affine slider, only set a CSS + // scale3d ratio on the wrapper (no geometry recompute). On release we commit + // the real value but DON'T reset the preview here — the mesh holds the old + // frame until the new atlas is decoded (atomicAtlas) and fires onFrameReady, + // which resets the preview so the swap is seamless (no backward flash). + const previewAxis = (k: keyof GuiValues, axis: "sx" | "sy" | "sz") => (v: number | string | boolean) => { + previewDragRef.current = true; + const base = (valuesRef.current[k] as number) || 1; + onPreviewAxisRef.current(axis, ((v as number) / base) || 1); + }; + const bake = (k: keyof GuiValues) => (v: number | string | boolean) => { + previewDragRef.current = false; + set(k, v); + }; + const shape = gui.addFolder("Shape"); c.profileMode = shape.add(cfg, "profileMode", { "Flat (slab)": "flat", @@ -889,19 +942,21 @@ function GuiPanel({ id, className = "", values, set, bezier, onBezier }: { id?: } c.warp = shape.add(cfg, "warp", { None: "none", "Arch up": "arch", "Arch down": "archDown", "Arc (circle)": "arc", Wave: "wave", Bulge: "bulge", "Cone (taper)": "cone", "Slant up": "slantUp", "Slant down": "slantDown" }).name("Warp").onChange(on("warp")); - c.bend = shape.add(cfg, "bend", 0, 1, 0.02).name("Bend").onChange(on("bend")); + // Tier-3 (changes poly count / non-linear): no live recompute — bake on release. + c.bend = shape.add(cfg, "bend", 0, 1, 0.02).name("Bend").onFinishChange(on("bend")); const layout = gui.addFolder("Layout"); - c.depth = layout.add(cfg, "depth", 2, 80, 1).name("Depth").onChange(on("depth")); - c.letterSpacing = layout.add(cfg, "letterSpacing", -20, 60, 1).name("Letter spacing").onChange(on("letterSpacing")); - c.lineHeight = layout.add(cfg, "lineHeight", 0.8, 2.5, 0.05).name("Line height").onChange(on("lineHeight")); + // Tier-1 (affine): live CSS scale preview while dragging, bake on release. + c.depth = layout.add(cfg, "depth", 2, 80, 1).name("Depth").onChange(previewAxis("depth", "sz")).onFinishChange(bake("depth")); + c.letterSpacing = layout.add(cfg, "letterSpacing", -20, 60, 1).name("Letter spacing").onFinishChange(on("letterSpacing")); + c.lineHeight = layout.add(cfg, "lineHeight", 0.8, 2.5, 0.05).name("Line height").onFinishChange(on("lineHeight")); + // Scale X/Y just update state → a live CSS wrapper transform (no recompute). c.scaleX = layout.add(cfg, "scaleX", 40, 200, 1).name("Scale X").onChange(on("scaleX")); c.scaleY = layout.add(cfg, "scaleY", 40, 200, 1).name("Scale Y").onChange(on("scaleY")); - c.curveSegments = layout.add(cfg, "curveSegments", 1, 12, 1).name("Curve segments").onChange(on("curveSegments")); - c.simplify = layout.add(cfg, "simplify", 0, 8, 0.5).name("Simplify").onChange(on("simplify")); - c.merge = layout.add(cfg, "merge").name("Merge polygons").onChange(on("merge")); - c.profileSegments = layout.add(cfg, "profileSegments", 2, 10, 1).name("Edge segments").onChange(on("profileSegments")); - c.offset = layout.add(cfg, "offset", 0, 32, 1).name("Layer offset").onChange(on("offset")); + c.curveSegments = layout.add(cfg, "curveSegments", 1, 12, 1).name("Curve segments").onFinishChange(on("curveSegments")); + c.simplify = layout.add(cfg, "simplify", 0, 8, 0.5).name("Simplify").onFinishChange(on("simplify")); + c.profileSegments = layout.add(cfg, "profileSegments", 2, 10, 1).name("Edge segments").onFinishChange(on("profileSegments")); + c.offset = layout.add(cfg, "offset", 0, 32, 1).name("Layer offset").onFinishChange(on("offset")); c.layered = layout.add(cfg, "layered").name("Flat layers").onChange(on("layered")); const cam = gui.addFolder("Camera"); @@ -922,8 +977,11 @@ function GuiPanel({ id, className = "", values, set, bezier, onBezier }: { id?: // Push React state back into the GUI display + toggle conditional controllers. useEffect(() => { - Object.assign(cfgRef.current, values); - for (const ctrl of Object.values(ctrlRef.current)) ctrl?.updateDisplay(); + // Skip the write-back mid preview-drag so it can't reset the dragged control. + if (!previewDragRef.current) { + Object.assign(cfgRef.current, values); + for (const ctrl of Object.values(ctrlRef.current)) ctrl?.updateDisplay(); + } bezierRef.current = bezier; onBezierRef.current = onBezier; const isCustom = values.profileMode.startsWith("custom");