Browse Source

fix(skills): Correct false camera-roll claim in mapbox-ops

Mapbox GL JS has no native camera roll — the GL JS changelog through
v3.26 has no such feature, and FreeCameraOptions orientation must be
representable using only pitch and bearing, so freeCamera cannot
express roll either. True roll (and pitch > 90) is MapLibre GL JS v5.

- camera-and-animation.md: banked turns are bearing+pitch choreography
  or freeCameraOptions positioning; code sample no longer passes roll;
  MapLibre v5 pointer added for anyone needing true roll
- SKILL.md: drop the roll>=3.5 gate from the TOC row and verifier blurb
- check-mapbox-facts.py: O6 inverted from camera-roll-gate (which
  enforced the false claim) to no-native-roll, failing if any line
  attributes a roll camera option/setRoll to Mapbox or the MapLibre
  pointer disappears

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
549007d3ec

+ 3 - 2
skills/mapbox-ops/SKILL.md

@@ -64,7 +64,7 @@ Read the matching reference file only when the task needs it:
 | Hover/select via `feature-state` (not `setData`), `queryRenderedFeatures` caveats, clustering, GeoJSON perf, event hygiene | [references/interaction-and-performance.md](references/interaction-and-performance.md) |
 | Hover/select via `feature-state` (not `setData`), `queryRenderedFeatures` caveats, clustering, GeoJSON perf, event hygiene | [references/interaction-and-performance.md](references/interaction-and-performance.md) |
 | Data viz & 3D — `fill-extrusion` buildings/extruded data, heatmap layer, data-join choropleth (feature-state/`match`), proportional symbols, sky/fog | [references/dataviz-and-3d.md](references/dataviz-and-3d.md) |
 | Data viz & 3D — `fill-extrusion` buildings/extruded data, heatmap layer, data-join choropleth (feature-state/`match`), proportional symbols, sky/fog | [references/dataviz-and-3d.md](references/dataviz-and-3d.md) |
 | **three.js in the map** — `CustomLayerInterface` + shared GL context, animated 3D objects/models, the baked-matrix vs reconstructed-camera (Threebox `CameraSync`) fork, raycast picking, ENU-metre scene space, constant screen-size actors, terrain elevation, far-plane clipping at pitch | [references/three-custom-layer.md](references/three-custom-layer.md) |
 | **three.js in the map** — `CustomLayerInterface` + shared GL context, animated 3D objects/models, the baked-matrix vs reconstructed-camera (Threebox `CameraSync`) fork, raycast picking, ENU-metre scene space, constant screen-size actors, terrain elevation, far-plane clipping at pitch | [references/three-custom-layer.md](references/three-custom-layer.md) |
-| Camera & animation — `flyTo`/`easeTo`/`fitBounds` padding, `freeCameraOptions` cinematics/orbit, flight/first-person camera (roll, 6-DoF), animated day–night cycle (`setLights`), HUD synced to camera, point-along-line, draw-in lines, paint transitions, spinning globe, the `essential`/reduced-motion gotcha | [references/camera-and-animation.md](references/camera-and-animation.md) |
+| Camera & animation — `flyTo`/`easeTo`/`fitBounds` padding, `freeCameraOptions` cinematics/orbit, flight/first-person camera (bearing+pitch choreography; roll is MapLibre-only), animated day–night cycle (`setLights`), HUD synced to camera, point-along-line, draw-in lines, paint transitions, spinning globe, the `essential`/reduced-motion gotcha | [references/camera-and-animation.md](references/camera-and-animation.md) |
 | Style library & composition — first-party style catalog, choosing a base by use case, custom/third-party styles, style switcher, light/dark, hand-rolled style JSON | [references/styles.md](references/styles.md) (+ [assets/style-catalog.json](assets/style-catalog.json)) |
 | Style library & composition — first-party style catalog, choosing a base by use case, custom/third-party styles, style switcher, light/dark, hand-rolled style JSON | [references/styles.md](references/styles.md) (+ [assets/style-catalog.json](assets/style-catalog.json)) |
 | `setStyle` wiping custom layers, the 0×0 `resize()` bug, SPA teardown / WebGL-context cap, token security, readiness events | [references/lifecycle.md](references/lifecycle.md) |
 | `setStyle` wiping custom layers, the 0×0 `resize()` bug, SPA teardown / WebGL-context cap, token security, readiness events | [references/lifecycle.md](references/lifecycle.md) |
 | **v3 Standard style** — slots vs `beforeId`, `setConfigProperty`/`lightPreset`, why layer-walking (palette/terrain) breaks; localisation, RTL, globe | [references/v3-standard-style.md](references/v3-standard-style.md) |
 | **v3 Standard style** — slots vs `beforeId`, `setConfigProperty`/`lightPreset`, why layer-walking (palette/terrain) breaks; localisation, RTL, globe | [references/v3-standard-style.md](references/v3-standard-style.md) |
