Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e50f9718d | ||
|
|
c297b20aed | ||
|
|
1d23c187ad | ||
|
|
badba0f7a4 | ||
|
|
6c753505f0 | ||
|
|
f4f95ec9cc | ||
|
|
4a702a4fdb | ||
|
|
16cba4ac32 | ||
|
|
3a74e15f6e | ||
|
|
fed0c0ed20 | ||
|
|
e997f0f73d | ||
|
|
1e41de92fd | ||
|
|
6b0e3b75e4 | ||
|
|
c8dabea53a | ||
|
|
8808b919c3 | ||
|
|
7d368d5444 | ||
|
|
967385261d | ||
|
|
85730f0038 | ||
|
|
37a83e0e8f |
@@ -15,3 +15,4 @@ wally.lock
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
audio-work/
|
audio-work/
|
||||||
|
tools/shots/
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Japanese expressway road-standard reference (verified)
|
||||||
|
|
||||||
|
Compiled 2026-08-01 for the Tokyo Wangan Racing circuit. Every figure here was
|
||||||
|
read from a primary standard or an official sign artwork file, **not** from
|
||||||
|
recollection. Where a commonly-repeated number turned out to be wrong, that is
|
||||||
|
called out explicitly, because the wrong versions are widespread online.
|
||||||
|
|
||||||
|
## Road markings — 全標協 (Japan Road Marking Association) 2012 / 2018
|
||||||
|
|
||||||
|
| Element | Spec | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 車線境界線 lane boundary (dashed) | line length **3–10 m**; gap **1×–2× the line length**; width **10–15 cm** (15 cm "desirable") | 全標協 2012 p.42 |
|
||||||
|
| Lane boundary where overtaking undesirable (屈曲部/curves) | becomes **solid** instead of dashed | 全標協 2012 p.42 |
|
||||||
|
| Boundary vs 変速車線 (accel/decel lane at ramps) | length **2 m** (3 m on decel lanes), width **45 cm** | 全標協 2012 p.42 |
|
||||||
|
| 導流帯 gore / zebra hatching | **45 cm** white solid bars repeated every **1.0 m**; bounded by a 15–20 cm edge line | 全標協 2018 pp.97–98 |
|
||||||
|
| Zebra around an obstruction | 45 cm bars at 1.0 m, guided by arrows upstream | 全標協 2018 |
|
||||||
|
| Dashed extension of edge line through intersections | width 15–30 cm, line+gap every 0.5–2.0 m | 警察庁 2014 p.11 様式4 |
|
||||||
|
|
||||||
|
**Correction to a widely-repeated claim.** The "45 cm wide lines on expressways"
|
||||||
|
figure that circulates online is *not* the lane boundary — 全標協 2012 p.42 gives
|
||||||
|
45 cm only for the boundary between a through lane and a **変速車線**
|
||||||
|
(acceleration/deceleration lane). Applying 45 cm to ordinary lane dividers makes
|
||||||
|
the road look like it is all ramp. Lane dividers are 15 cm.
|
||||||
|
|
||||||
|
**On 8 m / 12 m.** The standard specifies a *range* (3–10 m line, gap 1–2×), not a
|
||||||
|
single value. 8 m line / 12 m gap sits inside that range at the high-speed end
|
||||||
|
(gap = 1.5× line) and is the configuration used at expressway speeds; a 5 m/5 m
|
||||||
|
rhythm reads as a surface street. Treat 8/12 as "a legal expressway
|
||||||
|
configuration", not as "the one legally mandated number".
|
||||||
|
|
||||||
|
## Colour values — sampled from official sign artwork, not estimated
|
||||||
|
|
||||||
|
| Use | Value |
|
||||||
|
|---|---|
|
||||||
|
| Guide sign (案内標識) green | **#005D35** |
|
||||||
|
| Shutoko route badge green | **#007F1F** |
|
||||||
|
| Sign lettering | white #FFFFFF |
|
||||||
|
|
||||||
|
Japanese **expressway** guide signs are green; ordinary-road guide signs are
|
||||||
|
blue. Shutoko uses green.
|
||||||
|
|
||||||
|
## Speed limits (from the route articles' own signage tables)
|
||||||
|
|
||||||
|
| Route | Posted |
|
||||||
|
|---|---|
|
||||||
|
| C1 都心環状線 (Inner Circular) | **50 km/h** mainline, **40 km/h** inside the junction sections — 江戸橋JCT内, 浜崎橋JCT内, and 京橋JCT–旧白魚橋乗継所 are each posted 40 in the route's own 車線・最高速度 table |
|
||||||
|
| B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) |
|
||||||
|
|
||||||
|
The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be
|
||||||
|
posted 50, not 80.
|
||||||
|
|
||||||
|
## Caveats / not verified
|
||||||
|
|
||||||
|
- Lamp pole spacing (~35 m) and parapet heights are reasonable engineering
|
||||||
|
values but were **not** confirmed against a primary source; they are tuned by
|
||||||
|
eye for gameplay readability.
|
||||||
|
- Asphalt/concrete RGB values in MapBuilder are art direction, not measured
|
||||||
|
photometry. Porous asphalt (排水性舗装) does read darker and coarser than
|
||||||
|
ordinary asphalt, which the palette reflects.
|
||||||
|
|
||||||
|
## Structural sections of the C1 (drives MapBuilder geometry)
|
||||||
|
|
||||||
|
The C1 is not a uniform elevated tube — it changes structural type along its
|
||||||
|
length, and the circuit now renders each type differently.
|
||||||
|
|
||||||
|
| Section | Real-world form | In-game |
|
||||||
|
|---|---|---|
|
||||||
|
| Elevated viaduct (most of the loop) | Deck on concrete columns | `Pillar` every 3rd sample above Y=18 |
|
||||||
|
| Bridge spans (474 flagged samples) | Steel box girder 鋼箱桁橋 over the old canals | `BridgeGirder` + `GirderRib` under deck; `PierCap` + round `PierColumn` on a 6-sample pitch |
|
||||||
|
| Ginza 銀座 | Runs **below** street level in the filled-in 楓川・築地川 river beds; surviving city bridges cross **over** it, piers landing in the median | `GinzaOverpass` + `OverpassParapet`, `OverpassPier` in the median |
|
||||||
|
| Tunnel sections | Bored/cut-and-cover | Tunnel bore, ceiling, no sky lighting |
|
||||||
|
|
||||||
|
Girder depth is set from the span/depth rule of thumb for steel box girders
|
||||||
|
(≈1/20 of span): at ~35 m spans that is ~1.8 m ≈ 6.4 studs (`GIRDER_H`).
|
||||||
|
|
||||||
|
**Why Ginza bans lane changes.** The no-lane-change marking through Ginza is not
|
||||||
|
arbitrary styling — the crossing bridges' piers physically occupy the median
|
||||||
|
between the carriageways, so the lanes are separated by structure. The yellow
|
||||||
|
line and the `OverpassPier` geometry are two expressions of the same fact.
|
||||||
|
|
||||||
|
## Lane counts
|
||||||
|
|
||||||
|
| Section | Lanes |
|
||||||
|
|---|---|
|
||||||
|
| Most of C1 | 2 + 2 |
|
||||||
|
| Shiodome JCT → Hamazakibashi JCT | **3 + 3** (widened; carries the merge from the Haneda line) |
|
||||||
|
|
||||||
|
Rendered by `widthAt(i)`: 60 studs baseline, 82 studs in `WIDE_ZONES`, with a
|
||||||
|
taper between so the widening never steps more than ~1.7 studs per sample.
|
||||||
|
|
||||||
|
## Method note
|
||||||
|
|
||||||
|
Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and
|
||||||
|
table markup that carry these figures — the measurements only appear in the raw
|
||||||
|
wikitext (`prop=revisions&rvslots=main`). Query the wikitext when hunting for
|
||||||
|
specs.
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
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
|
||||||
|
```
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 303 KiB |
@@ -1,5 +1,5 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rojo = "rojo-rbx/rojo@7.5.1"
|
rojo = "rojo-rbx/rojo@7.7.0"
|
||||||
wally = "UpliftGames/[email protected]"
|
wally = "UpliftGames/[email protected]"
|
||||||
selene = "Kampfkarren/[email protected]"
|
selene = "Kampfkarren/[email protected]"
|
||||||
stylua = "JohnnyMorganz/[email protected]"
|
stylua = "JohnnyMorganz/[email protected]"
|
||||||
|
|||||||
@@ -163,11 +163,7 @@ function ChassisController.init()
|
|||||||
-- destroyed on respawn, so it MUST be nil-checked before it
|
-- destroyed on respawn, so it MUST be nil-checked before it
|
||||||
-- is indexed -- that crash is what killed this loop before.
|
-- is indexed -- that crash is what killed this loop before.
|
||||||
local stale = heartbeatConn ~= nil
|
local stale = heartbeatConn ~= nil
|
||||||
and (
|
and (drivingSeat ~= seatPart or seatPart == nil or not seatPart:IsDescendantOf(workspace))
|
||||||
drivingSeat ~= seatPart
|
|
||||||
or seatPart == nil
|
|
||||||
or not seatPart:IsDescendantOf(workspace)
|
|
||||||
)
|
|
||||||
if seated and (not heartbeatConn or stale) then
|
if seated and (not heartbeatConn or stale) then
|
||||||
stopDriving()
|
stopDriving()
|
||||||
startDriving(seatPart :: VehicleSeat)
|
startDriving(seatPart :: VehicleSeat)
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ local SETTLE_TIME = 1.0
|
|||||||
|
|
||||||
-- How far to sink an imported mesh shell into the chassis so it doesn't look
|
-- How far to sink an imported mesh shell into the chassis so it doesn't look
|
||||||
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
|
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
|
||||||
local SHELL_DROP = 0.6
|
-- Sink the shell slightly so the tyres aren't fully swallowed by the bodywork.
|
||||||
|
-- Every stud of drop is a stud of cabin height lost, and hyper_x (lowest roof
|
||||||
|
-- in the library at 4.64) has none to spare, so keep this small.
|
||||||
|
local SHELL_DROP = 0.25
|
||||||
|
|
||||||
-- Collision groups ------------------------------------------------------------
|
-- Collision groups ------------------------------------------------------------
|
||||||
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
||||||
@@ -63,6 +66,13 @@ local SHELL_DROP = 0.6
|
|||||||
-- group that ignores the chassis is the fix; the geometry is intentional.
|
-- group that ignores the chassis is the fix; the geometry is intentional.
|
||||||
local WHEEL_GROUP = "CarWheel"
|
local WHEEL_GROUP = "CarWheel"
|
||||||
local CHASSIS_GROUP = "CarChassis"
|
local CHASSIS_GROUP = "CarChassis"
|
||||||
|
-- Seating the driver on the cabin FLOOR (see Chassis.SEAT_ABOVE_GROUND) tucks
|
||||||
|
-- the avatar down inside the body slab, so its legs/torso now genuinely
|
||||||
|
-- interpenetrate the chassis. Overlapping colliders is exactly what launches a
|
||||||
|
-- character across the map, so the driver gets its own group that ignores the
|
||||||
|
-- car. The driver still collides with the world, so it can't clip through
|
||||||
|
-- terrain when ejected.
|
||||||
|
local DRIVER_GROUP = "CarDriver"
|
||||||
local groupsReady = false
|
local groupsReady = false
|
||||||
|
|
||||||
local function ensureCollisionGroups()
|
local function ensureCollisionGroups()
|
||||||
@@ -73,6 +83,7 @@ local function ensureCollisionGroups()
|
|||||||
local ok = pcall(function()
|
local ok = pcall(function()
|
||||||
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
|
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
|
||||||
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
|
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
|
||||||
|
PhysicsService:RegisterCollisionGroup(DRIVER_GROUP)
|
||||||
end)
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
-- Already registered (Play-solo re-entry) -- harmless.
|
-- Already registered (Play-solo re-entry) -- harmless.
|
||||||
@@ -80,6 +91,23 @@ local function ensureCollisionGroups()
|
|||||||
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
|
||||||
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
|
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
|
||||||
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, CHASSIS_GROUP, false)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, WHEEL_GROUP, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Put every part of a character into the driver collision group (or back into
|
||||||
|
--- Default when they get out), so a low-seated avatar can't fight the chassis.
|
||||||
|
local function setCharacterGroup(character: Model?, group: string)
|
||||||
|
if not character then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, d in ipairs(character:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
pcall(function()
|
||||||
|
d.CollisionGroup = group
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Helpers ---------------------------------------------------------------------
|
-- Helpers ---------------------------------------------------------------------
|
||||||
@@ -127,7 +155,21 @@ end
|
|||||||
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
||||||
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
||||||
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
||||||
local isFront = zSign < 0 -- car forward is -Z
|
-- FORWARD CONVENTION: car forward is body-local -Z. This is the Roblox
|
||||||
|
-- standard -- CFrame.LookVector IS the -Z basis vector.
|
||||||
|
--
|
||||||
|
-- DO NOT "fix" this to `zSign > 0`. That was tried on 2026-08-02 and made
|
||||||
|
-- things strictly worse: 2 of 5 cars were backwards before, all 5 after.
|
||||||
|
-- It was justified by tools/diag_seatdir.luau reporting "throttle drives
|
||||||
|
-- toward local +Z", which was a SIGN ERROR in the diagnostic, not a fact
|
||||||
|
-- about the game: expressing a part's own LookVector in its own local
|
||||||
|
-- frame is (0,0,-1) BY DEFINITION, so the script mixed world and local
|
||||||
|
-- space and then "confirmed" the inversion it was looking for.
|
||||||
|
--
|
||||||
|
-- Lesson: a measurement that merely restates a definition cannot be
|
||||||
|
-- evidence. Before trusting a diagnostic, ask what result would DISPROVE
|
||||||
|
-- the hypothesis -- if no possible output would, the check is worthless.
|
||||||
|
local isFront = zSign < 0
|
||||||
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
||||||
|
|
||||||
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
||||||
@@ -161,13 +203,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
||||||
local wheelParent: Part = hub
|
local wheelParent: Part = hub
|
||||||
if isFront then
|
if isFront then
|
||||||
|
-- Knuckle size is load-bearing, not cosmetic: it sets the mass the
|
||||||
|
-- steering servo actually has to work with. See Chassis.KNUCKLE_SIZE
|
||||||
|
-- for the measured sweep -- at the old 0.5 the servo overshot its own
|
||||||
|
-- limits by 70+ degrees and the front wheels were uncontrollable.
|
||||||
|
-- It is CanCollide=false and fully transparent, so growing it changes
|
||||||
|
-- physics authority only, never the look of the car.
|
||||||
local knuckle = makePart({
|
local knuckle = makePart({
|
||||||
Name = "Knuckle_" .. cornerName,
|
Name = "Knuckle_" .. cornerName,
|
||||||
Size = Vector3.new(0.5, 0.5, 0.5),
|
Size = Vector3.new(Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE),
|
||||||
CFrame = wheelWorld,
|
CFrame = wheelWorld,
|
||||||
CanCollide = false,
|
CanCollide = false,
|
||||||
Parent = model,
|
Parent = model,
|
||||||
})
|
})
|
||||||
|
knuckle.Transparency = 1
|
||||||
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
||||||
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
||||||
|
|
||||||
@@ -188,6 +237,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
|
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
|
||||||
|
-- VISIBILITY: this cylinder is the PHYSICS wheel and must be invisible when
|
||||||
|
-- a real imported shell is used, because that shell brings its own modelled
|
||||||
|
-- wheels. Leaving it opaque put TWO sets of wheels on every car -- the mesh's
|
||||||
|
-- tyre plus a grey rubber cylinder on top of it -- which is what read as
|
||||||
|
-- "way too wide". They don't even line up: the cylinder's outer edge sits at
|
||||||
|
-- X=4.10 while the gtr_r34 mesh tyre sits at 4.52, so the duplicate poked out
|
||||||
|
-- past the real tyre at a different track width. The shell is applied later
|
||||||
|
-- in buildCarModel and hides these; if NO shell exists the procedural CarBody
|
||||||
|
-- kit supplies rims, so the cylinder is never the intended visual either way.
|
||||||
local wheel = makePart({
|
local wheel = makePart({
|
||||||
Name = "Wheel_" .. cornerName,
|
Name = "Wheel_" .. cornerName,
|
||||||
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
||||||
@@ -203,6 +261,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
||||||
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
||||||
|
|
||||||
|
-- STEERING/MOTOR CONFLICT -- UNRESOLVED, see docs note below.
|
||||||
|
--
|
||||||
|
-- A steered front corner is two hinges in series: Hub --SteerServo(Y)-->
|
||||||
|
-- Knuckle --WheelMotor(X)--> Wheel. Measured (tools/diag_steer4.luau,
|
||||||
|
-- gtr_r34, target -20 deg): the servo tracks to within 0.01 deg ONLY when
|
||||||
|
-- the front wheel is detached or its hinge disabled; with the wheel
|
||||||
|
-- attached it stays pinned near 0 deg even hoisted in free air with
|
||||||
|
-- ServoMaxTorque up to 1e9. So it is NOT grip and NOT a torque contest.
|
||||||
|
--
|
||||||
|
-- Do NOT "fix" this by skipping the front motor: the motor hinge is the
|
||||||
|
-- only joint attaching Wheel_F* to Knuckle_F*, so omitting it leaves the
|
||||||
|
-- front wheels physically detached (verified, tools/diag_frontjoint.luau)
|
||||||
|
-- -- the servo then turns freely because it carries no load, and the car
|
||||||
|
-- generates no front lateral force at all.
|
||||||
local motor = Instance.new("HingeConstraint")
|
local motor = Instance.new("HingeConstraint")
|
||||||
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
||||||
motor.Attachment0 = parentAtt
|
motor.Attachment0 = parentAtt
|
||||||
@@ -260,10 +332,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
seat.HeadsUpDisplay = false
|
seat.HeadsUpDisplay = false
|
||||||
seat.CanCollide = false
|
seat.CanCollide = false
|
||||||
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
||||||
-- Seat pan just above the body slab (top = H/2): low enough to look tucked
|
-- Seat pan on the CABIN FLOOR, measured up from the ground plane the wheels
|
||||||
-- in the cabin, high enough that the avatar's legs never interpenetrate
|
-- rest on -- NOT up from the body slab. The slab sits at the car's vertical
|
||||||
-- the slab (which causes physics launches).
|
-- middle, so the old "slab top + 0.3" placement put the driver's hips at the
|
||||||
seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2)
|
-- car's waistline and its head through the roof. Sitting the avatar this low
|
||||||
|
-- means its torso now overlaps the slab, which is why the driver goes in its
|
||||||
|
-- own collision group (see DRIVER_GROUP).
|
||||||
|
seat.CFrame = origin * CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2)
|
||||||
seat.Parent = model
|
seat.Parent = model
|
||||||
local seatWeld = Instance.new("WeldConstraint")
|
local seatWeld = Instance.new("WeldConstraint")
|
||||||
seatWeld.Part0 = body
|
seatWeld.Part0 = body
|
||||||
@@ -288,10 +363,219 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
if shellTemplate then
|
if shellTemplate then
|
||||||
local shell = shellTemplate:Clone()
|
local shell = shellTemplate:Clone()
|
||||||
shell.Name = "Shell"
|
shell.Name = "Shell"
|
||||||
-- Seat the shell on the chassis: drop it so the mesh's underside sits at
|
-- Measure the shell from its PARTS, not from Model:GetExtentsSize() /
|
||||||
-- roughly the body plane rather than floating at the body's centreline.
|
-- Model:GetBoundingBox(). Both of those return an axis-aligned box that
|
||||||
local ext = shell:GetExtentsSize()
|
-- is inflated by rotated parts: silv_s15 reports H=12.32 when its real
|
||||||
shell:PivotTo(body.CFrame * CFrame.new(0, ext.Y / 2 - Chassis.BODY_SIZE.Y / 2 - SHELL_DROP, 0))
|
-- geometry is only 4.71 tall, which made it hover ~4 studs in the air.
|
||||||
|
-- Accumulating each part's own rotated half-extents gives the true box.
|
||||||
|
local function trueAABB(model)
|
||||||
|
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||||
|
local hi = Vector3.new(-math.huge, -math.huge, -math.huge)
|
||||||
|
local found = false
|
||||||
|
for _, p in ipairs(model:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
found = true
|
||||||
|
local cf, s = p.CFrame, p.Size
|
||||||
|
local hx = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.X * s.X)
|
||||||
|
+ math.abs(cf.UpVector.X * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.X * s.Z)
|
||||||
|
)
|
||||||
|
local hy = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.Y * s.X)
|
||||||
|
+ math.abs(cf.UpVector.Y * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.Y * s.Z)
|
||||||
|
)
|
||||||
|
local hz = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.Z * s.X)
|
||||||
|
+ math.abs(cf.UpVector.Z * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.Z * s.Z)
|
||||||
|
)
|
||||||
|
local c = p.Position
|
||||||
|
lo = Vector3.new(math.min(lo.X, c.X - hx), math.min(lo.Y, c.Y - hy), math.min(lo.Z, c.Z - hz))
|
||||||
|
hi = Vector3.new(math.max(hi.X, c.X + hx), math.max(hi.Y, c.Y + hy), math.max(hi.Z, c.Z + hz))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not found then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
return lo, hi, hi - lo
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1. NORMALISE SIZE. Library meshes range 15.6 -> 19.0 studs long at
|
||||||
|
-- native scale, so cars looked inconsistent and the short ones had no
|
||||||
|
-- cabin room for the avatar. Uniform Model:ScaleTo keeps each mesh's
|
||||||
|
-- own proportions while making every car the same length.
|
||||||
|
-- The long axis is NOT always Z: gtr_r34 and hyper_x are modelled
|
||||||
|
-- rotated 90 degrees (length on X), so scaling by .Z blew them up to
|
||||||
|
-- ~2x. Use whichever horizontal axis is actually longer.
|
||||||
|
local _, _, rawSize = trueAABB(shell)
|
||||||
|
if rawSize then
|
||||||
|
local rawLen = math.max(rawSize.X, rawSize.Z)
|
||||||
|
if rawLen > 0.01 then
|
||||||
|
shell:ScaleTo(Chassis.SHELL_LENGTH / rawLen)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- 2. SIT IT ON THE GROUND. Align the true box's BOTTOM to the ground
|
||||||
|
-- plane the wheels rest on, then sink by SHELL_DROP so tyres aren't
|
||||||
|
-- swallowed. Measured against the pivot, since PivotTo moves the pivot.
|
||||||
|
local lo2 = trueAABB(shell)
|
||||||
|
local bottomOffset = 0
|
||||||
|
if lo2 then
|
||||||
|
bottomOffset = lo2.Y - shell:GetPivot().Position.Y
|
||||||
|
end
|
||||||
|
-- 2b. FACE THE SHELL FORWARD. PivotTo below forces the shell's pivot
|
||||||
|
-- orientation to the body's, so a shell inherits whatever yaw its
|
||||||
|
-- author happened to save it with -- there is no automatic
|
||||||
|
-- alignment. Three of the five library shells were modelled
|
||||||
|
-- nose-toward -Z (the chassis convention, since the front axle is
|
||||||
|
-- built at Z = -WHEELBASE/2) and looked right by luck; hatch_junk
|
||||||
|
-- and hyper_x were modelled nose-toward +Z and rendered BACKWARDS
|
||||||
|
-- on a correctly-built chassis.
|
||||||
|
-- Derive each shell's true forward from its OWN wheel names rather
|
||||||
|
-- than a hand-maintained per-car constant: F* wheels are the front
|
||||||
|
-- axle, R* the rear, so (front - rear) is the nose direction in the
|
||||||
|
-- shell's local frame. Solve the yaw that maps it onto the chassis
|
||||||
|
-- forward axis. This also covers 90-degree-rotated shells, and
|
||||||
|
-- self-corrects for any shell added later. If the cue is missing or
|
||||||
|
-- degenerate, rotate nothing (fail soft) rather than guess.
|
||||||
|
--
|
||||||
|
-- HISTORY: a SHELL_YAW_OVERRIDE table used to pin hatch_junk and
|
||||||
|
-- hyper_x to yaw 0, on the theory that their wheel labels were
|
||||||
|
-- reversed. That was WRONG and has been removed. Measurement
|
||||||
|
-- (tools/diag_shellyaw.luau) showed shell-vs-body yaw was already
|
||||||
|
-- 0.0 degrees on those cars -- there was no shell rotation error at
|
||||||
|
-- all. The real defect was the chassis forward convention being
|
||||||
|
-- inverted against the VehicleSeat (see buildCorner, isFront), which
|
||||||
|
-- made every car drive out of its labelled rear. With that fixed,
|
||||||
|
-- an override forcing yaw 0 would REINTRODUCE a 180-degree flip.
|
||||||
|
-- Do not re-add per-car yaw constants without first running
|
||||||
|
-- diag_shellyaw.luau and showing a non-zero yawDelta.
|
||||||
|
local shellYaw = 0
|
||||||
|
local wheelsFolder = shell:FindFirstChild("Wheels")
|
||||||
|
if wheelsFolder then
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local fSum, fN = Vector3.zero, 0
|
||||||
|
local rSum, rN = Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheelsFolder:GetChildren()) do
|
||||||
|
-- Use the group's CENTROID, not an arbitrary first BasePart:
|
||||||
|
-- FindFirstChildWhichIsA can return a rotor or caliper that
|
||||||
|
-- sits up to ~1 stud off the wheel centre (measured, see the
|
||||||
|
-- root-pick note in the wheel re-hang below).
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
local localPos = pivotInv * (sum / n)
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + localPos, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + localPos, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
-- Horizontal component only; a shell sitting nose-down must not
|
||||||
|
-- tilt the whole body.
|
||||||
|
if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then
|
||||||
|
-- Map the shell's nose onto chassis forward, which is -Z
|
||||||
|
-- (see the forward-convention note in buildCorner).
|
||||||
|
shellYaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Rotating about the pivot swings the bodywork sideways whenever the
|
||||||
|
-- pivot is not the geometric centre (hyper_x's centre sits 0.70 studs
|
||||||
|
-- off it, so a 180-degree flip would shift the shell 1.4 studs off the
|
||||||
|
-- chassis). Cancel that by re-centring the shell's horizontal AABB
|
||||||
|
-- centre onto the body axis; Y is untouched, so the ground alignment
|
||||||
|
-- solved above still holds (a yaw rotation cannot change world Y
|
||||||
|
-- extents).
|
||||||
|
local hi2 = select(2, trueAABB(shell))
|
||||||
|
local centreFix = CFrame.new()
|
||||||
|
if lo2 and hi2 then
|
||||||
|
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
|
||||||
|
centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
|
||||||
|
end
|
||||||
|
shell:PivotTo(
|
||||||
|
body.CFrame
|
||||||
|
* CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0)
|
||||||
|
* CFrame.Angles(0, shellYaw, 0)
|
||||||
|
* centreFix
|
||||||
|
)
|
||||||
|
-- 3. DROP THE SEAT TO FIT THIS SHELL'S ROOF. Cabin height varies by more
|
||||||
|
-- than a stud across the library (hyper_x roof sits 0.72 above the
|
||||||
|
-- body plane, gtr_r34 1.80). A single fixed seat height that looks
|
||||||
|
-- right in the tallest car pushes the driver's head through the
|
||||||
|
-- roof of the lowest one, so solve for the seat from the measured
|
||||||
|
-- roofline instead: seat = roof - head height - clearance.
|
||||||
|
local seatPart = model:FindFirstChild(Chassis.SEAT_NAME)
|
||||||
|
if seatPart and rawSize then
|
||||||
|
-- The roofline is NOT the top of the shell's bounding box. Every
|
||||||
|
-- library mesh carries junk above the cabin -- gtr_r34 has a stray
|
||||||
|
-- part literally named "Brick" 0.74 studs above its roof, rx7_fc
|
||||||
|
-- 0.51 -- so measuring to the AABB top invents headroom that isn't
|
||||||
|
-- there and the head pokes through the actual roof. Take the top of
|
||||||
|
-- the largest part by volume instead: that is the main body mesh.
|
||||||
|
local lo3, hi3 = trueAABB(shell)
|
||||||
|
local roofY = hi3 and hi3.Y or nil
|
||||||
|
local bestVol = -1
|
||||||
|
for _, p in ipairs(shell:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestVol then
|
||||||
|
bestVol = vol
|
||||||
|
local cf, sz = p.CFrame, p.Size
|
||||||
|
roofY = p.Position.Y
|
||||||
|
+ 0.5
|
||||||
|
* (math.abs(cf.RightVector.Y * sz.X) + math.abs(cf.UpVector.Y * sz.Y) + math.abs(
|
||||||
|
cf.LookVector.Y * sz.Z
|
||||||
|
))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if roofY and lo3 then
|
||||||
|
local roofLocal = roofY - body.Position.Y
|
||||||
|
-- HEAD_ABOVE_SEAT was measured from the seat part's CENTRE, but
|
||||||
|
-- seatLocal below is the pan height (the part is then placed at
|
||||||
|
-- seatLocal + Size.Y/2). Subtract the half-thickness here or every
|
||||||
|
-- car quietly loses that much of its intended clearance -- which
|
||||||
|
-- is why hyper_x measured 0.07 instead of the requested 0.30.
|
||||||
|
local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT - Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2
|
||||||
|
-- Allow sinking to the cabin floor: a low roof must win over the
|
||||||
|
-- nominal seat height, otherwise the clamp silently discards the
|
||||||
|
-- whole adaptive solve (which is what used to happen). The shell
|
||||||
|
-- bottom sits SHELL_DROP below the ground plane, so the seat can
|
||||||
|
-- go slightly under GROUND_OFFSET without breaching the underside
|
||||||
|
-- -- hyper_x (lowest roof) needs that headroom.
|
||||||
|
local floor = Chassis.GROUND_OFFSET - SHELL_DROP + 0.1
|
||||||
|
local seatLocal = math.clamp(wanted, floor, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND)
|
||||||
|
seatPart.CFrame = body.CFrame * CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2)
|
||||||
|
-- A WeldConstraint locks in the relative offset it had when it
|
||||||
|
-- was created, so moving the seat afterwards would be snapped
|
||||||
|
-- straight back. Rebuild the weld at the new offset.
|
||||||
|
for _, w in ipairs(body:GetChildren()) do
|
||||||
|
if w:IsA("WeldConstraint") and (w.Part1 == seatPart or w.Part0 == seatPart) then
|
||||||
|
w:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local seatWeld2 = Instance.new("WeldConstraint")
|
||||||
|
seatWeld2.Part0 = body
|
||||||
|
seatWeld2.Part1 = seatPart
|
||||||
|
seatWeld2.Parent = body
|
||||||
|
end
|
||||||
|
end
|
||||||
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
|
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
|
||||||
for _, d in ipairs(shell:GetDescendants()) do
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
if d:IsA("BasePart") then
|
if d:IsA("BasePart") then
|
||||||
@@ -311,6 +595,110 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
shell.Parent = model
|
shell.Parent = model
|
||||||
-- Hide the placeholder blocks the mesh replaces.
|
-- Hide the placeholder blocks the mesh replaces.
|
||||||
body.Transparency = 1
|
body.Transparency = 1
|
||||||
|
-- ...INCLUDING the physics wheels. The shell brings its own modelled
|
||||||
|
-- wheels, so leaving the chassis cylinders opaque puts TWO sets of
|
||||||
|
-- wheels on the car -- the mesh tyre plus a grey rubber cylinder over
|
||||||
|
-- it. That is what reads as absurdly wide tyres. They aren't even
|
||||||
|
-- concentric: the cylinder's outer edge sits at X=4.10 against the
|
||||||
|
-- gtr_r34 mesh tyre's 4.52, so the duplicate juts out past the real
|
||||||
|
-- wheel at a different track width.
|
||||||
|
-- This is deliberately INSIDE the shell branch. The procedural CarBody
|
||||||
|
-- fallback (else-branch) has no wheels of its own -- buildRims only
|
||||||
|
-- welds a small Rim disc INTO each Wheel_ part, at 1.2x radius versus
|
||||||
|
-- the wheel's 2x, and transparency is not inherited by children. Hide
|
||||||
|
-- these unconditionally and a shell-less car visibly rides on four
|
||||||
|
-- undersized floating discs.
|
||||||
|
for _, d in ipairs(model:GetChildren()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
d.Transparency = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- RE-HANG THE MODELLED WHEELS ON THE PHYSICS WHEELS. The imported
|
||||||
|
-- shells weld every wheel part straight to the body mesh (the template
|
||||||
|
-- weld graph is MB <-> FL, MB <-> Rotor, MB <-> Caliper, ...), so the
|
||||||
|
-- visible tyres are rigidly glued to the chassis: their assembly root
|
||||||
|
-- comes out as DriveSeat rather than the wheel. They never spin and
|
||||||
|
-- never steer -- painted-on hubcaps, which is most of why the wheels
|
||||||
|
-- looked wrong in motion. Cut those welds and re-weld each wheel group
|
||||||
|
-- to its own Wheel_ cylinder so it inherits that cylinder's spin, and
|
||||||
|
-- on the front axle its steer.
|
||||||
|
local wheelGroups = shell:FindFirstChild("Wheels")
|
||||||
|
if wheelGroups then
|
||||||
|
for _, w in ipairs(wheelGroups:GetChildren()) do
|
||||||
|
local phys = model:FindFirstChild("Wheel_" .. w.Name:upper())
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[#group + 1] = w
|
||||||
|
inGroup[w] = true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- ROOT MUST BE THE TYRE, so pick the largest-volume part in the
|
||||||
|
-- group. This previously used FindFirstChildWhichIsA, i.e. an
|
||||||
|
-- ARBITRARY first BasePart -- on the 18-part wheel groups that
|
||||||
|
-- lands on a rotor/caliper offset from the wheel centre. The
|
||||||
|
-- group is then translated to make root concentric with the
|
||||||
|
-- axle (see delta below), so a bad root displaces the visible
|
||||||
|
-- tyre by exactly that offset, which reads as wheels sticking
|
||||||
|
-- out past the bodywork. Measured root-to-centre offsets on the
|
||||||
|
-- templates (tools/diag_wheelroot2.luau): rx7_fc 0.955,
|
||||||
|
-- silv_s15 0.988, gtr_r34 0.285-0.310, hatch_junk 0.000-0.035
|
||||||
|
-- -- matching the report that the RX-7 was the obvious one.
|
||||||
|
local root, bestVol = nil, -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestVol then
|
||||||
|
root, bestVol = p, vol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and root then
|
||||||
|
-- Drop only the welds that tie this group to the OUTSIDE
|
||||||
|
-- (i.e. to the bodywork). Welds wholly inside the group are
|
||||||
|
-- the wheel's own construction and must survive.
|
||||||
|
for _, c in ipairs(shell:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn, bIn = a ~= nil and inGroup[a], b ~= nil and inGroup[b]
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
c:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Make it CONCENTRIC with the cylinder it now rides on.
|
||||||
|
-- Each shell models its own track and wheelbase (the
|
||||||
|
-- gtr_r34 rear tyre sits 1.34 studs ahead of the chassis
|
||||||
|
-- axle, the fronts 0.33 inboard). A weld preserves relative
|
||||||
|
-- offset, so welding at that offset would make the tyre
|
||||||
|
-- ORBIT the axle at 1.34 studs radius instead of spinning
|
||||||
|
-- on it -- far worse than the static wheel it replaces.
|
||||||
|
local delta = phys.Position - root.Position
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
p.CFrame = p.CFrame + delta
|
||||||
|
end
|
||||||
|
-- The template hung every part off the body, so with those
|
||||||
|
-- welds cut the group has no internal rigidity left of its
|
||||||
|
-- own. Rebuild it around root (these parts are already
|
||||||
|
-- Massless + CanCollide=false, so this adds no physics
|
||||||
|
-- cost), then hang root off the cylinder.
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
if p ~= root then
|
||||||
|
local iw = Instance.new("WeldConstraint")
|
||||||
|
iw.Part0 = root
|
||||||
|
iw.Part1 = p
|
||||||
|
iw.Parent = root
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local pw = Instance.new("WeldConstraint")
|
||||||
|
pw.Part0 = phys
|
||||||
|
pw.Part1 = root
|
||||||
|
pw.Parent = phys
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
local cabin = model:FindFirstChild("Cabin")
|
local cabin = model:FindFirstChild("Cabin")
|
||||||
if cabin then
|
if cabin then
|
||||||
cabin:Destroy()
|
cabin:Destroy()
|
||||||
@@ -445,8 +833,22 @@ function CarManager.spawn(player: Player, carId: string?)
|
|||||||
end
|
end
|
||||||
|
|
||||||
AntiCheat.grant(player)
|
AntiCheat.grant(player)
|
||||||
|
-- Driver ignores the chassis BEFORE sitting: the avatar is seated
|
||||||
|
-- down inside the body slab, and overlapping colliders at Sit() is
|
||||||
|
-- what launches characters.
|
||||||
|
setCharacterGroup(character, DRIVER_GROUP)
|
||||||
seat:Sit(humanoid)
|
seat:Sit(humanoid)
|
||||||
|
|
||||||
|
-- Restore normal collision when they get out, otherwise the player
|
||||||
|
-- walks around permanently unable to touch cars.
|
||||||
|
local seatConn: RBXScriptConnection
|
||||||
|
seatConn = seat:GetPropertyChangedSignal("Occupant"):Connect(function()
|
||||||
|
if seat.Occupant == nil then
|
||||||
|
setCharacterGroup(character, "Default")
|
||||||
|
seatConn:Disconnect()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
task.defer(function()
|
task.defer(function()
|
||||||
task.wait(SETTLE_TIME) -- let Sit() + the solver settle
|
task.wait(SETTLE_TIME) -- let Sit() + the solver settle
|
||||||
if not primary:IsDescendantOf(Workspace) then
|
if not primary:IsDescendantOf(Workspace) then
|
||||||
@@ -581,8 +983,11 @@ function CarManager.init()
|
|||||||
end
|
end
|
||||||
if gone or fell then
|
if gone or fell then
|
||||||
warn(
|
warn(
|
||||||
("[CarManager] recovering car for %s (destroyed=%s fell=%s)")
|
("[CarManager] recovering car for %s (destroyed=%s fell=%s)"):format(
|
||||||
:format(player.Name, tostring(gone), tostring(fell))
|
player.Name,
|
||||||
|
tostring(gone),
|
||||||
|
tostring(fell)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
-- CRITICAL: spawn() teleports the car to wherever the
|
-- CRITICAL: spawn() teleports the car to wherever the
|
||||||
-- CHARACTER is. During a void fall the seated character is
|
-- CHARACTER is. During a void fall the seated character is
|
||||||
@@ -634,4 +1039,9 @@ function CarManager.getRoot(player: Player): BasePart?
|
|||||||
return model.PrimaryPart
|
return model.PrimaryPart
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Test seam: build a bare chassis with no player, seat or network ownership.
|
||||||
|
-- Used by tools/diag_steering.luau to measure handling headlessly. Not part of
|
||||||
|
-- the game path -- CarManager.spawn is the only way a player ever gets a car.
|
||||||
|
CarManager._buildForDiag = buildCarModel
|
||||||
|
|
||||||
return CarManager
|
return CarManager
|
||||||
|
|||||||
@@ -31,14 +31,86 @@ Chassis.DRIVE_SIGN = -1 -- forward (-Z) needs negative angular velocity about +X
|
|||||||
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
|
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
|
||||||
|
|
||||||
-- Geometry (studs) ------------------------------------------------------------
|
-- Geometry (studs) ------------------------------------------------------------
|
||||||
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12)
|
-- Sized to REAL imported car meshes, not the other way around. The donor models
|
||||||
Chassis.WHEEL_RADIUS = 1.4
|
-- in ServerStorage/CarLibrary average 17.4 studs long / ~8.5 wide / ~5 tall,
|
||||||
|
-- which is correct for a Roblox character (4.7 studs tall) to sit in. The old
|
||||||
|
-- 12-stud chassis forced those meshes to be scaled DOWN ~26%, which left the
|
||||||
|
-- driver sticking out of the roof. Chassis now matches the meshes at 1:1 scale.
|
||||||
|
Chassis.BODY_SIZE = Vector3.new(6.6, 1.6, 17.0)
|
||||||
|
-- Wheel size is set from the shell length, not picked by eye. A real car's
|
||||||
|
-- tyre diameter is ~14% of its length; at SHELL_LENGTH 19 that is ~2.7 studs.
|
||||||
|
-- The old 4.0-stud tyre (21%) is what made every car read as a monster truck,
|
||||||
|
-- and because GROUND_OFFSET is derived from it the whole body rode 4.2 studs
|
||||||
|
-- up in the air to clear it.
|
||||||
|
Chassis.WHEEL_RADIUS = 1.35
|
||||||
Chassis.WHEEL_WIDTH = 1.1
|
Chassis.WHEEL_WIDTH = 1.1
|
||||||
Chassis.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre
|
Chassis.TRACK_HALF = 3.55 -- lateral distance body centre -> wheel centre
|
||||||
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle
|
Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle
|
||||||
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre
|
Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre
|
||||||
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
|
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
|
||||||
|
|
||||||
|
-- Steering knuckle ------------------------------------------------------------
|
||||||
|
-- The knuckle must be a SUBSTANTIAL body, not a token 0.5 cube. The steering
|
||||||
|
-- servo yaws the knuckle, and the knuckle has to carry the wheel (mass 5.67)
|
||||||
|
-- against tyre-to-ground friction. At the old 0.5 size the knuckle massed
|
||||||
|
-- 0.087 -- 65x LIGHTER than the wheel hanging off it -- and the servo simply
|
||||||
|
-- lost: measured under real controller input, CurrentAngle missed TargetAngle
|
||||||
|
-- by a mean of 32 deg (peak 72 deg) and broke through the servo's own
|
||||||
|
-- LimitsEnabled bounds on 6 of 20 samples. A constraint blowing past its hard
|
||||||
|
-- limits is the signature of an under-massed link, not of a weak motor:
|
||||||
|
-- raising ServoMaxTorque cannot fix it because the torque was never the
|
||||||
|
-- binding constraint.
|
||||||
|
--
|
||||||
|
-- Measured sweep (full lock, real SteerFloat input, gtr_r34):
|
||||||
|
-- size 0.5 (mass 0.09) -> mean err 32.2 deg, 6/20 limit violations, drives
|
||||||
|
-- size 1.6 (mass 2.87) -> mean err 2.2 deg, 1/20 violations, drives
|
||||||
|
-- density 80 (mass 328) -> mean err 0.1 deg, 0/20 violations, CAR FROZEN
|
||||||
|
-- Mass is what buys control, but overshoot it and the knuckles outweigh the
|
||||||
|
-- 125-mass body and the car cannot move at all. 1.6 studs at stock density
|
||||||
|
-- puts the knuckle in the same order of magnitude as its wheel, which is the
|
||||||
|
-- physical relationship that matters. Do NOT "improve" this with density.
|
||||||
|
Chassis.KNUCKLE_SIZE = 1.6
|
||||||
|
|
||||||
|
-- Cabin fit --------------------------------------------------------------------
|
||||||
|
-- The driver used to stick out of the roof for THREE compounding reasons, all
|
||||||
|
-- fixed here + in CarManager:
|
||||||
|
--
|
||||||
|
-- 1. SEAT AT ROOF HEIGHT. The Body slab sits at the car's vertical MIDDLE,
|
||||||
|
-- but the seat was mounted just above it (BODY_SIZE.Y/2 + 0.3). That put
|
||||||
|
-- the avatar's hips level with the car's waistline and its head a full
|
||||||
|
-- stud above the roof. A seat belongs on the CABIN FLOOR, which is near
|
||||||
|
-- the car's BOTTOM -- i.e. measured up from the ground plane, not up from
|
||||||
|
-- the body slab. SEAT_ABOVE_GROUND does that.
|
||||||
|
-- 2. SHELLS NEVER SCALED. Imported meshes were parented at native size, which
|
||||||
|
-- ranges 15.4 -> 19.0 studs long across the library. Cars visibly differed
|
||||||
|
-- in size and the short ones had no cabin room. SHELL_LENGTH normalises
|
||||||
|
-- every shell to one length; the mesh's own proportions are preserved
|
||||||
|
-- because the scale is uniform (Model:ScaleTo).
|
||||||
|
-- 3. SHELL FLOATED. Placement used ext.Y/2 (a raw extents number that ignores
|
||||||
|
-- a model's pivot offset -- silv_s15's pivot is 1.45 studs off its own
|
||||||
|
-- geometry), so shells hovered above the wheels. CarManager now aligns the
|
||||||
|
-- shell's true bounding box BOTTOM to the ground plane.
|
||||||
|
--
|
||||||
|
-- Ground plane, relative to the Body part's centre: the wheels rest on it, so
|
||||||
|
-- it is one suspension drop plus one wheel radius below the body centre.
|
||||||
|
Chassis.GROUND_OFFSET = -(Chassis.SUSPENSION_DROP + Chassis.WHEEL_RADIUS)
|
||||||
|
-- Height of the seat PAN above that ground plane (cabin floor + cushion).
|
||||||
|
-- This is the STARTING height; CarManager lowers it per-car after the shell is
|
||||||
|
-- measured (see HEAD_ABOVE_SEAT / ROOF_CLEARANCE), because shells differ in
|
||||||
|
-- roof height by over a stud (hyper_x 4.92 vs gtr_r34 6.00).
|
||||||
|
Chassis.SEAT_ABOVE_GROUND = 0.45
|
||||||
|
-- Measured in Play on a default R15 avatar: the head CENTRE sits 3.27 above the
|
||||||
|
-- seat pan, but the head is 1.20 tall, so the CROWN is 3.87 up. The old 3.27
|
||||||
|
-- constant compared a centre against a roofline and so under-measured the
|
||||||
|
-- avatar by half a head -- the head cleared on paper and poked out on screen.
|
||||||
|
-- This is deliberately the crown, i.e. the number that must stay under the roof.
|
||||||
|
Chassis.HEAD_ABOVE_SEAT = 3.90
|
||||||
|
-- Target gap between the crown and the shell roofline. Now that HEAD_ABOVE_SEAT
|
||||||
|
-- is a true crown height this is real headroom, so it can be modest.
|
||||||
|
Chassis.ROOF_CLEARANCE = 0.30
|
||||||
|
-- Every imported shell is uniformly scaled so its bounding box is this long.
|
||||||
|
Chassis.SHELL_LENGTH = 19.0
|
||||||
|
|
||||||
-- Per-car tuning derived from stats -------------------------------------------
|
-- Per-car tuning derived from stats -------------------------------------------
|
||||||
export type Tuning = {
|
export type Tuning = {
|
||||||
MaxSpeed: number, -- studs/sec target top speed
|
MaxSpeed: number, -- studs/sec target top speed
|
||||||
@@ -71,6 +143,14 @@ export type Tuning = {
|
|||||||
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
|
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
|
||||||
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
|
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
|
||||||
-- that band by Handling so a grippy car is also a composed one.
|
-- that band by Handling so a grippy car is also a composed one.
|
||||||
|
--
|
||||||
|
-- !! CURRENTLY INERT !! CarManager.buildCorner welds the hub rigidly to the
|
||||||
|
-- body (see the comment there: a light hub on a stiff spring made the solver
|
||||||
|
-- diverge and fling the wheels away). There is no SpringConstraint in the rig
|
||||||
|
-- today, so SpringStiffness/SpringDamping below are computed but never
|
||||||
|
-- consumed -- the ride is rigid, not damped. These stay here because the
|
||||||
|
-- numbers are now correct and are what a re-introduced spring should use;
|
||||||
|
-- do NOT read a smooth ride-height trace as evidence they are working.
|
||||||
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
|
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
|
||||||
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
|
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
|
||||||
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
|
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
|
||||||
|
|||||||
@@ -873,6 +873,78 @@ for _, r in ipairs(RAW) do
|
|||||||
bridge = (r[5] :: number) == 1,
|
bridge = (r[5] :: number) == 1,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
-- DAIKOKU PA UNDERPASS ELEVATION ----------------------------------------------
|
||||||
|
-- Reference (Commons "Aerial view of Daikoku Junction", CC0, plus the
|
||||||
|
-- ground-level PA shots): the parking apron is at GROUND level and the
|
||||||
|
-- expressway flies OVER it on pillars. Cars park underneath the decks; the
|
||||||
|
-- PA's circulation road passes beneath the mainline. The elevated roadway
|
||||||
|
-- above a flat lot is the iconic look, and it is also what makes the PA
|
||||||
|
-- drivable.
|
||||||
|
--
|
||||||
|
-- BUG this fixes (2026-08-02): the mainline and the PA were BOTH baked at
|
||||||
|
-- y=46, and the PA ring road (radius 390) reaches 60 studs past the mainline
|
||||||
|
-- centreline (PA centre is only 330 studs off it). Same height + overlapping
|
||||||
|
-- footprint meant the ring's parapet stood in the middle of the carriageway --
|
||||||
|
-- anyone driving the wangan past the PA was destroyed by a wall in the road.
|
||||||
|
-- Vertical separation is what resolves it; the footprints are SUPPOSED to
|
||||||
|
-- overlap, exactly as they do in the aerial.
|
||||||
|
--
|
||||||
|
-- Applied here as a smooth profile rather than by hand-editing 1050 baked
|
||||||
|
-- heights. Everything downstream (deck, median, barrier, kill wall, lane
|
||||||
|
-- paint, ramps) is built from sample.pos, so lifting the samples lifts the
|
||||||
|
-- whole structure coherently.
|
||||||
|
-- 2026-08-02: raised 38 -> 56. 38 studs (~10.6 m) was less than the height of
|
||||||
|
-- the structures standing on the apron, so the mainline deck cut straight
|
||||||
|
-- through them -- PAHonkan (13 m / 46.4 studs) surfaced through Road_536..539,
|
||||||
|
-- which is the "building sitting on the road" artefact. Clearance must exceed
|
||||||
|
-- the TALLEST thing under the deck, not just a car. Honkan top sits 46.4 studs
|
||||||
|
-- above the apron, so the slab underside (46 + PA_LIFT - 1) needs PA_LIFT > 48.
|
||||||
|
-- 56 gives underside 101 -- 7.6 studs of daylight over the roof. This is also
|
||||||
|
-- closer to the real Bayshore Route viaduct, which clears the PA by ~15-20 m,
|
||||||
|
-- not 10. Verify with tools/diag_clearance.luau after changing.
|
||||||
|
Circuit.PA_LIFT = 56 -- studs of clearance over the apron (tallest = Honkan 46.4)
|
||||||
|
local LIFT_FLAT = 20 -- samples held at full lift, centred on the PA
|
||||||
|
-- Widened 18 -> 26 alongside the lift increase: the climb is spread over more
|
||||||
|
-- samples so the grade stays where it was instead of getting 47% steeper.
|
||||||
|
local LIFT_RAMP = 26 -- samples of smooth transition on each side
|
||||||
|
do
|
||||||
|
local c = Circuit.PA_CENTER
|
||||||
|
local n = #samples
|
||||||
|
-- Same "nearest non-tunnel sample" rule planPA uses, so the lift and the
|
||||||
|
-- ramp junctions can never disagree about where the PA is.
|
||||||
|
local bestIdx, bestD = 1, math.huge
|
||||||
|
for i, s in ipairs(samples) do
|
||||||
|
local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude
|
||||||
|
if not s.tunnel and d < bestD then
|
||||||
|
bestIdx, bestD = i, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Circuit.PA_NEAREST = bestIdx
|
||||||
|
|
||||||
|
local function circDist(i, j)
|
||||||
|
local d = math.abs(i - j)
|
||||||
|
return math.min(d, n - d)
|
||||||
|
end
|
||||||
|
|
||||||
|
for i, s in ipairs(samples) do
|
||||||
|
local d = circDist(i, bestIdx)
|
||||||
|
local t
|
||||||
|
if d <= LIFT_FLAT then
|
||||||
|
t = 1
|
||||||
|
elseif d >= LIFT_FLAT + LIFT_RAMP then
|
||||||
|
t = 0
|
||||||
|
else
|
||||||
|
-- Smoothstep: zero slope at both ends, so the grade eases in and
|
||||||
|
-- out instead of kinking the deck at the seam.
|
||||||
|
local u = (d - LIFT_FLAT) / LIFT_RAMP
|
||||||
|
t = 1 - (u * u * (3 - 2 * u))
|
||||||
|
end
|
||||||
|
if t > 0 then
|
||||||
|
s.pos = Vector3.new(s.pos.X, s.pos.Y + Circuit.PA_LIFT * t, s.pos.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
Circuit.samples = samples
|
Circuit.samples = samples
|
||||||
|
|
||||||
-- Cumulative arc length at each sample, and total lap length ------------------
|
-- Cumulative arc length at each sample, and total lap length ------------------
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the speed-change (aux) lanes ANCHORED to the parts themselves.
|
||||||
|
|
||||||
|
Hardcoded camera offsets are how you end up photographing empty air after the
|
||||||
|
geometry moves. Every shot here is framed from the LIVE position of the aux
|
||||||
|
lane / slip parts queried out of the DataModel at capture time, so the camera
|
||||||
|
cannot drift away from the thing under test.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# Ask Studio where the aux lanes actually are, then frame from that.
|
||||||
|
# Returns "x y z | x y z ..." anchors: west aux mid, east aux mid, and the
|
||||||
|
# two slip mouths.
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local aux = {}
|
||||||
|
local mouths = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local ai = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if ai then table.insert(aux, {i = tonumber(ai), p = d.Position}) end
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k and tonumber(k) <= 1 then mouths[s] = d.Position end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #aux == 0 then return "NOAUX" end
|
||||||
|
table.sort(aux, function(a, b) return a.i < b.i end)
|
||||||
|
-- Split the aux segments into two clusters (west run and east run) by index gap.
|
||||||
|
local groups, cur = {}, {aux[1]}
|
||||||
|
for k = 2, #aux do
|
||||||
|
if aux[k].i - aux[k - 1].i > 3 then
|
||||||
|
table.insert(groups, cur); cur = {}
|
||||||
|
end
|
||||||
|
table.insert(cur, aux[k])
|
||||||
|
end
|
||||||
|
table.insert(groups, cur)
|
||||||
|
local parts = {}
|
||||||
|
for _, g in ipairs(groups) do
|
||||||
|
local mid = g[math.max(1, math.floor(#g / 2))].p
|
||||||
|
table.insert(parts, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
|
||||||
|
end
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local m = mouths[s]
|
||||||
|
if m then table.insert(parts, ("%.1f %.1f %.1f"):format(m.X, m.Y, m.Z)) end
|
||||||
|
end
|
||||||
|
return table.concat(parts, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
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 "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
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)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOAUX" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
# Pull the coordinate triples out of whatever wrapper the bridge returns.
|
||||||
|
import re
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if not trip:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
pts = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
print("anchors: " + str(pts))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
for n, (x, y, z) in enumerate(pts[:2], start=1):
|
||||||
|
# Oblique from outside the deck, looking back at the merge.
|
||||||
|
d = grab(f"aux{n}_oblique", x + 260, y + 150, z + 260, x, y, z, fov=50)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
# Top-down: the merge taper reads clearest from directly above.
|
||||||
|
d = grab(f"aux{n}_topdown", x + 3, y + 430, z + 3, x, y, z, fov=42)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
# Driver's-eye down the lane.
|
||||||
|
d = grab(f"aux{n}_driver", x - 190, y + 16, z - 60, x + 120, y + 8, z + 40, fov=68)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the two spots the user circled in their screenshot.
|
||||||
|
|
||||||
|
Their view looks along the mainline with the ramp descending on the left; the
|
||||||
|
two marks sit at the suppression boundary (mainline samples ~525 and ~551),
|
||||||
|
where the aux lane ends and the parapet transitions back to the deck edge.
|
||||||
|
Anchor the camera to those SAMPLES, live, so the shot cannot drift.
|
||||||
|
|
||||||
|
left oval -> sample 525 area: was a 4-sample hole with no barrier
|
||||||
|
right circle -> sample 551 area: was a 26-stud protruding fence stub
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local deck = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, i in ipairs({520, 525, 538, 551, 556}) do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(d.Position.X, d.Position.Y, d.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #out < 5 then return "NOSAMP" end
|
||||||
|
return table.concat(out, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
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 "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
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)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOSAMP" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if len(trip) < 5:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
p = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
s520, s525, s538, s551, s556 = p
|
||||||
|
print("anchors: " + str(p))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
|
||||||
|
# LEFT OVAL: the formerly-bare run. Look along the road from upstream so the
|
||||||
|
# barrier line is seen edge-on -- a gap would read as an obvious break.
|
||||||
|
made.append(grab("fix_left_oval",
|
||||||
|
s520[0] - 90, s520[1] + 55, s520[2] - 90,
|
||||||
|
s538[0], s538[1], s538[2], fov=55))
|
||||||
|
|
||||||
|
# RIGHT CIRCLE: the transition where the fence used to jut out 26 studs.
|
||||||
|
made.append(grab("fix_right_circle",
|
||||||
|
s556[0] + 120, s556[1] + 50, s556[2] + 120,
|
||||||
|
s551[0], s551[1], s551[2], fov=50))
|
||||||
|
|
||||||
|
# Top-down over each boundary: a jut shows instantly as a step in the line.
|
||||||
|
made.append(grab("fix_boundary_525_top",
|
||||||
|
s525[0], s525[1] + 300, s525[2],
|
||||||
|
s525[0], s525[1], s525[2], fov=55))
|
||||||
|
made.append(grab("fix_boundary_551_top",
|
||||||
|
s551[0], s551[1] + 300, s551[2],
|
||||||
|
s551[0], s551[1], s551[2], fov=55))
|
||||||
|
|
||||||
|
# Wide overview matching the user's framing.
|
||||||
|
made.append(grab("fix_overview",
|
||||||
|
s538[0] - 420, s538[1] + 240, s538[2] - 420,
|
||||||
|
s538[0], s538[1], s538[2], fov=60))
|
||||||
|
|
||||||
|
made = [m for m in made if m]
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the PA slip ramps from the user's own viewpoint, LIVE-ANCHORED.
|
||||||
|
|
||||||
|
The user's screenshot looks along the mainline from above the deck with the
|
||||||
|
ramp descending on the left. Reproduce that framing from the actual ramp
|
||||||
|
geometry so the shot cannot drift onto empty air after an edit.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# Anchors: for each slip -> mouth (first deck seg), mid-span, landing (last seg).
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then
|
||||||
|
segs[s] = segs[s] or {}
|
||||||
|
table.insert(segs[s], {i = tonumber(k), p = d.Position})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local g = segs[s]
|
||||||
|
if g and #g > 2 then
|
||||||
|
table.sort(g, function(a, b) return a.i < b.i end)
|
||||||
|
local mouth = g[1].p
|
||||||
|
local mid = g[math.floor(#g / 2)].p
|
||||||
|
local land = g[#g].p
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(mouth.X, mouth.Y, mouth.Z))
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(land.X, land.Y, land.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #out == 0 then return "NOSLIP" end
|
||||||
|
return table.concat(out, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
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 "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
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)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOSLIP" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if len(trip) < 6:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
pts = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
print("anchors: " + str(pts))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
for n, base in enumerate((0, 3), start=1):
|
||||||
|
mouth, mid, land = pts[base], pts[base + 1], pts[base + 2]
|
||||||
|
tag = ["west", "east"][n - 1]
|
||||||
|
|
||||||
|
# 1. The user's view: high, behind the mouth, looking down the ramp.
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_userview",
|
||||||
|
mouth[0] - 300, mouth[1] + 120, mouth[2] - 300,
|
||||||
|
land[0], land[1], land[2], fov=60))
|
||||||
|
|
||||||
|
# 2. Side elevation: shows the descent profile against the deck.
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_side",
|
||||||
|
mid[0] + 420, mid[1] + 90, mid[2],
|
||||||
|
mid[0], mid[1], mid[2], fov=48))
|
||||||
|
|
||||||
|
# 3. Top-down over mid-span: is it parallel to the deck or cutting away?
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_topdown",
|
||||||
|
mid[0], mid[1] + 560, mid[2],
|
||||||
|
mid[0], mid[1], mid[2], fov=55))
|
||||||
|
|
||||||
|
made = [m for m in made if m]
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture a set of Studio viewport angles of the Daikoku PA junction.
|
||||||
|
|
||||||
|
Sets the Edit-mode camera via execute_luau, then pulls a screen_capture for
|
||||||
|
each shot. Bridge calls are slow (~30-60s each), so this runs as one batch.
|
||||||
|
|
||||||
|
NOTE (corrects an old assumption): screen_capture DOES work on this setup.
|
||||||
|
It needs capture_id='ScreenCapture_1'. Earlier notes claimed the Studio
|
||||||
|
viewport could not be screenshotted from wks-victus -- that was wrong.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# PA centre measured from the built parts.
|
||||||
|
CX, CY, CZ = -4116, 51, -7479
|
||||||
|
|
||||||
|
SHOTS = [
|
||||||
|
# name, camera offset from PA centre, look-at offset
|
||||||
|
("01_overview_oblique", (520, 340, 520), (0, 0, 0)),
|
||||||
|
("02_top_down", (5, 900, 5), (0, 0, 0)),
|
||||||
|
("03_underneath_clearance", (330, 20, 300), (0, 30, 0)),
|
||||||
|
("04_onramp_low", (-250, 70, 60), (60, 20, -40)),
|
||||||
|
("05_offramp_low", (300, 75, -160), (-60, 20, 40)),
|
||||||
|
("06_section_side", (900, 120, 40), (0, 25, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
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 "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
made = []
|
||||||
|
for name, (ox, oy, oz), (tx, ty, tz) in SHOTS:
|
||||||
|
fov = 55 if "top_down" not in name else 40
|
||||||
|
code = CAM_TPL.format(
|
||||||
|
fov=fov,
|
||||||
|
px=CX + ox,
|
||||||
|
py=CY + oy,
|
||||||
|
pz=CZ + oz,
|
||||||
|
tx=CX + tx,
|
||||||
|
ty=CY + ty,
|
||||||
|
tz=CZ + tz,
|
||||||
|
)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
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)[:160]}")
|
||||||
|
continue
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
made.append(dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
--[[
|
||||||
|
check_live_source.luau -- did the pushed CarManager source actually land,
|
||||||
|
and is the running session using it?
|
||||||
|
|
||||||
|
diag_steering still reports "found 4 motors" after a push that reported
|
||||||
|
assembled==expected match=true. Two candidate explanations:
|
||||||
|
1. the source in the DataModel is stale (push landed elsewhere), or
|
||||||
|
2. the source is fresh but require() returned a CACHED module instance
|
||||||
|
from earlier in this Play session, so the old build function is live.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local mod = server and server:FindFirstChild("CarManager")
|
||||||
|
log("module:", mod and mod:GetFullName() or "<not found>")
|
||||||
|
|
||||||
|
if mod then
|
||||||
|
local src = mod.Source
|
||||||
|
log("source bytes:", #src)
|
||||||
|
log("has REAR-WHEEL DRIVE marker:", tostring(src:find("REAR%-WHEEL DRIVE") ~= nil))
|
||||||
|
log("has 'if isFront then\\n\\t\\treturn' guard:", tostring(src:find("isFront then\n\t\treturn") ~= nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How many CarManager-like ModuleScripts exist anywhere? A duplicate copy
|
||||||
|
-- would explain a push landing in the wrong place.
|
||||||
|
local hits = {}
|
||||||
|
for _, d in ipairs(game:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
table.insert(hits, d:GetFullName() .. " (" .. #d.Source .. " bytes)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log("all CarManager ModuleScripts in this DataModel:")
|
||||||
|
for _, h in ipairs(hits) do
|
||||||
|
log(" " .. h)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- CONTROL + CONNECTIVITY.
|
||||||
|
-- 1. Control: deliberately test a part known to intersect structure, so a
|
||||||
|
-- 0-hit result on the ramps means "clear", not "probe is broken".
|
||||||
|
-- 2. Connectivity: largest gap between consecutive deck segments within each
|
||||||
|
-- run, and the trunk->near handoff distance.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "no Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
local structural, runs = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
|
||||||
|
table.insert(structural, d)
|
||||||
|
end
|
||||||
|
for _, base in ipairs({
|
||||||
|
"PAEntryRampTrunk",
|
||||||
|
"PAEntryRampCross",
|
||||||
|
"PAEntryRampNear",
|
||||||
|
"PAExitRampTrunk",
|
||||||
|
"PAExitRampCross",
|
||||||
|
"PAExitRampNear",
|
||||||
|
}) do
|
||||||
|
if nm:sub(1, #base + 1) == base .. "_" then
|
||||||
|
runs[base] = runs[base] or {}
|
||||||
|
table.insert(runs[base], d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function overlaps(p, s)
|
||||||
|
local d1, d2 = p.Position, s.Position
|
||||||
|
return math.abs(d1.X - d2.X) < (p.Size.X + s.Size.X) / 2
|
||||||
|
and math.abs(d1.Z - d2.Z) < (p.Size.Z + s.Size.Z) / 2
|
||||||
|
and math.abs(d1.Y - d2.Y) < (p.Size.Y + s.Size.Y) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
|
||||||
|
-- CONTROL: a probe box placed exactly on the first structural part must hit.
|
||||||
|
local ctrl = structural[1]
|
||||||
|
local fake = { Position = ctrl.Position, Size = Vector3.new(4, 4, 4) }
|
||||||
|
local ctrlHits = 0
|
||||||
|
for _, s in ipairs(structural) do
|
||||||
|
if overlaps(fake, s) then
|
||||||
|
ctrlHits += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("CONTROL (box on a known pier): hits=%d -> probe %s"):format(
|
||||||
|
ctrlHits,
|
||||||
|
ctrlHits > 0 and "WORKS" or "IS BROKEN/VACUOUS"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table.insert(out, "structuralParts=" .. #structural)
|
||||||
|
|
||||||
|
-- CONNECTIVITY
|
||||||
|
local function sortKey(p)
|
||||||
|
return tonumber(p.Name:match("_(%d+)$")) or 0
|
||||||
|
end
|
||||||
|
for _, base in ipairs({
|
||||||
|
"PAEntryRampTrunk",
|
||||||
|
"PAEntryRampCross",
|
||||||
|
"PAEntryRampNear",
|
||||||
|
"PAExitRampTrunk",
|
||||||
|
"PAExitRampCross",
|
||||||
|
"PAExitRampNear",
|
||||||
|
}) do
|
||||||
|
local parts = runs[base]
|
||||||
|
if not parts then
|
||||||
|
table.insert(out, base .. ": MISSING")
|
||||||
|
else
|
||||||
|
table.sort(parts, function(a, b)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
local maxGap = 0
|
||||||
|
for i = 2, #parts do
|
||||||
|
local g = (parts[i].Position - parts[i - 1].Position).Magnitude
|
||||||
|
maxGap = math.max(maxGap, g)
|
||||||
|
end
|
||||||
|
table.insert(out, ("%s: n=%d maxSegGap=%.1f"):format(base, #parts, maxGap))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 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)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
table.sort(nr, function(a, b)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
local best = math.huge
|
||||||
|
for _, a in ipairs({ t[1], t[#t] }) do
|
||||||
|
for _, b in ipairs({ nr[1], nr[#nr] }) do
|
||||||
|
best = math.min(best, (a.Position - b.Position).Magnitude)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(out, ("HANDOFF %s->%s closestEnd=%.1f"):format(pair[1], pair[2], best))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
After Width: | Height: | Size: 151 KiB |
@@ -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")
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_bootcheck.luau -- decide facing using the BOOT/SPOILER cue, which is
|
||||||
|
directly interpretable, and verify it survives the build.
|
||||||
|
|
||||||
|
Findings so far:
|
||||||
|
* gtr_r34 template: wheels -10.27, hood -6.22, boot +6.64 -> all three
|
||||||
|
cues AGREE, nose at -Z. So wheel labels are NOT swapped.
|
||||||
|
* bodywork "asym" gave gtr_r34 -0.37 while rx7_fc/silv_s15 were
|
||||||
|
positive, yet all three have boot at +Z. asym is therefore a poor
|
||||||
|
proxy (it is dominated by whichever big slab a given shell happens to
|
||||||
|
have) and its verdicts must be discarded.
|
||||||
|
|
||||||
|
The boot/spoiler is unambiguous: it belongs at the TAIL. On a correctly
|
||||||
|
faced car (chassis forward = -Z) the boot must land at POSITIVE body-local
|
||||||
|
Z. Measure that on the BUILT car, per shell, and compare to the template.
|
||||||
|
Shells with no named boot get a fallback: the tail is the end furthest
|
||||||
|
from the front wheels, per that shell's own wheel cue.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
local CarManager = require(cm)
|
||||||
|
|
||||||
|
local TAIL = { boot = true, spoiler = true, trunk = true, taillight = true, exhaust = true, diffuser = true }
|
||||||
|
local NOSE = { hood = true, windshield = true, bonnet = true, grille = true, headlight = true, splitter = true }
|
||||||
|
|
||||||
|
local function cueZ(root: Instance, frame: CFrame)
|
||||||
|
local inv = frame:Inverse()
|
||||||
|
local tS, tN, nS, nN = 0, 0, 0, 0
|
||||||
|
for _, p in ipairs(root:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
local z = (inv * p.Position).Z
|
||||||
|
if TAIL[nm] then
|
||||||
|
tS, tN = tS + z, tN + 1
|
||||||
|
elseif NOSE[nm] then
|
||||||
|
nS, nN = nS + z, nN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return (tN > 0 and tS / tN or nil), (nN > 0 and nS / nN or nil), tN, nN
|
||||||
|
end
|
||||||
|
|
||||||
|
log("chassis forward = -Z, so TAIL parts must land at POSITIVE body-local Z.")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-14s %22s %30s", "car", "TEMPLATE (tail/nose)", "BUILT (tail/nose) verdict"))
|
||||||
|
log(string.rep("-", 74))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = t.Name
|
||||||
|
local tT, tNz, tCount, nCount = cueZ(t, t:GetPivot())
|
||||||
|
|
||||||
|
local bT, bN = nil, nil
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 3 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local sh = m:FindFirstChild("Shell")
|
||||||
|
if sh then
|
||||||
|
bT, bN = cueZ(sh, m.PrimaryPart.CFrame)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if bT then
|
||||||
|
verdict = bT > 0 and "CORRECT" or "*** BACKWARDS ***"
|
||||||
|
elseif bN then
|
||||||
|
verdict = bN < 0 and "CORRECT" or "*** BACKWARDS ***"
|
||||||
|
else
|
||||||
|
verdict = "no named cue - cannot judge"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s tail %8s nose %7s tail %8s nose %7s %s",
|
||||||
|
id,
|
||||||
|
tT and string.format("%+.2f", tT) or "n/a",
|
||||||
|
tNz and string.format("%+.2f", tNz) or "n/a",
|
||||||
|
bT and string.format("%+.2f", bT) or "n/a",
|
||||||
|
bN and string.format("%+.2f", bN) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
cm:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
--[[
|
||||||
|
diag_builtnose.luau -- END-TO-END measurement. Build each car for real via
|
||||||
|
CarManager._buildForDiag, then measure where the finished shell's nose
|
||||||
|
actually points relative to the chassis forward axis.
|
||||||
|
|
||||||
|
Why this and not more code reading: the solver computes the right 180-degree
|
||||||
|
yaw (diag_solveryaw) and applies it without centring error (diag_centrefix),
|
||||||
|
yet two cars still look backwards. So something AFTER the PivotTo must be
|
||||||
|
undoing it. Rather than guess which line, measure the finished article.
|
||||||
|
|
||||||
|
noseDot = (shell nose direction) dot (body LookVector)
|
||||||
|
+1 -> shell nose points where the car drives (CORRECT)
|
||||||
|
-1 -> shell nose points backwards (REVERSED)
|
||||||
|
|
||||||
|
Cache guard first: require() caches per Studio session, so a stale
|
||||||
|
CarManager would silently build old code and make this whole test a lie.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local cmMod = find("CarManager")
|
||||||
|
if not cmMod then
|
||||||
|
return "FATAL: CarManager not found"
|
||||||
|
end
|
||||||
|
-- Guard: the reverted source must be what require() hands back.
|
||||||
|
local srcOK = cmMod.Source:find("isFront = zSign < 0", 1, true) ~= nil
|
||||||
|
if not srcOK then
|
||||||
|
return "FATAL: CarManager source is not the reverted version -- aborting"
|
||||||
|
end
|
||||||
|
local ok, CM = pcall(require, cmMod)
|
||||||
|
if not ok then
|
||||||
|
return "FATAL: require(CarManager) failed: " .. tostring(CM)
|
||||||
|
end
|
||||||
|
if type(CM._buildForDiag) ~= "function" then
|
||||||
|
return "FATAL: CarManager._buildForDiag missing (stale require cache?) -- restart Studio"
|
||||||
|
end
|
||||||
|
|
||||||
|
local Cars = require(RS:WaitForChild("Shared"):WaitForChild("Cars"))
|
||||||
|
local ChassisMod = require(RS:WaitForChild("Shared"):WaitForChild("Chassis"))
|
||||||
|
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
|
||||||
|
log(string.format("%-11s %-10s %-12s %s", "shell", "noseDot", "yaw err", "verdict"))
|
||||||
|
log(string.rep("-", 62))
|
||||||
|
|
||||||
|
local built = 0
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
-- buildCarModel(car: Car, tuning: Tuning) -- NOT a string id. Passing a
|
||||||
|
-- string was the harness bug that produced the CarManager:277 nil error.
|
||||||
|
local carDef = Cars.Get(name)
|
||||||
|
if not carDef then
|
||||||
|
log(string.format("%-11s NOT IN Cars.List", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local tuning = ChassisMod.tuningFor(carDef)
|
||||||
|
local okB, car = pcall(CM._buildForDiag, carDef, tuning)
|
||||||
|
if not okB or not car then
|
||||||
|
log(string.format("%-11s BUILD FAILED: %s", name, tostring(car)))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
built += 1
|
||||||
|
|
||||||
|
local body = car:FindFirstChild("Body") or car.PrimaryPart
|
||||||
|
local shell = nil
|
||||||
|
for _, d in ipairs(car:GetChildren()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") then
|
||||||
|
shell = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not body or not shell then
|
||||||
|
log(string.format("%-11s no body/shell on built car", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shell nose in WORLD space, from the shell's own wheel labels.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + (sum / n), fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + (sum / n), rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s no F/R labels on built shell", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
nose = Vector3.new(nose.X, 0, nose.Z)
|
||||||
|
if nose.Magnitude < 0.01 then
|
||||||
|
log(string.format("%-11s degenerate nose vector", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
nose = nose.Unit
|
||||||
|
|
||||||
|
local fwd = body.CFrame.LookVector
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z).Unit
|
||||||
|
|
||||||
|
local dot = nose:Dot(fwd)
|
||||||
|
local yawErr = math.deg(math.acos(math.clamp(dot, -1, 1)))
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if dot > 0.85 then
|
||||||
|
verdict = "CORRECT (nose = drive dir)"
|
||||||
|
elseif dot < -0.85 then
|
||||||
|
verdict = "*** REVERSED ***"
|
||||||
|
else
|
||||||
|
verdict = "skewed"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-10s %-12s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.3f", dot),
|
||||||
|
string.format("%.1f deg", yawErr),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
car:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if built == 0 then
|
||||||
|
log(">>> NOTHING BUILT -- this is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> built and measured %d/5 cars, then destroyed them (no scene changes)", built))
|
||||||
|
log(" This is the finished article, after every stage of the build pipeline.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
--[[
|
||||||
|
diag_centrefix.luau -- test whether centreFix is applied in the wrong order.
|
||||||
|
|
||||||
|
The build does:
|
||||||
|
shell:PivotTo(body.CFrame * heightCF * CFrame.Angles(0,yaw,0) * centreFix)
|
||||||
|
|
||||||
|
centreFix is a translation derived from the shell's CURRENT (unrotated)
|
||||||
|
AABB centre. Placing it to the RIGHT of CFrame.Angles means it is expressed
|
||||||
|
in the ROTATED frame, so a 180-degree yaw negates its X and Z. For the three
|
||||||
|
shells with yaw ~= 0 this is harmless. For hatch_junk and hyper_x (yaw 180)
|
||||||
|
it moves the shell by -2 * centreLocal instead of cancelling the offset.
|
||||||
|
|
||||||
|
This measures the resulting AABB-centre error for BOTH orderings, per shell.
|
||||||
|
The numbers can come out either way -- if the current ordering is fine, both
|
||||||
|
errors are ~0 and the hypothesis is dead.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
for _, t in ipairs(TARGETS) do
|
||||||
|
if d.Name == t then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Replicate trueAABB: min/max corner over all BaseParts, world space.
|
||||||
|
local function trueAABB(model)
|
||||||
|
local lo, hi
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = d.Position
|
||||||
|
lo = lo and Vector3.new(math.min(lo.X, p.X), math.min(lo.Y, p.Y), math.min(lo.Z, p.Z)) or p
|
||||||
|
hi = hi and Vector3.new(math.max(hi.X, p.X), math.max(hi.Y, p.Y), math.max(hi.Z, p.Z)) or p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return lo, hi
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-11s %-10s %-22s %-14s %s", "shell", "yaw", "centreLocal(x,z)", "CURRENT err", "FIXED err"))
|
||||||
|
log(string.rep("-", 82))
|
||||||
|
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
-- yaw the solver would compute
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local lp = pivotInv * (sum / n)
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local yaw = 0
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then
|
||||||
|
yaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local lo2, hi2 = trueAABB(shell)
|
||||||
|
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
|
||||||
|
local centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
|
||||||
|
|
||||||
|
-- Simulate both orderings against an identity body at the origin.
|
||||||
|
local body = CFrame.new()
|
||||||
|
local rot = CFrame.Angles(0, yaw, 0)
|
||||||
|
|
||||||
|
-- Where does the shell's AABB centre land, relative to the body axis?
|
||||||
|
-- Model the centre as a point at centreLocal in the shell's pivot frame.
|
||||||
|
local centrePoint = CFrame.new(centreLocal.X, 0, centreLocal.Z)
|
||||||
|
|
||||||
|
local currentCF = body * rot * centreFix
|
||||||
|
local fixedCF = body * centreFix * rot -- translation applied BEFORE rotation
|
||||||
|
|
||||||
|
local curCentre = (currentCF * centrePoint).Position
|
||||||
|
local fixCentre = (fixedCF * centrePoint).Position
|
||||||
|
|
||||||
|
local curErr = math.sqrt(curCentre.X ^ 2 + curCentre.Z ^ 2)
|
||||||
|
local fixErr = math.sqrt(fixCentre.X ^ 2 + fixCentre.Z ^ 2)
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-10s (%+.2f,%+.2f)%-8s %-14s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.0f deg", math.deg(yaw)),
|
||||||
|
centreLocal.X,
|
||||||
|
centreLocal.Z,
|
||||||
|
"",
|
||||||
|
string.format("%.3f", curErr),
|
||||||
|
string.format("%.3f", fixErr)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("err = how far the shell's AABB centre sits from the chassis axis (studs).")
|
||||||
|
log("A large CURRENT err on the 180-degree shells with a near-zero FIXED err")
|
||||||
|
log("confirms the ordering bug. If both columns match, the hypothesis is WRONG")
|
||||||
|
log("and the offset is not the cause of the reversal.")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -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, ",")
|
||||||
|
)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
--[[
|
||||||
|
diag_contact.luau -- the front wheels now steer correctly (0.01 deg error)
|
||||||
|
but the car still will not change heading (1.4 deg over 5 s, understeer
|
||||||
|
3465x). Steering geometry is therefore no longer the problem: the tyres
|
||||||
|
are producing no lateral force.
|
||||||
|
|
||||||
|
Most likely cause: the hub is welded RIGIDLY to the body with no
|
||||||
|
suspension travel, and the Body part is CanCollide=true. If the body
|
||||||
|
is resting on the pad, the car slides on a big low-friction box and the
|
||||||
|
wheels are carrying little or no load.
|
||||||
|
|
||||||
|
This measures actual ground contact and per-wheel load.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(6000, 8, 6000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
log(string.format("pad top surface Y = %.2f", padTop))
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local wheels, servos, motors = {}, {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
table.insert(wheels, d)
|
||||||
|
elseif d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(wheels, function(a, b)
|
||||||
|
return a.Name < b.Name
|
||||||
|
end)
|
||||||
|
|
||||||
|
for _ = 1, 120 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("--- AT REST ---")
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"body: centre Y %.2f, size %s, CanCollide %s",
|
||||||
|
body.Position.Y,
|
||||||
|
tostring(body.Size),
|
||||||
|
tostring(body.CanCollide)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"body underside Y = %.2f (pad top %.2f) -> gap %.2f %s",
|
||||||
|
body.Position.Y - body.Size.Y / 2,
|
||||||
|
padTop,
|
||||||
|
(body.Position.Y - body.Size.Y / 2) - padTop,
|
||||||
|
((body.Position.Y - body.Size.Y / 2) - padTop) < 0.05 and "*** BODY ON THE GROUND ***" or "(clear)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format("WHEEL_RADIUS %.2f", Chassis.WHEEL_RADIUS))
|
||||||
|
|
||||||
|
for _, w in ipairs(wheels) do
|
||||||
|
local bottom = w.Position.Y - Chassis.WHEEL_RADIUS
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-10s centre Y %7.2f bottom %7.2f gap to pad %6.2f %s",
|
||||||
|
w.Name,
|
||||||
|
w.Position.Y,
|
||||||
|
bottom,
|
||||||
|
bottom - padTop,
|
||||||
|
math.abs(bottom - padTop) < 0.1 and "CONTACT" or ((bottom - padTop) > 0.1 and "FLOATING" or "SUNK")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Who is actually touching the pad?
|
||||||
|
log("")
|
||||||
|
log("--- PARTS TOUCHING THE PAD ---")
|
||||||
|
local touching = pad:GetTouchingParts()
|
||||||
|
local names = {}
|
||||||
|
for _, p in ipairs(touching) do
|
||||||
|
if p:IsDescendantOf(model) then
|
||||||
|
names[#names + 1] = p.Name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(#names > 0 and (" " .. table.concat(names, ", ")) or " <nothing from the car>")
|
||||||
|
|
||||||
|
-- Rolling, steered: is the body dragging?
|
||||||
|
log("")
|
||||||
|
log("--- ROLLING @ ~200, STEERED -14 ---")
|
||||||
|
local topW = tuning.MaxWheelAngularVelocity
|
||||||
|
for step = 1, 240 do
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = tuning.MotorMaxTorque
|
||||||
|
mo.AngularVelocity = -topW
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -14
|
||||||
|
end
|
||||||
|
if step % 60 == 0 then
|
||||||
|
local contactNames = {}
|
||||||
|
for _, p in ipairs(pad:GetTouchingParts()) do
|
||||||
|
if p:IsDescendantOf(model) then
|
||||||
|
contactNames[#contactNames + 1] = p.Name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local slip = body.AssemblyLinearVelocity
|
||||||
|
local fwd = body.CFrame.LookVector
|
||||||
|
local right = body.CFrame.RightVector
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"t %.1f speed %6.1f fwd %6.1f lateral %6.1f yaw %6.3f bodyGap %5.2f touching: %s",
|
||||||
|
step / 60,
|
||||||
|
slip.Magnitude,
|
||||||
|
slip:Dot(fwd),
|
||||||
|
slip:Dot(right),
|
||||||
|
body.AssemblyAngularVelocity.Y,
|
||||||
|
(body.Position.Y - body.Size.Y / 2) - padTop,
|
||||||
|
#contactNames > 0 and table.concat(contactNames, ",") or "none"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
--!strict
|
||||||
|
-- Verify the rebuilt Daikoku PA junction IN-ENGINE, after MapBuilder has run.
|
||||||
|
--
|
||||||
|
-- MEASURE FIRST (project rule): ray tests and code reasoning both mislead;
|
||||||
|
-- actual part positions are ground truth. This reads the built parts back out
|
||||||
|
-- of Workspace rather than trusting the source that generated them.
|
||||||
|
--
|
||||||
|
-- Checks:
|
||||||
|
-- 1. No PA ring/parapet part intrudes into the mainline carriageway.
|
||||||
|
-- 2. Mainline deck clears the ring/apron by a usable height.
|
||||||
|
-- 3. Both ramps form a continuous, climbable chain (no cliff, no gap).
|
||||||
|
-- 4. The old PARingPillar parts are gone.
|
||||||
|
local ws = workspace
|
||||||
|
|
||||||
|
local function findFirst(name: string): Instance?
|
||||||
|
return ws:FindFirstChild(name, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function say(s: string)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Locate the PA + road containers -------------------------------------------
|
||||||
|
local pa = findFirst("ParkingArea") or findFirst("PA") or findFirst("DaikokuPA")
|
||||||
|
local road = findFirst("Road")
|
||||||
|
local struct = findFirst("Structure") or findFirst("Struct")
|
||||||
|
|
||||||
|
say("== CONTAINERS ==")
|
||||||
|
say(
|
||||||
|
("pa=%s road=%s struct=%s"):format(
|
||||||
|
pa and pa:GetFullName() or "NIL",
|
||||||
|
road and road:GetFullName() or "NIL",
|
||||||
|
struct and struct:GetFullName() or "NIL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Gather parts by name pattern anywhere under Workspace ----------------------
|
||||||
|
local buckets: { [string]: { BasePart } } = {}
|
||||||
|
local function bucket(key: string, p: BasePart)
|
||||||
|
buckets[key] = buckets[key] or {}
|
||||||
|
table.insert(buckets[key], p)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, d in ipairs(ws:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local n = d.Name
|
||||||
|
if n:match("^PARingWall") or n:match("^PARingRail") then
|
||||||
|
bucket("ringwall", d)
|
||||||
|
elseif n:match("^PARingPillar") then
|
||||||
|
bucket("ringpillar", d)
|
||||||
|
elseif n:match("^PARamp") and n:match("Pillar") then
|
||||||
|
bucket("ramppillar", d)
|
||||||
|
elseif n:match("^PARampKerb") then
|
||||||
|
bucket("rampkerb", d)
|
||||||
|
elseif n:match("^PAOnRamp") then
|
||||||
|
bucket("entryramp", d)
|
||||||
|
elseif n:match("^PAOffRamp") then
|
||||||
|
bucket("exitramp", d)
|
||||||
|
elseif n:match("^Road_") then
|
||||||
|
bucket("roaddeck", d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say("")
|
||||||
|
say("== PART COUNTS ==")
|
||||||
|
for _, k in ipairs({ "roaddeck", "ringwall", "ringpillar", "entryramp", "exitramp", "ramppillar", "rampkerb" }) do
|
||||||
|
say(("%-12s %d"):format(k, buckets[k] and #buckets[k] or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Old ring pillars must be gone ------------------------------------------
|
||||||
|
say("")
|
||||||
|
say("== CHECK: legacy ring pillars removed ==")
|
||||||
|
local rp = buckets["ringpillar"]
|
||||||
|
if rp and #rp > 0 then
|
||||||
|
say(("FAIL: %d PARingPillar parts still present (ring should be ground-level)"):format(#rp))
|
||||||
|
else
|
||||||
|
say("PASS: no PARingPillar parts (ring is ground-level)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1 + 2. Ring structures vs mainline carriageway -----------------------------
|
||||||
|
say("")
|
||||||
|
say("== CHECK: ring walls do not intrude into the carriageway ==")
|
||||||
|
local decks = buckets["roaddeck"] or {}
|
||||||
|
local walls = buckets["ringwall"] or {}
|
||||||
|
local worstGap, worstDesc = math.huge, "n/a"
|
||||||
|
local intrusions = 0
|
||||||
|
for _, w in ipairs(walls) do
|
||||||
|
local wTop = w.Position.Y + w.Size.Y / 2
|
||||||
|
for _, dk in ipairs(decks) do
|
||||||
|
local dxz = (Vector3.new(dk.Position.X, 0, dk.Position.Z) - Vector3.new(w.Position.X, 0, w.Position.Z)).Magnitude
|
||||||
|
-- Only decks whose footprint actually overlaps this wall matter.
|
||||||
|
if dxz < (dk.Size.X / 2 + w.Size.X / 2 + 30) then
|
||||||
|
local deckBottom = dk.Position.Y - dk.Size.Y / 2
|
||||||
|
local gap = deckBottom - wTop
|
||||||
|
if gap < worstGap then
|
||||||
|
worstGap = gap
|
||||||
|
worstDesc = ("%s vs %s dxz=%.1f"):format(dk.Name, w.Name, dxz)
|
||||||
|
end
|
||||||
|
if gap < 0 then
|
||||||
|
intrusions += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if worstGap == math.huge then
|
||||||
|
say("PASS: no ring wall shares a footprint with any mainline deck")
|
||||||
|
else
|
||||||
|
say(("closest ring-wall/deck pair: %s"):format(worstDesc))
|
||||||
|
say(("vertical gap = %.1f studs"):format(worstGap))
|
||||||
|
if intrusions > 0 then
|
||||||
|
say(("FAIL: %d wall/deck pairs overlap vertically -- wall in the road"):format(intrusions))
|
||||||
|
else
|
||||||
|
say("PASS: every overlapping ring wall passes UNDER the deck")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Ramp continuity ---------------------------------------------------------
|
||||||
|
local function checkRamp(key: string, label: string)
|
||||||
|
say("")
|
||||||
|
say(("== CHECK: %s continuity =="):format(label))
|
||||||
|
local segs = buckets[key]
|
||||||
|
if not segs or #segs == 0 then
|
||||||
|
say("FAIL: no segments found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b)
|
||||||
|
local ai = tonumber(a.Name:match("_(%d+)$")) or 0
|
||||||
|
local bi = tonumber(b.Name:match("_(%d+)$")) or 0
|
||||||
|
return ai < bi
|
||||||
|
end)
|
||||||
|
local loY = segs[1].Position.Y
|
||||||
|
local hiY = segs[#segs].Position.Y
|
||||||
|
say(("%d segments, Y from %.1f to %.1f (rise %.1f)"):format(#segs, loY, hiY, hiY - loY))
|
||||||
|
local maxStep, maxGap = 0, 0
|
||||||
|
for i = 2, #segs do
|
||||||
|
local a, b = segs[i - 1], segs[i]
|
||||||
|
local step = math.abs(b.Position.Y - a.Position.Y)
|
||||||
|
local horiz = (Vector3.new(a.Position.X, 0, a.Position.Z) - Vector3.new(b.Position.X, 0, b.Position.Z)).Magnitude
|
||||||
|
-- gap = horizontal distance minus the two half-lengths that face
|
||||||
|
-- each other; negative means the segments overlap (good).
|
||||||
|
local gap = horiz - (a.Size.Z / 2 + b.Size.Z / 2)
|
||||||
|
maxStep = math.max(maxStep, step)
|
||||||
|
maxGap = math.max(maxGap, gap)
|
||||||
|
end
|
||||||
|
say(("max vertical step between segments = %.2f studs"):format(maxStep))
|
||||||
|
say(("max horizontal gap between segments = %.2f studs (negative = overlap)"):format(maxGap))
|
||||||
|
if maxStep > 6 then
|
||||||
|
say("FAIL: a step that large is a cliff a car cannot climb")
|
||||||
|
elseif maxGap > 0.5 then
|
||||||
|
say("FAIL: segments do not meet -- wheels will drop into the seam")
|
||||||
|
else
|
||||||
|
say("PASS: ramp is continuous and climbable")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
checkRamp("entryramp", "entry ramp")
|
||||||
|
checkRamp("exitramp", "exit ramp")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
--[[
|
||||||
|
diag_editstate.luau -- read CarManager in the EDIT datamodel.
|
||||||
|
|
||||||
|
This is the datamodel that PERSISTS (unlike Play, which is a sandbox that
|
||||||
|
is discarded on stop -- the thing that silently ate every previous push).
|
||||||
|
|
||||||
|
Purpose: capture a BEFORE snapshot so that after Rojo connects we can prove
|
||||||
|
the sync actually replaced the source, rather than assuming it did. Records
|
||||||
|
length and the two markers unique to the current fixes:
|
||||||
|
|
||||||
|
SHELL_YAW_OVERRIDE -> facing fix for hatch_junk / hyper_x (NEW, x3 in src)
|
||||||
|
bestVol -> pre-existing var, NOT a freshness signal (x6 in src)
|
||||||
|
|
||||||
|
Note bestVol existed in the old code too (git HEAD line 479), so it proves
|
||||||
|
nothing on its own -- SHELL_YAW_OVERRIDE is the real discriminator.
|
||||||
|
Disk source is 41650 bytes; anything at 30631 is the old pre-fix version.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== EDIT datamodel: CarManager state ===")
|
||||||
|
|
||||||
|
local found = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
found[#found + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("instances found: " .. #found)
|
||||||
|
if #found == 0 then
|
||||||
|
log(" !! no CarManager in ServerScriptService at all")
|
||||||
|
log(" Rojo sync will CREATE it under ServerScriptService.Server")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, m in ipairs(found) do
|
||||||
|
local src = ""
|
||||||
|
pcall(function()
|
||||||
|
src = m.Source
|
||||||
|
end)
|
||||||
|
local hasOverride = src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil
|
||||||
|
log(string.format(" %s", m:GetFullName()))
|
||||||
|
log(string.format(" len=%d (disk source = 41650, old = 30631)", #src))
|
||||||
|
log(string.format(" SHELL_YAW_OVERRIDE=%s <-- the real freshness marker", tostring(hasOverride)))
|
||||||
|
log(string.format(" verdict: %s", hasOverride and "FIXED source present" or "OLD pre-fix source"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Also note whether the stale proof marker survived (it should NOT have --
|
||||||
|
-- it only ever existed in the discarded Play sandbox).
|
||||||
|
local marker = nil
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d.Name == "HERMES_PROOF_MARKER" then
|
||||||
|
marker = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log(
|
||||||
|
"HERMES_PROOF_MARKER in Edit: "
|
||||||
|
.. (marker and "present (unexpected)" or "absent (expected - Play sandbox discarded)")
|
||||||
|
)
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
--[[
|
||||||
|
diag_elevation.luau -- measure the vertical relationship between the wangan
|
||||||
|
mainline and the Daikoku PA, so the "elevate the highway over the PA"
|
||||||
|
rebuild can be designed from real numbers instead of guesses.
|
||||||
|
|
||||||
|
Reports, for the stretch of mainline nearest the PA:
|
||||||
|
* the deck Y profile (is it flat? already climbing? in a tunnel?)
|
||||||
|
* which SIDE of the carriageway the PA sits on, per sample
|
||||||
|
* perpendicular distance from the mainline centreline to the PA centre
|
||||||
|
* terrain/ground height under the PA and under the mainline
|
||||||
|
|
||||||
|
Read-only.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit"))
|
||||||
|
|
||||||
|
local samples = Circuit.samples
|
||||||
|
local c = Circuit.PA_CENTER
|
||||||
|
local n = #samples
|
||||||
|
|
||||||
|
-- Nearest non-tunnel sample = where planPA anchors the junctions.
|
||||||
|
local bestIdx, bestD = 1, math.huge
|
||||||
|
for i, s in ipairs(samples) do
|
||||||
|
local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude
|
||||||
|
if not s.tunnel and d < bestD then
|
||||||
|
bestIdx, bestD = i, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ground probe: ray straight down, ignoring the built map so we hit terrain.
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
local rp = RaycastParams.new()
|
||||||
|
rp.FilterType = Enum.RaycastFilterType.Exclude
|
||||||
|
rp.FilterDescendantsInstances = map and { map } or {}
|
||||||
|
|
||||||
|
local function groundY(x, z, fromY)
|
||||||
|
local hit = Workspace:Raycast(Vector3.new(x, fromY, z), Vector3.new(0, -4000, 0), rp)
|
||||||
|
return hit and math.floor(hit.Position.Y * 10) / 10 or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = {}
|
||||||
|
for k = -30, 30 do
|
||||||
|
local i = ((bestIdx + k - 1) % n) + 1
|
||||||
|
local s = samples[i]
|
||||||
|
local nxt = samples[(i % n) + 1]
|
||||||
|
local fwd = (nxt.pos - s.pos)
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z)
|
||||||
|
if fwd.Magnitude < 0.01 then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
fwd = fwd.Unit
|
||||||
|
local right = Vector3.new(-fwd.Z, 0, fwd.X)
|
||||||
|
local toPA = Vector3.new(c.X - s.pos.X, 0, c.Z - s.pos.Z)
|
||||||
|
table.insert(rows, {
|
||||||
|
k = k,
|
||||||
|
i = i,
|
||||||
|
y = math.floor(s.pos.Y * 10) / 10,
|
||||||
|
tun = s.tunnel and 1 or 0,
|
||||||
|
-- +1 => PA is to the RIGHT of travel, -1 => LEFT
|
||||||
|
side = (toPA.Unit:Dot(right) >= 0) and 1 or -1,
|
||||||
|
-- signed perpendicular offset and along-track offset to the PA centre
|
||||||
|
perp = math.floor(toPA:Dot(right)),
|
||||||
|
along = math.floor(toPA:Dot(fwd)),
|
||||||
|
dist = math.floor(toPA.Magnitude),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local ys = {}
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
table.insert(ys, r.y)
|
||||||
|
end
|
||||||
|
table.sort(ys)
|
||||||
|
|
||||||
|
return game:GetService("HttpService"):JSONEncode({
|
||||||
|
bestIdx = bestIdx,
|
||||||
|
bestDist = math.floor(bestD),
|
||||||
|
paY = c.Y,
|
||||||
|
mainYmin = ys[1],
|
||||||
|
mainYmax = ys[#ys],
|
||||||
|
groundUnderPA = groundY(c.X, c.Z, c.Y + 500),
|
||||||
|
groundUnderMain = groundY(samples[bestIdx].pos.X, samples[bestIdx].pos.Z, samples[bestIdx].pos.Y + 500),
|
||||||
|
ringRadius = (require(RS.Shared.Circuit) and Circuit.PA_RADIUS + 130) or 0,
|
||||||
|
rows = rows,
|
||||||
|
})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
--[[
|
||||||
|
diag_forward.luau -- settle which end of the car is the NOSE.
|
||||||
|
|
||||||
|
Contradiction to resolve: the chassis places its FRONT axle at local -Z
|
||||||
|
(WHEELBASE_HALF = 5.9, frontZ = -5.90) but Body.LookVector is +Z. Those
|
||||||
|
disagree: the part the code calls "front" is BEHIND the direction the body
|
||||||
|
looks. One of the two conventions is wrong, and the shell is being aligned
|
||||||
|
to the wrong one -- which is why the car renders backwards even though
|
||||||
|
shell and body agree (yawDelta 0).
|
||||||
|
|
||||||
|
Ground truth used here, in order of reliability:
|
||||||
|
|
||||||
|
1. VehicleSeat.LookVector -- in Roblox a seated driver faces the seat's
|
||||||
|
+Z look direction and positive throttle drives that way. This is the
|
||||||
|
direction the car ACTUALLY moves, so it defines "forward" for gameplay.
|
||||||
|
2. Steering: which wheels have a steering constraint. Only the FRONT
|
||||||
|
wheels steer, so a steered wheel's local Z tells us where the front is.
|
||||||
|
3. Shell art extents: the visual mesh bounding box relative to its pivot.
|
||||||
|
|
||||||
|
Reports each independently so a disagreement is visible rather than assumed.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local cars = {}
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d.Name:match("^Car_") then
|
||||||
|
cars[#cars + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #cars == 0 then
|
||||||
|
return "No live Car_* models."
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(cars) do
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
if not (body and body:IsA("BasePart")) then
|
||||||
|
log(" no Body part")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local bodyCF = body.CFrame
|
||||||
|
local inv = bodyCF:Inverse()
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" bodyLook (world) = (%.2f, %.2f, %.2f)",
|
||||||
|
bodyCF.LookVector.X,
|
||||||
|
bodyCF.LookVector.Y,
|
||||||
|
bodyCF.LookVector.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 1. VehicleSeat: the direction the car actually drives.
|
||||||
|
local seat = nil
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") then
|
||||||
|
seat = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if seat then
|
||||||
|
local seatLocalZ = (inv * seat.CFrame).Z
|
||||||
|
local dot = bodyCF.LookVector:Dot(seat.CFrame.LookVector)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [1] VehicleSeat: localZ=%.2f, seatLook.dot(bodyLook)=%.2f -> seat faces %s body",
|
||||||
|
seatLocalZ,
|
||||||
|
dot,
|
||||||
|
(dot > 0) and "SAME as" or "OPPOSITE to"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" => car drives toward body %s Z", (dot > 0) and "+" or "-"))
|
||||||
|
else
|
||||||
|
log(" [1] no VehicleSeat found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Which wheels steer? Only the front axle has a steering servo.
|
||||||
|
local steered, plain = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
local z = (inv * d.CFrame).Z
|
||||||
|
local hasServo = false
|
||||||
|
for _, c in ipairs(d:GetDescendants()) do
|
||||||
|
if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
hasServo = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- servo may live on the attachment's parent chain instead
|
||||||
|
if not hasServo and d.Parent then
|
||||||
|
for _, c in ipairs(d.Parent:GetChildren()) do
|
||||||
|
if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
local a1, a0 = c.Attachment1, c.Attachment0
|
||||||
|
if (a1 and a1:IsDescendantOf(d)) or (a0 and a0:IsDescendantOf(d)) then
|
||||||
|
hasServo = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(hasServo and steered or plain, { name = d.Name, z = z })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function meanZ(t)
|
||||||
|
if #t == 0 then
|
||||||
|
return 0 / 0
|
||||||
|
end
|
||||||
|
local s = 0
|
||||||
|
for _, v in ipairs(t) do
|
||||||
|
s = s + v.z
|
||||||
|
end
|
||||||
|
return s / #t
|
||||||
|
end
|
||||||
|
|
||||||
|
local sz, pz = meanZ(steered), meanZ(plain)
|
||||||
|
log(string.format(" [2] steered wheels=%d meanZ=%.2f | unsteered=%d meanZ=%.2f", #steered, sz, #plain, pz))
|
||||||
|
if #steered > 0 and #plain > 0 then
|
||||||
|
log(string.format(" => FRONT (steered) axle is at body %s Z", (sz < pz) and "-" or "+"))
|
||||||
|
end
|
||||||
|
for _, v in ipairs(steered) do
|
||||||
|
log(string.format(" steered: %-14s z=%.2f", v.name, v.z))
|
||||||
|
end
|
||||||
|
for _, v in ipairs(plain) do
|
||||||
|
log(string.format(" plain : %-14s z=%.2f", v.name, v.z))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Shell art: where is the visual mass, and is it symmetric?
|
||||||
|
local shell = nil
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
if c:IsA("Model") then
|
||||||
|
shell = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if shell then
|
||||||
|
local cf, size = shell:GetBoundingBox()
|
||||||
|
local localCentre = (inv * cf.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [3] shell bbox size=(%.1f, %.1f, %.1f) centre localZ=%.2f",
|
||||||
|
size.X,
|
||||||
|
size.Y,
|
||||||
|
size.Z,
|
||||||
|
localCentre.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- named bodywork gives an independent nose/tail cue
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
local n = d.Name:lower()
|
||||||
|
if
|
||||||
|
d:IsA("BasePart")
|
||||||
|
and (
|
||||||
|
n:match("hood")
|
||||||
|
or n:match("bonnet")
|
||||||
|
or n:match("boot")
|
||||||
|
or n:match("trunk")
|
||||||
|
or n:match("spoiler")
|
||||||
|
or n:match("windscreen")
|
||||||
|
or n:match("bumper")
|
||||||
|
)
|
||||||
|
then
|
||||||
|
log(string.format(" %-16s localZ=%.2f", d.Name, (inv * d.CFrame).Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" [3] no shell model child")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_frontjoint.luau -- is the front wheel still ATTACHED?
|
||||||
|
|
||||||
|
My RWD change returns early before creating the motor hinge, which is the
|
||||||
|
ONLY joint that ever connected Wheel_F* to Knuckle_F*. If nothing else
|
||||||
|
joins them, the front wheels are now free-floating parts that merely start
|
||||||
|
in the right place -- which would explain both observations at once:
|
||||||
|
the servo turns perfectly (it has nothing to fight) AND the car generates
|
||||||
|
no lateral force (the front tyres are not attached to the chassis).
|
||||||
|
|
||||||
|
The same doubt applies to the earlier V7 "front motors disabled" result:
|
||||||
|
Enabled=false may have freed the servo for the same wrong reason.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(6000, 8, 6000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
|
||||||
|
local function get(n)
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name == n then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("Joints touching each WHEEL part:")
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_FR", "Wheel_RL", "Wheel_RR" }) do
|
||||||
|
local w = get(wn)
|
||||||
|
local found = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
local p0, p1, kind
|
||||||
|
if d:IsA("WeldConstraint") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, "Weld"
|
||||||
|
elseif d:IsA("Constraint") then
|
||||||
|
p0 = d.Attachment0 and d.Attachment0.Parent
|
||||||
|
p1 = d.Attachment1 and d.Attachment1.Parent
|
||||||
|
kind = d.ClassName .. "/" .. d.Name
|
||||||
|
end
|
||||||
|
if p0 and p1 and (p0 == w or p1 == w) then
|
||||||
|
found[#found + 1] = string.format("%s(%s<->%s)", kind, p0.Name, p1.Name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" %-10s %s", wn, #found > 0 and table.concat(found, " ") or "*** NO JOINTS -- DETACHED ***"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop test: a detached wheel will not follow the body when the body is moved.
|
||||||
|
log("")
|
||||||
|
log("TELEPORT TEST (move body 300 studs; attached wheels follow):")
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local before = {}
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do
|
||||||
|
before[wn] = get(wn).Position
|
||||||
|
end
|
||||||
|
model:PivotTo(model:GetPivot() * CFrame.new(0, 0, -300))
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do
|
||||||
|
local w = get(wn)
|
||||||
|
local moved = (w.Position - before[wn]).Magnitude
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-10s moved %7.1f studs %s",
|
||||||
|
wn,
|
||||||
|
moved,
|
||||||
|
moved > 100 and "followed the car" or "*** LEFT BEHIND (detached) ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
--[[
|
||||||
|
diag_line277.luau -- the build harness died at CarManager:277 with
|
||||||
|
"attempt to concatenate string with nil", but line 277 of the LOCAL file is
|
||||||
|
a comment. So the code that ran is not the code on disk.
|
||||||
|
|
||||||
|
Two candidates:
|
||||||
|
(a) the pushed Source is out of sync with disk
|
||||||
|
(b) require() handed back a STALE CACHED module from earlier this session
|
||||||
|
(the known Studio trap: editing Source does NOT invalidate the cache)
|
||||||
|
|
||||||
|
This reads the Source text directly -- never require() -- and prints the
|
||||||
|
real lines around 277, plus the reverted-convention markers, so we can tell
|
||||||
|
(a) from (b) instead of guessing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local cm = find("CarManager")
|
||||||
|
if not cm then
|
||||||
|
return "FATAL: CarManager not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
local src = cm.Source
|
||||||
|
local lines = {}
|
||||||
|
for l in (src .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
|
table.insert(lines, l)
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
table.insert(out, string.format("%s %d bytes, %d lines", cm:GetFullName(), #src, #lines))
|
||||||
|
table.insert(out, "")
|
||||||
|
table.insert(out, "--- Studio Source lines 270..284 ---")
|
||||||
|
for i = 270, math.min(284, #lines) do
|
||||||
|
table.insert(out, string.format("%4d | %s", i, lines[i]))
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(out, "")
|
||||||
|
table.insert(out, "--- markers ---")
|
||||||
|
table.insert(out, " isFront = zSign < 0 present : " .. tostring(src:find("isFront = zSign < 0", 1, true) ~= nil))
|
||||||
|
table.insert(out, " _buildForDiag present : " .. tostring(src:find("_buildForDiag", 1, true) ~= nil))
|
||||||
|
|
||||||
|
-- Where does _buildForDiag get assigned, per the Studio copy?
|
||||||
|
for i, l in ipairs(lines) do
|
||||||
|
if l:find("_buildForDiag", 1, true) then
|
||||||
|
table.insert(out, string.format(" _buildForDiag at line %d: %s", i, l))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What does buildCarModel expect? Print its declaration line.
|
||||||
|
for i, l in ipairs(lines) do
|
||||||
|
if l:find("function buildCarModel", 1, true) or l:find("local function buildCarModel", 1, true) then
|
||||||
|
table.insert(out, string.format(" buildCarModel declared line %d: %s", i, l))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
--[[
|
||||||
|
diag_nosecue.luau -- confirm WHICH cue is lying.
|
||||||
|
|
||||||
|
diag_shellgeom (bodywork asymmetry, wheel-independent) says the built
|
||||||
|
fleet disagrees on facing: rx7_fc +0.550, silv_s15 +0.206, hatch_junk
|
||||||
|
+0.614 all land one way, while hyper_x -0.290 and gtr_r34 -0.362 land the
|
||||||
|
other. Exactly two cars wrong -- matching the user's report.
|
||||||
|
|
||||||
|
The aligner derives facing from each shell's Wheels folder. If a shell's
|
||||||
|
F*/R* wheel labels are swapped relative to its bodywork, the aligner
|
||||||
|
confidently rotates it backwards.
|
||||||
|
|
||||||
|
This cross-checks the wheel cue against two INDEPENDENT cues that cannot
|
||||||
|
both be wrong:
|
||||||
|
* named bodywork: Hood/Windshield sit at the nose, Boot/Spoiler at the tail
|
||||||
|
* bodywork asymmetry (as before)
|
||||||
|
If wheels disagree with the bodywork, the wheel labels are the bug.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-14s %10s %10s %10s %10s %s", "car", "wheelZ", "hoodZ", "bootZ", "asym", "wheels-vs-body"))
|
||||||
|
log(string.rep("-", 78))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local pivotInv = t:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- cue 1: wheels
|
||||||
|
local wheelDz = nil
|
||||||
|
local wf = t:FindFirstChild("Wheels")
|
||||||
|
if wf then
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local p = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if p then
|
||||||
|
local z = (pivotInv * p.Position).Z
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
wheelDz = fS / fN - rS / rN
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cue 2: named bodywork
|
||||||
|
local function avgZ(patterns)
|
||||||
|
local s, n = 0, 0
|
||||||
|
for _, p in ipairs(t:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
for _, pat in ipairs(patterns) do
|
||||||
|
if nm == pat then
|
||||||
|
s += (pivotInv * p.Position).Z
|
||||||
|
n += 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n > 0 and (s / n) or nil
|
||||||
|
end
|
||||||
|
local hoodZ = avgZ({ "hood", "windshield", "bonnet", "grille" })
|
||||||
|
local bootZ = avgZ({ "boot", "spoiler", "trunk" })
|
||||||
|
|
||||||
|
-- cue 3: asymmetry
|
||||||
|
local sum, wsum, lo, hi = 0, 0, math.huge, -math.huge
|
||||||
|
for _, p in ipairs(t:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
if not (nm:find("wheel") or nm:find("tyre") or nm:find("tire") or nm:find("rim")) then
|
||||||
|
local z = (pivotInv * p.Position).Z
|
||||||
|
local v = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
sum, wsum = sum + z * v, wsum + v
|
||||||
|
lo, hi = math.min(lo, z), math.max(hi, z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local asym = wsum > 0 and (sum / wsum - (lo + hi) / 2) or nil
|
||||||
|
|
||||||
|
-- Wheels say nose is at sign(wheelDz). Bodywork says nose is where the
|
||||||
|
-- hood is, i.e. sign(hoodZ). They must agree.
|
||||||
|
local verdict = "?"
|
||||||
|
if wheelDz and hoodZ then
|
||||||
|
local wheelNose = wheelDz < 0 and -1 or 1
|
||||||
|
local hoodNose = hoodZ < 0 and -1 or 1
|
||||||
|
verdict = (wheelNose == hoodNose) and "agree" or "*** DISAGREE -- wheel labels swapped ***"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s %10s %10s %10s %10s %s",
|
||||||
|
t.Name,
|
||||||
|
wheelDz and string.format("%+.2f", wheelDz) or "n/a",
|
||||||
|
hoodZ and string.format("%+.2f", hoodZ) or "n/a",
|
||||||
|
bootZ and string.format("%+.2f", bootZ) or "n/a",
|
||||||
|
asym and string.format("%+.2f", asym) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
--[[
|
||||||
|
diag_nosecue2.luau -- settle which is lying on hatch_junk / hyper_x: the
|
||||||
|
wheel LABELS, or the shell MESH itself.
|
||||||
|
|
||||||
|
Background: diag_shelllabels.luau showed those two shells label their F
|
||||||
|
axle toward +Z while rx7_fc / silv_s15 / gtr_r34 label F toward -Z. That
|
||||||
|
matches the reported symptom exactly, but it does NOT say which side is
|
||||||
|
wrong -- either the labels are swapped, or the whole mesh is authored
|
||||||
|
facing the other way and the labels are honest.
|
||||||
|
|
||||||
|
So: gather cues that are INDEPENDENT of the wheel names, from the shell's
|
||||||
|
own parts. Every cue is read off part names/positions in the shell's local
|
||||||
|
frame; none is derived from a constant, and each can come out either way.
|
||||||
|
|
||||||
|
headlights -- sit at the NOSE
|
||||||
|
taillights -- sit at the TAIL
|
||||||
|
*front* / *rear* named parts (bumpers, grille) -- self-describing
|
||||||
|
seat -- a VehicleSeat/Seat inside the shell looks FORWARD
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
* cues agree with labels -> the MESH is rotated; labels are honest
|
||||||
|
* cues oppose the labels -> the LABELS are swapped; mesh is fine
|
||||||
|
A shell where cues themselves disagree is reported as INCONCLUSIVE rather
|
||||||
|
than being forced into a verdict.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
local SS = game:GetService("ServerStorage")
|
||||||
|
|
||||||
|
local TARGETS = { rx7_fc = true, hatch_junk = true, silv_s15 = true, gtr_r34 = true, hyper_x = true }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect the five target shells wherever they live.
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, SS, workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mean local Z of every part whose lowercased name matches a pattern.
|
||||||
|
local function meanZ(shell, pivotInv, patterns)
|
||||||
|
local sum, n = 0, 0
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name:lower()
|
||||||
|
for _, pat in ipairs(patterns) do
|
||||||
|
if nm:find(pat, 1, true) then
|
||||||
|
sum += (pivotInv * d.Position).Z
|
||||||
|
n += 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n == 0 then
|
||||||
|
return nil, 0
|
||||||
|
end
|
||||||
|
return sum / n, n
|
||||||
|
end
|
||||||
|
|
||||||
|
local order = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-14s %-14s %-14s %-14s %s",
|
||||||
|
"shell",
|
||||||
|
"labelF.Z",
|
||||||
|
"headlight.Z",
|
||||||
|
"taillight.Z",
|
||||||
|
"seatFwd.Z",
|
||||||
|
"verdict"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.rep("-", 104))
|
||||||
|
|
||||||
|
local seen = 0
|
||||||
|
for _, name in ipairs(order) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
seen += 1
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- Wheel-label cue: sign of (F centroid - R centroid).Z
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local z = (pivotInv * (sum / n)).Z
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + z, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local labelZ = (fN > 0 and rN > 0) and ((fSum / fN) - (rSum / rN)) or nil
|
||||||
|
|
||||||
|
local headZ, headN = meanZ(shell, pivotInv, { "headlight", "headlamp", "grille", "grill" })
|
||||||
|
local tailZ, tailN = meanZ(shell, pivotInv, { "taillight", "taillamp", "tailight", "brakelight" })
|
||||||
|
|
||||||
|
-- Seat cue: a seat's LookVector, expressed in the SHELL's frame.
|
||||||
|
local seatZ = nil
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") or d:IsA("Seat") then
|
||||||
|
-- Rotate (not translate) the seat's world look into shell-local axes.
|
||||||
|
seatZ = (shell:GetPivot():Inverse() * (d.CFrame.LookVector + shell:GetPivot().Position)).Z
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Each cue votes for which local Z direction is the NOSE.
|
||||||
|
-- Convention target: nose should be at -Z.
|
||||||
|
local votes = {}
|
||||||
|
if headZ then
|
||||||
|
votes[#votes + 1] = (headZ < 0) and -1 or 1
|
||||||
|
end
|
||||||
|
if tailZ then
|
||||||
|
votes[#votes + 1] = (tailZ < 0) and 1 or -1
|
||||||
|
end -- tail opposes nose
|
||||||
|
if seatZ then
|
||||||
|
votes[#votes + 1] = (seatZ < 0) and -1 or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local noseSign = nil
|
||||||
|
if #votes > 0 then
|
||||||
|
local s = 0
|
||||||
|
for _, v in ipairs(votes) do
|
||||||
|
s += v
|
||||||
|
end
|
||||||
|
if s > 0 then
|
||||||
|
noseSign = 1
|
||||||
|
elseif s < 0 then
|
||||||
|
noseSign = -1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if not labelZ then
|
||||||
|
verdict = "no F/R labels"
|
||||||
|
elseif #votes == 0 then
|
||||||
|
verdict = "NO INDEPENDENT CUE (no lights/seat found)"
|
||||||
|
elseif noseSign == nil then
|
||||||
|
verdict = "INCONCLUSIVE (cues split)"
|
||||||
|
else
|
||||||
|
local labelSign = (labelZ < 0) and -1 or 1
|
||||||
|
if labelSign == noseSign then
|
||||||
|
verdict = (noseSign < 0) and "consistent, nose -Z (fine)" or "LABELS HONEST -> MESH is rotated 180"
|
||||||
|
else
|
||||||
|
verdict = "LABELS SWAPPED -> mesh is fine"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fmt(v, n)
|
||||||
|
return v and string.format("%+.1f(%d)", v, n or 0) or "--"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-14s %-14s %-14s %-14s %s",
|
||||||
|
name,
|
||||||
|
labelZ and string.format("%+.1f", labelZ) or "--",
|
||||||
|
fmt(headZ, headN),
|
||||||
|
fmt(tailZ, tailN),
|
||||||
|
seatZ and string.format("%+.2f", seatZ) or "--",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if seen == 0 then
|
||||||
|
log(">>> NOTHING INSPECTED -- this is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> inspected %d/5 shells", seen))
|
||||||
|
log(" labelF.Z = (F centroid - R centroid).Z; negative means labels put F toward -Z")
|
||||||
|
log(" headlight.Z = mean local Z of headlight/grille parts (count); nose should be -Z")
|
||||||
|
log(" seatFwd.Z = seat LookVector Z in shell frame; negative = seat faces -Z")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
--[[
|
||||||
|
diag_paexit.luau -- work out the real route from the SpawnPad out of the
|
||||||
|
Daikoku PA and onto the wangan mainline.
|
||||||
|
|
||||||
|
Reports, in world coordinates: the spawn pad, the ring road, both ramps,
|
||||||
|
and -- crucially -- the COMPASS BEARING and distance from the pad to each
|
||||||
|
ramp, so the answer can be phrased as "drive X studs toward <direction>"
|
||||||
|
rather than a vague description.
|
||||||
|
|
||||||
|
Bearings are given in Roblox world terms: -Z is treated as North.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map in Workspace -- build the map first (Play once)"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function grab(name)
|
||||||
|
local hit = map:FindFirstChild(name, true)
|
||||||
|
return (hit and hit:IsA("BasePart")) and hit or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local pad = grab("SpawnPad")
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: SpawnPad not found under Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"SpawnPad pos=(%.0f, %.0f, %.0f) size=(%.0f x %.0f)",
|
||||||
|
pad.Position.X,
|
||||||
|
pad.Position.Y,
|
||||||
|
pad.Position.Z,
|
||||||
|
pad.Size.X,
|
||||||
|
pad.Size.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Compass bearing from the pad to a target, with -Z = North.
|
||||||
|
local function bearing(from, to)
|
||||||
|
local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z)
|
||||||
|
if d.Magnitude < 0.01 then
|
||||||
|
return "--", 0
|
||||||
|
end
|
||||||
|
local deg = math.deg(math.atan2(d.X, -d.Z)) -- 0 = -Z (north), +90 = +X (east)
|
||||||
|
if deg < 0 then
|
||||||
|
deg += 360
|
||||||
|
end
|
||||||
|
local names = { "North", "NE", "East", "SE", "South", "SW", "West", "NW" }
|
||||||
|
local idx = math.floor(((deg + 22.5) % 360) / 45) + 1
|
||||||
|
return names[idx], d.Magnitude, deg
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("--- ramps and ring ---")
|
||||||
|
for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do
|
||||||
|
local p = grab(nm)
|
||||||
|
if p then
|
||||||
|
local dir, dist, deg = bearing(pad.Position, p.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-10s pos=(%.0f, %.0f, %.0f) %s of pad, %.0f studs, bearing %.0f deg",
|
||||||
|
nm,
|
||||||
|
p.Position.X,
|
||||||
|
p.Position.Y,
|
||||||
|
p.Position.Z,
|
||||||
|
dir,
|
||||||
|
dist,
|
||||||
|
deg or 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format("%-10s NOT FOUND", nm))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ring segments: report the ring's radius and height vs the pad.
|
||||||
|
local ringParts = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name:find("Ring") or d.Name == "PARing") then
|
||||||
|
table.insert(ringParts, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #ringParts > 0 then
|
||||||
|
local minY, maxY = math.huge, -math.huge
|
||||||
|
local maxR = 0
|
||||||
|
for _, p in ipairs(ringParts) do
|
||||||
|
minY = math.min(minY, p.Position.Y)
|
||||||
|
maxY = math.max(maxY, p.Position.Y)
|
||||||
|
local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
maxR = math.max(maxR, r)
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"ring %d parts, radius up to %.0f studs, Y %.0f..%.0f (pad Y %.0f)",
|
||||||
|
#ringParts,
|
||||||
|
maxR,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
pad.Position.Y
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" ring sits %.0f studs ABOVE the pad", minY - pad.Position.Y))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Where is the mainline relative to the pad?
|
||||||
|
log("")
|
||||||
|
log("--- mainline ---")
|
||||||
|
local roadFolder = map:FindFirstChild("Road") or map:FindFirstChild("Circuit")
|
||||||
|
if roadFolder then
|
||||||
|
local nearest, nearestD = nil, math.huge
|
||||||
|
for _, d in ipairs(roadFolder:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
if dist < nearestD then
|
||||||
|
nearestD, nearest = dist, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if nearest then
|
||||||
|
local dir, dist = bearing(pad.Position, nearest.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"nearest mainline part '%s' at (%.0f, %.0f, %.0f)",
|
||||||
|
nearest.Name,
|
||||||
|
nearest.Position.X,
|
||||||
|
nearest.Position.Y,
|
||||||
|
nearest.Position.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" -> %s of the pad, %.0f studs away, Y %+.0f vs pad",
|
||||||
|
dir,
|
||||||
|
dist,
|
||||||
|
nearest.Position.Y - pad.Position.Y
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local names = {}
|
||||||
|
for _, d in ipairs(map:GetChildren()) do
|
||||||
|
table.insert(names, d.Name)
|
||||||
|
end
|
||||||
|
log("no Road/Circuit folder; Map children: " .. table.concat(names, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
--[[
|
||||||
|
diag_paexit2.luau -- follow-up: the first pass looked for a "Road" folder,
|
||||||
|
but the map's mainline lives under "Expressway". Also resolve which of the
|
||||||
|
two ramps is the way OUT (the off-ramp rejoins the mainline), and give the
|
||||||
|
pad-relative direction in terms the player can act on.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map"
|
||||||
|
end
|
||||||
|
local pad = map:FindFirstChild("SpawnPad", true)
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: no SpawnPad"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bearing(from, to)
|
||||||
|
local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z)
|
||||||
|
if d.Magnitude < 0.01 then
|
||||||
|
return "--", 0, 0
|
||||||
|
end
|
||||||
|
local deg = math.deg(math.atan2(d.X, -d.Z))
|
||||||
|
if deg < 0 then
|
||||||
|
deg += 360
|
||||||
|
end
|
||||||
|
local names = { "North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West" }
|
||||||
|
return names[math.floor(((deg + 22.5) % 360) / 45) + 1], d.Magnitude, deg
|
||||||
|
end
|
||||||
|
|
||||||
|
local expw = map:FindFirstChild("Expressway")
|
||||||
|
log(string.format("pad at (%.0f, %.0f, %.0f)", pad.Position.X, pad.Position.Y, pad.Position.Z))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
if expw then
|
||||||
|
local nearest, nearestD
|
||||||
|
local count = 0
|
||||||
|
for _, d in ipairs(expw:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
count += 1
|
||||||
|
local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
if not nearestD or dist < nearestD then
|
||||||
|
nearestD, nearest = dist, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format("Expressway: %d parts", count))
|
||||||
|
if nearest then
|
||||||
|
local dir, dist = bearing(pad.Position, nearest.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" nearest mainline part '%s' (%.0f, %.0f, %.0f)",
|
||||||
|
nearest.Name,
|
||||||
|
nearest.Position.X,
|
||||||
|
nearest.Position.Y,
|
||||||
|
nearest.Position.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" -> %s of pad, %.0f studs, Y %+.0f vs pad", dir, dist, nearest.Position.Y - pad.Position.Y))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ramp detail: where each ramp STARTS (ring end) and ENDS (mainline end).
|
||||||
|
log("")
|
||||||
|
log("--- ramp geometry (which way out) ---")
|
||||||
|
for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do
|
||||||
|
local p = map:FindFirstChild(nm, true)
|
||||||
|
if not p then
|
||||||
|
log(nm .. ": NOT FOUND")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
-- A ramp deck is built with lookAt(mid, road): its LookVector points at the
|
||||||
|
-- mainline end. Report both ends so the direction of travel is unambiguous.
|
||||||
|
local halfLen = p.Size.Z / 2
|
||||||
|
local toRoad = p.CFrame * CFrame.new(0, 0, -halfLen)
|
||||||
|
local toRing = p.CFrame * CFrame.new(0, 0, halfLen)
|
||||||
|
local dir, dist = bearing(pad.Position, p.Position)
|
||||||
|
log(string.format("%s centre %s of pad, %.0f studs", nm, dir, dist))
|
||||||
|
log(string.format(" ring end (%.0f, %.0f, %.0f)", toRing.Position.X, toRing.Position.Y, toRing.Position.Z))
|
||||||
|
log(string.format(" mainline end (%.0f, %.0f, %.0f)", toRoad.Position.X, toRoad.Position.Y, toRoad.Position.Z))
|
||||||
|
log(string.format(" length %.0f studs, width %.0f", p.Size.Z, p.Size.X))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How far from the pad edge to the ring, in each cardinal direction?
|
||||||
|
log("")
|
||||||
|
log("--- getting from pad to ring ---")
|
||||||
|
log(string.format("pad is %.0f x %.0f, so its edge is %.0f studs from centre", pad.Size.X, pad.Size.Z, pad.Size.X / 2))
|
||||||
|
local ringParts = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:find("Ring") then
|
||||||
|
table.insert(ringParts, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #ringParts > 0 then
|
||||||
|
local minR, maxR = math.huge, 0
|
||||||
|
for _, p in ipairs(ringParts) do
|
||||||
|
local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
minR = math.min(minR, r)
|
||||||
|
maxR = math.max(maxR, r)
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"ring road lies %.0f..%.0f studs from the pad centre, in every direction (it encircles the lot)",
|
||||||
|
minR,
|
||||||
|
maxR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
--[[
|
||||||
|
diag_pawall.luau -- is the parking area actually sealed?
|
||||||
|
|
||||||
|
The user reports a continuous low wall around the lot with no way out. The
|
||||||
|
suspect: planPA builds PARingWall on BOTH edges of every ring segment
|
||||||
|
(for sgn in {1,-1}) with no opening carved where PAOnRamp / PAOffRamp meet
|
||||||
|
the ring -- so the INNER lip would form an unbroken fence around the apron.
|
||||||
|
|
||||||
|
This does not reason about the code. It fires RAYS outward from the spawn
|
||||||
|
pad in 72 directions and reports what each one hits first, so a genuine gap
|
||||||
|
shows up as a ray that reaches the ring deck (or beyond) without hitting a
|
||||||
|
wall. If every ray hits PARingWall, the lot is sealed -- confirmed by
|
||||||
|
measurement, not inspection.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map"
|
||||||
|
end
|
||||||
|
local pad = map:FindFirstChild("SpawnPad", true)
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: no SpawnPad"
|
||||||
|
end
|
||||||
|
|
||||||
|
local origin = pad.Position + Vector3.new(0, 2, 0)
|
||||||
|
|
||||||
|
local params = RaycastParams.new()
|
||||||
|
params.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
params.FilterDescendantsInstances = { map }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local N = 72
|
||||||
|
local hits = {}
|
||||||
|
local escapes = {}
|
||||||
|
local wallH = {}
|
||||||
|
|
||||||
|
for i = 0, N - 1 do
|
||||||
|
local ang = (i / N) * math.pi * 2
|
||||||
|
local dir = Vector3.new(math.cos(ang), 0, math.sin(ang))
|
||||||
|
local res = Workspace:Raycast(origin, dir * 600, params)
|
||||||
|
local bearingDeg = math.deg(math.atan2(dir.X, -dir.Z))
|
||||||
|
if bearingDeg < 0 then
|
||||||
|
bearingDeg += 360
|
||||||
|
end
|
||||||
|
if res then
|
||||||
|
local nm = res.Instance.Name
|
||||||
|
hits[nm] = (hits[nm] or 0) + 1
|
||||||
|
if nm:find("Wall") or nm:find("Barrier") or nm:find("Rail") then
|
||||||
|
-- how tall is the blocker, and how high above the apron does it reach?
|
||||||
|
local top = res.Instance.Position.Y + res.Instance.Size.Y / 2
|
||||||
|
table.insert(wallH, top - pad.Position.Y)
|
||||||
|
else
|
||||||
|
table.insert(escapes, string.format("bearing %3.0f deg -> %s at %.0f studs", bearingDeg, nm, res.Distance))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
table.insert(escapes, string.format("bearing %3.0f deg -> CLEAR (no hit in 600 studs)", bearingDeg))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("cast %d rays outward from the pad at Y+2", N))
|
||||||
|
log("")
|
||||||
|
log("--- what the rays hit first ---")
|
||||||
|
for nm, n in pairs(hits) do
|
||||||
|
log(string.format(" %-16s %d rays", nm, n))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if #escapes == 0 then
|
||||||
|
log(">>> SEALED: every one of the " .. N .. " rays hit a wall/barrier.")
|
||||||
|
log(" There is NO gap in the ring wall -- the lot is fully enclosed.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> %d ray(s) found a non-wall path out:", #escapes))
|
||||||
|
for _, e in ipairs(escapes) do
|
||||||
|
log(" " .. e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #wallH > 0 then
|
||||||
|
local mn, mx = math.huge, -math.huge
|
||||||
|
for _, h in ipairs(wallH) do
|
||||||
|
mn = math.min(mn, h)
|
||||||
|
mx = math.max(mx, h)
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log(string.format("blocking wall top sits %.1f..%.1f studs above the apron", mn, mx))
|
||||||
|
log("(a car can climb roughly 2-3 studs; anything above that is a hard barrier)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Distance from pad centre to the first wall, to size the enclosure.
|
||||||
|
local r = Workspace:Raycast(origin, Vector3.new(1, 0, 0) * 600, params)
|
||||||
|
if r then
|
||||||
|
log(string.format("first blocker due East is %.0f studs from pad centre", r.Distance))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- Diagnose the raycast outlier: report WHICH parts the rays hit.
|
||||||
|
-- The v3 raycast filtered on the whole map, so overhead geometry (overpass
|
||||||
|
-- decks, girders, gantries, tunnel ceilings) can be hit before the road.
|
||||||
|
local sss = game:GetService("ServerScriptService")
|
||||||
|
local orig = sss:FindFirstChild("MapBuilder", true)
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Name = "MapBuilder_Diag"
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local MB = require(clone)
|
||||||
|
local map = MB.build()
|
||||||
|
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
|
||||||
|
local samples = Circuit.samples
|
||||||
|
local n = #samples
|
||||||
|
|
||||||
|
local rp = RaycastParams.new()
|
||||||
|
rp.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
rp.FilterDescendantsInstances = { map }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
-- Road-only params: what the driver actually touches.
|
||||||
|
local roadOnly = RaycastParams.new()
|
||||||
|
roadOnly.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
roadOnly.FilterDescendantsInstances = { map:FindFirstChild("Expressway") }
|
||||||
|
|
||||||
|
local worstAll, worstRoad, atAll, atRoad = 0, 0, -1, -1
|
||||||
|
local hits = {}
|
||||||
|
for i = 1, n do
|
||||||
|
local j = (i % n) + 1
|
||||||
|
local p = samples[j].pos
|
||||||
|
local fwd = (samples[(j % n) + 1].pos - samples[i].pos)
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z)
|
||||||
|
if fwd.Magnitude > 0.01 then
|
||||||
|
fwd = fwd.Unit
|
||||||
|
local o1 = p - fwd * 3 + Vector3.new(0, 40, 0)
|
||||||
|
local o2 = p + fwd * 3 + Vector3.new(0, 40, 0)
|
||||||
|
local a1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), rp)
|
||||||
|
local a2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), rp)
|
||||||
|
if a1 and a2 then
|
||||||
|
local d = math.abs(a1.Position.Y - a2.Position.Y)
|
||||||
|
if d > worstAll then
|
||||||
|
worstAll, atAll = d, i
|
||||||
|
end
|
||||||
|
if d > 1 then
|
||||||
|
hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format(
|
||||||
|
i,
|
||||||
|
samples[i].piece,
|
||||||
|
d,
|
||||||
|
a1.Instance.Name,
|
||||||
|
a2.Instance.Name
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly)
|
||||||
|
local b2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), roadOnly)
|
||||||
|
if b1 and b2 then
|
||||||
|
local d = math.abs(b1.Position.Y - b2.Position.Y)
|
||||||
|
if d > worstRoad then
|
||||||
|
worstRoad, atRoad = d, i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll)
|
||||||
|
out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format(
|
||||||
|
worstRoad,
|
||||||
|
atRoad,
|
||||||
|
worstRoad <= 0.60 and "PASS" or "FAIL"
|
||||||
|
)
|
||||||
|
out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits)
|
||||||
|
for k = 1, math.min(12, #hits) do
|
||||||
|
out[#out + 1] = " " .. hits[k]
|
||||||
|
end
|
||||||
|
|
||||||
|
map:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
--[[
|
||||||
|
diag_seatdir.luau -- decide, empirically, which way the car DRIVES.
|
||||||
|
|
||||||
|
The contradiction: CarManager:158 says `isFront = zSign < 0 -- car forward
|
||||||
|
is -Z`, so the front axle is built at local -Z. But the VehicleSeat's
|
||||||
|
LookVector points +Z (measured: seatLook.dot(bodyLook) = 1.00).
|
||||||
|
|
||||||
|
In Roblox a VehicleSeat applies throttle along its OWN +Z LookVector, so
|
||||||
|
positive throttle drives the car toward +Z -- i.e. toward the axle the code
|
||||||
|
labels REAR. If that holds, every car drives "backwards" and the shell,
|
||||||
|
which is aligned to the chassis convention, renders nose-first into the
|
||||||
|
direction of travel... backwards on screen.
|
||||||
|
|
||||||
|
This does not guess: it reads the seat's actual Throttle/steer wiring and
|
||||||
|
compares the seat basis against the wheel labels, then reports which axle
|
||||||
|
leads under positive throttle.
|
||||||
|
|
||||||
|
It also checks whether the steering servo lives on the axle that leads,
|
||||||
|
because a car steering with its TRAILING axle is the classic shopping-cart
|
||||||
|
castering symptom the user reported.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(workspace:GetDescendants()) do
|
||||||
|
if not (model:IsA("Model") and model.Name:match("^Car_")) then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
local seat = nil
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") then
|
||||||
|
seat = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not (body and body:IsA("BasePart") and seat) then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
|
||||||
|
-- Seat look expressed in BODY-LOCAL coords: which local axis does the car
|
||||||
|
-- drive along under positive throttle?
|
||||||
|
local seatLookLocal = inv:VectorToObjectSpace(seat.CFrame.LookVector)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" seat look in body-local = (%.2f, %.2f, %.2f)",
|
||||||
|
seatLookLocal.X,
|
||||||
|
seatLookLocal.Y,
|
||||||
|
seatLookLocal.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local drivesTowardPosZ = seatLookLocal.Z > 0
|
||||||
|
log(string.format(" => positive throttle drives toward local %sZ", drivesTowardPosZ and "+" or "-"))
|
||||||
|
|
||||||
|
-- Where are the labelled axles, and which one has the steering servo?
|
||||||
|
local fZ, fN, rZ, rN = 0, 0, 0, 0
|
||||||
|
local steerAxleZ, steerCount = 0, 0
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
local z = (inv * d.CFrame).Z
|
||||||
|
if d.Name:sub(7, 7):upper() == "F" then
|
||||||
|
fZ, fN = fZ + z, fN + 1
|
||||||
|
else
|
||||||
|
rZ, rN = rZ + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- servo is parented to the HUB, not the wheel -- find it by name
|
||||||
|
if d:IsA("HingeConstraint") and d.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
local host = d.Parent
|
||||||
|
if host and host:IsA("BasePart") then
|
||||||
|
steerAxleZ = steerAxleZ + (inv * host.CFrame).Z
|
||||||
|
steerCount = steerCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local fm, rm = fZ / fN, rZ / rN
|
||||||
|
log(string.format(" labelled FRONT axle localZ=%.2f | REAR axle localZ=%.2f", fm, rm))
|
||||||
|
local leadingIsFront = (drivesTowardPosZ and fm > rm) or (not drivesTowardPosZ and fm < rm)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" => the axle LEADING under throttle is the one labelled %s",
|
||||||
|
leadingIsFront and "FRONT (correct)" or "REAR (car drives backwards!)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if steerCount > 0 then
|
||||||
|
local sm = steerAxleZ / steerCount
|
||||||
|
log(string.format(" steering servos=%d, mean host localZ=%.2f", steerCount, sm))
|
||||||
|
local steerLeads = (drivesTowardPosZ and sm > 0) or (not drivesTowardPosZ and sm < 0)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" => steered axle %s under throttle %s",
|
||||||
|
steerLeads and "LEADS" or "TRAILS",
|
||||||
|
steerLeads and "(normal car)" or "(SHOPPING-CART castering)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(" no steering servos found by name scan")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
if #out == 0 then
|
||||||
|
return "No live cars with a Body + VehicleSeat."
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellbuilt.luau -- does the solved yaw actually reach the built car?
|
||||||
|
|
||||||
|
diag_shellyaw2 proved the aligner SEES the problem: hatch_junk and hyper_x
|
||||||
|
are authored nose-toward +Z and solve to yaw ~180, the other three to ~0.
|
||||||
|
That is exactly the "at least two cars are backwards" the user reports, so
|
||||||
|
the cue is right and the question is whether the rotation survives to the
|
||||||
|
built model.
|
||||||
|
|
||||||
|
The earlier attempt measured -11.80 for every car -- that is the CHASSIS
|
||||||
|
wheelbase, i.e. it matched the chassis Wheel_* parts, not the shell's own
|
||||||
|
Wheels folder. This one scopes the search strictly INSIDE the Shell model
|
||||||
|
and skips anything named Wheel_* (the chassis convention), so it can only
|
||||||
|
ever read shell wheels.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(400, 8, 400)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
|
||||||
|
log("chassis forward = -Z. Shell nose must land at NEGATIVE body-local Z.")
|
||||||
|
log("")
|
||||||
|
|
||||||
|
for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = template.Name
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
if not car then
|
||||||
|
log(string.format("%-14s <no car def for this shell id>", id))
|
||||||
|
else
|
||||||
|
local model = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 5 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local shell = model:FindFirstChild("Shell")
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-14s <no Shell child -- using procedural CarBody>", id))
|
||||||
|
else
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
-- Only look at wheels INSIDE the shell, and never at chassis Wheel_*.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
if wf then
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
if w.Name:sub(1, 6) ~= "Wheel_" then
|
||||||
|
local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if part then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
local lp = inv * part.Position
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local f, r = fSum / fN, rSum / rN
|
||||||
|
local dz = f.Z - r.Z
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s shell wheels: frontZ %+6.2f rearZ %+6.2f dZ %+6.2f -> %s",
|
||||||
|
id,
|
||||||
|
f.Z,
|
||||||
|
r.Z,
|
||||||
|
dz,
|
||||||
|
dz < 0 and "CORRECT" or "*** STILL BACKWARDS ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format("%-14s <shell has no usable F/R wheels after build (F=%d R=%d)>", id, fN, rN))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
model:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellgeom.luau -- measure the SHELL BODYWORK directly.
|
||||||
|
|
||||||
|
Two prior attempts both returned dZ = -11.80 for every car, which is the
|
||||||
|
chassis wheelbase: the "shell wheels" being matched are in fact the
|
||||||
|
chassis Wheel_* parts, so those runs proved nothing about shell facing.
|
||||||
|
|
||||||
|
Stop relying on wheel names. Instead:
|
||||||
|
1. Dump the real child structure of one shell so we can see what is
|
||||||
|
actually in there after the build.
|
||||||
|
2. Measure asymmetric BODYWORK mass: a car's cabin/rear sits behind its
|
||||||
|
nose, so the shell's own parts are not symmetric front-to-back.
|
||||||
|
Compare the shell part centroid against the shell AABB centre along
|
||||||
|
body-local Z -- the sign flips when the shell is rotated 180.
|
||||||
|
3. Cross-check against the same measurement on the raw TEMPLATE, where
|
||||||
|
diag_shellyaw2 already established ground truth (hatch_junk and
|
||||||
|
hyper_x authored nose-toward +Z, the other three nose-toward -Z).
|
||||||
|
If the built car's sign matches the template's, the flip never happened.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cloneMod = orig:Clone()
|
||||||
|
cloneMod.Parent = orig.Parent
|
||||||
|
local CarManager = require(cloneMod)
|
||||||
|
|
||||||
|
-- 1. What is actually inside a built shell?
|
||||||
|
local car0 = Cars.Get("hyper_x")
|
||||||
|
local m0 = CarManager._buildForDiag(car0, Chassis.tuningFor(car0))
|
||||||
|
m0.Parent = workspace
|
||||||
|
m0:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
local shell0 = m0:FindFirstChild("Shell")
|
||||||
|
log("=== STRUCTURE of built hyper_x Shell ===")
|
||||||
|
if shell0 then
|
||||||
|
for _, c in ipairs(shell0:GetChildren()) do
|
||||||
|
local extra = ""
|
||||||
|
if c:IsA("Folder") or c:IsA("Model") then
|
||||||
|
local kids = {}
|
||||||
|
for _, k in ipairs(c:GetChildren()) do
|
||||||
|
kids[#kids + 1] = k.Name
|
||||||
|
end
|
||||||
|
extra = " {" .. table.concat(kids, ",") .. "}"
|
||||||
|
end
|
||||||
|
log(string.format(" %-16s %s%s", c.Name, c.ClassName, extra))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" <no Shell>")
|
||||||
|
end
|
||||||
|
m0:Destroy()
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- 2/3. Bodywork asymmetry, template vs built.
|
||||||
|
local function asymmetry(root: Instance, frame: CFrame): number?
|
||||||
|
-- Volume-weighted centroid of bodywork, minus AABB centre, along local Z.
|
||||||
|
local inv = frame:Inverse()
|
||||||
|
local sum, wsum = 0, 0
|
||||||
|
local lo, hi = math.huge, -math.huge
|
||||||
|
for _, p in ipairs(root:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local n = p.Name:lower()
|
||||||
|
-- exclude wheels/tyres of any naming convention
|
||||||
|
if not (n:find("wheel") or n:find("tyre") or n:find("tire") or n:find("rim")) then
|
||||||
|
local z = (inv * p.Position).Z
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
sum += z * vol
|
||||||
|
wsum += vol
|
||||||
|
lo = math.min(lo, z)
|
||||||
|
hi = math.max(hi, z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if wsum <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local centroid = sum / wsum
|
||||||
|
local aabbCentre = (lo + hi) / 2
|
||||||
|
return centroid - aabbCentre
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== BODYWORK ASYMMETRY along local Z (centroid - aabb centre) ===")
|
||||||
|
log("A car has more mass toward the REAR (cabin/boot), so on a correctly")
|
||||||
|
log("faced shell this should be POSITIVE (mass behind centre, nose at -Z).")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-14s %14s %14s %s", "car", "template", "built", "verdict"))
|
||||||
|
|
||||||
|
for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = template.Name
|
||||||
|
local tA = asymmetry(template, template:GetPivot())
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
local bA = nil
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 3 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local sh = m:FindFirstChild("Shell")
|
||||||
|
if sh then
|
||||||
|
bA = asymmetry(sh, m.PrimaryPart.CFrame)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
local verdict = "?"
|
||||||
|
if tA and bA then
|
||||||
|
local flipped = (tA > 0) ~= (bA > 0)
|
||||||
|
verdict = flipped and "rotated by build" or "NOT rotated"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s %14s %14s %s",
|
||||||
|
id,
|
||||||
|
tA and string.format("%+.3f", tA) or "n/a",
|
||||||
|
bA and string.format("%+.3f", bA) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
cloneMod:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
--[[
|
||||||
|
diag_shelllabels.luau -- compare the RAW shell assets, before any code
|
||||||
|
touches them. Purpose: find what is different about hatch_junk and hyper_x
|
||||||
|
(reported backwards) versus rx7_fc / silv_s15 / gtr_r34 (reported fine).
|
||||||
|
|
||||||
|
A convention error would hit all five equally, so the defect must be
|
||||||
|
per-asset. The shell yaw solver derives "nose" from the F*/R* wheel NAMES,
|
||||||
|
so if those two shells label their axles opposite to the others, the solver
|
||||||
|
is fed a reversed cue and dutifully rotates them 180 degrees.
|
||||||
|
|
||||||
|
IMPORTANT -- this reads the stored shell assets directly. It does NOT call
|
||||||
|
require() on CarManager, and it does NOT rely on any value derived from a
|
||||||
|
constant. Every number below is read off the asset's own geometry, so it
|
||||||
|
genuinely differs between a correctly-labelled and a mislabelled shell.
|
||||||
|
|
||||||
|
Independent cue: cars are longer than they are wide, so the shell's LONG
|
||||||
|
horizontal axis is the longitudinal (nose-tail) axis. Comparing the wheel
|
||||||
|
label cue against the bounding-box long axis tells us whether the labels
|
||||||
|
agree with the geometry -- without trusting either one alone.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Locate the folder holding the shell models. Search broadly rather than
|
||||||
|
-- assuming a path, and report what was found so a wrong guess is visible.
|
||||||
|
local function findShellRoot()
|
||||||
|
for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Folder") and (d.Name == "Shells" or d.Name == "CarShells" or d.Name == "Bodies") then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local shellRoot = findShellRoot()
|
||||||
|
if not shellRoot then
|
||||||
|
-- Fall back: find any Model that contains a "Wheels" child.
|
||||||
|
local found = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") then
|
||||||
|
table.insert(found, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #found == 0 then
|
||||||
|
return "FATAL: no shell models found (no Shells folder, no Model with a Wheels child)"
|
||||||
|
end
|
||||||
|
log("no Shells folder; found shells by Wheels child:")
|
||||||
|
shellRoot = nil
|
||||||
|
local list = {}
|
||||||
|
for _, m in ipairs(found) do
|
||||||
|
table.insert(list, m)
|
||||||
|
end
|
||||||
|
-- Reuse the same reporting path below.
|
||||||
|
shellRoot = {
|
||||||
|
GetChildren = function()
|
||||||
|
return list
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-26s %-22s %-18s %s",
|
||||||
|
"shell",
|
||||||
|
"F centroid (local)",
|
||||||
|
"R centroid",
|
||||||
|
"long axis",
|
||||||
|
"labels agree?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.rep("-", 100))
|
||||||
|
|
||||||
|
local shells = shellRoot:GetChildren()
|
||||||
|
local reported = 0
|
||||||
|
|
||||||
|
for _, shell in ipairs(shells) do
|
||||||
|
if not shell:IsA("Model") then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
if not wheels then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- Centroid of each labelled axle, in the shell's own local frame.
|
||||||
|
local fSum, fN = Vector3.zero, 0
|
||||||
|
local rSum, rN = Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
local localPos = pivotInv * (sum / n)
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + localPos, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + localPos, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s NO F/R WHEEL LABELS (F=%d R=%d)", shell.Name, fN, rN))
|
||||||
|
reported += 1
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local fc = fSum / fN
|
||||||
|
local rc = rSum / rN
|
||||||
|
local nose = fc - rc
|
||||||
|
|
||||||
|
-- Independent geometric cue: the shell's longest horizontal extent is the
|
||||||
|
-- nose-tail axis. If |nose.Z| dominates, labels lie along Z (expected).
|
||||||
|
local minV, maxV
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = pivotInv * d.Position
|
||||||
|
minV = minV and Vector3.new(math.min(minV.X, p.X), 0, math.min(minV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z)
|
||||||
|
maxV = maxV and Vector3.new(math.max(maxV.X, p.X), 0, math.max(maxV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local spanX = maxV and (maxV.X - minV.X) or 0
|
||||||
|
local spanZ = maxV and (maxV.Z - minV.Z) or 0
|
||||||
|
local longAxis = (spanZ >= spanX) and "Z" or "X"
|
||||||
|
|
||||||
|
-- The convention is: forward = -Z. So a correctly-labelled shell has its
|
||||||
|
-- FRONT axle at a MORE NEGATIVE local Z than its rear, i.e. nose.Z < 0.
|
||||||
|
local agrees = nose.Z < 0
|
||||||
|
local verdict
|
||||||
|
if math.abs(nose.Z) < 0.05 then
|
||||||
|
verdict = "DEGENERATE (no Z separation)"
|
||||||
|
elseif agrees then
|
||||||
|
verdict = "OK (F is toward -Z)"
|
||||||
|
else
|
||||||
|
verdict = "*** REVERSED (F is toward +Z) ***"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s F(%+.1f,%+.1f) %-11s R(%+.1f,%+.1f) %-6s %s(x%.0f z%.0f) %s",
|
||||||
|
shell.Name,
|
||||||
|
fc.X,
|
||||||
|
fc.Z,
|
||||||
|
"",
|
||||||
|
rc.X,
|
||||||
|
rc.Z,
|
||||||
|
"",
|
||||||
|
longAxis,
|
||||||
|
spanX,
|
||||||
|
spanZ,
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reported += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if reported == 0 then
|
||||||
|
log(">>> NOTHING INSPECTED -- no shells with a Wheels child. This is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> inspected %d shells", reported))
|
||||||
|
log(" Convention: forward = -Z, so a correct shell has F wheels at more NEGATIVE Z than R.")
|
||||||
|
log(" Any shell marked REVERSED has mislabelled wheels -- the yaw solver's cue is inverted")
|
||||||
|
log(" for that car only, which is exactly the per-car symptom reported.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellparts.luau -- dump the actual part inventory of hatch_junk and
|
||||||
|
hyper_x. diag_nosecue2 found no headlight/taillight/seat on either, so the
|
||||||
|
naming convention must differ. Read-only: list what IS there, plus a purely
|
||||||
|
geometric asymmetry cue that needs no naming at all.
|
||||||
|
|
||||||
|
Geometric cue (naming-independent): on a car, the passenger cabin /
|
||||||
|
windscreen sits BEHIND the nose, so the mass distribution along the
|
||||||
|
longitudinal axis is asymmetric. Cars are also taller at the cabin than at
|
||||||
|
the nose. Reporting mean height per Z-half lets the shape itself indicate
|
||||||
|
which end is the bonnet -- the tall half is the cabin/rear-biased end.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
local SS = game:GetService("ServerStorage")
|
||||||
|
|
||||||
|
local TARGETS = { hatch_junk = true, hyper_x = true, rx7_fc = true }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, SS, workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs({ "rx7_fc", "hatch_junk", "hyper_x" }) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(name .. ": NOT FOUND")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
log("=== " .. name .. " ===")
|
||||||
|
|
||||||
|
-- Part names outside the Wheels folder, with local Z, sorted front-to-back.
|
||||||
|
local rows = {}
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and not d:IsDescendantOf(shell.Wheels) then
|
||||||
|
local p = pivotInv * d.Position
|
||||||
|
table.insert(rows, { name = d.Name, z = p.Z, y = p.Y, cls = d.ClassName })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(rows, function(a, b)
|
||||||
|
return a.z < b.z
|
||||||
|
end)
|
||||||
|
|
||||||
|
log(string.format(" %d non-wheel parts (sorted by local Z, -Z first):", #rows))
|
||||||
|
local shown = 0
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if shown < 24 then
|
||||||
|
log(string.format(" z=%+7.2f y=%+6.2f %-28s %s", r.z, r.y, r.name, r.cls))
|
||||||
|
shown += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #rows > 24 then
|
||||||
|
log(string.format(" ... %d more", #rows - 24))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Naming-independent shape cue: mean height of parts in each Z half.
|
||||||
|
local negY, negN, posY, posN = 0, 0, 0, 0
|
||||||
|
local minZ, maxZ
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
minZ = minZ and math.min(minZ, r.z) or r.z
|
||||||
|
maxZ = maxZ and math.max(maxZ, r.z) or r.z
|
||||||
|
end
|
||||||
|
local midZ = (minZ and maxZ) and ((minZ + maxZ) / 2) or 0
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if r.z < midZ then
|
||||||
|
negY, negN = negY + r.y, negN + 1
|
||||||
|
else
|
||||||
|
posY, posN = posY + r.y, posN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if negN > 0 and posN > 0 then
|
||||||
|
local negMean, posMean = negY / negN, posY / posN
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" shape cue: mean height -Z half = %+.2f (n=%d) +Z half = %+.2f (n=%d)",
|
||||||
|
negMean,
|
||||||
|
negN,
|
||||||
|
posMean,
|
||||||
|
posN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local tallEnd = (negMean > posMean) and "-Z" or "+Z"
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" taller (cabin) end = %s => nose is likely %s",
|
||||||
|
tallEnd,
|
||||||
|
(tallEnd == "-Z") and "+Z" or "-Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(" shape cue: unavailable")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellyaw.luau -- measure the ACTUAL shell orientation on LIVE cars.
|
||||||
|
|
||||||
|
Why this exists: the previous harness measured Wheel_* positions, which come
|
||||||
|
from the constant Chassis.WHEEL_MOTOR/WHEELBASE_HALF (5.9). Those are
|
||||||
|
identical for every car whether or not any fix applied, so the test passed
|
||||||
|
vacuously. The visible "backwards" car is the COSMETIC SHELL, which is a
|
||||||
|
separate model attached to the chassis -- that is what must be measured.
|
||||||
|
|
||||||
|
Runs against the running game (Server datamodel), inspecting cars that the
|
||||||
|
real spawn path actually produced. Reports, per live car:
|
||||||
|
|
||||||
|
bodyLook : chassis Body forward (should be the car's true nose direction)
|
||||||
|
shellLook : shell model forward
|
||||||
|
yawDelta : signed angle between them, degrees. ~0 = aligned,
|
||||||
|
~180 = shell rendering backwards relative to chassis.
|
||||||
|
|
||||||
|
Also dumps the shell's own internal cue (mean Z of its F*/R* wheel labels)
|
||||||
|
so we can see WHY the solver chose what it chose, and whether
|
||||||
|
SHELL_YAW_OVERRIDE actually took effect on this car.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function yawBetween(a: Vector3, b: Vector3): number
|
||||||
|
local av = Vector3.new(a.X, 0, a.Z)
|
||||||
|
local bv = Vector3.new(b.X, 0, b.Z)
|
||||||
|
if av.Magnitude < 1e-4 or bv.Magnitude < 1e-4 then
|
||||||
|
return 0 / 0
|
||||||
|
end
|
||||||
|
av, bv = av.Unit, bv.Unit
|
||||||
|
local dot = math.clamp(av:Dot(bv), -1, 1)
|
||||||
|
local ang = math.deg(math.acos(dot))
|
||||||
|
local cross = av:Cross(bv).Y
|
||||||
|
return (cross < 0) and -ang or ang
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find live cars anywhere in workspace (folder name varies).
|
||||||
|
local cars = {}
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d.Name:match("^Car_") then
|
||||||
|
cars[#cars + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("live Car_* models found: %d", #cars))
|
||||||
|
if #cars == 0 then
|
||||||
|
log("")
|
||||||
|
log("No spawned cars. Drive/spawn a car, or this is the wrong datamodel.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(cars) do
|
||||||
|
log("")
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
if not body or not body:IsA("BasePart") then
|
||||||
|
log(" no Body part -- cannot establish chassis frame")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local bodyLook = body.CFrame.LookVector
|
||||||
|
log(string.format(" bodyLook = (%.2f, %.2f, %.2f)", bodyLook.X, bodyLook.Y, bodyLook.Z))
|
||||||
|
|
||||||
|
-- The shell: a Model child that is not the chassis scaffolding.
|
||||||
|
local shell = nil
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
if c:IsA("Model") then
|
||||||
|
shell = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not shell then
|
||||||
|
log(" NO SHELL MODEL as a child -- cosmetic mesh may be parented elsewhere")
|
||||||
|
local kids = {}
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
kids[#kids + 1] = c.ClassName .. ":" .. c.Name
|
||||||
|
end
|
||||||
|
log(" children: " .. table.concat(kids, ", "))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local shellLook = shell:GetPivot().LookVector
|
||||||
|
log(string.format(" shell = %s", shell.Name))
|
||||||
|
log(string.format(" shellLook = (%.2f, %.2f, %.2f)", shellLook.X, shellLook.Y, shellLook.Z))
|
||||||
|
|
||||||
|
local delta = yawBetween(bodyLook, shellLook)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" yawDelta = %.1f deg --> %s",
|
||||||
|
delta,
|
||||||
|
(math.abs(delta) < 30) and "ALIGNED"
|
||||||
|
or (math.abs(math.abs(delta) - 180) < 30) and "BACKWARDS (180 flip)"
|
||||||
|
or "SKEWED"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Why: the shell's own wheel-label cue, in the shell's local frame.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
if wf then
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, d in ipairs(wf:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name:upper()
|
||||||
|
local z = (pivotInv * d.CFrame).Z
|
||||||
|
if nm:match("^F") then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif nm:match("^R") then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" shell wheel cue: frontZ=%.2f rearZ=%.2f (labels say nose at %s)",
|
||||||
|
fS / fN,
|
||||||
|
rS / rN,
|
||||||
|
(fS / fN < rS / rN) and "-Z" or "+Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format(" shell wheel cue: unusable (F parts=%d, R parts=%d)", fN, rN))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" shell has no Wheels folder -- no cue available")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellyaw2.luau -- corrected. The first attempt looked in the wrong
|
||||||
|
place (CarShells/Shells/Cars) and then measured the CHASSIS wheels, so it
|
||||||
|
reported an identical -11.80 wheelbase for every car and proved nothing.
|
||||||
|
|
||||||
|
Shells actually live in ReplicatedStorage.CarModels[car.Id] (CarManager
|
||||||
|
line 347). This inspects those templates directly and reports the exact
|
||||||
|
cue the auto-aligner uses, plus the resulting yaw.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local models = ReplicatedStorage:FindFirstChild("CarModels")
|
||||||
|
if not models then
|
||||||
|
log("ReplicatedStorage.CarModels: MISSING -> every car uses the procedural")
|
||||||
|
log("CarBody shell, which is authored nose-toward -Z and cannot be backwards.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
log("ReplicatedStorage.CarModels children:")
|
||||||
|
for _, t in ipairs(models:GetChildren()) do
|
||||||
|
log(string.format(" %-14s (%s)", t.Name, t.ClassName))
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
|
||||||
|
for _, template in ipairs(models:GetChildren()) do
|
||||||
|
if template:IsA("Model") then
|
||||||
|
local wf = template:FindFirstChild("Wheels")
|
||||||
|
if not wf then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s Wheels folder MISSING -> shellYaw = 0 (fails soft, keeps authored yaw)",
|
||||||
|
template.Name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
local pivotInv = template:GetPivot():Inverse()
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
local names = {}
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
table.insert(names, w.Name)
|
||||||
|
local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if part then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
local lp = pivotInv * part.Position
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
local usable = (math.abs(nose.X) + math.abs(nose.Z)) > 0.05
|
||||||
|
local yaw = usable and -math.atan2(nose.X, -nose.Z) or 0
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s [%s] F=%d R=%d nose=(%.2f,%.2f,%.2f) -> yaw %+.1f deg %s",
|
||||||
|
template.Name,
|
||||||
|
table.concat(names, ","),
|
||||||
|
fN,
|
||||||
|
rN,
|
||||||
|
nose.X,
|
||||||
|
nose.Y,
|
||||||
|
nose.Z,
|
||||||
|
math.deg(yaw),
|
||||||
|
usable and "" or "*** DEGENERATE -> yaw 0 ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s [%s] F=%d R=%d -> *** NO F/R CUE, shellYaw = 0, stays as authored ***",
|
||||||
|
template.Name,
|
||||||
|
table.concat(names, ","),
|
||||||
|
fN,
|
||||||
|
rN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
--[[
|
||||||
|
diag_solveryaw.luau -- does the shell yaw solver actually fire for
|
||||||
|
hatch_junk / hyper_x, and what does it produce?
|
||||||
|
|
||||||
|
Hypothesis under test: the solver derives "nose" from the F/R wheel labels,
|
||||||
|
so for a shell whose F wheels sit at +Z it should compute a 180-degree yaw
|
||||||
|
and rotate the shell to face -Z. If that is happening, those two cars would
|
||||||
|
look CORRECT -- but they look backwards. So either the solver is not being
|
||||||
|
reached, or it computes something other than 180.
|
||||||
|
|
||||||
|
CACHE GUARD FIRST. require() caches for the whole Studio session, so a
|
||||||
|
build test can silently run OLD code. Before trusting anything below, the
|
||||||
|
script re-reads Chassis.DRIVE_SIGN via require() and compares it to the
|
||||||
|
.Source text. If they disagree, the cache is stale and the script ABORTS
|
||||||
|
rather than reporting a number that means nothing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- cache guard -------------------------------------------------------
|
||||||
|
local chMod = find("Chassis")
|
||||||
|
if not chMod then
|
||||||
|
return "FATAL: Chassis not found"
|
||||||
|
end
|
||||||
|
local srcHasNeg1 = chMod.Source:find("DRIVE_SIGN = -1", 1, true) ~= nil
|
||||||
|
local okReq, Chassis = pcall(require, chMod)
|
||||||
|
if not okReq then
|
||||||
|
return "FATAL: require(Chassis) failed: " .. tostring(Chassis)
|
||||||
|
end
|
||||||
|
local liveNeg1 = (Chassis.DRIVE_SIGN == -1)
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"cache guard: source DRIVE_SIGN=-1? %s require() DRIVE_SIGN=%s",
|
||||||
|
tostring(srcHasNeg1),
|
||||||
|
tostring(Chassis.DRIVE_SIGN)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if srcHasNeg1 ~= liveNeg1 then
|
||||||
|
log("")
|
||||||
|
log(">>> ABORT: require() cache disagrees with source. Any build measured now")
|
||||||
|
log(" would use STALE code. Restart Studio (or reopen the place) and re-run.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
log("cache guard: PASS (require matches source)")
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- ---- what does the solver compute? -------------------------------------
|
||||||
|
-- Recompute the solver's own input the same way the source does: nose is the
|
||||||
|
-- F-centroid minus R-centroid, in shell-local space; yaw = -atan2(nose.X, -nose.Z).
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
for _, t in ipairs(TARGETS) do
|
||||||
|
if d.Name == t then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-11s %-12s %-14s %s", "shell", "nose.Z", "solver yaw", "meaning"))
|
||||||
|
log(string.rep("-", 74))
|
||||||
|
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local lp = pivotInv * (sum / n)
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s no F/R labels", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
local yaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
local deg = math.deg(yaw)
|
||||||
|
local meaning
|
||||||
|
if math.abs(deg) < 15 then
|
||||||
|
meaning = "no rotation applied"
|
||||||
|
elseif math.abs(math.abs(deg) - 180) < 15 then
|
||||||
|
meaning = "180 flip -> solver DOES correct this shell"
|
||||||
|
else
|
||||||
|
meaning = "partial rotation"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-12s %-14s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.1f", nose.Z),
|
||||||
|
string.format("%+.1f deg", deg),
|
||||||
|
meaning
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("If the two reported-backwards shells show a 180 flip here, the solver is")
|
||||||
|
log("already correcting them in theory -- so the fault is that this code path")
|
||||||
|
log("is not reached at build time, NOT the maths. That distinction decides the fix.")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
--[[
|
||||||
|
diag_stale.luau -- is the live CarManager module the source I pushed?
|
||||||
|
|
||||||
|
diag_wheelroot2 failed with "_buildForDiag is nil" even though the file on
|
||||||
|
disk defines it at line 988 and push_module reported
|
||||||
|
assembled=39591 expected=39591 match=true.
|
||||||
|
|
||||||
|
So either (a) the ModuleScript I am cloning is not the one I pushed to, or
|
||||||
|
(b) there is more than one CarManager, or (c) the clone-and-require trick
|
||||||
|
is returning a cached table from the ORIGINAL require rather than
|
||||||
|
re-executing the clone's source.
|
||||||
|
|
||||||
|
(c) is the important one: if require(clone) hands back the cached original
|
||||||
|
table, then every "built car" measurement in this session came from the
|
||||||
|
OLD module, and all of those results are void.
|
||||||
|
|
||||||
|
Checks, with no assumptions:
|
||||||
|
1. Every ModuleScript named CarManager anywhere in the DataModel, with
|
||||||
|
its source length and whether its text contains _buildForDiag.
|
||||||
|
2. What require() actually returns for the original and for a fresh
|
||||||
|
clone -- key list and identity comparison.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== every CarManager ModuleScript in the DataModel ===")
|
||||||
|
local found = {}
|
||||||
|
for _, svc in ipairs({ ServerScriptService, ReplicatedStorage, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(svc:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
found[#found + 1] = d
|
||||||
|
local src = ""
|
||||||
|
local okSrc = pcall(function()
|
||||||
|
src = d.Source
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-58s len=%s has _buildForDiag=%s",
|
||||||
|
d:GetFullName(),
|
||||||
|
okSrc and tostring(#src) or "<unreadable>",
|
||||||
|
okSrc and tostring(src:find("_buildForDiag") ~= nil) or "?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" total found: %d", #found))
|
||||||
|
|
||||||
|
if #found == 0 then
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local target = found[1]
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=== what require() returns ===")
|
||||||
|
local okA, A = pcall(require, target)
|
||||||
|
log(" require(original): ok=" .. tostring(okA))
|
||||||
|
if okA and typeof(A) == "table" then
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(A) do
|
||||||
|
keys[#keys + 1] = tostring(k)
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
log(" keys: " .. table.concat(keys, ", "))
|
||||||
|
log(" _buildForDiag = " .. typeof(A._buildForDiag))
|
||||||
|
end
|
||||||
|
|
||||||
|
local clone = target:Clone()
|
||||||
|
clone.Parent = target.Parent
|
||||||
|
local okB, B = pcall(require, clone)
|
||||||
|
log(" require(clone): ok=" .. tostring(okB))
|
||||||
|
if okB and typeof(B) == "table" then
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(B) do
|
||||||
|
keys[#keys + 1] = tostring(k)
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
log(" keys: " .. table.concat(keys, ", "))
|
||||||
|
log(" _buildForDiag = " .. typeof(B._buildForDiag))
|
||||||
|
end
|
||||||
|
if okA and okB then
|
||||||
|
log(
|
||||||
|
" clone table IS original table: "
|
||||||
|
.. tostring(rawequal(A, B))
|
||||||
|
.. " <- if true, the clone trick does NOT reload source"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
clone:Destroy()
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer2.luau -- WHY does the steering servo not rotate?
|
||||||
|
|
||||||
|
diag_steering showed TargetAngle -14..-26 deg with CurrentAngle stuck near
|
||||||
|
0.3 deg. This isolates the cause by removing one load at a time:
|
||||||
|
|
||||||
|
A. HOISTED -- body anchored, wheels in free air. No tyre load at all.
|
||||||
|
If the servo still will not turn, the fault is the
|
||||||
|
constraint itself, not grip.
|
||||||
|
B. GROUND, STATIC -- resting on the pad, no throttle.
|
||||||
|
C. GROUND, ROLLING -- coasting, no drive torque (drive is what saturates
|
||||||
|
the friction circle).
|
||||||
|
D. TORQUE SWEEP -- is 1e6 actually enough?
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Name = "DiagPad2"
|
||||||
|
pad.Size = Vector3.new(4000, 8, 4000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
|
||||||
|
local function build()
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function partsOf(model)
|
||||||
|
local servos, motors = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors
|
||||||
|
end
|
||||||
|
|
||||||
|
local LOCK = tuning.MaxSteerAngle
|
||||||
|
log(string.format("MaxSteerAngle %.1f deg | ServoMaxTorque 1e6 | AngularSpeed %.1f rad/s", LOCK, tuning.SteerSpeed))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- Report the hinge axes once: a mis-set axis would make the servo yaw about
|
||||||
|
-- the wrong vector and look exactly like "no steering".
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for i, s in ipairs(servos) do
|
||||||
|
local a0 = s.Attachment0
|
||||||
|
local a1 = s.Attachment1
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"servo %d axis0 (%.2f,%.2f,%.2f) axis1 (%.2f,%.2f,%.2f) | A0 on %s, A1 on %s",
|
||||||
|
i,
|
||||||
|
a0.WorldAxis.X,
|
||||||
|
a0.WorldAxis.Y,
|
||||||
|
a0.WorldAxis.Z,
|
||||||
|
a1.WorldAxis.X,
|
||||||
|
a1.WorldAxis.Y,
|
||||||
|
a1.WorldAxis.Z,
|
||||||
|
a0.Parent.Name,
|
||||||
|
a1.Parent.Name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A. HOISTED ---------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 40, 0)) -- well clear of the pad
|
||||||
|
body.Anchored = true
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 30 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if step % 20 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("A. HOISTED (no tyre load): target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", ")))
|
||||||
|
log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK)))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- B. GROUND, STATIC --------------------------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if step % 20 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("B. GROUND STATIC: target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", ")))
|
||||||
|
log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK)))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- C. GROUND, COASTING (no drive torque) ------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local servos, motors = partsOf(m)
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
-- Push to a moderate speed, then cut drive entirely and steer.
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * 60
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0 -- freewheel: friction circle fully available for cornering
|
||||||
|
mo.AngularVelocity = 0
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace, yawTrace = {}, {}
|
||||||
|
for step = 1, 150 do
|
||||||
|
if step % 30 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
table.insert(yawTrace, string.format("%.3f", body.AssemblyAngularVelocity.Y))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("C. GROUND COASTING @60: CurrentAngle %s", table.concat(trace, ", ")))
|
||||||
|
log(string.format(" yawRate %s", table.concat(yawTrace, ", ")))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- D. TORQUE SWEEP (ground, static) -----------------------------------------
|
||||||
|
log("")
|
||||||
|
log("D. ServoMaxTorque sweep (ground, static, full lock, 1.5 s):")
|
||||||
|
for _, tq in ipairs({ 1e5, 1e6, 1e7, 1e8, 1e9 }) do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 45 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = tq
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" torque %10.0e -> CurrentAngle %6.1f (err %5.1f)",
|
||||||
|
tq,
|
||||||
|
servos[1].CurrentAngle,
|
||||||
|
math.abs(servos[1].CurrentAngle + LOCK)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer3.luau -- WHAT is holding the knuckle rigid?
|
||||||
|
|
||||||
|
diag_steer2 proved the servo will not rotate even hoisted with zero tyre
|
||||||
|
load and ServoMaxTorque up to 1e9, with both hinge axes correctly +Y.
|
||||||
|
That means the knuckle is rigidly bonded to the hub by something other
|
||||||
|
than the servo.
|
||||||
|
|
||||||
|
Decisive test: parts joined by a WELD share an AssemblyRootPart. Parts
|
||||||
|
joined by a moving CONSTRAINT do not. So if Hub_FL and Knuckle_FL report
|
||||||
|
the same AssemblyRootPart, a weld is bypassing the servo.
|
||||||
|
|
||||||
|
Also builds an isolated 2-part servo to confirm the engine/API itself
|
||||||
|
behaves, ruling out a bad assumption about HingeConstraint.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1. ISOLATED SERVO --------------------------------------------------------
|
||||||
|
-- Two free parts, one servo, nothing else. If this turns, the API is fine
|
||||||
|
-- and the fault is in how the car is assembled.
|
||||||
|
do
|
||||||
|
local folder = Instance.new("Folder")
|
||||||
|
folder.Name = "IsolatedServoTest"
|
||||||
|
folder.Parent = workspace
|
||||||
|
|
||||||
|
local base = Instance.new("Part")
|
||||||
|
base.Size = Vector3.new(4, 1, 4)
|
||||||
|
base.Position = Vector3.new(0, 5100, 0)
|
||||||
|
base.Anchored = true
|
||||||
|
base.Parent = folder
|
||||||
|
|
||||||
|
local arm = Instance.new("Part")
|
||||||
|
arm.Size = Vector3.new(1, 1, 6)
|
||||||
|
arm.Position = Vector3.new(0, 5102, 0)
|
||||||
|
arm.Parent = folder
|
||||||
|
|
||||||
|
local VERT = CFrame.Angles(0, 0, math.rad(90))
|
||||||
|
local a0 = Instance.new("Attachment")
|
||||||
|
a0.CFrame = VERT
|
||||||
|
a0.Parent = base
|
||||||
|
local a1 = Instance.new("Attachment")
|
||||||
|
a1.CFrame = VERT
|
||||||
|
a1.Parent = arm
|
||||||
|
|
||||||
|
local s = Instance.new("HingeConstraint")
|
||||||
|
s.Attachment0 = a0
|
||||||
|
s.Attachment1 = a1
|
||||||
|
s.ActuatorType = Enum.ActuatorType.Servo
|
||||||
|
s.ServoMaxTorque = 1e6
|
||||||
|
s.AngularSpeed = 6
|
||||||
|
s.LimitsEnabled = true
|
||||||
|
s.LowerAngle = -35
|
||||||
|
s.UpperAngle = 35
|
||||||
|
s.TargetAngle = -30
|
||||||
|
s.Parent = base
|
||||||
|
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"1. ISOLATED servo: target -30 -> CurrentAngle %.2f (%s)",
|
||||||
|
s.CurrentAngle,
|
||||||
|
math.abs(s.CurrentAngle + 30) < 5 and "WORKS" or "BROKEN"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
folder:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. ASSEMBLY TOPOLOGY OF A REAL CAR ---------------------------------------
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(2000, 8, 2000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, 5007, 0))
|
||||||
|
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get(name)
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name == name then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local hubFL = get("Hub_FL")
|
||||||
|
local knuckleFL = get("Knuckle_FL")
|
||||||
|
local wheelFL = get("Wheel_FL")
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("2. ASSEMBLY ROOTS (same root == rigidly welded together):")
|
||||||
|
local function rootName(p)
|
||||||
|
if not p then
|
||||||
|
return "<missing>"
|
||||||
|
end
|
||||||
|
local r = p.AssemblyRootPart
|
||||||
|
return r and r:GetFullName():gsub("^.*%.", "") or "<none>"
|
||||||
|
end
|
||||||
|
log(" Body root:", rootName(body))
|
||||||
|
log(" Hub_FL root:", rootName(hubFL))
|
||||||
|
log(" Knuckle_FL root:", rootName(knuckleFL))
|
||||||
|
log(" Wheel_FL root:", rootName(wheelFL))
|
||||||
|
if hubFL and knuckleFL then
|
||||||
|
local same = hubFL.AssemblyRootPart == knuckleFL.AssemblyRootPart
|
||||||
|
log(
|
||||||
|
" >>> Hub and Knuckle share an assembly:",
|
||||||
|
tostring(same),
|
||||||
|
same and "<-- WELDED, SERVO BYPASSED" or "(ok, servo is free)"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. EVERY joint touching those parts --------------------------------------
|
||||||
|
log("")
|
||||||
|
log("3. ALL joints touching Hub_FL / Knuckle_FL / Wheel_FL:")
|
||||||
|
local targets = { [hubFL] = "Hub_FL", [knuckleFL] = "Knuckle_FL", [wheelFL] = "Wheel_FL" }
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
local p0, p1, kind
|
||||||
|
if d:IsA("WeldConstraint") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, "WeldConstraint"
|
||||||
|
elseif d:IsA("Weld") or d:IsA("Motor6D") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, d.ClassName
|
||||||
|
elseif d:IsA("Constraint") then
|
||||||
|
local a0, a1 = d.Attachment0, d.Attachment1
|
||||||
|
p0 = a0 and a0.Parent or nil
|
||||||
|
p1 = a1 and a1.Parent or nil
|
||||||
|
kind = d.ClassName .. "/" .. d.Name
|
||||||
|
end
|
||||||
|
if p0 and p1 and (targets[p0] or targets[p1]) then
|
||||||
|
log(string.format(" %-28s %s <-> %s", kind, p0.Name, p1.Name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Servo runtime state ---------------------------------------------------
|
||||||
|
log("")
|
||||||
|
log("4. SERVO RUNTIME STATE:")
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %s Enabled=%s Active=%s Actuator=%s Torque=%.0e Speed=%.1f Limits=%s [%.1f..%.1f] Target=%.1f Current=%.2f",
|
||||||
|
d.Parent.Name,
|
||||||
|
tostring(d.Enabled),
|
||||||
|
tostring(d.Active),
|
||||||
|
d.ActuatorType.Name,
|
||||||
|
d.ServoMaxTorque,
|
||||||
|
d.AngularSpeed,
|
||||||
|
tostring(d.LimitsEnabled),
|
||||||
|
d.LowerAngle,
|
||||||
|
d.UpperAngle,
|
||||||
|
d.TargetAngle,
|
||||||
|
d.CurrentAngle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer4.luau -- isolate the real constraint on steering.
|
||||||
|
|
||||||
|
Prior runs had a CONFOUND: every hoisted/static test commanded exactly
|
||||||
|
-MaxSteerAngle, which is exactly the servo's LowerAngle limit. Sitting a
|
||||||
|
servo precisely on its own limit can deadlock it. The isolated 2-part
|
||||||
|
servo (target -30, limits +/-35) worked perfectly, so the API is fine.
|
||||||
|
|
||||||
|
This matrix commands -20 deg (well INSIDE the +/-35.08 limits) and varies
|
||||||
|
one thing at a time.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(3000, 8, 3000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local LIMIT = tuning.MaxSteerAngle
|
||||||
|
local INSIDE = -20 -- comfortably inside +/-35.08
|
||||||
|
|
||||||
|
local function build(y)
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, y, 0))
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function servosOf(model)
|
||||||
|
local servos, motors = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run one case: returns the CurrentAngle trace.
|
||||||
|
local function runCase(label, opts)
|
||||||
|
local m = build(opts.hoist and (padTop + 40) or (padTop + 3))
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local servos, motors = servosOf(m)
|
||||||
|
|
||||||
|
if opts.hoist then
|
||||||
|
body.Anchored = true
|
||||||
|
end
|
||||||
|
if opts.noWheel then
|
||||||
|
for _, d in ipairs(m:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
d:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if opts.motorOff then
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.Enabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if opts.noLimits then
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.LimitsEnabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.speed then
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * opts.speed
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = opts.target
|
||||||
|
end
|
||||||
|
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if opts.speed then
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = opts.target
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if step % 24 == 0 then
|
||||||
|
trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local final = servos[1].CurrentAngle
|
||||||
|
local ok = math.abs(final - opts.target) < 5
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-42s target %6.1f -> [%s] final %6.2f %s",
|
||||||
|
label,
|
||||||
|
opts.target,
|
||||||
|
table.concat(trace, " "),
|
||||||
|
final,
|
||||||
|
ok and "*** TURNS ***" or "stuck"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format("limits +/-%.2f deg | INSIDE target %.1f | AngularSpeed %.1f rad/s", LIMIT, INSIDE, tuning.SteerSpeed)
|
||||||
|
)
|
||||||
|
log("")
|
||||||
|
|
||||||
|
runCase("A1 hoisted, target INSIDE limits", { hoist = true, target = INSIDE })
|
||||||
|
runCase("A2 hoisted, target AT limit", { hoist = true, target = -LIMIT })
|
||||||
|
runCase("A3 hoisted, LimitsEnabled=false", { hoist = true, target = INSIDE, noLimits = true })
|
||||||
|
runCase("A4 hoisted, wheels removed", { hoist = true, target = INSIDE, noWheel = true })
|
||||||
|
runCase("A5 hoisted, wheel motors disabled", { hoist = true, target = INSIDE, motorOff = true })
|
||||||
|
log("")
|
||||||
|
runCase("B1 ground static, target INSIDE", { target = INSIDE })
|
||||||
|
runCase("B2 ground static, motors disabled", { target = INSIDE, motorOff = true })
|
||||||
|
log("")
|
||||||
|
runCase("C1 ground coasting @60, INSIDE", { target = INSIDE, speed = 60 })
|
||||||
|
runCase("C2 ground coasting @120, INSIDE", { target = INSIDE, speed = 120 })
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer5.luau -- find a FIX for the servo/motor conflict.
|
||||||
|
|
||||||
|
Established by diag_steer4: an ENABLED WheelMotor hinge locks the knuckle
|
||||||
|
so the steer servo cannot yaw it. Disabling the motor (or deleting the
|
||||||
|
wheel) restores perfect tracking (-19.99 of -20.0). MotorMaxTorque is 0 at
|
||||||
|
build time, so this is NOT a torque contest -- it is a degenerate
|
||||||
|
constraint configuration.
|
||||||
|
|
||||||
|
Suspicion: Hub, Knuckle and Wheel are all built at the SAME CFrame, and
|
||||||
|
the motor's two attachments are both identity CFrames, so every attachment
|
||||||
|
sits at the same world point. Two hinges sharing a point with perpendicular
|
||||||
|
axes is ill-conditioned for the solver.
|
||||||
|
|
||||||
|
Each variant below changes ONE thing and re-measures.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(3000, 8, 3000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local TARGET = -20
|
||||||
|
|
||||||
|
local function parts(model)
|
||||||
|
local servos, motors, wheels, knuckles = {}, {}, {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
elseif d:IsA("BasePart") then
|
||||||
|
if d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
table.insert(wheels, d)
|
||||||
|
elseif d.Name:sub(1, 8) == "Knuckle_" then
|
||||||
|
table.insert(knuckles, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors, wheels, knuckles
|
||||||
|
end
|
||||||
|
|
||||||
|
-- mutate: applied after build, before settling. drive: applied after settling.
|
||||||
|
local function variant(label, mutate, drive)
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
local servos, motors, wheels, knuckles = parts(m)
|
||||||
|
if mutate then
|
||||||
|
mutate(servos, motors, wheels, knuckles, m)
|
||||||
|
end
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * 80
|
||||||
|
if drive then
|
||||||
|
drive(servos, motors, wheels, knuckles, m)
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = TARGET
|
||||||
|
end
|
||||||
|
local trace, yaw = {}, {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = TARGET
|
||||||
|
end
|
||||||
|
if step % 24 == 0 then
|
||||||
|
trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle)
|
||||||
|
yaw[#yaw + 1] = string.format("%.2f", body.AssemblyAngularVelocity.Y)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local final = servos[1].CurrentAngle
|
||||||
|
local ok = math.abs(final - TARGET) < 5
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-40s [%s] final %6.2f yaw[%s] %s",
|
||||||
|
label,
|
||||||
|
table.concat(trace, " "),
|
||||||
|
final,
|
||||||
|
table.concat(yaw, " "),
|
||||||
|
ok and "*** STEERS ***" or "stuck"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("target %.1f deg, rolling at 80 studs/s, motors at build-default torque", TARGET))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
variant("V0 baseline (unmodified)", nil, nil)
|
||||||
|
|
||||||
|
variant("V1 motor ActuatorType = None", function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.ActuatorType = Enum.ActuatorType.None
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V2 motor attachments offset 0.01 X", function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.Attachment0.CFrame = mo.Attachment0.CFrame * CFrame.new(0.01, 0, 0)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V3 knuckle mass x20 (density)", function(_, _, _, knuckles)
|
||||||
|
for _, k in ipairs(knuckles) do
|
||||||
|
k.CustomPhysicalProperties = PhysicalProperties.new(20, 0.3, 0.5, 1, 1)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V4 wheel Massless=true", function(_, _, wheels)
|
||||||
|
for _, w in ipairs(wheels) do
|
||||||
|
w.Massless = true
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V5 servo torque 1e9 + speed 30", function(servos)
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = 1e9
|
||||||
|
s.AngularSpeed = 30
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V6 motor torque 0 held every frame", nil, function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0
|
||||||
|
mo.AngularVelocity = 0
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V7 front motors disabled only", function(_, motors)
|
||||||
|
-- rear-wheel drive: only the steered corners freewheel
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
local n = mo.Parent and mo.Parent.Name or ""
|
||||||
|
if n:find("F") then
|
||||||
|
mo.Enabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V8 V1 + V5 combined", function(servos, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.ActuatorType = Enum.ActuatorType.None
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = 1e9
|
||||||
|
s.AngularSpeed = 30
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
--[[
|
||||||
|
diag_steering.luau -- measure WHY the car will not turn.
|
||||||
|
|
||||||
|
Builds a real car through CarManager, gives it a straight test surface,
|
||||||
|
then drives it at full lock and measures what actually happens:
|
||||||
|
* masses (body vs unsprung vs ballast)
|
||||||
|
* servo TargetAngle vs CurrentAngle under load
|
||||||
|
* achieved yaw rate and turning radius vs the Ackermann prediction
|
||||||
|
* lateral slip at each wheel
|
||||||
|
Everything is measured in-engine; nothing here is assumed.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
-- Flat, generous test pad well away from the map.
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Name = "DiagPad"
|
||||||
|
pad.Size = Vector3.new(4000, 8, 4000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
log(
|
||||||
|
"pad friction:",
|
||||||
|
string.format("%.2f", pad.CurrentPhysicalProperties.Friction),
|
||||||
|
" frictionWeight:",
|
||||||
|
string.format("%.2f", pad.CurrentPhysicalProperties.FrictionWeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
local carId = "gtr_r34"
|
||||||
|
local car = Cars.Get(carId)
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
log("car:", carId, "Handling:", car.Stats.Handling, "Speed:", car.Stats.Speed)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"tuning MaxSteerAngle %.1f deg, SteerSpeed %.1f, WheelFriction %.2f, MaxSpeed %.0f",
|
||||||
|
tuning.MaxSteerAngle,
|
||||||
|
tuning.SteerSpeed,
|
||||||
|
tuning.WheelFriction,
|
||||||
|
tuning.MaxSpeed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Build the car exactly as the game does.
|
||||||
|
local model = CarManager._buildForDiag and CarManager._buildForDiag(car, tuning) or nil
|
||||||
|
if not model then
|
||||||
|
-- fall back: use the public spawn path into the pad area
|
||||||
|
log("NOTE: no diag hook; using CarManager.SpawnCarAt if present")
|
||||||
|
end
|
||||||
|
if not model then
|
||||||
|
log("FATAL: cannot build a car model headlessly -- need a build hook")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 4, 0))
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local function findAll(name)
|
||||||
|
local t = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d.Name == name or (d:IsA("BasePart") and d.Name:sub(1, #name) == name) then
|
||||||
|
table.insert(t, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- MASS BREAKDOWN -----------------------------------------------------------
|
||||||
|
local total, sprung, unsprung = 0, 0, 0
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and not d.Massless then
|
||||||
|
local m = d:GetMass()
|
||||||
|
total += m
|
||||||
|
if d.Name:sub(1, 5) == "Wheel" or d.Name:sub(1, 3) == "Hub" or d.Name:sub(1, 7) == "Knuckle" then
|
||||||
|
unsprung += m
|
||||||
|
else
|
||||||
|
sprung += m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"MASS total %.1f | sprung %.1f | unsprung %.1f (%.0f%% unsprung)",
|
||||||
|
total,
|
||||||
|
sprung,
|
||||||
|
unsprung,
|
||||||
|
100 * unsprung / math.max(total, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
local wheels = findAll("Wheel_")
|
||||||
|
local servos = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local motors = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("found", #wheels, "wheels,", #servos, "servos,", #motors, "motors")
|
||||||
|
if #wheels > 0 then
|
||||||
|
local w = wheels[1]
|
||||||
|
local p = w.CurrentPhysicalProperties
|
||||||
|
log(string.format("wheel friction %.2f weight %.2f | mass %.2f", p.Friction, p.FrictionWeight, w:GetMass()))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Let it settle on the pad.
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("settled: body Y %.2f, vel %.2f", body.Position.Y, body.AssemblyLinearVelocity.Magnitude))
|
||||||
|
|
||||||
|
-- DRIVE TEST: full throttle, full right lock --------------------------------
|
||||||
|
local samples = {}
|
||||||
|
local t0 = os.clock()
|
||||||
|
local startCF = body.CFrame
|
||||||
|
local maxServoErr, servoErrSum, servoN = 0, 0, 0
|
||||||
|
|
||||||
|
for step = 1, 300 do
|
||||||
|
local speed = body.AssemblyLinearVelocity.Magnitude
|
||||||
|
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1)
|
||||||
|
local lockScale = 1 - 0.6 * speedFrac
|
||||||
|
local targetAngle = Chassis.STEER_SIGN * 1 * tuning.MaxSteerAngle * lockScale
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = targetAngle
|
||||||
|
local err = math.abs(s.CurrentAngle - targetAngle)
|
||||||
|
maxServoErr = math.max(maxServoErr, err)
|
||||||
|
servoErrSum += err
|
||||||
|
servoN += 1
|
||||||
|
end
|
||||||
|
for _, m in ipairs(motors) do
|
||||||
|
m.AngularVelocity = Chassis.DRIVE_SIGN * tuning.MaxWheelAngularVelocity
|
||||||
|
m.MotorMaxTorque = tuning.MotorMaxTorque
|
||||||
|
end
|
||||||
|
if step % 30 == 0 then
|
||||||
|
local yawRate = body.AssemblyAngularVelocity.Y
|
||||||
|
table.insert(samples, {
|
||||||
|
t = os.clock() - t0,
|
||||||
|
speed = speed,
|
||||||
|
yaw = yawRate,
|
||||||
|
target = targetAngle,
|
||||||
|
cur = #servos > 0 and servos[1].CurrentAngle or 0,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("SERVO tracking: mean err %.2f deg, max %.2f deg", servoErrSum / math.max(servoN, 1), maxServoErr))
|
||||||
|
log("t(s) speed yawRate targetAng curAng radius")
|
||||||
|
for _, s in ipairs(samples) do
|
||||||
|
local radius = math.abs(s.yaw) > 1e-4 and (s.speed / math.abs(s.yaw)) or math.huge
|
||||||
|
log(string.format("%4.1f %6.1f %7.3f %8.1f %7.1f %8.1f", s.t, s.speed, s.yaw, s.target, s.cur, radius))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ackermann prediction for comparison.
|
||||||
|
local wheelbase = Chassis.WHEELBASE_HALF * 2
|
||||||
|
local last = samples[#samples]
|
||||||
|
if last then
|
||||||
|
local predicted = wheelbase / math.tan(math.rad(math.abs(last.cur)))
|
||||||
|
local actual = math.abs(last.yaw) > 1e-4 and (last.speed / math.abs(last.yaw)) or math.huge
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"wheelbase %.1f studs | Ackermann radius at %.1f deg = %.1f studs | ACTUAL %.1f studs",
|
||||||
|
wheelbase,
|
||||||
|
math.abs(last.cur),
|
||||||
|
predicted,
|
||||||
|
actual
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format("UNDERSTEER FACTOR: %.2fx (1.0 = neutral, >2 = plough)", actual / math.max(predicted, 0.01)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Net heading change over the run.
|
||||||
|
local dot = startCF.LookVector:Dot(body.CFrame.LookVector)
|
||||||
|
log(string.format("heading change over run: %.1f deg", math.deg(math.acos(math.clamp(dot, -1, 1)))))
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
--[[
|
||||||
|
diag_wallmap.luau -- export every wall/barrier part near the PA as JSON so
|
||||||
|
it can be plotted top-down on the workstation.
|
||||||
|
|
||||||
|
Screen capture of the Studio viewport is unavailable on this machine
|
||||||
|
(Vinegar/Wine on a nested X display, DXVK renders the viewport so XGetImage
|
||||||
|
returns black), so instead of a screenshot we dump geometry and draw the
|
||||||
|
map ourselves. For "are there gaps / is it jumbled" questions a top-down
|
||||||
|
plot is strictly better than a screenshot anyway: it is to scale and every
|
||||||
|
part is labelled.
|
||||||
|
|
||||||
|
Read-only. Builds nothing, changes nothing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local WALL_NAMES = {
|
||||||
|
Barrier = true,
|
||||||
|
EdgeRail = true,
|
||||||
|
KillWall = true,
|
||||||
|
PARingWall = true,
|
||||||
|
PARingRail = true,
|
||||||
|
Median = true,
|
||||||
|
MedianFence = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function classify(name)
|
||||||
|
if WALL_NAMES[name] then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
-- Median_12 / Road_37 style suffixed names
|
||||||
|
local base = name:match("^([%a]+)_%d+$")
|
||||||
|
if base and WALL_NAMES[base] then
|
||||||
|
return base
|
||||||
|
end
|
||||||
|
if name:find("^Road") or name:find("^PARing$") or name == "PARing" then
|
||||||
|
return "Road"
|
||||||
|
end
|
||||||
|
if name == "PAOnRamp" or name == "PAOffRamp" or name == "PARampFlare" then
|
||||||
|
return "Ramp"
|
||||||
|
end
|
||||||
|
if name == "PAApron" or name:find("Apron") then
|
||||||
|
return "Apron"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local counts = {}
|
||||||
|
|
||||||
|
-- The bridge truncates payloads at ~100k chars, so only export parts near the
|
||||||
|
-- PA -- that is the region under investigation anyway. Counts are still taken
|
||||||
|
-- over the WHOLE map so nothing is hidden by the filter.
|
||||||
|
local PA = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared"):WaitForChild("Circuit")).PA_CENTER
|
||||||
|
local NEAR = 900
|
||||||
|
|
||||||
|
for _, d in ipairs(Workspace:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local kind = classify(d.Name)
|
||||||
|
if kind then
|
||||||
|
counts[kind] = (counts[kind] or 0) + 1
|
||||||
|
local p = d.Position
|
||||||
|
local dist = math.sqrt((p.X - PA.X) ^ 2 + (p.Z - PA.Z) ^ 2)
|
||||||
|
if dist <= NEAR then
|
||||||
|
table.insert(out, {
|
||||||
|
k = kind,
|
||||||
|
x = math.floor(p.X * 10) / 10,
|
||||||
|
y = math.floor(p.Y * 10) / 10,
|
||||||
|
z = math.floor(p.Z * 10) / 10,
|
||||||
|
sx = math.floor(d.Size.X * 10) / 10,
|
||||||
|
sz = math.floor(d.Size.Z * 10) / 10,
|
||||||
|
r = math.floor(math.deg(select(2, d.CFrame:ToEulerAnglesYXZ())) * 10) / 10,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local summary = {}
|
||||||
|
for k, v in pairs(counts) do
|
||||||
|
table.insert(summary, string.format("%s=%d", k, v))
|
||||||
|
end
|
||||||
|
table.sort(summary)
|
||||||
|
|
||||||
|
-- execute_luau returns the RETURN VALUE, not stdout -- prints are discarded by
|
||||||
|
-- the bridge, so hand the payload back directly.
|
||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
return HttpService:JSONEncode({
|
||||||
|
counts = table.concat(summary, " "),
|
||||||
|
total = #out,
|
||||||
|
parts = out,
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
--[[
|
||||||
|
diag_whatami.luau -- answer three questions the user's report raises, with
|
||||||
|
no assumptions:
|
||||||
|
|
||||||
|
Q1. Is the module in THIS DataModel the source I edited on disk?
|
||||||
|
(compare length + marker strings; disk file is 41650 bytes and
|
||||||
|
contains SHELL_YAW_OVERRIDE and bestVol)
|
||||||
|
|
||||||
|
Q2. Is the require() cache still pinned to pre-push code?
|
||||||
|
(_buildForDiag present in source but nil after require == pinned,
|
||||||
|
meaning NO fix can be in effect yet this session)
|
||||||
|
|
||||||
|
Q3. Are the cars the user is looking at STALE STAGED PROPS?
|
||||||
|
workspace.ShellReview was populated by tools/shot_shells.luau in an
|
||||||
|
earlier run. Those models are a frozen snapshot -- they are inert
|
||||||
|
Instances, not live rebuilds, so they will look identical forever
|
||||||
|
regardless of what CarManager says. Report whether ShellReview
|
||||||
|
exists, what is in it, and how each car is oriented RIGHT NOW so we
|
||||||
|
can tell "stale props" from "fix genuinely not working".
|
||||||
|
|
||||||
|
For Q3 the facing is read from the staged model directly: compare the mean
|
||||||
|
Z of the F* wheel groups against the R* groups in the model's own frame.
|
||||||
|
Front should sit at NEGATIVE Z (chassis convention: front axle built at
|
||||||
|
Z = -WHEELBASE/2). If front is at +Z the car is backwards.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ Q1 + Q2
|
||||||
|
log("=== Q1: is the live module my edited source? ===")
|
||||||
|
local mods = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
mods[#mods + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" instances: %d", #mods))
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
local src = ""
|
||||||
|
pcall(function()
|
||||||
|
src = m.Source
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %s len=%d (disk 41650) SHELL_YAW_OVERRIDE=%s bestVol=%s",
|
||||||
|
m:GetFullName(),
|
||||||
|
#src,
|
||||||
|
tostring(src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil),
|
||||||
|
tostring(src:find("bestVol", 1, true) ~= nil)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=== Q2: is require() still pinned to old code? ===")
|
||||||
|
if #mods == 1 then
|
||||||
|
local ok, mod = pcall(require, mods[1])
|
||||||
|
if ok and typeof(mod) == "table" then
|
||||||
|
local fresh = typeof(mod._buildForDiag) == "function"
|
||||||
|
log(" _buildForDiag after require: " .. typeof(mod._buildForDiag))
|
||||||
|
log(
|
||||||
|
fresh and " => NEW code is live; fixes CAN take effect"
|
||||||
|
or " => STILL PINNED to pre-push code; NO fix can be in effect yet"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------- Q3
|
||||||
|
log("")
|
||||||
|
log("=== Q3: what is actually sitting in workspace.ShellReview? ===")
|
||||||
|
local review = workspace:FindFirstChild("ShellReview")
|
||||||
|
if not review then
|
||||||
|
log(" ShellReview does NOT exist -- user must be looking at something else")
|
||||||
|
log(" (spawned cars in-game, or the templates in ReplicatedStorage)")
|
||||||
|
else
|
||||||
|
log(" ShellReview EXISTS with " .. #review:GetChildren() .. " children.")
|
||||||
|
log(" These are FROZEN PROPS from an earlier shot_shells.luau run.")
|
||||||
|
log(" They do not rebuild themselves -- code fixes cannot change them.")
|
||||||
|
log("")
|
||||||
|
log(string.format(" %-14s %10s %10s %s", "model", "frontZ", "rearZ", "verdict"))
|
||||||
|
for _, m in ipairs(review:GetChildren()) do
|
||||||
|
if m:IsA("Model") then
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if not wg then
|
||||||
|
local f = m:FindFirstChild("Wheels", true)
|
||||||
|
wg = f
|
||||||
|
end
|
||||||
|
if wg then
|
||||||
|
local pivot = m:GetPivot()
|
||||||
|
local inv = pivot:Inverse()
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local z = (inv * (sum / n)).Z
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local fz, rz = fS / fN, rS / rN
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-14s %10.3f %10.3f %s",
|
||||||
|
m.Name,
|
||||||
|
fz,
|
||||||
|
rz,
|
||||||
|
fz < rz and "front at -Z OK" or "front at +Z BACKWARDS"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
--[[
|
||||||
|
diag_wheelroot.luau -- two suspected bugs in the shell wheel re-hang
|
||||||
|
(CarManager ~line 585-641).
|
||||||
|
|
||||||
|
(1) ROOT CHOICE. Line 589 picks
|
||||||
|
root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
For a wheel group that is a Model, FindFirstChildWhichIsA returns an
|
||||||
|
ARBITRARY first BasePart -- a rotor, caliper or hub, not necessarily
|
||||||
|
the tyre. Line 621 then does delta = phys.Position - root.Position and
|
||||||
|
shifts the whole group, making THAT part concentric with the axle. If
|
||||||
|
root is not the tyre centre, the visible tyre is displaced by the
|
||||||
|
root-to-tyre offset. User reports rx7_fc wheels "sticking out from the
|
||||||
|
side of the car", which is this signature.
|
||||||
|
|
||||||
|
(2) SURVIVING OUTSIDE WELDS. Line 605 only scans shell:GetDescendants()
|
||||||
|
for welds to cut. Any weld parented OUTSIDE the shell (on the model,
|
||||||
|
on body, etc.) that ties a wheel part to the bodywork survives, which
|
||||||
|
would rigidly glue the wheel to the chassis and pin the knuckle --
|
||||||
|
the measured steering lock.
|
||||||
|
|
||||||
|
Reports, per wheel group: which part was chosen as root, how far that root
|
||||||
|
sits from the group's true centre, and any surviving weld from the group
|
||||||
|
to a part outside it.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
local CarManager = require(cm)
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- template
|
||||||
|
log("=== TEMPLATE wheel groups: what does line 589 pick as root? ===")
|
||||||
|
log(string.format("%-12s %-5s %-16s %8s %8s", "car", "grp", "root picked", "off|X|", "nParts"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local wg = t:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
-- true centre of the group = AABB centre of all its parts
|
||||||
|
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||||
|
local hi = -lo
|
||||||
|
local n = 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
lo, hi, n = w.Position, w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
lo = Vector3.new(
|
||||||
|
math.min(lo.X, d.Position.X),
|
||||||
|
math.min(lo.Y, d.Position.Y),
|
||||||
|
math.min(lo.Z, d.Position.Z)
|
||||||
|
)
|
||||||
|
hi = Vector3.new(
|
||||||
|
math.max(hi.X, d.Position.X),
|
||||||
|
math.max(hi.Y, d.Position.Y),
|
||||||
|
math.max(hi.Z, d.Position.Z)
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if root and n > 0 then
|
||||||
|
local centre = (lo + hi) / 2
|
||||||
|
local off = root.Position - centre
|
||||||
|
log(string.format("%-12s %-5s %-16s %8.3f %8d", t.Name, w.Name, root.Name, math.abs(off.X), n))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- built
|
||||||
|
log("")
|
||||||
|
log("=== BUILT cars: tyre alignment + surviving outside welds ===")
|
||||||
|
log("tyreErrX = how far the widest shell wheel part sits from its physics")
|
||||||
|
log("cylinder along the car's lateral axis. Should be ~0 on every corner.")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds tying group to OUTSIDE"))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local car = Cars.Get(t.Name)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 4 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local phys = m:FindFirstChild("Wheel_" .. w.Name:upper())
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[1], inGroup[w] = w, true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and #group > 0 then
|
||||||
|
-- widest part = the tyre; compare its |X| to the cylinder's
|
||||||
|
local physX = (inv * phys.Position).X
|
||||||
|
local best, bestW = nil, -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestW then
|
||||||
|
best, bestW = p, vol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local errX = (inv * best.Position).X - physX
|
||||||
|
|
||||||
|
-- any weld from this group to something outside it,
|
||||||
|
-- searched across the WHOLE model, not just the shell
|
||||||
|
local bad = {}
|
||||||
|
for _, c in ipairs(m:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn = a ~= nil and inGroup[a] or false
|
||||||
|
local bIn = b ~= nil and inGroup[b] or false
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
local other = aIn and b or a
|
||||||
|
local oname = other and other.Name or "nil"
|
||||||
|
-- weld to its own physics cylinder is CORRECT
|
||||||
|
if oname ~= ("Wheel_" .. w.Name:upper()) then
|
||||||
|
bad[#bad + 1] = oname .. "(" .. c.Parent.Name .. ")"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %9.3f %s",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
errX,
|
||||||
|
#bad > 0 and table.concat(bad, ", ") or "-none-"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cm:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
--[[
|
||||||
|
diag_wheelroot2.luau -- same investigation as diag_wheelroot, but wrapped
|
||||||
|
so a failure reports WHERE. The first attempt died with a bare "attempt to
|
||||||
|
call a nil value" and no line, which tells us nothing; both _buildForDiag
|
||||||
|
(CarManager:988) and Chassis.tuningFor (Chassis:158) are confirmed to
|
||||||
|
exist, so the nil is something else in the script.
|
||||||
|
|
||||||
|
Runs each stage under pcall + traceback and prints progress markers, so
|
||||||
|
the last marker printed localises the fault even if the traceback is
|
||||||
|
swallowed by the executor.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stage(name, fn)
|
||||||
|
local ok, err = xpcall(fn, function(e)
|
||||||
|
return tostring(e) .. "\n" .. debug.traceback("", 2)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
log("!! FAILED at stage: " .. name)
|
||||||
|
log(err)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
log(" ok: " .. name)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared, Cars, Chassis, CarManager, cm
|
||||||
|
|
||||||
|
stage("require shared", function()
|
||||||
|
Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
Cars = require(Shared.Cars)
|
||||||
|
Chassis = require(Shared.Chassis)
|
||||||
|
end)
|
||||||
|
|
||||||
|
stage("clone+require CarManager", function()
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
CarManager = require(cm)
|
||||||
|
assert(typeof(CarManager._buildForDiag) == "function", "_buildForDiag is " .. typeof(CarManager._buildForDiag))
|
||||||
|
assert(typeof(Chassis.tuningFor) == "function", "tuningFor is " .. typeof(Chassis.tuningFor))
|
||||||
|
assert(typeof(Cars.Get) == "function", "Cars.Get is " .. typeof(Cars.Get))
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ------------------------------------------------- stage 1: template roots
|
||||||
|
stage("template root pick", function()
|
||||||
|
log("")
|
||||||
|
log("=== TEMPLATE: what does line 589 pick as root? ===")
|
||||||
|
log(string.format("%-12s %-5s %-18s %8s %7s", "car", "grp", "root picked", "offX", "nParts"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local wg = t:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
local parts = {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
parts[1] = w
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
parts[#parts + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if root and #parts > 0 then
|
||||||
|
local lo, hi = parts[1].Position, parts[1].Position
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
lo = Vector3.new(
|
||||||
|
math.min(lo.X, p.Position.X),
|
||||||
|
math.min(lo.Y, p.Position.Y),
|
||||||
|
math.min(lo.Z, p.Position.Z)
|
||||||
|
)
|
||||||
|
hi = Vector3.new(
|
||||||
|
math.max(hi.X, p.Position.X),
|
||||||
|
math.max(hi.Y, p.Position.Y),
|
||||||
|
math.max(hi.Z, p.Position.Z)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
local centre = (lo + hi) / 2
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %-18s %8.3f %7d",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
root.Name,
|
||||||
|
math.abs(root.Position.X - centre.X),
|
||||||
|
#parts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ------------------------------------------------- stage 2: built cars
|
||||||
|
stage("built car check", function()
|
||||||
|
log("")
|
||||||
|
log("=== BUILT: tyre offset from its physics cylinder + stray welds ===")
|
||||||
|
log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds to OUTSIDE the group"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local car = Cars.Get(t.Name)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 4 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if wg and body then
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local physName = "Wheel_" .. w.Name:upper()
|
||||||
|
local phys = m:FindFirstChild(physName)
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[1], inGroup[w] = w, true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and #group > 0 then
|
||||||
|
local physX = (inv * phys.Position).X
|
||||||
|
local best, bestV = group[1], -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local v = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if v > bestV then
|
||||||
|
best, bestV = p, v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local errX = (inv * best.Position).X - physX
|
||||||
|
local bad = {}
|
||||||
|
for _, c in ipairs(m:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn = (a ~= nil) and inGroup[a] or false
|
||||||
|
local bIn = (b ~= nil) and inGroup[b] or false
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
local other = aIn and b or a
|
||||||
|
local oname = other and other.Name or "nil"
|
||||||
|
if oname ~= physName then
|
||||||
|
bad[#bad + 1] = oname
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %9.3f %s",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
errX,
|
||||||
|
#bad > 0 and table.concat(bad, ", ") or "-none-"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if cm then
|
||||||
|
cm:Destroy()
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Export a top-down plan of the PA area as JSON-ish text so it can be
|
||||||
|
-- plotted offline, and measure the median-barrier opening properly by
|
||||||
|
-- looking for gaps along the mainline centreline near each junction.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
local PA = Vector3.new(-4124, 46, -7470)
|
||||||
|
local RANGE = 2600 -- studs around the PA to export
|
||||||
|
|
||||||
|
local buckets = {
|
||||||
|
EntryTrunk = {},
|
||||||
|
EntryNear = {},
|
||||||
|
ExitTrunk = {},
|
||||||
|
ExitNear = {},
|
||||||
|
Road = {},
|
||||||
|
Barrier = {},
|
||||||
|
Ring = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = d.Position
|
||||||
|
if math.abs(p.X - PA.X) < RANGE and math.abs(p.Z - PA.Z) < RANGE then
|
||||||
|
local nm = d.Name
|
||||||
|
local key
|
||||||
|
if nm:sub(1, 18) == "PAEntryRampTrunk_" or nm:sub(1, 17) == "PAEntryRampTrunk_" then
|
||||||
|
key = "EntryTrunk"
|
||||||
|
elseif nm:sub(1, 16) == "PAEntryRampNear_" then
|
||||||
|
key = "EntryNear"
|
||||||
|
elseif nm:sub(1, 16) == "PAExitRampTrunk_" then
|
||||||
|
key = "ExitTrunk"
|
||||||
|
elseif nm:sub(1, 15) == "PAExitRampNear_" then
|
||||||
|
key = "ExitNear"
|
||||||
|
elseif nm:gsub("_?%d+$", "") == "Road" then
|
||||||
|
key = "Road"
|
||||||
|
elseif nm:gsub("_?%d+$", "") == "Barrier" then
|
||||||
|
key = "Barrier"
|
||||||
|
elseif nm:find("PARing") then
|
||||||
|
key = "Ring"
|
||||||
|
end
|
||||||
|
if key then
|
||||||
|
table.insert(buckets[key], ("%d,%d,%d"):format(math.floor(p.X), math.floor(p.Z), math.floor(p.Y)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
for k, v in pairs(buckets) do
|
||||||
|
table.insert(out, "#" .. k .. "#" .. table.concat(v, ";"))
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
--[[
|
||||||
|
fix_dupes.luau -- remove leftover CarManager clones and report the truth.
|
||||||
|
|
||||||
|
diag_stale found TWO ModuleScripts at ServerScriptService.Server.CarManager,
|
||||||
|
both len=30631 with no _buildForDiag, while the file on disk is 39591 bytes
|
||||||
|
and defines it. My diagnostic scripts cloned the module and did not always
|
||||||
|
destroy the clone, so duplicates accumulated; require() may bind to either.
|
||||||
|
|
||||||
|
Deletes every CarManager except the one that is a genuine child of the
|
||||||
|
Server folder (keeping the first by index), then reports what remains so
|
||||||
|
the next push has a single unambiguous target.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
if not server then
|
||||||
|
return "no Server folder"
|
||||||
|
end
|
||||||
|
|
||||||
|
local mods = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
mods[#mods + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("found %d CarManager module(s)", #mods))
|
||||||
|
for i, m in ipairs(mods) do
|
||||||
|
local len = "?"
|
||||||
|
pcall(function()
|
||||||
|
len = tostring(#m.Source)
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [%d] %s len=%s parent=%s",
|
||||||
|
i,
|
||||||
|
m:GetFullName(),
|
||||||
|
len,
|
||||||
|
m.Parent and m.Parent:GetFullName() or "nil"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep exactly one: prefer a direct child of the Server folder.
|
||||||
|
local keep = nil
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
if m.Parent == server then
|
||||||
|
keep = m
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
keep = keep or mods[1]
|
||||||
|
|
||||||
|
local removed = 0
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
if m ~= keep then
|
||||||
|
m:Destroy()
|
||||||
|
removed += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log(string.format("kept: %s", keep and keep:GetFullName() or "nil"))
|
||||||
|
log(string.format("removed: %d duplicate(s)", removed))
|
||||||
|
|
||||||
|
-- Confirm the survivor
|
||||||
|
local after = 0
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
after += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format("remaining CarManager count: %d", after))
|
||||||
|
if keep then
|
||||||
|
local len = "?"
|
||||||
|
pcall(function()
|
||||||
|
len = tostring(#keep.Source)
|
||||||
|
end)
|
||||||
|
log("survivor source length: " .. len .. " (disk file is 39591)")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
-- Measure the redesigned ramps: extent, grade, side (E/W), and collisions
|
||||||
|
-- with STRUCTURAL parts (piers, masts, girders). Read-only.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "no Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
local PA = Vector3.new(-4124, 46, -7470)
|
||||||
|
|
||||||
|
-- Gather deck parts per run.
|
||||||
|
local runs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do
|
||||||
|
if nm:sub(1, #base) == base then
|
||||||
|
runs[base] = runs[base] or {}
|
||||||
|
table.insert(runs[base], d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local order = { "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }
|
||||||
|
for _, base in ipairs(order) do
|
||||||
|
local parts = runs[base]
|
||||||
|
if not parts then
|
||||||
|
table.insert(out, base .. ": MISSING")
|
||||||
|
else
|
||||||
|
local minY, maxY = math.huge, -math.huge
|
||||||
|
local minX, maxX = math.huge, -math.huge
|
||||||
|
local minZ, maxZ = math.huge, -math.huge
|
||||||
|
local maxGrade = 0
|
||||||
|
table.sort(parts, function(a, b)
|
||||||
|
return a.Name < b.Name
|
||||||
|
end)
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
local q = p.Position
|
||||||
|
minY, maxY = math.min(minY, q.Y), math.max(maxY, q.Y)
|
||||||
|
minX, maxX = math.min(minX, q.X), math.max(maxX, q.X)
|
||||||
|
minZ, maxZ = math.min(minZ, q.Z), math.max(maxZ, q.Z)
|
||||||
|
end
|
||||||
|
for i = 2, #parts do
|
||||||
|
local a, b = parts[i - 1].Position, parts[i].Position
|
||||||
|
local horiz = (Vector3.new(a.X, 0, a.Z) - Vector3.new(b.X, 0, b.Z)).Magnitude
|
||||||
|
if horiz > 0.5 then
|
||||||
|
maxGrade = math.max(maxGrade, math.abs(b.Y - a.Y) / horiz * 100)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- mean X relative to PA centre tells us east (+) or west (-)
|
||||||
|
local sx = 0
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
sx += p.Position.X
|
||||||
|
end
|
||||||
|
local side = (sx / #parts) > PA.X and "EAST" or "WEST"
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("%s: n=%d Y=%.0f..%.0f grade<=%.2f%% side=%s X=%.0f..%.0f Z=%.0f..%.0f"):format(
|
||||||
|
base,
|
||||||
|
#parts,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
maxGrade,
|
||||||
|
side,
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minZ,
|
||||||
|
maxZ
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Structural collision test: does any ramp deck part overlap a pier/mast?
|
||||||
|
local structural = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
|
||||||
|
table.insert(structural, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(out, "structuralParts=" .. #structural)
|
||||||
|
|
||||||
|
local hits = 0
|
||||||
|
local detail = {}
|
||||||
|
for _, base in ipairs(order) do
|
||||||
|
local parts = runs[base] or {}
|
||||||
|
local h = 0
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
for _, s in ipairs(structural) do
|
||||||
|
local dx = math.abs(p.Position.X - s.Position.X)
|
||||||
|
local dz = math.abs(p.Position.Z - s.Position.Z)
|
||||||
|
local dy = math.abs(p.Position.Y - s.Position.Y)
|
||||||
|
local rx = (p.Size.X + s.Size.X) / 2
|
||||||
|
local rz = (p.Size.Z + s.Size.Z) / 2
|
||||||
|
local ry = (p.Size.Y + s.Size.Y) / 2
|
||||||
|
if dx < rx and dz < rz and dy < ry then
|
||||||
|
h += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hits += h
|
||||||
|
table.insert(detail, base .. "=" .. h)
|
||||||
|
end
|
||||||
|
table.insert(out, "STRUCTURAL_HITS total=" .. hits .. " (" .. table.concat(detail, " ") .. ")")
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Median opening test, now against the CORRECT part name. The centre divider
|
||||||
|
-- is "Median_<i>"; "Barrier" is the outer edge parapet (which must NOT open).
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
|
||||||
|
local medians, roads = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local b = d.Name:gsub("_?%d+$", "")
|
||||||
|
if b == "Median" then
|
||||||
|
table.insert(medians, d.Position)
|
||||||
|
elseif b == "Road" then
|
||||||
|
table.insert(roads, d.Position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local anchors = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name == "PAEntryRampNear_200" or d.Name == "PAExitRampNear_200") then
|
||||||
|
table.insert(anchors, { d.Name, d.Position })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local WIN = 220
|
||||||
|
local function countNear(pos, list)
|
||||||
|
local n = 0
|
||||||
|
for _, bp in ipairs(list) do
|
||||||
|
if (Vector3.new(pos.X, 0, pos.Z) - Vector3.new(bp.X, 0, bp.Z)).Magnitude < WIN then
|
||||||
|
n += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
table.insert(out, ("medianParts=%d roadParts=%d window=+/-%d"):format(#medians, #roads, WIN))
|
||||||
|
|
||||||
|
local ctrl = {}
|
||||||
|
local step = math.max(1, math.floor(#roads / 14))
|
||||||
|
for i = 1, #roads, step do
|
||||||
|
local p = roads[i]
|
||||||
|
local far = true
|
||||||
|
for _, a in ipairs(anchors) do
|
||||||
|
if (Vector3.new(p.X, 0, p.Z) - Vector3.new(a[2].X, 0, a[2].Z)).Magnitude < 700 then
|
||||||
|
far = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if far then
|
||||||
|
table.insert(ctrl, countNear(p, medians))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(ctrl)
|
||||||
|
local sum = 0
|
||||||
|
for _, v in ipairs(ctrl) do
|
||||||
|
sum += v
|
||||||
|
end
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("CONTROL plain mainline (n=%d): min=%d median=%d max=%d mean=%.1f"):format(
|
||||||
|
#ctrl,
|
||||||
|
ctrl[1] or -1,
|
||||||
|
ctrl[math.max(1, math.floor(#ctrl / 2))] or -1,
|
||||||
|
ctrl[#ctrl] or -1,
|
||||||
|
#ctrl > 0 and sum / #ctrl or -1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _, a in ipairs(anchors) do
|
||||||
|
table.insert(out, ("JUNCTION %s: mediansInWindow=%d"):format(a[1], countNear(a[2], medians)))
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render the Daikoku PA junction from Circuit.luau geometry.
|
||||||
|
|
||||||
|
MEASURE FIRST: code reasoning about elevation misleads (see project notes).
|
||||||
|
This parses the ACTUAL baked samples + the lift profile out of Circuit.luau
|
||||||
|
and draws what the builder will produce, so it can be compared against the
|
||||||
|
real aerial photographs before pushing to Studio.
|
||||||
|
|
||||||
|
Outputs a 3-panel figure:
|
||||||
|
1. top-down -- mainline vs PA ring footprint (shows the overlap)
|
||||||
|
2. elevation -- deck height through the junction (shows the clearance)
|
||||||
|
3. section -- cross-section at the PA, mainline flying over the apron
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.patches import Circle, Rectangle
|
||||||
|
|
||||||
|
SRC = "/home/jesse/roblox/tokyo-wangan-racing/src/shared/Circuit.luau"
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/daikoku_check.png"
|
||||||
|
|
||||||
|
src = open(SRC).read()
|
||||||
|
PA_C = tuple(
|
||||||
|
float(x)
|
||||||
|
for x in re.search(
|
||||||
|
r"PA_CENTER = Vector3\.new\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)", src
|
||||||
|
).groups()
|
||||||
|
)
|
||||||
|
PA_R = float(re.search(r"PA_RADIUS\s*=\s*([\d.]+)", src).group(1))
|
||||||
|
LIFT = float(re.search(r"PA_LIFT\s*=\s*(\d+)", src).group(1))
|
||||||
|
FLAT = int(re.search(r"LIFT_FLAT\s*=\s*(\d+)", src).group(1))
|
||||||
|
RAMPN = int(re.search(r"LIFT_RAMP\s*=\s*(\d+)", src).group(1))
|
||||||
|
|
||||||
|
raw = re.findall(
|
||||||
|
r'\{\s*(-?[\d.]+),\s*(-?[\d.]+),\s*(-?[\d.]+),\s*(\d),\s*(\d),\s*"([^"]+)"\s*\}', src
|
||||||
|
)
|
||||||
|
S = [
|
||||||
|
{"x": float(a), "y": float(b), "z": float(c), "tun": int(d) == 1}
|
||||||
|
for a, b, c, d, e, f in raw
|
||||||
|
]
|
||||||
|
n = len(S)
|
||||||
|
|
||||||
|
best, bd = 0, 1e18
|
||||||
|
for i, s in enumerate(S):
|
||||||
|
d = math.hypot(s["x"] - PA_C[0], s["z"] - PA_C[2])
|
||||||
|
if not s["tun"] and d < bd:
|
||||||
|
best, bd = i, d
|
||||||
|
|
||||||
|
|
||||||
|
def circd(i, j):
|
||||||
|
d = abs(i - j)
|
||||||
|
return min(d, n - d)
|
||||||
|
|
||||||
|
|
||||||
|
def lift_t(i):
|
||||||
|
d = circd(i, best)
|
||||||
|
if d <= FLAT:
|
||||||
|
return 1.0
|
||||||
|
if d >= FLAT + RAMPN:
|
||||||
|
return 0.0
|
||||||
|
u = (d - FLAT) / RAMPN
|
||||||
|
return 1 - (u * u * (3 - 2 * u))
|
||||||
|
|
||||||
|
|
||||||
|
for i, s in enumerate(S):
|
||||||
|
s["ly"] = s["y"] + LIFT * lift_t(i)
|
||||||
|
|
||||||
|
RING_R = PA_R + 130
|
||||||
|
RING_W = 44
|
||||||
|
ringY = PA_C[1]
|
||||||
|
entry, exitI = (best - 14) % n, (best + 14) % n
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(17, 6.2))
|
||||||
|
fig.suptitle(
|
||||||
|
"Daikoku PA junction — rebuilt from reference photos (expressway flies OVER the apron)",
|
||||||
|
fontsize=13,
|
||||||
|
fontweight="bold",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 1. TOP-DOWN -----------------------------------------------------------
|
||||||
|
ax = fig.add_subplot(1, 3, 1)
|
||||||
|
ax.set_title("Top-down: footprints overlap by design", fontsize=10)
|
||||||
|
win = [i for i in range(n) if circd(i, best) <= 55]
|
||||||
|
win.sort(key=lambda i: circd(i, best))
|
||||||
|
seq = sorted(win, key=lambda i: (i - best) % n if (i - best) % n < n / 2 else (i - best) % n - n)
|
||||||
|
ax.plot([S[i]["x"] for i in seq], [S[i]["z"] for i in seq], "-", color="#333", lw=7,
|
||||||
|
solid_capstyle="round", label="mainline deck (elevated)")
|
||||||
|
ax.add_patch(Rectangle((PA_C[0] - PA_R, PA_C[2] - PA_R), PA_R * 2, PA_R * 2,
|
||||||
|
fc="#9a9a9a", ec="#555", lw=1, alpha=.55, label="parking apron (ground)"))
|
||||||
|
ax.add_patch(Circle((PA_C[0], PA_C[2]), RING_R + RING_W / 2, fill=False, ec="#c44", lw=2))
|
||||||
|
ax.add_patch(Circle((PA_C[0], PA_C[2]), RING_R - RING_W / 2, fill=False, ec="#c44", lw=2,
|
||||||
|
label="PA ring road (ground)"))
|
||||||
|
for nm, idx, col in (("entry", entry, "#1a7"), ("exit", exitI, "#17a")):
|
||||||
|
r = S[idx]
|
||||||
|
tox, toz = PA_C[0] - r["x"], PA_C[2] - r["z"]
|
||||||
|
m = math.hypot(tox, toz)
|
||||||
|
rp = (PA_C[0] - tox / m * RING_R, PA_C[2] - toz / m * RING_R)
|
||||||
|
ax.plot([r["x"], rp[0]], [r["z"], rp[1]], color=col, lw=5, solid_capstyle="round",
|
||||||
|
label=f"{nm} ramp (graded)")
|
||||||
|
ax.set_aspect("equal")
|
||||||
|
ax.legend(fontsize=7, loc="upper right")
|
||||||
|
ax.set_xlabel("X (studs)")
|
||||||
|
ax.set_ylabel("Z (studs)")
|
||||||
|
ax.grid(alpha=.25)
|
||||||
|
|
||||||
|
# ---- 2. ELEVATION PROFILE --------------------------------------------------
|
||||||
|
ax2 = fig.add_subplot(1, 3, 2)
|
||||||
|
ax2.set_title("Elevation through junction: 38-stud clearance", fontsize=10)
|
||||||
|
rng = [(best + k) % n for k in range(-60, 61)]
|
||||||
|
xs = list(range(-60, 61))
|
||||||
|
ax2.plot(xs, [S[i]["y"] for i in rng], "--", color="#b00", lw=1.6,
|
||||||
|
label="BEFORE — deck at apron level (wall in road)")
|
||||||
|
ax2.plot(xs, [S[i]["ly"] for i in rng], "-", color="#0a0", lw=2.4, label="AFTER — lifted deck")
|
||||||
|
ax2.axhline(ringY, color="#777", lw=1.4, ls=":", label="PA ring / apron level")
|
||||||
|
ax2.fill_between(xs, [S[i]["ly"] for i in rng], ringY, color="#0a0", alpha=.10)
|
||||||
|
for idx, lb, col in ((entry - best, "entry", "#1a7"), (exitI - best, "exit", "#17a")):
|
||||||
|
ax2.axvline(idx, color=col, lw=1.3, ls="-.")
|
||||||
|
ax2.text(idx, ringY - 7, lb, ha="center", fontsize=7, color=col)
|
||||||
|
ax2.annotate("", xy=(0, ringY), xytext=(0, ringY + LIFT),
|
||||||
|
arrowprops=dict(arrowstyle="<->", color="#333", lw=1.4))
|
||||||
|
ax2.text(2.5, ringY + LIFT / 2, f"{LIFT:.0f} studs\nclearance", fontsize=8, va="center")
|
||||||
|
ax2.set_xlabel("sample offset from PA centre")
|
||||||
|
ax2.set_ylabel("deck height Y (studs)")
|
||||||
|
ax2.legend(fontsize=7, loc="lower left")
|
||||||
|
ax2.grid(alpha=.25)
|
||||||
|
|
||||||
|
# ---- 3. CROSS-SECTION ------------------------------------------------------
|
||||||
|
ax3 = fig.add_subplot(1, 3, 3)
|
||||||
|
ax3.set_title("Section at PA — matches reference photos", fontsize=10)
|
||||||
|
ax3.add_patch(Rectangle((-PA_R, ringY - 3), PA_R * 2, 3, fc="#9a9a9a", ec="#555"))
|
||||||
|
ax3.text(0, ringY - 9, "parking apron (ground level)", ha="center", fontsize=8)
|
||||||
|
for sgn in (-1, 1):
|
||||||
|
ax3.add_patch(Rectangle((sgn * RING_R - RING_W / 2, ringY - 3), RING_W, 4,
|
||||||
|
fc="#c44", ec="#822"))
|
||||||
|
ax3.text(RING_R, ringY + 6, "ring road", ha="center", fontsize=7, color="#822")
|
||||||
|
deckY = ringY + LIFT
|
||||||
|
ax3.add_patch(Rectangle((-bd - 60, deckY), 120, 5, fc="#333", ec="#000"))
|
||||||
|
ax3.text(-bd, deckY + 12, "elevated mainline", ha="center", fontsize=8, fontweight="bold")
|
||||||
|
for px in (-bd - 40, -bd + 40):
|
||||||
|
ax3.add_patch(Rectangle((px - 4, ringY - 2), 8, LIFT + 2, fc="#bbb", ec="#777"))
|
||||||
|
for cx in (-160, -60, 40, 140, 240):
|
||||||
|
ax3.add_patch(Rectangle((cx, ringY, ), 26, 9, fc="#4a7fb5", ec="#245"))
|
||||||
|
ax3.annotate("", xy=(-bd + 70, ringY), xytext=(-bd + 70, deckY),
|
||||||
|
arrowprops=dict(arrowstyle="<->", color="#0a0", lw=1.6))
|
||||||
|
ax3.text(-bd + 78, ringY + LIFT / 2, f"{LIFT:.0f} studs\n(cars pass under)",
|
||||||
|
fontsize=8, va="center", color="#070")
|
||||||
|
ax3.set_xlim(-PA_R - 220, PA_R + 220)
|
||||||
|
ax3.set_ylim(ringY - 22, deckY + 30)
|
||||||
|
ax3.set_xlabel("distance from PA centre (studs)")
|
||||||
|
ax3.set_ylabel("Y (studs)")
|
||||||
|
ax3.grid(alpha=.25)
|
||||||
|
|
||||||
|
fig.tight_layout(rect=[0, 0, 1, .94])
|
||||||
|
fig.savefig(OUT, dpi=118)
|
||||||
|
print("wrote", OUT)
|
||||||
|
print(f"nearest sample={best} dist={bd:.1f} ring outer={RING_R + RING_W / 2:.1f}"
|
||||||
|
f" overlap={RING_R + RING_W / 2 - bd:+.1f} studs")
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plot_wallmap.py -- render the JSON dumped by tools/diag_wallmap.luau as a
|
||||||
|
top-down PNG.
|
||||||
|
|
||||||
|
WHY THIS EXISTS: the Studio viewport cannot be screenshotted on this machine
|
||||||
|
(Vinegar/Wine on nested X display :1, DXVK-rendered -> XGetImage returns
|
||||||
|
black). For "is there a gap / does it look jumbled / which way does it face"
|
||||||
|
questions, plotting the datamodel geometry top-down is both possible and more
|
||||||
|
precise than a screenshot.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tools/plot_wallmap.py /tmp/wallmap_raw.txt /tmp/wallmap.png ["Title"]
|
||||||
|
|
||||||
|
The input file is whatever rbx.call('execute_luau', ...) returned for
|
||||||
|
diag_wallmap.luau -- it may be JSON or a Python-repr dict, both are handled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
STYLE = {
|
||||||
|
"Road": ("#3a3a44", 0.5),
|
||||||
|
"Ramp": ("#4a4a58", 0.5),
|
||||||
|
"Apron": ("#2e2e36", 0.5),
|
||||||
|
"Barrier": ("#ffffff", 0.95),
|
||||||
|
"EdgeRail": ("#8a8a9a", 0.95),
|
||||||
|
"KillWall": ("#ff4444", 0.95),
|
||||||
|
"PARingWall": ("#00e5ff", 0.95),
|
||||||
|
"PARingRail": ("#0090a0", 0.95),
|
||||||
|
"Median": ("#ffd400", 0.95),
|
||||||
|
"MedianFence": ("#c0a000", 0.95),
|
||||||
|
}
|
||||||
|
# Draw decks first so walls land on top of them.
|
||||||
|
ORDER = [
|
||||||
|
"Road", "Ramp", "Apron", "Median", "MedianFence",
|
||||||
|
"KillWall", "EdgeRail", "Barrier", "PARingWall", "PARingRail",
|
||||||
|
]
|
||||||
|
LEGEND = [
|
||||||
|
("PARingWall (ring parapet)", "#00e5ff"),
|
||||||
|
("Barrier (mainline)", "#ffffff"),
|
||||||
|
("KillWall", "#ff4444"),
|
||||||
|
("Median", "#ffd400"),
|
||||||
|
("Road/Ramp deck", "#3a3a44"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
raw = open(path).read()
|
||||||
|
start = raw.find("{")
|
||||||
|
if start < 0:
|
||||||
|
raise SystemExit(f"no payload found in {path}")
|
||||||
|
body = raw[start:]
|
||||||
|
try:
|
||||||
|
return json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# The bridge often hands back a Python repr (single quotes, True/None).
|
||||||
|
return ast.literal_eval(body)
|
||||||
|
|
||||||
|
|
||||||
|
def analyse(parts):
|
||||||
|
"""Print the numbers that answer 'is it sealed' before drawing anything."""
|
||||||
|
ring = [p for p in parts if p["k"] == "PARingWall"]
|
||||||
|
if not ring:
|
||||||
|
return None, None
|
||||||
|
cx = sum(p["x"] for p in ring) / len(ring)
|
||||||
|
cz = sum(p["z"] for p in ring) / len(ring)
|
||||||
|
rad = [math.hypot(p["x"] - cx, p["z"] - cz) for p in ring]
|
||||||
|
split = (min(rad) + max(rad)) / 2
|
||||||
|
inner = [p for p in ring if math.hypot(p["x"] - cx, p["z"] - cz) < split]
|
||||||
|
outer = [p for p in ring if math.hypot(p["x"] - cx, p["z"] - cz) >= split]
|
||||||
|
print(f"PA centre ~({cx:.0f},{cz:.0f}) radii {min(rad):.0f}..{max(rad):.0f}")
|
||||||
|
print(f"inner-edge walls {len(inner)}/72 outer-edge walls {len(outer)}/72")
|
||||||
|
|
||||||
|
angs = sorted(math.degrees(math.atan2(p["z"] - cz, p["x"] - cx)) % 360 for p in inner)
|
||||||
|
gaps = []
|
||||||
|
for i, a in enumerate(angs):
|
||||||
|
nxt = angs[(i + 1) % len(angs)]
|
||||||
|
d = (nxt - a) % 360
|
||||||
|
if d > 7.5: # one segment is 5 deg; >7.5 means at least one is absent
|
||||||
|
gaps.append((round(a, 1), round(nxt, 1), round(d, 1)))
|
||||||
|
print("INNER-wall gaps >7.5deg (a usable exit shows here):", gaps or "NONE - sealed")
|
||||||
|
|
||||||
|
road = sum(1 for p in parts if p["k"] == "Road")
|
||||||
|
bar = sum(1 for p in parts if p["k"] == "Barrier")
|
||||||
|
if road:
|
||||||
|
# One barrier per side of every deck segment => healthy ratio ~2.0.
|
||||||
|
print(f"mainline Road={road} Barrier={bar} ratio={bar/road:.2f} (healthy ~2.0)")
|
||||||
|
return cx, cz
|
||||||
|
|
||||||
|
|
||||||
|
def render(parts, cx, cz, out_png, title, span=1000.0, size=1400):
|
||||||
|
def sx(x):
|
||||||
|
return (x - cx) / span * (size / 2) + size / 2
|
||||||
|
|
||||||
|
def sy(z):
|
||||||
|
return (z - cz) / span * (size / 2) + size / 2
|
||||||
|
|
||||||
|
svg = [
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
|
||||||
|
f'<rect width="{size}" height="{size}" fill="#0e0e12"/>'
|
||||||
|
]
|
||||||
|
for kind in ORDER:
|
||||||
|
col, op = STYLE.get(kind, ("#888888", 0.9))
|
||||||
|
for p in parts:
|
||||||
|
if p["k"] != kind:
|
||||||
|
continue
|
||||||
|
w = max(p["sx"] / span * (size / 2), 1.5)
|
||||||
|
h = max(p["sz"] / span * (size / 2), 1.5)
|
||||||
|
# Negate yaw: world +Z runs down the screen in this projection.
|
||||||
|
svg.append(
|
||||||
|
f'<g transform="translate({sx(p["x"]):.1f},{sy(p["z"]):.1f}) rotate({-p["r"]:.1f})">'
|
||||||
|
f'<rect x="{-w/2:.1f}" y="{-h/2:.1f}" width="{w:.1f}" height="{h:.1f}"'
|
||||||
|
f' fill="{col}" opacity="{op}"/></g>'
|
||||||
|
)
|
||||||
|
svg.append(f'<circle cx="{sx(cx):.0f}" cy="{sy(cz):.0f}" r="5" fill="#39ff14"/>')
|
||||||
|
svg.append(
|
||||||
|
f'<text x="20" y="34" fill="#eee" font-family="monospace" font-size="21">{title}</text>'
|
||||||
|
)
|
||||||
|
for i, (lab, col) in enumerate(LEGEND):
|
||||||
|
yy = 64 + i * 25
|
||||||
|
svg.append(
|
||||||
|
f'<rect x="20" y="{yy-11}" width="16" height="13" fill="{col}"/>'
|
||||||
|
f'<text x="44" y="{yy}" fill="#bbb" font-family="monospace" font-size="16">{lab}</text>'
|
||||||
|
)
|
||||||
|
svg.append("</svg>")
|
||||||
|
|
||||||
|
svg_path = out_png.rsplit(".", 1)[0] + ".svg"
|
||||||
|
open(svg_path, "w").write("\n".join(svg))
|
||||||
|
# rsvg-convert gives cleaner output but is often not installed; fall back to
|
||||||
|
# ImageMagick. FileNotFoundError (binary absent) must be caught too -- a
|
||||||
|
# returncode check alone does not cover it.
|
||||||
|
try:
|
||||||
|
rc = subprocess.run(
|
||||||
|
["rsvg-convert", "-w", str(size), svg_path, "-o", out_png],
|
||||||
|
capture_output=True,
|
||||||
|
).returncode
|
||||||
|
except FileNotFoundError:
|
||||||
|
rc = 1
|
||||||
|
if rc != 0:
|
||||||
|
subprocess.run(["convert", "-density", "96", svg_path, out_png], check=True)
|
||||||
|
print("wrote", out_png)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
src = sys.argv[1] if len(sys.argv) > 1 else "/tmp/wallmap_raw.txt"
|
||||||
|
dst = sys.argv[2] if len(sys.argv) > 2 else "/tmp/wallmap.png"
|
||||||
|
title = sys.argv[3] if len(sys.argv) > 3 else "Daikoku PA -- wall map"
|
||||||
|
data = load(src)
|
||||||
|
parts = data["parts"]
|
||||||
|
print("counts (whole map):", data.get("counts"))
|
||||||
|
print("plotted parts:", len(parts))
|
||||||
|
cx, cz = analyse(parts)
|
||||||
|
if cx is None:
|
||||||
|
raise SystemExit("no PARingWall parts found -- nothing to centre on")
|
||||||
|
render(parts, cx, cz, dst, title)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- The rebuild was interrupted mid-flight; its effect is UNKNOWN. Establish
|
||||||
|
-- ground truth before touching anything else.
|
||||||
|
-- (a) Did the build run? part count vs 16714 (user-edited) / 16820 (source).
|
||||||
|
-- (b) Is the aux lane back? user's edited workspace had 30 aux parts,
|
||||||
|
-- source produces 52 -- so they deleted ~22.
|
||||||
|
-- (c) Did the smoothing pass actually land on the east slip?
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "workspace.Map MISSING" end
|
||||||
|
|
||||||
|
local parts = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then parts += 1 end
|
||||||
|
end
|
||||||
|
say(("parts=%d (16714=user-edited, 16820=source rebuild)"):format(parts))
|
||||||
|
|
||||||
|
-- Aux lane inventory, with index range + position so I can tell WHICH lane.
|
||||||
|
local aux = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if k then table.insert(aux, { i = tonumber(k), p = d.Position, inst = d }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(aux, function(a, b) return a.i < b.i end)
|
||||||
|
say(("AuxLane parts=%d (30=after user deletion, 52=source)"):format(#aux))
|
||||||
|
if #aux > 0 then
|
||||||
|
-- group into contiguous runs so each physical lane shows up separately
|
||||||
|
local runs, cur = {}, nil
|
||||||
|
for _, a in ipairs(aux) do
|
||||||
|
if cur and a.i - cur.last <= 2 then cur.last = a.i; cur.n += 1
|
||||||
|
else
|
||||||
|
if cur then table.insert(runs, cur) end
|
||||||
|
cur = { first = a.i, last = a.i, n = 1, p = a.p }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if cur then table.insert(runs, cur) end
|
||||||
|
for _, r in ipairs(runs) do
|
||||||
|
say((" aux run idx %d..%d n=%d at (%.0f,%.0f,%.0f)"):format(
|
||||||
|
r.first, r.last, r.n, r.p.X, r.p.Y, r.p.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Slip smoothness: did the relaxation take effect?
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b) return a.i < b.i end)
|
||||||
|
if #segs == 0 then say(s .. ": ABSENT") continue end
|
||||||
|
local total, worst, worstAt, over = 0, 0, -1, 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector
|
||||||
|
local d = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||||
|
total += d
|
||||||
|
if d > worst then worst = d; worstAt = segs[j].i end
|
||||||
|
if d > 5 then over += 1 end
|
||||||
|
end
|
||||||
|
local topY, level = segs[1].p.Y, 0
|
||||||
|
for _, sg in ipairs(segs) do
|
||||||
|
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
|
||||||
|
end
|
||||||
|
say(("%s: segs=%d total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format(
|
||||||
|
s, #segs, total, worst, worstAt, over, level))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit"))
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "no Map" end
|
||||||
|
|
||||||
|
-- Aggregate PA parts EXCLUDING the slips we just built, so we can see the lot.
|
||||||
|
local agg = { xMin = math.huge, xMax = -math.huge, zMin = math.huge, zMax = -math.huge }
|
||||||
|
local names = {}
|
||||||
|
local biggest, biggestArea = nil, 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:sub(1, 2) == "PA" and not d.Name:find("Slip") then
|
||||||
|
names[d.Name] = (names[d.Name] or 0) + 1
|
||||||
|
agg.xMin = math.min(agg.xMin, d.Position.X - d.Size.X / 2)
|
||||||
|
agg.xMax = math.max(agg.xMax, d.Position.X + d.Size.X / 2)
|
||||||
|
agg.zMin = math.min(agg.zMin, d.Position.Z - d.Size.Z / 2)
|
||||||
|
agg.zMax = math.max(agg.zMax, d.Position.Z + d.Size.Z / 2)
|
||||||
|
local area = d.Size.X * d.Size.Z
|
||||||
|
if area > biggestArea then
|
||||||
|
biggestArea = area
|
||||||
|
biggest = ("%s size=(%d,%d,%d) pos=(%d,%d,%d) yTop=%.1f"):format(
|
||||||
|
d.Name, d.Size.X, d.Size.Y, d.Size.Z,
|
||||||
|
d.Position.X, d.Position.Y, d.Position.Z,
|
||||||
|
d.Position.Y + d.Size.Y / 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local nameList = {}
|
||||||
|
for n, c in pairs(names) do
|
||||||
|
table.insert(nameList, n .. "x" .. c)
|
||||||
|
end
|
||||||
|
table.sort(nameList)
|
||||||
|
-- cap the list so the payload can't truncate
|
||||||
|
local head = {}
|
||||||
|
for i = 1, math.min(#nameList, 40) do head[i] = nameList[i] end
|
||||||
|
|
||||||
|
local s = Circuit.samples
|
||||||
|
local function probe(i)
|
||||||
|
local a, b = s[i], s[i + 1]
|
||||||
|
local d = (b.pos - a.pos)
|
||||||
|
local fwd = Vector3.new(d.X, 0, d.Z).Unit
|
||||||
|
local right = fwd:Cross(Vector3.yAxis)
|
||||||
|
return ("s%d pos=(%d,%d) fwd=(%.2f,%.2f) right=(%.2f,%.2f)"):format(
|
||||||
|
i, a.pos.X, a.pos.Z, fwd.X, fwd.Z, right.X, right.Z)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HttpService:JSONEncode({
|
||||||
|
pa_nonslip_bounds = { x = { math.floor(agg.xMin), math.floor(agg.xMax) }, z = { math.floor(agg.zMin), math.floor(agg.zMax) } },
|
||||||
|
biggest_pa_part = biggest,
|
||||||
|
distinct_pa_names = #nameList,
|
||||||
|
sample_names = head,
|
||||||
|
pa_center = tostring(Circuit.PA_CENTER),
|
||||||
|
joins = { probe(525), probe(537), probe(551), probe(539) },
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
-- Two questions, measured rather than eyeballed:
|
||||||
|
-- (1) WHERE exactly is the mainline missing barriers? Walk the deck edge and
|
||||||
|
-- report contiguous runs with no Barrier/EdgeRail within reach.
|
||||||
|
-- (User deleted ~106 parts by hand, so gaps are wherever they cut.)
|
||||||
|
-- (2) HOW does the south on-ramp differ from the north off-ramp? Compare the
|
||||||
|
-- two slips feature-by-feature so "make it look like the other" is a
|
||||||
|
-- concrete list of deltas, not a guess.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
-- Collect barrier-ish parts into a coarse spatial hash for fast lookup.
|
||||||
|
local CELL = 60
|
||||||
|
local grid = {}
|
||||||
|
local function key(x, z) return math.floor(x / CELL) .. ":" .. math.floor(z / CELL) end
|
||||||
|
local barrierCount = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then
|
||||||
|
barrierCount += 1
|
||||||
|
local k = key(d.Position.X, d.Position.Z)
|
||||||
|
grid[k] = grid[k] or {}
|
||||||
|
table.insert(grid[k], d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("indexed %d Barrier/EdgeRail parts"):format(barrierCount))
|
||||||
|
|
||||||
|
local function nearestBarrier(p)
|
||||||
|
local best = math.huge
|
||||||
|
local cx, cz = math.floor(p.X / CELL), math.floor(p.Z / CELL)
|
||||||
|
for dx = -1, 1 do
|
||||||
|
for dz = -1, 1 do
|
||||||
|
local c = grid[(cx + dx) .. ":" .. (cz + dz)]
|
||||||
|
if c then
|
||||||
|
for _, b in ipairs(c) do
|
||||||
|
local d2 = (Vector3.new(b.Position.X, 0, b.Position.Z) - Vector3.new(p.X, 0, p.Z)).Magnitude
|
||||||
|
-- only count barriers at roughly this height
|
||||||
|
if math.abs(b.Position.Y - p.Y) < 14 and d2 < best then best = d2 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Walk the mainline deck parts to build an ordered centreline.
|
||||||
|
local deck = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
or d.Name:match("^RoadDeck_(%d+)$")
|
||||||
|
if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame, sz = d.Size }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(deck, function(a, b) return a.i < b.i end)
|
||||||
|
say(("mainline deck parts found: %d"):format(#deck))
|
||||||
|
|
||||||
|
if #deck > 0 then
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local runs = {}
|
||||||
|
local cur = nil
|
||||||
|
for j, s in ipairs(deck) do
|
||||||
|
local fwd = s.cf.LookVector
|
||||||
|
local right = fwd:Cross(UP).Unit
|
||||||
|
local half = s.sz.X / 2
|
||||||
|
-- probe just outboard of each edge
|
||||||
|
local gapL = nearestBarrier(s.p - right * (half + 2)) > 12
|
||||||
|
local gapR = nearestBarrier(s.p + right * (half + 2)) > 12
|
||||||
|
local bad = gapL or gapR
|
||||||
|
if bad then
|
||||||
|
local side = (gapL and gapR) and "BOTH" or (gapL and "L" or "R")
|
||||||
|
if cur and cur.side == side and s.i - cur.last <= 2 then
|
||||||
|
cur.last = s.i; cur.n += 1
|
||||||
|
else
|
||||||
|
if cur then table.insert(runs, cur) end
|
||||||
|
cur = { first = s.i, last = s.i, n = 1, side = side, pos = s.p }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if cur then table.insert(runs, cur); cur = nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if cur then table.insert(runs, cur) end
|
||||||
|
|
||||||
|
table.sort(runs, function(a, b) return a.n > b.n end)
|
||||||
|
say(("UNBARRIERED RUNS: %d total"):format(#runs))
|
||||||
|
for j = 1, math.min(#runs, 14) do
|
||||||
|
local r = runs[j]
|
||||||
|
say((" samples %d..%d (%d) side=%s at (%.0f,%.0f,%.0f)"):format(
|
||||||
|
r.first, r.last, r.n, r.side, r.pos.X, r.pos.Y, r.pos.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (2) Compare the two slips.
|
||||||
|
say("--- SLIP COMPARISON ---")
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local segs, walls, lines, other = {}, 0, 0, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^" .. s) then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then
|
||||||
|
table.insert(segs, { i = tonumber(k), p = d.Position, sz = d.Size, cf = d.CFrame })
|
||||||
|
elseif d.Name:match("_Wall_") then walls += 1
|
||||||
|
elseif d.Name:match("[Ll]ine") then lines += 1
|
||||||
|
else
|
||||||
|
local nm = d.Name:gsub("^" .. s .. "_", ""):gsub("_%d+$", "")
|
||||||
|
other[nm] = (other[nm] or 0) + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b) return a.i < b.i end)
|
||||||
|
local ol = {}
|
||||||
|
for k, v in pairs(other) do table.insert(ol, k .. "=" .. v) end
|
||||||
|
table.sort(ol)
|
||||||
|
if #segs > 0 then
|
||||||
|
-- heading of first and last segment: which way does it run?
|
||||||
|
local h1 = segs[1].cf.LookVector
|
||||||
|
local hN = segs[#segs].cf.LookVector
|
||||||
|
-- curvature: total heading change
|
||||||
|
local turn = 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local a = segs[j - 1].cf.LookVector
|
||||||
|
local b = segs[j].cf.LookVector
|
||||||
|
turn += math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||||
|
end
|
||||||
|
say(("%s: segs=%d walls=%d lines=%d startY=%.1f endY=%.1f"):format(s, #segs, walls, lines, segs[1].p.Y, segs[#segs].p.Y))
|
||||||
|
say((" heading first=(%.2f,%.2f) last=(%.2f,%.2f) totalTurn=%.1fdeg"):format(h1.X, h1.Z, hN.X, hN.Z, turn))
|
||||||
|
say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format(
|
||||||
|
segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z))
|
||||||
|
say(" other parts: " .. (#ol > 0 and table.concat(ol, " ") or "none"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- The require sweep passing only proves the modules LOAD. What runs on Play is
|
||||||
|
-- MapBuilder actually building. Execute it for real and capture the error.
|
||||||
|
local SS = game:GetService("ServerScriptService")
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local serverScript = SS:FindFirstChild("Server")
|
||||||
|
local mb
|
||||||
|
for _, d in ipairs(SS:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end
|
||||||
|
end
|
||||||
|
say("MapBuilder module: " .. (mb and mb:GetFullName() or "NOT FOUND"))
|
||||||
|
if not mb then return table.concat(out, "\n") end
|
||||||
|
|
||||||
|
local ok, mod = pcall(require, mb)
|
||||||
|
say("require: " .. (ok and "ok" or ("FAIL " .. tostring(mod))))
|
||||||
|
if not ok then return table.concat(out, "\n") end
|
||||||
|
|
||||||
|
say("exports: " .. type(mod))
|
||||||
|
if type(mod) == "table" then
|
||||||
|
local keys = {}
|
||||||
|
for k, v in pairs(mod) do table.insert(keys, ("%s(%s)"):format(tostring(k), type(v))) end
|
||||||
|
table.sort(keys)
|
||||||
|
say(" " .. table.concat(keys, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clear any existing map so we build from scratch, exactly like a fresh run.
|
||||||
|
local existing = workspace:FindFirstChild("Map")
|
||||||
|
say("existing Map in workspace: " .. (existing and "yes" or "no"))
|
||||||
|
|
||||||
|
local buildFn = type(mod) == "table" and (mod.Build or mod.build or mod.BuildMap)
|
||||||
|
if type(buildFn) ~= "function" then
|
||||||
|
say("NO BUILD FUNCTION EXPORTED -- cannot test build path")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
if existing then existing:Destroy() end
|
||||||
|
local t0 = os.clock()
|
||||||
|
local bok, berr = pcall(buildFn)
|
||||||
|
local dt = os.clock() - t0
|
||||||
|
say(("build pcall: %s (%.2fs)"):format(bok and "OK" or "FAIL", dt))
|
||||||
|
if not bok then
|
||||||
|
say("BUILD ERROR: " .. tostring(berr):sub(1, 600))
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local m = workspace:FindFirstChild("Map")
|
||||||
|
say("Map after build: " .. (m and "present" or "MISSING"))
|
||||||
|
if m then
|
||||||
|
local n = #m:GetDescendants()
|
||||||
|
say("descendants: " .. n)
|
||||||
|
if n == 0 then say("CONTROL FAIL: map built but empty") end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -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, " ; ")
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- Re-verify after un-pinning index 2, and settle the 4 "bare" samples:
|
||||||
|
-- 525L/526L/549L/550L sit at the ramp MOUTHS (west join=525, east join=551).
|
||||||
|
-- An open parapet at a mouth is correct -- that is the doorway cars drive
|
||||||
|
-- through, and the ramp deck's own walls take over. So check whether ramp deck
|
||||||
|
-- + ramp wall actually cover those samples. If yes: not a hole. If no: a hole.
|
||||||
|
local SS = game:GetService("ServerScriptService")
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local mod = SS:FindFirstChild("MapBuilder", true)
|
||||||
|
if not mod then return "MapBuilder NOT FOUND" end
|
||||||
|
local fresh = mod:Clone()
|
||||||
|
fresh.Name = "MB_fresh_" .. tostring(math.random(1e6))
|
||||||
|
fresh.Parent = SS
|
||||||
|
local ok, MB = pcall(require, fresh)
|
||||||
|
if not ok then fresh:Destroy() return "require FAILED: " .. tostring(MB) end
|
||||||
|
local ok2, err = pcall(function() MB.build() end)
|
||||||
|
fresh:Destroy()
|
||||||
|
if not ok2 then return "build ERROR: " .. tostring(err) end
|
||||||
|
say("build OK")
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
local parts = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then parts += 1 end
|
||||||
|
end
|
||||||
|
say(("parts=%d"):format(parts))
|
||||||
|
|
||||||
|
local deck, bars, slipAll = {}, {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end
|
||||||
|
if d.Name:match("^PA[EW][ae]stSlip") then table.insert(slipAll, d) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
local function nearest(list, p, ytol)
|
||||||
|
local best = math.huge
|
||||||
|
for _, b in ipairs(list) do
|
||||||
|
if math.abs(b.Position.Y - p.Y) < (ytol or 14) then
|
||||||
|
local dd = (flat(b.Position) - flat(p)).Magnitude
|
||||||
|
if dd < best then best = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
say("--- edge audit 520..555 (L side) ---")
|
||||||
|
local realHoles = {}
|
||||||
|
for i = 520, 555 do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
local edge = d.Position - right * (d.Size.X / 2 + 2)
|
||||||
|
local bd = nearest(bars, edge)
|
||||||
|
local sd = nearest(slipAll, edge, 20)
|
||||||
|
local covered = bd < 12 or sd < 30
|
||||||
|
if not covered then table.insert(realHoles, i) end
|
||||||
|
if bd >= 12 then
|
||||||
|
say((" %d: barrier=%.1f slipStruct=%.1f -> %s"):format(
|
||||||
|
i, bd, sd, covered and "OK (ramp mouth)" or "HOLE"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("REAL holes: %d %s"):format(#realHoles,
|
||||||
|
#realHoles > 0 and table.concat(realHoles, ",") or "(none)"))
|
||||||
|
|
||||||
|
say("--- smoothness ---")
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b) return a.i < b.i end)
|
||||||
|
local total, worst, worstAt, over = 0, 0, -1, 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector
|
||||||
|
local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||||
|
total += dth
|
||||||
|
if dth > worst then worst = dth; worstAt = segs[j].i end
|
||||||
|
if dth > 5 then over += 1 end
|
||||||
|
end
|
||||||
|
local topY, level = segs[1].p.Y, 0
|
||||||
|
for _, sg in ipairs(segs) do
|
||||||
|
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
|
||||||
|
end
|
||||||
|
say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format(
|
||||||
|
s, total, worst, worstAt, over, level))
|
||||||
|
say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format(
|
||||||
|
segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z))
|
||||||
|
end
|
||||||
|
|
||||||
|
local aux = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^AuxLane_%d+$") then aux += 1 end
|
||||||
|
end
|
||||||
|
say(("AuxLane total=%d (want 30)"):format(aux))
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- Studio caches required modules, which is why the previous rebuild silently
|
||||||
|
-- ran the OLD code (east slip still measured 216.0deg, unchanged). Force a
|
||||||
|
-- fresh load: clone the ModuleScript so require() sees a new instance and
|
||||||
|
-- recompiles from current source.
|
||||||
|
--
|
||||||
|
-- Verifies three things at once:
|
||||||
|
-- (1) smoothing landed -> east total heading change << 216deg, zero >5deg kinks
|
||||||
|
-- (2) aux lane stays deleted -> AuxLane run 527..548 ABSENT, total ~30 not 52
|
||||||
|
-- (3) barrier gap closed -> parapet present along 527..548
|
||||||
|
local SS = game:GetService("ServerScriptService")
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local mod = SS:FindFirstChild("MapBuilder", true)
|
||||||
|
if not mod then return "MapBuilder NOT FOUND" end
|
||||||
|
if not mod:IsA("ModuleScript") then return "MapBuilder is " .. mod.ClassName end
|
||||||
|
|
||||||
|
local fresh = mod:Clone()
|
||||||
|
fresh.Name = "MapBuilder_fresh_" .. tostring(math.random(1e6))
|
||||||
|
fresh.Parent = SS
|
||||||
|
|
||||||
|
local ok, MB = pcall(require, fresh)
|
||||||
|
if not ok then
|
||||||
|
fresh:Destroy()
|
||||||
|
return "require FAILED: " .. tostring(MB)
|
||||||
|
end
|
||||||
|
|
||||||
|
local t0 = os.clock()
|
||||||
|
local ok2, err = pcall(function() MB.build() end)
|
||||||
|
local dt = os.clock() - t0
|
||||||
|
fresh:Destroy()
|
||||||
|
if not ok2 then return "build ERROR: " .. tostring(err) end
|
||||||
|
say(("build OK (%.2fs) via fresh module"):format(dt))
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map missing" end
|
||||||
|
|
||||||
|
local parts = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then parts += 1 end
|
||||||
|
end
|
||||||
|
say(("parts=%d"):format(parts))
|
||||||
|
|
||||||
|
-- (2) aux lane
|
||||||
|
local aux = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if k then table.insert(aux, tonumber(k)) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(aux)
|
||||||
|
local inRange = 0
|
||||||
|
for _, i in ipairs(aux) do if i >= 527 and i <= 548 then inRange += 1 end end
|
||||||
|
say(("AuxLane total=%d inside 527..548 = %d (want 0)"):format(#aux, inRange))
|
||||||
|
|
||||||
|
-- (3) barrier coverage across the formerly-bare run
|
||||||
|
local deck, bars = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
local bare = {}
|
||||||
|
for i = 525, 550 do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
for _, sgn in ipairs({ -1, 1 }) do
|
||||||
|
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||||
|
local best = math.huge
|
||||||
|
for _, b in ipairs(bars) do
|
||||||
|
if math.abs(b.Position.Y - edge.Y) < 14 then
|
||||||
|
local dd = (flat(b.Position) - flat(edge)).Magnitude
|
||||||
|
if dd < best then best = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best > 12 then table.insert(bare, i .. (sgn < 0 and "L" or "R")) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("bare edge samples in 525..550: %d %s"):format(#bare,
|
||||||
|
#bare > 0 and ("-> " .. table.concat(bare, ",")) or "(want 0)"))
|
||||||
|
|
||||||
|
-- (1) smoothness
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b) return a.i < b.i end)
|
||||||
|
if #segs == 0 then say(s .. ": ABSENT") continue end
|
||||||
|
local total, worst, worstAt, over = 0, 0, -1, 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector
|
||||||
|
local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||||
|
total += dth
|
||||||
|
if dth > worst then worst = dth; worstAt = segs[j].i end
|
||||||
|
if dth > 5 then over += 1 end
|
||||||
|
end
|
||||||
|
local topY, level = segs[1].p.Y, 0
|
||||||
|
for _, sg in ipairs(segs) do
|
||||||
|
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
|
||||||
|
end
|
||||||
|
local lmin, lmax = math.huge, -math.huge
|
||||||
|
for j = 2, #segs do
|
||||||
|
local L = (segs[j].p - segs[j - 1].p).Magnitude
|
||||||
|
lmin = math.min(lmin, L); lmax = math.max(lmax, L)
|
||||||
|
end
|
||||||
|
say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d spacing=%.1f..%.1f"):format(
|
||||||
|
s, total, worst, worstAt, over, level, lmin, lmax))
|
||||||
|
say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format(
|
||||||
|
segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
-- Rebuild with AUX_SUPPRESS widened to 525..550, then verify BOTH marked
|
||||||
|
-- defects are gone:
|
||||||
|
-- (A) left oval -> zero samples with no left barrier
|
||||||
|
-- (B) right circle -> no 26-stud lateral snap at the suppression boundary
|
||||||
|
-- The 6.3-stud steps at 512/513/563 are the aux lane's own smooth taper
|
||||||
|
-- (width 26 -> 19 -> 13 -> 7 -> 0) and are CORRECT; the parapet rides the
|
||||||
|
-- taper. Only steps much larger than that are real juts.
|
||||||
|
local SS = game:GetService("ServerScriptService")
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local mod = SS:FindFirstChild("MapBuilder", true)
|
||||||
|
if not mod then return "MapBuilder NOT FOUND" end
|
||||||
|
local fresh = mod:Clone()
|
||||||
|
fresh.Name = "MB_fresh_" .. tostring(math.random(1e6))
|
||||||
|
fresh.Parent = SS
|
||||||
|
local ok, MB = pcall(require, fresh)
|
||||||
|
if not ok then fresh:Destroy() return "require FAILED: " .. tostring(MB) end
|
||||||
|
local ok2, err = pcall(function() MB.build() end)
|
||||||
|
fresh:Destroy()
|
||||||
|
if not ok2 then return "build ERROR: " .. tostring(err) end
|
||||||
|
say("build OK")
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
local parts = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then parts += 1 end
|
||||||
|
end
|
||||||
|
say(("parts=%d"):format(parts))
|
||||||
|
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local deck, aux, bars = {}, {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if a then aux[tonumber(a)] = d end
|
||||||
|
if d.Name == "Barrier" then table.insert(bars, d) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say("sample | auxW | left barrier lat | step")
|
||||||
|
local prevLat, jogs, gaps = nil, {}, {}
|
||||||
|
for i = 505, 570 do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
local c = d.Position
|
||||||
|
local best, bestLat = math.huge, nil
|
||||||
|
for _, b in ipairs(bars) do
|
||||||
|
if math.abs(b.Position.Y - c.Y) < 14 then
|
||||||
|
local rel = b.Position - c
|
||||||
|
local along = math.abs(rel:Dot(d.CFrame.LookVector))
|
||||||
|
local lat = rel:Dot(right)
|
||||||
|
if along < 22 and lat < 0 and along < best then best = along; bestLat = lat end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local auxW = aux[i] and aux[i].Size.X or 0
|
||||||
|
if bestLat then
|
||||||
|
local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0
|
||||||
|
if step > 10 then table.insert(jogs, ("%d(%.1f)"):format(i, step)) end
|
||||||
|
if step > 10 or i % 5 == 0 then
|
||||||
|
say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format(
|
||||||
|
i, auxW, bestLat, step, step > 10 and " <<< JOG" or ""))
|
||||||
|
end
|
||||||
|
prevLat = bestLat
|
||||||
|
else
|
||||||
|
table.insert(gaps, i)
|
||||||
|
say((" %d | aux=%.0f | NO LEFT BARRIER <<< GAP"):format(i, auxW))
|
||||||
|
prevLat = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("BIG JOGS (>10): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)"))
|
||||||
|
say(("GAPS: %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)"))
|
||||||
|
|
||||||
|
-- full-loop unguarded sweep, same rule as before
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
local allBars = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then
|
||||||
|
table.insert(allBars, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local slipParts = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("Slip") then table.insert(slipParts, d) end
|
||||||
|
end
|
||||||
|
local holes, swept = {}, 0
|
||||||
|
for i, d in pairs(deck) do
|
||||||
|
swept += 1
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
for _, sgn in ipairs({ -1, 1 }) do
|
||||||
|
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||||
|
local bd = math.huge
|
||||||
|
for _, b in ipairs(allBars) do
|
||||||
|
if math.abs(b.Position.Y - edge.Y) < 14 then
|
||||||
|
local dd = (flat(b.Position) - flat(edge)).Magnitude
|
||||||
|
if dd < bd then bd = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if bd >= 12 then
|
||||||
|
local ok2b = false
|
||||||
|
local a = aux[i]
|
||||||
|
if a then
|
||||||
|
local outer = a.Position + right * sgn * (a.Size.X / 2 + 2)
|
||||||
|
local bd2 = math.huge
|
||||||
|
for _, b in ipairs(allBars) do
|
||||||
|
if math.abs(b.Position.Y - outer.Y) < 14 then
|
||||||
|
local dd = (flat(b.Position) - flat(outer)).Magnitude
|
||||||
|
if dd < bd2 then bd2 = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if bd2 < 14 then ok2b = true end
|
||||||
|
end
|
||||||
|
if not ok2b then
|
||||||
|
local sd = math.huge
|
||||||
|
for _, p in ipairs(slipParts) do
|
||||||
|
local dd = (flat(p.Position) - flat(edge)).Magnitude
|
||||||
|
if dd < sd then sd = dd end
|
||||||
|
end
|
||||||
|
if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("FULL SWEEP: %d samples, unguarded=%d %s"):format(swept, #holes,
|
||||||
|
#holes > 0 and table.concat(holes, ",") or "(none)"))
|
||||||
|
|
||||||
|
local auxN = 0
|
||||||
|
for _ in pairs(aux) do auxN += 1 end
|
||||||
|
say(("AuxLane total=%d (want 30)"):format(auxN))
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- The 50-sample "unbarriered run" (513..562, LEFT) overlaps the aux-lane region
|
||||||
|
-- (510..565) exactly. In that stretch the parapet was deliberately RELOCATED
|
||||||
|
-- outboard, to the aux lane's outer edge -- so a 12-stud probe off the mainline
|
||||||
|
-- edge would report "missing" when the barrier is simply further out.
|
||||||
|
-- Do not claim a hole until this is disambiguated. Report ACTUAL distances.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
local bars = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail"
|
||||||
|
or d.Name == "KillWall" or d.Name:match("Parapet")) then
|
||||||
|
table.insert(bars, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("barrier-ish parts: %d"):format(#bars))
|
||||||
|
|
||||||
|
local aux = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^AuxLane_(%d+)$") then
|
||||||
|
table.insert(aux, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("aux lane parts: %d"):format(#aux))
|
||||||
|
|
||||||
|
local deck = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
or d.Name:match("^RoadDeck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
|
||||||
|
-- For a probe point, distance to nearest barrier AND nearest aux part.
|
||||||
|
local function nearest(list, p, ymax)
|
||||||
|
local best, bi = math.huge, nil
|
||||||
|
for _, b in ipairs(list) do
|
||||||
|
if math.abs(b.Position.Y - p.Y) < (ymax or 16) then
|
||||||
|
local d2 = (flat(b.Position) - flat(p)).Magnitude
|
||||||
|
if d2 < best then best = d2; bi = b end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best, bi
|
||||||
|
end
|
||||||
|
|
||||||
|
say("sample | edgeDist->barrier | auxHere | verdict")
|
||||||
|
local realGaps = {}
|
||||||
|
for i = 505, 570 do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
-- LEFT side probe (side = -right)
|
||||||
|
local edge = d.Position - right * (d.Size.X / 2 + 2)
|
||||||
|
local bd = nearest(bars, edge)
|
||||||
|
local ad = nearest(aux, edge)
|
||||||
|
local hasAux = ad < 20
|
||||||
|
-- A barrier is "present" if within 12 of the mainline edge, OR the aux
|
||||||
|
-- lane is here and a barrier sits within 12 of the aux OUTER edge.
|
||||||
|
local ok = bd < 12
|
||||||
|
if not ok and hasAux then
|
||||||
|
local auxEdge = d.Position - right * (d.Size.X / 2 + 26)
|
||||||
|
local bd2 = nearest(bars, auxEdge)
|
||||||
|
if bd2 < 14 then ok = true end
|
||||||
|
if i % 6 == 0 then
|
||||||
|
say((" %d | %.1f | aux=%s | auxEdgeBarrier=%.1f | %s"):format(
|
||||||
|
i, bd, tostring(hasAux), bd2, ok and "OK(relocated)" or "GAP"))
|
||||||
|
end
|
||||||
|
elseif i % 6 == 0 then
|
||||||
|
say((" %d | %.1f | aux=%s | %s"):format(i, bd, tostring(hasAux), ok and "OK" or "GAP"))
|
||||||
|
end
|
||||||
|
if not ok then table.insert(realGaps, i) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say(("REAL gaps after accounting for relocated parapet: %d samples"):format(#realGaps))
|
||||||
|
if #realGaps > 0 then
|
||||||
|
local runs, cur = {}, nil
|
||||||
|
for _, i in ipairs(realGaps) do
|
||||||
|
if cur and i - cur.last <= 2 then cur.last = i
|
||||||
|
else if cur then table.insert(runs, cur) end cur = { first = i, last = i } end
|
||||||
|
end
|
||||||
|
if cur then table.insert(runs, cur) end
|
||||||
|
for _, r in ipairs(runs) do
|
||||||
|
local d = deck[r.first]
|
||||||
|
say((" GAP RUN %d..%d at (%.0f,%.0f,%.0f)"):format(
|
||||||
|
r.first, r.last, d.Position.X, d.Position.Y, d.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- The 10 flagged samples all read barrier=22.9 -- the exact signature of the
|
||||||
|
-- RELOCATED parapet (aux lane still exists at 510..524 and 551..565, and its
|
||||||
|
-- outer edge carries the rail). probe_final's audit did not re-probe the aux
|
||||||
|
-- outer edge, so it mislabelled them. Settle it: for each flagged sample,
|
||||||
|
-- measure distance from the AUX OUTER edge to the nearest barrier.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
local deck, bars, aux = {}, {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end
|
||||||
|
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if a then aux[tonumber(a)] = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
local function nearest(p, ytol)
|
||||||
|
local best = math.huge
|
||||||
|
for _, b in ipairs(bars) do
|
||||||
|
if math.abs(b.Position.Y - p.Y) < (ytol or 14) then
|
||||||
|
local dd = (flat(b.Position) - flat(p)).Magnitude
|
||||||
|
if dd < best then best = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
local flagged = { 520, 521, 522, 523, 524, 551, 552, 553, 554, 555 }
|
||||||
|
local trueHoles = {}
|
||||||
|
say("sample | auxHere | barrier@auxOuterEdge | verdict")
|
||||||
|
for _, i in ipairs(flagged) do
|
||||||
|
local d, a = deck[i], aux[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
local verdict, dist
|
||||||
|
if a then
|
||||||
|
-- probe just outboard of the AUX lane, where the rail was moved to
|
||||||
|
local outer = a.Position - right * (a.Size.X / 2 + 2)
|
||||||
|
dist = nearest(outer)
|
||||||
|
verdict = dist < 14 and "OK (parapet relocated to aux edge)" or "HOLE"
|
||||||
|
else
|
||||||
|
local edge = d.Position - right * (d.Size.X / 2 + 2)
|
||||||
|
dist = nearest(edge)
|
||||||
|
verdict = dist < 12 and "OK" or "HOLE"
|
||||||
|
end
|
||||||
|
if verdict == "HOLE" then table.insert(trueHoles, i) end
|
||||||
|
say((" %d | aux=%s | %.1f | %s"):format(i, a and "yes" or "no", dist, verdict))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say(("TRUE UNGUARDED SAMPLES: %d %s"):format(#trueHoles,
|
||||||
|
#trueHoles > 0 and table.concat(trueHoles, ",") or "(none)"))
|
||||||
|
|
||||||
|
-- Full-loop sweep so nothing elsewhere is missed.
|
||||||
|
local swept, holes = 0, {}
|
||||||
|
for i, d in pairs(deck) do
|
||||||
|
swept += 1
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
for _, sgn in ipairs({ -1, 1 }) do
|
||||||
|
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||||
|
if nearest(edge) >= 12 then
|
||||||
|
local a = aux[i]
|
||||||
|
local ok = false
|
||||||
|
if a then
|
||||||
|
local outer = a.Position + right * sgn * (a.Size.X / 2 + 2)
|
||||||
|
if nearest(outer) < 14 then ok = true end
|
||||||
|
end
|
||||||
|
if not ok then
|
||||||
|
-- ramp mouth? slip structure nearby counts as guarded
|
||||||
|
local sd = math.huge
|
||||||
|
for _, p in ipairs(map:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") and p.Name:match("Slip") then
|
||||||
|
local dd = (flat(p.Position) - flat(edge)).Magnitude
|
||||||
|
if dd < sd then sd = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("FULL SWEEP: %d deck samples, unguarded=%d %s"):format(swept, #holes,
|
||||||
|
#holes > 0 and ("-> " .. table.concat(holes, ",")) or "(none)"))
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
-- Slip walls measured CLEAN (0 missing, 0 deviation, 0.00 lateral step), so
|
||||||
|
-- neither marked defect is on the ramp decks themselves. Prime suspect is the
|
||||||
|
-- suppression boundary I introduced: across 527..548 the parapet is forced back
|
||||||
|
-- to the mainline edge, while 510..526 and 549..565 still carry it relocated
|
||||||
|
-- OUT on the aux lane's outer edge (~26 studs further). At samples 527 and 549
|
||||||
|
-- that is a ~26-stud lateral JOG -- a barrier that visibly sticks out and ends
|
||||||
|
-- in a stub. And the gore between mainline barrier and ramp wall start may be
|
||||||
|
-- open -- the missing-barrier oval.
|
||||||
|
--
|
||||||
|
-- Measure signed lateral offset of every Barrier from the mainline centreline,
|
||||||
|
-- walk 505..570, and report steps + gaps.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
local UP = Vector3.new(0, 1, 0)
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
|
||||||
|
local deck, aux = {}, {}
|
||||||
|
local bars = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if a then aux[tonumber(a)] = d end
|
||||||
|
if d.Name == "Barrier" then table.insert(bars, d) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- For each mainline sample, find the barrier nearest to that sample's plane on
|
||||||
|
-- the LEFT side, and report its lateral offset from the centreline.
|
||||||
|
say("sample | auxW | barrier lateral offset from centreline | step")
|
||||||
|
local prevLat = nil
|
||||||
|
local jogs, gaps = {}, {}
|
||||||
|
for i = 505, 570 do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||||
|
local c = d.Position
|
||||||
|
-- gather barriers within half a sample spacing along the road axis
|
||||||
|
local best, bestLat = math.huge, nil
|
||||||
|
for _, b in ipairs(bars) do
|
||||||
|
if math.abs(b.Position.Y - c.Y) < 14 then
|
||||||
|
local rel = b.Position - c
|
||||||
|
local along = math.abs(rel:Dot(d.CFrame.LookVector))
|
||||||
|
local lat = rel:Dot(right)
|
||||||
|
if along < 22 and lat < 0 then -- LEFT side only
|
||||||
|
if along < best then best = along; bestLat = lat end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local auxW = aux[i] and aux[i].Size.X or 0
|
||||||
|
if bestLat then
|
||||||
|
local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0
|
||||||
|
if step > 6 then
|
||||||
|
table.insert(jogs, ("%d(step %.1f)"):format(i, step))
|
||||||
|
end
|
||||||
|
if i % 4 == 0 or step > 6 then
|
||||||
|
say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format(
|
||||||
|
i, auxW, bestLat, step, step > 6 and " <<< JOG" or ""))
|
||||||
|
end
|
||||||
|
prevLat = bestLat
|
||||||
|
else
|
||||||
|
table.insert(gaps, i)
|
||||||
|
say((" %d | aux=%.0f | NO BARRIER FOUND on left <<< GAP"):format(i, auxW))
|
||||||
|
prevLat = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say(("JOGS (lateral step >6): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)"))
|
||||||
|
say(("GAPS (no left barrier): %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)"))
|
||||||
|
|
||||||
|
-- Gore check: distance from each slip's FIRST wall to the nearest mainline
|
||||||
|
-- barrier. A large value means an open mouth corner -- the missing-barrier oval.
|
||||||
|
say("--- gore / mouth junction ---")
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local first, firstK = nil, math.huge
|
||||||
|
local last, lastK = nil, -1
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local wk = d.Name:match("^" .. s .. "_Wall_(%d+)$")
|
||||||
|
if wk then
|
||||||
|
local k = tonumber(wk)
|
||||||
|
if k < firstK then firstK = k; first = d end
|
||||||
|
if k > lastK then lastK = k; last = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if first then
|
||||||
|
local bd = math.huge
|
||||||
|
for _, b in ipairs(bars) do
|
||||||
|
if math.abs(b.Position.Y - first.Position.Y) < 16 then
|
||||||
|
local dd = (flat(b.Position) - flat(first.Position)).Magnitude
|
||||||
|
if dd < bd then bd = dd end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("%s: first wall seg %d at (%.0f,%.0f,%.0f) -> nearest mainline barrier %.1f"):format(
|
||||||
|
s, firstK, first.Position.X, first.Position.Y, first.Position.Z, bd))
|
||||||
|
say((" last wall seg %d at (%.0f,%.0f,%.0f)"):format(
|
||||||
|
lastK, last.Position.X, last.Position.Y, last.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- PAEastSlip turns 216deg vs PAWestSlip's 56.5deg -- nearly 4x. That is the
|
||||||
|
-- "looks terrible" the user means. Find WHERE the turn concentrates: is it a
|
||||||
|
-- smooth long curve, or a few violent kinks? Per-segment heading deltas tell us.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame, sz = d.Size }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b) return a.i < b.i end)
|
||||||
|
if #segs == 0 then say(s .. ": absent") continue end
|
||||||
|
|
||||||
|
local deltas = {}
|
||||||
|
for j = 2, #segs do
|
||||||
|
local a = segs[j - 1].cf.LookVector
|
||||||
|
local b = segs[j].cf.LookVector
|
||||||
|
local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||||
|
table.insert(deltas, { i = segs[j].i, d = dth, p = segs[j].p })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- worst kinks
|
||||||
|
local sorted = {}
|
||||||
|
for _, v in ipairs(deltas) do table.insert(sorted, v) end
|
||||||
|
table.sort(sorted, function(x, y) return x.d > y.d end)
|
||||||
|
|
||||||
|
local sum = 0
|
||||||
|
for _, v in ipairs(deltas) do sum += v.d end
|
||||||
|
|
||||||
|
say(("%s: total=%.1fdeg mean=%.2fdeg/seg n=%d"):format(s, sum, sum / #deltas, #deltas))
|
||||||
|
local worst = {}
|
||||||
|
for j = 1, math.min(8, #sorted) do
|
||||||
|
table.insert(worst, ("seg%d=%.1f"):format(sorted[j].i, sorted[j].d))
|
||||||
|
end
|
||||||
|
say(" worst kinks: " .. table.concat(worst, " "))
|
||||||
|
|
||||||
|
-- how many segments exceed a smooth-curve threshold?
|
||||||
|
local over = 0
|
||||||
|
for _, v in ipairs(deltas) do if v.d > 5 then over += 1 end end
|
||||||
|
say((" segments turning >5deg: %d"):format(over))
|
||||||
|
|
||||||
|
-- segment length consistency (uneven lengths also read as ugly)
|
||||||
|
local lmin, lmax, lsum = math.huge, -math.huge, 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local L = (segs[j].p - segs[j - 1].p).Magnitude
|
||||||
|
lmin = math.min(lmin, L); lmax = math.max(lmax, L); lsum += L
|
||||||
|
end
|
||||||
|
say((" seg spacing: min=%.1f max=%.1f mean=%.1f totalRun=%.0f"):format(
|
||||||
|
lmin, lmax, lsum / (#segs - 1), lsum))
|
||||||
|
|
||||||
|
-- vertical profile: where does it drop?
|
||||||
|
local topY = segs[1].p.Y
|
||||||
|
local level = 0
|
||||||
|
for _, sg in ipairs(segs) do
|
||||||
|
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
|
||||||
|
end
|
||||||
|
local steep = 0
|
||||||
|
for j = 2, #segs do
|
||||||
|
local dy = math.abs(segs[j].p.Y - segs[j - 1].p.Y)
|
||||||
|
local dxz = (Vector3.new(segs[j].p.X, 0, segs[j].p.Z) - Vector3.new(segs[j - 1].p.X, 0, segs[j - 1].p.Z)).Magnitude
|
||||||
|
if dxz > 0.1 and (dy / dxz) > 0.12 then steep += 1 end
|
||||||
|
end
|
||||||
|
say((" level-run=%d segments steeper than 12%%: %d"):format(level, steep))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- East slip kinks hard at segs 6,7,11,12 (27.8/17.5/58.2/50.1 deg). The slip
|
||||||
|
-- follows the mainline via centreAt(f), so either (a) the mainline bends
|
||||||
|
-- viciously at those samples, or (b) the slip's own path math misbehaves.
|
||||||
|
-- East runs join=551 -> land=539 (backwards, span=-12); west runs 525 -> 537.
|
||||||
|
-- Sample the mainline heading across BOTH spans to tell which.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
local deck = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
or d.Name:match("^RoadDeck_(%d+)$")
|
||||||
|
if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(deck, function(a, b) return a.i < b.i end)
|
||||||
|
local byIdx = {}
|
||||||
|
for _, d in ipairs(deck) do byIdx[d.i] = d end
|
||||||
|
say(("mainline parts=%d idx %d..%d"):format(#deck, deck[1].i, deck[#deck].i))
|
||||||
|
|
||||||
|
local function report(lo, hi, label)
|
||||||
|
say(("--- mainline heading, samples %d..%d (%s) ---"):format(lo, hi, label))
|
||||||
|
local prev, acc = nil, 0
|
||||||
|
local worst, worstAt = 0, -1
|
||||||
|
for i = lo, hi do
|
||||||
|
local d = byIdx[i]
|
||||||
|
if d then
|
||||||
|
local h = d.cf.LookVector
|
||||||
|
if prev then
|
||||||
|
local dth = math.deg(math.acos(math.clamp(prev:Dot(h), -1, 1)))
|
||||||
|
acc += dth
|
||||||
|
if dth > worst then worst = dth; worstAt = i end
|
||||||
|
if dth > 4 then
|
||||||
|
say((" sample %d: turn %.1fdeg at (%.0f,%.0f,%.0f)"):format(i, dth, d.p.X, d.p.Y, d.p.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
prev = h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say((" total=%.1fdeg worst=%.1fdeg@%d"):format(acc, worst, worstAt))
|
||||||
|
end
|
||||||
|
|
||||||
|
report(525, 537, "WEST slip span - smooth one")
|
||||||
|
report(539, 551, "EAST slip span - ugly one")
|
||||||
|
|
||||||
|
-- Also: does the east span cross the sample-wrap boundary or reverse direction?
|
||||||
|
say("--- endpoint geometry ---")
|
||||||
|
for _, pair in ipairs({ { 525, 537, "west" }, { 551, 539, "east" } }) do
|
||||||
|
local a, b = byIdx[pair[1]], byIdx[pair[2]]
|
||||||
|
if a and b then
|
||||||
|
local straight = (Vector3.new(a.p.X, 0, a.p.Z) - Vector3.new(b.p.X, 0, b.p.Z)).Magnitude
|
||||||
|
say(("%s: join@%d=(%.0f,%.0f) land@%d=(%.0f,%.0f) straightDist=%.0f"):format(
|
||||||
|
pair[3], pair[1], a.p.X, a.p.Z, pair[2], b.p.X, b.p.Z, straight))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- READ-ONLY inventory of the Daikoku Futo PA area so the capture camera can be
|
||||||
|
-- anchored to real geometry instead of guessed coordinates.
|
||||||
|
--
|
||||||
|
-- Wanted:
|
||||||
|
-- * what top-level containers/models exist for the PA
|
||||||
|
-- * every distinct part-name PREFIX in the PA region, with counts
|
||||||
|
-- * the PA's world-space bounding box + centre (for framing)
|
||||||
|
-- * key landmarks: slip ramps, apron/parking deck, any building/structure
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
-- 1. Top-level structure of Map
|
||||||
|
say("=== Map children ===")
|
||||||
|
for _, c in ipairs(map:GetChildren()) do
|
||||||
|
local n = 0
|
||||||
|
for _, d in ipairs(c:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then n += 1 end
|
||||||
|
end
|
||||||
|
say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Anything whose name mentions PA / Daikoku / parking
|
||||||
|
say("=== named PA-ish objects (models/folders) ===")
|
||||||
|
local seen = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if not d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
if nm:match("PA") or nm:lower():match("daikoku") or nm:lower():match("park")
|
||||||
|
or nm:lower():match("apron") or nm:lower():match("rest") then
|
||||||
|
local n = 0
|
||||||
|
for _, x in ipairs(d:GetDescendants()) do
|
||||||
|
if x:IsA("BasePart") then n += 1 end
|
||||||
|
end
|
||||||
|
say((" %s (%s) parts=%d parent=%s"):format(d.Name, d.ClassName, n, d.Parent.Name))
|
||||||
|
seen += 1
|
||||||
|
if seen > 40 then say(" ...(truncated)") break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if seen == 0 then say(" (none found by name)") end
|
||||||
|
|
||||||
|
-- 3. Locate the PA by its slip ramps: the apron is where the ramps land.
|
||||||
|
local land = {}
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local best, bestK = nil, -1
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k and tonumber(k) > bestK then bestK = tonumber(k); best = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best then
|
||||||
|
land[s] = best.Position
|
||||||
|
say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local anchor
|
||||||
|
if land.PAWestSlip and land.PAEastSlip then
|
||||||
|
anchor = (land.PAWestSlip + land.PAEastSlip) / 2
|
||||||
|
elseif land.PAWestSlip then
|
||||||
|
anchor = land.PAWestSlip
|
||||||
|
elseif land.PAEastSlip then
|
||||||
|
anchor = land.PAEastSlip
|
||||||
|
end
|
||||||
|
if not anchor then return table.concat(out, "\n") .. "\nNO SLIP LANDINGS -> cannot anchor" end
|
||||||
|
say(("PA anchor (between landings): (%.0f, %.0f, %.0f)"):format(anchor.X, anchor.Y, anchor.Z))
|
||||||
|
|
||||||
|
-- 4. Every part within 1400 studs of the anchor, grouped by name prefix.
|
||||||
|
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||||
|
local counts, mins, maxs = {}, {}, {}
|
||||||
|
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||||
|
local hi = Vector3.new(-math.huge, -math.huge, -math.huge)
|
||||||
|
local total = 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
if (flat(d.Position) - flat(anchor)).Magnitude < 1400 then
|
||||||
|
total += 1
|
||||||
|
-- prefix = name with trailing _<digits> stripped
|
||||||
|
local pre = d.Name:gsub("_%d+$", "")
|
||||||
|
counts[pre] = (counts[pre] or 0) + 1
|
||||||
|
lo = Vector3.new(math.min(lo.X, d.Position.X), math.min(lo.Y, d.Position.Y), math.min(lo.Z, d.Position.Z))
|
||||||
|
hi = Vector3.new(math.max(hi.X, d.Position.X), math.max(hi.Y, d.Position.Y), math.max(hi.Z, d.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
say(("=== %d parts within 1400 studs of PA anchor ==="):format(total))
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(counts) do table.insert(keys, k) end
|
||||||
|
table.sort(keys, function(a, b) return counts[a] > counts[b] end)
|
||||||
|
for idx, k in ipairs(keys) do
|
||||||
|
if idx > 30 then say((" ...and %d more prefixes"):format(#keys - 30)) break end
|
||||||
|
say((" %-28s %d"):format(k, counts[k]))
|
||||||
|
end
|
||||||
|
say(("bbox: (%.0f,%.0f,%.0f) .. (%.0f,%.0f,%.0f)"):format(lo.X, lo.Y, lo.Z, hi.X, hi.Y, hi.Z))
|
||||||
|
say(("bbox size: %.0f x %.0f x %.0f"):format(hi.X - lo.X, hi.Y - lo.Y, hi.Z - lo.Z))
|
||||||
|
local ctr = (lo + hi) / 2
|
||||||
|
say(("bbox centre: (%.0f, %.0f, %.0f)"):format(ctr.X, ctr.Y, ctr.Z))
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Minimal PA locator. The full inventory probe returned an empty error, so
|
||||||
|
-- start small and confirm the basics work before adding anything back.
|
||||||
|
local out = {}
|
||||||
|
local function say(s) table.insert(out, s) end
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "Map MISSING" end
|
||||||
|
|
||||||
|
say("=== Map children ===")
|
||||||
|
for _, c in ipairs(map:GetChildren()) do
|
||||||
|
local n = 0
|
||||||
|
for _, d in ipairs(c:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then n += 1 end
|
||||||
|
end
|
||||||
|
say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n))
|
||||||
|
end
|
||||||
|
|
||||||
|
local land = {}
|
||||||
|
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||||
|
local best, bestK = nil, -1
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k and tonumber(k) > bestK then
|
||||||
|
bestK = tonumber(k)
|
||||||
|
best = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best then
|
||||||
|
land[s] = best.Position
|
||||||
|
say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||