Disable PA on/off ramps; restore mainline to pre-ramp state

Three iterations of the Daikoku PA ramps never read as a real
interchange. Measured why, rather than tuning further:

  * the entry and exit decks INTERSECTED -- 2547 sample pairs within
    30 studs horizontally at <12 studs dY, worst case 0.5 studs apart
    at dY=0.0. On screen that is the bowtie tangle of white rails.
  * both real Daikoku ramps turn the SAME way (clockwise; the guide
    video says "right, right"). This build mirrored them into each
    other.
  * the real arc wraps OUTSIDE the lot and forms its north/east
    boundary. This build cut across the apron interior.

Gated behind BUILD_PA_RAMPS=false rather than deleted -- the radius
was already correct (470 studs vs an OSM-measured mean of 469) and
the merge lands tangent at 0.1-0.2 deg divergence. Both worth keeping.

Also removed two leftovers that would have outlived the ramps:

  * rampOpen punched holes in the mainline's outer parapet, railing
    and kill wall for ramps that no longer exist -- a barrier gap with
    a drop behind it. Sealed via RAMP_EDGE_OPENINGS=false. Verified:
    Barrier/EdgeRail/KillWall = 1646 each = 823 samples x 2 edges,
    0 road samples without a guard within 90 studs.
  * gore zebra hatching painted 1024 bars + 64 boundary lines marking
    a junction that is not there. Sealed via RAMP_GORE_MARKINGS=false.

Verified after a cache-busting fresh rebuild (Studio's require() cache
otherwise serves the previous map and reports a false pass):
  parts 22628 -> 15988
  all ramp part classes = 0
  Road = 823 (unchanged), spawn pad + PASpawn intact
  clip probe TRUSTWORTHY (control +11/-0), CLIPPING=0

