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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.1.1] - unreleased

### Added
- **Pill Alignment tool.** New Tools-tab calculator for OTK-style eccentric
kingpin pills: set top/bottom pill size and rotation per side and read
camber/caster/track-width instantly, explore the full reachable
camber–caster envelope on an interactive scatter (drag the marker to solve
pill angles), and use "Find Setup" to get ranked pill combinations for a
target alignment — with hole snapping, toe entry + overhead toe visual, a
resultant-toe color mode, and a "load from session setup" shortcut. Chassis
constants live in a modular profile system — built-in brand profiles (OTK /
Tony Kart, Kart Republic, CompKart, Birel ART, Praga, Sodi; estimated until
measured) plus measurement helpers that turn dial-indicator and gauge
readings into constants you can save as named measured profiles. Works
offline and from the homepage Tools drawer.
- **Updates blog.** New public `/updates` page with articles from the team —
release notes, news, and engineering write-ups — each at its own
SEO-friendly `/updates/<slug>` URL. Posts support Markdown (headers, bold,
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ src/
├── plugins/ # ★ Plugin framework (auto-discovered) — see src/plugins/README.md
│ ├── (framework) # types, registry, index, panels, mounts, fileSources, storage + hosts
│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (→ docs/backend.md)
│ ├── tools/ # ★ First-party plugin: Tools tab (kart seat-position viz; phone Lap Timer)
│ ├── tools/ # ★ First-party plugin: Tools tab (kart seat-position viz; phone Lap Timer; pill alignment calculator — plan 0011)
│ └── coaching/ # Gitignored slot for the AI coach (npm pkg in production)
├── types/racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, …
├── contexts/ # SettingsContext, SessionContext, PlaybackContext, DeviceContext, AuthContext
Expand Down
104 changes: 104 additions & 0 deletions docs/plans/0011-kart-pill-alignment-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 0011 — Kart Pill Alignment Calculator (tools plugin)

## Goal / problem

Karts with OTK-style eccentric kingpin pills (Tony Kart and the CompKart /
Birel / Kart Republic 22 mm-bore dial clones) set front camber/caster by
rotating two eccentric pills per side. Getting from "I want −0.9° camber" to
"turn the top pill here, the bottom pill there" is two-link planar geometry
that people currently do by trial and error or with paid standalone
calculators. We already ship setup storage and telemetry — the calculator
belongs in the Tools tab, fully offline (it's a paddock tool with no signal).

The math was reverse-engineered from a reference app and hand-specced before
this plan (forward model, two-circle inverse, envelope sweep, calibration
constants). This plan records how it landed in the codebase.

## Approach & key decisions

- **Pure TypeScript, no Rust/WASM.** The reference spec suggested a Rust
crate; rejected here. The envelope sweep is ~32k trig evaluations (<5 ms in
JS, memoized, recomputed only when pill sizes or calibration change), and
this repo is TS-only — the sole WASM (xrk importer) exists for a binary
format, not speed. Math lives in pure `.ts` modules inside the tools plugin
so it lands in Vitest coverage scope (`components/**/*.tsx` is excluded).
- **Tools-plugin tool, not a page.** One `ToolDef` entry in
`src/plugins/tools/toolList.ts` surfaces the tool both in the in-session
Tools tab and the landing-page Tools drawer — no host/framework changes.
Follows the seat-position convention: pure modules + tests, thin `.tsx`.
- **Modular chassis-profile system, built for measurement.** Real
eccentricities (mm offset per dot count) are not published for any brand,
so calibration is a first-class `ChassisProfile` record (`profiles.ts`)
rather than hardcoded presets. Built-ins cover the common eccentric-pill
brands (Generic, OTK/Tony Kart, Kart Republic, CompKart, Birel ART, Praga,
Sodi), all flagged `source: "estimated"` — placeholders sharing the 22 mm
dial geometry until someone measures the real numbers. The workflow the
system is designed around: measure a chassis (the calibration panel has
helpers — dial-indicator sweep over 180° → e = sweep/2, and a zero-point
back-out that inverts the forward model from gauge readings at size-0
pills), then "save as measured profile" freezes the constants as a named
user profile (plugin store, `pill-alignment:profiles:v1`). Adding a newly
measured brand later = one entry in `BUILTIN_PROFILES`. Every constant
stays hand-editable; any edit detaches the active profile. A least-squares
multi-config fit is a future follow-up.
- **Angle convention.** Dial angle 0° = dot forward, positive toward
outboard, per corner — so identical dial settings on both sides produce
symmetric camber by default. A `mirrorRight` calibration flag negates
right-side angles for users whose physical reference is a fixed global
handedness (cos unchanged → caster identical; sin flips → lateral mirrors).
Tests lock the internal convention so UI semantics can flip via the flag
without touching math.
- **Drag never dead-ends.** Dragging the envelope setpoint outside the
reachable annulus `[|e_t−e_b|, e_t+e_b]` for the chosen pill sizes projects
the target radially onto the annulus boundary (`nearestAngles`), then picks
the two-circle elbow nearest the current angles — the marker always lands
somewhere physical.
- **Resultant-toe color mode is a labelled heuristic.** The reference app's
color modes are themselves heuristics per the spec. Per-point resultant toe
= user's static per-side toe + `toeCouplingMmPerMm · Δwheelbase(point)`,
coupling constant editable in calibration. Full 3D steer kinematics
deferred. Computed at draw time over the cached sweep so toe edits recolor
without re-sweeping.

## Touch points

```
src/plugins/tools/pill-alignment/
├── model.ts / model.test.ts types, calibration, forwardCorner, snapToHole, measurement helpers, persisted state
├── profiles.ts / profiles.test.ts chassis-profile system: built-in brands + user "measured" profile CRUD
├── inverse.ts / inverse.test.ts findSetups (two-circle Find Setup), nearestAngles (drag-solve)
├── envelope.ts / envelope.test.ts sweepEnvelope, singlePillLoci, color buckets + ramp
├── toe.ts / toe.test.ts tie-rod→toe, toe-mm, resolveSetupAlignmentFields (session setup read)
├── PillAlignmentTool.tsx state, plugin-store persistence, layout
├── EnvelopePlot.tsx two-layer canvas scatter (GGDiagram pattern), draggable setpoint
├── PillDial.tsx SVG hub dial ×4, drag-to-rotate, keyboard steps
├── OverheadToeView.tsx SVG overhead toe widget
├── FindSetupPanel.tsx target → ranked candidates → Apply
└── CalibrationPanel.tsx advanced constants + presets
src/plugins/tools/shared/ NumRow + Section lifted out of SeatPositionTool for reuse
src/plugins/tools/toolList.ts + pill-alignment entry (the only wiring)
src/plugins/tools/locales/*.json + pillAlignment.* / pill.* keys (all 7 languages)
CHANGELOG.md, CLAUDE.md user-facing entry + architecture-map line
```

Persistence: `getPluginStore("tools")`, key `"pill-alignment:v1"` —
calibration, presetId, per-side pills, linked L/R flag, active side, color
mode, hole-snap, toe inputs.

## Status / phasing

- **Phase 1 (done):** forward model, envelope plot with draggable setpoint +
single-pill loci, 4 SVG pill dials, readouts, Find Setup solver,
calibration panel with presets.
- **Phase 2 (done):** toe inputs (tie-rod or per-side), overhead toe SVG,
resultant-toe color mode, camber deg⇄mm readout, read-only "load from
session setup" (default-template ids `f-toe`/`f-camber`/`f-castor`,
name-matched fallback for custom templates).
- **Phase 3 (follow-up, not built):** simulated laser-board visualiser
("sniper"-style) — pure projection `beamX = d·tan(toe)`,
`beamY = d·tan(camber)` per side on an SVG board with graduations; one
`boardDistanceMm` state field. Do last, after the calculator has earned
trust against real gauges.
- **Future:** per-chassis least-squares calibration fit from measured
configs; telemetry link (stamp sessions with alignment, data-derived grip
coloring on the envelope).
95 changes: 95 additions & 0 deletions src/plugins/tools/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,100 @@
"statusRecording": "Aufnahme",
"statusEnded": "Beendet",
"statusWaiting": "Warten"
},
"pillAlignment": {
"name": "Exzenter-Ausrichtung",
"description": "Sturz- und Nachlaufrechner für Exzenterbuchsen mit Setup-Löser.",
"badge": "Experimentell"
},
"pill": {
"linkSides": "L/R koppeln",
"snapHoles": "Auf Löcher rasten",
"loadFromSetup": "Aus Session-Setup laden",
"sideLeft": "Links",
"sideRight": "Rechts",
"sideL": "L",
"sideR": "R",
"colorMode": {
"trackDelta": "Δ Spurweite",
"thetaTop": "Winkel oberer Exzenter",
"resultantToe": "Resultierende Spur"
},
"envelope": {
"xLabel": "Sturz (°)",
"yLabel": "Nachlauf (°)",
"dragHint": "Marker ziehen, um die Exzenterwinkel für diesen Sturz/Nachlauf zu lösen. Orange = Bahn des oberen Exzenters, Magenta = unterer."
},
"dial": {
"topLeft": "Links · Oben",
"topRight": "Rechts · Oben",
"bottomLeft": "Links · Unten",
"bottomRight": "Rechts · Unten",
"size": "Exzentergröße",
"aria": "Exzenterwinkel {{label}}"
},
"readout": {
"camber": "Sturz",
"caster": "Nachlauf",
"track": "Spur",
"totalTrack": "Gesamtänderung der Spurweite: {{value}} mm"
},
"toe": {
"title": "Spur",
"usePerSide": "Pro Seite eingeben",
"useRod": "Spurstangenänderung eingeben",
"rodDelta": "Δ Spurstange",
"rArm": "Lenkhebelradius",
"left": "Spur links",
"right": "Spur rechts",
"out": "NACHSPUR",
"in": "VORSPUR",
"neutral": "Neutrale Spur"
},
"findSetup": {
"title": "Setup finden",
"targetCamber": "Ziel-Sturz",
"targetCaster": "Ziel-Nachlauf",
"solve": "Exzenterpositionen finden",
"apply": "Übernehmen",
"residual": "Abweichung",
"noResults": "Dieses Ziel liegt außerhalb dessen, was diese Exzenter erreichen können. Weniger Sturz/Nachlauf oder größere Exzenter versuchen.",
"top": "Oben",
"bottom": "Unten"
},
"cal": {
"title": "Chassis-Kalibrierung",
"disclaimer": "Diese Konstanten sind ungefähre Vorgaben — echte Exzentrizitäten variieren je Chassis und sind nicht veröffentlicht. Vor dem Vertrauen in die Zahlen mit einer Sturz-/Nachlauflehre prüfen und Abweichendes anpassen.",
"h": "Bohrungsabstand H",
"lRim": "Messweite der Lehre",
"split": "Radhöhenanteil",
"neutralX": "Neutralversatz vorn",
"neutralY": "Neutralversatz außen",
"gamma0": "Werksseitiger Sturz",
"holeCount": "Löcher pro Exzenter",
"toeCoupling": "Spur-Kopplung",
"eccentricity": "Exzentrizität je Größe (e0 ist immer 0)",
"signCamber": "Sturz-Vorzeichen umkehren",
"signCaster": "Nachlauf-Vorzeichen umkehren",
"mirrorRight": "Winkel rechts spiegeln",
"profile": "Chassis-Profil",
"profileCustom": "Benutzerdefiniert (nicht gespeichert)",
"userProfiles": "Meine gemessenen Profile",
"builtinProfiles": "Integriert",
"estimated": "geschätzt",
"measured": "gemessen",
"profileName": "Profilname",
"profileNamePlaceholder": "z. B. Mein Praga — Juli 2026",
"saveProfile": "Als gemessenes Profil speichern",
"deleteProfile": "Profil löschen",
"measureTitle": "Messhilfen",
"sweep": "Messuhr-Weg über 180°",
"sweepSize": "Exzentergröße",
"applySweep": "e = Weg ÷ 2 setzen",
"zeroIntro": "Mit beiden Exzentern auf Größe 0 die Lehrenwerte eingeben, um die werksseitigen Versätze des Chassis zu bestimmen.",
"zeroCamber": "Gemessener Sturz",
"zeroCaster": "Gemessener Nachlauf",
"applyZero": "Nullpunkt setzen"
}
}
}
95 changes: 95 additions & 0 deletions src/plugins/tools/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,100 @@
"statusRecording": "Recording",
"statusEnded": "Ended",
"statusWaiting": "Waiting"
},
"pillAlignment": {
"name": "Pill Alignment",
"description": "Eccentric-pill camber & caster calculator with a Find Setup solver.",
"badge": "Experimental"
},
"pill": {
"linkSides": "Link L/R",
"snapHoles": "Snap to holes",
"loadFromSetup": "Load from session setup",
"sideLeft": "Left",
"sideRight": "Right",
"sideL": "L",
"sideR": "R",
"colorMode": {
"trackDelta": "Track width Δ",
"thetaTop": "Top pill angle",
"resultantToe": "Resultant toe"
},
"envelope": {
"xLabel": "Camber (°)",
"yLabel": "Caster (°)",
"dragHint": "Drag the marker to solve pill angles for that camber/caster. Orange = top pill locus, magenta = bottom pill locus."
},
"dial": {
"topLeft": "Left · Top",
"topRight": "Right · Top",
"bottomLeft": "Left · Bottom",
"bottomRight": "Right · Bottom",
"size": "Pill size",
"aria": "{{label}} pill angle"
},
"readout": {
"camber": "Camber",
"caster": "Caster",
"track": "Track",
"totalTrack": "Total track width change: {{value}} mm"
},
"toe": {
"title": "Toe",
"usePerSide": "Enter per side",
"useRod": "Enter tie-rod change",
"rodDelta": "Tie-rod Δ",
"rArm": "Steering-arm radius",
"left": "Left toe",
"right": "Right toe",
"out": "TOE OUT",
"in": "TOE IN",
"neutral": "Neutral toe"
},
"findSetup": {
"title": "Find Setup",
"targetCamber": "Target camber",
"targetCaster": "Target caster",
"solve": "Find pill positions",
"apply": "Apply",
"residual": "off by",
"noResults": "That target is outside what these pills can reach. Try a smaller camber/caster or larger pills.",
"top": "Top",
"bottom": "Bot"
},
"cal": {
"title": "Chassis calibration",
"disclaimer": "These constants are approximate defaults — real eccentricities vary by chassis and are not published. Verify against a camber/caster gauge before trusting the numbers, and edit anything that disagrees.",
"h": "Bore gap H",
"lRim": "Gauge span",
"split": "Wheel height fraction",
"neutralX": "Neutral offset fwd",
"neutralY": "Neutral offset out",
"gamma0": "Built-in camber",
"holeCount": "Holes per pill",
"toeCoupling": "Toe drift coupling",
"eccentricity": "Eccentricity per pill size (e0 is always 0)",
"signCamber": "Flip camber sign",
"signCaster": "Flip caster sign",
"mirrorRight": "Mirror right-side angles",
"profile": "Chassis profile",
"profileCustom": "Custom (unsaved)",
"userProfiles": "My measured profiles",
"builtinProfiles": "Built-in",
"estimated": "estimated",
"measured": "measured",
"profileName": "Profile name",
"profileNamePlaceholder": "e.g. My Praga — July 2026",
"saveProfile": "Save as measured profile",
"deleteProfile": "Delete profile",
"measureTitle": "Measurement helpers",
"sweep": "Indicator sweep over 180°",
"sweepSize": "Pill size",
"applySweep": "Set e = sweep ÷ 2",
"zeroIntro": "With both pills at size 0, enter the gauge readings to back out the chassis's built-in offsets.",
"zeroCamber": "Measured camber",
"zeroCaster": "Measured caster",
"applyZero": "Set zero point"
}
}
}
Loading
Loading