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:
@@ -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")
|
||||
|
||||
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 } } = {}
|
||||
|
||||
+10
-2
@@ -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]
|
||||
|
||||
@@ -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".
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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