@@ -92,7 +92,8 @@ Read the matching reference file only when the task needs it:
   stdlib-only (no Playwright), guards the fast-moving facts this skill encodes
   stdlib-only (no Playwright), guards the fast-moving facts this skill encodes
   (SKILL-RESOURCE-PROTOCOL §7). `--offline` (default) asserts internal consistency —
   (SKILL-RESOURCE-PROTOCOL §7). `--offline` (default) asserts internal consistency —
   the v3 Standard config enums (`lightPreset`/`theme`), terrain tileset IDs, the weather
   the v3 Standard config enums (`lightPreset`/`theme`), terrain tileset IDs, the weather
-  (≥3.7) and camera-roll (≥3.5) version gates, and every style URL/id in
+  (≥3.7) version gate, the no-native-camera-roll fact (roll is MapLibre GL JS v5, not
+  Mapbox), and every style URL/id in
   [assets/style-catalog.json](assets/style-catalog.json). `--live` resolves the
   [assets/style-catalog.json](assets/style-catalog.json). `--live` resolves the
   third-party style URLs and probes whether Mapbox GL JS has shipped a major past v3.
   third-party style URLs and probes whether Mapbox GL JS has shipped a major past v3.
 
 

+ 19 - 13
skills/mapbox-ops/references/camera-and-animation.md

@@ -79,30 +79,36 @@ map.setFreeCameraOptions(cam);
 ## Flight / first-person camera (drone, fly-through, sim)
 ## Flight / first-person camera (drone, fly-through, sim)
 
 
 Continuous first-person movement — a flight sim, drone pass, or walkthrough — drives the
 Continuous first-person movement — a flight sim, drone pass, or walkthrough — drives the
-camera every frame from a `{lng, lat, alt, heading, pitch, roll, speed}` state.
+camera every frame from a `{lng, lat, alt, heading, pitch, speed}` state.
 
 
-- **Native (GL JS ≥ 3.5):** the camera now has **`roll`** alongside `bearing`/`pitch`
-  (`jumpTo`/`easeTo`/`flyTo({roll})`, `map.setRoll`) — enough for banking turns. Per-frame
-  `jumpTo` is instant (no easing lag):
+- **Native camera:** Mapbox GL JS has **no camera roll** — only `bearing` + `pitch`.
+  "Banked" turns are choreography: sweep `bearing` through the turn while easing `pitch`
+  up a few degrees; the combined motion reads as a bank even though the horizon stays
+  level. Per-frame `jumpTo` is instant (no easing lag):
 
 
 ```js
 ```js
-const s = { lng:146.9, lat:-36.1, alt:1500, heading:0, pitch:75, roll:0, speed:0 };
+const s = { lng:146.9, lat:-36.1, alt:1500, heading:0, pitch:75, speed:0 };
 function fly() {
 function fly() {
   // advance along heading by speed (deg/frame ≈ metres → deg)
   // advance along heading by speed (deg/frame ≈ metres → deg)
   const rad = s.heading * Math.PI/180, d = s.speed * 1e-5;
   const rad = s.heading * Math.PI/180, d = s.speed * 1e-5;
   s.lat += Math.cos(rad) * d; s.lng += Math.sin(rad) * d;
   s.lat += Math.cos(rad) * d; s.lng += Math.sin(rad) * d;
-  map.jumpTo({ center:[s.lng,s.lat], bearing:s.heading, pitch:s.pitch, roll:s.roll });
+  map.jumpTo({ center:[s.lng,s.lat], bearing:s.heading, pitch:s.pitch });
   requestAnimationFrame(fly);
   requestAnimationFrame(fly);
 }
 }
-fly();   // A/D → s.heading & s.roll, W/S → s.pitch, R/F → s.speed (key handlers)
+fly();   // A/D → s.heading, W/S → s.pitch, R/F → s.speed (key handlers)
 ```
 ```
 
 
