From f4f95ec9ccd4d4314d9dcbadd085c6e8c614040d Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Mon, 3 Aug 2026 03:11:06 -0400 Subject: [PATCH] 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. --- src/server/MapBuilder.luau | 162 +++++++++++++++++++++++++++++++------ 1 file changed, 139 insertions(+), 23 deletions(-) diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index 464cd14..b51885c 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -1208,11 +1208,19 @@ local function buildPA(map: Folder) -- 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 @@ -1341,38 +1349,124 @@ 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. -- - -- These are now GRADED: the ring sits on the apron at ringY while the - -- mainline flies Circuit.PA_LIFT studs overhead, so each ramp is a sloped - -- deck built as a stack of short segments that climb from the ring up to - -- the road. A single flat slab would leave a PA_LIFT-stud cliff at the - -- top -- undrivable in either direction. + -- 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: + -- + -- 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 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 - local span = (jn.ringPt - jn.road).Magnitude - -- The road end must meet the ACTUAL mainline deck height at that - -- junction (jn.roadY, captured from the sample), not a guess. - local yLo, yHi = ringY, jn.roadY or (ringY + LIFT) - local STEPS = math.max(8, math.floor(span / 40)) - -- Ease the grade at both ends so a car doesn't nose into the - -- transition or launch off the crest. + local helixR = HELIX_R[jn.name] + local turn = HELIX_TURN[jn.name] or 1 + local yHi = jn.roadY or (ringY + LIFT) + local yLo = ringY + + -- 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, + }) + 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) return t * t * (3 - 2 * t) end - for s = 0, STEPS - 1 do - local t0, t1 = s / STEPS, (s + 1) / STEPS - local p0 = jn.ringPt:Lerp(jn.road, t0) - local p1 = jn.ringPt:Lerp(jn.road, t1) - local y0 = yLo + (yHi - yLo) * ease(t0) - local y1 = yLo + (yHi - yLo) * ease(t1) - local a = Vector3.new(p0.X, y0, p0.Z) - local b = Vector3.new(p1.X, y1, p1.Z) + + 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) 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 d = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP)) - d.Name = jn.name .. "_" .. s + local dck = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP)) + dck.Name = jn.name .. "_" .. s + -- Support pillar down to the apron under the raised part of the -- ramp, matching the reference photos' elevated structure. 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 -- continuous in reality regardless of how high it happens to be, -- 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 local fwd2 = (b - a) fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z) @@ -1431,6 +1527,26 @@ local function buildPA(map: Folder) }) kerb.Parent = pa 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