Skip to content

feat(f1-kit): grandstand-bay tiers/roof/stairs/collision + garage-box floors/tower (Madring) - #14

Open
zugzug1 wants to merge 16 commits into
vibe-stack:mainfrom
zugzug1:feat/f1-madring-kit
Open

feat(f1-kit): grandstand-bay tiers/roof/stairs/collision + garage-box floors/tower (Madring)#14
zugzug1 wants to merge 16 commits into
vibe-stack:mainfrom
zugzug1:feat/f1-madring-kit

Conversation

@zugzug1

@zugzug1 zugzug1 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Approved by Miguel 2026-09-11 after review of the renders in devlo-racing's Madring ledger. Includes #5 (spectator-bridge deckHeight) — merging this supersedes it.

f1-grandstand-bay

  • tiers (1–4): stacked bowls, each upper tier cantilevered 3 rows over the one below; per-tier tierSpec (rows, plinth, lift, overlapRows, support, rearSupport, stairSide, stairWidth, roof).
  • Top-level roof: full (default — one canopy from the top tier's rear over the whole section to tier 0's front, columns landing on each promenade) | top | none | per-tier.
  • Top-level stairs: none (default) | left | right | both — scaffold stair towers on the end faces built from f1-stairs parts, folded flights per tier plus a ground flight; getFootprint() reports seating vs total width so an emitter puts towers only at run ends.
  • getCollisionVolumes() / parts.collision: convex wedge per tier bowl, thin box per column line, box per stair tower, roof excluded (topologyRole 'detail', not parented unless debug.collision). Header carries the TODO for collision as a first-class procedural config across the kit.
  • Every support foot asserted to land on ground or the tier below; roof clearance asserted per tier. {rows:8,width:10} byte-identical (sha256 vertex proof + unchanged .vtopo).

f1-garage-box

  • floors (1–3): dark-glazed storeys with terrace + red fascia band, roof deck at the official 18.5 m; tower: one-storey glass tower at −X. floors:1 byte-identical.

f1-stairs

  • Exports gratingTread, stringerStations, channelStringer (geometry untouched) so the tower reuses the real parts.

Tests: f1-kit 448/448, topology 77/77. Consumer: devlo-racing PRs #922–#925, #929 (merged).

Follow-ups landed since the review (2026-09-12)

  • Stair tower ground flight (77ddb0a): every stairs tower now has a flight from grade to tier 0's promenade, built from the same f1-stairs parts; foot asserted on ground.
  • Collision TODO (bd02454): header note for collision as a first-class procedural config contract across the kit. Consumer wiring is live in devlo-racing #929 (f1-grandstand-bay allowlisted, convex volumes, sweep 0 gaps).
  • Garage-box vibe-model critique pass: run (68636ae) and deliberately reverted (d474e64) — the critics asked for closed flank walls, a raked oversailing blade and stepped terraces with no measured datum for any of them, and the accepted floors/tower look stays. Re-run only once a flank/roof reference exists.

Tests: f1-kit + topology 525/525.

zugzug1 added 16 commits August 27, 2026 08:30
Keep visual Three.js recipes authoritative and ship a CompiledTopology
.vtopo per catalog model (welded hull, repaired manifold, LOD, collision)
with GameReadyClaims measured by measureIntegrity. Registry schema v2
emits artifacts and compiled representations. The compiler is not part of
published @f1-kit/f1-kit-core.
…t 6.4

The rig STRADDLES the racing surface, so its post-to-post span belongs to the
circuit, not the model — but `const span = 6.4` sat inside `rebuild()` and was
not on `F1StartLightsConfig`, so no consumer could reach it. On a 14 m road the
posts stood well inside the asphalt.

Now `span` on the config, plumbed through createModel + configure(), clamped
`>= 4`. DEFAULT IS UNCHANGED at 6.4, so every existing caller renders exactly
as before; only a caller that passes a span sees a difference.

The lamp panel deliberately does NOT scale with it — it keeps its FIA size
(5 columns at 0.36 pitch), centred under the beam, which is how the real
structure reads. Only the posts, the top chord and the beam follow the span.
…a hard-coded 5.5

