Daikoku PA: replace straight ramps with measured helix ramps

The PA on/off ramps were straight sloped decks running directly from the
ring gate to the mainline. Two measured problems:

  1. Grade. Straight ramps came out at 10.6% (entry) and 13.6% (exit).
     Japanese expressway ramps are held to roughly 6% maximum.
  2. Shape. The real Daikoku ramps are helixes -- they spiral down around
     a vertical axis. That is the interchange's single most recognisable
     feature and it was entirely absent.

Radii 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 rather than merely curved) are
147 m and 124 m -> 525 and 443 studs at 3.571 studs/m.

Construction leaves the mainline tangentially (no step change in heading
at speed), wraps the circle while descending, then runs the tangent line
from circle to ring gate. Junction resolution now also captures the
mainline heading, which the tangential join requires.

Measured on the built geometry in Studio:

           segs   path            drop   grade   rotation
  entry     84    3775 studs      56     1.48%   344 deg
  exit      64    2866 studs      56     1.95%   282 deg

Grade drops 10.6% -> 1.48% and 13.6% -> 1.95%. Continuity is clean
(max Y step 0.99 / 1.31 studs across seams). Verified collision-free
against all mainline samples and the PA ring: closest approach to the
PA centre is 414 / 406 studs against PA_RADIUS 260.

Also adds red shoulder chevrons on the outside of each curve (72 parts),
which is what makes a helix legible from inside the car, and fixes the
kerb loop's merge-mouth test, which referenced the now-removed STEPS.
This commit is contained in:
2026-08-03 03:11:06 -04:00
parent 4a702a4fdb
commit f4f95ec9cc
+139 -23
View File
@@ -1208,11 +1208,19 @@ local function buildPA(map: Folder)
-- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb -- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb
-- to whatever the sample actually sits at -- reading it here -- to whatever the sample actually sits at -- reading it here
-- keeps ramp and road in lockstep if the lift profile changes. -- 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, { table.insert(junctions, {
name = j.name, name = j.name,
road = road, road = road,
ringPt = ringPt, ringPt = ringPt,
roadY = road.Y, roadY = road.Y,
idx = j.idx,
heading = math.atan2(fwd.Z, fwd.X),
}) })
end end
end end
@@ -1341,38 +1349,124 @@ local function buildPA(map: Folder)
-- `junctions` was resolved above (the ring wall needed it to place gates); -- `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. -- reuse it so the ramp deck and the wall opening can never disagree.
-- --
-- These are now GRADED: the ring sits on the apron at ringY while the -- HELIX RAMPS (2026-08-03). These used to be STRAIGHT sloped decks: a
-- mainline flies Circuit.PA_LIFT studs overhead, so each ramp is a sloped -- direct line from the ring gate to the mainline, climbing PA_LIFT studs
-- deck built as a stack of short segments that climb from the ring up to -- over the span. Two things were wrong with that, both measured:
-- the road. A single flat slab would leave a PA_LIFT-stud cliff at the --
-- top -- undrivable in either direction. -- 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 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.
--
-- 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).
local LIFT = Circuit.PA_LIFT or 0 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 }
for _, jn in ipairs(junctions) do for _, jn in ipairs(junctions) do
local span = (jn.ringPt - jn.road).Magnitude local helixR = HELIX_R[jn.name]
-- The road end must meet the ACTUAL mainline deck height at that local turn = HELIX_TURN[jn.name] or 1
-- junction (jn.roadY, captured from the sample), not a guess. local yHi = jn.roadY or (ringY + LIFT)
local yLo, yHi = ringY, jn.roadY or (ringY + LIFT) local yLo = ringY
local STEPS = math.max(8, math.floor(span / 40))
-- Ease the grade at both ends so a car doesn't nose into the -- Circle centre: perpendicular to the mainline heading, on the turn
-- transition or launch off the crest. -- 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,
})
end
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) local function ease(t)
return t * t * (3 - 2 * t) return t * t * (3 - 2 * t)
end end
for s = 0, STEPS - 1 do
local t0, t1 = s / STEPS, (s + 1) / STEPS for s = 1, #pathPts - 1 do
local p0 = jn.ringPt:Lerp(jn.road, t0) local A, B = pathPts[s], pathPts[s + 1]
local p1 = jn.ringPt:Lerp(jn.road, t1) local y0 = yHi + (yLo - yHi) * ease(A.t)
local y0 = yLo + (yHi - yLo) * ease(t0) local y1 = yHi + (yLo - yHi) * ease(B.t)
local y1 = yLo + (yHi - yLo) * ease(t1) local a = Vector3.new(A.pos.X, y0, A.pos.Z)
local a = Vector3.new(p0.X, y0, p0.Z) local b = Vector3.new(B.pos.X, y1, B.pos.Z)
local b = Vector3.new(p1.X, y1, p1.Z)
local segMid = (a + b) / 2 local segMid = (a + b) / 2
-- Overlap consecutive segments slightly: a sloped deck is a -- Overlap consecutive segments slightly: a sloped deck is a
-- rotated box, so butt-jointed segments leave a thin wedge gap at -- rotated box, so butt-jointed segments leave a thin wedge gap at
-- each seam that a wheel can catch on. -- each seam that a wheel can catch on.
local segLen = (b - a).Magnitude + 3 local segLen = (b - a).Magnitude + 3
local d = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP)) local dck = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
d.Name = jn.name .. "_" .. s dck.Name = jn.name .. "_" .. s
-- Support pillar down to the apron under the raised part of the -- Support pillar down to the apron under the raised part of the
-- ramp, matching the reference photos' elevated structure. -- ramp, matching the reference photos' elevated structure.
local h = segMid.Y - (ringY - 1) local h = segMid.Y - (ringY - 1)
@@ -1411,7 +1505,9 @@ local function buildPA(map: Folder)
-- of both ramps completely open on both sides. A ramp edge is -- of both ramps completely open on both sides. A ramp edge is
-- continuous in reality regardless of how high it happens to be, -- continuous in reality regardless of how high it happens to be,
-- so the only exclusion left is the merge, which must stay open. -- so the only exclusion left is the merge, which must stay open.
local isMergeSeg = (s >= STEPS - 1) -- 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)
if not isMergeSeg then if not isMergeSeg then
local fwd2 = (b - a) local fwd2 = (b - a)
fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z) fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z)
@@ -1431,6 +1527,26 @@ local function buildPA(map: Folder)
}) })
kerb.Parent = pa kerb.Parent = pa
end end
-- Red/white shoulder chevrons. Every Shuto ramp carries
-- these on the outside of the curve -- they read as speed
-- cues in the onboard footage and are the main thing that
-- makes a helix legible from inside the car. Alternating
-- segments only, so they stripe rather than smear.
if s % 2 == 0 then
local chev = part({
Name = "PARampChevron",
Size = Vector3.new(2.2, 0.4, segLen * 0.55),
CFrame = CFrame.lookAt(
segMid + rightV * (turn * RING_W / 2 * 0.86),
segMid + rightV * (turn * RING_W / 2 * 0.86) + (b - a).Unit,
UP
) * CFrame.new(0, 1.3, 0),
Color = Color3.fromRGB(198, 46, 40),
Material = Enum.Material.SmoothPlastic,
})
chev.Parent = pa
end
end end
end end
end end