# Infrared City twin: guide for AI agents

This site is a 3D climate twin of cities (Cesium). Each city page has one JavaScript API for agents: `window.twin`.
You read the page and run JS in it (for example the Claude Chrome extension). You do not click the UI.

## Start

1. Open a city page: `/hk/` (Hong Kong, full data), `/london/`, `/newyork/`, `/singapore/`, `/stuttgart/`, `/vienna/`.
2. Wait: `await twin.ready` (it gives `twin.info()`). The first load can take 20 to 60 s.
3. Read `twin.help()`: all commands, with arguments and examples, and the UI controls of this page. It is made live, so it is always correct for the page.
4. Before a screenshot: `await twin.waitUntilIdle()` (camera still; the globe and every 3D tileset loaded: buildings, facades, roofs, trees). `capture.screenshot()` does this for you. `twin.scene.idle()` answers at once.

Rules:
- All commands return a promise, except `twin.help()` and `twin.on()`. A wrong argument gives an error with the usage and an example.
- Arguments go by position (`twin.query.sample(114.17, 22.3)`) or as one object (`twin.query.sample({ lon: 114.17, lat: 22.3 })`). Results are plain JSON.
- `twin.city(id)` loads another page. Your script stops there. On the new page, start again at step 2.
- `twin.info()` tells you what the city has: topics, metrics, ground value layers, layers, tools, Live mode, stories. Hong Kong and Singapore have values on the ground, facades and roofs. The other cities have geometry, ground materials, Shadows and Live (hourly forecast) only.
- Deep links: the URL hash keeps the camera, topic, metric, filter and mode (`twin.link()`). Open a link to get the same view again.
- Search and streets use free OpenStreetMap services (Photon, Overpass, OSRM demo servers). The API sends max one request a second to each. Do not loop over hundreds of searches.
- WebMCP: when the browser has `navigator.modelContext`, the same commands are tools named `twin_<command>` (dots become `_`).

## Recipes

### 1. Find the hottest spot in Mong Kok (Kowloon) and take a screenshot (HK)

```js
await twin.ready;
await twin.view.setTopic("heat");
const hot = await twin.query.hotspots({ metric: "utci", place: "Mong Kok", n: 3 });
const s = hot.spots[0];                                   // { lon, lat, value, area_m2 }
const [street] = await twin.streets.near(s.lon, s.lat, 80); // the street name there
await twin.camera.lookAt({ lon: s.lon, lat: s.lat, range: 450, heading: 200, pitch: -40 });
await twin.view.ui({ hidden: true });
const shot = await twin.capture.screenshot({ width: 1600, height: 900 }); // shot.dataUrl
await twin.view.ui({ hidden: false });
```

### 2. Drive along a street route

```js
const r = await twin.streets.route("Mong Kok Station", "Yau Ma Tei Station"); // { path, length_m }
await twin.camera.followPath(r.path, { height: 35, speed: 30, pitch: -15 });   // resolves at the end
// one named street as one line:
const nathan = await twin.streets.find("Nathan Road");
await twin.camera.followPath(nathan.path, { height: 60, speed: 60 });
```

### 3. Orbit a landmark and save a video

```js
const p = await twin.path.orbit({ lon: 114.1616, lat: 22.2793, h: 180, range: 800, pitch: -18, seconds: 24 });
await twin.path.play(p);                 // camera only, no UI
await twin.video.record({ path: p });    // video file (map only); withUI: true = the tab with the UI (the browser asks)
const cam = await twin.path.toShot(p);   // the same move as the cam of a story step
// a free orbit without a path: twin.camera.orbit.start(8, { lon: 114.1616, lat: 22.2793, seconds: 30 })
```

### 4. Compare two cities

```js
// page /hk/
await twin.camera.flyTo("Central");
const hk = await twin.query.describeView();   // camera, topic, legend, value stats, places on screen
await twin.capture.download({ name: "hk-central.png" });
await twin.city("london");                    // the page changes; run the rest there
// page /london/
await twin.ready;
await twin.camera.flyTo("City");
const ldn = await twin.query.describeView();
await twin.capture.download({ name: "london-city.png" });
```

### 5. Shadows on the shortest day

```js
await twin.tools.open("shadows", { date: "2026-12-21", time: "09:00" });
await twin.camera.flyTo("Westminster");      // a view name, or any place name
await twin.capture.download({ name: "westminster-shadows-0900.png" });
await twin.view.setTime({ time: "15:00" });  // same day, another hour
```

### 6. Live forecast of an area (cities with Live)

```js
await twin.view.setMode("live");
const area = [[-0.135, 51.500], [-0.120, 51.500], [-0.120, 51.508], [-0.135, 51.508]];
const now = await twin.query.areaStats(area, { sim: "utci" });      // mean, p5, p50, p95 now
await twin.view.setTime({ hour: 6 });                               // the map 6 h later
const later = await twin.query.areaStats(area, { sim: "utci", hour: 6 });
```

