From 99c8e1e18f1e77f3a69abaa1fa9bfeedbb26cf95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:23:17 +0000 Subject: [PATCH 1/3] =?UTF-8?q?plan=200011:=20pill=20alignment=20math=20co?= =?UTF-8?q?re=20=E2=80=94=20forward=20model,=20inverse=20solver,=20envelop?= =?UTF-8?q?e=20sweep,=20toe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure TS geometry for the OTK-style eccentric-pill calculator: forwardCorner (pills → camber/caster/track), two-circle Find Setup solver with hole snapping and cost ranking, the reachable-envelope sweep with color bucketing, tie-rod toe conversions, and session-setup alignment-field resolution. All Vitest-covered, including forward↔inverse round-trip invariants. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D2gb7LNbhFeapCaKAn9tvH --- docs/plans/0011-kart-pill-alignment-tool.md | 94 +++++++ .../tools/pill-alignment/envelope.test.ts | 79 ++++++ src/plugins/tools/pill-alignment/envelope.ts | 130 ++++++++++ .../tools/pill-alignment/inverse.test.ts | 122 +++++++++ src/plugins/tools/pill-alignment/inverse.ts | 204 ++++++++++++++++ .../tools/pill-alignment/model.test.ts | 136 +++++++++++ src/plugins/tools/pill-alignment/model.ts | 231 ++++++++++++++++++ src/plugins/tools/pill-alignment/toe.test.ts | 78 ++++++ src/plugins/tools/pill-alignment/toe.ts | 103 ++++++++ 9 files changed, 1177 insertions(+) create mode 100644 docs/plans/0011-kart-pill-alignment-tool.md create mode 100644 src/plugins/tools/pill-alignment/envelope.test.ts create mode 100644 src/plugins/tools/pill-alignment/envelope.ts create mode 100644 src/plugins/tools/pill-alignment/inverse.test.ts create mode 100644 src/plugins/tools/pill-alignment/inverse.ts create mode 100644 src/plugins/tools/pill-alignment/model.test.ts create mode 100644 src/plugins/tools/pill-alignment/model.ts create mode 100644 src/plugins/tools/pill-alignment/toe.test.ts create mode 100644 src/plugins/tools/pill-alignment/toe.ts diff --git a/docs/plans/0011-kart-pill-alignment-tool.md b/docs/plans/0011-kart-pill-alignment-tool.md new file mode 100644 index 00000000..50379211 --- /dev/null +++ b/docs/plans/0011-kart-pill-alignment-tool.md @@ -0,0 +1,94 @@ +# 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`. +- **Editable calibration with approximate presets.** Real OTK eccentricities + (mm offset per dot count) are not published. Every chassis constant (H, + e[0..5], neutral offset n, γ0, L_rim, wheel-height fraction f, signs, hole + count) is user-editable in an advanced section; presets are labelled + "(approx)" and the tool carries an Experimental badge. A least-squares + "calibrate to my kart" wizard 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+presets, forwardCorner, snapToHole, persisted state +├── 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). diff --git a/src/plugins/tools/pill-alignment/envelope.test.ts b/src/plugins/tools/pill-alignment/envelope.test.ts new file mode 100644 index 00000000..627c4313 --- /dev/null +++ b/src/plugins/tools/pill-alignment/envelope.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CALIBRATION, DEFAULT_TOE, forwardCorner, type PillCalibration } from "./model"; +import { + ENVELOPE_BUCKET_COUNT, + colorBucket, + colorForBucket, + colorMetric, + singlePillLoci, + sweepEnvelope, +} from "./envelope"; + +const cal: PillCalibration = { ...DEFAULT_CALIBRATION }; + +describe("sweepEnvelope", () => { + it("emits (360/step)^2 points", () => { + expect(sweepEnvelope(cal, 3, 3, "left", 10)).toHaveLength(36 * 36); + }); + + it("every point is honest — refeeding its angles reproduces its coordinates", () => { + const points = sweepEnvelope(cal, 4, 2, "left", 30); + for (const p of points) { + const r = forwardCorner(cal, { sTop: 4, sBot: 2, thetaTopDeg: p.thetaTopDeg, thetaBotDeg: p.thetaBotDeg }, "left"); + expect(r.camberDeg).toBeCloseTo(p.camberDeg, 9); + expect(r.casterDeg).toBeCloseTo(p.casterDeg, 9); + } + }); + + it("camber stays within the analytic annulus bound", () => { + const points = sweepEnvelope(cal, 3, 3, "left", 6); + const bound = Math.atan2(cal.eMm[3] * 2, cal.hMm) * (180 / Math.PI) + 1e-6; + for (const p of points) { + expect(Math.abs(p.camberDeg)).toBeLessThanOrEqual(bound); + } + }); + + it("is deterministic", () => { + expect(sweepEnvelope(cal, 2, 5, "right", 20)).toEqual(sweepEnvelope(cal, 2, 5, "right", 20)); + }); +}); + +describe("singlePillLoci", () => { + it("closes both loops", () => { + const { top, bottom } = singlePillLoci(cal, 3, 3, "left", 10); + expect(top[0].x).toBeCloseTo(top[top.length - 1].x, 9); + expect(top[0].y).toBeCloseTo(top[top.length - 1].y, 9); + expect(bottom[0].x).toBeCloseTo(bottom[bottom.length - 1].x, 9); + }); +}); + +describe("color mapping", () => { + it("buckets clamp to the valid range", () => { + expect(colorBucket(-5, 0, 10)).toBe(0); + expect(colorBucket(15, 0, 10)).toBe(ENVELOPE_BUCKET_COUNT - 1); + expect(colorBucket(5, 0, 10)).toBe(Math.floor(0.5 * ENVELOPE_BUCKET_COUNT)); + }); + + it("degenerate extent maps everything to bucket 0", () => { + expect(colorBucket(3, 3, 3)).toBe(0); + }); + + it("every bucket yields a valid rgb() color, red end to green end", () => { + for (let b = 0; b < ENVELOPE_BUCKET_COUNT; b++) { + expect(colorForBucket(b)).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + } + const low = colorForBucket(0).match(/\d+/g)!.map(Number); + const high = colorForBucket(ENVELOPE_BUCKET_COUNT - 1).match(/\d+/g)!.map(Number); + expect(low[0]).toBeGreaterThan(low[1]); // red-dominant + expect(high[1]).toBeGreaterThan(high[0]); // green-dominant + }); + + it("resultantToe metric shifts with static toe and fore-aft coupling", () => { + const p = sweepEnvelope(cal, 3, 3, "left", 90)[1]; + const base = colorMetric(p, "resultantToe", cal, { ...DEFAULT_TOE, leftToeMm: -2 }, "left"); + const moreOut = colorMetric(p, "resultantToe", cal, { ...DEFAULT_TOE, leftToeMm: -3 }, "left"); + expect(moreOut).toBeCloseTo(base - 1, 9); + expect(colorMetric(p, "trackDelta", cal, DEFAULT_TOE, "left")).toBe(p.trackDeltaMm); + expect(colorMetric(p, "thetaTop", cal, DEFAULT_TOE, "left")).toBe(p.thetaTopDeg); + }); +}); diff --git a/src/plugins/tools/pill-alignment/envelope.ts b/src/plugins/tools/pill-alignment/envelope.ts new file mode 100644 index 00000000..2f20d879 --- /dev/null +++ b/src/plugins/tools/pill-alignment/envelope.ts @@ -0,0 +1,130 @@ +// Envelope sweep for the camber/caster scatter plot (plan 0011). +// +// For a fixed pair of pill sizes, sweep both pill angles over a grid and +// forward-map each combination. ~32k trig evals at the default 2° step — +// cheap enough to run synchronously in JS; the component memoizes on +// (calibration, sizes, side) so it only reruns when those change. + +import { + forwardCorner, + type CornerPills, + type PillCalibration, + type PillSize, + type Side, + type ToeState, + type EnvelopeColorMode, +} from "./model"; +import { effectiveToeMm } from "./toe"; + +export interface EnvelopePoint { + camberDeg: number; + casterDeg: number; + thetaTopDeg: number; + thetaBotDeg: number; + trackDeltaMm: number; + wheelbaseDeltaMm: number; +} + +export function sweepEnvelope( + cal: PillCalibration, + sTop: PillSize, + sBot: PillSize, + side: Side, + stepDeg = 2, +): EnvelopePoint[] { + const n = Math.max(1, Math.round(360 / stepDeg)); + const points: EnvelopePoint[] = new Array(n * n); + const pills: CornerPills = { sTop, sBot, thetaTopDeg: 0, thetaBotDeg: 0 }; + let i = 0; + for (let a = 0; a < n; a++) { + pills.thetaTopDeg = a * stepDeg; + for (let b = 0; b < n; b++) { + pills.thetaBotDeg = b * stepDeg; + const r = forwardCorner(cal, pills, side); + points[i++] = { + camberDeg: r.camberDeg, + casterDeg: r.casterDeg, + thetaTopDeg: pills.thetaTopDeg, + thetaBotDeg: pills.thetaBotDeg, + trackDeltaMm: r.trackDeltaMm, + wheelbaseDeltaMm: r.wheelbaseDeltaMm, + }; + } + } + return points; +} + +/** The two single-pill loci circles: rotate one pill, hold the other at 0°. */ +export function singlePillLoci( + cal: PillCalibration, + sTop: PillSize, + sBot: PillSize, + side: Side, + stepDeg = 4, +): { top: Array<{ x: number; y: number }>; bottom: Array<{ x: number; y: number }> } { + const n = Math.max(1, Math.round(360 / stepDeg)) + 1; // +1 closes the loop + const top: Array<{ x: number; y: number }> = new Array(n); + const bottom: Array<{ x: number; y: number }> = new Array(n); + for (let i = 0; i < n; i++) { + const theta = i * stepDeg; + const rt = forwardCorner(cal, { sTop, sBot, thetaTopDeg: theta, thetaBotDeg: 0 }, side); + const rb = forwardCorner(cal, { sTop, sBot, thetaTopDeg: 0, thetaBotDeg: theta }, side); + top[i] = { x: rt.camberDeg, y: rt.casterDeg }; + bottom[i] = { x: rb.camberDeg, y: rb.casterDeg }; + } + return { top, bottom }; +} + +// --------------------------------------------------------------------------- +// Color metric — points are bucketed so the canvas pays ≤ BUCKET_COUNT +// fillStyle changes for the whole cloud. +// --------------------------------------------------------------------------- + +export const ENVELOPE_BUCKET_COUNT = 20; + +/** The metric value a point contributes under a color mode. */ +export function colorMetric(p: EnvelopePoint, mode: EnvelopeColorMode, cal: PillCalibration, toe: ToeState, side: Side): number { + switch (mode) { + case "trackDelta": + return p.trackDeltaMm; + case "thetaTop": + return p.thetaTopDeg; + case "resultantToe": + // Heuristic: static toe plus a linear drift with fore-aft kingpin shift. + return effectiveToeMm(toe, side, cal) + cal.toeCouplingMmPerMm * p.wheelbaseDeltaMm; + } +} + +/** Quantize a metric value into a bucket index [0, ENVELOPE_BUCKET_COUNT). */ +export function colorBucket(value: number, min: number, max: number): number { + if (!(max > min)) return 0; + const ratio = (value - min) / (max - min); + return Math.min(ENVELOPE_BUCKET_COUNT - 1, Math.max(0, Math.floor(ratio * ENVELOPE_BUCKET_COUNT))); +} + +/** + * Bucket → CSS color, red→yellow→green (low→high), matching the reference + * app's resultant-toe ramp. Same 3-stop piecewise-linear shape as + * lib/speedHeatmap's getSpeedColor, re-derived here because that one is + * speed-typed and direction-reversed. + */ +export function colorForBucket(bucket: number): string { + const ratio = ENVELOPE_BUCKET_COUNT <= 1 ? 0 : bucket / (ENVELOPE_BUCKET_COUNT - 1); + let r: number; + let g: number; + let b: number; + if (ratio < 0.5) { + // red (200,40,40) → yellow (230,200,60) + const t = ratio / 0.5; + r = Math.round(200 + 30 * t); + g = Math.round(40 + 160 * t); + b = Math.round(40 + 20 * t); + } else { + // yellow (230,200,60) → green (60,170,70) + const t = (ratio - 0.5) / 0.5; + r = Math.round(230 - 170 * t); + g = Math.round(200 - 30 * t); + b = Math.round(60 + 10 * t); + } + return `rgb(${r},${g},${b})`; +} diff --git a/src/plugins/tools/pill-alignment/inverse.test.ts b/src/plugins/tools/pill-alignment/inverse.test.ts new file mode 100644 index 00000000..7b33551b --- /dev/null +++ b/src/plugins/tools/pill-alignment/inverse.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CALIBRATION, forwardCorner, normalizeDeg, type CornerPills, type PillCalibration } from "./model"; +import { DEFAULT_SOLVE_OPTIONS, findSetups, nearestAngles, type SolveOptions } from "./inverse"; + +const cal = (over: Partial = {}): PillCalibration => ({ + ...DEFAULT_CALIBRATION, + ...over, +}); + +const noSnap: SolveOptions = { ...DEFAULT_SOLVE_OPTIONS, snapHoles: false }; + +describe("findSetups", () => { + it("round-trips targets sampled from the forward model", () => { + const c = cal({ nXMm: 0.4, nYMm: -0.2, gamma0Deg: -0.4 }); + const samples: CornerPills[] = [ + { sTop: 3, sBot: 3, thetaTopDeg: 30, thetaBotDeg: 200 }, + { sTop: 5, sBot: 2, thetaTopDeg: 300, thetaBotDeg: 45 }, + { sTop: 1, sBot: 4, thetaTopDeg: 120, thetaBotDeg: 350 }, + ]; + for (const p of samples) { + const target = forwardCorner(c, p, "left"); + const [best] = findSetups(c, { camberDeg: target.camberDeg, casterDeg: target.casterDeg }, "left", noSnap); + expect(best).toBeDefined(); + expect(best.residualDeg).toBeLessThan(0.05); + const check = forwardCorner(c, best.pills, "left"); + expect(check.camberDeg).toBeCloseTo(target.camberDeg, 2); + expect(check.casterDeg).toBeCloseTo(target.casterDeg, 2); + } + }); + + it("hole-snapped solutions land on the hole grid with a bounded residual", () => { + const c = cal(); + const target = forwardCorner(c, { sTop: 4, sBot: 2, thetaTopDeg: 100, thetaBotDeg: 10 }, "left"); + const sols = findSetups(c, { camberDeg: target.camberDeg, casterDeg: target.casterDeg }, "left", { + ...DEFAULT_SOLVE_OPTIONS, + snapHoles: true, + }); + expect(sols.length).toBeGreaterThan(0); + const step = 360 / c.holeCount; + for (const s of sols) { + expect(normalizeDeg(s.pills.thetaTopDeg) % step).toBeCloseTo(0, 6); + expect(normalizeDeg(s.pills.thetaBotDeg) % step).toBeCloseTo(0, 6); + } + }); + + it("returns nothing for a target far outside the reachable envelope", () => { + const sols = findSetups(cal(), { camberDeg: 45, casterDeg: 0 }, "left", noSnap); + expect(sols).toHaveLength(0); + }); + + it("solves the one-pill degenerate cases", () => { + const c = cal(); + // Only the top pill offset: e.g. size 3 top at 90° with bottom size 0. + const topOnly = forwardCorner(c, { sTop: 3, sBot: 0, thetaTopDeg: 90, thetaBotDeg: 0 }, "left"); + const solsTop = findSetups(c, { camberDeg: topOnly.camberDeg, casterDeg: topOnly.casterDeg }, "left", noSnap); + expect(solsTop.some((s) => s.residualDeg < 0.05)).toBe(true); + const botOnly = forwardCorner(c, { sTop: 0, sBot: 3, thetaTopDeg: 0, thetaBotDeg: 45 }, "left"); + const solsBot = findSetups(c, { camberDeg: botOnly.camberDeg, casterDeg: botOnly.casterDeg }, "left", noSnap); + expect(solsBot.some((s) => s.residualDeg < 0.05)).toBe(true); + }); + + it("raising the OEM weight never prefers bigger pills at equal residual", () => { + const c = cal(); + const target = { camberDeg: 0, casterDeg: 0 }; // exactly reachable by any equal pair at equal angles + const oemHeavy = findSetups(c, target, "left", { ...noSnap, weights: { track: 0, oem: 10 } }); + const best = oemHeavy[0]; + expect(best.pills.sTop + best.pills.sBot).toBe(0); + }); + + it("mirrorRight solutions still verify through the forward model on the right", () => { + const c = cal({ mirrorRight: true }); + const p: CornerPills = { sTop: 4, sBot: 1, thetaTopDeg: 77, thetaBotDeg: 300 }; + const target = forwardCorner(c, p, "right"); + const [best] = findSetups(c, { camberDeg: target.camberDeg, casterDeg: target.casterDeg }, "right", noSnap); + expect(best.residualDeg).toBeLessThan(0.05); + }); +}); + +describe("nearestAngles", () => { + const current: CornerPills = { sTop: 3, sBot: 3, thetaTopDeg: 0, thetaBotDeg: 0 }; + + it("hits an in-envelope target exactly", () => { + const c = cal(); + const goal = forwardCorner(c, { sTop: 3, sBot: 3, thetaTopDeg: 60, thetaBotDeg: 240 }, "left"); + const solved = nearestAngles(c, current, { camberDeg: goal.camberDeg, casterDeg: goal.casterDeg }, "left", false); + const check = forwardCorner(c, solved, "left"); + expect(check.camberDeg).toBeCloseTo(goal.camberDeg, 4); + expect(check.casterDeg).toBeCloseTo(goal.casterDeg, 4); + }); + + it("projects an out-of-reach target onto the envelope boundary", () => { + const c = cal(); + const solved = nearestAngles(c, current, { camberDeg: 30, casterDeg: 0 }, "left", false); + const check = forwardCorner(c, solved, "left"); + // Max lateral offset = e[3]+e[3] = 3 mm → max camber = atan(3/110). + const maxCamber = Math.atan2(2 * c.eMm[3], c.hMm) * (180 / Math.PI); + expect(check.camberDeg).toBeCloseTo(maxCamber, 3); + expect(Number.isFinite(check.casterDeg)).toBe(true); + }); + + it("keeps the dead pill's angle when one size is 0", () => { + const c = cal(); + const cur: CornerPills = { sTop: 3, sBot: 0, thetaTopDeg: 10, thetaBotDeg: 45 }; + const solved = nearestAngles(c, cur, { camberDeg: 0.5, casterDeg: 0.2 }, "left", false); + expect(solved.thetaBotDeg).toBe(45); + }); + + it("returns current settings unchanged when both pills are size 0", () => { + const c = cal(); + const cur: CornerPills = { sTop: 0, sBot: 0, thetaTopDeg: 0, thetaBotDeg: 0 }; + expect(nearestAngles(c, cur, { camberDeg: 1, casterDeg: 1 }, "left", false)).toBe(cur); + }); + + it("snaps to the hole grid when requested", () => { + const c = cal(); + const goal = forwardCorner(c, { sTop: 3, sBot: 3, thetaTopDeg: 61, thetaBotDeg: 239 }, "left"); + const solved = nearestAngles(c, current, { camberDeg: goal.camberDeg, casterDeg: goal.casterDeg }, "left", true); + const step = 360 / c.holeCount; + expect(normalizeDeg(solved.thetaTopDeg) % step).toBeCloseTo(0, 6); + expect(normalizeDeg(solved.thetaBotDeg) % step).toBeCloseTo(0, 6); + }); +}); diff --git a/src/plugins/tools/pill-alignment/inverse.ts b/src/plugins/tools/pill-alignment/inverse.ts new file mode 100644 index 00000000..ea3f17cf --- /dev/null +++ b/src/plugins/tools/pill-alignment/inverse.ts @@ -0,0 +1,204 @@ +// Pill alignment inverse solvers (plan 0011). +// +// Both solvers reduce to a two-circle (two-link) intersection: the pills must +// jointly contribute the horizontal kingpin delta R = D*(target) − n, where the +// top pill adds e_t·û(θ_t) and the bottom subtracts e_b·û(θ_b). Feasible iff +// |R| lies inside the annulus [|e_t−e_b|, e_t+e_b]; inside it there are two +// "elbow" solutions. + +import { + forwardCorner, + localRadToDial, + normalizeDeg, + pillContribution, + snapToHole, + PILL_SIZES, + type CornerPills, + type CornerResult, + type PillCalibration, + type PillSize, + type Side, +} from "./model"; + +export interface SetupTarget { + camberDeg: number; + /** Omitted → hold the current caster (solver substitutes it). */ + casterDeg: number; +} + +export interface SolveOptions { + snapHoles: boolean; + /** Cost weights: residual is always weight 1. */ + weights: { track: number; oem: number }; + topN: number; +} + +export const DEFAULT_SOLVE_OPTIONS: SolveOptions = { + snapHoles: true, + weights: { track: 0.3, oem: 0.05 }, + topN: 8, +}; + +export interface SetupCandidate { + pills: CornerPills; + result: CornerResult; + /** Achieved-vs-target distance in (camber, caster) degrees. */ + residualDeg: number; + cost: number; +} + +/** Tolerance (mm) for the degenerate one-pill cases where |R| must equal e exactly. */ +const ONE_PILL_TOL_MM = 0.05; + +/** + * Two-circle intersection in the local frame. Returns candidate + * [thetaTopRad, thetaBotRad] pairs (0–2 solutions, or the degenerate forms). + */ +function intersectLocal(et: number, eb: number, rX: number, rY: number): Array<[number, number]> { + const rMag = Math.hypot(rX, rY); + const phiR = Math.atan2(rY, rX); + + if (et === 0 && eb === 0) { + return rMag < 1e-6 ? [[0, 0]] : []; + } + if (eb === 0) { + return Math.abs(rMag - et) < ONE_PILL_TOL_MM ? [[phiR, 0]] : []; + } + if (et === 0) { + // −e_b·û(θ_b) must equal R → θ_b points opposite R. + return Math.abs(rMag - eb) < ONE_PILL_TOL_MM ? [[0, phiR + Math.PI]] : []; + } + + const c = (et * et - eb * eb - rMag * rMag) / (2 * eb); + if (rMag < 1e-9 || Math.abs(c / rMag) > 1) return []; + const out: Array<[number, number]> = []; + for (const s of [1, -1]) { + const tb = phiR + s * Math.acos(c / rMag); + const tt = Math.atan2(rY + eb * Math.sin(tb), rX + eb * Math.cos(tb)); + out.push([tt, tb]); + } + return out; +} + +/** + * Find Setup: rank every feasible pill combination for a camber/caster target. + * Hole-snap happens BEFORE scoring, so residuals reflect what the user can + * physically set. + */ +export function findSetups( + cal: PillCalibration, + target: SetupTarget, + side: Side, + opts: SolveOptions = DEFAULT_SOLVE_OPTIONS, +): SetupCandidate[] { + const { rX, rY } = pillContribution(cal, target.camberDeg, target.casterDeg); + + const seen = new Set(); + const sols: SetupCandidate[] = []; + + for (const sTop of PILL_SIZES) { + for (const sBot of PILL_SIZES) { + for (const [ttLocal, tbLocal] of intersectLocal(cal.eMm[sTop], cal.eMm[sBot], rX, rY)) { + let thetaTopDeg = localRadToDial(cal, side, ttLocal); + let thetaBotDeg = localRadToDial(cal, side, tbLocal); + if (opts.snapHoles) { + thetaTopDeg = snapToHole(thetaTopDeg, cal.holeCount); + thetaBotDeg = snapToHole(thetaBotDeg, cal.holeCount); + } + // Concentric pills contribute nothing — pin their angle to 0 so + // equivalent solutions dedupe. + if (cal.eMm[sTop] === 0) thetaTopDeg = 0; + if (cal.eMm[sBot] === 0) thetaBotDeg = 0; + + const key = `${sTop}/${sBot}/${thetaTopDeg.toFixed(1)}/${thetaBotDeg.toFixed(1)}`; + if (seen.has(key)) continue; + seen.add(key); + + const pills: CornerPills = { sTop, sBot, thetaTopDeg, thetaBotDeg }; + const result = forwardCorner(cal, pills, side); + const residualDeg = Math.hypot( + result.camberDeg - target.camberDeg, + result.casterDeg - target.casterDeg, + ); + const cost = + residualDeg + + opts.weights.track * Math.abs(result.trackDeltaMm) + + opts.weights.oem * (sTop + sBot); + sols.push({ pills, result, residualDeg, cost }); + } + } + } + + return sols.sort((a, b) => a.cost - b.cost).slice(0, opts.topN); +} + +/** Shortest signed angular distance a→b in degrees (−180, 180]. */ +function angDistDeg(a: number, b: number): number { + const d = normalizeDeg(b - a); + return d > 180 ? d - 360 : d; +} + +/** + * Drag-solve: with pill sizes FIXED, the angles that get closest to a target. + * An out-of-reach target is projected radially onto the reachable annulus, so + * this never fails — the envelope marker always lands somewhere physical. + * Of the two elbow solutions, prefers the one nearest the current angles. + */ +export function nearestAngles( + cal: PillCalibration, + current: CornerPills, + target: { camberDeg: number; casterDeg: number }, + side: Side, + snapHoles: boolean, +): CornerPills { + const et = cal.eMm[current.sTop]; + const eb = cal.eMm[current.sBot]; + if (et === 0 && eb === 0) return current; + + let { rX, rY } = pillContribution(cal, target.camberDeg, target.casterDeg); + let rMag = Math.hypot(rX, rY); + + const rMin = Math.abs(et - eb); + const rMax = et + eb; + const clamped = Math.min(Math.max(rMag, rMin), rMax); + if (clamped !== rMag) { + if (rMag < 1e-9) { + // Zero target vector but the annulus excludes zero: keep the current + // direction so the projection is stable instead of jumping to +x. + const cur = forwardCorner(cal, current, side); + const phi = Math.atan2(cur.dYMm - cal.nYMm, cur.dXMm - cal.nXMm); + rX = clamped * Math.cos(phi); + rY = clamped * Math.sin(phi); + } else { + rX = (rX / rMag) * clamped; + rY = (rY / rMag) * clamped; + } + rMag = clamped; + } + + const candidates = intersectLocal(et, eb, rX, rY); + if (candidates.length === 0) { + // Boundary numerics: land exactly on the elbow-straight configuration. + const phi = Math.atan2(rY, rX); + candidates.push(rMag >= rMax - 1e-9 ? [phi, phi + Math.PI] : [phi, phi]); + } + + let best: CornerPills = current; + let bestDist = Infinity; + for (const [ttLocal, tbLocal] of candidates) { + let thetaTopDeg = et === 0 ? current.thetaTopDeg : localRadToDial(cal, side, ttLocal); + let thetaBotDeg = eb === 0 ? current.thetaBotDeg : localRadToDial(cal, side, tbLocal); + if (snapHoles) { + thetaTopDeg = snapToHole(thetaTopDeg, cal.holeCount); + thetaBotDeg = snapToHole(thetaBotDeg, cal.holeCount); + } + const dist = + Math.abs(angDistDeg(current.thetaTopDeg, thetaTopDeg)) + + Math.abs(angDistDeg(current.thetaBotDeg, thetaBotDeg)); + if (dist < bestDist) { + bestDist = dist; + best = { ...current, thetaTopDeg, thetaBotDeg }; + } + } + return best; +} diff --git a/src/plugins/tools/pill-alignment/model.test.ts b/src/plugins/tools/pill-alignment/model.test.ts new file mode 100644 index 00000000..fb0ae233 --- /dev/null +++ b/src/plugins/tools/pill-alignment/model.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_CALIBRATION, + forwardCorner, + holeIndex, + normalizeDeg, + pillContribution, + snapToHole, + type CornerPills, + type PillCalibration, +} from "./model"; + +const cal = (over: Partial = {}): PillCalibration => ({ + ...DEFAULT_CALIBRATION, + ...over, +}); + +const pills = (over: Partial = {}): CornerPills => ({ + sTop: 3, + sBot: 3, + thetaTopDeg: 0, + thetaBotDeg: 0, + ...over, +}); + +describe("forwardCorner", () => { + it("concentric pills reproduce only the built-in geometry", () => { + const c = cal({ gamma0Deg: -0.4, nXMm: 1, nYMm: -0.5 }); + const r = forwardCorner(c, pills({ sTop: 0, sBot: 0 }), "left"); + expect(r.camberDeg).toBeCloseTo(-0.4 + Math.atan2(-0.5, c.hMm) * (180 / Math.PI), 6); + expect(r.casterDeg).toBeCloseTo(Math.atan2(-1, c.hMm) * (180 / Math.PI), 6); + expect(r.trackDeltaMm).toBeCloseTo(0, 9); + expect(r.wheelbaseDeltaMm).toBeCloseTo(0, 9); + }); + + it("angle is irrelevant for a size-0 pill", () => { + const a = forwardCorner(cal(), pills({ sTop: 0, thetaTopDeg: 0 }), "left"); + const b = forwardCorner(cal(), pills({ sTop: 0, thetaTopDeg: 123 }), "left"); + expect(a.camberDeg).toBeCloseTo(b.camberDeg, 9); + expect(a.casterDeg).toBeCloseTo(b.casterDeg, 9); + }); + + it("equal pills at equal angles cancel (top minus bottom)", () => { + const r = forwardCorner(cal(), pills({ thetaTopDeg: 77, thetaBotDeg: 77 }), "left"); + expect(r.camberDeg).toBeCloseTo(0, 9); + expect(r.casterDeg).toBeCloseTo(0, 9); + expect(r.dXMm).toBeCloseTo(0, 9); + expect(r.dYMm).toBeCloseTo(0, 9); + }); + + it("top pill outboard (θ=90°) adds positive camber; sign flag flips it", () => { + const p = pills({ sBot: 0, thetaTopDeg: 90 }); + const r = forwardCorner(cal(), p, "left"); + expect(r.camberDeg).toBeGreaterThan(0); + const flipped = forwardCorner(cal({ signCamber: -1 }), p, "left"); + expect(flipped.camberDeg).toBeCloseTo(-r.camberDeg, 9); + }); + + it("top pill forward (θ=0°) gives negative caster (top leading); sign flag flips", () => { + const p = pills({ sBot: 0, thetaTopDeg: 0 }); + const r = forwardCorner(cal(), p, "left"); + expect(r.casterDeg).toBeLessThan(0); + const flipped = forwardCorner(cal({ signCaster: -1 }), p, "left"); + expect(flipped.casterDeg).toBeCloseTo(-r.casterDeg, 9); + }); + + it("identical dials on both sides are symmetric by default", () => { + const p = pills({ thetaTopDeg: 33, thetaBotDeg: 210 }); + const left = forwardCorner(cal(), p, "left"); + const right = forwardCorner(cal(), p, "right"); + expect(right.camberDeg).toBeCloseTo(left.camberDeg, 9); + expect(right.casterDeg).toBeCloseTo(left.casterDeg, 9); + }); + + it("mirrorRight negates the lateral (camber) axis on the right, keeps caster", () => { + const c = cal({ mirrorRight: true }); + const p = pills({ sBot: 0, thetaTopDeg: 90 }); + const left = forwardCorner(c, p, "left"); + const right = forwardCorner(c, p, "right"); + expect(right.camberDeg).toBeCloseTo(-left.camberDeg, 9); + expect(right.casterDeg).toBeCloseTo(left.casterDeg, 9); + }); + + it("camberMm is the gauge-span tangent of camber", () => { + const c = cal(); + const r = forwardCorner(c, pills({ sBot: 0, thetaTopDeg: 90 }), "left"); + expect(r.camberMm).toBeCloseTo(c.lRimMm * Math.tan((r.camberDeg * Math.PI) / 180), 6); + }); + + it("Δtrack/Δwheelbase split by wheelFrac at cardinal angles", () => { + const c = cal({ wheelFrac: 0.7 }); + const et = c.eMm[3]; + const outboard = forwardCorner(c, pills({ thetaTopDeg: 90, thetaBotDeg: 90 }), "left"); + expect(outboard.trackDeltaMm).toBeCloseTo(0.7 * et + 0.3 * et, 9); + expect(outboard.wheelbaseDeltaMm).toBeCloseTo(0, 9); + const forward = forwardCorner(c, pills({ thetaTopDeg: 0, thetaBotDeg: 0 }), "left"); + expect(forward.wheelbaseDeltaMm).toBeCloseTo(et, 9); + expect(forward.trackDeltaMm).toBeCloseTo(0, 9); + }); +}); + +describe("pillContribution", () => { + it("inverts the forward map's D vector", () => { + const c = cal({ nXMm: 0.8, nYMm: -0.3, gamma0Deg: -0.4 }); + const p = pills({ thetaTopDeg: 40, thetaBotDeg: 260 }); + const fwd = forwardCorner(c, p, "left"); + const { rX, rY } = pillContribution(c, fwd.camberDeg, fwd.casterDeg); + expect(rX).toBeCloseTo(fwd.dXMm - c.nXMm, 6); + expect(rY).toBeCloseTo(fwd.dYMm - c.nYMm, 6); + }); +}); + +describe("angles & holes", () => { + it("normalizeDeg wraps into [0, 360)", () => { + expect(normalizeDeg(-30)).toBeCloseTo(330); + expect(normalizeDeg(360)).toBeCloseTo(0); + expect(normalizeDeg(725)).toBeCloseTo(5); + }); + + it("snapToHole snaps to the 18° grid for 20 holes and wraps", () => { + expect(snapToHole(10, 20)).toBeCloseTo(18); + expect(snapToHole(8, 20)).toBeCloseTo(0); + expect(snapToHole(355, 20)).toBeCloseTo(0); + }); + + it("snapToHole with holeCount 0 is identity (mod 360)", () => { + expect(snapToHole(123.4, 0)).toBeCloseTo(123.4); + expect(snapToHole(-10, 0)).toBeCloseTo(350); + }); + + it("holeIndex reports the nearest hole", () => { + expect(holeIndex(18, 20)).toBe(1); + expect(holeIndex(355, 20)).toBe(0); + expect(holeIndex(90, 20)).toBe(5); + }); +}); diff --git a/src/plugins/tools/pill-alignment/model.ts b/src/plugins/tools/pill-alignment/model.ts new file mode 100644 index 00000000..5afe2338 --- /dev/null +++ b/src/plugins/tools/pill-alignment/model.ts @@ -0,0 +1,231 @@ +// Pill alignment forward model — pure geometry, no I/O (plan 0011). +// +// OTK-style eccentric kingpin pills: each of the two bores (top/bottom) holds +// a pill whose kingpin hole is offset from the bore centre by an eccentricity +// that depends on the pill's dot count ("size" 0–5). Rotating a pill moves the +// kingpin hole around a circle, tilting the kingpin axis → camber/caster. +// +// Per-corner frame (right-handed): x = forward, y = outboard, z = up. +// Dial convention: 0° = dot forward, positive rotation toward outboard — so +// identical dial settings on both sides give symmetric camber. The +// `mirrorRight` calibration flag negates right-side angles for users whose +// physical reference is a fixed global handedness instead. + +export type Side = "left" | "right"; + +/** Pill dot count 0–5. 0 = concentric (no offset), 3 = OEM, 5 = max. */ +export type PillSize = 0 | 1 | 2 | 3 | 4 | 5; + +export const PILL_SIZES: readonly PillSize[] = [0, 1, 2, 3, 4, 5]; + +export interface PillCalibration { + /** Vertical gap between the top and bottom bore centres (mm). */ + hMm: number; + /** Kingpin-hole eccentricity per pill size (mm); eMm[0] must be 0. */ + eMm: [number, number, number, number, number, number]; + /** Built-in (concentric-pills) horizontal kingpin offset: forward (mm). Sets factory caster. */ + nXMm: number; + /** Built-in horizontal kingpin offset: outboard (mm). Sets factory KPI/camber. */ + nYMm: number; + /** Built-in spindle camber at concentric pills (deg). */ + gamma0Deg: number; + /** Camber-gauge contact span for deg→mm readouts (mm, typically rim diameter). */ + lRimMm: number; + /** Wheel-centre height as a fraction of the kingpin (0..1); weights Δtrack/Δwheelbase. */ + wheelFrac: number; + signCamber: 1 | -1; + signCaster: 1 | -1; + /** Negate right-side dial angles (global-handedness angle references). */ + mirrorRight: boolean; + /** Index holes per pill revolution; 0 = free (friction) pill. */ + holeCount: number; + /** Heuristic toe drift per mm of fore-aft kingpin shift (mm toe per mm), for the resultant-toe color mode. */ + toeCouplingMmPerMm: number; +} + +/** One corner's pill settings, in dial angles (deg). */ +export interface CornerPills { + sTop: PillSize; + sBot: PillSize; + thetaTopDeg: number; + thetaBotDeg: number; +} + +export interface CornerResult { + camberDeg: number; + casterDeg: number; + /** Camber expressed as a gauge reading across lRimMm. */ + camberMm: number; + /** Track-width change at this corner vs concentric pills (+ = wider). */ + trackDeltaMm: number; + /** Wheelbase change at this corner vs concentric pills (+ = longer). */ + wheelbaseDeltaMm: number; + /** Horizontal kingpin delta (bottom→top bore), for the inverse solver and tests. */ + dXMm: number; + dYMm: number; +} + +const D2R = Math.PI / 180; +const R2D = 180 / Math.PI; + +/** Normalize an angle to [0, 360). */ +export function normalizeDeg(deg: number): number { + const m = deg % 360; + return m < 0 ? m + 360 : m; +} + +/** Dial angle → local-frame angle (rad); right side mirrors when configured. */ +export function dialToLocalRad(cal: PillCalibration, side: Side, dialDeg: number): number { + const sign = side === "right" && cal.mirrorRight ? -1 : 1; + return sign * dialDeg * D2R; +} + +/** Local-frame angle (rad) → dial angle (deg), inverse of dialToLocalRad. */ +export function localRadToDial(cal: PillCalibration, side: Side, localRad: number): number { + const sign = side === "right" && cal.mirrorRight ? -1 : 1; + return normalizeDeg(sign * localRad * R2D); +} + +/** Snap a dial angle to the nearest index hole; holeCount 0 = free pill (identity). */ +export function snapToHole(dialDeg: number, holeCount: number): number { + if (holeCount <= 0) return normalizeDeg(dialDeg); + const step = 360 / holeCount; + return normalizeDeg(Math.round(dialDeg / step) * step); +} + +/** Hole index (0-based) closest to a dial angle, for display next to degrees. */ +export function holeIndex(dialDeg: number, holeCount: number): number { + if (holeCount <= 0) return 0; + return Math.round(normalizeDeg(dialDeg) / (360 / holeCount)) % holeCount; +} + +/** Forward model: pill settings → camber/caster/track for one corner. */ +export function forwardCorner(cal: PillCalibration, pills: CornerPills, side: Side): CornerResult { + const et = cal.eMm[pills.sTop]; + const eb = cal.eMm[pills.sBot]; + const tt = dialToLocalRad(cal, side, pills.thetaTopDeg); + const tb = dialToLocalRad(cal, side, pills.thetaBotDeg); + + const dX = cal.nXMm + et * Math.cos(tt) - eb * Math.cos(tb); + const dY = cal.nYMm + et * Math.sin(tt) - eb * Math.sin(tb); + + const casterRad = cal.signCaster * Math.atan2(-dX, cal.hMm); + const camberRad = cal.signCamber * (cal.gamma0Deg * D2R + Math.atan2(dY, cal.hMm)); + + const f = cal.wheelFrac; + return { + camberDeg: camberRad * R2D, + casterDeg: casterRad * R2D, + camberMm: cal.lRimMm * Math.tan(camberRad), + trackDeltaMm: f * et * Math.sin(tt) + (1 - f) * eb * Math.sin(tb), + wheelbaseDeltaMm: f * et * Math.cos(tt) + (1 - f) * eb * Math.cos(tb), + dXMm: dX, + dYMm: dY, + }; +} + +/** + * Required kingpin delta for a camber/caster target, minus the built-in offset — + * i.e. what the two pills together must contribute. Shared by the solvers. + */ +export function pillContribution( + cal: PillCalibration, + targetCamberDeg: number, + targetCasterDeg: number, +): { rX: number; rY: number } { + const aY = cal.signCamber * targetCamberDeg * D2R - cal.gamma0Deg * D2R; + const aX = cal.signCaster * targetCasterDeg * D2R; + return { + rX: -cal.hMm * Math.tan(aX) - cal.nXMm, + rY: cal.hMm * Math.tan(aY) - cal.nYMm, + }; +} + +// --------------------------------------------------------------------------- +// Calibration defaults & presets +// +// Real OTK eccentricities are not published; these are plausible round numbers +// so the tool behaves sensibly out of the box. Everything is user-editable and +// the UI labels presets "(approx)" with an experimental disclaimer. +// --------------------------------------------------------------------------- + +export const DEFAULT_CALIBRATION: PillCalibration = { + hMm: 110, + eMm: [0, 0.5, 1.0, 1.5, 2.0, 2.5], + nXMm: 0, + nYMm: 0, + gamma0Deg: 0, + lRimMm: 130, + wheelFrac: 0.5, + signCamber: 1, + signCaster: 1, + mirrorRight: false, + holeCount: 20, + toeCouplingMmPerMm: 0.5, +}; + +export interface ChassisPreset { + id: string; + cal: PillCalibration; +} + +export const CHASSIS_PRESETS: readonly ChassisPreset[] = [ + { id: "generic", cal: DEFAULT_CALIBRATION }, + { + id: "otk-approx", + cal: { ...DEFAULT_CALIBRATION, hMm: 112, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0], gamma0Deg: -0.4 }, + }, +]; + +// --------------------------------------------------------------------------- +// Persisted tool state (plugin store, key "pill-alignment:v1") +// --------------------------------------------------------------------------- + +export type EnvelopeColorMode = "trackDelta" | "thetaTop" | "resultantToe"; + +export type ToeMode = "rod" | "perSide"; + +export interface ToeState { + mode: ToeMode; + /** Tie-rod length change from baseline (mm), rod mode. */ + rodDeltaMm: number; + /** Steering-arm effective radius (mm), rod mode. */ + rArmMm: number; + /** Per-side toe (mm across lRimMm), OUT negative. */ + leftToeMm: number; + rightToeMm: number; +} + +export interface PersistedStateV1 { + calibration: PillCalibration; + /** Active preset id, or null once any constant is hand-edited. */ + presetId: string | null; + corners: { left: CornerPills; right: CornerPills }; + /** Mirror left-side edits onto the right side. */ + linked: boolean; + activeSide: Side; + colorMode: EnvelopeColorMode; + snapHoles: boolean; + toe: ToeState; +} + +export const DEFAULT_CORNER: CornerPills = { sTop: 3, sBot: 3, thetaTopDeg: 0, thetaBotDeg: 0 }; + +export const DEFAULT_TOE: ToeState = { + mode: "perSide", + rodDeltaMm: 0, + rArmMm: 70, + leftToeMm: 0, + rightToeMm: 0, +}; + +export const DEFAULT_STATE: PersistedStateV1 = { + calibration: DEFAULT_CALIBRATION, + presetId: "generic", + corners: { left: DEFAULT_CORNER, right: DEFAULT_CORNER }, + linked: true, + activeSide: "left", + colorMode: "trackDelta", + snapHoles: true, + toe: DEFAULT_TOE, +}; diff --git a/src/plugins/tools/pill-alignment/toe.test.ts b/src/plugins/tools/pill-alignment/toe.test.ts new file mode 100644 index 00000000..31f36a69 --- /dev/null +++ b/src/plugins/tools/pill-alignment/toe.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { SetupTemplate } from "@/lib/templateStorage"; +import { DEFAULT_CALIBRATION, DEFAULT_TOE } from "./model"; +import { effectiveToeMm, resolveSetupAlignmentFields, toeDegFromMm, toeDegFromRod, toeMmFromDeg } from "./toe"; + +describe("toe conversions", () => { + it("rod→deg→mm round-trips through the arm and rim spans", () => { + const deg = toeDegFromRod(2, 70); + expect(deg).toBeCloseTo(Math.atan(2 / 70) * (180 / Math.PI), 9); + const mm = toeMmFromDeg(deg, 130); + expect(toeDegFromMm(mm, 130)).toBeCloseTo(deg, 9); + }); + + it("toe OUT is negative end to end", () => { + expect(toeDegFromRod(-2, 70)).toBeLessThan(0); + expect(toeMmFromDeg(-1, 130)).toBeLessThan(0); + }); + + it("guards zero/negative geometry", () => { + expect(toeDegFromRod(2, 0)).toBe(0); + expect(toeDegFromMm(2, 0)).toBe(0); + }); + + it("effectiveToeMm follows the active mode", () => { + const cal = DEFAULT_CALIBRATION; + expect(effectiveToeMm({ ...DEFAULT_TOE, mode: "perSide", leftToeMm: -2, rightToeMm: -1 }, "left", cal)).toBe(-2); + expect(effectiveToeMm({ ...DEFAULT_TOE, mode: "perSide", leftToeMm: -2, rightToeMm: -1 }, "right", cal)).toBe(-1); + const rod = effectiveToeMm({ ...DEFAULT_TOE, mode: "rod", rodDeltaMm: 2, rArmMm: 70 }, "left", cal); + expect(rod).toBeCloseTo(toeMmFromDeg(toeDegFromRod(2, 70), cal.lRimMm), 9); + }); +}); + +describe("resolveSetupAlignmentFields", () => { + it("reads default-template ids without a template", () => { + const values = resolveSetupAlignmentFields(null, { + "f-toe": -2, + "f-camber": "-0.9", + "f-castor": 2.5, + "f-front-width": 1180, + }); + expect(values).toEqual({ toe: -2, camber: -0.9, castor: 2.5, frontWidthMm: 1180 }); + }); + + it("matches custom-template fields by name, number fields only", () => { + const template: SetupTemplate = { + id: "t1", + vehicleTypeId: "v1", + name: "Custom", + wheelCount: 4, + includeTires: false, + isDefault: false, + createdAt: 0, + updatedAt: 0, + sections: [ + { + id: "s1", + name: "Front", + fields: [ + { id: "abc", name: "Toe (mm)", type: "number" }, + { id: "def", name: "Caster", type: "number" }, + { id: "ghi", name: "Camber notes", type: "string" }, + { id: "jkl", name: "camber", type: "number" }, + ], + }, + ], + }; + const values = resolveSetupAlignmentFields(template, { abc: -1.5, def: 2, ghi: "3", jkl: -0.8 }); + expect(values.toe).toBe(-1.5); + expect(values.castor).toBe(2); + expect(values.camber).toBe(-0.8); + expect(values.frontWidthMm).toBeNull(); + }); + + it("returns nulls for absent or non-numeric values", () => { + const values = resolveSetupAlignmentFields(null, { "f-toe": "not a number", "f-camber": null }); + expect(values).toEqual({ toe: null, camber: null, castor: null, frontWidthMm: null }); + }); +}); diff --git a/src/plugins/tools/pill-alignment/toe.ts b/src/plugins/tools/pill-alignment/toe.ts new file mode 100644 index 00000000..30e6a3fa --- /dev/null +++ b/src/plugins/tools/pill-alignment/toe.ts @@ -0,0 +1,103 @@ +// Toe model + session-setup field resolution (plan 0011, phase 2). +// +// Toe is set by the tie-rod, essentially independent of the pills at this +// fidelity. Convention follows the reference app: toe-OUT is negative, and mm +// readouts are the gauge reading across the same lRimMm span camber uses. + +import type { SetupTemplate } from "@/lib/templateStorage"; +import type { PillCalibration, Side, ToeState } from "./model"; + +const R2D = 180 / Math.PI; +const D2R = Math.PI / 180; + +/** Per-side toe angle from a tie-rod length change (both sides move together). */ +export function toeDegFromRod(rodDeltaMm: number, rArmMm: number): number { + if (rArmMm <= 0) return 0; + return Math.atan(rodDeltaMm / rArmMm) * R2D; +} + +export function toeMmFromDeg(toeDeg: number, lRimMm: number): number { + return lRimMm * Math.tan(toeDeg * D2R); +} + +export function toeDegFromMm(toeMm: number, lRimMm: number): number { + if (lRimMm <= 0) return 0; + return Math.atan(toeMm / lRimMm) * R2D; +} + +/** The static per-side toe (mm) implied by the current toe inputs. */ +export function effectiveToeMm(toe: ToeState, side: Side, cal: PillCalibration): number { + if (toe.mode === "rod") { + return toeMmFromDeg(toeDegFromRod(toe.rodDeltaMm, toe.rArmMm), cal.lRimMm); + } + return side === "left" ? toe.leftToeMm : toe.rightToeMm; +} + +// --------------------------------------------------------------------------- +// Session-setup field resolution +// +// The default kart template stores alignment as f-toe / f-camber / f-castor / +// f-front-width in VehicleSetup.customFields. User-created templates have +// random field ids, so fall back to case-insensitive name matching. +// --------------------------------------------------------------------------- + +export interface SetupAlignmentValues { + toe: number | null; + camber: number | null; + castor: number | null; + frontWidthMm: number | null; +} + +const DEFAULT_IDS: Record = { + toe: "f-toe", + camber: "f-camber", + castor: "f-castor", + frontWidthMm: "f-front-width", +}; + +const NAME_MATCHERS: Record = { + toe: /^toe\b/i, + camber: /^camber\b/i, + castor: /^cast(o|e)r\b/i, + frontWidthMm: /^front\s*width\b/i, +}; + +/** + * Pull alignment numbers out of a setup's customFields using its template. + * Returns nulls for anything absent or non-numeric — callers only seed inputs + * from non-null values. + */ +export function resolveSetupAlignmentFields( + template: SetupTemplate | null, + customFields: Record, +): SetupAlignmentValues { + const out: SetupAlignmentValues = { toe: null, camber: null, castor: null, frontWidthMm: null }; + + const numeric = (v: string | number | null | undefined): number | null => { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = parseFloat(v); + if (Number.isFinite(n)) return n; + } + return null; + }; + + for (const key of Object.keys(out) as Array) { + out[key] = numeric(customFields[DEFAULT_IDS[key]]); + } + + if (template) { + for (const section of template.sections) { + for (const field of section.fields) { + if (field.type !== "number") continue; + for (const key of Object.keys(out) as Array) { + if (out[key] === null && NAME_MATCHERS[key].test(field.name.trim())) { + out[key] = numeric(customFields[field.id]); + } + } + } + } + } + + return out; +} From fb26d1e1f17aedbefb681472177cb339ddbc1a11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:31:26 +0000 Subject: [PATCH 2/3] =?UTF-8?q?plan=200011:=20pill=20alignment=20tool=20UI?= =?UTF-8?q?=20=E2=80=94=20envelope=20plot,=20pill=20dials,=20Find=20Setup,?= =?UTF-8?q?=20toe=20visual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calculator itself: draggable camber/caster envelope scatter (two-canvas layered, dots batched by color bucket), 2×2 SVG pill hub dials with drag-to-rotate and hole snapping, per-side readouts, ranked Find Setup candidates with one-tap Apply, editable chassis calibration with approximate presets, toe entry (tie-rod or per-side) with an overhead toe widget, and a read-only load-from-session-setup seed. Registered as a lazy tools-plugin entry so it surfaces on both the landing Tools drawer and the in-session Tools tab. NumRow/Section lifted to tools/shared for reuse with the seat tool. Translated across all 7 locales. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D2gb7LNbhFeapCaKAn9tvH --- CHANGELOG.md | 9 + CLAUDE.md | 2 +- src/plugins/tools/locales/de.json | 82 ++++ src/plugins/tools/locales/en.json | 82 ++++ src/plugins/tools/locales/es.json | 82 ++++ src/plugins/tools/locales/fr.json | 82 ++++ src/plugins/tools/locales/it.json | 82 ++++ src/plugins/tools/locales/ja.json | 82 ++++ src/plugins/tools/locales/pt-BR.json | 82 ++++ .../tools/pill-alignment/CalibrationPanel.tsx | 121 ++++++ .../tools/pill-alignment/EnvelopePlot.tsx | 300 ++++++++++++++ .../tools/pill-alignment/FindSetupPanel.tsx | 82 ++++ .../tools/pill-alignment/OverheadToeView.tsx | 41 ++ .../pill-alignment/PillAlignmentTool.tsx | 386 ++++++++++++++++++ src/plugins/tools/pill-alignment/PillDial.tsx | 153 +++++++ .../tools/seat-position/SeatPositionTool.tsx | 62 +-- src/plugins/tools/shared/NumRow.tsx | 63 +++ src/plugins/tools/toolList.ts | 10 +- 18 files changed, 1742 insertions(+), 61 deletions(-) create mode 100644 src/plugins/tools/pill-alignment/CalibrationPanel.tsx create mode 100644 src/plugins/tools/pill-alignment/EnvelopePlot.tsx create mode 100644 src/plugins/tools/pill-alignment/FindSetupPanel.tsx create mode 100644 src/plugins/tools/pill-alignment/OverheadToeView.tsx create mode 100644 src/plugins/tools/pill-alignment/PillAlignmentTool.tsx create mode 100644 src/plugins/tools/pill-alignment/PillDial.tsx create mode 100644 src/plugins/tools/shared/NumRow.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f60738..6cb56d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ 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 + calibration is fully editable with approximate presets; works offline and + from the homepage Tools drawer. - **Simulator: load your own `.dovex`.** The `/simulator` page now has a session picker — feed the firmware any dove-family log (`.dovex`/`.dovep`/`.dove`) instead of only the bundled demo, with a diff --git a/CLAUDE.md b/CLAUDE.md index 36dcdff2..e804eb88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,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 diff --git a/src/plugins/tools/locales/de.json b/src/plugins/tools/locales/de.json index 663d1923..9b17bdd6 100644 --- a/src/plugins/tools/locales/de.json +++ b/src/plugins/tools/locales/de.json @@ -123,5 +123,87 @@ "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.", + "preset": "Voreinstellung", + "presetGeneric": "Generisches Kart", + "presetOtk": "OTK (ca.)", + "presetCustom": "Benutzerdefiniert", + "resetPreset": "Auf Voreinstellung zurücksetzen", + "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" + } } } diff --git a/src/plugins/tools/locales/en.json b/src/plugins/tools/locales/en.json index 9c0c85a5..69569674 100644 --- a/src/plugins/tools/locales/en.json +++ b/src/plugins/tools/locales/en.json @@ -122,5 +122,87 @@ "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.", + "preset": "Preset", + "presetGeneric": "Generic kart", + "presetOtk": "OTK (approx)", + "presetCustom": "Custom", + "resetPreset": "Reset to preset", + "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" + } } } diff --git a/src/plugins/tools/locales/es.json b/src/plugins/tools/locales/es.json index ed32d883..6bc94dc1 100644 --- a/src/plugins/tools/locales/es.json +++ b/src/plugins/tools/locales/es.json @@ -123,5 +123,87 @@ "statusRecording": "Grabando", "statusEnded": "Finalizada", "statusWaiting": "Esperando" + }, + "pillAlignment": { + "name": "Alineación de excéntricas", + "description": "Calculadora de caída y avance por excéntricas, con solucionador de reglajes.", + "badge": "Experimental" + }, + "pill": { + "linkSides": "Vincular izq./der.", + "snapHoles": "Ajustar a los agujeros", + "loadFromSetup": "Cargar del reglaje de la sesión", + "sideLeft": "Izquierda", + "sideRight": "Derecha", + "sideL": "I", + "sideR": "D", + "colorMode": { + "trackDelta": "Δ ancho de vía", + "thetaTop": "Ángulo excéntrica sup.", + "resultantToe": "Convergencia resultante" + }, + "envelope": { + "xLabel": "Caída (°)", + "yLabel": "Avance (°)", + "dragHint": "Arrastra el marcador para resolver los ángulos de las excéntricas para esa caída/avance. Naranja = lugar de la excéntrica superior, magenta = inferior." + }, + "dial": { + "topLeft": "Izq. · Superior", + "topRight": "Der. · Superior", + "bottomLeft": "Izq. · Inferior", + "bottomRight": "Der. · Inferior", + "size": "Tamaño de excéntrica", + "aria": "Ángulo de la excéntrica {{label}}" + }, + "readout": { + "camber": "Caída", + "caster": "Avance", + "track": "Vía", + "totalTrack": "Cambio total de ancho de vía: {{value}} mm" + }, + "toe": { + "title": "Convergencia", + "usePerSide": "Introducir por lado", + "useRod": "Introducir cambio de tirante", + "rodDelta": "Δ tirante", + "rArm": "Radio del brazo de dirección", + "left": "Convergencia izq.", + "right": "Convergencia der.", + "out": "DIVERGENCIA", + "in": "CONVERGENCIA", + "neutral": "Convergencia neutra" + }, + "findSetup": { + "title": "Buscar reglaje", + "targetCamber": "Caída objetivo", + "targetCaster": "Avance objetivo", + "solve": "Buscar posiciones de excéntricas", + "apply": "Aplicar", + "residual": "desvío", + "noResults": "Ese objetivo está fuera del alcance de estas excéntricas. Prueba menos caída/avance o excéntricas mayores.", + "top": "Sup", + "bottom": "Inf" + }, + "cal": { + "title": "Calibración del chasis", + "disclaimer": "Estas constantes son valores aproximados por defecto: las excentricidades reales varían según el chasis y no están publicadas. Verifica con un medidor de caída/avance antes de fiarte de los números y edita lo que no cuadre.", + "preset": "Preajuste", + "presetGeneric": "Kart genérico", + "presetOtk": "OTK (aprox.)", + "presetCustom": "Personalizado", + "resetPreset": "Restablecer al preajuste", + "h": "Separación de alojamientos H", + "lRim": "Luz del medidor", + "split": "Fracción de altura de rueda", + "neutralX": "Desplazamiento neutro adelante", + "neutralY": "Desplazamiento neutro afuera", + "gamma0": "Caída de fábrica", + "holeCount": "Agujeros por excéntrica", + "toeCoupling": "Acoplamiento de convergencia", + "eccentricity": "Excentricidad por tamaño (e0 siempre es 0)", + "signCamber": "Invertir signo de caída", + "signCaster": "Invertir signo de avance", + "mirrorRight": "Reflejar ángulos del lado derecho" + } } } diff --git a/src/plugins/tools/locales/fr.json b/src/plugins/tools/locales/fr.json index 6911ce5b..566be695 100644 --- a/src/plugins/tools/locales/fr.json +++ b/src/plugins/tools/locales/fr.json @@ -123,5 +123,87 @@ "statusRecording": "Enregistrement", "statusEnded": "Terminée", "statusWaiting": "En attente" + }, + "pillAlignment": { + "name": "Alignement par excentriques", + "description": "Calculateur de carrossage et de chasse par excentriques, avec solveur de réglage.", + "badge": "Expérimental" + }, + "pill": { + "linkSides": "Lier G/D", + "snapHoles": "Caler sur les trous", + "loadFromSetup": "Charger depuis le réglage de la session", + "sideLeft": "Gauche", + "sideRight": "Droite", + "sideL": "G", + "sideR": "D", + "colorMode": { + "trackDelta": "Δ largeur de voie", + "thetaTop": "Angle excentrique sup.", + "resultantToe": "Pincement résultant" + }, + "envelope": { + "xLabel": "Carrossage (°)", + "yLabel": "Chasse (°)", + "dragHint": "Faites glisser le repère pour résoudre les angles d'excentriques pour ce carrossage/chasse. Orange = lieu de l'excentrique supérieur, magenta = inférieur." + }, + "dial": { + "topLeft": "Gauche · Haut", + "topRight": "Droite · Haut", + "bottomLeft": "Gauche · Bas", + "bottomRight": "Droite · Bas", + "size": "Taille d'excentrique", + "aria": "Angle de l'excentrique {{label}}" + }, + "readout": { + "camber": "Carrossage", + "caster": "Chasse", + "track": "Voie", + "totalTrack": "Variation totale de la voie : {{value}} mm" + }, + "toe": { + "title": "Pincement", + "usePerSide": "Saisir par côté", + "useRod": "Saisir la variation de biellette", + "rodDelta": "Δ biellette", + "rArm": "Rayon du levier de direction", + "left": "Pincement gauche", + "right": "Pincement droit", + "out": "OUVERTURE", + "in": "PINCEMENT", + "neutral": "Pincement neutre" + }, + "findSetup": { + "title": "Trouver un réglage", + "targetCamber": "Carrossage cible", + "targetCaster": "Chasse cible", + "solve": "Trouver les positions d'excentriques", + "apply": "Appliquer", + "residual": "écart", + "noResults": "Cette cible dépasse ce que ces excentriques peuvent atteindre. Essayez moins de carrossage/chasse ou des excentriques plus grands.", + "top": "Haut", + "bottom": "Bas" + }, + "cal": { + "title": "Calibration du châssis", + "disclaimer": "Ces constantes sont des valeurs approximatives par défaut : les excentricités réelles varient selon le châssis et ne sont pas publiées. Vérifiez avec une jauge de carrossage/chasse avant de vous fier aux chiffres, et modifiez ce qui ne correspond pas.", + "preset": "Préréglage", + "presetGeneric": "Kart générique", + "presetOtk": "OTK (approx.)", + "presetCustom": "Personnalisé", + "resetPreset": "Rétablir le préréglage", + "h": "Écart des logements H", + "lRim": "Portée de la jauge", + "split": "Fraction de hauteur de roue", + "neutralX": "Décalage neutre avant", + "neutralY": "Décalage neutre extérieur", + "gamma0": "Carrossage d'origine", + "holeCount": "Trous par excentrique", + "toeCoupling": "Couplage de pincement", + "eccentricity": "Excentricité par taille (e0 vaut toujours 0)", + "signCamber": "Inverser le signe du carrossage", + "signCaster": "Inverser le signe de la chasse", + "mirrorRight": "Miroir des angles côté droit" + } } } diff --git a/src/plugins/tools/locales/it.json b/src/plugins/tools/locales/it.json index 06fabde3..eb2a7d53 100644 --- a/src/plugins/tools/locales/it.json +++ b/src/plugins/tools/locales/it.json @@ -123,5 +123,87 @@ "statusRecording": "Registrazione", "statusEnded": "Terminata", "statusWaiting": "In attesa" + }, + "pillAlignment": { + "name": "Allineamento eccentrici", + "description": "Calcolatore di campanatura e incidenza tramite eccentrici, con risolutore di assetto.", + "badge": "Sperimentale" + }, + "pill": { + "linkSides": "Collega sx/dx", + "snapHoles": "Aggancia ai fori", + "loadFromSetup": "Carica dall'assetto della sessione", + "sideLeft": "Sinistra", + "sideRight": "Destra", + "sideL": "S", + "sideR": "D", + "colorMode": { + "trackDelta": "Δ carreggiata", + "thetaTop": "Angolo eccentrico sup.", + "resultantToe": "Convergenza risultante" + }, + "envelope": { + "xLabel": "Campanatura (°)", + "yLabel": "Incidenza (°)", + "dragHint": "Trascina il marcatore per risolvere gli angoli degli eccentrici per quella campanatura/incidenza. Arancione = luogo dell'eccentrico superiore, magenta = inferiore." + }, + "dial": { + "topLeft": "Sx · Superiore", + "topRight": "Dx · Superiore", + "bottomLeft": "Sx · Inferiore", + "bottomRight": "Dx · Inferiore", + "size": "Misura eccentrico", + "aria": "Angolo dell'eccentrico {{label}}" + }, + "readout": { + "camber": "Campanatura", + "caster": "Incidenza", + "track": "Carreggiata", + "totalTrack": "Variazione totale carreggiata: {{value}} mm" + }, + "toe": { + "title": "Convergenza", + "usePerSide": "Inserisci per lato", + "useRod": "Inserisci variazione tirante", + "rodDelta": "Δ tirante", + "rArm": "Raggio braccio sterzo", + "left": "Convergenza sx", + "right": "Convergenza dx", + "out": "APERTA", + "in": "CHIUSA", + "neutral": "Convergenza neutra" + }, + "findSetup": { + "title": "Trova assetto", + "targetCamber": "Campanatura obiettivo", + "targetCaster": "Incidenza obiettivo", + "solve": "Trova posizioni eccentrici", + "apply": "Applica", + "residual": "scarto", + "noResults": "Quell'obiettivo è fuori dalla portata di questi eccentrici. Prova meno campanatura/incidenza o eccentrici più grandi.", + "top": "Sup", + "bottom": "Inf" + }, + "cal": { + "title": "Calibrazione telaio", + "disclaimer": "Queste costanti sono valori predefiniti approssimativi: le eccentricità reali variano da telaio a telaio e non sono pubblicate. Verifica con un goniometro campanatura/incidenza prima di fidarti dei numeri e correggi ciò che non torna.", + "preset": "Preimpostazione", + "presetGeneric": "Kart generico", + "presetOtk": "OTK (appross.)", + "presetCustom": "Personalizzato", + "resetPreset": "Ripristina preimpostazione", + "h": "Interasse sedi H", + "lRim": "Luce del goniometro", + "split": "Frazione altezza ruota", + "neutralX": "Offset neutro avanti", + "neutralY": "Offset neutro esterno", + "gamma0": "Campanatura di fabbrica", + "holeCount": "Fori per eccentrico", + "toeCoupling": "Accoppiamento convergenza", + "eccentricity": "Eccentricità per misura (e0 è sempre 0)", + "signCamber": "Inverti segno campanatura", + "signCaster": "Inverti segno incidenza", + "mirrorRight": "Specchia angoli lato destro" + } } } diff --git a/src/plugins/tools/locales/ja.json b/src/plugins/tools/locales/ja.json index 47c0dbc4..21330c5b 100644 --- a/src/plugins/tools/locales/ja.json +++ b/src/plugins/tools/locales/ja.json @@ -123,5 +123,87 @@ "statusRecording": "記録中", "statusEnded": "終了", "statusWaiting": "待機中" + }, + "pillAlignment": { + "name": "ピルアライメント", + "description": "偏心ピルによるキャンバー/キャスター計算機。セットアップ探索付き。", + "badge": "実験的" + }, + "pill": { + "linkSides": "左右を連動", + "snapHoles": "穴にスナップ", + "loadFromSetup": "セッションのセットアップから読み込む", + "sideLeft": "左", + "sideRight": "右", + "sideL": "左", + "sideR": "右", + "colorMode": { + "trackDelta": "トレッド幅Δ", + "thetaTop": "上ピル角度", + "resultantToe": "合成トー" + }, + "envelope": { + "xLabel": "キャンバー (°)", + "yLabel": "キャスター (°)", + "dragHint": "マーカーをドラッグすると、そのキャンバー/キャスターに対するピル角度を解きます。オレンジ = 上ピルの軌跡、マゼンタ = 下ピルの軌跡。" + }, + "dial": { + "topLeft": "左・上", + "topRight": "右・上", + "bottomLeft": "左・下", + "bottomRight": "右・下", + "size": "ピルサイズ", + "aria": "{{label}}のピル角度" + }, + "readout": { + "camber": "キャンバー", + "caster": "キャスター", + "track": "トレッド", + "totalTrack": "トレッド幅の合計変化: {{value}} mm" + }, + "toe": { + "title": "トー", + "usePerSide": "左右別に入力", + "useRod": "タイロッド変化で入力", + "rodDelta": "タイロッドΔ", + "rArm": "ステアリングアーム半径", + "left": "左トー", + "right": "右トー", + "out": "トーアウト", + "in": "トーイン", + "neutral": "トーゼロ" + }, + "findSetup": { + "title": "セットアップ探索", + "targetCamber": "目標キャンバー", + "targetCaster": "目標キャスター", + "solve": "ピル位置を探索", + "apply": "適用", + "residual": "誤差", + "noResults": "その目標はこのピルでは到達できません。キャンバー/キャスターを小さくするか、大きいピルを試してください。", + "top": "上", + "bottom": "下" + }, + "cal": { + "title": "シャシー校正", + "disclaimer": "これらの定数はあくまで近似の既定値です。実際の偏心量はシャシーごとに異なり、公表されていません。数値を信頼する前にキャンバー/キャスターゲージで確認し、合わない値は編集してください。", + "preset": "プリセット", + "presetGeneric": "汎用カート", + "presetOtk": "OTK(近似)", + "presetCustom": "カスタム", + "resetPreset": "プリセットに戻す", + "h": "ボア間隔 H", + "lRim": "ゲージ測定幅", + "split": "ホイール高さ比率", + "neutralX": "中立オフセット前方", + "neutralY": "中立オフセット外側", + "gamma0": "初期キャンバー", + "holeCount": "ピルの穴数", + "toeCoupling": "トー連成係数", + "eccentricity": "サイズ別偏心量(e0 は常に 0)", + "signCamber": "キャンバー符号を反転", + "signCaster": "キャスター符号を反転", + "mirrorRight": "右側の角度をミラー" + } } } diff --git a/src/plugins/tools/locales/pt-BR.json b/src/plugins/tools/locales/pt-BR.json index 2890cd7b..5caa04f5 100644 --- a/src/plugins/tools/locales/pt-BR.json +++ b/src/plugins/tools/locales/pt-BR.json @@ -123,5 +123,87 @@ "statusRecording": "Gravando", "statusEnded": "Encerrada", "statusWaiting": "Aguardando" + }, + "pillAlignment": { + "name": "Alinhamento por excêntricos", + "description": "Calculadora de cambagem e caster por excêntricos, com solucionador de acerto.", + "badge": "Experimental" + }, + "pill": { + "linkSides": "Vincular esq./dir.", + "snapHoles": "Encaixar nos furos", + "loadFromSetup": "Carregar do acerto da sessão", + "sideLeft": "Esquerda", + "sideRight": "Direita", + "sideL": "E", + "sideR": "D", + "colorMode": { + "trackDelta": "Δ bitola", + "thetaTop": "Ângulo excêntrico sup.", + "resultantToe": "Convergência resultante" + }, + "envelope": { + "xLabel": "Cambagem (°)", + "yLabel": "Caster (°)", + "dragHint": "Arraste o marcador para resolver os ângulos dos excêntricos para essa cambagem/caster. Laranja = trajetória do excêntrico superior, magenta = inferior." + }, + "dial": { + "topLeft": "Esq. · Superior", + "topRight": "Dir. · Superior", + "bottomLeft": "Esq. · Inferior", + "bottomRight": "Dir. · Inferior", + "size": "Tamanho do excêntrico", + "aria": "Ângulo do excêntrico {{label}}" + }, + "readout": { + "camber": "Cambagem", + "caster": "Caster", + "track": "Bitola", + "totalTrack": "Variação total da bitola: {{value}} mm" + }, + "toe": { + "title": "Convergência", + "usePerSide": "Inserir por lado", + "useRod": "Inserir variação do tirante", + "rodDelta": "Δ tirante", + "rArm": "Raio do braço de direção", + "left": "Convergência esq.", + "right": "Convergência dir.", + "out": "DIVERGENTE", + "in": "CONVERGENTE", + "neutral": "Convergência neutra" + }, + "findSetup": { + "title": "Encontrar acerto", + "targetCamber": "Cambagem alvo", + "targetCaster": "Caster alvo", + "solve": "Encontrar posições dos excêntricos", + "apply": "Aplicar", + "residual": "desvio", + "noResults": "Esse alvo está fora do alcance destes excêntricos. Tente menos cambagem/caster ou excêntricos maiores.", + "top": "Sup", + "bottom": "Inf" + }, + "cal": { + "title": "Calibração do chassi", + "disclaimer": "Estas constantes são padrões aproximados — as excentricidades reais variam por chassi e não são publicadas. Confira com um medidor de cambagem/caster antes de confiar nos números e ajuste o que não bater.", + "preset": "Predefinição", + "presetGeneric": "Kart genérico", + "presetOtk": "OTK (aprox.)", + "presetCustom": "Personalizado", + "resetPreset": "Restaurar predefinição", + "h": "Distância entre alojamentos H", + "lRim": "Vão do medidor", + "split": "Fração da altura da roda", + "neutralX": "Deslocamento neutro à frente", + "neutralY": "Deslocamento neutro para fora", + "gamma0": "Cambagem de fábrica", + "holeCount": "Furos por excêntrico", + "toeCoupling": "Acoplamento de convergência", + "eccentricity": "Excentricidade por tamanho (e0 é sempre 0)", + "signCamber": "Inverter sinal da cambagem", + "signCaster": "Inverter sinal do caster", + "mirrorRight": "Espelhar ângulos do lado direito" + } } } diff --git a/src/plugins/tools/pill-alignment/CalibrationPanel.tsx b/src/plugins/tools/pill-alignment/CalibrationPanel.tsx new file mode 100644 index 00000000..848c5b3f --- /dev/null +++ b/src/plugins/tools/pill-alignment/CalibrationPanel.tsx @@ -0,0 +1,121 @@ +// Chassis calibration editor (plan 0011). Real OTK eccentricities aren't +// published, so every constant is editable; presets are approximate starting +// points and any hand edit detaches the preset. + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useToolsT, type ToolsKey } from "../i18n"; +import { NumRow } from "../shared/NumRow"; +import { CHASSIS_PRESETS, type PillCalibration } from "./model"; + +interface CalibrationPanelProps { + cal: PillCalibration; + presetId: string | null; + onChange: (cal: PillCalibration, presetId: string | null) => void; +} + +const PRESET_NAME_KEYS: Record = { + generic: "pill.cal.presetGeneric", + "otk-approx": "pill.cal.presetOtk", +}; + +export function CalibrationPanel({ cal, presetId, onChange }: CalibrationPanelProps) { + const t = useToolsT(); + const edit = (patch: Partial) => onChange({ ...cal, ...patch }, null); + const editE = (i: number, v: number) => { + const eMm = [...cal.eMm] as PillCalibration["eMm"]; + eMm[i] = Math.max(v, 0); + edit({ eMm }); + }; + + return ( +
+

