Files
tokyo-wangan-racing/src/server/MapBuilder.luau
T
jesse 1d23c187ad Daikoku PA: two perimeter ramps, far side served by the median opening
Rework the PA junction to match the reference drawing: yellow = PA -> highway
(entry), red = highway -> PA (exit). Entry and exit swap sides (entry now
EAST, exit WEST) and both trunks hug the PA perimeter instead of running
concentric spirals.

The simplification: both carriageways sit at the same deck level, adjacent,
so the median opening IS the far-side access path. The under-deck crossing
and merge runs are deleted outright -- 8 runs collapse to 4, and the
girder-soffit ceiling logic that constrained the crossing goes with them.
Part count 24,210 -> 21,691.

The median opening is now load-bearing rather than cosmetic (it is the only
route from the far carriageway to the ramps), so it widens from 6 to 9
samples per junction.

Orientation is a single flip-able constant (ORIENT) so a mirrored result is
a one-line correction, not a rewrite.

Measured on the built map (not asserted):
  - 600 entry + 600 exit ramp parts
  - max grade 8.00% / 8.27%, under the 9% limit
  - 0 collisions with piers/masts/girders (558 structural parts)
  - segment continuity <= 2.5 studs; trunk->merge handoff <= 1.7 studs
  - merge lands at dY 0.0 (exact deck level), 33 studs lateral
  - median open at junctions: 0 parts vs. control ~7 on plain mainline

Two traps worth recording, both caught by controls:

1. Studio's require() cache silently rebuilt STALE code. The first "passing"
   build reported 17,575 parts and zero ramps, and the map contained
   PAOffRamp/PAOnRamp parts whose names exist nowhere in the current source.
   Every measurement taken before this was describing an old map. Added
   tools/rebuild_fresh.luau, which clones the module to bust the cache; use
   it rather than requiring MapBuilder directly.

2. A reported "median never opens" bug was a bad probe, not a defect. It
   counted `Barrier` (the outer edge parapet, which must NEVER open) instead
   of `Median_<i>`. Re-run against the right name: control 7, junctions 0.

Both were only visible because the probes carry controls -- a 0-hit result
with no control is vacuous. The verification harnesses are kept in tools/
with their controls built in.
2026-08-03 11:53:40 -04:00

2785 lines
108 KiB
Luau