Research and rebuild spec kept for when this is revisited:
reference/ramps/BUILD_SPEC.md, INDEX.md (10 CC-licensed Commons
photos), docs/daikoku-reference-spec.md (OSM circle fits).
This commit is contained in:
2026-08-03 21:15:25 -04:00
parent 1d23c187ad
commit c297b20aed
34 changed files with 2206 additions and 46 deletions
+64
View File
@@ -0,0 +1,64 @@
# PA Ramp Build Spec — reconciled
Written 2026-08-03 after research + measurement. Supersedes ad-hoc azimuth
tuning in `MapBuilder.luau`.
## Source conflict — READ THIS FIRST
Two sources disagree, and the disagreement matters:
| | Generic standard (AASHTO/FHWA/JP, via research) | Measured Daikoku (OSM circle fits) |
|---|---|---|
| Ramp form | short shallow **parallel slip road** | **loop helix**, 150–220° of turn |
| Radius | 80–120 m (285–430 studs) | 71–204 m, mean 131 m (**469 studs**) |
| Arc | 30–45° | 217° (main helix, rms 1.2 = truly circular) |
**Resolution: measured Daikoku wins for THIS build.** The generic spec describes
a typical roadside PA on a flat ground-level expressway. Daikoku PA is not that
— it sits at the bottom of a **five-level junction stack** on reclaimed land, so
its ramps are genuinely helical loops, not slip roads. The satellite plan view
(`aerial/daikoku_z19.jpg`) shows one large-radius arc sweeping around the lot.
The generic spec is still authoritative for the **merge detail** (below), which
is not Daikoku-specific.
## Numbers to build to
| Quantity | Value | Source |
|---|---|---|
| Scale | 3.571 studs/m (1 stud = 0.28 m) | project |
| Main entry helix radius | **525 studs** (147 m) | OSM way 32403901, rms 1.2 |
| Secondary helix radius | **440 studs** (124 m) | OSM way 779045459, rms 0.7 |
| Tight connector radius | 255 studs (71 m) | OSM way 32355896 |
| Turn direction | **clockwise (right) — BOTH ramps** | 6 of 8 OSM ways; video 「右、右」 |
| Divergence at merge | 2–5° (current build: 0.1–0.2°, tangent) | AASHTO/FHWA |
| Deceleration lane | 150–200 m before gore | AASHTO |
| Acceleration lane | 200–300 m after gore | AASHTO |
| Gore apex angle | < 5°, chevron-painted over 30–50 m | AASHTO |
| Superelevation | 4–8% bank on curves | AASHTO |
| Deck-to-deck spacing | 36–43 studs/level (10–12 m) | JP standard |
## Hard constraints (checkable in a probe)
1. **Entry and exit ramps must NEVER intersect.** Current build fails this:
2547 point-pairs within 30 studs planar at < 12 studs dY, worst case
**0.5 studs apart at dY = 0.0**. This is the dominant visual defect —
it reads as a bowtie tangle. Probe must assert 0 such pairs.
2. Both ramps curve **the same direction** (clockwise). They are at opposite
ends of the lot and wrap the same way; they do not mirror into each other.
3. Ramp arc wraps **outside** the parking apron, forming its boundary — it does
not cut across the lot interior.
4. Merge is tangent (already achieved: 0.1–0.2°).
5. No ramp part intersects any Workspace-level building model (obstacle scope
must be INVERTED, not a name whitelist — see verification skill).
## Edge / structure treatment (from onboard video + Commons photos)
- Edge = **low concrete jersey base + see-through chain-link mesh fence above**.
NOT a tall solid parapet. Current build's heavy kerb chain is why the ramps
read as thick white rails.
- **Red/orange chevron shoulder paint** through every ramp curve — present in
all five reference videos, day and night. Cheapest high-value detail.
- Piers: **large cylindrical columns**, widely spaced, under a **trapezoidal
box-girder** deck underside. White/pale concrete.
- White zebra hatching fills the gore wedge.
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+38
View File
@@ -0,0 +1,38 @@
# PA Ramp Reference Images
Downloaded 2026-08-03. All files verified present on disk and integrity-checked
with PIL (12/12 valid, 0 corrupt).
## Wikimedia Commons (fetched by research agent)
| File | Px | Shows | License |
|---|---|---|---|
| `Daikoku-PA.JPG` | 2487x1797 | Daikoku PA overview | CC BY 3.0 |
| `Daikoku-JCT.JPG` | 2560x1920 | Daikoku JCT elevated stack | CC BY 3.0 |
| `DaikokuJCT-Bnishi.JPG` | 640x480 | Daikoku JCT bayside west | CC BY-SA 3.0 |
| `Shutoko_daikokuPA.jpg` | 1280x960 | Daikoku PA elevated structure | CC BY-SA 3.0 |
| `Shutoko_oiPA_east.jpg` | 1280x960 | Oi PA east | CC BY-SA 3.0 |
| `Shutoko_oiPA_west.jpg` | 1280x960 | Oi PA west (ground level, facility bldg) | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_up.jpg` | 1280x960 | Heiwajima PA inbound | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_down.jpg` | 1280x960 | Heiwajima PA outbound | CC BY-SA 3.0 |
| `Shutoko_shibauraPA.jpg` | 1280x960 | Shibaura PA | CC BY-SA 3.0 |
| `Shuto_expressway_shibaura_pa.jpg` | 1000x680 | Shibaura PA / expressway | CC BY-SA 3.0 |
License note: all CC BY / CC BY-SA. Reference-only for shape study. Do NOT
trace directly into shipped assets without attribution (see
`reference-image-research` skill, license matrix).
## From project NAS archive (higher value for geometry)
| File | Px | Shows |
|---|---|---|
| `aerial/daikoku_z19.jpg` | Esri z19 | **Best plan view.** Satellite of Daikoku PA: single large-radius arc wrapping the lot |
| `aerial/daikoku_bldg_big.jpg` | | Daikoku facility building aerial |
| `daikoku_jct_sheet2.jpg` | 1728x972 | Onboard contact sheet, Daikoku JCT |
| `exit_to_baybridge_sheet2.jpg` | 1728x1296 | **Best ground view.** Off-ramp divergence, gore hatching, jersey+mesh edge, box girder |
## Measured source of truth
`docs/daikoku-reference-spec.md` — OSM Overpass geometry with Kasa circle fits
of the actual ramp ways. This is measured, not estimated, and outranks generic
standards for Daikoku-specific radii.
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -0,0 +1,160 @@
# Daikoku Futo PA — Measured Reference Spec
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
frame-sampled onboard video.
Generated 2026-08-03.
---
## 1. Scale
| Quantity | Value |
|---|---|
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
| Reciprocal | 1 stud = 0.28 m |
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
> geometry. PA work uses 3.571.
---
## 2. The real PA footprint (OSM)
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
`35.46246657, 139.68149001` (user's Google Earth point).
| OSM way | Type | Extent (m) | Perimeter | Name |
|---|---|---|---|---|
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
apron extent is not the problem.
The *shape* is the divergence: the build is an axis-aligned square; the real
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
---
## 3. Loop ramp radii — MEASURED
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
`rms` = fit residual (low = genuinely circular).
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|---|---|---|---|---|---|---|
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
**Headline numbers**
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
- The two cleanest circular fits (rms < 1.5) are the true helixes:
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
`右、右と覚えておくとPAに着きます` — "remember right, right".
**Recommended build values**
| Ramp | Radius (m) | Radius (studs) | Turn |
|---|---|---|---|
| Main entry helix | 147 | **525** | ~220° CW |
| Secondary helix | 124 | **440** | ~160° CCW |
| Tight connector | 71 | **255** | ~270° CW |
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
---
## 4. Deck layers — the vertical stack
OSM `layer` tags on bridge ways in the interchange:
| Layer | Bridge ways | Total length |
|---|---|---|
| 1 | 11 | 17,696 m |
| 2 | 7 | 5,637 m |
| 3 | 8 | 5,368 m |
| 4 | 10 | 10,096 m |
| 5 | 6 | 1,417 m |
**Five distinct deck levels.** This is the single most important structural
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
that is one level. A faithful build wants at least 3 visible levels overhead.
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
that is **36–43 studs per level**.
---
## 5. Video-derived detail
Sources archived at
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
| Route | Video ID | Len | Light | Value |
|---|---|---|---|---|
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
### Confirmed visual details
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
curve. Present in all five videos, day and night. Highest-value cheap
detail available.
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
underside (clearest at `yokohane_down` t=218–242s).
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
low concrete jersey base.
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
`50` km/h limit discs on ramps.
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
t=104s).
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
visible**, painted directional arrows, low guardrail perimeter.
7. Arrival reads as **descending into a canyon** — decks close in overhead.
---
## 6. Gaps / not yet measured
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
Needs either a DEM lookup or estimation from video pier counts.
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
are captured in full, the Honmoku run covers the approach only.
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
sampled.
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
not yet fitted.
---
## 7. Reproduce
```bash
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
python3 /tmp/sat.py # Esri aerial tiles
```
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Pull real Daikoku JCT / PA geometry from OpenStreetMap via Overpass.
Outputs measured, real-world numbers:
- PA service-area polygon (extent, area)
- Each ramp/link way: length, turn direction, fitted circle radius
- Bridge levels present
All distances in metres. Local ENU projection about the PA centre so we can
fit circles in a flat plane without map-projection distortion.
"""
import json, math, urllib.request, urllib.parse, sys
LAT0, LON0 = 35.46246657, 139.68149001 # user's Google Earth point
R_EARTH = 6378137.0
# Generous bbox around the whole interchange
S, W, N, E = 35.4545, 139.6700, 35.4705, 139.6935
Q = f"""
[out:json][timeout:120];
(
way["highway"]({S},{W},{N},{E});
way["amenity"="parking"]({S},{W},{N},{E});
way["highway"="services"]({S},{W},{N},{E});
way["highway"="rest_area"]({S},{W},{N},{E});
);
out body geom;
"""
ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
def fetch():
last = None
for ep in ENDPOINTS:
try:
data = urllib.parse.urlencode({"data": Q}).encode()
req = urllib.request.Request(ep, data=data,
headers={"User-Agent": "daikoku-ref/1.0"})
with urllib.request.urlopen(req, timeout=150) as r:
return json.loads(r.read().decode())
except Exception as e:
last = f"{ep}: {e}"
print(f" endpoint failed {last}", file=sys.stderr)
raise SystemExit(f"all overpass endpoints failed: {last}")
def enu(lat, lon):
"""Local east/north metres relative to (LAT0, LON0)."""
x = math.radians(lon - LON0) * R_EARTH * math.cos(math.radians(LAT0))
y = math.radians(lat - LAT0) * R_EARTH
return x, y
def polyline_len(pts):
return sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
def fit_circle(pts):
"""Algebraic (Kasa) circle fit. Returns (cx, cy, R, rms_residual)."""
n = len(pts)
if n < 3:
return None
sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n
u = [p[0] - sx for p in pts]
v = [p[1] - sy for p in pts]
Suu = sum(a * a for a in u); Svv = sum(b * b for b in v)
Suv = sum(u[i] * v[i] for i in range(n))
Suuu = sum(a ** 3 for a in u); Svvv = sum(b ** 3 for b in v)
Suvv = sum(u[i] * v[i] * v[i] for i in range(n))
Svuu = sum(v[i] * u[i] * u[i] for i in range(n))
d = 2 * (Suu * Svv - Suv * Suv)
if abs(d) < 1e-9:
return None
uc = (Svv * (Suuu + Suvv) - Suv * (Svvv + Svuu)) / d
vc = (Suu * (Svvv + Svuu) - Suv * (Suuu + Suvv)) / d
cx, cy = uc + sx, vc + sy
rs = [math.dist(p, (cx, cy)) for p in pts]
R = sum(rs) / len(rs)
rms = math.sqrt(sum((r - R) ** 2 for r in rs) / len(rs))
return cx, cy, R, rms
def total_turn(pts):
"""Signed cumulative heading change in degrees. +ccw / -cw."""
tot = 0.0
for i in range(1, len(pts) - 1):
a1 = math.atan2(pts[i][1] - pts[i - 1][1], pts[i][0] - pts[i - 1][0])
a2 = math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0])
d = math.degrees(a2 - a1)
while d > 180: d -= 360
while d < -180: d += 360
tot += d
return tot
def main():
data = fetch()
els = data.get("elements", [])
print(f"overpass returned {len(els)} ways\n")
ramps, areas = [], []
for el in els:
if el.get("type") != "way" or "geometry" not in el:
continue
t = el.get("tags", {})
pts = [enu(g["lat"], g["lon"]) for g in el["geometry"]]
if len(pts) < 2:
continue
rec = {"id": el["id"], "tags": t, "pts": pts,
"len": polyline_len(pts)}
hw = t.get("highway", "")
if hw in ("services", "rest_area") or t.get("amenity") == "parking":
areas.append(rec)
elif hw.endswith("_link") or t.get("oneway") == "yes":
ramps.append(rec)
# ---- PA area ----
print("=" * 70)
print("PARKING / SERVICE AREA POLYGONS")
print("=" * 70)
for a in sorted(areas, key=lambda r: -r["len"])[:6]:
xs = [p[0] for p in a["pts"]]; ys = [p[1] for p in a["pts"]]
w = max(xs) - min(xs); h = max(ys) - min(ys)
nm = a["tags"].get("name", a["tags"].get("name:en", "-"))
print(f" way {a['id']:>12} {a['tags'].get('highway') or a['tags'].get('amenity'):<10}"
f" extent {w:6.0f} x {h:6.0f} m perim {a['len']:7.0f} m {nm}")
print(f" E {min(xs):7.0f}..{max(xs):7.0f} N {min(ys):7.0f}..{max(ys):7.0f}")
# ---- curved ramps: the loops ----
print()
print("=" * 70)
print("CURVED RAMPS (|turn| >= 120 deg) -- the helix loops")
print("=" * 70)
print(f" {'way id':>12} {'len(m)':>7} {'turn':>8} {'radius(m)':>10} {'rms':>6} layer/bridge")
loops = []
for r in ramps:
turn = total_turn(r["pts"])
if abs(turn) < 120 or r["len"] < 60:
continue
fc = fit_circle(r["pts"])
if not fc:
continue
cx, cy, R, rms = fc
if R > 400: # not a tight loop
continue
loops.append((r, turn, cx, cy, R, rms))
for r, turn, cx, cy, R, rms in sorted(loops, key=lambda z: -abs(z[1])):
t = r["tags"]
lb = f"layer={t.get('layer','-')} bridge={t.get('bridge','-')}"
print(f" {r['id']:>12} {r['len']:7.0f} {turn:7.0f}\u00b0 {R:10.1f} {rms:6.1f} {lb}")
print(f" centre E {cx:7.0f} N {cy:7.0f} "
f"{'CCW/left' if turn>0 else 'CW/right'}")
if loops:
rr = [z[4] for z in loops]
print(f"\n loop radius: min {min(rr):.0f} m max {max(rr):.0f} m "
f"mean {sum(rr)/len(rr):.0f} m")
print(f" in studs (1 stud = 0.28 m): mean {sum(rr)/len(rr)/0.28:.0f} studs")
# ---- layers present ----
print()
print("=" * 70)
print("DECK LAYERS (bridge ways)")
print("=" * 70)
lay = {}
for r in ramps:
if r["tags"].get("bridge"):
L = r["tags"].get("layer", "0")
lay.setdefault(L, []).append(r["len"])
for L in sorted(lay, key=lambda s: int(s) if s.lstrip("-").isdigit() else 0):
v = lay[L]
print(f" layer {L:>3}: {len(v):3d} bridge ways, total {sum(v):8.0f} m")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Download a route video, sample frames every N seconds, and build labelled
contact sheets. Built for Daikoku entrance/exit reference gathering.
Usage: route.py <videoid> <shortname> [step_seconds]
"""
import os, re, subprocess, sys, glob, json
from PIL import Image, ImageDraw
YTDLP = os.path.expanduser("~/.local/bin/yt-dlp")
ROOT = "/tmp/routes"
def run(cmd, **kw):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
def meta(vid):
r = run(f'{YTDLP} --no-warnings -J --skip-download "https://www.youtube.com/watch?v={vid}" 2>/dev/null')
try:
d = json.loads(r.stdout)
except Exception:
return {}
return {k: d.get(k) for k in ("title", "uploader", "duration", "upload_date", "width", "height")}
def fetch(vid, name):
d = f"{ROOT}/{name}"
os.makedirs(d, exist_ok=True)
mp4 = f"{d}/{name}.mp4"
if not os.path.exists(mp4):
r = run(f'{YTDLP} -f "bv*[height<=1080][ext=mp4]/bv*[height<=1080]/b" '
f'--no-warnings -o "{mp4}" "https://www.youtube.com/watch?v={vid}"')
if not os.path.exists(mp4):
print(f" DOWNLOAD FAILED: {r.stderr[-300:]}")
return None
return mp4
def sample(mp4, d, step):
dur = float(run(f'ffprobe -v error -show_entries format=duration '
f'-of default=nw=1:nk=1 "{mp4}"').stdout.strip() or 0)
for f in glob.glob(f"{d}/fr_*.jpg"):
os.remove(f)
ts = list(range(2, int(dur), step))
for t in ts:
run(f'ffmpeg -v error -ss {t} -i "{mp4}" -frames:v 1 -q:v 2 '
f'-vf scale=960:-1 "{d}/fr_{t:04d}.jpg" -y')
return dur, ts
def sheets(d, name, cols=3, per=9):
fs = sorted(glob.glob(f"{d}/fr_*.jpg"))
out = []
for page in range((len(fs) + per - 1) // per):
sub = fs[page * per:(page + 1) * per]
ims = [Image.open(f).convert("RGB") for f in sub]
w, h = ims[0].size
rows = (len(sub) + cols - 1) // cols
s = Image.new("RGB", (w * cols, h * rows), (10, 10, 10))
for i, im in enumerate(ims):
s.paste(im, ((i % cols) * w, (i // cols) * h))
dr = ImageDraw.Draw(s)
for i, f in enumerate(sub):
t = int(re.search(r"fr_(\d+)", f).group(1))
x, y = (i % cols) * w, (i // cols) * h
dr.rectangle([x + 6, y + 6, x + 190, y + 54], fill=(0, 0, 0))
dr.text((x + 20, y + 22), f"t={t}s", fill=(255, 225, 0))
s = s.resize((int(s.width * 0.60), int(s.height * 0.60)), Image.LANCZOS)
o = f"{d}/{name}_sheet{page+1}.jpg"
s.save(o, quality=90)
out.append(o)
return out
if __name__ == "__main__":
vid, name = sys.argv[1], sys.argv[2]
step = int(sys.argv[3]) if len(sys.argv) > 3 else 8
m = meta(vid)
print(f"=== {name} [{vid}] ===")
print(f" title: {m.get('title')}")
print(f" dur : {m.get('duration')}s {m.get('width')}x{m.get('height')}")
mp4 = fetch(vid, name)
if not mp4:
sys.exit(1)
d = f"{ROOT}/{name}"
dur, ts = sample(mp4, d, step)
sh = sheets(d, name)
print(f" frames: {len(ts)} @ {step}s sheets: {len(sh)}")
for s in sh:
print(f" {s}")
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Fetch + stitch Esri World Imagery tiles (no API key) around a lat/lon.
Esri World Imagery over Japan is high-res aerial. Tile scheme is standard
Web Mercator XYZ, same as OSM.
"""
import math, sys, urllib.request, io
from PIL import Image
URL = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) research/1.0"}
def deg2tile(lat, lon, z):
lat_r = math.radians(lat)
n = 2.0 ** z
xt = (lon + 180.0) / 360.0 * n
yt = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
return xt, yt
def fetch(z, x, y):
u = URL.format(z=z, x=x, y=y)
req = urllib.request.Request(u, headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
return Image.open(io.BytesIO(r.read())).convert("RGB")
def build(lat, lon, z, half, out):
xt, yt = deg2tile(lat, lon, z)
cx, cy = int(xt), int(yt)
n = half * 2 + 1
canvas = Image.new("RGB", (256 * n, 256 * n))
ok = 0
for i, x in enumerate(range(cx - half, cx + half + 1)):
for j, y in enumerate(range(cy - half, cy + half + 1)):
try:
canvas.paste(fetch(z, x, y), (i * 256, j * 256))
ok += 1
except Exception as e:
print(f" tile {z}/{x}/{y} FAIL {e}", file=sys.stderr)
canvas.save(out, quality=92)
# metres per pixel at this latitude
mpp = 156543.03392 * math.cos(math.radians(lat)) / (2 ** z)
span = mpp * 256 * n
print(f"{out} z={z} tiles={ok}/{n*n} size={canvas.size} "
f"mpp={mpp:.3f} span={span:.0f}m")
return canvas
if __name__ == "__main__":
LAT, LON = 35.46246657, 139.68149001
build(LAT, LON, 17, 2, "/tmp/daikoku_z17.jpg") # wide: whole loop
build(LAT, LON, 18, 2, "/tmp/daikoku_z18.jpg") # closer
build(LAT, LON, 19, 2, "/tmp/daikoku_z19.jpg") # detail: stalls
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+198 -42
View File
@@ -92,6 +92,9 @@ local LANE_W = 3.25 * M -- 11.6 studs; narrow urban lane used on the C1
-- every 1.0 m, bounded by a 15-20 cm edge line.
local ZEBRA_W = 0.45 * M
local ZEBRA_PITCH = 1.0 * M
-- Gore hatching + speed-change-lane boundary lines only make sense where a
-- ramp actually diverges. Tied to BUILD_PA_RAMPS in buildPA (2026-08-03).
local RAMP_GORE_MARKINGS = false
local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting
local MARK_H = 2.1
-- Heading change per 60-stud sample above which the curve counts as tight
@@ -399,10 +402,21 @@ local function planPA()
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,
-- GATE MOVED TO THE PA's NORTH EDGE (2026-08-03).
-- It used to be az=270 -- the SOUTH edge -- with a 122 deg sweep,
-- which walked the trunk right around the PA on a r=470 arc. The
-- shop models sit on a ring of radius ~460-515 across the south
-- and west sectors (Fast Food Restaurant, McDonald's, Restaurant1,
-- Updated Ramen Shop, すき家, 7 eleven), so that arc drove straight
-- through them: 183 measured clips. The mainline is on the +Z
-- (north) side of the PA, and the north sector Z>-7325 is clear of
-- shops, so the gate belongs there. A short sweep also answers the
-- "angle is too round" note -- the ramp now leaves the perimeter
-- almost straight at the mainline instead of curling around it.
az = 95.0, -- ring gate on the PA's north edge, facing the mainline
turn = 1, -- CCW, short arc across the clear north sector
sweep = 48.0,
-- yFork is derived from the deck at the merge sample (see below).
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
@@ -412,10 +426,14 @@ local function planPA()
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,
-- Gate also moved to the north edge (see PAEntryRamp above); it
-- was az=250 sweeping 118 deg through the west-sector shops.
-- 65 deg keeps ~250 studs of clear kerb between the two ramp
-- mouths at r=470 so they don't share an opening.
az = 65.0, -- ring gate on the north edge, east of red's
turn = -1, -- CW, short arc across the clear north sector
sweep = 44.0,
-- yFork derived (see below); a constant caused the dY -6.0 step.
m = 3, -- merges onto the carriageway at sample 537, heading east
out = 0,
nearSide = -1,
@@ -443,7 +461,10 @@ local function planPA()
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)
-- Deck height at the merge sample: the ramp must hand off at exactly
-- the mainline's local Y or it walls off (was a hardcoded 96 vs 102).
local yForkDerived = samples[nearIdxM].pos.Y
local emerge = Vector3.new(basePos.X, yForkDerived, basePos.Z) - nrm * (def.out * def.nearSide)
table.insert(junctionDefs, {
name = def.name,
idx = i0,
@@ -453,7 +474,7 @@ local function planPA()
az = math.rad(def.az),
turn = def.turn,
sweep = math.rad(def.sweep),
yFork = def.yFork,
yFork = yForkDerived,
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
@@ -468,7 +489,7 @@ local function planPA()
-- 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,
yForkDerived,
c.Z + math.sin(math.rad(def.az) + def.turn * math.rad(def.sweep)) * def.forkR
),
pos = nearPos,
@@ -510,11 +531,19 @@ local function planPA()
-- 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
local i = ((jd.idx + k - 1) % n) + 1
rampOpen[i] = true
rampOpenSide[i] = jd.side
-- RAMPS DISABLED (2026-08-03): with no ramp deck built, an opening in the
-- outer parapet is a hole in the barrier with a drop behind it -- the
-- exact "no walls near the parking area" failure described above, just
-- caused from the other end. Leave the edge sealed while the ramps are
-- off. Re-enable together with BUILD_PA_RAMPS in buildPA.
local RAMP_EDGE_OPENINGS = false
if RAMP_EDGE_OPENINGS then
for _, jd in ipairs(junctionDefs) do
for k = -RAMP_SPAN, RAMP_SPAN do
local i = ((jd.idx + k - 1) % n) + 1
rampOpen[i] = true
rampOpenSide[i] = jd.side
end
end
end
PAPlan = {
@@ -945,7 +974,13 @@ local function buildRoad(map: Folder)
-- Where the ramp splits off the mainline the real road paints a
-- zebra-hatched no-drive wedge in the gore. 45 cm bars every 1.0 m
-- per 全標協 2018 pp.97-98.
if plan.medianOpen[i] then
--
-- RAMPS DISABLED (2026-08-03): a gore is the wedge BETWEEN a mainline
-- and a diverging ramp. With no ramp there is no gore, so this paints
-- 1024 hatch bars and 64 boundary lines marking a junction that does
-- not exist -- it reads as random white striping across the deck.
-- Re-enable together with BUILD_PA_RAMPS in buildPA.
if plan.medianOpen[i] and RAMP_GORE_MARKINGS then
local nBars = math.floor(delta.Magnitude / ZEBRA_PITCH)
for k = 0, nBars - 1 do
local z = (k * ZEBRA_PITCH - delta.Magnitude / 2) + ZEBRA_PITCH / 2
@@ -1578,6 +1613,33 @@ local function buildPA(map: Folder)
end
end
-- PA ON/OFF RAMPS -- DISABLED (2026-08-03, user request).
--
-- Held off deliberately, not abandoned. Three iterations all failed to
-- look like a real interchange, and the measurements said why:
-- * entry and exit decks INTERSECTED -- 2547 sample pairs within 30
-- studs horizontally at <12 studs dY, worst case 0.5 studs apart at
-- dY = 0.0. That reads on screen as a bowtie tangle of white rails.
-- * both real Daikoku ramps turn the SAME way (clockwise; the guide
-- video says 「右、右」). This build mirrored them into each other.
-- * the real arc wraps OUTSIDE the lot and forms its north/east
-- boundary. This build cut across the apron interior.
--
-- What was already right and should be kept when this is revisited:
-- radius (470 studs vs a measured OSM mean of 469) and the merge, which
-- lands tangent at 0.1-0.2 degrees of divergence.
--
-- Rebuild spec, reference imagery and the checkable constraint list:
-- reference/ramps/BUILD_SPEC.md
-- reference/ramps/INDEX.md
-- reference/ramps/docs/daikoku-reference-spec.md (OSM circle fits)
--
-- Flip this to true to re-enable the existing (defective) generator.
-- The ring wall keeps its gate openings either way -- they are placed
-- from `junctions` above, independently of this flag, so the PA stays
-- drivable and the perimeter stays closed with the ramps off.
local BUILD_PA_RAMPS = false
-- Two ramps: mainline -> ring (entry) and ring -> mainline (exit).
-- `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.
@@ -1688,20 +1750,47 @@ local function buildPA(map: Folder)
-- 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
-- SUPPORT SPACING + FORM (2026-08-03, user note: "supports are
-- supposed to be beams / poles like in real life").
-- Was `s % 2 == 0` -- a column every 2 segments, 162 of them,
-- which reads as a solid palisade under the deck rather than
-- discrete piers. Real elevated ramps carry the deck on widely
-- spaced slender columns, each topped by a transverse HEAD BEAM
-- (cap) that the deck sits on. Every 14th segment gives roughly
-- one bent per 30-40 m of span, which matches the reference
-- footage under Daikoku.
local PIER_EVERY = 14
if h > 10 and s % PIER_EVERY == 0 and not blocked then
-- slimmer column: 4.5 studs instead of 6, so it reads as a
-- pole rather than a fat block
local pil = part({
Name = "PARampPillar",
-- Cylinder axis is local X, so Size.X is the height and
-- Size.Y/Z the diameter; the Z rotation stands it upright.
-- (Same transposition that had all 80 viaduct PierColumns
-- built as 8-stud discs -- see the PierColumn note above.)
Size = Vector3.new(h, 6, 6),
Size = Vector3.new(h, 4.5, 4.5),
CFrame = CFrame.new(segMid.X, segMid.Y - h / 2, segMid.Z) * CFrame.Angles(0, 0, math.rad(90)),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
Shape = Enum.PartType.Cylinder,
})
pil.Parent = pa
-- HEAD BEAM: a short transverse girder across the top of the
-- column, directly under the deck, so the ramp visibly rests
-- on something instead of balancing on a stick. Oriented
-- across the run using the segment's own heading.
-- (a and b are this segment's endpoints; there is no segDir
-- in scope -- deriving it here rather than assuming one.)
local hdg = math.atan2(b.Z - a.Z, b.X - a.X)
local cap = part({
Name = "PARampPierCap",
Size = Vector3.new(3.0, 2.6, RAMP_W * 0.92),
CFrame = CFrame.new(segMid.X, segMid.Y - 1.6, segMid.Z) * CFrame.Angles(0, -hdg, 0),
Color = C_CONCRETE,
Material = Enum.Material.Concrete,
})
cap.Parent = pa
end
-- Edge kerbs so a car can't slide off the side of a raised ramp.
--
@@ -1830,8 +1919,10 @@ local function buildPA(map: Folder)
end
for _, jn in ipairs(junctions) do
if not BUILD_PA_RAMPS then
continue
end
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
@@ -1848,46 +1939,111 @@ local function buildPA(map: Folder)
-- 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.
--
-- DECK_CLEAR is the flyover's soffit clearance above the mainline
-- deck. Japanese expressway minimum vertical clearance is 4.5 m;
-- at this map's scale (1 m = 3.57 studs) that is ~16 studs to the
-- UNDERSIDE, so add the ramp slab depth on top.
local DECK_CLEAR = 34
-- 1. TRUNK -- perimeter spiral, ring gate out to the fork point. The
-- radius eases across the WHOLE sweep so the ramp hugs the PA edge
-- and flares out toward the mainline -- the shape in the drawing.
--
-- The trunk now climbs to OVER_Y (above the deck) rather than to deck
-- level: BOTH ramps cross the expressway on a flyover and serve the
-- FAR carriageway, per the drawing -- red comes off the highway, over
-- the deck and circles down into the PA; yellow leaves the PA, over
-- the deck, and merges into the northbound (far) lanes. The previous
-- pass deleted this crossing and substituted a median gap; that is
-- what kept both ramps stranded on the near side.
local OVER_Y = yd + DECK_CLEAR
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)
local y = APRON_Y + (OVER_Y - 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 = {}
-- 2. CROSSING -- fork straight out over BOTH carriageways at constant
-- OVER_Y. This is the flyover proper: it must clear the deck, so it
-- holds height all the way across and only descends afterwards.
local farSide = -ns
local overFar = carriagewayPoint(mergeIdx, farSide)
local cross = {}
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
)
cross,
Vector3.new(fork.X + (overFar.X - fork.X) * f, OVER_Y, fork.Z + (overFar.Z - fork.Z) * f)
)
end
-- 3. DESCENT -- from above the far carriageway, run ALONGSIDE the
-- mainline and come down onto it. The along-road length is what makes
-- the grade legal: dropping DECK_CLEAR studs over ~6 samples of road
-- (~370 studs) is ~5.4%, inside the 9% limit.
--
-- Which way it runs is set by the traffic direction, not by taste.
-- The far carriageway travels toward INCREASING sample index, so an
-- off-ramp must diverge BEFORE the crossing (lower index) and an
-- on-ramp must merge AFTER it (higher index) -- either one pointing
-- the same way as the traffic it joins or leaves.
local LAND_SPAN = 6
-- MERGE AT THE EDGE, NOT ON TOP OF THE DECK (2026-08-03).
-- The descent used to target carriagewayPoint(), which is the CENTRE
-- of the far carriageway (LANE_OFF = ROAD_WIDTH/4 from the
-- centreline). Landing there drops the ramp mouth straight onto the
-- running lanes -- the "top of the ramps are sitting right on the
-- highway" note. A real ramp runs up the OUTER shoulder and tapers in
-- over an acceleration lane.
--
-- So the ramp now tracks the carriageway's OUTER EDGE for most of the
-- descent and only drifts to the lane centre over the last stretch:
-- edgeOff = ROAD_WIDTH/2 - RAMP_W/2 (outer kerb, ramp fully on deck)
-- laneOff = LANE_OFF (lane centre, fully merged)
-- taper() is 0 along the shoulder run and eases to 1 at the mouth,
-- so the merge is a gradual convergence rather than a drop-in.
local edgeOff = ROAD_WIDTH / 2 - RAMP_W / 2
local function landPoint(gi, f)
local i0f = math.floor(gi)
local frac = gi - i0f
local ia = i0f + 1
local ib = ((i0f + 1) % nS) + 1
local sa, sb = samples[ia].pos, samples[ib].pos
local p = sa + (sb - sa) * frac
-- local heading -> normal, so the offset is perpendicular to the
-- road at this point rather than a global axis
local nxt = samples[(ia % nS) + 1].pos
local h = math.atan2(nxt.Z - sa.Z, nxt.X - sa.X)
local nx, nz = -math.sin(h), math.cos(h)
-- for an off-ramp f runs mouth->highway, so invert the taper
local t = (jn.kind == "off") and (1 - f) or f
-- hold the shoulder for the first 65%, then converge
local conv = math.clamp((t - 0.65) / 0.35, 0, 1)
conv = conv * conv * (3 - 2 * conv)
local off = edgeOff + (LANE_OFF - edgeOff) * conv
return Vector3.new(p.X + nx * off * farSide, p.Y, p.Z + nz * off * farSide)
end
local descent = {}
for s = 0, 200 do
local f = s / 200
-- follow the carriageway's own curve rather than a straight
-- chord, so the ramp lands tangent instead of at an angle
local gi = ((mergeIdx - 1 + (jn.kind == "off" and -LAND_SPAN or LAND_SPAN) * f) % nS)
local p = landPoint(gi, f)
table.insert(descent, Vector3.new(p.X, OVER_Y + (yd - OVER_Y) * ease(f), p.Z))
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.
-- here is open at both ends. Chevrons go on the trunk only: that is
-- the curved helix they exist to make legible.
emitRun(jn.name .. "Trunk", trunk, true, true, true)
emitRun(jn.name .. "Near", near, true, true, false)
emitRun(jn.name .. "Cross", cross, true, true, false)
emitRun(jn.name .. "Near", descent, true, true, false)
-- Flare the ramp mouth across the outer gate so the opening in the
-- ring parapet is fully backed by deck -- the trunk meets the ring at
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Eyes-on shots at the two REPORTED defects (not generic beauty angles).
Targets:
A entry ramp start (-4122, 47, -7860) -- the 390-stud access gap
B exit ramp start (-4258, 47, -7836)
C mainline junction (-3700, 102, -7184) -- the dY -6.0 step
D ramp vs nearest building(-4419, 223, -6751)
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
SHOTS = [
# name, camera pos, look-at
("D1_entry_ramp_start", (-4122 + 210, 47 + 120, -7860 + 210), (-4122, 47, -7860)),
("D2_entry_start_low", (-4122 + 70, 47 + 25, -7860 + 70), (-4122, 55, -7860)),
("D3_exit_ramp_start", (-4258 - 200, 47 + 110, -7836 + 200), (-4258, 47, -7836)),
("D4_junction_step", (-3700 + 190, 102 + 90, -7184 + 190), (-3700, 102, -7184)),
("D5_ramp_vs_building", (-4419 + 520, 223 + 180, -6751 + 520), (-4300, 120, -7100)),
("D6_corridor_along", (-4122, 47 + 260, -7860 + 480), (-4300, 110, -7200)),
]
CAM = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
return "ok"
"""
def main():
made = []
for name, (px, py, pz), (tx, ty, tz) in SHOTS:
fov = 70 if "low" in name or "along" in name else 55
r = rbx.call(
"execute_luau",
{
"datamodel_type": "Edit",
"code": CAM.format(
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
),
},
wait=12,
timeout=300,
)
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {str(r)[:120]}")
continue
time.sleep(1.5)
cap = rbx.call(
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
continue
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
made.append(dest)
print(f"{name}: OK -> {dest}")
print(f"\nMADE {len(made)}")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Eyes-on shots of the PA flyover ramps.
Camera targets are QUERIED FROM THE LIVE GEOMETRY on every run, never
hardcoded. The previous capture script pinned literal coordinates at the
old defect sites; after the ramps were redesigned those coords pointed at
empty air and the shots silently framed the wrong thing. Same class of bug
as the hardcoded yFork=96 -- a constant that stops matching the model.
Each shot names a part and an offset; the part's real position is looked up
in Studio at capture time. If a named part is missing the shot is SKIPPED
with a loud message rather than falling back to a stale guess.
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
# name, anchor part, camera offset from anchor, look-at offset from anchor, fov
SHOTS = [
# The flyover itself, from the side -- the money shot: ramp over deck.
("F1_flyover_side", "PAEntryRampCross_100", (300, 150, 300), (0, -20, 0), 55),
# Underneath the crossing, looking up: proves real clearance.
("F2_flyover_under", "PAEntryRampCross_100", (140, -70, 140), (0, 10, 0), 75),
# The spiral descending into the PA apron.
("F3_spiral_into_pa", "PAEntryRampTrunk_200", (340, 220, 340), (-120, -60, -120), 60),
# Exit ramp climbing out of the PA.
("F4_exit_climb", "PAExitRampTrunk_200", (-340, 200, 300), (60, -40, 40), 60),
# Where the descent lands on the far carriageway.
("F5_landing_merge", "PAEntryRampNear_200", (220, 130, 220), (0, -15, 0), 55),
# Wide establishing shot of the whole interchange.
("F6_wide", "PAEntryRampCross_100", (900, 620, 900), (-250, -120, -250), 60),
]
POS_QUERY = """
local ws=game:GetService("Workspace"); local map=ws:FindFirstChild("Map")
if not map then return "NOMAP" end
local want={}
for _,n in ipairs({%s}) do want[n]=true end
local found={}
for _,d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and want[d.Name] then
found[#found+1]=("%%s=%%.1f,%%.1f,%%.1f"):format(d.Name,d.Position.X,d.Position.Y,d.Position.Z)
end
end
return table.concat(found,"|")
"""
CAM = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
return "ok"
"""
def fetch_anchors(names):
"""Look up each anchor part's real position in the live datamodel."""
lst = ",".join('"%s"' % n for n in sorted(set(names)))
r = rbx.call(
"execute_luau",
{"datamodel_type": "Edit", "code": POS_QUERY % lst},
wait=40,
timeout=400,
)
txt = str(r)
out = {}
for chunk in txt.split("|"):
if "=" not in chunk:
continue
nm, _, vals = chunk.partition("=")
nm = nm.strip().strip("'\"[]{}() ")
parts = vals.strip().strip("'\"[]{}() ").split(",")
if len(parts) != 3:
continue
try:
out[nm] = tuple(float(v) for v in parts)
except ValueError:
continue
return out
def main():
anchors = fetch_anchors([s[1] for s in SHOTS])
print(f"resolved {len(anchors)} anchors: {sorted(anchors)}")
made, skipped = [], []
for name, anchor, cam_off, look_off, fov in SHOTS:
if anchor not in anchors:
print(f"{name}: SKIP -- anchor {anchor} not found in model")
skipped.append(name)
continue
ax, ay, az = anchors[anchor]
px, py, pz = ax + cam_off[0], ay + cam_off[1], az + cam_off[2]
tx, ty, tz = ax + look_off[0], ay + look_off[1], az + look_off[2]
r = rbx.call(
"execute_luau",
{
"datamodel_type": "Edit",
"code": CAM.format(
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
),
},
wait=12,
timeout=300,
)
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {str(r)[:120]}")
skipped.append(name)
continue
time.sleep(1.5)
cap = rbx.call(
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
skipped.append(name)
continue
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
made.append(dest)
print(f"{name}: OK ({anchor} @ {ax:.0f},{ay:.0f},{az:.0f}) -> {dest}")
print(f"\nMADE {len(made)} SKIPPED {len(skipped)} {skipped if skipped else ''}")
if __name__ == "__main__":
main()
+25 -4
View File
@@ -16,7 +16,14 @@ for _, d in ipairs(map:GetDescendants()) do
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
for _, base in ipairs({
"PAEntryRampTrunk",
"PAEntryRampCross",
"PAEntryRampNear",
"PAExitRampTrunk",
"PAExitRampCross",
"PAExitRampNear",
}) do
if nm:sub(1, #base + 1) == base .. "_" then
runs[base] = runs[base] or {}
table.insert(runs[base], d)
@@ -56,7 +63,14 @@ table.insert(out, "structuralParts=" .. #structural)
local function sortKey(p)
return tonumber(p.Name:match("_(%d+)$")) or 0
end
for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do
for _, base in ipairs({
"PAEntryRampTrunk",
"PAEntryRampCross",
"PAEntryRampNear",
"PAExitRampTrunk",
"PAExitRampCross",
"PAExitRampNear",
}) do
local parts = runs[base]
if not parts then
table.insert(out, base .. ": MISSING")
@@ -73,8 +87,15 @@ for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk"
end
end
-- Trunk -> Near handoff
for _, pair in ipairs({ { "PAEntryRampTrunk", "PAEntryRampNear" }, { "PAExitRampTrunk", "PAExitRampNear" } }) do
-- Handoffs along the flyover chain: Trunk -> Cross -> Near. The ramps now
-- cross OVER the mainline, so `Cross` sits between the spiral and the
-- descent; checking Trunk->Near directly would report a false gap.
for _, pair in ipairs({
{ "PAEntryRampTrunk", "PAEntryRampCross" },
{ "PAEntryRampCross", "PAEntryRampNear" },
{ "PAExitRampTrunk", "PAExitRampCross" },
{ "PAExitRampCross", "PAExitRampNear" },
}) do
local t, nr = runs[pair[1]], runs[pair[2]]
if t and nr then
table.sort(t, function(a, b)
+145
View File
@@ -0,0 +1,145 @@
-- Diagnose the two reported defects:
-- (1) ramps not accessible in either direction (connectivity)
-- (2) ramps pass through buildings (clipping)
-- Uses native GetPartBoundsInBox so this stays fast on ~22k parts.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
Structure = true,
PalaceWall = true,
}
local ramp, drivable = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local n = d.Name
if n:find("PAEntryRamp") or n:find("PAExitRamp") then
table.insert(ramp, d)
elseif n:match("^Road_%d+$") or n == "PAFloor" then
table.insert(drivable, d)
end
end
end
if #ramp == 0 then
return "no ramp parts"
end
-- ---------- (2) BUILDING CLIP CHECK ----------
local op = OverlapParams.new()
op.MaxParts = 40
local clipHits, clipNames, worst = 0, {}, {}
for _, r in ipairs(ramp) do
local hits = ws:GetPartBoundsInBox(r.CFrame, r.Size, op)
for _, h in ipairs(hits) do
if h ~= r and BUILDING[h.Name] then
clipHits += 1
clipNames[h.Name] = (clipNames[h.Name] or 0) + 1
if #worst < 5 then
table.insert(worst, ("%s@%.0f,%.0f,%.0f"):format(r.Name, r.Position.X, r.Position.Y, r.Position.Z))
end
break
end
end
end
-- CONTROL: same probe against mainline Road_ parts (known-good, should be ~0)
local ctrlHits, ctrlN = 0, 0
for i = 1, #drivable, 9 do
local d = drivable[i]
ctrlN += 1
local hits = ws:GetPartBoundsInBox(d.CFrame, d.Size, op)
for _, h in ipairs(hits) do
if h ~= d and BUILDING[h.Name] then
ctrlHits += 1
break
end
end
end
local cn = {}
for k, v in pairs(clipNames) do
table.insert(cn, k .. "=" .. v)
end
-- ---------- (1) CONNECTIVITY ----------
-- Ramp basename groups, then endpoints of each group by trailing index.
local groups = {}
for _, r in ipairs(ramp) do
local base, idx = r.Name:match("^(.-)_?(%d+)$")
base = base or r.Name
idx = tonumber(idx) or 0
local g = groups[base]
if not g then
g = { lo = math.huge, hi = -math.huge, loP = nil, hiP = nil, n = 0 }
groups[base] = g
end
g.n += 1
if idx < g.lo then
g.lo, g.loP = idx, r
end
if idx > g.hi then
g.hi, g.hiP = idx, r
end
end
-- For an endpoint, find nearest drivable surface and report gap + height delta.
local function nearestDrivable(p)
local bd, bp = math.huge, nil
for _, d in ipairs(drivable) do
local dd = (d.Position - p).Magnitude
if dd < bd then
bd, bp = dd, d
end
end
return bd, bp
end
local lines = {}
for base, g in pairs(groups) do
if g.loP and g.hiP then
local d1, p1 = nearestDrivable(g.loP.Position)
local d2, p2 = nearestDrivable(g.hiP.Position)
table.insert(
lines,
("%s n=%d | START gap=%.0f dY=%.1f to %s | END gap=%.0f dY=%.1f to %s"):format(
base,
g.n,
d1,
p1 and (g.loP.Position.Y - p1.Position.Y) or -999,
p1 and p1.Name or "nil",
d2,
p2 and (g.hiP.Position.Y - p2.Position.Y) or -999,
p2 and p2.Name or "nil"
)
)
end
end
return ("CLIP ramp=%d/%d parts hit buildings [%s] samples=[%s] || CONTROL road %d/%d hit\n%s"):format(
clipHits,
#ramp,
table.concat(cn, ", "),
table.concat(worst, " ; "),
ctrlHits,
ctrlN,
table.concat(lines, "\n")
)
+91
View File
@@ -0,0 +1,91 @@
-- Why did the building-clip probe return 0 with a 0 control? Diagnose the metric.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
Structure = true,
PalaceWall = true,
}
local ramp, bld = {}, {}
local anyQueryOff, anyCollideOff = 0, 0
for _, d in ipairs(ws:GetDescendants()) do
if d:IsA("BasePart") then
if d.Name:find("PAEntryRamp") or d.Name:find("PAExitRamp") then
table.insert(ramp, d)
elseif BUILDING[d.Name] then
table.insert(bld, d)
if not d.CanQuery then
anyQueryOff += 1
end
if not d.CanCollide then
anyCollideOff += 1
end
end
end
end
if #bld == 0 then
return "NO building parts found in Workspace at all (name set wrong?)"
end
-- nearest building to the ramp chain (subsample ramps for speed)
local bd, br, bb = math.huge, nil, nil
for i = 1, #ramp, 7 do
local r = ramp[i]
for _, b in ipairs(bld) do
local dd = (r.Position - b.Position).Magnitude
if dd < bd then
bd, br, bb = dd, r, b
end
end
end
-- how many buildings are anywhere near the PA at all?
local cen = Vector3.new(-4128, 81, -7438)
local near2000 = 0
for _, b in ipairs(bld) do
if (b.Position - cen).Magnitude < 2000 then
near2000 += 1
end
end
-- does the raw overlap API return ANYTHING for a ramp part? (metric sanity)
local op = OverlapParams.new()
op.MaxParts = 50
local sample = ramp[math.floor(#ramp / 2)]
local hits = ws:GetPartBoundsInBox(sample.CFrame, sample.Size * 1.2, op)
local hn = {}
for i = 1, math.min(#hits, 8) do
table.insert(hn, hits[i].Name)
end
return ("buildings=%d (CanQuery off=%d, CanCollide off=%d) | nearestBldToRamp=%.0f (%s -> %s @%.0f,%.0f,%.0f) | bldWithin2000ofPA=%d | overlapSanity hits=%d [%s]"):format(
#bld,
anyQueryOff,
anyCollideOff,
bd,
br and br.Name or "nil",
bb and bb.Name or "nil",
bb and bb.Position.X or 0,
bb and bb.Position.Y or 0,
bb and bb.Position.Z or 0,
near2000,
#hits,
table.concat(hn, ",")
)
+108
View File
@@ -0,0 +1,108 @@
-- Building-clip probe, CORRECTLY SCOPED.
--
-- The previous version tested only PAEntryRamp*/PAExitRamp* against buildings
-- and reported 0/1200 -- but its control ALSO returned 0, which makes the
-- result vacuous. Worse, the part visibly speared by a tower is a Road_*
-- segment, i.e. it was sitting in the control group, not the test group.
--
-- This version:
-- * tests EVERY drivable part (Road_*, ramps, PA surfaces) -- 21k parts
-- * proves the metric can detect a hit BEFORE trusting a zero (positive
-- control: a synthetic part deliberately placed inside a building)
-- * reports the worst offenders with coordinates so they can be shot
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
PalaceWall = true,
}
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
local drive, bld = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
if BUILDING[d.Name] then
table.insert(bld, d)
elseif isDrivable(d.Name) then
table.insert(drive, d)
end
end
end
if #bld == 0 or #drive == 0 then
return ("SCOPE FAIL drive=%d bld=%d"):format(#drive, #bld)
end
-- Build an OverlapParams limited to building parts only.
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = bld
op.MaxParts = 12
-- POSITIVE CONTROL: put a probe cube dead-centre inside a building and
-- confirm the metric reports it. If this returns 0, the metric is broken and
-- every other number below is meaningless.
local target = bld[math.floor(#bld / 2)]
local ctrlHits = ws:GetPartBoundsInBox(CFrame.new(target.Position), Vector3.new(4, 4, 4), op)
local controlOK = #ctrlHits > 0
local hits, samples = 0, {}
for _, p in ipairs(drive) do
-- shrink slightly so parts merely TOUCHING a facade aren't flagged;
-- we want genuine interpenetration
local probe = p.Size - Vector3.new(1.5, 1.5, 1.5)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
if #samples < 6 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f in %s"):format(
p.Name,
p.Position.X,
p.Position.Y,
p.Position.Z,
h[1].Name
)
)
end
end
end
end
return ("CONTROL=%s (%d hits on a part inside a building) || drivable=%d bld=%d CLIPPING=%d [%s]"):format(
controlOK and "PASS" or "FAIL-METRIC-BROKEN",
#ctrlHits,
#drive,
#bld,
hits,
table.concat(samples, " ; ")
)
+146
View File
@@ -0,0 +1,146 @@
-- CLIP PROBE v3 -- INVERTED SCOPE.
--
-- v2 tested drivable parts against a HARDCODED WHITELIST of 16 building
-- names (Building, TokyoTower, GinzaBlock, ...). It reported CLIPPING=0 with
-- a passing control, and I reported that as fact. The user can see ramps
-- going through buildings, so the zero was false.
--
-- The bug is the whitelist: any obstacle whose name isn't one of those 16 is
-- invisible to the test. The PA structures (shops, kiosks, canopies) are the
-- prime suspect -- they sit exactly where the ramp ends land.
--
-- v3 inverts the scope: an obstacle is ANY part that is not drivable and not
-- infrastructure (pier/rail/sign/ground). No name list to go stale.
-- It also reports offenders GROUPED BY OBSTACLE NAME, so the answer names
-- the thing being speared instead of just counting.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
-- things that are ALLOWED to intersect a road (structure, decor, markings)
local function isInfra(n)
return n:find("Pier")
or n:find("Pillar")
or n:find("Column")
or n:find("Support")
or n:find("Beam")
or n:find("Rail")
or n:find("Guard")
or n:find("Barrier")
or n:find("Sign")
or n:find("Light")
or n:find("Lamp")
or n:find("Line")
or n:find("Mark")
or n:find("Stripe")
or n:find("Deck")
or n:find("Water")
or n == "Ground"
or n:find("Grounds")
or n:find("Median")
or n:find("Curb")
or n:find("Wall")
end
local drive, obst = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local n = d.Name
if isDrivable(n) then
table.insert(drive, d)
elseif not isInfra(n) then
table.insert(obst, d)
end
end
end
if #obst == 0 or #drive == 0 then
return ("SCOPE FAIL drive=%d obst=%d"):format(#drive, #obst)
end
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = obst
op.MaxParts = 12
-- POSITIVE CONTROL: probe dead-centre of a known obstacle must report a hit.
local target = obst[math.floor(#obst / 2)]
local ctrl = ws:GetPartBoundsInBox(CFrame.new(target.Position), Vector3.new(4, 4, 4), op)
local controlOK = #ctrl > 0
-- SECOND CONTROL: a box far out in empty sky must report ZERO. Guards the
-- opposite failure -- a filter so broad everything "hits".
local negCtrl = ws:GetPartBoundsInBox(CFrame.new(0, 40000, 0), Vector3.new(8, 8, 8), op)
local negOK = #negCtrl == 0
local hits = 0
local byObst, byRamp = {}, {}
local samples = {}
for _, p in ipairs(drive) do
local probe = p.Size - Vector3.new(1.5, 1.5, 1.5)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
local on = h[1].Name
byObst[on] = (byObst[on] or 0) + 1
local rn = p.Name:gsub("_%d+$", "")
byRamp[rn] = (byRamp[rn] or 0) + 1
if #samples < 10 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f IN %s"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, on)
)
end
end
end
end
local out = {}
table.insert(
out,
("CONTROL+=%s(%d) CONTROL-=%s(%d) -> probe %s"):format(
controlOK and "PASS" or "FAIL",
#ctrl,
negOK and "PASS" or "FAIL",
#negCtrl,
(controlOK and negOK) and "TRUSTWORTHY" or "BROKEN-IGNORE-RESULTS"
)
)
table.insert(out, ("drivable=%d obstacles=%d CLIPPING=%d"):format(#drive, #obst, hits))
local function dump(t, label)
local l = {}
for k, v in pairs(t) do
table.insert(l, ("%s=%d"):format(k, v))
end
table.sort(l, function(a, b)
return a > b
end)
local keep = {}
for i = 1, math.min(#l, 12) do
table.insert(keep, l[i])
end
table.insert(out, label .. ": " .. table.concat(keep, " "))
end
if hits > 0 then
dump(byObst, "speared obstacles")
dump(byRamp, "offending road groups")
for _, s in ipairs(samples) do
table.insert(out, " " .. s)
end
end
return table.concat(out, "\n")
+125
View File
@@ -0,0 +1,125 @@
-- CLIP PROBE v4 -- FULL-WORKSPACE SCOPE. This is the one that finds the
-- defect the user can see.
--
-- WHY v2 AND v3 BOTH RETURNED A FALSE ZERO:
-- both scoped obstacles to the `Map` folder. The PA buildings are NOT in
-- Map -- they are Creator-Store models parented directly to Workspace as
-- siblings: McDonald's (1666 parts), Gas Station 1 (1463), 7 eleven (627),
-- Restaurant1, Updated Ramen Shop, Fast Food Restaurant, すき家, Parking lot.
-- The probes literally could not see the things being speared. A passing
-- positive control did NOT save me: the control proved the metric works,
-- not that the SCOPE covers the real obstacles.
--
-- v4 collects obstacles from the ENTIRE workspace, excluding only the Map
-- folder's own drivable/infra parts. Reports per-model hit counts so we know
-- exactly which shop each ramp is driving through.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
-- collect drivable parts (ramps especially) from Map
local drive = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and isDrivable(d.Name) then
table.insert(drive, d)
end
end
-- collect obstacles: every BasePart in workspace that is NOT inside Map.
-- These are the imported building models.
local obst = {}
local modelOf = {}
for _, top in ipairs(ws:GetChildren()) do
if top ~= map and not top:IsA("Terrain") and not top:IsA("Camera") then
if top:IsA("BasePart") then
-- loose parts like stray PAPerimeter/PARampKerb: skip, they're infra
if not isDrivable(top.Name) and not top.Name:find("Perimeter") and not top.Name:find("Kerb") then
table.insert(obst, top)
modelOf[top] = top.Name
end
else
for _, d in ipairs(top:GetDescendants()) do
if d:IsA("BasePart") then
table.insert(obst, d)
modelOf[d] = top.Name
end
end
end
end
end
if #obst == 0 then
return "SCOPE FAIL: no obstacles found outside Map"
end
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = obst
op.MaxParts = 20
-- CONTROL+ : a box at a known obstacle's centre must report a hit
local tgt = obst[math.floor(#obst / 2)]
local cPos = ws:GetPartBoundsInBox(CFrame.new(tgt.Position), Vector3.new(4, 4, 4), op)
-- CONTROL- : empty sky must report zero
local cNeg = ws:GetPartBoundsInBox(CFrame.new(0, 40000, 0), Vector3.new(8, 8, 8), op)
local trustworthy = #cPos > 0 and #cNeg == 0
local hits = 0
local byModel, byRamp = {}, {}
local samples = {}
for _, p in ipairs(drive) do
local probe = p.Size - Vector3.new(1.0, 1.0, 1.0)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
local mn = modelOf[h[1]] or h[1].Name
byModel[mn] = (byModel[mn] or 0) + 1
local rn = p.Name:gsub("_%d+$", "")
byRamp[rn] = (byRamp[rn] or 0) + 1
if #samples < 10 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f IN %s"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, mn)
)
end
end
end
end
local out = {}
table.insert(
out,
("CONTROL+=%d CONTROL-=%d -> %s"):format(#cPos, #cNeg, trustworthy and "TRUSTWORTHY" or "BROKEN-IGNORE")
)
table.insert(out, ("drivable=%d obstacles(outside Map)=%d CLIPPING=%d"):format(#drive, #obst, hits))
local function dump(t, label)
local l = {}
for k, v in pairs(t) do
table.insert(l, ("%s=%d"):format(k, v))
end
table.sort(l)
table.insert(out, label .. ": " .. table.concat(l, " | "))
end
if hits > 0 then
dump(byModel, "MODELS SPEARED")
dump(byRamp, "offending ramp groups")
for _, s in ipairs(samples) do
table.insert(out, " " .. s)
end
end
return table.concat(out, "\n")
+139
View File
@@ -0,0 +1,139 @@
-- FLOATING SCENERY PROBE.
--
-- The wide captures show grey slabs/towers hanging in the sky with nothing
-- under them. No existing probe catches this: the clip probe only asks
-- "does road intersect building", which is 0 and correct. Floating geometry
-- is the opposite failure -- parts that touch NOTHING at all.
--
-- Metric: for each candidate scenery part, raycast straight DOWN from its
-- underside and report the distance to the first thing hit. A part sitting
-- on ground/deck returns ~0. A floating part returns a large number or nil
-- (nothing beneath it at all).
--
-- CONTROL: the same ray cast from a part known to be seated (a Road part)
-- must return a small distance. If the control comes back nil/huge the
-- raycast itself is broken and every "floating" result is meaningless --
-- the silent-zero trap that produced two phantom defects earlier.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local candidates, roads = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
if nm:sub(1, 5) == "Road_" then
table.insert(roads, d)
elseif
nm:find("Building")
or nm:find("Tower")
or nm:find("Skyline")
or nm:find("Block")
or nm:find("Slab")
or nm:find("Distant")
then
table.insert(candidates, d)
end
end
end
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
-- distance from a part's underside straight down to the first hit
local function dropDist(p)
params.FilterDescendantsInstances = { p }
local origin = p.Position - Vector3.new(0, p.Size.Y / 2 + 1, 0)
local res = ws:Raycast(origin, Vector3.new(0, -6000, 0), params)
if not res then
return nil
end
return (origin - res.Position).Magnitude
end
local out = {}
-- CONTROL: a road part must find something close beneath it (deck/pier/ground).
local ctl, ctlN = nil, "none"
for _, rd in ipairs(roads) do
local d = dropDist(rd)
if d then
ctl, ctlN = d, rd.Name
break
end
end
if ctl == nil then
table.insert(out, "CONTROL=FAIL raycast found nothing under ANY road -- probe is broken, ignore results below")
else
table.insert(out, ("CONTROL=PASS %.1f studs under %s (raycast works)"):format(ctl, ctlN))
end
-- Classify candidates.
local FLOAT_MIN = 60 -- studs of empty air below = floating
local floating, seated, voidBelow = 0, 0, 0
local worst, worstNm, worstPos = -1, "none", nil
local samples = {}
for _, p in ipairs(candidates) do
local d = dropDist(p)
if d == nil then
voidBelow += 1
if #samples < 8 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f NOTHING-BELOW"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z)
)
end
elseif d > FLOAT_MIN then
floating += 1
if d > worst then
worst, worstNm, worstPos = d, p.Name, p.Position
end
if #samples < 8 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f drop=%.0f"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, d)
)
end
else
seated += 1
end
end
table.insert(
out,
("candidates=%d roads=%d | seated=%d floating(>%d)=%d nothingBelow=%d"):format(
#candidates,
#roads,
seated,
FLOAT_MIN,
floating,
voidBelow
)
)
if worstPos then
table.insert(
out,
("WORST %s drop=%.0f at %.0f,%.0f,%.0f"):format(worstNm, worst, worstPos.X, worstPos.Y, worstPos.Z)
)
end
for _, s in ipairs(samples) do
table.insert(out, " " .. s)
end
-- Name histogram so we can see WHICH generator made them.
local hist = {}
for _, p in ipairs(candidates) do
local base = p.Name:gsub("_%d+$", "")
hist[base] = (hist[base] or 0) + 1
end
local hl = {}
for k, v in pairs(hist) do
table.insert(hl, ("%s=%d"):format(k, v))
end
table.sort(hl)
table.insert(out, "byName: " .. table.concat(hl, " "))
return table.concat(out, "\n")
+104
View File
@@ -0,0 +1,104 @@
-- FLYOVER PROBE. Two things the connectivity check cannot tell you:
--
-- 1. Does the crossing actually pass OVER the mainline deck, with real
-- vertical clearance? A "flyover" that intersects the deck is just a
-- wall. Measured as: for every Cross part, find mainline Road parts
-- directly beneath it in plan, and report the worst (smallest) gap
-- between the ramp underside and the road topside.
--
-- 2. Does the descent land tangent and flush on the FAR carriageway?
-- Reported as the dY at the last descent part vs the road beneath it.
--
-- Every metric carries a CONTROL that must come back non-zero, because a
-- silent 0 from a mis-scoped query is indistinguishable from a genuine pass
-- -- that exact trap produced two phantom "defects" earlier in this build.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local cross, descent, roads = {}, {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
if nm:find("RampCross_") then
table.insert(cross, d)
elseif nm:find("RampNear_") then
table.insert(descent, d)
elseif nm:sub(1, 5) == "Road_" then
table.insert(roads, d)
end
end
end
-- plan-view overlap (ignores Y entirely)
local function planOverlap(a, b)
return math.abs(a.Position.X - b.Position.X) < (a.Size.X + b.Size.X) / 2
and math.abs(a.Position.Z - b.Position.Z) < (a.Size.Z + b.Size.Z) / 2
end
local out = {}
-- CONTROL 1: a road part must plan-overlap itself.
local ctlSelf = (#roads > 0 and planOverlap(roads[1], roads[1])) and 1 or 0
table.insert(out, ("CONTROL planOverlap-self=%d (want 1)"):format(ctlSelf))
-- CLEARANCE over the deck.
local worst, worstAt, nOver = 1e9, "none", 0
for _, cp in ipairs(cross) do
for _, rd in ipairs(roads) do
if planOverlap(cp, rd) then
nOver += 1
-- ramp underside minus road topside
local gap = (cp.Position.Y - cp.Size.Y / 2) - (rd.Position.Y + rd.Size.Y / 2)
if gap < worst then
worst, worstAt = gap, cp.Name .. " over " .. rd.Name
end
end
end
end
if nOver == 0 then
table.insert(out, "CROSSING: 0 road parts under any Cross part -- ramp does NOT span the deck")
else
table.insert(out, ("CROSSING: %d overlaps | worst vertical gap=%.1f at %s"):format(nOver, worst, worstAt))
end
-- LANDING: last descent part of each ramp vs the road under it.
local function tailOf(prefix)
local best, bestIdx = nil, -1
for _, p in ipairs(descent) do
if p.Name:sub(1, #prefix) == prefix then
local i = tonumber(p.Name:match("_(%d+)$")) or 0
if i > bestIdx then
best, bestIdx = p, i
end
end
end
return best
end
for _, pre in ipairs({ "PAEntryRampNear", "PAExitRampNear" }) do
local t = tailOf(pre)
if not t then
table.insert(out, pre .. ": no tail part")
else
local bestGap, bestNm = 1e9, "none"
for _, rd in ipairs(roads) do
if planOverlap(t, rd) then
local dy = (t.Position.Y - t.Size.Y / 2) - (rd.Position.Y + rd.Size.Y / 2)
if math.abs(dy) < math.abs(bestGap) then
bestGap, bestNm = dy, rd.Name
end
end
end
if bestNm == "none" then
table.insert(out, ("%s LANDING: tail is over NO road -- lands off the carriageway"):format(pre))
else
table.insert(out, ("%s LANDING: dY=%.1f onto %s"):format(pre, bestGap, bestNm))
end
end
end
table.insert(out, ("counts cross=%d descent=%d roads=%d"):format(#cross, #descent, #roads))
return table.concat(out, "\n")
+119
View File
@@ -0,0 +1,119 @@
-- GROUND-TRUTH PROBE for the floating-scenery finding.
--
-- probe_floating reported 664 parts with "nothing below". That number alone
-- is ambiguous: it reads identically whether (a) the buildings genuinely
-- hang in the air, or (b) the map has no ground/terrain plane at all, so
-- NOTHING can ever return a hit. Distinguish before calling it a defect.
--
-- Measure:
-- 1. Does the map contain terrain / a baseplate / a water or ground slab?
-- 2. Bottom-face Y of each scenery part vs the road deck Y and vs 0.
-- A building whose bottom is at/below ~0 is "planted" even with no
-- ground mesh; one whose bottom is hundreds of studs up is FLOATING.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local out = {}
-- 1. terrain / ground candidates
local terr = ws:FindFirstChildOfClass("Terrain")
local terrHas = false
if terr then
local ok, reg = pcall(function()
return terr.MaxExtents
end)
-- MaxExtents is a Region3int16; its Min/Max are Vector3int16 which have
-- no .Magnitude. Compare components instead.
if ok and reg then
local mn, mx = reg.Min, reg.Max
terrHas = (mx.X - mn.X) > 1 or (mx.Y - mn.Y) > 1 or (mx.Z - mn.Z) > 1
end
end
table.insert(out, ("Terrain present=%s hasVoxels=%s"):format(tostring(terr ~= nil), tostring(terrHas)))
local groundish = {}
for _, d in ipairs(ws:GetDescendants()) do
if d:IsA("BasePart") then
local n = d.Name:lower()
if
n:find("ground")
or n:find("base")
or n:find("water")
or n:find("sea")
or n:find("bay")
or n:find("floor")
then
local sz = d.Size
if sz.X > 200 or sz.Z > 200 then
table.insert(
groundish,
("%s size=%.0fx%.0f topY=%.1f"):format(d.Name, sz.X, sz.Z, d.Position.Y + sz.Y / 2)
)
end
end
end
end
table.insert(out, ("large ground-like slabs=%d"):format(#groundish))
for i = 1, math.min(#groundish, 6) do
table.insert(out, " " .. groundish[i])
end
-- 2. bottom-face Y of scenery vs deck
local roadY, roadN = {}, 0
local scen = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
if nm:sub(1, 5) == "Road_" then
roadN += 1
table.insert(roadY, d.Position.Y)
elseif
nm:find("Building")
or nm:find("Tower")
or nm:find("Ginza")
or nm:find("Shiodome")
or nm:find("Diet")
then
table.insert(scen, d)
end
end
end
table.sort(roadY)
local medRoad = roadY[math.max(1, math.floor(#roadY / 2))] or 0
table.insert(out, ("median road Y=%.1f (n=%d)"):format(medRoad, roadN))
local planted, floatHigh = 0, 0
local hist = {}
local worst, worstNm, worstP = -1e9, "none", nil
for _, p in ipairs(scen) do
local btm = p.Position.Y - p.Size.Y / 2
if btm <= 5 then
planted += 1
else
floatHigh += 1
local base = p.Name:gsub("_%d+$", "")
hist[base] = (hist[base] or 0) + 1
if btm > worst then
worst, worstNm, worstP = btm, p.Name, p.Position
end
end
end
table.insert(out, ("scenery=%d | bottom<=5 (planted)=%d | bottom>5 (airborne)=%d"):format(#scen, planted, floatHigh))
if worstP then
table.insert(
out,
("HIGHEST bottom: %s btmY=%.0f at %.0f,%.0f,%.0f"):format(worstNm, worst, worstP.X, worstP.Y, worstP.Z)
)
end
local hl = {}
for k, v in pairs(hist) do
table.insert(hl, ("%s=%d"):format(k, v))
end
table.sort(hl)
table.insert(out, "airborne byName: " .. (#hl > 0 and table.concat(hl, " ") or "(none)"))
return table.concat(out, "\n")
+109
View File
@@ -0,0 +1,109 @@
-- What actually sits near the four ramp ENDS? The user reports ramps going
-- through buildings; the clip probe only found pillars vs perimeter wall.
-- So: name every non-drivable part within 140 studs of each ramp end, and
-- separately list PA-area structures with their footprints, so the ramp
-- can be re-aimed at a genuinely clear corridor instead of guessed at.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local WANT = {
PAEntryRampNear_200 = true,
PAExitRampNear_200 = true,
PAEntryRampTrunk_1 = true,
PAExitRampTrunk_1 = true,
}
local ends = {}
local all = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
if WANT[d.Name] then
ends[d.Name] = d.Position
end
table.insert(all, d)
end
end
local function drivable(n)
return n:match("^Road_") or n:find("Ramp") or n == "PAFloor" or n == "Ground" or n:find("Water")
end
local out = {}
for n, p in pairs(ends) do
local near = {}
local closest, closeName = 1e9, "none"
for _, d in ipairs(all) do
if not drivable(d.Name) then
local dist = (d.Position - p).Magnitude
if dist < 140 then
near[d.Name] = (near[d.Name] or 0) + 1
if dist < closest then
closest, closeName = dist, d.Name
end
end
end
end
local l = {}
for k, v in pairs(near) do
table.insert(l, ("%s x%d"):format(k, v))
end
table.sort(l)
local keep = {}
for i = 1, math.min(#l, 10) do
table.insert(keep, l[i])
end
table.insert(
out,
("%s @%.0f,%.0f,%.0f closest=%s(%.0f) near: %s"):format(
n,
p.X,
p.Y,
p.Z,
closeName,
closest,
table.concat(keep, ", ")
)
)
end
-- PA-area structures with footprints so we can aim at a clear corridor.
table.insert(out, "--- PA structures (within 700 of PA centre) ---")
local paf = nil
for _, d in ipairs(all) do
if d.Name == "PAFloor" then
paf = d
end
end
if paf then
local c = paf.Position
table.insert(out, ("PAFloor centre %.0f,%.0f,%.0f size %.0fx%.0f"):format(c.X, c.Y, c.Z, paf.Size.X, paf.Size.Z))
local seen = {}
local rows = {}
for _, d in ipairs(all) do
if not drivable(d.Name) and (d.Position - c).Magnitude < 700 then
local base = d.Name:gsub("_%d+$", "")
if not seen[base] then
seen[base] = { minx = 1e9, maxx = -1e9, minz = 1e9, maxz = -1e9, n = 0 }
end
local s = seen[base]
s.n += 1
s.minx = math.min(s.minx, d.Position.X - d.Size.X / 2)
s.maxx = math.max(s.maxx, d.Position.X + d.Size.X / 2)
s.minz = math.min(s.minz, d.Position.Z - d.Size.Z / 2)
s.maxz = math.max(s.maxz, d.Position.Z + d.Size.Z / 2)
end
end
for k, s in pairs(seen) do
table.insert(rows, ("%s n=%d X[%.0f..%.0f] Z[%.0f..%.0f]"):format(k, s.n, s.minx, s.maxx, s.minz, s.maxz))
end
table.sort(rows)
for _, r in ipairs(rows) do
table.insert(out, " " .. r)
end
end
return table.concat(out, "\n")
+103
View File
@@ -0,0 +1,103 @@
-- Geometry facts needed to re-cut the ramps so they (a) merge at the highway
-- EDGE instead of landing on top of it, (b) run a corridor that misses the PA
-- shops, and (c) get pole/beam supports instead of whatever's there now.
--
-- Outputs:
-- 1. Mainline cross-section at the PA: per-carriageway edge X/Z + width, so
-- a merge can target the OUTER edge rather than the deck centreline.
-- 2. Plan-footprint of every shop model near the PA, as an X/Z box, so a
-- clear corridor can be computed instead of guessed.
-- 3. Current support part sizes/shapes, to see what needs replacing.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
local out = {}
-- 1. mainline cross-section near the PA (sample by Z band)
local band = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and d.Name:match("^Road_") then
local p = d.Position
if p.Z > -7250 and p.Z < -7050 and p.X > -4700 and p.X < -3400 then
table.insert(band, d)
end
end
end
table.sort(band, function(a, b)
return a.Position.Z < b.Position.Z
end)
table.insert(out, ("mainline sample parts in band=%d"):format(#band))
-- cluster by Z to find the two carriageways
local zmin, zmax = 1e9, -1e9
for _, d in ipairs(band) do
zmin = math.min(zmin, d.Position.Z - d.Size.Z / 2)
zmax = math.max(zmax, d.Position.Z + d.Size.Z / 2)
end
table.insert(out, ("mainline Z extent %.1f .. %.1f (total %.1f)"):format(zmin, zmax, zmax - zmin))
for i = 1, math.min(#band, 6) do
local d = band[i]
table.insert(
out,
(" %s pos %.0f,%.1f,%.1f size %.1fx%.1f rotY=%.0f"):format(
d.Name,
d.Position.X,
d.Position.Y,
d.Position.Z,
d.Size.X,
d.Size.Z,
math.deg(select(2, d.CFrame:ToEulerAnglesYXZ()))
)
)
end
-- 2. shop model footprints near the PA
table.insert(out, "--- shop model footprints (plan boxes) ---")
for _, top in ipairs(ws:GetChildren()) do
if top ~= map and not top:IsA("Terrain") and not top:IsA("Camera") and not top:IsA("BasePart") then
local x0, x1, z0, z1, y0, y1, n = 1e9, -1e9, 1e9, -1e9, 1e9, -1e9, 0
for _, d in ipairs(top:GetDescendants()) do
if d:IsA("BasePart") then
n += 1
x0 = math.min(x0, d.Position.X - d.Size.X / 2)
x1 = math.max(x1, d.Position.X + d.Size.X / 2)
z0 = math.min(z0, d.Position.Z - d.Size.Z / 2)
z1 = math.max(z1, d.Position.Z + d.Size.Z / 2)
y0 = math.min(y0, d.Position.Y - d.Size.Y / 2)
y1 = math.max(y1, d.Position.Y + d.Size.Y / 2)
end
end
if n > 0 and x0 > -6000 and x1 < -2500 and z0 > -9000 and z1 < -6000 then
table.insert(
out,
(" %-42s n=%-5d X[%.0f..%.0f] Z[%.0f..%.0f] Y[%.0f..%.0f]"):format(top.Name, n, x0, x1, z0, z1, y0, y1)
)
end
end
end
-- 3. what the current supports look like
table.insert(out, "--- current ramp supports ---")
local sup = {}
for _, d in ipairs(ws:GetDescendants()) do
if d:IsA("BasePart") and (d.Name:find("RampPillar") or d.Name:find("PierColumn") or d.Name:find("PierCap")) then
local k = ("%s shape=%s size=%.1fx%.1fx%.1f"):format(
d.Name,
d.Shape and d.Shape.Name or "n/a",
d.Size.X,
d.Size.Y,
d.Size.Z
)
sup[k] = (sup[k] or 0) + 1
end
end
local sl = {}
for k, v in pairs(sup) do
table.insert(sl, ("%s x%d"):format(k, v))
end
table.sort(sl)
for i = 1, math.min(#sl, 12) do
table.insert(out, " " .. sl[i])
end
return table.concat(out, "\n")