### 7. Values at a point and the building there

```js
const v = await twin.query.sample(114.1694, 22.3193);   // ground values of all metrics, Live value, surface in view
await twin.camera.lookAt({ lon: 114.1616, lat: 22.2793, range: 500, heading: 200, pitch: -25 });
await twin.waitUntilIdle();
const b = await twin.query.pickAt({ x: innerWidth / 2, y: innerHeight / 2 });  // kind, value, props
```

### 8. Make a content story (camera path + topic per step)

```js
await twin.story.play({ id: "kowloon-heat", title: "Kowloon heat", sub: "3 steps", steps: [
  { t: "Mong Kok", text: "The hottest streets of Kowloon.", topic: "heat", cam: { view: [114.1694, 22.3193, 20, 200, -35, 900], dur: 3 } },
  { t: "Nathan Road", text: "A deep street canyon.", topic: "svf", cam: { path: [[114.1721, 22.3010, 380, 353, -40, 2.5], [114.1716, 22.3060, 190, 353, -26, 3.5]] } },
  { t: "Bank of China Tower", text: "Sun on the upper facades.", topic: "sun", cam: { orbit: { lon: 114.1616, lat: 22.2793, h: 200, range: 800, pitch: -18, from: 0, to: 120, dur: 12 } } },
] }, { auto: true, wait: 8 });
```

### 9. Make a ride video

Follow "Plan a ride (vision loop)" below. Then:

```js
await twin.view.plain(true);                       // optional: grey city + trees, no result colours
await twin.highlight({ lon: 114.1616, lat: 22.2793 }, { label: "Bank of China Tower" });
await twin.video.record({ path: ride, size: 1080 }); // quality render when the recorder has it; a video file (download)
await twin.clearHighlights(); await twin.view.plain(false);
```

### 10. Google 3D tiles with our results (x-ray lens, swipe, ground)

The page shows Google Photorealistic 3D Tiles together with our results. It is hidden: open the page with `?3d` (for example `/singapore/?3d`, `/hk/?3d`). Desktop only. The Google 3D card is below the main panel.

Modes (`google3d.mode`):
- `lens` (x-ray): Google 3D everywhere, our results (facades, ground, trees) inside a round disc that follows the cursor. Click pins the disc. Radius 30 to 1000 m.
- `swipe`: Google 3D on the left, our results on the right. Drag the divider.
- `ground`: our ground colours draped on the flat Google ground; Google buildings stay.

Keys (while Google 3D is on): `Z` / `X` = lens smaller / bigger (smooth; hold = faster), `V` = next topic (facade topics first). The keys work during a flight-recorder recording and an orbit. Hover the card for the key help.

```js
await twin.ready;
await twin.ui.do("google3d", { on: true });
await twin.ui.do("google3d.mode", { value: "lens" });
await twin.ui.do("google3d.radius", { value: 400 });
await twin.ui.do("google3d.lens", { lon: 103.851, lat: 1.284 });          // pin the lens (follow: true = follow the cursor again)
await twin.ui.do("google3d.reveal", { lon: 103.851, lat: 1.284, from: 30, to: 600, seconds: 4 }); // lens grows: for videos
await twin.waitUntilIdle();
```

Rules: every Google 3D load costs a paid session with a monthly cap. Turn it on only when you need it, do not loop page loads with `?3d`, and do not run it in headless test browsers. When the cap is reached, `google3d` gives an error; the rest of the page works. Keep the Google credits visible in screenshots and videos. Do not take data (heights, shapes) from the Google geometry.

## Plan a ride (vision loop)

Paths made from raw heights go through buildings. You MUST do these steps, in this order:

1. **Scout.** For each subject, make a `camera.preview(pose)` and LOOK at the image. Change the pose until the subject is in frame and nothing blocks it.
2. **Build waypoints** with `aboveTop` (m over the highest surface near the point) or `aboveGround`, and `lookAt: { lon, lat, h }` for the subject. Do not use a raw ellipsoid `h` near buildings. `query.clearance(lon, lat, { radius })` gives `suggestedMinH`.
3. **Check.** `path.check(path)` must give `hits: 0`. Else use `path.autoLift(path)` (use the returned `path`) or move the keys, and check again.
4. **Preview.** `path.preview(path)` gives one contact sheet (frames with time and clearance). LOOK at it: framing, subject in view, no wall in front, no red labels. Fix the waypoints and repeat steps 3 and 4 until it is clean.
5. **Render** only then: `video.record({ path })`.

Street-level shots: only along a street centre line (`streets.route` or `streets.find`), with `query.heightAt` + a margin of 3 m or more, or `camera.followPath(path, { height })`.

Worked example (HK, Central):

