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:
+198
-18
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,10 +42,24 @@
|
||||
* `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,
|
||||
@@ -279,7 +293,38 @@ export interface PlanProblem {
|
||||
* 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.
|
||||
@@ -311,6 +356,12 @@ function devBuild(): boolean {
|
||||
|
||||
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[];
|
||||
@@ -327,6 +378,7 @@ export class Plan {
|
||||
|
||||
constructor(office: Office, options: PlanOptions = {}) {
|
||||
this.office = office;
|
||||
this.depth = options.depth ?? "full";
|
||||
this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT;
|
||||
|
||||
const problems: PlanProblem[] = [];
|
||||
@@ -395,6 +447,11 @@ export class Plan {
|
||||
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);
|
||||
@@ -493,9 +550,16 @@ export class Plan {
|
||||
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;
|
||||
@@ -545,11 +609,13 @@ export class Plan {
|
||||
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;
|
||||
@@ -560,6 +626,7 @@ export class Plan {
|
||||
|
||||
(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;
|
||||
@@ -571,6 +638,7 @@ export class Plan {
|
||||
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;
|
||||
|
||||
@@ -18,6 +18,21 @@
|
||||
* turn a private id into a public coordinate, which is the exact thing the split
|
||||
* exists to prevent.
|
||||
*
|
||||
* ### The public office does not call this file
|
||||
*
|
||||
* `createOfficeScene(office, { depth: "public" })` never constructs a
|
||||
* `PresenceLayer`. Not an empty one, not a hidden one — none. That is worth
|
||||
* stating here rather than only at the call site, because the tempting change to
|
||||
* this file, the first time somebody wants an anonymous view, is a `visible`
|
||||
* flag or an `if (anonymous) return` inside `setPresence`. Both of those leave a
|
||||
* layer in the scene graph that is one call away from being populated, and
|
||||
* `officeScene.ts` is where the decision belongs precisely so that the layer
|
||||
* that must not exist is not built at all.
|
||||
*
|
||||
* The split above is what makes that cheap: an office pack has no people in it,
|
||||
* so a building with no presence layer is not a building with something taken
|
||||
* out of it. It is the same building, before anyone arrived.
|
||||
*
|
||||
* ### Figures
|
||||
*
|
||||
* Two poses, one merged geometry each, one material per colour, one mesh per
|
||||
|
||||
@@ -106,6 +106,58 @@ export type AssetId = string;
|
||||
*/
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -220,6 +272,12 @@ export interface Room {
|
||||
* 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. */
|
||||
@@ -331,6 +389,8 @@ export interface Prop {
|
||||
* without knowing which mesh is which.
|
||||
*/
|
||||
seat?: string;
|
||||
/** See `Audience`. Absent means public. */
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,6 +453,12 @@ export interface DeskBank {
|
||||
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 ------------------------------------------------------
|
||||
@@ -413,6 +479,13 @@ export interface Seat {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,6 +503,13 @@ export interface Zone {
|
||||
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 -----------------------------------------------------------
|
||||
@@ -454,6 +534,17 @@ export interface Viewpoint extends View {
|
||||
/** 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 -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user