-- **Full 6-DoF** (arbitrary position + look vector, e.g. a nose-down dive that the
-  pitch-clamped map camera can't reach): use `freeCameraOptions` — set
-  `position = MercatorCoordinate.fromLngLat([lng,lat], alt)` and orientation each frame.
-- Pre-3.5, roll required `freeCameraOptions` (the map camera had only bearing/pitch).
-- Cap pitch near 85° on the native camera; beyond that switch to freeCamera or the horizon
-  inverts.
+- **Full positional control** (camera at an exact altitude, aimed at a target): use
+  `freeCameraOptions` — set `position = MercatorCoordinate.fromLngLat([lng,lat], alt)`
+  and orientation each frame. This does **not** unlock roll either: the orientation
+  quaternion "must be representable using only pitch and bearing" (per the
+  FreeCameraOptions docs — a non-conforming orientation is discarded). freeCamera buys
+  position freedom, not a rolled horizon.
+- **Need true roll?** Switch libraries — MapLibre GL JS v5 added first-class camera roll
+  (`easeTo`/`flyTo({roll})`, `map.setRoll`) plus pitch beyond 90°. Mapbox GL JS has no
+  equivalent on any camera API.
+- Cap pitch near 85° — the native camera's hard maximum (`maxPitch`); a nose-down or
+  looking-up shot past that is likewise MapLibre-v5-only territory.
 
 
 ## Animated day–night cycle
 ## Animated day–night cycle
 
 

+ 28 - 9
skills/mapbox-ops/scripts/check-mapbox-facts.py

@@ -5,9 +5,12 @@
 #   --offline (default): NO network. Asserts the skill is internally consistent —
 #   --offline (default): NO network. Asserts the skill is internally consistent —
 #                        style-catalog.json parses, the v3 Standard config enums
 #                        style-catalog.json parses, the v3 Standard config enums
 #                        (lightPreset/theme) agree between catalog and references,
 #                        (lightPreset/theme) agree between catalog and references,
-#                        the terrain tileset IDs and version gates (weather >= 3.7,
-#                        camera roll >= 3.5) are stated consistently, every classic
-#                        style url matches its id, every third-party entry is
+#                        the terrain tileset IDs and the weather >= 3.7 version gate
+#                        are stated consistently, no reference attributes camera roll
+#                        to Mapbox GL JS (roll is MapLibre GL JS v5 — Mapbox has none,
+#                        and freeCameraOptions orientation must be pitch+bearing-
+#                        representable), every classic style url matches its id,
+#                        every third-party entry is
 #                        addressable. Runs in PR CI and MAY block.
 #                        addressable. Runs in PR CI and MAY block.
 #   --live:              network. Resolves the concrete third-party style-JSON URLs
 #   --live:              network. Resolves the concrete third-party style-JSON URLs
 #                        and probes whether Mapbox GL JS has shipped a major beyond
 #                        and probes whether Mapbox GL JS has shipped a major beyond
@@ -141,14 +144,30 @@ def run_offline(findings: list[Finding]) -> None:
         findings.append(Finding("weather-gate", "fail",
         findings.append(Finding("weather-gate", "fail",
                                 f"weather version gate mismatch (catalog={cat_ver!r}, ref={ref_ver!r}, want 3.7)"))
                                 f"weather version gate mismatch (catalog={cat_ver!r}, ref={ref_ver!r}, want 3.7)"))
 
 
-    # O6 — camera roll gate >= 3.5 stated in camera-and-animation.md.
+    # O6 — no native camera roll. Mapbox GL JS has no roll on any camera API
+    # (FreeCameraOptions orientation "must be representable using only pitch and
+    # bearing"); true roll is MapLibre GL JS v5. The reference must not attribute
+    # a roll camera option / setRoll to Mapbox, and must keep the MapLibre pointer
+    # for anyone who needs real roll.
     camera_md = read_text(REFS / "camera-and-animation.md") if (REFS / "camera-and-animation.md").exists() else ""
     camera_md = read_text(REFS / "camera-and-animation.md") if (REFS / "camera-and-animation.md").exists() else ""
-    roll_ver = _first_gl_gate(camera_md, near="roll")
-    if roll_ver == "3.5":
-        findings.append(Finding("camera-roll-gate", "ok", "native roll GL JS >= 3.5 stated"))
+    cam_lines = camera_md.splitlines()
+    roll_api = re.compile(r"setRoll|\{[^}]*\broll\b|\broll\s*:")
+    misattributed = []
+    for i, ln in enumerate(cam_lines):
+        if roll_api.search(ln):
+            context = " ".join(cam_lines[max(0, i - 2):i + 2])
+            if "maplibre" not in context.lower():
+                misattributed.append(ln.strip())
+    if misattributed:
+        findings.append(Finding("no-native-roll", "fail",
+                                "roll API attributed to Mapbox GL JS (it has none): "
+                                + " | ".join(misattributed[:3])))
+    elif "maplibre" not in camera_md.lower():
+        findings.append(Finding("no-native-roll", "fail",
+                                "MapLibre-v5 roll pointer missing from camera-and-animation.md"))
     else:
     else:
-        findings.append(Finding("camera-roll-gate", "fail",
-                                f"camera roll gate = {roll_ver!r}, want 3.5"))
+        findings.append(Finding("no-native-roll", "ok",
+                                "no Mapbox roll claim; MapLibre v5 pointer present"))
 
 
     # O7 — GL JS major scope: SKILL.md says v3.
     # O7 — GL JS major scope: SKILL.md says v3.
     skill_md = read_text(SKILL_MD)
     skill_md = read_text(SKILL_MD)