```js
await twin.ready;
const boc = { lon: 114.1616, lat: 22.2793, h: 200 };          // subject: the tower at mid height
// 1. scout two key poses and LOOK at each image
const a = await twin.camera.preview({ lon: 114.1560, lat: 22.2745, aboveTop: 150, lookAt: boc });
const b = await twin.camera.preview({ lon: 114.1650, lat: 22.2760, aboveTop: 120, lookAt: boc });
// 2. waypoints: heights over the roofs, the camera aims at the subject
let ride = await twin.path.fromWaypoints([
  { lon: 114.1560, lat: 22.2745, aboveTop: 150, lookAt: boc },
  { lon: 114.1605, lat: 22.2740, aboveTop: 130, lookAt: boc, dur: 6 },
  { lon: 114.1650, lat: 22.2760, aboveTop: 120, lookAt: boc, dur: 6, ease: "out" },
]);
// 3. check; lift when it hits
let c = await twin.path.check(ride);
if (c.hits) ({ path: ride } = await twin.path.autoLift(ride, { margin: 10 }));
// 4. contact sheet: LOOK at sheet.dataUrl; change the waypoints and repeat 3 + 4 until it is clean
const sheet = await twin.path.preview(ride, { n: 12, width: 320 });
// 5. render
await twin.video.record({ path: ride, size: 1080 });
```

## Story format

The same format as the stories of the Hong Kong page (`3d/storydata.js`). Do not make a new one.

- Story: `{ id, title, sub, steps: [step, ...] }`.
- Step: `{ t: title, text, num: [value, unit, caption], topic, marks, cam, pick }`. Only `t` is necessary.
- `topic`: a topic id of `twin.info().topics`.
- `cam` (angles in degrees, heights and ranges in m, `dur` in s):
  - `{ view: [lon, lat, h, heading, pitch, range], dur }`: look at a point.
  - `{ path: [[lon, lat, h, heading, pitch, dur], ...] }`: camera positions in sequence (a low flight along a street).
  - `{ orbit: { lon, lat, h, range, pitch, from, to, dur } }`: circle a target, heading from `from` to `to`.
  - `{ fit: true, heading, pitch }`: frame the marks (HK only).
- `marks` and `pick` (outlines, labels, a building popup) work on the HK page only (`3d/markup.js`, `3d/storyplaces.js`).
- HK: `story.play` shows the story card in the Stories page. Other cities: the camera and the topic of each step only; the step text comes back in the result.

## Limits

- `query.hotspots` reads the ground value tiles (max 6 km a side; the default area is the view). Facade and roof hotspots are not available: the tiles have no statistics. Use `describeView` and `pickAt` for facades.
- `query.sample`: the facade or roof value comes from the surface at the point (max 20 m away). The point must be on screen; when a building in front hides it, `surface.kind` is "hidden": look from above.
- Ground materials (`material`) come from the ground layer colours (config cities with a ground layer). They are classes, not surveyed.
- `streets.route` uses the public OSRM demo servers (foot and car). Use them for demos only.
- Live tools (what-if, route, sample, zones, path, sunlit places, alerts): `tools.open("live.<id>")` opens the panel; the panel needs clicks. For alert numbers use `query.areaStats`.
- `path.play`, `path.toShot` and `video.record` use the flight recorder (`core/flightrec/`, the Rec / Path buttons of the fly camera panel). A video file is made in the browser (MediaRecorder) and saved as a download. `path.toShot` needs the desktop fly camera.

## Reference

The tables below are made from the code (`core/agent/commands.js`, `core/agent/controls.js`). `twin.help()` gives the same, live.

<!-- BEGIN GENERATED: run node tests/static/gen-agents-doc.mjs -->

### Commands

All commands return a promise, except `twin.help()` and `twin.on()`. Arguments: by position, or one object with the names.

#### general

| Command | Returns | What it does | Example |
|---|---|---|---|
| `help(command?: string)` | { commands, controls, events, notes } | This list, made live from the API and the UI registry. | `twin.help("query.hotspots")` |
| `cities()` | [{ id, name, url, at, current }] | All city pages. | `twin.cities()` |
| `city(id: string)` | { navigating, url } | Fly out and load another city (as the city picker). The page reloads: wait for twin.ready on the new page. | `twin.city("london")` |
| `info()` | { city, page, mode, topics, topic, metrics, metric, layers, tools, bounds, live, flags } | What this city has: topics, metrics, layers, tools, data bounds, Live. | `twin.info()` |
| `link()` | string | Deep link of the current view (camera, topic, metric, filter, mode). | `twin.link()` |
| `waitUntilIdle(opts?: object)` | { idle, ms, tiles } | Resolves when the camera is still, the globe and every shown 3D tileset (buildings, facades, roofs, trees) are loaded, or at the timeout (then idle: false and the tilesets that still load). Call it before a screenshot. | `await twin.waitUntilIdle({ timeout: 90000 })` |
| `on(event: string, fn: any)` | function (call it to stop) | Listen to an event. | `const off = twin.on("cameraIdle", c => console.log(c))` |
| `call(command: string, args?: any)` | result of the command | Run a command by name. args = an object with the param names (JSON, as WebMCP), or an array of positional args. | `twin.call("camera.flyTo", { target: { lon: 114.17, lat: 22.3, range: 900 } })` |
| `highlight(target: object, opts?: object)` | { id, kind, lon, lat } | Mark a building (outline), a point (ground ring) or an area (ground polygon), with an optional label. A building must be on screen. | `await twin.highlight({ lon: 114.1616, lat: 22.2793 }, { label: "Bank of China Tower" })` |
| `label(at: object, text: string, opts?: object)` | { lon, lat, h, text } | On-map callout (a pin with a text and a leader line). | `await twin.label({ lon: 114.1694, lat: 22.3193, h: 120 }, "39 °C at noon")` |
| `clearHighlights()` | { cleared } | Remove all highlights and labels of the agent. | `await twin.clearHighlights()` |

