1
0

A plan view in the corner, a night you can actually see, and three kinds of visitor

The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:53:30 -07:00
parent 47faec9f9d
commit 5bc7258753
24 changed files with 3982 additions and 211 deletions
+198 -18
View File
@@ -36,12 +36,40 @@
* ceilings come off, the walls between you and what you are looking at go
* translucent, and the existing camera, flight and picking machinery is reused
* verbatim.
*
* ### Two depths, and the public one is the architecture without the people
*
* `depth: "public"` is the office an anonymous visitor gets, and the office is
* becoming a front door in its own right, so this is the majority case rather
* than a degraded one. It keeps the shell, the floor plan, the furniture, the
* lighting and every named `View`. It builds **no presence layer at all** — no
* occupants, no avatars, no seat states, nothing to hover that could name a
* person — and `Plan` has already dropped whatever the pack marked
* `audience: "private"` before this file sees it.
*
* The rule the two depths are written to is *build-time exclusion, never
* visibility toggling*. There is no `presence.group.visible = false` path here
* and there must not be one: a scene that constructs the private objects and
* then hides them still hands every one of them to `scene.traverse`, to the
* devtools scene graph and to anyone who types `scene.children` into a console.
* That is a data leak dressed as a privacy feature, and it is worse than not
* having the feature, because it looks like it works.
*
* **None of that is a security boundary.** The office pack is bundled into the
* static build, so its contents are public by construction whatever they are
* marked, and `lumbridge-hq.ts` is fabricated sample data besides. The only
* thing genuinely being withheld from an anonymous visitor is occupancy, and it
* is withheld because live `Presence` comes from the API and **the API is what
* refuses an anonymous caller** — not because this file declined to draw it. If
* a future deployment ever ships real occupant data, that server-side refusal is
* the fix; a `depth` argument in the browser is not, and never will be. See the
* note on `Audience` in `types.ts`.
*/
import * as THREE from "three";
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
import type { StageScene } from "../engine/stage.ts";
import type { LightingState, View } from "../engine/types.ts";
import type { LightingState, Pin, View } from "../engine/types.ts";
import type { AssetRegistry } from "../assets/kit.ts";
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
import type { InteriorPalette } from "../assets/palette.ts";
@@ -51,11 +79,15 @@ import type { InteriorPalette } from "../assets/palette.ts";
// replacement of a built-in id.
import "../assets/office/index.ts";
import { createFurnishings, type Furnishings } from "./furnish.ts";
import { Plan, type PlanOptions } from "./plan.ts";
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
// Re-exported so a caller can name the tier it is asking for without importing
// the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
export type { Depth } from "./plan.ts";
export interface OfficeSceneOptions {
/**
* The renderer's canvas. Orbit input and pointer coordinates are read against
@@ -63,9 +95,25 @@ export interface OfficeSceneOptions {
* renderer and has its own everything else.
*/
dom: HTMLElement;
/**
* How much of the office to build. Defaults to `"full"`, which is every
* caller that existed before this option did.
*
* `"public"` is the not-signed-in building: same shell, same plan, same
* furniture, same lighting, same views, and no people. See the header for what
* that means and, more importantly, for what it does not mean.
*
* There is no way to change this after construction, on purpose. Signing in
* while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"`, which is cheap if you hand both of them the
* same `materials` — the textures are the expensive part and they are drawn
* once per registry, not once per office.
*/
depth?: Depth;
/**
* Bring your own, to share one set of materials and textures across two
* offices. Made here otherwise, and disposed here only if it was made here.
* offices — or across the same office reopened at another depth. Made here
* otherwise, and disposed here only if it was made here.
*/
materials?: MaterialRegistry;
quality?: MaterialQuality;
@@ -76,7 +124,19 @@ export interface OfficeSceneOptions {
colorFor?: (key: string) => number | undefined;
/** Resolves a `Presence.colorKey` to a colour. Also opaque. */
presencePalette?: PresencePalette;
/** Full depth only. At `"public"` there is no presence to pick. */
onPresencePick?: (presence: Presence | null) => void;
/**
* Public depth only: the pointer is over a desk, and here is what a stranger
* is allowed to be told about it.
*
* The public office is not a diorama — you can still hover the furniture — but
* what comes back is a `Pin` and never a `Presence`, and its label is
* `"Desk 14"`. It is a separate callback rather than a widened
* `onPresencePick` because the two carry different things: one says who is
* there, and this one says only that there is a there.
*/
onPlacePick?: (place: Pin | null) => void;
/** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */
lighting?: LightingState;
/** Defaults to false — the lid comes off, because that is the whole view. */
@@ -93,23 +153,45 @@ export interface OfficeSceneOptions {
export interface OfficeScene extends StageScene {
plan: Plan;
/**
* What this office actually is, so the caller can tell what it got rather than
* assuming it got what it asked for. The UI reads this to decide whether to
* print the "no presence" badge and whether to offer a sign-in.
*/
depth: Depth;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[];
flyTo(viewId: string): void;
current(): string | null;
onViewChange(fn: (id: string) => void): void;
/** Occupancy, bound by seat id. Safe to call before the scene is shown. */
/**
* Occupancy, bound by seat id. Safe to call before the scene is shown.
*
* A no-op at public depth — there is no layer to put anybody in — and it warns
* once rather than silently accepting people it will not draw. A caller that
* finds itself needing that warning is asking an anonymous session for
* occupancy, which is a question the API should already have refused.
*/
setPresence(people: Presence[]): void;
/** Scene-space label anchors per presence id, for an HTML overlay. */
/** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
}
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
const plan = new Plan(office, options.plan ?? {});
const depth: Depth = options.depth ?? "full";
// The scene's `depth` wins over anything `plan` carried. There is one tier per
// office and it is chosen here; a `PlanOptions.depth` that disagreed with the
// handle's would produce a scene whose `depth` field was a lie, which is the
// one field a caller has to be able to trust.
const plan = new Plan(office, { ...(options.plan ?? {}), depth });
const scene = new THREE.Scene();
scene.name = `office:${office.id}`;
// The public build says so in the scene graph, and the full one keeps the name
// it has always had. Whoever is reading `scene.name` in the devtools is the
// exact person who needs to know which of the two buildings they are looking
// at before they conclude anything from what is missing.
scene.name = depth === "full" ? `office:${office.id}` : `office:${office.id}:public`;
const ownsMaterials = options.materials === undefined;
const materials =
@@ -169,8 +251,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
const presence: PresenceLayer = createPresenceLayer(plan, options.presencePalette ?? {});
scene.add(shell.group, furnishings.group, presence.group);
// A public office has no presence layer, rather than an empty one. The
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
// named "presence" hanging in the scene graph, a `setPresence` that works, and
// a pair of figure geometries one call away from being populated by any code
// that gets a handle on it. None of that should exist in the building a
// stranger is looking at. The layer is `null`, the group is never added, and
// every path that would have used it is written to cope with its absence
// rather than to hide it. See the header.
const presence: PresenceLayer | null =
depth === "full" ? createPresenceLayer(plan, options.presencePalette ?? {}) : null;
scene.add(shell.group, furnishings.group);
if (presence) scene.add(presence.group);
shell.ceilings.visible = options.showCeilings ?? false;
// ---- Viewpoints ---------------------------------------------------------
@@ -241,13 +333,56 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// ---- Picking ------------------------------------------------------------
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
// rather than the array: the office outlives any one set of people in it.
kit.setPicking<Presence>({
targets: () => presence.pickables,
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
onChange: (person) => options.onPresencePick?.(person),
});
/**
* At public depth, the desks are the pick surface and a desk is a number.
*
* Built once, up front, and handed out by reference — `SceneKit` decides
* whether the hover changed by comparing what `resolve` returned against what
* it returned last frame, so a fresh object literal per hit would fire
* `onChange` every frame the pointer sat still.
*
* The numbering is the point of the map. A desk's real address is its seat id,
* `eng-14`, and that string says which team sits there — it is the id a
* private occupancy API is keyed on precisely because it means something. A
* stranger gets `Desk 14`, numbered from one in plan order across the whole
* building, which says only that this office has at least fourteen desks. The
* bank ids, the seat ids and the station numbers stay on this side of the
* callback.
*/
const places: Map<string, Pin> | null = depth === "public" ? new Map() : null;
if (places) {
let n = 0;
for (const level of plan.levels) {
for (const prop of level.props) {
if (prop.source?.part !== "desk") continue;
n += 1;
places.set(prop.id, { id: `desk-${n}`, label: `Desk ${n}`, colorKey: "desk" });
}
}
}
if (presence) {
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
// rather than the array: the office outlives any one set of people in it.
kit.setPicking<Presence>({
targets: () => presence.pickables,
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
onChange: (person) => options.onPresencePick?.(person),
});
} else if (places) {
// The furnishings are instanced, so the hit resolves in two steps: the
// instanced mesh plus the instance index gives a prop id, and only the prop
// ids that are in the map — the desks — resolve to anything at all. A chair,
// a plant or a light is not a place and comes back `null`.
kit.setPicking<Pin>({
targets: () => furnishings.pickables,
resolve: (hit) => {
const id = furnishings.propAt(hit.object, hit.instanceId);
return id === null ? null : (places.get(id) ?? null);
},
onChange: (place) => options.onPlacePick?.(place),
});
}
// ---- Occlusion fade -----------------------------------------------------
@@ -296,19 +431,51 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// ---- The scene, as the stage sees it ------------------------------------
/**
* Disposal is one-way and it is checked, because the reason this handle gets
* thrown away is usually that another one is being built to replace it.
*
* Signing in while standing in the public office disposes this scene and
* constructs a `"full"` one; the stage is mid-frame when that happens, and a
* `tick` arriving after `dispose` would drive an `OrbitControls` that has
* already released its listeners. Guarding here rather than asking every
* caller to sequence it correctly is the difference between a dispose you can
* rely on and one that mostly works.
*/
let disposed = false;
let warnedNoPresence = false;
return {
scene,
camera: kit.camera,
controls: kit.controls,
plan,
depth,
views,
anchors: presence.anchors,
// A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an
// HTML overlay that writes into what it was handed should not be able to
// reach across into another office.
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
flyTo,
current: () => currentView,
onViewChange(fn) {
viewListeners.push(fn);
},
setPresence(people) {
if (!presence) {
// Once, not once per poll: an occupancy feed pointed at the public
// office will call this every few seconds, and the console is where the
// author of the caller finds out that nothing is happening.
if (!warnedNoPresence) {
warnedNoPresence = true;
console.warn(
`[tera/interiors] office "${office.id}" was built at depth "public"; ` +
`${people.length} presence record(s) ignored. Rebuild at "full" to show people.`,
);
}
return;
}
presence.setPresence(people);
},
setCeilingsVisible(visible) {
@@ -321,11 +488,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// detail card for whoever the pointer was over survives the journey.
onExit: () => kit.resetPick(),
tick(dt) {
if (disposed) return;
kit.tick(dt);
updateOcclusion();
},
dispose() {
presence.dispose();
if (disposed) return;
disposed = true;
presence?.dispose();
furnishings.dispose();
shell.dispose();
kit.dispose();
@@ -333,6 +503,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// other asset in the page is still using it.
if (ownsMaterials) materials.dispose();
scene.clear();
// Three things the old version left behind, and all three matter when the
// reason for disposing is that a second office is about to be built: the
// background `Color`, the view listeners — whose closures reach back into
// whatever UI created this scene — and the desk table. None of them is
// large; all of them are held for as long as anything holds this handle,
// and a handle is exactly the sort of thing a `let office` keeps a stale
// copy of.
scene.background = null;
viewListeners.length = 0;
places?.clear();
},
};
}