{t("pill.cal.disclaimer")}

+ +
+
+ + +
+ {presetId === null && ( + + )} +
+ +
+ edit({ hMm: Math.max(v, 10) })} /> + edit({ lRimMm: Math.max(v, 10) })} step={5} /> + edit({ wheelFrac: Math.min(Math.max(v, 0), 1) })} step={0.05} /> + edit({ nXMm: v })} step={0.1} /> + edit({ nYMm: v })} step={0.1} /> + edit({ gamma0Deg: v })} step={0.1} /> + edit({ holeCount: Math.max(Math.round(v), 0) })} /> + edit({ toeCouplingMmPerMm: v })} step={0.1} /> +
+ +
+ +
+ {cal.eMm.map((e, i) => ( + editE(i, v)} + // e0 is concentric by definition; editing it would break the solver's degenerate cases. + className={i === 0 ? "pointer-events-none opacity-50" : undefined} + /> + ))} +
+
+ +
+ + + +
+
+ ); +} diff --git a/src/plugins/tools/pill-alignment/EnvelopePlot.tsx b/src/plugins/tools/pill-alignment/EnvelopePlot.tsx new file mode 100644 index 00000000..b874e341 --- /dev/null +++ b/src/plugins/tools/pill-alignment/EnvelopePlot.tsx @@ -0,0 +1,300 @@ +// Camber/caster reachable-envelope scatter (plan 0011). +// +// Two stacked canvases (GGDiagram pattern): the static layer holds the ~32k +// swept dots + the two single-pill loci and only redraws when the sweep, +// colors, or geometry change; the overlay holds the current-setpoint marker so +// dial edits and drags never repaint the cloud. Dots are batched per color +// bucket, so the whole cloud costs ≤20 fillStyle changes. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { getChartColors } from "@/lib/chartColors"; +import { prepare2dCanvas } from "@/lib/canvas2d"; +import { + ENVELOPE_BUCKET_COUNT, + colorBucket, + colorForBucket, + colorMetric, + type EnvelopePoint, +} from "./envelope"; +import type { EnvelopeColorMode, PillCalibration, Side, ToeState } from "./model"; + +interface EnvelopePlotProps { + points: EnvelopePoint[]; + loci: { top: Array<{ x: number; y: number }>; bottom: Array<{ x: number; y: number }> }; + colorMode: EnvelopeColorMode; + cal: PillCalibration; + toe: ToeState; + side: Side; + current: { camberDeg: number; casterDeg: number }; + onTarget: (camberDeg: number, casterDeg: number) => void; + darkMode: boolean; + xLabel: string; + yLabel: string; + legendLabel: string; +} + +const TOP_LOCUS_COLOR = "hsl(28, 90%, 55%)"; // orange, matches the reference app +const BOT_LOCUS_COLOR = "hsl(300, 85%, 55%)"; // magenta + +const MARGIN = { left: 34, right: 8, top: 8, bottom: 26 }; + +/** ~5 round-numbered ticks across a range. */ +function ticks(min: number, max: number): number[] { + const span = max - min; + if (!(span > 0)) return []; + const raw = span / 5; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => span / s <= 6) ?? 10 * mag; + const out: number[] = []; + for (let v = Math.ceil(min / step) * step; v <= max + 1e-9; v += step) out.push(v); + return out; +} + +export function EnvelopePlot({ + points, + loci, + colorMode, + cal, + toe, + side, + current, + onTarget, + darkMode, + xLabel, + yLabel, + legendLabel, +}: EnvelopePlotProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const overlayRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const chartColors = useMemo(() => getChartColors(darkMode), [darkMode]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height }); + } + }); + ro.observe(container); + return () => ro.disconnect(); + }, []); + + // Data extents padded 10% so boundary dots don't sit on the frame. + const extents = useMemo(() => { + if (points.length === 0) return null; + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + for (const p of points) { + if (p.camberDeg < xMin) xMin = p.camberDeg; + if (p.camberDeg > xMax) xMax = p.camberDeg; + if (p.casterDeg < yMin) yMin = p.casterDeg; + if (p.casterDeg > yMax) yMax = p.casterDeg; + } + const padX = Math.max((xMax - xMin) * 0.1, 0.1); + const padY = Math.max((yMax - yMin) * 0.1, 0.1); + return { xMin: xMin - padX, xMax: xMax + padX, yMin: yMin - padY, yMax: yMax + padY }; + }, [points]); + + // Per-point color buckets + the metric extent for the legend. + const buckets = useMemo(() => { + let min = Infinity; + let max = -Infinity; + const metrics = new Float64Array(points.length); + for (let i = 0; i < points.length; i++) { + const m = colorMetric(points[i], colorMode, cal, toe, side); + metrics[i] = m; + if (m < min) min = m; + if (m > max) max = m; + } + const idx = new Uint8Array(points.length); + for (let i = 0; i < points.length; i++) idx[i] = colorBucket(metrics[i], min, max); + return { idx, min, max }; + }, [points, colorMode, cal, toe, side]); + + const geometry = useMemo(() => { + if (!extents) return null; + const plotW = dimensions.width - MARGIN.left - MARGIN.right; + const plotH = dimensions.height - MARGIN.top - MARGIN.bottom; + if (plotW <= 0 || plotH <= 0) return null; + const sx = (camber: number) => MARGIN.left + ((camber - extents.xMin) / (extents.xMax - extents.xMin)) * plotW; + const sy = (caster: number) => MARGIN.top + (1 - (caster - extents.yMin) / (extents.yMax - extents.yMin)) * plotH; + const invert = (px: number, py: number) => ({ + camberDeg: extents.xMin + ((px - MARGIN.left) / plotW) * (extents.xMax - extents.xMin), + casterDeg: extents.yMin + (1 - (py - MARGIN.top) / plotH) * (extents.yMax - extents.yMin), + }); + return { sx, sy, invert, plotW, plotH }; + }, [extents, dimensions]); + + // Static layer: grid, axes, dot cloud, loci. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || dimensions.width === 0 || dimensions.height === 0) return; + const ctx = prepare2dCanvas(canvas, dimensions.width, dimensions.height, window.devicePixelRatio || 1); + if (!ctx) return; + + ctx.fillStyle = chartColors.background; + ctx.fillRect(0, 0, dimensions.width, dimensions.height); + if (!geometry || !extents) return; + const { sx, sy } = geometry; + + ctx.font = "9px JetBrains Mono, monospace"; + for (const v of ticks(extents.xMin, extents.xMax)) { + const x = sx(v); + ctx.strokeStyle = chartColors.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, MARGIN.top); + ctx.lineTo(x, dimensions.height - MARGIN.bottom); + ctx.stroke(); + ctx.fillStyle = chartColors.axisText; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(v.toFixed(Math.abs(v) < 10 && v % 1 !== 0 ? 1 : 0), x, dimensions.height - MARGIN.bottom + 3); + } + for (const v of ticks(extents.yMin, extents.yMax)) { + const y = sy(v); + ctx.strokeStyle = chartColors.grid; + ctx.beginPath(); + ctx.moveTo(MARGIN.left, y); + ctx.lineTo(dimensions.width - MARGIN.right, y); + ctx.stroke(); + ctx.fillStyle = chartColors.axisText; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + ctx.fillText(v.toFixed(Math.abs(v) < 10 && v % 1 !== 0 ? 1 : 0), MARGIN.left - 4, y); + } + // Zero axes, slightly stronger than the grid. + ctx.strokeStyle = chartColors.zeroLine; + if (extents.xMin < 0 && extents.xMax > 0) { + ctx.beginPath(); + ctx.moveTo(sx(0), MARGIN.top); + ctx.lineTo(sx(0), dimensions.height - MARGIN.bottom); + ctx.stroke(); + } + if (extents.yMin < 0 && extents.yMax > 0) { + ctx.beginPath(); + ctx.moveTo(MARGIN.left, sy(0)); + ctx.lineTo(dimensions.width - MARGIN.right, sy(0)); + ctx.stroke(); + } + + // Dot cloud, one pass per bucket to bound fillStyle churn. + ctx.globalAlpha = 0.75; + for (let b = 0; b < ENVELOPE_BUCKET_COUNT; b++) { + ctx.fillStyle = colorForBucket(b); + for (let i = 0; i < points.length; i++) { + if (buckets.idx[i] !== b) continue; + ctx.fillRect(sx(points[i].camberDeg) - 1, sy(points[i].casterDeg) - 1, 2, 2); + } + } + ctx.globalAlpha = 1; + + // Single-pill loci circles. + const stroke = (path: Array<{ x: number; y: number }>, color: string) => { + if (path.length === 0) return; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(sx(path[0].x), sy(path[0].y)); + for (let i = 1; i < path.length; i++) ctx.lineTo(sx(path[i].x), sy(path[i].y)); + ctx.stroke(); + }; + stroke(loci.top, TOP_LOCUS_COLOR); + stroke(loci.bottom, BOT_LOCUS_COLOR); + + // Axis titles. + ctx.fillStyle = chartColors.axisText; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(xLabel, MARGIN.left + geometry.plotW / 2, dimensions.height - 2); + ctx.save(); + ctx.translate(9, MARGIN.top + geometry.plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText(yLabel, 0, 0); + ctx.restore(); + }, [dimensions, geometry, extents, points, buckets, loci, chartColors, xLabel, yLabel]); + + // Overlay layer: the current-setpoint marker only. + useEffect(() => { + const canvas = overlayRef.current; + if (!canvas || dimensions.width === 0 || dimensions.height === 0) return; + const ctx = prepare2dCanvas(canvas, dimensions.width, dimensions.height, window.devicePixelRatio || 1); + if (!ctx) return; + ctx.clearRect(0, 0, dimensions.width, dimensions.height); + if (!geometry) return; + const x = geometry.sx(current.camberDeg); + const y = geometry.sy(current.casterDeg); + ctx.strokeStyle = chartColors.scrubCursor; + ctx.fillStyle = chartColors.background; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - 10, y); + ctx.lineTo(x + 10, y); + ctx.moveTo(x, y - 10); + ctx.lineTo(x, y + 10); + ctx.lineWidth = 1; + ctx.stroke(); + }, [current, dimensions, geometry, chartColors]); + + // Drag the setpoint: pointer capture so the drag survives leaving the plot. + const dragging = useRef(false); + const rafRef = useRef(0); + const emitTarget = (clientX: number, clientY: number) => { + const container = containerRef.current; + if (!container || !geometry) return; + const rect = container.getBoundingClientRect(); + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + const { camberDeg, casterDeg } = geometry.invert(clientX - rect.left, clientY - rect.top); + onTarget(camberDeg, casterDeg); + }); + }; + + return ( +
+
{ + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + emitTarget(e.clientX, e.clientY); + }} + onPointerMove={(e) => { + if (dragging.current) emitTarget(e.clientX, e.clientY); + }} + onPointerUp={() => { + dragging.current = false; + }} + onPointerCancel={() => { + dragging.current = false; + }} + > + + +
+
+ {buckets.min === Infinity ? "" : buckets.min.toFixed(1)} +
+ {buckets.max === -Infinity ? "" : buckets.max.toFixed(1)} + {legendLabel} +
+
+ ); +} diff --git a/src/plugins/tools/pill-alignment/FindSetupPanel.tsx b/src/plugins/tools/pill-alignment/FindSetupPanel.tsx new file mode 100644 index 00000000..53aaff4b --- /dev/null +++ b/src/plugins/tools/pill-alignment/FindSetupPanel.tsx @@ -0,0 +1,82 @@ +// "Find Setup" inverse-solver panel (plan 0011): target camber/caster in, +// ranked pill combinations out, one tap to apply. + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useToolsT } from "../i18n"; +import { NumRow } from "../shared/NumRow"; +import { findSetups, DEFAULT_SOLVE_OPTIONS, type SetupCandidate } from "./inverse"; +import { holeIndex, type CornerPills, type PillCalibration, type Side } from "./model"; + +interface FindSetupPanelProps { + cal: PillCalibration; + side: Side; + snapHoles: boolean; + currentCasterDeg: number; + /** Seed values pulled from the session setup, if any. */ + seedCamberDeg?: number | null; + seedCasterDeg?: number | null; + onApply: (pills: CornerPills) => void; +} + +export function FindSetupPanel({ cal, side, snapHoles, currentCasterDeg, seedCamberDeg, seedCasterDeg, onApply }: FindSetupPanelProps) { + const t = useToolsT(); + const [camber, setCamber] = useState(seedCamberDeg ?? 0); + const [caster, setCaster] = useState(seedCasterDeg ?? null); + const [results, setResults] = useState(null); + + const solve = () => { + setResults( + findSetups(cal, { camberDeg: camber, casterDeg: caster ?? currentCasterDeg }, side, { + ...DEFAULT_SOLVE_OPTIONS, + snapHoles, + }), + ); + }; + + const angleText = (deg: number) => + cal.holeCount > 0 ? `${Math.round(deg)}° · #${holeIndex(deg, cal.holeCount)}` : `${Math.round(deg)}°`; + + return ( +
+
+ + +
+ + {results !== null && ( +
+ {results.length === 0 &&

{t("pill.findSetup.noResults")}

} + {results.map((r, i) => ( +
+
+

+ {t("pill.findSetup.top")} {r.pills.sTop} @ {angleText(r.pills.thetaTopDeg)} ·{" "} + {t("pill.findSetup.bottom")} {r.pills.sBot} @ {angleText(r.pills.thetaBotDeg)} +

+

+ {r.result.camberDeg.toFixed(2)}° / {r.result.casterDeg.toFixed(2)}° ·{" "} + {t("pill.findSetup.residual")} {r.residualDeg.toFixed(2)}° · Δ{r.result.trackDeltaMm.toFixed(1)}mm +

+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/plugins/tools/pill-alignment/OverheadToeView.tsx b/src/plugins/tools/pill-alignment/OverheadToeView.tsx new file mode 100644 index 00000000..54ea4f7b --- /dev/null +++ b/src/plugins/tools/pill-alignment/OverheadToeView.tsx @@ -0,0 +1,41 @@ +// Overhead toe widget (plan 0011, phase 2): the two front wheels seen from +// above, steered by their per-side toe angles. Angles are exaggerated so a +// couple of millimetres reads visually; numbers carry the truth. + +import { toeDegFromMm } from "./toe"; + +interface OverheadToeViewProps { + leftToeMm: number; + rightToeMm: number; + lRimMm: number; + /** "TOE OUT" / "TOE IN" / neutral caption, localized by the parent. */ + caption: string; +} + +const EXAGGERATION = 6; + +export function OverheadToeView({ leftToeMm, rightToeMm, lRimMm, caption }: OverheadToeViewProps) { + const leftDeg = toeDegFromMm(leftToeMm, lRimMm) * EXAGGERATION; + const rightDeg = toeDegFromMm(rightToeMm, lRimMm) * EXAGGERATION; + + // Toe OUT (negative) = leading edges apart: left wheel noses left (negative + // screen rotation), right wheel noses right. + return ( + + {/* Direction of travel */} + + + + + + + + + + + + {caption} + + + ); +} diff --git a/src/plugins/tools/pill-alignment/PillAlignmentTool.tsx b/src/plugins/tools/pill-alignment/PillAlignmentTool.tsx new file mode 100644 index 00000000..51436c36 --- /dev/null +++ b/src/plugins/tools/pill-alignment/PillAlignmentTool.tsx @@ -0,0 +1,386 @@ +// Pill alignment calculator (plan 0011) — eccentric kingpin pills → camber / +// caster / track width, with an inverse "Find Setup" solver. +// +// All geometry lives in the pure model/inverse/envelope/toe modules; this file +// is rendering + state. Settings persist to the tools plugin store so a +// calibrated chassis survives reloads and works fully offline trackside. The +// tool renders identically in-session and on the landing page — the only +// session tie-in is the optional "load from setup" seed. + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useOptionalSettingsContext } from "@/contexts/SettingsContext"; +import type { PluginPanelProps } from "@/plugins/panels"; +import { getPluginStore } from "@/plugins/storage"; +import { getTemplate } from "@/lib/templateStorage"; +import { useToolsT, type ToolsKey } from "../i18n"; +import { NumRow, Section } from "../shared/NumRow"; +import { + DEFAULT_STATE, + forwardCorner, + holeIndex, + normalizeDeg, + snapToHole, + PILL_SIZES, + type CornerPills, + type EnvelopeColorMode, + type PersistedStateV1, + type PillSize, + type Side, +} from "./model"; +import { nearestAngles } from "./inverse"; +import { singlePillLoci, sweepEnvelope } from "./envelope"; +import { resolveSetupAlignmentFields, type SetupAlignmentValues } from "./toe"; +import { EnvelopePlot } from "./EnvelopePlot"; +import { PillDial } from "./PillDial"; +import { FindSetupPanel } from "./FindSetupPanel"; +import { OverheadToeView } from "./OverheadToeView"; +import { CalibrationPanel } from "./CalibrationPanel"; + +const STORE_KEY = "pill-alignment:v1"; + +const COLOR_MODE_KEYS: Record = { + trackDelta: "pill.colorMode.trackDelta", + thetaTop: "pill.colorMode.thetaTop", + resultantToe: "pill.colorMode.resultantToe", +}; + +function signed(value: number, digits: number): string { + const r = value.toFixed(digits); + return value >= 0 ? `+${r}` : r.replace("-", "−"); +} + +export default function PillAlignmentTool({ sessionSetup }: PluginPanelProps) { + const t = useToolsT(); + const settings = useOptionalSettingsContext(); + const darkMode = settings?.darkMode ?? true; + const store = useMemo(() => getPluginStore("tools"), []); + + const [state, setState] = useState(DEFAULT_STATE); + const [loaded, setLoaded] = useState(false); + const [setupSeed, setSetupSeed] = useState(null); + + useEffect(() => { + let active = true; + store + .get(STORE_KEY) + .then((saved) => { + if (active && saved) { + setState({ + ...DEFAULT_STATE, + ...saved, + calibration: { ...DEFAULT_STATE.calibration, ...saved.calibration }, + corners: { + left: { ...DEFAULT_STATE.corners.left, ...saved.corners?.left }, + right: { ...DEFAULT_STATE.corners.right, ...saved.corners?.right }, + }, + toe: { ...DEFAULT_STATE.toe, ...saved.toe }, + }); + } + }) + .catch(() => undefined) + .finally(() => { + if (active) setLoaded(true); + }); + return () => { + active = false; + }; + }, [store]); + + useEffect(() => { + if (!loaded) return; + const timer = setTimeout(() => { + void store.set(STORE_KEY, state satisfies PersistedStateV1).catch(() => undefined); + }, 400); + return () => clearTimeout(timer); + }, [state, loaded, store]); + + const { calibration: cal, corners, linked, activeSide, colorMode, snapHoles, toe } = state; + const active = corners[activeSide]; + + const setCorner = (side: Side, pills: CornerPills) => + setState((s) => ({ + ...s, + corners: s.linked ? { left: pills, right: pills } : { ...s.corners, [side]: pills }, + })); + + const results = useMemo( + () => ({ + left: forwardCorner(cal, corners.left, "left"), + right: forwardCorner(cal, corners.right, "right"), + }), + [cal, corners], + ); + + const envelope = useMemo( + () => sweepEnvelope(cal, active.sTop, active.sBot, activeSide), + [cal, active.sTop, active.sBot, activeSide], + ); + const loci = useMemo( + () => singlePillLoci(cal, active.sTop, active.sBot, activeSide), + [cal, active.sTop, active.sBot, activeSide], + ); + + const onEnvelopeTarget = (camberDeg: number, casterDeg: number) => + setCorner(activeSide, nearestAngles(cal, active, { camberDeg, casterDeg }, activeSide, snapHoles)); + + const loadFromSetup = async () => { + if (!sessionSetup) return; + const template = await getTemplate(sessionSetup.templateId).catch(() => null); + const values = resolveSetupAlignmentFields(template, sessionSetup.customFields); + setSetupSeed(values); + if (values.toe !== null) { + const toeMm = values.toe; + setState((s) => ({ ...s, toe: { ...s.toe, mode: "perSide", leftToeMm: toeMm, rightToeMm: toeMm } })); + } + }; + + const stepDeg = cal.holeCount > 0 ? 360 / cal.holeCount : 5; + + const dialCell = (side: Side, bore: "top" | "bot") => { + const pills = corners[side]; + const size = bore === "top" ? pills.sTop : pills.sBot; + const angle = bore === "top" ? pills.thetaTopDeg : pills.thetaBotDeg; + const setAngle = (deg: number) => + setCorner(side, bore === "top" ? { ...pills, thetaTopDeg: deg } : { ...pills, thetaBotDeg: deg }); + const setSize = (s: PillSize) => + setCorner(side, bore === "top" ? { ...pills, sTop: s } : { ...pills, sBot: s }); + const mirrored = linked && side === "right"; + const label = t( + bore === "top" + ? side === "left" + ? "pill.dial.topLeft" + : "pill.dial.topRight" + : side === "left" + ? "pill.dial.bottomLeft" + : "pill.dial.bottomRight", + ); + + return ( +
+

{label}

+ setAngle(snapHoles ? snapToHole(deg, cal.holeCount) : deg)} + ariaLabel={t("pill.dial.aria", { label })} + disabled={mirrored} + /> +
+ + + + + {Math.round(normalizeDeg(angle))}° + {cal.holeCount > 0 && #{holeIndex(angle, cal.holeCount)}} + +
+
+ ); + }; + + const readoutRow = (side: Side) => { + const r = results[side]; + return ( +
+ {t(side === "left" ? "pill.sideL" : "pill.sideR")} + {signed(r.camberDeg, 2)}° + {signed(r.camberMm, 1)}mm + {signed(r.casterDeg, 2)}° + Δ{signed(r.trackDeltaMm, 1)}mm +
+ ); + }; + + const toeCaption = + toe.leftToeMm + toe.rightToeMm < -0.05 + ? t("pill.toe.out") + : toe.leftToeMm + toe.rightToeMm > 0.05 + ? t("pill.toe.in") + : t("pill.toe.neutral"); + + return ( +
+
+
+ + + {sessionSetup && ( + + )} + {t("pillAlignment.badge")} +
+ +
+
+
+
+ {!linked && ( +
+ {(["left", "right"] as const).map((side) => ( + + ))} +
+ )} + +
+ +

{t("pill.envelope.dragHint")}

+
+ +
+ {dialCell("left", "top")} + {dialCell("right", "top")} + {dialCell("left", "bot")} + {dialCell("right", "bot")} +
+
+ +
+
+
+ + {t("pill.readout.camber")} + mm + {t("pill.readout.caster")} + {t("pill.readout.track")} +
+ {readoutRow("left")} + {readoutRow("right")} +

+ {t("pill.readout.totalTrack", { value: signed(results.left.trackDeltaMm + results.right.trackDeltaMm, 2) })} +

+
+ +
+
+

{t("pill.toe.title")}

+ +
+ {toe.mode === "rod" ? ( +
+ setState((s) => ({ ...s, toe: { ...s.toe, rodDeltaMm: v } }))} /> + setState((s) => ({ ...s, toe: { ...s.toe, rArmMm: Math.max(v, 1) } }))} /> +
+ ) : ( +
+ setState((s) => ({ ...s, toe: { ...s.toe, leftToeMm: v } }))} /> + setState((s) => ({ ...s, toe: { ...s.toe, rightToeMm: v } }))} /> +
+ )} + +
+
+
+ +
+ setCorner(activeSide, pills)} + /> +
+ +
+ setState((s) => ({ ...s, calibration, presetId }))} + /> +
+
+
+ ); +} diff --git a/src/plugins/tools/pill-alignment/PillDial.tsx b/src/plugins/tools/pill-alignment/PillDial.tsx new file mode 100644 index 00000000..b01fa6dc --- /dev/null +++ b/src/plugins/tools/pill-alignment/PillDial.tsx @@ -0,0 +1,153 @@ +// One eccentric-pill hub dial (plan 0011): SVG rendering of where to +// physically rotate a pill, with drag-to-rotate and arrow-key stepping. +// +// Screen orientation matches standing over the kart: forward is up, outboard +// points away from the centerline (screen-left for the left corner). The dot +// marks the dial angle; the offset bore circle shows where the kingpin hole +// sits for the chosen pill size. + +import { useCallback, useRef } from "react"; +import { holeIndex, normalizeDeg, snapToHole, type PillSize, type Side } from "./model"; + +interface PillDialProps { + side: Side; + size: PillSize; + angleDeg: number; + holeCount: number; + snap: boolean; + /** Eccentricity of the current size (mm), for the bore-offset visual. */ + eccentricityMm: number; + maxEccentricityMm: number; + onAngle: (angleDeg: number) => void; + ariaLabel: string; + disabled?: boolean; +} + +const VIEW = 100; +const C = VIEW / 2; +const HUB_R = 44; +const HOLE_RING_R = 36; +const BORE_R = 15; + +export function PillDial({ + side, + size, + angleDeg, + holeCount, + snap, + eccentricityMm, + maxEccentricityMm, + onAngle, + ariaLabel, + disabled, +}: PillDialProps) { + const svgRef = useRef(null); + + // Dial angle (0°=forward, + toward outboard) → screen point at radius r. + const toScreen = useCallback( + (deg: number, r: number): { x: number; y: number } => { + const rad = (deg * Math.PI) / 180; + const fwd = Math.cos(rad); + const out = Math.sin(rad); + return { x: C + (side === "left" ? -out : out) * r, y: C - fwd * r }; + }, + [side], + ); + + const angleFromPointer = useCallback( + (clientX: number, clientY: number): number => { + const svg = svgRef.current; + if (!svg) return angleDeg; + const rect = svg.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * VIEW - C; + const y = ((clientY - rect.top) / rect.height) * VIEW - C; + const fwd = -y; + const out = side === "left" ? -x : x; + const deg = normalizeDeg((Math.atan2(out, fwd) * 180) / Math.PI); + return snap ? snapToHole(deg, holeCount) : Math.round(deg); + }, + [angleDeg, side, snap, holeCount], + ); + + const dragging = useRef(false); + + const stepDeg = holeCount > 0 ? 360 / holeCount : 5; + const offsetPx = maxEccentricityMm > 0 ? (eccentricityMm / maxEccentricityMm) * 9 : 0; + const boreCenter = toScreen(angleDeg, offsetPx); + const dot = toScreen(angleDeg, HOLE_RING_R); + const holes = holeCount > 0 + ? Array.from({ length: holeCount }, (_, i) => toScreen((i * 360) / holeCount, HOLE_RING_R)) + : []; + const activeHole = holeIndex(angleDeg, holeCount); + + return ( + { + if (disabled) return; + if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + onAngle(normalizeDeg(angleDeg + stepDeg)); + } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + onAngle(normalizeDeg(angleDeg - stepDeg)); + } + }} + onPointerDown={(e) => { + if (disabled) return; + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + onAngle(angleFromPointer(e.clientX, e.clientY)); + }} + onPointerMove={(e) => { + if (!dragging.current || disabled) return; + onAngle(angleFromPointer(e.clientX, e.clientY)); + }} + onPointerUp={() => { + dragging.current = false; + }} + onPointerCancel={() => { + dragging.current = false; + }} + > + {/* Hub body */} + + {/* Index holes (or a plain ring for free pills) */} + {holes.length > 0 ? ( + holes.map((h, i) => ( + + )) + ) : ( + + )} + {/* Eccentric bore (kingpin hole), offset by the pill's eccentricity */} + + {size === 0 ? ( + + ) : ( + <> + {/* Dot-direction pointer + the pill's dot itself */} + + + + )} + {/* Forward marker at 12 o'clock */} + + + ); +} diff --git a/src/plugins/tools/seat-position/SeatPositionTool.tsx b/src/plugins/tools/seat-position/SeatPositionTool.tsx index 88c95115..a216ff5a 100644 --- a/src/plugins/tools/seat-position/SeatPositionTool.tsx +++ b/src/plugins/tools/seat-position/SeatPositionTool.tsx @@ -5,17 +5,16 @@ // plugin's own IndexedDB store, so a calibrated setup survives reloads and // works fully offline trackside. -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Trans } from "react-i18next"; -import { ChevronDown, RotateCcw, Crosshair } from "lucide-react"; +import { RotateCcw, Crosshair } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { NumberInput } from "@/components/ui/number-input"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import type { PluginPanelProps } from "@/plugins/panels"; import { getPluginStore } from "@/plugins/storage"; +import { NumRow, Section } from "../shared/NumRow"; import { SeatDiagram } from "./SeatDiagram"; import { TOOLS_NS, useToolsT } from "../i18n"; import { @@ -77,61 +76,6 @@ function signed(value: number, digits: number): string { return value >= 0 ? `+${r}` : r.replace("-", "−"); } -/** Number field that doesn't fight the keyboard: commits parseable input, resyncs on outside change. */ -function NumRow({ label, value, onChange, unit, step = 1, className }: { - label: string; - value: number; - onChange: (v: number) => void; - unit?: string; - step?: number; - className?: string; -}) { - const display = useMemo(() => String(Number(value.toFixed(2))), [value]); - const [text, setText] = useState(display); - const editing = useRef(false); - useEffect(() => { - if (!editing.current) setText(display); - }, [display]); - return ( -
- - { - editing.current = true; - }} - onBlur={() => { - editing.current = false; - setText(display); - }} - onValueChange={(raw) => { - setText(raw); - const v = parseFloat(raw); - if (Number.isFinite(v)) onChange(v); - }} - /> -
- ); -} - -function Section({ title, defaultOpen, children }: { title: string; defaultOpen?: boolean; children: ReactNode }) { - const [open, setOpen] = useState(!!defaultOpen); - return ( - - - {title} - - - {children} - - ); -} - export default function SeatPositionTool(_props: PluginPanelProps) { const t = useToolsT(); const store = useMemo(() => getPluginStore("tools"), []); diff --git a/src/plugins/tools/shared/NumRow.tsx b/src/plugins/tools/shared/NumRow.tsx new file mode 100644 index 00000000..696dd070 --- /dev/null +++ b/src/plugins/tools/shared/NumRow.tsx @@ -0,0 +1,63 @@ +// Shared tool-form primitives, lifted from the seat-position tool so every +// calculator gets the same committed-number-input behaviour and section chrome. + +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { NumberInput } from "@/components/ui/number-input"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; + +/** Number field that doesn't fight the keyboard: commits parseable input, resyncs on outside change. */ +export function NumRow({ label, value, onChange, unit, step = 1, className }: { + label: string; + value: number; + onChange: (v: number) => void; + unit?: string; + step?: number; + className?: string; +}) { + const display = useMemo(() => String(Number(value.toFixed(2))), [value]); + const [text, setText] = useState(display); + const editing = useRef(false); + useEffect(() => { + if (!editing.current) setText(display); + }, [display]); + return ( +
+ + { + editing.current = true; + }} + onBlur={() => { + editing.current = false; + setText(display); + }} + onValueChange={(raw) => { + setText(raw); + const v = parseFloat(raw); + if (Number.isFinite(v)) onChange(v); + }} + /> +
+ ); +} + +export function Section({ title, defaultOpen, children }: { title: string; defaultOpen?: boolean; children: ReactNode }) { + const [open, setOpen] = useState(!!defaultOpen); + return ( + + + {title} + + + {children} + + ); +} diff --git a/src/plugins/tools/toolList.ts b/src/plugins/tools/toolList.ts index ad9506ba..e5194c96 100644 --- a/src/plugins/tools/toolList.ts +++ b/src/plugins/tools/toolList.ts @@ -4,7 +4,7 @@ // the loaded session, but most are standalone calculators that ignore it. import { lazy, type ComponentType } from "react"; -import { Armchair, Satellite } from "lucide-react"; +import { Armchair, Compass, Satellite } from "lucide-react"; import type { PluginPanelProps } from "@/plugins/panels"; import type { ToolsKey } from "./i18n"; @@ -39,4 +39,12 @@ export const TOOLS: ToolDef[] = [ icon: Satellite, component: lazy(() => import("./laptimer/LapTimerTool")), }, + { + id: "pill-alignment", + nameKey: "pillAlignment.name", + descriptionKey: "pillAlignment.description", + badgeKey: "pillAlignment.badge", + icon: Compass, + component: lazy(() => import("./pill-alignment/PillAlignmentTool")), + }, ]; From 05e1053cd8e81609fcccecf3d17d7c885ddb9f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:46:10 +0000 Subject: [PATCH 3/3] plan 0011: modular chassis-profile system + measurement helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calibration constants become first-class ChassisProfile records instead of two hardcoded presets, so brands get added as they're measured. Built-ins cover OTK/Tony Kart, Kart Republic, CompKart, Birel ART, Praga, and Sodi — all flagged 'estimated' until real numbers exist. The calibration panel gains measurement helpers (dial-indicator sweep → e = sweep/2; zero-point back-out of the built-in offsets from gauge readings at size-0 pills) and a 'save as measured profile' flow that freezes the current constants as a named user profile in the plugin store. Legacy preset ids migrate on load. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D2gb7LNbhFeapCaKAn9tvH --- CHANGELOG.md | 7 +- docs/plans/0011-kart-pill-alignment-tool.md | 24 ++- src/plugins/tools/locales/de.json | 25 ++- src/plugins/tools/locales/en.json | 25 ++- src/plugins/tools/locales/es.json | 25 ++- src/plugins/tools/locales/fr.json | 25 ++- src/plugins/tools/locales/it.json | 25 ++- src/plugins/tools/locales/ja.json | 25 ++- src/plugins/tools/locales/pt-BR.json | 25 ++- .../tools/pill-alignment/CalibrationPanel.tsx | 182 +++++++++++++++--- .../pill-alignment/PillAlignmentTool.tsx | 41 +++- src/plugins/tools/pill-alignment/model.ts | 47 +++-- .../tools/pill-alignment/profiles.test.ts | 106 ++++++++++ src/plugins/tools/pill-alignment/profiles.ts | 109 +++++++++++ 14 files changed, 587 insertions(+), 104 deletions(-) create mode 100644 src/plugins/tools/pill-alignment/profiles.test.ts create mode 100644 src/plugins/tools/pill-alignment/profiles.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb56d2b..def4ddc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 - calibration is fully editable with approximate presets; works offline and - from the homepage Tools drawer. + 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. - **Simulator: load your own `.dovex`.** The `/simulator` page now has a session picker — feed the firmware any dove-family log (`.dovex`/`.dovep`/`.dove`) instead of only the bundled demo, with a diff --git a/docs/plans/0011-kart-pill-alignment-tool.md b/docs/plans/0011-kart-pill-alignment-tool.md index 50379211..961fd1fe 100644 --- a/docs/plans/0011-kart-pill-alignment-tool.md +++ b/docs/plans/0011-kart-pill-alignment-tool.md @@ -26,12 +26,21 @@ constants). This plan records how it landed in the codebase. `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`. -- **Editable calibration with approximate presets.** Real OTK eccentricities - (mm offset per dot count) are not published. Every chassis constant (H, - e[0..5], neutral offset n, γ0, L_rim, wheel-height fraction f, signs, hole - count) is user-editable in an advanced section; presets are labelled - "(approx)" and the tool carries an Experimental badge. A least-squares - "calibrate to my kart" wizard is a future follow-up. +- **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 @@ -55,7 +64,8 @@ constants). This plan records how it landed in the codebase. ``` src/plugins/tools/pill-alignment/ -├── model.ts / model.test.ts types, calibration+presets, forwardCorner, snapToHole, persisted state +├── 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) diff --git a/src/plugins/tools/locales/de.json b/src/plugins/tools/locales/de.json index 9b17bdd6..7ae4e0aa 100644 --- a/src/plugins/tools/locales/de.json +++ b/src/plugins/tools/locales/de.json @@ -187,11 +187,6 @@ "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.", - "preset": "Voreinstellung", - "presetGeneric": "Generisches Kart", - "presetOtk": "OTK (ca.)", - "presetCustom": "Benutzerdefiniert", - "resetPreset": "Auf Voreinstellung zurücksetzen", "h": "Bohrungsabstand H", "lRim": "Messweite der Lehre", "split": "Radhöhenanteil", @@ -203,7 +198,25 @@ "eccentricity": "Exzentrizität je Größe (e0 ist immer 0)", "signCamber": "Sturz-Vorzeichen umkehren", "signCaster": "Nachlauf-Vorzeichen umkehren", - "mirrorRight": "Winkel rechts spiegeln" + "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" } } } diff --git a/src/plugins/tools/locales/en.json b/src/plugins/tools/locales/en.json index 69569674..5de03d60 100644 --- a/src/plugins/tools/locales/en.json +++ b/src/plugins/tools/locales/en.json @@ -186,11 +186,6 @@ "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.", - "preset": "Preset", - "presetGeneric": "Generic kart", - "presetOtk": "OTK (approx)", - "presetCustom": "Custom", - "resetPreset": "Reset to preset", "h": "Bore gap H", "lRim": "Gauge span", "split": "Wheel height fraction", @@ -202,7 +197,25 @@ "eccentricity": "Eccentricity per pill size (e0 is always 0)", "signCamber": "Flip camber sign", "signCaster": "Flip caster sign", - "mirrorRight": "Mirror right-side angles" + "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" } } } diff --git a/src/plugins/tools/locales/es.json b/src/plugins/tools/locales/es.json index 6bc94dc1..115e1ce7 100644 --- a/src/plugins/tools/locales/es.json +++ b/src/plugins/tools/locales/es.json @@ -187,11 +187,6 @@ "cal": { "title": "Calibración del chasis", "disclaimer": "Estas constantes son valores aproximados por defecto: las excentricidades reales varían según el chasis y no están publicadas. Verifica con un medidor de caída/avance antes de fiarte de los números y edita lo que no cuadre.", - "preset": "Preajuste", - "presetGeneric": "Kart genérico", - "presetOtk": "OTK (aprox.)", - "presetCustom": "Personalizado", - "resetPreset": "Restablecer al preajuste", "h": "Separación de alojamientos H", "lRim": "Luz del medidor", "split": "Fracción de altura de rueda", @@ -203,7 +198,25 @@ "eccentricity": "Excentricidad por tamaño (e0 siempre es 0)", "signCamber": "Invertir signo de caída", "signCaster": "Invertir signo de avance", - "mirrorRight": "Reflejar ángulos del lado derecho" + "mirrorRight": "Reflejar ángulos del lado derecho", + "profile": "Perfil de chasis", + "profileCustom": "Personalizado (sin guardar)", + "userProfiles": "Mis perfiles medidos", + "builtinProfiles": "Integrados", + "estimated": "estimado", + "measured": "medido", + "profileName": "Nombre del perfil", + "profileNamePlaceholder": "p. ej. Mi Praga — julio 2026", + "saveProfile": "Guardar como perfil medido", + "deleteProfile": "Eliminar perfil", + "measureTitle": "Ayudas de medición", + "sweep": "Recorrido del reloj en 180°", + "sweepSize": "Tamaño de excéntrica", + "applySweep": "Fijar e = recorrido ÷ 2", + "zeroIntro": "Con ambas excéntricas en tamaño 0, introduce las lecturas del medidor para deducir los desplazamientos de fábrica del chasis.", + "zeroCamber": "Caída medida", + "zeroCaster": "Avance medido", + "applyZero": "Fijar punto cero" } } } diff --git a/src/plugins/tools/locales/fr.json b/src/plugins/tools/locales/fr.json index 566be695..4b67ceef 100644 --- a/src/plugins/tools/locales/fr.json +++ b/src/plugins/tools/locales/fr.json @@ -187,11 +187,6 @@ "cal": { "title": "Calibration du châssis", "disclaimer": "Ces constantes sont des valeurs approximatives par défaut : les excentricités réelles varient selon le châssis et ne sont pas publiées. Vérifiez avec une jauge de carrossage/chasse avant de vous fier aux chiffres, et modifiez ce qui ne correspond pas.", - "preset": "Préréglage", - "presetGeneric": "Kart générique", - "presetOtk": "OTK (approx.)", - "presetCustom": "Personnalisé", - "resetPreset": "Rétablir le préréglage", "h": "Écart des logements H", "lRim": "Portée de la jauge", "split": "Fraction de hauteur de roue", @@ -203,7 +198,25 @@ "eccentricity": "Excentricité par taille (e0 vaut toujours 0)", "signCamber": "Inverser le signe du carrossage", "signCaster": "Inverser le signe de la chasse", - "mirrorRight": "Miroir des angles côté droit" + "mirrorRight": "Miroir des angles côté droit", + "profile": "Profil de châssis", + "profileCustom": "Personnalisé (non enregistré)", + "userProfiles": "Mes profils mesurés", + "builtinProfiles": "Intégrés", + "estimated": "estimé", + "measured": "mesuré", + "profileName": "Nom du profil", + "profileNamePlaceholder": "ex. Mon Praga — juillet 2026", + "saveProfile": "Enregistrer comme profil mesuré", + "deleteProfile": "Supprimer le profil", + "measureTitle": "Aides à la mesure", + "sweep": "Course du comparateur sur 180°", + "sweepSize": "Taille d'excentrique", + "applySweep": "Définir e = course ÷ 2", + "zeroIntro": "Avec les deux excentriques en taille 0, saisissez les lectures de la jauge pour déduire les décalages d'origine du châssis.", + "zeroCamber": "Carrossage mesuré", + "zeroCaster": "Chasse mesurée", + "applyZero": "Définir le point zéro" } } } diff --git a/src/plugins/tools/locales/it.json b/src/plugins/tools/locales/it.json index eb2a7d53..e378ff7a 100644 --- a/src/plugins/tools/locales/it.json +++ b/src/plugins/tools/locales/it.json @@ -187,11 +187,6 @@ "cal": { "title": "Calibrazione telaio", "disclaimer": "Queste costanti sono valori predefiniti approssimativi: le eccentricità reali variano da telaio a telaio e non sono pubblicate. Verifica con un goniometro campanatura/incidenza prima di fidarti dei numeri e correggi ciò che non torna.", - "preset": "Preimpostazione", - "presetGeneric": "Kart generico", - "presetOtk": "OTK (appross.)", - "presetCustom": "Personalizzato", - "resetPreset": "Ripristina preimpostazione", "h": "Interasse sedi H", "lRim": "Luce del goniometro", "split": "Frazione altezza ruota", @@ -203,7 +198,25 @@ "eccentricity": "Eccentricità per misura (e0 è sempre 0)", "signCamber": "Inverti segno campanatura", "signCaster": "Inverti segno incidenza", - "mirrorRight": "Specchia angoli lato destro" + "mirrorRight": "Specchia angoli lato destro", + "profile": "Profilo telaio", + "profileCustom": "Personalizzato (non salvato)", + "userProfiles": "I miei profili misurati", + "builtinProfiles": "Integrati", + "estimated": "stimato", + "measured": "misurato", + "profileName": "Nome del profilo", + "profileNamePlaceholder": "es. Il mio Praga — luglio 2026", + "saveProfile": "Salva come profilo misurato", + "deleteProfile": "Elimina profilo", + "measureTitle": "Aiuti alla misura", + "sweep": "Corsa del comparatore su 180°", + "sweepSize": "Misura eccentrico", + "applySweep": "Imposta e = corsa ÷ 2", + "zeroIntro": "Con entrambi gli eccentrici a misura 0, inserisci le letture del goniometro per ricavare gli offset di fabbrica del telaio.", + "zeroCamber": "Campanatura misurata", + "zeroCaster": "Incidenza misurata", + "applyZero": "Imposta punto zero" } } } diff --git a/src/plugins/tools/locales/ja.json b/src/plugins/tools/locales/ja.json index 21330c5b..d1ef9b30 100644 --- a/src/plugins/tools/locales/ja.json +++ b/src/plugins/tools/locales/ja.json @@ -187,11 +187,6 @@ "cal": { "title": "シャシー校正", "disclaimer": "これらの定数はあくまで近似の既定値です。実際の偏心量はシャシーごとに異なり、公表されていません。数値を信頼する前にキャンバー/キャスターゲージで確認し、合わない値は編集してください。", - "preset": "プリセット", - "presetGeneric": "汎用カート", - "presetOtk": "OTK(近似)", - "presetCustom": "カスタム", - "resetPreset": "プリセットに戻す", "h": "ボア間隔 H", "lRim": "ゲージ測定幅", "split": "ホイール高さ比率", @@ -203,7 +198,25 @@ "eccentricity": "サイズ別偏心量(e0 は常に 0)", "signCamber": "キャンバー符号を反転", "signCaster": "キャスター符号を反転", - "mirrorRight": "右側の角度をミラー" + "mirrorRight": "右側の角度をミラー", + "profile": "シャシープロファイル", + "profileCustom": "カスタム(未保存)", + "userProfiles": "自分の実測プロファイル", + "builtinProfiles": "内蔵", + "estimated": "推定", + "measured": "実測", + "profileName": "プロファイル名", + "profileNamePlaceholder": "例: 自分のPraga — 2026年7月", + "saveProfile": "実測プロファイルとして保存", + "deleteProfile": "プロファイルを削除", + "measureTitle": "測定ヘルパー", + "sweep": "180°回転時のダイヤルゲージ振れ", + "sweepSize": "ピルサイズ", + "applySweep": "e = 振れ ÷ 2 を設定", + "zeroIntro": "両方のピルをサイズ0にした状態でゲージの読みを入力すると、シャシー固有のオフセットを逆算します。", + "zeroCamber": "実測キャンバー", + "zeroCaster": "実測キャスター", + "applyZero": "ゼロ点を設定" } } } diff --git a/src/plugins/tools/locales/pt-BR.json b/src/plugins/tools/locales/pt-BR.json index 5caa04f5..360f149b 100644 --- a/src/plugins/tools/locales/pt-BR.json +++ b/src/plugins/tools/locales/pt-BR.json @@ -187,11 +187,6 @@ "cal": { "title": "Calibração do chassi", "disclaimer": "Estas constantes são padrões aproximados — as excentricidades reais variam por chassi e não são publicadas. Confira com um medidor de cambagem/caster antes de confiar nos números e ajuste o que não bater.", - "preset": "Predefinição", - "presetGeneric": "Kart genérico", - "presetOtk": "OTK (aprox.)", - "presetCustom": "Personalizado", - "resetPreset": "Restaurar predefinição", "h": "Distância entre alojamentos H", "lRim": "Vão do medidor", "split": "Fração da altura da roda", @@ -203,7 +198,25 @@ "eccentricity": "Excentricidade por tamanho (e0 é sempre 0)", "signCamber": "Inverter sinal da cambagem", "signCaster": "Inverter sinal do caster", - "mirrorRight": "Espelhar ângulos do lado direito" + "mirrorRight": "Espelhar ângulos do lado direito", + "profile": "Perfil de chassi", + "profileCustom": "Personalizado (não salvo)", + "userProfiles": "Meus perfis medidos", + "builtinProfiles": "Integrados", + "estimated": "estimado", + "measured": "medido", + "profileName": "Nome do perfil", + "profileNamePlaceholder": "ex. Meu Praga — julho 2026", + "saveProfile": "Salvar como perfil medido", + "deleteProfile": "Excluir perfil", + "measureTitle": "Auxiliares de medição", + "sweep": "Curso do relógio em 180°", + "sweepSize": "Tamanho do excêntrico", + "applySweep": "Definir e = curso ÷ 2", + "zeroIntro": "Com os dois excêntricos no tamanho 0, insira as leituras do medidor para deduzir os deslocamentos de fábrica do chassi.", + "zeroCamber": "Cambagem medida", + "zeroCaster": "Caster medido", + "applyZero": "Definir ponto zero" } } } diff --git a/src/plugins/tools/pill-alignment/CalibrationPanel.tsx b/src/plugins/tools/pill-alignment/CalibrationPanel.tsx index 848c5b3f..d5bb6d09 100644 --- a/src/plugins/tools/pill-alignment/CalibrationPanel.tsx +++ b/src/plugins/tools/pill-alignment/CalibrationPanel.tsx @@ -1,28 +1,59 @@ -// Chassis calibration editor (plan 0011). Real OTK eccentricities aren't -// published, so every constant is editable; presets are approximate starting -// points and any hand edit detaches the preset. +// Chassis calibration editor (plan 0011), built around the profile system: +// pick a built-in brand profile (all estimated until someone measures one), +// turn paddock measurements into constants with the helpers, and freeze the +// result as a named "measured" profile for reuse. Any hand edit detaches the +// active profile. +import { useState } from "react"; +import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { useToolsT, type ToolsKey } from "../i18n"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToolsT } from "../i18n"; import { NumRow } from "../shared/NumRow"; -import { CHASSIS_PRESETS, type PillCalibration } from "./model"; +import { + eccentricityFromSweep, + neutralFromMeasured, + PILL_SIZES, + type PillCalibration, + type PillSize, +} from "./model"; +import { BUILTIN_PROFILES, findProfile, type ChassisProfile } from "./profiles"; interface CalibrationPanelProps { cal: PillCalibration; - presetId: string | null; - onChange: (cal: PillCalibration, presetId: string | null) => void; + profileId: string | null; + userProfiles: ChassisProfile[]; + onChange: (cal: PillCalibration, profileId: string | null) => void; + onSaveProfile: (name: string) => void; + onDeleteProfile: (id: string) => void; } -const PRESET_NAME_KEYS: Record = { - generic: "pill.cal.presetGeneric", - "otk-approx": "pill.cal.presetOtk", -}; - -export function CalibrationPanel({ cal, presetId, onChange }: CalibrationPanelProps) { +export function CalibrationPanel({ + cal, + profileId, + userProfiles, + onChange, + onSaveProfile, + onDeleteProfile, +}: CalibrationPanelProps) { const t = useToolsT(); + const [profileName, setProfileName] = useState(""); + const [sweepMm, setSweepMm] = useState(0); + const [sweepSize, setSweepSize] = useState(3); + const [zeroCamber, setZeroCamber] = useState(0); + const [zeroCaster, setZeroCaster] = useState(0); + const edit = (patch: Partial) => onChange({ ...cal, ...patch }, null); const editE = (i: number, v: number) => { const eMm = [...cal.eMm] as PillCalibration["eMm"]; @@ -30,49 +61,93 @@ export function CalibrationPanel({ cal, presetId, onChange }: CalibrationPanelPr edit({ eMm }); }; + const activeProfile = findProfile(profileId, userProfiles); + const isUserProfile = activeProfile !== null && userProfiles.some((p) => p.id === activeProfile.id); + const sourceLabel = (p: ChassisProfile) => + p.source === "measured" ? t("pill.cal.measured") : t("pill.cal.estimated"); + return (

