rally-livery
Create, change and debug the livery (paint scheme) of an imported Gravel Rally car - the runtime atlas painter, chart seams, panel-line edges, checker/stripe grids, and every debugging technique we us
SKILL.md.claude/skills/rally-livery/SKILL.md
GitHubRally liveries - create, update, debug
Worked example for everything below: src/games/rally/cars/bimmer-m3/ (livery.ts; model.source.json atlas.chartBoxes (each with a $comment) = the overrides it needed and why, summary in DETAILS.md "Livery chart boxes"; DETAILS.md "Bonnet" = fitting an outline to crest + seam lines). Import workflow of the car itself: .claude/skills/rally-car-import/SKILL.md. Procedural cars
(cars/shared/livery.ts canvas atlas) are a different, simpler system - this skill is for imported STL / GLB cars whose
body is painted by <car>/livery.ts.
1. How the paint reaches the car
stl-to-glb.mjsgives every BODY triangle to ONE chart by the direction it faces (largest normal component):left/right(+x / -x),top/bottom(+y / -y),front/rear(+z / -z). Each chart is a plain orthographic projection of the car laid out in metres in one atlas (model.source.jsonatlas:pxPerMetre,bounds,charts= chart origins). Glass, lamps, grille, wing, trim... are separate parts with their own materials and are never painted.<car>/livery.tsexports aCarAtlaswhosepaint(ctx, info, seed)draws in model-space metres withatlasKit(A)(cars/shared/atlas-painter.ts):sidePx/topPx/frontPx/rearPxgive canvas px for a model point; the Bimmer / Skoda Rallyfill(ctx, chart, pts, style)helper clips to the chart. Side shapes are drawn on both sides, the top chart is(z, x)with +x = the car's LEFT, side charts are(z, y), end charts(x, y).- The same surface can sit on two charts (a quarter panel's crest, a bumper corner, a bonnet flank): triangles near the switch pick a chart by tiny normal differences, and neighbouring triangles on different charts sample different texels. Almost every glitch is this. The chart a face lands on is also what the texture there means: a vertical wall on the left chart reads the LEFT side's paint at its (z, y) even if it is a bonnet crease.
- Seed 0 = the reference scheme; other seeds reuse the shapes with
info.accent/accent2. No text, numbers or real brand logos (door plate decal does the number;rally-car-importstep 6). - Edit
livery.ts(instant, HMR). Editmodel.source.jsonatlas.chartBoxesor the converter and you must rebuild the GLB:node scripts/car-model/stl-to-glb.mjs <car>/model.source.json sources/cars/<car>/<car>-parts.stl(dev server restarts itself; reload the page).
2. Creating a livery
- Start from a clean base (body colour everywhere, dark underside on the
bottomchart). Never trace photos automatically (tried on the GR Yaris, rejected). Save the user's reference photos tosources/cars/<car>/first (source-filesskill) and list them in the car DETAILS.md. - Ask what to keep: sponsors, flags and text are out by rule; users usually want 2 - 4 big shapes (a band, a split line, a flag). Build one element at a time and look at it before the next.
- Per element: measure, don't eyeball. Edges that follow a panel line, seam, crease or lamp come from the mesh (section 4),
not from a photo or a hand-drawn guess; shape coordinates go in
livery.tsas named constants with a comment saying what they were measured against. - Shapes that cross charts (a band over a roof edge, a flag round a bumper corner): put the boundaries so each seam falls INSIDE one cell/shape painted identically on both charts (section 5).
- Grids (checkers/stripes): build row by row from the lowest row up, inspect each row (close-up, both sides, from behind)
before adding the next. Constants (
CELL,ROWS,FIRST_COL,COLS_X,CREST,ROOF_X) each get a comment naming the seam / lamp / ledge that fixed their value. A checker's solid-colour region ends on a stepped edge: start each row on a white cell ((col + row) odd). - Mirror symmetric elements with a loop over
[1, -1]; the mesh is symmetric in height but NOT guaranteed in every face - always look at both sides. - Finish with the verification checklist (section 8) and update the car DETAILS.md "Livery" + TODO.
3. Updating / fixing a livery (the usual request)
The user sends a small, scoped fix, often a camera link. Do exactly that change (see the memory note
livery-iteration-style): open their view, find what is wrong, fix it, re-check the same view AND the mirrored view, report
what else you noticed without fixing it.
- Open the link (section 4.1). 2. Name the chart of the bad pixels (4.2). 3. Measure the true edge/seam (4.3 - 4.6).
- Fix at the cheapest level (section 6). 5. Re-render the same view; expect 2 - 3 rounds on an edge ("you over-corrected" means: fit to what their view shows). 6. Both sides + one wide shot. 7. Docs (section 8).
4. Debug toolbox
4.1 Cameras: look from exactly where the user looks
Car viewer URL:
.../games/rally/car-viewer.html?car=<id>&cam=x,y,z&look=x,y,z&fov=<deg>&clean=1(model space, metres, +z = nose, +x = the car's LEFT, y up;clean=1hides the panel; a smallfovfrom far away is near-orthographic).The panel's Pose -> "Copy camera link" copies the current orbit (position, target, fov) as such a URL. The user's links point at THEIR dev server (port 3000): keep the query, replace the origin with your own preview's (
preview_list), add&clean=1. If they say some links are "the back view / ignore those", use only the ones they say count.One
navigate+wait 7-8+screenshotper view; batch several views in onebrowser_batch. Standard recipes (replaceXby -1 for the car's right side):View cam/look/fovstraight top-down on a spot (z0, x0) cam=x0,3,z0&look=x0,0.8,z0&fov=14(image: front = down, +x = right)side close-up at ~1000 px/m cam=X*2.6,1.4,z0+1&look=X*0.8,0.85,z0&fov=10..13straight from behind, low cam=0,0.75,-9&look=0,0.65,-2&fov=14rear 3/4 cam=-4.2,0.9,-6&look=-0.6,0.62,-2.1&fov=10roof / door top cam=-3.6,2.4,0.2&look=-0.7,1.0,-0.4&fov=22Look from the glitch's side AND from straight above: top-chart errors only show from above, wall/seam errors only at a low angle (a straight top-down view can look perfectly aligned while a 2 - 3 cm spill on a bevel shows from the side).
~300 px/m hides 2 cm errors. Use fov 7 - 13 from 2 - 6 m for edges.
4.2 Name the chart: tint the atlas in the page (debug only, nothing saved)
Paste via javascript_tool on the car-viewer page (find the body atlas = the canvas texture bigger than 1000 px; layout numbers
from model.source.json atlas, here the Bimmer M3's):
let tex;
__carViewer.shell.scene.traverse((c) =>
[].concat(c.material || []).forEach((m) => {
if (m?.map?.image && m.map.image.width > 1000 && m.map.image.height > 2000)
tex = m.map;
}),
);
const x = tex.image.getContext('2d'),
K = 256;
x.globalAlpha = 0.5;
const T = (c, cx, cy, w, h) => {
x.fillStyle = c;
x.fillRect(cx * K, cy * K, w * K, h * K);
};
T('#0f0', 0, 0, 4.75, 1.45);
T('#ff0', 0, 1.5, 4.75, 1.45); // left green, right yellow (side charts 4.75 x 1.45 m)
T('#f00', 0, 3.0, 4.75, 2.1); // top red
T('#0ff', 0, 5.15, 2.1, 1.45);
T('#f0f', 2.2, 5.15, 2.1, 1.45); // front cyan, rear magenta
x.globalAlpha = 1;
tex.needsUpdate = true;
(Chart rects: origin from atlas.charts, size bounds.z x bounds.y for left/right/front/rear-ish, bounds.z x bounds.x for
top/bottom - see chart-probe.py chart_rects().) Reading it: interleaved tints on a surface = triangles of two charts
alternating (teeth); a strip of the wrong tint along a painted edge = faces that belong to another chart (walls, bevels,
ledges); then probe them (4.4). Reload the page to undo.
4.3 Read positions off the car: a debug grid
Draw lines of constant |x| (or z) into the top chart the same way, one colour per 5 cm (canvas y = (chart.y + x - bounds.x[0]) * K,
along z canvas x = (chart.x + bounds.z[1] - z) * K), then screenshot the user's own view. The lines lie on the surface, so
where a painted edge crosses which line gives its |x| directly - this found the bonnet-tip spill (blue at |x| 0.33 vs the
raised part's wall at 0.36) that top-down views hid.
4.4 Ask the mesh: scripts/car-model/chart-probe.py
python scripts/car-model/chart-probe.py <model.source.json> --box x0,x1,y0,y1,z0,z1 [--abs-x] # per chart: tris, area, normal & position ranges in a box
python scripts/car-model/chart-probe.py <model.source.json> --seam rear # per height: where the side chart ends / the rear chart starts
python scripts/car-model/chart-probe.py <model.source.json> --profile z0,z1,x0,x1 # surface height grid (cm)
Typical use: a bad strip in box B -> run --box B -> "left 47 tris, normal y -0.7..0.7" tells you which faces (walls, ledges)
sit on which chart; --seam rear tells you the |x| / z where a cell edge must NOT be.
4.5 Find a panel line / seam / crest: use NORMALS, not heights
A bonnet/fender step, roof gutter or shoulder crest is a wall: outward-facing triangles with a low up-component (ny ~0.1 - 0.25).
Slice by z, list the |x| of those walls (numpy over the GLB's body primitive; see the snippets in cars/bimmer-m3/DETAILS.md or
chart-probe.py for the GLB reader). Rules learnt:
- the wall is ~3 cm wide and the visible seam is on its outer side (fitting the inner side left a white gap);
- a height profile misleads: it shows the later drop to the cowl (4 - 10 cm further out) not the seam;
- bevels add ~2 cm to a wall: keep a shape 1 - 2 cm clear of a wall it must not cross;
- crease walls inside a painted shape face sideways and land on side charts (white lines) - re-route them (6c);
- raw crease renders help:
python scripts/car-model/view-glb.py <model.source.json> out --views top --crop z0,z1,x0,x1 --scale 800 --grid 0.02.
4.6 Geometry must not move
After any converter/chartBoxes change compare sorted vertex positions per primitive against the previous GLB (np.allclose):
only the UV chart assignment may differ. Keep a copy of the old GLB in the scratchpad before rebuilding.
5. Seam rules (how grids and shapes survive chart boundaries)
- Put the seam inside one cell/shape painted the same on both charts. Measure it first (
--seam, normals), then choose cell edges around it: the Bimmer side's last column = the rear's outer column; the side's crest row = the top chart's outer cell. - A cell edge on a crest/seam makes teeth. The quarter-panel crest at |x| ~0.72 inside a 43 cm cell -> clean; an edge at 0.72 -> saw-teeth. Move the edge past the crest (or widen the cell).
- One colour across a strip that holds several heights (quarter-panel top + bumper ledge on the same top-chart strip): give the rows involved the same parity and colour, or two colours split at some x make teeth.
- Rows aligned with features: a row boundary at lamp top/bottom, bumper ledge height; cells behind a lamp are dropped (slivers).
- Changing the column / row count moves every edge: recheck each
chartBoxesboundary (its$commentnames the cell it relies on) and every edge near a lamp corner. Move a single bad edge on its own (LAMP_EDGE_Zincars/bimmer-m3/livery.ts). - A thin line/strip sticking out of a shape = a vertical wall or ledge on another chart at the shape's edge: route it onto the shape's chart (6c), never "fix" it by shrinking the shape.
- Edges that follow a panel line: outer side of the groove wall, 1 - 2 cm clear of walls you must not cross, and verify in a low-angle close-up on BOTH sides.
6. Fix levels (cheapest first)
a. Move the outline in livery.ts onto the measured edge (most fixes; HMR, instant).
b. Make the charts agree (cell/shape edges, colours, parity) - still only livery.ts.
c. Re-route faces with atlas.chartBoxes in model.source.json (rebuild GLB; cookbook below).
chartBoxes (converter stl-to-glb.mjs; boxes in model space, x is |x| so both sides; first matching box wins; every box needs
a $comment saying which glitch it fixed):
| Symptom | Box |
|---|---|
| white lines / wedge through a painted top shape along a crease | chart: top, minNy -0.6 (also the sloped walls), box around the crease |
| thin strip/line beyond a painted roof/hood edge (a vertical edge wall on a side chart) | chart: top, minNy 0.05 around the wall |
| arch flares picking up the paint of the opposite arch (end charts are shared front-to-rear) | chart: side boxes round each arch (front and rear arch separately) |
| steep fender shoulder next to a painted edge turned into slivers | chart: side, maxNy 0.85 |
| horizontal ledge (sill, exhaust recess) needing the side's split line | chart: side, maxNy 1.01, minNy -0.2 |
| low bumper lip among colour rows takes the top chart's colour | chart: rear (or front), minNy 0.3 |
7. Symptom -> first suspect
| What you see | Likely cause -> where to look |
|---|---|
| paint spills onto the neighbouring panel by 2 - 10 cm | outline measured from a height drop / inner wall side -> 4.5 |
| white gap between paint and a seam | outline on the inner side of the wall / too far in -> 4.5 |
| teeth along the edge of a stripe | cell edge on a crest, or two colours across one strip -> 5 |
| a long thin line past the paint's end | edge wall on a side chart -> 4.2 + chartBoxes top |
| one square of the wrong colour among a row | ledge/lip on another chart -> chartBoxes rear/side |
| paint on the wrong side of the car | the shape is on the top/left chart only; or non-mirrored loop -> [1, -1] |
| bumper corner pattern breaks | seam not inside one cell -> --seam rear, 5 |
| blue specks in a lamp recess | rim faces of the recess: shrink the outline 1 - 2 cm from the rim; not worth boxes |
| a tooth / wedge of one cell at a lamp corner | a cell edge on the curved lip there: raycast it, move that edge 1 - 2 cm past it |
| texture looks soft/blurry | pxPerMetre 256 = 4 mm per texel; fine for 10 cm cells, don't paint 1 cm details |
8. Gotchas and verification
Mute the game: any URL that can start the game (
/games/rally/?car=...) gets&mute=1(AGENTS.md "Sound while testing").Blank viewer /
__carViewerundefined: the dev build is failing, usually ANOTHER session's file (a half-writtendata.json, a missing import).preview_logs(errors; grep "File:"), wait until it is valid, reload. Not yours to fix.Screenshot timeouts ("pane not displayed"):
tabs_selectthe tab; if hidden/closed,preview_startwith aurl(new tab id, pass it explicitly). Do not retry a hungnavigatein a loop.Browser caches the GLB after a rebuild: reload with a changed query (
&v=2) if the old model shows.Other sessions edit the same tree: lint/tsc/test failures in files you did not touch are theirs - report, don't fix.
Heredocs with apostrophes break in Bash; patch long files with Python slicing by unique anchors and
assertthey exist.Debug overlays are in-page only: they vanish on reload and must never be committed.
Verification before you say "done": npx tsc --noEmit -p tsconfig.json, npm run lint, npm run test; the user's view + its
mirror + a wide 3/4 + a top-down; for converter changes the geometry-unchanged check (4.6). Docs (short, AGENTS.md "Write short"): car DETAILS.md "Livery"
(the shapes and the one-line why of each seam fix, not the attempts), TODO.md (leftovers), model.source.json $comments; add a new technique here when you learn one.