diff --git a/environments/tera_spatial/README.md b/environments/tera_spatial/README.md new file mode 100644 index 0000000..955e049 --- /dev/null +++ b/environments/tera_spatial/README.md @@ -0,0 +1,55 @@ +# tera-spatial + +Arena's bridge to Tera's four renderer-independent spatial environments. + +The environments themselves live in the `tera` repository, in TypeScript, as +`tera.arena/v1`. They are not reimplemented here and they never will be. This +package vendors their import closure, runs it in a resident `node` worker, and +transports results. The rule the whole thing is built around: + +> **Never recompute in Python a number that came out of TypeScript.** + +Rewards, per-step state checksums, scenario materialisation and the four +baseline returns that every reward is normalised against are all computed once, +inside Tera, and only ever carried across the pipe. + +## What is here + +| Path | What it is | +|---|---| +| `tera_spatial/worker.mjs` | the resident NDJSON worker: `reset`, `step`, `snapshot`, `restore`, `trace`, `replay`, `oracle`, `checksum` | +| `tera_spatial/bridge.py` | `TeraWorker` / `TeraEpisode` — transport, and nothing else | +| `tera_spatial/vendor/tera/` | the 23-file, 283 KB import closure of `src/arena/index.ts` | +| `tera_spatial/hashes.py` | SHA-256 of all 23, generated; the worker refuses to start if the tree has drifted | +| `tera_spatial/closure.py` | the import walk both the sync script and the gate use | +| `scripts/sync_tera.py` | reproduces the vendoring from a `tera` checkout | +| `tests/test_replay.py` | the replay gate | + +The tasksets are not here yet. This is the bridge and its correctness proof. + +## The gate + +```bash +uv run python -m unittest discover -s environments/tera_spatial/tests -v +``` + +32 episodes — 4 environments x 4 public scenarios x 2 seeds — each driven from +Python one JSON step at a time, then replayed in a fresh TypeScript environment. +All 32 must reach an identical FNV-1a-64 checksum. Then seven forgeries per +environment, each tried twice: raw, where the envelope checksum catches it, and +**re-sealed with a checksum Tera itself recomputed**, where only `replay()` +re-running the simulator can. All must be rejected. + +`node` >= 22.18 is required — the vendored sources are raw `.ts` and are +type-stripped, not compiled. Set `TERA_NODE` to point at a specific binary. + +## Re-vendoring + +```bash +uv run python scripts/sync_tera.py --tera ~/repos/gitea/tera # copy + regenerate hashes +uv run python scripts/sync_tera.py --check # CI: is the tree clean? +``` + +The closure is walked, not declared, so a new import in `tera` travels with it. +A bare specifier is a hard failure: the wheel has no `node_modules` and a +simulator that needs one is not a simulator that ships. diff --git a/environments/tera_spatial/pyproject.toml b/environments/tera_spatial/pyproject.toml new file mode 100644 index 0000000..bf90634 --- /dev/null +++ b/environments/tera_spatial/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "tera-spatial" +version = "0.1.0" +description = "tera-spatial — Arena's bridge to Tera's four renderer-independent spatial environments, executed in TypeScript and graded in Python." +requires-python = ">=3.11" +dependencies = ["verifiers"] + +# One directory, four tasksets: the environments share a vendored simulator and +# a single worker, so splitting them into four wheels would ship the same 283 KB +# of TypeScript four times. B1's discovery reads this key instead of assuming +# one wheel is one taskset. +[tool.arena] +tasksets = [ + "tera-drive-101", + "tera-office-nav", + "tera-crow-nav", + "tera-california-flight", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["tera_spatial"] diff --git a/environments/tera_spatial/scripts/sync_tera.py b/environments/tera_spatial/scripts/sync_tera.py new file mode 100644 index 0000000..d49d852 --- /dev/null +++ b/environments/tera_spatial/scripts/sync_tera.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Vendor the Tera arena TypeScript closure into this package. + +Tera is a TypeScript repository with no Python toolchain, and this package is a +Python wheel. The bridge's rule is that a number the model is graded on is +computed *once*, in TypeScript, and only ever transported into Python — so the +TypeScript has to travel with the wheel rather than be resolved at runtime from +a checkout that may not exist on the machine doing the eval. + +What travels is the transitive import closure of `src/arena/index.ts` and +nothing else: 23 files, no bare specifiers, no Three.js, no DOM. That closure is +walked here rather than declared, so a new import in `tera` is picked up the +next time somebody runs this instead of silently going missing. + + uv run python scripts/sync_tera.py --tera ~/repos/gitea/tera + +Writes `tera_spatial/vendor/tera/` for every file in the closure and +regenerates `tera_spatial/hashes.py`. Both are committed. `hashes.py` is what +`test_replay.py` checks the vendored tree against, so an edit to a vendored +file that does not come back through this script fails the gate. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tera_spatial.closure import ENTRY, walk # noqa: E402 + + +def sha256(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(argv: list[str] | None = None) -> int: + package = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tera", type=Path, default=Path.home() / "repos/gitea/tera") + parser.add_argument("--check", action="store_true", help="verify only; write nothing") + args = parser.parse_args(argv) + + root = args.tera.expanduser().resolve() + if not (root / ENTRY).is_file(): + print(f"not a tera checkout: {root}", file=sys.stderr) + return 2 + + files, bare = walk(root) + if bare: + # A bare specifier means the closure needs `node_modules`, and the wheel + # would ship a bridge that only runs next to a `npm install`. + print("closure is not self-contained:", file=sys.stderr) + for entry in bare: + print(f" {entry}", file=sys.stderr) + return 1 + + vendor = package / "tera_spatial" / "vendor" / "tera" + if args.check: + stale = [rel for rel in files if not (vendor / rel).is_file()] + if stale: + print("missing from vendor:", *stale, sep="\n ", file=sys.stderr) + return 1 + else: + if vendor.exists(): + shutil.rmtree(vendor) + for rel in files: + destination = vendor / rel + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / rel, destination) + + digests = {rel: sha256(vendor / rel) for rel in files} + total = sum((vendor / rel).stat().st_size for rel in files) + aggregate = hashlib.sha256() + for rel in files: + aggregate.update(rel.encode()) + aggregate.update(b"\0") + aggregate.update((vendor / rel).read_bytes()) + aggregate.update(b"\0") + + body = _render(digests, total, "sha256:" + aggregate.hexdigest()) + target = package / "tera_spatial" / "hashes.py" + if args.check: + if target.read_text(encoding="utf-8") != body: + print("hashes.py is stale; re-run scripts/sync_tera.py", file=sys.stderr) + return 1 + print(f"vendored closure ok: {len(files)} files, {total} bytes") + return 0 + + target.write_text(body, encoding="utf-8") + print(f"vendored {len(files)} files ({total} bytes) from {root}") + return 0 + + +def _render(digests: dict[str, str], total: int, aggregate: str) -> str: + lines = [ + '"""SHA-256 of every vendored Tera source file. Generated — do not edit.', + "", + "Regenerate with `scripts/sync_tera.py`. `verify()` is called by the replay", + "gate: the checksums a trace carries are only worth something if the code that", + "produced them is the code on disk.", + '"""', + "", + "from __future__ import annotations", + "", + "import hashlib", + "from pathlib import Path", + "", + f"CLOSURE_ENTRY = {ENTRY!r}", + f"CLOSURE_BYTES = {total}", + f"CLOSURE_SHA256 = {aggregate!r}", + "", + "VENDORED_FILES: dict[str, str] = {", + ] + lines += [f" {rel!r}: {digest!r}," for rel, digest in digests.items()] + lines += [ + "}", + "", + "VENDOR_ROOT = Path(__file__).resolve().parent / \"vendor\" / \"tera\"", + "", + "", + "def verify() -> list[str]:", + ' """Return a list of complaints about the vendored tree. Empty means clean."""', + " problems: list[str] = []", + " for relative, expected in VENDORED_FILES.items():", + " path = VENDOR_ROOT / relative", + " if not path.is_file():", + " problems.append(f\"missing: {relative}\")", + " continue", + ' actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()', + " if actual != expected:", + ' problems.append(f"modified: {relative} ({actual} != {expected})")', + " for path in sorted(VENDOR_ROOT.rglob(\"*\")):", + " if path.is_file():", + " relative = path.relative_to(VENDOR_ROOT).as_posix()", + " if relative not in VENDORED_FILES:", + ' problems.append(f"unrecorded: {relative}")', + " return problems", + "", + ] + return "\n".join(lines) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/environments/tera_spatial/tera_spatial/__init__.py b/environments/tera_spatial/tera_spatial/__init__.py new file mode 100644 index 0000000..3608f52 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/__init__.py @@ -0,0 +1,19 @@ +"""tera-spatial — the bridge from Arena's Python harness to Tera's TypeScript arena. + +Four spatial environments (`drive-101-v1`, `office-nav-v1`, `crow-nav-v1`, +`california-flight-v1`) live in the `tera` repository as renderer-independent +TypeScript. This package vendors their import closure, executes it in a resident +`node` worker, and never reimplements any of it. The tasksets that sit on top of +this bridge land after it. +""" + +from tera_spatial.bridge import TeraEpisode, TeraError, TeraWorker + +ENVIRONMENT_IDS = ( + "drive-101-v1", + "office-nav-v1", + "crow-nav-v1", + "california-flight-v1", +) + +__all__ = ["ENVIRONMENT_IDS", "TeraEpisode", "TeraError", "TeraWorker"] diff --git a/environments/tera_spatial/tera_spatial/bridge.py b/environments/tera_spatial/tera_spatial/bridge.py new file mode 100644 index 0000000..0be02af --- /dev/null +++ b/environments/tera_spatial/tera_spatial/bridge.py @@ -0,0 +1,237 @@ +"""The Python side of the Tera bridge. + +One resident `node` process, NDJSON over its stdio, and a rule: **never +recompute in Python a number that came out of TypeScript.** Rewards, checksums, +scenario materialisation and the baseline denominators are all computed once by +the vendored `tera.arena/v1` code and only ever transported here. Python's job +is transport and policy, never simulation. + + with TeraWorker() as worker: + env = worker.open("crow-nav-v1", seed=115, scenario={"split": "train"}) + env.step({"forward": 1.0, "yaw": 0.0, "climb": 0.2}) + trace = env.trace() + worker.replay("crow-nav-v1", trace) # raises TeraError on divergence + +The worker starts in ~92 ms and answers a round trip in ~19 us, so it is opened +once per eval process and shared by every rollout in it. It is +deliberately not an HTTP service: there is no port to collide, nothing to +authenticate, and no way to be talking to a build other than the one in this +wheel. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import threading +from pathlib import Path +from typing import Any + +from tera_spatial import hashes + +WORKER = Path(__file__).resolve().parent / "worker.mjs" + +#: Node type-strips raw `.ts` natively from 22.6 (behind a flag) and 23.6 (on by +#: default). Below that the vendored closure will not import at all, and the +#: failure mode is a confusing syntax error rather than a version complaint. +MINIMUM_NODE = (22, 18, 0) + + +class TeraError(RuntimeError): + """An error raised inside TypeScript and carried across the pipe. + + A tampered trace, a stepped-past-terminal episode and an unknown scenario id + all arrive this way. They are the worker behaving correctly, so they are an + exception here and not a dead subprocess. + """ + + def __init__(self, message: str, error_type: str = "Error") -> None: + super().__init__(message) + self.error_type = error_type + + +def node_executable() -> str: + node = os.environ.get("TERA_NODE") or shutil.which("node") + if not node: + raise RuntimeError( + "the Tera bridge needs `node` on PATH (or TERA_NODE set); the vendored " + "environments are TypeScript and are executed, not translated" + ) + return node + + +def node_version(node: str | None = None) -> tuple[int, ...]: + raw = subprocess.run( + [node or node_executable(), "--version"], capture_output=True, text=True, check=True + ).stdout.strip() + return tuple(int(part) for part in raw.lstrip("v").split(".")[:3]) + + +class TeraWorker: + """A resident `node` process speaking NDJSON. + + Not thread-safe by accident — a lock serialises the request/response pairs, + because the protocol is one line in and one line out and interleaving two + callers would hand each the other's answer. + """ + + def __init__(self, *, node: str | None = None, verify_vendor: bool = True) -> None: + if verify_vendor: + problems = hashes.verify() + if problems: + raise RuntimeError( + "vendored Tera sources do not match hashes.py; re-run " + "scripts/sync_tera.py:\n " + "\n ".join(problems) + ) + self._node = node or node_executable() + version = node_version(self._node) + if version < MINIMUM_NODE: + raise RuntimeError( + f"node {'.'.join(map(str, version))} cannot type-strip TypeScript; " + f"the bridge needs >= {'.'.join(map(str, MINIMUM_NODE))}" + ) + self._lock = threading.Lock() + self._counter = 0 + self._handles = 0 + self._process = subprocess.Popen( + [self._node, str(WORKER)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + ready = self._readline() + if not ready.get("ok"): + raise RuntimeError(f"tera worker failed to start: {ready}") + + # -- protocol --------------------------------------------------------- + + def _readline(self) -> dict[str, Any]: + assert self._process.stdout is not None + line = self._process.stdout.readline() + if not line: + stderr = self._process.stderr.read() if self._process.stderr else "" + raise RuntimeError(f"tera worker exited ({self._process.poll()}): {stderr.strip()}") + return json.loads(line) + + def request(self, op: str, **payload: Any) -> Any: + with self._lock: + self._counter += 1 + request_id = self._counter + assert self._process.stdin is not None + self._process.stdin.write(json.dumps({"id": request_id, "op": op, **payload}) + "\n") + self._process.stdin.flush() + response = self._readline() + if response.get("id") != request_id: + raise RuntimeError(f"tera worker response out of order: {response}") + if not response.get("ok"): + raise TeraError(response.get("error", "unknown"), response.get("errorType", "Error")) + return response["result"] + + # -- environments ----------------------------------------------------- + + def manifests(self) -> list[dict[str, Any]]: + return self.request("manifests")["manifests"] + + def source_hashes(self) -> dict[str, dict[str, str]]: + return self.request("sourceHashes")["sourceHashes"] + + def checksum(self, value: Any) -> str: + """Tera's canonical FNV-1a-64, computed by Tera. + + Python has no business owning a second implementation of this — the + checksum is the thing the two runtimes have to agree about. + """ + return self.request("checksum", value=value)["checksum"] + + def open(self, env_id: str, seed: int, scenario: str | dict[str, Any]) -> TeraEpisode: + self._handles += 1 + handle = f"h{self._handles}" + result = self.request("reset", handle=handle, env=env_id, seed=seed, scenario=scenario) + return TeraEpisode(self, handle, env_id, result) + + def replay(self, env_id: str, trace: dict[str, Any]) -> dict[str, Any]: + """Re-execute a trace in a fresh environment. Raises `TeraError` on any divergence.""" + return self.request("replay", env=env_id, trace=trace) + + def oracle( + self, + env_id: str, + seed: int, + scenario: str | dict[str, Any], + policy: str = "scripted", + max_steps: int | None = None, + ) -> dict[str, Any]: + """Run one of the baselines Tera exports, inside Tera. + + The scripted return is the denominator every reward here is normalised + against, so it is not a thing to translate: `src/arena/index.ts` already + exports all four policies and the worker calls them. + """ + return self.request( + "oracle", env=env_id, seed=seed, scenario=scenario, policy=policy, maxSteps=max_steps + ) + + # -- lifecycle -------------------------------------------------------- + + def close(self) -> None: + if self._process.poll() is None: + try: + assert self._process.stdin is not None + self._process.stdin.close() + except (BrokenPipeError, ValueError): + pass + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def __enter__(self) -> TeraWorker: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +class TeraEpisode: + """One live episode inside the worker, addressed by handle.""" + + def __init__( + self, worker: TeraWorker, handle: str, env_id: str, reset: dict[str, Any] + ) -> None: + self._worker = worker + self._handle = handle + self.env_id = env_id + self.observation = reset["observation"] + self.info = reset["info"] + + def step(self, action: dict[str, Any]) -> dict[str, Any]: + result = self._worker.request("step", handle=self._handle, action=action) + self.observation = result["observation"] + self.info = result["info"] + return result + + def snapshot(self) -> dict[str, Any]: + return self._worker.request("snapshot", handle=self._handle)["snapshot"] + + def restore(self, snapshot: dict[str, Any]) -> dict[str, Any]: + result = self._worker.request("restore", handle=self._handle, snapshot=snapshot) + self.observation = result["observation"] + self.info = result["info"] + return result + + def trace(self) -> dict[str, Any]: + return self._worker.request("trace", handle=self._handle)["trace"] + + def close(self) -> None: + self._worker.request("close", handle=self._handle) + + def __enter__(self) -> TeraEpisode: + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/environments/tera_spatial/tera_spatial/closure.py b/environments/tera_spatial/tera_spatial/closure.py new file mode 100644 index 0000000..865aeb1 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/closure.py @@ -0,0 +1,59 @@ +"""Walking the TypeScript import closure, in Python. + +Both the vendoring script and the replay gate need the same answer to the same +question — *which files does `src/arena/index.ts` actually pull in, and does it +pull in anything from `node_modules`?* — so the walk lives here and neither of +them declares a file list by hand. A list you maintain is a list that goes stale +the first time somebody adds an import in `tera`. + +Regex over the source rather than a parser: the closure is 23 hand-written ESM +files with explicit `.ts` specifiers and no `export * from` indirection, and a +parser would be a dependency for no extra truth. The `--check` mode of +`scripts/sync_tera.py` is what catches it if that ever stops being true. +""" + +from __future__ import annotations + +import posixpath +import re +from pathlib import Path + +ENTRY = "src/arena/index.ts" + +_STATIC = re.compile(r"""(?:^|\n)\s*(?:import|export)\b[\s\S]*?\sfrom\s+["']([^"']+)["']""") +_DYNAMIC = re.compile(r"""\bimport\s*\(\s*["']([^"']+)["']\s*\)""") + + +def walk(root: Path, entry: str = ENTRY) -> tuple[list[str], list[str]]: + """Transitive relative-import closure of `entry` under `root`. + + Returns `(files, bare)`. Anything in `bare` is a specifier that would need + `node_modules` at runtime, which the vendored wheel does not have. + """ + seen: set[str] = set() + bare: set[str] = set() + stack = [entry] + while stack: + relative = stack.pop() + if relative in seen: + continue + seen.add(relative) + source = (root / relative).read_text(encoding="utf-8") + for pattern in (_STATIC, _DYNAMIC): + for specifier in pattern.findall(source): + if not specifier.startswith("."): + bare.add(f"{relative} -> {specifier}") + continue + stack.append( + posixpath.normpath(posixpath.join(posixpath.dirname(relative), specifier)) + ) + return sorted(seen), sorted(bare) + + +def vendored_root() -> Path: + return Path(__file__).resolve().parent / "vendor" / "tera" + + +def bare_specifiers() -> list[str]: + """Specifiers in the *vendored* tree that would need an install to resolve.""" + return walk(vendored_root())[1] diff --git a/environments/tera_spatial/tera_spatial/hashes.py b/environments/tera_spatial/tera_spatial/hashes.py new file mode 100644 index 0000000..8b46d4d --- /dev/null +++ b/environments/tera_spatial/tera_spatial/hashes.py @@ -0,0 +1,62 @@ +"""SHA-256 of every vendored Tera source file. Generated — do not edit. + +Regenerate with `scripts/sync_tera.py`. `verify()` is called by the replay +gate: the checksums a trace carries are only worth something if the code that +produced them is the code on disk. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +CLOSURE_ENTRY = 'src/arena/index.ts' +CLOSURE_BYTES = 282684 +CLOSURE_SHA256 = 'sha256:f833df14c0cc751f50c67e7005c9724b647e7716a28f084d4fad63a802fdda19' + +VENDORED_FILES: dict[str, str] = { + 'src/actors/controller.ts': 'sha256:45e7651af8041a9422b492885a360d96822594ea52bb7b7afce5fe9af8635d22', + 'src/aircraft/controller.ts': 'sha256:1a702cb9f6c301731e5df1216346d56e26b00bfb73b1e4f7bde1ed09f81b6ebe', + 'src/arena/base.ts': 'sha256:e52e40bf41f10a28a68a5fac33e2dc6ce54c42c53f8a48aa4a512a15c053979c', + 'src/arena/californiaFlight.ts': 'sha256:5b8b124b1217f20826b3995bda927374aa24fb805bd2a5836234870cbdf09072', + 'src/arena/checksum.ts': 'sha256:c070bebd3456af4e3354a44bb3adea08e6213608203bd4406c400c3329307d14', + 'src/arena/crowNav.ts': 'sha256:9ebef2f39783f89a85460c333a3ce7bb3c02f834ba3edb5febe828d15b12b08c', + 'src/arena/drive101.ts': 'sha256:33e7d74dca8d9c7ceb0aecccbb76c42a6546c8fc990ce700c1230cc463b4ca48', + 'src/arena/index.ts': 'sha256:dfe54d981f7dfb5a59c26799b3dd16475467f29c4fdc586c6ea884bc58340c3c', + 'src/arena/officeNav.ts': 'sha256:ac51827841c58829f0ea6b16e2486f9233a4653dcc7fb0a694fc19a36c0e46cb', + 'src/arena/random.ts': 'sha256:c0716ff67aa966d4f4e0d597edf06f1a54b7dd400988a3d2f28afb4003c22cce', + 'src/arena/scenarios.ts': 'sha256:e1e4f61b618400c8caeea3374e2e7ed49f37cdb71c25a97b979ef1436596ca9c', + 'src/arena/sourceHashes.ts': 'sha256:148d3b95fdd28f477ec71bf27dec0954a6673c03e892e0b146bc1c7f27eb5fe3', + 'src/arena/types.ts': 'sha256:e599f1876460547f1a375fb3843ab3c3af8c982799e7cb18cac64ff4694458ab', + 'src/engine/types.ts': 'sha256:0aec7e03740f413709420753dbca599750500aa34997a1692e1d718b567b3dc6', + 'src/interiors/plan.ts': 'sha256:d4dd89dc7781c85c8bb75026a3c49c8f964f3632049b96fb6b73f9f27275223b', + 'src/interiors/types.ts': 'sha256:1eae1e28252f1b5360e1ea617d04ab084a138b8511e9c18df75a356b14309953', + 'src/interiors/walker.ts': 'sha256:b0fb9e41f615ddd2bfe5552fe07a86664b3d9cc63d9dcc5f8fa4f8ef6499a241', + 'src/offices/frontier-valley.ts': 'sha256:0a22b187241659fae484ae7491cd7f5d51bcd876b2a99425d8c4fda78764707b', + 'src/offices/sites.ts': 'sha256:14cad3b0d3d9161f543782642d92a67ae4efeb4a35354d483dbdb279b56ff345', + 'src/transport/california.ts': 'sha256:42fe3bf0fb53e791ecf600f8076fd9d49e849638fab12018d5a23f630e8b41b4', + 'src/transport/types.ts': 'sha256:de5b1030f85f4d17bb4c670ebbeac21fdaafe434f5c11c7157726e4c018e9f48', + 'src/transport/vehicleController.ts': 'sha256:7896b0cbed5ba5235fe01368842419b57d07c4e6fb1b8b293db8a31974ccc863', + 'src/transport/vehicleSim.ts': 'sha256:3ffbb67cbef9a0a117385598e67d5ebe643ee3744f19909cc9972a1611808fff', +} + +VENDOR_ROOT = Path(__file__).resolve().parent / "vendor" / "tera" + + +def verify() -> list[str]: + """Return a list of complaints about the vendored tree. Empty means clean.""" + problems: list[str] = [] + for relative, expected in VENDORED_FILES.items(): + path = VENDOR_ROOT / relative + if not path.is_file(): + problems.append(f"missing: {relative}") + continue + actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + problems.append(f"modified: {relative} ({actual} != {expected})") + for path in sorted(VENDOR_ROOT.rglob("*")): + if path.is_file(): + relative = path.relative_to(VENDOR_ROOT).as_posix() + if relative not in VENDORED_FILES: + problems.append(f"unrecorded: {relative}") + return problems diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts new file mode 100644 index 0000000..4a62577 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts @@ -0,0 +1,820 @@ +/** + * Renderer-independent playable actor simulation. + * + * DOM, touch and gamepad adapters reduce their input to `ActorActionSnapshot`. + * This state machine then advances on a deterministic fixed clock and publishes + * metre-scale transforms that any Three.js rig can consume. At yaw zero actors + * face -Z, matching the procedural assets under `assets/actors`. + */ + +const TWO_PI = Math.PI * 2; + +export type ActorKind = "humanoid" | "dog" | "crow"; +export type ActorMode = "ground" | "flight"; +export type ActorModeRequest = "none" | ActorMode; +export type ActorKindRequest = "none" | ActorKind; +export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch"; + +export interface CrowWind { + /** Positive X blows east/right in the actor's metre-space adapter. */ + xMps: number; + /** Positive Z blows toward the actor's rear at yaw zero. */ + zMps: number; +} + +export interface CrowThermal { + x: number; + z: number; + radiusM: number; + /** Maximum deterministic updraft at the core. */ + liftMps: number; +} + +/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */ +export interface ActorProfile { + handle?: string; + pronouns?: string; + faceImageUrl?: string; + appearance?: { + skinTone?: string; + primaryColor?: string; + accentColor?: string; + hairColor?: string; + bodyShape?: "slim" | "average" | "broad"; + }; +} + +/** Identity follows the player when their visible actor kind changes. */ +export interface ActorIdentity { + id: string; + displayName: string; + authenticated: boolean; + profile: ActorProfile; +} + +/** Device-neutral held inputs and one-shot requests. All axes normalize to [-1, 1]. */ +export interface ActorActionSnapshot { + /** Ground forward/back; flight airspeed demand. */ + forward: number; + /** Ground strafe. Ignored in flight. */ + right: number; + /** Yaw left/right. */ + turn: number; + /** Crow nose-up/nose-down request. */ + pitch: number; + /** Crow vertical thrust independent of its nose. */ + climb: number; + sprint: boolean; + glide: boolean; + modeRequest: ActorModeRequest; + kindRequest: ActorKindRequest; + reset: boolean; +} + +export const NEUTRAL_ACTOR_ACTIONS: Readonly = Object.freeze({ + forward: 0, + right: 0, + turn: 0, + pitch: 0, + climb: 0, + sprint: false, + glide: false, + modeRequest: "none", + kindRequest: "none", + reset: false, +}); + +export interface ActorPosition { + x: number; + y: number; + z: number; +} + +export interface ActorHorizontalBounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +export interface ActorControllerOptions { + kind: ActorKind; + identity: ActorIdentity; + position?: Partial; + yaw?: number; + /** Used only when spawning a crow in flight. */ + pitch?: number; + mode?: ActorMode; + groundY?: number; + minFlightAltitude?: number; + maxFlightAltitude?: number; + walkSpeedMps?: number; + runSpeedMps?: number; + dogSpeedScale?: number; + groundTurnRateRadPerSecond?: number; + flightTurnRateRadPerSecond?: number; + maximumFlightPitchRad?: number; + minimumFlightSpeedMps?: number; + maximumFlightSpeedMps?: number; + glideSpeedMps?: number; + maximumClimbSpeedMps?: number; + /** Constant air-mass velocity. Defaults to still air. */ + crowWind?: Partial; + /** Bounded deterministic radial updrafts in actor metre space. */ + crowThermals?: readonly CrowThermal[]; + /** Normalized reserve used by powered flapping. Defaults to one. */ + crowInitialEnergy?: number; + /** Reserve spent per second at a representative powered cruise. */ + crowEnergyDrainPerSecond?: number; + /** Reserve recovered per second while gliding or perched. */ + crowEnergyRecoveryPerSecond?: number; + /** Optional rectangle for a board or office adapter without collision geometry. */ + horizontalBounds?: ActorHorizontalBounds; + fixedStepSeconds?: number; + /** Caps catch-up after a sleeping tab. */ + maxFrameDeltaSeconds?: number; +} + +export interface ActorControllerState extends ActorPosition { + kind: ActorKind; + mode: ActorMode; + identity: Readonly; + yaw: number; + pitch: number; + speedMps: number; + verticalSpeedMps: number; + /** Renderer request for a cyclic walk/flap pose, wrapped to [0, 2π). */ + posePhase: number; + /** Renderer request for pose intensity, in [0, 1]. */ + poseAmount: number; + gliding: boolean; + /** Signed visual/turn bank in radians. */ + roll: number; + /** Normalized powered-flight reserve in [0, 1]. */ + flightEnergy: number; + /** Aerodynamic and thermal vertical contribution before pilot climb. */ + liftMps: number; + thermalLiftMps: number; + windXMps: number; + windZMps: number; + crowPose: CrowFlightPoseState; + perched: boolean; + altitudeBoundContact: "none" | "minimum" | "maximum"; + distanceM: number; + elapsedSteps: number; +} + +export interface ActorControllerSnapshot extends Omit { + identity: ActorIdentity; +} + +export interface TimedActorInputFrame { + steps: number; + actions?: Partial; +} + +export interface ActorReplayResult { + trajectory: readonly ActorControllerSnapshot[]; + final: ActorControllerSnapshot; +} + +interface ResolvedOptions { + kind: ActorKind; + identity: Readonly; + position: ActorPosition; + yaw: number; + pitch: number; + mode: ActorMode; + groundY: number; + minFlightAltitude: number; + maxFlightAltitude: number; + walkSpeedMps: number; + runSpeedMps: number; + dogSpeedScale: number; + groundTurnRateRadPerSecond: number; + flightTurnRateRadPerSecond: number; + maximumFlightPitchRad: number; + minimumFlightSpeedMps: number; + maximumFlightSpeedMps: number; + glideSpeedMps: number; + maximumClimbSpeedMps: number; + crowWind: CrowWind; + crowThermals: readonly CrowThermal[]; + crowInitialEnergy: number; + crowEnergyDrainPerSecond: number; + crowEnergyRecoveryPerSecond: number; + horizontalBounds?: ActorHorizontalBounds; + fixedStepSeconds: number; + maxFrameDeltaSeconds: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function finiteOr(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function positive(value: number | undefined, fallback: number, name: string): number { + const result = finiteOr(value, fallback); + if (!(result > 0)) throw new RangeError(`${name} must be finite and positive`); + return result; +} + +function wrapAngle(value: number): number { + return ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; +} + +function wrapPhase(value: number): number { + return ((value % TWO_PI) + TWO_PI) % TWO_PI; +} + +function moveToward(value: number, target: number, maximumDelta: number): number { + if (value < target) return Math.min(value + maximumDelta, target); + if (value > target) return Math.max(value - maximumDelta, target); + return value; +} + +function validKind(value: unknown): value is ActorKind { + return value === "humanoid" || value === "dog" || value === "crow"; +} + +function copyProfile(profile: ActorProfile): ActorProfile { + const appearance = profile.appearance + ? { + ...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }), + ...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }), + ...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }), + ...(profile.appearance.hairColor === undefined ? {} : { hairColor: profile.appearance.hairColor }), + ...(profile.appearance.bodyShape === undefined ? {} : { bodyShape: profile.appearance.bodyShape }), + } + : undefined; + return { + ...(profile.handle === undefined ? {} : { handle: profile.handle }), + ...(profile.pronouns === undefined ? {} : { pronouns: profile.pronouns }), + ...(profile.faceImageUrl === undefined ? {} : { faceImageUrl: profile.faceImageUrl }), + ...(appearance === undefined ? {} : { appearance }), + }; +} + +function checkedIdentity(identity: ActorIdentity): Readonly { + if (!identity || typeof identity.id !== "string" || identity.id.length === 0) { + throw new RangeError("actor identity must have a non-empty string id"); + } + if (typeof identity.displayName !== "string" || typeof identity.authenticated !== "boolean") { + throw new RangeError("actor identity must contain a display name and authentication flag"); + } + const profile = identity.profile ?? {}; + const strings = [ + profile.handle, + profile.pronouns, + profile.faceImageUrl, + profile.appearance?.skinTone, + profile.appearance?.primaryColor, + profile.appearance?.accentColor, + profile.appearance?.hairColor, + ]; + if (strings.some((item) => item !== undefined && typeof item !== "string")) { + throw new RangeError("actor profile fields must be strings"); + } + if ( + profile.appearance?.bodyShape !== undefined && + profile.appearance.bodyShape !== "slim" && + profile.appearance.bodyShape !== "average" && + profile.appearance.bodyShape !== "broad" + ) throw new RangeError("actor body shape is invalid"); + const copy: ActorIdentity = { + id: identity.id, + displayName: identity.displayName, + authenticated: identity.authenticated, + profile: copyProfile(profile), + }; + if (copy.profile.appearance) Object.freeze(copy.profile.appearance); + Object.freeze(copy.profile); + return Object.freeze(copy); +} + +function copyIdentity(identity: Readonly): ActorIdentity { + return { + id: identity.id, + displayName: identity.displayName, + authenticated: identity.authenticated, + profile: copyProfile(identity.profile), + }; +} + +function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizontalBounds | undefined { + if (!bounds) return undefined; + if ( + !Number.isFinite(bounds.minX) || + !Number.isFinite(bounds.maxX) || + !Number.isFinite(bounds.minZ) || + !Number.isFinite(bounds.maxZ) || + bounds.minX >= bounds.maxX || + bounds.minZ >= bounds.maxZ + ) { + throw new RangeError("actor horizontal bounds must be finite and ordered"); + } + return { ...bounds }; +} + +function checkedCrowWind(value: Partial | undefined): CrowWind { + const xMps = finiteOr(value?.xMps, 0); + const zMps = finiteOr(value?.zMps, 0); + // A malformed adapter cannot inject hurricane-scale displacement into an + // authoritative actor step. The generous cap still covers severe weather. + return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) }; +} + +function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] { + if (!value) return Object.freeze([]); + if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts"); + const checked = value.map((thermal) => { + if ( + !Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) || + !Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 || + !Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0 + ) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift"); + return Object.freeze({ + x: thermal.x, + z: thermal.z, + radiusM: clamp(thermal.radiusM, 0.5, 10_000), + liftMps: clamp(thermal.liftMps, 0, 20), + }); + }); + return Object.freeze(checked); +} + +/** Clamp malformed adapter input before it can poison authoritative state. */ +export function normalizeActorActions( + actions: Partial | undefined, +): ActorActionSnapshot { + let forward = clamp(finiteOr(actions?.forward, 0), -1, 1); + let right = clamp(finiteOr(actions?.right, 0), -1, 1); + const groundLength = Math.hypot(forward, right); + if (groundLength > 1) { + forward /= groundLength; + right /= groundLength; + } + const mode = actions?.modeRequest; + const kind = actions?.kindRequest; + return { + forward, + right, + turn: clamp(finiteOr(actions?.turn, 0), -1, 1), + pitch: clamp(finiteOr(actions?.pitch, 0), -1, 1), + climb: clamp(finiteOr(actions?.climb, 0), -1, 1), + sprint: actions?.sprint === true, + glide: actions?.glide === true, + modeRequest: mode === "ground" || mode === "flight" ? mode : "none", + kindRequest: validKind(kind) ? kind : "none", + reset: actions?.reset === true, + }; +} + +function resolveOptions(options: ActorControllerOptions): ResolvedOptions { + if (!validKind(options.kind)) throw new RangeError("unknown actor kind"); + const groundY = finiteOr(options.groundY, 0); + const minFlightAltitude = positive(options.minFlightAltitude, 0.75, "minFlightAltitude"); + const maxFlightAltitude = positive(options.maxFlightAltitude, 120, "maxFlightAltitude"); + if (maxFlightAltitude <= minFlightAltitude) { + throw new RangeError("maxFlightAltitude must exceed minFlightAltitude"); + } + const mode: ActorMode = options.kind === "crow" && options.mode === "flight" ? "flight" : "ground"; + const position = { + x: finiteOr(options.position?.x, 0), + y: mode === "flight" + ? clamp(finiteOr(options.position?.y, groundY + minFlightAltitude), groundY + minFlightAltitude, groundY + maxFlightAltitude) + : groundY, + z: finiteOr(options.position?.z, 0), + }; + const maximumFlightSpeedMps = positive(options.maximumFlightSpeedMps, 16, "maximumFlightSpeedMps"); + const minimumFlightSpeedMps = positive(options.minimumFlightSpeedMps, 4, "minimumFlightSpeedMps"); + if (maximumFlightSpeedMps < minimumFlightSpeedMps) { + throw new RangeError("maximumFlightSpeedMps must not be below minimumFlightSpeedMps"); + } + const bounds = checkedBounds(options.horizontalBounds); + if (bounds) { + position.x = clamp(position.x, bounds.minX, bounds.maxX); + position.z = clamp(position.z, bounds.minZ, bounds.maxZ); + } + return { + kind: options.kind, + identity: checkedIdentity(options.identity), + position, + yaw: wrapAngle(finiteOr(options.yaw, 0)), + pitch: mode === "flight" ? finiteOr(options.pitch, 0) : 0, + mode, + groundY, + minFlightAltitude, + maxFlightAltitude, + walkSpeedMps: positive(options.walkSpeedMps, 1.7, "walkSpeedMps"), + runSpeedMps: positive(options.runSpeedMps, 4.5, "runSpeedMps"), + dogSpeedScale: positive(options.dogSpeedScale, 1.25, "dogSpeedScale"), + groundTurnRateRadPerSecond: positive(options.groundTurnRateRadPerSecond, 2.8, "groundTurnRateRadPerSecond"), + flightTurnRateRadPerSecond: positive(options.flightTurnRateRadPerSecond, 1.75, "flightTurnRateRadPerSecond"), + maximumFlightPitchRad: clamp(positive(options.maximumFlightPitchRad, 0.72, "maximumFlightPitchRad"), 0.1, Math.PI / 2 - 0.05), + minimumFlightSpeedMps, + maximumFlightSpeedMps, + glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps), + maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"), + crowWind: checkedCrowWind(options.crowWind), + crowThermals: checkedCrowThermals(options.crowThermals), + crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1), + crowEnergyDrainPerSecond: clamp( + positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"), + 0.0001, + 1, + ), + crowEnergyRecoveryPerSecond: clamp( + positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"), + 0.0001, + 1, + ), + horizontalBounds: bounds, + fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1), + maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1), + }; +} + +export class ActorController { + private readonly options: ResolvedOptions; + private readonly current: ActorControllerState; + private accumulator = 0; + + constructor(options: ActorControllerOptions) { + this.options = resolveOptions(options); + this.options.pitch = clamp(this.options.pitch, -this.options.maximumFlightPitchRad, this.options.maximumFlightPitchRad); + this.current = { + kind: this.options.kind, + mode: this.options.mode, + identity: this.options.identity, + ...this.options.position, + yaw: this.options.yaw, + pitch: this.options.pitch, + speedMps: 0, + verticalSpeedMps: 0, + posePhase: 0, + poseAmount: 0, + gliding: false, + roll: 0, + flightEnergy: this.options.crowInitialEnergy, + liftMps: 0, + thermalLiftMps: 0, + windXMps: this.options.crowWind.xMps, + windZMps: this.options.crowWind.zMps, + crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch", + perched: this.options.kind === "crow" && this.options.mode === "ground", + altitudeBoundContact: "none", + distanceM: 0, + elapsedSteps: 0, + }; + } + + fixedStepSeconds(): number { + return this.options.fixedStepSeconds; + } + + /** Stable allocation-free view. Treat nested identity as read-only and frozen. */ + state(): Readonly { + return this.current; + } + + /** Detached JSON-safe state for persistence, networking and tests. */ + snapshot(): ActorControllerSnapshot { + return { ...this.current, identity: copyIdentity(this.current.identity) }; + } + + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: ActorControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + if ( + !numeric || !validKind(snapshot.kind) || + (snapshot.mode !== "ground" && snapshot.mode !== "flight") || + (snapshot.altitudeBoundContact !== "none" && + snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("actor snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) }); + this.applyHorizontalBounds(); + if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds(); + this.accumulator = 0; + } + + /** Replace profile/identity without changing kind, pose or position. */ + setIdentity(identity: ActorIdentity): void { + this.current.identity = checkedIdentity(identity); + } + + /** Change visible actor while preserving identity and finite world position. */ + setActorKind(kind: ActorKind): void { + if (!validKind(kind)) throw new RangeError("unknown actor kind"); + if (kind === this.current.kind) return; + this.current.kind = kind; + this.current.speedMps = 0; + this.current.verticalSpeedMps = 0; + this.current.pitch = 0; + this.current.poseAmount = 0; + this.current.gliding = false; + this.current.roll = 0; + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; + this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap"; + this.current.perched = kind === "crow" && this.current.mode === "ground"; + this.current.altitudeBoundContact = "none"; + if (kind !== "crow") this.land(); + } + + /** Request a locomotion mode without consuming a simulation step. Non-crows cannot fly. */ + setMode(mode: ActorMode): void { + if (mode === "flight" && this.current.kind === "crow") this.takeOff(); + else this.land(); + } + + /** Restore the original spawn, identity and kind and clear fractional time. */ + reset(): void { + this.accumulator = 0; + Object.assign(this.current, this.options.position, { + kind: this.options.kind, + mode: this.options.mode, + identity: this.options.identity, + yaw: this.options.yaw, + pitch: this.options.pitch, + speedMps: 0, + verticalSpeedMps: 0, + posePhase: 0, + poseAmount: 0, + gliding: false, + roll: 0, + flightEnergy: this.options.crowInitialEnergy, + liftMps: 0, + thermalLiftMps: 0, + windXMps: this.options.crowWind.xMps, + windZMps: this.options.crowWind.zMps, + crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch", + perched: this.options.kind === "crow" && this.options.mode === "ground", + altitudeBoundContact: "none", + distanceM: 0, + elapsedSteps: 0, + }); + } + + tick(deltaSeconds: number, actions: Partial = NEUTRAL_ACTOR_ACTIONS): number { + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0; + const normalized = normalizeActorActions(actions); + if (normalized.reset) { + this.reset(); + return 0; + } + this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds); + let steps = 0; + while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) { + this.stepNormalized(normalized); + this.accumulator -= this.options.fixedStepSeconds; + steps += 1; + } + return steps; + } + + stepFixed(actions: Partial = NEUTRAL_ACTOR_ACTIONS): void { + const normalized = normalizeActorActions(actions); + if (normalized.reset) { + this.reset(); + return; + } + this.stepNormalized(normalized); + } + + private stepNormalized(actions: ActorActionSnapshot): void { + if (actions.kindRequest !== "none") this.setActorKind(actions.kindRequest); + if (actions.modeRequest === "ground") this.land(); + else if (actions.modeRequest === "flight" && this.current.kind === "crow") this.takeOff(); + + if (this.current.mode === "flight" && this.current.kind === "crow") this.stepFlight(actions); + else this.stepGround(actions); + this.current.elapsedSteps += 1; + } + + private stepGround(actions: ActorActionSnapshot): void { + const dt = this.options.fixedStepSeconds; + this.current.yaw = wrapAngle( + this.current.yaw + actions.turn * this.options.groundTurnRateRadPerSecond * dt, + ); + const input = Math.hypot(actions.forward, actions.right); + const kindScale = this.current.kind === "dog" ? this.options.dogSpeedScale : 1; + const maximum = (actions.sprint ? this.options.runSpeedMps : this.options.walkSpeedMps) * kindScale; + const speed = maximum * input; + const dx = (actions.right * Math.cos(this.current.yaw) - actions.forward * Math.sin(this.current.yaw)) * maximum * dt; + const dz = (-actions.right * Math.sin(this.current.yaw) - actions.forward * Math.cos(this.current.yaw)) * maximum * dt; + const beforeX = this.current.x; + const beforeZ = this.current.z; + this.current.x += dx; + this.current.z += dz; + this.applyHorizontalBounds(); + const moved = Math.hypot(this.current.x - beforeX, this.current.z - beforeZ); + this.current.distanceM += moved; + this.current.speedMps = speed; + this.current.verticalSpeedMps = 0; + this.current.y = this.options.groundY; + this.current.pitch = 0; + this.current.gliding = false; + this.current.roll = moveToward(this.current.roll, 0, 7 * dt); + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; + this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap"; + this.current.perched = this.current.kind === "crow"; + this.current.flightEnergy = clamp( + this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt, + 0, + 1, + ); + this.current.altitudeBoundContact = "none"; + this.current.poseAmount = input; + const strideLength = this.current.kind === "dog" ? 0.54 : 0.78; + this.current.posePhase = wrapPhase(this.current.posePhase + (moved / strideLength) * TWO_PI); + } + + private stepFlight(actions: ActorActionSnapshot): void { + const dt = this.options.fixedStepSeconds; + const idleSoaring = + !actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 && + Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02; + const speedFraction = clamp( + this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001), + 0, + 2, + ); + const targetRoll = actions.turn * 0.62; + this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt); + this.current.yaw = wrapAngle( + this.current.yaw + + ( + actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) + + Math.sin(this.current.roll) * 0.34 + ) * dt, + ); + const targetPitch = actions.pitch * this.options.maximumFlightPitchRad; + this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt); + + const throttle = (actions.forward + 1) / 2; + const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1; + const recovering = actions.glide; + if (recovering) { + this.current.flightEnergy = clamp( + this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt, + 0, + 1, + ); + } else { + const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55; + this.current.flightEnergy = clamp( + this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt, + 0, + 1, + ); + } + const energyAuthority = 0.52 + this.current.flightEnergy * 0.48; + const poweredTarget = this.options.minimumFlightSpeedMps + + (this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority; + const targetSpeed = tucking + ? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45) + : actions.glide ? this.options.glideSpeedMps : poweredTarget; + const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority; + this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt); + // Parasitic drag rises with the square of airspeed. It is small enough that + // a healthy powered bird holds its requested target but makes a tucked dive + // finite instead of a perpetual acceleration source. + const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps; + this.current.speedMps = clamp( + this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25), + 0, + this.options.maximumFlightSpeedMps, + ); + + let thermalLiftMps = 0; + for (const thermal of this.options.crowThermals) { + const normalizedDistance = Math.hypot( + this.current.x - thermal.x, + this.current.z - thermal.z, + ) / thermal.radiusM; + if (normalizedDistance >= 1) continue; + const falloff = 1 - normalizedDistance; + thermalLiftMps += thermal.liftMps * falloff * falloff; + } + this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5); + const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001); + const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38); + const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority; + const tuckPenaltyMps = tucking ? 1.35 : 0; + this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps; + const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps; + const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18; + const targetVertical = + pitchLift + + actions.climb * this.options.maximumClimbSpeedMps * energyAuthority + + this.current.liftMps - + baselineSink; + this.current.verticalSpeedMps = moveToward( + this.current.verticalSpeedMps, + clamp( + targetVertical, + -this.options.maximumClimbSpeedMps * 1.35, + this.options.maximumClimbSpeedMps + this.current.thermalLiftMps, + ), + 8 * dt, + ); + + const horizontalSpeed = this.current.speedMps * Math.cos(this.current.pitch); + const beforeX = this.current.x; + const beforeY = this.current.y; + const beforeZ = this.current.z; + this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt; + this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt; + this.current.y += this.current.verticalSpeedMps * dt; + this.applyHorizontalBounds(); + this.applyAltitudeBounds(); + this.current.distanceM += Math.hypot( + this.current.x - beforeX, + this.current.y - beforeY, + this.current.z - beforeZ, + ); + this.current.gliding = actions.glide; + this.current.perched = false; + this.current.crowPose = tucking + ? "tuck" + : actions.glide + ? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide" + : idleSoaring ? "glide" : "flap"; + this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45; + if (!actions.glide) { + // A tired bird loses cadence before it loses basic control authority. + const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8); + this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt); + } + } + + private land(): void { + this.current.mode = "ground"; + this.current.y = this.options.groundY; + this.current.pitch = 0; + this.current.roll = 0; + this.current.verticalSpeedMps = 0; + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; + this.current.gliding = false; + this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap"; + this.current.perched = this.current.kind === "crow"; + this.current.altitudeBoundContact = "none"; + } + + private takeOff(): void { + this.current.mode = "flight"; + this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude); + this.current.crowPose = "flap"; + this.current.perched = false; + this.current.altitudeBoundContact = "none"; + } + + private applyHorizontalBounds(): void { + const bounds = this.options.horizontalBounds; + if (!bounds) return; + this.current.x = clamp(this.current.x, bounds.minX, bounds.maxX); + this.current.z = clamp(this.current.z, bounds.minZ, bounds.maxZ); + } + + private applyAltitudeBounds(): void { + const minimum = this.options.groundY + this.options.minFlightAltitude; + const maximum = this.options.groundY + this.options.maxFlightAltitude; + if (this.current.y <= minimum) { + this.current.y = minimum; + this.current.verticalSpeedMps = Math.max(0, this.current.verticalSpeedMps); + this.current.altitudeBoundContact = "minimum"; + } else if (this.current.y >= maximum) { + this.current.y = maximum; + this.current.verticalSpeedMps = Math.min(0, this.current.verticalSpeedMps); + this.current.altitudeBoundContact = "maximum"; + } else { + this.current.altitudeBoundContact = "none"; + } + } +} + +/** Execute an exact, allocation-friendly input recording for tests or playback. */ +export function replayActorInputs( + options: ActorControllerOptions, + frames: readonly TimedActorInputFrame[], +): ActorReplayResult { + const controller = new ActorController(options); + const trajectory: ActorControllerSnapshot[] = [controller.snapshot()]; + for (const frame of frames) { + const steps = Number.isSafeInteger(frame.steps) && frame.steps > 0 ? frame.steps : 0; + for (let index = 0; index < steps; index += 1) { + controller.stepFixed(frame.actions); + trajectory.push(controller.snapshot()); + } + } + return { trajectory, final: controller.snapshot() }; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts new file mode 100644 index 0000000..1c2a158 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts @@ -0,0 +1,595 @@ +/** Deterministic, renderer-independent fixed-wing flight over California. */ + +const EARTH_RADIUS_M = 6_371_000; +const TWO_PI = Math.PI * 2; + +export type AircraftControlMode = "assisted" | "manual"; +export type AircraftModeRequest = "none" | AircraftControlMode; + +export interface AircraftGeographicPoint { + lat: number; + lng: number; +} + +export interface AircraftWaypoint extends AircraftGeographicPoint { + id: string; + altitudeM: number; +} + +export interface AircraftActionSnapshot { + throttle: number; + yaw: number; + pitch: number; + roll: number; + modeRequest: AircraftModeRequest; + reset: boolean; +} + +export const NEUTRAL_AIRCRAFT_ACTIONS: Readonly = Object.freeze({ + throttle: 0, + yaw: 0, + pitch: 0, + roll: 0, + modeRequest: "none", + reset: false, +}); + +export interface CaliforniaFlightEnvelope { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + minAltitudeM: number; + maxAltitudeM: number; +} + +export const DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE: Readonly = + Object.freeze({ + minLat: 32.4, + maxLat: 42.1, + minLng: -124.6, + maxLng: -114.0, + minAltitudeM: 75, + maxAltitudeM: 6_000, + }); + +export interface AircraftControllerOptions { + initialPosition?: Partial; + initialAltitudeM?: number; + initialHeadingDeg?: number; + initialSpeedMps?: number; + mode?: AircraftControlMode; + route?: readonly AircraftWaypoint[]; + envelope?: CaliforniaFlightEnvelope; + minimumSpeedMps?: number; + maximumSpeedMps?: number; + assistedCruiseMps?: number; + assistedAltitudeM?: number; + /** Deterministic scenario wind, positive north/east in metres per second. */ + windNorthMps?: number; + windEastMps?: number; + /** Deterministic sinusoidal gust amplitude. Zero disables turbulence. */ + turbulenceMps?: number; + stallSpeedMps?: number; + batteryCapacityWh?: number; + /** Terrain/runway elevation callback used for ground contact and landing. */ + terrainElevationM?: (lat: number, lng: number) => number; + fixedStepSeconds?: number; + maxFrameDeltaSeconds?: number; +} + +export interface AircraftControllerState extends AircraftGeographicPoint { + altitudeM: number; + headingDeg: number; + pitchDeg: number; + rollDeg: number; + speedMps: number; + verticalSpeedMps: number; + mode: AircraftControlMode; + routeWaypointIndex: number; + routeWaypointId: string | null; + throttle: number; + yawInput: number; + pitchInput: number; + rollInput: number; + fanRadians: number; + angleOfAttackDeg: number; + liftCoefficient: number; + loadFactorG: number; + stalled: boolean; + windNorthMps: number; + windEastMps: number; + batteryWh: number; + energyUsedWh: number; + groundClearanceM: number; + onGround: boolean; + hardLanding: boolean; + envelopeContact: boolean; + elapsedSteps: number; +} + +export interface AircraftControllerSnapshot extends AircraftControllerState {} + +export interface TimedAircraftInputFrame { + steps: number; + actions?: Partial; +} + +export interface AircraftReplayResult { + trajectory: readonly AircraftControllerSnapshot[]; + final: AircraftControllerSnapshot; +} + +export interface AircraftCameraPose { + position: AircraftGeographicPoint & { altitudeM: number }; + target: AircraftGeographicPoint & { altitudeM: number }; + rollDeg: number; +} + +interface ResolvedOptions { + initialPosition: AircraftGeographicPoint; + initialAltitudeM: number; + initialHeadingDeg: number; + initialSpeedMps: number; + mode: AircraftControlMode; + route: readonly AircraftWaypoint[]; + envelope: CaliforniaFlightEnvelope; + minimumSpeedMps: number; + maximumSpeedMps: number; + assistedCruiseMps: number; + assistedAltitudeM: number; + windNorthMps: number; + windEastMps: number; + turbulenceMps: number; + stallSpeedMps: number; + batteryCapacityWh: number; + terrainElevationM: (lat: number, lng: number) => number; + fixedStepSeconds: number; + maxFrameDeltaSeconds: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function finiteOr(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function moveToward(value: number, target: number, maximumDelta: number): number { + if (value < target) return Math.min(value + maximumDelta, target); + if (value > target) return Math.max(value - maximumDelta, target); + return value; +} + +function wrapDegrees(value: number): number { + return ((value % 360) + 360) % 360; +} + +function signedAngleDegrees(from: number, to: number): number { + return ((to - from + 540) % 360) - 180; +} + +function distanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number { + const mean = ((a.lat + b.lat) / 2) * Math.PI / 180; + const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M; + const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M; + return Math.hypot(north, east); +} + +function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number { + const mean = ((a.lat + b.lat) / 2) * Math.PI / 180; + return wrapDegrees(Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI); +} + +function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): CaliforniaFlightEnvelope { + const envelope = { ...(value ?? DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE) }; + if ( + !Number.isFinite(envelope.minLat) || !Number.isFinite(envelope.maxLat) || + !Number.isFinite(envelope.minLng) || !Number.isFinite(envelope.maxLng) || + !Number.isFinite(envelope.minAltitudeM) || !Number.isFinite(envelope.maxAltitudeM) || + envelope.minLat >= envelope.maxLat || envelope.minLng >= envelope.maxLng || + envelope.minAltitudeM >= envelope.maxAltitudeM + ) throw new RangeError("aircraft flight envelope must be finite and ordered"); + return envelope; +} + +function checkedRoute( + route: readonly AircraftWaypoint[] | undefined, + envelope: CaliforniaFlightEnvelope, +): readonly AircraftWaypoint[] { + if (!route) return []; + const ids = new Set(); + return route.map((point) => { + if ( + typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) || + !Number.isFinite(point.lat) || !Number.isFinite(point.lng) || + !Number.isFinite(point.altitudeM) || + point.lat < envelope.minLat || point.lat > envelope.maxLat || + point.lng < envelope.minLng || point.lng > envelope.maxLng || + point.altitudeM < envelope.minAltitudeM || point.altitudeM > envelope.maxAltitudeM + ) throw new RangeError( + "aircraft route waypoints must be finite, unique, and inside the flight envelope", + ); + ids.add(point.id); + return { ...point }; + }); +} + +function resolveOptions(value: AircraftControllerOptions): ResolvedOptions { + const envelope = checkedEnvelope(value.envelope); + const minimumSpeedMps = clamp(finiteOr(value.minimumSpeedMps, 20), 5, 100); + const maximumSpeedMps = clamp(finiteOr(value.maximumSpeedMps, 95), minimumSpeedMps, 250); + const assistedAltitudeM = clamp( + finiteOr(value.assistedAltitudeM, 1_500), + envelope.minAltitudeM, + envelope.maxAltitudeM, + ); + const stallSpeedMps = clamp( + finiteOr(value.stallSpeedMps, Math.max(18, minimumSpeedMps + 3)), + minimumSpeedMps, + maximumSpeedMps * 0.8, + ); + const terrainElevationM = value.terrainElevationM ?? (() => envelope.minAltitudeM); + if (typeof terrainElevationM !== "function") { + throw new RangeError("aircraft terrainElevationM must be a function"); + } + return { + initialPosition: { + lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat), + lng: clamp(finiteOr(value.initialPosition?.lng, -118.2437), envelope.minLng, envelope.maxLng), + }, + initialAltitudeM: clamp(finiteOr(value.initialAltitudeM, assistedAltitudeM), envelope.minAltitudeM, envelope.maxAltitudeM), + initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)), + initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps), + mode: value.mode === "manual" ? "manual" : "assisted", + route: checkedRoute(value.route, envelope), + envelope, + minimumSpeedMps, + maximumSpeedMps, + assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps), + assistedAltitudeM, + windNorthMps: clamp(finiteOr(value.windNorthMps, 0), -80, 80), + windEastMps: clamp(finiteOr(value.windEastMps, 0), -80, 80), + turbulenceMps: clamp(finiteOr(value.turbulenceMps, 0), 0, 30), + stallSpeedMps, + batteryCapacityWh: clamp(finiteOr(value.batteryCapacityWh, 54_000), 1_000, 500_000), + terrainElevationM, + fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1), + maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1), + }; +} + +export function normalizeAircraftActions( + value: Partial | undefined, +): AircraftActionSnapshot { + return { + throttle: clamp(finiteOr(value?.throttle, 0), 0, 1), + yaw: clamp(finiteOr(value?.yaw, 0), -1, 1), + pitch: clamp(finiteOr(value?.pitch, 0), -1, 1), + roll: clamp(finiteOr(value?.roll, 0), -1, 1), + modeRequest: value?.modeRequest === "manual" || value?.modeRequest === "assisted" + ? value.modeRequest + : "none", + reset: value?.reset === true, + }; +} + +function hasManualIntent(actions: AircraftActionSnapshot): boolean { + return ( + actions.modeRequest === "manual" || actions.throttle > 0.02 || + Math.abs(actions.yaw) > 0.06 || Math.abs(actions.pitch) > 0.06 || Math.abs(actions.roll) > 0.06 + ); +} + +export class AircraftController { + private readonly options: ResolvedOptions; + private accumulator = 0; + private readonly current: AircraftControllerState; + + constructor(options: AircraftControllerOptions = {}) { + this.options = resolveOptions(options); + this.current = { + ...this.options.initialPosition, + altitudeM: this.options.initialAltitudeM, + headingDeg: this.options.initialHeadingDeg, + pitchDeg: 0, + rollDeg: 0, + speedMps: this.options.initialSpeedMps, + verticalSpeedMps: 0, + mode: this.options.mode, + routeWaypointIndex: 0, + routeWaypointId: null, + throttle: 0, + yawInput: 0, + pitchInput: 0, + rollInput: 0, + fanRadians: 0, + angleOfAttackDeg: 0, + liftCoefficient: 1, + loadFactorG: 1, + stalled: false, + windNorthMps: this.options.windNorthMps, + windEastMps: this.options.windEastMps, + batteryWh: this.options.batteryCapacityWh, + energyUsedWh: 0, + groundClearanceM: 0, + onGround: false, + hardLanding: false, + envelopeContact: false, + elapsedSteps: 0, + }; + this.reset(); + } + + fixedStepSeconds(): number { + return this.options.fixedStepSeconds; + } + + state(): Readonly { + return this.current; + } + + snapshot(): AircraftControllerSnapshot { + return { ...this.current }; + } + + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: AircraftControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + const envelope = this.options.envelope; + if ( + !numeric || (snapshot.mode !== "manual" && snapshot.mode !== "assisted") || + snapshot.lat < envelope.minLat || snapshot.lat > envelope.maxLat || + snapshot.lng < envelope.minLng || snapshot.lng > envelope.maxLng || + snapshot.altitudeM < envelope.minAltitudeM || snapshot.altitudeM > envelope.maxAltitudeM || + snapshot.speedMps < this.options.minimumSpeedMps || + snapshot.speedMps > this.options.maximumSpeedMps || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("aircraft snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot); + this.accumulator = 0; + } + + reset(): void { + this.accumulator = 0; + Object.assign(this.current, this.options.initialPosition, { + altitudeM: this.options.initialAltitudeM, + headingDeg: this.options.initialHeadingDeg, + pitchDeg: 0, + rollDeg: 0, + speedMps: this.options.initialSpeedMps, + verticalSpeedMps: 0, + mode: this.options.mode, + routeWaypointIndex: 0, + routeWaypointId: this.options.route[0]?.id ?? null, + throttle: 0, + yawInput: 0, + pitchInput: 0, + rollInput: 0, + fanRadians: 0, + angleOfAttackDeg: 0, + liftCoefficient: 1, + loadFactorG: 1, + stalled: false, + windNorthMps: this.options.windNorthMps, + windEastMps: this.options.windEastMps, + batteryWh: this.options.batteryCapacityWh, + energyUsedWh: 0, + groundClearanceM: Math.max(0, this.options.initialAltitudeM - this.groundElevationM( + this.options.initialPosition.lat, + this.options.initialPosition.lng, + )), + onGround: false, + hardLanding: false, + envelopeContact: false, + elapsedSteps: 0, + }); + } + + private groundElevationM(lat: number, lng: number): number { + const value = this.options.terrainElevationM(lat, lng); + if (!Number.isFinite(value)) { + throw new RangeError("aircraft terrainElevationM must return a finite elevation"); + } + return clamp(value, this.options.envelope.minAltitudeM, this.options.envelope.maxAltitudeM); + } + + tick(deltaSeconds: number, actions: Partial = NEUTRAL_AIRCRAFT_ACTIONS): number { + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0; + const normalized = normalizeAircraftActions(actions); + if (normalized.reset) { + this.reset(); + return 0; + } + this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds); + let steps = 0; + while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) { + this.stepNormalized(normalized); + this.accumulator -= this.options.fixedStepSeconds; + steps += 1; + } + return steps; + } + + stepFixed(actions: Partial = NEUTRAL_AIRCRAFT_ACTIONS): void { + const normalized = normalizeAircraftActions(actions); + if (normalized.reset) this.reset(); + else this.stepNormalized(normalized); + } + + private stepNormalized(actions: AircraftActionSnapshot): void { + const dt = this.options.fixedStepSeconds; + const manual = hasManualIntent(actions); + if (manual) this.current.mode = "manual"; + else if (actions.modeRequest === "assisted") this.current.mode = "assisted"; + + let throttle = actions.throttle; + let yaw = actions.yaw; + let pitch = actions.pitch; + let roll = actions.roll; + if (this.current.mode === "assisted") { + throttle = clamp(0.5 + (this.options.assistedCruiseMps - this.current.speedMps) / 18, 0, 1); + const target = this.options.route[this.current.routeWaypointIndex]; + if (target && distanceM(this.current, target) < 3_000 && this.options.route.length > 1) { + this.current.routeWaypointIndex = (this.current.routeWaypointIndex + 1) % this.options.route.length; + } + const waypoint = this.options.route[this.current.routeWaypointIndex]; + this.current.routeWaypointId = waypoint?.id ?? null; + const desiredHeading = waypoint ? bearingDeg(this.current, waypoint) : this.options.initialHeadingDeg; + const headingError = signedAngleDegrees(this.current.headingDeg, desiredHeading); + roll = clamp(headingError / 38, -1, 1); + yaw = clamp(headingError / 90, -0.45, 0.45); + const altitudeTarget = waypoint?.altitudeM ?? this.options.assistedAltitudeM; + pitch = clamp((altitudeTarget - this.current.altitudeM) / 350, -0.65, 0.65); + } + + this.current.throttle = moveToward(this.current.throttle, throttle, 0.8 * dt); + this.current.yawInput = moveToward(this.current.yawInput, yaw, 2.5 * dt); + this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt); + this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt); + + const gustPhase = this.current.elapsedSteps * dt * 0.73; + const gustNorth = Math.sin(gustPhase) * this.options.turbulenceMps; + const gustEast = Math.sin(gustPhase * 0.61 + 1.7) * this.options.turbulenceMps * 0.72; + this.current.windNorthMps = this.options.windNorthMps + gustNorth; + this.current.windEastMps = this.options.windEastMps + gustEast; + + const targetRoll = this.current.rollInput * 58 + gustEast * 0.22; + const targetPitch = this.current.pitchInput * 22 + gustNorth * 0.08; + this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt); + this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt); + const flightPathDeg = Math.atan2( + this.current.verticalSpeedMps, + Math.max(1, this.current.speedMps), + ) * 180 / Math.PI; + this.current.angleOfAttackDeg = this.current.pitchDeg - flightPathDeg; + this.current.liftCoefficient = clamp(1 + this.current.angleOfAttackDeg * 0.055, 0.05, 1.6); + this.current.stalled = this.current.speedMps < this.options.stallSpeedMps || + Math.abs(this.current.angleOfAttackDeg) > 19; + const stallDrag = this.current.stalled ? 3.8 : 0; + const batteryFactor = clamp(this.current.batteryWh / Math.max(1, this.options.batteryCapacityWh * 0.08), 0, 1); + const thrust = this.current.throttle * 8.5 * batteryFactor; + const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075 + stallDrag; + this.current.speedMps = clamp( + this.current.speedMps + (thrust - drag) * dt, + this.options.minimumSpeedMps, + this.options.maximumSpeedMps, + ); + const bankTurn = Math.sin(this.current.rollDeg * Math.PI / 180) * 24; + this.current.headingDeg = wrapDegrees( + this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt, + ); + const liftAuthority = clamp( + this.current.liftCoefficient * (this.current.speedMps / Math.max(1, this.options.assistedCruiseMps)), + 0.08, + 1.35, + ); + const commandedVerticalSpeed = Math.sin(this.current.pitchDeg * Math.PI / 180) * + this.current.speedMps * liftAuthority; + const stallSinkMps = this.current.stalled + ? clamp((this.options.stallSpeedMps - this.current.speedMps) * 0.7 + 2.5, 2.5, 14) + : 0; + this.current.verticalSpeedMps = moveToward( + this.current.verticalSpeedMps, + commandedVerticalSpeed - stallSinkMps, + (this.current.stalled ? 8 : 5) * dt, + ); + this.current.loadFactorG = clamp( + liftAuthority / Math.max(0.25, Math.cos(this.current.rollDeg * Math.PI / 180)), + 0, + 3.5, + ); + + const heading = this.current.headingDeg * Math.PI / 180; + const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps; + const northM = (Math.cos(heading) * horizontalSpeed + this.current.windNorthMps) * dt; + const eastM = (Math.sin(heading) * horizontalSpeed + this.current.windEastMps) * dt; + const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI; + const nextLng = this.current.lng + eastM / + (EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI; + let nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt; + const groundElevationM = this.groundElevationM(nextLat, nextLng); + const wasVerticalSpeedMps = this.current.verticalSpeedMps; + this.current.onGround = nextAltitude <= groundElevationM + 0.75; + this.current.hardLanding = this.current.onGround && wasVerticalSpeedMps < -4.5; + if (this.current.onGround) { + nextAltitude = groundElevationM; + this.current.verticalSpeedMps = 0; + this.current.rollDeg = moveToward(this.current.rollDeg, 0, 70 * dt); + this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 45 * dt); + if (this.current.throttle < 0.55) { + this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps - 4 * dt); + } else if ( + this.current.speedMps > this.options.stallSpeedMps * 1.08 && + this.current.pitchInput > 0.2 + ) { + this.current.onGround = false; + nextAltitude = groundElevationM + 0.8; + this.current.verticalSpeedMps = 0.8; + } + } + const envelope = this.options.envelope; + this.current.envelopeContact = + nextLat < envelope.minLat || nextLat > envelope.maxLat || + nextLng < envelope.minLng || nextLng > envelope.maxLng || + nextAltitude < envelope.minAltitudeM || nextAltitude > envelope.maxAltitudeM; + this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat); + this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng); + this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM); + this.current.groundClearanceM = Math.max(0, this.current.altitudeM - groundElevationM); + if (this.current.envelopeContact) { + this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92); + this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt); + this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt); + } + this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI; + const electricalPowerW = 8_000 + this.current.throttle * 122_000 + + Math.abs(this.current.verticalSpeedMps) * 420; + const usedWh = Math.min(this.current.batteryWh, electricalPowerW * dt / 3_600); + this.current.batteryWh -= usedWh; + this.current.energyUsedWh += usedWh; + this.current.elapsedSteps += 1; + } +} + +export function replayAircraftInputs( + options: AircraftControllerOptions, + frames: readonly TimedAircraftInputFrame[], +): AircraftReplayResult { + const controller = new AircraftController(options); + const trajectory: AircraftControllerSnapshot[] = [controller.snapshot()]; + for (const frame of frames) { + const steps = Number.isFinite(frame.steps) ? Math.max(0, Math.floor(frame.steps)) : 0; + const held = normalizeAircraftActions(frame.actions); + for (let index = 0; index < steps; index += 1) { + controller.stepFixed(index === 0 ? held : { ...held, modeRequest: "none", reset: false }); + trajectory.push(controller.snapshot()); + } + } + return { trajectory, final: trajectory.at(-1) ?? controller.snapshot() }; +} + +/** Renderer-neutral geographic chase camera derived from an authoritative pose. */ +export function aircraftChaseCameraPose( + state: Readonly, + distanceBehindM = 24, + heightAboveM = 8, + lookAheadM = 35, +): AircraftCameraPose { + const heading = state.headingDeg * Math.PI / 180; + const geographicOffset = (northM: number, eastM: number): AircraftGeographicPoint => ({ + lat: state.lat + northM / EARTH_RADIUS_M * 180 / Math.PI, + lng: state.lng + eastM / + (EARTH_RADIUS_M * Math.max(0.01, Math.cos(state.lat * Math.PI / 180))) * 180 / Math.PI, + }); + const behind = geographicOffset(-Math.cos(heading) * distanceBehindM, -Math.sin(heading) * distanceBehindM); + const ahead = geographicOffset(Math.cos(heading) * lookAheadM, Math.sin(heading) * lookAheadM); + return { + position: { ...behind, altitudeM: state.altitudeM + heightAboveM }, + target: { ...ahead, altitudeM: state.altitudeM + state.verticalSpeedMps * 0.4 }, + rollDeg: state.rollDeg * 0.2, + }; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts new file mode 100644 index 0000000..5645665 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts @@ -0,0 +1,273 @@ +import { arenaChecksum } from "./checksum.ts"; +import type { ArenaScenarioRegistry } from "./scenarios.ts"; +import { + ARENA_API_VERSION, + type ArenaEnvironment, + type ArenaInfo, + type ArenaManifest, + type ArenaReplayResult, + type ArenaResetResult, + type ArenaScenario, + type ArenaScenarioRequest, + type ArenaSnapshot, + type ArenaSourceHashes, + type ArenaStepResult, + type ArenaTraceEnvelope, + type ArenaTraceStep, +} from "./types.ts"; + +export interface SimulationTransition> { + observation: O; + rewardComponents: R; + terminated?: boolean; + terminalReason?: string; +} + +/** Shared episode, checksum, snapshot and replay semantics for concrete environments. */ +export abstract class BaseArenaEnvironment< + A, + O, + R extends Record, + S, + P extends object, +> implements ArenaEnvironment { + abstract readonly manifest: ArenaManifest; + protected abstract readonly registry: ArenaScenarioRegistry

