Render C1 structural variety: girder bridges, Ginza overpasses, JCT speeds, 3+3 widening

Four fidelity fixes, all verified in a live Studio datamodel (not just built):

1. Wire up the dead `bridge` flag. Circuit.luau marks 474 samples as bridge
   spans but nothing ever read the field, so every elevated metre rendered as
   the same slab on square columns. Flagged spans now get a steel box girder
   (鋼箱桁橋) with stiffener ribs under the deck, carried on a transverse pier
   cap and round columns at a wider pitch than the viaduct pillars.

2. Ginza 跨道橋. The Ginza section sits below street level in the filled-in
   楓川・築地川 river beds and the surviving city bridges cross over it, their
   piers landing in the median. Adds the crossing decks, parapets and centre
   piers -- which is the physical reason lane changes are banned there, not
   just a paint choice.

3. Junction speed plates. 江戸橋JCT内 / 浜崎橋JCT内 / 京橋JCT-旧白魚橋乗継所 are
   posted 40 km/h, not the 50 km/h mainline limit; plates repeat more often
   through the short restricted stretches.

4. Shiodome JCT -> Hamazakibashi JCT widened to 3+3 lanes via widthAt(), with
   a taper (max ~1.7 studs/sample) instead of a step. Every fixed-width
   consumer -- deck, median, parapets, lane marks, signage offsets, skyline
   setback, finish line -- now reads widthAt() so nothing floats or clips.

Verified headlessly (tools/verify_c1_fixes*.luau):
  girders 488 / 474 flagged samples, pier caps 85, columns 80
  Ginza overpasses 7, piers 7, lateral offset from centreline 0.000 studs
  speed plates 40km/h x7, 50km/h x8
  deck width Shiodome 81.5 vs Kasumigaseki 60.0
  seam lip max 0.0006, road-surface raycast step max 0.260, 0 deck holes

