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
+31 -1
View File
@@ -43,7 +43,7 @@ blue. Shutoko uses green.
| Route | Posted | | Route | Posted |
|---|---| |---|---|
| C1 都心環状線 (Inner Circular) | **50 km/h**, dropping to **40 km/h** in the tightest junction sections (e.g. Edobashi JCT) | | C1 都心環状線 (Inner Circular) | **50 km/h** mainline, **40 km/h** inside the junction sections — 江戸橋JCT内, 浜崎橋JCT内, and 京橋JCT–旧白魚橋乗継所 are each posted 40 in the route's own 車線・最高速度 table |
| B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) | | B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) |
The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be
@@ -58,6 +58,36 @@ posted 50, not 80.
photometry. Porous asphalt (排水性舗装) does read darker and coarser than photometry. Porous asphalt (排水性舗装) does read darker and coarser than
ordinary asphalt, which the palette reflects. ordinary asphalt, which the palette reflects.
## Structural sections of the C1 (drives MapBuilder geometry)
The C1 is not a uniform elevated tube — it changes structural type along its
length, and the circuit now renders each type differently.
| Section | Real-world form | In-game |
|---|---|---|
| Elevated viaduct (most of the loop) | Deck on concrete columns | `Pillar` every 3rd sample above Y=18 |
| Bridge spans (474 flagged samples) | Steel box girder 鋼箱桁橋 over the old canals | `BridgeGirder` + `GirderRib` under deck; `PierCap` + round `PierColumn` on a 6-sample pitch |
| Ginza 銀座 | Runs **below** street level in the filled-in 楓川・築地川 river beds; surviving city bridges cross **over** it, piers landing in the median | `GinzaOverpass` + `OverpassParapet`, `OverpassPier` in the median |
| Tunnel sections | Bored/cut-and-cover | Tunnel bore, ceiling, no sky lighting |
Girder depth is set from the span/depth rule of thumb for steel box girders
(≈1/20 of span): at ~35 m spans that is ~1.8 m ≈ 6.4 studs (`GIRDER_H`).
**Why Ginza bans lane changes.** The no-lane-change marking through Ginza is not
arbitrary styling — the crossing bridges' piers physically occupy the median
between the carriageways, so the lanes are separated by structure. The yellow
line and the `OverpassPier` geometry are two expressions of the same fact.
## Lane counts
| Section | Lanes |
|---|---|
| Most of C1 | 2 + 2 |
| Shiodome JCT → Hamazakibashi JCT | **3 + 3** (widened; carries the merge from the Haneda line) |
Rendered by `widthAt(i)`: 60 studs baseline, 82 studs in `WIDE_ZONES`, with a
taper between so the widening never steps more than ~1.7 studs per sample.
## Method note ## Method note
Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and
+207 -29
View File
@@ -49,11 +49,16 @@ local MEDIAN_HEIGHT = 3.2
local BARRIER_H = 5 -- white concrete parapet height local BARRIER_H = 5 -- white concrete parapet height
local BARRIER_T = 2.2 -- parapet thickness local BARRIER_T = 2.2 -- parapet thickness
local RAIL_H = 3 -- dark metal railing sitting on the parapet 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_ASPHALT = Color3.fromRGB(78, 78, 82)
local C_CONCRETE = Color3.fromRGB(214, 212, 205) -- Shutoko pale concrete local C_CONCRETE = Color3.fromRGB(214, 212, 205) -- Shutoko pale concrete
local C_PARAPET = Color3.fromRGB(226, 224, 218) local C_PARAPET = Color3.fromRGB(226, 224, 218)
local C_RAIL = Color3.fromRGB(96, 100, 106) 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) local UP = Vector3.new(0, 1, 0)
-- REAL-WORLD SCALE ------------------------------------------------------------ -- 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_TEAL = Color3.fromRGB(40, 230, 200)
local NEON_AMBER = Color3.fromRGB(255, 170, 60) 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 function part(props: { [string]: any }): Part
local p = Instance.new("Part") local p = Instance.new("Part")
p.Anchored = true p.Anchored = true
@@ -320,7 +389,7 @@ local function buildRoad(map: Folder)
local deck = part({ local deck = part({
Name = "Road_" .. i, Name = "Road_" .. i,
Size = Vector3.new(ROAD_WIDTH, 2, len), Size = Vector3.new(widthAt(i), 2, len),
CFrame = cf, CFrame = cf,
Color = C_ASPHALT, Color = C_ASPHALT,
Material = Enum.Material.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 -- Real Wangan has a solid median, not a painted line: two lanes each
-- way, physically separated. This is collidable, so a head-on into -- way, physically separated. This is collidable, so a head-on into
-- oncoming traffic isn't possible -- you hit the divider instead. -- 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 local MED_HALF = MEDIAN_WIDTH / 2
-- ...except at the PA junctions, where it opens so both directions of -- ...except at the PA junctions, where it opens so both directions of
-- travel can cross into the parking area (see planPA). -- travel can cross into the parking area (see planPA).
@@ -356,21 +425,23 @@ local function buildRoad(map: Folder)
fence.Parent = struct fence.Parent = struct
end end
-- LANE MARKINGS: 2 lanes per carriageway ---------------------------- -- LANE MARKINGS -----------------------------------------------------
-- C1 is a 2-lane-per-direction urban expressway (confirmed against -- Lane count varies by section (see SECTION PROFILE above): most of the
-- OSM tagging of 首都高速都心環状線: lanes=2, maxspeed=50). Lane width -- C1 is 2+2, but 汐留JCT-浜崎橋JCT is 3+3. A 3-lane carriageway needs
-- is the narrow urban 3.25 m rather than the 3.5 m used on the -- TWO boundary lines, not one, so the markings are generated per lane
-- Bayshore, which is what leaves so little shoulder on the real road. -- boundary rather than assuming a single centre dash.
-- --
-- Dashes are driven by ARC LENGTH along the circuit, not by sample -- 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 -- 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 -- 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 -- the right rhythm -- it has to be computed in metres and clipped to
-- each segment. -- each segment.
local cwMid = MED_HALF + LANE_W local nLanes = lanesAt(i)
-- Lane changes are prohibited (yellow line) through the tunnels and -- Lane changes are prohibited (yellow line) through the tunnels, on the
-- on the tight curves, which on the real C1 is most of the loop. -- tight curves, and through Ginza -- where 跨道橋 piers stand between
local laneColor = (a.tunnel or curvature > TIGHT_CURVE) and C_PAINT_YELLOW or C_PAINT_WHITE -- 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 for _, s in ipairs({ -1, 1 }) do
-- 車道外側線: solid, continuous, 0.20 m wide. -- 車道外側線: solid, continuous, 0.20 m wide.
@@ -383,7 +454,9 @@ local function buildRoad(map: Folder)
}) })
edge.Parent = road edge.Parent = road
-- 車線境界線 between the two lanes. -- 車線境界線: 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 if laneColor == C_PAINT_YELLOW then
-- Yellow no-lane-change line is SOLID, not dashed. -- Yellow no-lane-change line is SOLID, not dashed.
local solid = part({ local solid = part({
@@ -419,6 +492,7 @@ local function buildRoad(map: Folder)
end end
end end
end end
end
-- OUTER EDGE BARRIERS ----------------------------------------------- -- OUTER EDGE BARRIERS -----------------------------------------------
-- Visible concrete Jersey barrier sitting ON the deck (this is the part -- Visible concrete Jersey barrier sitting ON the deck (this is the part
@@ -469,7 +543,7 @@ local function buildRoad(map: Folder)
-- Yamate/C1-style bore: walls, ceiling, strip lighting -- Yamate/C1-style bore: walls, ceiling, strip lighting
local h = 24 local h = 24
for _, side in ipairs({ -1, 1 }) do 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({ local w = part({
Name = "TunnelWall", Name = "TunnelWall",
Size = Vector3.new(3, h, len + 2), Size = Vector3.new(3, h, len + 2),
@@ -481,7 +555,7 @@ local function buildRoad(map: Folder)
end end
local ceil = part({ local ceil = part({
Name = "TunnelCeiling", 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), CFrame = cf * CFrame.new(0, h + 1, 0),
Color = Color3.fromRGB(30, 30, 38), Color = Color3.fromRGB(30, 30, 38),
Material = Enum.Material.Concrete, Material = Enum.Material.Concrete,
@@ -493,15 +567,68 @@ local function buildRoad(map: Folder)
-- every segment rather than every other one. -- every segment rather than every other one.
local strip = part({ local strip = part({
Name = "TunnelLight", 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), CFrame = cf * CFrame.new(0, h - 0.5, 0),
Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED, Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED,
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
}) })
strip.Parent = struct strip.Parent = struct
else 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 -- 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({ local pil = part({
Name = "Pillar", Name = "Pillar",
Size = Vector3.new(9, a.pos.Y, 9), Size = Vector3.new(9, a.pos.Y, 9),
@@ -518,7 +645,7 @@ local function buildRoad(map: Folder)
local lamp = part({ local lamp = part({
Name = "RoadLight", Name = "RoadLight",
Size = Vector3.new(1.4, 1.4, 1.4), 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, Color = C_SODIUM,
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
}) })
@@ -530,7 +657,7 @@ local function buildRoad(map: Folder)
local delin = part({ local delin = part({
Name = "Delineator", Name = "Delineator",
Size = Vector3.new(0.5, 1.1, 0.5), 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, Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic, Material = Enum.Material.SmoothPlastic,
}) })
@@ -538,7 +665,7 @@ local function buildRoad(map: Folder)
local eye = part({ local eye = part({
Name = "DelineatorEye", Name = "DelineatorEye",
Size = Vector3.new(0.36, 0.36, 0.36), 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), Color = Color3.fromRGB(255, 190, 80),
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
}) })
@@ -547,7 +674,7 @@ local function buildRoad(map: Folder)
local pylon = part({ local pylon = part({
Name = "Pylon", Name = "Pylon",
Size = Vector3.new(1, 14, 1), 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, Color = (i % 18 == 0) and NEON_PINK or NEON_BLUE,
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
}) })
@@ -555,6 +682,49 @@ local function buildRoad(map: Folder)
end end
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 -------------------- -- 導流帯 / gore zebra at the PA ramp junctions --------------------
-- Where the ramp splits off the mainline the real road paints a -- 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 -- 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 flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis
local right = flat:Cross(UP) local right = flat:Cross(UP)
-- C1 is posted at 50 km/h; repeat roughly every 1 km, off to the side. -- Posted speed: 50 km/h on the mainline, but 40 km/h inside the
if i % 60 == 0 and not a.tunnel then -- junction sections (江戸橋JCT内, 浜崎橋JCT内, 京橋JCT-旧白魚橋乗継所
local p = a.pos + right * (ROAD_WIDTH / 2 - 3) + Vector3.new(0, 9, 0) -- are all posted 40 in the route's own 車線・最高速度 table). Signs
makeSpeedSign(50, CFrame.lookAt(p, p - flat, UP), folder) -- 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 end
-- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs). -- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs).
if dist >= nextKm * 500 * M then 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) makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder)
nextKm += 1 nextKm += 1
end 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). -- 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 -- Sample every point too -- striding by 3 skipped the closest approach on
-- tight corners. -- 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 local near = math.huge
for k = 1, n do for k = 1, n do
local s = samples[k].pos local s = samples[k].pos
@@ -1347,7 +1525,7 @@ local function buildBridges(map: Folder)
local cf = CFrame.lookAt(m, m + fwd, UP) local cf = CFrame.lookAt(m, m + fwd, UP)
local g = part({ local g = part({
Name = "BoxGirder", 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), CFrame = cf * CFrame.new(0, -6, 0),
Color = Color3.fromRGB(64, 64, 72), Color = Color3.fromRGB(64, 64, 72),
Material = Enum.Material.Metal, Material = Enum.Material.Metal,
@@ -1462,7 +1640,7 @@ local function buildBridges(map: Folder)
local fwd = Circuit.forwardAt(i) local fwd = Circuit.forwardAt(i)
local g = part({ local g = part({
Name = "BridgeGirder", 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), CFrame = CFrame.lookAt(m, m + fwd, UP) * CFrame.new(0, -5, 0),
Color = RED, Color = RED,
Material = Enum.Material.Metal, Material = Enum.Material.Metal,
@@ -1504,7 +1682,7 @@ function MapBuilder.build(): Folder
local fwd = Circuit.forwardAt(1) local fwd = Circuit.forwardAt(1)
local line = part({ local line = part({
Name = "FinishLine", 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), 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), Color = Color3.fromRGB(250, 250, 255),
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
+69
View File
@@ -0,0 +1,69 @@
-- Diagnose the raycast outlier: report WHICH parts the rays hit.
-- The v3 raycast filtered on the whole map, so overhead geometry (overpass
-- decks, girders, gantries, tunnel ceilings) can be hit before the road.
local sss = game:GetService("ServerScriptService")
local orig = sss:FindFirstChild("MapBuilder", true)
local clone = orig:Clone()
clone.Name = "MapBuilder_Diag"
clone.Parent = orig.Parent
local MB = require(clone)
local map = MB.build()
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
local samples = Circuit.samples
local n = #samples
local rp = RaycastParams.new()
rp.FilterType = Enum.RaycastFilterType.Include
rp.FilterDescendantsInstances = { map }
local out = {}
-- Road-only params: what the driver actually touches.
local roadOnly = RaycastParams.new()
roadOnly.FilterType = Enum.RaycastFilterType.Include
roadOnly.FilterDescendantsInstances = { map:FindFirstChild("Expressway") }
local worstAll, worstRoad, atAll, atRoad = 0, 0, -1, -1
local hits = {}
for i = 1, n do
local j = (i % n) + 1
local p = samples[j].pos
local fwd = (samples[(j % n) + 1].pos - samples[i].pos)
fwd = Vector3.new(fwd.X, 0, fwd.Z)
if fwd.Magnitude > 0.01 then
fwd = fwd.Unit
local o1 = p - fwd * 3 + Vector3.new(0, 40, 0)
local o2 = p + fwd * 3 + Vector3.new(0, 40, 0)
local a1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), rp)
local a2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), rp)
if a1 and a2 then
local d = math.abs(a1.Position.Y - a2.Position.Y)
if d > worstAll then
worstAll, atAll = d, i
end
if d > 1 then
hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format(
i, samples[i].piece, d, a1.Instance.Name, a2.Instance.Name)
end
end
local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly)
local b2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), roadOnly)
if b1 and b2 then
local d = math.abs(b1.Position.Y - b2.Position.Y)
if d > worstRoad then
worstRoad, atRoad = d, i
end
end
end
end
out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll)
out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format(
worstRoad, atRoad, worstRoad <= 0.60 and "PASS" or "FAIL")
out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits)
for k = 1, math.min(12, #hits) do
out[#out + 1] = " " .. hits[k]
end
map:Destroy()
clone:Destroy()
return table.concat(out, "\n")
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Push MapBuilder.luau source into a LIVE (Play-mode) Studio via the MCP bridge.
Rojo cannot reach the running server datamodel in Play mode, so the source is
shipped as ~9000-char chunks into named StringValue children (p0, p1, ...) under
ReplicatedStorage, then concatenated and assigned to the ModuleScript's .Source.
Named children matter: a retried chunk overwrites its slot instead of appending
a duplicate. Bridge calls fail intermittently ("Unable to find an active Studio
instance") even while Studio is up, so every call is retried.
"""
import sys, time
sys.path.insert(0, "/tmp")
import rbx
SRC_PATH = "/home/jesse/roblox/tokyo-wangan-racing/src/server/MapBuilder.luau"
MODULE = 'game:GetService("ServerScriptService"):FindFirstChild("Server", true)'
CHUNK = 9000
def call(code, tries=4):
last = None
for a in range(tries):
try:
r = rbx.call(
"execute_luau",
{"code": code, "datamodel_type": "Server"},
warmup=6,
wait=45,
timeout=300,
)
if r and "Unable to find an active Studio" not in str(r):
return r
last = r
except Exception as e: # noqa: BLE001
last = e
time.sleep(5)
raise RuntimeError(f"bridge failed after {tries} tries: {last}")
src = open(SRC_PATH, encoding="utf-8").read()
nbytes = len(src.encode())
print(f"source: {len(src)} chars, {nbytes} utf-8 bytes")
# Pick a long-bracket level that does not occur in the source.
lvl = 0
while ("]" + "=" * lvl + "]") in src or ("[" + "=" * lvl + "[") in src:
lvl += 1
eq = "=" * lvl
print(f"long-bracket level: {lvl}")
chunks = [src[i : i + CHUNK] for i in range(0, len(src), CHUNK)]
print(f"chunks: {len(chunks)}")
print(call('local rs=game:GetService("ReplicatedStorage")\n'
'local b=rs:FindFirstChild("MBBuf") if b then b:Destroy() end\n'
'b=Instance.new("Folder") b.Name="MBBuf" b.Parent=rs\n'
'return "buffer reset"'))
for i, c in enumerate(chunks):
code = (
'local rs=game:GetService("ReplicatedStorage")\n'
'local b=rs:WaitForChild("MBBuf")\n'
f'local n="p{i}"\n'
"local sv=b:FindFirstChild(n) or Instance.new(\"StringValue\")\n"
"sv.Name=n sv.Parent=b\n"
f"sv.Value=[{eq}[{c}]{eq}]\n"
f'return "p{i} "..#sv.Value'
)
print(f" chunk {i+1}/{len(chunks)}: {call(code)}")
assemble = f"""
local rs=game:GetService("ReplicatedStorage")
local b=rs:WaitForChild("MBBuf")
local parts={{}}
for i=0,{len(chunks)-1} do
local sv=b:FindFirstChild("p"..i)
if not sv then return "MISSING CHUNK p"..i end
parts[#parts+1]=sv.Value
end
local s=table.concat(parts)
local sss=game:GetService("ServerScriptService")
local m=sss:FindFirstChild("MapBuilder", true)
if not m then return "MapBuilder ModuleScript NOT FOUND" end
m.Source=s
return ("assembled=%d expected={nbytes} match=%s class=%s path=%s"):format(
#s, tostring(#s=={nbytes}), m.ClassName, m:GetFullName())
"""
print(call(assemble))
+164
View File
@@ -0,0 +1,164 @@
-- Headless verification of the four C1 fidelity fixes.
-- Asserts NUMBERS the build produced, not "the build ran".
local sss = game:GetService("ServerScriptService")
local orig = sss:FindFirstChild("MapBuilder", true)
if not orig then return "FAIL: MapBuilder not found" end
-- Freshness gate: prove we are measuring the NEW source before trusting anything.
local fresh = {}
for _, tok in ipairs({ "WIDE_ROAD_WIDTH", "BridgeGirder", "GinzaOverpass", "isJctSample", "GIRDER_H" }) do
fresh[#fresh + 1] = tok .. "=" .. tostring(orig.Source:find(tok, 1, true) ~= nil)
end
-- Fresh-require so Studio's module cache cannot serve a stale copy.
local clone = orig:Clone()
clone.Name = "MapBuilder_Verify"
clone.Parent = orig.Parent
local okM, MB = pcall(require, clone)
if not okM then
clone:Destroy()
return "FAIL require: " .. tostring(MB)
end
local okB, map = pcall(MB.build)
if not okB then
clone:Destroy()
return "FAIL build: " .. tostring(map)
end
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
local counts = {}
local decks = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
counts[d.Name] = (counts[d.Name] or 0) + 1
local idx = d.Name:match("^Road_(%d+)$")
if idx then
decks[tonumber(idx)] = d
end
end
end
local out = {}
local function line(s)
out[#out + 1] = s
end
line("FRESHNESS: " .. table.concat(fresh, " "))
-- FIX 1: bridge flag wired up ------------------------------------------------
local nBridgeSamples = 0
for _, s in ipairs(Circuit.samples) do
if s.bridge then
nBridgeSamples += 1
end
end
local girders = counts["BridgeGirder"] or 0
line(("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format(
nBridgeSamples, girders, counts["PierCap"] or 0, counts["PierColumn"] or 0,
(girders >= nBridgeSamples * 0.5 and girders > 0) and "PASS" or "FAIL"))
-- FIX 2: Ginza overpasses with piers between the carriageways ----------------
local ov, pier = counts["GinzaOverpass"] or 0, counts["OverpassPier"] or 0
line(("FIX2 Ginza 跨道橋: overpass=%d pier=%d parapet=%d -> %s"):format(
ov, pier, counts["OverpassParapet"] or 0, (ov > 0 and pier == ov) and "PASS" or "FAIL"))
-- Piers must actually sit BETWEEN the carriageways (near the centreline),
-- not off to one side -- that is the whole point of the fix.
local worstPier = 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "OverpassPier" then
local best = math.huge
for i, s in ipairs(Circuit.samples) do
if s.piece == "Ginza" then
local dd = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude
best = math.min(best, dd)
local _ = i
end
end
worstPier = math.max(worstPier, best)
end
end
line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format(
worstPier, worstPier < 6 and "PASS" or "FAIL"))
-- FIX 3: 40 km/h plates in junctions ----------------------------------------
local n40, n50 = 0, 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "SpeedLimitSign" then
for _, g in ipairs(d:GetDescendants()) do
if g:IsA("TextLabel") then
if g.Text == "40" then
n40 += 1
elseif g.Text == "50" then
n50 += 1
end
break
end
end
end
end
line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format(
n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL"))
-- FIX 4: Shiodome 3+3 widening ----------------------------------------------
local shioW, baseW, shioN, baseN = 0, 0, 0, 0
for i, s in ipairs(Circuit.samples) do
local d = decks[i]
if d then
if s.piece == "Shiodome" then
shioW += d.Size.X
shioN += 1
elseif s.piece == "Kasumigaseki" then
baseW += d.Size.X
baseN += 1
end
end
end
shioW = shioN > 0 and shioW / shioN or 0
baseW = baseN > 0 and baseW / baseN or 0
line(("FIX4 deck width: Shiodome avg=%.1f Kasumigaseki avg=%.1f -> %s"):format(
shioW, baseW, (shioW > baseW + 8) and "PASS" or "FAIL"))
-- Lane boundary count: 3 lanes per side needs 2 boundary lines per side.
local function boundariesNear(zone)
local best, bestI = math.huge, nil
for i, s in ipairs(Circuit.samples) do
if s.piece == zone then
bestI = bestI or i
best = 0
end
end
local _ = best
return bestI
end
local si = boundariesNear("Shiodome")
local nLines = 0
if si and decks[si] then
local c = decks[si].Position
for _, d in ipairs(map:GetDescendants()) do
if (d.Name == "LaneLine" or d.Name == "Dash") and (d.Position - c).Magnitude < 40 then
nLines += 1
end
end
end
line(("FIX4 lane boundaries near Shiodome sample %s: %d markings"):format(tostring(si), nLines))
-- REGRESSION: no holes in the deck, seam lips still within tolerance --------
local holes, maxLip = 0, 0
local n = #Circuit.samples
for i = 1, n do
local a, b = decks[i], decks[(i % n) + 1]
if a and b then
local lip = math.abs((a.Position.Y + a.Size.Y / 2) - (b.Position.Y + b.Size.Y / 2))
maxLip = math.max(maxLip, lip)
elseif not a then
holes += 1
end
end
line(("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format(
n, holes, maxLip, (holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL"))
map:Destroy()
clone:Destroy()
return table.concat(out, "\n")
+144
View File
@@ -0,0 +1,144 @@
-- Verification v2: corrected geometric metrics.
--
-- v1 had two badly-written assertions (the CODE was fine, the TEST was wrong):
-- * seam lip used Position.Y + Size.Y/2, but decks are pitched by
-- CFrame.lookAt(mid, mid+dir, UP), so that is the centre height, not the
-- surface height at the seam. On a sloped segment it over-reports badly.
-- Correct: evaluate each deck's TOP SURFACE at the shared sample point.
-- * pier offset measured distance to the nearest sample, but piers sit at
-- segment MIDPOINTS and samples are 60 studs apart -- so ~30.0 is the
-- correct answer for a perfectly centred pier. Correct: perpendicular
-- distance to the centreline polyline.
local sss = game:GetService("ServerScriptService")
local orig = sss:FindFirstChild("MapBuilder", true)
local clone = orig:Clone()
clone.Name = "MapBuilder_Verify2"
clone.Parent = orig.Parent
local okM, MB = pcall(require, clone)
if not okM then
clone:Destroy()
return "FAIL require: " .. tostring(MB)
end
local okB, map = pcall(MB.build)
if not okB then
clone:Destroy()
return "FAIL build: " .. tostring(map)
end
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
local samples = Circuit.samples
local n = #samples
local decks = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local idx = d.Name:match("^Road_(%d+)$")
if idx then
decks[tonumber(idx)] = d
end
end
end
local out = {}
local function line(s)
out[#out + 1] = s
end
-- TRUE SEAM LIP -------------------------------------------------------------
-- Deck i spans local z in [-Size.Z/2, +Size.Z/2] and is 2 studs longer than
-- its segment (deliberate overlap), so the shared sample point sits at
-- local z = +(Size.Z - 2)/2 for deck i and -(Size.Z - 2)/2 for deck i+1.
local worst, worstAt = 0, -1
local lips = {}
for i = 1, n do
local j = (i % n) + 1
local a, b = decks[i], decks[j]
if a and b then
local ta = (a.CFrame * CFrame.new(0, a.Size.Y / 2, (a.Size.Z - 2) / 2)).Y
local tb = (b.CFrame * CFrame.new(0, b.Size.Y / 2, -(b.Size.Z - 2) / 2)).Y
local lip = math.abs(ta - tb)
lips[#lips + 1] = { lip, i }
if lip > worst then
worst, worstAt = lip, i
end
end
end
table.sort(lips, function(p, q)
return p[1] > q[1]
end)
local top = {}
for k = 1, math.min(5, #lips) do
top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece)
end
line(("SEAM true max lip=%.4f at i=%d -> %s"):format(worst, worstAt, worst <= 0.30 and "PASS" or "FAIL"))
line("SEAM top5: " .. table.concat(top, " "))
-- Deck-width continuity: the taper must not jump more than a few studs
-- between neighbours, or the widening would look like a step in the road.
local maxJump, jumpAt = 0, -1
for i = 1, n do
local j = (i % n) + 1
if decks[i] and decks[j] then
local dw = math.abs(decks[i].Size.X - decks[j].Size.X)
if dw > maxJump then
maxJump, jumpAt = dw, i
end
end
end
line(("TAPER max width step=%.2f studs at i=%d -> %s"):format(
maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL"))
-- PIER LATERAL OFFSET -------------------------------------------------------
-- Perpendicular distance from each pier to the centreline polyline (XZ).
local function segDist(p, u, v)
local pu = Vector3.new(p.X - u.X, 0, p.Z - u.Z)
local uv = Vector3.new(v.X - u.X, 0, v.Z - u.Z)
local L2 = uv:Dot(uv)
if L2 < 1e-6 then
return pu.Magnitude
end
local t = math.clamp(pu:Dot(uv) / L2, 0, 1)
return (pu - uv * t).Magnitude
end
local worstPier, nPier = 0, 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "OverpassPier" then
nPier += 1
local best = math.huge
for i = 1, n do
local j = (i % n) + 1
best = math.min(best, segDist(d.Position, samples[i].pos, samples[j].pos))
end
worstPier = math.max(worstPier, best)
end
end
line(("PIER lateral offset from centreline: n=%d worst=%.3f studs -> %s"):format(
nPier, worstPier, (nPier > 0 and worstPier < 3) and "PASS" or "FAIL"))
-- Girders must hang BELOW their deck, never poke through the driving surface.
local bad, nG = 0, 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "BridgeGirder" then
nG += 1
local topY = d.Position.Y + d.Size.Y / 2
local best, bi = math.huge, nil
for i = 1, n do
local dd = (Vector3.new(samples[i].pos.X, 0, samples[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude
if dd < best then
best, bi = dd, i
end
end
if bi and decks[bi] then
local deckBottom = decks[bi].Position.Y - decks[bi].Size.Y / 2
if topY > deckBottom + 0.6 then
bad += 1
end
end
end
end
line(("GIRDER below deck: n=%d intruding=%d -> %s"):format(nG, bad, bad == 0 and "PASS" or "FAIL"))
map:Destroy()
clone:Destroy()
return table.concat(out, "\n")
+120
View File
@@ -0,0 +1,120 @@
-- Verification v3: seam lip measured against the KNOWN shared sample point.
--
-- v2 still had a sign bug: Roblox CFrame.LookVector is the -Z axis, so a deck
-- built with CFrame.lookAt(mid, mid+dir) has its FORWARD end at local
-- z = -(Size.Z-2)/2 and its REAR end at +(Size.Z-2)/2. v2 had these swapped,
-- comparing deck i's rear to deck i+1's rear -- a systematic error that
-- reported a near-constant 5.2 on every sloped segment regardless of zone.
--
-- This version avoids guessing signs: for each deck it takes BOTH end faces,
-- picks whichever is nearer the shared sample point b.pos in XZ, and reports
-- the surface-height difference there. Sanity-checked against the flat-ground
-- expectation and cross-checked with a downward raycast.
local sss = game:GetService("ServerScriptService")
local orig = sss:FindFirstChild("MapBuilder", true)
local clone = orig:Clone()
clone.Name = "MapBuilder_Verify3"
clone.Parent = orig.Parent
local okM, MB = pcall(require, clone)
if not okM then
clone:Destroy()
return "FAIL require: " .. tostring(MB)
end
local okB, map = pcall(MB.build)
if not okB then
clone:Destroy()
return "FAIL build: " .. tostring(map)
end
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
local samples = Circuit.samples
local n = #samples
local decks = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local idx = d.Name:match("^Road_(%d+)$")
if idx then
decks[tonumber(idx)] = d
end
end
end
local out = {}
local function line(s)
out[#out + 1] = s
end
-- Top-surface height of deck `d` at the end nearest world point `p` (XZ).
local function topAtNearestEnd(d, p)
local half = (d.Size.Z - 2) / 2
local best, bestD = nil, math.huge
for _, z in ipairs({ -half, half }) do
local w = d.CFrame * CFrame.new(0, d.Size.Y / 2, z)
local dd = (Vector3.new(w.X, 0, w.Z) - Vector3.new(p.X, 0, p.Z)).Magnitude
if dd < bestD then
best, bestD = w.Y, dd
end
end
return best, bestD
end
local worst, worstAt, worstGap = 0, -1, 0
local lips = {}
for i = 1, n do
local j = (i % n) + 1
local a, b = decks[i], decks[j]
if a and b then
local shared = samples[j].pos -- the sample both decks meet at
local ya, da = topAtNearestEnd(a, shared)
local yb, db = topAtNearestEnd(b, shared)
local lip = math.abs(ya - yb)
lips[#lips + 1] = { lip, i, math.max(da, db) }
if lip > worst then
worst, worstAt, worstGap = lip, i, math.max(da, db)
end
end
end
table.sort(lips, function(p, q)
return p[1] > q[1]
end)
local top = {}
for k = 1, math.min(5, #lips) do
top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece)
end
line(("SEAM max lip=%.4f at i=%d (endpoint XZ err %.3f) -> %s"):format(
worst, worstAt, worstGap, worst <= 0.30 and "PASS" or "FAIL"))
line("SEAM top5: " .. table.concat(top, " "))
-- Independent cross-check: raycast down onto the driving surface just before
-- and just after each seam. A real step would show up here too.
-- MUST filter to the Expressway folder only. Filtering on the whole map lets
-- the ray hit TunnelCeiling / MedianFence / overpass decks on the way down and
-- reports a bogus ~19-stud "step" that has nothing to do with the road surface.
local rp = RaycastParams.new()
rp.FilterType = Enum.RaycastFilterType.Include
rp.FilterDescendantsInstances = { map:FindFirstChild("Expressway") }
local rWorst, rAt = 0, -1
for i = 1, n do
local j = (i % n) + 1
local p = samples[j].pos
local fwd = (samples[(j % n) + 1].pos - samples[i].pos)
fwd = Vector3.new(fwd.X, 0, fwd.Z)
if fwd.Magnitude > 0.01 then
fwd = fwd.Unit
local h1 = workspace:Raycast(p - fwd * 3 + Vector3.new(0, 40, 0), Vector3.new(0, -120, 0), rp)
local h2 = workspace:Raycast(p + fwd * 3 + Vector3.new(0, 40, 0), Vector3.new(0, -120, 0), rp)
if h1 and h2 then
local d = math.abs(h1.Position.Y - h2.Position.Y)
if d > rWorst then
rWorst, rAt = d, i
end
end
end
end
line(("RAYCAST surface step across seams (+-3 studs): max=%.4f at i=%d -> %s"):format(
rWorst, rAt, rWorst <= 0.60 and "PASS" or "FAIL"))
map:Destroy()
clone:Destroy()
return table.concat(out, "\n")