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.
This commit is contained in:
2026-08-03 11:53:40 -04:00
parent badba0f7a4
commit 1d23c187ad
15 changed files with 1506 additions and 192 deletions
+449 -163
View File
@@ -324,15 +324,168 @@ local function planPA()
bestIdx, bestD = i, d
end
end
-- Entry ramp leaves the mainline before the PA, exit ramp rejoins after it.
local n = #samples
local SPAN = 6 -- samples of median opening around each junction
local entryIdx = ((bestIdx - 14 - 1) % n) + 1
local exitIdx = ((bestIdx + 14 - 1) % n) + 1
-- 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 _, centreIdx in ipairs({ entryIdx, exitIdx }) do
for _, jd in ipairs(junctionDefs) do
for k = -SPAN, SPAN do
open[((centreIdx + k - 1) % n) + 1] = true
open[((jd.idx + k - 1) % n) + 1] = true
end
end
-- The OUTER edge barrier needs its own, much narrower opening -- and it is
@@ -351,17 +504,29 @@ local function planPA()
-- and only right at the ramp mouth.
local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median
local rampOpen = {}
for _, centreIdx in ipairs({ entryIdx, exitIdx }) do
-- 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
rampOpen[((centreIdx + k - 1) % n) + 1] = true
local i = ((jd.idx + k - 1) % n) + 1
rampOpen[i] = true
rampOpenSide[i] = jd.side
end
end
PAPlan = {
nearIdx = bestIdx,
entryIdx = entryIdx,
exitIdx = exitIdx,
-- 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
@@ -530,16 +695,16 @@ local function buildRoad(map: Folder)
-- 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 = 0
if junction then
-- Which side of the carriageway is the PA on? Positive dot with
-- `right` means the PA lies to the +1 side at this sample.
local toPA = Vector3.new(Circuit.PA_CENTER.X - mid.X, 0, Circuit.PA_CENTER.Z - mid.Z)
if toPA.Magnitude > 0.01 then
paSide = (toPA.Unit:Dot(right) >= 0) and 1 or -1
end
end
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
@@ -1162,6 +1327,50 @@ local function buildPA(map: Folder)
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)
@@ -1207,35 +1416,47 @@ local function buildPA(map: Folder)
local GATE_IN = 70 -- half-width of the apron-side opening
local GATE_OUT = 30 -- half-width of the ramp-side opening
local junctions = {}
for _, j in ipairs({
{ idx = plan.entryIdx, name = "PAOnRamp" },
{ idx = plan.exitIdx, name = "PAOffRamp" },
}) do
local road = samples[j.idx].pos
local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z)
if toC.Magnitude >= 1 then
local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R
if (ringPt - road).Magnitude >= 4 then
-- roadY: the mainline deck height AT this junction. The road is
-- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb
-- to whatever the sample actually sits at -- reading it here
-- keeps ramp and road in lockstep if the lift profile changes.
--
-- heading: mainline direction of travel at this junction. The
-- helix ramp must leave the carriageway TANGENTIALLY, so it
-- needs the direction, not just the two endpoints.
local nxt = samples[(j.idx % #samples) + 1].pos
local fwd = Vector3.new(nxt.X - road.X, 0, nxt.Z - road.Z)
table.insert(junctions, {
name = j.name,
road = road,
ringPt = ringPt,
roadY = road.Y,
idx = j.idx,
heading = math.atan2(fwd.Z, fwd.X),
})
end
end
-- 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)
@@ -1361,123 +1582,85 @@ local function buildPA(map: Folder)
-- `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.
--
-- HELIX RAMPS (2026-08-03). These used to be STRAIGHT sloped decks: a
-- direct line from the ring gate to the mainline, climbing PA_LIFT studs
-- over the span. Two things were wrong with that, both measured:
-- PA-CONCENTRIC LOOP RAMPS (2026-08-03, second pass).
--
-- 1. GRADE. Straight ramps came out at 10.6% (entry) and 13.6% (exit).
-- Japanese expressway ramps are held to ~6% maximum; 13% is a
-- launch ramp, not a road.
-- 2. SHAPE. Real Daikoku entry/exit ramps are HELIXES -- they spiral
-- down around a vertical axis. That is the single most recognisable
-- thing about the interchange and it was entirely absent.
-- 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 radii below are MEASURED, not chosen: an OSM Overpass pull of the
-- interchange's curved ways, each fitted with a Kasa circle fit. The two
-- cleanest fits (rms < 1.5 m -- i.e. genuinely circular, not just curved)
-- are 147 m and 124 m, which at M = 3.571 studs/m give 525 and 443 studs.
-- See docs/daikoku-reference-spec.md for the full survey.
-- 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.
--
-- Construction: leave the mainline TANGENTIALLY (no step change in
-- heading at speed), wrap the circle while descending, then run the
-- tangent line from the circle to the ring gate. Solved numerically
-- offline and verified collision-free against all 823 mainline samples
-- (min horizontal clearance: 751 studs entry, 570 exit) and against the
-- PA ring itself (every helix point lies outside RING_R).
-- 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 M_PER_STUD = 3.571
local HELIX_R = {
PAOnRamp = 147 * M_PER_STUD, -- 525 studs, OSM way 32403901 (rms 1.2)
PAOffRamp = 124 * M_PER_STUD, -- 443 studs, OSM way 779045459 (rms 0.7)
}
-- Turn direction, plan view. +1 = counter-clockwise in Roblox's X/Z
-- (Z grows "south"), which is what both solved geometries need for the
-- gate to fall OUTSIDE the helix circle -- the clockwise solutions put
-- the gate inside the circle, where no tangent exists at all.
local HELIX_TURN = { PAOnRamp = 1, PAOffRamp = 1 }
local GIRDER_DEPTH = 8 -- deck soffit sits this far under the running surface
local CAR_H = 9
for _, jn in ipairs(junctions) do
local helixR = HELIX_R[jn.name]
local turn = HELIX_TURN[jn.name] or 1
local yHi = jn.roadY or (ringY + LIFT)
local yLo = ringY
local function ease(t)
if t < 0 then
t = 0
elseif t > 1 then
t = 1
end
return t * t * (3 - 2 * t)
end
-- Circle centre: perpendicular to the mainline heading, on the turn
-- side, one radius away. This is what makes the join tangential.
local nrm = jn.heading + turn * math.pi / 2
local O = Vector3.new(jn.road.X + math.cos(nrm) * helixR, 0, jn.road.Z + math.sin(nrm) * helixR)
-- Tangent from the circle to the ring gate.
local G = jn.ringPt
local dGO = Vector3.new(G.X - O.X, 0, G.Z - O.Z)
local d = dGO.Magnitude
local pathPts = {}
if d > helixR + 1 then
local tail = math.sqrt(d * d - helixR * helixR)
local alpha = math.atan2(dGO.Z, dGO.X)
local beta = math.acos(math.clamp(helixR / d, -1, 1))
local tangA = alpha - turn * beta
local th0 = math.atan2(jn.road.Z - O.Z, jn.road.X - O.X)
local sweep = (tangA - th0) * turn
sweep = sweep % (math.pi * 2)
local arcLen = helixR * sweep
local total = arcLen + tail
-- Sample the arc, then the straight tail, as one continuous path
-- with a single monotonic descent so the grade never changes sign.
local ARC_STEPS = math.max(16, math.floor(arcLen / 45))
for s = 0, ARC_STEPS do
local f = s / ARC_STEPS
local th = th0 + turn * sweep * f
local dist = helixR * sweep * f
table.insert(pathPts, {
pos = Vector3.new(O.X + math.cos(th) * helixR, 0, O.Z + math.sin(th) * helixR),
t = dist / total,
})
end
local T = pathPts[#pathPts].pos
local TAIL_STEPS = math.max(4, math.floor(tail / 45))
for s = 1, TAIL_STEPS do
local f = s / TAIL_STEPS
table.insert(pathPts, {
pos = T:Lerp(Vector3.new(G.X, 0, G.Z), f),
t = (arcLen + tail * f) / total,
})
end
else
-- Degenerate: gate sits inside the helix circle, so no tangent
-- exists. Fall back to the old straight ramp rather than build
-- nothing -- a missing ramp strands the player on the apron.
local STEPS = math.max(8, math.floor((G - jn.road).Magnitude / 40))
for s = 0, STEPS do
local f = s / STEPS
table.insert(pathPts, {
pos = Vector3.new(jn.road.X, 0, jn.road.Z):Lerp(Vector3.new(G.X, 0, G.Z), f),
t = f,
})
-- 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
-- Ease the descent at both ends so a car doesn't nose into the
-- transition at the top or bottom out at the bottom.
local function ease(t)
return t * t * (3 - 2 * t)
end
for s = 1, #pathPts - 1 do
local A, B = pathPts[s], pathPts[s + 1]
local y0 = yHi + (yLo - yHi) * ease(A.t)
local y1 = yHi + (yLo - yHi) * ease(B.t)
local a = Vector3.new(A.pos.X, y0, A.pos.Z)
local b = Vector3.new(B.pos.X, y1, B.pos.Z)
-- 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(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
dck.Name = jn.name .. "_" .. s
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.
@@ -1497,7 +1680,15 @@ local function buildPA(map: Folder)
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
if h > 10 and s % 2 == 0 then
-- ...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
@@ -1539,7 +1730,11 @@ local function buildPA(map: Folder)
-- 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).
local isMergeSeg = (s >= #pathPts - 2)
-- 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)
@@ -1550,8 +1745,8 @@ local function buildPA(map: Folder)
Name = "PARampKerb",
Size = Vector3.new(1.6, 3.2, segLen),
CFrame = CFrame.lookAt(
segMid + rightV * (sg * RING_W / 2),
segMid + rightV * (sg * RING_W / 2) + (b - a).Unit,
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,
@@ -1577,8 +1772,8 @@ local function buildPA(map: Folder)
-- segment stands in for the diagonal hatching.
--
-- Outward side of the curve. Taking the raw radial
-- (segMid - O) as the offset direction is WRONG on the
-- straight tangent tail: there the radial is no longer
-- (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
@@ -1586,10 +1781,13 @@ local function buildPA(map: Folder)
--
-- Offset along the true perpendicular `rightV` always, and
-- use the radial ONLY to choose which side is outboard.
local radial = Vector3.new(segMid.X - O.X, 0, segMid.Z - O.Z)
if radial.Magnitude > 0.01 and rightV.Magnitude > 0.01 then
-- 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 * RING_W / 2)
local face = segMid + rightV * (sgn * RAMP_W / 2)
local chev = part({
Name = "PARampChevron",
Size = Vector3.new(0.5, 2.0, segLen),
@@ -1602,10 +1800,98 @@ local function buildPA(map: Folder)
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 ramp meets the ring at a
-- tangent and would otherwise leave triangular gaps at the corners.
-- 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(
@@ -1613,7 +1899,7 @@ local function buildPA(map: Folder)
Vector3.new(jn.road.X, ringY, jn.road.Z),
UP
)
local flare = deck("PARampFlare", Vector3.new(GATE_OUT * 2 + RING_W, 2, RING_W + 24), flareCF)
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