--- name: ride-customizer description: "Build and extend Ryan's Pimp My Ride garage: author or adapt detailed GLB car assets in Blender, paint/decal/sticker customization, validated saved designs, Snap & share, and garage-to-racer compatibility. Use when creating custom vehicle assets, changing ride-data validation, loading models, or fixing mobile customization and sharing." --- # Ride customizer Original authored for Ryan's arcade, with AI assistance, from the project's own garage, asset pipeline, and racer contracts. No downloaded or third-party skill text is included. This is not a blanket license for project content, vehicle models, or vehicle trademarks. Manufacturer websites were used only as visual references; no manufacturer photos, CAD, badges, or logos ship in any asset. ## Architecture at a glance `public/ride-data.js` owns the design schema and validation. `public/ride-detailed.js` loads the three local GLBs once and clones per-instance cars. `public/ride-model.js` exposes the shared `buildRideModel(design)` API. `public/ride-art.js` draws decal and sticker artwork as canvas textures. `public/ride-3d.js` is the garage renderer (`createRideView`), `public/ride-ui.js` and `public/ride.js` are the garage controller and sharing, and `public/drive-3d.js` reuses the same models for racing. Editable sources live in `assets/rides/*.blend`; runtime GLBs live in `public/models/rides/` with `provenance.json` and `credits.html`; the Blender scripts live in `tools/`. ## Choosing how to make a custom vehicle asset 1. Decide the source route before modelling, and record it. There are three acceptable routes. Route A is original procedural authoring in Blender from code; this is how the EV9-inspired SUV and Bolt-inspired hatch were made. Route B is adapting a permissively licensed model; this is how the dream car was made from the Khronos `CarConcept` sample (CC BY 4.0). Route C is a locally generated AI mesh, for example from an image-to-3D model. Route C was evaluated but not used for shipped cars because generated meshes need heavy retopology, have no named wheel pivots or paint slots, and carry tool-licence questions. If you use it, treat the output like Route B input: clean it, rig named pivots, check the tool's output licence, and document it. 2. Never ship a downloaded paid asset, a manufacturer CAD/photo texture, a badge, a licence plate, or a brand logo. Reference photos may guide proportions only. Call the result "inspired" or an "approximation", never an official model, and never imply endorsement. 3. For Route B, pin the exact source commit URL, read the licence file, and keep the author, copyright owner, year, licence URL, upstream origin, and every modification in `public/models/rides/provenance.json`. Show the human-readable attribution in `public/models/rides/credits.html`. Also embed it with the glTF exporter's `export_copyright` field. CC BY requires attribution forever; CC0 does not, but record it anyway. ## Route A: original procedural authoring (how EV9 and Bolt were built) 1. Use `tools/author-electric-rides.py`, run headless: `blender --background --python tools/author-electric-rides.py`. It was written for Blender 5.2 LTS. Principled BSDF socket names such as `Coat Weight` and `Transmission Weight` differ across Blender versions, so check them when upgrading. 2. Start each car from `bpy.ops.wm.read_factory_settings(use_empty=True)` so runs are deterministic. Drive every dimension from a few real-world numbers per car: length, width, height, axle offset, tire radius, belt line, and roof and glass stations. The EV9-inspired body is 5.01 m × 1.98 m × 1.78 m with 0.395 m tires; the Bolt-inspired hatch is 4.22 m × 1.8 m × 1.62 m with 0.35 m tires. 3. Build curved body panels as parametric surfaces: a helper evaluates `fn(u, v)` over a grid, writes vertices and quads, and recalculates normals with `bmesh.ops.recalc_face_normals`. Add separate meshes for glass, wheel arches and wells, lamps, trim, mirrors, and a simple interior (seats, headrests, dashboard) so the glass reads correctly. 4. Create materials by name, because the runtime depends on names. The body colour must be `RidePaint`, or Blender's suffixed `RidePaint.001` form. Every panel that should take the player's paint and receive decals must use it. Other materials are `CarbonTrim`, `Glass`, `Rubber`, `Alloy`, `Chrome`, `BrakeDisc`, `LED`, `TailLED`, and `Interior`. Use opaque tinted glass with metalness instead of transmission; transmission costs extra render passes on phones. 5. Create exactly four wheel pivot empties named `WheelFrontL`, `WheelFrontR`, `WheelRearL`, `WheelRearR`, each at its wheel centre. Parent the tire, barrel, spokes, tread, hub, and `BrakeRotor` to the pivot while preserving world transforms. Name calipers or pads `BrakePad` so the runtime leaves them static while the wheel rolls. Coordinates are nose +X, Y up in glTF, and wheel axle along +Z. 6. Before export, convert curves to meshes. Then reduce draw calls by joining meshes that share the same parent and material, except the root-level `RidePaint` panels, which stay separate for decal projection. Without this step a detailed car can exceed 300 draw calls. 7. Save the editable `.blend` to `assets/rides/.blend` and export GLB to `public/models/rides/.glb` with animations, cameras, and lights disabled. Delete any `.blend1` backups Blender leaves behind; do not commit them. ## Route B: adapting a licensed model (how the dream car was built) 1. Download the pinned source into a scratch folder, not the repository, then run `blender --background --python tools/prepare-ride-asset.py -- SOURCE.glb public/models/rides/concept.glb assets/rides/concept.blend`. 2. Inspect the source's baked pose before anything else. The Khronos concept car shipped with its front wheels visibly steered. Baking that pose made the wheels roll crooked at runtime. The script measures each front pivot's axle against the `WheelRearL` axle and rotates the front pivots to match before baking. Check every new source for posed wheels, doors, or suspension. 3. Remove what you cannot ship: licence plates, logo-bearing paint textures, the engine, pedals, and interior parts nobody can see. Unlink texture inputs from paint materials so no artwork survives under a solid colour. Rename the main paint slot to `RidePaint` and the secondary paint to `CarbonTrim`. 4. Normalize: find the nose using a known part (the hood), rotate so the nose is +X, scale to the arcade length (4.7 m for the dream car, with width scaled to 85% so it fits the racing lanes), centre it, and put the ground at zero. Bake world transforms into mesh data and keep only the four wheel pivots as empties, re-centred on their wheel meshes' bounds. 5. Fit the mobile budget: apply a Decimate modifier (ratio 0.4) to meshes over 400 polygons, resize every image to at most 512 px, pack resources, save the `.blend`, and export the GLB with `export_copyright` attribution. The script prints a `RIDE_ASSET` JSON line with bytes, meshes, pivots, triangles, and dimensions; copy the numbers into `provenance.json`. ## Runtime asset budgets and loading contract 1. Keep each car under 90,000 triangles, 180 mesh draw calls, and 6 MB. Current figures: EV9 about 1.97 MB and 84,596 triangles; Bolt about 1.90 MB and 81,716 triangles; dream about 5.25 MB and 85,173 triangles. `tests/ride-model.test.js` enforces finite geometry, budgets, wheel ground clearance, and straight wheel axles. 2. Load with `await prepareDetailedRide()`, which loads all three GLBs through the vendored `GLTFLoader` using `Promise.allSettled`. A retry loads only the sources that failed and keeps the ones that loaded. After that, `buildRideModel(design)` is synchronous and returns `{ group, bodyMeshes, wheels, animateWheels(distance, steering), dispose() }`. `createRideView` and `createDriveView` are both async; show progress and a retryable error instead of a blank canvas. 3. Clone per instance: `source.clone(true)`, then clone each geometry and material. Apply paint only to cloned `RidePaint` materials (metalness 0.65, roughness 0.23, clearcoat 1). `dispose()` must be idempotent and free only that instance's clones and generated textures, never the cached source textures shared by other cars. 4. For wheels, the pivot rotates Y by `-steering` for front wheels only. A generated `Rolling` child holds everything except `BrakePad` and rotates Z by `-distance / radius`, where the radius comes from each wheel's bounding box. Drive rolling from signed simulated distance, never render time, so wheels do not spin while paused or teleport-spin after a reset. 5. Frame the garage camera from the model's bounding sphere so tall SUVs and long sports cars both fit on a 320 px-wide phone. ## Customization: paint, decals, and stickers 1. The design is exactly `{ version: 1, carId, paint, decal, stickers }`. Car IDs are `ev9`, `bolt`, `dream`; never rename them, because saved designs and the racer depend on them. Read `PAINTS`, `DECALS`, and `STICKERS` from `ride-data.js` rather than hard-coding copies. 2. Generate all artwork in code with `createRideArtwork(kind)` on a canvas: decals (`stripes`, `flames`, `lightning`) are 1024×256, stickers 256×256, and the texture colour space is sRGB. No image files are needed, which keeps the asset licence simple. 3. Project artwork with the vendored `DecalGeometry` against every `RidePaint` body mesh, skipping empty results. Use a transparent material with `depthWrite: false`, `polygonOffset: true`, `polygonOffsetFactor: -4`, and `renderOrder = 2` to avoid z-fighting. Side decals project from both sides at a fixed body-relative box; stickers use the stored `point` and `normal` with a 0.32 m box. 4. Place stickers by raycasting the pointer against `bodyMeshes` only, storing the hit point and world normal. Separate a sticker tap from an orbit drag by movement threshold, and make both work with mouse and touch. 5. Garage sound comes from the shared procedural `public/arcade-audio.js` (Web Audio synthesis, no files, CSP-safe). `ride.js` calls `playSound` only from UI events: `swap` on car change, `paint` on respray or reset, `decal`, `sticker` on placement (`error` when the 20-sticker limit is hit), `click` for tool/undo, `photo` when the share snapshot is taken, and `success`/`error` for the share result. The renderer stays silent. A mute button (`#ride-sound`, class `arcade-sound`) sits just before the Full screen/Exit control and shares the arcade-wide `localStorage` mute. 6. Garage theme music: `ride.js` calls `playMusic(THEMES.pimpMyRide.url)` at load, and it starts on the first gesture. Generate with a local text-to-music model (we used ACE-Step 1.5 XL-SFT, MIT, via `Generate-Audio.cmd -Style -Prompt "..." -Duration 48 -Seed 42`) using an original instrumental prompt that names no artists or songs and asks for steady energy with no intro, breakdown, or ending. Do not trust the preset BPM: measure the real repeat period with `tools/make-music-loop.py` (onset autocorrelation plus a spectral self-similarity lag curve; the maze take came out at 110 BPM, not the preset 112), cut a bar-aligned 16-bar loop at the most self-similar start, crossfade the seam for 120 ms (equal power), apply linear gain to about -16 LUFS with at least 1 dB true-peak headroom, and encode AAC 112 kbps M4A (roughly 0.4 to 0.55 MB). Record model, preset, seed, steps, prompt, loop start, and loop length in `public/audio/provenance.json`. Play themes with `playMusic(THEMES..url)` from `arcade-audio.js`: fetch plus `decodeAudioData` (covered by `connect-src 'self'`), a looping `AudioBufferSourceNode` on a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, and `stopMusic({ fade })`. Each `THEMES` entry stores its exact loop length, and playback sets `loopStart`/`loopEnd` to that window (skipping any AAC priming silence the browser keeps, with a 2 ms tolerance), so the loop never clicks or gaps. Music waits for the first gesture, shares the mute button, pauses with the hidden page, and fails silently if a file is missing. The service worker caches theme files at runtime (cache-first) rather than precaching about 1.5 MB, so filenames are immutable: ship a new take under a new name. ## Validation, saving, and Snap & share 1. Validate every edit, import, and save with `validateRide`: at most 20 stickers, each with only `kind`, `point`, and `normal`; points finite within ±6; normal components within ±1 and length 0.9–1.1; no extra fields, unknown cars, or unsupported colours. 2. `serializeRide` produces the design string, capped at 12,000 characters. Shared Firestore records in `arcadeRides` are document version 2: a serialized `design` plus a matching `carId`, a 1–60 character title, and a JPEG data-URL photo up to 180,000 characters checked by `validatePhoto`. Readers use `parseSavedRide`. Older version-1 photo-only records are labelled "photo only — not drivable", never reconstructed from pixels. 3. Sharing is a single batched write: the ride document plus the user's `arcadeRidePublishers/{uid}` record, each stamped with `request.time` and cross-checked with `getAfter`. The rules allow one share per 30 seconds per user, verified Google/GitHub accounts only, and immutable photos. If sharing fails with a permission error, compare the live rules with the local `firestore.rules` before blaming the client. A real outage came from rules that had never been deployed. Deploying rules needs explicit owner approval and emulator tests first. 4. Render untrusted titles with text nodes, keep pagination (`PAGE_SIZE` 12) with a cursor, and drop stale responses after account changes. ## Hosting, CSP, and offline 1. Embedded GLB textures become `blob:` URLs. Production CSP must allow `blob:` in `connect-src` (ImageBitmapLoader path) and in `img-src` (the TextureLoader fallback used when `createImageBitmap` is missing). Do not add it to script or style directives. Test the full garage page under the exact production CSP: an isolated loader check missed 13 blocked texture loads that the full page exposed. Also force `window.createImageBitmap = undefined` to exercise the fallback, and listen for `securitypolicyviolation`. 2. Add every new GLB, script, and vendored addon (`GLTFLoader`, `BufferGeometryUtils`, `SkeletonUtils`, `DecalGeometry`) to the service-worker precache, and bump the cache name once per release. 3. Homepage preview images are real in-game renders captured with a headless browser, using intercepted test-only account responses, saved as 960×540 WebP, and lazily loaded with alt text. Never use a mock-up that the game cannot actually produce. ## Testing 1. From `web`: `node --test tests/ride.test.js tests/ride-model.test.js tests/ride-ui.test.js tests/drive.test.js`. Cover round-trip serialization, malformed and oversize rejection, mismatched wrapper car IDs, photo-only handling, model budgets, wheel axles, per-instance material independence, disposal, and retry-only-failed loading. 2. Run `node --test tests/firestore-rules.test.js` against the Firestore emulator for any sharing change. Never use production data as fixtures. 3. Browser-check with headless Chromium and software WebGL (`--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`) at 320×568, 390×844, 844×390, and desktop sizes. Intercept only the account and Firestore test routes; never mock the code under test. Software-WebGL timings are not real-device performance claims. 4. Visually inspect screenshots of all three cars with paint, a decal, and stickers before reporting success. Passing geometry tests do not prove a car looks right. ## Completion Summarize the asset source route, licence, budgets measured, schema compatibility, and verified tests. If the schema changes intentionally, migrate writers, readers, and rules together; do not silently invalidate existing shared designs or loosen validation. Never deploy hosting or rules without explicit owner approval.