From 1d23c187ad99717b44ded47ec88f1d41d894ced6 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Mon, 3 Aug 2026 11:53:40 -0400 Subject: [PATCH] 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_`. 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. --- src/server/MapBuilder.luau | 612 ++++++++++++++++++++-------- tools/control_and_connectivity.luau | 95 +++++ tools/diag_daikoku.luau | 12 +- tools/diag_raycast.luau | 12 +- tools/export_plan.luau | 51 +++ tools/measure_new_ramps.luau | 110 +++++ tools/median_correct.luau | 71 ++++ tools/probe_ramp_geo.luau | 82 ++++ tools/rebuild_fresh.luau | 58 +++ tools/triage_ramp_hits.luau | 139 +++++++ tools/verify_c1_fixes.luau | 51 ++- tools/verify_c1_fixes2.luau | 12 +- tools/verify_c1_fixes3.luau | 19 +- tools/verify_merge.luau | 71 ++++ tools/verify_shared_ramps.luau | 303 ++++++++++++++ 15 files changed, 1506 insertions(+), 192 deletions(-) create mode 100644 tools/control_and_connectivity.luau create mode 100644 tools/export_plan.luau create mode 100644 tools/measure_new_ramps.luau create mode 100644 tools/median_correct.luau create mode 100644 tools/probe_ramp_geo.luau create mode 100644 tools/rebuild_fresh.luau create mode 100644 tools/triage_ramp_hits.luau create mode 100644 tools/verify_merge.luau create mode 100644 tools/verify_shared_ramps.luau diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index cea849d..4e8e86d 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -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 diff --git a/tools/control_and_connectivity.luau b/tools/control_and_connectivity.luau new file mode 100644 index 0000000..737442a --- /dev/null +++ b/tools/control_and_connectivity.luau @@ -0,0 +1,95 @@ +-- CONTROL + CONNECTIVITY. +-- 1. Control: deliberately test a part known to intersect structure, so a +-- 0-hit result on the ramps means "clear", not "probe is broken". +-- 2. Connectivity: largest gap between consecutive deck segments within each +-- run, and the trunk->near handoff distance. +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") +if not map then + return "no Map" +end + +local structural, runs = {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name + if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then + table.insert(structural, d) + end + for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do + if nm:sub(1, #base + 1) == base .. "_" then + runs[base] = runs[base] or {} + table.insert(runs[base], d) + end + end + end +end + +local function overlaps(p, s) + local d1, d2 = p.Position, s.Position + return math.abs(d1.X - d2.X) < (p.Size.X + s.Size.X) / 2 + and math.abs(d1.Z - d2.Z) < (p.Size.Z + s.Size.Z) / 2 + and math.abs(d1.Y - d2.Y) < (p.Size.Y + s.Size.Y) / 2 +end + +local out = {} + +-- CONTROL: a probe box placed exactly on the first structural part must hit. +local ctrl = structural[1] +local fake = { Position = ctrl.Position, Size = Vector3.new(4, 4, 4) } +local ctrlHits = 0 +for _, s in ipairs(structural) do + if overlaps(fake, s) then + ctrlHits += 1 + end +end +table.insert( + out, + ("CONTROL (box on a known pier): hits=%d -> probe %s"):format( + ctrlHits, + ctrlHits > 0 and "WORKS" or "IS BROKEN/VACUOUS" + ) +) +table.insert(out, "structuralParts=" .. #structural) + +-- CONNECTIVITY +local function sortKey(p) + return tonumber(p.Name:match("_(%d+)$")) or 0 +end +for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do + local parts = runs[base] + if not parts then + table.insert(out, base .. ": MISSING") + else + table.sort(parts, function(a, b) + return sortKey(a) < sortKey(b) + end) + local maxGap = 0 + for i = 2, #parts do + local g = (parts[i].Position - parts[i - 1].Position).Magnitude + maxGap = math.max(maxGap, g) + end + table.insert(out, ("%s: n=%d maxSegGap=%.1f"):format(base, #parts, maxGap)) + end +end + +-- Trunk -> Near handoff +for _, pair in ipairs({ { "PAEntryRampTrunk", "PAEntryRampNear" }, { "PAExitRampTrunk", "PAExitRampNear" } }) do + local t, nr = runs[pair[1]], runs[pair[2]] + if t and nr then + table.sort(t, function(a, b) + return sortKey(a) < sortKey(b) + end) + table.sort(nr, function(a, b) + return sortKey(a) < sortKey(b) + end) + local best = math.huge + for _, a in ipairs({ t[1], t[#t] }) do + for _, b in ipairs({ nr[1], nr[#nr] }) do + best = math.min(best, (a.Position - b.Position).Magnitude) + end + end + table.insert(out, ("HANDOFF %s->%s closestEnd=%.1f"):format(pair[1], pair[2], best)) + end +end +return table.concat(out, "\n") diff --git a/tools/diag_daikoku.luau b/tools/diag_daikoku.luau index c8fe739..50f4779 100644 --- a/tools/diag_daikoku.luau +++ b/tools/diag_daikoku.luau @@ -27,11 +27,13 @@ local road = findFirst("Road") local struct = findFirst("Structure") or findFirst("Struct") say("== CONTAINERS ==") -say(("pa=%s road=%s struct=%s"):format( - pa and pa:GetFullName() or "NIL", - road and road:GetFullName() or "NIL", - struct and struct:GetFullName() or "NIL" -)) +say( + ("pa=%s road=%s struct=%s"):format( + pa and pa:GetFullName() or "NIL", + road and road:GetFullName() or "NIL", + struct and struct:GetFullName() or "NIL" + ) +) -- Gather parts by name pattern anywhere under Workspace ---------------------- local buckets: { [string]: { BasePart } } = {} diff --git a/tools/diag_raycast.luau b/tools/diag_raycast.luau index ca6931f..d5a2c23 100644 --- a/tools/diag_raycast.luau +++ b/tools/diag_raycast.luau @@ -42,7 +42,12 @@ for i = 1, n do end if d > 1 then hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format( - i, samples[i].piece, d, a1.Instance.Name, a2.Instance.Name) + i, + samples[i].piece, + d, + a1.Instance.Name, + a2.Instance.Name + ) end end local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly) @@ -58,7 +63,10 @@ end out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll) out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format( - worstRoad, atRoad, worstRoad <= 0.60 and "PASS" or "FAIL") + worstRoad, + atRoad, + worstRoad <= 0.60 and "PASS" or "FAIL" +) out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits) for k = 1, math.min(12, #hits) do out[#out + 1] = " " .. hits[k] diff --git a/tools/export_plan.luau b/tools/export_plan.luau new file mode 100644 index 0000000..df95d6e --- /dev/null +++ b/tools/export_plan.luau @@ -0,0 +1,51 @@ +-- Export a top-down plan of the PA area as JSON-ish text so it can be +-- plotted offline, and measure the median-barrier opening properly by +-- looking for gaps along the mainline centreline near each junction. +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") +local PA = Vector3.new(-4124, 46, -7470) +local RANGE = 2600 -- studs around the PA to export + +local buckets = { + EntryTrunk = {}, + EntryNear = {}, + ExitTrunk = {}, + ExitNear = {}, + Road = {}, + Barrier = {}, + Ring = {}, +} + +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local p = d.Position + if math.abs(p.X - PA.X) < RANGE and math.abs(p.Z - PA.Z) < RANGE then + local nm = d.Name + local key + if nm:sub(1, 18) == "PAEntryRampTrunk_" or nm:sub(1, 17) == "PAEntryRampTrunk_" then + key = "EntryTrunk" + elseif nm:sub(1, 16) == "PAEntryRampNear_" then + key = "EntryNear" + elseif nm:sub(1, 16) == "PAExitRampTrunk_" then + key = "ExitTrunk" + elseif nm:sub(1, 15) == "PAExitRampNear_" then + key = "ExitNear" + elseif nm:gsub("_?%d+$", "") == "Road" then + key = "Road" + elseif nm:gsub("_?%d+$", "") == "Barrier" then + key = "Barrier" + elseif nm:find("PARing") then + key = "Ring" + end + if key then + table.insert(buckets[key], ("%d,%d,%d"):format(math.floor(p.X), math.floor(p.Z), math.floor(p.Y))) + end + end + end +end + +local out = {} +for k, v in pairs(buckets) do + table.insert(out, "#" .. k .. "#" .. table.concat(v, ";")) +end +return table.concat(out, "\n") diff --git a/tools/measure_new_ramps.luau b/tools/measure_new_ramps.luau new file mode 100644 index 0000000..0bc9059 --- /dev/null +++ b/tools/measure_new_ramps.luau @@ -0,0 +1,110 @@ +-- Measure the redesigned ramps: extent, grade, side (E/W), and collisions +-- with STRUCTURAL parts (piers, masts, girders). Read-only. +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") +if not map then + return "no Map" +end + +local PA = Vector3.new(-4124, 46, -7470) + +-- Gather deck parts per run. +local runs = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name + for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do + if nm:sub(1, #base) == base then + runs[base] = runs[base] or {} + table.insert(runs[base], d) + end + end + end +end + +local out = {} +local order = { "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" } +for _, base in ipairs(order) do + local parts = runs[base] + if not parts then + table.insert(out, base .. ": MISSING") + else + local minY, maxY = math.huge, -math.huge + local minX, maxX = math.huge, -math.huge + local minZ, maxZ = math.huge, -math.huge + local maxGrade = 0 + table.sort(parts, function(a, b) + return a.Name < b.Name + end) + for _, p in ipairs(parts) do + local q = p.Position + minY, maxY = math.min(minY, q.Y), math.max(maxY, q.Y) + minX, maxX = math.min(minX, q.X), math.max(maxX, q.X) + minZ, maxZ = math.min(minZ, q.Z), math.max(maxZ, q.Z) + end + for i = 2, #parts do + local a, b = parts[i - 1].Position, parts[i].Position + local horiz = (Vector3.new(a.X, 0, a.Z) - Vector3.new(b.X, 0, b.Z)).Magnitude + if horiz > 0.5 then + maxGrade = math.max(maxGrade, math.abs(b.Y - a.Y) / horiz * 100) + end + end + -- mean X relative to PA centre tells us east (+) or west (-) + local sx = 0 + for _, p in ipairs(parts) do + sx += p.Position.X + end + local side = (sx / #parts) > PA.X and "EAST" or "WEST" + table.insert( + out, + ("%s: n=%d Y=%.0f..%.0f grade<=%.2f%% side=%s X=%.0f..%.0f Z=%.0f..%.0f"):format( + base, + #parts, + minY, + maxY, + maxGrade, + side, + minX, + maxX, + minZ, + maxZ + ) + ) + end +end + +-- Structural collision test: does any ramp deck part overlap a pier/mast? +local structural = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name + if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then + table.insert(structural, d) + end + end +end +table.insert(out, "structuralParts=" .. #structural) + +local hits = 0 +local detail = {} +for _, base in ipairs(order) do + local parts = runs[base] or {} + local h = 0 + for _, p in ipairs(parts) do + for _, s in ipairs(structural) do + local dx = math.abs(p.Position.X - s.Position.X) + local dz = math.abs(p.Position.Z - s.Position.Z) + local dy = math.abs(p.Position.Y - s.Position.Y) + local rx = (p.Size.X + s.Size.X) / 2 + local rz = (p.Size.Z + s.Size.Z) / 2 + local ry = (p.Size.Y + s.Size.Y) / 2 + if dx < rx and dz < rz and dy < ry then + h += 1 + end + end + end + hits += h + table.insert(detail, base .. "=" .. h) +end +table.insert(out, "STRUCTURAL_HITS total=" .. hits .. " (" .. table.concat(detail, " ") .. ")") +return table.concat(out, "\n") diff --git a/tools/median_correct.luau b/tools/median_correct.luau new file mode 100644 index 0000000..d857b19 --- /dev/null +++ b/tools/median_correct.luau @@ -0,0 +1,71 @@ +-- Median opening test, now against the CORRECT part name. The centre divider +-- is "Median_"; "Barrier" is the outer edge parapet (which must NOT open). +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") + +local medians, roads = {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local b = d.Name:gsub("_?%d+$", "") + if b == "Median" then + table.insert(medians, d.Position) + elseif b == "Road" then + table.insert(roads, d.Position) + end + end +end + +local anchors = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and (d.Name == "PAEntryRampNear_200" or d.Name == "PAExitRampNear_200") then + table.insert(anchors, { d.Name, d.Position }) + end +end + +local WIN = 220 +local function countNear(pos, list) + local n = 0 + for _, bp in ipairs(list) do + if (Vector3.new(pos.X, 0, pos.Z) - Vector3.new(bp.X, 0, bp.Z)).Magnitude < WIN then + n += 1 + end + end + return n +end + +local out = {} +table.insert(out, ("medianParts=%d roadParts=%d window=+/-%d"):format(#medians, #roads, WIN)) + +local ctrl = {} +local step = math.max(1, math.floor(#roads / 14)) +for i = 1, #roads, step do + local p = roads[i] + local far = true + for _, a in ipairs(anchors) do + if (Vector3.new(p.X, 0, p.Z) - Vector3.new(a[2].X, 0, a[2].Z)).Magnitude < 700 then + far = false + end + end + if far then + table.insert(ctrl, countNear(p, medians)) + end +end +table.sort(ctrl) +local sum = 0 +for _, v in ipairs(ctrl) do + sum += v +end +table.insert( + out, + ("CONTROL plain mainline (n=%d): min=%d median=%d max=%d mean=%.1f"):format( + #ctrl, + ctrl[1] or -1, + ctrl[math.max(1, math.floor(#ctrl / 2))] or -1, + ctrl[#ctrl] or -1, + #ctrl > 0 and sum / #ctrl or -1 + ) +) +for _, a in ipairs(anchors) do + table.insert(out, ("JUNCTION %s: mediansInWindow=%d"):format(a[1], countNear(a[2], medians))) +end +return table.concat(out, "\n") diff --git a/tools/probe_ramp_geo.luau b/tools/probe_ramp_geo.luau new file mode 100644 index 0000000..78f644e --- /dev/null +++ b/tools/probe_ramp_geo.luau @@ -0,0 +1,82 @@ +-- READ-ONLY geometry probe for the ramp redesign. Does NOT call MapBuilder.build(). +local rs = game:GetService("ReplicatedStorage") +local cm = rs:FindFirstChild("Shared") and rs.Shared:FindFirstChild("Circuit") +if not cm then + return "Circuit module not found at ReplicatedStorage.Shared.Circuit" +end +local Circuit = require(cm) +local c = Circuit.PA_CENTER +local R = Circuit.PA_RADIUS +local S = Circuit.samples +local out = {} +local function log(...) + table.insert(out, table.concat({ ... }, " ")) +end + +log( + ("PA_CENTER = %.0f, %.0f, %.0f PA_RADIUS = %.0f ringR = %.0f samples = %d"):format( + c.X, + c.Y, + c.Z, + R, + R + 130, + #S + ) +) + +-- Apron extent from the LIVE map (no rebuild). +local m = workspace:FindFirstChild("Map") +if m then + local f = m:FindFirstChild("PAFloor", true) + if f then + log( + ("PAFloor centre %.0f,%.0f size %.0f x %.0f -> X %.0f..%.0f Z %.0f..%.0f"):format( + f.Position.X, + f.Position.Z, + f.Size.X, + f.Size.Z, + f.Position.X - f.Size.X / 2, + f.Position.X + f.Size.X / 2, + f.Position.Z - f.Size.Z / 2, + f.Position.Z + f.Size.Z / 2 + ) + ) + end + -- Orientation anchors: which world direction the buildings sit in. + for _, n in ipairs({ "PAHonkan", "PANibankan" }) do + local b = m:FindFirstChild(n, true) + if b then + log( + ("%-12s at %.0f, %.0f (dX %+.0f, dZ %+.0f from PA centre)"):format( + n, + b.Position.X, + b.Position.Z, + b.Position.X - c.X, + b.Position.Z - c.Z + ) + ) + end + end +end + +-- Mainline samples near the PA: index, world pos, deck height, distance and +-- bearing from the PA centre. Bearing is atan2(dZ,dX) in degrees. +log("") +log("idx X Z deckY dist bearing note") +for i, s in ipairs(S) do + local dx, dz = s.pos.X - c.X, s.pos.Z - c.Z + local d = math.sqrt(dx * dx + dz * dz) + if d < 1000 then + local br = math.deg(math.atan2(dz, dx)) + if br < 0 then + br += 360 + end + local note = "" + if s.tunnel then + note = "TUNNEL" + end + log(("%4d %7.0f %7.0f %6.1f %6.0f %6.1f %s"):format(i, s.pos.X, s.pos.Z, s.pos.Y, d, br, note)) + end +end + +return table.concat(out, "\n") diff --git a/tools/rebuild_fresh.luau b/tools/rebuild_fresh.luau new file mode 100644 index 0000000..4a9d4ec --- /dev/null +++ b/tools/rebuild_fresh.luau @@ -0,0 +1,58 @@ +-- Rebuild using a CLONED module so Studio's require() cache is bypassed. +-- Without this, require() returns the version compiled at first-require, +-- which silently rebuilds the OLD map (symptom: PAOffRamp/PAOnRamp parts +-- that exist nowhere in the current source). +local sss = game:GetService("ServerScriptService") +local ss = game:GetService("ServerStorage") +local ws = game:GetService("Workspace") + +local realMod = sss:FindFirstChild("MapBuilder", true) +if not realMod then + return "MapBuilder not found" +end + +local bust = ss:FindFirstChild("_cachebust") +if not bust then + bust = Instance.new("Folder") + bust.Name = "_cachebust" + bust.Parent = ss +end +for _, ch in ipairs(bust:GetChildren()) do + ch:Destroy() +end + +local clone = realMod:Clone() +clone.Name = "MapBuilder_fresh_" .. tostring(math.random(1e6, 9e6)) +clone.Parent = bust + +local mb = require(clone) + +local old = ws:FindFirstChild("Map") +if old then + old:Destroy() +end + +local ok, err = pcall(function() + mb.build() +end) +if not ok then + return "BUILD ERROR: " .. tostring(err) +end + +local map = ws:FindFirstChild("Map") +if not map then + return "no Map after build" +end + +local total, entry, exit = 0, 0, 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + total += 1 + if d.Name:find("PAEntryRamp") then + entry += 1 + elseif d.Name:find("PAExitRamp") then + exit += 1 + end + end +end +return ("srcLen=%d parts=%d PAEntryRamp=%d PAExitRamp=%d"):format(#clone.Source, total, entry, exit) diff --git a/tools/triage_ramp_hits.luau b/tools/triage_ramp_hits.luau new file mode 100644 index 0000000..7b9e8a0 --- /dev/null +++ b/tools/triage_ramp_hits.luau @@ -0,0 +1,139 @@ +-- Triage the obstacle hits: WHAT is hit, on WHICH segments, and how far +-- through the run. Contact with the apron at the ring mouth and with the +-- carriageway at the merge is intended; contact with a pier is never. +-- SAFETY: MapBuilder.build() destroys Workspace.Map first. Stash the live map +-- aside and restore it afterwards -- see verify_shared_ramps.luau. +local ServerScriptService = game:GetService("ServerScriptService") +local live = workspace:FindFirstChild("Map") +local stash = nil +if live then + stash = Instance.new("Folder") + stash.Name = "__MAP_STASH__" + stash.Parent = ServerScriptService + live.Parent = stash +end +local function restoreLive() + if stash then + local held = stash:FindFirstChild("Map") + local rebuilt = workspace:FindFirstChild("Map") + if rebuilt then + rebuilt:Destroy() + end + if held then + held.Parent = workspace + end + stash:Destroy() + end +end + +local out = {} +local function log(...) + table.insert(out, table.concat({ ... }, " ")) +end + +local src = ServerScriptService:FindFirstChild("Server") +src = src and src:FindFirstChild("MapBuilder") or ServerScriptService:FindFirstChild("MapBuilder", true) +local MapBuilder = require(src:Clone()) +local map = MapBuilder.build and MapBuilder.build() or MapBuilder.Build() +map.Name = "TRIAGE_CLONE" +map.Parent = workspace + +local RUNS = { + "PAEntryRampTrunk", + "PAEntryRampNear", + "PAEntryRampCross", + "PAEntryRampMerge", + "PAExitRampTrunk", + "PAExitRampNear", + "PAExitRampCross", + "PAExitRampMerge", +} +local deckOf = {} +for _, r in ipairs(RUNS) do + deckOf[r] = {} +end +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + for _, r in ipairs(RUNS) do + local idx = d.Name:match("^" .. r .. "_(%d+)$") + if idx then + table.insert(deckOf[r], { i = tonumber(idx), p = d }) + end + end + end +end +for _, r in ipairs(RUNS) do + table.sort(deckOf[r], function(a, b) + return a.i < b.i + end) +end + +local function isRampPart(p) + local n = p.Name + if n:find("PARamp") or n:find("PARing") then + return true + end + for _, r in ipairs(RUNS) do + if n:sub(1, #r) == r then + return true + end + end + return false +end + +-- Normalise an obstacle name to its class (Road_537 -> Road_). +local function klass(n) + if n:sub(1, 5) == "Road_" then + return "Road_*" + end + return (n:gsub("_%d+$", "_*")) +end + +for _, r in ipairs(RUNS) do + local segs = deckOf[r] + local byClass = {} + for k, e in ipairs(segs) do + local params = OverlapParams.new() + params.FilterType = Enum.RaycastFilterType.Include + params.FilterDescendantsInstances = { map } + params.MaxParts = 60 + for _, o in ipairs(workspace:GetPartsInPart(e.p, params)) do + if o ~= e.p and not isRampPart(o) then + local c = klass(o.Name) + local rec = byClass[c] + if not rec then + rec = { lo = k, hi = k, n = 0 } + byClass[c] = rec + end + rec.lo = math.min(rec.lo, k) + rec.hi = math.max(rec.hi, k) + rec.n += 1 + end + end + end + local any = false + for c, rec in pairs(byClass) do + any = true + log( + string.format( + "%-18s %-16s segs %d..%d of %d (%.0f%%..%.0f%% along) %d contacts", + r, + c, + rec.lo, + rec.hi, + #segs, + rec.lo / #segs * 100, + rec.hi / #segs * 100, + rec.n + ) + ) + end + if not any then + log(string.format("%-18s CLEAN", r)) + end +end + +map:Destroy() +restoreLive() +log(string.format("live map restored: %s", tostring(workspace:FindFirstChild("Map") ~= nil))) +return table.concat(out, "\n") diff --git a/tools/verify_c1_fixes.luau b/tools/verify_c1_fixes.luau index 3660bb1..96de5ab 100644 --- a/tools/verify_c1_fixes.luau +++ b/tools/verify_c1_fixes.luau @@ -2,7 +2,9 @@ -- Asserts NUMBERS the build produced, not "the build ran". local sss = game:GetService("ServerScriptService") local orig = sss:FindFirstChild("MapBuilder", true) -if not orig then return "FAIL: MapBuilder not found" end +if not orig then + return "FAIL: MapBuilder not found" +end -- Freshness gate: prove we are measuring the NEW source before trusting anything. local fresh = {} @@ -54,14 +56,26 @@ for _, s in ipairs(Circuit.samples) do end end local girders = counts["BridgeGirder"] or 0 -line(("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format( - nBridgeSamples, girders, counts["PierCap"] or 0, counts["PierColumn"] or 0, - (girders >= nBridgeSamples * 0.5 and girders > 0) and "PASS" or "FAIL")) +line( + ("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format( + nBridgeSamples, + girders, + counts["PierCap"] or 0, + counts["PierColumn"] or 0, + (girders >= nBridgeSamples * 0.5 and girders > 0) and "PASS" or "FAIL" + ) +) -- FIX 2: Ginza overpasses with piers between the carriageways ---------------- local ov, pier = counts["GinzaOverpass"] or 0, counts["OverpassPier"] or 0 -line(("FIX2 Ginza 跨道橋: overpass=%d pier=%d parapet=%d -> %s"):format( - ov, pier, counts["OverpassParapet"] or 0, (ov > 0 and pier == ov) and "PASS" or "FAIL")) +line( + ("FIX2 Ginza 跨道橋: overpass=%d pier=%d parapet=%d -> %s"):format( + ov, + pier, + counts["OverpassParapet"] or 0, + (ov > 0 and pier == ov) and "PASS" or "FAIL" + ) +) -- Piers must actually sit BETWEEN the carriageways (near the centreline), -- not off to one side -- that is the whole point of the fix. @@ -79,8 +93,7 @@ for _, d in ipairs(map:GetDescendants()) do worstPier = math.max(worstPier, best) end end -line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format( - worstPier, worstPier < 6 and "PASS" or "FAIL")) +line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format(worstPier, worstPier < 6 and "PASS" or "FAIL")) -- FIX 3: 40 km/h plates in junctions ---------------------------------------- local n40, n50 = 0, 0 @@ -98,8 +111,7 @@ for _, d in ipairs(map:GetDescendants()) do end end end -line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format( - n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL")) +line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format(n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL")) -- FIX 4: Shiodome 3+3 widening ---------------------------------------------- local shioW, baseW, shioN, baseN = 0, 0, 0, 0 @@ -117,8 +129,13 @@ for i, s in ipairs(Circuit.samples) do end shioW = shioN > 0 and shioW / shioN or 0 baseW = baseN > 0 and baseW / baseN or 0 -line(("FIX4 deck width: Shiodome avg=%.1f Kasumigaseki avg=%.1f -> %s"):format( - shioW, baseW, (shioW > baseW + 8) and "PASS" or "FAIL")) +line( + ("FIX4 deck width: Shiodome avg=%.1f Kasumigaseki avg=%.1f -> %s"):format( + shioW, + baseW, + (shioW > baseW + 8) and "PASS" or "FAIL" + ) +) -- Lane boundary count: 3 lanes per side needs 2 boundary lines per side. local function boundariesNear(zone) @@ -156,8 +173,14 @@ for i = 1, n do holes += 1 end end -line(("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format( - n, holes, maxLip, (holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL")) +line( + ("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format( + n, + holes, + maxLip, + (holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL" + ) +) map:Destroy() clone:Destroy() diff --git a/tools/verify_c1_fixes2.luau b/tools/verify_c1_fixes2.luau index 1ebae51..7a0f40a 100644 --- a/tools/verify_c1_fixes2.luau +++ b/tools/verify_c1_fixes2.luau @@ -85,8 +85,7 @@ for i = 1, n do end end end -line(("TAPER max width step=%.2f studs at i=%d -> %s"):format( - maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL")) +line(("TAPER max width step=%.2f studs at i=%d -> %s"):format(maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL")) -- PIER LATERAL OFFSET ------------------------------------------------------- -- Perpendicular distance from each pier to the centreline polyline (XZ). @@ -113,8 +112,13 @@ for _, d in ipairs(map:GetDescendants()) do worstPier = math.max(worstPier, best) end end -line(("PIER lateral offset from centreline: n=%d worst=%.3f studs -> %s"):format( - nPier, worstPier, (nPier > 0 and worstPier < 3) and "PASS" or "FAIL")) +line( + ("PIER lateral offset from centreline: n=%d worst=%.3f studs -> %s"):format( + nPier, + worstPier, + (nPier > 0 and worstPier < 3) and "PASS" or "FAIL" + ) +) -- Girders must hang BELOW their deck, never poke through the driving surface. local bad, nG = 0, 0 diff --git a/tools/verify_c1_fixes3.luau b/tools/verify_c1_fixes3.luau index 5648d4d..3b021fa 100644 --- a/tools/verify_c1_fixes3.luau +++ b/tools/verify_c1_fixes3.luau @@ -82,8 +82,14 @@ local top = {} for k = 1, math.min(5, #lips) do top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece) end -line(("SEAM max lip=%.4f at i=%d (endpoint XZ err %.3f) -> %s"):format( - worst, worstAt, worstGap, worst <= 0.30 and "PASS" or "FAIL")) +line( + ("SEAM max lip=%.4f at i=%d (endpoint XZ err %.3f) -> %s"):format( + worst, + worstAt, + worstGap, + worst <= 0.30 and "PASS" or "FAIL" + ) +) line("SEAM top5: " .. table.concat(top, " ")) -- Independent cross-check: raycast down onto the driving surface just before @@ -112,8 +118,13 @@ for i = 1, n do end end end -line(("RAYCAST surface step across seams (+-3 studs): max=%.4f at i=%d -> %s"):format( - rWorst, rAt, rWorst <= 0.60 and "PASS" or "FAIL")) +line( + ("RAYCAST surface step across seams (+-3 studs): max=%.4f at i=%d -> %s"):format( + rWorst, + rAt, + rWorst <= 0.60 and "PASS" or "FAIL" + ) +) map:Destroy() clone:Destroy() diff --git a/tools/verify_merge.luau b/tools/verify_merge.luau new file mode 100644 index 0000000..5d79b0f --- /dev/null +++ b/tools/verify_merge.luau @@ -0,0 +1,71 @@ +-- Correct probe: mainline deck parts are named "Road". Barriers are found by +-- actual name, not a guess. Also verifies the ramp tip lands ON a Road part +-- (not merely near one) and that the median parapet really is open at the +-- junctions. +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") +local exp = map:FindFirstChild("Expressway") + +local road, runs = {}, {} +local barrierNames = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name + if nm:gsub("_?%d+$", "") == "Road" then + table.insert(road, d) + end + for _, base in ipairs({ "PAEntryRampNear", "PAExitRampNear", "PAEntryRampTrunk", "PAExitRampTrunk" }) do + if nm:sub(1, #base + 1) == base .. "_" then + runs[base] = runs[base] or {} + table.insert(runs[base], d) + end + end + if + nm:lower():find("barrier") + or nm:lower():find("parapet") + or nm:lower():find("wall") + or nm:lower():find("rail") + then + local b = nm:gsub("_?%d+$", "") + barrierNames[b] = (barrierNames[b] or 0) + 1 + end + end +end + +local out = {} +table.insert(out, "roadDeckParts=" .. #road) +local bn = {} +for k, v in pairs(barrierNames) do + table.insert(bn, k .. "=" .. v) +end +table.sort(bn) +table.insert(out, "barrier-ish: " .. table.concat(bn, " ")) + +local function sortKey(p) + return tonumber(p.Name:match("_(%d+)$")) or 0 +end + +-- Ramp tip -> nearest Road part, horizontal and vertical separately. +for _, base in ipairs({ "PAEntryRampNear", "PAExitRampNear" }) do + local parts = runs[base] + if not parts then + table.insert(out, base .. ": MISSING") + continue + end + table.sort(parts, function(a, b) + return sortKey(a) < sortKey(b) + end) + for _, which in ipairs({ { "head", parts[1] }, { "tail", parts[#parts] } }) do + local p = which[2] + local bestH, bestDY = math.huge, 0 + for _, m in ipairs(road) do + local h = (Vector3.new(p.Position.X, 0, p.Position.Z) - Vector3.new(m.Position.X, 0, m.Position.Z)).Magnitude + if h < bestH then + bestH = h + bestDY = p.Position.Y - m.Position.Y + end + end + table.insert(out, ("%s %s: horizToRoad=%.1f dY=%.1f"):format(base, which[1], bestH, bestDY)) + end +end +return table.concat(out, "\n") diff --git a/tools/verify_shared_ramps.luau b/tools/verify_shared_ramps.luau new file mode 100644 index 0000000..e2342b5 --- /dev/null +++ b/tools/verify_shared_ramps.luau @@ -0,0 +1,303 @@ +-- Verify the SHARED-TRUNK PA ramps as actually BUILT (not as solved offline). +-- +-- The offline solver + /tmp/verify_shared.py agreed on the numbers, but they +-- both worked from the parameter table. This measures the real Roblox parts +-- that MapBuilder emits, which is the only thing the player can hit. +-- +-- Runs on a CLONE parented to workspace so spatial queries work, with an +-- Include filter so the LIVE map can never contribute a false collision. +-- SAFETY (learned the hard way, 2026-08-03): MapBuilder.build() begins by +-- destroying Workspace.Map. A verifier that calls build() therefore DELETES +-- THE LIVE MAP as a side effect, and its own map:Destroy() at the end then +-- leaves the workspace with no map at all. Snapshot the live map aside first +-- and restore it in a finally-style block so the user's session survives even +-- if this script errors halfway through. +local ServerScriptService = game:GetService("ServerScriptService") +local live = workspace:FindFirstChild("Map") +local stash = nil +if live then + stash = Instance.new("Folder") + stash.Name = "__MAP_STASH__" + stash.Parent = ServerScriptService + live.Parent = stash -- reparent, do NOT clone: 24k parts +end +local function restoreLive() + if stash then + local held = stash:FindFirstChild("Map") + local rebuilt = workspace:FindFirstChild("Map") + if rebuilt then + rebuilt:Destroy() + end + if held then + held.Parent = workspace + end + stash:Destroy() + end +end + +local out = {} +local function log(...) + table.insert(out, table.concat({ ... }, " ")) +end +local pass, fail = 0, 0 +local function check(name, ok, detail) + if ok then + pass += 1 + log("PASS", name, detail or "") + else + fail += 1 + log("FAIL", name, detail or "") + end +end + +local src = ServerScriptService:FindFirstChild("Server") +src = src and src:FindFirstChild("MapBuilder") or ServerScriptService:FindFirstChild("MapBuilder", true) +if not src then + return "FATAL: MapBuilder module not found" +end +local MapBuilder = require(src:Clone()) +local map = MapBuilder.build and MapBuilder.build() or MapBuilder.Build() +map.Name = "VERIFY_CLONE" +map.Parent = workspace + +local RUNS = { + "PAEntryRampTrunk", + "PAEntryRampNear", + "PAEntryRampCross", + "PAEntryRampMerge", + "PAExitRampTrunk", + "PAExitRampNear", + "PAExitRampCross", + "PAExitRampMerge", +} + +-- Collect the deck parts of each run, in segment order. +local deckOf = {} +for _, r in ipairs(RUNS) do + deckOf[r] = {} +end +local everything = map:GetDescendants() +for _, d in ipairs(everything) do + if d:IsA("BasePart") then + for _, r in ipairs(RUNS) do + local idx = d.Name:match("^" .. r .. "_(%d+)$") + if idx then + table.insert(deckOf[r], { i = tonumber(idx), p = d }) + end + end + end +end +for _, r in ipairs(RUNS) do + table.sort(deckOf[r], function(a, b) + return a.i < b.i + end) +end + +log("=== 0. RUNS BUILT ===") +local allBuilt = true +for _, r in ipairs(RUNS) do + local n = #deckOf[r] + if n == 0 then + allBuilt = false + end + log(string.format(" %-20s %d segments", r, n)) +end +check("all 8 runs emitted", allBuilt) + +-- What counts as "our own structure" rather than an obstacle. +local function isRampPart(p) + local n = p.Name + if n:find("PARamp") or n:find("PARing") then + return true + end + for _, r in ipairs(RUNS) do + if n:sub(1, #r) == r then + return true + end + end + return false +end + +log("") +log("=== 1. SOLID-OBSTACLE HITS ===") +-- A run's first and last few segments are MOUTHS: they are supposed to touch +-- the ring deck / the mainline carriageway. Everything in between must be in +-- clear air. +local MOUTH = 6 +local totalHits = 0 +for _, r in ipairs(RUNS) do + local segs = deckOf[r] + local hits, sample = 0, nil + for k, e in ipairs(segs) do + if k > MOUTH and k <= #segs - MOUTH then + local params = OverlapParams.new() + params.FilterType = Enum.RaycastFilterType.Include + params.FilterDescendantsInstances = { map } + params.MaxParts = 40 + for _, o in ipairs(workspace:GetPartsInPart(e.p, params)) do + if o ~= e.p and not isRampPart(o) then + hits += 1 + if not sample then + sample = string.format("seg %d vs %s", k, o.Name) + end + end + end + end + end + totalHits += hits + check(string.format("%s clear of solid obstacles", r), hits == 0, sample or "0 hits") +end + +log("") +log("=== 2. GRADE ===") +-- Limit is 9%; the solve claimed <= 8.28% entry, <= 5.29% exit. +local LIMIT = 9.0 +for _, r in ipairs(RUNS) do + local segs = deckOf[r] + local worst, atK = 0, 0 + for k = 1, #segs - 1 do + local a, b = segs[k].p.Position, segs[k + 1].p.Position + local horiz = Vector3.new(b.X - a.X, 0, b.Z - a.Z).Magnitude + if horiz > 0.5 then + local g = math.abs(b.Y - a.Y) / horiz * 100 + if g > worst then + worst, atK = g, k + end + end + end + check( + string.format("%s grade <= %.0f%%", r, LIMIT), + worst <= LIMIT, + string.format("max %.2f%% at seg %d", worst, atK) + ) +end + +log("") +log("=== 3. UNDER-DECK CLEARANCE (cross runs) ===") +-- The crossing must fit between the ground and the girder soffit, with a car +-- on it. Measured against the ACTUAL mainline deck parts overhead, not against +-- the assumed profile. +local roadParts = {} +for _, d in ipairs(everything) do + if d:IsA("BasePart") and (d.Name:sub(1, 5) == "Road_" or d.Name:sub(1, 4) == "Deck") then + table.insert(roadParts, d) + end +end +local GIRDER = 8 +local CAR = 9 +for _, r in ipairs({ "PAEntryRampCross", "PAExitRampCross" }) do + local segs = deckOf[r] + local worst, overhead = math.huge, 0 + for _, e in ipairs(segs) do + local p = e.p.Position + for _, rp in ipairs(roadParts) do + local dx = math.abs(p.X - rp.Position.X) - rp.Size.X / 2 + local dz = math.abs(p.Z - rp.Position.Z) - rp.Size.Z / 2 + if dx < 20 and dz < 20 and rp.Position.Y > p.Y then + overhead += 1 + local gap = (rp.Position.Y - GIRDER) - (p.Y + 1) + worst = math.min(worst, gap) + end + end + end + if overhead == 0 then + check( + string.format("%s passes under the deck", r), + false, + "NO overhead deck found -- crossing may miss the mainline" + ) + else + check( + string.format("%s clears the soffit with a car (%d st)", r, CAR), + worst >= CAR, + string.format("min gap %.1f st over %d overhead samples", worst, overhead) + ) + end +end + +log("") +log("=== 4. MERGE ACCURACY ===") +-- The last segment of each slip must land ON its carriageway, not beside it. +local samples = {} +for _, d in ipairs(everything) do + if d:IsA("BasePart") and d.Name:sub(1, 5) == "Road_" then + table.insert(samples, d) + end +end +for _, r in ipairs({ "PAEntryRampNear", "PAEntryRampMerge", "PAExitRampNear", "PAExitRampMerge" }) do + local segs = deckOf[r] + if #segs > 0 then + local tip = segs[#segs].p.Position + local best, bestName = math.huge, "?" + for _, rp in ipairs(samples) do + local d = (Vector3.new(rp.Position.X, 0, rp.Position.Z) - Vector3.new(tip.X, 0, tip.Z)).Magnitude + if d < best then + best, bestName = d, rp.Name + end + end + -- Must be within half a carriageway of a road sample, and at its height. + local dy = math.huge + for _, rp in ipairs(samples) do + if rp.Name == bestName then + dy = math.abs(rp.Position.Y - tip.Y) + end + end + check( + string.format("%s lands on the carriageway", r), + best <= 40 and dy <= 6, + string.format("%.1f st from %s, dY %.1f", best, bestName, dy) + ) + end +end + +log("") +log("=== 5. TRUNK SEPARATION ===") +-- The two trunks spiral concentrically about the same centre; where they pass +-- close in plan they must be well apart vertically (32-stud decks). +local a, b = deckOf.PAEntryRampTrunk, deckOf.PAExitRampTrunk +local worstSep, closePairs = math.huge, 0 +for _, ea in ipairs(a) do + for _, eb in ipairs(b) do + local pa2, pb2 = ea.p.Position, eb.p.Position + local plan = Vector3.new(pa2.X - pb2.X, 0, pa2.Z - pb2.Z).Magnitude + if plan < 40 then + closePairs += 1 + worstSep = math.min(worstSep, math.abs(pa2.Y - pb2.Y)) + end + end +end +if closePairs == 0 then + check("trunks never pass within 40 st in plan", true, "0 close pairs -- no conflict") +else + check( + "trunks vertically separated where they pass close", + worstSep >= 20, + string.format("min %.1f st vertical over %d close pairs", worstSep, closePairs) + ) +end + +log("") +log("=== 6. PILLARS NOT IN THE EXPRESSWAY ===") +local badPillars, worstPil = 0, nil +for _, d in ipairs(everything) do + if d:IsA("BasePart") and d.Name == "PARampPillar" then + for _, rp in ipairs(roadParts) do + local dx = math.abs(d.Position.X - rp.Position.X) - rp.Size.X / 2 + local dz = math.abs(d.Position.Z - rp.Position.Z) - rp.Size.Z / 2 + if dx < 0 and dz < 0 then + badPillars += 1 + worstPil = worstPil + or string.format("pillar at %.0f,%.0f under %s", d.Position.X, d.Position.Z, rp.Name) + break + end + end + end +end +check("no ramp pillar planted in the mainline footprint", badPillars == 0, worstPil or "0") + +map:Destroy() +restoreLive() +log("") +log(string.format("RESULT %d passed, %d failed (obstacle hits total: %d)", pass, fail, totalHits)) +log(string.format("live map restored: %s", tostring(workspace:FindFirstChild("Map") ~= nil))) +return table.concat(out, "\n")