{t("pill.cal.disclaimer")}

+ {/* Profile picker + lifecycle */}
- +
- {presetId === null && ( + {isUserProfile && ( )}
+ {/* Freeze the current constants as a named measured profile */} +
+
+ + setProfileName(e.target.value)} + placeholder={t("pill.cal.profileNamePlaceholder")} + /> +
+ +
+
edit({ hMm: Math.max(v, 10) })} /> edit({ lRimMm: Math.max(v, 10) })} step={5} /> @@ -102,6 +177,53 @@ export function CalibrationPanel({ cal, presetId, onChange }: CalibrationPanelPr
+ {/* Measurement helpers — turn paddock readings into constants */} +
+

{t("pill.cal.measureTitle")}

+
+ +
+ + +
+ +
+
+

{t("pill.cal.zeroIntro")}

+
+ + + +
+
+
+
diff --git a/src/plugins/tools/pill-alignment/model.ts b/src/plugins/tools/pill-alignment/model.ts index 5afe2338..c7aaef01 100644 --- a/src/plugins/tools/pill-alignment/model.ts +++ b/src/plugins/tools/pill-alignment/model.ts @@ -142,11 +142,11 @@ export function pillContribution( } // --------------------------------------------------------------------------- -// Calibration defaults & presets +// Calibration defaults & measurement helpers // -// Real OTK eccentricities are not published; these are plausible round numbers -// so the tool behaves sensibly out of the box. Everything is user-editable and -// the UI labels presets "(approx)" with an experimental disclaimer. +// Real eccentricities are not published; the default is a plausible starting +// point. Chassis-specific values live in the profile system (profiles.ts) and +// the helpers below turn paddock measurements into constants. // --------------------------------------------------------------------------- export const DEFAULT_CALIBRATION: PillCalibration = { @@ -164,18 +164,31 @@ export const DEFAULT_CALIBRATION: PillCalibration = { toeCouplingMmPerMm: 0.5, }; -export interface ChassisPreset { - id: string; - cal: PillCalibration; +/** + * Eccentricity from a dial indicator on the bore: turning the pill 180° sweeps + * the hole across the full offset diameter, so e = total sweep / 2. + */ +export function eccentricityFromSweep(totalSweepMm: number): number { + return Math.max(totalSweepMm, 0) / 2; } -export const CHASSIS_PRESETS: readonly ChassisPreset[] = [ - { id: "generic", cal: DEFAULT_CALIBRATION }, - { - id: "otk-approx", - cal: { ...DEFAULT_CALIBRATION, hMm: 112, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0], gamma0Deg: -0.4 }, - }, -]; +/** + * Back out the built-in offsets from gauge readings taken with BOTH pills at + * size 0 (concentric). γ0 and nY are redundant at this fidelity (both shift + * camber), so the whole lateral term is folded into nY — callers should zero + * gamma0Deg when applying. + */ +export function neutralFromMeasured( + cal: PillCalibration, + measuredCamberDeg: number, + measuredCasterDeg: number, +): { nXMm: number; nYMm: number } { + const D2R_ = Math.PI / 180; + return { + nXMm: -cal.hMm * Math.tan(cal.signCaster * measuredCasterDeg * D2R_), + nYMm: cal.hMm * Math.tan(cal.signCamber * measuredCamberDeg * D2R_), + }; +} // --------------------------------------------------------------------------- // Persisted tool state (plugin store, key "pill-alignment:v1") @@ -198,8 +211,8 @@ export interface ToeState { export interface PersistedStateV1 { calibration: PillCalibration; - /** Active preset id, or null once any constant is hand-edited. */ - presetId: string | null; + /** Active chassis-profile id (built-in or user), or null once any constant is hand-edited. */ + profileId: string | null; corners: { left: CornerPills; right: CornerPills }; /** Mirror left-side edits onto the right side. */ linked: boolean; @@ -221,7 +234,7 @@ export const DEFAULT_TOE: ToeState = { export const DEFAULT_STATE: PersistedStateV1 = { calibration: DEFAULT_CALIBRATION, - presetId: "generic", + profileId: "generic", corners: { left: DEFAULT_CORNER, right: DEFAULT_CORNER }, linked: true, activeSide: "left", diff --git a/src/plugins/tools/pill-alignment/profiles.test.ts b/src/plugins/tools/pill-alignment/profiles.test.ts new file mode 100644 index 00000000..0172a29e --- /dev/null +++ b/src/plugins/tools/pill-alignment/profiles.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CALIBRATION, eccentricityFromSweep, forwardCorner, neutralFromMeasured } from "./model"; +import { + BUILTIN_PROFILES, + findProfile, + makeUserProfile, + migrateProfileId, + removeUserProfile, + upsertUserProfile, + type ChassisProfile, +} from "./profiles"; + +describe("built-in profiles", () => { + it("cover the supported brands with valid calibrations", () => { + const ids = BUILTIN_PROFILES.map((p) => p.id); + for (const brand of ["generic", "otk", "kart-republic", "compkart", "birel-art", "praga", "sodi"]) { + expect(ids).toContain(brand); + } + for (const p of BUILTIN_PROFILES) { + expect(p.source).toBe("estimated"); + expect(p.cal.eMm[0]).toBe(0); + expect(p.cal.eMm).toHaveLength(6); + expect(p.cal.hMm).toBeGreaterThan(0); + } + }); + + it("have unique ids", () => { + const ids = BUILTIN_PROFILES.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("profile lookup & migration", () => { + it("finds user profiles before built-ins and falls back to built-ins", () => { + const user: ChassisProfile = { id: "user-x", name: "X", source: "measured", cal: DEFAULT_CALIBRATION }; + expect(findProfile("user-x", [user])).toBe(user); + expect(findProfile("praga", [user])?.name).toBe("Praga"); + expect(findProfile("nope", [user])).toBeNull(); + expect(findProfile(null, [user])).toBeNull(); + }); + + it("migrates the legacy preset id and passes others through", () => { + expect(migrateProfileId("otk-approx")).toBe("otk"); + expect(migrateProfileId("praga")).toBe("praga"); + expect(migrateProfileId(null)).toBeNull(); + expect(migrateProfileId(undefined)).toBeNull(); + }); +}); + +describe("user profile CRUD", () => { + it("makeUserProfile slugs the name and marks it measured", () => { + const p = makeUserProfile("My Praga — July 2026", DEFAULT_CALIBRATION, []); + expect(p.id).toBe("user-my-praga-july-2026"); + expect(p.name).toBe("My Praga — July 2026"); + expect(p.source).toBe("measured"); + }); + + it("deduplicates ids against built-ins and existing user profiles", () => { + const first = makeUserProfile("Praga", DEFAULT_CALIBRATION, []); + const second = makeUserProfile("Praga", DEFAULT_CALIBRATION, [first]); + expect(first.id).not.toBe(second.id); + expect(second.id).toBe("user-praga-2"); + }); + + it("snapshots the calibration (later edits don't leak into the profile)", () => { + const cal = { ...DEFAULT_CALIBRATION, eMm: [...DEFAULT_CALIBRATION.eMm] as typeof DEFAULT_CALIBRATION.eMm }; + const p = makeUserProfile("Snap", cal, []); + cal.eMm[3] = 99; + expect(p.cal.eMm[3]).not.toBe(99); + }); + + it("upsert replaces same-id entries and sorts by name; remove filters", () => { + const a = makeUserProfile("Bravo", DEFAULT_CALIBRATION, []); + const b = makeUserProfile("Alpha", DEFAULT_CALIBRATION, [a]); + let list = upsertUserProfile([a], b); + expect(list.map((p) => p.name)).toEqual(["Alpha", "Bravo"]); + list = upsertUserProfile(list, { ...a, name: "Zulu" }); + expect(list.map((p) => p.name)).toEqual(["Alpha", "Zulu"]); + expect(removeUserProfile(list, a.id).map((p) => p.name)).toEqual(["Alpha"]); + }); +}); + +describe("measurement helpers", () => { + it("eccentricityFromSweep halves the indicator sweep and clamps negatives", () => { + expect(eccentricityFromSweep(3)).toBe(1.5); + expect(eccentricityFromSweep(-1)).toBe(0); + }); + + it("neutralFromMeasured round-trips through the forward model at size 0", () => { + const measured = { camberDeg: -0.7, casterDeg: 2.3 }; + const { nXMm, nYMm } = neutralFromMeasured(DEFAULT_CALIBRATION, measured.camberDeg, measured.casterDeg); + const cal = { ...DEFAULT_CALIBRATION, nXMm, nYMm, gamma0Deg: 0 }; + const r = forwardCorner(cal, { sTop: 0, sBot: 0, thetaTopDeg: 0, thetaBotDeg: 0 }, "left"); + expect(r.camberDeg).toBeCloseTo(measured.camberDeg, 6); + expect(r.casterDeg).toBeCloseTo(measured.casterDeg, 6); + }); + + it("neutralFromMeasured respects flipped sign conventions", () => { + const flipped = { ...DEFAULT_CALIBRATION, signCamber: -1 as const, signCaster: -1 as const }; + const { nXMm, nYMm } = neutralFromMeasured(flipped, -0.7, 2.3); + const cal = { ...flipped, nXMm, nYMm, gamma0Deg: 0 }; + const r = forwardCorner(cal, { sTop: 0, sBot: 0, thetaTopDeg: 0, thetaBotDeg: 0 }, "left"); + expect(r.camberDeg).toBeCloseTo(-0.7, 6); + expect(r.casterDeg).toBeCloseTo(2.3, 6); + }); +}); diff --git a/src/plugins/tools/pill-alignment/profiles.ts b/src/plugins/tools/pill-alignment/profiles.ts new file mode 100644 index 00000000..dacc1e1d --- /dev/null +++ b/src/plugins/tools/pill-alignment/profiles.ts @@ -0,0 +1,109 @@ +// Chassis profile system (plan 0011): the calibration constants for a chassis, +// as a first-class record you can add to as karts get measured. +// +// Built-in profiles cover the common eccentric-pill brands but are all +// `source: "estimated"` — real eccentricities aren't published, so the values +// below are plausible placeholders sharing the same 22 mm-bore dial geometry. +// The point of the system is that a measured chassis replaces guesswork: the +// measurement helpers in the calibration panel fill in real constants, and +// "save as measured profile" freezes them as a named, reusable profile +// (persisted per-user in the plugin store). Brand names are proper nouns and +// deliberately not translated. + +import { DEFAULT_CALIBRATION, type PillCalibration } from "./model"; + +export type ProfileSource = "estimated" | "measured"; + +export interface ChassisProfile { + id: string; + name: string; + source: ProfileSource; + cal: PillCalibration; +} + +export const BUILTIN_PROFILES: readonly ChassisProfile[] = [ + { id: "generic", name: "Generic kart", source: "estimated", cal: DEFAULT_CALIBRATION }, + { + id: "otk", + name: "OTK / Tony Kart", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 112, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0], gamma0Deg: -0.4 }, + }, + { + id: "kart-republic", + name: "Kart Republic", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 112, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0], gamma0Deg: -0.4 }, + }, + { + id: "compkart", + name: "CompKart", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 112, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0] }, + }, + { + id: "birel-art", + name: "Birel ART", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 110, eMm: [0, 0.6, 1.2, 1.8, 2.4, 3.0] }, + }, + { + id: "praga", + name: "Praga", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 110, eMm: [0, 0.5, 1.0, 1.5, 2.0, 2.5] }, + }, + { + id: "sodi", + name: "Sodi", + source: "estimated", + cal: { ...DEFAULT_CALIBRATION, hMm: 110, eMm: [0, 0.5, 1.0, 1.5, 2.0, 2.5] }, + }, +]; + +/** Ids the first release shipped under, mapped to their profile successors. */ +const LEGACY_PRESET_IDS: Record = { "otk-approx": "otk" }; + +export function migrateProfileId(id: string | null | undefined): string | null { + if (!id) return null; + return LEGACY_PRESET_IDS[id] ?? id; +} + +export function findProfile(id: string | null, userProfiles: readonly ChassisProfile[]): ChassisProfile | null { + if (!id) return null; + return userProfiles.find((p) => p.id === id) ?? BUILTIN_PROFILES.find((p) => p.id === id) ?? null; +} + +/** + * Freeze the current calibration as a user profile. Ids are slugs of the name, + * suffixed when taken, so profiles stay stable across renames of others. + */ +export function makeUserProfile( + name: string, + cal: PillCalibration, + existing: readonly ChassisProfile[], +): ChassisProfile { + const base = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "measured"; + const taken = new Set([...BUILTIN_PROFILES, ...existing].map((p) => p.id)); + let id = `user-${base}`; + for (let n = 2; taken.has(id); n++) id = `user-${base}-${n}`; + return { id, name: name.trim() || id, source: "measured", cal: { ...cal, eMm: [...cal.eMm] } }; +} + +export function upsertUserProfile( + list: readonly ChassisProfile[], + profile: ChassisProfile, +): ChassisProfile[] { + const next = list.filter((p) => p.id !== profile.id); + next.push(profile); + return next.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function removeUserProfile(list: readonly ChassisProfile[], id: string): ChassisProfile[] { + return list.filter((p) => p.id !== id); +}