From 6fdc833d3ea1cfa47398bbbe2cfebf8b71fdbaee Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 19 Aug 2026 23:17:50 -0700 Subject: [PATCH] website: live dev tuner + noisy settled-pile wordmark formation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tuner (see TUNER.md): a draggable, collapsible dev panel rendering every group registered via defineTunables() as slider + number-input rows. - Live: physics/light/material/halo values are read per frame; formation values trigger a debounced re-sample - Values persist to localStorage; modified labels tint accent; Copy puts a JSON snapshot on the clipboard for baking back into defaults; Reset clears - Shown in dev builds or with ?tuner=1; drag position and section collapse state persist - Policy (documented): every new tweakable constant registers with the tuner by default - Hero groups: Formation, Physics, Cursor & Light, Material, Halo Formation: the hex lattice read too uniform. Balls are now placed by multi-pass dart throwing over the glyph mask — big balls claim spots first, smaller ones settle into leftover gaps, random depth within the slab, 3D overlap rejection via spatial hash — a faked settled-pile impression, not a physics solve. Stroke-margin test keeps big balls inside the letterforms so the type stays crisp. --- frontend/apps/artcraft-website-next/TUNER.md | 61 +++++ .../artcraft-website-next/src/app/layout.tsx | 2 + .../src/components/dev/tuner-panel.tsx | 237 ++++++++++++++++++ .../src/components/landing/hero-tunables.ts | 62 +++++ .../src/components/landing/hero-wordmark.tsx | 110 ++++++-- .../src/components/landing/wordmark-sim.ts | 167 +++++++----- .../artcraft-website-next/src/lib/tuner.ts | 133 ++++++++++ 7 files changed, 683 insertions(+), 89 deletions(-) create mode 100644 frontend/apps/artcraft-website-next/TUNER.md create mode 100644 frontend/apps/artcraft-website-next/src/components/dev/tuner-panel.tsx create mode 100644 frontend/apps/artcraft-website-next/src/components/landing/hero-tunables.ts create mode 100644 frontend/apps/artcraft-website-next/src/lib/tuner.ts diff --git a/frontend/apps/artcraft-website-next/TUNER.md b/frontend/apps/artcraft-website-next/TUNER.md new file mode 100644 index 0000000000..8f93e11bce --- /dev/null +++ b/frontend/apps/artcraft-website-next/TUNER.md @@ -0,0 +1,61 @@ +# Dev Tuner + +A floating, draggable panel for live-tuning the site's visual/physics +constants without an edit-build-reload loop. Drag it by its header; collapse +the whole box with `–`, or expand/collapse individual sections. + +## When it shows + +- Always in `next dev`. +- In any build when the URL has `?tuner=1` (handy for tuning a deployed + preview). +- Never otherwise — production visitors don't see it. + +## Behavior + +- **Live**: physics/lighting/material values are read every frame, so slider + changes apply instantly. Formation values (anything that changes the ball + arrangement itself) trigger a debounced rebuild (~250 ms after the last + change). +- **Persistent**: tuned values are stored in `localStorage` + (`artcraft-tuner`), so a refresh keeps your tuning. Panel position and + section collapse state persist too (`artcraft-tuner-ui`). +- **Modified values** show their label in the accent color. +- **Copy** puts a JSON snapshot of all current values on the clipboard — + paste it into a message or use it to update defaults in code. +- **Reset** clears every override back to the in-code defaults. + +## Adding tunables (POLICY: always do this) + +Whenever you introduce a new tweakable constant — a force, an intensity, a +size, a duration — register it with the tuner instead of hardcoding a bare +literal. Adding a slider is free; a missing one costs a whole +iteration/build round-trip. + +```ts +// some-feature-tunables.ts +import { defineTunables } from "@/lib/tuner"; + +export const glowTuner = defineTunables("glow", "Glow", { + radius: { label: "Radius px", min: 0, max: 400, step: 5, default: 120 }, + strength: { label: "Strength", min: 0, max: 2, step: 0.05, default: 0.8 }, +}); +``` + +Then read values where they're used: + +- **Per-frame consumers** (`useFrame`, rAF loops): call `glowTuner.read()` + inside the loop — it's cheap and picks up changes instantly. +- **Build-time consumers** (values baked into geometry/sampling): read at + build time and subscribe to `useTunerStore` to trigger a debounced rebuild + when the group's values change (see the Formation wiring in + `hero-wordmark.tsx`). + +The panel picks up new groups automatically — no panel changes needed. +Registered groups: Formation, Physics, Cursor & Light, Material, Halo (all +for the hero wordmark; see `src/components/landing/hero-tunables.ts`). + +## Shipping tuned values + +The defaults in `defineTunables` are the shipped values. After a tuning +session: Copy → paste the JSON → update the `default:` fields → Reset. diff --git a/frontend/apps/artcraft-website-next/src/app/layout.tsx b/frontend/apps/artcraft-website-next/src/app/layout.tsx index 788cca8de8..7282200d44 100644 --- a/frontend/apps/artcraft-website-next/src/app/layout.tsx +++ b/frontend/apps/artcraft-website-next/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Outfit, Instrument_Serif, Source_Sans_3, Geist_Mono } from "next/font/g import SiteNav from "@/components/site-nav"; import SiteFooter from "@/components/site-footer"; import MotionProvider from "@/components/motion-provider"; +import TunerPanel from "@/components/dev/tuner-panel"; import "./globals.css"; const SITE_URL = "https://getartcraft.com"; @@ -103,6 +104,7 @@ export default function RootLayout({
{children}
+ ); diff --git a/frontend/apps/artcraft-website-next/src/components/dev/tuner-panel.tsx b/frontend/apps/artcraft-website-next/src/components/dev/tuner-panel.tsx new file mode 100644 index 0000000000..cbc323c2d1 --- /dev/null +++ b/frontend/apps/artcraft-website-next/src/components/dev/tuner-panel.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + useTunerStore, + tunerSnapshot, + type TunableGroup, +} from "@/lib/tuner"; + +const UI_KEY = "artcraft-tuner-ui"; + +type UiState = { + x: number; + y: number; + collapsed: boolean; + open: Record; +}; + +const DEFAULT_UI: UiState = { x: 16, y: 72, collapsed: false, open: {} }; + +function loadUi(): UiState { + try { + return { ...DEFAULT_UI, ...JSON.parse(localStorage.getItem(UI_KEY) ?? "{}") }; + } catch { + return DEFAULT_UI; + } +} + +// Floating dev tuner: renders every group registered via defineTunables as a +// collapsible section of slider + number-input rows. Drag by the header. +// Shown in dev builds, or anywhere with ?tuner=1. See TUNER.md. +export default function TunerPanel() { + const [enabled, setEnabled] = useState(false); + useEffect(() => { + setEnabled( + process.env.NODE_ENV === "development" || + new URLSearchParams(window.location.search).has("tuner"), + ); + }, []); + if (!enabled) return null; + return ; +} + +function Panel() { + const groups = useTunerStore((s) => s.groups); + const resetAll = useTunerStore((s) => s.resetAll); + const [ui, setUi] = useState(loadUi); + const [copied, setCopied] = useState(false); + const dragOrigin = useRef<{ px: number; py: number; x: number; y: number } | null>(null); + + const saveUi = (next: UiState) => { + setUi(next); + try { + localStorage.setItem(UI_KEY, JSON.stringify(next)); + } catch { + // Non-persistent tuning is still tuning. + } + }; + + const onHeaderPointerDown = (e: React.PointerEvent) => { + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + dragOrigin.current = { px: e.clientX, py: e.clientY, x: ui.x, y: ui.y }; + }; + const onHeaderPointerMove = (e: React.PointerEvent) => { + const o = dragOrigin.current; + if (!o) return; + saveUi({ + ...ui, + x: Math.max(0, Math.min(window.innerWidth - 60, o.x + e.clientX - o.px)), + y: Math.max(0, Math.min(window.innerHeight - 40, o.y + e.clientY - o.py)), + }); + }; + const onHeaderPointerUp = () => { + dragOrigin.current = null; + }; + + const copyValues = async () => { + try { + await navigator.clipboard.writeText( + JSON.stringify(tunerSnapshot(), null, 2), + ); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + // Clipboard can be blocked; the values are still visible in the panel. + } + }; + + const ordered = Object.values(groups).sort((a, b) => a.order - b.order); + + return ( +
+
+