Devlo-racing's Madring footbridge needs to sit on a cut-and-cover lid, whose clearance
above the racing surface is not the kit's standard 5.5 m catch-fence figure. The rig's
ground datum belongs to the consumer, not the model, but `const DECK_H =
SPECTATOR_BRIDGE.deckHeight` sat at module scope and every quantity derived from it
(girder chord heights, pier heads, stair rise/run/rake, tower cap) was computed once at
import time, so nothing downstream could reach it.

`deckHeight` is now on `F1SpectatorBridgeConfig`, plumbed through createModel and
configure(), clamped to >= 4 (finite required, else falls back). DEFAULT IS UNCHANGED at
5.5, so every existing caller renders exactly as before — proved by re-running the kit's
full `.vtopo` regeneration (`f1:compile-topology`): f1-spectator-bridge.vtopo comes out
byte-identical (same sha256), and every other model's sidecar is untouched too.

The DECK_H-derived module constants are now `let`s recomputed by a new
`applyDeckHeight()` at the top of `rebuild()`, mirroring the mutable-derivation pattern
this file already used for per-rebuild state; JS's single-threaded synchronous execution
means two bridge instances with different deckHeight configs never interleave a build
against the other's derived values — each rebuild bakes vertex positions before anything
else runs.

The piers and the switchback stair towers (and their handrails) scale to reach the new
deck height; girder depth and parapet/rail proportions are unchanged, matching how
f1-start-lights' span option (44f299b) kept its own proportions fixed.

Tests: default build is byte-identical (vertex count + AABB) to an explicit
`deckHeight: 5.5`; `deckHeight: 6.5` measurably raises the deck, piers and stair towers;
non-finite and sub-4 values are rejected (clamped/fallback) both at construction and via
configure().
…n top

Adds tiers: number (1-4, default 1) to F1GrandstandBayConfig. Tier k>=1
stacks a full bowl whose promenade sits at tier k-1's bowlTop + ROOF_CLEAR
and whose front sits at tier k-1's rear edge (stepped back and up, no
cantilever overlap) — both derived from layoutOf's own constants, not new
magic numbers. Each non-top tier gets its own frontage (fascia/hoarding/
guard-rail) and a rear wall stretched to bowlTop + (ROOF_CLEAR - DECK) so
it closes flush against the tier above (no see-through gap into the
concourse). Only the top tier gets the membrane roof + rafters + columns +
fascia, built exactly as before.

tiers: 1 is untouched: tier 0 still emits straight into the bowl/roof
groups with an identity transform, so geometry, part names, and the
compiled .vtopo sidecar stay byte-identical to before this change
(verified by sha256 over world-space vertex data + instance matrices for
{rows:8,width:10} and {rows:24,width:10}, and by recompiling the sidecar
— same bytes, same hash).

No stair/vomitory — out of scope per brief, skipped.
…ding as a kit module

