From 67f8df8d53986ca1ec63581f700bc06419227daf Mon Sep 17 00:00:00 2001 From: Karti Tripathi Date: Tue, 4 Aug 2026 21:49:08 -0700 Subject: [PATCH] =?UTF-8?q?Lumbridge=20Simulate=20Engine=20=E2=80=94=20the?= =?UTF-8?q?=20city,=20and=20the=20licence=20it=20can=20actually=20ship=20u?= =?UTF-8?q?nder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 8 + ARCHITECTURE.md | 274 +++++++++ LICENSE | 201 ++++++ NOTICE | 72 +++ README.md | 87 +++ index.html | 48 ++ package-lock.json | 1255 ++++++++++++++++++++++++++++++++++++++ package.json | 22 + src/cities/sf.ts | 1066 ++++++++++++++++++++++++++++++++ src/engine/blocks.ts | 208 +++++++ src/engine/flights.ts | 177 ++++++ src/engine/markers.ts | 113 ++++ src/engine/scene.ts | 267 ++++++++ src/engine/structures.ts | 141 +++++ src/engine/terrain.ts | 200 ++++++ src/engine/types.ts | 239 ++++++++ src/engine/world.ts | 297 +++++++++ src/main.ts | 88 +++ tsconfig.json | 18 + vite.config.ts | 8 + 20 files changed, 4789 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cities/sf.ts create mode 100644 src/engine/blocks.ts create mode 100644 src/engine/flights.ts create mode 100644 src/engine/markers.ts create mode 100644 src/engine/scene.ts create mode 100644 src/engine/structures.ts create mode 100644 src/engine/terrain.ts create mode 100644 src/engine/types.ts create mode 100644 src/engine/world.ts create mode 100644 src/main.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..274275c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fba255c --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..818ca91 --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f39bc47 --- /dev/null +++ b/README.md @@ -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). + +![San Francisco](docs/sf.jpg) + +--- + +## 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/.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). diff --git a/index.html b/index.html new file mode 100644 index 0000000..0dd7d71 --- /dev/null +++ b/index.html @@ -0,0 +1,48 @@ + + + + + + Lumbridge Simulate — San Francisco + + + + +
+
+

San Francisco

+

Lumbridge Simulate Engine

+
+ +

+
+ +

drag to orbit · scroll to zoom

