Daikoku PA: two perimeter ramps, far side served by the median opening
Rework the PA junction to match the reference drawing: yellow = PA -> highway (entry), red = highway -> PA (exit). Entry and exit swap sides (entry now EAST, exit WEST) and both trunks hug the PA perimeter instead of running concentric spirals. The simplification: both carriageways sit at the same deck level, adjacent, so the median opening IS the far-side access path. The under-deck crossing and merge runs are deleted outright -- 8 runs collapse to 4, and the girder-soffit ceiling logic that constrained the crossing goes with them. Part count 24,210 -> 21,691. The median opening is now load-bearing rather than cosmetic (it is the only route from the far carriageway to the ramps), so it widens from 6 to 9 samples per junction. Orientation is a single flip-able constant (ORIENT) so a mirrored result is a one-line correction, not a rewrite. Measured on the built map (not asserted): - 600 entry + 600 exit ramp parts - max grade 8.00% / 8.27%, under the 9% limit - 0 collisions with piers/masts/girders (558 structural parts) - segment continuity <= 2.5 studs; trunk->merge handoff <= 1.7 studs - merge lands at dY 0.0 (exact deck level), 33 studs lateral - median open at junctions: 0 parts vs. control ~7 on plain mainline Two traps worth recording, both caught by controls: 1. Studio's require() cache silently rebuilt STALE code. The first "passing" build reported 17,575 parts and zero ramps, and the map contained PAOffRamp/PAOnRamp parts whose names exist nowhere in the current source. Every measurement taken before this was describing an old map. Added tools/rebuild_fresh.luau, which clones the module to bust the cache; use it rather than requiring MapBuilder directly. 2. A reported "median never opens" bug was a bad probe, not a defect. It counted `Barrier` (the outer edge parapet, which must NEVER open) instead of `Median_<i>`. Re-run against the right name: control 7, junctions 0. Both were only visible because the probes carry controls -- a 0-hit result with no control is vacuous. The verification harnesses are kept in tools/ with their controls built in.
This commit is contained in:
+449
-163
@@ -324,15 +324,168 @@ local function planPA()
|
|||||||
bestIdx, bestD = i, d
|
bestIdx, bestD = i, d
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- Entry ramp leaves the mainline before the PA, exit ramp rejoins after it.
|
|
||||||
local n = #samples
|
local n = #samples
|
||||||
local SPAN = 6 -- samples of median opening around each junction
|
|
||||||
local entryIdx = ((bestIdx - 14 - 1) % n) + 1
|
-- TWO PERIMETER RAMPS, FAR SIDE SERVED BY THE MEDIAN OPENING (2026-08-03,
|
||||||
local exitIdx = ((bestIdx + 14 - 1) % n) + 1
|
-- 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 = {}
|
local open = {}
|
||||||
for _, centreIdx in ipairs({ entryIdx, exitIdx }) do
|
for _, jd in ipairs(junctionDefs) do
|
||||||
for k = -SPAN, SPAN do
|
for k = -SPAN, SPAN do
|
||||||
open[((centreIdx + k - 1) % n) + 1] = true
|
open[((jd.idx + k - 1) % n) + 1] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- The OUTER edge barrier needs its own, much narrower opening -- and it is
|
-- 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.
|
-- and only right at the ramp mouth.
|
||||||
local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median
|
local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median
|
||||||
local rampOpen = {}
|
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
|
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
|
||||||
end
|
end
|
||||||
PAPlan = {
|
PAPlan = {
|
||||||
nearIdx = bestIdx,
|
nearIdx = bestIdx,
|
||||||
entryIdx = entryIdx,
|
-- Kept for anything still referencing the old two-ramp names; they now
|
||||||
exitIdx = exitIdx,
|
-- 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,
|
medianOpen = open,
|
||||||
rampOpen = rampOpen,
|
rampOpen = rampOpen,
|
||||||
|
rampOpenSide = rampOpenSide,
|
||||||
ringRadius = Circuit.PA_RADIUS + 130,
|
ringRadius = Circuit.PA_RADIUS + 130,
|
||||||
}
|
}
|
||||||
return PAPlan
|
return PAPlan
|
||||||
@@ -530,16 +695,16 @@ local function buildRoad(map: Folder)
|
|||||||
-- opening is a different, wider thing for a different purpose.
|
-- opening is a different, wider thing for a different purpose.
|
||||||
-- * only skip the ONE side the PA is actually on. The far edge of
|
-- * 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.
|
-- 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 junction = plan.rampOpen[i]
|
||||||
local paSide = 0
|
local paSide = junction and (plan.rampOpenSide[i] or 0) or 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
|
|
||||||
for _, side in ipairs({ -1, 1 }) do
|
for _, side in ipairs({ -1, 1 }) do
|
||||||
if junction and side == paSide then
|
if junction and side == paSide then
|
||||||
continue
|
continue
|
||||||
@@ -1162,6 +1327,50 @@ local function buildPA(map: Folder)
|
|||||||
local plan = planPA()
|
local plan = planPA()
|
||||||
local RING_R = plan.ringRadius
|
local RING_R = plan.ringRadius
|
||||||
local RING_W = 44
|
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 ARC_N = 72
|
||||||
|
|
||||||
local function deck(name, size, cframe, colour)
|
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_IN = 70 -- half-width of the apron-side opening
|
||||||
local GATE_OUT = 30 -- half-width of the ramp-side opening
|
local GATE_OUT = 30 -- half-width of the ramp-side opening
|
||||||
local junctions = {}
|
local junctions = {}
|
||||||
for _, j in ipairs({
|
-- FOUR junctions now, one per ramp, resolved in planPA (see the big note
|
||||||
{ idx = plan.entryIdx, name = "PAOnRamp" },
|
-- there). Each carries its own carriageway attach point, its own direction
|
||||||
{ idx = plan.exitIdx, name = "PAOffRamp" },
|
-- of travel and its own concentric radius; nothing is shared between the
|
||||||
}) do
|
-- two directions any more.
|
||||||
local road = samples[j.idx].pos
|
for _, jd in ipairs(plan.junctionDefs or {}) do
|
||||||
local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z)
|
local road = jd.nearPos
|
||||||
if toC.Magnitude >= 1 then
|
-- Turn direction, start angle and sweep are SOLVED in planPA now (they
|
||||||
local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R
|
-- are what place the fork point), so they are read here rather than
|
||||||
if (ringPt - road).Magnitude >= 4 then
|
-- re-derived. Re-deriving them was safe when a ramp just had to point
|
||||||
-- roadY: the mainline deck height AT this junction. The road is
|
-- the right way; with a fork feeding two carriageways, any
|
||||||
-- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb
|
-- disagreement between this block and the deck builder would put the
|
||||||
-- to whatever the sample actually sits at -- reading it here
|
-- parapet opening somewhere the ramp never arrives.
|
||||||
-- keeps ramp and road in lockstep if the lift profile changes.
|
local th0 = jd.az
|
||||||
--
|
local turn = jd.turn
|
||||||
-- heading: mainline direction of travel at this junction. The
|
local thEnd = th0 + turn * jd.sweep
|
||||||
-- helix ramp must leave the carriageway TANGENTIALLY, so it
|
table.insert(junctions, {
|
||||||
-- needs the direction, not just the two endpoints.
|
name = jd.name,
|
||||||
local nxt = samples[(j.idx % #samples) + 1].pos
|
road = road,
|
||||||
local fwd = Vector3.new(nxt.X - road.X, 0, nxt.Z - road.Z)
|
roadY = road.Y,
|
||||||
table.insert(junctions, {
|
idx = jd.idx,
|
||||||
name = j.name,
|
mergeIdx = jd.mergeIdx,
|
||||||
road = road,
|
side = jd.side,
|
||||||
ringPt = ringPt,
|
kind = jd.kind,
|
||||||
roadY = road.Y,
|
radius = jd.radius,
|
||||||
idx = j.idx,
|
turn = turn,
|
||||||
heading = math.atan2(fwd.Z, fwd.X),
|
th0 = th0,
|
||||||
})
|
sweep = jd.sweep,
|
||||||
end
|
yFork = jd.yFork,
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
local function nearJunction(pos, halfWidth)
|
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);
|
-- `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.
|
||||||
--
|
--
|
||||||
-- HELIX RAMPS (2026-08-03). These used to be STRAIGHT sloped decks: a
|
-- PA-CONCENTRIC LOOP RAMPS (2026-08-03, second pass).
|
||||||
-- 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).
|
-- The first helix attempt spiralled around a circle placed one radius
|
||||||
-- Japanese expressway ramps are held to ~6% maximum; 13% is a
|
-- perpendicular to the MAINLINE. That fixed the grade (13.6% -> ~2%) but
|
||||||
-- launch ramp, not a road.
|
-- produced the wrong shape, and the measurements said so plainly: the two
|
||||||
-- 2. SHAPE. Real Daikoku entry/exit ramps are HELIXES -- they spiral
|
-- ramps swung 414..1674 studs from the PA centre and covered only 60-75
|
||||||
-- down around a vertical axis. That is the single most recognisable
|
-- degrees of compass around it. They were long sweeping curves that
|
||||||
-- thing about the interchange and it was entirely absent.
|
-- 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
|
-- THE FIX: spiral concentric with THE PA CENTRE, not with the mainline.
|
||||||
-- interchange's curved ways, each fitted with a Kasa circle fit. The two
|
-- Each ramp holds a fixed radius about `c` and wraps ~300 degrees while
|
||||||
-- cleanest fits (rms < 1.5 m -- i.e. genuinely circular, not just curved)
|
-- descending, so it visibly circles the parking structure exactly like the
|
||||||
-- are 147 m and 124 m, which at M = 3.571 studs/m give 525 and 443 studs.
|
-- real Daikoku loop ramps.
|
||||||
-- See docs/daikoku-reference-spec.md for the full survey.
|
|
||||||
--
|
--
|
||||||
-- Construction: leave the mainline TANGENTIALLY (no step change in
|
-- Radii are NESTED (620 / 690 / 840 / 970 studs), which is what makes four
|
||||||
-- heading at speed), wrap the circle while descending, then run the
|
-- overlapping ramps safe: concentric circles of different radii cannot
|
||||||
-- tangent line from the circle to the ring gate. Solved numerically
|
-- intersect, so no pair can ever collide no matter how far they wrap. The
|
||||||
-- offline and verified collision-free against all 823 mainline samples
|
-- junctions are chosen (in planPA) at points where the carriageway is
|
||||||
-- (min horizontal clearance: 751 studs entry, 570 exit) and against the
|
-- ALREADY near its ramp's radius, so no ramp has to cut radially across a
|
||||||
-- PA ring itself (every helix point lies outside RING_R).
|
-- 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 LIFT = Circuit.PA_LIFT or 0
|
||||||
local M_PER_STUD = 3.571
|
local GIRDER_DEPTH = 8 -- deck soffit sits this far under the running surface
|
||||||
local HELIX_R = {
|
local CAR_H = 9
|
||||||
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 function ease(t)
|
||||||
local helixR = HELIX_R[jn.name]
|
if t < 0 then
|
||||||
local turn = HELIX_TURN[jn.name] or 1
|
t = 0
|
||||||
local yHi = jn.roadY or (ringY + LIFT)
|
elseif t > 1 then
|
||||||
local yLo = ringY
|
t = 1
|
||||||
|
end
|
||||||
|
return t * t * (3 - 2 * t)
|
||||||
|
end
|
||||||
|
|
||||||
-- Circle centre: perpendicular to the mainline heading, on the turn
|
-- Is `p` inside the mainline's horizontal footprint? Used to stop ramp
|
||||||
-- side, one radius away. This is what makes the join tangential.
|
-- pillars being planted in the expressway (or in its piers) where the
|
||||||
local nrm = jn.heading + turn * math.pi / 2
|
-- under-deck crossing passes beneath it.
|
||||||
local O = Vector3.new(jn.road.X + math.cos(nrm) * helixR, 0, jn.road.Z + math.sin(nrm) * helixR)
|
local function underMainline(p, pad)
|
||||||
|
for _, s in ipairs(samples) do
|
||||||
-- Tangent from the circle to the ring gate.
|
local dx, dz = p.X - s.pos.X, p.Z - s.pos.Z
|
||||||
local G = jn.ringPt
|
if dx * dx + dz * dz < (ROAD_WIDTH / 2 + pad) ^ 2 then
|
||||||
local dGO = Vector3.new(G.X - O.X, 0, G.Z - O.Z)
|
return true
|
||||||
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
|
||||||
end
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Ease the descent at both ends so a car doesn't nose into the
|
-- One emitter for every run of ramp deck. Takes a plain list of world
|
||||||
-- transition at the top or bottom out at the bottom.
|
-- points (the vertical profile is already baked in) plus flags for which
|
||||||
local function ease(t)
|
-- ends are mouths that must stay open.
|
||||||
return t * t * (3 - 2 * t)
|
--
|
||||||
end
|
-- Previously the deck, pillars, kerbs and chevrons were written inline in
|
||||||
|
-- a single spiral loop. There are now FOUR runs per ramp (trunk, near
|
||||||
for s = 1, #pathPts - 1 do
|
-- slip, under-deck crossing, far merge), so that code has to be shared or
|
||||||
local A, B = pathPts[s], pathPts[s + 1]
|
-- the four runs drift apart in appearance and in kerb behaviour.
|
||||||
local y0 = yHi + (yLo - yHi) * ease(A.t)
|
local function emitRun(runName, pts, openHead, openTail, wantChevrons)
|
||||||
local y1 = yHi + (yLo - yHi) * ease(B.t)
|
for s = 1, #pts - 1 do
|
||||||
local a = Vector3.new(A.pos.X, y0, A.pos.Z)
|
local a, b = pts[s], pts[s + 1]
|
||||||
local b = Vector3.new(B.pos.X, y1, B.pos.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 dck = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
|
local dck = deck(runName, Vector3.new(RAMP_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
|
||||||
dck.Name = jn.name .. "_" .. s
|
dck.Name = runName .. "_" .. 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.
|
||||||
@@ -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 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 baseY = overApron and (ringY - 1) or -1
|
||||||
local h = segMid.Y - baseY
|
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({
|
local pil = part({
|
||||||
Name = "PARampPillar",
|
Name = "PARampPillar",
|
||||||
-- Cylinder axis is local X, so Size.X is the height and
|
-- 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.
|
-- 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,
|
-- 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).
|
-- 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
|
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)
|
||||||
@@ -1550,8 +1745,8 @@ local function buildPA(map: Folder)
|
|||||||
Name = "PARampKerb",
|
Name = "PARampKerb",
|
||||||
Size = Vector3.new(1.6, 3.2, segLen),
|
Size = Vector3.new(1.6, 3.2, segLen),
|
||||||
CFrame = CFrame.lookAt(
|
CFrame = CFrame.lookAt(
|
||||||
segMid + rightV * (sg * RING_W / 2),
|
segMid + rightV * (sg * RAMP_W / 2),
|
||||||
segMid + rightV * (sg * RING_W / 2) + (b - a).Unit,
|
segMid + rightV * (sg * RAMP_W / 2) + (b - a).Unit,
|
||||||
UP
|
UP
|
||||||
) * CFrame.new(0, 2.6, 0),
|
) * CFrame.new(0, 2.6, 0),
|
||||||
Color = C_PARAPET,
|
Color = C_PARAPET,
|
||||||
@@ -1577,8 +1772,8 @@ local function buildPA(map: Folder)
|
|||||||
-- segment stands in for the diagonal hatching.
|
-- segment stands in for the diagonal hatching.
|
||||||
--
|
--
|
||||||
-- Outward side of the curve. Taking the raw radial
|
-- Outward side of the curve. Taking the raw radial
|
||||||
-- (segMid - O) as the offset direction is WRONG on the
|
-- (segMid - c) as the offset direction is WRONG on the
|
||||||
-- straight tangent tail: there the radial is no longer
|
-- landing peel: there the radial is no longer
|
||||||
-- perpendicular to travel, so panels skew along the road
|
-- perpendicular to travel, so panels skew along the road
|
||||||
-- (measured: up to 33 studs of along-travel error over the
|
-- (measured: up to 33 studs of along-travel error over the
|
||||||
-- last ~9 segments, walking the panel line diagonally
|
-- 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
|
-- Offset along the true perpendicular `rightV` always, and
|
||||||
-- use the radial ONLY to choose which side is outboard.
|
-- use the radial ONLY to choose which side is outboard.
|
||||||
local radial = Vector3.new(segMid.X - O.X, 0, segMid.Z - O.Z)
|
-- The ramps spiral concentrically about the PA centre, so
|
||||||
if radial.Magnitude > 0.01 and rightV.Magnitude > 0.01 then
|
-- `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 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({
|
local chev = part({
|
||||||
Name = "PARampChevron",
|
Name = "PARampChevron",
|
||||||
Size = Vector3.new(0.5, 2.0, segLen),
|
Size = Vector3.new(0.5, 2.0, segLen),
|
||||||
@@ -1602,10 +1800,98 @@ local function buildPA(map: Folder)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SHARED TRUNK + FORK, one pair of slips per ramp -------------------------
|
||||||
|
-- Built from the SOLVED parameters in planPA. Each of the four runs below
|
||||||
|
-- is generated by exactly the same arithmetic the offline solver and the
|
||||||
|
-- independent verifier used, so what drives here is what was measured:
|
||||||
|
-- trunk ring -> fork, concentric spiral about the PA centre, climbing
|
||||||
|
-- apron level -> yFork
|
||||||
|
-- near fork -> near carriageway, descending to the LOCAL deck height
|
||||||
|
-- cross fork -> emerge, held FLAT at yc so it passes under the mainline
|
||||||
|
-- merge emerge -> far carriageway, climbing to the LOCAL deck height
|
||||||
|
--
|
||||||
|
-- Sample counts match the verifier (401 on the trunk, 201 on the slips) so
|
||||||
|
-- the built centreline IS the verified centreline, not a re-approximation
|
||||||
|
-- of it at some other resolution.
|
||||||
|
local APRON_Y = c.Y + 1
|
||||||
|
local LANE_OFF = ROAD_WIDTH / 4
|
||||||
|
local nS = #samples
|
||||||
|
|
||||||
|
-- Centre of one carriageway at sample i. Must match planPA's
|
||||||
|
-- carriagewayPoint exactly -- if these two disagree the slip lands beside
|
||||||
|
-- the road instead of on it.
|
||||||
|
local function carriagewayPoint(i, side)
|
||||||
|
local s = samples[i].pos
|
||||||
|
local nxt = samples[(i % nS) + 1].pos
|
||||||
|
local hh = math.atan2(nxt.Z - s.Z, nxt.X - s.X)
|
||||||
|
return Vector3.new(s.X - math.sin(hh) * LANE_OFF * side, s.Y, s.Z + math.cos(hh) * LANE_OFF * side)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, jn in ipairs(junctions) do
|
||||||
|
local rampR = jn.radius
|
||||||
|
local yf = jn.yFork
|
||||||
|
local ns = jn.nearSide
|
||||||
|
local mergeIdx = jn.mergeIdx
|
||||||
|
-- ACTUAL local deck height at the merge, not a constant 102. The
|
||||||
|
-- mainline is a real profile; the exit merge sits at 96.6.
|
||||||
|
local yd = samples[mergeIdx].pos.Y
|
||||||
|
|
||||||
|
-- The under-deck crossing is gone, so the girder-soffit ceiling that
|
||||||
|
-- constrained it is no longer needed here.
|
||||||
|
|
||||||
|
-- 1. TRUNK -- perimeter spiral, ring gate out to the fork point. The
|
||||||
|
-- radius eases across the WHOLE sweep (previously it snapped out over
|
||||||
|
-- the first 22% and then held a constant circle), so the ramp hugs
|
||||||
|
-- the PA edge and flares out to meet the mainline -- the shape in the
|
||||||
|
-- drawing. Climb is spread over the full run too: 50 studs across a
|
||||||
|
-- ~900-stud arc is ~5.5%, well inside the 9% limit, so there is no
|
||||||
|
-- reason to front-load it.
|
||||||
|
local trunk = {}
|
||||||
|
for s = 0, 400 do
|
||||||
|
local f = s / 400
|
||||||
|
local th = jn.th0 + jn.turn * jn.sweep * f
|
||||||
|
local r = RING_R + (rampR - RING_R) * ease(f)
|
||||||
|
local y = APRON_Y + (yf - APRON_Y) * ease(f)
|
||||||
|
table.insert(trunk, Vector3.new(c.X + math.cos(th) * r, y, c.Z + math.sin(th) * r))
|
||||||
|
end
|
||||||
|
local fork = trunk[#trunk]
|
||||||
|
|
||||||
|
-- 2. SLIP -- fork to the carriageway on the PA side, at deck level.
|
||||||
|
-- There is only ONE slip now. The far carriageway is reached through
|
||||||
|
-- the gap in the centre divider (see `open` in planPA), which is what
|
||||||
|
-- the user's drawing is about: "this is why the middle
|
||||||
|
-- barriers/dividers are important because each side will have access
|
||||||
|
-- to on and off ramps". Both carriageways sit at the same deck height
|
||||||
|
-- and are directly adjacent, so a second slip diving under the
|
||||||
|
-- expressway was solving a problem that does not exist.
|
||||||
|
local tgtNear = carriagewayPoint(mergeIdx, ns)
|
||||||
|
local near = {}
|
||||||
|
for s = 0, 200 do
|
||||||
|
local f = s / 200
|
||||||
|
table.insert(
|
||||||
|
near,
|
||||||
|
Vector3.new(
|
||||||
|
fork.X + (tgtNear.X - fork.X) * f,
|
||||||
|
yf + (yd - yf) * ease(f),
|
||||||
|
fork.Z + (tgtNear.Z - fork.Z) * f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- An on-ramp is this same geometry driven the other way -- the deck is
|
||||||
|
-- undirected, so only which ends are MOUTHS matters, and every run
|
||||||
|
-- here is open at both ends (ring gate / fork, fork / carriageway).
|
||||||
|
-- Chevrons go on the trunk only: that is the curved helix they exist
|
||||||
|
-- to make legible, and on the straight slip they would just be a red
|
||||||
|
-- wall alongside the merge.
|
||||||
|
emitRun(jn.name .. "Trunk", trunk, true, true, true)
|
||||||
|
emitRun(jn.name .. "Near", near, true, true, false)
|
||||||
|
|
||||||
-- Flare the ramp mouth across the outer gate so the opening in the
|
-- 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
|
-- ring parapet is fully backed by deck -- the trunk meets the ring at
|
||||||
-- tangent and would otherwise leave triangular gaps at the corners.
|
-- 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)
|
local toRing = Vector3.new(jn.ringPt.X - jn.road.X, 0, jn.ringPt.Z - jn.road.Z)
|
||||||
if toRing.Magnitude > 1 then
|
if toRing.Magnitude > 1 then
|
||||||
local flareCF = CFrame.lookAt(
|
local flareCF = CFrame.lookAt(
|
||||||
@@ -1613,7 +1899,7 @@ local function buildPA(map: Folder)
|
|||||||
Vector3.new(jn.road.X, ringY, jn.road.Z),
|
Vector3.new(jn.road.X, ringY, jn.road.Z),
|
||||||
UP
|
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)
|
flare.Color = Color3.fromRGB(38, 38, 44)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -27,11 +27,13 @@ local road = findFirst("Road")
|
|||||||
local struct = findFirst("Structure") or findFirst("Struct")
|
local struct = findFirst("Structure") or findFirst("Struct")
|
||||||
|
|
||||||
say("== CONTAINERS ==")
|
say("== CONTAINERS ==")
|
||||||
say(("pa=%s road=%s struct=%s"):format(
|
say(
|
||||||
pa and pa:GetFullName() or "NIL",
|
("pa=%s road=%s struct=%s"):format(
|
||||||
road and road:GetFullName() or "NIL",
|
pa and pa:GetFullName() or "NIL",
|
||||||
struct and struct:GetFullName() or "NIL"
|
road and road:GetFullName() or "NIL",
|
||||||
))
|
struct and struct:GetFullName() or "NIL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
-- Gather parts by name pattern anywhere under Workspace ----------------------
|
-- Gather parts by name pattern anywhere under Workspace ----------------------
|
||||||
local buckets: { [string]: { BasePart } } = {}
|
local buckets: { [string]: { BasePart } } = {}
|
||||||
|
|||||||
+10
-2
@@ -42,7 +42,12 @@ for i = 1, n do
|
|||||||
end
|
end
|
||||||
if d > 1 then
|
if d > 1 then
|
||||||
hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format(
|
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
|
||||||
end
|
end
|
||||||
local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly)
|
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] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll)
|
||||||
out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format(
|
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)
|
out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits)
|
||||||
for k = 1, math.min(12, #hits) do
|
for k = 1, math.min(12, #hits) do
|
||||||
out[#out + 1] = " " .. hits[k]
|
out[#out + 1] = " " .. hits[k]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Median opening test, now against the CORRECT part name. The centre divider
|
||||||
|
-- is "Median_<i>"; "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")
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
+37
-14
@@ -2,7 +2,9 @@
|
|||||||
-- Asserts NUMBERS the build produced, not "the build ran".
|
-- Asserts NUMBERS the build produced, not "the build ran".
|
||||||
local sss = game:GetService("ServerScriptService")
|
local sss = game:GetService("ServerScriptService")
|
||||||
local orig = sss:FindFirstChild("MapBuilder", true)
|
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.
|
-- Freshness gate: prove we are measuring the NEW source before trusting anything.
|
||||||
local fresh = {}
|
local fresh = {}
|
||||||
@@ -54,14 +56,26 @@ for _, s in ipairs(Circuit.samples) do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local girders = counts["BridgeGirder"] or 0
|
local girders = counts["BridgeGirder"] or 0
|
||||||
line(("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format(
|
line(
|
||||||
nBridgeSamples, girders, counts["PierCap"] or 0, counts["PierColumn"] or 0,
|
("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format(
|
||||||
(girders >= nBridgeSamples * 0.5 and girders > 0) and "PASS" or "FAIL"))
|
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 ----------------
|
-- FIX 2: Ginza overpasses with piers between the carriageways ----------------
|
||||||
local ov, pier = counts["GinzaOverpass"] or 0, counts["OverpassPier"] or 0
|
local ov, pier = counts["GinzaOverpass"] or 0, counts["OverpassPier"] or 0
|
||||||
line(("FIX2 Ginza 跨道橋: overpass=%d pier=%d parapet=%d -> %s"):format(
|
line(
|
||||||
ov, pier, counts["OverpassParapet"] or 0, (ov > 0 and pier == ov) and "PASS" or "FAIL"))
|
("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),
|
-- Piers must actually sit BETWEEN the carriageways (near the centreline),
|
||||||
-- not off to one side -- that is the whole point of the fix.
|
-- 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)
|
worstPier = math.max(worstPier, best)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format(
|
line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format(worstPier, worstPier < 6 and "PASS" or "FAIL"))
|
||||||
worstPier, worstPier < 6 and "PASS" or "FAIL"))
|
|
||||||
|
|
||||||
-- FIX 3: 40 km/h plates in junctions ----------------------------------------
|
-- FIX 3: 40 km/h plates in junctions ----------------------------------------
|
||||||
local n40, n50 = 0, 0
|
local n40, n50 = 0, 0
|
||||||
@@ -98,8 +111,7 @@ for _, d in ipairs(map:GetDescendants()) do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format(
|
line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format(n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL"))
|
||||||
n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL"))
|
|
||||||
|
|
||||||
-- FIX 4: Shiodome 3+3 widening ----------------------------------------------
|
-- FIX 4: Shiodome 3+3 widening ----------------------------------------------
|
||||||
local shioW, baseW, shioN, baseN = 0, 0, 0, 0
|
local shioW, baseW, shioN, baseN = 0, 0, 0, 0
|
||||||
@@ -117,8 +129,13 @@ for i, s in ipairs(Circuit.samples) do
|
|||||||
end
|
end
|
||||||
shioW = shioN > 0 and shioW / shioN or 0
|
shioW = shioN > 0 and shioW / shioN or 0
|
||||||
baseW = baseN > 0 and baseW / baseN or 0
|
baseW = baseN > 0 and baseW / baseN or 0
|
||||||
line(("FIX4 deck width: Shiodome avg=%.1f Kasumigaseki avg=%.1f -> %s"):format(
|
line(
|
||||||
shioW, baseW, (shioW > baseW + 8) and "PASS" or "FAIL"))
|
("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.
|
-- Lane boundary count: 3 lanes per side needs 2 boundary lines per side.
|
||||||
local function boundariesNear(zone)
|
local function boundariesNear(zone)
|
||||||
@@ -156,8 +173,14 @@ for i = 1, n do
|
|||||||
holes += 1
|
holes += 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format(
|
line(
|
||||||
n, holes, maxLip, (holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL"))
|
("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format(
|
||||||
|
n,
|
||||||
|
holes,
|
||||||
|
maxLip,
|
||||||
|
(holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
map:Destroy()
|
map:Destroy()
|
||||||
clone:Destroy()
|
clone:Destroy()
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ for i = 1, n do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("TAPER max width step=%.2f studs at i=%d -> %s"):format(
|
line(("TAPER max width step=%.2f studs at i=%d -> %s"):format(maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL"))
|
||||||
maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL"))
|
|
||||||
|
|
||||||
-- PIER LATERAL OFFSET -------------------------------------------------------
|
-- PIER LATERAL OFFSET -------------------------------------------------------
|
||||||
-- Perpendicular distance from each pier to the centreline polyline (XZ).
|
-- 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)
|
worstPier = math.max(worstPier, best)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("PIER lateral offset from centreline: n=%d worst=%.3f studs -> %s"):format(
|
line(
|
||||||
nPier, worstPier, (nPier > 0 and worstPier < 3) and "PASS" or "FAIL"))
|
("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.
|
-- Girders must hang BELOW their deck, never poke through the driving surface.
|
||||||
local bad, nG = 0, 0
|
local bad, nG = 0, 0
|
||||||
|
|||||||
@@ -82,8 +82,14 @@ local top = {}
|
|||||||
for k = 1, math.min(5, #lips) do
|
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)
|
top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece)
|
||||||
end
|
end
|
||||||
line(("SEAM max lip=%.4f at i=%d (endpoint XZ err %.3f) -> %s"):format(
|
line(
|
||||||
worst, worstAt, worstGap, worst <= 0.30 and "PASS" or "FAIL"))
|
("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, " "))
|
line("SEAM top5: " .. table.concat(top, " "))
|
||||||
|
|
||||||
-- Independent cross-check: raycast down onto the driving surface just before
|
-- Independent cross-check: raycast down onto the driving surface just before
|
||||||
@@ -112,8 +118,13 @@ for i = 1, n do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
line(("RAYCAST surface step across seams (+-3 studs): max=%.4f at i=%d -> %s"):format(
|
line(
|
||||||
rWorst, rAt, rWorst <= 0.60 and "PASS" or "FAIL"))
|
("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()
|
map:Destroy()
|
||||||
clone:Destroy()
|
clone:Destroy()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user