Tuner

+
+ + + saveUi({ ...ui, collapsed: !ui.collapsed })} + label={ui.collapsed ? "+" : "–"} + /> +
+
+ + {!ui.collapsed && ( +
+ {ordered.length === 0 && ( +

+ No tunables registered +

+ )} + {ordered.map((group) => ( +
+ saveUi({ + ...ui, + open: { ...ui.open, [group.id]: !(ui.open[group.id] ?? false) }, + }) + } + /> + ))} +
+ )} +
+ ); +} + +function HeaderButton({ onClick, label }: { onClick: () => void; label: string }) { + return ( + + ); +} + +function Section({ + group, + open, + onToggle, +}: { + group: TunableGroup; + open: boolean; + onToggle: () => void; +}) { + return ( +
+ + {open && ( +
+ {Object.entries(group.defs).map(([key, def]) => ( + + ))} +
+ )} +
+ ); +} + +function Row({ + groupId, + tunableKey, + def, +}: { + groupId: string; + tunableKey: string; + def: TunableGroup["defs"][string]; +}) { + const storeKey = `${groupId}.${tunableKey}`; + const value = useTunerStore((s) => s.values[storeKey] ?? def.default); + const setValue = useTunerStore((s) => s.setValue); + const modified = value !== def.default; + + return ( + + ); +} + +function round(v: number): number { + return Math.round(v * 10000) / 10000; +} diff --git a/frontend/apps/artcraft-website-next/src/components/landing/hero-tunables.ts b/frontend/apps/artcraft-website-next/src/components/landing/hero-tunables.ts new file mode 100644 index 0000000000..9967904b3d --- /dev/null +++ b/frontend/apps/artcraft-website-next/src/components/landing/hero-tunables.ts @@ -0,0 +1,62 @@ +// Every tweakable constant of the hero wordmark, registered with the dev +// tuner (see TUNER.md). Defaults here ARE the shipped values — tune live, +// copy the JSON from the panel, then update the defaults. + +import { defineTunables } from "@/lib/tuner"; + +export const formationTuner = defineTunables("formation", "Formation", { + ballScale: { label: "Ball scale", min: 0.5, max: 2.5, step: 0.05, default: 1 }, + sizeMin: { label: "Size min ×", min: 0.05, max: 0.6, step: 0.01, default: 0.18 }, + sizeMax: { label: "Size max ×", min: 0.2, max: 1.4, step: 0.01, default: 0.66 }, + sizeNoiseScale: { label: "Size noise px", min: 30, max: 400, step: 5, default: 130 }, + sizeRoughen: { label: "Size roughen", min: 0, max: 1, step: 0.02, default: 0.4 }, + gap: { label: "Gap px", min: 0, max: 6, step: 0.1, default: 0.6 }, + depth: { label: "Depth ×", min: 0.2, max: 5, step: 0.1, default: 1.9 }, + density: { label: "Fill density", min: 0.2, max: 3, step: 0.05, default: 1 }, + edgeMargin: { label: "Edge margin", min: 0, max: 1, step: 0.05, default: 0.55 }, + seed: { label: "Seed", min: 1, max: 9999, step: 1, default: 1013 }, +}); + +export const physicsTuner = defineTunables("physics", "Physics", { + spring: { label: "Spring", min: 5, max: 140, step: 1, default: 46 }, + damping: { label: "Damping", min: 1, max: 14, step: 0.1, default: 5.2 }, + pushRadius: { label: "Push radius", min: 40, max: 450, step: 5, default: 180 }, + pushStrength: { label: "Push strength", min: 1000, max: 80000, step: 500, default: 23000 }, + swirl: { label: "Swirl", min: 0, max: 1.5, step: 0.05, default: 0.55 }, + popZ: { label: "Pop toward viewer", min: 0, max: 2, step: 0.05, default: 0.6 }, + bobAmp: { label: "Idle bob px", min: 0, max: 8, step: 0.1, default: 1.4 }, + scatter: { label: "Intro scatter px", min: 0, max: 1400, step: 20, default: 480 }, +}); + +export const lightTuner = defineTunables("light", "Cursor & Light", { + intensityDark: { label: "Cursor int (dark)", min: 0, max: 220000, step: 1000, default: 58000 }, + intensityLight: { label: "Cursor int (light)", min: 0, max: 220000, step: 1000, default: 30000 }, + idleDim: { label: "Idle dim", min: 0, max: 1, step: 0.05, default: 0.3 }, + lightZ: { label: "Light z", min: 30, max: 500, step: 5, default: 130 }, + lightRange: { label: "Light range", min: 100, max: 3000, step: 50, default: 900 }, + keyIntensity: { label: "Back key (dark)", min: 0, max: 8, step: 0.1, default: 2.2 }, + counterIntensity: { label: "Counter rim (dark)", min: 0, max: 4, step: 0.05, default: 0.7 }, + frontFill: { label: "Front fill (dark)", min: 0, max: 1, step: 0.01, default: 0.06 }, + ambientDark: { label: "Ambient (dark)", min: 0, max: 1, step: 0.01, default: 0.05 }, + ambientLight: { label: "Ambient (light)", min: 0, max: 2, step: 0.05, default: 0.55 }, + dirLight: { label: "Key (light)", min: 0, max: 3, step: 0.05, default: 0.75 }, +}); + +export const materialTuner = defineTunables("material", "Material", { + roughnessDark: { label: "Roughness (dark)", min: 0, max: 1, step: 0.02, default: 0.42 }, + metalnessDark: { label: "Metalness (dark)", min: 0, max: 1, step: 0.02, default: 0.5 }, + clearcoat: { label: "Clearcoat (dark)", min: 0, max: 1, step: 0.02, default: 0.9 }, + clearcoatRoughness: { label: "Coat rough (dark)", min: 0, max: 1, step: 0.02, default: 0.22 }, + roughnessLight: { label: "Roughness (light)", min: 0, max: 1, step: 0.02, default: 0.3 }, + metalnessLight: { label: "Metalness (light)", min: 0, max: 1, step: 0.02, default: 0.15 }, + envLight: { label: "Env int (light)", min: 0, max: 2, step: 0.05, default: 0.55 }, + accentShare: { label: "Accent share", min: 0, max: 0.3, step: 0.005, default: 0.05 }, +}); + +export const haloTuner = defineTunables("halo", "Halo", { + size: { label: "Size px", min: 200, max: 1800, step: 20, default: 720 }, + alphaDark: { label: "Alpha (dark)", min: 0, max: 1, step: 0.02, default: 0.5 }, + alphaLight: { label: "Alpha (light)", min: 0, max: 0.6, step: 0.01, default: 0.14 }, + z: { label: "Z offset", min: -400, max: 0, step: 5, default: -90 }, + falloff: { label: "Falloff", min: 0.5, max: 6, step: 0.1, default: 2.6 }, +}); diff --git a/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx b/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx index 875b2586a1..d40bb6ef9b 100644 --- a/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx +++ b/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx @@ -12,12 +12,19 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors"; +import { useTunerStore } from "@/lib/tuner"; import { sampleWordmark, WordmarkSim, - DEFAULT_PARAMS, type SampledWordmark, } from "./wordmark-sim"; +import { + formationTuner, + physicsTuner, + lightTuner, + materialTuner, + haloTuner, +} from "./hero-tunables"; const WORDMARK = "artcraft"; const FONT_WEIGHT = 700; @@ -62,7 +69,8 @@ export default function HeroWordmark() { }, [ready]); // Rasterize + sample once the display font is really loaded; re-sample when - // the container width changes meaningfully (not on mobile URL-bar jitter). + // the container width changes meaningfully (not on mobile URL-bar jitter) + // or when a Formation tunable moves in the dev tuner. useEffect(() => { if (!ready) return; const container = containerRef.current; @@ -72,9 +80,9 @@ export default function HeroWordmark() { let lastWidth = 0; let timer: ReturnType | undefined; - const build = async () => { + const build = async (force = false) => { const width = container.clientWidth; - if (!width || width === lastWidth) return; + if (!width || (!force && width === lastWidth)) return; lastWidth = width; const family = getComputedStyle(container).fontFamily; @@ -85,7 +93,8 @@ export default function HeroWordmark() { } if (cancelled) return; - const spacing = Math.min(18, Math.max(11, width / 62)); + const { ballScale, ...formation } = formationTuner.read(); + const spacing = Math.min(18, Math.max(11, width / 62)) * ballScale; setSample( sampleWordmark({ text: WORDMARK, @@ -93,7 +102,7 @@ export default function HeroWordmark() { fontWeight: FONT_WEIGHT, targetWidth: Math.min(width * 0.88, 1280), spacing, - layers: width < 720 ? 2 : 3, + formation, }), ); }; @@ -104,10 +113,22 @@ export default function HeroWordmark() { timer = setTimeout(build, 350); }); ro.observe(container); + + // Formation tunables change the arrangement itself — debounce a rebuild. + let lastFormation = JSON.stringify(formationTuner.read()); + const unsubscribe = useTunerStore.subscribe(() => { + const now = JSON.stringify(formationTuner.read()); + if (now === lastFormation) return; + lastFormation = now; + clearTimeout(timer); + timer = setTimeout(() => build(true), 250); + }); + return () => { cancelled = true; clearTimeout(timer); ro.disconnect(); + unsubscribe(); }; }, [ready]); @@ -221,9 +242,10 @@ const HALO_FRAGMENT = /* glsl */ ` varying vec2 vUv; uniform vec3 uColor; uniform float uAlpha; + uniform float uFalloff; void main() { float d = distance(vUv, vec2(0.5)) * 2.0; - float a = pow(clamp(1.0 - d, 0.0, 1.0), 2.6); + float a = pow(clamp(1.0 - d, 0.0, 1.0), uFalloff); gl_FragColor = vec4(uColor, a * uAlpha); } `; @@ -240,9 +262,20 @@ function BallField({ const meshRef = useRef(null); const lightRef = useRef(null); const haloRef = useRef(null); + const ambientRef = useRef(null); + const keyRef = useRef(null); + const counterRef = useRef(null); + const fillRef = useRef(null); const lightPos = useRef(new THREE.Vector3(0, 120, 0)); - const sim = useMemo(() => new WordmarkSim(sample), [sample]); + const sim = useMemo( + () => new WordmarkSim(sample, physicsTuner.read().scatter), + [sample], + ); const dummy = useMemo(() => new THREE.Object3D(), []); + // Reactive so recoloring reruns when the accent share slider moves. + const accentShare = useTunerStore( + (s) => s.values["material.accentShare"] ?? 0.05, + ); const dark = useMemo(() => { const c = new THREE.Color(colors.bg); @@ -261,6 +294,7 @@ function BallField({ value: new THREE.Color(dark ? "#a8c4ff" : colors.accent), }, uAlpha: { value: dark ? 0.5 : 0.14 }, + uFalloff: { value: 2.6 }, }, transparent: true, depthWrite: false, @@ -285,7 +319,7 @@ function BallField({ const c = new THREE.Color(); const rand = seeded(4242); for (let i = 0; i < sim.n; i++) { - if (rand() < 0.05) { + if (rand() < accentShare) { c.copy(accent); if (dark) c.multiplyScalar(0.8); } else { @@ -294,14 +328,20 @@ function BallField({ mesh.setColorAt(i, c); } if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - }, [colors, sim, dark]); + }, [colors, sim, dark, accentShare]); useFrame((state, delta) => { const mesh = meshRef.current; if (!mesh) return; const p = pointer.current; - sim.step(delta, p.x, p.y, p.active, DEFAULT_PARAMS); + // Live tuner values — read every frame so slider changes apply instantly. + const phys = physicsTuner.read(); + const lt = lightTuner.read(); + const mt = materialTuner.read(); + const ht = haloTuner.read(); + + sim.step(delta, p.x, p.y, p.active, phys); for (let i = 0; i < sim.n; i++) { dummy.position.set(sim.x[i], sim.y[i], sim.z[i]); @@ -322,22 +362,50 @@ function BallField({ lightRef.current?.position.set( lightPos.current.x, lightPos.current.y, - 130, + lt.lightZ, ); haloRef.current?.position.set( lightPos.current.x, lightPos.current.y, - -90, + ht.z, ); // At rest the word sits in near-eclipse; approaching with the cursor // brings the light up to full. const light = lightRef.current; if (light) { - const full = dark ? 58000 : 30000; - const target = p.active ? full : full * 0.3; + light.distance = lt.lightRange; + const full = dark ? lt.intensityDark : lt.intensityLight; + const target = p.active ? full : full * lt.idleDim; light.intensity += (target - light.intensity) * k; } + + // Rig + material + halo bindings. + if (ambientRef.current) { + ambientRef.current.intensity = dark ? lt.ambientDark : lt.ambientLight; + } + if (keyRef.current) { + keyRef.current.intensity = dark ? lt.keyIntensity : lt.dirLight; + } + if (counterRef.current) counterRef.current.intensity = lt.counterIntensity; + if (fillRef.current) fillRef.current.intensity = lt.frontFill; + + const mat = mesh.material as THREE.MeshPhysicalMaterial & + THREE.MeshStandardMaterial; + if (dark) { + mat.roughness = mt.roughnessDark; + mat.metalness = mt.metalnessDark; + mat.clearcoat = mt.clearcoat; + mat.clearcoatRoughness = mt.clearcoatRoughness; + } else { + mat.roughness = mt.roughnessLight; + mat.metalness = mt.metalnessLight; + mat.envMapIntensity = mt.envLight; + } + + haloMaterial.uniforms.uAlpha.value = dark ? ht.alphaDark : ht.alphaLight; + haloMaterial.uniforms.uFalloff.value = ht.falloff; + haloRef.current?.scale.setScalar(ht.size / 720); }); return ( @@ -353,15 +421,15 @@ function BallField({ // rims the top edges, a faint counter-rim catches the lower-right, // and the cursor light does the actual revealing. <> - - - - + + + + ) : ( <> - - + + )} diff --git a/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts b/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts index 26b01dfbd4..a22cb4051e 100644 --- a/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts +++ b/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts @@ -1,8 +1,9 @@ // Wordmark ball field: the brand name rasterized to a mask, then filled with -// small 3D balls on a jittered close-packed lattice a few layers deep. The -// balls separate under the cursor's push and rejoin on their home springs; a -// 3D collision pass keeps them from intersecting, so the text reads as a -// solid object built from spheres rather than a flat particle billboard. +// small 3D balls by multi-pass dart throwing — big balls land first, smaller +// ones settle into the gaps, like a pile that collided into the letter shape +// (a faked impression, not a physics solve; it must run on laptops/phones). +// The balls separate under the cursor's push and rejoin on their home +// springs; a 3D collision pass keeps them from intersecting. // // Units are CSS pixels at the text's z=0 plane — the camera is fitted so one // world unit projects to one pixel there, which keeps pointer math trivial. @@ -20,6 +21,20 @@ export type SampledWordmark = { height: number; }; +// All knobs of the arrangement — registered with the dev tuner in +// hero-tunables.ts. Size factors are multiples of `spacing`. +export type FormationParams = { + sizeMin: number; + sizeMax: number; + sizeNoiseScale: number; // px wavelength of the size clusters + sizeRoughen: number; // 0..1 per-ball white-noise contribution + gap: number; // px clearance between resting balls + depth: number; // slab thickness, × spacing + density: number; // dart-attempt multiplier + edgeMargin: number; // 0..1 — how far inside the stroke big balls stay + seed: number; +}; + // ————————————————————————————— sampling ————————————————————————————— export function sampleWordmark(opts: { @@ -27,12 +42,12 @@ export function sampleWordmark(opts: { fontFamily: string; fontWeight: number; targetWidth: number; - /** Lattice spacing in px — sets ball size (~spacing/2) and count. */ + /** Base unit in px — ball sizes and slab depth scale from this. */ spacing: number; - /** Depth layers (2–3 keeps it chunky without mush). */ - layers: number; + formation: FormationParams; }): SampledWordmark { - const { text, fontFamily, fontWeight, targetWidth, spacing, layers } = opts; + const { text, fontFamily, fontWeight, targetWidth, spacing, formation } = + opts; // Each glyph is measured and drawn individually with a small manual gap: // no shaping ever runs across letters, so the font can't fuse tight pairs @@ -75,90 +90,106 @@ export function sampleWordmark(opts: { return alpha[(y * width + x) * 4 + 3] > 127; }; - const rand = mulberry32(1013); - const jitter = spacing * 0.13; - const rowH = spacing * (Math.SQRT2 / 2) * 1.22; // hex-ish row pitch - const balls: Ball[] = []; - - // Organic sizing: a smooth spatial noise drifts the ball size across the - // word (clusters of big and small), white noise roughens it per ball. The - // neighbor cap below keeps every draw collision-safe, so variance only - // ever shrinks balls — which is exactly what reads as hand-made. - const sizeNoise = makeValueNoise(41); - const desiredRadius = (x: number, y: number) => - spacing * - (0.24 + - 0.36 * Math.pow(sizeNoise(x / 130, y / 130), 1.15) + - rand() * 0.16); - - for (let layer = 0; layer < layers; layer++) { - const z = (layer - (layers - 1) / 2) * spacing * 0.92; - // Alternate layers shift half a cell both ways so depth neighbors nest - // into the gaps instead of stacking into columns. - const layerOff = layer % 2 === 0 ? 0 : spacing * 0.5; - let row = 0; - for (let y = pad * 0.5; y < height - pad * 0.5; y += rowH, row++) { - const rowOff = (row % 2 === 0 ? 0 : spacing * 0.5) + layerOff; - for (let x = pad * 0.5 + rowOff; x < width - pad * 0.5; x += spacing) { - const jx = x + (rand() - 0.5) * jitter * 2; - const jy = y + (rand() - 0.5) * jitter * 2; - if (!covered(Math.round(jx), Math.round(jy))) continue; - balls.push({ - x: jx, - y: jy, - z: z + (rand() - 0.5) * jitter * 2, - r: desiredRadius(jx, jy), // desired; capped below - }); - } + // Fast "random point inside the ink" sampling: collect covered pixels on a + // coarse stride once, then darts pick from that list with subpixel jitter. + const inkCells: number[] = []; + for (let y = 0; y < height; y += 2) { + for (let x = 0; x < width; x += 2) { + if (covered(x, y)) inkCells.push(y * width + x); } } + if (!inkCells.length) return { balls: [], width, height }; - // Cap every radius at half its nearest-home-neighbor distance so the rest - // pose is non-overlapping regardless of jitter — collisions then only ever - // matter mid-shove, never in the settled wordmark. - capRadiiToNeighbors(balls, spacing); + const rand = mulberry32(formation.seed); + const sizeNoise = makeValueNoise(formation.seed + 41); + const rMin = spacing * formation.sizeMin; + const rMax = spacing * formation.sizeMax; + const halfDepth = (spacing * formation.depth) / 2; - return { balls, width, height }; -} + // Organic sizing: smooth spatial noise drifts the size in clusters across + // the word; white noise roughens it per ball. + const desiredRadius = (x: number, y: number) => { + const n = Math.pow( + sizeNoise(x / formation.sizeNoiseScale, y / formation.sizeNoiseScale), + 1.15, + ); + const roughened = + n * (1 - formation.sizeRoughen) + rand() * formation.sizeRoughen; + return rMin + (rMax - rMin) * roughened; + }; -function capRadiiToNeighbors(balls: Ball[], spacing: number) { - const cell = spacing * 2; + // A ball only counts as "inside the letter" if its center plus four cross + // points at ~edgeMargin·r are on ink — big balls stay inside the strokes, + // small ones may nestle right up to the edge, keeping letterforms crisp. + const insideStroke = (x: number, y: number, r: number) => { + const m = r * formation.edgeMargin; + return ( + covered(Math.round(x), Math.round(y)) && + covered(Math.round(x + m), Math.round(y)) && + covered(Math.round(x - m), Math.round(y)) && + covered(Math.round(x), Math.round(y + m)) && + covered(Math.round(x), Math.round(y - m)) + ); + }; + + // Dart throwing with a 3D hash for overlap rejection. Three passes with a + // descending size floor: the big balls claim their spots first and the + // smaller ones settle into the leftover gaps — the arrangement reads like + // a settled pile instead of a grid. + const balls: Ball[] = []; + const cell = rMax * 2 + formation.gap; const grid = new Map(); const key = (x: number, y: number, z: number) => (Math.floor(x / cell) * 73856093) ^ (Math.floor(y / cell) * 19349663) ^ (Math.floor(z / cell) * 83492791); - balls.forEach((b, i) => { - const k = key(b.x, b.y, b.z); - const bucket = grid.get(k); - if (bucket) bucket.push(i); - else grid.set(k, [i]); - }); - const nearest = new Float32Array(balls.length).fill(Infinity); - for (let i = 0; i < balls.length; i++) { - const b = balls[i]; + const fits = (x: number, y: number, z: number, r: number) => { for (let gz = -1; gz <= 1; gz++) { for (let gy = -1; gy <= 1; gy++) { for (let gx = -1; gx <= 1; gx++) { const bucket = grid.get( - key(b.x + gx * cell, b.y + gy * cell, b.z + gz * cell), + key(x + gx * cell, y + gy * cell, z + gz * cell), ); if (!bucket) continue; for (const j of bucket) { - if (j === i) continue; const o = balls[j]; - const d = Math.hypot(b.x - o.x, b.y - o.y, b.z - o.z); - if (d < nearest[i]) nearest[i] = d; + const dx = o.x - x; + const dy = o.y - y; + const dz = o.z - z; + const min = o.r + r + formation.gap; + if (dx * dx + dy * dy + dz * dz < min * min) return false; } } } } + return true; + }; + + const passes = [0.7, 0.35, 0]; // descending floor on the size range + const attemptsPerPass = Math.floor( + inkCells.length * 0.9 * formation.density, + ); + for (const floor of passes) { + for (let a = 0; a < attemptsPerPass; a++) { + const c = inkCells[(rand() * inkCells.length) | 0]; + const x = (c % width) + rand() * 2 - 1; + const y = Math.floor(c / width) + rand() * 2 - 1; + const z = (rand() * 2 - 1) * halfDepth; + let r = desiredRadius(x, y); + const passMin = rMin + (rMax - rMin) * floor; + if (r < passMin) r = passMin + (r - rMin) * 0.2; + if (!insideStroke(x, y, r)) continue; + if (!fits(x, y, z, r)) continue; + balls.push({ x, y, z, r }); + const k = key(x, y, z); + const bucket = grid.get(k); + if (bucket) bucket.push(balls.length - 1); + else grid.set(k, [balls.length - 1]); + } } - for (let i = 0; i < balls.length; i++) { - const fit = nearest[i] * 0.5 - 0.4; - balls[i].r = Math.max(spacing * 0.15, Math.min(balls[i].r, fit)); - } + + return { balls, width, height }; } // Deterministic bilinear value noise, 0..1 — smooth variation across space. diff --git a/frontend/apps/artcraft-website-next/src/lib/tuner.ts b/frontend/apps/artcraft-website-next/src/lib/tuner.ts new file mode 100644 index 0000000000..18b3522e90 --- /dev/null +++ b/frontend/apps/artcraft-website-next/src/lib/tuner.ts @@ -0,0 +1,133 @@ +// Live dev tuner (see TUNER.md). +// +// Any module can register a group of numeric tunables with `defineTunables`; +// the TunerPanel renders every registered group with sliders + number inputs, +// and consumers read live values per frame (or subscribe for rebuilds). +// Values persist to localStorage so a refresh keeps the current tuning. +// +// POLICY: every new tweakable constant in this app gets registered here by +// default — adding a slider is free, a missing one costs an iteration. + +import { create } from "zustand"; + +export type TunableDef = { + label: string; + min: number; + max: number; + step: number; + default: number; +}; + +export type TunableGroup = { + id: string; + title: string; + order: number; + defs: Record; +}; + +type TunerState = { + groups: Record; + /** Sparse overrides, keyed "group.key". Defaults live in the defs. */ + values: Record; + /** Bumped on every change — cheap signal for rebuild subscribers. */ + version: number; + registerGroup: ( + id: string, + title: string, + defs: Record, + ) => void; + setValue: (groupId: string, key: string, value: number) => void; + resetAll: () => void; +}; + +const STORAGE_KEY = "artcraft-tuner"; + +function loadStored(): Record { + if (typeof window === "undefined") return {}; + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +function save(values: Record) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); + } catch { + // Storage may be unavailable (private mode); tuning just won't persist. + } +} + +export const useTunerStore = create((set) => ({ + groups: {}, + values: loadStored(), + version: 0, + registerGroup: (id, title, defs) => + set((s) => + s.groups[id] + ? s + : { + groups: { + ...s.groups, + [id]: { id, title, order: Object.keys(s.groups).length, defs }, + }, + }, + ), + setValue: (groupId, key, value) => + set((s) => { + const values = { ...s.values, [`${groupId}.${key}`]: value }; + save(values); + return { values, version: s.version + 1 }; + }), + resetAll: () => + set((s) => { + save({}); + return { values: {}, version: s.version + 1 }; + }), +})); + +export type TunableReader> = { + id: string; + defs: T; + /** Current values (defaults merged with overrides). Cheap; call per frame. */ + read: () => { [K in keyof T]: number }; +}; + +// Registers a tunable group (once) and returns a typed live reader. +// Call at module scope in the file that owns the constants. +export function defineTunables>( + id: string, + title: string, + defs: T, +): TunableReader { + useTunerStore.getState().registerGroup(id, title, defs); + return { + id, + defs, + read: () => { + const { values } = useTunerStore.getState(); + const out = {} as { [K in keyof T]: number }; + for (const key in defs) { + const override = values[`${id}.${String(key)}`]; + out[key] = override ?? defs[key].default; + } + return out; + }, + }; +} + +// Merged snapshot of every registered group — used by the panel's copy +// button so tuned values can be pasted back into code as new defaults. +export function tunerSnapshot(): Record> { + const { groups, values } = useTunerStore.getState(); + const out: Record> = {}; + for (const g of Object.values(groups)) { + out[g.id] = {}; + for (const key in g.defs) { + out[g.id][key] = values[`${g.id}.${key}`] ?? g.defs[key].default; + } + } + return out; +}