Kieron Oates, home

Code from my projects

kieron@portfolio:~/codeC:\Users\kieron\code
  • Moving 32,000 satellites on the graphics card

    kieron@portfolio:~/overhead$ cat src/globe/orbit.glsl.tsPS C:\Users\kieron\overhead> Get-Content src\globe\orbit.glsl.ts

    // Per object, 18 floats the GPU reads straight from one buffer:
    // a e M0 ṁ | Px Py Pz ω̇ | Qx Qy Qz Ω̇ | t2 kind glint launchDay | groups epochDay
    
    /** Where an object is, dt seconds from its anchor: Kepler's equation, then SGP4's secular drift. TEME km. */
    vec3 orbitPosition(vec3 elements, float mdot, vec4 P, vec4 Q, float t2, float dt) {
      float a = elements.x;
      float e = elements.y;
      float M = mod(elements.z + mdot * dt + t2 * dt * dt, 6.2831853);
      float E = e < 0.8 ? M : PI;
      for (int i = 0; i < 5; i++) E -= (E - e * sin(E) - M) / (1.0 - e * cos(E));
      vec2 xy = vec2(a * (cos(E) - e), a * sqrt(1.0 - e * e) * sin(E));
      float dw = P.w * dt;
      float dO = Q.w * dt;
      xy = mat2(cos(dw), sin(dw), -sin(dw), cos(dw)) * xy;
      vec3 r = xy.x * P.xyz + xy.y * Q.xyz;
      r.xy = mat2(cos(dO), sin(dO), -sin(dO), cos(dO)) * r.xy;
      return r;
    }
    
    /** TEME km to scene units: (x, z, -y), one Earth radius to the unit. */
    vec3 toScene(vec3 r) {
      return vec3(r.x, r.z, -r.y) / 6371.0;
    }

    The problem: Running the full SGP4 model for 32,000 objects every frame is far too slow for a phone, but a straight-line guess between updates is already 15 km out after 60 seconds.

    Why this approach: Once a minute (in sim time) I fit each object’s orbit as an ellipse plus SGP4’s slow drift in perigee, node and drag, and let the vertex shader do the rest. The whole catalogue is one draw call. Idle frames take 0.5 ms at p99 on desktop and 2.4 ms with 50,000 objects on a 6× throttled phone, and the main thread spends 0.03 ms a frame. Against real SGP4 it’s 0.5 km off (median) at 60 s and 12 km at 24 hours, where plain two-body orbits are 659 km off.

    What to notice: Kepler’s equation has no closed-form answer, so each vertex solves it with five Newton steps. Starting from π for very eccentric orbits stops those steps diverging.

    Open Overhead in my projects
  • Tap-to-select by searching the screen, not the GPU

    kieron@portfolio:~/overhead$ cat src/data/pick.tsPS C:\Users\kieron\overhead> Get-Content src\data\pick.ts

    export function findNearby(
      elements: Float32Array, objects: Pickable[], anchorTime: number,
      { x, y, radius, width, height, viewProjection: m, camera, time }: Omit<PickRequest, 'type' | 'id'>,
      limit = 8,
      kinds = ALL_KINDS,
      // … an optional extra filter
    ): Candidate[] {
      const [cx, cy, cz] = camera;
      const outside = cx * cx + cy * cy + cz * cz - 1;
      const dt = (time - anchorTime) / 1000;
      const day = time / DAY_MS;
      const found: (Candidate & { score: number; depth: number })[] = [];
      for (let slot = 0; slot < objects.length; slot++) {
        const o = slot * STRIDE;
        const info = objects[slot]!;
        if (!(elements[o]! > 0) || info.launchDay > day || !((kinds >> elements[o + KIND]!) & 1)) continue;
        keplerPosition(elements, o, dt, position);
        const px = position[0]! * KM, py = position[2]! * KM, pz = -position[1]! * KM;
        const w = m[3]! * px + m[7]! * py + m[11]! * pz + m[15]!;
        if (w <= 0) continue;
        const sx = (((m[0]! * px + m[4]! * py + m[8]! * pz + m[12]!) / w + 1) / 2) * width;
        const sy = ((1 - (m[1]! * px + m[5]! * py + m[9]! * pz + m[13]!) / w) / 2) * height;
        const distance = Math.hypot(sx - x, sy - y);
        if (distance > radius) continue;
        // Hidden if the ray from the camera meets the Earth before it reaches the object
        const dx = px - cx, dy = py - cy, dz = pz - cz;
        const length = Math.hypot(dx, dy, dz);
        const b = (cx * dx + cy * dy + cz * dz) / length;
        const disc = b * b - outside;
        if (disc > 0) {
          const hit = -b - Math.sqrt(disc);
          if (hit > 0 && hit < length) continue;
        }
        const station = (info.lists & STATION) !== 0;
        const score = distance - (station ? 6 : 0) - (info.lists & VISUAL ? 4 : 0) - (info.type === 'payload' ? 2 : 0);
        found.push({ slot, norad: info.norad, name: info.name, type: info.type, station, distance, score, depth: length });
      }
      found.sort((a, b) => a.score - b.score || a.depth - b.depth);
      return found.slice(0, limit).map(({ slot, norad, name, type, station, distance }) => ({ slot, norad, name, type, station, distance }));
    }

    The problem: At globe scale a fingertip covers a median of 87 satellites, and only 3.5% of taps land on a lone object. Tapping has to pick something sensible every time.

    Why this approach: I tried the usual GPU picking first: draw every object in a unique colour and read back the pixel. With overlapping points it returns whichever was drawn last, not the nearest. It agreed with the nearest object on only 8 of 40 desktop taps and 1 of 40 phone taps, and its readback peaked at 28 ms. Searching on the CPU, off the main thread, takes 5.4 ms (median) on desktop and lets me rank what’s under the finger.

    What to notice: The ray–sphere test throws out anything behind the Earth, and the small score bonuses mean a tap near the ISS finds the ISS, not the debris next to it.

    Open Overhead in my projects
  • A follow camera that doesn't shake

    kieron@portfolio:~/overhead$ cat src/globe/satellites.tsPS C:\Users\kieron\overhead> Get-Content src\globe\satellites.ts

    // The followed object. Each new set of orbits is fitted afresh, and its fit can sit a few hundred metres from the
    // last one: nothing at globe scale, but the follow camera, metres away, would lurch. The difference becomes an
    // offset that fades out, so the path stays continuous, however often new orbits arrive.
    const STEADY_MS = 1200;
    let steady = -1;
    const offset = new Vector3();
    let offsetAt = -Infinity;
    
    /** How much of the offset is left: all of it as new orbits arrive, none STEADY_MS later, eased out. */
    function fading(now: number) {
      const t = Math.min(Math.max((now - offsetAt) / STEADY_MS, 0), 1);
      return 1 - t * t * (3 - 2 * t);
    }
    
    function setElements({ el, time }: ElementsMessage) {
      const attribute = geometry.getAttribute('position') as InterleavedBufferAttribute | undefined;
      const outgoing = attribute?.data.array as Float32Array | undefined;
      if (outgoing && place(outgoing, anchorTime, steady, lastTime, before) && place(el, time, steady, lastTime, after)) {
        const now = performance.now();
        offset.multiplyScalar(fading(now)).add(before).sub(after);
        offsetAt = now;
      }
      // … upload the new orbits
    }
    
    /** Where an object is drawn at this sim time, using the shader's own maths, in double precision. */
    function positionOf(slot: number, time: number, out: Vector3) {
      const attribute = geometry.getAttribute('position') as InterleavedBufferAttribute | undefined;
      const el = attribute?.data.array as Float32Array | undefined;
      if (!el || slot >= count || !place(el, anchorTime, slot, time, out)) return null;
      return slot === steady ? out.addScaledVector(offset, fading(performance.now())) : out;
    }

    The problem: Following a satellite from a few hundred metres away, it shook. The drawn point moved 2.4 px frame to frame (max 9.5), and each orbit refit jumped the camera by up to 394 m.

    Why this approach: There were two causes, so there are two fixes. Shaders work in 32-bit floats, and near orbit height one step is 0.76 m, so the followed object is hidden from the shader and drawn as its own marker placed from this double-precision code. Each refit’s jump becomes an offset that fades out over 1.2 s instead of snapping. Afterwards the marker moves at most 0.2 px, and a test that failed the old code with a 559 m jump now passes.

    What to notice: offset.multiplyScalar(fading(now)) keeps whatever is left of the previous offset, so new orbits arriving mid-fade chain smoothly instead of resetting.

    Open Overhead in my projects
  • Finding when the ISS is visible tonight

    kieron@portfolio:~/overhead$ cat src/sky/passes.tsPS C:\Users\kieron\overhead> Get-Content src\sky\passes.ts

    export function findPasses(rec: SatRec, latDeg: number, lonDeg: number, from: number, to: number, limit = Infinity): Pass[] {
      const where = { latitude: latDeg * RAD, longitude: lonDeg * RAD, height: 0 };
      const seenAt = (t: number) => look(rec, where, t)?.visible ?? false;
      const edge = (a: number, b: number) => {
        const before = seenAt(a);
        while (b - a > PRECISION_MS) {
          const m = (a + b) / 2;
          if (seenAt(m) === before) a = m;
          else b = m;
        }
        return (a + b) / 2;
      };
      const highest = (a: number, b: number) => {
        const g = (Math.sqrt(5) - 1) / 2;
        const height = (t: number) => look(rec, where, t)?.elevation ?? -Infinity;
        let c = b - g * (b - a);
        let d = a + g * (b - a);
        while (b - a > PRECISION_MS) {
          if (height(c) > height(d)) b = d;
          else a = c;
          c = b - g * (b - a);
          d = a + g * (b - a);
        }
        return (a + b) / 2;
      };
      const passes: Pass[] = [];
      let was = seenAt(from);
      let start: number | null = was ? from : null;
      for (let t = from + STEP_MS; t <= to && passes.length < limit; t += STEP_MS) {
        const is = seenAt(t);
        if (is && !was) start = edge(t - STEP_MS, t);
        if (!is && was && start !== null) {
          const end = edge(t - STEP_MS, t);
          passes.push({ start, peak: highest(start, end), end /* … directions */ });
          start = null;
        }
        was = is;
      }
      return passes;
    }

    The problem: “Visible” means three things at once: at least 10° up, lit by the Sun, and against a sky dark enough (Sun 6° below the horizon). Checking every second for a week is 600,000 orbit calculations per object.

    Why this approach: A 20-second scan is coarse enough to be quick and fine enough not to miss the shortest pass. Each edge is then halved down to 250 ms, and the peak is found with a golden-section search, which reuses one of its two points every step. A station-week takes about 50 ms. Against in-the-sky.org’s London table, my start times are within 7.1 s, end times within 5.2 s and peak heights within 0.75°.

    What to notice: The bisection compares against whatever the visibility was at the start of the gap, so the same function finds both the start and the end of a pass.

    Open Overhead in my projects
  • Estimating how bright a satellite is from its radar size

    kieron@portfolio:~/overhead$ cat src/orbit/brightness.tsPS C:\Users\kieron\overhead> Get-Content src\orbit\brightness.ts

    /**
     * Standard magnitude (1,000 km away, half lit) from radar cross-section: m = a + b·log10(RCS m²).
     * Fitted per object type against visual magnitude observations; held-out error is a median of
     * 0.56 magnitudes. The slope sits near the −2.5 you'd expect if reflected light scales with area.
     */
    const FIT: Record<ObjectType, [a: number, b: number]> = {
      payload: [6.35, -2.27],
      rocket: [6.08, -2.57],
      debris: [5.95, -2.08],
    };
    
    /** When there's no measured RCS, the catalogue's size class: the fit at each class's median RCS. */
    const SIZE_CLASS: Record<string, number> = { LARGE: 4.5, MEDIUM: 7.5, SMALL: 10.0 };
    
    /** Above this the fit runs about three magnitudes too faint (the ISS and other giants), so no number is given. */
    export const GIANT_RCS = 50;
    
    export function estimateBrightness(type: ObjectType, rcs: number | undefined, sizeClass: string | undefined): Brightness {
      if (rcs !== undefined && rcs > GIANT_RCS) return { magnitude: null, giant: true };
      if (rcs !== undefined && rcs > 0) {
        const [a, b] = FIT[type];
        return { magnitude: a + b * Math.log10(rcs), giant: false };
      }
      const fromClass = sizeClass ? SIZE_CLASS[sizeClass] : undefined;
      return { magnitude: fromClass ?? null, giant: false };
    }

    The problem: I wanted to show which satellites you could see by eye, but the usual brightness list can’t be redistributed.

    Why this approach: Radar cross-section is published for about 14,500 objects, so I fitted brightness against it, one line per object type, and checked it on objects held back from the fit. The median error is 0.56 magnitudes, and the slope lands near the −2.5 physics predicts, which told me the fit was measuring something real.

    What to notice: It’s honest about where it breaks. Above 50 m² the fit runs about three magnitudes too faint, so the ISS and the other giants get no number at all rather than a wrong one.

    Open Overhead in my projects
  • Catching the curve fitter when it goes wrong

    kieron@portfolio:~/handwriting-font$ cat src/core/ink/fit.tsPS C:\Users\kieron\handwriting-font> Get-Content src\core\ink\fit.ts

    /**
     * Schneider's algorithm occasionally returns a curve that loops far away from the points it was given
     * (one test glyph strayed 294 units). Every curve is checked against its own points; one that strays
     * more than 3 × maxError is thrown away and the run is split in half and refitted.
     */
    function guardedFit(run: Point[], maxError: number, depth = 0): Cubic[] {
      if (run.length <= 3 || depth > 8) return polyline(run);
      const curves = fitCurve(run, maxError) as Cubic[];
      if (curves.some((curve) => strayDistance(curve, run) > maxError * 3)) {
        const mid = run.length >> 1;
        return [...guardedFit(run.slice(0, mid + 1), maxError, depth + 1), ...guardedFit(run.slice(mid), maxError, depth + 1)];
      }
      return curves;
    }
    
    function strayDistance(curve: Cubic, run: Point[]): number {
      let worst = 0;
      for (const p of flatten([curve], 24)) {
        let nearest = Infinity;
        for (let i = 1; i < run.length; i++) nearest = Math.min(nearest, distToSegment(p, run[i - 1]!, run[i]!));
        if (nearest > worst) worst = nearest;
      }
      return worst;
    }
    
    const polyline = (run: Point[]): Cubic[] => run.slice(1).map((q, i) => line(run[i]!, q));

    The problem: Every drawn letter becomes an outline of Bézier curves, and the standard fitting algorithm (Schneider’s) usually does a great job. But on 3 of 312 test glyphs it produced curves that shot off the page, one of them 294 font units from where the ink actually was.

    Why this approach: The fitter isn’t wrong often enough to replace, so I check its answer instead. Each curve is sampled and measured against the points it was meant to follow, and if any strays, that run is split in half and fitted again. Across 1,128 synthetic handwritten glyphs the worst deviation dropped from 294 units to 3.46, with no failures.

    What to notice: The recursion has two exits. Short runs become straight lines, and the depth limit means a pathological stroke can’t loop forever. It degrades to a polyline that is always correct, just less smooth.

    Open Handwriting Font Maker in my projects
  • Letters that take turns

    kieron@portfolio:~/handwriting-font$ cat src/core/font/gsub.tsPS C:\Users\kieron\handwriting-font> Get-Content src\core\font\gsub.ts

    /** Chaining context format 3: one backtrack glyph from `after`, one input glyph from `input` → `lookup`. */
    function chainOne(after: number[], input: number[], lookup: number): Uint8Array {
      // … writes the subtable: one backtrack coverage, one input coverage, one lookup record
    }
    
    /**
     * Letters take turns: each letter picks the variant after the one the previous glyph used, cycling
     * default → alt1 → alt2 → default. The chaining lookup reads the glyph before it, which the same pass
     * has already substituted, so the state carries along the line. Joined pairs run first, as their own
     * `liga` feature, so they form from plain letters and then count as a default glyph for the cycle.
     */
    export function makeGsub({ variants, pairs, neutral }: GsubInput): Uint8Array | null {
      const lookups: Lookup[] = [];
      const features: Feature[] = [];
      if (pairs.length) {
        features.push({ tag: 'liga', lookups: [lookups.length] });
        lookups.push({ type: LIGATURE, subtables: [ligatureSubst(pairs)] });
      }
      const withAlt = (k: number) => variants.filter((v) => v.alts[k] !== undefined).map((v): [number, number] => [v.base, v.alts[k]!]);
      const alt1 = withAlt(0), alt2 = withAlt(1);
      if (alt1.length) {
        const alt1Lookup = lookups.length;
        lookups.push({ type: SINGLE, subtables: [singleSubst(alt1)] });
        const chains = [chainOne(neutral, alt1.map(([b]) => b), alt1Lookup)];
        if (alt2.length) {
          const alt2Lookup = lookups.length;
          lookups.push({ type: SINGLE, subtables: [singleSubst(alt2)] });
          chains.push(chainOne(alt1.map(([, a]) => a), alt2.map(([b]) => b), alt2Lookup));
        }
        features.push({ tag: 'calt', lookups: [lookups.length] });
        lookups.push({ type: CHAINING, subtables: chains });
      }
      return lookups.length ? writeLayoutTable(features, lookups, EXTENSION) : null;
    }

    The problem: A handwriting font gives itself away when every “e” in “letter” is identical. People can draw their busiest letters up to three times, and the font should rotate through them as you type, with no code running in Word or Pages to help.

    Why this approach: Random alternates aren’t possible in OpenType, and a fixed rotation per letter still repeats visibly. This builds a tiny state machine out of contextual substitution. Each letter looks at the glyph before it, which the same pass has already swapped, so “after a default, use version 2; after version 2, use version 3” carries along the whole line. I checked it in HarfBuzz, fontkit, Chromium, Firefox, WebKit and Windows’ DirectWrite.

    What to notice: The order of the features. Joined pairs (liga) form first from plain letters, then count as “default” for the cycle, so a word with a joined “th” still rotates the letters around it.

    Open Handwriting Font Maker in my projects
  • Kerning from the shape of your own letters

    kieron@portfolio:~/handwriting-font$ cat src/core/metrics/kerning.tsPS C:\Users\kieron\handwriting-font> Get-Content src\core\metrics\kerning.ts

    /**
     * The visual gap between two glyphs set side by side: band by band, the white space from a's ink to b's,
     * with each side's contribution clamped so deep open counters (the inside of a C) don't count as gap.
     */
    export function pairGap(a: KernGlyph, b: KernGlyph, depthCap: number): PairGap | null {
      let minGap = Infinity, sum = 0, n = 0;
      for (let i = 0; i < GRID.bands; i++) {
        const ra = a.profile.right[i]!, lb = b.profile.left[i]!;
        if (!Number.isFinite(ra) || !Number.isFinite(lb)) continue;
        const ga = a.advance - ra, gb = lb;
        minGap = Math.min(minGap, ga + gb);
        sum += Math.min(ga, a.rsb + depthCap) + Math.min(gb, b.lsb + depthCap);
        n++;
      }
      return n >= 2 ? { eff: sum / n, minGap } : null;
    }
    
    /**
     * Kern a pair towards the gap the user's own straight-sided letters make (nn, HH, Hn, nH): a pair that
     * looks looser than that comes together, one that looks tighter opens up, and nothing ever touches.
     */
    export function kernValue(a: KernGlyph, b: KernGlyph, refs: ReferenceGaps, xHeight: number): number {
      const gap = pairGap(a, b, 0.6 * xHeight);
      if (!gap) return 0;
      const ref = refs[`${side(a.text.at(-1)!)}${side(b.text[0]!)}` as keyof ReferenceGaps];
      const floor = MIN_CLEARANCE - gap.minGap;
      let k = Math.min(MAX_KERN, Math.max(MIN_KERN, STRENGTH * (ref - gap.eff), floor));
      // Round to a multiple of 5 and drop tiny values, but never below the clearance floor.
      k = Math.round(k / 5) * 5;
      if (k < floor && k + 5 <= MAX_KERN) k += 5;
      return Math.abs(k) < 10 && floor <= 0 ? 0 : k;
    }

    The problem: “AV” and “To” look wrong at the same spacing as “nn”. A type designer kerns thousands of pairs by eye. This has to do it automatically, for letters nobody has seen before, on a phone, in well under a second.

    Why this approach: Each glyph’s left and right edges are scanned once into horizontal bands, and every pair is then compared with a reference: the gap between the user’s own “n”s or “H”s. So the kerning matches how tightly that person writes, not a fixed number. Measuring from those stored profiles instead of the outlines took kerning from 1.4 s to 0.16 s, and a 291-glyph font with 6,943 kerned class pairs now assembles in 155 ms on my desktop.

    What to notice: The depth cap. Without it, the open side of a “C” or “L” reads as a huge gap and drags the next letter inside it. Capping how deep a counter can count is what keeps “LT” from colliding.

    Open Handwriting Font Maker in my projects
  • Writing WOFF2 without the usual encoder

    kieron@portfolio:~/handwriting-font$ cat src/core/font/woff2.tsPS C:\Users\kieron\handwriting-font> Get-Content src\core\font\woff2.ts

    /** WOFF2's variable-length unsigned integer: 7 bits per byte, most significant first, high bit = more follows. */
    export function uintBase128(value: number): number[] {
      const out = [value & 0x7f];
      for (let v = value >>> 7; v > 0; v >>>= 7) out.unshift(0x80 | (v & 0x7f));
      return out;
    }
    
    /**
     * WOFF2 with the null transform on every table: a 48-byte header, a compact table directory and one
     * Brotli stream of the tables back to back. Skipping the optional glyf/loca transform costs a few
     * percent of size, and avoids the WebAssembly WOFF2 encoders, which all need `eval` (blocked by my CSP).
     */
    export function toWoff2(font: ArrayBuffer, brotli: (data: Uint8Array) => Uint8Array): Uint8Array {
      const tables = [...readTables(font)].sort(([a], [b]) => (a < b ? -1 : 1));
      // … moves loca straight after glyf, which decoders expect
    
      const directory: number[] = [];
      for (const [tag, data] of tables) {
        const known = KNOWN_TAGS.indexOf(tag);
        const transform = tag === 'glyf' || tag === 'loca' ? NULL_TRANSFORM_GLYF_LOCA : 0;
        directory.push((known >= 0 ? known : ARBITRARY_TAG) | transform);
        if (known < 0) for (let i = 0; i < 4; i++) directory.push(tag.charCodeAt(i));
        directory.push(...uintBase128(data.length));
      }
    
      // … copies the tables back to back into `raw`
      const compressed = brotli(raw);
    
      const headerSize = 48;
      const length = pad4(headerSize + directory.length + compressed.length);
      const out = new Uint8Array(length), dv = new DataView(out.buffer);
      dv.setUint32(0, 0x774f4632); // 'wOF2'
      dv.setUint32(4, isTrueType(font) ? 0x00010000 : 0x4f54544f);
      // … total length, table count, uncompressed size, compressed size
      out.set(directory, headerSize);
      out.set(compressed, headerSize + directory.length);
      return out;
    }

    The problem: WOFF2 is the format websites want, and every browser-ready encoder I tried is an Emscripten build that creates functions with eval. My Content Security Policy blocks that, and I wasn’t going to weaken it for one download button.

    Why this approach: I read the WOFF2 spec and noticed the clever part, the glyph-table transform, is optional. Writing the header and directory by hand, and sending the tables through one Brotli stream (brotli-wasm, which doesn’t need eval), gives a valid WOFF2 only 4.9% larger than Google’s encoder: 60,756 bytes against 57,940. OTS, fontTools and all three browser engines load it under the production CSP.

    What to notice: uintBase128. WOFF2 stores table lengths in a variable-length integer format, seven bits per byte, and that one loop is the whole encoder.

    Open Handwriting Font Maker in my projects
  • Finding a page's corners to a fraction of a pixel

    kieron@portfolio:~/handwriting-font$ cat src/core/photo/markers.tsPS C:\Users\kieron\handwriting-font> Get-Content src\core\photo\markers.ts

    /**
     * Sub-pixel corners. The detector's corners can be 2–4 px out on a blurry photo, and with only three
     * markers that error grows towards the far corner of the page. Each marker is a black square on white
     * paper, so find where the image crosses halfway from dark to light at points along each edge, fit a
     * line through them, and take the corners where neighbouring lines meet.
     */
    export function refineCorners(flat: Float32Array, W: number, H: number, corners: Point[]): Point[] {
      const lines: (Line | null)[] = corners.map((a, k) => {
        const b = corners[(k + 1) % 4]!;
        // … unit normal (nx, ny) pointing out of the marker, and how far to search either side of the edge
        const edge: Point[] = [];
        for (let i = 1; i <= 12; i++) {
          const t = 0.12 + (0.76 * i) / 13, px = a[0] + (b[0] - a[0]) * t, py = a[1] + (b[1] - a[1]) * t;
          const profile: number[] = [];
          for (let d = -reach; d <= reach; d += step) profile.push(sample(flat, W, H, px + nx * d, py + ny * d, 1));
          const inside = Math.min(...profile), outside = Math.max(...profile);
          if (outside - inside < 0.2) continue;
          const half = (inside + outside) / 2;
          // … the crossing of `half` nearest the detected edge, interpolated between samples → `found`
          if (found !== null) edge.push([px + nx * found, py + ny * found]);
        }
        return edge.length >= 6 ? fitLine(edge) : null;
      });
      return corners.map((c, k) => {
        const before = lines[(k + 3) % 4], after = lines[k];
        const p = before && after ? intersect(before, after) : null;
        return p && Math.hypot(p[0] - c[0], p[1] - c[1]) < 8 ? p : c;
      });
    }

    The problem: If you’d rather write on paper, you print a template, fill it in and photograph it. To cut out each letter, the photo has to be straightened precisely, from four printed markers. On a blurry or shadowed phone photo the marker detector’s corners are 2–4 px out, and when only three markers are found that error grew to 13.8 px at the far corner of the page. That’s enough to slice the tops off letters.

    Why this approach: Instead of trusting four noisy points, I use the whole edge. Twelve times along each side of the marker, it samples a brightness profile across the edge (on an image with the shadows already flattened) and finds where it crosses halfway from black to white. A least-squares line through those twelve crossings, intersected with its neighbour, gives a corner 0.5–1.5 px out. Every corner of every marker then goes into one least-squares homography to straighten the page.

    What to notice: The last line. A refined corner more than 8 px from the original is rejected, so a smudge or a finger near a marker can’t make things worse than the detector’s own guess.

    Open Handwriting Font Maker in my projects
  • Searching from both ends at once, and counting every shortest route

    kieron@portfolio:~/wikiracer$ cat pipeline/lib/bfs.mjsPS C:\Users\kieron\wikiracer> Get-Content pipeline\lib\bfs.mjs

    // Each step expands one side's whole frontier: the side whose frontier has fewer links to follow.
    // If no article had been reached by both sides before the step, every shortest route is longer
    // than (forward depth - 1) + backward depth, so the first step that meets gives
    // d = forward depth + backward depth. Each shortest route crosses the newly expanded level once,
    // so k = sum of cntF * cntB over the meeting articles.
    function search(s, t) {
      // … reset, and seed both sides with depth 0 and count 1
      while (front[0].length && front[1].length) {
        let cost0 = 0, cost1 = 0;
        for (const u of front[0]) cost0 += outOff[u + 1] - outOff[u];
        for (const u of front[1]) cost1 += inOff[u + 1] - inOff[u];
        const side = cost0 <= cost1 ? 0 : 1;
        const other = 1 - side;
        const { off, adj } = sides[side];
        const d = dep[side], c = cnt[side], depth = L[side] + 1;
        const next = [];
        for (const u of front[side]) {
          const cu = c[u];
          for (let k = off[u]; k < off[u + 1]; k++) {
            const v = adj[k];
            if (d[v] < 0) { d[v] = depth; c[v] = cu; next.push(v); /* … parent, for drawing the routes */ }
            else if (d[v] === depth) c[v] += cu;
          }
        }
        L[side] = depth;
        front[side] = next;
        const meet = [];
        for (const v of next) if (dep[other][v] >= 0) meet.push(v);
        if (meet.length) {
          let k = 0;
          for (const v of meet) {
            if (dep[other][v] !== L[other]) throw new Error(`BFS invariant: meeting depth ${dep[other][v]} != ${L[other]}`);
            k += cnt[0][v] * cnt[1][v];
          }
          return { d: L[0] + L[1], k, meet };
        }
      }
      return { d: Infinity, k: 0, meet: [] };
    }

    The problem: After each race I show the shortest route and how many shortest routes there were, across 7.2 million articles and 713 million links.

    Why this approach: Searching from one end grows by a factor of the average link count every step. Searching from both ends and meeting in the middle only goes half as deep on each side, which is the difference between millions of articles and thousands. Expanding whichever side has fewer links to follow keeps the two sides balanced.

    What to notice: Route counts come almost for free. Each article carries how many shortest ways reach it, and multiplying the two sides’ counts where they meet gives the total. The comment is the proof, and the throw checks it on every search.

    Open Wikiracer in my projects
  • Downloading only the bytes a search needs

    kieron@portfolio:~/wikiracer$ cat src/search/reader.tsPS C:\Users\kieron\wikiracer> Get-Content src\search\reader.ts

    async readGroups(table: SlotTable, groups: Group[], onGroup: (g: Group, bytes: Uint8Array) => void, ctx: ReadContext = {}): Promise<void> {
      const S = this.manifest.slotBytes;
      const waits: Promise<void>[] = [];
      const fresh: Group[] = [];
      for (const g of groups) {
        const key = `${table}:${g.slot}`;
        const hit = this.cacheGet(key);
        if (hit) { onGroup(g, hit); continue; }
        const p = this.pending.get(key);
        // another read is fetching it; if that read is cancelled, fetch it for this one
        if (p) waits.push(p.then((b) => onGroup(g, b), (e) => (isAbort(e) && !ctx.signal?.aborted ? this.readGroups(table, [g], onGroup, ctx) : Promise.reject(e))));
        else fresh.push(g);
      }
      fresh.sort((a, b) => a.slot - b.slot);
      const runs: Group[][] = [];
      for (const g of fresh) {
        const run = runs.at(-1);
        const last = run?.at(-1);
        if (run && last && last.slot + last.slots === g.slot && (g.slot + g.slots - run[0]!.slot) * S <= this.maxRunBytes) run.push(g);
        else runs.push([g]);
      }
      for (const run of runs) {
        const start = run[0]!.slot * S;
        const end = (run.at(-1)!.slot + run.at(-1)!.slots) * S;
        const fetched = this.fetchRange(table, start, end, ctx);
        for (const g of run) {
          const key = `${table}:${g.slot}`;
          const p = fetched.then((bytes) => {
            const own = bytes.slice(g.slot * S - start, (g.slot + g.slots) * S - start);
            this.cachePut(key, own);
            return own;
          });
          this.pending.set(key, p);
          void p.finally(() => this.pending.delete(key)).catch(() => undefined);
          waits.push(p.then((b) => onGroup(g, b)));
        }
      }
      await Promise.all(waits);
    }

    The problem: The link map is gigabytes of static files on free hosting, and there’s no search server. The browser has to find routes by downloading as little of it as possible.

    Why this approach: The map is stored in 2 KB slots, and HTTP Range requests fetch just the slots a search step needs. Nearby slots are merged into one request of up to 1 MB, slots already on their way are shared rather than fetched twice, and cached ones are handed over at once. A whole search reads about a megabyte.

    What to notice: If the read that started a fetch is cancelled, the other reads waiting on it don’t fail with it: each one fetches its slot for itself.

    Open Wikiracer in my projects
  • Renumbering 7 million articles so linked ones sit together

    kieron@portfolio:~/wikiracer$ cat pipeline/lib/renumber.mjsPS C:\Users\kieron\wikiracer> Get-Content pipeline\lib\renumber.mjs

    // BFS renumbering: roots by in-degree (desc, ties by graph id), each
    // component breadth-first over out-links then in-links in CSR order. Returns rank: graph id → map id.
    export function bfsRank({ N, outOff, out, inOff, inAdj }) {
      if (N >= 2 ** 23) throw new Error('N past 2^23: widen the degree sort key');
      const rank = new Int32Array(N).fill(-1);
      // key = (2^22 - in-degree) * 2^23 + id stays below 2^45, exact in a float64
      const byDeg = new Float64Array(N);
      for (let i = 0; i < N; i++) {
        const d = inOff[i + 1] - inOff[i];
        if (d >= 2 ** 22) throw new Error('in-degree past 2^22: widen the degree sort key');
        byDeg[i] = (2 ** 22 - d) * 2 ** 23 + i;
      }
      byDeg.sort();
      const queue = new Int32Array(N);
      let next = 0, head = 0, tail = 0;
      for (let r = 0; r < N; r++) {
        const root = byDeg[r] % 2 ** 23; // the low 23 bits hold the id
        if (rank[root] >= 0) continue;
        rank[root] = next++; queue[tail++] = root;
        while (head < tail) {
          const u = queue[head++];
          for (let k = outOff[u]; k < outOff[u + 1]; k++) { const v = out[k]; if (rank[v] < 0) { rank[v] = next++; queue[tail++] = v; } }
          for (let k = inOff[u]; k < inOff[u + 1]; k++) { const v = inAdj[k]; if (rank[v] < 0) { rank[v] = next++; queue[tail++] = v; } }
        }
      }
      if (next !== N) throw new Error(`BFS numbered ${next} of ${N}`);
      return rank;
    }

    The problem: Wikipedia’s own page ids are in roughly the order articles were created, so an article’s links point all over the file, and every search step would need many separate downloads.

    Why this approach: I renumber articles in breadth-first order from the most linked-to ones, so articles that link to each other get nearby numbers. Their lists then share the same slots (fewer Range requests), and the gaps between ids in each list get smaller, so the lists compress better too.

    What to notice: The sort key packs the degree and the id into one float64, below 2^45 so it’s exact. That lets Float64Array.sort() order 7.2 million articles natively, with no comparator function, and % 2**23 gets the id back out. The guards throw if the data ever outgrows the packing.

    Open Wikiracer in my projects
  • Multiplayer rooms that cost nothing while idle

    kieron@portfolio:~/wikiracer$ cat worker/course.tsPS C:\Users\kieron\wikiracer> Get-Content worker\course.ts

    // No timers: one alarm holds the next deadline, so the object can hibernate between messages
    export class Course extends DurableObject<Env> {
      constructor(ctx: DurableObjectState, env: Env) {
        super(ctx, env);
        // … load the room from SQLite storage
        // The runtime answers heartbeats without waking the object
        ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
      }
    
      // … save(room) writes the room's row and calls setAlarm(nextAlarm(room))
    
      async alarm(): Promise<void> {
        const now = Date.now();
        const room = this.room;
        if (!room) return;
        if (expired(room, now)) {
          for (const ws of this.ctx.getWebSockets()) {
            this.send(ws, { t: 'error', code: 'expired' });
            ws.close(CLOSE_EXPIRED, 'expired');
          }
          this.room = null;
          this.results = [];
          await this.ctx.storage.deleteAlarm();
          await this.ctx.storage.deleteAll();
          return;
        }
        if (phaseAt(room, now) === 'race') {
          const next = endRound(room, now);
          if (next.stage !== room.stage) {
            this.save(next);
            this.broadcast(room, next, now);
            return;
          }
        }
        await this.ctx.storage.setAlarm(nextAlarm(room));
      }
    }
    
    // The round's end while one runs, else expiry 30 days after the last activity
    export const nextAlarm = (r: Room) => (r.stage === 'round' ? r.startAt! + limitMs(r) : r.lastActivity + EXPIRY_MS);

    The problem: Friends race each other live in rooms, which needs a server holding open WebSockets, and the whole project has to stay on Cloudflare’s free tier.

    Why this approach: Each room is one Durable Object that hibernates between messages, so an idle room costs nothing, even with players connected. A setTimeout would keep it awake, so there are no timers: a single alarm holds whichever deadline is next, the round’s end or the room’s expiry.

    What to notice: Heartbeat pings are answered by the runtime itself without waking the object, and the expiry alarm deletes all of the room’s storage, so nothing is left behind.

    Open Wikiracer in my projects
  • Reading 11 GB of Wikipedia dumps without allocating per row

    kieron@portfolio:~/wikiracer$ cat pipeline/lib/sql.mjsPS C:\Users\kieron\wikiracer> Get-Content pipeline\lib\sql.mjs

    // Every line of a MediaWiki dump starting with "(" is a row. Fields are read in order
    // with a cursor, so nothing is allocated per row.
    // P is the cursor into the current buffer; S/E are the last string's raw (still escaped) bounds.
    export let P = 0, S = 0, E = 0;
    
    export async function forEachRow(file, onRow) {
      const gz = createReadStream(file, { highWaterMark: 1 << 23 }).pipe(createGunzip({ chunkSize: 1 << 23 }));
      let carry = null;
      for await (const chunk of gz) {
        const buf = carry ? Buffer.concat([carry, chunk]) : chunk;
        let start = 0;
        let nl;
        while ((nl = buf.indexOf(10, start)) !== -1) {
          if (buf[start] === 40) { P = start + 1; onRow(buf, nl); }
          start = nl + 1;
        }
        carry = start < buf.length ? Buffer.from(buf.subarray(start)) : null;
      }
      if (carry && carry[0] === 40) { P = 1; onRow(carry, carry.length); }
      // … returns the byte count
    }
    
    export function int(b) {
      let neg = false;
      if (b[P] === 45) { neg = true; P++; }
      let v = 0, c;
      while ((c = b[P]) >= 48 && c <= 57) { v = v * 10 + (c - 48); P++; }
      P++;
      return neg ? -v : v;
    }
    
    // Reads a quoted string into S/E. Returns false for NULL.
    export function str(b) {
      if (b[P] !== 39) { while (b[P] !== 44 && b[P] !== 41) P++; P++; S = E = 0; return false; }
      P++; S = P;
      for (;;) { const c = b[P]; if (c === 92) { P += 2; continue; } if (c === 39) break; P++; }
      E = P; P += 2;
      return true;
    }

    The problem: The map is rebuilt from 11 GB of compressed SQL dumps holding hundreds of millions of rows. Parsing each row into strings and objects would spend most of the time in the garbage collector.

    Why this approach: I never parse SQL at all. Each row is one line starting with (, so the reader walks the decompressed bytes in 8 MB chunks with a shared cursor, reading numbers digit by digit and strings as start/end positions into the buffer. A string only becomes a JavaScript string if the caller actually needs one.

    What to notice: A line that straddles two chunks is carried over and joined to the next one, so no row is ever split.

    Open Wikiracer in my projects
  • Shuffling 29^3200 pages so every one has exactly one shelf

    kieron@portfolio:~/every-page$ cat src/engine/feistel.tsPS C:\Users\kieron\every-page> Get-Content src\engine\feistel.ts

    /** Location digits to page digits. Each round: (L, R) → (R, L ⊞ F(R)), ⊞ = digit-wise addition mod 29. */
    export function scramble(digits: Uint8Array): Uint8Array {
      let left = digits.slice(0, HALF);
      let right = digits.slice(HALF, PAGE_LENGTH);
      for (let r = 0; r < ROUNDS; r++) {
        round(right, ROUND_KEYS[r]!, scratch);
        for (let i = 0; i < HALF; i++) {
          const s = left[i]! + scratch[i]!;
          left[i] = s >= RADIX ? s - RADIX : s;
        }
        [left, right] = [right, left];
      }
      // …join the halves
    }
    
    /** Page digits back to location digits: the same rounds, run backwards with subtraction. */
    export function unscramble(digits: Uint8Array): Uint8Array {
      let left = digits.slice(0, HALF);
      let right = digits.slice(HALF, PAGE_LENGTH);
      for (let r = ROUNDS - 1; r >= 0; r--) {
        round(left, ROUND_KEYS[r]!, scratch);
        for (let i = 0; i < HALF; i++) {
          const s = right[i]! - scratch[i]!;
          right[i] = s < 0 ? s + RADIX : s;
        }
        [left, right] = [right, left];
      }
      // …join the halves
    }

    The problem: The library has 29^3200 pages, a number 4,680 digits long, and I need a shuffle between shelf positions and pages that runs both ways, so searching for text is just running it backwards.

    Why this approach: The obvious choice, multiply by a big number and add another, is reversible but leaves a pattern: neighbouring pages repeated the same difference 66.7% of the time in my tests. A Feistel network over the digits themselves gets that down to 3.46%, which is what pure randomness gives (3.45%), and it works directly in base 29, with no cycle-walking and no BigInt inside the rounds.

    What to notice: round() is never inverted. A Feistel network is a bijection whatever the round function returns, so it can be a fast, messy hash. Undoing it only means subtracting the same noise in reverse order.

    Open Every Page in my projects
  • Camera flights that never corner or brake too hard

    kieron@portfolio:~/every-page$ cat src/scene/journey.tsPS C:\Users\kieron\every-page> Get-Content src\scene\journey.ts

    export function planPath(points: Vector3[], { vMax, aLat, aLong, vEnd }: Limits): Plan {
      const curve = new CatmullRomCurve3(points, false, 'centripetal');
      const length = curve.getLength();
      const ds = length / SAMPLES;
      const pts = Array.from({ length: SAMPLES + 1 }, (_, i) => curve.getPointAt(i / SAMPLES));
      const v = new Float64Array(SAMPLES + 1);
      // …two scratch vectors
      for (let i = 0; i <= SAMPLES; i++) {
        a.subVectors(pts[i]!, pts[Math.max(0, i - 1)]!);
        b.subVectors(pts[Math.min(SAMPLES, i + 1)]!, pts[i]!);
        const turn = a.lengthSq() > 0 && b.lengthSq() > 0 ? a.angleTo(b) : 0;
        const curvature = turn / Math.max(ds, 1e-6);
        v[i] = curvature > 1e-6 ? Math.min(vMax, Math.sqrt(aLat / curvature)) : vMax;
      }
      v[0] = Math.min(v[0]!, vEnd);
      v[SAMPLES] = Math.min(v[SAMPLES]!, vEnd);
      for (let i = 1; i <= SAMPLES; i++) v[i] = Math.min(v[i]!, Math.sqrt(v[i - 1]! ** 2 + 2 * aLong * ds));
      for (let i = SAMPLES - 1; i >= 0; i--) v[i] = Math.min(v[i]!, Math.sqrt(v[i + 1]! ** 2 + 2 * aLong * ds));
      const t = new Float64Array(SAMPLES + 1);
      for (let i = 1; i <= SAMPLES; i++) t[i] = t[i - 1]! + (2 * ds) / (v[i]! + v[i - 1]!);
      // …returns at(time), a binary search over t
    }

    The problem: Searching flies you through the library to your page, and a camera that whips round corners or slams to a stop makes people feel sick.

    Why this approach: Easing curves only shape time, not physics. Here every point on the path gets the top speed its curvature allows (v = √(a/κ)), then a forward pass and a backward pass cap acceleration and braking, the same way racing games plan a lap. A binary search over the top speed then makes every flight last eight seconds without ever breaking those limits.

    What to notice: The two one-line loops. Going forwards limits how fast you can speed up, going backwards limits how late you can brake, and together they give a smooth speed profile with no tuning.

    Open Every Page in my projects
  • Endless rooms with no per-frame work

    kieron@portfolio:~/every-page$ cat src/scene/shaders/library.vertPS C:\Users\kieron\every-page> Get-Content src\scene\shaders\library.vert

    uniform vec2 uRoomOffset; // whole rooms between world origin and the library's real coordinates
    
    void main() {
      mat4 m = modelMatrix * instanceMatrix;
      vec3 pos = position;
      #ifdef BOOKS
        // Every book's height and colour come from which room and slot it is in, so moving
        // the whole set of rooms by a whole room re-dresses it for free.
        vec3 origin = (m * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
        vec2 room = roomOf(origin) + uRoomOffset;
        float slot = float(gl_InstanceID - (gl_InstanceID / 640) * 640);
        float height = 0.40 + 0.14 * hash13(vec3(room, slot));
        pos.y = (pos.y + 0.5) * height;
        pos.z *= 0.88 + 0.12 * hash13(vec3(room.yx, slot + 7.0));
        vColor = spineColour(hash13(vec3(room + 0.37, slot * 1.3)));
        // …the searched-for book stands proud in its own colour
      #endif
      vec4 w = m * vec4(pos, 1.0);
      gl_Position = projectionMatrix * viewMatrix * w;
    }

    The problem: The library never ends, so as you walk, rooms have to be recycled ahead of you, and every one holds 640 books that each need a different height and colour.

    Why this approach: Rewriting 5,760 instance matrices and colours every time you crossed into a new room cost 10.6 ms on a throttled phone CPU, most of a frame. Working the look out in the vertex shader from the room’s coordinates means moving the whole block by one room in JavaScript is a single position change, and the books re-dress themselves.

    What to notice: uRoomOffset. The camera is periodically pulled back to the origin so floats stay precise, and this offset keeps the books’ “real” coordinates, so the same shelf always looks the same.

    Open Every Page in my projects
  • Showing how the library will read what you type

    kieron@portfolio:~/every-page$ cat src/engine/alphabet.tsPS C:\Users\kieron\every-page> Get-Content src\engine\alphabet.ts

    export function normalise(input: string): Normalised {
      let text = '';
      const changes: Change[] = [];
      const removed: string[] = [];
      let truncated = false;
      for (const ch of input) {
        let out: string;
        const lower = ch.toLowerCase();
        if (INDEX.has(lower) && lower !== ' ') out = lower;
        else if (TO_SPACE.test(ch)) out = ' ';
        else if (TO_STOP.test(ch)) out = '.';
        else if (DROPPED_QUIETLY.test(ch)) out = '';
        else if (FOLDS[ch] !== undefined) out = FOLDS[ch];
        else {
          const stripped = ch.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
          out = [...stripped].every((c) => INDEX.has(c)) ? stripped : '';
          if (out === '') {
            if (!removed.includes(ch)) removed.push(ch);
            changes.push({ kind: 'removed', char: ch });
            continue;
          }
        }
        // runs of spaces (often left behind by removed characters) collapse to one, and none lead
        if (out === ' ' && (text === '' || text.endsWith(' '))) continue;
        if (text.length + out.length > PAGE_LENGTH) { truncated = true; break; }
        // Lowercasing and dropping quote marks are expected, so they aren't reported
        if (out !== '' && out !== lower) changes.push({ kind: 'altered', from: ch, to: out, at: text.length });
        text += out;
      }
      if (text.endsWith(' ')) text = text.slice(0, -1);
      return { text, changes, removed, truncated };
    }

    The problem: The library only has 29 symbols, but people type capitals, accents, digits, emoji and smart quotes, and silently changing their words feels broken.

    Why this approach: Rather than reject input, every character is mapped the gentlest way it can be (é to e, ß to ss, ! to a full stop) and every change is recorded. The search card shows a live “will read as” line with the altered letters underlined and the removed ones listed, so you see exactly what the library will look for before you search.

    What to notice: It separates expected changes (lowercasing, quotes) from surprising ones, so the feedback only speaks up when it matters.

    Open Every Page in my projects
  • Share links fifty characters long for pages thousands of digits deep

    kieron@portfolio:~/every-page$ cat src/engine/links.tsPS C:\Users\kieron\every-page> Get-Content src\engine\links.ts

    export function formatLink({ anchor, offset, move }: Link): string {
      let hash: string;
      if (anchor.kind === 'query') {
        hash = 'q=' + anchor.text.replaceAll(' ', SPACE);
        if (anchor.variant) hash += `&k=${anchor.variant}`;
      } else if (anchor.kind === 'random') {
        hash = 'r=' + anchor.seed.toString(36);
      } else {
        const { hexagon, wall, shelf, volume, page } = anchor.location;
        hash = `a=${hexagon.toString(36)}.${wall + 1}.${shelf + 1}.${volume + 1}.${page + 1}`;
      }
      if (move) {
        hash += `&m=${move.east}.${move.up}`;
        if (move.book) hash += `.${move.book.wall + 1}.${move.book.shelf + 1}.${move.book.volume + 1}.${move.book.page + 1}`;
      }
      if (offset) hash += `&d=${offset}`;
      return '#' + hash;
    }

    The problem: A page’s address is a number about 3,000 characters long in base 36, far too long to paste into a chat.

    Why this approach: A link stores how you got somewhere instead of where it is: the words you searched for, or the seed of a random volume, plus small relative moves (galleries east, floors up, pages on). Opening it re-runs the search on your device and replays the moves, so the link is typically about 50 characters, and it lives after the # so it never reaches a server.

    What to notice: Moves are relative to the anchor, never a raw page difference. One floor up is a few characters in a link, but as a page difference it would be thousands of digits long.

    Open Every Page in my projects
  • Real weights, squeezed into a range the physics can handle

    kieron@portfolio:~/perfect-balance$ cat src/physics/world.tsPS C:\Users\kieron\perfect-balance> Get-Content src\physics\world.ts

    /** Heaviest body after the lock-in mass switch; only ratios matter because the beam is scripted. */
    const LOCK_MAX_MASS = 10;
    /** Real weights span 1000:1 and more; contacts stay stable at 50:1. */
    const LOCK_MASS_RATIO = 50;
    
    /**
     * Switches every body to its real weight for the reveal. A light pan under a heavy pile
     * pushes objects into its floor, so each pan is also made at least as heavy as its load.
     */
    lock(masses: LockMasses): void {
      const known = masses.filter(([id]) => this.bodies.has(id));
      if (known.length > 0) {
        const heaviest = Math.max(...known.map(([, kg]) => kg));
        const floor = heaviest / LOCK_MASS_RATIO;
        for (const [id, kg] of known) {
          const mass = (Math.max(kg, floor) / heaviest) * LOCK_MAX_MASS;
          this.setMass(this.bodies.get(id)!, mass);
        }
      }
      this.locked = true;
      this.updatePanMasses();
    }
    
    private updatePanMasses(): void {
      const load: Record<PanSide, number> = { left: 0, right: 0 };
      for (const body of this.bodies.values()) {
        const where = this.locateBody(body);
        if (where === 'left' || where === 'right') load[where] += 1 / body.GetMotionProperties().GetInverseMass();
      }
      for (const side of ['left', 'right'] as const) {
        const pan = this.pans[side];
        const target = Math.max(RIG.panBaseMass, load[side]);
        const current = 1 / pan.GetMotionProperties().GetInverseMass();
        if (Math.abs(current - target) > 1e-3) this.setMass(pan, target);
      }
    }

    The problem: A round can put an apple on the same scale as an ambulance. Physics engines handle heavy things resting on light things badly: stacks jitter, and objects sink through the pan.

    Why this approach: The beam’s verdict is worked out from the real weights, not simulated, so the physics only has to look right. At lock-in I scale the heaviest object to 10 and raise anything lighter than a fiftieth of it to that fiftieth. In testing, contacts stayed stable up to 50:1, and real weights in a round can span 1000:1 or more.

    What to notice: The pans get heavier too. A pan lighter than the pile on it lets objects push through its floor, so each pan is made at least as heavy as its load.

    Open Perfect Balance in my projects
  • Turning a 3D model into a few simple collision shapes

    kieron@portfolio:~/perfect-balance$ cat tools/models/build-models.tsPS C:\Users\kieron\perfect-balance> Get-Content tools\models\build-models.ts

    const MAX_PART_POINTS = 24;
    const MAX_PARTS = 4;
    const CUTS = 16;
    
    /** Up to four hulls: the model is cut along whichever axis-aligned plane leaves the least empty space, until another cut barely helps. */
    function compound(mesh: Mesh): number[][] {
      const triangle = (t: number) => [0, 1, 2].flatMap((k) => [...mesh.positions.subarray(mesh.indices[t + k]! * 3, mesh.indices[t + k]! * 3 + 3)]);
      const centroid = (t: number, axis: number) => (mesh.positions[mesh.indices[t]! * 3 + axis]! + mesh.positions[mesh.indices[t + 1]! * 3 + axis]! + mesh.positions[mesh.indices[t + 2]! * 3 + axis]!) / 3;
      const part = (tris: number[]) => ({ tris, volume: hullOf(tris.flatMap(triangle)).volume });
      const all = Array.from({ length: mesh.indices.length / 3 }, (_, i) => i * 3);
      let parts = [part(all)];
      const whole = parts[0]!.volume;
      while (parts.length < MAX_PARTS) {
        let best = { gain: 0.04 * whole, index: -1, halves: [] as ReturnType<typeof part>[] };
        parts.forEach((p, index) => {
          for (let axis = 0; axis < 3; axis++) {
            const along = p.tris.map((t) => centroid(t, axis));
            const [lo, hi] = [Math.min(...along), Math.max(...along)];
            for (let s = 1; s < CUTS; s++) {
              const at = lo + ((hi - lo) * s) / CUTS;
              const halves = [part(p.tris.filter((_, i) => along[i]! < at)), part(p.tris.filter((_, i) => along[i]! >= at))];
              // a cut that leaves a flat sliver can't stand alone as a hull
              if (halves.some((h) => h.volume < 1e-4)) continue;
              const gain = p.volume - halves[0]!.volume - halves[1]!.volume;
              if (gain > best.gain) best = { gain, index, halves };
            }
          }
        });
        if (best.index < 0) break;
        parts = [...parts.filter((_, k) => k !== best.index), ...best.halves];
      }
      return parts.map((p) => reduce(p.tris.flatMap(triangle), MAX_PART_POINTS));
    }

    The problem: Physics engines collide convex shapes quickly, but a chair, a bucket or a banana isn’t convex. Wrapping each in a single hull fills in the gaps, so things stack on air.

    Why this approach: At build time each model is split greedily. Every candidate cut (15 positions on each of the three axes) is scored by how much empty hull volume it removes, the best one is kept, and splitting stops at four parts or when no cut saves at least 4% of the whole. It runs once, offline, and the hulls are saved inside the model file, so the game does none of this work.

    What to notice: A cut that leaves a flat sliver is skipped, because a hull with almost no volume makes the physics unstable on its own.

    Open Perfect Balance in my projects
  • Proving every round can be won

    kieron@portfolio:~/perfect-balance$ cat src/game/generate.tsPS C:\Users\kieron\perfect-balance> Get-Content src\game\generate.ts

    /** Fine enough that 30 rounded weights stay well inside the window's width. */
    const RESOLUTION = 4000;
    
    /**
     * The proof a round can be won: the fewest objects for every total, as a bounded DP over all
     * inventory types (any number of each). A total found this way is re-checked with the real weights.
     */
    export function solve(inventory: readonly CatalogueEntry[], promptKg: number, { margin, maxItems } = DAILY_RULES): { entry: CatalogueEntry; count: number }[] | undefined {
      const top = Math.floor(margin.max * RESOLUTION);
      const weights = inventory.map((e) => Math.max(1, Math.round((e.weightKg / promptKg) * RESOLUTION)));
      const fewest = new Uint8Array(top + 1).fill(255);
      const via = new Int16Array(top + 1).fill(-1);
      fewest[0] = 0;
      for (let s = 1; s <= top; s++) {
        for (let i = 0; i < weights.length; i++) {
          const w = weights[i]!;
          if (w <= s && fewest[s - w]! + 1 < fewest[s]!) {
            fewest[s] = fewest[s - w]! + 1;
            via[s] = i;
          }
        }
      }
      for (let s = Math.ceil(margin.min * RESOLUTION); s <= top; s++) {
        if (fewest[s]! > maxItems) continue;
        const counts = new Map<number, number>();
        for (let t = s; t > 0; t -= weights[via[t]!]!) counts.set(via[t]!, (counts.get(via[t]!) ?? 0) + 1);
        const kg = [...counts].reduce((sum, [i, c]) => sum + inventory[i]!.weightKg * c, 0);
        if (verdictFor(kg / promptKg, margin) === 'balanced') return [...counts].map(([i, count]) => ({ entry: inventory[i]!, count }));
      }
      return undefined;
    }

    The problem: Every daily round is generated from a seed, and a round you can’t win would be a broken puzzle for everyone that day. “It’s probably winnable” isn’t good enough.

    Why this approach: Weights become whole numbers on a 4000-step scale relative to the target, which turns the question into the classic coin-change problem: for every total, the fewest objects that reach it. It runs on every round as it’s generated, and the back-pointers rebuild an actual winning mix.

    What to notice: Rounding can lie, so each winning total from the grid is re-checked against the real weights before the round counts as proven.

    Open Perfect Balance in my projects
  • A balance beam that acts out the result instead of simulating it

    kieron@portfolio:~/perfect-balance$ cat src/physics/beam.tsPS C:\Users\kieron\perfect-balance> Get-Content src\physics\beam.ts

    /**
     * The beam is kinematic: its angle is scripted from the result, never simulated, so the
     * verdict can't be undone by a jittery stack. Positive angles lower the right-hand pan.
     */
    /** About four swings, 3.8° then 2.7°, 1.2° and nothing, over 1.8 s. */
    export const TEETER = { amplitude: 4 * DEG, frequency: 1.1, seconds: 1.8 } as const;
    
    /** A clean miss drops fast; a near miss sinks slowly. `ratio` is total / prompt weight. */
    export function tipFor(ratio: number): BeamMotion {
      const severity = ratio <= 0 ? 1 : Math.min(1, Math.abs(Math.log(ratio)) / Math.LN2);
      return { kind: 'tip', direction: ratio > 1 ? 1 : -1, duration: 1.1 - 0.65 * severity };
    }
    
    export function beamAngle(motion: BeamMotion, t: number): number {
      switch (motion.kind) {
        // … locked, wobble, level
        case 'tip': {
          // accelerates like a falling arm, then stops dead on the rest pad
          const k = Math.min(1, t / motion.duration);
          return motion.direction * RIG.stopAngle * k * k;
        }
        case 'teeter': {
          // the swing dies away smoothly, so the beam is level and still when it ends
          if (t < motion.seconds) return motion.amplitude * 0.5 * (1 + Math.cos((Math.PI * t) / motion.seconds)) * Math.sin(2 * Math.PI * motion.frequency * t);
          if (!motion.tip) return 0;
          const k = Math.min(1, (t - motion.seconds) / motion.tip.duration);
          return motion.tip.direction * RIG.stopAngle * k * k;
        }
      }
    }

    The problem: The reveal is the whole game. If the beam were simulated, a pile settling a millimetre could tip a winning answer into a loss, and that would feel like cheating.

    Why this approach: The beam is moved by script from the verdict, while still being a physics body the piles react to. Every reveal teeters the same way, a win or a miss, so you can’t tell the outcome until it stops rocking. Then a miss tips, and how fast it drops depends on how far off you were: 0.45 s for a clean miss, 1.1 s for a near one.

    What to notice: The teeter fades out with a raised cosine rather than a decaying exponential, so it reaches exactly level and still at 1.8 s instead of trembling forever.

    Open Perfect Balance in my projects
  • Backup codes: a year of progress in one line you can paste

    kieron@portfolio:~/perfect-balance$ cat src/game/storage.tsPS C:\Users\kieron\perfect-balance> Get-Content src\game\storage.ts

    const toBase64Url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
    
    function fromBase64Url(code: string): Uint8Array {
      const binary = atob(code.replace(/-/g, '+').replace(/_/g, '/'));
      return Uint8Array.from(binary, (c) => c.charCodeAt(0));
    }
    
    async function pipe(bytes: Uint8Array, stream: CompressionStream | DecompressionStream): Promise<Uint8Array> {
      return new Uint8Array(await new Response(new Blob([bytes as BlobPart]).stream().pipeThrough(stream)).arrayBuffer());
    }
    
    /** All progress as one line of text that can be pasted into another browser. Compressed, as a year of results is long. */
    export async function backupCode(progress: Progress): Promise<string> {
      const bytes = await pipe(new TextEncoder().encode(JSON.stringify(progress)), new CompressionStream('deflate-raw'));
      let code = '';
      // in slices, so a long backup doesn't overflow the argument list
      for (let i = 0; i < bytes.length; i += 3 * 4096) code += toBase64Url(bytes.subarray(i, i + 3 * 4096));
      return code;
    }
    
    /** The progress in a backup code, or undefined if it isn't one. */
    export async function restoreCode(code: string): Promise<Progress | undefined> {
      try {
        const bytes = await pipe(fromBase64Url(code.replace(/\s+/g, '')), new DecompressionStream('deflate-raw'));
        return normalise(JSON.parse(new TextDecoder().decode(bytes)));
      } catch {
        return undefined;
      }
    }

    The problem: There are no accounts, so your streak lives in one browser’s storage. Switching to a new phone would lose it, and I didn’t want to run a server just for accounts.

    Why this approach: Progress is compressed with the browser’s built-in CompressionStream and turned into URL-safe base64: one line you can paste anywhere, with no library. Restoring runs it through the same field-by-field check as the saved data, so a mangled code just returns nothing instead of breaking the game.

    What to notice: The slices are 3 × 4096 bytes. A multiple of 3 encodes to base64 with no padding, so the pieces join into one valid code. Slicing at all is needed because spreading a big array into String.fromCharCode overflows the argument limit.

    Open Perfect Balance in my projects
  • Checking who's signed in before a route runs
    // Signed JSON Web Tokens decide who can reach each route
    const jwt = require("jsonwebtoken");
    const jwtSecret = process.env.JWT_SECRET;
    
    exports.adminAuth = (req, res, next) => {
      const token = req.cookies.jwt;
      if (token) {
        jwt.verify(token, jwtSecret, (err, decodedToken) => {
          if (err) {
            return res.status(401).json({ message: "Not authorized" });
          } else {
            if (decodedToken.role !== "admin") {
              return res.status(401).json({ message: "Not authorized" });
            } else {
              next();
            }
          }
        });
      } else {
        return res
          .status(401)
          .json({ message: "Not authorized, token not available" });
      }
    };
    
    // exports.userAuth is the same check for the "Basic" (customer) role

    The problem: NFC Savers has two kinds of user. Customers see their points and deals, and admins manage the shop, so every route has to know who’s asking before it does anything.

    Why this approach: When someone signs in, the server signs a token holding their role and keeps it in a cookie. Each protected route has a small piece of Express middleware in front of it that checks the signature and the role, so the check doesn’t need a database lookup on every request.

    What to notice: It fails closed. A missing token, a bad signature and the wrong role all end in a 401 before the route itself runs, and next() is only reached when everything checks out.

    Open Dissertation in my projects
  • Describing the shop's stock for MongoDB
    const Mongoose = require("mongoose");
    
    // Every stock item has the same shape, checked before it's saved
    const StockItemSchema = new Mongoose.Schema({
      name: {
        type: String,
        required: true
      },
      points: {
        type: String,
        required: true
      },
      price: {
        type: Number,
        required: true
      },
      quantity: {
        type: Number,
        required: true
      },
      stockId: {
        type: Number,
        required: false
      },
    }, { collection: "Stock" });
    
    const Stock = Mongoose.model("stock", StockItemSchema);
    
    module.exports = Stock;

    The problem: MongoDB will store any shape of document you give it, but the app needs every product to have a name, a price, a quantity and the points it earns, or the customer’s screen breaks.

    Why this approach: A Mongoose schema gives the collection the structure the database itself doesn’t enforce. Every item is checked against it before it’s saved, so a product with no price is rejected by the server instead of turning up half-empty on someone’s phone.

    What to notice: { collection: "Stock" } pins the collection’s name. Without it, Mongoose would quietly pluralise the model name and store everything in stocks.

    Open Dissertation in my projects
  • Checking an email address before saving images to it

    kieron@portfolio:~/js-array$ cat js/main.jsPS C:\Users\kieron\js-array> Get-Content js\main.js

    const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
    
    const emailValidationRules = {
      minLength: 5,
      maxLength: 254,
    };
    
    // The problem with an email, or null when it's fine
    function validateEmail(email) {
      if (!email || email.length < emailValidationRules.minLength ||
          email.length > emailValidationRules.maxLength) {
        return "Email must be between 5 and 254 characters";
      }
    
      if (!emailPattern.test(email)) {
        return "Please enter a valid email address";
      }
    
      return null;
    }
    
    emailForm.addEventListener('submit', function (event) {
      event.preventDefault();
      const email = emailInput.value.trim();
      const validationError = validateEmail(email);
    
      if (validationError) {
        showToast(validationError, true);
        return;
      }
    
      attachImageToEmail(email, images[currentIndex]);
    });

    The problem: The app fetches random photos and lets you save the ones you like against an email address, so the address has to be checked before anything is stored under it.

    Why this approach: Two cheap checks catch nearly every typo: a length limit (254 characters is the longest address the standards allow) and a pattern that needs something before the @, a domain and a dot. The checks live in one function that returns a message, so the form decides how to show it.

    What to notice: validateEmail returns the reason it failed rather than true or false, so the toast can tell you exactly what to fix.

  • One loop for every coloured menu link

    kieron@portfolio:~/netmatters-homepage$ cat scss/partials/utilities/_functions.scssPS C:\Users\kieron\netmatters-homepage> Get-Content scss\partials\utilities\_functions.scss

    // One colour per section
    $colors: (
      1: #526781,
      2: #4183D7,
      3: #2ECC71,
      4: #D64541,
      5: #926FB1,
      6: #F62459,
      7: #CE4125
    );
    
    @each $key, $color in $colors {
      .link-#{$key} {
        span {
          color: $color;
        }
        &:hover {
          background-color: $color;
          span {
            color: white;
          }
        }
        // the small triangle under the link
        &::after {
          border-top: 10px solid $color;
          color: $color;
        }
      }
    }

    The problem: The Netmatters homepage menu gives each section its own colour: the link text, its hover fill and the small triangle under it. Written by hand, that’s seven near-identical blocks of CSS that all have to change together.

    Why this approach: The colours live in one Sass map, and @each writes the rules for every entry in it. Adding a section, or changing a colour, is one line in the map.

    What to notice: #{$key} builds the class name from the map’s key, so .link-3 always gets colour 3, and the same map drives the services boxes elsewhere on the page.