; + protected abstract readonly sourceHashes: ArenaSourceHashes; + + private scenario: ArenaScenario

| null = null; + private stepIndex = 0; + private cumulativeReward = 0; + private terminated = false; + private truncated = false; + private terminalReason: string | null = null; + private initialStateChecksum = ""; + private frames: ArenaTraceStep[] = []; + + protected abstract resetSimulation(scenario: ArenaScenario

): O; + protected abstract normalizeAction(action: A): A; + protected abstract advanceSimulation(action: A): SimulationTransition; + protected abstract simulationSnapshot(): S; + protected abstract restoreSimulation(snapshot: S): O; + + reset(seed: number, request: string | ArenaScenarioRequest): ArenaResetResult { + this.scenario = this.registry.resolve(seed, request); + this.stepIndex = 0; + this.cumulativeReward = 0; + this.terminated = false; + this.truncated = false; + this.terminalReason = null; + this.frames = []; + const observation = this.resetSimulation(this.scenario); + this.initialStateChecksum = arenaChecksum(this.statePayload()); + return { observation, info: this.info() }; + } + + step(rawAction: A): ArenaStepResult { + this.requireReset(); + if (this.terminated || this.truncated) { + throw new Error("arena episode is complete; call reset before step"); + } + const action = this.normalizeAction(rawAction); + const transition = this.advanceSimulation(action); + const values = Object.values(transition.rewardComponents); + if (values.some((value) => !Number.isFinite(value))) { + throw new Error("arena reward components must be finite"); + } + const reward = values.reduce((sum, value) => sum + value, 0); + this.stepIndex += 1; + this.cumulativeReward += reward; + this.terminated = transition.terminated === true; + this.truncated = !this.terminated && this.stepIndex >= this.manifest.maxSteps; + this.terminalReason = transition.terminalReason ?? (this.truncated ? "max-steps" : null); + const stateChecksum = arenaChecksum(this.statePayload()); + this.frames.push({ + index: this.stepIndex, + action: structuredClone(action), + reward, + rewardComponents: structuredClone(transition.rewardComponents), + terminated: this.terminated, + truncated: this.truncated, + terminalReason: this.terminalReason, + stateChecksum, + }); + return { + observation: transition.observation, + reward, + rewardComponents: structuredClone(transition.rewardComponents), + terminated: this.terminated, + truncated: this.truncated, + info: this.info(stateChecksum), + }; + } + + snapshot(): ArenaSnapshot { + const scenario = this.requireReset(); + const core = { + apiVersion: ARENA_API_VERSION, + envId: this.manifest.id, + envVersion: this.manifest.version, + envHash: this.envHash(), + seed: scenario.seed, + scenarioId: scenario.id, + scenarioSplit: scenario.split, + scenarioHash: scenario.hash, + step: this.stepIndex, + cumulativeReward: this.cumulativeReward, + terminated: this.terminated, + truncated: this.truncated, + terminalReason: this.terminalReason, + simulation: structuredClone(this.simulationSnapshot()), + }; + return { ...core, checksum: arenaChecksum(core) }; + } + + restore(snapshot: ArenaSnapshot): ArenaResetResult { + const { checksum, ...core } = snapshot; + if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch"); + if ( + snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id || + snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash() + ) throw new Error("arena snapshot is incompatible with this environment"); + if ( + !Number.isSafeInteger(snapshot.step) || snapshot.step < 0 || + snapshot.step > this.manifest.maxSteps || !Number.isFinite(snapshot.cumulativeReward) || + typeof snapshot.terminated !== "boolean" || typeof snapshot.truncated !== "boolean" || + (snapshot.terminated && snapshot.truncated) || + (snapshot.terminalReason !== null && typeof snapshot.terminalReason !== "string") + ) throw new Error("arena snapshot episode state is invalid"); + const resolved = this.registry.resolve(snapshot.seed, { + split: snapshot.scenarioSplit, + id: snapshot.scenarioId, + }); + if (resolved.hash !== snapshot.scenarioHash) throw new Error("arena scenario hash mismatch"); + this.scenario = resolved; + this.stepIndex = snapshot.step; + this.cumulativeReward = snapshot.cumulativeReward; + this.terminated = snapshot.terminated; + this.truncated = snapshot.truncated; + this.terminalReason = snapshot.terminalReason; + this.frames = []; + const observation = this.restoreSimulation(structuredClone(snapshot.simulation)); + this.initialStateChecksum = arenaChecksum(this.statePayload()); + return { observation, info: this.info() }; + } + + trace(): ArenaTraceEnvelope { + const scenario = this.requireReset(); + const core = { + apiVersion: ARENA_API_VERSION, + envId: this.manifest.id, + envVersion: this.manifest.version, + envHash: this.envHash(), + scenarioId: scenario.id, + scenarioSplit: scenario.split, + scenarioHash: scenario.hash, + sourceHashes: this.sourceHashes, + seed: scenario.seed, + initialStateChecksum: this.initialStateChecksum, + steps: structuredClone(this.frames), + finalStateChecksum: arenaChecksum(this.statePayload()), + cumulativeReward: this.cumulativeReward, + }; + return { ...core, checksum: arenaChecksum(core) }; + } + + replay(trace: ArenaTraceEnvelope): ArenaReplayResult { + const { checksum, ...core } = trace; + if (arenaChecksum(core) !== checksum) throw new Error("arena trace checksum mismatch"); + if ( + trace.apiVersion !== ARENA_API_VERSION || trace.envId !== this.manifest.id || + trace.envVersion !== this.manifest.version || + trace.envHash !== this.envHash() || arenaChecksum(trace.sourceHashes) !== arenaChecksum(this.sourceHashes) + ) throw new Error("arena trace is incompatible with this environment"); + if ( + !Array.isArray(trace.steps) || trace.steps.length > this.manifest.maxSteps || + !Number.isFinite(trace.cumulativeReward) + ) throw new Error("arena trace episode state is invalid"); + let reset = this.reset(trace.seed, { split: trace.scenarioSplit, id: trace.scenarioId }); + if (trace.scenarioHash !== reset.info.scenarioHash || trace.initialStateChecksum !== reset.info.stateChecksum) { + throw new Error("arena trace initial state mismatch"); + } + let observation = reset.observation; + for (let offset = 0; offset < trace.steps.length; offset += 1) { + const expected = trace.steps[offset]!; + if ( + expected.index !== offset + 1 || !Number.isFinite(expected.reward) || + typeof expected.terminated !== "boolean" || typeof expected.truncated !== "boolean" || + (expected.terminated && expected.truncated) + ) throw new Error(`arena trace frame ${offset + 1} is invalid`); + const actual = this.step(expected.action); + observation = actual.observation; + if ( + actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward || + actual.terminated !== expected.terminated || actual.truncated !== expected.truncated || + arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents) + ) throw new Error(`arena trace diverged at step ${expected.index}`); + } + const replayed = this.trace(); + if ( + replayed.finalStateChecksum !== trace.finalStateChecksum || + replayed.cumulativeReward !== trace.cumulativeReward + ) throw new Error("arena trace final state mismatch"); + return { + observation, + steps: this.stepIndex, + cumulativeReward: this.cumulativeReward, + finalStateChecksum: replayed.finalStateChecksum, + }; + } + + protected currentScenario(): ArenaScenario

{ + return this.requireReset(); + } + + protected currentStep(): number { + return this.stepIndex; + } + + private requireReset(): ArenaScenario

{ + if (!this.scenario) throw new Error("arena environment must be reset before use"); + return this.scenario; + } + + private envHash(): string { + return arenaChecksum(this.manifest); + } + + private statePayload(): object { + const scenario = this.requireReset(); + return { + envId: this.manifest.id, + envVersion: this.manifest.version, + seed: scenario.seed, + scenarioHash: scenario.hash, + step: this.stepIndex, + cumulativeReward: this.cumulativeReward, + terminated: this.terminated, + truncated: this.truncated, + terminalReason: this.terminalReason, + simulation: this.simulationSnapshot(), + }; + } + + private info(checksum = arenaChecksum(this.statePayload())): ArenaInfo { + const scenario = this.requireReset(); + return { + apiVersion: ARENA_API_VERSION, + envId: this.manifest.id, + envVersion: this.manifest.version, + envHash: this.envHash(), + scenarioId: scenario.id, + scenarioSplit: scenario.split, + scenarioHash: scenario.hash, + simulatorHash: this.sourceHashes.simulator, + environmentSourceHash: this.sourceHashes.environment, + seed: scenario.seed, + step: this.stepIndex, + maxSteps: this.manifest.maxSteps, + terminalReason: this.terminalReason, + stateChecksum: checksum, + }; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts new file mode 100644 index 0000000..49ac17f --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts @@ -0,0 +1,315 @@ +import { + AircraftController, + type AircraftControllerSnapshot, + type AircraftGeographicPoint, +} from "../aircraft/controller.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { + ARENA_API_VERSION, + type ArenaManifest, + type ArenaScenario, +} from "./types.ts"; + +export interface CaliforniaFlightAction { + throttle: number; + yaw: number; + pitch: number; + roll: number; +} + +export interface CaliforniaFlightObservation { + lat: number; + lng: number; + altitudeM: number; + headingDeg: number; + pitchDeg: number; + rollDeg: number; + speedMps: number; + verticalSpeedMps: number; + goalLat: number; + goalLng: number; + goalAltitudeM: number; + distanceToGoalM: number; + bearingToGoalDeg: number; + altitudeErrorM: number; + envelopeContact: boolean; +} + +export type CaliforniaFlightReward = Record< + "progress" | "success" | "time" | "altitude" | "control" | "safety", + number +>; + +interface FlightScenarioParameters { + startLat: number; + startLng: number; + startAltitudeM: number; + startHeadingDeg: number; + goalLat: number; + goalLng: number; + goalAltitudeM: number; +} + +interface FlightSimulationSnapshot { + controller: AircraftControllerSnapshot; + previousGoalDistanceM: number; +} + +const EARTH_RADIUS_M = 6_371_000; +const FIXED_STEP = 0.1; +const MAX_STEPS = 500; +const SUCCESS_RADIUS_M = 90; + +const DEFINITIONS = [ + { + id: "train-la-east-leg", + split: "train" as const, + parameters: { + startLat: 34.0522, startLng: -118.2437, startAltitudeM: 1_200, startHeadingDeg: 0, + goalLat: 34.0522, goalLng: -118.2325, goalAltitudeM: 1_200, + }, + }, + { + id: "train-bay-west-climb", + split: "train" as const, + parameters: { + startLat: 37.70, startLng: -122.30, startAltitudeM: 1_350, startHeadingDeg: 15, + goalLat: 37.70, goalLng: -122.313, goalAltitudeM: 1_450, + }, + }, + { + id: "dev-socal-southeast", + split: "dev" as const, + parameters: { + startLat: 34.20, startLng: -118.40, startAltitudeM: 1_500, startHeadingDeg: 330, + goalLat: 34.194, goalLng: -118.391, goalAltitudeM: 1_380, + }, + }, + { + id: "dev-bay-northeast", + split: "dev" as const, + parameters: { + startLat: 37.62, startLng: -122.35, startAltitudeM: 1_100, startHeadingDeg: 280, + goalLat: 37.628, goalLng: -122.341, goalAltitudeM: 1_260, + }, + }, +] as const; + +export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry( + "california-flight-v1", + DEFINITIONS, + (base, random) => { + const northJitter = random.between(-0.00015, 0.00015); + const eastJitter = random.between(-0.00015, 0.00015); + return { + ...base, + startLat: base.startLat + northJitter, + startLng: base.startLng + eastJitter, + startAltitudeM: base.startAltitudeM + random.between(-12, 12), + startHeadingDeg: base.startHeadingDeg + random.between(-3, 3), + goalLat: base.goalLat - northJitter, + goalLng: base.goalLng - eastJitter, + goalAltitudeM: base.goalAltitudeM + random.between(-12, 12), + }; + }, +); + +export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "california-flight-v1", + version: 1, + title: "California electric-flight waypoint", + description: "Manual fixed-wing waypoint control over Tera's renderer-neutral aircraft simulator.", + simulator: "AircraftController", + fixedStepSeconds: FIXED_STEP, + maxSteps: MAX_STEPS, + actionFields: ["throttle", "yaw", "pitch", "roll"], + observationFields: [ + "lat", "lng", "altitudeM", "headingDeg", "pitchDeg", "rollDeg", "speedMps", + "verticalSpeedMps", "goalLat", "goalLng", "goalAltitudeM", "distanceToGoalM", + "bearingToGoalDeg", "altitudeErrorM", "envelopeContact", + ], + rewardComponents: { + progress: "Reduction in three-dimensional waypoint distance.", + success: "Sparse arrival bonus.", + time: "Per-step pressure; straight-ahead inaction misses lateral goals.", + altitude: "Counterweight on altitude error.", + control: "Small cost on throttle and surface demand.", + safety: "Terminal California flight-envelope penalty.", + }, + safetyTerminals: ["flight-envelope-contact"], + scenarioIds: { + train: CALIFORNIA_FLIGHT_SCENARIOS.ids("train"), + dev: CALIFORNIA_FLIGHT_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: "Zero surfaces and throttle fly straight past the lateral waypoint and remain below zero return.", + scripted: "Proportional bearing, bank, yaw and altitude guidance completes every public scenario.", + }, +}); + +export const CALIFORNIA_FLIGHT_INACTION: Readonly = Object.freeze({ + throttle: 0, yaw: 0, pitch: 0, roll: 0, +}); + +function horizontalDistanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number { + const mean = (a.lat + b.lat) / 2 * Math.PI / 180; + const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M; + const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M; + return Math.hypot(north, east); +} + +function distance3dM( + a: AircraftGeographicPoint & { altitudeM: number }, + b: AircraftGeographicPoint & { altitudeM: number }, +): number { + return Math.hypot(horizontalDistanceM(a, b), b.altitudeM - a.altitudeM); +} + +function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number { + const mean = (a.lat + b.lat) / 2 * Math.PI / 180; + return (Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI + 360) % 360; +} + +function signedAngleDeg(from: number, to: number): number { + return ((to - from + 540) % 360) - 180; +} + +export function californiaFlightScriptedBaseline( + observation: CaliforniaFlightObservation, +): CaliforniaFlightAction { + const headingError = signedAngleDeg(observation.headingDeg, observation.bearingToGoalDeg); + return { + throttle: 0.62, + yaw: Math.max(-0.6, Math.min(0.6, headingError / 80)), + pitch: Math.max(-0.7, Math.min(0.7, observation.altitudeErrorM / 220)), + roll: Math.max(-1, Math.min(1, headingError / 42)), + }; +} + +export class CaliforniaFlightEnvironment extends BaseArenaEnvironment< + CaliforniaFlightAction, + CaliforniaFlightObservation, + CaliforniaFlightReward, + FlightSimulationSnapshot, + FlightScenarioParameters +> { + readonly manifest = CALIFORNIA_FLIGHT_MANIFEST; + protected readonly registry = CALIFORNIA_FLIGHT_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["california-flight-v1"]!; + private controller: AircraftController | null = null; + private previousGoalDistanceM = 0; + + protected resetSimulation(scenario: ArenaScenario): CaliforniaFlightObservation { + const parameters = scenario.parameters; + const altitudeMin = Math.min(parameters.startAltitudeM, parameters.goalAltitudeM); + const altitudeMax = Math.max(parameters.startAltitudeM, parameters.goalAltitudeM); + this.controller = new AircraftController({ + initialPosition: { lat: parameters.startLat, lng: parameters.startLng }, + initialAltitudeM: parameters.startAltitudeM, + initialHeadingDeg: parameters.startHeadingDeg, + initialSpeedMps: 38, + mode: "manual", + fixedStepSeconds: FIXED_STEP, + envelope: { + minLat: Math.min(parameters.startLat, parameters.goalLat) - 0.02, + maxLat: Math.max(parameters.startLat, parameters.goalLat) + 0.02, + minLng: Math.min(parameters.startLng, parameters.goalLng) - 0.02, + maxLng: Math.max(parameters.startLng, parameters.goalLng) + 0.02, + minAltitudeM: altitudeMin - 260, + maxAltitudeM: altitudeMax + 260, + }, + }); + this.previousGoalDistanceM = this.goalDistance(); + return this.observation(); + } + + protected normalizeAction(action: CaliforniaFlightAction): CaliforniaFlightAction { + const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0)); + return { + throttle: Math.max(0, Math.min(1, Number.isFinite(action?.throttle) ? action.throttle : 0)), + yaw: axis(action?.yaw), + pitch: axis(action?.pitch), + roll: axis(action?.roll), + }; + } + + protected advanceSimulation( + action: CaliforniaFlightAction, + ): SimulationTransition { + const controller = this.requireController(); + controller.stepFixed({ ...action, modeRequest: "manual", reset: false }); + const state = controller.state(); + const distance = this.goalDistance(); + const progress = this.previousGoalDistanceM - distance; + this.previousGoalDistanceM = distance; + const success = distance <= SUCCESS_RADIUS_M; + const safety = state.envelopeContact; + const altitudeError = this.currentScenario().parameters.goalAltitudeM - state.altitudeM; + return { + observation: this.observation(), + rewardComponents: { + progress: progress * 0.0025, + success: success ? 3.5 : 0, + time: -0.006, + altitude: -0.000002 * altitudeError ** 2, + control: -0.0005 * (action.throttle ** 2 + action.yaw ** 2 + + action.pitch ** 2 + action.roll ** 2), + safety: safety ? -4 : 0, + }, + terminated: success || safety, + terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined, + }; + } + + protected simulationSnapshot(): FlightSimulationSnapshot { + return { + controller: this.requireController().snapshot(), + previousGoalDistanceM: this.previousGoalDistanceM, + }; + } + + protected restoreSimulation(snapshot: FlightSimulationSnapshot): CaliforniaFlightObservation { + this.resetSimulation(this.currentScenario()); + this.requireController().restore(snapshot.controller); + this.previousGoalDistanceM = snapshot.previousGoalDistanceM; + return this.observation(); + } + + private observation(): CaliforniaFlightObservation { + const state = this.requireController().state(); + const goal = this.currentScenario().parameters; + return { + lat: state.lat, + lng: state.lng, + altitudeM: state.altitudeM, + headingDeg: state.headingDeg, + pitchDeg: state.pitchDeg, + rollDeg: state.rollDeg, + speedMps: state.speedMps, + verticalSpeedMps: state.verticalSpeedMps, + goalLat: goal.goalLat, + goalLng: goal.goalLng, + goalAltitudeM: goal.goalAltitudeM, + distanceToGoalM: this.goalDistance(), + bearingToGoalDeg: bearingDeg(state, { lat: goal.goalLat, lng: goal.goalLng }), + altitudeErrorM: goal.goalAltitudeM - state.altitudeM, + envelopeContact: state.envelopeContact, + }; + } + + private goalDistance(): number { + const state = this.requireController().state(); + const goal = this.currentScenario().parameters; + return distance3dM(state, { + lat: goal.goalLat, lng: goal.goalLng, altitudeM: goal.goalAltitudeM, + }); + } + + private requireController(): AircraftController { + if (!this.controller) throw new Error("flight environment has not been reset"); + return this.controller; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts new file mode 100644 index 0000000..f9c03b9 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts @@ -0,0 +1,44 @@ +/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */ + +function canonical(value: unknown, stack: Set): string { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers"); + return Object.is(value, -0) ? "0" : JSON.stringify(value); + } + if (Array.isArray(value)) { + if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles"); + stack.add(value); + const result = `[${value.map((entry) => canonical(entry, stack)).join(",")}]`; + stack.delete(value); + return result; + } + if (typeof value === "object") { + if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles"); + stack.add(value); + const record = value as Record; + const entries = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(record[key], stack)}`); + stack.delete(value); + return `{${entries.join(",")}}`; + } + throw new TypeError(`arena checksums do not accept ${typeof value}`); +} + +export function canonicalJson(value: unknown): string { + return canonical(value, new Set()); +} + +/** 64-bit FNV-1a over UTF-8, encoded with an algorithm prefix. */ +export function arenaChecksum(value: unknown): string { + const bytes = new TextEncoder().encode(canonicalJson(value)); + let hash = 0xcbf29ce484222325n; + for (const byte of bytes) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return `fnv1a64:${hash.toString(16).padStart(16, "0")}`; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts new file mode 100644 index 0000000..1d31271 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts @@ -0,0 +1,285 @@ +import { + ActorController, + type ActorControllerSnapshot, +} from "../actors/controller.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { + ARENA_API_VERSION, + type ArenaManifest, + type ArenaScenario, +} from "./types.ts"; + +export interface CrowNavAction { + forward: number; + turn: number; + pitch: number; + climb: number; + glide: boolean; +} + +export interface CrowNavObservation { + x: number; + y: number; + z: number; + yaw: number; + pitch: number; + speedMps: number; + verticalSpeedMps: number; + goalX: number; + goalY: number; + goalZ: number; + deltaX: number; + deltaY: number; + deltaZ: number; + distanceToGoalM: number; + altitudeBoundContact: "none" | "minimum" | "maximum"; +} + +export type CrowNavReward = Record< + "progress" | "success" | "time" | "energy" | "heading" | "safety", + number +>; + +interface CrowScenarioParameters { + startX: number; + startY: number; + startZ: number; + startYaw: number; + goalX: number; + goalY: number; + goalZ: number; +} + +interface CrowSimulationSnapshot { + controller: ActorControllerSnapshot; + previousGoalDistanceM: number; +} + +const FIXED_STEP = 0.1; +const MAX_STEPS = 300; +const SUCCESS_RADIUS_M = 2.5; +const BOUNDS = Object.freeze({ minX: -120, maxX: 120, minZ: -120, maxZ: 120 }); + +const DEFINITIONS = [ + { + id: "train-east-crosswind", + split: "train" as const, + parameters: { startX: 0, startY: 12, startZ: 0, startYaw: 0, goalX: 60, goalY: 12, goalZ: 0 }, + }, + { + id: "train-north-climb", + split: "train" as const, + parameters: { startX: 5, startY: 10, startZ: 20, startYaw: -1, goalX: 5, goalY: 16, goalZ: -48 }, + }, + { + id: "dev-west-return", + split: "dev" as const, + parameters: { startX: 25, startY: 15, startZ: -10, startYaw: 0.4, goalX: -48, goalY: 13, goalZ: -10 }, + }, + { + id: "dev-south-descent", + split: "dev" as const, + parameters: { startX: -15, startY: 18, startZ: -40, startYaw: -0.8, goalX: -5, goalY: 11, goalZ: 38 }, + }, +] as const; + +export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry( + "crow-nav-v1", + DEFINITIONS, + (base, random) => ({ + ...base, + startX: base.startX + random.between(-1, 1), + startY: base.startY + random.between(-0.4, 0.4), + startZ: base.startZ + random.between(-1, 1), + startYaw: base.startYaw + random.between(-0.08, 0.08), + goalX: base.goalX + random.between(-1, 1), + goalY: base.goalY + random.between(-0.4, 0.4), + goalZ: base.goalZ + random.between(-1, 1), + }), +); + +export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "crow-nav-v1", + version: 1, + title: "Crow waypoint navigation", + description: "Three-dimensional waypoint control over Tera's deterministic crow flight controller.", + simulator: "ActorController(kind=crow, mode=flight)", + fixedStepSeconds: FIXED_STEP, + maxSteps: MAX_STEPS, + actionFields: ["forward", "turn", "pitch", "climb", "glide"], + observationFields: [ + "x", "y", "z", "yaw", "pitch", "speedMps", "verticalSpeedMps", + "goalX", "goalY", "goalZ", "deltaX", "deltaY", "deltaZ", + "distanceToGoalM", "altitudeBoundContact", + ], + rewardComponents: { + progress: "Reduction in 3D distance to the waypoint.", + success: "Sparse waypoint completion bonus.", + time: "Per-step pressure that makes minimum-power inaction negative.", + energy: "Counterweight on powered speed and control demand.", + heading: "Small cost for pointing away from the target.", + safety: "Terminal flight-envelope contact penalty.", + }, + safetyTerminals: ["flight-envelope-contact"], + scenarioIds: { + train: CROW_NAV_SCENARIOS.ids("train"), + dev: CROW_NAV_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: "Minimum-power straight flight (forward=-1) misses the lateral waypoint and stays below zero return.", + scripted: "Proportional yaw and climb guidance reaches every public waypoint.", + }, +}); + +export const CROW_NAV_INACTION: Readonly = Object.freeze({ + forward: -1, turn: 0, pitch: 0, climb: 0.03, glide: false, +}); + +function wrapAngle(value: number): number { + return ((value + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI; +} + +export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNavAction { + const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ); + const headingError = wrapAngle(desiredYaw - observation.yaw); + return { + forward: 0.45, + turn: Math.max(-1, Math.min(1, headingError / 0.55)), + pitch: 0, + climb: Math.max(-0.8, Math.min(0.8, observation.deltaY / 10 - 0.2)), + glide: false, + }; +} + +export class CrowNavEnvironment extends BaseArenaEnvironment< + CrowNavAction, + CrowNavObservation, + CrowNavReward, + CrowSimulationSnapshot, + CrowScenarioParameters +> { + readonly manifest = CROW_NAV_MANIFEST; + protected readonly registry = CROW_NAV_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["crow-nav-v1"]!; + private controller: ActorController | null = null; + private previousGoalDistanceM = 0; + + protected resetSimulation(scenario: ArenaScenario): CrowNavObservation { + const parameters = scenario.parameters; + this.controller = new ActorController({ + kind: "crow", + mode: "flight", + identity: { + id: "arena-crow", displayName: "Arena Crow", authenticated: false, profile: {}, + }, + position: { x: parameters.startX, y: parameters.startY, z: parameters.startZ }, + yaw: parameters.startYaw, + groundY: 0, + minFlightAltitude: 2, + maxFlightAltitude: 40, + horizontalBounds: BOUNDS, + fixedStepSeconds: FIXED_STEP, + }); + this.previousGoalDistanceM = this.goalDistance(); + return this.observation(); + } + + protected normalizeAction(action: CrowNavAction): CrowNavAction { + const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0)); + return { + forward: axis(action?.forward), + turn: axis(action?.turn), + pitch: axis(action?.pitch), + climb: axis(action?.climb), + glide: action?.glide === true, + }; + } + + protected advanceSimulation(action: CrowNavAction): SimulationTransition { + const controller = this.requireController(); + controller.stepFixed({ + ...action, + right: 0, + sprint: false, + modeRequest: "none", + kindRequest: "none", + reset: false, + }); + const state = controller.state(); + const distance = this.goalDistance(); + const progress = this.previousGoalDistanceM - distance; + this.previousGoalDistanceM = distance; + const success = distance <= SUCCESS_RADIUS_M; + const horizontalContact = + state.x <= BOUNDS.minX || state.x >= BOUNDS.maxX || + state.z <= BOUNDS.minZ || state.z >= BOUNDS.maxZ; + const safety = horizontalContact || state.altitudeBoundContact !== "none"; + const observation = this.observation(); + const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ); + const headingError = Math.abs(wrapAngle(desiredYaw - observation.yaw)); + return { + observation, + rewardComponents: { + progress: progress * 0.045, + success: success ? 3 : 0, + time: -0.008, + energy: -0.001 * ((action.forward + 1) / 2 + Math.abs(action.turn) + + Math.abs(action.pitch) + Math.abs(action.climb)), + heading: -0.0008 * (headingError / Math.PI) ** 2, + safety: safety ? -3 : 0, + }, + terminated: success || safety, + terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined, + }; + } + + protected simulationSnapshot(): CrowSimulationSnapshot { + return { + controller: this.requireController().snapshot(), + previousGoalDistanceM: this.previousGoalDistanceM, + }; + } + + protected restoreSimulation(snapshot: CrowSimulationSnapshot): CrowNavObservation { + this.resetSimulation(this.currentScenario()); + this.requireController().restore(snapshot.controller); + this.previousGoalDistanceM = snapshot.previousGoalDistanceM; + return this.observation(); + } + + private observation(): CrowNavObservation { + const state = this.requireController().state(); + const goal = this.currentScenario().parameters; + return { + x: state.x, + y: state.y, + z: state.z, + yaw: state.yaw, + pitch: state.pitch, + speedMps: state.speedMps, + verticalSpeedMps: state.verticalSpeedMps, + goalX: goal.goalX, + goalY: goal.goalY, + goalZ: goal.goalZ, + deltaX: goal.goalX - state.x, + deltaY: goal.goalY - state.y, + deltaZ: goal.goalZ - state.z, + distanceToGoalM: this.goalDistance(), + altitudeBoundContact: state.altitudeBoundContact, + }; + } + + private goalDistance(): number { + const state = this.requireController().state(); + const goal = this.currentScenario().parameters; + return Math.hypot(goal.goalX - state.x, goal.goalY - state.y, goal.goalZ - state.z); + } + + private requireController(): ActorController { + if (!this.controller) throw new Error("crow environment has not been reset"); + return this.controller; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts new file mode 100644 index 0000000..6a3e030 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts @@ -0,0 +1,253 @@ +import CALIFORNIA_TRANSPORT from "../transport/california.ts"; +import { + VehicleController, + type VehicleControllerSnapshot, +} from "../transport/vehicleController.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { + ARENA_API_VERSION, + type ArenaManifest, + type ArenaScenario, +} from "./types.ts"; + +export interface DriveAction { + throttle: number; + brake: number; + steering: number; + handbrake: boolean; +} + +export interface DriveObservation { + routeId: string; + progressM: number; + remainingM: number; + lateralOffsetM: number; + speedMps: number; + speedLimitMps: number; + steering: number; + guardrailContact: boolean; + roadName: string; +} + +export type DriveReward = Record< + "progress" | "success" | "time" | "lane" | "speed" | "control" | "safety", + number +>; + +interface DriveScenarioParameters { + routeId: string; + initialDistanceM: number; + initialLateralOffsetM: number; + initialSpeedMps: number; + targetTravelM: number; +} + +interface DriveSimulationSnapshot { + controller: VehicleControllerSnapshot; + travelledM: number; + previousDistanceM: number; +} + +const FIXED_STEP = 1 / 30; +const MAX_STEPS = 360; +const TRAVEL_SCALE = 10; + +const DEFINITIONS = [ + { + id: "train-us101-ventura", + split: "train" as const, + parameters: { + routeId: "la-sf-us-101", initialDistanceM: 25_000, + initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 850, + }, + }, + { + id: "train-i5-grapevine", + split: "train" as const, + parameters: { + routeId: "la-sf-i-5", initialDistanceM: 55_000, + initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 900, + }, + }, + { + id: "dev-us101-salinas", + split: "dev" as const, + parameters: { + routeId: "la-sf-us-101", initialDistanceM: 390_000, + initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 950, + }, + }, + { + id: "dev-i5-bay-approach", + split: "dev" as const, + parameters: { + routeId: "la-sf-i-5", initialDistanceM: 470_000, + initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 800, + }, + }, +] as const; + +export const DRIVE_101_SCENARIOS = new ArenaScenarioRegistry( + "drive-101-v1", + DEFINITIONS, + (base, random) => ({ + ...base, + initialDistanceM: base.initialDistanceM + random.between(-750, 750), + initialLateralOffsetM: random.between(-0.15, 0.15), + initialSpeedMps: base.initialSpeedMps + random.between(-0.4, 0.4), + targetTravelM: base.targetTravelM + random.between(-40, 40), + }), +); + +export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "drive-101-v1", + version: 1, + title: "California corridor driving", + description: "Manual route-relative driving on Tera's authored US-101 and I-5 plans.", + simulator: "VehicleController + CALIFORNIA_TRANSPORT", + fixedStepSeconds: FIXED_STEP, + maxSteps: MAX_STEPS, + actionFields: ["throttle", "brake", "steering", "handbrake"], + observationFields: [ + "routeId", "progressM", "remainingM", "lateralOffsetM", "speedMps", + "speedLimitMps", "steering", "guardrailContact", "roadName", + ], + rewardComponents: { + progress: "Forward route progress, normalized by the episode target.", + success: "Sparse completion bonus.", + time: "Per-step pressure; a stopped or coasting policy has a negative floor.", + lane: "Continuous lane-centering cost.", + speed: "Cost for exceeding 110% of the authored road limit.", + control: "Small counterweight on abrupt or conflicting control demand.", + safety: "Terminal guardrail-contact penalty.", + }, + safetyTerminals: ["guardrail-contact"], + scenarioIds: { + train: DRIVE_101_SCENARIOS.ids("train"), + dev: DRIVE_101_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: "Zero controls coast below the target and incur the time floor.", + scripted: "throttle=0.72, steering=0 completes every public scenario without contact.", + }, +}); + +export const DRIVE_INACTION: Readonly = Object.freeze({ + throttle: 0, brake: 0, steering: 0, handbrake: false, +}); + +export function driveScriptedBaseline(): DriveAction { + return { throttle: 0.72, brake: 0, steering: 0, handbrake: false }; +} + +export class Drive101Environment extends BaseArenaEnvironment< + DriveAction, + DriveObservation, + DriveReward, + DriveSimulationSnapshot, + DriveScenarioParameters +> { + readonly manifest = DRIVE_101_MANIFEST; + protected readonly registry = DRIVE_101_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["drive-101-v1"]!; + private controller: VehicleController | null = null; + private travelledM = 0; + private previousDistanceM = 0; + + protected resetSimulation(scenario: ArenaScenario): DriveObservation { + const parameters = scenario.parameters; + this.controller = new VehicleController(CALIFORNIA_TRANSPORT, { + routeId: parameters.routeId, + mode: "manual", + initialDistanceM: parameters.initialDistanceM, + initialLateralOffsetM: parameters.initialLateralOffsetM, + initialSpeedMps: parameters.initialSpeedMps, + fixedStepSeconds: FIXED_STEP, + maximumSpeedMps: 42, + guardrailOffsetM: 4.5, + travelScale: TRAVEL_SCALE, + }); + this.travelledM = 0; + this.previousDistanceM = this.controller.state().distanceM; + return this.observation(); + } + + protected normalizeAction(action: DriveAction): DriveAction { + const finite = (value: number) => Number.isFinite(value) ? value : 0; + return { + throttle: Math.max(0, Math.min(1, finite(action?.throttle))), + brake: Math.max(0, Math.min(1, finite(action?.brake))), + steering: Math.max(-1, Math.min(1, finite(action?.steering))), + handbrake: action?.handbrake === true, + }; + } + + protected advanceSimulation(action: DriveAction): SimulationTransition { + const controller = this.requireController(); + controller.stepFixed({ ...action, modeRequest: "manual", reset: false }); + const state = controller.state(); + const delta = Math.max(0, state.distanceM - this.previousDistanceM); + this.previousDistanceM = state.distanceM; + this.travelledM += delta; + const target = this.currentScenario().parameters.targetTravelM; + const success = this.travelledM >= target; + const limitMps = state.speedLimitMph * 0.44704; + const overspeed = Math.max(0, state.speedMps - limitMps * 1.1); + const safety = state.guardrailContact; + return { + observation: this.observation(), + rewardComponents: { + progress: delta / target * 2, + success: success ? 3 : 0, + time: -0.004, + lane: -0.002 * (state.lateralOffsetM / 2.5) ** 2, + speed: -0.004 * overspeed ** 2, + control: -0.0005 * (action.steering ** 2 + action.brake ** 2 + + (action.throttle > 0 && action.brake > 0 ? 1 : 0)), + safety: safety ? -3 : 0, + }, + terminated: success || safety, + terminalReason: success ? "goal" : safety ? "guardrail-contact" : undefined, + }; + } + + protected simulationSnapshot(): DriveSimulationSnapshot { + return { + controller: this.requireController().snapshot(), + travelledM: this.travelledM, + previousDistanceM: this.previousDistanceM, + }; + } + + protected restoreSimulation(snapshot: DriveSimulationSnapshot): DriveObservation { + this.resetSimulation(this.currentScenario()); + this.requireController().restore(snapshot.controller); + this.travelledM = snapshot.travelledM; + this.previousDistanceM = snapshot.previousDistanceM; + return this.observation(); + } + + private observation(): DriveObservation { + const state = this.requireController().state(); + const target = this.currentScenario().parameters.targetTravelM; + return { + routeId: state.routeId, + progressM: this.travelledM, + remainingM: Math.max(0, target - this.travelledM), + lateralOffsetM: state.lateralOffsetM, + speedMps: state.speedMps, + speedLimitMps: state.speedLimitMph * 0.44704, + steering: state.steering, + guardrailContact: state.guardrailContact, + roadName: state.roadName, + }; + } + + private requireController(): VehicleController { + if (!this.controller) throw new Error("drive environment has not been reset"); + return this.controller; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts new file mode 100644 index 0000000..d350b54 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts @@ -0,0 +1,79 @@ +export { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +export { arenaChecksum, canonicalJson } from "./checksum.ts"; +export { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts"; +export { + ArenaScenarioRegistry, + type ArenaScenarioDefinition, + type ScenarioSampler, +} from "./scenarios.ts"; +export { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +export { + ARENA_API_VERSION, + type ArenaEnvironment, + type ArenaInfo, + type ArenaManifest, + type ArenaReplayResult, + type ArenaResetResult, + type ArenaScenario, + type ArenaScenarioRequest, + type ArenaSnapshot, + type ArenaSourceHashes, + type ArenaSplit, + type ArenaStepResult, + type ArenaTraceEnvelope, + type ArenaTraceStep, +} from "./types.ts"; + +export { + DRIVE_101_MANIFEST, + DRIVE_101_SCENARIOS, + DRIVE_INACTION, + Drive101Environment, + driveScriptedBaseline, + type DriveAction, + type DriveObservation, + type DriveReward, +} from "./drive101.ts"; +export { + OFFICE_NAV_INACTION, + OFFICE_NAV_MANIFEST, + OFFICE_NAV_SCENARIOS, + OfficeNavEnvironment, + officeNavScriptedBaseline, + type OfficeNavAction, + type OfficeNavObservation, + type OfficeNavReward, +} from "./officeNav.ts"; +export { + CROW_NAV_INACTION, + CROW_NAV_MANIFEST, + CROW_NAV_SCENARIOS, + CrowNavEnvironment, + crowNavScriptedBaseline, + type CrowNavAction, + type CrowNavObservation, + type CrowNavReward, +} from "./crowNav.ts"; +export { + CALIFORNIA_FLIGHT_INACTION, + CALIFORNIA_FLIGHT_MANIFEST, + CALIFORNIA_FLIGHT_SCENARIOS, + CaliforniaFlightEnvironment, + californiaFlightScriptedBaseline, + type CaliforniaFlightAction, + type CaliforniaFlightObservation, + type CaliforniaFlightReward, +} from "./californiaFlight.ts"; + +import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts"; +import { CROW_NAV_MANIFEST } from "./crowNav.ts"; +import { DRIVE_101_MANIFEST } from "./drive101.ts"; +import { OFFICE_NAV_MANIFEST } from "./officeNav.ts"; + +/** Machine-readable public environment catalogue. */ +export const ARENA_MANIFESTS = Object.freeze([ + DRIVE_101_MANIFEST, + OFFICE_NAV_MANIFEST, + CROW_NAV_MANIFEST, + CALIFORNIA_FLIGHT_MANIFEST, +]); diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts new file mode 100644 index 0000000..608bf01 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts @@ -0,0 +1,250 @@ +import { Plan } from "../interiors/plan.ts"; +import { + createWalker, + type WalkerController, + type WalkerState, +} from "../interiors/walker.ts"; +import { FRONTIER_VALLEY } from "../offices/frontier-valley.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { + ARENA_API_VERSION, + type ArenaManifest, + type ArenaScenario, +} from "./types.ts"; + +export interface OfficeNavAction { + x: number; + z: number; +} + +export interface OfficeNavObservation { + levelId: string; + x: number; + z: number; + goalX: number; + goalZ: number; + deltaX: number; + deltaZ: number; + distanceToGoalM: number; + travelledM: number; + blockedStreak: number; +} + +export type OfficeNavReward = Record< + "progress" | "success" | "time" | "control" | "collision" | "safety", + number +>; + +interface OfficeScenarioParameters { + levelId: string; + startX: number; + startZ: number; + goalX: number; + goalZ: number; +} + +interface OfficeSimulationSnapshot { + walker: WalkerState; + previousGoalDistanceM: number; + blockedStreak: number; +} + +const FIXED_STEP = 0.1; +const MAX_STEPS = 160; +const SUCCESS_RADIUS_M = 0.45; +const PLAN = new Plan(FRONTIER_VALLEY, { depth: "public", warn: false }); + +const DEFINITIONS = [ + { + id: "train-hangar-crossing", + split: "train" as const, + parameters: { levelId: "level-1", startX: 20, startZ: 18, goalX: 34, goalZ: 18 }, + }, + { + id: "train-galley-aisle", + split: "train" as const, + parameters: { levelId: "level-1", startX: 18, startZ: 21, goalX: 18, goalZ: 27 }, + }, + { + id: "dev-apron-approach", + split: "dev" as const, + parameters: { levelId: "level-1", startX: 36, startZ: 19, goalX: 46, goalZ: 19 }, + }, + { + id: "dev-south-aisle", + split: "dev" as const, + parameters: { levelId: "level-1", startX: 22, startZ: 20, goalX: 32, goalZ: 25 }, + }, +] as const; + +export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry( + "office-nav-v1", + DEFINITIONS, + (base, random) => { + const offsetX = random.between(-0.08, 0.08); + const offsetZ = random.between(-0.08, 0.08); + return { + ...base, + startX: base.startX + offsetX, + startZ: base.startZ + offsetZ, + goalX: base.goalX - offsetX, + goalZ: base.goalZ - offsetZ, + }; + }, +); + +export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "office-nav-v1", + version: 1, + title: "Frontier Valley office navigation", + description: "Headless navigation through the resolved public office plan and exact wall collision.", + simulator: "Plan(FRONTIER_VALLEY) + createWalker", + fixedStepSeconds: FIXED_STEP, + maxSteps: MAX_STEPS, + actionFields: ["x", "z"], + observationFields: [ + "levelId", "x", "z", "goalX", "goalZ", "deltaX", "deltaZ", + "distanceToGoalM", "travelledM", "blockedStreak", + ], + rewardComponents: { + progress: "Reduction in Euclidean distance to the goal.", + success: "Sparse arrival bonus.", + time: "Negative inaction floor and path-efficiency pressure.", + control: "Small cost on action magnitude.", + collision: "Cost when commanded travel is blocked by the resolved plan.", + safety: "Terminal repeated-collision penalty.", + }, + safetyTerminals: ["collision-stall"], + scenarioIds: { + train: OFFICE_NAV_SCENARIOS.ids("train"), + dev: OFFICE_NAV_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: "x=0,z=0 reaches no goal and accumulates the time floor.", + scripted: "A normalized direct-to-goal vector completes all collision-clear public scenarios.", + }, +}); + +export const OFFICE_NAV_INACTION: Readonly = Object.freeze({ x: 0, z: 0 }); + +export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction { + const length = Math.hypot(observation.deltaX, observation.deltaZ); + if (length === 0) return { x: 0, z: 0 }; + return { x: observation.deltaX / length, z: observation.deltaZ / length }; +} + +export class OfficeNavEnvironment extends BaseArenaEnvironment< + OfficeNavAction, + OfficeNavObservation, + OfficeNavReward, + OfficeSimulationSnapshot, + OfficeScenarioParameters +> { + readonly manifest = OFFICE_NAV_MANIFEST; + protected readonly registry = OFFICE_NAV_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-nav-v1"]!; + private walker: WalkerController | null = null; + private previousGoalDistanceM = 0; + private blockedStreak = 0; + + protected resetSimulation(scenario: ArenaScenario): OfficeNavObservation { + const parameters = scenario.parameters; + this.walker = createWalker(PLAN, { + levelId: parameters.levelId, + position: { x: parameters.startX, z: parameters.startZ }, + speed: 2, + fixedStep: FIXED_STEP, + maxCatchUpSteps: 1, + }); + this.previousGoalDistanceM = this.goalDistance(); + this.blockedStreak = 0; + return this.observation(); + } + + protected normalizeAction(action: OfficeNavAction): OfficeNavAction { + const x = Number.isFinite(action?.x) ? action.x : 0; + const z = Number.isFinite(action?.z) ? action.z : 0; + const length = Math.hypot(x, z); + return length > 1 ? { x: x / length, z: z / length } : { x, z }; + } + + protected advanceSimulation( + action: OfficeNavAction, + ): SimulationTransition { + const walker = this.requireWalker(); + const before = walker.state(); + const after = walker.tick(FIXED_STEP, action); + const moved = Math.hypot( + after.position.x - before.position.x, + after.position.z - before.position.z, + ); + const demand = Math.hypot(action.x, action.z); + const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2; + this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; + const distance = this.goalDistance(); + const progress = this.previousGoalDistanceM - distance; + this.previousGoalDistanceM = distance; + const success = distance <= SUCCESS_RADIUS_M; + const failure = this.blockedStreak >= 8; + return { + observation: this.observation(), + rewardComponents: { + progress: progress * 0.22, + success: success ? 2.5 : 0, + time: -0.01, + control: -0.001 * demand ** 2, + collision: blocked ? -0.08 : 0, + safety: failure ? -1.5 : 0, + }, + terminated: success || failure, + terminalReason: success ? "goal" : failure ? "collision-stall" : undefined, + }; + } + + protected simulationSnapshot(): OfficeSimulationSnapshot { + return { + walker: this.requireWalker().state(), + previousGoalDistanceM: this.previousGoalDistanceM, + blockedStreak: this.blockedStreak, + }; + } + + protected restoreSimulation(snapshot: OfficeSimulationSnapshot): OfficeNavObservation { + this.resetSimulation(this.currentScenario()); + this.requireWalker().restore(snapshot.walker); + this.previousGoalDistanceM = snapshot.previousGoalDistanceM; + this.blockedStreak = snapshot.blockedStreak; + return this.observation(); + } + + private observation(): OfficeNavObservation { + const state = this.requireWalker().state(); + const goal = this.currentScenario().parameters; + return { + levelId: state.levelId, + x: state.position.x, + z: state.position.z, + goalX: goal.goalX, + goalZ: goal.goalZ, + deltaX: goal.goalX - state.position.x, + deltaZ: goal.goalZ - state.position.z, + distanceToGoalM: this.goalDistance(), + travelledM: state.distance, + blockedStreak: this.blockedStreak, + }; + } + + private goalDistance(): number { + const state = this.requireWalker().state(); + const goal = this.currentScenario().parameters; + return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z); + } + + private requireWalker(): WalkerController { + if (!this.walker) throw new Error("office environment has not been reset"); + return this.walker; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts new file mode 100644 index 0000000..e7b8b54 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts @@ -0,0 +1,35 @@ +/** Seed normalization and deterministic PRNG used only for scenario materialization. */ + +export function normalizeArenaSeed(value: number): number { + if (!Number.isSafeInteger(value)) throw new RangeError("arena seed must be a safe integer"); + return value >>> 0; +} + +export class ArenaRandom { + private state: number; + + constructor(seed: number) { + this.state = normalizeArenaSeed(seed) || 0x6d2b79f5; + } + + next(): number { + let value = this.state += 0x6d2b79f5; + value = Math.imul(value ^ value >>> 15, value | 1); + value ^= value + Math.imul(value ^ value >>> 7, value | 61); + this.state = value >>> 0; + return ((value ^ value >>> 14) >>> 0) / 4_294_967_296; + } + + between(min: number, max: number): number { + return min + (max - min) * this.next(); + } +} + +export function deriveArenaSeed(seed: number, label: string): number { + let value = normalizeArenaSeed(seed) ^ 0x811c9dc5; + for (let index = 0; index < label.length; index += 1) { + value ^= label.charCodeAt(index); + value = Math.imul(value, 0x01000193) >>> 0; + } + return value; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts new file mode 100644 index 0000000..07f9be7 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts @@ -0,0 +1,75 @@ +import { arenaChecksum } from "./checksum.ts"; +import { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts"; +import type { ArenaScenario, ArenaScenarioRequest, ArenaSplit } from "./types.ts"; + +export interface ArenaScenarioDefinition

{ + id: string; + split: ArenaSplit; + parameters: P; +} + +export type ScenarioSampler

= (parameters: Readonly

, random: ArenaRandom) => P; + +/** Immutable public scenario catalogue with seeded, reproducible materialization. */ +export class ArenaScenarioRegistry

{ + readonly envId: string; + readonly definitions: readonly ArenaScenarioDefinition

[]; + private readonly byId = new Map>(); + private readonly sampler: ScenarioSampler

; + + constructor( + envId: string, + definitions: readonly ArenaScenarioDefinition

[], + sampler: ScenarioSampler

, + ) { + if (definitions.length === 0) throw new RangeError("arena scenario registry cannot be empty"); + this.envId = envId; + this.definitions = definitions.map((definition) => ({ + ...definition, + parameters: structuredClone(definition.parameters), + })); + this.sampler = sampler; + for (const definition of this.definitions) { + if (!definition.id || this.byId.has(definition.id)) { + throw new RangeError(`duplicate or empty scenario id: ${definition.id}`); + } + this.byId.set(definition.id, definition); + } + for (const split of ["train", "dev"] as const) { + if (!this.definitions.some((definition) => definition.split === split)) { + throw new RangeError(`${envId} must expose at least one ${split} scenario`); + } + } + } + + ids(split: ArenaSplit): readonly string[] { + return this.definitions + .filter((definition) => definition.split === split) + .map((definition) => definition.id); + } + + resolve(seedValue: number, request: string | ArenaScenarioRequest): ArenaScenario

{ + const seed = normalizeArenaSeed(seedValue); + let definition: ArenaScenarioDefinition

| undefined; + if (typeof request === "string") { + definition = this.byId.get(request); + } else if (request.id !== undefined) { + definition = this.byId.get(request.id); + if (definition?.split !== request.split) definition = undefined; + } else { + const candidates = this.definitions.filter((entry) => entry.split === request.split); + definition = candidates[seed % candidates.length]; + } + if (!definition) throw new RangeError(`unknown ${this.envId} scenario`); + const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`)); + const parameters = this.sampler(definition.parameters, random); + const hash = arenaChecksum({ + envId: this.envId, + id: definition.id, + split: definition.split, + seed, + parameters, + }); + return { id: definition.id, split: definition.split, seed, parameters, hash }; + } +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts new file mode 100644 index 0000000..2a6fbd0 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts @@ -0,0 +1,24 @@ +import type { ArenaSourceHashes } from "./types.ts"; + +/** + * SHA-256 values are generated from committed source files by + * `npm run arena:source-hashes`. Placeholder values are replaced before commit. + */ +export const ARENA_SOURCE_HASHES: Readonly> = Object.freeze({ + "drive-101-v1": { + environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125", + simulator: "sha256:be24cacb480279e88c64e72803d2a8a94db1b084312b64da55050d2ca95c90af", + }, + "office-nav-v1": { + environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a", + simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3", + }, + "crow-nav-v1": { + environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797", + simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8", + }, + "california-flight-v1": { + environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f", + simulator: "sha256:997aa7c63779ae77af44d584758f55b6679836305115aef5e13f207232ec4d6f", + }, +}); diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts new file mode 100644 index 0000000..3f8ed76 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts @@ -0,0 +1,146 @@ +/** Renderer-independent, JSON-safe reinforcement-learning contract. */ + +export const ARENA_API_VERSION = "tera.arena/v1" as const; + +export type ArenaSplit = "train" | "dev"; + +export interface ArenaScenarioRequest { + /** Public splits only. Held-out/private evaluation belongs outside this package. */ + split: ArenaSplit; + /** Omit to choose deterministically from the requested split using the seed. */ + id?: string; +} + +export interface ArenaScenario

> { + id: string; + split: ArenaSplit; + seed: number; + parameters: P; + hash: string; +} + +export interface ArenaSourceHashes { + /** Hash of the environment implementation source, pinned and checked in CI. */ + environment: string; + /** Hash of the renderer-independent controller/plan sources the environment wraps. */ + simulator: string; +} + +export interface ArenaInfo { + apiVersion: typeof ARENA_API_VERSION; + envId: string; + envVersion: number; + envHash: string; + scenarioId: string; + scenarioSplit: ArenaSplit; + scenarioHash: string; + simulatorHash: string; + environmentSourceHash: string; + seed: number; + step: number; + maxSteps: number; + terminalReason: string | null; + stateChecksum: string; +} + +export interface ArenaResetResult { + observation: O; + info: ArenaInfo; +} + +export interface ArenaStepResult> { + observation: O; + reward: number; + rewardComponents: R; + terminated: boolean; + truncated: boolean; + info: ArenaInfo; +} + +export interface ArenaManifest { + apiVersion: typeof ARENA_API_VERSION; + id: string; + version: number; + title: string; + description: string; + simulator: string; + fixedStepSeconds: number; + maxSteps: number; + actionFields: readonly string[]; + observationFields: readonly string[]; + rewardComponents: Readonly>; + safetyTerminals: readonly string[]; + scenarioIds: Readonly>; + baselines: { + inaction: string; + scripted: string; + }; +} + +export interface ArenaSnapshot { + apiVersion: typeof ARENA_API_VERSION; + envId: string; + envVersion: number; + envHash: string; + seed: number; + scenarioId: string; + scenarioSplit: ArenaSplit; + scenarioHash: string; + step: number; + cumulativeReward: number; + terminated: boolean; + truncated: boolean; + terminalReason: string | null; + simulation: S; + checksum: string; +} + +export interface ArenaTraceStep> { + index: number; + action: A; + reward: number; + rewardComponents: R; + terminated: boolean; + truncated: boolean; + terminalReason: string | null; + stateChecksum: string; +} + +export interface ArenaTraceEnvelope> { + apiVersion: typeof ARENA_API_VERSION; + envId: string; + envVersion: number; + envHash: string; + scenarioId: string; + scenarioSplit: ArenaSplit; + scenarioHash: string; + sourceHashes: ArenaSourceHashes; + seed: number; + initialStateChecksum: string; + steps: readonly ArenaTraceStep[]; + finalStateChecksum: string; + cumulativeReward: number; + checksum: string; +} + +export interface ArenaReplayResult { + observation: O; + steps: number; + cumulativeReward: number; + finalStateChecksum: string; +} + +export interface ArenaEnvironment< + A, + O, + R extends Record, + S, +> { + readonly manifest: ArenaManifest; + reset(seed: number, scenario: string | ArenaScenarioRequest): ArenaResetResult; + step(action: A): ArenaStepResult; + snapshot(): ArenaSnapshot; + restore(snapshot: ArenaSnapshot): ArenaResetResult; + trace(): ArenaTraceEnvelope; + replay(trace: ArenaTraceEnvelope): ArenaReplayResult; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts new file mode 100644 index 0000000..51f806c --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts @@ -0,0 +1,360 @@ +/** + * 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 named destination, as the interface knows it: what the legend prints and + * what `flyTo` is keyed on. + * + * Split out of `Chapter` because an office has exactly the same idea — a short + * list of places you can jump to — but positions them in metres, not in + * latitude and longitude. Only this half of a chapter is shared; the pose is + * not. `number` and `description` are optional here and required on `Chapter`, + * because a city's chapters are a numbered tour with a sentence each and an + * office's views are usually just "Reception" and "The desk bay". + */ +export interface View { + id: string; + label: string; + shortLabel: string; + number?: string; + description?: string; +} + +/** A camera destination, and a sentence about why it is on the map. */ +export interface Chapter extends View { + number: string; + description: string; + focus: { + lat: number; + lng: number; + distance: number; + height: number; + rotation: number; + }; +} + +/** + * 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; +} + +// ---- Lighting ------------------------------------------------------------- + +/** + * Everything the light rig needs, as plain numbers. + * + * This is a *state*, not an observation: `Environment` — `{ time, sun, weather }` + * — is what the world is doing, and a `LightingState` is what that means for the + * rig. `Atmosphere` owns the conversion and is the only thing allowed to make + * one; a scene applies it and never writes back. Two modules both constructing + * and mutating the same three lights is the failure this shape exists to + * prevent. See CONTRACT.md §4. + * + * Colours are `0xrrggbb`, matching `ScenePalette` and three.js. + */ +export interface LightingState { + sun: { + /** + * Unit vector from the scene toward the sun. Distance is deliberately + * absent: how far away to place the light is a fact about the scale of the + * scene, and the sun does not know whether it is shining on 94 m per unit + * or on 1 m per unit. + */ + direction: [number, number, number]; + color: number; + intensity: number; + }; + hemisphere: { sky: number; ground: number; intensity: number }; + ambient: { color: number; intensity: number }; + /** + * Background gradient, or `null` to leave the background alone — which is + * what an interior wants, since it has walls and no horizon. + */ + sky: { top: number; horizon: number } | null; + /** `null` for no fog at all. An office gets none. */ + fog: { color: number; near: number; far: number } | null; +} + +// ---- Markers -------------------------------------------------------------- + +/** + * A thing worth pointing at, minus where it is. + * + * `colorKey` is deliberately opaque to the engine — it indexes into a palette + * the caller supplies. The engine will not learn what "rejected" means. + * + * This is the half that survives a change of coordinate system: a pin on a city + * at 37.79 N, -122.40 E and a pin on a desk 4.2 m along the east wall are the + * same kind of thing to everything downstream of the geometry, so an office can + * carry its own positions and still hand a `Pin` to the same detail card. + */ +export interface Pin { + id: string; + label: string; + colorKey: string; + /** Optional href for the detail card. */ + url?: string; + /** Optional one-liner for the detail card. */ + blurb?: string; +} + +/** + * A small, map-scale building drawn in place of a pin. + * + * This is deliberately a glyph rather than an architectural model. A city is + * normally viewed from kilometres away, so loading a façade kit with one mesh + * per window buys triangles nobody can see and gives up the one-draw-call city + * that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar + * — a ground floor, repeated bays, a roof line and a deterministic silhouette + * — and expresses it in a handful of procedural meshes. + * + * Metres are used here because these values describe a real building even + * though the city scene does not: `World` converts horizontal metres with + * `metresPerUnit` and vertical metres with the city's exaggeration. + */ +export interface BuildingGlyph { + kind: "building"; + width: number; + depth: number; + height: number; + /** Approximate occupied floors; used to choose the façade rhythm. */ + storeys: number; + /** Compass bearing of local −Z, degrees clockwise from true north. */ + heading: number; + /** The silhouette family, not a tenant or product category. */ + profile: "tower" | "hangar" | "courtyard" | "block"; + /** Stable variation for bay widths and lit panes. */ + seed?: number; + /** Neutral shell colour. The marker palette still supplies the door/accent. */ + bodyColor?: number; +} + +/** A `Pin` placed on a city, in degrees. */ +export interface Marker extends Pin { + lat: number; + lng: number; + /** + * 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; + /** Optional map-scale representation. Omit it for the ordinary pin. */ + glyph?: BuildingGlyph; +} + +/** 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; +} + +// ---- Satellites ----------------------------------------------------------- + +/** + * Which constellation a satellite belongs to, as far as anyone looking up cares. + * + * A **display bucket and not a taxonomy**: no orbital regime, no operator, no + * launch date. `engine/satellites.ts` picks a colour from it and nothing else + * reads it, and a field that carried more would be a field that got wrong. + * + * `starlink` is broken out from `comms` because it is the reason the layer + * exists — it is the constellation people can see with their eyes, in a train, + * forty minutes after sunset. `other` is not a failure; most of the catalogue is + * other. + * + * This lives here rather than in `server/wire.ts` for the same reason `Marker` + * does: the renderer owns the vocabulary and the wire carries it, so a body off + * the network is handed to the engine as-is with no adapter in between. + */ +export type SatelliteGroup = + | "starlink" + | "comms" + | "navigation" + | "station" + | "weather" + | "other"; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts new file mode 100644 index 0000000..16d814c --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts @@ -0,0 +1,1310 @@ +/** + * An `Office` resolved into something the renderer and the walk controller can + * ask questions of: wall runs, collision segments, seats, props, zones and + * bounds. + * + * This is `World`'s opposite number, and it keeps the same discipline: build + * everything once in the constructor, then answer questions. An office pack is + * static data — nothing in it changes between frames — so anything that can be + * computed at load time should be, and every consumer should be reading the same + * answer rather than deriving its own. + * + * Like `World`, and unlike everything downstream of it, **`Plan` imports no + * three.js**. Its output is plain numbers and plain records. That keeps the + * splitting pass testable without a WebGL context, and it keeps the office + * contract (`types.ts`) and its resolution (this file) on the same side of the + * line from the mesh library that consumes them. + * + * ### The wall pass is the point of this file + * + * CONTRACT.md §2 chose an explicit wall list with 1-D openings over walls + * inferred from room edges, and the reason is here: splitting a wall around its + * openings has to happen anyway to draw the solid parts, and the *same* + * decomposition says where a walker can and cannot go. Two products, one pass, + * no second list to keep in sync. A lintel over a door is drawn and is not + * collided with; the apron under a window is drawn and *is* collided with; and + * neither of those facts is stated twice. + * + * ### Everything here is in office-world metres + * + * The authored contract measures heights "above this level's floor", because + * that is how a person describes a wall. A `LevelPlan` has already added + * `Level.elevation` to every `y`, `bottom`, `top`, `sill` and `head` it exposes. + * Mixing the two conventions in one build product is how a mezzanine ends up in + * the basement, so the conversion happens exactly once, here, and what comes out + * can be dropped into a single scene at the origin. + * + * ### It drops rather than throws + * + * A self-hoster authoring their first office will write a polygon that crosses + * itself, a door that runs off the end of its wall, and two things with the same + * id. Every one of those is reported through `problems` — and, in dev, through + * `console.warn` — and then the offending item is dropped or repaired. An + * exception with no context in the middle of a 180-prop pack tells the author + * nothing and loses the other 179. + * + * ### Depth: a public build does not build the private half + * + * `PlanOptions.depth` is the other reason something can be absent from the build + * product, and it is the one that is not an error. At `"public"` every item the + * pack marked `audience: "private"` is skipped here, in the resolution pass, + * before it is a placement and long before it is a mesh. That ordering is the + * whole point: a private prop that is built and then hidden is still in + * `scene.traverse`, in the devtools graph and in a `JSON.stringify` of this + * object, which is a data leak with a checkbox in front of it. Read the note on + * `Audience` in `types.ts` — including the paragraph saying this is a UI tier + * and not a security boundary, because a pack is bundled into the static build + * and is public whatever it is marked. + */ + +import type { + AssetId, + Audience, + DeskBank, + Level, + Office, + Opening, + OpeningKind, + Outline, + Point2, + Prop, + Room, + Seat, + SeatPose, + SurfaceId, + Viewpoint, + Wall, + Yaw, +} from "./types.ts"; + +/** Documented in `types.ts` as the fallback when a level names no thickness. */ +const DEFAULT_WALL_THICKNESS = 0.12; + +/** + * Desk centre to seat centre when a `DeskBank` does not say. A 0.75 m desk plus + * a chair pulled most of the way under it; far enough that the chair reads as a + * separate object, close enough that nobody looks like they are reaching. + */ +const DEFAULT_SEAT_OFFSET = 0.6; + +/** + * How much of a hole a walker needs before it counts as a way through, in metres + * above the floor. A door clears it, a window does not, and a serving hatch does + * not — which is the answer you want in all three cases without the format + * having to name any of them. + */ +const DEFAULT_WALK_HEIGHT = 1.1; + +/** Below this, two floats are the same number and a length is zero. */ +const EPS = 1e-6; + +// ---- Build products ------------------------------------------------------- + +/** An axis-aligned extent on the floor plane, in metres. */ +export interface Bounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; + width: number; + depth: number; + center: Point2; +} + +/** + * One solid piece of wall: a box `length` long, `thickness` deep and + * `top - bottom` tall, standing on the floor plane at `center` and turned by + * `yaw`. + * + * These map one-to-one onto `parts.wallRun(length, top - bottom, thickness)` + * placed at `{ x: center.x, y: bottom, z: center.z, yaw }` — that part runs + * along local +X with its base at y = 0, which is why the run reports a centre + * and a base rather than two endpoints. + */ +export interface WallRun { + wallId: string; + center: Point2; + length: number; + thickness: number; + /** Office-world metres, level elevation included. */ + bottom: number; + top: number; + yaw: Yaw; + surface: SurfaceId | undefined; + /** Distance along the wall from its `from` end, for anyone who wants it back in 1-D. */ + start: number; + end: number; + /** + * What this run is. A `solid` run spans the wall's full height; a `lintel` + * sits over an opening and an `apron` under one, and both name the opening so + * a reveal or a frame can be drawn against the same numbers. + */ + role: "solid" | "lintel" | "apron"; + openingId?: string; +} + +/** + * A hole in a wall, resolved out of its 1-D interval into a rectangle standing + * in the world. + * + * The opening keeps its own record because the leaf, frame or glazing is drawn + * by the opening rather than by the runs around it — see the note on `Opening` + * in `types.ts` about why there is no `tera:shell.door`. + */ +export interface ResolvedOpening { + /** `"${wallId}#0"`. Openings are not authored with ids; a mesh needs one. */ + id: string; + wallId: string; + kind: OpeningKind; + /** Centre of the hole on the floor plane. */ + center: Point2; + width: number; + /** Office-world metres, level elevation included. */ + sill: number; + head: number; + thickness: number; + yaw: Yaw; + start: number; + end: number; + /** True when a walker can pass through this hole. See `DEFAULT_WALK_HEIGHT`. */ + passable: boolean; +} + +/** + * A stretch of wall a walker cannot cross, as a centreline segment. + * + * Adjacent blocking stretches are merged, so a wall of five windows is one + * segment rather than eleven. The consumer inflates by `thickness / 2` plus + * whatever radius it gives the walker; `Plan.blocked()` does exactly that and is + * the reason this is a segment and not a box. + */ +export interface Segment { + wallId: string; + from: Point2; + to: Point2; + thickness: number; +} + +/** + * One prop, placed. The build input the mesh layer walks. + * + * This is CONTRACT.md §2's `Placement[]`, renamed. `src/assets/parts.ts` already + * exports a `Placement` meaning a part inside one asset's mesh, and exporting + * that name twice meaning two different things is the exact failure the contract + * exists to stop. This one is an asset instance in an office; that one is a box + * inside an asset. + */ +export interface PropPlacement { + id: string; + kind: AssetId; + levelId: string; + /** Office-world metres. `y` is the base of the prop, level elevation included. */ + position: { x: number; y: number; z: number }; + rotation: Yaw; + /** Always three numbers here, whatever the pack wrote. */ + scale: [number, number, number]; + colorKey: string | undefined; + /** The seat this prop belongs to, if it was bound to one. Purely an address. */ + seat: string | undefined; + /** Set when this prop came out of a `DeskBank` rather than being authored. */ + source: { bankId: string; station: number; part: "desk" | "chair" } | undefined; +} + +/** A seat, resolved onto a level. Still an address, now with a floor under it. */ +export interface ResolvedSeat { + id: string; + levelId: string; + position: Point2; + /** Office-world metres: the floor the seat stands on. */ + y: number; + facing: Yaw; + pose: SeatPose; + source: { bankId: string; station: number } | undefined; +} + +/** A room's floor slab, cleaned, re-wound and measured. */ +export interface ResolvedRoom { + id: string; + name: string; + levelId: string; + /** Counter-clockwise in plan view, no repeated closing point. */ + outline: Outline; + floor: SurfaceId; + /** `null` is an atrium: the pack said so explicitly. */ + ceiling: { height: number; surface: SurfaceId | undefined } | null; + /** Office-world metres: the top of the floor slab. */ + y: number; + bounds: Bounds; + /** Square metres. Always positive. */ + area: number; + centroid: Point2; +} + +/** A zone, cleaned and measured. Still means nothing to the engine. */ +export interface ResolvedZone { + id: string; + name: string; + levelId: string; + outline: Outline; + colorKey: string | undefined; + y: number; + bounds: Bounds; + area: number; + centroid: Point2; +} + +/** One storey, resolved. Everything in office-world metres. */ +export interface LevelPlan { + id: string; + name: string; + index: number; + /** Office-world metres: this storey's floor. */ + floorY: number; + wallHeight: number; + wallThickness: number; + wallSurface: SurfaceId | undefined; + rooms: readonly ResolvedRoom[]; + runs: readonly WallRun[]; + openings: readonly ResolvedOpening[]; + props: readonly PropPlacement[]; + seats: readonly ResolvedSeat[]; + zones: readonly ResolvedZone[]; + collision: readonly Segment[]; + bounds: Bounds; +} + +// ---- Problems ------------------------------------------------------------- + +/** + * One thing the pack got wrong, and what was done about it. + * + * Kept as data rather than only as a log line so that a pack can be checked in a + * test, or by a self-hoster's own tooling, without capturing `console`. + */ +export interface PlanProblem { + /** Where it was, as a path into the pack: `"levels[0].walls[3].openings[1]"`. */ + where: string; + message: string; + /** `dropped` means it is not in the build product; `repaired` means it was fixed in place. */ + action: "dropped" | "repaired"; +} + +/** How every pass reports. Threaded through rather than closed over, so the + * polygon and opening helpers can stay free functions. */ +type Report = (where: string, message: string, action: PlanProblem["action"]) => void; + +/** + * How much of a pack to resolve. + * + * The counterpart to `Audience` and deliberately not the same union: an item + * says who it is *for* (`"public"` or `"private"`), a build says how far in it + * *goes* (`"public"` or `"full"`). Spelling both with a shared two-member union + * would make `depth === audience` compile and mean nothing. + * + * It lives here rather than in `types.ts` because it is not something a pack can + * say. `types.ts` is the contract for authored data; this is an argument to the + * thing that reads it. + */ +export type Depth = "public" | "full"; + +/** Whether a build at `depth` includes an item the pack marked `audience`. */ +function included(depth: Depth, audience: Audience | undefined): boolean { + return depth === "full" || audience !== "private"; +} + +export interface PlanOptions { + /** + * `"full"` — the default — resolves the whole pack. `"public"` skips every + * item marked `audience: "private"`, which is how the office gets a + * not-signed-in version without a second pack to keep in step. See the header. + * + * One consequence worth knowing about: id collisions are detected against what + * was actually resolved, so a pack whose private half collides with its public + * half reports that problem at `"full"` and not at `"public"`. Validate a pack + * at full depth — that is the build the author is responsible for, and the + * public one is a subset of it. + */ + depth?: Depth; + /** + * How high a hole must clear for a walker to pass through it, in metres. + * Defaults to 1.1. + */ + walkHeight?: number; + /** + * Whether to `console.warn` each problem. Defaults to true under a dev build + * and false otherwise — an author wants to hear about the door that runs off + * the end of its wall, a visitor does not. `plan.problems` is populated either + * way. + */ + warn?: boolean; +} + +/** + * Vite replaces `import.meta.env.DEV` at build time; Node leaves `env` + * undefined, which is the answer we want there anyway. Read defensively so this + * module stays importable from a plain test runner. + */ +function devBuild(): boolean { + try { + return Boolean((import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV); + } catch { + return false; + } +} + +// ---- Plan ----------------------------------------------------------------- + +export class Plan { + readonly office: Office; + /** + * How much of the pack this is. Read it rather than inferring it from what is + * missing — an office with nothing marked private resolves identically at both + * depths, and that is the normal case rather than a suspicious one. + */ + readonly depth: Depth; + readonly levels: readonly LevelPlan[]; + /** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */ + readonly viewpoints: readonly Viewpoint[]; + /** Everything the validation pass dropped or repaired, in build order. */ + readonly problems: readonly PlanProblem[]; + /** The whole office, every level unioned. */ + readonly bounds: Bounds; + + private readonly walkHeight: number; + private readonly levelsById = new Map(); + private readonly seatsById = new Map(); + private readonly propsById = new Map(); + private readonly viewpointsById = new Map(); + + constructor(office: Office, options: PlanOptions = {}) { + this.office = office; + this.depth = options.depth ?? "full"; + this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT; + + const problems: PlanProblem[] = []; + const warn = options.warn ?? devBuild(); + const report: Report = (where, message, action) => { + problems.push({ where, message, action }); + if (warn) console.warn(`[office ${office.id}] ${where}: ${message} (${action})`); + }; + + // Ids are checked per kind and across the whole office, not per level. A + // seat id is what a `Presence` binds to and a prop id is what an occupancy + // layer dims, so both have to mean one thing in the building, not one thing + // per storey. + const seen = { + level: new Set(), + room: new Set(), + wall: new Set(), + prop: new Set(), + seat: new Set(), + zone: new Set(), + viewpoint: new Set(), + }; + + // `levels` and `viewpoints` are required by the type, but a pack arriving as + // JSON has been through no type checker at all, and a missing array should + // produce an empty office rather than a TypeError with a stack trace in it. + const levels: LevelPlan[] = []; + (office.levels ?? []).forEach((level, li) => { + const where = `levels[${li}]`; + if (seen.level.has(level.id)) { + report(where, `duplicate level id "${level.id}"`, "dropped"); + return; + } + seen.level.add(level.id); + const built = this.buildLevel(level, levels.length, where, seen, report); + levels.push(built); + this.levelsById.set(built.id, built); + for (const seat of built.seats) this.seatsById.set(seat.id, seat); + for (const prop of built.props) this.propsById.set(prop.id, prop); + }); + + // Prop-to-seat bindings are resolved last, because a prop on level 1 may + // legitimately name a seat declared on level 2 and the check would be a + // false positive if it ran during the level pass. + for (const level of levels) { + for (const prop of level.props) { + if (prop.seat !== undefined && !this.seatsById.has(prop.seat)) { + report( + `levels[${level.index}].props "${prop.id}"`, + `bound to unknown seat "${prop.seat}"`, + "repaired", + ); + prop.seat = undefined; + } + } + } + + const viewpoints: Viewpoint[] = []; + (office.viewpoints ?? []).forEach((viewpoint, vi) => { + const where = `viewpoints[${vi}]`; + if (seen.viewpoint.has(viewpoint.id)) { + report(where, `duplicate viewpoint id "${viewpoint.id}"`, "dropped"); + return; + } + if (!this.levelsById.has(viewpoint.levelId)) { + report(where, `on unknown level "${viewpoint.levelId}"`, "dropped"); + return; + } + // Not a problem, so not reported: a viewpoint the pack reserved for the + // signed-in building is absent from `viewpoints` at public depth, which + // means it is absent from the legend too rather than leaving a button that + // flies nowhere. + if (!included(this.depth, viewpoint.audience)) return; + seen.viewpoint.add(viewpoint.id); + viewpoints.push(viewpoint); + this.viewpointsById.set(viewpoint.id, viewpoint); + }); + + const extent = new Extent(); + for (const level of levels) extent.addBounds(level.bounds); + + this.levels = levels; + this.viewpoints = viewpoints; + this.problems = problems; + this.bounds = extent.finish(); + } + + // ---- Queries ------------------------------------------------------------ + + level(id: string): LevelPlan | null { + return this.levelsById.get(id) ?? null; + } + + seat(id: string): ResolvedSeat | null { + return this.seatsById.get(id) ?? null; + } + + prop(id: string): PropPlacement | null { + return this.propsById.get(id) ?? null; + } + + viewpoint(id: string): Viewpoint | null { + return this.viewpointsById.get(id) ?? null; + } + + /** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */ + arrival(): Viewpoint | null { + return this.viewpoints[0] ?? null; + } + + /** Every seat in the building, in declaration order, banks expanded. */ + allSeats(): ResolvedSeat[] { + return [...this.seatsById.values()]; + } + + /** + * Which room a point is in. + * + * Later rooms win, because a pack lays the open floor down first and then puts + * the meeting rooms on top of it — the same order it would be drawn in, and + * the same order a reader of the source expects. + */ + roomAt(levelId: string, point: Point2): ResolvedRoom | null { + const level = this.levelsById.get(levelId); + if (!level) return null; + for (let i = level.rooms.length - 1; i >= 0; i--) { + const room = level.rooms[i]; + if (!room) continue; + if (point.x < room.bounds.minX || point.x > room.bounds.maxX) continue; + if (point.z < room.bounds.minZ || point.z > room.bounds.maxZ) continue; + if (pointInOutline(point, room.outline)) return room; + } + return null; + } + + collisionAt(levelId: string): readonly Segment[] { + return this.levelsById.get(levelId)?.collision ?? []; + } + + /** + * Whether a walker of `radius` moving from one point to another crosses a + * wall on this level. + * + * A capsule-versus-segment test rather than a point-in-box one, so a fast + * walker cannot tunnel through a 0.12 m partition between two frames. It lives + * here rather than in the controller because inflating the centreline by half + * the wall thickness is the sort of detail that gets forgotten in one of the + * three places that needs it. + */ + blocked(levelId: string, from: Point2, to: Point2, radius = 0.3): boolean { + for (const seg of this.collisionAt(levelId)) { + const clearance = radius + seg.thickness / 2; + if (segmentDistance(from, to, seg.from, seg.to) < clearance) return true; + } + return false; + } + + // ---- Build -------------------------------------------------------------- + + private buildLevel( + level: Level, + index: number, + where: string, + seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set>, + report: Report, + ): LevelPlan { + const floorY = level.elevation; + const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS; + const floorplan = level.floorplan; + const extent = new Extent(); + + // Every one of the five passes below opens the same way: a private item at + // public depth is skipped before anything is resolved about it, so it never + // becomes a `ResolvedRoom`, a `PropPlacement` or a `ResolvedSeat` and there + // is nothing downstream for a mesh layer to build or a traversal to find. + // It is not reported — the pack is not wrong, it is being read at a depth + // that does not include it. + const rooms: ResolvedRoom[] = []; + (floorplan.rooms ?? []).forEach((room, ri) => { + const at = `${where}.rooms[${ri}]`; + if (!included(this.depth, room.audience)) return; + if (seen.room.has(room.id)) { + report(at, `duplicate room id "${room.id}"`, "dropped"); + return; + } + const outline = cleanOutline(room.outline, at, report); + if (!outline) return; + seen.room.add(room.id); + const bounds = outlineBounds(outline); + extent.addBounds(bounds); + rooms.push({ + id: room.id, + name: room.name, + levelId: level.id, + outline, + floor: room.floor, + ceiling: resolveCeiling(room, level, floorY), + y: floorY, + bounds, + area: Math.abs(shoelace(outline)) / 2, + centroid: centroidOf(outline), + }); + }); + + const runs: WallRun[] = []; + const openings: ResolvedOpening[] = []; + const collision: Segment[] = []; + (floorplan.walls ?? []).forEach((wall, wi) => { + const at = `${where}.walls[${wi}]`; + if (seen.wall.has(wall.id)) { + report(at, `duplicate wall id "${wall.id}"`, "dropped"); + return; + } + seen.wall.add(wall.id); + const split = this.splitWall(wall, level, wallThickness, floorY, at, report); + if (!split) return; + runs.push(...split.runs); + openings.push(...split.openings); + collision.push(...split.collision); + extent.add(wall.from); + extent.add(wall.to); + }); + + // Desk banks expand before authored props and seats so that an authored seat + // colliding with a generated one is reported against the hand-written line, + // which is the one whose author can do something about it. + const props: PropPlacement[] = []; + const seats: ResolvedSeat[] = []; + (floorplan.deskBanks ?? []).forEach((bank, bi) => { + const at = `${where}.deskBanks[${bi}]`; + if (!included(this.depth, bank.audience)) return; + this.expandBank(bank, level, floorY, at, seen, report, props, seats); + }); + + (floorplan.props ?? []).forEach((prop, pi) => { + const at = `${where}.props[${pi}]`; + if (!included(this.depth, prop.audience)) return; + if (seen.prop.has(prop.id)) { + report(at, `duplicate prop id "${prop.id}"`, "dropped"); + return; + } + seen.prop.add(prop.id); + props.push(placeProp(prop, level.id, floorY)); + }); + + (floorplan.seats ?? []).forEach((seat, si) => { + const at = `${where}.seats[${si}]`; + if (!included(this.depth, seat.audience)) return; + if (seen.seat.has(seat.id)) { + report(at, `duplicate seat id "${seat.id}"`, "dropped"); + return; + } + seen.seat.add(seat.id); + seats.push(placeSeat(seat, level.id, floorY)); + }); + + const zones: ResolvedZone[] = []; + (floorplan.zones ?? []).forEach((zone, zi) => { + const at = `${where}.zones[${zi}]`; + if (!included(this.depth, zone.audience)) return; + if (seen.zone.has(zone.id)) { + report(at, `duplicate zone id "${zone.id}"`, "dropped"); + return; + } + const outline = cleanOutline(zone.outline, at, report); + if (!outline) return; + seen.zone.add(zone.id); + const bounds = outlineBounds(outline); + extent.addBounds(bounds); + zones.push({ + id: zone.id, + name: zone.name, + levelId: level.id, + outline, + colorKey: zone.colorKey, + y: floorY, + bounds, + area: Math.abs(shoelace(outline)) / 2, + centroid: centroidOf(outline), + }); + }); + + // Props and seats join the extent last so that bank expansions are included + // too — the bounds of a floor whose only content is one desk bank should not + // come out as a point at the origin. + for (const prop of props) extent.add(prop.position); + for (const seat of seats) extent.add(seat.position); + + return { + id: level.id, + name: level.name, + index, + floorY, + wallHeight: level.wallHeight, + wallThickness, + wallSurface: level.wallSurface, + rooms, + runs, + openings, + props, + seats, + zones, + collision, + bounds: extent.finish(), + }; + } + + /** + * The pass CONTRACT.md §2 is built around: one wall, its openings sorted and + * validated, decomposed into intervals along its length. + * + * Each interval is either solid — one full-height run, and it blocks — or a + * hole, which contributes an apron below and a lintel above, and blocks only + * if the hole does not clear `walkHeight` from the floor. Blocking intervals + * are merged as they are walked, so a wall of five windows produces one + * collision segment rather than eleven. + */ + private splitWall( + wall: Wall, + level: Level, + levelThickness: number, + floorY: number, + where: string, + report: Report, + ): { runs: WallRun[]; openings: ResolvedOpening[]; collision: Segment[] } | null { + const dx = wall.to.x - wall.from.x; + const dz = wall.to.z - wall.from.z; + const length = Math.hypot(dx, dz); + if (length < EPS) { + report(where, `wall "${wall.id}" has zero length`, "dropped"); + return null; + } + + const ux = dx / length; + const uz = dz / length; + // A run's mesh lies along its local +X, which for yaw φ points at + // (cos φ, -sin φ) — three.js's rotation about +Y, as `Yaw` promises. The + // `+ 0` turns IEEE's negative zero back into zero: an east-west wall + // otherwise reports a yaw of `-0`, which renders identically and looks like + // a bug in every diff and every snapshot. + const yaw = Math.atan2(-uz, ux) + 0; + const height = wall.height ?? level.wallHeight; + const thickness = wall.thickness ?? levelThickness; + const surface = wall.surface ?? level.wallSurface; + const at = (u: number): Point2 => ({ x: wall.from.x + ux * u, z: wall.from.z + uz * u }); + + const holes = acceptOpenings(wall, length, height, where, report); + + const runs: WallRun[] = []; + const openings: ResolvedOpening[] = []; + const collision: Segment[] = []; + let blockStart: number | null = null; + + const pushRun = ( + u0: number, + u1: number, + bottom: number, + top: number, + role: WallRun["role"], + openingId?: string, + ): void => { + if (u1 - u0 < EPS || top - bottom < EPS) return; + runs.push({ + wallId: wall.id, + center: at((u0 + u1) / 2), + length: u1 - u0, + thickness, + bottom: floorY + bottom, + top: floorY + top, + yaw, + surface, + start: u0, + end: u1, + role, + openingId, + }); + }; + + /** + * Extend or close the run of blocked wall. `u0` is where the interval just + * decided about begins, which is also where an open interval ends — so a + * stretch of solid wall, a window and more solid wall arrives as one segment + * rather than three. + */ + const block = (u0: number, blocks: boolean): void => { + if (blocks) { + if (blockStart === null) blockStart = u0; + return; + } + const from = blockStart; + if (from !== null) { + collision.push({ wallId: wall.id, from: at(from), to: at(u0), thickness }); + blockStart = null; + } + }; + + let cursor = 0; + for (const hole of holes) { + pushRun(cursor, hole.start, 0, height, "solid"); + block(cursor, hole.start - cursor > EPS); + + // Numbered by the opening's position in the *authored* list, so that + // dropping a bad opening does not renumber its siblings and move whatever + // a mesh cached against the id. + const id = `${wall.id}#${hole.index}`; + pushRun(hole.start, hole.end, 0, hole.sill, "apron", id); + pushRun(hole.start, hole.end, hole.head, height, "lintel", id); + + const passable = hole.sill <= EPS && hole.head >= this.walkHeight - EPS; + block(hole.start, !passable); + + openings.push({ + id, + wallId: wall.id, + kind: hole.kind, + center: at((hole.start + hole.end) / 2), + width: hole.end - hole.start, + sill: floorY + hole.sill, + head: floorY + hole.head, + thickness, + yaw, + start: hole.start, + end: hole.end, + passable, + }); + cursor = hole.end; + } + + pushRun(cursor, length, 0, height, "solid"); + block(cursor, length - cursor > EPS); + // Close whatever was still blocking when the wall ran out. + const tail = blockStart; + if (tail !== null) { + collision.push({ wallId: wall.id, from: at(tail), to: at(length), thickness }); + } + + return { runs, openings, collision }; + } + + /** + * A `DeskBank` into props and seats. + * + * The generated ids are contractual — `types.ts` promises a pack author that + * the fourth station of bank `eng` is seat `eng-04`, because a `Presence` + * binds to that string and is written by hand somewhere else entirely. Nothing + * here may renumber. + */ + private expandBank( + bank: DeskBank, + level: Level, + floorY: number, + where: string, + seen: Record<"prop" | "seat", Set>, + report: Report, + props: PropPlacement[], + seats: ResolvedSeat[], + ): void { + const columns = Math.floor(bank.columns); + const rows = Math.floor(bank.rows); + if (!(columns >= 1) || !(rows >= 1)) { + report(where, `bank "${bank.id}" has ${bank.columns} x ${bank.rows} stations`, "dropped"); + return; + } + if (!(bank.pitch > 0)) { + report(where, `bank "${bank.id}" has a pitch of ${bank.pitch} m`, "dropped"); + return; + } + + const rowPitch = bank.rowPitch ?? bank.pitch; + const seatOffset = bank.seatOffset ?? DEFAULT_SEAT_OFFSET; + const pose = bank.pose ?? "sit"; + const prefix = bank.seatPrefix ?? bank.id; + const cos = Math.cos(bank.rotation); + const sin = Math.sin(bank.rotation); + + // Named arguments, because desk and chair differ in three of six fields and + // a positional call would eventually put a chair where a desk goes. + const pushProp = (p: { + id: string; + kind: AssetId; + at: Point2; + rotation: Yaw; + seat: string; + part: "desk" | "chair"; + station: number; + }): void => { + if (seen.prop.has(p.id)) { + report(where, `station ${p.station} generates the taken prop id "${p.id}"`, "dropped"); + return; + } + seen.prop.add(p.id); + props.push({ + id: p.id, + kind: p.kind, + levelId: level.id, + position: { x: p.at.x, y: floorY, z: p.at.z }, + rotation: p.rotation, + scale: [1, 1, 1], + colorKey: undefined, + seat: p.seat, + source: { bankId: bank.id, station: p.station, part: p.part }, + }); + }; + + for (let r = 0; r < rows; r++) { + // With `facingRows`, the first row of each pair turns to look back down + // the bank, which puts its desk between the two occupants and its chair on + // the outside — a bench, rather than two rows of people staring at the + // back of each other's heads. + const flipped = bank.facingRows === true && r % 2 === 0; + const facing = bank.rotation + (flipped ? Math.PI : 0); + // Where a seat sits relative to its desk: behind it, so that the occupant + // looks out over the desktop. That is the desk's local +Z, which for yaw φ + // points at (sin φ, cos φ). + const seatX = Math.sin(facing) * seatOffset; + const seatZ = Math.cos(facing) * seatOffset; + + for (let c = 0; c < columns; c++) { + const station = r * columns + c + 1; + const n = String(station).padStart(2, "0"); + const u = c * bank.pitch; + const v = r * rowPitch; + const x = bank.origin.x + u * cos + v * sin; + const z = bank.origin.z - u * sin + v * cos; + + const seatId = `${prefix}-${n}`; + if (seen.seat.has(seatId)) { + report(where, `station ${station} generates the taken seat id "${seatId}"`, "dropped"); + continue; + } + seen.seat.add(seatId); + seats.push({ + id: seatId, + levelId: level.id, + position: { x: x + seatX, z: z + seatZ }, + y: floorY, + facing, + pose, + source: { bankId: bank.id, station }, + }); + + pushProp({ + id: `${bank.id}-desk-${n}`, + kind: bank.desk, + at: { x, z }, + rotation: facing, + seat: seatId, + part: "desk", + station, + }); + + if (bank.chair !== undefined) { + pushProp({ + id: `${bank.id}-chair-${n}`, + kind: bank.chair, + at: { x: x + seatX, z: z + seatZ }, + rotation: facing, + seat: seatId, + part: "chair", + station, + }); + } + } + } + } +} + +// ---- Placement helpers ---------------------------------------------------- + +function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement { + const s = prop.scale ?? 1; + const scale: [number, number, number] = + typeof s === "number" ? [s, s, s] : [s[0], s[1], s[2]]; + return { + id: prop.id, + kind: prop.kind, + levelId, + position: { x: prop.position.x, y: floorY + (prop.elevation ?? 0), z: prop.position.z }, + rotation: prop.rotation, + scale, + colorKey: prop.colorKey, + seat: prop.seat, + source: undefined, + }; +} + +function placeSeat(seat: Seat, levelId: string, floorY: number): ResolvedSeat { + return { + id: seat.id, + levelId, + position: { x: seat.position.x, z: seat.position.z }, + y: floorY, + facing: seat.facing, + pose: seat.pose, + source: undefined, + }; +} + +/** + * A room's ceiling. `undefined` means the level's default, explicit `null` means + * there is not one. + * + * The surface is allowed to stay undefined: a level has a default *wall* finish + * but no default ceiling finish, and inventing one here would be worse than + * letting the material registry fall back to its own `ceilingTile` role — which + * is where that decision belongs. + */ +function resolveCeiling(room: Room, level: Level, floorY: number): ResolvedRoom["ceiling"] { + if (room.ceiling === null) return null; + return { + height: floorY + (room.ceiling?.height ?? level.wallHeight), + surface: room.ceiling?.surface, + }; +} + +/** An opening that survived validation. `index` is its position in the authored list. */ +interface AcceptedOpening { + index: number; + kind: OpeningKind; + start: number; + end: number; + sill: number; + head: number; +} + +/** + * The openings that survive, sorted along the wall and guaranteed not to + * overlap — which is what lets the splitting pass be a single left-to-right + * walk rather than an interval-tree problem. + */ +function acceptOpenings( + wall: Wall, + length: number, + height: number, + where: string, + report: Report, +): AcceptedOpening[] { + const indexed = (wall.openings ?? []).map((opening, index) => ({ opening, index })); + indexed.sort((a, b) => a.opening.start - b.opening.start); + + const kept: AcceptedOpening[] = []; + for (const { opening, index } of indexed) { + const at = `${where}.openings[${index}]`; + const accepted = normaliseOpening(opening, index, length, height, at, report); + if (!accepted) continue; + // Sorted by `start`, so only the previous survivor can be in the way. + const previous = kept[kept.length - 1]; + if (previous && accepted.start < previous.end - EPS) { + report(at, `overlaps the opening starting at ${previous.start.toFixed(2)} m`, "dropped"); + continue; + } + kept.push(accepted); + } + return kept; +} + +/** + * One opening, checked against its wall. + * + * The horizontal errors are fatal to the opening — a hole that runs off the end + * of a wall has no sensible repair, and clamping it would silently move a door. + * The vertical ones are clamped, because a head above the wall is unambiguously + * "all the way up" and a negative sill is unambiguously "on the floor". + */ +function normaliseOpening( + opening: Opening, + index: number, + length: number, + height: number, + where: string, + report: Report, +): AcceptedOpening | null { + if (!(opening.width > EPS)) { + report(where, `width is ${opening.width} m`, "dropped"); + return null; + } + if (opening.start < -EPS) { + report(where, `starts ${(-opening.start).toFixed(2)} m before its wall`, "dropped"); + return null; + } + const end = opening.start + opening.width; + if (end > length + EPS) { + report( + where, + `runs ${(end - length).toFixed(2)} m past the end of a ${length.toFixed(2)} m wall`, + "dropped", + ); + return null; + } + + let sill = opening.sill; + let head = opening.head; + if (sill < 0) { + report(where, `sill is ${sill} m; clamped to the floor`, "repaired"); + sill = 0; + } + if (head > height + EPS) { + report(where, `head is above a ${height.toFixed(2)} m wall; clamped`, "repaired"); + head = height; + } + if (head - sill < EPS) { + report(where, `head ${head} m is not above sill ${sill} m`, "dropped"); + return null; + } + + return { index, kind: opening.kind, start: opening.start, end, sill, head }; +} + +// ---- Polygons ------------------------------------------------------------- + +/** + * A usable outline, or nothing. + * + * Three repairs and two rejections. Repeated points collapse — including a + * repeated *first* point, which is the single most common thing a new author + * writes, because every other polygon format they have met wanted the ring + * closed by hand. Reversed winding is re-wound silently, as `types.ts` promises. + * Fewer than three distinct points, or an outline that crosses itself, is + * dropped: a self-intersecting floor slab triangulates into a black bowtie, and + * every downstream area, centroid and point-in-polygon answer about it is + * meaningless. + */ +function cleanOutline( + outline: Outline, + where: string, + report: Report, +): Outline | null { + const points: Point2[] = []; + for (const p of outline) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.z)) { + report(where, `outline has a non-finite point`, "dropped"); + return null; + } + const last = points[points.length - 1]; + if (last && Math.abs(last.x - p.x) < EPS && Math.abs(last.z - p.z) < EPS) continue; + points.push({ x: p.x, z: p.z }); + } + const first = points[0]; + const last = points[points.length - 1]; + if (points.length > 1 && first && last && Math.abs(first.x - last.x) < EPS && Math.abs(first.z - last.z) < EPS) { + report(where, "outline repeats its first point; outlines are implicitly closed", "repaired"); + points.pop(); + } + + if (points.length < 3) { + report(where, `outline has ${points.length} distinct points`, "dropped"); + return null; + } + + const area = shoelace(points); + if (Math.abs(area) / 2 < EPS) { + report(where, "outline encloses no area", "dropped"); + return null; + } + if (selfIntersects(points)) { + report(where, "outline crosses itself", "dropped"); + return null; + } + + // Positive shoelace over (x, z) is *clockwise* in plan view, because +Z runs + // down the page. Counter-clockwise in plan view is also what a floor slab + // needs to triangulate into +Y-facing triangles, which is why the convention + // is worth normalising rather than merely documenting. + if (area > 0) points.reverse(); + return points; +} + +function shoelace(outline: Outline): number { + let sum = 0; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[j]; + const b = outline[i]; + if (!a || !b) continue; + sum += a.x * b.z - b.x * a.z; + } + return sum; +} + +function centroidOf(outline: Outline): Point2 { + let cx = 0; + let cz = 0; + let twiceArea = 0; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[j]; + const b = outline[i]; + if (!a || !b) continue; + const cross = a.x * b.z - b.x * a.z; + twiceArea += cross; + cx += (a.x + b.x) * cross; + cz += (a.z + b.z) * cross; + } + if (Math.abs(twiceArea) < EPS) { + // Degenerate rings never reach here, but a caller with its own outline + // might; the vertex mean is at least inside the hull. + let mx = 0; + let mz = 0; + for (const p of outline) { + mx += p.x; + mz += p.z; + } + const n = Math.max(1, outline.length); + return { x: mx / n, z: mz / n }; + } + return { x: cx / (3 * twiceArea), z: cz / (3 * twiceArea) }; +} + +/** + * Whether any two non-adjacent edges cross. + * + * O(n²), and deliberately so: an office room is a dozen points, a sweep-line is + * fifty lines of code that would be wrong in the collinear cases, and this runs + * once at load. + */ +function selfIntersects(outline: Outline): boolean { + const n = outline.length; + for (let i = 0; i < n; i++) { + const a1 = outline[i]; + const a2 = outline[(i + 1) % n]; + if (!a1 || !a2) continue; + for (let j = i + 1; j < n; j++) { + // Skip the shared-vertex pairs: consecutive edges always touch, and the + // last edge always touches the first. + if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue; + const b1 = outline[j]; + const b2 = outline[(j + 1) % n]; + if (!b1 || !b2) continue; + if (segmentsCross(a1, a2, b1, b2)) return true; + } + } + return false; +} + +function cross(o: Point2, a: Point2, b: Point2): number { + return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); +} + +/** Proper crossing only. Touching endpoints and collinear overlap do not count. */ +function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean { + const d1 = cross(a1, a2, b1); + const d2 = cross(a1, a2, b2); + const d3 = cross(b1, b2, a1); + const d4 = cross(b1, b2, a2); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** Ray casting, in the XZ plane. The same algorithm `World` uses on lat/lng. */ +function pointInOutline(point: Point2, outline: Outline): boolean { + let inside = false; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[i]; + const b = outline[j]; + if (!a || !b) continue; + if (a.z > point.z !== b.z > point.z) { + const x = ((b.x - a.x) * (point.z - a.z)) / (b.z - a.z) + a.x; + if (point.x < x) inside = !inside; + } + } + return inside; +} + +// ---- Distance ------------------------------------------------------------- + +function pointToSegment(p: Point2, a: Point2, b: Point2): number { + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + let t = lenSq < EPS ? 0 : ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz)); +} + +/** + * Closest approach between two segments in 2D. + * + * Crossing segments are zero apart; otherwise the minimum is attained at one of + * the four endpoints, which is a well-known property of convex sets and much + * less error-prone than solving the parametric system. + */ +function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number { + if (segmentsCross(a1, a2, b1, b2)) return 0; + return Math.min( + pointToSegment(a1, b1, b2), + pointToSegment(a2, b1, b2), + pointToSegment(b1, a1, a2), + pointToSegment(b2, a1, a2), + ); +} + +// ---- Extents -------------------------------------------------------------- + +class Extent { + private minX = Infinity; + private maxX = -Infinity; + private minZ = Infinity; + private maxZ = -Infinity; + + add(p: Point2): void { + if (p.x < this.minX) this.minX = p.x; + if (p.x > this.maxX) this.maxX = p.x; + if (p.z < this.minZ) this.minZ = p.z; + if (p.z > this.maxZ) this.maxZ = p.z; + } + + addBounds(b: Bounds): void { + this.add({ x: b.minX, z: b.minZ }); + this.add({ x: b.maxX, z: b.maxZ }); + } + + finish(): Bounds { + if (!Number.isFinite(this.minX)) { + return { minX: 0, maxX: 0, minZ: 0, maxZ: 0, width: 0, depth: 0, center: { x: 0, z: 0 } }; + } + return { + minX: this.minX, + maxX: this.maxX, + minZ: this.minZ, + maxZ: this.maxZ, + width: this.maxX - this.minX, + depth: this.maxZ - this.minZ, + center: { x: (this.minX + this.maxX) / 2, z: (this.minZ + this.maxZ) / 2 }, + }; + } +} + +function outlineBounds(outline: Outline): Bounds { + const extent = new Extent(); + for (const p of outline) extent.add(p); + return extent.finish(); +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts new file mode 100644 index 0000000..f863ed3 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts @@ -0,0 +1,644 @@ +/** + * The office contract — what an office pack is allowed to say. + * + * This is the interiors half of `engine/types.ts`, and it obeys the same rule: + * the engine renders what an `Office` describes and takes no position on what it + * means. It does not know that a room is a room because people meet in it, that + * `zone: "eng"` is a team, or that anybody is sitting anywhere. See + * ARCHITECTURE.md §3.3 and CONTRACT.md §2. + * + * **Everything here is strictly JSON-serialisable.** No functions, no classes, + * no getters, no `THREE` types, no `Date`. A pack hand-written as a `.ts` module + * and a pack arriving as a `.json` body over HTTP have to be literally the same + * thing — the moment one of them can carry a callback, the other stops being a + * pack and starts being a second format nobody maintains. + * + * `Plan` (`src/interiors/plan.ts`) is the only thing that turns an `Office` into + * geometry. Its output — wall runs, collision segments, resolved placements — is + * a build product and is deliberately not authorable here. + * + * ### Coordinates and units + * + * Offices are authored in **metres, 1 unit = 1 m**, which is also how assets are + * authored (CONTRACT.md §3). This is not the city's scale and cannot be: SF puts + * one scene unit at ~94 m with 3.6x vertical exaggeration, which is why an + * office gets its own `THREE.Scene`. + * + * The floor is the **XZ plane** with **+Y up**, three.js's convention. Plan view + * throughout this file means looking down at that plane with +X to the right and + * +Z down the page. + */ + +import type { BuildingGlyph, Pin, View } from "../engine/types.ts"; + +// ---- Geometry ------------------------------------------------------------- + +/** + * A point on the floor plane, in metres. + * + * Named fields rather than a `[number, number]` tuple — unlike the city's + * `LatLng`, where the pair has an obvious reading, `[4, 6]` in an office gives a + * reader no way to tell whether the second number is depth or height. The field + * is called `z` precisely so that the answer is on the page. + */ +export interface Point2 { + x: number; + z: number; +} + +/** + * A polygon, in plan. + * + * Do **not** repeat the first point at the end; the outline is implicitly + * closed. Author them counter-clockwise in plan view. `Plan` re-winds anything + * that arrives the other way round rather than rendering a black hole, so this + * is a style rule and not a trap. + */ +export type Outline = Point2[]; + +/** + * Yaw about the +Y axis, in radians. Zero faces **-Z**, and the angle increases + * counter-clockwise seen from above. + * + * That is exactly three.js's `object.rotation.y`, and it is stated in those + * terms on purpose. A plan-space "degrees clockwise from north" angle — which is + * what `District.gridAngle` and `Aircraft.heading` use, because for a city it + * reads better — would need a sign flip on the way into the scene, and a sign + * flip that lives in one place is a sign flip that eventually gets applied + * twice. Nothing converts this. + */ +export type Yaw = number; + +// ---- Identifiers ---------------------------------------------------------- + +/** + * A namespaced asset id, like `"tera:desk.workstation"`. + * + * **`src/assets/kit.ts` is the authority** on what ids exist and what they + * build; this alias exists so that the office contract does not import the mesh + * library. It is the same type by the same name in both places — one string, one + * meaning — not two ideas that collided. Interiors is data; assets is code that + * turns data into geometry, and data should not depend on it to be parsed, + * validated or stored. + * + * The `tera:` namespace is the one this repo ships. A self-hoster registers + * `acme:desk.standing` with `overrides: "tera:desk.workstation"` and reskins the + * reference office without forking it. An id with no registration resolves to a + * placeholder box rather than throwing, because an office pack with one typo in + * it should still open. + */ +export type AssetId = string; + +/** + * A material id for a floor, wall or ceiling finish, like `"tera:carpet.loop"`. + * + * Same arrangement as `AssetId`, one level down: `src/assets/materials.ts` holds + * the `MaterialRegistry` and the closed `SurfaceRole` union it is keyed on, and + * this alias is the loose string an authored pack carries. The name differs from + * `SurfaceRole` deliberately — a type name exported twice meaning two different + * things is the exact failure CONTRACT.md §4 was written to stop. + * + * There is no per-instance tint here, unlike `Prop.colorKey`. One blue meeting + * room is a *material* — register `acme:paint.blue` and point the wall at it — + * whereas one red chair in a row of grey ones is genuinely an instance. Giving + * surfaces a tint key as well would duplicate the override mechanism that + * already exists and leave two ways to answer the same question. + */ +export type SurfaceId = string; + +// ---- Audience ------------------------------------------------------------- + +/** + * Who a piece of a pack is built for. + * + * An office has two audiences now. `office.lumbridgecorp.com` is a front door + * anyone can walk up to, and the same building signed in is the one with the + * people in it. Marking a room, a prop, a bank, a seat, a zone or a viewpoint + * `"private"` says: this exists for the second audience and not the first, and + * a public build must never construct it. + * + * Absent means `"public"`. Every pack written before this field existed keeps + * working, and a pack that never thinks about it never has to. + * + * ### It is not built, rather than built and hidden + * + * `Plan` drops private items during resolution, so a public build has no + * `PropPlacement`, no `ResolvedSeat` and no mesh for them at all. Building them + * and setting `visible = false` would leave every one of them in + * `scene.traverse`, in the devtools scene graph and in a `JSON.stringify` of the + * plan — a data leak dressed as a privacy feature. See `PlanOptions.depth` in + * `plan.ts`, which is where the drop happens. + * + * ### Walls have no audience, and cannot get one + * + * A wall is the difference between a floor plan and a floor, and it is what the + * collision pass is made of. A building whose partitions come and go with who is + * looking at it is two different buildings, and the walk-mode collider would be + * describing whichever one you were not in. Mark what stands in the room. A + * `Room` *can* be marked, but a private room takes its floor slab and its + * ceiling with it and leaves a hole in the plan, so that is nearly always the + * wrong field to reach for — mark the contents. + * + * ### This is a UI tier and it is not a security boundary + * + * **A pack is bundled into the static build, so everything in it is public by + * construction**, whatever this field says. The file is in the JavaScript; + * anyone who wants the private half can read it out of the bundle in ten + * seconds. What the field buys is that an anonymous visitor is not *shown* the + * parts of a building that are nobody's business. That is a product decision + * worth making, and it is not the same act as withholding them. + * + * The thing that is genuinely private is `Presence` — who is in today and where + * they sit — and it is private because it never appears in a pack at all. It + * arrives from an API over authentication, and **the API is what refuses an + * anonymous caller**. Nothing on this side of the wire can enforce that. A pack + * that puts something actually secret behind `audience: "private"` has published + * it, and the reason this paragraph is here is so that nobody discovers that + * later. + */ +export type Audience = "public" | "private"; + +// ---- The office ----------------------------------------------------------- + +/** + * One building's interior: the whole authored pack, and the thing a self-hoster + * copies to make their own. + * + * An office contains no people. `Presence` is runtime data that arrives + * separately and binds by seat id — see the note on that type, which is the + * single most important paragraph in this file. + */ +export interface Office { + id: string; + name: string; + + /** + * Ground floor first. An office with one storey declares one level; nothing + * else in the format changes. + */ + levels: Level[]; + + /** + * Named camera poses. `viewpoints[0]` is where you arrive, so put reception — + * or whatever the pack wants a first impression to be — at the front. + */ + viewpoints: Viewpoint[]; + + /** + * Where on the earth this building stands, if it stands anywhere. + * + * Optional, and its absence is a supported state rather than a gap: a pack + * with no `site` renders exactly as every pack did before this field existed, + * under the fixed interior rig. That matters because the format's promise is + * that you can author a floor plan without an account, a key or a coordinate. + * + * What it buys when you do supply it is the sun. `interiors/types.ts` used to + * say flatly that an office has no orientation on the earth, and that was + * true and also the thing standing between an office and real daylight: you + * cannot put the sun in the right place without knowing both where the + * building is and which way it faces. + */ + site?: OfficeSite; + + meta?: OfficeMeta; +} + +/** + * A building's address in the world, in the four numbers that change what you + * see out of the window. + * + * Deliberately not a street address. Nothing here is geocoded, nothing is looked + * up, and no network call can be made from any of it — see CONTRACT.md §8 for + * why a coordinate's provenance is a licensing question in this repo. These are + * numbers a pack author types, the same as every other number in a pack. + */ +export interface OfficeSite { + /** Degrees north. */ + lat: number; + /** Degrees east. */ + lng: number; + /** + * How far this pack's level-0 floor sits above **the ground outside**, in + * metres. Not above sea level. + * + * The renderer's question is "how high up am I", not "how tall is the tower", + * and this is the number that answers it: it is exactly where the horizon + * goes. An office on the 48th floor of a downtown tower is a couple of hundred + * metres here and a shed on reclaimed land is three, and that single + * difference is most of what makes the two feel like different places. + */ + elevation: number; + /** + * The compass bearing, in degrees clockwise from true north, that the pack's + * **−Z direction** points along. + * + * `0` means the pack's "north" really is north, which is what the reference + * pack's comments have always assumed while being careful to say it was only a + * convenience. A building rotated to face the street sets this, and the sun + * then comes through the windows it actually comes through. + * + * Degrees clockwise from north, like `District.gridAngle` and unlike `Yaw` — + * a bearing reads better for a thing on a map, and radians-counter-clockwise + * reads better for a thing in a scene graph. The conversion happens once, at + * the point the two meet. + */ + heading: number; + /** + * What to call the place, for a caption. `null` or absent where the building + * has no name worth printing. + */ + label?: string; + /** + * The public silhouette the city can draw before this office pack is loaded. + * + * Optional for the same reason `site` is optional: an office does not need a + * world address to render, and a sited office does not need to claim that its + * exterior is known. When present, `offices/sites.ts` hands this plain data to + * the generic marker layer; the city still never imports an office pack. + */ + exterior?: BuildingGlyph; +} + +/** + * Provenance for a pack, and nothing the renderer reads. + * + * Optional, but a pack meant to be shared should fill in `author` and `license`. + * The art in this repo is Apache-2.0 with the artistic output additionally + * dedicated under CC0-1.0 (CONTRACT.md §3.1); a pack built elsewhere is under + * whatever its author says here, and saying nothing helps nobody. + */ +export interface OfficeMeta { + description?: string; + author?: string; + /** SPDX identifier where there is one, e.g. `"CC0-1.0"`. */ + license?: string; + version?: string; + /** ISO-8601 date string. A string, not a `Date` — this has to survive JSON. */ + updated?: string; +} + +/** + * One storey. + * + * The three `wall*` fields are the storey's defaults, not a constraint: an + * individual `Wall` overrides any of them. They live here because a floor of + * forty walls that are all 3 m of painted plasterboard should say so once, and + * the interesting wall — the 1.4 m partition around the desk bay — should be the + * one that stands out in the source. + */ +export interface Level { + id: string; + name: string; + + /** Floor slab height above the office origin, in metres. Ground is `0`. */ + elevation: number; + + /** Storey height: the default top of a wall, measured from this floor. */ + wallHeight: number; + /** Default wall thickness in metres. `Plan` uses 0.12 when this is absent. */ + wallThickness?: number; + /** Default wall finish. */ + wallSurface?: SurfaceId; + + floorplan: Floorplan; +} + +/** + * Everything on one storey. + * + * `rooms` and `walls` are required because a level without them is not a level; + * the rest are optional because a bare lobby genuinely has no desk banks, and a + * pack arriving over HTTP will drop empty arrays. Consumers read the optional + * ones as `?? []`. + */ +export interface Floorplan { + rooms: Room[]; + walls: Wall[]; + props?: Prop[]; + deskBanks?: DeskBank[]; + seats?: Seat[]; + zones?: Zone[]; +} + +// ---- Rooms ---------------------------------------------------------------- + +/** + * A floor slab with a name. + * + * **A room implies no walls.** This is the load-bearing half of CONTRACT.md §2: + * rooms are surfaces, walls are a separate explicit list, and the two are not + * derived from each other. Deriving walls from shared room edges sounds tidy + * until it needs float-equality dedup to decide whether two rooms touch, and + * then it is a source of gaps that only appear in one build out of ten. + * + * Rooms may overlap and may leave gaps. An open-plan floor is one big room with + * a handful of walls standing on it. + */ +export interface Room { + id: string; + name: string; + outline: Outline; + floor: SurfaceId; + /** + * Omit for the level default. Explicit `null` means **no ceiling at all** — + * an atrium, a double-height void, or a cutaway you want to look down into. + */ + ceiling?: RoomCeiling | null; + /** + * See `Audience`. Absent means public. A private room takes its floor slab and + * its ceiling with it and leaves a hole in the plan, which is almost never + * what is wanted — mark the props in the room instead. + */ + audience?: Audience; +} + +/** A ceiling override for one room. Both fields fall back to the level. */ +export interface RoomCeiling { + /** Metres above this level's floor. */ + height?: number; + surface?: SurfaceId; +} + +// ---- Walls and openings --------------------------------------------------- + +/** + * A single straight wall segment, from `from` to `to`, centred on that line. + * + * Walls are an explicit list rather than something inferred from room edges, and + * this is the decision the rest of the interiors code is built on. The pass that + * splits a wall around its openings has to run anyway to produce the solid runs + * you can see; running it once produces the **walk-mode collision segments for + * free**, with the gaps in exactly the places you can walk through. Any other + * arrangement keeps two lists in sync by hand. + * + * A wall belongs to no room. It stands where it is put. + */ +export interface Wall { + id: string; + from: Point2; + to: Point2; + /** Metres. Falls back to the level's `wallThickness`. */ + thickness?: number; + /** Metres above this level's floor. Falls back to the level's `wallHeight`. */ + height?: number; + surface?: SurfaceId; + /** Doors, windows and arches punched out of this wall. Order is irrelevant. */ + openings?: Opening[]; +} + +export type OpeningKind = "door" | "window" | "arch"; + +/** + * A hole in a wall, described as a 1-D interval along it. + * + * `start` is measured **from the wall's `from` end**, along the wall, in metres; + * `width` runs on from there. That is the whole of the horizontal placement — + * an opening has no position of its own and cannot drift off its wall, which is + * the point of expressing it this way. + * + * Doors and windows are openings, never placeable assets. There is no + * `tera:shell.door`: shipping both a door prop and a door-shaped hole would put + * every opening in the scene twice, or — worse, because it is invisible until + * somebody walks through a wall — leave the collider with no gap where the door + * is. `Plan` hands each solid run to a parameterised `wallRun` part, and the + * frame, leaf or glazing is drawn by the opening itself. + * + * Typical values, in metres: a door is `sill: 0, head: 2.1`; a window is + * `sill: 0.9, head: 2.2`; an arch is `sill: 0, head: 2.4`. They are required + * rather than defaulted by kind, because a data contract with hidden per-kind + * defaults is one where the numbers you read are not the numbers you get. + */ +export interface Opening { + kind: OpeningKind; + /** Metres along the wall from the `from` end to the near edge of the hole. */ + start: number; + /** Metres. Must be positive, and must fit inside the wall. */ + width: number; + /** Bottom of the hole, metres above this level's floor. */ + sill: number; + /** Top of the hole, metres above this level's floor. */ + head: number; +} + +// ---- Props ---------------------------------------------------------------- + +/** + * One instance of one asset, placed. + * + * `kind` is an `AssetId` because the prop registry and the asset registry are + * the same registry (CONTRACT.md §3) — there is no separate table of things you + * are allowed to put in a room. + */ +export interface Prop { + id: string; + kind: AssetId; + /** Where it stands, on the floor plane. */ + position: Point2; + /** See `Yaw`. */ + rotation: Yaw; + /** + * Metres above this level's floor. Omitted means standing on it, which is + * true of nearly everything; a wall-mounted screen or a monitor on a desktop + * says so here. + */ + elevation?: number; + /** + * Uniform scale, or per-axis. Use sparingly — an asset that is wanted at + * another size is usually better registered as its own id. + */ + scale?: number | [number, number, number]; + /** + * An opaque palette key, resolved by the caller's palette exactly as + * `Pin.colorKey` is. The engine will not learn that `"focus"` means a quiet + * booth or that red means anything at all. + */ + colorKey?: string; + /** + * The id of a `Seat` this prop belongs to — the chair pulled up to `eng-04`. + * + * Purely an address. The prop is still positioned by its own `position`; + * binding it to a seat is what lets an occupancy layer dim the empty chairs + * without knowing which mesh is which. + */ + seat?: string; + /** See `Audience`. Absent means public. */ + audience?: Audience; +} + +/** + * A row or grid of identical desks, declared once. + * + * `Plan` expands one of these into props and seats. It exists for a plain + * reason: the reference office is about 180 props, and 180 hand-written literals + * is not a file anybody edits twice. A bank of twelve is six lines here. + * + * The grid is laid out in the bank's own frame — `columns` run along its local + * +X, `rows` step along its local +Z — and then rotated by `rotation` about + * `origin`, which is the centre of station (1, 1). + * + * ### Generated ids + * + * These are part of the contract, because a `Presence` binds to a seat id and a + * pack author has to be able to predict what it will be without running the + * expansion. Stations are numbered from 1, along each row and then down the + * rows, and the number is zero-padded to two digits: + * + * - seat `${seatPrefix ?? id}-01`, `-02`, … + * - desk prop `${id}-desk-01`, chair prop `${id}-chair-01` + * + * So a bank with `id: "eng"` and no `seatPrefix` gives you `eng-04` as the + * fourth seat, which is the id the private occupancy API is expected to know. + */ +export interface DeskBank { + id: string; + /** The asset placed at every station. */ + desk: AssetId; + /** Placed at every seat, if given. */ + chair?: AssetId; + + /** Centre of the first station, before rotation. */ + origin: Point2; + /** Orientation of the whole bank. See `Yaw`. */ + rotation: Yaw; + + /** Stations across, along the bank's local +X. At least 1. */ + columns: number; + /** Rows deep, along the bank's local +Z. At least 1. */ + rows: number; + /** Centre-to-centre spacing between columns, in metres. */ + pitch: number; + /** Centre-to-centre spacing between rows, in metres. Defaults to `pitch`. */ + rowPitch?: number; + + /** + * When true, consecutive rows face each other rather than all facing the same + * way — bench seating, where two rows share a run of desktop. This is the + * difference between an office that looks laid out and one that looks like a + * spreadsheet, which is why it is here rather than left to the author to fake + * with two banks. + */ + facingRows?: boolean; + + /** Metres from the desk centre to the seat, on the seated side. */ + seatOffset?: number; + /** Pose for every seat in the bank. Defaults to `"sit"`. */ + pose?: SeatPose; + /** Overrides the bank `id` as the seat-id prefix. */ + seatPrefix?: string; + /** + * See `Audience`. Absent means public, and it covers the whole expansion: a + * private bank generates no desks, no chairs and no seats, so there is nothing + * left for a presence to bind to. + */ + audience?: Audience; +} + +// ---- Seats and zones ------------------------------------------------------ + +export type SeatPose = "sit" | "stand"; + +/** + * A place a person can be. + * + * **Seats are addresses.** A seat is not a chair and not a person; it is a + * stable name for a spot on the floor, so that something outside this repo can + * say "eng-04" and mean somewhere without ever being told a coordinate. Ids + * should be stable across pack edits for the same reason street numbers are. + */ +export interface Seat { + id: string; + position: Point2; + /** Which way an occupant looks. See `Yaw`. */ + facing: Yaw; + pose: SeatPose; + /** + * See `Audience`. Absent means public. A private seat is not resolved at + * public depth, so a `Presence` naming it is dropped the same way one naming a + * seat that does not exist is — which is the answer you want, since at public + * depth there is no presence layer to drop it into either. + */ + audience?: Audience; +} + +/** + * A named region of floor. + * + * A zone has no behaviour and no effect on geometry. It is a label on an area — + * a team's corner, a quiet zone, a phone-booth cluster — that a consuming app + * can highlight, filter or count against. Whether membership of a zone *means* + * anything is not the engine's business, which is why `colorKey` is opaque and + * there is no `kind` field. + */ +export interface Zone { + id: string; + name: string; + outline: Outline; + /** Opaque palette key, resolved by the caller. */ + colorKey?: string; + /** + * See `Audience`. Absent means public. A zone is a label on an area and a + * label is exactly the sort of thing that turns out to be organisational — + * "Engineering" says who sits there — so this is the field a pack reaches for + * most. + */ + audience?: Audience; +} + +// ---- Viewpoints ----------------------------------------------------------- + +/** + * A named camera pose — the office analogue of a city `Chapter`. + * + * It shares `View` with the city rather than redeclaring it, because the half + * that the interface cares about — what the legend prints, what `flyTo` is keyed + * on — is identical, and only the pose differs: a chapter focuses on a latitude + * and longitude, a viewpoint focuses on a point in metres on a particular level. + */ +export interface Viewpoint extends View { + levelId: string; + focus: { + /** What the camera looks at, on the floor plane. */ + at: Point2; + /** Metres from the target to the camera. */ + distance: number; + /** Metres above this level's floor, for both target and camera height. */ + height: number; + /** Camera azimuth about the target. See `Yaw`. */ + rotation: Yaw; + }; + /** + * See `Audience`. Absent means public. + * + * Use it sparingly and think first. A viewpoint is a promise printed in a + * legend, and a visitor told there are five and shown three has been lied to; + * a private viewpoint disappears from `views` entirely rather than leaving a + * dead button, but the honest fix is usually to reframe the shot rather than + * to withhold it. Mark one private only when the *pose itself* is the + * disclosure — a camera two metres from the whiteboard in the board room. + */ + audience?: Audience; +} + +// ---- Presence ------------------------------------------------------------- + +/** + * Somebody at a seat. + * + * **A `Presence` binds to a `seatId` and never to a coordinate, and it never + * appears in an office pack.** This is the whole trick, and it is the marker + * rule one level in. + * + * The pack knows where seat `eng-04` is. A private API knows who is sitting in + * it. Neither knows the other, so occupancy can be private data behind + * authentication — names, faces, who is in today — while the office geometry + * stays public, open-source and copyable by anyone. If a presence carried an + * `{x, z}`, then publishing the geometry and publishing the people would be the + * same act, and one of them could never be published at all. + * + * It extends `Pin` for the same reason `Marker` does: a thing worth pointing at, + * with a label and an opaque colour key, that a detail card can render without + * caring whether it was placed by latitude or by seat id. + */ +export interface Presence extends Pin { + seatId: string; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts new file mode 100644 index 0000000..0a9774a --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts @@ -0,0 +1,353 @@ +/** + * Renderer-independent walking over a resolved office plan. + * + * Input is a direction on the office floor plane, not keys or stick events. + * The controller advances on a fixed clock, sweeps the walker's circular + * footprint against the exact collision segments produced by `Plan`, and + * projects blocked motion along a wall so diagonal input slides instead of + * stopping. Doors need no special case: the wall resolver has already left a + * gap in `LevelPlan.collision` for every passable opening. + */ + +import type { Bounds, LevelPlan, Segment } from "./plan.ts"; +import type { Point2 } from "./types.ts"; + +const EPSILON = 1e-8; +const BISECTION_STEPS = 24; +const SLIDE_PASSES = 3; + +export const DEFAULT_WALKER_RADIUS = 0.3; +export const DEFAULT_WALKER_SPEED = 1.6; +export const DEFAULT_FIXED_STEP = 1 / 60; +export const DEFAULT_MAX_CATCH_UP_STEPS = 8; + +/** A world-space direction on the office floor plane. */ +export interface WalkerAction { + x: number; + z: number; +} + +export interface WalkerSpawn { + levelId: string; + position: Point2; +} + +export interface WalkerOptions extends WalkerSpawn { + /** Initial unit direction; defaults to north / local -Z. */ + facing?: Point2; + /** Circular footprint radius, in metres. */ + radius?: number; + /** Metres per second at full input. */ + speed?: number; + /** Simulation seconds per movement step. */ + fixedStep?: number; + /** Prevents a resumed/backgrounded tab from running an unbounded backlog. */ + maxCatchUpSteps?: number; +} + +export interface WalkerState { + levelId: string; + position: Point2; + /** Last non-zero normalized action; useful as a renderer-facing heading. */ + facing: Point2; + /** Total successfully travelled distance, in metres, since the last reset. */ + distance: number; +} + +/** The small part of `Plan` movement depends on. A test or server can implement it too. */ +export interface WalkerPlan { + level(id: string): Pick | null; + blocked(levelId: string, from: Point2, to: Point2, radius?: number): boolean; +} + +export interface WalkerController { + /** A defensive snapshot: callers cannot corrupt the simulation's finite state. */ + state(): WalkerState; + /** Add real time; zero or more fixed simulation steps may run. */ + tick(elapsedSeconds: number, action: WalkerAction): WalkerState; + /** Return to the original spawn, or atomically adopt another valid spawn. */ + reset(spawn?: WalkerSpawn): WalkerState; + /** Restore a trusted JSON snapshot without changing the configured spawn. */ + restore(snapshot: WalkerState): WalkerState; +} + +/** + * Clamp arbitrary planar input to the unit disc. Non-finite input means idle; + * letting one bad gamepad sample become NaN would otherwise poison every frame. + */ +export function normalizeWalkerAction(action: WalkerAction): WalkerAction { + if (!finitePoint(action)) return { x: 0, z: 0 }; + const length = Math.hypot(action.x, action.z); + if (length <= 1) return { x: action.x, z: action.z }; + return { x: action.x / length, z: action.z / length }; +} + +export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerController { + const radius = positive(options.radius ?? DEFAULT_WALKER_RADIUS, "radius"); + const speed = positive(options.speed ?? DEFAULT_WALKER_SPEED, "speed"); + const fixedStep = positive(options.fixedStep ?? DEFAULT_FIXED_STEP, "fixedStep"); + const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS); + + let spawn = checkedSpawn(plan, options, radius); + let position = copy(spawn.position); + const initialFacing = normalizedFacing(options.facing); + let facing: Point2 = copy(initialFacing); + let distance = 0; + let accumulator = 0; + + function snapshot(): WalkerState { + return { + levelId: spawn.levelId, + position: copy(position), + facing: copy(facing), + distance, + }; + } + + function reset(next = spawn): WalkerState { + spawn = checkedSpawn(plan, next, radius); + position = copy(spawn.position); + facing = copy(initialFacing); + distance = 0; + accumulator = 0; + return snapshot(); + } + + function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState { + // The internals are private, but this also makes the recovery policy clear + // if a future refactor exposes a mutable transport/state object. + if (!finitePoint(position) || !validPosition(plan, spawn.levelId, position, radius)) reset(); + if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot(); + + const action = normalizeWalkerAction(rawAction); + if (Math.hypot(action.x, action.z) > EPSILON) facing = copy(action); + + const maxBacklog = fixedStep * maxCatchUpSteps; + accumulator = Math.min(maxBacklog, accumulator + elapsedSeconds); + let steps = 0; + while (accumulator + EPSILON >= fixedStep && steps < maxCatchUpSteps) { + accumulator -= fixedStep; + if (accumulator < 0) accumulator = 0; + steps += 1; + const amount = speed * fixedStep; + const before = position; + position = moveWithSliding(plan, spawn.levelId, position, { + x: action.x * amount, + z: action.z * amount, + }, radius); + distance += Math.hypot(position.x - before.x, position.z - before.z); + } + return snapshot(); + } + + function restore(next: WalkerState): WalkerState { + if ( + next.levelId !== spawn.levelId || !finitePoint(next.position) || !finitePoint(next.facing) || + !Number.isFinite(next.distance) || next.distance < 0 || + !validPosition(plan, next.levelId, next.position, radius) + ) throw new RangeError("walker snapshot is incompatible or invalid"); + position = copy(next.position); + facing = normalizedFacing(next.facing); + distance = next.distance; + accumulator = 0; + return snapshot(); + } + + return { state: snapshot, tick, reset, restore }; +} + +function normalizedFacing(value: Point2 | undefined): Point2 { + if (!value || !finitePoint(value)) return { x: 0, z: -1 }; + const length = Math.hypot(value.x, value.z); + return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 }; +} + +function moveWithSliding( + plan: WalkerPlan, + levelId: string, + start: Point2, + displacement: Point2, + radius: number, +): Point2 { + const level = plan.level(levelId); + if (!level) return copy(start); + + // A configured high speed still cannot tunnel: no sweep is longer than half + // a radius. `Plan.blocked` is swept too; the subdivision primarily makes a + // corner followed by a slide behave consistently. + const length = Math.hypot(displacement.x, displacement.z); + const slices = Math.max(1, Math.ceil(length / Math.max(radius * 0.5, 0.01))); + const slice = { x: displacement.x / slices, z: displacement.z / slices }; + let at = copy(start); + for (let index = 0; index < slices; index += 1) { + at = moveSlice(plan, levelId, level.bounds, level.collision, at, slice, radius); + } + return at; +} + +function moveSlice( + plan: WalkerPlan, + levelId: string, + bounds: Bounds, + segments: readonly Segment[], + start: Point2, + initial: Point2, + radius: number, +): Point2 { + let at = copy(start); + let remaining = copy(initial); + + for (let pass = 0; pass < SLIDE_PASSES; pass += 1) { + if (Math.hypot(remaining.x, remaining.z) <= EPSILON) break; + const target = bounded(add(at, remaining), bounds, radius); + const attempted = { x: target.x - at.x, z: target.z - at.z }; + if (Math.hypot(attempted.x, attempted.z) <= EPSILON) break; + if (!plan.blocked(levelId, at, target, radius)) { + at = target; + break; + } + + const fraction = clearFraction(plan, levelId, at, attempted, radius); + if (fraction > 0) at = add(at, scale(attempted, fraction)); + const left = scale(attempted, 1 - fraction); + const wall = nearestBlockingSegment(at, add(at, left), segments, radius); + if (!wall) break; + + const wx = wall.to.x - wall.from.x; + const wz = wall.to.z - wall.from.z; + const wallLength = Math.hypot(wx, wz); + if (wallLength <= EPSILON) break; + const tx = wx / wallLength; + const tz = wz / wallLength; + const along = left.x * tx + left.z * tz; + remaining = { x: tx * along, z: tz * along }; + } + return at; +} + +/** Largest prefix of a blocked displacement whose whole swept capsule is clear. */ +function clearFraction( + plan: WalkerPlan, + levelId: string, + start: Point2, + displacement: Point2, + radius: number, +): number { + let low = 0; + let high = 1; + for (let index = 0; index < BISECTION_STEPS; index += 1) { + const middle = (low + high) / 2; + if (plan.blocked(levelId, start, add(start, scale(displacement, middle)), radius)) high = middle; + else low = middle; + } + // Stay microscopically on the clear side so the projected slide does not + // begin inside the wall because of a last-bit rounding difference. + return Math.max(0, low - 1e-7); +} + +function nearestBlockingSegment( + from: Point2, + to: Point2, + segments: readonly Segment[], + radius: number, +): Segment | null { + let nearest: Segment | null = null; + let best = Infinity; + for (const segment of segments) { + const distance = segmentDistance(from, to, segment.from, segment.to); + const clearance = radius + segment.thickness / 2; + if (distance >= clearance + 1e-6 || distance >= best) continue; + best = distance; + nearest = segment; + } + return nearest; +} + +function checkedSpawn(plan: WalkerPlan, spawn: WalkerSpawn, radius: number): WalkerSpawn { + if (!spawn.levelId || !finitePoint(spawn.position)) { + throw new RangeError("walker spawn must name a level and contain finite coordinates"); + } + if (!validPosition(plan, spawn.levelId, spawn.position, radius)) { + throw new RangeError("walker spawn must be inside the level bounds and clear of walls"); + } + return { levelId: spawn.levelId, position: copy(spawn.position) }; +} + +function validPosition(plan: WalkerPlan, levelId: string, point: Point2, radius: number): boolean { + const level = plan.level(levelId); + return level !== null && inside(point, level.bounds, radius) && !plan.blocked(levelId, point, point, radius); +} + +function inside(point: Point2, bounds: Bounds, radius: number): boolean { + return ( + point.x >= bounds.minX + radius && point.x <= bounds.maxX - radius && + point.z >= bounds.minZ + radius && point.z <= bounds.maxZ - radius + ); +} + +function bounded(point: Point2, bounds: Bounds, radius: number): Point2 { + return { + x: Math.min(bounds.maxX - radius, Math.max(bounds.minX + radius, point.x)), + z: Math.min(bounds.maxZ - radius, Math.max(bounds.minZ + radius, point.z)), + }; +} + +function positive(value: number, name: string): number { + if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`); + return value; +} + +function integer(value: number): number { + if (!Number.isInteger(value) || value < 1) throw new RangeError("maxCatchUpSteps must be a positive integer"); + return value; +} + +function finitePoint(point: Point2): boolean { + return Number.isFinite(point.x) && Number.isFinite(point.z); +} + +function copy(point: Point2): Point2 { + return { x: point.x, z: point.z }; +} + +function add(a: Point2, b: Point2): Point2 { + return { x: a.x + b.x, z: a.z + b.z }; +} + +function scale(point: Point2, amount: number): Point2 { + return { x: point.x * amount, z: point.z * amount }; +} + +function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number { + if (segmentsCross(a1, a2, b1, b2)) return 0; + return Math.min( + pointSegmentDistance(a1, b1, b2), + pointSegmentDistance(a2, b1, b2), + pointSegmentDistance(b1, a1, a2), + pointSegmentDistance(b2, a1, a2), + ); +} + +function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean { + const ab1 = cross(a1, a2, b1); + const ab2 = cross(a1, a2, b2); + const ba1 = cross(b1, b2, a1); + const ba2 = cross(b1, b2, a2); + // Proper crossing only. Collinear, disjoint segments must fall through to + // endpoint distance; treating every collinear pair as a crossing would make + // a walker sliding parallel to a distant wall collide with it. + return ab1 * ab2 < 0 && ba1 * ba2 < 0; +} + +function cross(a: Point2, b: Point2, point: Point2): number { + return (b.x - a.x) * (point.z - a.z) - (b.z - a.z) * (point.x - a.x); +} + +function pointSegmentDistance(point: Point2, a: Point2, b: Point2): number { + const dx = b.x - a.x; + const dz = b.z - a.z; + const lengthSquared = dx * dx + dz * dz; + if (lengthSquared <= EPSILON) return Math.hypot(point.x - a.x, point.z - a.z); + const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared)); + return Math.hypot(point.x - (a.x + t * dx), point.z - (a.z + t * dz)); +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts new file mode 100644 index 0000000..aa403b4 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts @@ -0,0 +1,687 @@ +/** + * Frontier Valley — a startup in a hangar at Alameda Point. + * + * The second office pack, and it exists to prove the format describes more than + * one kind of building. `lumbridge-hq.ts` is the dev kit: a corridor, fifteen + * rooms, a grid, everything a commercial floor plate has. This is the opposite + * building in every respect that matters, and the contrast is the point. + * + * ### Why a hangar, and why here + * + * Alameda Point is the former Naval Air Station Alameda, decommissioned in 1997: + * a mile of runway, a seaplane lagoon, and a row of enormous steel-framed + * hangars that have spent the last quarter century being rented to distilleries, + * film crews and companies that need a very large room cheaply. Putting a + * startup in one is not a conceit — it is the single most characteristic thing + * that happens on that piece of land. + * + * It also gives the site field something to say. `lumbridge-hq` is 188 m up a + * tower with the horizon far below it; this is **four metres above reclaimed + * ground on a flat island**, looking across the estuary at the city that the + * other office is inside. Same engine, same clock, same sun — and the two feel + * nothing like each other, which is the whole argument for `OfficeSite`. + * + * ### One level, one room, and a mezzanine that is furniture + * + * A hangar is a single volume. There is no corridor here because there is + * nothing to connect: the meeting rooms are freestanding boxes dropped on the + * slab, with their own low lids, and everything else is open floor. That is + * expressible in this format without a single new feature, which is worth + * knowing — the format was written against a cellular office and turns out to + * describe a shed just as well. + * + * ### The coordinate frame + * + * Metres, `1 unit = 1 m`, floor on the XZ plane with +Y up. Origin at the + * north-west corner. +X east, +Z south, so in plan view +Z runs down the page. + * The one difference from the reference pack is that here "north" is a genuine + * claim: `site.heading` is 0, because the hangars at Alameda Point really are + * laid out square to the compass along the old runways. + */ + +import type { + AssetId, + DeskBank, + Level, + Office, + Opening, + Outline, + Point2, + Prop, + Room, + Seat, + Viewpoint, + Wall, + Yaw, + Zone, +} from "../interiors/types.ts"; +import { FRONTIER_VALLEY_SITE } from "./sites.ts"; + +// ---- The shed ------------------------------------------------------------- + +/** + * A hangar, in three numbers. + * + * 54 x 30 m is a small one by Alameda standards — the surviving hangars on the + * seaplane lagoon run to twice this — but it is a plausible sublet, and it is + * already three times the floor area of the reference office's desk floor with + * nothing standing in it. + */ +const WIDTH = 54.0; +const DEPTH = 30.0; + +/** + * Nine metres to the underside of the trusses. + * + * This is the number that makes it a hangar rather than a warehouse-themed + * office. A commercial storey is 2.8 m; at 9 m the roof is somewhere you look + * *up* at, the meeting boxes read as objects standing in a room rather than as + * rooms carved out of one, and a mezzanine is a thing you can put under it. + */ +const RIDGE = 9.0; + +/** The freestanding boxes get their own low lids. A booth in a shed is a box. */ +const BOX_CEILING = 3.0; + +const EXT_THICKNESS = 0.35; +const INT_THICKNESS = 0.12; +const EXT_FACE = EXT_THICKNESS / 2; +const INT_FACE = INT_THICKNESS / 2; + +const DISPLAY_OFFSET = 0.07; +const BOARD_OFFSET = 0.06; + +/** + * The mezzanine deck. + * + * It is a `Level`, and getting there took one wrong turn worth recording. A deck + * inside a single volume reads like furniture, so it was first authored as a + * `Room` on the ground floor with everything on it carrying `elevation: 4.4`. + * That is not expressible: a `Room` is a floor *finish* and has no height, so + * the deck's slab lay flat on the concrete while its chairs floated four and a + * half metres over it, its glass balustrade fenced off a patch of ground-floor + * slab, and anybody sitting at `mezz-01` stood on the concrete underneath their + * own chair. + * + * A `Level` is the only thing in this format that carries a floor height, so the + * deck is one — with a storey height of `RIDGE - MEZZ_HEIGHT`, because what is + * above it is the same roof. + */ +const MEZZ_W = 38.0; +const MEZZ_N = 20.4; +const MEZZ_HEIGHT = 4.4; + +// The three freestanding boxes along the west end. +const BOX_1_W = 3.0; +const BOX_1_E = 10.2; +const BOX_2_W = 11.4; +const BOX_2_E = 17.4; +const BOX_N = 3.0; +const BOX_S = 9.6; + +// ---- Yaw ------------------------------------------------------------------ + +const NORTH: Yaw = 0; +const EAST: Yaw = -Math.PI / 2; +const SOUTH: Yaw = Math.PI; +const WEST: Yaw = Math.PI / 2; + +// ---- Assets --------------------------------------------------------------- + +const DESK: AssetId = "tera:desk.workstation"; +const PEDESTAL: AssetId = "tera:desk.pedestal"; +const TASK_CHAIR: AssetId = "tera:seat.task-chair"; +const LOUNGE_CHAIR: AssetId = "tera:seat.lounge"; +const MEETING_TABLE: AssetId = "tera:table.meeting"; +const SIDE_TABLE: AssetId = "tera:table.side"; +const SHELF: AssetId = "tera:storage.shelf"; +const LOCKER: AssetId = "tera:storage.locker"; +const DISPLAY: AssetId = "tera:screen.wall-display"; +const PLANT: AssetId = "tera:plant.potted"; +const TREE: AssetId = "tera:plant.tall"; +const PENDANT: AssetId = "tera:light.pendant"; +const RUG: AssetId = "tera:rug"; +const WHITEBOARD: AssetId = "tera:whiteboard"; + +const CONCRETE = "tera:concrete.polished"; +const WOOD = "tera:wood.plank"; +const CARPET_ACCENT = "tera:carpetAccent.broadloom"; +const PAINT = "tera:paint.matt"; +const ACCENT_PAINT = "tera:plasterAccent.deep"; +const GLASS = "tera:glass.curtain"; +const STEEL = "tera:steel.panel"; +const FELT = "tera:felt.acoustic"; + +// ---- Helpers -------------------------------------------------------------- + +function rect(x0: number, z0: number, x1: number, z1: number): Outline { + return [ + { x: x0, z: z0 }, + { x: x0, z: z1 }, + { x: x1, z: z1 }, + { x: x1, z: z0 }, + ]; +} + +function grid(x0: number, z0: number, columns: number, rows: number, dx: number, dz: number): Point2[] { + const points: Point2[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < columns; c++) points.push({ x: x0 + c * dx, z: z0 + r * dz }); + } + return points; +} + +function scatter( + prefix: string, + kind: AssetId, + points: Point2[], + opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {}, +): Prop[] { + return points.map((position, i) => ({ + id: `${prefix}-${String(i + 1).padStart(2, "0")}`, + kind, + position, + rotation: opts.rotation ?? NORTH, + elevation: opts.elevation, + colorKey: opts.colorKey, + })); +} + +function doorway(start: number, width = 0.9, head = 2.1): Opening { + return { kind: "door", start, width, sill: 0, head }; +} + +function pane(start: number, width: number, sill = 0.9, head = 2.2): Opening { + return { kind: "window", start, width, sill, head }; +} + +function archway(start: number, width: number, head = 2.4): Opening { + return { kind: "arch", start, width, sill: 0, head }; +} + +// ---- Rooms ---------------------------------------------------------------- + +const ROOMS: Room[] = [ + /** + * The slab. One room, fifty-four by thirty, notched around nothing. + * + * Every other "room" in this pack sits on top of this one as a different floor + * finish, and they are authored *after* it so `Plan.roomAt` resolves them + * first. That is the one ordering rule in the format and it is doing real work + * here: in the reference office the floor is a jigsaw of abutting slabs, and + * here it is one slab with rugs on it. + */ + { + id: "floor", + name: "The Floor", + outline: rect(0, 0, WIDTH, DEPTH), + floor: CONCRETE, + ceiling: null, + }, + { + id: "box-standup", + name: "Standup", + outline: rect(BOX_1_W, BOX_N, BOX_1_E, BOX_S), + floor: CARPET_ACCENT, + ceiling: { height: BOX_CEILING, surface: FELT }, + }, + { + id: "box-quiet", + name: "The Quiet Box", + outline: rect(BOX_2_W, BOX_N, BOX_2_E, BOX_S), + floor: CARPET_ACCENT, + ceiling: { height: BOX_CEILING, surface: FELT }, + }, + { + id: "workshop", + name: "The Bench", + // Against the east gable, where the big door is. A hardware startup in a + // hangar puts the thing it is building next to the way out. + outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0), + floor: CONCRETE, + ceiling: null, + }, + { + id: "galley", + name: "The Galley", + outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4), + floor: WOOD, + ceiling: null, + }, +]; + +// ---- Walls ---------------------------------------------------------------- + +const WALLS: Wall[] = [ + // -- The envelope, nine metres of it -------------------------------------- + { + id: "gable-west", + from: { x: 0, z: 0 }, + to: { x: 0, z: DEPTH }, + thickness: EXT_THICKNESS, + height: RIDGE, + surface: STEEL, + openings: [doorway(13.6, 1.8, 2.4), pane(4.0, 6.0, 3.6, 7.2), pane(19.0, 6.0, 3.6, 7.2)], + }, + { + id: "gable-east", + from: { x: WIDTH, z: 0 }, + to: { x: WIDTH, z: DEPTH }, + thickness: EXT_THICKNESS, + height: RIDGE, + surface: STEEL, + // The hangar door. Twelve metres of it, full height, because that is what a + // hangar is — and it is an `arch` rather than a `door` so the collider + // knows you can walk out onto the apron through it. + openings: [archway(6.0, 12.0, 6.4)], + }, + { + id: "eave-north", + from: { x: 0, z: 0 }, + to: { x: WIDTH, z: 0 }, + thickness: EXT_THICKNESS, + height: RIDGE, + surface: STEEL, + // Clerestory glazing high on the north side: the light a shed actually + // wants, and the sill is well above head height so none of it is a doorway. + openings: [ + pane(4.0, 9.0, 5.4, 8.2), + pane(16.0, 9.0, 5.4, 8.2), + pane(28.0, 9.0, 5.4, 8.2), + pane(40.0, 9.0, 5.4, 8.2), + ], + }, + { + id: "eave-south", + from: { x: 0, z: DEPTH }, + to: { x: WIDTH, z: DEPTH }, + thickness: EXT_THICKNESS, + height: RIDGE, + surface: STEEL, + // The lagoon side, and the only wall with glazing you can see out of at + // standing height. This is the view the whole building is arranged around. + openings: [ + pane(3.0, 10.0, 0.9, 3.4), + pane(16.0, 10.0, 0.9, 3.4), + pane(30.0, 8.0, 0.9, 3.4), + doorway(44.0, 1.8, 2.4), + ], + }, + + // -- The freestanding boxes ----------------------------------------------- + // + // Three metres tall inside a nine-metre volume, which is what makes them read + // as objects on the floor. Their fourth sides are open — a box you can see + // into is a box people use. + { + id: "standup-north", + from: { x: BOX_1_W, z: BOX_N }, + to: { x: BOX_1_E, z: BOX_N }, + height: BOX_CEILING, + surface: ACCENT_PAINT, + }, + { + id: "standup-west", + from: { x: BOX_1_W, z: BOX_N }, + to: { x: BOX_1_W, z: BOX_S }, + height: BOX_CEILING, + surface: ACCENT_PAINT, + }, + { + id: "standup-south", + from: { x: BOX_1_W, z: BOX_S }, + to: { x: BOX_1_E, z: BOX_S }, + height: BOX_CEILING, + surface: GLASS, + openings: [archway(2.4, 2.4, 2.4)], + }, + { + id: "quiet-north", + from: { x: BOX_2_W, z: BOX_N }, + to: { x: BOX_2_E, z: BOX_N }, + height: BOX_CEILING, + surface: ACCENT_PAINT, + }, + { + id: "quiet-east", + from: { x: BOX_2_E, z: BOX_N }, + to: { x: BOX_2_E, z: BOX_S }, + height: BOX_CEILING, + surface: ACCENT_PAINT, + }, + { + id: "quiet-south", + from: { x: BOX_2_W, z: BOX_S }, + to: { x: BOX_2_E, z: BOX_S }, + height: BOX_CEILING, + surface: GLASS, + openings: [doorway(2.6, 0.9, 2.1)], + }, + +]; + +// ---- Desks ---------------------------------------------------------------- + +/** + * Two long benches down the middle of the shed, and nothing against a wall. + * + * A hangar's walls are its least valuable surface — they are steel, they are + * cold, and the good light comes from above. So the desks sit in the volume and + * the edges are left for the things that want an edge. + */ +const DESK_BANKS: DeskBank[] = [ + { + id: "fv", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 22.0, z: 5.2 }, + rotation: 0, + columns: 7, + rows: 2, + pitch: 1.7, + rowPitch: 0.85, + facingRows: true, + }, + { + id: "fv-b", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 22.0, z: 10.4 }, + rotation: 0, + columns: 7, + rows: 2, + pitch: 1.7, + rowPitch: 0.85, + facingRows: true, + seatPrefix: "fv-b", + }, +]; + +const SEATS: Seat[] = [ + { id: "standup-01", position: { x: 5.0, z: 5.6 }, facing: EAST, pose: "stand" }, + { id: "standup-02", position: { x: 8.2, z: 5.6 }, facing: WEST, pose: "stand" }, + { id: "standup-03", position: { x: 6.6, z: 4.4 }, facing: SOUTH, pose: "stand" }, + + { id: "quietbox-01", position: { x: 13.0, z: 5.4 }, facing: EAST, pose: "sit" }, + { id: "quietbox-02", position: { x: 15.8, z: 5.4 }, facing: WEST, pose: "sit" }, + + { id: "galley-01", position: { x: 6.0, z: 25.0 }, facing: SOUTH, pose: "stand" }, + { id: "galley-02", position: { x: 7.6, z: 25.0 }, facing: SOUTH, pose: "stand" }, + { id: "galley-03", position: { x: 9.2, z: 25.0 }, facing: SOUTH, pose: "stand" }, + // On the table's south long side, not on the tabletop: the table is centred on + // z 27.0 and is 1.2 m deep, so a seat at 27.0 was inside it. + { id: "galley-04", position: { x: 12.4, z: 28.0 }, facing: NORTH, pose: "sit" }, + { id: "galley-05", position: { x: 14.0, z: 28.0 }, facing: NORTH, pose: "sit" }, + + { id: "bench-01", position: { x: 44.4, z: 6.0 }, facing: EAST, pose: "stand" }, + { id: "bench-02", position: { x: 44.4, z: 8.4 }, facing: EAST, pose: "stand" }, + { id: "bench-03", position: { x: 44.4, z: 10.8 }, facing: EAST, pose: "stand" }, + +]; + +// ---- Props ---------------------------------------------------------------- + +const PROPS: Prop[] = [ + // -- The desk floor ------------------------------------------------------- + ...scatter("fv-ped", PEDESTAL, grid(22.0, 6.8, 7, 1, 1.7, 0)), + ...scatter("fv-ped-b", PEDESTAL, grid(22.0, 12.0, 7, 1, 1.7, 0)), + /** + * The lights, hung from the trusses at seven metres. + * + * The one place this pack really needs the ceiling-fixture exception: a + * pendant is authored with its origin at the mounting plane and its geometry + * below, so 7.2 is a real height in a 9 m shed and not an offset from a lid + * that does not exist. + */ + ...scatter("truss-light", PENDANT, grid(8.0, 5.0, 6, 4, 9.0, 7.0), { elevation: 7.2 }), + + // -- The boxes ------------------------------------------------------------ + { id: "standup-board", kind: WHITEBOARD, position: { x: 6.6, z: BOX_N + INT_FACE + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 }, + { id: "standup-rug", kind: RUG, position: { x: 6.6, z: 6.3 }, rotation: NORTH, colorKey: "team" }, + { id: "quiet-table", kind: MEETING_TABLE, position: { x: 14.4, z: 5.4 }, rotation: NORTH }, + ...scatter("quiet-chair", TASK_CHAIR, [ + { x: 13.0, z: 5.4 }, + { x: 15.8, z: 5.4 }, + ]), + { id: "quiet-display", kind: DISPLAY, position: { x: 14.4, z: BOX_N + INT_FACE + DISPLAY_OFFSET }, rotation: NORTH, elevation: 1.15 }, + + // -- The bench ------------------------------------------------------------ + // Four, starting at z 1.1: the gable is solid only for z 0..6, and the hangar + // door's arch runs from 6.0 to 18.0 — a five-shelf run from 4.0 put three of + // them standing in the open doorway. + ...scatter("bench-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 1.1, 1, 4, 0, 1.2), { rotation: EAST }), + { id: "bench-board", kind: WHITEBOARD, position: { x: 43.0, z: 3.0 + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 }, + ...scatter("bench-locker", LOCKER, grid(42.4, 13.0, 3, 1, 1.1, 0), { rotation: WEST }), + + // -- The galley ----------------------------------------------------------- + { id: "galley-rug", kind: RUG, position: { x: 9.0, z: 26.4 }, rotation: NORTH, colorKey: "social" }, + { id: "galley-table", kind: MEETING_TABLE, position: { x: 13.2, z: 27.0 }, rotation: NORTH }, + ...scatter("galley-lounge", LOUNGE_CHAIR, [ + { x: 6.0, z: 26.8 }, + { x: 8.4, z: 26.8 }, + { x: 10.8, z: 26.8 }, + ]), + { id: "galley-side", kind: SIDE_TABLE, position: { x: 7.2, z: 25.8 }, rotation: NORTH }, + ...scatter("galley-plant", TREE, [ + { x: 4.0, z: 23.4 }, + { x: 15.0, z: 23.4 }, + ]), + + // -- The floor, kept mostly empty ----------------------------------------- + // + // Three trees and nothing else. The shed's argument is the volume, and a + // fifty-four-metre room with forty objects in it is a fifty-four-metre room + // you cannot see across. + ...scatter("floor-tree", TREE, [ + { x: 20.0, z: 20.0 }, + { x: 30.0, z: 24.0 }, + { x: 36.0, z: 4.0 }, + ]), + ...scatter("floor-plant", PLANT, [ + { x: 19.6, z: 3.2 }, + { x: 34.0, z: 14.0 }, + ]), + +]; + +// ---- Zones ---------------------------------------------------------------- + +const ZONES: Zone[] = [ + { id: "zone-build", name: "Build", outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0), colorKey: "team" }, + { id: "zone-galley", name: "Galley", outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4), colorKey: "social" }, +]; + +// ---- Viewpoints ----------------------------------------------------------- + +const VIEWPOINTS: Viewpoint[] = [ + { + id: "hangar", + number: "01", + label: "The Hangar", + shortLabel: "Hangar", + levelId: "level-1", + focus: { at: { x: 27, z: 15 }, distance: 52, height: 26, rotation: 0.6 }, + description: + "Fifty-four metres by thirty, nine to the trusses, one room. Everything Frontier Valley has is on this slab or on the deck at the far end.", + }, + { + id: "benches", + number: "02", + label: "The Benches", + shortLabel: "Benches", + levelId: "level-1", + focus: { at: { x: 27.4, z: 8.0 }, distance: 15, height: 4.6, rotation: 0.4 }, + description: + "Twenty-eight seats in two runs down the middle of the floor, under the trusses. Nothing is against a wall — in a shed the walls are the worst surface in the building.", + }, + { + id: "big-door", + number: "03", + label: "The Big Door", + shortLabel: "Big Door", + levelId: "level-1", + // Low and looking east, straight out through the twelve-metre opening. + focus: { at: { x: 46, z: 9.5 }, distance: 18, height: 3.0, rotation: 1.5 }, + description: + "Twelve metres of hangar door, open onto the apron, with the bench inside it. The reason a company rents one of these rather than a floor of an office block.", + }, + { + id: "mezzanine", + number: "04", + label: "The Mezzanine", + shortLabel: "Mezzanine", + levelId: "level-mezz", + // `height` is metres above *this* level's floor, so 3.0 here is standing on + // the deck rather than 3 m off the concrete. + focus: { at: { x: 46, z: 25 }, distance: 13, height: 3.0, rotation: 2.4 }, + description: + "A deck four and a half metres up with another four and a half above it. Its own level, because a level is the only thing in this format that carries a floor height — and without one its chairs floated over bare concrete.", + }, + { + id: "galley", + number: "05", + label: "The Galley", + shortLabel: "Galley", + levelId: "level-1", + focus: { at: { x: 9.5, z: 26 }, distance: 12, height: 3.2, rotation: 3.4 }, + description: + "The lagoon side, and the only glazing you can see out of standing up. The whole social end of the building is arranged around one row of windows.", + }, +]; + +// ---- The pack ------------------------------------------------------------- + +// ---- The mezzanine, as its own level -------------------------------------- +// +// Authored in its own floor's frame, with the deck at zero. `Plan` adds +// `MEZZ_HEIGHT` to every coordinate here exactly once. + +const MEZZ_ROOMS: Room[] = [ + { + id: "mezzanine", + name: "The Mezzanine", + outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4), + floor: WOOD, + ceiling: null, + }, +]; + +/** + * The balustrade, and — as in the reference pack's gallery — a **wall** and not + * a prop, because `Plan` derives the walk collider from the wall list and the + * drop is 4.4 m onto polished concrete. No openings: that is what a balustrade + * is. + */ +const MEZZ_WALLS: Wall[] = [ + { + id: "mezz-west", + from: { x: MEZZ_W, z: MEZZ_N }, + to: { x: MEZZ_W, z: DEPTH - 0.4 }, + height: 1.1, + surface: GLASS, + }, + { + id: "mezz-north", + from: { x: MEZZ_W, z: MEZZ_N }, + to: { x: WIDTH - 0.4, z: MEZZ_N }, + height: 1.1, + surface: GLASS, + }, +]; + +const MEZZ_SEATS: Seat[] = [ + { id: "mezz-01", position: { x: 44.0, z: 23.0 }, facing: NORTH, pose: "sit" }, + { id: "mezz-02", position: { x: 46.4, z: 23.0 }, facing: NORTH, pose: "sit" }, + { id: "mezz-03", position: { x: 48.8, z: 23.0 }, facing: NORTH, pose: "sit" }, +]; + +const MEZZ_PROPS: Prop[] = [ + { id: "mezz-table", kind: MEETING_TABLE, position: { x: 46.4, z: 24.2 }, rotation: NORTH }, + ...scatter("mezz-chair", TASK_CHAIR, [ + { x: 44.0, z: 23.0 }, + { x: 46.4, z: 23.0 }, + { x: 48.8, z: 23.0 }, + ]), + ...scatter("mezz-lounge", LOUNGE_CHAIR, [ + { x: 44.0, z: 27.4 }, + { x: 47.0, z: 27.4 }, + ], { rotation: SOUTH }), + { id: "mezz-rug", kind: RUG, position: { x: 45.5, z: 27.4 }, rotation: NORTH, colorKey: "social" }, + // Half-depth plus a 25 mm reveal off the wall face, which is how the reference + // pack stands the same asset against a wall — `EXT_FACE + 0.2` is the shelf's + // *clearance* and leaves it floating 0.42 m out in the room. + ...scatter("mezz-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 22.0, 1, 3, 0, 1.2), { + rotation: EAST, + }), +]; + +const MEZZ_ZONES: Zone[] = [ + { id: "zone-mezz", name: "Mezzanine", outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4), colorKey: "social" }, +]; + +const LEVEL_MEZZ: Level = { + id: "level-mezz", + name: "The Mezzanine", + elevation: MEZZ_HEIGHT, + // What is above the deck is the same roof, so its storey height is whatever is + // left of the shed. + wallHeight: RIDGE - MEZZ_HEIGHT, + wallThickness: INT_THICKNESS, + wallSurface: PAINT, + floorplan: { + rooms: MEZZ_ROOMS, + walls: MEZZ_WALLS, + props: MEZZ_PROPS, + seats: MEZZ_SEATS, + zones: MEZZ_ZONES, + }, +}; + +const LEVEL_1: Level = { + id: "level-1", + name: "The Slab", + elevation: 0, + wallHeight: RIDGE, + wallThickness: INT_THICKNESS, + wallSurface: PAINT, + floorplan: { + rooms: ROOMS, + walls: WALLS, + props: PROPS, + deskBanks: DESK_BANKS, + seats: SEATS, + zones: ZONES, + }, +}; + +export const FRONTIER_VALLEY: Office = { + id: "frontier-valley", + name: "Frontier Valley", + levels: [LEVEL_1, LEVEL_MEZZ], + viewpoints: VIEWPOINTS, + /** + * Alameda Point, on the estuary side of Alameda Island. + * + * Typed by hand from the street grid of the former Naval Air Station, not + * geocoded — CONTRACT.md §8. Four metres of elevation because this is + * reclaimed flat ground and the slab is barely above it, which is the exact + * opposite of `lumbridge-hq`'s 188 m and is the whole point of having both. + * + * `heading: 0` is a real claim rather than the reference pack's convenience: + * the hangars here are laid out square to the old runways, which run close + * enough to north–south that calling the clerestory wall "north" is true. + * So the high glazing really does take north light and the lagoon really is + * to the south. + */ + site: FRONTIER_VALLEY_SITE, + meta: { + description: + "A startup in a hangar at Alameda Point: one room, fifty-four by thirty, nine metres to the trusses. The second pack, and the one that shows the format describes a shed as well as it describes a corridor.", + author: "Lumbridge", + license: "CC0-1.0", + version: "1.0.0", + updated: "2026-08-07", + }, +}; + +export default FRONTIER_VALLEY; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts new file mode 100644 index 0000000..ca7f6cd --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts @@ -0,0 +1,107 @@ +/** + * Where the shipped buildings stand, separately from the buildings themselves. + * + * An `Office` pack carries its own `site`, and that is still the authority — a + * pack handed over HTTP by a self-hoster brings its site with it and never + * touches this file. What this file exists for is the one question the *city* + * asks, which a pack cannot answer without being loaded: **where are the + * buildings I could walk into?** + * + * The office packs are deliberately lazy chunks. `lumbridge-hq` alone is 25 kB + * of furniture and floor plan, and the whole point of `loadOffice`'s dynamic + * import is that a visitor who only ever looks at the city never pays for it. + * But the city wants to draw a marker on the two buildings the moment the board + * appears, which is long before anybody has opened a door — so importing a pack + * to read four numbers off it would put the entire catalogue back in the entry + * chunk and undo the split. + * + * Hence a tiny eagerly-imported module holding just the coordinates, and each + * pack importing its own site **from here** rather than declaring it inline. One + * source of truth, and the direction of the dependency is the safe one: the + * small thing does not know about the large one. + */ + +import type { OfficeSite } from "../interiors/types.ts"; + +/** + * High in a Transbay tower. See `lumbridge-hq.ts` for what the four numbers mean + * and why `heading` is the one that decides whether the sun ever gets in. + */ +export const LUMBRIDGE_HQ_SITE: OfficeSite = { + lat: 37.7897, + lng: -122.3972, + elevation: 188, + heading: 205, + label: "Transbay, San Francisco", + exterior: { + kind: "building", + width: 48, + depth: 42, + height: 326, + storeys: 61, + heading: 205, + profile: "tower", + seed: 115, + bodyColor: 0x8799a8, + }, +}; + +/** A hangar on the old naval air station. See `frontier-valley.ts`. */ +export const FRONTIER_VALLEY_SITE: OfficeSite = { + lat: 37.7756, + lng: -122.3186, + elevation: 4, + heading: 0, + label: "Alameda Point", + exterior: { + kind: "building", + width: 54, + depth: 30, + height: 11, + storeys: 2, + heading: 0, + profile: "hangar", + seed: 2718, + bodyColor: 0x899397, + }, +}; + +/** + * A courtyard block in the Arts District, square to the pueblo grid rather than + * to the compass — which is the whole reason `heading` is a field. See + * `mateo-court.ts` for where the numbers come from: 1.2 m is a loading dock, and + * 36° is the 1781 survey the downtown street grid still follows. + */ +export const MATEO_COURT_SITE: OfficeSite = { + lat: 34.0395, + lng: -118.2288, + elevation: 1.2, + heading: 36, + label: "Arts District, Los Angeles", + exterior: { + kind: "building", + width: 36, + depth: 26, + height: 9, + storeys: 2, + heading: 36, + profile: "courtyard", + seed: 1781, + bodyColor: 0xa87960, + }, +}; + +/** + * Every building this build can walk into, for the city to point at. + * + * `id` matches the pack's own `Office.id` and the `OFFICES` table in `main.ts`, + * which is what lets a click on a marker resolve to a door. Keeping the three in + * step is not enforced by the type system; it is enforced by there being three of + * them and by `office.test.ts` asserting that each pack's site is the very + * object named here — identity, not equality, so a restated coordinate fails. + */ +export const OFFICE_SITES: { id: string; name: string; site: OfficeSite }[] = [ + { id: "lumbridge-hq", name: "Lumbridge HQ", site: LUMBRIDGE_HQ_SITE }, + { id: "frontier-valley", name: "Frontier Valley", site: FRONTIER_VALLEY_SITE }, + { id: "mateo-court", name: "Mateo Court", site: MATEO_COURT_SITE }, +]; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts new file mode 100644 index 0000000..2148fb1 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts @@ -0,0 +1,380 @@ +/** + * The first California transport corridor: two coarse LA-to-Bay itineraries. + * + * This is simulation geometry, not a navigation dataset. Coordinates were + * placed by hand at recognisable cities and junctions, with long road sections + * represented by one straight edge. In particular, I-5 does not enter San + * Francisco: that itinerary names its real Bay approach over I-580 and I-80. + */ + +import type { + TransportAnchor, + TransportNode, + TransportPack, + TransportRoute, + TransportSegment, +} from "./types.ts"; + +const AUTHORED_NODE = + "Original coarse waypoint authored by the Tera project from general California geography; approximate and not for navigation."; +const AUTHORED_ROAD = + "Original coarse simulation segment authored by the Tera project; road identity and representative speed/lanes are approximate, not live navigation data."; +const INTERNAL_OFFICE = + "Transition coordinate mirrors Tera's authored office site catalogue; it is project data, not copied map geometry."; + +export const CALIFORNIA_TRANSPORT_NODES: readonly TransportNode[] = [ + { + id: "los-angeles", + label: "Los Angeles", + kind: "terminus", + position: { lat: 34.0522, lng: -118.2437 }, + provenance: AUTHORED_NODE, + }, + + // US-101: the coast and Salinas Valley approach. + { + id: "ventura", + label: "Ventura", + kind: "waypoint", + position: { lat: 34.2805, lng: -119.2945 }, + provenance: AUTHORED_NODE, + }, + { + id: "santa-barbara", + label: "Santa Barbara", + kind: "waypoint", + position: { lat: 34.4208, lng: -119.6982 }, + provenance: AUTHORED_NODE, + }, + { + id: "santa-maria", + label: "Santa Maria", + kind: "waypoint", + position: { lat: 34.953, lng: -120.4357 }, + provenance: AUTHORED_NODE, + }, + { + id: "san-luis-obispo", + label: "San Luis Obispo", + kind: "waypoint", + position: { lat: 35.2828, lng: -120.6596 }, + provenance: AUTHORED_NODE, + }, + { + id: "paso-robles", + label: "Paso Robles", + kind: "waypoint", + position: { lat: 35.626, lng: -120.691 }, + provenance: AUTHORED_NODE, + }, + { + id: "king-city", + label: "King City", + kind: "waypoint", + position: { lat: 36.2127, lng: -121.126 }, + provenance: AUTHORED_NODE, + }, + { + id: "salinas", + label: "Salinas", + kind: "waypoint", + position: { lat: 36.6777, lng: -121.6555 }, + provenance: AUTHORED_NODE, + }, + { + id: "gilroy", + label: "Gilroy", + kind: "waypoint", + position: { lat: 37.0058, lng: -121.5683 }, + provenance: AUTHORED_NODE, + }, + { + id: "san-jose", + label: "San Jose", + kind: "junction", + position: { lat: 37.3382, lng: -121.8863 }, + provenance: AUTHORED_NODE, + }, + { + id: "redwood-city", + label: "Redwood City", + kind: "waypoint", + position: { lat: 37.4852, lng: -122.2364 }, + provenance: AUTHORED_NODE, + }, + + // I-5: Central Valley, followed by an explicitly named Bay connector. + { + id: "santa-clarita", + label: "Santa Clarita", + kind: "waypoint", + position: { lat: 34.3917, lng: -118.5426 }, + provenance: AUTHORED_NODE, + }, + { + id: "grapevine", + label: "Grapevine", + kind: "waypoint", + position: { lat: 34.9416, lng: -118.929 }, + provenance: AUTHORED_NODE, + }, + { + id: "lost-hills", + label: "Lost Hills", + kind: "waypoint", + position: { lat: 35.6166, lng: -119.6943 }, + provenance: AUTHORED_NODE, + }, + { + id: "coalinga-interchange", + label: "Coalinga / SR-198", + kind: "junction", + position: { lat: 36.253, lng: -120.237 }, + provenance: AUTHORED_NODE, + }, + { + id: "santa-nella", + label: "Santa Nella", + kind: "junction", + position: { lat: 37.102, lng: -121.016 }, + provenance: AUTHORED_NODE, + }, + { + id: "tracy", + label: "Tracy", + kind: "junction", + position: { lat: 37.7397, lng: -121.4252 }, + provenance: AUTHORED_NODE, + }, + { + id: "altamont-pass", + label: "Altamont Pass", + kind: "waypoint", + position: { lat: 37.696, lng: -121.686 }, + provenance: AUTHORED_NODE, + }, + { + id: "dublin", + label: "Dublin", + kind: "waypoint", + position: { lat: 37.7022, lng: -121.9358 }, + provenance: AUTHORED_NODE, + }, + { + id: "oakland", + label: "Oakland", + kind: "junction", + position: { lat: 37.8044, lng: -122.2712 }, + provenance: AUTHORED_NODE, + }, + { + id: "bay-bridge", + label: "San Francisco-Oakland Bay Bridge", + kind: "junction", + position: { lat: 37.7983, lng: -122.3778 }, + provenance: AUTHORED_NODE, + }, + { + id: "san-francisco", + label: "San Francisco", + kind: "terminus", + position: { lat: 37.7749, lng: -122.4194 }, + provenance: AUTHORED_NODE, + }, +]; + +export const CALIFORNIA_TRANSPORT_SEGMENTS: readonly TransportSegment[] = [ + // US-101 route. + ["us101-la-ventura", "los-angeles", "ventura", 65, 3], + ["us101-ventura-santa-barbara", "ventura", "santa-barbara", 65, 2], + ["us101-santa-barbara-santa-maria", "santa-barbara", "santa-maria", 65, 2], + ["us101-santa-maria-san-luis-obispo", "santa-maria", "san-luis-obispo", 65, 2], + ["us101-san-luis-obispo-paso-robles", "san-luis-obispo", "paso-robles", 65, 2], + ["us101-paso-robles-king-city", "paso-robles", "king-city", 65, 2], + ["us101-king-city-salinas", "king-city", "salinas", 65, 2], + ["us101-salinas-gilroy", "salinas", "gilroy", 65, 2], + ["us101-gilroy-san-jose", "gilroy", "san-jose", 65, 3], + ["us101-san-jose-redwood-city", "san-jose", "redwood-city", 65, 4], + ["us101-redwood-city-san-francisco", "redwood-city", "san-francisco", 65, 4], +].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({ + id: id as string, + fromNodeId: fromNodeId as string, + toNodeId: toNodeId as string, + roadName: "US-101", + kind: "us-highway" as const, + speedLimitMph: speedLimitMph as number, + lanesPerDirection: lanesPerDirection as number, + provenance: AUTHORED_ROAD, +})); + +const INTERSTATE_SEGMENTS: readonly TransportSegment[] = [ + ["i5-la-santa-clarita", "los-angeles", "santa-clarita", 65, 4], + ["i5-santa-clarita-grapevine", "santa-clarita", "grapevine", 65, 3], + ["i5-grapevine-lost-hills", "grapevine", "lost-hills", 70, 2], + ["i5-lost-hills-coalinga", "lost-hills", "coalinga-interchange", 70, 2], + ["i5-coalinga-santa-nella", "coalinga-interchange", "santa-nella", 70, 2], + ["i5-santa-nella-tracy", "santa-nella", "tracy", 70, 3], +].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({ + id: id as string, + fromNodeId: fromNodeId as string, + toNodeId: toNodeId as string, + roadName: "I-5", + kind: "interstate" as const, + speedLimitMph: speedLimitMph as number, + lanesPerDirection: lanesPerDirection as number, + provenance: AUTHORED_ROAD, +})); + +const BAY_CONNECTOR_SEGMENTS: readonly TransportSegment[] = [ + { + id: "i580-tracy-altamont", + fromNodeId: "tracy", + toNodeId: "altamont-pass", + roadName: "I-580", + kind: "connector", + speedLimitMph: 65, + lanesPerDirection: 3, + provenance: AUTHORED_ROAD, + }, + { + id: "i580-altamont-dublin", + fromNodeId: "altamont-pass", + toNodeId: "dublin", + roadName: "I-580", + kind: "connector", + speedLimitMph: 65, + lanesPerDirection: 4, + provenance: AUTHORED_ROAD, + }, + { + id: "i580-dublin-oakland", + fromNodeId: "dublin", + toNodeId: "oakland", + roadName: "I-580", + kind: "connector", + speedLimitMph: 65, + lanesPerDirection: 4, + provenance: AUTHORED_ROAD, + }, + { + id: "i80-oakland-bay-bridge", + fromNodeId: "oakland", + toNodeId: "bay-bridge", + roadName: "I-80 / Bay Bridge", + kind: "connector", + speedLimitMph: 50, + lanesPerDirection: 5, + provenance: AUTHORED_ROAD, + }, + { + id: "i80-bay-bridge-san-francisco", + fromNodeId: "bay-bridge", + toNodeId: "san-francisco", + roadName: "I-80", + kind: "connector", + speedLimitMph: 50, + lanesPerDirection: 5, + provenance: AUTHORED_ROAD, + }, +]; + +export const CALIFORNIA_I5_SEGMENTS: readonly TransportSegment[] = [ + ...INTERSTATE_SEGMENTS, + ...BAY_CONNECTOR_SEGMENTS, +]; + +const US_101_SEGMENT_IDS = CALIFORNIA_TRANSPORT_SEGMENTS.map((segment) => segment.id); +const I_5_SEGMENT_IDS = CALIFORNIA_I5_SEGMENTS.map((segment) => segment.id); + +export const CALIFORNIA_TRANSPORT_ROUTES: readonly TransportRoute[] = [ + { + id: "la-sf-us-101", + label: "Los Angeles to San Francisco via US-101", + description: "The coastal and Salinas Valley route through Santa Barbara and San Jose.", + fromNodeId: "los-angeles", + toNodeId: "san-francisco", + segmentIds: US_101_SEGMENT_IDS, + provenance: AUTHORED_ROAD, + }, + { + id: "la-sf-i-5", + label: "Los Angeles to San Francisco via I-5, I-580, and I-80", + description: + "The Central Valley route, leaving I-5 at Tracy for I-580 and I-80 across the Bay Bridge.", + fromNodeId: "los-angeles", + toNodeId: "san-francisco", + segmentIds: I_5_SEGMENT_IDS, + provenance: AUTHORED_ROAD, + }, +]; + +export const CALIFORNIA_TRANSPORT_ANCHORS: readonly TransportAnchor[] = [ + { + id: "city-socal", + kind: "city", + cityId: "socal", + label: "Southern California", + nodeId: "los-angeles", + position: { lat: 34.0522, lng: -118.2437 }, + provenance: AUTHORED_NODE, + }, + { + id: "city-sf", + kind: "city", + cityId: "sf", + label: "San Francisco", + nodeId: "san-francisco", + position: { lat: 37.7749, lng: -122.4194 }, + provenance: AUTHORED_NODE, + }, + { + id: "office-mateo-court", + kind: "office", + cityId: "socal", + officeId: "mateo-court", + label: "Mateo Court", + nodeId: "los-angeles", + position: { lat: 34.0395, lng: -118.2288 }, + provenance: INTERNAL_OFFICE, + }, + { + id: "office-lumbridge-hq", + kind: "office", + cityId: "sf", + officeId: "lumbridge-hq", + label: "Lumbridge HQ", + nodeId: "san-francisco", + position: { lat: 37.7897, lng: -122.3972 }, + provenance: INTERNAL_OFFICE, + }, + { + id: "office-frontier-valley", + kind: "office", + cityId: "sf", + officeId: "frontier-valley", + label: "Frontier Valley", + nodeId: "oakland", + position: { lat: 37.7756, lng: -122.3186 }, + provenance: INTERNAL_OFFICE, + }, +]; + +/** All corridor data in one JSON-safe object. */ +export const CALIFORNIA_TRANSPORT: TransportPack = { + id: "california-la-bay", + name: "California: Los Angeles to the Bay", + schemaVersion: 1, + description: + "A coarse statewide simulation corridor connecting Tera's Southern California and San Francisco scenes.", + nodes: CALIFORNIA_TRANSPORT_NODES, + segments: [...CALIFORNIA_TRANSPORT_SEGMENTS, ...CALIFORNIA_I5_SEGMENTS], + routes: CALIFORNIA_TRANSPORT_ROUTES, + anchors: CALIFORNIA_TRANSPORT_ANCHORS, + provenance: [ + "Original manually authored Tera project data; no third-party map geometry is embedded.", + "Coordinates, lane counts, and speed envelopes are coarse simulation inputs and must not be used for navigation.", + "Office transition coordinates mirror the repository's own src/offices/sites.ts catalogue.", + ], +}; + +export default CALIFORNIA_TRANSPORT; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts new file mode 100644 index 0000000..b618117 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts @@ -0,0 +1,94 @@ +/** + * Serializable contracts for transport packs. + * + * These types deliberately contain data only: no classes, dates, maps, or + * three.js values. A pack can therefore cross an HTTP boundary, live in a + * Worker, or be recorded for a deterministic replay without translation. + */ + +/** A WGS84-like geographic position in decimal degrees. */ +export interface GeographicPoint { + lat: number; + lng: number; +} + +/** The road system responsible for a segment. */ +export type RoadKind = "interstate" | "us-highway" | "connector"; + +/** Why a node exists in the deliberately sparse corridor graph. */ +export type TransportNodeKind = "terminus" | "waypoint" | "junction"; + +export interface TransportNode { + id: string; + label: string; + kind: TransportNodeKind; + position: GeographicPoint; + /** Human-readable origin and accuracy note for this authored coordinate. */ + provenance: string; +} + +/** + * A directed driveable edge. Reverse itineraries traverse the same edge in + * reverse; geometry is intentionally not duplicated for each direction. + */ +export interface TransportSegment { + id: string; + fromNodeId: string; + toNodeId: string; + /** The name a route badge or itinerary should show. */ + roadName: string; + kind: RoadKind; + /** Coarse simulation envelope, not live traffic or navigation advice. */ + speedLimitMph: number; + /** Number of through lanes in one direction at the representative section. */ + lanesPerDirection: number; + provenance: string; +} + +/** A named, ordered itinerary through the segment graph. */ +export interface TransportRoute { + id: string; + label: string; + description: string; + fromNodeId: string; + toNodeId: string; + segmentIds: readonly string[]; + provenance: string; +} + +interface AnchorBase { + id: string; + label: string; + /** Nearest corridor node used to enter or leave the large-scale simulation. */ + nodeId: string; + /** Exact transition marker; it need not lie on the corridor centreline. */ + position: GeographicPoint; + provenance: string; +} + +export interface CityTransportAnchor extends AnchorBase { + kind: "city"; + cityId: string; +} + +export interface OfficeTransportAnchor extends AnchorBase { + kind: "office"; + cityId: string; + officeId: string; +} + +export type TransportAnchor = CityTransportAnchor | OfficeTransportAnchor; + +/** A complete coarse world graph and its links to finer Tera scenes. */ +export interface TransportPack { + id: string; + name: string; + schemaVersion: 1; + description: string; + nodes: readonly TransportNode[]; + segments: readonly TransportSegment[]; + routes: readonly TransportRoute[]; + anchors: readonly TransportAnchor[]; + /** Pack-wide authorship and fitness-for-purpose notices. */ + provenance: readonly string[]; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts new file mode 100644 index 0000000..a6a5358 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts @@ -0,0 +1,542 @@ +/** + * Renderer-independent solo vehicle controls for a route-relative simulation. + * + * Inputs are normalized action snapshots rather than DOM events, so keyboards, + * gamepads, touch controls, remote clients, and recorded replays all drive the + * same deterministic fixed-step state machine. + */ + +import type { GeographicPoint, TransportPack } from "./types.ts"; +import { + buildRoutePath, + sampleRoute, + type RoutePath, + type RouteSample, +} from "./vehicleSim.ts"; + +const MPH_TO_MPS = 0.44704; +const EARTH_RADIUS_M = 6_371_000; +const TWO_PI = Math.PI * 2; + +export type VehicleControlMode = "assisted" | "manual"; +export type VehicleModeRequest = "none" | VehicleControlMode; + +/** Device-neutral actions sampled for one rendered or fixed simulation frame. */ +export interface VehicleActionSnapshot { + /** Accelerator position in the inclusive range [0, 1]. */ + throttle: number; + /** Service brake position in the inclusive range [0, 1]. */ + brake: number; + /** Steering input where -1 is full left and 1 is full right. */ + steering: number; + handbrake: boolean; + /** One-shot mode request. Meaningful even when all analogue axes are neutral. */ + modeRequest: VehicleModeRequest; + /** One-shot request to restore the configured initial state. */ + reset: boolean; +} + +export const NEUTRAL_VEHICLE_ACTIONS: Readonly = Object.freeze({ + throttle: 0, + brake: 0, + steering: 0, + handbrake: false, + modeRequest: "none", + reset: false, +}); + +export interface VehicleControllerOptions { + routeId: string; + mode?: VehicleControlMode; + direction?: 1 | -1; + initialDistanceM?: number; + initialLateralOffsetM?: number; + initialSpeedMps?: number; + /** Defaults to 60 Hz and is clamped to a safe simulation range. */ + fixedStepSeconds?: number; + /** Caps catch-up after a sleeping tab. Defaults to 0.25 seconds. */ + maxFrameDeltaSeconds?: number; + maximumSpeedMps?: number; + assistedCruiseRatio?: number; + guardrailOffsetM?: number; + wheelRadiusM?: number; + /** + * Multiplies longitudinal route progress without changing acceleration or + * steering response. State-scale boards use compression; metre-scale roads + * leave this at 1. Defaults to 1. + */ + travelScale?: number; +} + +export interface VehicleControllerState extends GeographicPoint { + routeId: string; + mode: VehicleControlMode; + direction: 1 | -1; + /** Distance from the route's declared start. A restored endpoint may equal route length. */ + distanceM: number; + progress: number; + /** Signed offset from route centre; positive is to the driver's right. */ + lateralOffsetM: number; + speedMps: number; + /** Smoothed normalized steering position, independent of input device. */ + steering: number; + routeHeadingDeg: number; + headingDeg: number; + segmentId: string; + roadName: string; + speedLimitMph: number; + wheelRadians: number; + guardrailContact: boolean; + /** Signed realized acceleration and jerk from the deterministic fixed step. */ + longitudinalAccelerationMps2: number; + jerkMps3: number; + /** 1 is centred/stable, 0 is at the configured road edge. */ + laneKeepingScore: number; + leadGapM: number | null; + leadSpeedMps: number | null; + timeHeadwaySeconds: number | null; + /** Normalized [0,1] closing/gap risk estimate. */ + collisionRisk: number; + trafficIntervention: boolean; + elapsedSteps: number; +} + +export interface VehicleTrafficContext { + leadGapM: number | null; + leadSpeedMps: number | null; +} + +export interface VehicleControllerSnapshot extends VehicleControllerState {} + +/** A held input snapshot and its exact duration in fixed simulation steps. */ +export interface TimedVehicleInputFrame { + steps: number; + actions?: Partial; +} + +export interface VehicleReplayResult { + /** Initial state followed by one snapshot after every simulated step. */ + trajectory: readonly VehicleControllerSnapshot[]; + final: VehicleControllerSnapshot; +} + +interface ResolvedOptions { + routeId: string; + mode: VehicleControlMode; + direction: 1 | -1; + initialDistanceM: number; + initialLateralOffsetM: number; + initialSpeedMps: number; + fixedStepSeconds: number; + maxFrameDeltaSeconds: number; + maximumSpeedMps: number; + assistedCruiseRatio: number; + guardrailOffsetM: number; + wheelRadiusM: number; + travelScale: number; +} + +function finiteOr(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function wrap(value: number, modulus: number): number { + return ((value % modulus) + modulus) % modulus; +} + +function moveToward(value: number, target: number, maximumDelta: number): number { + if (value < target) return Math.min(value + maximumDelta, target); + if (value > target) return Math.max(value - maximumDelta, target); + return value; +} + +/** Clamp and sanitize input from any adapter before it reaches simulation. */ +export function normalizeVehicleActions( + actions: Partial | undefined, +): VehicleActionSnapshot { + const modeRequest = actions?.modeRequest; + return { + throttle: clamp(finiteOr(actions?.throttle, 0), 0, 1), + brake: clamp(finiteOr(actions?.brake, 0), 0, 1), + steering: clamp(finiteOr(actions?.steering, 0), -1, 1), + handbrake: actions?.handbrake === true, + modeRequest: modeRequest === "manual" || modeRequest === "assisted" ? modeRequest : "none", + reset: actions?.reset === true, + }; +} + +function resolveOptions(options: VehicleControllerOptions): ResolvedOptions { + return { + routeId: options.routeId, + mode: options.mode === "manual" ? "manual" : "assisted", + direction: options.direction === -1 ? -1 : 1, + initialDistanceM: finiteOr(options.initialDistanceM, 0), + initialLateralOffsetM: finiteOr(options.initialLateralOffsetM, 0), + initialSpeedMps: Math.max(0, finiteOr(options.initialSpeedMps, 0)), + fixedStepSeconds: clamp(finiteOr(options.fixedStepSeconds, 1 / 60), 1 / 240, 0.1), + maxFrameDeltaSeconds: clamp(finiteOr(options.maxFrameDeltaSeconds, 0.25), 0.05, 1), + maximumSpeedMps: clamp(finiteOr(options.maximumSpeedMps, 58), 5, 100), + assistedCruiseRatio: clamp(finiteOr(options.assistedCruiseRatio, 0.92), 0.25, 1.1), + guardrailOffsetM: clamp(finiteOr(options.guardrailOffsetM, 5.4), 1, 20), + wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, 0.36), 0.1, 1), + travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000), + }; +} + +function hasManualIntent(actions: VehicleActionSnapshot): boolean { + return ( + actions.modeRequest === "manual" || + actions.handbrake || + actions.throttle > 0.02 || + actions.brake > 0.02 || + Math.abs(actions.steering) > 0.08 + ); +} + +function offsetPoint(sample: RouteSample, lateralOffsetM: number): GeographicPoint { + const heading = (sample.headingDeg * Math.PI) / 180; + // Right-hand normal to a compass bearing: south for eastbound, east for northbound. + const northM = -Math.sin(heading) * lateralOffsetM; + const eastM = Math.cos(heading) * lateralOffsetM; + const latitudeRadians = (sample.lat * Math.PI) / 180; + return { + lat: sample.lat + (northM / EARTH_RADIUS_M) * (180 / Math.PI), + lng: + sample.lng + + (eastM / (EARTH_RADIUS_M * Math.max(0.01, Math.cos(latitudeRadians)))) * (180 / Math.PI), + }; +} + +/** + * Deterministic route-relative driving state machine. + * + * `tick` adapts render time to a fixed clock. `stepFixed` is the authoritative + * primitive for tests, networking, and replay and always advances exactly once. + */ +export class VehicleController { + private readonly pack: TransportPack; + private readonly options: ResolvedOptions; + private path: RoutePath; + private accumulator = 0; + private traffic: VehicleTrafficContext = { leadGapM: null, leadSpeedMps: null }; + private readonly current: VehicleControllerState; + + constructor(pack: TransportPack, options: VehicleControllerOptions) { + this.pack = pack; + this.options = resolveOptions(options); + this.path = buildRoutePath(pack, this.options.routeId); + if (options.initialDistanceM === undefined && this.options.direction === -1) { + this.options.initialDistanceM = this.path.lengthM; + } + const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction); + const point = offsetPoint(sample, 0); + this.current = { + ...point, + routeId: this.path.route.id, + mode: this.options.mode, + direction: this.options.direction, + distanceM: 0, + progress: 0, + lateralOffsetM: 0, + speedMps: 0, + steering: 0, + routeHeadingDeg: sample.headingDeg, + headingDeg: sample.headingDeg, + segmentId: sample.segmentId, + roadName: sample.roadName, + speedLimitMph: sample.speedLimitMph, + wheelRadians: 0, + guardrailContact: false, + longitudinalAccelerationMps2: 0, + jerkMps3: 0, + laneKeepingScore: 1, + leadGapM: null, + leadSpeedMps: null, + timeHeadwaySeconds: null, + collisionRisk: 0, + trafficIntervention: false, + elapsedSteps: 0, + }; + this.reset(); + } + + fixedStepSeconds(): number { + return this.options.fixedStepSeconds; + } + + routeId(): string { + return this.path.route.id; + } + + /** Stable state object for allocation-free polling. Treat it as read-only. */ + state(): Readonly { + return this.current; + } + + /** Detached state suitable for logs, network frames, and equality assertions. */ + snapshot(): VehicleControllerSnapshot { + return { ...this.current }; + } + + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: VehicleControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + if ( + !numeric || snapshot.routeId !== this.path.route.id || + snapshot.direction !== this.options.direction || + (snapshot.mode !== "manual" && snapshot.mode !== "assisted") || + snapshot.distanceM < 0 || snapshot.distanceM > this.path.lengthM || + Math.abs(snapshot.lateralOffsetM) > this.options.guardrailOffsetM || + snapshot.speedMps < 0 || snapshot.speedMps > this.options.maximumSpeedMps || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("vehicle snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot); + this.traffic = { + leadGapM: snapshot.leadGapM, + leadSpeedMps: snapshot.leadSpeedMps, + }; + this.accumulator = 0; + } + + /** Supply a renderer/simulation-neutral nearest-lead observation. */ + setTrafficContext(context: Partial | null): void { + const gap = context?.leadGapM; + const speed = context?.leadSpeedMps; + this.traffic = { + leadGapM: typeof gap === "number" && Number.isFinite(gap) && gap >= 0 ? gap : null, + leadSpeedMps: typeof speed === "number" && Number.isFinite(speed) && speed >= 0 ? speed : null, + }; + } + + /** Restore the configured spawn state and clear pending fractional time. */ + reset(): void { + this.accumulator = 0; + const wrappedDistanceM = wrap(this.options.initialDistanceM, this.path.lengthM); + const distanceM = wrappedDistanceM === 0 && + this.options.initialDistanceM > 0 && + this.options.initialDistanceM <= this.path.lengthM + ? this.path.lengthM + : wrappedDistanceM; + const lateralOffsetM = clamp( + this.options.initialLateralOffsetM, + -this.options.guardrailOffsetM, + this.options.guardrailOffsetM, + ); + const speedMps = clamp(this.options.initialSpeedMps, 0, this.options.maximumSpeedMps); + const sample = sampleRoute(this.path, distanceM, this.options.direction); + const point = offsetPoint(sample, lateralOffsetM); + Object.assign(this.current, point, { + routeId: this.path.route.id, + mode: this.options.mode, + direction: this.options.direction, + distanceM, + progress: distanceM / this.path.lengthM, + lateralOffsetM, + speedMps, + steering: 0, + routeHeadingDeg: sample.headingDeg, + headingDeg: sample.headingDeg, + segmentId: sample.segmentId, + roadName: sample.roadName, + speedLimitMph: sample.speedLimitMph, + wheelRadians: 0, + guardrailContact: false, + longitudinalAccelerationMps2: 0, + jerkMps3: 0, + laneKeepingScore: 1 - Math.abs(lateralOffsetM) / this.options.guardrailOffsetM, + leadGapM: this.traffic.leadGapM, + leadSpeedMps: this.traffic.leadSpeedMps, + timeHeadwaySeconds: null, + collisionRisk: 0, + trafficIntervention: false, + elapsedSteps: 0, + }); + } + + /** + * Change corridor as an explicit reset. Progress may optionally be preserved, + * which is useful for switching route variants without retaining stale metres. + */ + setRoute(routeId: string, preserveProgress = false): void { + if (routeId === this.path.route.id) return; + const previousProgress = this.current.progress; + this.path = buildRoutePath(this.pack, routeId); + this.options.routeId = routeId; + this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0; + this.traffic = { leadGapM: null, leadSpeedMps: null }; + this.reset(); + } + + /** Advance rendered seconds and return the number of fixed steps executed. */ + tick( + deltaSeconds: number, + actions: Partial = NEUTRAL_VEHICLE_ACTIONS, + ): number { + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0; + const normalized = normalizeVehicleActions(actions); + if (normalized.reset) { + this.reset(); + return 0; + } + this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds); + let steps = 0; + while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) { + this.stepNormalized(normalized); + this.accumulator -= this.options.fixedStepSeconds; + steps += 1; + } + return steps; + } + + /** Advance exactly one authoritative simulation step. */ + stepFixed(actions: Partial = NEUTRAL_VEHICLE_ACTIONS): void { + const normalized = normalizeVehicleActions(actions); + if (normalized.reset) { + this.reset(); + return; + } + this.stepNormalized(normalized); + } + + private stepNormalized(actions: VehicleActionSnapshot): void { + const dt = this.options.fixedStepSeconds; + const previousSpeedMps = this.current.speedMps; + const previousAcceleration = this.current.longitudinalAccelerationMps2; + const manualIntent = hasManualIntent(actions); + // Direct human input always wins, including over a simultaneous request to + // resume assistance. A neutral assisted request can re-engage on the next step. + if (manualIntent) this.current.mode = "manual"; + else if (actions.modeRequest === "assisted") this.current.mode = "assisted"; + + let throttle = actions.throttle; + let brake = actions.brake; + let steeringTarget = actions.steering; + + if (this.current.mode === "assisted") { + const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio; + let targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps); + const safeGapM = Math.max(10, this.current.speedMps * 1.8); + if (this.traffic.leadGapM !== null && this.traffic.leadSpeedMps !== null) { + targetSpeed = Math.min( + targetSpeed, + Math.max(0, this.traffic.leadSpeedMps + (this.traffic.leadGapM - safeGapM) * 0.55), + ); + } + const speedError = targetSpeed - this.current.speedMps; + throttle = clamp(speedError / 5, 0, 1); + brake = clamp(-speedError / 7, 0, 1); + steeringTarget = clamp(-this.current.lateralOffsetM / 2.4, -1, 1); + } + + this.current.steering = moveToward(this.current.steering, steeringTarget, 3.8 * dt); + + const aeroDrag = this.current.speedMps * this.current.speedMps * 0.0018; + const rollingDrag = this.current.speedMps > 0 ? 0.12 : 0; + const engineFade = 1 - 0.55 * (this.current.speedMps / this.options.maximumSpeedMps); + const acceleration = + throttle * 5.4 * Math.max(0.2, engineFade) - + brake * 9.5 - + (actions.handbrake ? 13 : 0) - + aeroDrag - + rollingDrag; + this.current.speedMps = clamp( + this.current.speedMps + acceleration * dt, + 0, + this.options.maximumSpeedMps, + ); + + const previousDistance = this.current.distanceM; + const physicalTravelled = this.current.speedMps * dt; + const routeTravelled = physicalTravelled * this.options.travelScale; + this.current.distanceM = wrap( + previousDistance + routeTravelled * this.current.direction, + this.path.lengthM, + ); + + let proposedLateral = + this.current.lateralOffsetM + this.current.steering * this.current.speedMps * 0.2 * dt; + if (this.current.mode === "assisted") { + // Assistance damps the final few centimetres without an abrupt lane snap. + proposedLateral *= Math.exp(-0.35 * dt); + } + this.current.guardrailContact = Math.abs(proposedLateral) > this.options.guardrailOffsetM; + if (this.current.guardrailContact) { + proposedLateral = clamp( + proposedLateral, + -this.options.guardrailOffsetM, + this.options.guardrailOffsetM, + ); + this.current.speedMps = Math.min(this.current.speedMps * 0.78, 12); + this.current.steering *= 0.35; + } + this.current.lateralOffsetM = proposedLateral; + + const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction); + const point = offsetPoint(sample, this.current.lateralOffsetM); + const wheelDelta = physicalTravelled / this.options.wheelRadiusM; + const realizedAcceleration = (this.current.speedMps - previousSpeedMps) / dt; + const leadGapM = this.traffic.leadGapM; + const leadSpeedMps = this.traffic.leadSpeedMps; + const timeHeadwaySeconds = leadGapM === null || this.current.speedMps < 0.1 + ? null + : leadGapM / this.current.speedMps; + const closingSpeedMps = leadSpeedMps === null ? 0 : Math.max(0, this.current.speedMps - leadSpeedMps); + const timeToCollision = leadGapM === null || closingSpeedMps < 0.1 + ? Number.POSITIVE_INFINITY + : leadGapM / closingSpeedMps; + const gapRisk = leadGapM === null ? 0 : clamp(1 - leadGapM / Math.max(12, this.current.speedMps * 2), 0, 1); + const collisionRisk = Math.max(gapRisk, clamp(1 - timeToCollision / 6, 0, 1)); + Object.assign(this.current, point, { + progress: this.current.distanceM / this.path.lengthM, + routeHeadingDeg: sample.headingDeg, + headingDeg: sample.headingDeg + this.current.steering * 9, + segmentId: sample.segmentId, + roadName: sample.roadName, + speedLimitMph: sample.speedLimitMph, + wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI), + longitudinalAccelerationMps2: realizedAcceleration, + jerkMps3: (realizedAcceleration - previousAcceleration) / dt, + laneKeepingScore: clamp(1 - Math.abs(this.current.lateralOffsetM) / this.options.guardrailOffsetM, 0, 1), + leadGapM, + leadSpeedMps, + timeHeadwaySeconds, + collisionRisk, + trafficIntervention: + this.current.mode === "assisted" && + leadGapM !== null && + leadGapM < Math.max(12, this.current.speedMps * 2.1), + elapsedSteps: this.current.elapsedSteps + 1, + }); + } +} + +/** Execute an exact, renderer-independent input recording. */ +export function replayVehicleInputs( + pack: TransportPack, + options: VehicleControllerOptions, + frames: readonly TimedVehicleInputFrame[], +): VehicleReplayResult { + const controller = new VehicleController(pack, options); + const trajectory: VehicleControllerSnapshot[] = [controller.snapshot()]; + for (const frame of frames) { + const steps = Math.max(0, Math.floor(finiteOr(frame.steps, 0))); + const actions = normalizeVehicleActions(frame.actions); + for (let index = 0; index < steps; index += 1) { + // Reset and mode requests are edge-triggered at the start of a timed frame. + controller.stepFixed( + index === 0 + ? actions + : { ...actions, modeRequest: "none", reset: false }, + ); + trajectory.push(controller.snapshot()); + } + } + const final = trajectory.at(-1) ?? controller.snapshot(); + return { trajectory, final }; +} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts new file mode 100644 index 0000000..25b249f --- /dev/null +++ b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts @@ -0,0 +1,287 @@ +/** + * Deterministic road traffic over a serializable `TransportPack`. + * + * The simulation owns route progress, never render objects. It advances on a + * fixed clock and exposes plain geographic poses, so a city scene can project + * them through `World` while a server or replay runner can use the same code + * without three.js. Long background-tab deltas are capped rather than replayed + * as a burst; traffic resumes smoothly instead of teleporting through a route. + */ + +import type { + GeographicPoint, + TransportPack, + TransportRoute, + TransportSegment, +} from "./types.ts"; + +const EARTH_RADIUS_M = 6_371_000; +const MPH_TO_MPS = 0.44704; +const FIXED_STEP = 1 / 20; +const MAX_FRAME_DELTA = 0.25; + +export interface RouteLeg { + segment: TransportSegment; + from: GeographicPoint; + to: GeographicPoint; + lengthM: number; + startM: number; + endM: number; +} + +export interface RoutePath { + route: TransportRoute; + legs: readonly RouteLeg[]; + lengthM: number; +} + +export interface RouteSample extends GeographicPoint { + /** Compass bearing in degrees clockwise from true north. */ + headingDeg: number; + segmentId: string; + roadName: string; + speedLimitMph: number; +} + +export interface VehiclePose extends RouteSample { + id: string; + routeId: string; + /** 0 is the median-side lane; positive values move toward the shoulder. */ + lane: number; + direction: 1 | -1; + speedMps: number; + distanceM: number; + progress: number; + wheelRadians: number; +} + +export interface VehicleSimulationOptions { + routeId: string; + count?: number; + seed?: number; + /** Time compression for the statewide board. Defaults to 900x. */ + timeScale?: number; +} + +export interface LeadVehicleObservation { + id: string; + gapM: number; + speedMps: number; + lane: number; +} + +interface VehicleState { + pose: VehiclePose; + cruise: number; +} + +function radians(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */ +export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number { + const meanLat = radians((a.lat + b.lat) / 2); + const dy = radians(b.lat - a.lat); + const dx = radians(b.lng - a.lng) * Math.cos(meanLat); + return Math.hypot(dx, dy) * EARTH_RADIUS_M; +} + +function bearing(a: GeographicPoint, b: GeographicPoint): number { + const meanLat = radians((a.lat + b.lat) / 2); + const north = b.lat - a.lat; + const east = (b.lng - a.lng) * Math.cos(meanLat); + return (Math.atan2(east, north) * 180) / Math.PI; +} + +export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath { + const route = pack.routes.find((candidate) => candidate.id === routeId); + if (!route) throw new Error(`transport: unknown route "${routeId}"`); + const nodes = new Map(pack.nodes.map((node) => [node.id, node])); + const segments = new Map(pack.segments.map((segment) => [segment.id, segment])); + const legs: RouteLeg[] = []; + let cursor = 0; + + for (const id of route.segmentIds) { + const segment = segments.get(id); + if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`); + const from = nodes.get(segment.fromNodeId)?.position; + const to = nodes.get(segment.toNodeId)?.position; + if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`); + const lengthM = distanceMetres(from, to); + if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`); + legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM }); + cursor += lengthM; + } + + if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`); + return { route, legs, lengthM: cursor }; +} + +function wrap(value: number, modulus: number): number { + return ((value % modulus) + modulus) % modulus; +} + +export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample { + const wrapped = wrap(distanceM, path.lengthM); + // Preserve the authored endpoint when a caller deliberately samples an + // exact positive route length. Simulation steps store their already-wrapped + // zero and still loop normally; restored journey progress=1 must remain at + // San Francisco instead of teleporting to Los Angeles before play resumes. + const travelled = wrapped === 0 && distanceM > 0 && distanceM <= path.lengthM + ? path.lengthM + : wrapped; + const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1); + if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`); + const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM)); + const from = direction === 1 ? leg.from : leg.to; + const to = direction === 1 ? leg.to : leg.from; + // `distanceM` is always measured from the route's declared start. Reverse + // traffic advances that scalar downward, so its geographic interpolation is + // still `t`; only its bearing is reversed. Mirroring `t` here makes a + // southbound car move north while visually facing south. + const u = t; + return { + lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u, + lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u, + headingDeg: bearing(from, to), + segmentId: leg.segment.id, + roadName: leg.segment.roadName, + speedLimitMph: leg.segment.speedLimitMph, + }; +} + +/** Small reproducible generator; simulation results never depend on `Math.random()`. */ +function seeded(seed: number): () => number { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +export class VehicleSimulation { + private readonly pack: TransportPack; + private path: RoutePath; + private readonly count: number; + private readonly seed: number; + private readonly timeScale: number; + private readonly segments: ReadonlyMap; + private accumulator = 0; + private states: VehicleState[] = []; + private poseView: VehiclePose[] = []; + + constructor(pack: TransportPack, options: VehicleSimulationOptions) { + this.pack = pack; + this.path = buildRoutePath(pack, options.routeId); + this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12))); + this.seed = options.seed ?? 115; + this.timeScale = Math.max(1, options.timeScale ?? 900); + this.segments = new Map(pack.segments.map((segment) => [segment.id, segment])); + this.reset(); + } + + routeId(): string { + return this.path.route.id; + } + + setRoute(routeId: string): void { + if (routeId === this.path.route.id) return; + this.path = buildRoutePath(this.pack, routeId); + this.accumulator = 0; + this.reset(); + } + + private reset(): void { + const rand = seeded(this.seed ^ hash(this.path.route.id)); + this.states = Array.from({ length: this.count }, (_, index) => { + // The first vehicle is the northbound follow-camera hero. Every third + // background vehicle after it runs south so both carriageways stay alive. + const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1; + const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM; + const sample = sampleRoute(this.path, distanceM, direction); + const cruise = 0.86 + rand() * 0.12; + const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise; + const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2; + return { + cruise, + pose: { + ...sample, + id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`, + routeId: this.path.route.id, + lane: index % Math.max(2, Math.min(3, laneCount)), + direction, + speedMps, + distanceM, + progress: distanceM / this.path.lengthM, + wheelRadians: 0, + }, + }; + }); + // Keep one stable array for render consumers. The pose objects within it + // are already mutated in place by `step`, so a 60 fps scene should not pay + // for a fresh wrapper array on every frame. + this.poseView = this.states.map((state) => state.pose); + } + + /** Advance by rendered seconds; internally every state change is a 20 Hz step. */ + tick(dt: number): void { + if (!Number.isFinite(dt) || dt <= 0) return; + this.accumulator += Math.min(dt, MAX_FRAME_DELTA); + while (this.accumulator >= FIXED_STEP) { + this.step(FIXED_STEP); + this.accumulator -= FIXED_STEP; + } + } + + private step(dt: number): void { + for (const state of this.states) { + const previous = state.pose; + const signed = previous.speedMps * this.timeScale * dt * previous.direction; + const distanceM = wrap(previous.distanceM + signed, this.path.lengthM); + const sample = sampleRoute(this.path, distanceM, previous.direction); + const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise; + const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2; + Object.assign(previous, sample, { + lane: Math.min(previous.lane, Math.max(1, laneCount - 1)), + speedMps, + distanceM, + progress: distanceM / this.path.lengthM, + // 0.36 m is a representative Model X tyre radius. + wheelRadians: wrap(previous.wheelRadians + (Math.abs(signed) / 0.36), Math.PI * 2), + }); + } + } + + /** Stable objects, mutated in place; render layers may retain references. */ + poses(): readonly VehiclePose[] { + return this.poseView; + } + + /** Nearest same-lane vehicle ahead, suitable for deterministic ACC input. */ + leadVehicle(distanceM: number, direction: 1 | -1, lane = 0): LeadVehicleObservation | null { + let nearest: LeadVehicleObservation | null = null; + for (let index = 1; index < this.states.length; index += 1) { + const pose = this.states[index]?.pose; + if (!pose || pose.direction !== direction || pose.lane !== lane) continue; + const gapM = direction === 1 + ? wrap(pose.distanceM - distanceM, this.path.lengthM) + : wrap(distanceM - pose.distanceM, this.path.lengthM); + if (gapM < 0.5 || (nearest && gapM >= nearest.gapM)) continue; + nearest = { id: pose.id, gapM, speedMps: pose.speedMps, lane: pose.lane }; + } + return nearest; + } +} + +function hash(value: string): number { + let out = 2_166_136_261; + for (let index = 0; index < value.length; index += 1) { + out ^= value.charCodeAt(index); + out = Math.imul(out, 16_777_619); + } + return out >>> 0; +} diff --git a/environments/tera_spatial/tera_spatial/worker.mjs b/environments/tera_spatial/tera_spatial/worker.mjs new file mode 100644 index 0000000..b8a4ca1 --- /dev/null +++ b/environments/tera_spatial/tera_spatial/worker.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// A long-lived NDJSON worker over stdio, wrapping the vendored `tera.arena/v1` +// environments. +// +// Why a resident worker rather than a process per rollout or an HTTP server: +// measured on amd-server, `node` needs ~92 ms to type-strip and import the +// closure, a round trip then costs ~19 us and a step ~106 us. Paying the 92 ms +// once per eval instead of once per rollout is the whole difference, and stdio +// has no port, no auth surface and no way to be talking to somebody else's +// build. +// +// Protocol: one JSON object per line in, one per line out. +// -> {"id": 1, "op": "reset", "env": "crow-nav-v1", "seed": 115, +// "scenario": {"split": "train"}} +// <- {"id": 1, "ok": true, "result": {...}} +// <- {"id": 1, "ok": false, "error": "..."} +// +// Every number in a result came out of TypeScript. Nothing here recomputes a +// reward, a checksum or a baseline in another language — including the oracle, +// which calls the baselines `src/arena/index.ts` already exports rather than +// reimplementing them where they could drift. + +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +const arena = await import( + new URL("./vendor/tera/src/arena/index.ts", import.meta.url).href +); + +const ENVIRONMENTS = { + "drive-101-v1": { + Environment: arena.Drive101Environment, + scripted: arena.driveScriptedBaseline, + inaction: arena.DRIVE_INACTION, + }, + "office-nav-v1": { + Environment: arena.OfficeNavEnvironment, + scripted: arena.officeNavScriptedBaseline, + inaction: arena.OFFICE_NAV_INACTION, + }, + "crow-nav-v1": { + Environment: arena.CrowNavEnvironment, + scripted: arena.crowNavScriptedBaseline, + inaction: arena.CROW_NAV_INACTION, + }, + "california-flight-v1": { + Environment: arena.CaliforniaFlightEnvironment, + scripted: arena.californiaFlightScriptedBaseline, + inaction: arena.CALIFORNIA_FLIGHT_INACTION, + }, +}; + +function registration(envId) { + const entry = ENVIRONMENTS[envId]; + if (!entry) throw new Error(`unknown environment: ${envId}`); + return entry; +} + +/** Live episodes, keyed by a handle the caller chooses. */ +const handles = new Map(); + +function held(handle) { + const env = handles.get(handle); + if (!env) throw new Error(`unknown handle: ${handle}`); + return env; +} + +/** + * Run a whole episode inside TypeScript under one of the exported baselines. + * + * This is the reward denominator. Reimplementing these four policies in Python + * would put the number every environment is normalised against on the far side + * of a hand translation — so the loop lives here and Python receives a result. + */ +function rollBaseline(envId, seed, scenario, policy, maxSteps) { + const { Environment, scripted, inaction } = registration(envId); + const env = new Environment(); + const start = env.reset(seed, scenario); + const cap = maxSteps ?? env.manifest.maxSteps; + let observation = start.observation; + let cumulativeReward = 0; + let steps = 0; + let terminated = false; + let truncated = false; + let terminalReason = null; + const components = {}; + while (steps < cap) { + const action = policy === "inaction" ? inaction : scripted(observation); + const result = env.step(action); + observation = result.observation; + cumulativeReward += result.reward; + steps += 1; + for (const [name, value] of Object.entries(result.rewardComponents)) { + components[name] = (components[name] ?? 0) + value; + } + terminated = result.terminated; + truncated = result.truncated; + terminalReason = result.info.terminalReason; + if (terminated || truncated) break; + } + return { + envId, + policy, + seed, + scenario, + steps, + cumulativeReward, + rewardComponents: components, + terminated, + truncated, + terminalReason, + observation, + trace: env.trace(), + }; +} + +const OPS = { + ping: () => ({ pong: true }), + + manifests: () => ({ manifests: arena.ARENA_MANIFESTS }), + + sourceHashes: () => ({ sourceHashes: arena.ARENA_SOURCE_HASHES }), + + // Tera's own canonical FNV-1a-64. Exposed so a caller can re-seal an envelope + // it has edited — which is how the replay gate proves that `replay()` catches + // a *self-consistent* forgery, not merely a broken checksum. + checksum: ({ value }) => ({ checksum: arena.arenaChecksum(value) }), + + reset: ({ handle, env: envId, seed, scenario }) => { + const { Environment } = registration(envId); + const env = new Environment(); + const result = env.reset(seed, scenario); + handles.set(handle, env); + return result; + }, + + step: ({ handle, action }) => held(handle).step(action), + + snapshot: ({ handle }) => ({ snapshot: held(handle).snapshot() }), + + restore: ({ handle, snapshot }) => held(handle).restore(snapshot), + + trace: ({ handle }) => ({ trace: held(handle).trace() }), + + // A fresh environment every time: replaying into the instance that produced + // the trace would let residual state answer the question the gate is asking. + replay: ({ env: envId, trace }) => { + const { Environment } = registration(envId); + return new Environment().replay(trace); + }, + + oracle: ({ env: envId, seed, scenario, policy = "scripted", maxSteps = null }) => + rollBaseline(envId, seed, scenario, policy, maxSteps), + + close: ({ handle }) => ({ closed: handles.delete(handle) }), + + shutdown: () => { + queueMicrotask(() => process.exit(0)); + return { shutdown: true }; + }, +}; + +const out = process.stdout; +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +lines.on("line", (line) => { + const text = line.trim(); + if (!text) return; + let request; + try { + request = JSON.parse(text); + } catch (error) { + out.write(JSON.stringify({ id: null, ok: false, error: `bad request json: ${error.message}` }) + "\n"); + return; + } + const id = request.id ?? null; + try { + const op = OPS[request.op]; + if (!op) throw new Error(`unknown op: ${request.op}`); + out.write(JSON.stringify({ id, ok: true, result: op(request) }) + "\n"); + } catch (error) { + // Environment errors — a tampered trace, a stepped-past-terminal episode — + // are the worker's normal output, not a crash. They cross as `ok: false`. + out.write(JSON.stringify({ + id, + ok: false, + error: String(error && error.message ? error.message : error), + errorType: error?.constructor?.name ?? "Error", + }) + "\n"); + } +}); + +lines.on("close", () => process.exit(0)); + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + out.write(JSON.stringify({ id: null, ok: true, result: { ready: true, pid: process.pid } }) + "\n"); +} diff --git a/environments/tera_spatial/tests/__init__.py b/environments/tera_spatial/tests/__init__.py new file mode 100644 index 0000000..35a49d1 --- /dev/null +++ b/environments/tera_spatial/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for tera-spatial.""" diff --git a/environments/tera_spatial/tests/test_replay.py b/environments/tera_spatial/tests/test_replay.py new file mode 100644 index 0000000..abe5937 --- /dev/null +++ b/environments/tera_spatial/tests/test_replay.py @@ -0,0 +1,269 @@ +"""The bridge's correctness gate. + +The whole claim this package makes is: *a trace produced by driving Tera from +Python replays, inside Tera, to a bit-identical FNV-1a-64 checksum — and a trace +that has been edited by so much as one float does not.* If that holds, a reward +that reaches Python is a reward that TypeScript actually produced, and Arena can +grade a spatial rollout without a second implementation of the simulator to +disagree with the first. + +So this file is not a smoke test. It exercises **every** public scenario at two +seeds — 4 environments x 4 scenarios x 2 seeds = 32 episodes — and for each one: + +1. drives the episode a step at a time from Python, using actions that made the + round trip through `json`, and checks every per-step checksum against the + trace Tera produced under its own baseline; +2. replays the Python-driven trace in a *fresh* environment; +3. replays it again after a `json.dumps`/`json.loads` cycle, because the wire + format is the thing under test and a float that does not survive Python's + repr is a silent divergence; +4. tampers with it seven ways and requires every one to be rejected. + +Run: + + uv run python -m unittest discover -s environments/tera_spatial/tests -v +""" + +from __future__ import annotations + +import copy +import json +import time +import unittest + +from tera_spatial import ENVIRONMENT_IDS, TeraError, TeraWorker +from tera_spatial import hashes +from tera_spatial.closure import bare_specifiers + +SEEDS = (115, 2718) + + +def scenario_seed_matrix(worker: TeraWorker) -> list[tuple[str, str, str, int]]: + """(env id, scenario id, split, seed) for every public scenario at every seed.""" + matrix: list[tuple[str, str, str, int]] = [] + for manifest in worker.manifests(): + for split in ("train", "dev"): + for scenario_id in manifest["scenarioIds"][split]: + for seed in SEEDS: + matrix.append((manifest["id"], scenario_id, split, seed)) + return matrix + + +class VendoredSourceTests(unittest.TestCase): + """The checksums in a trace are only worth something if the code is the pinned code.""" + + def test_vendored_tree_matches_recorded_hashes(self) -> None: + self.assertEqual(hashes.verify(), []) + + def test_closure_is_the_expected_shape(self) -> None: + # 23 files and 283 KB is the measured closure of `src/arena/index.ts`. A + # change here is not necessarily wrong, but it is never incidental. + self.assertEqual(len(hashes.VENDORED_FILES), 23) + self.assertEqual(hashes.CLOSURE_BYTES, 282_684) + + def test_closure_has_no_bare_specifiers(self) -> None: + """No `node_modules`: the wheel must run the simulator with nothing installed.""" + self.assertEqual(bare_specifiers(), []) + + +class ReplayGateTests(unittest.TestCase): + """Python drives, TypeScript simulates, and the two agree to the last bit.""" + + worker: TeraWorker + + @classmethod + def setUpClass(cls) -> None: + started = time.perf_counter() + cls.worker = TeraWorker() + cls.startup_seconds = time.perf_counter() - started + + @classmethod + def tearDownClass(cls) -> None: + cls.worker.close() + + def test_worker_exposes_the_four_environments(self) -> None: + ids = [manifest["id"] for manifest in self.worker.manifests()] + self.assertEqual(sorted(ids), sorted(ENVIRONMENT_IDS)) + + def test_every_scenario_and_seed_round_trips_and_replays(self) -> None: + matrix = scenario_seed_matrix(self.worker) + self.assertEqual(len(matrix), 32, "4 environments x 4 scenarios x 2 seeds") + for env_id, scenario_id, split, seed in matrix: + with self.subTest(env=env_id, scenario=scenario_id, seed=seed): + self._assert_round_trip(env_id, scenario_id, split, seed) + + def _assert_round_trip(self, env_id: str, scenario_id: str, split: str, seed: int) -> None: + # The reference episode runs entirely inside TypeScript, under the + # baseline `src/arena/index.ts` exports. Its trace is the ground truth. + reference = self.worker.oracle(env_id, seed, scenario_id, policy="scripted") + oracle_trace = reference["trace"] + self.assertGreater(len(oracle_trace["steps"]), 0) + + # Now drive the same episode from Python, one step at a time, with every + # action having crossed the pipe as JSON. This is the direction that + # matters: it is how a rollout will actually run. + with self.worker.open(env_id, seed, scenario_id) as episode: + self.assertEqual( + episode.info["stateChecksum"], oracle_trace["initialStateChecksum"] + ) + for frame in oracle_trace["steps"]: + result = episode.step(json.loads(json.dumps(frame["action"]))) + self.assertEqual(result["info"]["stateChecksum"], frame["stateChecksum"]) + self.assertEqual(result["reward"], frame["reward"]) + self.assertEqual(result["terminated"], frame["terminated"]) + self.assertEqual(result["truncated"], frame["truncated"]) + driven = episode.trace() + + # Python drove it; the envelope checksum says TypeScript agrees it is the + # same episode. + self.assertEqual(driven["checksum"], oracle_trace["checksum"]) + self.assertEqual(driven["finalStateChecksum"], oracle_trace["finalStateChecksum"]) + self.assertEqual(driven["cumulativeReward"], oracle_trace["cumulativeReward"]) + + # And the wire format survives Python's float repr. `json` is the only + # thing between the two runtimes, so it is part of the claim. + wired = json.loads(json.dumps(driven)) + self.assertEqual(wired["checksum"], oracle_trace["checksum"]) + + replayed = self.worker.replay(env_id, wired) + self.assertEqual(replayed["finalStateChecksum"], oracle_trace["finalStateChecksum"]) + self.assertEqual(replayed["cumulativeReward"], oracle_trace["cumulativeReward"]) + self.assertEqual(replayed["steps"], len(oracle_trace["steps"])) + + def test_snapshot_restore_continues_an_episode_exactly(self) -> None: + for env_id in ENVIRONMENT_IDS: + with self.subTest(env=env_id): + reference = self.worker.oracle(env_id, 115, {"split": "train"}) + frames = reference["trace"]["steps"] + cut = max(1, len(frames) // 2) + with self.worker.open(env_id, 115, {"split": "train"}) as episode: + for frame in frames[:cut]: + episode.step(frame["action"]) + snapshot = json.loads(json.dumps(episode.snapshot())) + with self.worker.open(env_id, 115, {"split": "train"}) as resumed: + restored = resumed.restore(snapshot) + self.assertEqual( + restored["info"]["stateChecksum"], frames[cut - 1]["stateChecksum"] + ) + for frame in frames[cut:]: + result = resumed.step(frame["action"]) + self.assertEqual( + result["info"]["stateChecksum"], frame["stateChecksum"] + ) + + def test_a_broken_seal_is_rejected(self) -> None: + """Seven edits, none of them re-sealed. The envelope checksum catches all seven.""" + for env_id in ENVIRONMENT_IDS: + clean = self.worker.oracle(env_id, 115, {"split": "train"})["trace"] + self.worker.replay(env_id, copy.deepcopy(clean)) # the control: it replays + for name, tamper in _TAMPERS.items(): + with self.subTest(env=env_id, tamper=name): + forged = copy.deepcopy(clean) + tamper(forged) + with self.assertRaises(TeraError) as caught: + self.worker.replay(env_id, forged) + self.assertIn("arena", str(caught.exception)) + + def test_a_resealed_forgery_is_rejected(self) -> None: + """The edit that a checksum alone cannot catch. + + Every tamper above breaks the envelope seal, so on its own the previous + test only proves the seal works. The interesting adversary edits the + trace *and recomputes the checksum* — a rollout reporting a reward it + did not earn would look exactly like this. Catching it is `replay()` + re-running the simulator and comparing, which is the actual claim. + """ + for env_id in ENVIRONMENT_IDS: + clean = self.worker.oracle(env_id, 115, {"split": "train"})["trace"] + for name, tamper in _TAMPERS.items(): + if name == "envelope-checksum": + continue # re-sealing it is just the clean trace again + with self.subTest(env=env_id, tamper=name): + forged = self._reseal(copy.deepcopy(clean), tamper) + # The seal is now valid — prove it, or the test is trivially + # passing for the wrong reason. + core = {k: v for k, v in forged.items() if k != "checksum"} + self.assertEqual(forged["checksum"], self.worker.checksum(core)) + with self.assertRaises(TeraError) as caught: + self.worker.replay(env_id, forged) + self.assertIn("arena", str(caught.exception)) + + def _reseal(self, trace: dict, tamper) -> dict: + tamper(trace) + core = {key: value for key, value in trace.items() if key != "checksum"} + trace["checksum"] = self.worker.checksum(core) + return trace + + def test_stepping_a_finished_episode_is_an_error(self) -> None: + reference = self.worker.oracle("crow-nav-v1", 115, {"split": "train"}) + with self.worker.open("crow-nav-v1", 115, {"split": "train"}) as episode: + for frame in reference["trace"]["steps"]: + episode.step(frame["action"]) + with self.assertRaises(TeraError): + episode.step(reference["trace"]["steps"][-1]["action"]) + + def test_an_unknown_scenario_is_an_error(self) -> None: + with self.assertRaises(TeraError): + self.worker.open("crow-nav-v1", 115, "dev-there-is-no-such-place") + + def test_baselines_come_from_typescript_and_separate(self) -> None: + """The oracle op exists so the reward denominator is never a Python port. + + Inaction below zero, scripted above it, is Tera's own documented claim; + checking it here is checking that the bridge is calling the real + baselines and not, say, handing both policies the same action. + """ + for env_id in ENVIRONMENT_IDS: + with self.subTest(env=env_id): + scripted = self.worker.oracle(env_id, 115, {"split": "train"}, "scripted") + inaction = self.worker.oracle(env_id, 115, {"split": "train"}, "inaction") + self.assertGreater(scripted["cumulativeReward"], inaction["cumulativeReward"]) + self.assertLess(inaction["cumulativeReward"], 0.0) + self.assertGreater(scripted["cumulativeReward"], 0.0) + + +def _bump_reward(trace: dict) -> None: + trace["steps"][0]["reward"] += 1.0 + + +def _bump_cumulative(trace: dict) -> None: + trace["cumulativeReward"] += 1.0 + + +def _swap_action(trace: dict) -> None: + action = trace["steps"][0]["action"] + key = sorted(k for k, v in action.items() if isinstance(v, (int, float)))[0] + action[key] = float(action[key]) + 0.5 + + +def _forge_state_checksum(trace: dict) -> None: + trace["steps"][0]["stateChecksum"] = "fnv1a64:0000000000000000" + + +def _forge_envelope_checksum(trace: dict) -> None: + trace["checksum"] = "fnv1a64:0000000000000000" + + +def _drop_last_step(trace: dict) -> None: + # Truncation alone keeps every surviving frame honest, so only the final + # state and the cumulative reward can catch it. + trace["steps"] = trace["steps"][:-1] + + +def _forge_source_hashes(trace: dict) -> None: + trace["sourceHashes"]["simulator"] = "sha256:" + "0" * 64 + + +_TAMPERS = { + "reward": _bump_reward, + "cumulative-reward": _bump_cumulative, + "action": _swap_action, + "state-checksum": _forge_state_checksum, + "envelope-checksum": _forge_envelope_checksum, + "dropped-step": _drop_last_step, + "source-hashes": _forge_source_hashes, +} + + +if __name__ == "__main__": + unittest.main()