diff --git a/reference/ramps/BUILD_SPEC.md b/reference/ramps/BUILD_SPEC.md new file mode 100644 index 0000000..ec8fddd --- /dev/null +++ b/reference/ramps/BUILD_SPEC.md @@ -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. diff --git a/reference/ramps/Daikoku-JCT.JPG b/reference/ramps/Daikoku-JCT.JPG new file mode 100644 index 0000000..fba841d Binary files /dev/null and b/reference/ramps/Daikoku-JCT.JPG differ diff --git a/reference/ramps/Daikoku-PA.JPG b/reference/ramps/Daikoku-PA.JPG new file mode 100644 index 0000000..37717b4 Binary files /dev/null and b/reference/ramps/Daikoku-PA.JPG differ diff --git a/reference/ramps/DaikokuJCT-Bnishi.JPG b/reference/ramps/DaikokuJCT-Bnishi.JPG new file mode 100644 index 0000000..d1ca7dc Binary files /dev/null and b/reference/ramps/DaikokuJCT-Bnishi.JPG differ diff --git a/reference/ramps/INDEX.md b/reference/ramps/INDEX.md new file mode 100644 index 0000000..d9fe188 --- /dev/null +++ b/reference/ramps/INDEX.md @@ -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. diff --git a/reference/ramps/Shuto_expressway_shibaura_pa.jpg b/reference/ramps/Shuto_expressway_shibaura_pa.jpg new file mode 100644 index 0000000..a72554c Binary files /dev/null and b/reference/ramps/Shuto_expressway_shibaura_pa.jpg differ diff --git a/reference/ramps/Shutoko_daikokuPA.jpg b/reference/ramps/Shutoko_daikokuPA.jpg new file mode 100644 index 0000000..d796e90 Binary files /dev/null and b/reference/ramps/Shutoko_daikokuPA.jpg differ diff --git a/reference/ramps/Shutoko_heiwajimaPA_down.jpg b/reference/ramps/Shutoko_heiwajimaPA_down.jpg new file mode 100644 index 0000000..c7c6e37 Binary files /dev/null and b/reference/ramps/Shutoko_heiwajimaPA_down.jpg differ diff --git a/reference/ramps/Shutoko_heiwajimaPA_up.jpg b/reference/ramps/Shutoko_heiwajimaPA_up.jpg new file mode 100644 index 0000000..a2a345e Binary files /dev/null and b/reference/ramps/Shutoko_heiwajimaPA_up.jpg differ diff --git a/reference/ramps/Shutoko_oiPA_east.jpg b/reference/ramps/Shutoko_oiPA_east.jpg new file mode 100644 index 0000000..10c6e23 Binary files /dev/null and b/reference/ramps/Shutoko_oiPA_east.jpg differ diff --git a/reference/ramps/Shutoko_oiPA_west.jpg b/reference/ramps/Shutoko_oiPA_west.jpg new file mode 100644 index 0000000..69404e9 Binary files /dev/null and b/reference/ramps/Shutoko_oiPA_west.jpg differ diff --git a/reference/ramps/Shutoko_shibauraPA.jpg b/reference/ramps/Shutoko_shibauraPA.jpg new file mode 100644 index 0000000..2b3024d Binary files /dev/null and b/reference/ramps/Shutoko_shibauraPA.jpg differ diff --git a/reference/ramps/aerial/daikoku_bldg_big.jpg b/reference/ramps/aerial/daikoku_bldg_big.jpg new file mode 100644 index 0000000..bbfe249 Binary files /dev/null and b/reference/ramps/aerial/daikoku_bldg_big.jpg differ diff --git a/reference/ramps/aerial/daikoku_z19.jpg b/reference/ramps/aerial/daikoku_z19.jpg new file mode 100644 index 0000000..799ea7f Binary files /dev/null and b/reference/ramps/aerial/daikoku_z19.jpg differ diff --git a/reference/ramps/daikoku_jct_sheet2.jpg b/reference/ramps/daikoku_jct_sheet2.jpg new file mode 100644 index 0000000..f4e87b8 Binary files /dev/null and b/reference/ramps/daikoku_jct_sheet2.jpg differ diff --git a/reference/ramps/docs/daikoku-reference-spec.md b/reference/ramps/docs/daikoku-reference-spec.md new file mode 100644 index 0000000..b9b4939 --- /dev/null +++ b/reference/ramps/docs/daikoku-reference-spec.md @@ -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 # video -> contact sheets +python3 /tmp/sat.py # Esri aerial tiles +``` diff --git a/reference/ramps/docs/osm_daikoku.py b/reference/ramps/docs/osm_daikoku.py new file mode 100644 index 0000000..ceeac38 --- /dev/null +++ b/reference/ramps/docs/osm_daikoku.py @@ -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() diff --git a/reference/ramps/docs/route.py b/reference/ramps/docs/route.py new file mode 100644 index 0000000..db3be0d --- /dev/null +++ b/reference/ramps/docs/route.py @@ -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 [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}") diff --git a/reference/ramps/docs/sat.py b/reference/ramps/docs/sat.py new file mode 100644 index 0000000..31a34d7 --- /dev/null +++ b/reference/ramps/docs/sat.py @@ -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 diff --git a/reference/ramps/exit_to_baybridge_sheet2.jpg b/reference/ramps/exit_to_baybridge_sheet2.jpg new file mode 100644 index 0000000..397729b Binary files /dev/null and b/reference/ramps/exit_to_baybridge_sheet2.jpg differ diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index 4e8e86d..c3b503b 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -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 diff --git a/tools/capture_defects.py b/tools/capture_defects.py new file mode 100644 index 0000000..dadd442 --- /dev/null +++ b/tools/capture_defects.py @@ -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() diff --git a/tools/capture_flyover.py b/tools/capture_flyover.py new file mode 100644 index 0000000..a382435 --- /dev/null +++ b/tools/capture_flyover.py @@ -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() diff --git a/tools/control_and_connectivity.luau b/tools/control_and_connectivity.luau index 737442a..51c0554 100644 --- a/tools/control_and_connectivity.luau +++ b/tools/control_and_connectivity.luau @@ -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) diff --git a/tools/diag_access_clip.luau b/tools/diag_access_clip.luau new file mode 100644 index 0000000..2df6790 --- /dev/null +++ b/tools/diag_access_clip.luau @@ -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") +) diff --git a/tools/diag_clipmetric.luau b/tools/diag_clipmetric.luau new file mode 100644 index 0000000..486d392 --- /dev/null +++ b/tools/diag_clipmetric.luau @@ -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, ",") +) diff --git a/tools/probe_building_clip.luau b/tools/probe_building_clip.luau new file mode 100644 index 0000000..60309e8 --- /dev/null +++ b/tools/probe_building_clip.luau @@ -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, " ; ") +) diff --git a/tools/probe_building_clip_v3.luau b/tools/probe_building_clip_v3.luau new file mode 100644 index 0000000..6d90483 --- /dev/null +++ b/tools/probe_building_clip_v3.luau @@ -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") diff --git a/tools/probe_clip_v4.luau b/tools/probe_clip_v4.luau new file mode 100644 index 0000000..9ea476d --- /dev/null +++ b/tools/probe_clip_v4.luau @@ -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") diff --git a/tools/probe_floating.luau b/tools/probe_floating.luau new file mode 100644 index 0000000..01784cf --- /dev/null +++ b/tools/probe_floating.luau @@ -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") diff --git a/tools/probe_flyover.luau b/tools/probe_flyover.luau new file mode 100644 index 0000000..4ebb161 --- /dev/null +++ b/tools/probe_flyover.luau @@ -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") diff --git a/tools/probe_ground_truth.luau b/tools/probe_ground_truth.luau new file mode 100644 index 0000000..90a98f4 --- /dev/null +++ b/tools/probe_ground_truth.luau @@ -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") diff --git a/tools/probe_ramp_surroundings.luau b/tools/probe_ramp_surroundings.luau new file mode 100644 index 0000000..457292d --- /dev/null +++ b/tools/probe_ramp_surroundings.luau @@ -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") diff --git a/tools/probe_recut_facts.luau b/tools/probe_recut_facts.luau new file mode 100644 index 0000000..defece7 --- /dev/null +++ b/tools/probe_recut_facts.luau @@ -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")