Two earlier "failures" were bad tests, not bad geometry, and the fixed
harnesses document the traps: deck parts are pitched by CFrame.lookAt so
Position.Y+Size.Y/2 is not the surface height (and LookVector is -Z, so the
end faces are easy to swap), and a seam raycast must filter to the Expressway
folder or it hits TunnelCeiling/MedianFence and reports a phantom 19-stud step.
This commit is contained in:
2026-08-01 18:10:00 -04:00
parent 967385261d
commit 7d368d5444
7 changed files with 856 additions and 61 deletions
+238 -60
View File
@@ -49,11 +49,16 @@ local MEDIAN_HEIGHT = 3.2
local BARRIER_H = 5 -- white concrete parapet height
local BARRIER_T = 2.2 -- parapet thickness
local RAIL_H = 3 -- dark metal railing sitting on the parapet
-- Steel box-girder depth under bridge decks (鋼箱桁). Real Shutoko girders run
-- roughly 1/20 of the span depth; at ~35 m spans that's ~1.8 m => ~6.4 studs.
local GIRDER_H = 6.4
local C_ASPHALT = Color3.fromRGB(78, 78, 82)
local C_CONCRETE = Color3.fromRGB(214, 212, 205) -- Shutoko pale concrete
local C_PARAPET = Color3.fromRGB(226, 224, 218)
local C_RAIL = Color3.fromRGB(96, 100, 106)
-- Weathered grey-green steel of the box girders under the elevated sections.
local C_GIRDER = Color3.fromRGB(122, 128, 124)
local UP = Vector3.new(0, 1, 0)
-- REAL-WORLD SCALE ------------------------------------------------------------
@@ -115,6 +120,70 @@ local NEON_BLUE = Color3.fromRGB(40, 160, 255)
local NEON_TEAL = Color3.fromRGB(40, 230, 200)
local NEON_AMBER = Color3.fromRGB(255, 170, 60)
-- SECTION PROFILE (real C1 lane counts and posted speeds) ---------------------
-- The C1 is NOT uniform. Verified against the 車線・最高速度 table in the
-- 首都高速都心環状線 article:
--
-- 江戸橋JCT内 2 = 1+1 40 km/h
-- 江戸橋JCT - 汐留JCT 4 = 2+2 50 km/h
-- 汐留JCT - 浜崎橋JCT 6 = 3+3 50 km/h <-- widest section
-- 浜崎橋JCT内 4 = 2+2 40 km/h
-- 浜崎橋JCT - 江戸橋JCT 4 = 2+2 50 km/h
-- 京橋JCT - 旧白魚橋乗継所 3 = 1+2 40 km/h
--
-- So junction interiors are posted 40, mainline 50, and the Shiodome ->
-- Hamazakibashi run carries three lanes each way rather than two.
local JCT_ZONES: { [string]: boolean } = {
["Edobashi junction"] = true,
["Takebashi Junction"] = true,
["Miyakezaka JCT"] = true,
["Miyakezaka Junction"] = true,
["Tanimachi JCT"] = true,
}
-- 汐留JCT - 浜崎橋JCT: 6 lanes (3+3).
local WIDE_ZONES: { [string]: boolean } = { ["Shiodome"] = true }
-- 銀座: the 半地下 section built in the filled-in 楓川/築地川 river beds. Several
-- of the original river bridges survive as 跨道橋 crossing OVER the expressway,
-- and their piers stand BETWEEN the running lanes -- which is why the real road
-- bans lane changes through here.
local GINZA_ZONES: { [string]: boolean } = { ["Ginza"] = true }
-- Deck width needed for 3+3: 6 x 3.25 m lanes + median + shoulders.
local WIDE_ROAD_WIDTH = 82
local MAX_ROAD_WIDTH = math.max(ROAD_WIDTH, WIDE_ROAD_WIDTH)
local function zoneOf(i: number): string
return Circuit.samples[i].piece
end
local function isJctSample(i: number): boolean
return JCT_ZONES[zoneOf(i)] == true
end
local function lanesAt(i: number): number
return WIDE_ZONES[zoneOf(i)] and 3 or 2
end
-- Per-sample deck width, smoothed so the carriageway tapers into and out of the
-- 3+3 section instead of stepping by 22 studs between two adjacent segments.
local ROAD_W: { number } = {}
do
local n = #Circuit.samples
local base: { number } = {}
for i = 1, n do
base[i] = WIDE_ZONES[Circuit.samples[i].piece] and WIDE_ROAD_WIDTH or ROAD_WIDTH
end
local TAPER = 6
for i = 1, n do
local acc = 0
for k = -TAPER, TAPER do
acc += base[((i - 1 + k) % n) + 1]
end
ROAD_W[i] = acc / (2 * TAPER + 1)
end
end
local function widthAt(i: number): number
return ROAD_W[i]
end
local function part(props: { [string]: any }): Part
local p = Instance.new("Part")
p.Anchored = true
@@ -320,7 +389,7 @@ local function buildRoad(map: Folder)
local deck = part({
Name = "Road_" .. i,
Size = Vector3.new(ROAD_WIDTH, 2, len),
Size = Vector3.new(widthAt(i), 2, len),
CFrame = cf,
Color = C_ASPHALT,
Material = Enum.Material.Asphalt,
@@ -331,7 +400,7 @@ local function buildRoad(map: Folder)
-- Real Wangan has a solid median, not a painted line: two lanes each
-- way, physically separated. This is collidable, so a head-on into
-- oncoming traffic isn't possible -- you hit the divider instead.
local HALF = ROAD_WIDTH / 2
local HALF = widthAt(i) / 2
local MED_HALF = MEDIAN_WIDTH / 2
-- ...except at the PA junctions, where it opens so both directions of
-- travel can cross into the parking area (see planPA).
@@ -356,21 +425,23 @@ local function buildRoad(map: Folder)
fence.Parent = struct
end
-- LANE MARKINGS: 2 lanes per carriageway ----------------------------
-- C1 is a 2-lane-per-direction urban expressway (confirmed against
-- OSM tagging of 首都高速都心環状線: lanes=2, maxspeed=50). Lane width
-- is the narrow urban 3.25 m rather than the 3.5 m used on the
-- Bayshore, which is what leaves so little shoulder on the real road.
-- LANE MARKINGS -----------------------------------------------------
-- Lane count varies by section (see SECTION PROFILE above): most of the
-- C1 is 2+2, but 汐留JCT-浜崎橋JCT is 3+3. A 3-lane carriageway needs
-- TWO boundary lines, not one, so the markings are generated per lane
-- boundary rather than assuming a single centre dash.
--
-- Dashes are driven by ARC LENGTH along the circuit, not by sample
-- index. That matters: samples are 60 studs (16.8 m) apart but the
-- legal dash cycle is 20 m, so an index-based dash can never land on
-- the right rhythm -- it has to be computed in metres and clipped to
-- each segment.
local cwMid = MED_HALF + LANE_W
-- Lane changes are prohibited (yellow line) through the tunnels and
-- on the tight curves, which on the real C1 is most of the loop.
local laneColor = (a.tunnel or curvature > TIGHT_CURVE) and C_PAINT_YELLOW or C_PAINT_WHITE
local nLanes = lanesAt(i)
-- Lane changes are prohibited (yellow line) through the tunnels, on the
-- tight curves, and through Ginza -- where 跨道橋 piers stand between
-- the lanes, which is exactly why the real road forbids it there.
local noLaneChange = a.tunnel or curvature > TIGHT_CURVE or GINZA_ZONES[a.piece] == true
local laneColor = noLaneChange and C_PAINT_YELLOW or C_PAINT_WHITE
for _, s in ipairs({ -1, 1 }) do
-- 車道外側線: solid, continuous, 0.20 m wide.
@@ -383,39 +454,42 @@ local function buildRoad(map: Folder)
})
edge.Parent = road
-- 車線境界線 between the two lanes.
if laneColor == C_PAINT_YELLOW then
-- Yellow no-lane-change line is SOLID, not dashed.
local solid = part({
Name = "LaneLine",
Size = Vector3.new(LANE_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, 0),
Color = C_PAINT_YELLOW,
Material = Enum.Material.SmoothPlastic,
})
solid.Parent = road
else
-- Dashed: paint the parts of the 20 m cycle that fall inside
-- this segment. segStart is the distance travelled around the
-- loop, so the rhythm stays continuous across segment joins.
local segLen = delta.Magnitude
local k = math.floor(segStart / DASH_CYCLE)
while k * DASH_CYCLE < segStart + segLen do
local pStart = math.max(k * DASH_CYCLE, segStart)
local pEnd = math.min(k * DASH_CYCLE + DASH_PAINT, segStart + segLen)
if pEnd > pStart then
-- Convert back to an offset from the segment centre.
local mid01 = ((pStart + pEnd) / 2 - segStart) / segLen
local dash = part({
Name = "Dash",
Size = Vector3.new(LANE_LINE_W, MARK_H, pEnd - pStart),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, (mid01 - 0.5) * segLen),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
dash.Parent = road
-- 車線境界線: one per boundary between adjacent lanes.
for lane = 1, nLanes - 1 do
local cwMid = MED_HALF + LANE_W * lane
if laneColor == C_PAINT_YELLOW then
-- Yellow no-lane-change line is SOLID, not dashed.
local solid = part({
Name = "LaneLine",
Size = Vector3.new(LANE_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, 0),
Color = C_PAINT_YELLOW,
Material = Enum.Material.SmoothPlastic,
})
solid.Parent = road
else
-- Dashed: paint the parts of the 20 m cycle that fall inside
-- this segment. segStart is the distance travelled around the
-- loop, so the rhythm stays continuous across segment joins.
local segLen = delta.Magnitude
local k = math.floor(segStart / DASH_CYCLE)
while k * DASH_CYCLE < segStart + segLen do
local pStart = math.max(k * DASH_CYCLE, segStart)
local pEnd = math.min(k * DASH_CYCLE + DASH_PAINT, segStart + segLen)
if pEnd > pStart then
-- Convert back to an offset from the segment centre.
local mid01 = ((pStart + pEnd) / 2 - segStart) / segLen
local dash = part({
Name = "Dash",
Size = Vector3.new(LANE_LINE_W, MARK_H, pEnd - pStart),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, (mid01 - 0.5) * segLen),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
dash.Parent = road
end
k += 1
end
k += 1
end
end
end
@@ -469,7 +543,7 @@ local function buildRoad(map: Folder)
-- Yamate/C1-style bore: walls, ceiling, strip lighting
local h = 24
for _, side in ipairs({ -1, 1 }) do
local woff = right * (side * (ROAD_WIDTH / 2 + 1.5))
local woff = right * (side * (HALF + 1.5))
local w = part({
Name = "TunnelWall",
Size = Vector3.new(3, h, len + 2),
@@ -481,7 +555,7 @@ local function buildRoad(map: Folder)
end
local ceil = part({
Name = "TunnelCeiling",
Size = Vector3.new(ROAD_WIDTH + 6, 2, len + 2),
Size = Vector3.new(widthAt(i) + 6, 2, len + 2),
CFrame = cf * CFrame.new(0, h + 1, 0),
Color = Color3.fromRGB(30, 30, 38),
Material = Enum.Material.Concrete,
@@ -493,15 +567,68 @@ local function buildRoad(map: Folder)
-- every segment rather than every other one.
local strip = part({
Name = "TunnelLight",
Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, len),
Size = Vector3.new(widthAt(i) * 0.5, 0.6, len),
CFrame = cf * CFrame.new(0, h - 0.5, 0),
Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED,
Material = Enum.Material.Neon,
})
strip.Parent = struct
else
-- BRIDGE / VIADUCT SUPERSTRUCTURE ------------------------------
-- Circuit.luau flags 474 samples as bridge=1, but nothing was ever
-- reading that flag -- every elevated span rendered as the same
-- plain slab on square columns. On the real C1 the flagged
-- sections are steel box-girder spans (鋼箱桁橋) over the old
-- river/canal alignments: a deep girder under the deck, sitting on
-- a transverse pier cap carried by round columns on a wider pitch.
if a.bridge then
-- Deep girder immediately under the deck -- this is the single
-- most recognisable feature of a Shutoko viaduct in profile.
local girder = part({
Name = "BridgeGirder",
Size = Vector3.new(widthAt(i) * 0.72, GIRDER_H, len),
CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, 0),
Color = C_GIRDER,
Material = Enum.Material.Metal,
})
girder.Parent = struct
-- Girders are fabricated in segments; the transverse stiffener
-- ribs between them are what give the underside its rhythm.
local rib = part({
Name = "GirderRib",
Size = Vector3.new(widthAt(i) * 0.78, GIRDER_H * 0.82, 1.2),
CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, len / 2),
Color = C_GIRDER,
Material = Enum.Material.Metal,
})
rib.Parent = struct
-- Piers on a wider pitch than the viaduct columns, with the
-- pier cap (橋脚梁) spanning the full deck width.
if i % 6 == 0 and a.pos.Y > 14 then
local capY = a.pos.Y - 1 - GIRDER_H - 1.6
local cap = part({
Name = "PierCap",
Size = Vector3.new(widthAt(i) * 0.92, 3.2, 7),
CFrame = CFrame.new(mid.X, capY, mid.Z) * CFrame.Angles(0, math.atan2(flatDir.X, flatDir.Z), 0),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
})
cap.Parent = struct
local col = part({
Name = "PierColumn",
Size = Vector3.new(8, capY - 1.6, 8),
CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
Shape = Enum.PartType.Cylinder,
})
-- Cylinder's axis is X, so stand it upright.
col.CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z) * CFrame.Angles(0, 0, math.rad(90))
col.Parent = struct
end
-- Elevated viaduct: support pillars down to street level
if i % 3 == 0 and a.pos.Y > 18 then
elseif i % 3 == 0 and a.pos.Y > 18 then
local pil = part({
Name = "Pillar",
Size = Vector3.new(9, a.pos.Y, 9),
@@ -518,7 +645,7 @@ local function buildRoad(map: Folder)
local lamp = part({
Name = "RoadLight",
Size = Vector3.new(1.4, 1.4, 1.4),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2) + Vector3.new(0, 5.5, 0)),
CFrame = CFrame.new(mid + right * HALF + Vector3.new(0, 5.5, 0)),
Color = C_SODIUM,
Material = Enum.Material.Neon,
})
@@ -530,7 +657,7 @@ local function buildRoad(map: Folder)
local delin = part({
Name = "Delineator",
Size = Vector3.new(0.5, 1.1, 0.5),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 2.5, 0)),
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 2.5, 0)),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
@@ -538,7 +665,7 @@ local function buildRoad(map: Folder)
local eye = part({
Name = "DelineatorEye",
Size = Vector3.new(0.36, 0.36, 0.36),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 3.0, 0)),
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 3.0, 0)),
Color = Color3.fromRGB(255, 190, 80),
Material = Enum.Material.Neon,
})
@@ -547,7 +674,7 @@ local function buildRoad(map: Folder)
local pylon = part({
Name = "Pylon",
Size = Vector3.new(1, 14, 1),
CFrame = CFrame.new(mid - right * (ROAD_WIDTH / 2 + 3) + Vector3.new(0, 7, 0)),
CFrame = CFrame.new(mid - right * (HALF + 3) + Vector3.new(0, 7, 0)),
Color = (i % 18 == 0) and NEON_PINK or NEON_BLUE,
Material = Enum.Material.Neon,
})
@@ -555,6 +682,49 @@ local function buildRoad(map: Folder)
end
end
-- GINZA 跨道橋 (bridges crossing OVER the expressway) ----------------
-- The Ginza section runs in the filled-in 楓川・築地川 river beds, so it
-- sits BELOW street level and the surviving city bridges cross OVER it.
-- Their piers land in the median, physically between the carriageways --
-- which is the real reason lane changes are banned here (the yellow
-- line above is driven by GINZA_ZONES for exactly this reason).
-- Placed outside the tunnel/viaduct branch: Ginza has 0 bridge-flagged
-- samples, so nesting this under `a.bridge` would never render it.
if GINZA_ZONES[a.piece] and i % 7 == 0 then
local deckY = a.pos.Y + 26
local yaw = math.atan2(flatDir.X, flatDir.Z)
local ov = part({
Name = "GinzaOverpass",
Size = Vector3.new(widthAt(i) + 70, 3.4, 26),
CFrame = CFrame.new(mid.X, deckY, mid.Z) * CFrame.Angles(0, yaw, 0),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
})
ov.Parent = struct
for _, es in ipairs({ -1, 1 }) do
local par = part({
Name = "OverpassParapet",
Size = Vector3.new(widthAt(i) + 70, 3.6, 1.4),
CFrame = CFrame.new(mid.X, deckY + 3.4, mid.Z)
* CFrame.Angles(0, yaw, 0)
* CFrame.new(0, 0, es * 12.3),
Color = C_PARAPET,
Material = Enum.Material.Concrete,
})
par.Parent = struct
end
-- Centre pier, standing in the median between the directions.
local pierH = deckY - a.pos.Y - 1.7
local pier = part({
Name = "OverpassPier",
Size = Vector3.new(4.2, pierH, 9),
CFrame = CFrame.new(mid.X, a.pos.Y + pierH / 2 + 1, mid.Z) * CFrame.Angles(0, yaw, 0),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
})
pier.Parent = struct
end
-- 導流帯 / gore zebra at the PA ramp junctions --------------------
-- Where the ramp splits off the mainline the real road paints a
-- zebra-hatched no-drive wedge in the gore. 45 cm bars every 1.0 m
@@ -708,15 +878,21 @@ local function buildJunctionSigns(map: Folder)
flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis
local right = flat:Cross(UP)
-- C1 is posted at 50 km/h; repeat roughly every 1 km, off to the side.
if i % 60 == 0 and not a.tunnel then
local p = a.pos + right * (ROAD_WIDTH / 2 - 3) + Vector3.new(0, 9, 0)
makeSpeedSign(50, CFrame.lookAt(p, p - flat, UP), folder)
-- Posted speed: 50 km/h on the mainline, but 40 km/h inside the
-- junction sections (江戸橋JCT内, 浜崎橋JCT内, 京橋JCT-旧白魚橋乗継所
-- are all posted 40 in the route's own 車線・最高速度 table). Signs
-- repeat roughly every 1 km; junction plates go up more often because
-- the restricted stretches are short.
local jct = isJctSample(i)
local every = jct and 24 or 60
if i % every == 0 and not a.tunnel then
local p = a.pos + right * (widthAt(i) / 2 - 3) + Vector3.new(0, 9, 0)
makeSpeedSign(jct and 40 or 50, CFrame.lookAt(p, p - flat, UP), folder)
end
-- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs).
if dist >= nextKm * 500 * M then
local p = a.pos + right * (ROAD_WIDTH / 2 - 2) + Vector3.new(0, 4, 0)
local p = a.pos + right * (widthAt(i) / 2 - 2) + Vector3.new(0, 4, 0)
makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder)
nextKm += 1
end
@@ -1150,7 +1326,9 @@ local function buildSkyline(map: Folder)
-- put it through the tunnel bore (walls sit at ROAD_WIDTH/2 + 1.5 + 3).
-- Sample every point too -- striding by 3 skipped the closest approach on
-- tight corners.
local CLEAR = ROAD_WIDTH / 2 + 60
-- Use the WIDEST deck (3+3 Shiodome section), otherwise buildings sited
-- against the 2+2 width would intrude into the widened carriageway.
local CLEAR = MAX_ROAD_WIDTH / 2 + 60
local near = math.huge
for k = 1, n do
local s = samples[k].pos
@@ -1347,7 +1525,7 @@ local function buildBridges(map: Folder)
local cf = CFrame.lookAt(m, m + fwd, UP)
local g = part({
Name = "BoxGirder",
Size = Vector3.new(ROAD_WIDTH + 10, 9, 64),
Size = Vector3.new(widthAt(i) + 10, 9, 64),
CFrame = cf * CFrame.new(0, -6, 0),
Color = Color3.fromRGB(64, 64, 72),
Material = Enum.Material.Metal,
@@ -1462,7 +1640,7 @@ local function buildBridges(map: Folder)
local fwd = Circuit.forwardAt(i)
local g = part({
Name = "BridgeGirder",
Size = Vector3.new(ROAD_WIDTH + 14, 7, 64),
Size = Vector3.new(widthAt(i) + 14, 7, 64),
CFrame = CFrame.lookAt(m, m + fwd, UP) * CFrame.new(0, -5, 0),
Color = RED,
Material = Enum.Material.Metal,
@@ -1504,7 +1682,7 @@ function MapBuilder.build(): Folder
local fwd = Circuit.forwardAt(1)
local line = part({
Name = "FinishLine",
Size = Vector3.new(ROAD_WIDTH, 0.6, 8),
Size = Vector3.new(widthAt(1), 0.6, 8),
CFrame = CFrame.lookAt(s1.pos + Vector3.new(0, 1.4, 0), s1.pos + Vector3.new(0, 1.4, 0) + fwd, UP),
Color = Color3.fromRGB(250, 250, 255),
Material = Enum.Material.Neon,