website: live dev tuner + noisy settled-pile wordmark formation

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.
This commit is contained in:
Tanish Manku
2026-08-19 23:17:50 -07:00
parent 93232c4d73
commit 6fdc833d3e
7 changed files with 683 additions and 89 deletions
@@ -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.
@@ -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({
<main>{children}</main>
<SiteFooter />
</MotionProvider>
<TunerPanel />
</body>
</html>
);
@@ -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<string, boolean>;
};
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 <Panel />;
}
function Panel() {
const groups = useTunerStore((s) => s.groups);
const resetAll = useTunerStore((s) => s.resetAll);
const [ui, setUi] = useState<UiState>(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 (
<div
className="fixed z-[100] w-[300px] border border-line-strong bg-bg-raised text-ink shadow-[0_8px_32px_rgba(0,0,0,0.35)] select-none"
style={{ left: ui.x, top: ui.y }}
>
<div
className="flex cursor-grab items-center justify-between border-b border-line bg-bg-sunken px-3 py-2 active:cursor-grabbing"
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
>
<p className="hud-label font-bold">Tuner</p>
<div className="flex items-center gap-1">
<HeaderButton onClick={copyValues} label={copied ? "Copied" : "Copy"} />
<HeaderButton onClick={resetAll} label="Reset" />
<HeaderButton
onClick={() => saveUi({ ...ui, collapsed: !ui.collapsed })}
label={ui.collapsed ? "+" : "–"}
/>
</div>
</div>
{!ui.collapsed && (
<div className="max-h-[70vh] overflow-y-auto">
{ordered.length === 0 && (
<p className="hud-label px-3 py-4 text-faint">
No tunables registered
</p>
)}
{ordered.map((group) => (
<Section
key={group.id}
group={group}
open={ui.open[group.id] ?? false}
onToggle={() =>
saveUi({
...ui,
open: { ...ui.open, [group.id]: !(ui.open[group.id] ?? false) },
})
}
/>
))}
</div>
)}
</div>
);
}
function HeaderButton({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button
type="button"
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
className="hud-label min-w-[44px] border border-line px-1.5 py-0.5 text-[10px] text-muted hover:bg-invert-bg hover:text-invert-fg"
>
{label}
</button>
);
}
function Section({
group,
open,
onToggle,
}: {
group: TunableGroup;
open: boolean;
onToggle: () => void;
}) {
return (
<div className="border-b border-line last:border-b-0">
<button
type="button"
onClick={onToggle}
className="hud-label flex w-full items-center justify-between px-3 py-2 text-muted hover:text-ink"
aria-expanded={open}
>
{group.title}
<span aria-hidden className="text-faint">
{open ? "–" : "+"}
</span>
</button>
{open && (
<div className="flex flex-col gap-2 px-3 pb-3">
{Object.entries(group.defs).map(([key, def]) => (
<Row key={key} groupId={group.id} tunableKey={key} def={def} />
))}
</div>
)}
</div>
);
}
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 (
<label className="flex flex-col gap-1">
<span className="flex items-center justify-between">
<span
className={`font-mono text-[10px] tracking-[0.08em] uppercase ${
modified ? "text-accent-ink" : "text-muted"
}`}
>
{def.label}
</span>
<input
type="number"
value={round(value)}
min={def.min}
max={def.max}
step={def.step}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) setValue(groupId, tunableKey, v);
}}
className="w-[72px] border border-line bg-bg px-1 py-0.5 text-right font-mono text-[11px] text-ink focus:outline-none focus:border-line-strong"
/>
</span>
<input
type="range"
value={value}
min={def.min}
max={def.max}
step={def.step}
onChange={(e) => setValue(groupId, tunableKey, Number(e.target.value))}
className="h-1 w-full cursor-ew-resize accent-[var(--accent)]"
/>
</label>
);
}
function round(v: number): number {
return Math.round(v * 10000) / 10000;
}
@@ -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 },
});
@@ -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<typeof setTimeout> | 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<THREE.InstancedMesh>(null);
const lightRef = useRef<THREE.PointLight>(null);
const haloRef = useRef<THREE.Mesh>(null);
const ambientRef = useRef<THREE.AmbientLight>(null);
const keyRef = useRef<THREE.DirectionalLight>(null);
const counterRef = useRef<THREE.DirectionalLight>(null);
const fillRef = useRef<THREE.DirectionalLight>(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.
<>
<ambientLight intensity={0.05} />
<directionalLight position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
<directionalLight position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
<directionalLight position={[0, -60, 520]} intensity={0.06} />
<ambientLight ref={ambientRef} intensity={0.05} />
<directionalLight ref={keyRef} position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
<directionalLight ref={counterRef} position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
<directionalLight ref={fillRef} position={[0, -60, 520]} intensity={0.06} />
</>
) : (
<>
<ambientLight intensity={0.55} />
<directionalLight position={[-260, 340, 420]} intensity={0.75} />
<ambientLight ref={ambientRef} intensity={0.55} />
<directionalLight ref={keyRef} position={[-260, 340, 420]} intensity={0.75} />
<directionalLight position={[180, 260, -420]} intensity={0.5} color="#eef3ff" />
</>
)}
@@ -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<number, number[]>();
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.
@@ -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<string, TunableDef>;
};
type TunerState = {
groups: Record<string, TunableGroup>;
/** Sparse overrides, keyed "group.key". Defaults live in the defs. */
values: Record<string, number>;
/** Bumped on every change — cheap signal for rebuild subscribers. */
version: number;
registerGroup: (
id: string,
title: string,
defs: Record<string, TunableDef>,
) => void;
setValue: (groupId: string, key: string, value: number) => void;
resetAll: () => void;
};
const STORAGE_KEY = "artcraft-tuner";
function loadStored(): Record<string, number> {
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<string, number>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// Storage may be unavailable (private mode); tuning just won't persist.
}
}
export const useTunerStore = create<TunerState>((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<T extends Record<string, TunableDef>> = {
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<T extends Record<string, TunableDef>>(
id: string,
title: string,
defs: T,
): TunableReader<T> {
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<string, Record<string, number>> {
const { groups, values } = useTunerStore.getState();
const out: Record<string, Record<string, number>> = {};
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;
}