--!strict
--[[
MapBuilder.luau (SERVER)
v4 — "C1: Shuto Inner Circular". The road is no longer an invented
rounded-rectangle: it is the REAL Shuto Expressway Inner Circular Route
(首都高速都心環状線) traced from OpenStreetMap and driven straight off
Circuit.samples, so the map and the lap timing share one geometry.
What that buys you, all of it authentic:
* The true C1 shape — Tanimachi JCT, Miyakezaka, Kasumigaseki, Ginza,
Kyobashi, Edobashi, Hitotsubashi — in their real relative positions.
* Real tunnel sections (the C1 spends a lot of its length underground)
built as lit bores; real bridge/viaduct sections built elevated on
pillars over the city.
* Tokyo landmarks placed at their true coordinates relative to the
loop: Tokyo Tower, the Imperial Palace moat, Tokyo Station, Ginza,
the Shiodome towers, Roppongi Hills, the National Diet, Skytree.
* A Daikoku-style PA hanging off the straightest non-tunnel run.
Gameplay parts keep their contract names (SpawnPad, FinishLine, KillWall_*)
so nothing else in the codebase changes.
]]
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Circuit = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("Circuit"))
local MapBuilder = {}
local ROAD_WIDTH = Circuit.ROAD_WIDTH
local WALL_HEIGHT = 26
-- Divided-expressway layout (real Wangan/C1 geometry) -------------------------
-- The deck is ONE slab; the median is what splits it into two carriageways
-- running in opposite directions. Outer edges get a visible concrete barrier
-- (you can see it, like real life) backed by an invisible taller wall so a car
-- at 300 km/h can't tunnel through it in a single physics step.
-- Dimensions and palette verified against real photographs of Daikoku JCT /
-- Daikoku Futo PA (Wikimedia Commons aerial + ground refs) and OSM tagging of
-- the junction (way data: lanes=2, oneway=yes per carriageway, layers 1-4).
-- Corrections made after looking at the real thing rather than guessing:
-- * Shutoko structures are PALE CONCRETE / off-white, not dark grey.
-- * The median is a NARROW raised kerb, not a wide chunky wall.
-- * The edge is a low white parapet TOPPED BY A DARK METAL RAILING -- that
-- silhouette is the single most recognisable feature of the road.
-- * Asphalt reads mid-grey in daylight, not near-black.
local MEDIAN_WIDTH = 6
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 ------------------------------------------------------------
-- The rest of the codebase converts speed with 0.28 m per stud, so all the
-- metric road-standard figures below are converted through the same constant.
-- Sanity check: the 49,383-stud loop comes out at 13.8 km against the real
-- C1 Inner Circular Route's 14.8 km, so the circuit is very close to 1:1.
local M = 1 / 0.28 -- studs per metre (~3.571)
-- Road markings per 全標協 (Japan Road Marking Assoc.) 2012 p.42 / 2018 pp.97-98,
-- read from the primary standard rather than repeated folklore:
-- * 車線境界線 (lane boundary, dashed): line length 3-10 m, gap = 1x to 2x the
-- line length, width 10-15 cm (15 cm "desirable"). The 8 m line / 12 m gap
-- used here is the high-speed end of that legal range (gap = 1.5x line) and
-- is the configuration expressways actually use -- it's what makes the
-- rhythm read as an expressway rather than a 5 m/5 m surface street.
-- * Where overtaking is undesirable (屈曲部 -- curves), the standard says the
-- line becomes SOLID rather than dashed. On expressways that no-lane-change
-- line is painted yellow.
-- * NOTE: the 45 cm width sometimes quoted for "expressway lines" is NOT the
-- lane boundary -- it's the boundary against a 変速車線 (accel/decel lane).
-- Applied below at ramps only.
local DASH_PAINT = 8 * M -- 28.6 studs
local DASH_GAP = 12 * M -- 42.9 studs
local DASH_CYCLE = DASH_PAINT + DASH_GAP -- 20 m
local LANE_LINE_W = 0.15 * M -- 0.54 studs
local EDGE_LINE_W = 0.20 * M -- 0.71 studs
local RAMP_LINE_W = 0.45 * M -- 1.61 studs; through lane vs speed-change lane
local LANE_W = 3.25 * M -- 11.6 studs; narrow urban lane used on the C1
-- 導流帯 (gore/zebra) per 全標協 2018 pp.97-98: 45 cm white solid lines repeated
-- every 1.0 m, bounded by a 15-20 cm edge line.
local ZEBRA_W = 0.45 * M
local ZEBRA_PITCH = 1.0 * M
local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting
local MARK_H = 2.1
-- Heading change per 60-stud sample above which the curve counts as tight
-- enough that the real road would paint a solid yellow no-lane-change line.
local TIGHT_CURVE = math.rad(4)
local C_PAINT_WHITE = Color3.fromRGB(232, 232, 236)
local C_PAINT_YELLOW = Color3.fromRGB(240, 190, 40) -- 黄色 no-lane-change line
-- Signage colours sampled from the official Japanese sign artwork (Wikimedia
-- Commons SVGs of the 標識令 plates), not eyeballed:
-- guide sign (案内標識) green #005D35
-- Shutoko route badge green #007F1F
local C_SIGN_GREEN = Color3.fromRGB(0, 93, 53)
local C_BADGE_GREEN = Color3.fromRGB(0, 127, 31)
local C_SIGN_WHITE = Color3.fromRGB(255, 255, 255)
-- Lighting. Japanese expressway/tunnel lighting is high-pressure sodium
-- (warm amber) with newer LED sections in cool white -- NOT the blue/teal
-- that reads as generic sci-fi. Tunnel basic lighting runs continuously.
local C_SODIUM = Color3.fromRGB(255, 176, 92)
local C_TUNNEL_LED = Color3.fromRGB(255, 244, 224)
local NEON_PINK = Color3.fromRGB(255, 40, 120)
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
p.TopSurface = Enum.SurfaceType.Smooth
p.BottomSurface = Enum.SurfaceType.Smooth
for k, v in pairs(props) do
(p :: any)[k] = v
end
return p
end
-- Deterministic pseudo-random so every server builds the same city ------------
local function det(seed: number): number
local x = math.sin(seed * 12.9898) * 43758.5453
return x - math.floor(x)
end
-- Real interchange names. Japanese is what's actually painted on the gantry;
-- the romaji underneath is the smaller secondary line every Shutoko guide sign
-- carries. Names cross-checked against OpenStreetMap's tagging of the real
-- Inner Circular Route (name / name:en on the motorway_junction nodes).
local JUNCTION_NAMES: { [string]: { string } } = {
["Kyobashi"] = { "京橋", "Kyobashi" },
["Takaracho"] = { "宝町", "Takaracho" },
["Edobashi junction"] = { "江戸橋JCT", "Edobashi JCT" },
["Takebashi Junction"] = { "竹橋JCT", "Takebashi JCT" },
["Hitotsubashi"] = { "一ツ橋", "Hitotsubashi" },
["Kitanomaru"] = { "北の丸", "Kitanomaru" },
["Daikancho"] = { "代官町", "Daikancho" },
["Miyakezaka JCT"] = { "三宅坂JCT", "Miyakezaka JCT" },
["Miyakezaka Junction"] = { "三宅坂JCT", "Miyakezaka JCT" },
["Kasumigaseki"] = { "霞が関", "Kasumigaseki" },
["Tanimachi JCT"] = { "谷町JCT", "Tanimachi JCT" },
["Iikura"] = { "飯倉", "Iikura" },
["Shiodome"] = { "汐留", "Shiodome" },
["Ginza"] = { "銀座", "Ginza" },
["Shintomicho"] = { "新富町", "Shintomicho" },
}
-- Japanese expressway guide sign (案内標識). Green board, white text, white
-- hairline border inset from the edge, Japanese place name large with the
-- romaji reading beneath it, and the circular route badge on the left.
local function makeSign(text: string, cf: CFrame, parent: Instance)
local names = JUNCTION_NAMES[text] or { text, text }
local ja, romaji = names[1], names[2]
local board = part({
Name = "Sign",
Size = Vector3.new(34, 10, 1),
CFrame = cf,
Color = C_SIGN_GREEN,
Material = Enum.Material.SmoothPlastic,
})
board.Parent = parent
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Front
gui.CanvasSize = Vector2.new(680, 200)
gui.Parent = board
-- Thin white border inset from the panel edge -- present on every real
-- Japanese guide sign and a big part of why they read as "Japanese".
local border = Instance.new("Frame")
border.Size = UDim2.new(1, -24, 1, -24)
border.Position = UDim2.fromOffset(12, 12)
border.BackgroundTransparency = 1
border.BorderSizePixel = 3
border.BorderColor3 = C_SIGN_WHITE
border.Parent = gui
-- Route badge: the C1 shield, green disc with white ring and "C1".
local badge = Instance.new("Frame")
badge.Size = UDim2.fromOffset(104, 104)
badge.Position = UDim2.fromOffset(34, 48)
badge.BackgroundColor3 = C_BADGE_GREEN
badge.BorderSizePixel = 0
badge.Parent = gui
local badgeCorner = Instance.new("UICorner")
badgeCorner.CornerRadius = UDim.new(1, 0)
badgeCorner.Parent = badge
local badgeStroke = Instance.new("UIStroke")
badgeStroke.Color = C_SIGN_WHITE
badgeStroke.Thickness = 5
badgeStroke.Parent = badge
local badgeText = Instance.new("TextLabel")
badgeText.Size = UDim2.fromScale(1, 1)
badgeText.BackgroundTransparency = 1
badgeText.Font = Enum.Font.GothamBold
badgeText.TextColor3 = C_SIGN_WHITE
badgeText.TextScaled = true
badgeText.Text = "C1"
badgeText.Parent = badge
-- Japanese name (primary, large)
local jaLabel = Instance.new("TextLabel")
jaLabel.Size = UDim2.new(1, -190, 0, 96)
jaLabel.Position = UDim2.fromOffset(168, 36)
jaLabel.BackgroundTransparency = 1
jaLabel.Font = Enum.Font.GothamBold
jaLabel.TextColor3 = C_SIGN_WHITE
jaLabel.TextScaled = true
jaLabel.TextXAlignment = Enum.TextXAlignment.Left
jaLabel.Text = ja
jaLabel.Parent = gui
-- Romaji reading (secondary, smaller, beneath)
local enLabel = Instance.new("TextLabel")
enLabel.Size = UDim2.new(1, -190, 0, 44)
enLabel.Position = UDim2.fromOffset(168, 132)
enLabel.BackgroundTransparency = 1
enLabel.Font = Enum.Font.Gotham
enLabel.TextColor3 = C_SIGN_WHITE
enLabel.TextScaled = true
enLabel.TextXAlignment = Enum.TextXAlignment.Left
enLabel.Text = romaji
enLabel.Parent = gui
end
-- PA junction planning -----------------------------------------------------------
-- Daikoku PA sits inside a loop of ramp road, and is reachable from BOTH
-- directions of travel. That means the median has to OPEN at the junctions,
-- otherwise the divider we just added would wall the PA off from the far
-- carriageway. buildRoad and buildPA must agree on exactly where those
-- openings are, so the plan is computed once, here, and shared.
local PAPlan = nil :: any
local function planPA()
if PAPlan then
return PAPlan
end
local samples = Circuit.samples
local c = Circuit.PA_CENTER
-- Nearest non-tunnel sample to the PA = where the ramps meet the mainline.
local bestIdx, bestD = 1, math.huge
for i, s in ipairs(samples) do
local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude
if not s.tunnel and d < bestD then
bestIdx, bestD = i, d
end
end
local n = #samples
-- TWO PERIMETER RAMPS, FAR SIDE SERVED BY THE MEDIAN OPENING (2026-08-03,
-- fourth pass, user drawing: yellow = PA -> highway, red = highway -> PA,
-- "this is why the middle barriers/dividers are important because each
-- side will have access to on and off ramps going to and from daikoku
-- futo pa").
--
-- What changed from the shared-trunk version:
--
-- 1. ENTRY AND EXIT SWAP SIDES. The drawing puts red (into the PA) on
-- the EAST and yellow (out of the PA) on the WEST. The previous pass
-- had it the other way round -- exit forked at bearing 19 deg (east),
-- entry at 149 deg (west).
--
-- 2. THE TRUNKS HUG THE PA PERIMETER. Previously the trunk snapped out
-- to its full radius over the first 22% of the sweep and then held a
-- constant circle. Now the radius eases across the WHOLE sweep, ring
-- gate -> fork, so each ramp spirals out from the apron edge and
-- flares to meet the mainline -- the shape in the drawing.
--
-- 3. THE UNDER-DECK CROSSING IS DELETED. This is the big one. The two
-- carriageways are at the SAME deck height and directly adjacent, so
-- the far carriageway does not need its own slip diving under the
-- expressway -- it just needs a gap in the centre divider. The
-- median opening IS the far-side access path. That removes the
-- `Cross` and `Merge` runs (8 built runs -> 4) and with them BOTH
-- outstanding defects, which lived entirely in that geometry:
-- PAExitRampNear -> PierColumn at seg 120
-- PAExitRampTrunk -> PierColumn + 6x PAMast
-- They stop existing rather than needing a fix.
--
-- The two ramps never overlap in bearing -- red spans 270..392 deg,
-- yellow spans 132..250 deg -- so no ramp-vs-ramp separation constraint
-- binds and no nested-circle radius ladder is needed.
--
-- Measured mainline geometry these numbers are fitted to (probe_ramp_geo,
-- ReplicatedStorage.Shared.Circuit, 823 samples):
-- PA_CENTER -4124, 46, -7470 PA_RADIUS 260 ring 390
-- PAFloor 828 x 828 -> X -4538..-3710 Z -7884..-7056
-- deck is FLAT Y=102 across the whole PA frontage (56 studs above apron)
-- nearest approach 330 studs at sample 540; usable band 533..551
-- bearing 0 = +X (east), 90 = +Z, 270 = -Z (the PA's south edge)
--
-- ORIENT flips the whole layout east<->west in one place. The drawing was
-- read as image-right = world east (+X, toward sample 550). If it turns
-- out mirrored, set ORIENT = -1 and rebuild -- nothing else changes.
local ORIENT = 1
local LANE_OFF = ROAD_WIDTH / 4 -- centre of one carriageway from the centreline
local APRON_Y = c.Y + 1
-- Point on carriageway `side` at sample i, plus that carriageway's actual
-- direction of travel (the far carriageway drives the other way).
local function carriagewayPoint(i, side)
local s = samples[i].pos
local nxt = samples[(i % n) + 1].pos
local h = math.atan2(nxt.Z - s.Z, nxt.X - s.X)
local nx, nz = -math.sin(h), math.cos(h)
return Vector3.new(s.X + nx * LANE_OFF * side, s.Y, s.Z + nz * LANE_OFF * side),
(side > 0) and h or (h + math.pi)
end
-- `k` is the offset from the PA-nearest sample (540), so these stay valid
-- if the circuit is resampled. `az` is the RING GATE bearing; the fork
-- lands at az + turn*sweep. Angles in degrees, lengths in studs.
--
-- Eastbound traffic (increasing sample index) meets yellow's merge at
-- 537 first, then red's divergence at 542 -- the left-then-right order in
-- the drawing.
local SHARED = {
{
name = "PAEntryRamp", -- RED: highway -> PA, east side
kind = "off",
k = 7, -- fork at sample 547, bearing 32 deg
forkR = 470.0, -- mainline is 533 out at that bearing: 63 in from it
az = 270.0, -- ring gate on the PA's south edge
turn = 1, -- CCW: 270 -> 392 (=32), down the east side
sweep = 122.0,
yFork = 96,
m = -5, -- diverges from the carriageway at sample 542
out = 0, -- unused: no under-deck crossing any more
nearSide = -1, -- PA is on the -Z side of the mainline here
},
{
name = "PAExitRamp", -- YELLOW: PA -> highway, west side
kind = "on",
k = -6, -- fork at sample 534, bearing 132 deg
forkR = 470.0,
az = 250.0, -- ring gate on the south edge, 135 studs west of red's
turn = -1, -- CW: 250 -> 132, up the west side
sweep = 118.0,
yFork = 96,
m = 3, -- merges onto the carriageway at sample 537, heading east
out = 0,
nearSide = -1,
},
}
for _, def in ipairs(SHARED) do
if ORIENT < 0 then
def.az = (180 - def.az) % 360
def.turn = -def.turn
def.k = -def.k
def.m = -def.m
end
end
local junctionDefs = {}
for _, def in ipairs(SHARED) do
local i0 = ((bestIdx - 1 + def.k) % n) + 1
local nearIdxM = ((i0 - 1 + def.m) % n) + 1
local nearPos, nearTravel = carriagewayPoint(nearIdxM, def.nearSide)
local farPos, farTravel = carriagewayPoint(nearIdxM, -def.nearSide)
-- Where the far slip emerges from under the deck, `out` studs beyond
-- the far carriageway edge, still at fork height.
local basePos = carriagewayPoint(i0, -def.nearSide)
local s0 = samples[i0].pos
local nx0 = samples[(i0 % n) + 1].pos
local h0 = math.atan2(nx0.Z - s0.Z, nx0.X - s0.X)
local nrm = Vector3.new(-math.sin(h0), 0, math.cos(h0))
local emerge = Vector3.new(basePos.X, def.yFork, basePos.Z) - nrm * (def.out * def.nearSide)
table.insert(junctionDefs, {
name = def.name,
idx = i0,
mergeIdx = nearIdxM,
kind = def.kind,
radius = def.forkR,
az = math.rad(def.az),
turn = def.turn,
sweep = math.rad(def.sweep),
yFork = def.yFork,
nearSide = def.nearSide,
-- `side` kept for the barrier/median code, which still thinks in
-- terms of one carriageway per junction; the near side is the one
-- whose parapet the trunk actually leaves through.
side = def.nearSide,
nearPos = nearPos,
nearTravel = nearTravel,
farPos = farPos,
farTravel = farTravel,
emerge = emerge,
-- The fork point, in world space. Everything downstream (slips,
-- gates) hangs off this.
fork = Vector3.new(
c.X + math.cos(math.rad(def.az) + def.turn * math.rad(def.sweep)) * def.forkR,
def.yFork,
c.Z + math.sin(math.rad(def.az) + def.turn * math.rad(def.sweep)) * def.forkR
),
pos = nearPos,
travel = nearTravel,
})
end
-- The median opening is now LOAD-BEARING, not cosmetic. It is the only
-- path from the far carriageway to the ramps, so it must be wide enough
-- to actually turn through at speed -- hence 9 samples either side rather
-- than 6. This is the "middle barriers/dividers are important" point in
-- the user's drawing.
local SPAN = 9 -- samples of median opening around each junction
local open = {}
for _, jd in ipairs(junctionDefs) do
for k = -SPAN, SPAN do
open[((jd.idx + k - 1) % n) + 1] = true
end
end
-- The OUTER edge barrier needs its own, much narrower opening -- and it is
-- NOT the same thing as the median opening.
--
-- BUG (2026-08-02): buildRoad reused `medianOpen` to skip the outer
-- barriers too, and did it inside `for side in {-1,1}`, so at all 13
-- samples per junction the mainline lost its parapet, railing AND kill
-- wall on BOTH edges -- 26 samples of expressway with no walls at all,
-- including the far side where no ramp exists. That is the "some of the
-- wangan has no walls, jumbled near the parking area" report.
--
-- `medianOpen` exists so both carriageways can reach the PA (the median is
-- the centre divider). The outer edge is a different structure with a drop
-- behind it, so it may only open on the ONE side the ramp leaves through,
-- and only right at the ramp mouth.
local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median
local rampOpen = {}
-- Which SIDE of the road each opening is on. With four ramps the two
-- carriageways leave through opposite edges of the deck, so a single
-- "this sample is open" flag is no longer enough -- opening both edges
-- would strip the parapet off the far carriageway again (the 2026-08-02
-- "no walls near the parking area" bug, in a new form).
local rampOpenSide = {}
for _, jd in ipairs(junctionDefs) do
for k = -RAMP_SPAN, RAMP_SPAN do
local i = ((jd.idx + k - 1) % n) + 1
rampOpen[i] = true
rampOpenSide[i] = jd.side
end
end
PAPlan = {
nearIdx = bestIdx,
-- Kept for anything still referencing the old two-ramp names; they now
-- point at the forward carriageway's pair.
entryIdx = junctionDefs[2] and junctionDefs[2].idx or bestIdx,
exitIdx = junctionDefs[1] and junctionDefs[1].idx or bestIdx,
junctionDefs = junctionDefs,
medianOpen = open,
rampOpen = rampOpen,
rampOpenSide = rampOpenSide,
ringRadius = Circuit.PA_RADIUS + 130,
}
return PAPlan
end
-- The expressway itself ---------------------------------------------------------
local function buildRoad(map: Folder)
local road = Instance.new("Folder")
road.Name = "Expressway"
road.Parent = map
local struct = Instance.new("Folder")
struct.Name = "Structure"
struct.Parent = map
local samples = Circuit.samples
local n = #samples
local plan = planPA()
-- Running arc length around the loop, so lane dashes keep a continuous
-- 20 m rhythm instead of restarting at every sample.
local segStart = 0
for i = 1, n do
local a = samples[i]
local b = samples[(i % n) + 1]
local delta = b.pos - a.pos
if delta.Magnitude < 0.5 then
continue
end
local len = delta.Magnitude + 2
local mid = (a.pos + b.pos) / 2
local dir = delta.Unit
local flatDir = Vector3.new(dir.X, 0, dir.Z)
flatDir = flatDir.Magnitude > 0.01 and flatDir.Unit or Vector3.xAxis
local right = flatDir:Cross(UP)
local cf = CFrame.lookAt(mid, mid + dir, UP)
-- How hard this segment turns, used to decide where the real road
-- would forbid lane changes (solid yellow) rather than allow them.
local prev = samples[((i - 2) % n) + 1]
local inDir = (a.pos - prev.pos)
inDir = Vector3.new(inDir.X, 0, inDir.Z)
local curvature = 0
if inDir.Magnitude > 0.01 then
curvature = math.acos(math.clamp(inDir.Unit:Dot(flatDir), -1, 1))
end
local deck = part({
Name = "Road_" .. i,
Size = Vector3.new(widthAt(i), 2, len),
CFrame = cf,
Color = C_ASPHALT,
Material = Enum.Material.Asphalt,
})
deck.Parent = road
-- CENTRAL DIVIDER ---------------------------------------------------
-- 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 = 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).
if not plan.medianOpen[i] then
local median = part({
Name = "Median_" .. i,
Size = Vector3.new(MEDIAN_WIDTH, MEDIAN_HEIGHT, len),
CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT / 2, 0),
Color = C_PARAPET,
Material = Enum.Material.Concrete,
})
median.Parent = struct
-- Dark steel anti-glare fence along the median -- on the real road
-- this strip of vertical blades is what separates the directions.
local fence = part({
Name = "MedianFence",
Size = Vector3.new(0.8, 2.6, len),
CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT + 1.3, 0),
Color = C_RAIL,
Material = Enum.Material.Metal,
})
fence.Parent = struct
end
-- 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 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.
local edge = part({
Name = "EdgeLine",
Size = Vector3.new(EDGE_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * (HALF - 1.6), MARK_Y, 0),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
edge.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
end
end
end
-- OUTER EDGE BARRIERS -----------------------------------------------
-- Visible concrete Jersey barrier sitting ON the deck (this is the part
-- you actually see), then an invisible taller wall just outside it as a
-- backstop against tunnelling at high speed.
-- At a PA junction the barrier AND the kill wall must open on the side
-- the ramp leaves through, or the ramp would dead-end into a wall.
--
-- FIXED (2026-08-02): this used `plan.medianOpen[i]` and `continue`d
-- for BOTH sides, stripping barrier + rail + kill wall from 26 samples
-- of mainline on both edges. Two corrections:
-- * use `rampOpen` (5 samples) not `medianOpen` (13) -- the median
-- opening is a different, wider thing for a different purpose.
-- * only skip the ONE side the PA is actually on. The far edge of
-- the expressway has a drop behind it and must never open.
--
-- UPDATED (2026-08-03) for four ramps. "Which side is the PA on" is no
-- longer the right question: both carriageways now have their own
-- ramps, and the opposite carriageway's ramp leaves through the edge
-- FACING AWAY from the PA before spiralling round to it. Asking about
-- the PA direction would open the wrong edge there and wall that ramp
-- off. planPA already knows which edge each junction uses, so read it
-- rather than re-deriving it from geometry that no longer implies it.
local junction = plan.rampOpen[i]
local paSide = junction and (plan.rampOpenSide[i] or 0) or 0
for _, side in ipairs({ -1, 1 }) do
if junction and side == paSide then
continue
end
local boff = right * (side * (HALF - BARRIER_T / 2))
local bcf = CFrame.lookAt(mid + boff, mid + boff + dir, UP)
-- White concrete parapet...
local barrier = part({
Name = "Barrier",
Size = Vector3.new(BARRIER_T, BARRIER_H, len),
CFrame = bcf * CFrame.new(0, 1 + BARRIER_H / 2, 0),
Color = C_PARAPET,
Material = Enum.Material.Concrete,
})
barrier.Parent = struct
-- ...topped by the dark metal railing. Every photo of the Shutoko
-- shows this two-tone parapet+railing edge; without the railing the
-- road reads as a generic slab instead of a Japanese expressway.
local rail = part({
Name = "EdgeRail",
Size = Vector3.new(0.6, RAIL_H, len),
CFrame = bcf * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0),
Color = C_RAIL,
Material = Enum.Material.Metal,
})
rail.Parent = struct
local woff = right * (side * (HALF + 1))
local wall = part({
Name = "KillWall",
Size = Vector3.new(2, WALL_HEIGHT, len + 6),
CFrame = CFrame.lookAt(mid + woff, mid + woff + dir, UP) * CFrame.new(0, WALL_HEIGHT / 2, 0),
Transparency = 1,
CanCollide = true,
})
wall.Parent = struct
end
if a.tunnel then
-- Yamate/C1-style bore: walls, ceiling, strip lighting
local h = 24
for _, side in ipairs({ -1, 1 }) do
local woff = right * (side * (HALF + 1.5))
local w = part({
Name = "TunnelWall",
Size = Vector3.new(3, h, len + 2),
CFrame = CFrame.lookAt(mid + woff, mid + woff + dir, UP) * CFrame.new(0, h / 2, 0),
Color = Color3.fromRGB(38, 38, 46),
Material = Enum.Material.Concrete,
})
w.Parent = struct
end
local ceil = part({
Name = "TunnelCeiling",
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,
})
ceil.Parent = struct
-- Tunnel lighting is high-pressure sodium (warm amber) with newer
-- LED sections in cool white. Real tunnel "basic lighting" is a
-- continuous run, not isolated dots, so the strip is placed on
-- every segment rather than every other one.
local strip = part({
Name = "TunnelLight",
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 colH = capY - 1.6
local col = part({
Name = "PierColumn",
-- Enum.PartType.Cylinder puts the circular faces on the
-- LOCAL X axis: Size.X is the length ALONG the axis and
-- Size.Y/Z are the diameter.
--
-- BUG (found 2026-08-03 by measuring the built parts):
-- this passed Size = (8, colH, 8), i.e. 8 studs along the
-- axis by colH wide -- a disc, not a column. Measured on
-- all 80 instances: world vertical extent 8.0 studs for a
-- column that should stand 35.4, leaving each pier
-- floating with a 13.7-stud gap down to the ground.
-- The Z rotation below was already correct; only the Size
-- components were transposed.
Size = Vector3.new(colH, 8, 8),
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, colH / 2, mid.Z) * CFrame.Angles(0, 0, math.rad(90))
col.Parent = struct
end
-- Elevated viaduct: support pillars down to street level
elseif i % 3 == 0 and a.pos.Y > 18 then
local pil = part({
Name = "Pillar",
Size = Vector3.new(9, a.pos.Y, 9),
CFrame = CFrame.new(mid.X, a.pos.Y / 2 - 1, mid.Z),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
})
pil.Parent = struct
end
-- Sodium lamps along the deck. Japanese expressway lighting is
-- warm high-pressure sodium on a ~35 m pole pitch; samples are
-- 60 studs (16.8 m) apart so every 2nd sample ~= 33.6 m.
if i % 2 == 0 then
local lamp = part({
Name = "RoadLight",
Size = Vector3.new(1.4, 1.4, 1.4),
CFrame = CFrame.new(mid + right * HALF + Vector3.new(0, 5.5, 0)),
Color = C_SODIUM,
Material = Enum.Material.Neon,
})
lamp.Parent = struct
end
-- Delineators (視線誘導標): small reflective posts on the outer
-- edge that mark the road's line ahead at night. Real spacing on
-- curves is tight; ~17 m here.
local delin = part({
Name = "Delineator",
Size = Vector3.new(0.5, 1.1, 0.5),
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 2.5, 0)),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
delin.Parent = struct
local eye = part({
Name = "DelineatorEye",
Size = Vector3.new(0.36, 0.36, 0.36),
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 3.0, 0)),
Color = Color3.fromRGB(255, 190, 80),
Material = Enum.Material.Neon,
})
eye.Parent = struct
if i % 9 == 0 then
local pylon = part({
Name = "Pylon",
Size = Vector3.new(1, 14, 1),
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,
})
pylon.Parent = struct
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
-- per 全標協 2018 pp.97-98.
if plan.medianOpen[i] then
local nBars = math.floor(delta.Magnitude / ZEBRA_PITCH)
for k = 0, nBars - 1 do
local z = (k * ZEBRA_PITCH - delta.Magnitude / 2) + ZEBRA_PITCH / 2
for _, s in ipairs({ -1, 1 }) do
local bar = part({
Name = "GoreZebra",
Size = Vector3.new(6, MARK_H, ZEBRA_W),
CFrame = cf * CFrame.new(s * (HALF - 5), MARK_Y, z),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
bar.Parent = road
end
end
-- 45 cm boundary line separating the through lane from the
-- speed-change lane, per 全標協 2012 p.42.
for _, s in ipairs({ -1, 1 }) do
local rl = part({
Name = "RampBoundary",
Size = Vector3.new(RAMP_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * (HALF - 8.5), MARK_Y, 0),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
rl.Parent = road
end
end
-- Advance the running arc length so the next segment's dashes
-- continue the 20 m rhythm instead of restarting.
segStart += delta.Magnitude
end
end
-- Regulatory speed limit sign (規制標識 323 最高速度) -----------------------------
-- Circular, white face, red annulus, black numerals. The real C1 is posted at
-- 50 km/h (40 in the tightest junction sections) -- verified against the route
-- article's own signage table, not the 80 km/h used on the Bayshore Route.
local function makeSpeedSign(kmh: number, cf: CFrame, parent: Instance)
local post = part({
Name = "SignPost",
Size = Vector3.new(0.6, 12, 0.6),
CFrame = cf * CFrame.new(0, -6, 0),
Color = C_RAIL,
Material = Enum.Material.Metal,
})
post.Parent = parent
local face = part({
Name = "SpeedLimitSign",
Size = Vector3.new(6, 6, 0.4),
CFrame = cf,
Color = Color3.fromRGB(255, 255, 255),
Material = Enum.Material.SmoothPlastic,
Shape = Enum.PartType.Cylinder,
})
-- Cylinder's flat faces point along X, so rotate it to face the driver.
face.CFrame = cf * CFrame.Angles(0, math.rad(90), 0)
face.Parent = parent
for _, faceId in ipairs({ Enum.NormalId.Right, Enum.NormalId.Left }) do
local gui = Instance.new("SurfaceGui")
gui.Face = faceId
gui.CanvasSize = Vector2.new(256, 256)
gui.Parent = face
local disc = Instance.new("Frame")
disc.Size = UDim2.fromScale(1, 1)
disc.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
disc.BorderSizePixel = 0
disc.Parent = gui
local dc = Instance.new("UICorner")
dc.CornerRadius = UDim.new(1, 0)
dc.Parent = disc
-- Red annulus is ~11% of the diameter on the JP plate.
local ring = Instance.new("UIStroke")
ring.Color = Color3.fromRGB(200, 30, 45)
ring.Thickness = 28
ring.Parent = disc
local num = Instance.new("TextLabel")
num.Size = UDim2.fromScale(0.72, 0.56)
num.Position = UDim2.fromScale(0.14, 0.22)
num.BackgroundTransparency = 1
num.Font = Enum.Font.GothamBold
num.TextColor3 = Color3.fromRGB(20, 20, 20)
num.TextScaled = true
num.Text = tostring(kmh)
num.Parent = disc
end
end
-- Kilometre post (キロポスト) ----------------------------------------------------
local function makeKmPost(km: number, cf: CFrame, parent: Instance)
local board = part({
Name = "KmPost",
Size = Vector3.new(2.6, 3.2, 0.3),
CFrame = cf,
Color = C_SIGN_GREEN,
Material = Enum.Material.SmoothPlastic,
})
board.Parent = parent
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Front
gui.CanvasSize = Vector2.new(130, 160)
gui.Parent = board
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Font = Enum.Font.GothamBold
label.TextColor3 = C_SIGN_WHITE
label.TextScaled = true
label.Text = string.format("%.1f", km)
label.Parent = gui
end
-- Junction gantry signs at each real interchange ---------------------------------
local function buildJunctionSigns(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Signs"
folder.Parent = map
local samples = Circuit.samples
local n = #samples
local seen: { [string]: boolean } = {}
for i = 1, n do
local s = samples[i]
if s.tunnel or seen[s.piece] then
continue
end
seen[s.piece] = true
local fwd = Circuit.forwardAt(i)
local pos = s.pos + Vector3.new(0, 15, 0)
makeSign(s.piece, CFrame.lookAt(pos, pos - fwd, UP), folder)
end
-- Speed limit repeaters and kilometre posts around the loop.
local dist = 0
local nextKm = 0
for i = 1, n do
local a = samples[i]
local b = samples[(i % n) + 1]
local seg = (b.pos - a.pos).Magnitude
local fwd = Circuit.forwardAt(i)
local flat = Vector3.new(fwd.X, 0, fwd.Z)
flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis
local right = flat:Cross(UP)
-- 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 * (widthAt(i) / 2 - 2) + Vector3.new(0, 4, 0)
makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder)
nextKm += 1
end
dist += seg
end
end
-- Daikoku-style PA ---------------------------------------------------------------
local function buildPA(map: Folder)
local pa = Instance.new("Folder")
pa.Name = "ParkingArea"
pa.Parent = map
local c = Circuit.PA_CENTER
local R = Circuit.PA_RADIUS
-- Real Daikoku Futo PA: the parking apron is a huge flat lot, and the loop
-- ramps sweep AROUND and OVER it on pillars (see the aerial reference) --
-- the parking is inside the ring, at deck level, with the ring elevated
-- above it. The apron reaches out under the ring so there is no void gap.
local APRON = Circuit.PA_RADIUS + 130 + 24 -- ringRadius + half ring width
local slab = part({
Name = "PAFloor",
Size = Vector3.new(APRON * 2, 2, APRON * 2),
CFrame = CFrame.new(c),
Color = C_ASPHALT,
Material = Enum.Material.Asphalt,
})
slab.Parent = pa
-- REAL DAIKOKU FUTO PA LAYOUT ---------------------------------------------
-- Footprint measured from OpenStreetMap way 59608205 (大黒ふ頭パーキングエリア):
-- the apron is 181 m x 174 m (1.6 ha) -- a big, essentially SQUARE lot, not
-- a circular one. The spiral ramps sweep around and over it, which is the
-- "螺旋状の道路" the PA is famous for as a photo backdrop.
--
-- Capacity from ja.wikipedia 大黒パーキングエリア (首都高速で最大のPA, opened
-- 1989-09-27): 小型 341 + 大型 59 + 身障者用 4 = 404 spaces. Those three
-- counts are reproduced EXACTLY below rather than approximated, because the
-- lot's character is the sheer amount of parking.
--
-- Stall dimensions (小型 2.5 x 5.0 m, 大型 3.3 x 12 m, 身障者用 3.5 x 5.0 m,
-- 6 m aisles) are conventional Japanese parking-design values -- NOT read
-- from a primary standard, unlike the road markings. Paint is 15 cm, which
-- does match the 全標協 line width used on the mainline.
-- APRON EXTENT. Previously 181 x 174 m, which was the bounding box of the
-- OSM parking polygon alone. That polygon covers only the STALLS -- the
-- buildings, terrace and fountain sit alongside it and are separate OSM
-- ways, so a slab sized to the stalls left 本館, フォンターナ and the Lawson
-- hanging over the edge (measured: 4 of 12 features off-slab).
--
-- Sized here to the FULL surveyed site instead. After the +32 m north shift
-- and 0.88 uniform scale applied to the survey (see the building block),
-- the outermost features reach ~100 m radius, so a 210 x 200 m slab clears
-- everything with a margin for circulation. Still inside the ring road,
-- whose clear inner radius is 103.0 m.
local APRON_X = 210 * M
local APRON_Z = 200 * M
local STALL_W = 2.5 * M
local STALL_D = 5.0 * M
local BIG_W = 3.3 * M
local BIG_D = 12.0 * M
local ACC_W = 3.5 * M
local AISLE = 6.0 * M
local LINE_W = 0.15 * M
local DECK_Y = c.Y + 1 -- top face of the apron slab
local C_PAINT = Color3.fromRGB(235, 235, 240)
-- Painted markings are PAINT, not kerbs. They must be flush with the apron.
--
-- BUG (2026-08-02, test drive): these were spawned 2.2 studs tall sitting
-- at DECK_Y + 0.1, so every stall divider stood ~1.2 studs PROUD of the
-- tarmac -- a kerb-height ridge exactly where you park. Driving the rows
-- meant bouncing over 300+ concrete strips ("like a whole foot out of the
-- ground" -- measured 1.2, and a stud is ~a foot, so: exactly right).
--
-- Fix: 0.12 studs thick, sunk so its TOP sits a hair above the deck. A
-- decal would be flatter still, but a thin slab keeps this buildable from
-- pure geometry with no asset IDs, and 0.06 studs proud is under tyre
-- resolution -- you cannot feel it.
local PAINT_T = 0.12
-- STALL PAINT DISABLED (2026-08-02, user call): the generated stall
-- markings are being replaced with pre-made parking assets from the
-- Creator Store. Set this back to true to restore the painted lines.
--
-- Deliberately a FLAG rather than deleting markRow and its callers: the
-- row placement below is surveyed against OSM (二番館 frontage, the 大型
-- rows, the 身障者用 bays) and feeds PACapacity, which the headless
-- verifier asserts. Ripping it out would throw away that survey work and
-- silently change the recorded capacity. With the flag off, every stall
-- position is still computed -- they just emit no geometry.
local DRAW_STALL_PAINT = false
local function paint(name, size, x, z, colour)
if not DRAW_STALL_PAINT then
return nil
end
local m = part({
Name = name,
-- caller passes the old 2.2 in size.Y; ignore it and use PAINT_T
Size = Vector3.new(size.X, PAINT_T, size.Z),
CFrame = CFrame.new(x, DECK_Y + PAINT_T / 2 - 0.06, z),
Color = colour or C_PAINT,
Material = Enum.Material.SmoothPlastic,
})
m.Parent = pa
return m
end
-- One row of stalls: n+1 divider lines perpendicular to the aisle, plus the
-- head line the cars nose up to. `sign` is the direction the stalls extend
-- away from the aisle edge.
local function markRow(name, x0, zEdge, n, w, d, sign)
local zMid = zEdge + sign * d / 2
for k = 0, n do
paint(name .. "Line", Vector3.new(LINE_W, 2.2, d), x0 + k * w, zMid)
end
paint(name .. "Head", Vector3.new(n * w, 2.2, LINE_W), x0 + n * w / 2, zEdge)
end
-- 小型 341: eleven rows of 31, stacked as back-to-back pairs with 6 m aisles.
-- Z origin is derived, not eyeballed: the block starts one stall-depth clear
-- of the フォンターナ frontage and must finish north of the 大型 rows. Placing
-- it by hand overlapped the truck bays by 42 studs -- see verify_daikoku_pa.
--
-- 2026-08-02: this used to hang off the old (wrong) north-edge 二番館
-- position. The building is now surveyed to OSM way 88790141, centre
-- (+13.9 E, +123.1 N) m with a 43 m long axis, so its southern frontage is
-- at 123.1 - 43/2 = 101.6 m north in OSM coords. That must go through the
-- SAME shift+scale as the buildings themselves (OSM_SHIFT_N = 32 m,
-- OSM_SCALE = 0.88, both defined with the building block below) or the
-- stalls detach from the building they are supposed to line up with.
local SMALL_PER_ROW = 31
local SMALL_ROWS = 11 -- 31 x 11 = 341 exactly
local smallX0 = c.X - 20 * M
local pairPitch = 2 * STALL_D + AISLE
local nibanFace = c.Z - (123.1 - 43 / 2 - 32.0) * 0.88 * M
local smallZ0 = nibanFace + STALL_D + 8
local placedSmall = 0
for row = 0, SMALL_ROWS - 1 do
local pairIdx = math.floor(row / 2)
local zc = smallZ0 + pairIdx * pairPitch
local sign = (row % 2 == 0) and -1 or 1
markRow("Stall", smallX0, zc, SMALL_PER_ROW, STALL_W, STALL_D, sign)
placedSmall += SMALL_PER_ROW
end
-- 大型 59: trucks and buses park along the south edge where the bays are
-- long enough -- three rows of 20/20/19.
local bigZ = c.Z + APRON_Z / 2 - BIG_D - 14
local bigCounts = { 20, 20, 19 } -- = 59
local placedBig = 0
for r, n in ipairs(bigCounts) do
markRow("BigStall", c.X - APRON_X / 2 + 18 * M, bigZ - (r - 1) * (BIG_D + AISLE), n, BIG_W, BIG_D, 1)
placedBig += n
end
-- 身障者用 4: hard against the 本館 entrance, as they are in reality.
local accX = c.X - APRON_X / 2 + 34 * M
markRow("AccStall", accX, c.Z - 2 * ACC_W, 4, ACC_W, STALL_D, 1)
local placedAcc = 4
-- Recorded so the headless verifier can assert the real capacity.
local cap = Instance.new("StringValue")
cap.Name = "PACapacity"
cap.Value = ("small=%d,large=%d,accessible=%d,total=%d"):format(
placedSmall,
placedBig,
placedAcc,
placedSmall + placedBig + placedAcc
)
cap.Parent = pa
-- Light towers at the apron corners.
-- Height 60 -> 40 (2026-08-02). With PA_LIFT at 56 the mainline slab
-- underside sits at ~101; a 60-stud mast topped out at 107 and would have
-- speared the deck -- swapping one clash for another. At 40 the head sits
-- at 87, ~14 studs clear. Real PA lighting under a viaduct is short for
-- exactly this reason. If PA_LIFT changes again, re-check this.
local MAST_H = 40
for _, sx in ipairs({ -1, 1 }) do
for _, sz in ipairs({ -1, 1 }) do
local lx = c.X + sx * (APRON_X / 2 - 10 * M)
local lz = c.Z + sz * (APRON_Z / 2 - 10 * M)
local mast = part({
Name = "PAMast",
Size = Vector3.new(3, MAST_H, 3),
CFrame = CFrame.new(lx, DECK_Y + MAST_H / 2, lz),
Color = Color3.fromRGB(70, 70, 78),
Material = Enum.Material.Metal,
})
mast.Parent = pa
local head = part({
Name = "PALight",
Size = Vector3.new(10, 2, 10),
CFrame = CFrame.new(lx, DECK_Y + MAST_H, lz),
Color = Color3.fromRGB(255, 240, 200),
Material = Enum.Material.Neon,
})
head.Parent = pa
local pl = Instance.new("PointLight")
pl.Brightness = 3
pl.Range = 120
pl.Color = Color3.fromRGB(255, 235, 190)
pl.Parent = head
end
end
-- RING ROAD + ON/OFF RAMPS ------------------------------------------------
-- At the real Daikoku Futo PA the slip road wraps right around the parking
-- area in a full circle, with the on-ramp and off-ramp feeding that loop.
-- Build the ring as a fan of short arc segments, then two ramps joining it
-- to the expressway at the junctions the median opens for.
local samples = Circuit.samples
local plan = planPA()
local RING_R = plan.ringRadius
local RING_W = 44
-- Ramp deck width. Deliberately NARROWER than RING_W: the four loop ramps
-- nest at radii 70-130 studs apart and the offline collision solve was run
-- at 32 studs, which is what the measured 51-stud minimum separation below
-- is a clearance FOR. Widening this eats directly into that margin. 32 is
-- also right on its own terms: real Daikoku loop ramps are single-lane, ~9 m.
local RAMP_W = 32
-- Per-ramp wrap and landing profile. NOT tunable by eye -- solved offline
-- as a joint search over all four ramps at once (solve_land.py).
--
-- WHY PER-RAMP, and why this was not obvious: the nested-radii argument
-- ("concentric circles at different radii cannot intersect") only holds
-- while each ramp HOLDS its radius. Every ramp must eventually peel inward
-- to the ring to actually deliver the car onto the apron, and that peel
-- cuts straight across every inner ramp's circle. A first pass used one
-- shared 300-degree sweep and a shared landing fraction, and measured 7
-- studs of ramp-to-ramp separation -- four ramps merging into each other
-- at the gate. The "concentric circles can't collide" reasoning was right
-- about the wrap and silently wrong about the landing.
--
-- The crossing is unavoidable in plan view, so it is cleared in HEIGHT
-- instead: each ramp drops 55 studs over its wrap, so staggering the
-- sweeps staggers the altitude at which each one crosses the others.
-- Measured result of these values: 51 studs minimum separation in 3D.
--
-- Read by BOTH the junction/gate placement (which needs to know where the
-- spiral ends, to cut the parapet opening there) and the deck builder.
-- One table, so those two can never disagree.
-- SWEEP NOW COMES FROM planPA, NOT FROM HERE (2026-08-03, shared-ramp
-- pass). It used to be a hand-tuned per-ramp constant, staggered so four
-- concentric ramps crossed at different altitudes. With two shared trunks
-- the sweep is a SOLVED quantity -- it is what positions the fork point so
-- that both slips can reach their carriageways without hitting anything --
-- so it travels with the junction (`jn.sweep`) and this table only carries
-- the landing fraction.
--
-- The old hazard the per-ramp values guarded against is still real and
-- still handled: the "Horizon Racing garage" is pre-existing map decor, so
-- ramp-vs-ramp and ramp-vs-road checks are blind to it. The offline solver
-- tests every candidate against the FULL static-obstacle set (garage
-- included), which is why the accepted sweeps report 0 obstacle hits.
local RAMP_PROFILE_DEFAULT = { land = 0.10 }
local RAMP_PROFILE = {}
local EASE_R = 0.15 -- fraction of the ramp spent easing onto its radius
local ARC_N = 72
local function deck(name, size, cframe, colour)
local d = part({
Name = name,
Size = size,
CFrame = cframe,
Color = colour or Color3.fromRGB(38, 38, 44),
Material = Enum.Material.Asphalt,
})
d.Parent = pa
return d
end
-- The ring itself
local ringY = c.Y
-- Ramp junctions, resolved BEFORE the ring is built so the ring wall can
-- open a gate at each one.
--
-- BUG (2026-08-02): the parapet loop below ran `for sgn in {1,-1}` over
-- EVERY segment, walling both edges of the ring with no opening anywhere.
-- The inner lip therefore sealed the parking apron completely: 72 of 72
-- rays cast outward from the SpawnPad hit a wall/barrier standing ~5.7
-- studs above the apron (tools/diag_pawall.luau), well over the ~2-3 studs
-- a car can climb -- so there was no way to drive out of the PA at all.
-- The mainline barrier already handles this correctly at PA junctions
-- ("the barrier AND the kill wall must open on the side the ramp leaves
-- through", see the deck loop above); the ring never got the same
-- treatment.
--
-- TWO gates are needed per junction, and both matter:
-- * INNER lip -- so the apron connects to the ring road at all.
-- * OUTER lip -- so the ring road connects to the ramp deck.
-- Opening only one still leaves the lot sealed.
--
-- The two gates are deliberately different sizes. Inside the ring the
-- apron is solid deck, so a wide opening is free. OUTSIDE the ring is a
-- ~46-stud drop to ground level, so that gate is kept tight and every
-- stud of it is backed by ramp/flare deck (see PARampFlare below) --
-- otherwise we would re-create the old "drove off the side and fell past
-- FallenPartsDestroyHeight" failure this structure was built to prevent.
local GATE_IN = 70 -- half-width of the apron-side opening
local GATE_OUT = 30 -- half-width of the ramp-side opening
local junctions = {}
-- FOUR junctions now, one per ramp, resolved in planPA (see the big note
-- there). Each carries its own carriageway attach point, its own direction
-- of travel and its own concentric radius; nothing is shared between the
-- two directions any more.
for _, jd in ipairs(plan.junctionDefs or {}) do
local road = jd.nearPos
-- Turn direction, start angle and sweep are SOLVED in planPA now (they
-- are what place the fork point), so they are read here rather than
-- re-derived. Re-deriving them was safe when a ramp just had to point
-- the right way; with a fork feeding two carriageways, any
-- disagreement between this block and the deck builder would put the
-- parapet opening somewhere the ramp never arrives.
local th0 = jd.az
local turn = jd.turn
local thEnd = th0 + turn * jd.sweep
table.insert(junctions, {
name = jd.name,
road = road,
roadY = road.Y,
idx = jd.idx,
mergeIdx = jd.mergeIdx,
side = jd.side,
kind = jd.kind,
radius = jd.radius,
turn = turn,
th0 = th0,
sweep = jd.sweep,
yFork = jd.yFork,
nearSide = jd.nearSide,
nearPos = jd.nearPos,
nearTravel = jd.nearTravel,
farPos = jd.farPos,
farTravel = jd.farTravel,
emerge = jd.emerge,
fork = jd.fork,
-- `travel` is this CARRIAGEWAY's direction of travel, not the
-- centreline heading -- the opposite carriageway drives the
-- other way and its slip has to leave pointing that way.
heading = jd.nearTravel,
ringPt = Vector3.new(c.X + math.cos(th0) * RING_R, ringY, c.Z + math.sin(th0) * RING_R),
})
end
local function nearJunction(pos, halfWidth)
for _, jn in ipairs(junctions) do
local d = Vector3.new(pos.X - jn.ringPt.X, 0, pos.Z - jn.ringPt.Z)
if d.Magnitude <= halfWidth then
return true
end
end
return false
end
for i = 0, ARC_N - 1 do
local a0 = (i / ARC_N) * math.pi * 2
local a1 = ((i + 1) / ARC_N) * math.pi * 2
local p0 = Vector3.new(c.X + math.cos(a0) * RING_R, ringY, c.Z + math.sin(a0) * RING_R)
local p1 = Vector3.new(c.X + math.cos(a1) * RING_R, ringY, c.Z + math.sin(a1) * RING_R)
local segMid = (p0 + p1) / 2
local segLen = (p1 - p0).Magnitude + 1.5
local segCF = CFrame.lookAt(segMid, p1, UP)
-- PARing deck REMOVED (2026-08-02, user call): this laid a 44-stud-wide
-- band of road deck in a circle around the PA. Measured flush with the
-- apron (both tops at y=47.00) and entirely inside the apron footprint
-- (ring bbox X -4535..-3712 vs apron X -4538..-3710), so it was not
-- structural -- just a differently-shaded ring of pavement painted over
-- asphalt the apron already provides. Reads as a leftover ring where
-- the old barriers used to be. Deleting it leaves no hole: the apron
-- slab underneath is continuous.
--
-- The loop itself still runs: `junctions` and the ramp/flare geometry
-- below are positioned off these arc points, so the circle is still
-- the reference line -- it just no longer emits a deck.
-- Ring edge: NO barrier on the circulation road any more.
--
-- CHANGE (2026-08-02, user call, third pass): the edge used to be
-- emitted here, per ring-road arc segment, so it inherited the ring's
-- CIRCLE -- radius ~412 about the PA centre. But the parking area is a
-- SQUARE 828 x 828 apron (real Daikoku Futo is 181 m x 174 m, see the
-- layout note above). A circle inscribed in that square bulges past
-- the apron at the four corners and cuts INSIDE it at the four edge
-- midpoints, so the barrier visibly sliced across the lot instead of
-- following its perimeter -- read as a second ring.
--
-- The perimeter barrier is now built ONCE, after this loop, as four
-- straight runs along the apron edge. Nothing is emitted per-arc.
-- No pillars under the ring any more. The ring is now a GROUND-LEVEL
-- circulation road sharing the apron slab (Circuit.PA_LIFT raises the
-- MAINLINE over the PA instead of leaving both at y=46). It is the
-- expressway that flies overhead on pillars here, exactly as in the
-- reference photos: a flat lot with parked cars, elevated deck above.
-- Mainline pillars are built in buildRoad; see PA_LIFT.
end
-- PERIMETER BARRIER -------------------------------------------------------
-- A single simple barrier following the EDGE OF THE PARKING AREA, which is
-- a square apron (APRON half-extent), not the circular ring road. Built as
-- four straight runs of short segments so gaps can be opened wherever a
-- ramp crosses the boundary -- otherwise the barrier would seal the PA and
-- trap the player. Segment length is kept modest so a gap lands cleanly.
do
local PERIM_H = 3.0
local PERIM_T = 1.6
local SEG = 40
-- Sit the barrier just inside the slab edge so it reads as the lot's
-- own kerb line rather than floating off the asphalt.
local half = APRON - PERIM_T / 2 - 1
local baseY = DECK_Y + 1 + PERIM_H / 2
-- Ramps tee in at the ring; open the perimeter where a ramp's centre
-- line crosses the square, plus clearance for the full ramp width.
local GATE = 90
local function blocked(x, z)
for _, jn in ipairs(junctions) do
-- Project the junction outward onto the square boundary and
-- compare against this segment's centre.
local d = Vector3.new(x - jn.ringPt.X, 0, z - jn.ringPt.Z)
if d.Magnitude <= GATE then
return true
end
end
return false
end
local n = 0
for _, side in ipairs({ "N", "S", "E", "W" }) do
local along = -half
while along < half do
local segLen = math.min(SEG, half - along)
local mid = along + segLen / 2
local x, z, sizeV
if side == "N" then
x, z = c.X + mid, c.Z - half
sizeV = Vector3.new(segLen + 0.5, PERIM_H, PERIM_T)
elseif side == "S" then
x, z = c.X + mid, c.Z + half
sizeV = Vector3.new(segLen + 0.5, PERIM_H, PERIM_T)
elseif side == "W" then
x, z = c.X - half, c.Z + mid
sizeV = Vector3.new(PERIM_T, PERIM_H, segLen + 0.5)
else
x, z = c.X + half, c.Z + mid
sizeV = Vector3.new(PERIM_T, PERIM_H, segLen + 0.5)
end
if not blocked(x, z) then
local seg = part({
Name = "PAPerimeter",
Size = sizeV,
CFrame = CFrame.new(x, baseY, z),
Color = C_PARAPET,
Material = Enum.Material.Concrete,
})
seg.Parent = pa
n = n + 1
end
along = along + segLen
end
end
end
-- Two ramps: mainline -> ring (entry) and ring -> mainline (exit).
-- `junctions` was resolved above (the ring wall needed it to place gates);
-- reuse it so the ramp deck and the wall opening can never disagree.
--
-- PA-CONCENTRIC LOOP RAMPS (2026-08-03, second pass).
--
-- The first helix attempt spiralled around a circle placed one radius
-- perpendicular to the MAINLINE. That fixed the grade (13.6% -> ~2%) but
-- produced the wrong shape, and the measurements said so plainly: the two
-- ramps swung 414..1674 studs from the PA centre and covered only 60-75
-- degrees of compass around it. They were long sweeping curves that
-- wandered off over open water -- not loops, and they never went round the
-- parking area at all, because nothing in the construction referenced the
-- parking area.
--
-- THE FIX: spiral concentric with THE PA CENTRE, not with the mainline.
-- Each ramp holds a fixed radius about `c` and wraps ~300 degrees while
-- descending, so it visibly circles the parking structure exactly like the
-- real Daikoku loop ramps.
--
-- Radii are NESTED (620 / 690 / 840 / 970 studs), which is what makes four
-- overlapping ramps safe: concentric circles of different radii cannot
-- intersect, so no pair can ever collide no matter how far they wrap. The
-- junctions are chosen (in planPA) at points where the carriageway is
-- ALREADY near its ramp's radius, so no ramp has to cut radially across a
-- neighbour's circle to reach its own.
--
-- For scale: an OSM Overpass pull of the real interchange's curved ways,
-- Kasa-circle-fitted, gives cleanest radii of 147 m and 124 m = 525 and
-- 443 studs. The apron's corner radius here is 518 studs, so a ramp has to
-- sit somewhat outside that to clear the lot -- 620 is the tightest that
-- does. See docs/daikoku-reference-spec.md.
--
-- Solved and verified offline against all 823 mainline samples:
-- grade 2.0 - 3.8% (was 10.6 / 13.6% straight, ~6% is the target)
-- wrap 210 - 255 deg (was 60-75)
-- radius 390..1093 (was 414..1674)
-- ramp/ramp 51 studs min 3D separation for a 32-stud deck
-- ramp/road 17 studs min vertical clearance at crossings
local LIFT = Circuit.PA_LIFT or 0
local GIRDER_DEPTH = 8 -- deck soffit sits this far under the running surface
local CAR_H = 9
local function ease(t)
if t < 0 then
t = 0
elseif t > 1 then
t = 1
end
return t * t * (3 - 2 * t)
end
-- Is `p` inside the mainline's horizontal footprint? Used to stop ramp
-- pillars being planted in the expressway (or in its piers) where the
-- under-deck crossing passes beneath it.
local function underMainline(p, pad)
for _, s in ipairs(samples) do
local dx, dz = p.X - s.pos.X, p.Z - s.pos.Z
if dx * dx + dz * dz < (ROAD_WIDTH / 2 + pad) ^ 2 then
return true
end
end
return false
end
-- One emitter for every run of ramp deck. Takes a plain list of world
-- points (the vertical profile is already baked in) plus flags for which
-- ends are mouths that must stay open.
--
-- Previously the deck, pillars, kerbs and chevrons were written inline in
-- a single spiral loop. There are now FOUR runs per ramp (trunk, near
-- slip, under-deck crossing, far merge), so that code has to be shared or
-- the four runs drift apart in appearance and in kerb behaviour.
local function emitRun(runName, pts, openHead, openTail, wantChevrons)
for s = 1, #pts - 1 do
local a, b = pts[s], pts[s + 1]
local segMid = (a + b) / 2
-- Overlap consecutive segments slightly: a sloped deck is a
-- rotated box, so butt-jointed segments leave a thin wedge gap at
-- each seam that a wheel can catch on.
local segLen = (b - a).Magnitude + 3
local dck = deck(runName, Vector3.new(RAMP_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
dck.Name = runName .. "_" .. s
-- Support pillar down to the apron under the raised part of the
-- ramp, matching the reference photos' elevated structure.
--
-- Round, not square (corrected 2026-08-03 against the underside
-- footage in honmoku_jct_sheet3, which is actually shot beneath
-- Daikoku itself): every column carrying the ramp decks there is
-- a smooth cylindrical concrete pier. These were 6x6 boxes.
--
-- The base has to be chosen per-pillar. The old code always cut
-- the column off at `ringY - 1` (Y=45, the underside of the PA
-- apron slab). That is right only while the ramp is still over
-- the slab. The measured helix climbs up to 1182 studs BEYOND
-- the slab edge, and out there nothing exists at Y=45 -- no
-- terrain, no baseplate -- so all 55 columns ended in mid-air.
-- Beyond the slab, run them to ground like the viaduct piers do.
local overApron = math.abs(segMid.X - c.X) <= APRON and math.abs(segMid.Z - c.Z) <= APRON
local baseY = overApron and (ringY - 1) or -1
local h = segMid.Y - baseY
-- ...and NOT where the run passes beneath the expressway. The
-- under-deck crossing threads the gap between the girder soffit
-- and the ground; a column raised there would be planted through
-- the mainline deck itself (and through its piers), which is
-- exactly the class of solid-obstacle hit the offline solve was
-- run to eliminate. Those spans are short and are carried by the
-- piers on either side.
local blocked = underMainline(segMid, RAMP_W)
if h > 10 and s % 2 == 0 and not blocked then
local pil = part({
Name = "PARampPillar",
-- Cylinder axis is local X, so Size.X is the height and
-- Size.Y/Z the diameter; the Z rotation stands it upright.
-- (Same transposition that had all 80 viaduct PierColumns
-- built as 8-stud discs -- see the PierColumn note above.)
Size = Vector3.new(h, 6, 6),
CFrame = CFrame.new(segMid.X, segMid.Y - h / 2, segMid.Z) * CFrame.Angles(0, 0, math.rad(90)),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
Shape = Enum.PartType.Cylinder,
})
pil.Parent = pa
end
-- Edge kerbs so a car can't slide off the side of a raised ramp.
--
-- BUG (2026-08-02, found by test drive + tools/diag_ramp.luau):
-- the guard used to be `h > 8`, which is a HEIGHT test on a ramp
-- that starts at apron level. The first 2-3 segments of every ramp
-- (heightAboveApron 0.5 .. 5.6) therefore got no kerb at all, so
-- the barrier visibly stopped part-way down the ramp -- the
-- "side barriers are not actually fully finished" report. The low
-- end is not a fall hazard, but it still needs an edge or the
-- structure just ends in mid-air visually.
--
-- The other half of that report -- "barriers extend too far into
-- the main highway" -- is the opposite end: the kerb ran right to
-- the road end of the ramp, so a kerb stood IN the mainline
-- driving lane at the merge (measured: a 2.4-stud step 14 studs
-- right of the centreline, hit as Road_526 -> PARampKerb). A merge
-- has to be open, so the last segment carries no kerb.
-- Kerb EVERY segment except the merge mouth. The height test is
-- gone entirely: `h > 8` then `h > 2` both still failed on the
-- foot of the ramp (h = 0.5 .. 0.8 at apron level), which is
-- exactly where the barrier was seen "ending in mid-air" -- the
-- edge began one segment UP the ramp, leaving the first ~40 studs
-- of both ramps completely open on both sides. A ramp edge is
-- continuous in reality regardless of how high it happens to be,
-- so the only exclusion left is the merge, which must stay open.
-- The merge mouth is now the LAST path point (the ramp is a helix,
-- so `STEPS` no longer exists -- the path length varies per ramp).
-- The mouth is no longer always the tail: a run can be open at
-- either end now (the fork end of the near slip is open to the
-- trunk, the merge end is open to the mainline), so the caller
-- declares which ends are mouths. `#pathPts` no longer exists.
local isMergeSeg = (openTail and s >= #pts - 2) or (openHead and s <= 2)
if not isMergeSeg then
local fwd2 = (b - a)
fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z)
if fwd2.Magnitude > 0.01 then
local rightV = fwd2.Unit:Cross(UP)
for _, sg in ipairs({ -1, 1 }) do
local kerb = part({
Name = "PARampKerb",
Size = Vector3.new(1.6, 3.2, segLen),
CFrame = CFrame.lookAt(
segMid + rightV * (sg * RAMP_W / 2),
segMid + rightV * (sg * RAMP_W / 2) + (b - a).Unit,
UP
) * CFrame.new(0, 2.6, 0),
Color = C_PARAPET,
Material = Enum.Material.Concrete,
})
kerb.Parent = pa
end
-- Red/white chevron delineators.
--
-- CORRECTED 2026-08-03 against 4K dusk onboard footage of
-- the junction (jct_tsubasa_dusk, t=110..140s). These are
-- NOT road-surface paint, which is what the first pass
-- built -- a flat red slab lying on the deck. In reality
-- they are VERTICAL PANELS bolted to the outside face of
-- the concrete barrier, alternating red and white, running
-- continuously along the outside of the curve. They are
-- the main thing that makes a helix legible from inside
-- the car, and on the deck they were nearly invisible.
--
-- Roblox part granularity can't do a diagonal stripe
-- inside one panel, so alternating whole panels per
-- segment stands in for the diagonal hatching.
--
-- Outward side of the curve. Taking the raw radial
-- (segMid - c) as the offset direction is WRONG on the
-- landing peel: there the radial is no longer
-- perpendicular to travel, so panels skew along the road
-- (measured: up to 33 studs of along-travel error over the
-- last ~9 segments, walking the panel line diagonally
-- across the deck instead of tracking its edge).
--
-- Offset along the true perpendicular `rightV` always, and
-- use the radial ONLY to choose which side is outboard.
-- The ramps spiral concentrically about the PA centre, so
-- `c` is the axis to measure outboard from. (This read `O`,
-- the old per-ramp helix origin, which no longer exists.)
local radial = Vector3.new(segMid.X - c.X, 0, segMid.Z - c.Z)
if wantChevrons and radial.Magnitude > 0.01 and rightV.Magnitude > 0.01 then
local sgn = (rightV:Dot(radial.Unit) >= 0) and 1 or -1
local face = segMid + rightV * (sgn * RAMP_W / 2)
local chev = part({
Name = "PARampChevron",
Size = Vector3.new(0.5, 2.0, segLen),
CFrame = CFrame.lookAt(face, face + (b - a).Unit, UP) * CFrame.new(0, 2.7, 0),
Color = (s % 2 == 0) and Color3.fromRGB(198, 46, 40) or Color3.fromRGB(240, 238, 232),
Material = Enum.Material.SmoothPlastic,
})
chev.Parent = pa
end
end
end
end
end
-- SHARED TRUNK + FORK, one pair of slips per ramp -------------------------
-- Built from the SOLVED parameters in planPA. Each of the four runs below
-- is generated by exactly the same arithmetic the offline solver and the
-- independent verifier used, so what drives here is what was measured:
-- trunk ring -> fork, concentric spiral about the PA centre, climbing
-- apron level -> yFork
-- near fork -> near carriageway, descending to the LOCAL deck height
-- cross fork -> emerge, held FLAT at yc so it passes under the mainline
-- merge emerge -> far carriageway, climbing to the LOCAL deck height
--
-- Sample counts match the verifier (401 on the trunk, 201 on the slips) so
-- the built centreline IS the verified centreline, not a re-approximation
-- of it at some other resolution.
local APRON_Y = c.Y + 1
local LANE_OFF = ROAD_WIDTH / 4
local nS = #samples
-- Centre of one carriageway at sample i. Must match planPA's
-- carriagewayPoint exactly -- if these two disagree the slip lands beside
-- the road instead of on it.
local function carriagewayPoint(i, side)
local s = samples[i].pos
local nxt = samples[(i % nS) + 1].pos
local hh = math.atan2(nxt.Z - s.Z, nxt.X - s.X)
return Vector3.new(s.X - math.sin(hh) * LANE_OFF * side, s.Y, s.Z + math.cos(hh) * LANE_OFF * side)
end
for _, jn in ipairs(junctions) do
local rampR = jn.radius
local yf = jn.yFork
local ns = jn.nearSide
local mergeIdx = jn.mergeIdx
-- ACTUAL local deck height at the merge, not a constant 102. The
-- mainline is a real profile; the exit merge sits at 96.6.
local yd = samples[mergeIdx].pos.Y
-- The under-deck crossing is gone, so the girder-soffit ceiling that
-- constrained it is no longer needed here.
-- 1. TRUNK -- perimeter spiral, ring gate out to the fork point. The
-- radius eases across the WHOLE sweep (previously it snapped out over
-- the first 22% and then held a constant circle), so the ramp hugs
-- the PA edge and flares out to meet the mainline -- the shape in the
-- drawing. Climb is spread over the full run too: 50 studs across a
-- ~900-stud arc is ~5.5%, well inside the 9% limit, so there is no
-- reason to front-load it.
local trunk = {}
for s = 0, 400 do
local f = s / 400
local th = jn.th0 + jn.turn * jn.sweep * f
local r = RING_R + (rampR - RING_R) * ease(f)
local y = APRON_Y + (yf - APRON_Y) * ease(f)
table.insert(trunk, Vector3.new(c.X + math.cos(th) * r, y, c.Z + math.sin(th) * r))
end
local fork = trunk[#trunk]
-- 2. SLIP -- fork to the carriageway on the PA side, at deck level.
-- There is only ONE slip now. The far carriageway is reached through
-- the gap in the centre divider (see `open` in planPA), which is what
-- the user's drawing is about: "this is why the middle
-- barriers/dividers are important because each side will have access
-- to on and off ramps". Both carriageways sit at the same deck height
-- and are directly adjacent, so a second slip diving under the
-- expressway was solving a problem that does not exist.
local tgtNear = carriagewayPoint(mergeIdx, ns)
local near = {}
for s = 0, 200 do
local f = s / 200
table.insert(
near,
Vector3.new(
fork.X + (tgtNear.X - fork.X) * f,
yf + (yd - yf) * ease(f),
fork.Z + (tgtNear.Z - fork.Z) * f
)
)
end
-- An on-ramp is this same geometry driven the other way -- the deck is
-- undirected, so only which ends are MOUTHS matters, and every run
-- here is open at both ends (ring gate / fork, fork / carriageway).
-- Chevrons go on the trunk only: that is the curved helix they exist
-- to make legible, and on the straight slip they would just be a red
-- wall alongside the merge.
emitRun(jn.name .. "Trunk", trunk, true, true, true)
emitRun(jn.name .. "Near", near, true, true, false)
-- Flare the ramp mouth across the outer gate so the opening in the
-- ring parapet is fully backed by deck -- the trunk meets the ring at
-- a tangent and would otherwise leave triangular gaps at the corners.
local toRing = Vector3.new(jn.ringPt.X - jn.road.X, 0, jn.ringPt.Z - jn.road.Z)
if toRing.Magnitude > 1 then
local flareCF = CFrame.lookAt(
Vector3.new(jn.ringPt.X, ringY, jn.ringPt.Z),
Vector3.new(jn.road.X, ringY, jn.road.Z),
UP
)
local flare = deck("PARampFlare", Vector3.new(GATE_OUT * 2 + RAMP_W, 2, RAMP_W + 24), flareCF)
flare.Color = Color3.fromRGB(38, 38, 44)
end
end
-- The PA apron used to be a bare 520x520 slab floating ~46 studs up with
-- nothing at its edges: endurance testing drove straight off the side and
-- fell past FallenPartsDestroyHeight (-500), destroying the car and
-- stranding the player seatless. That is now solved structurally -- the
-- apron below extends all the way out to the ring road, and the ring's
-- outer lip walls the whole thing -- so there is no exposed edge to fall
-- off and no need for the old four-sided perimeter box.
-- SPAWN PAD PLACEMENT. This used to sit exactly on PA_CENTER, which was
-- fine while the apron centre was empty. It is NOT fine after the survey
-- landed: the OSM stall block is centred near the apron middle, so the pad
-- punched a 120x120 dark hole straight through the painted 小型 rows
-- (visible as a black rectangle in the overhead capture).
--
-- The offset below is MEASURED, not chosen: a 15-stud grid search over the
-- whole apron scored every candidate by its clearance to all 804 built PA
-- obstacles (buildings, stalls, props) while staying inside the ring road.
-- (-165, -150) is the best cell, with 93 studs of clearance -- comfortably
-- more than the pad's 60-stud half-width. It lands on the open southwest
-- circulation area, which is empty tarmac on the real site too.
local SPAWN_OFF = Vector3.new(-165, 0.3, -150)
local pad = part({
Name = "SpawnPad",
Size = Vector3.new(120, 2.4, 120),
CFrame = CFrame.new(c + SPAWN_OFF),
Color = Color3.fromRGB(20, 20, 26),
Material = Enum.Material.Neon,
Transparency = 0.25,
})
pad.Parent = pa
-- Roblox drops the character at (0,0,0) unless a real SpawnLocation exists,
-- and CarManager spawns the car onto the character -- so without this the
-- player starts ~8,600 studs off-circuit regardless of where SpawnPad is.
-- Neutral + non-collidable so it never becomes an obstacle on the pad.
-- Must sit ON the pad -- it is keyed to the same measured offset, because
-- CarManager spawns the car onto the character. Leaving this at PA_CENTER
-- while the pad moved would drop the player into the middle of the 小型
-- stall rows instead of onto clear tarmac.
local spawnLoc = Instance.new("SpawnLocation")
spawnLoc.Name = "PASpawn"
spawnLoc.Size = Vector3.new(24, 1, 24)
spawnLoc.CFrame = CFrame.new(c + Vector3.new(SPAWN_OFF.X, 2, SPAWN_OFF.Z))
spawnLoc.Transparency = 1
spawnLoc.CanCollide = false
spawnLoc.Anchored = true
spawnLoc.Neutral = true
spawnLoc.Duration = 0
spawnLoc.Parent = pa
-- BUILDINGS ---------------------------------------------------------------
-- SURVEYED LAYOUT, not art direction. Every position below is read from
-- OpenStreetMap and converted to studs, so the arrangement matches the real
-- 大黒ふ頭PA rather than approximating it.
--
-- Sources (fetched 2026-08-02 via the OSM API, not Overpass -- Overpass
-- 504s repeatedly on this bbox):
-- * way 59608205 amenity=parking 大黒ふ頭パーキングエリア -- the apron
-- * way 405966005 highway=rest_area 大黒PA (wikidata Q10938704)
-- * building ways + amenity/shop nodes within 230 m of the apron centroid
--
-- Local frame: origin = apron polygon centroid (35.461455 N, 139.680201 E),
-- +xe = EAST metres, +zn = NORTH metres. Mapped into the game world as
-- East -> +X and North -> -Z, which is the convention the rest of the map
-- already uses.
--
-- THE BIG CORRECTION: the apron is NOT the axis-aligned square the previous
-- code assumed. Measured from the polygon, it is a PARALLELOGRAM lying on a
-- SW-NE diagonal -- principal axis bearing ~37 deg, longest edge 155.8 m at
-- bearing 45.4 deg, area 1.60 ha. The 181 x 174 m figure quoted before is
-- just the bounding box of that diagonal lot, which is why buildings placed
-- on N/S/E/W edges landed in the wrong places. The two named buildings sit
-- at OPPOSITE ENDS OF THE DIAGONAL, ~175 m apart:
-- 本館 (main building) at the SOUTH-WEST end, near the neck
-- フォンターナ・ダイコク at the NORTH-EAST tip
-- That single fact is most of what makes the lot read as Daikoku.
local C_BLDG = Color3.fromRGB(214, 210, 200)
local C_GLASS = Color3.fromRGB(120, 150, 165)
-- OSM local metres -> world studs.
--
-- Two corrections are folded into this transform, both forced by MEASURED
-- constraints rather than taste:
--
-- 1. ORIGIN SHIFT (+32 m North). The OSM parking polygon's centroid is not
-- the centre of the SITE -- the buildings all sit north and east of the
-- stalls, so the full site bbox spans east -87..+111 and north -53..+137.
-- Centring on the parking centroid pushed 7 of 12 features off the apron.
-- Solving for the origin that minimises the enclosing radius gives
-- (+0.0 E, +32.0 N), which is the value used here.
--
-- 2. UNIFORM SCALE (0.88). The real site needs a 113.9 m radius after the
-- shift, but the PA ring road only offers 103.0 m of clear space inside
-- it (ring centreline PA_RADIUS+130 = 390 studs, ring 44 studs wide).
-- The ring CANNOT simply be grown: the nearest mainline sample is only
-- 330 studs from PA_CENTER (measured in-engine), so the ring is already
-- wider than its clearance to the expressway and enlarging it would push
-- the loop through the road deck and break the ramp junctions.
-- Scaling the survey down by 0.88 instead fits it in the existing
-- envelope. Because the scale is UNIFORM, every distance and angle keeps
-- its true proportion -- the relative arrangement of the buildings, which
-- is what makes the lot recognisable as Daikoku, is preserved exactly.
local OSM_SHIFT_N = 32.0
local OSM_SCALE = 0.88
local function osmX(xe: number): number
return c.X + xe * OSM_SCALE * M
end
local function osmZ(zn: number): number
return c.Z - (zn - OSM_SHIFT_N) * OSM_SCALE * M
end
-- OSM edge bearing (degrees, measured as atan2(east, north) the way the
-- survey reports it) -> Roblox Y rotation. Derivation: we want the part's
-- LookVector (-sin t, 0, -cos t) to lie along the OSM direction
-- (cos a East, sin a North) = (cos a, 0, -sin a) in world axes, which
-- solves to t = a - 90 deg.
local function osmYaw(angDeg: number): number
return math.rad(angDeg - 90)
end
-- GENERATED PA STRUCTURES DISABLED (2026-08-02, user call): the placeholder
-- buildings and props are being replaced with Creator Store assets placed
-- by hand into workspace.PAProps. Flip this back to true to restore them.
--
-- A flag, not a deletion: every position below is surveyed against a real
-- OSM way (本館, フォンターナ way 88790141, toilets 88790136, kiosk 88790109,
-- hut 88790185) via osmX/osmZ with OSM_SHIFT_N + OSM_SCALE. That survey is
-- the useful part -- it tells you exactly where each real structure stands,
-- which is the reference for placing the store assets. Keeping the calls
-- live but silent preserves it.
local DRAW_PA_STRUCTURES = false
-- `long` runs along the building's own long axis, `wide` across it.
-- Footprints are scaled by the same OSM_SCALE as the positions, so the lot
-- stays a faithful uniform reduction rather than a stretched one.
local function building(name, long_m, height_m, wide_m, xe, zn, angDeg)
if not DRAW_PA_STRUCTURES then
return nil
end
local sy = height_m * M
local cf = CFrame.new(osmX(xe), DECK_Y + sy / 2, osmZ(zn)) * CFrame.Angles(0, osmYaw(angDeg), 0)
local b = part({
Name = name,
Size = Vector3.new(wide_m * OSM_SCALE * M, sy, long_m * OSM_SCALE * M),
CFrame = cf,
Color = C_BLDG,
Material = Enum.Material.Concrete,
})
b.Parent = pa
-- Ground-floor glazing so it reads as a rest stop, not a warehouse.
local glassH = math.min(4 * M, sy * 0.7)
local glass = part({
Name = name .. "Glazing",
Size = Vector3.new(wide_m * OSM_SCALE * M + 0.6, glassH, long_m * OSM_SCALE * M + 0.6),
CFrame = cf * CFrame.new(0, -sy / 2 + glassH / 2 + 0.2 * M, 0),
Color = C_GLASS,
Material = Enum.Material.Glass,
Transparency = 0.35,
})
glass.Parent = pa
return b
end
-- 本館 (main building) -- SW end of the lot. Three storeys (1F shop/toilet
-- block, 2F rest area reopened 2025-10-30, 3F closed). Its footprint is not
-- mapped as a building way in OSM, so the extent is reconstructed from the
-- tight POI cluster that sits inside it: food_court (-22,-64), Bay Side
-- Restaurant (-25,-62), bakery Little Marmaid (-31,-62), toilets (-22,-59),
-- elevator (-34,-51), telephone (-21,-49), Blue Seal (-42,-50), cafe
-- Cafe de Crie (-43,-49), convenience e-Mart (-40,-45). Those span roughly
-- x -45..-18, z -66..-43, hence a ~46 x 34 m block centred (-32,-53)
-- squared up to the 45 deg lot grid.
building("PAHonkan", 46, 13, 34, -32, -53, 45)
if DRAW_PA_STRUCTURES then
makeSign("本館 MAIN BUILDING", CFrame.new(osmX(-32), DECK_Y + 15 * M, osmZ(-53) + 20 * M), pa)
end
-- フォンターナ・ダイコク / ベイサイドテラス -- NE tip. OSM way 88790141,
-- oriented bbox 28 x 43 m at bearing +43 deg, centre (+13.9, +123.1).
building("PANibankan", 43, 7, 28, 13.9, 123.1, 43)
if DRAW_PA_STRUCTURES then
makeSign("フォンターナ・ダイコク", CFrame.new(osmX(13.9), DECK_Y + 9 * M, osmZ(123.1) + 16 * M), pa)
end
-- Remaining mapped structures, each from its own OSM building way.
building("PAToiletNorth", 21, 5, 7, -17.5, 109.8, -44) -- way 88790136
building("PAKioskWest", 16, 5, 9, -87.2, 24.5, 46) -- way 88790109
building("PAHutWest", 14, 4.5, 10, -71.9, 42.2, -45) -- way 88790185
-- Free-standing amenity nodes. Small props, but they are what stops the
-- apron reading as an empty car park.
local function prop(name, sx_m, sy_m, sz_m, xe, zn, colour, mat)
if not DRAW_PA_STRUCTURES then
return nil
end
local p = part({
Name = name,
Size = Vector3.new(sx_m * OSM_SCALE * M, sy_m * M, sz_m * OSM_SCALE * M),
CFrame = CFrame.new(osmX(xe), DECK_Y + (sy_m * M) / 2, osmZ(zn)),
Color = colour,
Material = mat or Enum.Material.Metal,
})
p.Parent = pa
return p
end
-- Lawson (+21,+136.7) and the north toilet block (-3.3,+124) flank Fontana.
prop("PALawson", 14, 4, 10, 21.0, 136.7, Color3.fromRGB(70, 130, 200), Enum.Material.Concrete)
prop("PAToiletBlockN", 12, 4, 8, -3.3, 124.0, Color3.fromRGB(205, 200, 190), Enum.Material.Concrete)
-- 噴水 fountain (+5.9,+122.5) -- the photo spot on the north terrace.
prop("PAFountain", 9, 0.8, 9, 5.9, 122.5, Color3.fromRGB(150, 170, 185), Enum.Material.Slate)
-- Smoking shelters: north terrace (+1.4,+103.9) and by the main building.
prop("PASmokingN", 7, 3, 5, 1.4, 103.9, Color3.fromRGB(150, 150, 155))
prop("PASmokingS", 7, 3, 5, -47.7, -31.5, Color3.fromRGB(150, 150, 155))
-- 二輪車駐車場: motorcycles park separately, right by the main building.
prop("PAMotorcycleBay", 20, 0.05, 12, 9.0, -44.9, Color3.fromRGB(70, 70, 78), Enum.Material.Asphalt)
-- EV rapid charger: first on any Japanese expressway, installed 2008-10-10.
-- OSM charging_station node sits at (-58.3, +30.2), on the west flank.
for k = 0, 1 do
if not DRAW_PA_STRUCTURES then
break
end
local ev = part({
Name = "PAEVCharger",
Size = Vector3.new(1.6 * M, 2.2 * M, 0.9 * M),
CFrame = CFrame.new(osmX(-58.3) + k * 4 * M, DECK_Y + 1.1 * M, osmZ(30.2)),
Color = Color3.fromRGB(60, 130, 90),
Material = Enum.Material.Metal,
})
ev.Parent = pa
end
-- 段差: the real PA had bumps retrofitted specifically to keep illegally
-- lowered 車高短 cars out. Deliberately shallow here (~0.1 m) -- enough to
-- be visible and to unsettle a stiff car, not enough to beach anything.
for k = -1, 1 do
local bump = part({
Name = "PALowCarBump",
Size = Vector3.new(26 * M, 0.1 * M, 0.8 * M),
CFrame = CFrame.new(c.X + k * 28 * M, DECK_Y + 0.05 * M, c.Z + APRON_Z / 2 - 4 * M),
Color = Color3.fromRGB(200, 190, 90),
Material = Enum.Material.Concrete,
})
bump.Parent = pa
end
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
end
-- Tokyo landmarks, at their true positions relative to the loop -----------------
local function buildLandmarks(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Landmarks"
folder.Parent = map
-- Tokyo Tower (-3557, -5638): orange/white lattice, twin decks, antenna
do
local base = Vector3.new(-3557, 0, -5638)
local seg = 12
local H = 333 / 0.28 / 6 -- scaled for readability at driving speed
for i = 0, seg - 1 do
local f0 = i / seg
local w0 = 90 * (1 - f0) ^ 1.6 + 8
local h = H / seg
local col = (i % 2 == 0) and Color3.fromRGB(235, 90, 40) or Color3.fromRGB(240, 240, 245)
local s = part({
Name = "TokyoTower",
Size = Vector3.new(w0, h, w0),
CFrame = CFrame.new(base + Vector3.new(0, f0 * H + h / 2, 0)),
Color = col,
Material = Enum.Material.DiamondPlate,
Transparency = 0.12,
})
s.Parent = folder
end
for _, dh in ipairs({ 0.38, 0.66 }) do
local deck = part({
Name = "TowerDeck",
Size = Vector3.new(70, 10, 70),
CFrame = CFrame.new(base + Vector3.new(0, dh * H, 0)),
Color = Color3.fromRGB(250, 250, 255),
Material = Enum.Material.Neon,
Transparency = 0.15,
})
deck.Parent = folder
end
local ant = part({
Name = "TowerAntenna",
Size = Vector3.new(5, H * 0.22, 5),
CFrame = CFrame.new(base + Vector3.new(0, H + H * 0.11, 0)),
Color = Color3.fromRGB(220, 90, 60),
Material = Enum.Material.Neon,
})
ant.Parent = folder
end
-- Tokyo Skytree (17523, 14829): far to the north-east, visible on the horizon
do
local base = Vector3.new(17523, 0, 14829)
local H = 634 / 0.28 / 6
for i = 0, 9 do
local f = i / 10
local w = 70 * (1 - f) ^ 1.3 + 10
local s = part({
Name = "Skytree",
Size = Vector3.new(w, H / 10, w),
CFrame = CFrame.new(base + Vector3.new(0, f * H + H / 20, 0)),
Color = Color3.fromRGB(215, 225, 240),
Material = Enum.Material.Neon,
Transparency = 0.25,
})
s.Parent = folder
end
end
-- Imperial Palace (-1173, 4933): dark moat ring + green grounds + stone wall
do
local c = Vector3.new(-1173, 0, 4933)
local grounds = part({
Name = "PalaceGrounds",
Size = Vector3.new(1500, 6, 1400),
CFrame = CFrame.new(c + Vector3.new(0, 3, 0)),
Color = Color3.fromRGB(24, 52, 32),
Material = Enum.Material.Grass,
})
grounds.Parent = folder
for i = 0, 23 do
local a = (i / 24) * math.pi * 2
local moat = part({
Name = "PalaceMoat",
Size = Vector3.new(150, 3, 60),
CFrame = CFrame.new(c + Vector3.new(math.sin(a) * 800, 5, math.cos(a) * 760)) * CFrame.Angles(0, -a, 0),
Color = Color3.fromRGB(18, 34, 48),
Material = Enum.Material.Water,
})
moat.Parent = folder
local wall = part({
Name = "PalaceWall",
Size = Vector3.new(150, 26, 20),
CFrame = CFrame.new(c + Vector3.new(math.sin(a) * 720, 13, math.cos(a) * 690))
* CFrame.Angles(0, -a, 0),
Color = Color3.fromRGB(90, 88, 80),
Material = Enum.Material.Slate,
})
wall.Parent = folder
end
end
-- Ginza (2764, -525): bright signage district
do
local c = Vector3.new(2764, 0, -525)
for i = 1, 26 do
local a = det(i * 3.1) * math.pi * 2
local r = 260 + det(i * 7.7) * 900
local h = 260 + det(i * 5.3) * 420
local p = c + Vector3.new(math.sin(a) * r, h / 2, math.cos(a) * r)
local b = part({
Name = "GinzaBlock",
Size = Vector3.new(150 + det(i) * 90, h, 150 + det(i * 2) * 90),
CFrame = CFrame.new(p),
Color = Color3.fromRGB(26, 26, 34),
Material = Enum.Material.Glass,
Reflectance = 0.25,
})
b.Parent = folder
local sign = part({
Name = "GinzaSign",
Size = Vector3.new(16, h * 0.5, 6),
CFrame = CFrame.new(p + Vector3.new(80, 0, 0)),
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
Material = Enum.Material.Neon,
})
sign.Parent = folder
end
end
-- Shiodome towers (1117, -2847): the glass cluster the C1 threads past
do
local c = Vector3.new(1117, 0, -2847)
for i = 1, 7 do
local h = 700 + det(i * 11.3) * 500
local p = c + Vector3.new((det(i * 2.2) - 0.5) * 1100, h / 2, (det(i * 4.4) - 0.5) * 1100)
local t = part({
Name = "ShiodomeTower",
Size = Vector3.new(170, h, 170),
CFrame = CFrame.new(p),
Color = Color3.fromRGB(30, 34, 44),
Material = Enum.Material.Glass,
Reflectance = 0.35,
})
t.Parent = folder
local crown = part({
Name = "TowerCrown",
Size = Vector3.new(175, 8, 175),
CFrame = CFrame.new(p + Vector3.new(0, h / 2, 0)),
Color = NEON_BLUE,
Material = Enum.Material.Neon,
})
crown.Parent = folder
end
end
-- Tokyo Station (3448, 3367): red brick Marunouchi facade
do
local c = Vector3.new(3448, 0, 3367)
local hall = part({
Name = "TokyoStation",
Size = Vector3.new(900, 120, 220),
CFrame = CFrame.new(c + Vector3.new(0, 60, 0)),
Color = Color3.fromRGB(120, 52, 44),
Material = Enum.Material.Brick,
})
hall.Parent = folder
for _, sx in ipairs({ -320, 0, 320 }) do
local dome = part({
Name = "StationDome",
Shape = Enum.PartType.Ball,
Size = Vector3.new(150, 150, 150),
CFrame = CFrame.new(c + Vector3.new(sx, 140, 0)),
Color = Color3.fromRGB(60, 74, 70),
Material = Enum.Material.Slate,
})
dome.Parent = folder
end
end
-- Roppongi Hills (-8715, -4899) + National Diet (-3712, 1280)
do
local rh = Vector3.new(-8715, 0, -4899)
local mori = part({
Name = "RoppongiHills",
Size = Vector3.new(260, 1300, 260),
CFrame = CFrame.new(rh + Vector3.new(0, 650, 0)),
Color = Color3.fromRGB(34, 36, 46),
Material = Enum.Material.Glass,
Reflectance = 0.3,
})
mori.Parent = folder
local diet = Vector3.new(-3712, 0, 1280)
local body = part({
Name = "NationalDiet",
Size = Vector3.new(700, 130, 260),
CFrame = CFrame.new(diet + Vector3.new(0, 65, 0)),
Color = Color3.fromRGB(150, 148, 140),
Material = Enum.Material.Granite,
})
body.Parent = folder
local tower = part({
Name = "DietTower",
Size = Vector3.new(150, 190, 150),
CFrame = CFrame.new(diet + Vector3.new(0, 190, 0)),
Color = Color3.fromRGB(150, 148, 140),
Material = Enum.Material.Granite,
})
tower.Parent = folder
end
end
-- Procedural night city filling the space around the loop -----------------------
local function buildSkyline(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Skyline"
folder.Parent = map
-- PA keep-out. Returns true if a `w`-wide box centred at (x,z) clears the
-- Daikoku PA island entirely. RING_OUTER mirrors buildPA's own geometry:
-- ringRadius (PA_RADIUS + 130) + half the 44-stud ring width.
local PA_C = Circuit.PA_CENTER
local RING_OUTER = Circuit.PA_RADIUS + 130 + 22
local PA_MARGIN = 60
local function paKeepout(x: number, z: number, w: number): boolean
local dx, dz = x - PA_C.X, z - PA_C.Z
local r = math.sqrt(dx * dx + dz * dz)
return r - w / 2 > RING_OUTER + PA_MARGIN
end
local samples = Circuit.samples
local n = #samples
local placed = 0
for i = 1, 900 do
local a = det(i * 1.7) * math.pi * 2
local r = 900 + det(i * 2.9) ^ 0.6 * 9000
local x = math.sin(a) * r - 477
local z = math.cos(a) * r - 473
local h = 120 + det(i * 5.1) ^ 2.2 * 900
local w = 90 + det(i * 8.3) * 130
-- keep buildings off the racing surface.
-- The test must use the building's OUTER FACE, not its centre: a 220-stud
-- centre setback still lets a 220-wide tower reach 110 studs inward, which
-- 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.
-- 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
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude
if d < near then
near = d
end
end
if near - w / 2 < CLEAR then
continue
end
-- ...but `samples` is the MAINLINE centreline ONLY. The PA ring road,
-- its two ramps and the parking apron are generated separately (see
-- buildPA) and appear nowhere in `samples`, so the test above is blind
-- to the entire PA island. A 186-wide tower landed at r=428 from
-- PA_CENTER, spanning radius 335->521 and punching straight through the
-- ring road band [368,412] -- a skyscraper standing in the road.
--
-- Exclude the whole PA disc, not just the ring band: nothing generated
-- belongs inside the ring at all. Test the building's INNER FACE
-- (r - w/2) against the ring's outer kerb + the same 60-stud margin.
if not paKeepout(x, z, w) then
continue
end
local b = part({
Name = "Building",
Size = Vector3.new(w, h, w),
CFrame = CFrame.new(x, h / 2, z),
Color = Color3.fromRGB(18 + det(i) * 16, 18 + det(i * 2) * 16, 26 + det(i * 3) * 20),
Material = Enum.Material.Glass,
Reflectance = 0.18,
})
b.Parent = folder
placed += 1
-- signs hang off the +X face, so they need their own clearance check
if det(i * 13.7) > 0.72 then
local sx = x + w / 2 + 6
local snear = math.huge
for k = 1, n do
local s = samples[k].pos
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(sx, 0, z)).Magnitude
if d < snear then
snear = d
end
end
if snear > CLEAR and paKeepout(sx, z, 12) then
local sign = part({
Name = "NeonSign",
Size = Vector3.new(12, h * 0.42, 5),
CFrame = CFrame.new(sx, h * 0.62, z),
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
Material = Enum.Material.Neon,
})
sign.Parent = folder
end
end
if placed > 620 then
break
end
end
-- ground plane under the whole city
local ground = part({
Name = "Ground",
Size = Vector3.new(26000, 4, 26000),
CFrame = CFrame.new(-477, -2, -473),
Color = Color3.fromRGB(14, 14, 18),
Material = Enum.Material.Asphalt,
})
ground.Parent = folder
end
--[[
Signature crossings.
Two hand-placed structures on top of the generated road:
* NihonbashiViaduct -- the C1's most famous stretch. The expressway runs
on a box girder directly OVER the historic Nihonbashi stone bridge and
the Nihonbashi-gawa. The stone bridge underneath is the actual landmark,
so it is modelled too (deck, balustrades, ornamental posts, arches).
* CableStayed -- a red twin-tower cable-stayed span on the straightest
bay-side run, with fanned stay cables and aviation warning lights.
Both index into Circuit.samples, so they follow the road wherever it moves.
Span indices were chosen from the georeferenced OSM trace: Nihonbashi maps
to the Edobashi-junction samples, the cable-stayed span to the Shiodome
straight (lowest cumulative heading change of any elevated, non-tunnel run).
]]
local function buildBridges(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Bridges"
folder.Parent = map
local samples = Circuit.samples
local n = Circuit.SAMPLE_COUNT
local function mid(i: number): Vector3
local a = samples[i]
local b = samples[(i % n) + 1]
return (a.pos + b.pos) / 2
end
----------------------------------------------------------------- Nihonbashi
do
local NB = Instance.new("Folder")
NB.Name = "NihonbashiViaduct"
NB.Parent = folder
local A, B = 46, 62
local pa, pb = samples[A].pos, samples[B].pos
local centre = (pa + pb) / 2
local axis = pb - pa
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
local right = flat:Cross(UP)
local span = axis.Magnitude
-- Nihonbashi-gawa flowing under the viaduct
local waterCF = CFrame.lookAt(Vector3.new(centre.X, 2, centre.Z), Vector3.new(centre.X, 2, centre.Z) + flat, UP)
local water = part({
Name = "RiverWater",
Size = Vector3.new(150, 3, span + 300),
CFrame = waterCF,
Color = Color3.fromRGB(18, 32, 42),
Material = Enum.Material.Glass,
})
water.Reflectance = 0.35
water.Parent = NB
for _, side in ipairs({ -1, 1 }) do
local q = part({
Name = "RiverWall",
Size = Vector3.new(10, 26, span + 300),
CFrame = waterCF * CFrame.new(side * 80, 11, 0),
Color = Color3.fromRGB(58, 56, 52),
Material = Enum.Material.Concrete,
})
q.Parent = NB
end
-- the historic stone bridge, crossing UNDER the expressway
local bpos = Vector3.new(centre.X, 16, centre.Z)
local bcf = CFrame.lookAt(bpos, bpos + right, UP)
local deck = part({
Name = "NihonbashiStoneDeck",
Size = Vector3.new(46, 4, 180),
CFrame = bcf,
Color = Color3.fromRGB(150, 142, 128),
Material = Enum.Material.Slate,
})
deck.Parent = NB
for _, s in ipairs({ -1, 1 }) do
local bal = part({
Name = "Balustrade",
Size = Vector3.new(2.5, 5, 180),
CFrame = bcf * CFrame.new(s * 22, 4, 0),
Color = Color3.fromRGB(168, 160, 146),
Material = Enum.Material.Slate,
})
bal.Parent = NB
for k = -2, 2 do
local post = part({
Name = "BridgePost",
Size = Vector3.new(6, 14, 6),
CFrame = bcf * CFrame.new(s * 22, 8, k * 44),
Color = Color3.fromRGB(120, 112, 100),
Material = Enum.Material.Slate,
})
post.Parent = NB
if math.abs(k) == 2 then
local lamp = part({
Name = "BridgeLamp",
Size = Vector3.new(4, 6, 4),
CFrame = bcf * CFrame.new(s * 22, 17, k * 44),
Color = Color3.fromRGB(255, 214, 150),
Material = Enum.Material.Neon,
})
local pl = Instance.new("PointLight")
pl.Brightness = 2
pl.Range = 60
pl.Color = Color3.fromRGB(255, 214, 150)
pl.Parent = lamp
lamp.Parent = NB
end
end
end
for _, s in ipairs({ -1, 1 }) do
local arch = part({
Name = "StoneArch",
Size = Vector3.new(40, 22, 70),
CFrame = bcf * CFrame.new(0, -10, s * 46),
Color = Color3.fromRGB(138, 130, 118),
Material = Enum.Material.Slate,
})
arch.Parent = NB
end
-- box girder + pier bents carrying the expressway deck
for i = A, B do
local m = mid(i)
local fwd = Circuit.forwardAt(i)
local cf = CFrame.lookAt(m, m + fwd, UP)
local g = part({
Name = "BoxGirder",
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,
})
g.Parent = NB
if i % 3 == 0 then
local y = samples[i].pos.Y
for _, side in ipairs({ -1, 1 }) do
local leg = part({
Name = "ViaductPier",
Size = Vector3.new(9, math.max(y - 14, 4), 9),
CFrame = CFrame.new(Vector3.new(m.X, math.max(y - 14, 4) / 2 + 14, m.Z) + right * (side * 34)),
Color = Color3.fromRGB(72, 70, 76),
Material = Enum.Material.Concrete,
})
leg.Parent = NB
end
local cap = part({
Name = "PierCap",
Size = Vector3.new(88, 6, 12),
CFrame = cf * CFrame.new(0, -13, 0),
Color = Color3.fromRGB(72, 70, 76),
Material = Enum.Material.Concrete,
})
cap.Parent = NB
end
end
end
--------------------------------------------------------------- cable-stayed
do
local CS = Instance.new("Folder")
CS.Name = "CableStayed"
CS.Parent = folder
local A, B = 646, 659
local pa, pb = samples[A].pos, samples[B].pos
local axis = pb - pa
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
local right = flat:Cross(UP)
local deckY = pa.Y
local TOWER_H = 210
local RED = Color3.fromRGB(196, 72, 72)
local tops = {}
for _, idx in ipairs({ A + 3, B - 3 }) do
local c = mid(idx)
for _, side in ipairs({ -1, 1 }) do
local top = deckY + TOWER_H
local leg = part({
Name = "BridgeTower",
Size = Vector3.new(12, top, 12),
CFrame = CFrame.new(Vector3.new(c.X, top / 2, c.Z) + right * (side * 56)),
Color = RED,
Material = Enum.Material.Metal,
})
leg.Parent = CS
table.insert(tops, { pos = Vector3.new(c.X, top, c.Z) + right * (side * 56), idx = idx, side = side })
end
for _, h in ipairs({ 0.55, 0.85 }) do
local y = deckY + TOWER_H * h
local beam = part({
Name = "TowerCrossbeam",
Size = Vector3.new(124, 8, 8),
CFrame = CFrame.lookAt(Vector3.new(c.X, y, c.Z), Vector3.new(c.X, y, c.Z) + flat, UP)
* CFrame.Angles(0, math.pi / 2, 0),
Color = RED,
Material = Enum.Material.Metal,
})
beam.Parent = CS
end
local al = part({
Name = "AviationLight",
Size = Vector3.new(6, 3, 6),
CFrame = CFrame.new(c.X, deckY + TOWER_H + 3, c.Z),
Color = Color3.fromRGB(255, 60, 60),
Material = Enum.Material.Neon,
})
local pl = Instance.new("PointLight")
pl.Brightness = 3
pl.Range = 140
pl.Color = Color3.fromRGB(255, 60, 60)
pl.Parent = al
al.Parent = CS
end
-- fanned stay cables
for _, t in ipairs(tops) do
for k = 1, 6 do
local target = math.clamp(t.idx + (k * 2 - 7), A, B)
local anchor = mid(target) + right * (t.side * 46) + Vector3.new(0, 2, 0)
local delta = anchor - t.pos
local dist = delta.Magnitude
if dist > 1 then
local cable = part({
Name = "Cable",
Size = Vector3.new(0.9, 0.9, dist),
CFrame = CFrame.lookAt(t.pos + delta / 2, anchor, UP),
Color = Color3.fromRGB(225, 225, 232),
Material = Enum.Material.Metal,
})
cable.Parent = CS
end
end
end
for i = A, B do
local m = mid(i)
local fwd = Circuit.forwardAt(i)
local g = part({
Name = "BridgeGirder",
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,
})
g.Parent = CS
end
local centre = (pa + pb) / 2
local bay = part({
Name = "BayWater",
Size = Vector3.new(900, 3, axis.Magnitude + 500),
CFrame = CFrame.lookAt(Vector3.new(centre.X, 1, centre.Z), Vector3.new(centre.X, 1, centre.Z) + flat, UP),
Color = Color3.fromRGB(14, 26, 38),
Material = Enum.Material.Glass,
})
bay.Reflectance = 0.4
bay.Parent = CS
end
end
function MapBuilder.build(): Folder
local existing = Workspace:FindFirstChild("Map")
if existing then
existing:Destroy()
end
local map = Instance.new("Folder")
map.Name = "Map"
buildRoad(map)
buildJunctionSigns(map)
buildPA(map)
buildLandmarks(map)
buildSkyline(map)
buildBridges(map)
-- Start/finish across the road at sample 1
local s1 = Circuit.samples[1]
local fwd = Circuit.forwardAt(1)
local line = part({
Name = "FinishLine",
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,
})
line.Parent = map
map.Parent = Workspace
return map
end
return MapBuilder