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