#### scene

| Command | Returns | What it does | Example |
|---|---|---|---|
| `scene.idle()` | { idle, globe, tilesets, waiting: [tileset], animating } | Is the scene idle now (no wait)? Lists the 3D tilesets that still load. | `(await twin.scene.idle()).idle` |

#### camera

| Command | Returns | What it does | Example |
|---|---|---|---|
| `camera.get()` | { lon, lat, h, heading, pitch, roll, target: { lon, lat, h, range } \| null } | Camera position (deg, m) and the ground point at the screen centre. | `twin.camera.get()` |
| `camera.set(lon: number, lat: number, h?: number, heading?: number, pitch?: number, roll?: number, aboveGround?: number)` | camera.get() | Put the camera there at once (angles in deg). Height: h (ellipsoid) or aboveGround (terrain). | `twin.camera.set({ lon: 114.17, lat: 22.29, h: 600, heading: 0, pitch: -35 })` |
| `camera.flyTo(target: any, opts?: object)` | camera.get() at the end | Fly the camera. With range: look at the point from that distance; else go to that position. | `await twin.camera.flyTo({ lon: 114.172, lat: 22.318, range: 900, pitch: -35 })` |
| `camera.lookAt(lon: number, lat: number, range?: number, heading?: number, pitch?: number, h?: number)` | camera.get() | Look at a ground point from heading / pitch / range at once. | `twin.camera.lookAt({ lon: 114.1616, lat: 22.2793, range: 500, heading: 200, pitch: -25 })` |
| `camera.orbit.start(degPerSec?: number, opts?: object)` | { orbiting } | Turn slowly around the screen centre (or a point). | `twin.camera.orbit.start(6, { seconds: 20 })` |
| `camera.orbit.stop()` | { orbiting } | Stop the orbit. | `twin.camera.orbit.stop()` |
| `camera.followPath(path: array, opts?: object)` | { done, length, seconds } | Drive or fly the camera along a line (a street from streets.route). Resolves at the end. | `await twin.camera.followPath(route.path, { height: 25, speed: 30 })` |
| `camera.preview(pose: object, opts?: object)` | { dataUrl, width, height, pose } | One small screenshot from a pose, to scout a keyframe. The camera goes back after. | `const s = await twin.camera.preview({ lon: 114.158, lat: 22.276, aboveTop: 120, lookAt: { lon: 114.1616, lat: 22.2793, h: 200 } })` |
| `camera.stop()` | { stopped } | Stop any camera flight, orbit, path or video path. | `twin.camera.stop()` |

#### view

| Command | Returns | What it does | Example |
|---|---|---|---|
| `view.setTopic(id: string)` | { topic } | Show a topic (see info().topics). | `await twin.view.setTopic("heat")` |
| `view.setMetric(id: string)` | { metric } | Switch the metric of the topic (see info().metrics), e.g. utci or tcs (HK heat), a ground result metric, a Live sim. | `await twin.view.setMetric("tcs")` |
| `view.setFilter(filter?: object)` | { filter } | Hide facades and roofs outside the range (topics with facades). | `twin.view.setFilter({ min: 1200 })` |
| `view.layers()` | [{ name, on, label }] | Map layers and their state. | `twin.view.layers()` |
| `view.setLayer(name: string, on: boolean)` | view.layers() | Turn a map layer on or off. | `twin.view.setLayer("trees", false)` |
| `view.setMode(mode: string)` | { mode } | Climate atlas or Live now (cities with Live). | `await twin.view.setMode("live")` |
| `view.setTime(time: object)` | { time } | Live forecast hour, or the Shadows day and hour. | `await twin.view.setTime({ date: "2026-06-21", time: "16:00" })` |
| `view.plain(on?: boolean)` | { plain, topic, layers } | Neutral look in one call: no results, no ground layer, no shadows; grey buildings and trees (clean shots). plain(false) restores the topic. | `await twin.view.plain(true)` |
| `view.ui(opts?: object)` | { hidden } | Hide or show all page UI. | `twin.view.ui({ hidden: true })` |

#### query

| Command | Returns | What it does | Example |
|---|---|---|---|
| `query.sample(lon: number, lat: number)` | { lon, lat, ground: { metric: value }, surface: { kind, value, metric } \| null, live: { sim, value } \| null, units } | Values at a point: all ground metrics, the facade / roof under the point (if on screen), Live. | `await twin.query.sample(114.1694, 22.3193)` |
| `query.heightAt(lon: number, lat: number, opts?: object)` | { terrain, top, above_terrain, safe_h, clearance } | Terrain height and the highest surface (buildings, trees; loaded tiles) at a point, in m above the ellipsoid. safe_h = top + clearance: a camera height that clears the roofs. | `const { safe_h } = await twin.query.heightAt(114.1616, 22.2793, { clearance: 40 })` |
| `query.clearance(lon: number, lat: number, opts?: object)` | { terrain, top, suggestedMinH, radius } | Highest surface within a radius (buildings, trees, terrain) and the lowest safe camera height there (m above the ellipsoid). | `const c = await twin.query.clearance(114.1616, 22.2793, { radius: 60 })` |
| `query.pickAt(at: any)` | { kind, lon, lat, h, props, value } | What is at a point: building properties, facade / roof value, ground. | `await twin.query.pickAt({ x: 640, y: 400 })` |
| `query.describeView(opts?: object)` | { city, camera, mode, topic, metric, legend, filter, stats, places, link } | What is on screen now, in words and numbers. | `await twin.query.describeView()` |
| `query.hotspots(filter?: object)` | { metric, unit, sort, spots: [{ lon, lat, value, area_m2, material? }], cells, note } | Top-N hot (or cool) spots of a ground metric in an area (grid sample of the value tiles; neighbours merge to spots). | `await twin.query.hotspots({ metric: "utci", place: "Mong Kok", n: 3 })` |
| `query.areaStats(polygon: array, opts?: object)` | { sim, mean, p5, p50, p95, max, coverage } | Live statistics of an area (the area-alert numbers). Cities with Live tools. | `await twin.query.areaStats([[114.168,22.316],[114.172,22.316],[114.172,22.32]])` |

#### search

| Command | Returns | What it does | Example |
|---|---|---|---|
| `search.geocode(text: string, opts?: object)` | [{ name, kind, lon, lat, bbox, source }] | Find places by name: the views of this city first, then Photon (OpenStreetMap; max 1 request / s). | `await twin.search.geocode("Temple Street")` |
| `search.nearby(lon: number, lat: number, opts?: object)` | [{ name, kind, lon, lat, dist_m }] | Named places near a point (Overpass, OpenStreetMap). | `await twin.search.nearby(114.17, 22.31, { kind: "park" })` |

#### streets

| Command | Returns | What it does | Example |
|---|---|---|---|
| `streets.near(lon: number, lat: number, radius?: number)` | [{ name, highway, length_m, dist_m, path }] | Named streets near a point, with their lines (Overpass, OpenStreetMap). | `await twin.streets.near(114.1716, 22.3125, 200)` |
| `streets.find(name: string, opts?: object)` | { name, length_m, path, parts } | One street by name as one line (the longest connected chain). | `await twin.streets.find("Nathan Road")` |
| `streets.route(from: any, to: any, opts?: object)` | { path, length_m, seconds, source } | Street route between two points (public OSRM demo servers; for demos only). | `const r = await twin.streets.route("Mong Kok Station", "Yau Ma Tei Station")` |

#### tools

| Command | Returns | What it does | Example |
|---|---|---|---|
| `tools.list()` | [{ id, label, open, args }] | Tools of this page: pages (districts, stories, heat risk), Shadows, point pins, Live tools. | `twin.tools.list()` |
| `tools.open(id: string, args?: object)` | { open } | Open a tool. | `await twin.tools.open("shadows", { date: "2026-12-21", time: "09:00" })` |
| `tools.close()` | { open: null } | Close the open tool (home page; Live: no tool). | `twin.tools.close()` |

#### ui

| Command | Returns | What it does | Example |
|---|---|---|---|
| `ui.list(filter?: object)` | [{ id, label, kind, group, args, state, options }] | All UI controls of this page with their state (from the registry). | `twin.ui.list({ group: "layer" })` |
| `ui.do(id: string, args?: any)` | { id, state } | Use a UI control as a person would. | `await twin.ui.do("topic", { value: "solar" })` |

#### story

| Command | Returns | What it does | Example |
|---|---|---|---|
| `story.list()` | [{ index, id, title, sub, steps }] | Stories of this page (HK: the Stories page; other cities: stories given to story.play). | `await twin.story.list()` |
| `story.play(story: any, opts?: object)` | { id, step, n } | Open a story at a step. A story object is added to the list first (HK: in the Stories page; other cities: camera + topic only). | `await twin.story.play("hk90")` |
| `story.step(step: any)` | { id, step, n } | Go to a step of the open story. | `await twin.story.step("next")` |
| `story.stop()` | { stopped } | Close the story. | `twin.story.stop()` |

#### capture

| Command | Returns | What it does | Example |
|---|---|---|---|
| `capture.screenshot(opts?: object)` | { dataUrl, width, height } | Image of the map (canvas only, no UI). | `const { dataUrl } = await twin.capture.screenshot({ width: 1600, height: 900 })` |
| `capture.download(opts?: object)` | { name, width, height } | Screenshot saved as a file by the browser. | `await twin.capture.download({ name: "mongkok-heat.png" })` |

#### path

| Command | Returns | What it does | Example |
|---|---|---|---|
| `path.fromKeyframes(keys: array)` | path JSON | Camera path from keyframes (flight recorder format). | `const p = twin.path.fromKeyframes([{ lon: 114.16, lat: 22.28, h: 400, heading: 0, pitch: -30, t: 0 }, { lon: 114.17, lat: 22.3, h: 300, heading: 20, pitch: -25, t: 12 }])` |
| `path.fromWaypoints(points: array)` | path JSON | Camera path from waypoints (for path.play, video.record, path.toShot). | `const p = await twin.path.fromWaypoints([{ lon: 114.172, lat: 22.30, h: 300 }, { lon: 114.1716, lat: 22.315, h: 180, dur: 8 }])` |
| `path.check(path: object, opts?: object)` | { hits, spans: [{ t0, t1, worst_t, need_m, lon, lat, building }], min_clearance_m, length_m, seconds } | Does the camera path go into buildings, trees or terrain? Samples every 0.25 s / 8 m against the most detailed heights. 0 hits = good. | `const c = await twin.path.check(ride)` |
| `path.autoLift(path: object, opts?: object)` | { path, rounds, hits, spans, ... } | Raise the keys around each hit, check again (max 4 rounds). Use the returned path. | `const fixed = (await twin.path.autoLift(ride, { margin: 10 })).path` |
| `path.preview(path: object, opts?: object)` | { dataUrl, width, height, cols, frames: [{ i, t, clearance_m, lon, lat, h }] } | ONE contact-sheet image of the ride: n frames at even times, each labelled with t and clearance (red = under 5 m). LOOK at it before you render. | `const sheet = await twin.path.preview(ride, { n: 12 })` |
| `path.orbit(lon: number, lat: number, h?: number, range?: number, pitch?: number, heading?: number, degrees?: number, seconds?: number)` | path JSON | Camera path that circles a point (for path.play or video.record). | `const p = await twin.path.orbit({ lon: 114.1616, lat: 22.2793, h: 180, range: 800, pitch: -18, seconds: 24 })` |
| `path.play(path: object, opts?: object)` | { done, seconds } | Play a camera path (flight recorder). Resolves at the end. clean: false keeps the UI. | `await twin.path.play(p)` |
| `path.toShot(path: object)` | { path: [[lon, lat, h, heading, pitch, dur], ...] } | Story camera shot (the cam of a story step) from a path. Desktop pages with the fly camera. | `const cam = await twin.path.toShot(p)` |

#### video

| Command | Returns | What it does | Example |
|---|---|---|---|
| `video.record(opts: object)` | { saved, type } | Play the path and save it as a video file. | `await twin.video.record({ path: p })` |

Events for `twin.on(event, fn)`: `ready`, `tilesLoaded`, `cameraIdle`, `cameraMove`, `topic`, `mode`.

### UI controls (registry)

`twin.ui.list()` gives the controls of the page with their state and options; `twin.ui.do(id, args)` uses one.