Adds \`floors\` (1-3, default 1) and \`tower\` (default false) to f1-garage-box's
config so the Madring pit building — 14 garages + two Paddock Club levels + a
glass entry tower — is one kit module instead of a bespoke building.

- floors:1 stays byte-identical to today's box (same .vtopo hash, same
  triangle/vertex counts, same AABB) — verified by test and by regenerating
  the sidecar.
- floors 2-3 stack glazed storeys above the garage roof: a mullioned curtain
  wall, a pit-lane terrace with a guard rail, and a red leading-edge fascia
  band under each terrace (the catalogue tell). Storey height is solved
  backward from the circuit's published 18.5 m / 3-floor max
  (estimate:official-max-height). Top storey caps with a roof slab + parapet.
- tower:true adds a one-bay glazed shaft at the model's -X end, mullioned
  like the floors, rising two storeys above the top floor
  (estimate:photo).
- Glass is a local non-transmissive MeshStandardMaterial (transparent+opacity,
  same pattern as f1-team-motorhome's glazing) — never \`transmission\`, which
  the host forbids for perf. The red band uses TOKEN.RED_500 directly rather
  than the shared kit.red instance, which is reserved for fire equipment.
- Glazing was reading as a see-through glass box; the reference
  (pit-building-render.png, imagen-paddock.webp) is near-opaque blue-black
  curtain wall with the mullion grid as the light lines and floor plates
  behind only faintly visible. Darkened + raised opacity (0x050a10,
  roughness 0.05, metalness 0.55, opacity 0.9 — still non-transmissive).
  estimate:render.
- Tower rose two storeys above the roof; f1com-YDRAY-foto-pit-building.webp
  shows about one. TOWER_EXTRA_STOREYS 2 -> 1 (roof deck + 1x STOREY_H +
  roof slab + parapet). estimate:photo.
- floors:1 stays byte-identical (.vtopo SHA unchanged, identity test still
  passes). Tests + typecheck re-run clean; 9 review PNGs re-rendered.
Tier k's front now cantilevers OVERLAP_ROWS (3, estimate:photo — the
Madring S/F stand photo's balcony fronts hang over roughly the last three
rows of the tier beneath) rows' worth of tread past tier k-1's rear edge,
instead of sitting flush on top of it. Concretely: the z-step between
tiers shrinks by OVERLAP_ROWS * TREAD, so tier k's own bowl slides forward
over tier k-1's last few rows.

No new soffit geometry is needed: each tier's bowl mass is already a
closed, full-depth loft (see buildBowl's profile), so the same slab that
was already there becomes the soffit over the newly-covered rows once the
tier is shifted forward. buildFrontage already runs per tier, so its
fascia/hoarding/guard-rail already lands right at the new overhang's
leading edge, matching tier 0's frontage. The lower tier's rear wall stays
at its existing (already-correct) extended height/position — the tier
above's slab is 2*halfD deep vs an OVERLAP_ROWS*TREAD overhang, so it
still reaches past the wall and closes flush against it; the wall now
sits well inside the covered volume instead of at a visible seam.

Headroom is asserted, not assumed: rebuild() computes the clearance from
the lowest covered tread (row rows-OVERLAP_ROWS of the tier below) to the
tier above's soffit and throws if it undercuts ROOF_CLEAR. The clearance
works out to OVERLAP_ROWS*RISE + ROOF_CLEAR - DECK = 2.62 m for any rows
(the rows-dependent terms cancel), 0.37 m over the 2.25 m ROOF_CLEAR
minimum.

tiers: 1 stays byte-identical (re-verified: sha256 over world-space
vertex + instance data unchanged for rows:8 and rows:24, .vtopo sidecar
recompiles to the same 180828 bytes / hash). Depth for tiers:3 drops by
2 * OVERLAP_ROWS * TREAD = 4.8 m as expected (rows:8: 24.79m -> 19.99m;
rows:10: 29.59m -> 24.79m); height is untouched (rows:8: 20.641m;
rows:10: 23.281m — the deck-height rule didn't change).
…stairs, plinth/lift

Adds tierSpec (indexed by tier, 0 = bottom) to f1-grandstand-bay so a stacked
stand is never left floating:
- plinth (tier 0) / lift (tiers >=1) make per-tier height configurable
- support / rearSupport ('columns' | 'wall' | 'none') carry each tier's
  cantilever front and brace its rear with X cross-bracing, every foot
  computed off surfaceY or true ground, never assumed
- stairs (default true, tiers >=1) run a straight flight — or a folded
  switchback when it would not fit inside width — from the lower tier's
  promenade to this tier's, reusing a shared guard-rail helper
- roof is now per-tier (default: top tier only), gated by a thrown
  ROOF_CLEAR check against the tier above
- tiers may be given directly as the tierSpec array (tiers = array.length)

tiers: 1 with default tierSpec stays byte-identical (sha256 vertex hash
locked in a new test); .vtopo recompiles unchanged for the catalog entry.
The stairs were across the front of the bowl. They are now a stair TOWER on the
bay's END face, travelling along Z parallel to the rake and climbing promenade to
promenade — a scaffold cage bolted to the end, the way a temporary stand's stair
tower is built (Madring IMG_2437).

- `tierSpec.stairs: boolean` -> `stairSide: 'left' | 'right' | 'both' | 'none'`
  (default `'right'` for tiers >= 1, `'none'` for tier 0) plus `stairWidth`
  (default 1.4 m, f1-stairs' own flight width).
- ONE cage per end face serves the whole stack, not one per tier: successive
  tiers step back as they stack, so per-tier cages would interleave standards in
  the same volume — and a shared cage makes tier k's head landing and tier k+1's
  foot landing the SAME landing, which is what they are in a real stand.
- Each promenade-to-promenade leg folds into a switchback whose two lane runs are
  fixed by the geometry (runA - runB is the step back less one landing, runA +
  runB the nominal run at the FIA going), so the free variable is the step split,
  chosen to keep both lanes' derived going near 280 mm.
- Flights are built from f1-stairs' OWN tread pan, stringer stations and channel
  stringer — those three are exported from that module (geometry untouched) so
  the stand's access stair and the catalogue flight are literally one part. The
  tower adds a raking bottom chord and web per stringer, because f1-stairs' 220 mm
  channel is sized for a two-metre catalogue flight, not an eight-metre one.
- `getFootprint()` reports the seating module vs the total width including the
  towers, so an emitter tiling a run of bays can place towers only at the ends;
  bays mid-run pass `stairSide: 'none'`.
- `getCollisionVolumes()` / `parts.collision`: a first cut at what a physics
  engine should get for one placed stand — one convex prism per tier's bowl mass,
  one box per support line, one box per stair tower. The roof is excluded by
  design: nothing that collides reaches it, and its cantilever is exactly what
  would push a whole-model AABB out over the racing line. The group is not
  parented to `root` (so it can never reach the visual merge or the .vtopo
  compile); `debug: { collision: true }` parents it for a capture.

`tiers: 1` is byte-identical — the sha256 vertex proof still holds and both
`.vtopo` sidecars recompile unchanged.
…irs switches

Miguel on 8r-3t: "looks wrong because the canopy up top doesn't extend over the
whole section like it does for rows 24 tiers 1. Fix that. We should also be able
to config the roof on or off, and config stairs or no stairs so we can squeeze
bays together. Make it obvious."

THE CANOPY. A stack used to park a small lid on the top deck. It now spans the
whole SECTION by default: the rear tie stays exactly where the top tier's own
roof tied it (that tier's bowlTop + ROOF_CLEAR at zBack) and the leading edge
runs out to TIER 0's halfD + ROOF_FRONT — the same z a single-tier bay's roof
leads at, to the millimetre (measured: 5.060 m either way). So 8r-3t reads with
the rows-24 single-tier profile at three-tier height: one rafter falling
ROOF_FALL from the rear tie, then the TIP_U curl opening the front out over the
track. Roof z-extent 20.880 m against a stand 21.045 m deep.

Nothing is forked to do it. rafterAt / membranePanel / bayWeb / roofRafters /
roofEdges / buildRoof / buildColumns / buildFascia are already parameterised by
the layout's zBack/zFront/yBack/yFront, so the canopy is the TOP tier's own
layout with zFront pushed forward. tiers:1 takes an early return — today's three
calls on today's layout object, in today's order.

- Intermediate props: a twenty-metre rafter needs more than its leading-edge
  columns, so every tier BELOW the top carries a column line on its own
  promenade, on the roof's BAY pitch, up to the rafter underside. Every foot is
  computed through surfaceY and asserted to be that promenade; gangway stations
  are skipped on the same clear-width test seats are.
- Clearance is solved, not assumed: the rafter line is checked against every
  tier's EXPOSED mass top (bowlMassTopAt — not surfaceY, which clamps the rear
  strip one rise low) at 24 stations across the depth that tier actually shows
  to the sky, and yFront is RAISED until it clears ROOF_CLEAR. Raising the front
  only opens the canopy further, so the ROOF_FALL/TIP_U/ROOF_LIFT semantics
  survive; a solve that would push the leading edge above its own rear tie means
  the stack is wrong, and throws. No config tested needs a raise (0.000 m).

THE TWO SWITCHES, documented with a six-line example at the top of the header.

- roof: 'full' | 'top' | 'none' | 'per-tier', default 'full'. 'top' is the old
  top-tier-only canopy; 'none' removes membrane, rafters, columns and fascia.
  'per-tier' is the ONLY value that reads tierSpec[k].roof — under every other
  value that per-tier field is ignored, so the precedence is explicit rather
  than magic.
- stairs: 'none' | 'left' | 'right' | 'both' | 'ends', default 'none', so tiled
  bays squeeze together at exactly `width` and the EMITTER decides which bay
  gets a tower. 'ends' is a documented alias for 'both'. This one is only the
  DEFAULT a tier falls back to: tierSpec[k].stairSide still wins when set.
- getFootprint() is unchanged in shape and reports the RESOLVED stairs;
  configure() patches both live.

GATE. {rows:8,width:10} is byte-identical — sha256 vertex proof still
3570efa087f65d4981ccb6826b05f41abd494267e920100f1c578521b24b1674 — and the
.vtopo sidecar is untouched. tsc clean, 447/447 f1-kit tests, 77/77 topology.
The cage's lowest landing was tier 0's own promenade (DECK 0.95 m + plinth), so
the bottom bays carried no stair and the stand had no way in from grade — the
lower third of every tower read as an empty braced shaft.

- An ENTRY flight now runs grade -> tier 0's promenade: f1-stairs' own tread pan,
  channel stringer, double rail, toe board and 180/280 pitch, built by the same
  `flightGeometry` as every other flight in the cage.
- It rides INSIDE the cage's existing plan. A folded cage is two lanes wide and
  the tier 0 -> tier 1 flight leaves the foot landing's rear edge on the OUTBOARD
  lane, so the inboard lane's rear is free to grade; the entry climbs it and
  switchbacks at tier 0. `getFootprint()`, the tower's zFront/zRear and the
  stair-tower collision box are byte-for-byte what they were. The `'front'`
  fallback (a cage whose flights all run straight — ~175 rows) hangs off the
  landing's front edge instead and lets zFront grow to cover it.
- The entry flight alone is built without `flightGeometry`'s underslung bottom
  chord: that chord hangs 0.46 m below the flight datum, and this flight's datum
  is grade. It bears on sole boards instead.
- `stairs: 'none'`, the default `{rows:8,width:10}` and the catalogue
  `{rows:2,width:3}` stay byte-identical (sha256 vertex proof); .vtopo unchanged.
`floors`/`tower` (f82c6d4 + 0084aab) were built without the preview -> critique
loop. Ran it: four fresh critics against the Madring references
(pit-building-render, paddock-club-render, imagen-paddock,
f1com-YDRAY-foto-pit-building) over `{count:4, floors:3}` and the tower variant.

Three highest-impact fixes, one per iteration:

1. Each Paddock Club storey is a CLOSED volume. It had glazing on the pit-lane
   face only, so the floor slab and plate were open on their other three sides
   and the stack read as floating trays over voids. Solid end walls and rear
   wall, held a party-wall thickness inside the slab and plate so the two
   horizontal bands stand proud as shadow reveals.
2. The roof oversails the pit lane. It capped flush at the facade; it now
   cantilevers 3.4 m as a blade thinner than the slab it grows out of, on a dark
   soffit with a bright leading lip.
3. The blade RAKES (hinged at the facade, falling 0.105 rad), and the parapet
   became an upstand held 0.5 m back from the slab edge with nothing on the
   pit-lane side — a band wrapping all four edges added itself to the slab and
   the two read as one thick white lid.
4. Terraces STEP OUT as they rise (1.7 m, then 2.5 m) instead of stacking flush,
   which is what turns the red leading edge from a stripe on a sheer wall into a
   real balcony edge. Both later critics asked for this independently.

Scores across the pass: 35 -> 34 -> 56 -> 48 -> 38. The signal plateaued into
noise, not the model: the last critic scored 38 while asserting the terraces
"sit perfectly plumb on one flat face", which the new `terrace-1.max.z >
terrace-0.max.z + 0.5` assertion measures as false. Stopped at the plateau per
the loop rule; the remaining gap is render/material evidence, not geometry.

- `floors: 1` stays byte-identical (identity test green, .vtopo unchanged).
- The 18.5 m datum is now asserted directly on the ROOF DECK, plus tests for the
  storey envelopes, the stepped terraces and the raked canopy oversailing them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant