Lumbridge Simulate Engine — the city, and the licence it can actually ship under
LSE is the third of the three, beside lumbridge-compute and lumbridge-bench: a 3D engine for walkable places. This first commit is the outside of the world — San Francisco — plus the seams the inside will attach to. The engine renders a City and a list of Markers and knows nothing else. It does not know markers are usually companies and it will never learn that "rejected" is red; that mapping lives in an adapter. Which is what lets one renderer serve a private map, a public one, and a self-hoster with no Lumbridge account, none of them a fork of the others. Three things were designed around the licence rather than discovered after it, because each one is a promise Apache 2.0 makes that is easy to break by accident. No trademarks in the repo — logos are fetched at runtime, and public/logos/ is gitignored. No OpenStreetMap-derived coordinates, which is why every coastline in cities/sf.ts was traced by hand: Nominatim output is ODbL, share-alike, and would attach to the whole pack. And no FlightRadar24 client — their terms forbid scraping and redistribution, so flights are an interface with a simulator and open community ADS-B behind it. The privacy constraint and the licence constraint turned out to want the same thing. Geocoded company positions and pipeline status both stay behind Workie's API; the open repo holds the city and the renderer. The tempting shortcut — commit an sf-companies.json — breaks both at once. Ported out of Workie, where a 3D city engine had no business living. Workie's /live is deleted rather than deprecated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Company logos are TRADEMARKS and are never committed — they are fetched at
|
||||||
|
# runtime and cached in the browser. An Apache-2.0 repo containing them would
|
||||||
|
# be relicensing marks it does not own. See ARCHITECTURE.md §3.1.
|
||||||
|
public/logos/
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
# Lumbridge Simulate Engine — architecture
|
||||||
|
|
||||||
|
**LSE** is the third of the three: `lumbridge-compute`, `lumbridge-bench`,
|
||||||
|
`lumbridge-simulate`. A 3D engine for walkable places — a city from above, an
|
||||||
|
office from inside, one renderer and one asset library.
|
||||||
|
|
||||||
|
Ships at `lumbridgecorp.com/live`. Apache 2.0, self-hostable, Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this is its own repo, and why it is public
|
||||||
|
|
||||||
|
LSE began as `/live` inside Workie, the private career portal. That is now
|
||||||
|
**deleted**, not deprecated — a 3D city engine has no business living in a job
|
||||||
|
tracker, and Workie is a private repo besides.
|
||||||
|
|
||||||
|
**You cannot cleanly Apache-2.0 a subdirectory of a private repo.** The licence
|
||||||
|
attaches to a distribution, and a consumer has to be able to clone the thing the
|
||||||
|
licence covers. A folder inside `lumbridgecorp-website` or inside
|
||||||
|
`lumbridge-runtime` would mean every release is a manual extraction, and every
|
||||||
|
extraction is a chance to ship something private by accident. The point of this
|
||||||
|
project — a reusable asset library other people can build on and self-host — needs
|
||||||
|
a repo that *is* the open-source artefact.
|
||||||
|
|
||||||
|
So: **`lumbridge-public/lumbridge-simulate` on the fleet's own Gitea.** Not
|
||||||
|
GitHub. Self-hosting the forge is the same claim the engine makes: the whole
|
||||||
|
thing runs on hardware you own, and nothing about it requires somebody else's
|
||||||
|
platform to stay alive.
|
||||||
|
|
||||||
|
### World 1 is this
|
||||||
|
|
||||||
|
This is the part that makes LSE load-bearing rather than a demo. The existing
|
||||||
|
Lumbridge world is a Phaser 2D office, and its tileset is what stops it being
|
||||||
|
open-sourceable. World 1 becomes **the San Francisco city view plus the office
|
||||||
|
interiors**, rendered here, with original assets.
|
||||||
|
|
||||||
|
That reframes the whole repo: LSE is not a map that happens to live next to the
|
||||||
|
platform, it is the platform's renderer. The city is the outside of the world
|
||||||
|
and the offices are the inside of it, and a tenant moves between them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
lumbridge-simulate/
|
||||||
|
├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights.
|
||||||
|
│ ├── types.ts # City, District, Hill, Marker, FlightSource
|
||||||
|
│ ├── world.ts # projection + cached heightfield, built per city
|
||||||
|
│ ├── terrain.ts # relief, coastline, parks
|
||||||
|
│ ├── blocks.ts # the built city, on a per-district street lattice
|
||||||
|
│ ├── structures.ts # bridges, streets, freeways
|
||||||
|
│ ├── markers.ts # pins. Takes Marker[], knows nothing about companies.
|
||||||
|
│ ├── flights.ts # FlightSource interface + a simulated implementation
|
||||||
|
│ └── scene.ts # lights, sky, camera flights, render loop
|
||||||
|
├── src/interiors/ # the inside of the world — floorplans, desks, presence
|
||||||
|
├── src/assets/ # the library. Original meshes and materials, Apache 2.0.
|
||||||
|
├── src/cities/ # data packs. Pure geography, no code.
|
||||||
|
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
|
||||||
|
│ └── la.ts # LA / OC / Riverside
|
||||||
|
├── src/adapters/ # where outside data plugs in
|
||||||
|
│ └── workie.ts # Workie API -> Marker[]
|
||||||
|
└── src/main.ts # the standalone demo app
|
||||||
|
```
|
||||||
|
|
||||||
|
The split that matters: **`engine` never imports `cities`, and neither imports
|
||||||
|
`adapters`.** A city is data handed to the engine. A marker is a `{id, lat, lng,
|
||||||
|
label, colorKey}` handed to the engine. The engine has no idea Workie exists,
|
||||||
|
which is what makes it publishable, self-hostable by someone with no Lumbridge
|
||||||
|
account at all, and free of anyone's private data.
|
||||||
|
|
||||||
|
`interiors` and `assets` sit beside `engine` rather than inside it: an office
|
||||||
|
and a city share the projection, camera, lighting and render loop, and differ
|
||||||
|
only in what they put in the scene. That is why `scene.ts` owns the loop and
|
||||||
|
knows nothing about terrain specifically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Three rules that keep Apache 2.0 honest
|
||||||
|
|
||||||
|
Apache 2.0 is a promise that everything in the repo is ours to give away. Three
|
||||||
|
things would quietly break that promise, and all three are easy to walk into.
|
||||||
|
|
||||||
|
### 3.1 No trademarks in the repo
|
||||||
|
|
||||||
|
Company logos are **trademarks**, not code. Salesforce's logo is not ours to
|
||||||
|
relicense no matter how the file got here, and an Apache-2.0 repo containing
|
||||||
|
`logos/salesforce.svg` is making a claim it cannot back.
|
||||||
|
|
||||||
|
The NYC atlas ships 47 of these in `public/logos/`. We do not.
|
||||||
|
|
||||||
|
- Logos are fetched **at runtime**, client-side, and cached in the browser.
|
||||||
|
- The repo carries a fetch script and zero logo files. `public/logos/` is
|
||||||
|
`.gitignore`d, and CI fails if anything lands there.
|
||||||
|
- `NOTICE` carries the standard "trademarks are the property of their
|
||||||
|
respective owners; their use here is nominative" line.
|
||||||
|
|
||||||
|
### 3.2 No OSM-derived coordinates in the repo
|
||||||
|
|
||||||
|
This is the subtle one, and it is the reason the geography in `cities/sf.ts` is
|
||||||
|
hand-traced rather than imported.
|
||||||
|
|
||||||
|
OpenStreetMap data — including anything that comes out of Nominatim geocoding —
|
||||||
|
is **ODbL**. ODbL is share-alike: a "derivative database" has to be released
|
||||||
|
under ODbL too. Committing a table of company lat/lngs geocoded from Nominatim
|
||||||
|
into an Apache-2.0 repo mixes an incompatible share-alike obligation into a
|
||||||
|
permissive one. That is exactly the kind of thing that makes a repo unusable for
|
||||||
|
the people we want using it.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
- **Geography** (coastlines, hills, districts) is traced by hand from scratch.
|
||||||
|
Original expression, ours, Apache 2.0. This is already true for SF.
|
||||||
|
- **Elevation**, when we want real terrain, comes from **USGS/SRTM**, which is
|
||||||
|
US-government public domain. Not OSM.
|
||||||
|
- **Geocoded company coordinates never enter this repo.** They live in Workie's
|
||||||
|
private database and arrive over the API at runtime.
|
||||||
|
|
||||||
|
Note where that lands: the licence constraint and the privacy constraint want
|
||||||
|
exactly the same thing. Company positions and pipeline status both stay behind
|
||||||
|
the API; the open-source repo holds the city and the renderer. That is a nice
|
||||||
|
result and it should be defended, because the tempting shortcut — "just commit a
|
||||||
|
`sf-companies.json`" — breaks both at once.
|
||||||
|
|
||||||
|
### 3.3 The engine takes no position on what a marker means
|
||||||
|
|
||||||
|
`markers.ts` renders `Marker[]`. A marker has a `colorKey`, not a `status`. The
|
||||||
|
mapping from "rejected" to red lives in the *adapter*, in the consuming app.
|
||||||
|
|
||||||
|
That is what lets the same engine serve the private Workie build (coloured by
|
||||||
|
pipeline state), the public Lumbridge demo (coloured by sector), and whatever
|
||||||
|
someone else builds from this repo, without any of them being a fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flights: FlightRadar24 is the wrong source for this
|
||||||
|
|
||||||
|
Real aircraft over the Bay is a great idea. FR24 specifically is a problem, and
|
||||||
|
it is worth being clear about why before any code is written against it.
|
||||||
|
|
||||||
|
FlightRadar24's terms prohibit scraping and prohibit redistributing their data;
|
||||||
|
their commercial API is licensed per-seat and explicitly does not grant
|
||||||
|
redistribution. An Apache-2.0 repo shipping an FR24 client is publishing
|
||||||
|
instructions for violating a ToS, and any FR24 data that reached the browser
|
||||||
|
could not be relicensed onward. Neither is fatal to a private deployment, but
|
||||||
|
both are fatal to the open-source story that is the point of this repo.
|
||||||
|
|
||||||
|
The engine therefore takes a **`FlightSource` interface**, and the repo ships
|
||||||
|
only sources it can actually give away:
|
||||||
|
|
||||||
|
| source | licence | notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **`SimulatedFlights`** | ours, Apache 2.0 | great-circle tracks on real SFO/OAK/SJC approach and departure paths. **This is what ships first**, and it is genuinely enough — the map wants convincing motion, not a spotter's log. |
|
||||||
|
| **`adsb.lol` / `airplanes.live`** | community, open terms | free, no key, real aircraft. The default "real data" adapter. |
|
||||||
|
| **OpenSky Network** | free, non-commercial | fine for a demo, awkward for a commercial product page. |
|
||||||
|
| **your own receiver** | *no licence at all* | an RTL-SDR + `dump1090` on any fleet box in the Bay produces first-party ADS-B. Nothing to comply with, and it is very on-brand. |
|
||||||
|
| FlightRadar24 | commercial, no redistribution | if it is ever wanted, it is a **private adapter in the Lumbridge deployment**, not in this repo. |
|
||||||
|
|
||||||
|
Recommendation: build `SimulatedFlights` now, `adsb.lol` next, and treat a
|
||||||
|
receiver as the eventual real answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. LA is not "SF but more data"
|
||||||
|
|
||||||
|
SF is 0.20° × 0.36°. At the 45 m cells the SF heightfield uses, that is 336k
|
||||||
|
lattice points and a 1.0 s build.
|
||||||
|
|
||||||
|
LA + Orange County + Riverside is roughly 0.5° × 1.9° — about **14× the area**.
|
||||||
|
The same approach would be 4.6M lattice points and a mesh nobody can load. This
|
||||||
|
is the one place where the engine as written does not simply extend, and it is
|
||||||
|
better to know that before writing `cities/la.ts` than after.
|
||||||
|
|
||||||
|
Two changes make it work, and both are cheap now:
|
||||||
|
|
||||||
|
1. **Cell size is per-city, not a constant.** LA's basin gets coarse cells;
|
||||||
|
nothing is lost, because LA's relief is mountains at the edges rather than
|
||||||
|
SF's hills every four blocks.
|
||||||
|
2. **Cities declare `focusRegions`** — a handful of boxes (DTLA, Santa Monica,
|
||||||
|
Culver, Irvine, Pasadena) rendered at fine resolution, with the basin between
|
||||||
|
them coarse. This is the LOD story, and it is a per-city data declaration
|
||||||
|
rather than engine machinery.
|
||||||
|
|
||||||
|
SF gets one focus region covering the whole city and behaves exactly as it does
|
||||||
|
now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How Workie feeds it
|
||||||
|
|
||||||
|
Workie stays the system of record for companies. LSE never gets a database.
|
||||||
|
|
||||||
|
```
|
||||||
|
Workie GET /api/live/markers (private, tailnet) -> status colours
|
||||||
|
Workie GET /api/public/markers (public, allowlist) -> sector colours
|
||||||
|
|
|
||||||
|
v
|
||||||
|
adapters/workie.ts -> Marker[]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
engine/markers.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The public endpoint goes through the **same `export-site.ts` field allowlist**
|
||||||
|
that already guards radar.karti.ai and work.karti.ai — the one that is
|
||||||
|
fail-closed and aborts a deploy rather than shipping an unknown field. No second
|
||||||
|
implementation of that gate. That was the argument for keeping `/live` inside
|
||||||
|
Workie originally, and it still holds — but only for the *data path*. The
|
||||||
|
renderer left; the gate stayed where it was.
|
||||||
|
|
||||||
|
Self-hosters get neither endpoint and do not need one: `setMarkers()` takes an
|
||||||
|
array, and where it comes from is the deployment's business.
|
||||||
|
|
||||||
|
`lumbridgecorp.com/live` is a static page, so it calls a small
|
||||||
|
`lumbridge-simulate` service behind the existing Caddy `handle /api/*` — the
|
||||||
|
same pattern `lumbridge-intake.service` already uses on cloud-2 — for flights
|
||||||
|
and for proxying the public marker feed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Interiors — and why the asset library is the real product
|
||||||
|
|
||||||
|
The existing Lumbridge world is a Phaser 4 2D office, and Karti's read is right:
|
||||||
|
its tileset is the thing that stops it being open-sourceable. Redrawing that in
|
||||||
|
3D with original assets is not a graphics exercise, it is a **licensing
|
||||||
|
unlock** — it is what lets the walkable-office half of Lumbridge ship under
|
||||||
|
Apache 2.0 at all.
|
||||||
|
|
||||||
|
That makes `src/assets` the highest-leverage part of this repo over time: an
|
||||||
|
original, consistent, Apache-2.0 library of desks, chairs, partitions, screens,
|
||||||
|
doors, lighting rigs, floor and wall materials. Built once, used by every
|
||||||
|
Lumbridge world and by anyone else who wants one.
|
||||||
|
|
||||||
|
Interiors share the engine's projection, camera and render loop, and swap the
|
||||||
|
city layer for a floorplan layer. Same `Marker` type — a desk with a person at
|
||||||
|
it is a marker with a different `colorKey`. The city view and the office view
|
||||||
|
are the outside and the inside of one world, and moving between them is a camera
|
||||||
|
transition, not a different application.
|
||||||
|
|
||||||
|
Reference point: the per-office spatial products in this space (Simile and
|
||||||
|
friends) are the shape to aim at. The difference LSE is going for is that this
|
||||||
|
one is Apache 2.0 and self-hostable — you can run your own world on your own
|
||||||
|
hardware, which is the same claim `lumbridge-compute` makes about compute.
|
||||||
|
|
||||||
|
This is the next phase after the city lands, and it is where `src/assets` starts
|
||||||
|
earning its keep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Order of work
|
||||||
|
|
||||||
|
1. **Port** SF out of Workie into `engine` + `cities/sf`, parameterised by city;
|
||||||
|
delete Workie's `/live`. *(this commit)*
|
||||||
|
2. **Markers + simulated flights.** A demo worth showing, with no data pipeline
|
||||||
|
and no licence questions. *(this commit)*
|
||||||
|
3. **Mount at `lumbridgecorp.com/live`**, with a small `lumbridge-simulate`
|
||||||
|
service behind Caddy for flights.
|
||||||
|
4. **Interiors + the asset library** — the office view, original assets, and
|
||||||
|
World 1 moving off Phaser. This is the one that unlocks the platform.
|
||||||
|
5. **Workie adapter**, once its geocoding pipeline lands. Workie gets an API,
|
||||||
|
not a renderer.
|
||||||
|
6. **`cities/la`** — LA / OC / Riverside. Needs `focusRegions` from §5 first.
|
||||||
|
7. **NYC.**
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
1. **Package name.** `@lumbridge/simulate` implies an npm publish; consuming
|
||||||
|
straight from Gitea is simpler until someone outside asks for a registry.
|
||||||
|
2. **Licence for the asset library specifically.** Apache 2.0 covers code
|
||||||
|
cleanly; art is sometimes better served by CC0 or CC-BY so it can be reused
|
||||||
|
outside software. Worth deciding before the first mesh lands, because
|
||||||
|
relicensing art after contributors exist is painful.
|
||||||
|
3. **How much of World 1 moves at once.** The Phaser world is live and has real
|
||||||
|
tenants; the city view can ship at `/live` well before any office does.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright {yyyy} {name of copyright owner}
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
LSE — Lumbridge Simulate
|
||||||
|
Copyright 2026 Lumbridge Corp.
|
||||||
|
|
||||||
|
This product includes software developed at Lumbridge Corp.
|
||||||
|
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||||
|
|
||||||
|
|
||||||
|
ORIGIN AND ORIGINALITY
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
LSE's city view was inspired by the interaction model of two prior works —
|
||||||
|
Levels.fyi Atlas, and Nutlope's NYC AI Atlas (https://github.com/Nutlope/
|
||||||
|
nyc-ai-atlas), which itself credits Levels.fyi. What was taken is the *idea*:
|
||||||
|
a left-hand chapter legend, animated camera flights into map areas, clickable
|
||||||
|
place labels, and ambient motion in the scene.
|
||||||
|
|
||||||
|
No code, geometry, or asset from either work is present here. LSE's renderer,
|
||||||
|
its terrain system, its block-lattice building placement, and every coordinate
|
||||||
|
in every city pack were written from scratch. The two projects do not even
|
||||||
|
share a subject: the NYC atlas renders hand-traced Manhattan on flat ground,
|
||||||
|
and LSE's premise is relief — a city you cannot read without its section.
|
||||||
|
|
||||||
|
Stating this plainly matters, because at the time of writing the NYC AI Atlas
|
||||||
|
repository carries no LICENSE file. Absent a licence grant, the default is that
|
||||||
|
no rights are given, so nothing from it could have been reused even had we
|
||||||
|
wanted to. Attribution here is courtesy toward a good idea, not a licence
|
||||||
|
obligation.
|
||||||
|
|
||||||
|
|
||||||
|
GEOGRAPHIC DATA
|
||||||
|
---------------
|
||||||
|
|
||||||
|
All geography in src/cities/ is original expression, traced by hand.
|
||||||
|
|
||||||
|
It is deliberately NOT derived from OpenStreetMap. OSM data — including
|
||||||
|
coordinates returned by Nominatim geocoding — is licensed under the Open
|
||||||
|
Database License (ODbL), whose share-alike terms would attach to any derivative
|
||||||
|
database and cannot be reconciled with the permissive licence above.
|
||||||
|
|
||||||
|
Where real elevation data is used in future, it will come from USGS/SRTM
|
||||||
|
sources, which are United States government works in the public domain.
|
||||||
|
|
||||||
|
|
||||||
|
TRADEMARKS
|
||||||
|
----------
|
||||||
|
|
||||||
|
This repository contains no third-party logos, wordmarks, or brand assets.
|
||||||
|
|
||||||
|
Organisation logos displayed by applications built on LSE are fetched at
|
||||||
|
runtime and are never redistributed as part of this project. All trademarks,
|
||||||
|
service marks, and trade names are the property of their respective owners.
|
||||||
|
Any use is nominative — to identify the organisation referred to — and does not
|
||||||
|
imply affiliation with or endorsement by the trademark holder.
|
||||||
|
|
||||||
|
|
||||||
|
THIRD-PARTY DEPENDENCIES
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
three.js — MIT License — Copyright (c) 2010-2026 three.js authors
|
||||||
|
https://github.com/mrdoob/three.js
|
||||||
|
|
||||||
|
Build tooling (Vite, TypeScript, esbuild) is a development dependency only and
|
||||||
|
is not redistributed in built output.
|
||||||
|
|
||||||
|
|
||||||
|
AIRCRAFT DATA
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The flight sources shipped in this repository are a simulator (original work)
|
||||||
|
and clients for open community ADS-B feeds. No commercial aviation data
|
||||||
|
provider's data is included or redistributed, and no client for a provider
|
||||||
|
whose terms prohibit such use is present.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Lumbridge Simulate Engine
|
||||||
|
|
||||||
|
**Lumbridge Simulate** — a 3D engine for walkable places. Cities from above,
|
||||||
|
buildings from inside, one renderer and one asset library.
|
||||||
|
|
||||||
|
Apache 2.0. Runs at [lumbridgecorp.com/live](https://lumbridgecorp.com/live).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
An engine plus data packs. The engine renders terrain, coastline, a built city
|
||||||
|
on real street grids, bridges, roads, markers and air traffic. A *city pack* is
|
||||||
|
pure data — coastlines, hills, districts, landmarks, camera chapters — so adding
|
||||||
|
a city is a data contribution anyone can review, not a fork.
|
||||||
|
|
||||||
|
San Francisco ships today. Los Angeles / Orange County / Riverside is next; New
|
||||||
|
York after that.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the engine
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createScene } from "@lumbridge/simulate/engine/scene.ts";
|
||||||
|
import SAN_FRANCISCO from "@lumbridge/simulate/cities/sf.ts";
|
||||||
|
|
||||||
|
const scene = createScene(canvas, {
|
||||||
|
city: SAN_FRANCISCO,
|
||||||
|
markerPalette: { hiring: 0x4ade80, closed: 0xef4444 },
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.setMarkers([
|
||||||
|
{ id: "1", lat: 37.7765, lng: -122.4241, label: "Somewhere", colorKey: "hiring" },
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine renders `Marker[]` and looks colours up by `colorKey` in a palette
|
||||||
|
you supply. It does not know what your markers *mean* — that mapping lives in
|
||||||
|
your adapter. This is what lets one renderer serve a private map coloured by
|
||||||
|
one scheme and a public map coloured by another, without either being a fork.
|
||||||
|
|
||||||
|
## Adding a city
|
||||||
|
|
||||||
|
Write `src/cities/<id>.ts` exporting a `City`. Trace the coastline and parks by
|
||||||
|
hand, place hills as radial peaks, and give each district its street bearing.
|
||||||
|
|
||||||
|
Two rules, and they are not stylistic:
|
||||||
|
|
||||||
|
- **Do not import geometry from OpenStreetMap.** OSM and Nominatim output is
|
||||||
|
ODbL — share-alike, and incompatible with this repo's licence.
|
||||||
|
- **Do not commit logos or brand assets.** They are trademarks, not code.
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](ARCHITECTURE.md) §3 for the full reasoning, and
|
||||||
|
[NOTICE](NOTICE) for the attribution and data-provenance statement.
|
||||||
|
|
||||||
|
## Aircraft
|
||||||
|
|
||||||
|
The engine takes a `FlightSource`. Two ship here: `SimulatedFlights` (original,
|
||||||
|
flies real approach and departure corridors) and `AdsbFlights` (open community
|
||||||
|
ADS-B feeds such as adsb.lol).
|
||||||
|
|
||||||
|
FlightRadar24 is deliberately absent — their terms forbid scraping and forbid
|
||||||
|
redistributing their data, so a client for it cannot live in an Apache-2.0
|
||||||
|
repository. Commercial sources belong in private deployments. The best long-term
|
||||||
|
answer is an RTL-SDR receiver: first-party data with nothing to comply with.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene
|
||||||
|
src/cities/ data packs — pure geography, no code
|
||||||
|
src/adapters/ where outside data plugs in
|
||||||
|
```
|
||||||
|
|
||||||
|
`engine` never imports `cities`; neither imports `adapters`.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Lumbridge Simulate — San Francisco</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; background: #d9e6ee; overflow: hidden;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
#scene { display: block; width: 100vw; height: 100vh; }
|
||||||
|
#panel { position: fixed; top: 0; left: 0; padding: 1rem; width: 19rem;
|
||||||
|
display: flex; flex-direction: column; gap: 0.25rem; pointer-events: none; }
|
||||||
|
#panel > * { pointer-events: auto; }
|
||||||
|
.card { background: rgba(8,12,16,0.55); backdrop-filter: blur(6px);
|
||||||
|
border-radius: 6px; padding: 0.75rem; color: rgba(255,255,255,0.72); font-size: 11px;
|
||||||
|
line-height: 1.5; }
|
||||||
|
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||||
|
color: #f2b134; }
|
||||||
|
h1 + p { margin: 0.15rem 0 0; color: rgba(255,255,255,0.55); }
|
||||||
|
#chapters { display: flex; flex-direction: column; gap: 1px; background: rgba(8,12,16,0.45);
|
||||||
|
backdrop-filter: blur(6px); border-radius: 6px; padding: 4px; }
|
||||||
|
.chapter { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.5rem;
|
||||||
|
background: none; border: 0; border-radius: 4px; cursor: pointer; text-align: left;
|
||||||
|
font: inherit; font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||||
|
.chapter:hover { background: rgba(255,255,255,0.09); }
|
||||||
|
.chapter.active { background: rgba(242,177,52,0.2); color: #ffd68a; }
|
||||||
|
.num { font-size: 10px; opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||||
|
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
|
||||||
|
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
|
||||||
|
color: rgba(20,30,40,0.5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="scene"></canvas>
|
||||||
|
<div id="panel">
|
||||||
|
<div class="card">
|
||||||
|
<h1>San Francisco</h1>
|
||||||
|
<p>Lumbridge Simulate Engine</p>
|
||||||
|
</div>
|
||||||
|
<nav id="chapters"></nav>
|
||||||
|
<p class="card" id="blurb"></p>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="detail" hidden></div>
|
||||||
|
<p id="hint">drag to orbit · scroll to zoom</p>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1255
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@lumbridge/simulate",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Lumbridge Simulate Engine (LSE) — a 3D engine for walkable places. Cities from above, buildings from inside.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.182.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1066
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* The built city, plus the handful of landmarks placed by hand.
|
||||||
|
*
|
||||||
|
* Two things make this read as a city rather than as noise:
|
||||||
|
*
|
||||||
|
* - **Buildings step along a block lattice** in each district's own grid
|
||||||
|
* bearing. An earlier version rejection-sampled uniformly inside each
|
||||||
|
* district and it looked like rubble, because a city is not a Poisson
|
||||||
|
* process. In San Francisco the lattice also reproduces the 46° between
|
||||||
|
* the grid north of Market and the grid south of it, and Market Street
|
||||||
|
* falls out as a seam rather than having to be drawn.
|
||||||
|
* - **Buildings stand on the terrain.** Every base is sampled from
|
||||||
|
* `world.groundAt`, so Nob Hill's low-rises tower over taller blocks in the
|
||||||
|
* flats below — which is true of that city, and which a flat map gets
|
||||||
|
* exactly backwards.
|
||||||
|
*
|
||||||
|
* Everything is instanced: one draw call for the whole city.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { District } from "./types.ts";
|
||||||
|
import { seededRandom, type World } from "./world.ts";
|
||||||
|
|
||||||
|
/** Lot size in scene units, and how many lots sit between cross-streets. */
|
||||||
|
const LOT = 0.42; // ~40 m at SF's scale
|
||||||
|
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
|
||||||
|
|
||||||
|
const PALETTES = {
|
||||||
|
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
|
||||||
|
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
|
||||||
|
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
|
||||||
|
} satisfies Record<District["palette"], number[]>;
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
h: number;
|
||||||
|
rot: number;
|
||||||
|
color: THREE.Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonBounds(poly: [number, number][]) {
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
let minLng = Infinity;
|
||||||
|
let maxLng = -Infinity;
|
||||||
|
for (const [lat, lng] of poly) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
}
|
||||||
|
return { minLat, maxLat, minLng, maxLng };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBlocks(world: World): THREE.InstancedMesh {
|
||||||
|
const boxes: Box[] = [];
|
||||||
|
let seedBase = 1337;
|
||||||
|
|
||||||
|
for (const district of world.city.districts) {
|
||||||
|
const rand = seededRandom(seedBase);
|
||||||
|
seedBase += 7919;
|
||||||
|
|
||||||
|
const palette = PALETTES[district.palette];
|
||||||
|
const angle = district.gridAngle;
|
||||||
|
const coverage = district.coverage ?? 0.88;
|
||||||
|
|
||||||
|
// The district's extent in scene space, padded so the rotated lattice
|
||||||
|
// still covers the corners once it is turned.
|
||||||
|
const b = polygonBounds(district.polygon);
|
||||||
|
const corners = [
|
||||||
|
world.project(b.minLat, b.minLng),
|
||||||
|
world.project(b.minLat, b.maxLng),
|
||||||
|
world.project(b.maxLat, b.minLng),
|
||||||
|
world.project(b.maxLat, b.maxLng),
|
||||||
|
];
|
||||||
|
const xs = corners.map((c) => c[0]);
|
||||||
|
const zs = corners.map((c) => c[1]);
|
||||||
|
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||||
|
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
|
||||||
|
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;
|
||||||
|
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
const steps = Math.ceil(reach / LOT);
|
||||||
|
|
||||||
|
for (let iu = -steps; iu <= steps; iu++) {
|
||||||
|
if (((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||||
|
for (let iv = -steps; iv <= steps; iv++) {
|
||||||
|
if (((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||||
|
|
||||||
|
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
|
||||||
|
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
|
||||||
|
const x = cx + u * cos - v * sin;
|
||||||
|
const z = cz + u * sin + v * cos;
|
||||||
|
|
||||||
|
const [lat, lng] = world.unproject(x, z);
|
||||||
|
if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
|
||||||
|
if (!world.isLand(lat, lng)) continue;
|
||||||
|
if (world.pointInAny(lat, lng, world.city.parks)) continue;
|
||||||
|
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
|
||||||
|
|
||||||
|
// Cubed, so tall buildings stay rare and the skyline keeps a
|
||||||
|
// silhouette instead of turning into a plateau.
|
||||||
|
const roll = rand();
|
||||||
|
const isTower = rand() < district.towerChance;
|
||||||
|
const t = isTower ? 0.55 + roll * 0.45 : roll ** 3;
|
||||||
|
const heightM = district.minHeight + t * (district.maxHeight - district.minHeight);
|
||||||
|
|
||||||
|
// Towers take several lots. A 260 m tower on one 40 m lot is a 25:1
|
||||||
|
// needle, and downtown came out looking like a bed of nails; real
|
||||||
|
// towers assemble their sites, and Salesforce Tower is about 5:1.
|
||||||
|
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
|
||||||
|
|
||||||
|
boxes.push({
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
y: world.groundAt(lat, lng),
|
||||||
|
w: LOT * fill,
|
||||||
|
d: LOT * fill * (0.85 + rand() * 0.3),
|
||||||
|
h: world.metres(heightM),
|
||||||
|
rot: angle + (rand() - 0.5) * 0.03,
|
||||||
|
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
|
||||||
|
|
||||||
|
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
|
||||||
|
mesh.name = "blocks";
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
const quat = new THREE.Quaternion();
|
||||||
|
const pos = new THREE.Vector3();
|
||||||
|
const scl = new THREE.Vector3();
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
boxes.forEach((b, i) => {
|
||||||
|
pos.set(b.x, b.y, b.z);
|
||||||
|
quat.setFromAxisAngle(up, b.rot);
|
||||||
|
scl.set(b.w, b.h, b.d);
|
||||||
|
matrix.compose(pos, quat, scl);
|
||||||
|
mesh.setMatrixAt(i, matrix);
|
||||||
|
mesh.setColorAt(i, b.color);
|
||||||
|
});
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The named buildings. Separate meshes because the eye goes looking for these
|
||||||
|
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
||||||
|
* Hill, the red tripod on the ridge — and a box would not do.
|
||||||
|
*/
|
||||||
|
export function createLandmarks(world: World): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "landmarks";
|
||||||
|
|
||||||
|
for (const lm of world.city.landmarks) {
|
||||||
|
const [x, z] = world.project(lm.lat, lm.lng);
|
||||||
|
const base = world.groundAt(lm.lat, lm.lng);
|
||||||
|
const h = world.metres(lm.height);
|
||||||
|
const w = lm.footprint * world.lngScale * 2;
|
||||||
|
|
||||||
|
let geo: THREE.BufferGeometry;
|
||||||
|
switch (lm.shape) {
|
||||||
|
case "pyramid":
|
||||||
|
geo = new THREE.ConeGeometry(w * 0.72, h, 4);
|
||||||
|
geo.translate(0, h / 2, 0);
|
||||||
|
geo.rotateY(Math.PI / 4);
|
||||||
|
break;
|
||||||
|
case "cylinder":
|
||||||
|
geo = new THREE.CylinderGeometry(w * 0.6, w * 0.68, h, 20);
|
||||||
|
geo.translate(0, h / 2, 0);
|
||||||
|
break;
|
||||||
|
case "tower":
|
||||||
|
geo = new THREE.CylinderGeometry(w * 0.42, w * 0.62, h, 4);
|
||||||
|
geo.rotateY(Math.PI / 4);
|
||||||
|
geo.translate(0, h / 2, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
geo = new THREE.BoxGeometry(w, h, w);
|
||||||
|
geo.translate(0, h / 2, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
geo,
|
||||||
|
new THREE.MeshLambertMaterial({ color: lm.color ?? 0xaebac6 }),
|
||||||
|
);
|
||||||
|
mesh.position.set(x, base, z);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData.landmark = lm;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Aircraft over the city.
|
||||||
|
*
|
||||||
|
* The engine takes a `FlightSource` rather than talking to any particular
|
||||||
|
* service, because the obvious one cannot ship here. FlightRadar24's terms
|
||||||
|
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
|
||||||
|
* containing an FR24 client would be publishing instructions for breaking a
|
||||||
|
* ToS and shipping data it has no right to relicense. Commercial sources are
|
||||||
|
* adapters in a private deployment; this file holds what we can actually give
|
||||||
|
* away. See ARCHITECTURE.md §4.
|
||||||
|
*
|
||||||
|
* `SimulatedFlights` is the default and is genuinely enough for the map — what
|
||||||
|
* a city view wants is convincing motion in the right corridors, not a
|
||||||
|
* spotter's log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { Aircraft, FlightSource } from "./types.ts";
|
||||||
|
import { seededRandom, type World } from "./world.ts";
|
||||||
|
|
||||||
|
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
|
||||||
|
export interface SimRoute {
|
||||||
|
callsign: string;
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
/** Metres at the start and end of the leg. */
|
||||||
|
fromAlt: number;
|
||||||
|
toAlt: number;
|
||||||
|
/** Seconds for a full traversal. */
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traffic that behaves like the real thing without being it: aircraft move
|
||||||
|
* along fixed legs at fixed speeds, looping, with each one offset in phase so
|
||||||
|
* the sky is never empty and never synchronised.
|
||||||
|
*/
|
||||||
|
export class SimulatedFlights implements FlightSource {
|
||||||
|
readonly interval = 1;
|
||||||
|
private readonly routes: SimRoute[];
|
||||||
|
private readonly phase: number[];
|
||||||
|
private t = 0;
|
||||||
|
private last = 0;
|
||||||
|
|
||||||
|
constructor(routes: SimRoute[], seed = 4711) {
|
||||||
|
this.routes = routes;
|
||||||
|
const rand = seededRandom(seed);
|
||||||
|
this.phase = routes.map(() => rand());
|
||||||
|
this.last = nowSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
poll(): Aircraft[] {
|
||||||
|
const now = nowSeconds();
|
||||||
|
this.t += Math.min(now - this.last, 5);
|
||||||
|
this.last = now;
|
||||||
|
|
||||||
|
return this.routes.map((route, i) => {
|
||||||
|
const p = ((this.t / route.duration + (this.phase[i] ?? 0)) % 1 + 1) % 1;
|
||||||
|
const lat = route.from[0] + (route.to[0] - route.from[0]) * p;
|
||||||
|
const lng = route.from[1] + (route.to[1] - route.from[1]) * p;
|
||||||
|
// Ease the altitude so departures climb steeply and level off.
|
||||||
|
const ease = 1 - (1 - p) ** 2;
|
||||||
|
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
|
||||||
|
const heading =
|
||||||
|
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
|
||||||
|
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowSeconds(): number {
|
||||||
|
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community ADS-B, for when real traffic is wanted.
|
||||||
|
*
|
||||||
|
* `adsb.lol` and `airplanes.live` both serve open, key-free feeds of
|
||||||
|
* volunteer-fed ADS-B and are the sources this project can point at without a
|
||||||
|
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
|
||||||
|
* first-party data, nothing to comply with.
|
||||||
|
*/
|
||||||
|
export class AdsbFlights implements FlightSource {
|
||||||
|
readonly interval = 8;
|
||||||
|
constructor(
|
||||||
|
private readonly endpoint: string,
|
||||||
|
private readonly radiusNm = 25,
|
||||||
|
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async poll(): Promise<Aircraft[]> {
|
||||||
|
const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = (await res.json()) as { ac?: RawAircraft[] };
|
||||||
|
return (body.ac ?? [])
|
||||||
|
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.hex ?? `${a.flight ?? "?"}`,
|
||||||
|
callsign: a.flight?.trim(),
|
||||||
|
lat: a.lat as number,
|
||||||
|
lng: a.lon as number,
|
||||||
|
// Feed reports feet; the scene works in metres.
|
||||||
|
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
|
||||||
|
heading: typeof a.track === "number" ? a.track : 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// A dead feed must not take the render loop with it.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAircraft {
|
||||||
|
hex?: string;
|
||||||
|
flight?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
alt_baro?: number;
|
||||||
|
track?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rendering ------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FlightLayer {
|
||||||
|
group: THREE.Group;
|
||||||
|
update(aircraft: Aircraft[]): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aircraft as small darts with a shadow-less trail. Rendered at true altitude
|
||||||
|
* through the world's vertical exaggeration, so a jet on approach sits visibly
|
||||||
|
* below one at cruise.
|
||||||
|
*/
|
||||||
|
export function createFlightLayer(world: World): FlightLayer {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "flights";
|
||||||
|
|
||||||
|
const geo = new THREE.ConeGeometry(0.1, 0.42, 5);
|
||||||
|
geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation
|
||||||
|
const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 });
|
||||||
|
const meshes = new Map<string, THREE.Mesh>();
|
||||||
|
|
||||||
|
function update(aircraft: Aircraft[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const a of aircraft) {
|
||||||
|
seen.add(a.id);
|
||||||
|
let mesh = meshes.get(a.id);
|
||||||
|
if (!mesh) {
|
||||||
|
mesh = new THREE.Mesh(geo, material);
|
||||||
|
meshes.set(a.id, mesh);
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
const [x, z] = world.project(a.lat, a.lng);
|
||||||
|
mesh.position.set(x, world.metres(a.altitude), z);
|
||||||
|
mesh.rotation.y = -(a.heading * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
for (const [id, mesh] of meshes) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
group.remove(mesh);
|
||||||
|
meshes.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update,
|
||||||
|
dispose() {
|
||||||
|
geo.dispose();
|
||||||
|
material.dispose();
|
||||||
|
meshes.clear();
|
||||||
|
group.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Pins on the map.
|
||||||
|
*
|
||||||
|
* This module is deliberately ignorant. It renders `Marker[]` and looks colours
|
||||||
|
* up by `colorKey` in a palette the caller supplies. It does not know that
|
||||||
|
* markers are usually companies, and it will never learn that "rejected" is
|
||||||
|
* red — that mapping belongs to the adapter in the consuming app, which is what
|
||||||
|
* lets one engine serve a private career map and a public sector map without
|
||||||
|
* either being a fork. See ARCHITECTURE.md §3.3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { Marker, MarkerPalette } from "./types.ts";
|
||||||
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
|
const FALLBACK_COLOR = 0x9aa4ad;
|
||||||
|
|
||||||
|
export interface MarkerLayer {
|
||||||
|
group: THREE.Group;
|
||||||
|
/** Raycast targets, for hover and click. */
|
||||||
|
pickables: THREE.Object3D[];
|
||||||
|
/** Scene-space head position per marker id, for the HTML label layer. */
|
||||||
|
anchors: Map<string, THREE.Vector3>;
|
||||||
|
setMarkers(markers: Marker[]): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How high above the roofline a pin floats, in scene units. Enough to clear
|
||||||
|
* a mid-rise; towers will still occlude, which is honest — a pin behind the
|
||||||
|
* skyline should look like it is behind the skyline.
|
||||||
|
*/
|
||||||
|
const PIN_LIFT = 1.6;
|
||||||
|
const PIN_HEIGHT = 1.1;
|
||||||
|
|
||||||
|
export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerLayer {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "markers";
|
||||||
|
const pickables: THREE.Object3D[] = [];
|
||||||
|
const anchors = new Map<string, THREE.Vector3>();
|
||||||
|
|
||||||
|
// One shared geometry per shape; colour varies per instance material, which
|
||||||
|
// is cheap enough at the scale markers live at (hundreds, not tens of
|
||||||
|
// thousands — that is what `blocks` is for).
|
||||||
|
const stemGeo = new THREE.CylinderGeometry(0.045, 0.045, PIN_HEIGHT, 6);
|
||||||
|
stemGeo.translate(0, PIN_HEIGHT / 2, 0);
|
||||||
|
const headGeo = new THREE.SphereGeometry(0.16, 12, 10);
|
||||||
|
const ghostGeo = new THREE.OctahedronGeometry(0.15);
|
||||||
|
|
||||||
|
const materials = new Map<string, THREE.Material>();
|
||||||
|
const materialFor = (key: string, located: boolean): THREE.Material => {
|
||||||
|
const id = `${key}:${located ? "solid" : "ghost"}`;
|
||||||
|
const existing = materials.get(id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const color = palette[key] ?? FALLBACK_COLOR;
|
||||||
|
const mat = located
|
||||||
|
? new THREE.MeshLambertMaterial({ color, emissive: color, emissiveIntensity: 0.28 })
|
||||||
|
: new THREE.MeshLambertMaterial({ color, transparent: true, opacity: 0.42 });
|
||||||
|
materials.set(id, mat);
|
||||||
|
return mat;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
for (const child of [...group.children]) group.remove(child);
|
||||||
|
pickables.length = 0;
|
||||||
|
anchors.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMarkers(markers: Marker[]) {
|
||||||
|
clear();
|
||||||
|
for (const m of markers) {
|
||||||
|
const located = m.located !== false;
|
||||||
|
const [x, z] = world.project(m.lat, m.lng);
|
||||||
|
const base = world.groundAt(m.lat, m.lng);
|
||||||
|
|
||||||
|
const pin = new THREE.Group();
|
||||||
|
pin.position.set(x, base + PIN_LIFT, z);
|
||||||
|
|
||||||
|
const stem = new THREE.Mesh(stemGeo, materialFor(m.colorKey, located));
|
||||||
|
pin.add(stem);
|
||||||
|
|
||||||
|
// Unplaced markers get a different silhouette as well as a different
|
||||||
|
// opacity. Colour alone is not enough of a tell, and a map whose premise
|
||||||
|
// is that it is real must not quietly invent addresses.
|
||||||
|
const head = new THREE.Mesh(
|
||||||
|
located ? headGeo : ghostGeo,
|
||||||
|
materialFor(m.colorKey, located),
|
||||||
|
);
|
||||||
|
head.position.y = PIN_HEIGHT;
|
||||||
|
head.userData.marker = m;
|
||||||
|
pin.add(head);
|
||||||
|
pickables.push(head);
|
||||||
|
|
||||||
|
anchors.set(m.id, new THREE.Vector3(x, base + PIN_LIFT + PIN_HEIGHT, z));
|
||||||
|
group.add(pin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
pickables,
|
||||||
|
anchors,
|
||||||
|
setMarkers,
|
||||||
|
dispose() {
|
||||||
|
clear();
|
||||||
|
stemGeo.dispose();
|
||||||
|
headGeo.dispose();
|
||||||
|
ghostGeo.dispose();
|
||||||
|
for (const m of materials.values()) m.dispose();
|
||||||
|
materials.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* The scene: lights, sky, layers, camera flights, render loop.
|
||||||
|
*
|
||||||
|
* `createScene` owns a canvas and a `City` and nothing else. It knows nothing
|
||||||
|
* about React, about any API, or about what the markers mean — the caller hands
|
||||||
|
* it data and gets back a small imperative handle. That boundary is what lets
|
||||||
|
* one renderer serve a private map coloured by pipeline state and a public one
|
||||||
|
* coloured by sector without either being a fork.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import { createBlocks, createLandmarks } from "./blocks.ts";
|
||||||
|
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||||
|
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||||
|
import { createBridges, createRoads } from "./structures.ts";
|
||||||
|
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||||
|
import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts";
|
||||||
|
import { World } from "./world.ts";
|
||||||
|
|
||||||
|
export interface SceneOptions {
|
||||||
|
city: City;
|
||||||
|
markerPalette?: MarkerPalette;
|
||||||
|
flights?: FlightSource;
|
||||||
|
/** Fires on hover/click of a marker head. */
|
||||||
|
onMarkerPick?: (marker: Marker | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneHandle {
|
||||||
|
world: World;
|
||||||
|
chapters: Chapter[];
|
||||||
|
flyTo(chapterId: string): void;
|
||||||
|
current(): string;
|
||||||
|
onChapterChange(fn: (id: string) => void): void;
|
||||||
|
setMarkers(markers: Marker[]): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle {
|
||||||
|
const { city } = options;
|
||||||
|
const world = new World(city);
|
||||||
|
const pal = paletteFor(world);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon);
|
||||||
|
scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
42,
|
||||||
|
canvas.clientWidth / Math.max(1, canvas.clientHeight),
|
||||||
|
0.1,
|
||||||
|
900,
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.07;
|
||||||
|
controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane
|
||||||
|
controls.minDistance = 12;
|
||||||
|
controls.maxDistance = 340;
|
||||||
|
|
||||||
|
// Late-afternoon sun from the west, which throws the hills' shadows east
|
||||||
|
// across the flats.
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff3e0, 2.1);
|
||||||
|
sun.position.set(-150, 170, 70);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
|
sun.shadow.camera.near = 10;
|
||||||
|
sun.shadow.camera.far = 520;
|
||||||
|
const extent = 170;
|
||||||
|
sun.shadow.camera.left = -extent;
|
||||||
|
sun.shadow.camera.right = extent;
|
||||||
|
sun.shadow.camera.top = extent;
|
||||||
|
sun.shadow.camera.bottom = -extent;
|
||||||
|
sun.shadow.bias = -0.0012;
|
||||||
|
scene.add(sun);
|
||||||
|
scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05));
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.32));
|
||||||
|
|
||||||
|
scene.add(createWater(world));
|
||||||
|
scene.add(createShorePlates(world));
|
||||||
|
scene.add(createTerrain(world));
|
||||||
|
scene.add(createRoads(world));
|
||||||
|
scene.add(createBlocks(world));
|
||||||
|
scene.add(createLandmarks(world));
|
||||||
|
scene.add(createBridges(world));
|
||||||
|
|
||||||
|
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||||
|
scene.add(markerLayer.group);
|
||||||
|
|
||||||
|
let flightLayer: FlightLayer | null = null;
|
||||||
|
let flightTimer = 0;
|
||||||
|
if (options.flights) {
|
||||||
|
flightLayer = createFlightLayer(world);
|
||||||
|
scene.add(flightLayer.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Camera flights -----------------------------------------------------
|
||||||
|
|
||||||
|
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
|
||||||
|
const first = city.chapters[0];
|
||||||
|
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
|
||||||
|
|
||||||
|
const desiredTarget = new THREE.Vector3();
|
||||||
|
const desiredPosition = new THREE.Vector3();
|
||||||
|
const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() };
|
||||||
|
let flying = false;
|
||||||
|
let flightT = 0;
|
||||||
|
let currentChapter = first.id;
|
||||||
|
const chapterListeners: ((id: string) => void)[] = [];
|
||||||
|
|
||||||
|
function chapterPose(ch: Chapter) {
|
||||||
|
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
||||||
|
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
|
||||||
|
return {
|
||||||
|
target: new THREE.Vector3(x, groundY, z),
|
||||||
|
pos: new THREE.Vector3(
|
||||||
|
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
||||||
|
groundY + ch.focus.height,
|
||||||
|
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flyTo(chapterId: string) {
|
||||||
|
const ch = chapterById[chapterId];
|
||||||
|
if (!ch) return;
|
||||||
|
const pose = chapterPose(ch);
|
||||||
|
flightFrom.pos.copy(camera.position);
|
||||||
|
flightFrom.target.copy(controls.target);
|
||||||
|
desiredPosition.copy(pose.pos);
|
||||||
|
desiredTarget.copy(pose.target);
|
||||||
|
flightT = 0;
|
||||||
|
flying = true;
|
||||||
|
if (currentChapter !== chapterId) {
|
||||||
|
currentChapter = chapterId;
|
||||||
|
for (const fn of chapterListeners) fn(chapterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const pose = chapterPose(first);
|
||||||
|
camera.position.copy(pose.pos);
|
||||||
|
controls.target.copy(pose.target);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Picking ------------------------------------------------------------
|
||||||
|
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
let hovered: Marker | null = null;
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0];
|
||||||
|
const marker = (hit?.object.userData.marker as Marker | undefined) ?? null;
|
||||||
|
if (marker !== hovered) {
|
||||||
|
hovered = marker;
|
||||||
|
canvas.style.cursor = marker ? "pointer" : "";
|
||||||
|
options.onMarkerPick?.(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.addEventListener("pointermove", onPointerMove);
|
||||||
|
|
||||||
|
// ---- Loop ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
if (canvas.width !== w || canvas.height !== h) {
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
if (flying) {
|
||||||
|
flightT = Math.min(1, flightT + dt * 0.65);
|
||||||
|
// easeInOutCubic — a flight that starts and lands gently
|
||||||
|
const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
|
||||||
|
camera.position.lerpVectors(flightFrom.pos, desiredPosition, e);
|
||||||
|
controls.target.lerpVectors(flightFrom.target, desiredTarget, e);
|
||||||
|
if (flightT >= 1) flying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.flights && flightLayer) {
|
||||||
|
flightTimer -= dt;
|
||||||
|
if (flightTimer <= 0) {
|
||||||
|
flightTimer = options.flights.interval;
|
||||||
|
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
|
||||||
|
const onWindowResize = () => resize();
|
||||||
|
window.addEventListener("resize", onWindowResize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
world,
|
||||||
|
chapters: city.chapters,
|
||||||
|
flyTo,
|
||||||
|
current: () => currentChapter,
|
||||||
|
onChapterChange(fn) {
|
||||||
|
chapterListeners.push(fn);
|
||||||
|
},
|
||||||
|
setMarkers(markers) {
|
||||||
|
markerLayer.setMarkers(markers);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("resize", onWindowResize);
|
||||||
|
canvas.removeEventListener("pointermove", onPointerMove);
|
||||||
|
options.flights?.dispose?.();
|
||||||
|
flightLayer?.dispose();
|
||||||
|
markerLayer.dispose();
|
||||||
|
controls.dispose();
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
const mesh = obj as THREE.Mesh;
|
||||||
|
mesh.geometry?.dispose();
|
||||||
|
const mat = mesh.material;
|
||||||
|
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
|
||||||
|
else if (mat) (mat as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
renderer.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 2;
|
||||||
|
canvas.height = 256;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
||||||
|
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
|
||||||
|
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, 2, 256);
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
tex.magFilter = THREE.LinearFilter;
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Bridges and roads — the lines that tie the landmasses together and give the
|
||||||
|
* grid something to hang off.
|
||||||
|
*
|
||||||
|
* Roads follow the terrain: each path is resampled far more finely than it is
|
||||||
|
* written in the city pack, and every sample takes its height from the ground,
|
||||||
|
* so a street climbs out of the flats instead of burrowing through the hill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { Bridge, LatLng } from "./types.ts";
|
||||||
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
|
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||||
|
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
|
||||||
|
const out: THREE.Vector3[] = [];
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const from = path[i];
|
||||||
|
const to = path[i + 1];
|
||||||
|
if (!from || !to) continue;
|
||||||
|
const [lat0, lng0] = from;
|
||||||
|
const [lat1, lng1] = to;
|
||||||
|
const steps = i === path.length - 2 ? samplesPerLeg : samplesPerLeg - 1;
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const t = s / samplesPerLeg;
|
||||||
|
const lat = lat0 + (lat1 - lat0) * t;
|
||||||
|
const lng = lng0 + (lng1 - lng0) * t;
|
||||||
|
const [x, z] = world.project(lat, lng);
|
||||||
|
out.push(new THREE.Vector3(x, world.groundAt(lat, lng) + lift, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
|
||||||
|
const curve = new THREE.CatmullRomCurve3(points);
|
||||||
|
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
|
||||||
|
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoads(world: World): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "roads";
|
||||||
|
for (const road of world.city.roads) {
|
||||||
|
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||||
|
group.add(ribbon(drapePath(world, road.path), road.width, color));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suspension bridge: deck, towers, and a main cable sagging between them.
|
||||||
|
*
|
||||||
|
* The cable is the detail worth the code. Two orange towers with a straight
|
||||||
|
* line between them read as a trestle; the catenary is what makes the shape at
|
||||||
|
* the mouth of the bay unmistakably the Golden Gate.
|
||||||
|
*/
|
||||||
|
export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = bridge.name;
|
||||||
|
|
||||||
|
const deckY = world.metres(bridge.deckHeight);
|
||||||
|
const towerY = world.metres(bridge.towerHeight);
|
||||||
|
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
|
||||||
|
|
||||||
|
const deckPoints = bridge.path.map(([lat, lng]) => {
|
||||||
|
const [x, z] = world.project(lat, lng);
|
||||||
|
return new THREE.Vector3(x, deckY, z);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deck = ribbon(deckPoints, 0.5, bridge.color);
|
||||||
|
deck.castShadow = true;
|
||||||
|
group.add(deck);
|
||||||
|
|
||||||
|
const towerTops: THREE.Vector3[] = [];
|
||||||
|
for (const [lat, lng] of bridge.towers) {
|
||||||
|
const [x, z] = world.project(lat, lng);
|
||||||
|
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
|
||||||
|
geo.translate(0, towerY / 2, 0);
|
||||||
|
const tower = new THREE.Mesh(geo, material());
|
||||||
|
tower.position.set(x, 0, z);
|
||||||
|
tower.castShadow = true;
|
||||||
|
group.add(tower);
|
||||||
|
|
||||||
|
// Cross-braces, which is most of what you see of a tower at distance.
|
||||||
|
for (const frac of [0.55, 0.82]) {
|
||||||
|
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
|
||||||
|
brace.position.set(x, towerY * frac, z);
|
||||||
|
group.add(brace);
|
||||||
|
}
|
||||||
|
towerTops.push(new THREE.Vector3(x, towerY, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]];
|
||||||
|
for (let i = 0; i < anchors.length - 1; i++) {
|
||||||
|
const a = anchors[i];
|
||||||
|
const b = anchors[i + 1];
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const isMainSpan = i > 0 && i < anchors.length - 2;
|
||||||
|
const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42);
|
||||||
|
|
||||||
|
const pts: THREE.Vector3[] = [];
|
||||||
|
for (let s = 0; s <= 18; s++) {
|
||||||
|
const t = s / 18;
|
||||||
|
const p = a.clone().lerp(b, t);
|
||||||
|
p.y -= Math.sin(t * Math.PI) * sag;
|
||||||
|
pts.push(p);
|
||||||
|
}
|
||||||
|
group.add(
|
||||||
|
new THREE.Mesh(
|
||||||
|
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
|
||||||
|
material(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vertical hangers down to the deck.
|
||||||
|
for (let s = 2; s < 18; s += 2) {
|
||||||
|
const t = s / 18;
|
||||||
|
const p = a.clone().lerp(b, t);
|
||||||
|
const top = p.y - Math.sin(t * Math.PI) * sag;
|
||||||
|
if (top <= deckY + 0.2) continue;
|
||||||
|
const h = top - deckY;
|
||||||
|
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
|
||||||
|
geo.translate(0, h / 2, 0);
|
||||||
|
const hanger = new THREE.Mesh(geo, material());
|
||||||
|
hanger.position.set(p.x, deckY, p.z);
|
||||||
|
group.add(hanger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBridges(world: World): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "bridges";
|
||||||
|
for (const b of world.city.bridges) group.add(createBridge(world, b));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* The ground: water, coastline, and relief.
|
||||||
|
*
|
||||||
|
* Drawn as two meshes rather than one, deliberately:
|
||||||
|
*
|
||||||
|
* 1. A **shore plate** per landmass — the smooth polygon from the city pack,
|
||||||
|
* triangulated flat at y=0. This is what gives the coastline a crisp edge
|
||||||
|
* at any zoom.
|
||||||
|
* 2. A **terrain grid** on top, clipped to land and displaced. Its edge is
|
||||||
|
* necessarily stair-stepped at cell size, which is why the world's coastal
|
||||||
|
* falloff ramps every height to zero near the water: the grid's blocky rim
|
||||||
|
* ends up flat, at y≈0, exactly where the smooth plate already is and in
|
||||||
|
* the same colour. The steps vanish.
|
||||||
|
*
|
||||||
|
* One mesh would have to choose between a crisp coastline and cheap relief.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { ScenePalette } from "./types.ts";
|
||||||
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
|
export const DEFAULT_PALETTE: ScenePalette = {
|
||||||
|
skyTop: 0x8fb8d8,
|
||||||
|
skyHorizon: 0xd9e6ee,
|
||||||
|
sea: 0x4a7a99,
|
||||||
|
lake: 0x527f9c,
|
||||||
|
shore: 0xa8a495,
|
||||||
|
sand: 0xc4b79b,
|
||||||
|
flats: 0x9d9c93,
|
||||||
|
upland: 0x8f9084,
|
||||||
|
park: 0x6f8a5c,
|
||||||
|
parkHigh: 0x5d7a4c,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function paletteFor(world: World): ScenePalette {
|
||||||
|
return { ...DEFAULT_PALETTE, ...(world.city.palette ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground colour is about land *use*, not altitude.
|
||||||
|
*
|
||||||
|
* An earlier version ramped green with elevation, which turned every hill into
|
||||||
|
* a meadow — and in San Francisco the hills are the most thoroughly built-on
|
||||||
|
* part of the city. Nob Hill, Pacific Heights, Bernal and Potrero are houses to
|
||||||
|
* the summit; the genuinely green high ground is Twin Peaks, Sutro, Davidson
|
||||||
|
* and McLaren, all of which are parks and get their green from being in
|
||||||
|
* `city.parks`. Everywhere else stays city-coloured however high it goes, and
|
||||||
|
* the buildings do the rest of the talking.
|
||||||
|
*/
|
||||||
|
function groundColor(
|
||||||
|
world: World,
|
||||||
|
pal: ScenePalette,
|
||||||
|
scratch: THREE.Color,
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
elevation: number,
|
||||||
|
): THREE.Color {
|
||||||
|
if (world.pointInAny(lat, lng, world.city.parks)) {
|
||||||
|
return scratch
|
||||||
|
.setHex(pal.park)
|
||||||
|
.lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
|
||||||
|
}
|
||||||
|
if (elevation < 3) {
|
||||||
|
return scratch.setHex(pal.sand).lerp(new THREE.Color(pal.flats), elevation / 3);
|
||||||
|
}
|
||||||
|
return scratch
|
||||||
|
.setHex(pal.flats)
|
||||||
|
.lerp(new THREE.Color(pal.upland), Math.min(1, (elevation - 3) / 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The smooth flat polygon under each landmass — the crisp coastline. */
|
||||||
|
export function createShorePlates(world: World): THREE.Mesh {
|
||||||
|
const pal = paletteFor(world);
|
||||||
|
const positions: number[] = [];
|
||||||
|
|
||||||
|
for (const poly of world.city.landmasses) {
|
||||||
|
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
|
||||||
|
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
|
||||||
|
geo.rotateX(Math.PI / 2); // the shape's XY plane onto the scene's XZ ground
|
||||||
|
const pos = geo.getAttribute("position");
|
||||||
|
const index = geo.getIndex();
|
||||||
|
if (index) {
|
||||||
|
for (let i = 0; i < index.count; i++) {
|
||||||
|
const k = index.getX(i);
|
||||||
|
positions.push(pos.getX(k), pos.getY(k), pos.getZ(k));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < pos.count; i++) positions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
|
||||||
|
}
|
||||||
|
geo.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
geo,
|
||||||
|
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
|
||||||
|
);
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.name = "shorePlates";
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The displaced ground. Indexed, and holding only the cells that are fully on
|
||||||
|
* land — a partial cell would poke a stair-step out over the water that the
|
||||||
|
* shore plate cannot hide.
|
||||||
|
*/
|
||||||
|
export function createTerrain(world: World): THREE.Mesh {
|
||||||
|
const pal = paletteFor(world);
|
||||||
|
const { latSteps, lngSteps, height, land } = world.lattice();
|
||||||
|
const { bounds, cellLat, cellLng } = world.city;
|
||||||
|
|
||||||
|
const positions: number[] = [];
|
||||||
|
const colors: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
const scratch = new THREE.Color();
|
||||||
|
|
||||||
|
// Lattice corner -> emitted vertex, so the four cells sharing a corner share
|
||||||
|
// its vertex. Non-indexed, SF's terrain was 724k vertices for 241k triangles.
|
||||||
|
const vertexAt = new Int32Array((latSteps + 1) * (lngSteps + 1)).fill(-1);
|
||||||
|
|
||||||
|
const vertex = (i: number, j: number): number => {
|
||||||
|
const k = i * (lngSteps + 1) + j;
|
||||||
|
const existing = vertexAt[k];
|
||||||
|
if (existing !== undefined && existing >= 0) return existing;
|
||||||
|
const lat = bounds.minLat + i * cellLat;
|
||||||
|
const lng = bounds.minLng + j * cellLng;
|
||||||
|
const e = height[k] ?? 0;
|
||||||
|
const [x, z] = world.project(lat, lng);
|
||||||
|
positions.push(x, world.metres(e) + 0.012, z);
|
||||||
|
const c = groundColor(world, pal, scratch, lat, lng, e);
|
||||||
|
colors.push(c.r, c.g, c.b);
|
||||||
|
const id = positions.length / 3 - 1;
|
||||||
|
vertexAt[k] = id;
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < latSteps; i++) {
|
||||||
|
for (let j = 0; j < lngSteps; j++) {
|
||||||
|
const a = i * (lngSteps + 1) + j;
|
||||||
|
const b = a + 1;
|
||||||
|
const c = a + (lngSteps + 1);
|
||||||
|
const d = c + 1;
|
||||||
|
if (!land[a] || !land[b] || !land[c] || !land[d]) continue;
|
||||||
|
indices.push(vertex(i, j), vertex(i + 1, j), vertex(i, j + 1));
|
||||||
|
indices.push(vertex(i, j + 1), vertex(i + 1, j), vertex(i + 1, j + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||||
|
geo.setIndex(indices);
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
geo,
|
||||||
|
new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
|
||||||
|
);
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.name = "terrain";
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ocean and bay: one plane under everything, plus any inland water. */
|
||||||
|
export function createWater(world: World): THREE.Group {
|
||||||
|
const pal = paletteFor(world);
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "water";
|
||||||
|
|
||||||
|
const { bounds } = world.city;
|
||||||
|
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
|
||||||
|
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
|
||||||
|
|
||||||
|
const sea = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
|
||||||
|
new THREE.MeshLambertMaterial({ color: pal.sea }),
|
||||||
|
);
|
||||||
|
sea.rotation.x = -Math.PI / 2;
|
||||||
|
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
|
||||||
|
sea.receiveShadow = true;
|
||||||
|
group.add(sea);
|
||||||
|
|
||||||
|
for (const poly of world.city.inlandWater) {
|
||||||
|
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
|
||||||
|
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
|
||||||
|
geo.rotateX(Math.PI / 2);
|
||||||
|
const lake = new THREE.Mesh(
|
||||||
|
geo,
|
||||||
|
new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }),
|
||||||
|
);
|
||||||
|
lake.position.y = 0.05;
|
||||||
|
group.add(lake);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* The contract between the engine and everything else.
|
||||||
|
*
|
||||||
|
* The engine renders a `City` and a list of `Marker`s. It does not know what a
|
||||||
|
* marker *is* — not that markers are companies, not that a red one means a
|
||||||
|
* rejection. That mapping lives in an adapter, outside this package, which is
|
||||||
|
* what lets one renderer serve a private career map, a public sector map, and
|
||||||
|
* whatever anyone else builds, without any of them being a fork.
|
||||||
|
*
|
||||||
|
* See ARCHITECTURE.md §3.3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `[latitude, longitude]`, always in that order. */
|
||||||
|
export type LatLng = [number, number];
|
||||||
|
|
||||||
|
// ---- Geography ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hill, as a radial peak summed into the heightfield.
|
||||||
|
*
|
||||||
|
* `elevation` is metres above sea level at the summit. `radius` is roughly
|
||||||
|
* where the hill meets the flats, in degrees of latitude.
|
||||||
|
*/
|
||||||
|
export interface Hill {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
elevation: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where buildings go, how tall, and on what street grid.
|
||||||
|
*
|
||||||
|
* `gridAngle` is the district's street bearing in radians. It is per-district
|
||||||
|
* rather than per-city because that is the fact on the ground in San Francisco:
|
||||||
|
* the grid north of Market and the grid south of it are 46° out of true, and
|
||||||
|
* reproducing that is most of what makes the city recognisable from above.
|
||||||
|
*/
|
||||||
|
export interface District {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
polygon: LatLng[];
|
||||||
|
/** Street bearing, radians clockwise from true north. */
|
||||||
|
gridAngle: number;
|
||||||
|
minHeight: number;
|
||||||
|
maxHeight: number;
|
||||||
|
/** Chance a given lot gets a tower rather than a low-rise. */
|
||||||
|
towerChance: number;
|
||||||
|
/** Facade palette key; see `blocks.ts`. */
|
||||||
|
palette: "downtown" | "residential" | "industrial";
|
||||||
|
/** Fraction of lots that get built on at all. Defaults to 0.88. */
|
||||||
|
coverage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A building placed by hand because the eye goes looking for it. */
|
||||||
|
export interface Landmark {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** Roof height in metres. */
|
||||||
|
height: number;
|
||||||
|
/** Half-width in degrees of longitude. */
|
||||||
|
footprint: number;
|
||||||
|
shape: "box" | "pyramid" | "tower" | "cylinder";
|
||||||
|
color?: number;
|
||||||
|
label?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bridge {
|
||||||
|
name: string;
|
||||||
|
/** Deck centreline. Both ends should run onto land. */
|
||||||
|
path: LatLng[];
|
||||||
|
towers: LatLng[];
|
||||||
|
towerHeight: number;
|
||||||
|
deckHeight: number;
|
||||||
|
/** Suspension sag as a fraction of tower height. */
|
||||||
|
sag: number;
|
||||||
|
color: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Road {
|
||||||
|
path: LatLng[];
|
||||||
|
width: number;
|
||||||
|
kind: "street" | "freeway";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A camera destination, and a sentence about why it is on the map. */
|
||||||
|
export interface Chapter {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
focus: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
distance: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rectangle rendered at fine terrain resolution.
|
||||||
|
*
|
||||||
|
* SF declares one covering the whole city and behaves as if this did not exist.
|
||||||
|
* LA needs six — DTLA, Santa Monica, Culver, Irvine, Pasadena, downtown
|
||||||
|
* Riverside — with the basin between them coarse, because LA/OC/Riverside is
|
||||||
|
* roughly fourteen times SF's area and a uniform 45 m lattice over it would be
|
||||||
|
* 4.6M points. See ARCHITECTURE.md §5.
|
||||||
|
*/
|
||||||
|
export interface FocusRegion {
|
||||||
|
minLat: number;
|
||||||
|
maxLat: number;
|
||||||
|
minLng: number;
|
||||||
|
maxLng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the engine needs to draw a place. Pure data — a city pack must
|
||||||
|
* contain no code, so that adding one is a contribution anybody can review.
|
||||||
|
*/
|
||||||
|
export interface City {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Map centre, and the origin of scene space. */
|
||||||
|
center: { lat: number; lng: number };
|
||||||
|
/** Scene bounds. Everything outside this is open water or off-frame. */
|
||||||
|
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Degrees to scene units, for latitude. Longitude is derived as
|
||||||
|
* `latScale * cos(center.lat)` so the place keeps its true proportions.
|
||||||
|
*/
|
||||||
|
latScale: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much taller than life the vertical is. Terrain and buildings share it,
|
||||||
|
* so they stay honest relative to each other.
|
||||||
|
*/
|
||||||
|
verticalExaggeration: number;
|
||||||
|
|
||||||
|
/** Ground-cell size inside a focus region, in degrees. */
|
||||||
|
cellLat: number;
|
||||||
|
cellLng: number;
|
||||||
|
/** Multiplier applied to cell size outside every focus region. 1 = uniform. */
|
||||||
|
coarseFactor?: number;
|
||||||
|
focusRegions?: FocusRegion[];
|
||||||
|
|
||||||
|
/** Distance from open water, in degrees, over which relief ramps to zero. */
|
||||||
|
coastFalloff: number;
|
||||||
|
|
||||||
|
landmasses: LatLng[][];
|
||||||
|
parks: LatLng[][];
|
||||||
|
inlandWater: LatLng[][];
|
||||||
|
hills: Hill[];
|
||||||
|
districts: District[];
|
||||||
|
landmarks: Landmark[];
|
||||||
|
bridges: Bridge[];
|
||||||
|
roads: Road[];
|
||||||
|
chapters: Chapter[];
|
||||||
|
|
||||||
|
/** Palette overrides; every field is optional. */
|
||||||
|
palette?: Partial<ScenePalette>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenePalette {
|
||||||
|
skyTop: number;
|
||||||
|
skyHorizon: number;
|
||||||
|
sea: number;
|
||||||
|
lake: number;
|
||||||
|
shore: number;
|
||||||
|
sand: number;
|
||||||
|
flats: number;
|
||||||
|
upland: number;
|
||||||
|
park: number;
|
||||||
|
parkHigh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Markers --------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thing on the map.
|
||||||
|
*
|
||||||
|
* `colorKey` is deliberately opaque to the engine — it indexes into a palette
|
||||||
|
* the caller supplies. The engine will not learn what "rejected" means.
|
||||||
|
*/
|
||||||
|
export interface Marker {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
label: string;
|
||||||
|
colorKey: string;
|
||||||
|
/** Optional href for the detail card. */
|
||||||
|
url?: string;
|
||||||
|
/** Optional one-liner for the detail card. */
|
||||||
|
blurb?: string;
|
||||||
|
/**
|
||||||
|
* False when the position is a placeholder rather than a real address.
|
||||||
|
* Rendered distinctly, because inventing a location on a map whose premise
|
||||||
|
* is that it is real is worse than admitting the gap.
|
||||||
|
*/
|
||||||
|
located?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller-supplied `colorKey` -> colour. */
|
||||||
|
export type MarkerPalette = Record<string, number>;
|
||||||
|
|
||||||
|
// ---- Flights --------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Aircraft {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** Barometric altitude in metres. */
|
||||||
|
altitude: number;
|
||||||
|
/** Degrees clockwise from true north. */
|
||||||
|
heading: number;
|
||||||
|
callsign?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where aircraft come from.
|
||||||
|
*
|
||||||
|
* An interface rather than a client because the obvious source — FlightRadar24
|
||||||
|
* — cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
|
||||||
|
* redistributing the data. This package ships a simulator and open community
|
||||||
|
* sources; anything commercial is an adapter in a private deployment. See
|
||||||
|
* ARCHITECTURE.md §4.
|
||||||
|
*/
|
||||||
|
export interface FlightSource {
|
||||||
|
/** Current traffic. Called on a timer; must be cheap and must not throw. */
|
||||||
|
poll(): Promise<Aircraft[]> | Aircraft[];
|
||||||
|
/** Seconds between polls. */
|
||||||
|
interval: number;
|
||||||
|
dispose?(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* A `City` turned into something the renderer can ask questions of: projection,
|
||||||
|
* polygon predicates, and the cached heightfield everything else samples.
|
||||||
|
*
|
||||||
|
* One `World` per city, built once. The engine's other modules take a `World`
|
||||||
|
* rather than importing constants, which is the whole reason a second city is
|
||||||
|
* a data file and not a fork.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { City, LatLng } from "./types.ts";
|
||||||
|
|
||||||
|
export class World {
|
||||||
|
readonly city: City;
|
||||||
|
readonly lngScale: number;
|
||||||
|
/** Metres of latitude per scene unit. */
|
||||||
|
readonly metresPerUnit: number;
|
||||||
|
/** Longitude's foreshortening at this latitude, for distance maths. */
|
||||||
|
readonly lngSquash: number;
|
||||||
|
|
||||||
|
private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
|
||||||
|
private field: Float32Array | null = null;
|
||||||
|
private fieldLand: Uint8Array | null = null;
|
||||||
|
private latSteps = 0;
|
||||||
|
private lngSteps = 0;
|
||||||
|
|
||||||
|
constructor(city: City) {
|
||||||
|
this.city = city;
|
||||||
|
this.lngScale = city.latScale * Math.cos((city.center.lat * Math.PI) / 180);
|
||||||
|
this.metresPerUnit = 111_320 / city.latScale;
|
||||||
|
this.lngSquash = Math.cos((city.center.lat * Math.PI) / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Projection ---------------------------------------------------------
|
||||||
|
|
||||||
|
projectX(lng: number): number {
|
||||||
|
return (lng - this.city.center.lng) * this.lngScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectZ(lat: number): number {
|
||||||
|
return -(lat - this.city.center.lat) * this.city.latScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[x, z]`. `x` runs east, `z` runs south, so north is `-z`. */
|
||||||
|
project(lat: number, lng: number): [number, number] {
|
||||||
|
return [this.projectX(lng), this.projectZ(lat)];
|
||||||
|
}
|
||||||
|
|
||||||
|
unproject(x: number, z: number): [number, number] {
|
||||||
|
return [this.city.center.lat - z / this.city.latScale, this.city.center.lng + x / this.lngScale];
|
||||||
|
}
|
||||||
|
|
||||||
|
projectPolygon(coords: LatLng[]): [number, number][] {
|
||||||
|
return coords.map(([lat, lng]) => this.project(lat, lng));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres above sea level to scene units, exaggeration applied. */
|
||||||
|
metres(m: number): number {
|
||||||
|
return (m / this.metresPerUnit) * this.city.verticalExaggeration;
|
||||||
|
}
|
||||||
|
|
||||||
|
unitsToMetres(u: number): number {
|
||||||
|
return (u * this.metresPerUnit) / this.city.verticalExaggeration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Polygon predicates -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounding box, cached per polygon array.
|
||||||
|
*
|
||||||
|
* The heightfield asks `isLand` at hundreds of thousands of lattice points,
|
||||||
|
* and without this each one walked every edge of every landmass — the SF
|
||||||
|
* outline alone is fifty. Four comparisons first took the SF terrain build
|
||||||
|
* from 2.3 s to 1.0 s.
|
||||||
|
*/
|
||||||
|
private bbox(poly: LatLng[]): Float64Array {
|
||||||
|
const hit = this.bboxes.get(poly);
|
||||||
|
if (hit) return hit;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
let minLng = Infinity;
|
||||||
|
let maxLng = -Infinity;
|
||||||
|
for (const [lat, lng] of poly) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
}
|
||||||
|
const box = Float64Array.of(minLat, maxLat, minLng, maxLng);
|
||||||
|
this.bboxes.set(poly, box);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointInPolygon(lat: number, lng: number, poly: LatLng[]): boolean {
|
||||||
|
const box = this.bbox(poly);
|
||||||
|
if (lat < box[0]! || lat > box[1]! || lng < box[2]! || lng > box[3]!) return false;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const a = poly[i];
|
||||||
|
const b = poly[j];
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const [latI, lngI] = a;
|
||||||
|
const [latJ, lngJ] = b;
|
||||||
|
if (latI > lat !== latJ > lat) {
|
||||||
|
const x = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
|
||||||
|
if (lng < x) inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointInAny(lat: number, lng: number, polys: LatLng[][]): boolean {
|
||||||
|
for (const p of polys) if (this.pointInPolygon(lat, lng, p)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortest distance to a polygon's boundary, in degrees. */
|
||||||
|
private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number {
|
||||||
|
let best = Infinity;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const from = poly[j];
|
||||||
|
const to = poly[i];
|
||||||
|
if (!from || !to) continue;
|
||||||
|
const [aLat, aLng] = from;
|
||||||
|
const [bLat, bLng] = to;
|
||||||
|
const dLat = bLat - aLat;
|
||||||
|
const dLng = bLng - aLng;
|
||||||
|
const lenSq = dLat * dLat + dLng * dLng;
|
||||||
|
let t = lenSq === 0 ? 0 : ((lat - aLat) * dLat + (lng - aLng) * dLng) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const d = Math.hypot(lat - (aLat + t * dLat), lng - (aLng + t * dLng));
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLand(lat: number, lng: number): boolean {
|
||||||
|
if (this.pointInAny(lat, lng, this.city.inlandWater)) return false;
|
||||||
|
return this.pointInAny(lat, lng, this.city.landmasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Relief -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground elevation in metres, from the city's hills.
|
||||||
|
*
|
||||||
|
* Overlapping hills combine as `tallest + 35% of the rest`. A straight sum
|
||||||
|
* puts Twin Peaks and Mount Sutro — 1.2 km apart, 281 m and 275 m — at a
|
||||||
|
* fictional 500 m; a plain max leaves a suspicious notch between them. The
|
||||||
|
* blend keeps summits near their real heights and still builds the ridge
|
||||||
|
* that actually connects them.
|
||||||
|
*/
|
||||||
|
elevationAt(lat: number, lng: number): number {
|
||||||
|
let peak = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const hill of this.city.hills) {
|
||||||
|
const dLat = lat - hill.lat;
|
||||||
|
const dLng = (lng - hill.lng) * this.lngSquash;
|
||||||
|
const d = Math.hypot(dLat, dLng) / hill.radius;
|
||||||
|
if (d >= 1) continue;
|
||||||
|
const f = (1 - d * d) ** 2; // 1 at the summit, 0 with zero gradient at the edge
|
||||||
|
const h = hill.elevation * f;
|
||||||
|
total += h;
|
||||||
|
if (h > peak) peak = h;
|
||||||
|
}
|
||||||
|
if (peak === 0) return 0;
|
||||||
|
const h = peak + (total - peak) * 0.35;
|
||||||
|
// Roughen. Bare radial bumps read as golf balls; real hills have spurs and
|
||||||
|
// gullies. Multiplied rather than added so the flats stay flat instead of
|
||||||
|
// growing dunes.
|
||||||
|
const rough = 0.82 + 0.36 * fbm(lat / 0.0042, lng / 0.0053);
|
||||||
|
return h * rough * this.coastalFalloff(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
|
||||||
|
private coastalFalloff(lat: number, lng: number): number {
|
||||||
|
let d = Infinity;
|
||||||
|
for (const poly of this.city.landmasses) {
|
||||||
|
if (this.pointInPolygon(lat, lng, poly)) {
|
||||||
|
d = Math.min(d, this.distanceToEdge(lat, lng, poly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(d)) return 0;
|
||||||
|
const t = Math.min(1, d / this.city.coastFalloff);
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cached heightfield -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `elevationAt` is not cheap — every hill, four octaves of noise, and a
|
||||||
|
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
|
||||||
|
* thousands of lattice points, and then every building, road sample and
|
||||||
|
* camera target wants it again. Computed once, read back bilinearly.
|
||||||
|
*/
|
||||||
|
private buildField(): { height: Float32Array; land: Uint8Array } {
|
||||||
|
if (this.field && this.fieldLand) return { height: this.field, land: this.fieldLand };
|
||||||
|
const { bounds, cellLat, cellLng } = this.city;
|
||||||
|
this.latSteps = Math.ceil((bounds.maxLat - bounds.minLat) / cellLat);
|
||||||
|
this.lngSteps = Math.ceil((bounds.maxLng - bounds.minLng) / cellLng);
|
||||||
|
const w = this.lngSteps + 1;
|
||||||
|
const height = new Float32Array((this.latSteps + 1) * w);
|
||||||
|
const land = new Uint8Array((this.latSteps + 1) * w);
|
||||||
|
for (let i = 0; i <= this.latSteps; i++) {
|
||||||
|
const lat = bounds.minLat + i * cellLat;
|
||||||
|
for (let j = 0; j <= this.lngSteps; j++) {
|
||||||
|
const lng = bounds.minLng + j * cellLng;
|
||||||
|
const k = i * w + j;
|
||||||
|
const onLand = this.isLand(lat, lng);
|
||||||
|
land[k] = onLand ? 1 : 0;
|
||||||
|
height[k] = onLand ? this.elevationAt(lat, lng) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.field = height;
|
||||||
|
this.fieldLand = land;
|
||||||
|
return { height, land };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lattice dimensions, for the terrain mesh builder. */
|
||||||
|
lattice(): { latSteps: number; lngSteps: number; height: Float32Array; land: Uint8Array } {
|
||||||
|
const { height, land } = this.buildField();
|
||||||
|
return { latSteps: this.latSteps, lngSteps: this.lngSteps, height, land };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elevation in metres, bilinearly sampled from the cached lattice. */
|
||||||
|
elevationSampled(lat: number, lng: number): number {
|
||||||
|
const { height } = this.buildField();
|
||||||
|
const { bounds, cellLat, cellLng } = this.city;
|
||||||
|
const w = this.lngSteps + 1;
|
||||||
|
const fi = (lat - bounds.minLat) / cellLat;
|
||||||
|
const fj = (lng - bounds.minLng) / cellLng;
|
||||||
|
if (fi < 0 || fj < 0 || fi >= this.latSteps || fj >= this.lngSteps) return 0;
|
||||||
|
const i = Math.floor(fi);
|
||||||
|
const j = Math.floor(fj);
|
||||||
|
const ti = fi - i;
|
||||||
|
const tj = fj - j;
|
||||||
|
const a = height[i * w + j] ?? 0;
|
||||||
|
const b = height[i * w + j + 1] ?? 0;
|
||||||
|
const c = height[(i + 1) * w + j] ?? 0;
|
||||||
|
const d = height[(i + 1) * w + j + 1] ?? 0;
|
||||||
|
return (a * (1 - tj) + b * tj) * (1 - ti) + (c * (1 - tj) + d * tj) * ti;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scene-space ground height. What everything stands on. */
|
||||||
|
groundAt(lat: number, lng: number): number {
|
||||||
|
return this.metres(this.elevationSampled(lat, lng));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Deterministic noise and randomness -----------------------------------
|
||||||
|
|
||||||
|
function hash2(x: number, y: number): number {
|
||||||
|
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
|
||||||
|
return s - Math.floor(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueNoise(x: number, y: number): number {
|
||||||
|
const xi = Math.floor(x);
|
||||||
|
const yi = Math.floor(y);
|
||||||
|
const xf = x - xi;
|
||||||
|
const yf = y - yi;
|
||||||
|
const u = xf * xf * (3 - 2 * xf);
|
||||||
|
const v = yf * yf * (3 - 2 * yf);
|
||||||
|
const a = hash2(xi, yi);
|
||||||
|
const b = hash2(xi + 1, yi);
|
||||||
|
const c = hash2(xi, yi + 1);
|
||||||
|
const d = hash2(xi + 1, yi + 1);
|
||||||
|
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four octaves is enough texture at 45 m cells; more is invisible. */
|
||||||
|
export function fbm(x: number, y: number): number {
|
||||||
|
let f = 0;
|
||||||
|
let amp = 0.5;
|
||||||
|
let freq = 1;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
f += amp * valueNoise(x * freq, y * freq);
|
||||||
|
freq *= 2.1;
|
||||||
|
amp *= 0.5;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic PRNG (mulberry32). Every scatter in the scene draws from one of
|
||||||
|
* these so a reload produces the same city. A map that reshuffles its own
|
||||||
|
* buildings between visits is a lava lamp, not a map.
|
||||||
|
*/
|
||||||
|
export function seededRandom(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = s;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* The standalone demo: San Francisco, simulated traffic, chapter legend.
|
||||||
|
*
|
||||||
|
* Deliberately ships **no company data**. Markers are demonstrated using the
|
||||||
|
* city's own landmarks — buildings, not businesses — because company positions
|
||||||
|
* are geocoded (ODbL) and company pipeline status is private, and neither
|
||||||
|
* belongs in this repo. Real markers arrive at runtime from an adapter; see
|
||||||
|
* `src/adapters/` and ARCHITECTURE.md §3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createScene } from "./engine/scene.ts";
|
||||||
|
import { SimulatedFlights, type SimRoute } from "./engine/flights.ts";
|
||||||
|
import type { Marker, MarkerPalette } from "./engine/types.ts";
|
||||||
|
import SAN_FRANCISCO from "./cities/sf.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bay Area traffic, roughly where it actually is: SFO sits south of frame and
|
||||||
|
* its arrivals run down the peninsula, Oakland is east across the bay, and the
|
||||||
|
* coastal departures turn out over the Pacific.
|
||||||
|
*/
|
||||||
|
const ROUTES: SimRoute[] = [
|
||||||
|
{ callsign: "UAL 1", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
|
||||||
|
{ callsign: "ASA 22", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
|
||||||
|
{ callsign: "SWA 118", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
|
||||||
|
{ callsign: "DAL 407", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
|
||||||
|
{ callsign: "UAL 88", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
|
||||||
|
{ callsign: "N512SP", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
|
||||||
|
{ callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARKER_PALETTE: MarkerPalette = {
|
||||||
|
landmark: 0xf2b134,
|
||||||
|
neutral: 0x9aa4ad,
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
||||||
|
if (!canvas) throw new Error("#scene canvas missing");
|
||||||
|
|
||||||
|
const scene = createScene(canvas, {
|
||||||
|
city: SAN_FRANCISCO,
|
||||||
|
markerPalette: MARKER_PALETTE,
|
||||||
|
flights: new SimulatedFlights(ROUTES),
|
||||||
|
onMarkerPick: (marker) => {
|
||||||
|
const card = document.querySelector<HTMLElement>("#detail");
|
||||||
|
if (!card) return;
|
||||||
|
if (!marker) {
|
||||||
|
card.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.hidden = false;
|
||||||
|
card.textContent = marker.label;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Demo markers: the city's own named buildings.
|
||||||
|
const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks
|
||||||
|
.filter((l) => l.label)
|
||||||
|
.map((l) => ({
|
||||||
|
id: l.name,
|
||||||
|
lat: l.lat,
|
||||||
|
lng: l.lng,
|
||||||
|
label: l.name,
|
||||||
|
colorKey: "landmark",
|
||||||
|
located: true,
|
||||||
|
}));
|
||||||
|
scene.setMarkers(demoMarkers);
|
||||||
|
|
||||||
|
// ---- Chapter legend -------------------------------------------------------
|
||||||
|
|
||||||
|
const nav = document.querySelector<HTMLElement>("#chapters");
|
||||||
|
const blurb = document.querySelector<HTMLElement>("#blurb");
|
||||||
|
|
||||||
|
function renderLegend(activeId: string) {
|
||||||
|
if (!nav) return;
|
||||||
|
nav.replaceChildren();
|
||||||
|
for (const chapter of scene.chapters) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = chapter.id === activeId ? "chapter active" : "chapter";
|
||||||
|
button.innerHTML = `<span class="num">${chapter.number}</span><span>${chapter.shortLabel}</span>`;
|
||||||
|
button.addEventListener("click", () => scene.flyTo(chapter.id));
|
||||||
|
nav.append(button);
|
||||||
|
}
|
||||||
|
const active = scene.chapters.find((c) => c.id === activeId);
|
||||||
|
if (blurb && active) blurb.textContent = active.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLegend(scene.current());
|
||||||
|
scene.onChapterChange(renderLegend);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Mounted under lumbridgecorp.com/live/ in production; the trailing
|
||||||
|
// slash matters, since every asset URL is resolved against it.
|
||||||
|
base: process.env.LSE_BASE ?? "/",
|
||||||
|
build: { outDir: "dist", target: "es2022" },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user