| id | Kind | Group | Args | Pages | What it is |
|---|---|---|---|---|---|
| `city` | select | nav | `{"value":"string"}` | all | City picker (header). Changes the page. |
| `mode` | select | nav | `{"value":"atlas\|live"}` | live | Climate atlas or Live now. |
| `page` | select | nav | `{"value":"string"}` | all | Open a page of the left column (home, advanced, credits, districts, stories, heatrisk ...). |
| `topic` | select | topic | `{"value":"string"}` | all | Topic tab (Sun, Sky, Heat, Solar, Shadows, City, Ground ...). |
| `heat.metric` | select | topic | `{"value":"hottest\|hours"}` | hk | Heat topic: hottest-day UTCI or heat-stress hours. |
| `results.metric` | select | topic | `{"value":"string"}` | city | Result metric of the topic (ground, or facades + roofs + ground with engine 3). |
| `results.surfaces` | toggle | layer | `{"on":"boolean"}` | city (results engine 3) | Engine 3 results topic: facade and roof results on or off. |
| `results.ground` | toggle | layer | `{"on":"boolean"}` | city (results engine 3) | Engine 3 results topic: ground results on or off. |
| `filter` | slider | topic | `{"min":"number?","max":"number?"}` | hk, city (flag click_tools) | Value filter: hide facades and roofs outside min..max (topic units). |
| `view` | select | camera | `{"value":"string\|number"}` | all | Map view button / place chip (flies the camera). |
| `flycam.fly` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam) | Fly camera (FLY button, key F). Desktop. |
| `flycam.orbit` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam) | Slow orbit around the screen centre (key O). Desktop. |
| `rec.record` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam), desktop | Flight recorder: record the camera (Record flight / Stop). Stop opens the path editor. |
| `rec.editor` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam), desktop | Flight path editor (Edit path button). No saved path: a new path, key 1 = the current camera. |
| `rec.play` | button | camera | `{"clean":"boolean?"}` | hk, city (flag flycam), desktop | Play the flight path (clean = no UI). |
| `rec.smooth` | slider | camera | `{"value":"number"}` | hk, city (flag flycam), desktop | Rebuild the keys from the recording: strength 0 (more keys) .. 1 (fewer keys). |
| `rec.video` | button | camera | `{"withUI":"boolean?","res":"720\|1080\|2160\|window"}` | hk, city (flag flycam), desktop | Record the flight path as a video file (no dialog). |
| `rec.export` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam), desktop | Export video dialog of the editor (open / close). |
| `rec.key.add` | button | camera | `{"t":"number?"}` | hk, city (flag flycam), desktop | Add a key with the current camera at the playhead (or at t); a key there = select it. |
| `rec.key.update` | button | camera | `{"i":"number?"}` | hk, city (flag flycam), desktop | The selected key (or key i) takes the current camera. |
| `rec.key.delete` | button | camera | `{"i":"number?"}` | hk, city (flag flycam), desktop | Delete the selected key (or key i). A path keeps 2 keys or more. |
| `rec.key.select` | slider | camera | `{"value":"number"}` | hk, city (flag flycam), desktop | Select key i (0-based): playhead and camera go there, the key popover opens. |
| `rec.key.edit` | slider | camera | `{"i":"number?","t":"number?","height":"number?","heading":"number?","pitch":"number?","fov":"number?","motion":"string?"}` | hk, city (flag flycam), desktop | Change the selected key (or key i): t (s), height (m above ground), heading, pitch, fov (deg), motion smooth\|ease\|linear. |
| `rec.autokey` | toggle | camera | `{"on":"boolean"}` | hk, city (flag flycam), desktop | Auto-key: camera moves change the key at the playhead (or add a key). |
| `rec.length` | slider | camera | `{"value":"number"}` | hk, city (flag flycam), desktop | Video length in seconds (all key times scale). |
| `rec.seek` | slider | camera | `{"value":"number\|start\|end\|prev\|next"}` | hk, city (flag flycam), desktop | Playhead: seconds, or start\|end\|prev\|next (prev / next = keys). |
| `rec.undo` | button | camera | `{}` | hk, city (flag flycam), desktop | Undo the last path edit. |
| `rec.redo` | button | camera | `{}` | hk, city (flag flycam), desktop | Redo the last undone path edit. |
| `rec.raise` | button | camera | `{}` | hk, city (flag flycam), desktop | Raise camera here: clear the first collision (a raised key at the worst point). State: collisions. |
| `rec.preview` | button | camera | `{}` | hk, city (flag flycam), desktop | Preview the path full screen, no UI (Esc stops). |
| `layer.buildings` | toggle | layer | `{"on":"boolean"}` | city | Grey buildings. |
| `layer.ground` | toggle | layer | `{"on":"boolean"}` | city | Ground materials layer. |
| `layer.ground_opacity` | slider | layer | `{"value":"number"}` | city | Ground layer opacity 0.2..1. |
| `layer.trees` | toggle | layer | `{"on":"boolean"}` | all (with trees) | Trees. |
| `layer.ao` | toggle | layer | `{"on":"boolean"}` | city (flag ao) | Ambient occlusion on the grey city (desktop). |
| `layer.surfaces` | select | layer | `{"value":"sun\|svf\|solar\|off"}` | hk | Raw facade + roof layer (sun, svf, solar, off). |
| `layer.outlines` | toggle | layer | `{"on":"boolean"}` | hk | District outlines. |
| `layer.pednet` | toggle | layer | `{"on":"boolean"}` | hk | Pedestrian network. |
| `google3d` | toggle | layer | `{"on":"boolean"}` | all (desktop) | Google Photorealistic 3D Tiles with our results (pill card below the main panel). Key V (while on): next topic, facade topics first. Desktop; tiles through the proxy Worker (core/google-tiles.js). |
| `google3d.mode` | select | layer | `{"value":"lens\|swipe\|ground"}` | all (desktop) | Google 3D mode: lens (our results inside a disc), swipe (Google left, ours right) or ground (our ground colours on the flat Google ground). |
| `google3d.radius` | slider | layer | `{"value":"number"}` | all (desktop) | Google 3D lens radius 30..1000 m. Keys Z / X (while on): smaller / bigger, smooth (tap = x1.25, hold = x1.6 per second). |
| `google3d.split` | slider | layer | `{"value":"number"}` | all (desktop) | Google 3D swipe divider position 0..1 (left to right). |
| `google3d.lens` | button | layer | `{"lon":"number?","lat":"number?","follow":"boolean?"}` | all (desktop) | Pin the Google 3D lens at a point (follow: true = the lens follows the cursor again). |
| `google3d.reveal` | button | layer | `{"lon":"number","lat":"number","to":"number?","seconds":"number?","from":"number?","at":"number?","glow":"boolean?"}` | all (desktop) | Reveal wave for videos: the lens grows from `from` to `to` m over `seconds` at lon, lat. With `at` (s) the flight path time drives it (deterministic render). |
| `pick.mode` | select | tool | `{"value":"building\|point"}` | hk, city (flag click_tools) | Map click: building popup or value pin. |
| `story` | select | tool | `{"value":"string","step":"number?"}` | hk | Open a story (guided tour; value "list" = the story list). |
| `story.step` | slider | tool | `{"value":"number\|next\|back"}` | hk | Step of the open story: a step index, "next" or "back". |
| `shadows.time` | slider | tool | `{"doy":"number?","minute":"number?"}` | hk, city (flag shadows) | Shadows topic: day of year (1..365) and minute of the day (local time). |
| `live.sim` | select | live | `{"value":"string"}` | live | Live layer: utci, wind, daylight. |
| `live.hour` | slider | live | `{"value":"number"}` | live | Live forecast hour index (0 = now). |
| `live.tool` | select | live | `{"value":"string"}` | live | Live tool rail (what-if, route, sample, zones, profile, sunlit, alerts) or none. |
| `projects.tool` | select | projects | `{"value":"none\|projects\|whatif\|report"}` | Singapore ATLAS (?projects) | Open the ATLAS Projects, intervention or report window. |
| `projects.site` | select | projects | `{"value":"string"}` | Singapore ATLAS (?projects) | Choose a prepared ATLAS study site. |
| `projects.version` | select | projects | `{"value":"string"}` | Singapore ATLAS (?projects) | Choose Existing, a prepared design or the temporary draft. |
| `projects.mode` | select | projects | `{"value":"tree\|surface\|select"}` | Singapore ATLAS (?projects) | Intervention mode. |
| `projects.select` | select | projects | `{"value":"string"}` | Singapore ATLAS (?projects) | Select a prepared building, installation, tree or surface. |
| `projects.preset` | select | projects | `{"value":"string"}` | Singapore ATLAS (?projects) | Change the selected element material. |
| `projects.archetype` | select | projects | `{"value":"broadleaf\|conifer\|columnar\|palm"}` | Singapore ATLAS (?projects) | Tree archetype. |
| `projects.size` | select | projects | `{"value":"S\|M\|L"}` | Singapore ATLAS (?projects) | Tree size preset. |
| `projects.roof` | select | projects | `{"value":"existing\|light\|green"}` | Singapore ATLAS (?projects) | Selected building roof material. |
| `projects.transmittance` | slider | projects | `{"value":"number"}` | Singapore ATLAS (?projects) | Selected installation transmittance from 0 to 1. |
| `projects.tree` | button | projects | `{"lon":"number","lat":"number"}` | Singapore ATLAS (?projects) | Place a tree at a site coordinate. |
| `projects.vertex` | button | projects | `{"lon":"number","lat":"number"}` | Singapore ATLAS (?projects) | Add a site or ground polygon vertex. |
| `projects.finish` | button | projects | `{}` | Singapore ATLAS (?projects) | Close the drawn polygon. |
| `projects.cancelDrawing` | button | projects | `{}` | Singapore ATLAS (?projects) | Cancel the current drawing. |
| `projects.create` | button | projects | `{"name":"string?"}` | Singapore ATLAS (?projects) | Draw a temporary study site. |
| `projects.remove` | button | projects | `{}` | Singapore ATLAS (?projects) | Remove the selected new tree or patch. |
| `projects.preview` | button | projects | `{}` | Singapore ATLAS (?projects) | Preview the current immutable input before a paid run. |
| `projects.run` | button | projects | `{"maxTokens":"number?"}` | Singapore ATLAS (?projects) | Run the checked intervention within its token limit. |
| `projects.stop` | button | projects | `{"id":"string"}` | Singapore ATLAS (?projects) | Stop further submissions for a run. |
| `projects.result` | select | projects | `{"value":"base\|scenario\|diff"}` | Singapore ATLAS (?projects) | Show the baseline, scenario or compatible grid difference. |

<!-- END GENERATED -->

Data credits: see the "Data and credits" page of each city. Search and streets: © OpenStreetMap contributors (ODbL).