+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..398b2e5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1255 @@ +{ + "name": "@lumbridge/simulate", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lumbridge/simulate", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "three": "^0.182.0" + }, + "devDependencies": { + "@types/three": "^0.182.0", + "typescript": "^5.7.2", + "vite": "^7.3.6" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bc9d9d6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cities/sf.ts b/src/cities/sf.ts new file mode 100644 index 0000000..be27d01 --- /dev/null +++ b/src/cities/sf.ts @@ -0,0 +1,1066 @@ +/** + * San Francisco — a LSE city pack. + * + * Pure data. Coastlines, hills, districts, landmarks and camera chapters, + * traced by hand to be recognizable rather than survey-accurate: the + * peninsula's tip, the Golden Gate, the Bayshore's industrial hook down to + * Candlestick, Ocean Beach's long straight edge. + * + * **Traced from scratch, on purpose.** Importing this geometry from + * OpenStreetMap would have been faster and would have made the whole pack + * ODbL — a share-alike obligation that cannot live inside an Apache-2.0 repo + * without infecting it. Original expression is the only kind of map data this + * project can actually give away. See ARCHITECTURE.md §3.2. + * + * The relief is the reason this city was first: San Francisco is a place whose + * plan you cannot read without its section. Twin Peaks and Mount Davidson are + * the wall the Sunset hides behind, Potrero and Bernal are why the Mission + * feels like a floor, and the AI cluster in Hayes Valley sits in the bowl below + * all of it. A flat San Francisco is a street grid on a napkin. + */ + +import type { Bridge, City, District, Hill, Landmark, LatLng } from "../engine/types.ts"; + +/** + * San Francisco geography for the atlas, all coordinates `[lat, lng]`. + * + * Traced to be recognizable rather than survey-accurate: the peninsula's tip, + * the Golden Gate, the Bayshore's industrial hook down to Candlestick, Ocean + * Beach's long straight edge. Building scatter is clipped to these polygons so + * nothing ever lands in the water. + * + * Written from scratch for SF. The NYC atlas this project takes its interaction + * model from has an equivalent file, and not one coordinate of it transfers — + * different city, different coastline, and above all different *relief*. NYC + * renders on flat ground; the whole point of an SF map is that SF is not flat, + * so this file also carries HILLS, which has no NYC counterpart. + */ + + +// ---- Coastlines ----------------------------------------------------------- + +/** + * The city proper, traced clockwise from Fort Point (the Golden Gate Bridge's + * south anchorage) — north shore east to the Ferry Building, down the Bayshore + * to Candlestick, west along the San Mateo county line, then north up Ocean + * Beach and around Lands End. + */ +export const SAN_FRANCISCO: LatLng[] = [ + // North shore, west -> east + [37.8106, -122.4775], // Fort Point, under the bridge + [37.8085, -122.473], + [37.8062, -122.468], // Crissy Field west + [37.8055, -122.462], // Crissy Field + [37.807, -122.455], // Marina west + [37.8075, -122.448], // Marina Green + [37.8068, -122.44], + [37.8065, -122.43], // Fort Mason + [37.808, -122.425], // Aquatic Park + [37.8085, -122.42], // Hyde Street Pier + [37.8095, -122.415], // Fisherman's Wharf + [37.81, -122.41], // Pier 39 + [37.8085, -122.405], // Pier 27 + [37.8035, -122.399], // Piers 15-19 + [37.7975, -122.393], // Ferry Building + [37.793, -122.388], // Rincon / Bay Bridge landing + [37.786, -122.3865], // South Beach + [37.78, -122.387], // Oracle Park / China Basin + [37.776, -122.3855], // Mission Bay + [37.77, -122.383], + [37.763, -122.382], // Pier 70 / Dogpatch + [37.756, -122.38], // Potrero Point + [37.748, -122.38], // Islais Creek + [37.74, -122.376], // Bayview + [37.733, -122.37], // Hunters Point, the shipyard hook + [37.728, -122.366], + [37.723, -122.377], // India Basin south + [37.718, -122.383], // Candlestick Point + [37.711, -122.39], + [37.708, -122.398], // county line, southeast corner + // San Mateo county line, east -> west + [37.708, -122.41], + [37.7085, -122.43], + [37.709, -122.455], + [37.71, -122.48], + [37.711, -122.498], // Lake Merced + [37.713, -122.506], // Fort Funston + // Pacific shore (Ocean Beach), south -> north + [37.72, -122.507], + [37.73, -122.509], + [37.74, -122.51], + [37.75, -122.5105], + [37.76, -122.511], + [37.77, -122.512], + [37.778, -122.513], // Cliff House / Sutro Baths + [37.782, -122.512], + [37.786, -122.506], // Lands End + [37.788, -122.499], + [37.7885, -122.491], // China Beach + [37.791, -122.487], + [37.7935, -122.4835], // Baker Beach + [37.799, -122.4805], // Presidio bluff + [37.805, -122.479], +]; + +/** + * The Marin Headlands across the Golden Gate. Present for context and for the + * bridge to land on — the north side of the Gate is most of what you see from + * any camera pointed at the bridge, and without it the span ends in fog. + */ +export const MARIN: LatLng[] = [ + [37.829, -122.532], // Point Bonita + [37.8255, -122.527], + [37.824, -122.517], // Rodeo Cove + [37.8265, -122.505], + [37.829, -122.493], // Kirby Cove + [37.832, -122.483], // Golden Gate Bridge, north anchorage + [37.8335, -122.476], // Fort Baker + [37.839, -122.479], // Horseshoe Bay + [37.846, -122.483], + [37.857, -122.4855], // Sausalito + [37.868, -122.49], + [37.874, -122.4945], + // inland edge, kept close: Marin is here to carry the bridge and close the + // Gate, not to compete with the city for the eye. + [37.8765, -122.5125], + [37.8685, -122.5255], + [37.855, -122.5335], + [37.8425, -122.5395], + [37.8335, -122.5405], +]; + +/** + * The East Bay shore — Oakland, Emeryville, Berkeley. Only the water-facing + * edge is traced; the interior runs off the frame. + */ +export const EAST_BAY: LatLng[] = [ + [37.73, -122.32], + [37.745, -122.31], + [37.76, -122.3], + [37.775, -122.29], // Alameda + [37.79, -122.28], // Port of Oakland + [37.805, -122.29], // Jack London Square + [37.82, -122.3], + [37.835, -122.305], // Emeryville + [37.85, -122.31], + [37.87, -122.315], // Berkeley Marina + // frame edges, inland + [37.88, -122.24], + [37.72, -122.22], +]; + +export const ALCATRAZ: LatLng[] = [ + [37.8285, -122.4245], + [37.8285, -122.4215], + [37.8262, -122.4205], + [37.8248, -122.4222], + [37.8255, -122.4248], +]; + +export const YERBA_BUENA: LatLng[] = [ + [37.8125, -122.3675], + [37.8125, -122.3615], + [37.8085, -122.3595], + [37.8055, -122.3635], + [37.8075, -122.3685], +]; + +/** Treasure Island — the flat man-made rectangle north of Yerba Buena. */ +export const TREASURE_ISLAND: LatLng[] = [ + [37.829, -122.3735], + [37.829, -122.3655], + [37.8175, -122.3635], + [37.8145, -122.3695], + [37.818, -122.3745], +]; + +export const ANGEL_ISLAND: LatLng[] = [ + [37.8695, -122.4345], + [37.868, -122.4245], + [37.8625, -122.4205], + [37.8575, -122.4265], + [37.859, -122.4365], + [37.8645, -122.4405], +]; + +export const ISLANDS = [ALCATRAZ, YERBA_BUENA, TREASURE_ISLAND, ANGEL_ISLAND]; + +/** Everything the buildings may stand on. */ +export const LANDMASSES = [SAN_FRANCISCO, MARIN, EAST_BAY, ...ISLANDS]; + +// ---- Parks and open space ------------------------------------------------- + +/** + * Golden Gate Park: three miles long, half a mile deep, and famously a + * rectangle — the one piece of SF geography you can trace with four points. + */ +export const GOLDEN_GATE_PARK: LatLng[] = [ + [37.7745, -122.5105], + [37.7735, -122.454], + [37.7655, -122.4535], + [37.7665, -122.51], +]; + +export const PRESIDIO: LatLng[] = [ + [37.8106, -122.4775], + [37.801, -122.479], + [37.791, -122.483], // Baker Beach + [37.7885, -122.474], + [37.7875, -122.46], // Lombard gate + [37.799, -122.453], + [37.8055, -122.462], + [37.8085, -122.473], +]; + +export const MOUNT_SUTRO: LatLng[] = [ + [37.7625, -122.4625], + [37.7615, -122.4535], + [37.7535, -122.4545], + [37.7525, -122.462], +]; + +export const DOLORES_PARK: LatLng[] = [ + [37.7615, -122.4285], + [37.761, -122.4245], + [37.7585, -122.4248], + [37.759, -122.4288], +]; + +export const BUENA_VISTA: LatLng[] = [ + [37.7705, -122.4455], + [37.7695, -122.4395], + [37.7655, -122.4405], + [37.7665, -122.446], +]; + +export const MCLAREN_PARK: LatLng[] = [ + [37.7245, -122.4235], + [37.7225, -122.4095], + [37.7145, -122.412], + [37.7165, -122.4245], +]; + +export const LINCOLN_PARK: LatLng[] = [ + [37.7855, -122.5055], + [37.7845, -122.4955], + [37.7795, -122.4965], + [37.7805, -122.506], +]; + +/** + * Twin Peaks and Mount Davidson are parks in fact as well as on this map — + * the two highest points in the city are public open space, which is why the + * ridge reads green from every angle while Nob Hill and Bernal, just as steep, + * are solid houses to the summit. + */ +export const TWIN_PEAKS: LatLng[] = [ + [37.7605, -122.4535], + [37.7595, -122.4425], + [37.7495, -122.4415], + [37.7485, -122.4525], +]; + +export const MOUNT_DAVIDSON: LatLng[] = [ + [37.7435, -122.4605], + [37.7425, -122.4495], + [37.7335, -122.4505], + [37.7345, -122.4605], +]; + +export const GLEN_CANYON: LatLng[] = [ + [37.7455, -122.4475], + [37.7448, -122.4395], + [37.7375, -122.4405], + [37.7385, -122.4485], +]; + +export const PARKS = [ + GOLDEN_GATE_PARK, + PRESIDIO, + MOUNT_SUTRO, + TWIN_PEAKS, + MOUNT_DAVIDSON, + GLEN_CANYON, + DOLORES_PARK, + BUENA_VISTA, + MCLAREN_PARK, + LINCOLN_PARK, +]; + +/** Lake Merced, the only real inland water. */ +export const LAKE_MERCED: LatLng[] = [ + [37.7285, -122.4965], + [37.7275, -122.4885], + [37.7215, -122.4875], + [37.7185, -122.4935], + [37.7235, -122.4985], +]; + +/** Stow Lake, inside Golden Gate Park — small, but it reads at chapter zoom. */ +export const STOW_LAKE: LatLng[] = [ + [37.7712, -122.4785], + [37.7708, -122.4745], + [37.7688, -122.4742], + [37.7685, -122.4785], +]; + +export const INLAND_WATER = [LAKE_MERCED, STOW_LAKE]; + +// ---- Relief --------------------------------------------------------------- + +/** + * SF's hills, as radial peaks summed into a heightfield by terrain.ts. + * + * This is the file's reason for existing. San Francisco is a city whose plan + * you cannot read without its section: Twin Peaks and Mount Davidson are the + * wall the Sunset hides behind, Potrero and Bernal are why the Mission feels + * like a floor, and the AI cluster in Hayes Valley sits in the bowl below all + * of it. A flat SF is just a street grid on a napkin. + * + * `elevation` is metres above sea level at the summit; `radius` is roughly + * where the hill meets the flats, in degrees of latitude. Both are eyeballed + * to look right in section, not surveyed. + */ + +export const HILLS: Hill[] = [ + // The central ridge — the city's spine + { name: "Twin Peaks", lat: 37.7544, lng: -122.4477, elevation: 281, radius: 0.016 }, + { name: "Mount Davidson", lat: 37.7382, lng: -122.4544, elevation: 283, radius: 0.015 }, + { name: "Mount Sutro", lat: 37.7576, lng: -122.4585, elevation: 275, radius: 0.012 }, + { name: "Golden Gate Heights", lat: 37.7527, lng: -122.4737, elevation: 200, radius: 0.014 }, + { name: "Forest Hill", lat: 37.7466, lng: -122.4676, elevation: 195, radius: 0.012 }, + { name: "Diamond Heights", lat: 37.7414, lng: -122.4404, elevation: 190, radius: 0.011 }, + + // Downtown's hills — small, steep, and the ones people mean by "SF hills" + { name: "Nob Hill", lat: 37.793, lng: -122.4161, elevation: 103, radius: 0.008 }, + { name: "Russian Hill", lat: 37.801, lng: -122.418, elevation: 90, radius: 0.007 }, + { name: "Telegraph Hill", lat: 37.8024, lng: -122.4058, elevation: 84, radius: 0.005 }, + { name: "Rincon Hill", lat: 37.786, lng: -122.39, elevation: 30, radius: 0.005 }, + { name: "Pacific Heights", lat: 37.7925, lng: -122.438, elevation: 113, radius: 0.011 }, + { name: "Lone Mountain", lat: 37.779, lng: -122.452, elevation: 116, radius: 0.008 }, + + // The southern and eastern hills + { name: "Bernal Heights", lat: 37.743, lng: -122.416, elevation: 130, radius: 0.009 }, + { name: "Potrero Hill", lat: 37.758, lng: -122.4, elevation: 91, radius: 0.009 }, + { name: "Corona Heights", lat: 37.7655, lng: -122.438, elevation: 156, radius: 0.007 }, + { name: "Buena Vista", lat: 37.7685, lng: -122.442, elevation: 175, radius: 0.008 }, + { name: "Bayview Hill", lat: 37.7205, lng: -122.394, elevation: 130, radius: 0.008 }, + { name: "McLaren Ridge", lat: 37.7195, lng: -122.418, elevation: 170, radius: 0.011 }, + { name: "Hunters Point Hill", lat: 37.729, lng: -122.38, elevation: 71, radius: 0.007 }, + + // The Presidio and the western headlands + { name: "Presidio Ridge", lat: 37.7985, lng: -122.4665, elevation: 110, radius: 0.01 }, + { name: "Lands End", lat: 37.784, lng: -122.5045, elevation: 90, radius: 0.008 }, + + // Marin — the wall on the far side of the Gate, taller than anything in SF + { name: "Marin Headlands", lat: 37.8275, lng: -122.5065, elevation: 280, radius: 0.016 }, + { name: "Wolfback Ridge", lat: 37.8425, lng: -122.5, elevation: 240, radius: 0.014 }, + { name: "Slacker Ridge", lat: 37.8345, lng: -122.4915, elevation: 210, radius: 0.009 }, + { name: "Fort Baker Rise", lat: 37.8375, lng: -122.4785, elevation: 120, radius: 0.007 }, + { name: "Sausalito Ridge", lat: 37.8595, lng: -122.5015, elevation: 230, radius: 0.013 }, + { name: "Angel Island", lat: 37.8625, lng: -122.4305, elevation: 240, radius: 0.007 }, + + // East Bay — a low hint of the hills behind Oakland, mostly off-frame + { name: "Oakland Hills", lat: 37.83, lng: -122.235, elevation: 330, radius: 0.03 }, +]; + +// ---- Streets -------------------------------------------------------------- + +/** + * Market Street, the diagonal that breaks SF's two grids against each other. + * Everything downtown is oriented to it, so it is the first line to draw and + * the one that makes the rest of the street plan legible. + */ +export const MARKET_STREET: LatLng[] = [ + [37.7946, -122.3949], // Ferry Building + [37.7897, -122.3999], + [37.7855, -122.4055], + [37.781, -122.411], + [37.7765, -122.417], + [37.772, -122.423], + [37.768, -122.429], + [37.762, -122.435], // Castro +]; + +export const THE_EMBARCADERO: LatLng[] = [ + [37.8085, -122.4165], + [37.8065, -122.4095], + [37.803, -122.4025], + [37.7975, -122.3945], + [37.7925, -122.389], + [37.7855, -122.3875], +]; + +export const VAN_NESS: LatLng[] = [ + [37.8055, -122.4225], + [37.7965, -122.4215], + [37.7855, -122.4205], + [37.7745, -122.4195], +]; + +export const GEARY: LatLng[] = [ + [37.7865, -122.4035], + [37.7855, -122.42], + [37.7845, -122.4455], + [37.7805, -122.4735], + [37.7795, -122.4955], +]; + +export const MISSION_STREET: LatLng[] = [ + [37.7905, -122.3965], + [37.7845, -122.4045], + [37.7785, -122.4125], + [37.7715, -122.4195], + [37.7635, -122.4195], + [37.7525, -122.4185], + [37.7405, -122.4235], +]; + +export const NINETEENTH_AVE: LatLng[] = [ + [37.8045, -122.4715], + [37.7845, -122.4745], + [37.7645, -122.4755], + [37.7395, -122.4755], + [37.7175, -122.4735], +]; + +export const THIRD_STREET: LatLng[] = [ + [37.7885, -122.3985], + [37.7795, -122.3915], + [37.7695, -122.3895], + [37.7565, -122.3885], + [37.7395, -122.3895], + [37.7255, -122.3925], +]; + +export const HIGHWAY_101: LatLng[] = [ + [37.8065, -122.4275], // Lombard, feeding the bridge + [37.7985, -122.4215], + [37.7885, -122.4065], + [37.7755, -122.4055], + [37.7565, -122.4055], + [37.7365, -122.4055], + [37.7135, -122.3985], +]; + +export const INTERSTATE_280: LatLng[] = [ + [37.7775, -122.3925], + [37.7665, -122.3975], + [37.7555, -122.4055], + [37.7425, -122.4145], + [37.7275, -122.4415], + [37.7105, -122.4595], +]; + +export const ROADS: City["roads"] = [ + { path: MARKET_STREET, width: 0.34, kind: "street" }, + { path: THE_EMBARCADERO, width: 0.28, kind: "street" }, + { path: VAN_NESS, width: 0.26, kind: "street" }, + { path: GEARY, width: 0.24, kind: "street" }, + { path: MISSION_STREET, width: 0.22, kind: "street" }, + { path: NINETEENTH_AVE, width: 0.24, kind: "street" }, + { path: THIRD_STREET, width: 0.22, kind: "street" }, + { path: HIGHWAY_101, width: 0.3, kind: "freeway" }, + { path: INTERSTATE_280, width: 0.3, kind: "freeway" }, +]; + +// ---- Bridges -------------------------------------------------------------- + + +export const GOLDEN_GATE_BRIDGE: Bridge = { + name: "Golden Gate Bridge", + // Both ends run well onto land. Started at the waterline, the deck appeared + // to float with its approaches missing — the span has to touch the Presidio + // bluff on one side and Fort Baker on the other to read as a bridge. + path: [ + [37.8025, -122.4752], + [37.8106, -122.4775], + [37.8155, -122.4783], + [37.825, -122.479], + [37.8325, -122.4798], + [37.8375, -122.4806], + ], + towers: [ + [37.8155, -122.4783], + [37.825, -122.479], + ], + towerHeight: 227, + deckHeight: 67, + sag: 0.55, + color: 0xc0442c, // International Orange +}; + +export const BAY_BRIDGE: Bridge = { + name: "Bay Bridge", + path: [ + [37.7905, -122.3885], + [37.7965, -122.3805], + [37.8035, -122.3725], + [37.8095, -122.3648], // Yerba Buena + [37.8155, -122.3535], + [37.8205, -122.3405], + [37.8225, -122.3275], + ], + towers: [ + [37.7955, -122.3815], + [37.8035, -122.3725], + [37.8165, -122.3515], + ], + towerHeight: 160, + deckHeight: 58, + sag: 0.4, + color: 0x9aa6b2, +}; + +export const BRIDGES = [GOLDEN_GATE_BRIDGE, BAY_BRIDGE]; + +// ---- Landmarks ------------------------------------------------------------ + +/** + * Buildings placed by hand because the eye looks for them. Everything else + * downtown is procedural scatter; these are the five or six shapes that tell + * you at a glance that you are looking at San Francisco and not Seattle. + */ + +export const LANDMARKS: Landmark[] = [ + { + name: "Salesforce Tower", + lat: 37.7897, + lng: -122.3972, + height: 326, + footprint: 0.00055, + shape: "tower", + label: true, + }, + { + name: "Transamerica Pyramid", + lat: 37.7952, + lng: -122.4028, + height: 260, + footprint: 0.00048, + shape: "pyramid", + color: 0xdfe4e8, + label: true, + }, + { + name: "Coit Tower", + lat: 37.8024, + lng: -122.4058, + height: 64, + footprint: 0.00016, + shape: "cylinder", + color: 0xe6e2d8, + label: true, + }, + { + name: "Ferry Building", + lat: 37.7955, + lng: -122.3937, + height: 74, + footprint: 0.0003, + shape: "tower", + color: 0xe8dfcd, + label: true, + }, + { + name: "555 California", + lat: 37.7924, + lng: -122.4038, + height: 237, + footprint: 0.0005, + shape: "box", + color: 0x8c6f5e, + }, + { + name: "181 Fremont", + lat: 37.7889, + lng: -122.3963, + height: 244, + footprint: 0.00035, + shape: "tower", + }, + { + name: "Sutro Tower", + lat: 37.7552, + lng: -122.4528, + height: 298, + footprint: 0.00022, + shape: "tower", + color: 0xd06a4a, + label: true, + }, + { + name: "Oracle Park", + lat: 37.7786, + lng: -122.3893, + height: 38, + footprint: 0.0009, + shape: "cylinder", + color: 0x4a6b52, + label: true, + }, + { + name: "Palace of Fine Arts", + lat: 37.8029, + lng: -122.4484, + height: 49, + footprint: 0.00035, + shape: "cylinder", + color: 0xdcc9ad, + }, +]; + +// ---- Districts ------------------------------------------------------------ + +/** + * Where buildings go, how tall, and how densely. The scatter in buildings.ts + * reads these; the chapters in `chapters.ts` fly to them. + * + * `density` is buildings per square degree-ish — tuned by eye. `maxHeight` and + * `minHeight` are metres, and the distribution between them is cubed so that + * tall buildings stay rare and the skyline keeps a silhouette. + */ + +export const DISTRICTS: District[] = [ + { + id: "fidi", + name: "Financial District", + polygon: [ + [37.8005, -122.4065], + [37.7975, -122.3925], + [37.7885, -122.3925], + [37.7855, -122.4045], + [37.7925, -122.4105], + ], + minHeight: 40, + maxHeight: 185, + gridAngle: 0.8, + palette: "downtown", + towerChance: 0.15, + }, + { + id: "soma", + name: "SoMa", + polygon: [ + [37.7885, -122.4085], + [37.7855, -122.3895], + [37.7715, -122.3945], + [37.7735, -122.4165], + ], + minHeight: 18, + maxHeight: 130, + gridAngle: 0.8, + palette: "downtown", + towerChance: 0.035, + }, + { + id: "mission-bay", + name: "Mission Bay", + polygon: [ + [37.7765, -122.3925], + [37.7745, -122.3835], + [37.7625, -122.3845], + [37.7645, -122.3985], + ], + minHeight: 20, + maxHeight: 95, + gridAngle: 0.8, + palette: "downtown", + towerChance: 0.1, + }, + { + id: "hayes-valley", + name: "Hayes Valley", + polygon: [ + [37.7815, -122.4315], + [37.7785, -122.4185], + [37.7695, -122.4215], + [37.7715, -122.4345], + ], + minHeight: 12, + maxHeight: 48, + gridAngle: 0.03, + palette: "residential", + towerChance: 0.03, + }, + { + id: "nob-hill", + name: "Nob Hill & Russian Hill", + polygon: [ + [37.8055, -122.425], + [37.8025, -122.4055], + [37.7885, -122.4085], + [37.7905, -122.4265], + ], + minHeight: 14, + maxHeight: 85, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.06, + }, + { + id: "north-beach", + name: "North Beach & Wharf", + polygon: [ + [37.8095, -122.4185], + [37.8065, -122.4045], + [37.7995, -122.4045], + [37.8015, -122.4205], + ], + minHeight: 10, + maxHeight: 40, + gridAngle: 0.05, + palette: "residential", + towerChance: 0.02, + }, + { + id: "mission", + name: "The Mission", + polygon: [ + [37.7695, -122.4295], + [37.7675, -122.405], + [37.7455, -122.4075], + [37.7485, -122.4305], + ], + minHeight: 9, + maxHeight: 34, + gridAngle: 0.03, + palette: "residential", + towerChance: 0.012, + }, + { + id: "potrero", + name: "Potrero & Dogpatch", + polygon: [ + [37.7665, -122.4055], + [37.7635, -122.3835], + [37.7495, -122.3845], + [37.7525, -122.4065], + ], + minHeight: 8, + maxHeight: 42, + gridAngle: 0.8, + palette: "industrial", + towerChance: 0.015, + }, + { + id: "marina", + name: "Marina & Pacific Heights", + polygon: [ + [37.8055, -122.4485], + [37.8035, -122.4265], + [37.7855, -122.4295], + [37.7885, -122.4525], + ], + minHeight: 9, + maxHeight: 40, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.01, + }, + { + id: "richmond", + name: "The Richmond", + polygon: [ + [37.7885, -122.5065], + [37.7855, -122.4525], + [37.7715, -122.4545], + [37.7745, -122.5095], + ], + minHeight: 8, + maxHeight: 26, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.004, + }, + { + id: "sunset", + name: "The Sunset", + polygon: [ + [37.7645, -122.5105], + [37.7615, -122.4595], + [37.7275, -122.4665], + [37.7315, -122.5085], + ], + minHeight: 8, + maxHeight: 22, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.002, + }, + { + id: "western-addition", + name: "Western Addition & Haight", + polygon: [ + [37.7855, -122.4535], + [37.7825, -122.4295], + [37.7665, -122.4355], + [37.7695, -122.4585], + ], + minHeight: 9, + maxHeight: 38, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.008, + }, + { + id: "bernal", + name: "Bernal & Glen Park", + polygon: [ + [37.7485, -122.4405], + [37.7455, -122.4055], + [37.7275, -122.4105], + [37.7305, -122.4455], + ], + minHeight: 8, + maxHeight: 24, + gridAngle: 0.06, + palette: "residential", + towerChance: 0.003, + }, + { + id: "bayview", + name: "Bayview & Hunters Point", + polygon: [ + [37.7475, -122.4005], + [37.7445, -122.3765], + [37.7185, -122.3855], + [37.7245, -122.4085], + ], + minHeight: 7, + maxHeight: 28, + gridAngle: 0.72, + palette: "industrial", + towerChance: 0.004, + }, + { + id: "excelsior", + name: "Excelsior & Ingleside", + polygon: [ + [37.7305, -122.4655], + [37.7285, -122.4145], + [37.7095, -122.4205], + [37.7115, -122.4695], + ], + minHeight: 7, + maxHeight: 20, + gridAngle: 0.12, + palette: "residential", + towerChance: 0.002, + }, + { + id: "castro", + name: "Castro & Noe Valley", + polygon: [ + [37.7695, -122.4485], + [37.7675, -122.4265], + [37.7405, -122.4305], + [37.7425, -122.4495], + ], + minHeight: 8, + maxHeight: 26, + gridAngle: 0.03, + palette: "residential", + towerChance: 0.002, + }, + { + id: "west-portal", + name: "West Portal & Forest Hill", + polygon: [ + [37.7605, -122.4795], + [37.7585, -122.4465], + [37.7345, -122.4525], + [37.7365, -122.4785], + ], + minHeight: 7, + maxHeight: 22, + gridAngle: 0.18, + palette: "residential", + towerChance: 0.002, + }, + { + id: "glen-park", + name: "Glen Park & Diamond Heights", + polygon: [ + [37.7505, -122.4585], + [37.7495, -122.4375], + [37.7295, -122.4415], + [37.7315, -122.4595], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.2, + palette: "residential", + towerChance: 0.003, + }, + { + id: "lakeshore", + name: "Lakeshore & Parkmerced", + polygon: [ + [37.7365, -122.5035], + [37.7345, -122.4655], + [37.7105, -122.4695], + [37.7125, -122.5045], + ], + minHeight: 8, + maxHeight: 46, + gridAngle: 0.08, + palette: "residential", + towerChance: 0.012, + }, + { + id: "presidio-heights", + name: "Presidio & Laurel Heights", + polygon: [ + [37.7935, -122.4655], + [37.7915, -122.4445], + [37.7815, -122.4465], + [37.7835, -122.4665], + ], + minHeight: 9, + maxHeight: 32, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.005, + }, + { + id: "sausalito", + name: "Sausalito", + polygon: [ + [37.868, -122.492], + [37.866, -122.484], + [37.845, -122.482], + [37.847, -122.492], + ], + minHeight: 7, + maxHeight: 18, + gridAngle: 0.35, + palette: "industrial", + towerChance: 0.002, + }, + { + id: "oakland", + name: "Oakland", + polygon: [ + [37.815, -122.295], + [37.812, -122.26], + [37.788, -122.262], + [37.792, -122.297], + ], + minHeight: 10, + maxHeight: 120, + gridAngle: 0.45, + palette: "downtown", + towerChance: 0.05, + }, +]; + + + +const WHOLE_BOARD: City["chapters"][number] = { + id: "all", + number: "01", + label: "The Whole Board", + shortLabel: "Whole Board", + focus: { lat: 37.775, lng: -122.432, distance: 178, height: 118, rotation: 0.52 }, + description: + "Seven by seven miles on the tip of a peninsula, with the Pacific on one side and the Bay on the other. Every company on this map is somewhere in this frame.", +}; + +export const CHAPTERS: City["chapters"] = [ + WHOLE_BOARD, + { + id: "hayes-valley", + number: "02", + label: "Hayes Valley", + shortLabel: "Hayes Valley", + focus: { lat: 37.7765, lng: -122.4241, distance: 34, height: 25, rotation: 0.4 }, + description: + "Cerebral Valley. A dozen square blocks of Victorians and hacker houses that ended up the densest concentration of AI companies anywhere, sitting in the bowl below Buena Vista and Corona Heights.", + }, + { + id: "soma", + number: "03", + label: "SoMa", + shortLabel: "SoMa", + focus: { lat: 37.7799, lng: -122.4038, distance: 42, height: 30, rotation: 0.78 }, + description: + "South of Market, where the grid turns forty-six degrees and the blocks get long. The infrastructure and platform companies, warehouses turned offices, and the tallest thing west of the Mississippi.", + }, + { + id: "fidi", + number: "04", + label: "FiDi & Jackson Square", + shortLabel: "FiDi", + focus: { lat: 37.7945, lng: -122.4015, distance: 36, height: 28, rotation: 0.95 }, + description: + "The old money and the venture money, stacked between Montgomery and the Embarcadero under the pyramid. Enterprise sales offices and the funds that write the cheques.", + }, + { + id: "mission-bay", + number: "05", + label: "Mission Bay", + shortLabel: "Mission Bay", + focus: { lat: 37.7702, lng: -122.3905, distance: 34, height: 24, rotation: 0.72 }, + description: + "Landfill turned biotech campus. Everything here was built after 1998, which is why it is the only part of the city on a grid nobody argues about.", + }, + { + id: "mission", + number: "06", + label: "Mission, Potrero & Dogpatch", + shortLabel: "Mission", + focus: { lat: 37.7605, lng: -122.4055, distance: 46, height: 30, rotation: 0.5 }, + description: + "The flat sunny part and the hill above it. Applied-AI teams and hardware startups in the old industrial edge along the water, with Bernal closing the valley to the south.", + }, + { + id: "peninsula", + number: "07", + label: "The Peninsula", + shortLabel: "Peninsula", + focus: { lat: 37.735, lng: -122.42, distance: 205, height: 138, rotation: 0.18 }, + description: + "Looking south down the spine toward Palo Alto and Menlo Park. Plenty of companies that file their address as San Francisco are really down here, an hour away on the 101.", + }, +]; + + +// ---- The pack ------------------------------------------------------------ + +export const SAN_FRANCISCO_CITY: City = { + id: "sf", + name: "San Francisco", + center: { lat: 37.7749, lng: -122.4194 }, + bounds: { minLat: 37.6975, maxLat: 37.8925, minLng: -122.5725, maxLng: -122.2175 }, + + latScale: 1180, + + /** + * Tuned down from 4.5 once the hills were built on. Bare domes needed the + * height to register at all; a hillside covered in houses shows its slope by + * itself, and 4.5 turned Twin Peaks into a volcano. + */ + verticalExaggeration: 3.6, + + // ~45 m cells. One focus region covering everything, because the whole city + // fits comfortably at this resolution — see ARCHITECTURE.md §5 for why Los + // Angeles will not. + cellLat: 0.000404, + cellLng: 0.000511, + focusRegions: [{ minLat: 37.6975, maxLat: 37.8925, minLng: -122.5725, maxLng: -122.2175 }], + + coastFalloff: 0.0018, // ~200 m + + landmasses: LANDMASSES, + parks: PARKS, + inlandWater: INLAND_WATER, + hills: HILLS, + districts: DISTRICTS, + landmarks: LANDMARKS, + bridges: BRIDGES, + roads: ROADS, + chapters: CHAPTERS, +}; + +export default SAN_FRANCISCO_CITY; diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts new file mode 100644 index 0000000..7c11765 --- /dev/null +++ b/src/engine/blocks.ts @@ -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; + +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; +} diff --git a/src/engine/flights.ts b/src/engine/flights.ts new file mode 100644 index 0000000..f587815 --- /dev/null +++ b/src/engine/flights.ts @@ -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 { + 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(); + + function update(aircraft: Aircraft[]) { + const seen = new Set(); + 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(); + }, + }; +} diff --git a/src/engine/markers.ts b/src/engine/markers.ts new file mode 100644 index 0000000..9d0c839 --- /dev/null +++ b/src/engine/markers.ts @@ -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; + 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(); + + // 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(); + 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(); + }, + }; +} diff --git a/src/engine/scene.ts b/src/engine/scene.ts new file mode 100644 index 0000000..80b30cd --- /dev/null +++ b/src/engine/scene.ts @@ -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; +} diff --git a/src/engine/structures.ts b/src/engine/structures.ts new file mode 100644 index 0000000..2c9d639 --- /dev/null +++ b/src/engine/structures.ts @@ -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; +} diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts new file mode 100644 index 0000000..0056730 --- /dev/null +++ b/src/engine/terrain.ts @@ -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; +} diff --git a/src/engine/types.ts b/src/engine/types.ts new file mode 100644 index 0000000..dba741b --- /dev/null +++ b/src/engine/types.ts @@ -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; +} + +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; + +// ---- 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[]; + /** Seconds between polls. */ + interval: number; + dispose?(): void; +} diff --git a/src/engine/world.ts b/src/engine/world.ts new file mode 100644 index 0000000..4c47b06 --- /dev/null +++ b/src/engine/world.ts @@ -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(); + 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; + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..29fa778 --- /dev/null +++ b/src/main.ts @@ -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("#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("#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("#chapters"); +const blurb = document.querySelector("#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 = `${chapter.number}${chapter.shortLabel}`; + 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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..521b1e8 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cbe3cb5 --- /dev/null +++ b/vite.config.ts @@ -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" }, +});