diff --git a/index.html b/index.html index f4ed077..6464f5d 100644 --- a/index.html +++ b/index.html @@ -263,6 +263,21 @@ .city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); } } .city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); } + .office-choice { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--s1); + text-align: left; + } + .office-choice__status { + font-size: 8px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-3); + } + .office-choice[aria-pressed="true"] .office-choice__status { color: var(--amber-ink); } .enter { font: inherit; @@ -840,6 +855,8 @@ was 17px tall. */ #panel-toggle, #enter, + #walk, + #screens, .city, .chapter, .sheet-close { diff --git a/server/src/test/ice.test.ts b/server/src/test/ice.test.ts index c75ea19..79e3a5a 100644 --- a/server/src/test/ice.test.ts +++ b/server/src/test/ice.test.ts @@ -22,8 +22,8 @@ const configured = (overrides: Partial = {}): IceConfig => ({ ...overrides, }); -const BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor" } as const; -const SECOND_BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "open-floor", screenId: "open-display" } as const; +const BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "live-work", screenId: "lobby-monitor" } as const; +const SECOND_BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "live-work", screenId: "open-display" } as const; const PROVIDER_CREDENTIAL: ScreenShareCredential = { sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value", }; diff --git a/server/src/test/media.test.ts b/server/src/test/media.test.ts index e479d20..8fa4475 100644 --- a/server/src/test/media.test.ts +++ b/server/src/test/media.test.ts @@ -14,7 +14,7 @@ import type { } from "../../../src/media/signalingTypes.ts"; const BINDING: ScreenShareBinding = { - officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor", + officeId: "lumbridge-hq", levelId: "level-1", roomId: "live-work", screenId: "lobby-monitor", }; const create = (sequence = 1): ScreenShareCreateRequest => ({ type: "screen-share-create-request", protocolVersion: 1, requestId: "request-create", sequence, @@ -193,7 +193,7 @@ describe("office media signaling service", () => { it("validates the exact authored level, room and screen prop", () => { assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, BINDING), true); - assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, roomId: "open-floor" }), false); + assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, roomId: "kitchen" }), false); assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, screenId: "not-authored" }), false); }); }); diff --git a/src/adapters/sample.ts b/src/adapters/sample.ts index c0d1601..be3b7bd 100644 --- a/src/adapters/sample.ts +++ b/src/adapters/sample.ts @@ -583,6 +583,20 @@ export function samplePresenceAt(when: Date): Presence[] { return out; } +/** + * Office-aware fallback for shipped environments. + * + * The legacy roster above is valuable as a renderer fixture, but its seat ids + * describe the original reference tower and therefore cannot truthfully appear + * in either active HQ. A missing presence service now means what it says: no + * fabricated people. Keeping this resolver keyed by the stable office id gives + * self-hosters and future sample packs an explicit seam without ever treating a + * shiny room as evidence of employees. + */ +export function samplePresenceForOfficeAt(_officeId: string, _when: Date): Presence[] { + return []; +} + /** * Minutes since local midnight, as a literal. Every window in this section is * written with it so that the source reads as a timetable. diff --git a/src/assets/office/habitat.ts b/src/assets/office/habitat.ts new file mode 100644 index 0000000..13ccecf --- /dev/null +++ b/src/assets/office/habitat.ts @@ -0,0 +1,441 @@ +/** + * The small-space habitat kit. + * + * Offices prove the scene format with desks and meeting rooms. A studio home + * needs a different handful of silhouettes before it reads honestly: a bed is + * not a wide desk, a kitchen is not a row of lockers, and two lounge chairs do + * not become a sofa because they touch. These assets stay inside the same + * primitive grammar and material palette as the office kit, so they add no + * binary art, texture download, or second visual language. + * + * As with every asset in this directory, source is Apache-2.0 and the generated + * artistic output is dedicated under CC0-1.0 by `src/assets/LICENSE-ART`. + */ + +import * as THREE from "three"; +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { panelSlab, slab, tintable } from "./common.ts"; + +type BedParams = { + width: number; + length: number; + platformHeight: number; + headboardHeight: number; +}; + +/** A low platform bed. The headboard is at local -Z and the foot faces +Z. */ +export const bedPlatform = defineAsset({ + id: "tera:bed.platform", + label: "Platform bed", + defaults: { width: 1.52, length: 2.03, platformHeight: 0.28, headboardHeight: 0.92 }, + + footprint(p) { + return { width: p.width, depth: p.length, height: p.headboardHeight, clearance: 0.7 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const wood = ctx.materials.get("shelf"); + const textile = tintable(ctx, "upholstery"); + const linen = ctx.materials.get("paper"); + const frameH = Math.max(0.12, p.platformHeight); + const mattressH = 0.2; + + // A recessed plinth gives the platform a shadow line instead of a box-on-floor read. + bin.add(ctx.parts.box(), ctx.materials.get("chairBase"), { + y: 0.035, + size: [p.width - 0.22, 0.07, p.length - 0.18], + }); + slab(bin, ctx, wood, { + y: 0.09, + width: p.width, + depth: p.length, + thickness: frameH - 0.09, + }); + bin.add(ctx.parts.roundedBox(0.09), textile, { + y: frameH, + z: 0.03, + size: [p.width - 0.12, mattressH, p.length - 0.12], + }); + panelSlab(bin, ctx, wood, { + y: 0, + z: -p.length / 2 + 0.035, + width: p.width, + height: p.headboardHeight, + thickness: 0.07, + faces: "front", + }); + + // Two pillows, separated enough that the silhouette survives the dollhouse view. + for (const sx of [-1, 1]) { + bin.add(ctx.parts.roundedBox(0.12), linen, { + x: sx * p.width * 0.23, + y: frameH + mattressH + 0.015, + z: -p.length / 2 + 0.38, + size: [p.width * 0.38, 0.11, 0.48], + yaw: sx * 0.035, + }); + } + + return bin.build("bed.platform"); + }, +}); + +type SofaParams = { + width: number; + depth: number; + seatHeight: number; + backHeight: number; + modules: number; +}; + +/** A low modular sofa with individually readable seat and back cushions. */ +export const sofaModular = defineAsset({ + id: "tera:sofa.modular", + label: "Modular sofa", + defaults: { width: 2.2, depth: 0.9, seatHeight: 0.42, backHeight: 0.82, modules: 3 }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.backHeight, clearance: 0.7 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const P = ctx.parts; + const base = ctx.materials.get("chairShell"); + const textile = tintable(ctx, "upholstery"); + const modules = Math.max(2, Math.min(4, Math.round(p.modules))); + const innerW = p.width - 0.28; + const moduleW = innerW / modules; + + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.rod(), ctx.materials.get("chairBase"), { + x: sx * (p.width / 2 - 0.12), + z: sz * (p.depth / 2 - 0.12), + size: [0.04, 0.08, 0.04], + }); + } + } + bin.add(P.box(), base, { y: 0.08, size: [innerW, p.seatHeight - 0.15, p.depth - 0.14] }); + + for (let i = 0; i < modules; i += 1) { + const x = -innerW / 2 + moduleW * (i + 0.5); + bin.add(P.roundedBox(0.07), textile, { + x, + y: p.seatHeight - 0.12, + z: -0.04, + size: [moduleW - 0.025, 0.14, p.depth - 0.25], + }); + bin.add(P.roundedBox(0.09), textile, { + x, + y: p.seatHeight + 0.015, + z: p.depth / 2 - 0.15, + size: [moduleW - 0.035, p.backHeight - p.seatHeight, 0.17], + pitch: 0.1, + }); + } + for (const sx of [-1, 1]) { + bin.add(P.roundedBox(0.09), textile, { + x: sx * (p.width / 2 - 0.07), + y: 0.08, + size: [0.14, p.seatHeight + 0.18, p.depth - 0.1], + }); + } + + return bin.build("sofa.modular"); + }, +}); + +type KitchenRunParams = { + width: number; + depth: number; + counterHeight: number; + height: number; + bays: number; +}; + +/** A complete compact kitchen wall: base units, worktop, sink, backsplash and uppers. */ +export const kitchenRun = defineAsset({ + id: "tera:kitchen.run", + label: "Kitchen wall", + defaults: { width: 3.6, depth: 0.62, counterHeight: 0.91, height: 2.16, bays: 5 }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 1 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const P = ctx.parts; + const cabinet = tintable(ctx, "cabinet"); + const top = ctx.materials.get("tableTop"); + const metal = ctx.materials.get("metalTrim"); + const bays = Math.max(3, Math.min(7, Math.round(p.bays))); + const bayW = p.width / bays; + + bin.add(P.box(), ctx.materials.get("chairBase"), { + y: 0.02, + z: -0.035, + size: [p.width - 0.08, 0.08, p.depth - 0.12], + }); + for (let i = 0; i < bays; i += 1) { + const x = -p.width / 2 + bayW * (i + 0.5); + bin.add(P.box(), cabinet, { + x, + y: 0.1, + z: -0.03, + size: [bayW - 0.018, p.counterHeight - 0.15, p.depth - 0.06], + }); + bin.add(P.box(), metal, { + x: x + bayW * 0.34, + y: p.counterHeight * 0.55, + z: p.depth / 2 + 0.006, + size: [0.012, 0.12, 0.012], + }); + } + slab(bin, ctx, top, { + y: p.counterHeight - 0.045, + width: p.width, + depth: p.depth + 0.05, + thickness: 0.045, + }); + + // Backsplash and upper units stop short over the sink to create a focal bay. + panelSlab(bin, ctx, ctx.materials.get("tile"), { + y: p.counterHeight, + z: -p.depth / 2 + 0.012, + width: p.width, + height: 0.62, + thickness: 0.024, + faces: "front", + }); + const upperY = 1.48; + for (let i = 0; i < bays; i += 1) { + if (i === Math.floor(bays / 2)) continue; + const x = -p.width / 2 + bayW * (i + 0.5); + bin.add(P.box(), cabinet, { + x, + y: upperY, + z: -p.depth / 2 + 0.17, + size: [bayW - 0.018, p.height - upperY, 0.34], + }); + bin.add(P.box(), metal, { + x: x + bayW * 0.34, + y: upperY + 0.2, + z: -p.depth / 2 + 0.345, + size: [0.012, 0.13, 0.012], + }); + } + + const sinkX = -p.width / 2 + bayW * (Math.floor(bays / 2) + 0.5); + bin.add(P.box(), metal, { + x: sinkX, + y: p.counterHeight + 0.003, + size: [bayW * 0.62, 0.018, p.depth * 0.5], + }); + bin.add(P.rod(), metal, { + x: sinkX, + y: p.counterHeight + 0.02, + z: -0.12, + size: [0.025, 0.24, 0.025], + }); + bin.add(P.rod(), metal, { + x: sinkX, + y: p.counterHeight + 0.24, + z: -0.04, + size: [0.025, 0.025, 0.16], + }); + + return bin.build("kitchen.run"); + }, +}); + +type IslandParams = { + length: number; + depth: number; + height: number; + overhang: number; +}; + +/** A storage island with a real seating overhang on its +Z side. */ +export const kitchenIsland = defineAsset({ + id: "tera:kitchen.island", + label: "Kitchen island", + defaults: { length: 2.2, depth: 0.9, height: 0.92, overhang: 0.24 }, + + footprint(p) { + return { width: p.length, depth: p.depth, height: p.height, clearance: 0.8 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const cabinetDepth = Math.max(0.42, p.depth - p.overhang); + bin.add(ctx.parts.box(), tintable(ctx, "cabinet"), { + y: 0.06, + z: -p.overhang / 2, + size: [p.length - 0.12, p.height - 0.12, cabinetDepth], + }); + slab(bin, ctx, ctx.materials.get("tableTop"), { + y: p.height - 0.05, + width: p.length, + depth: p.depth, + thickness: 0.05, + }); + for (const sx of [-1, 1]) { + bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), { + x: sx * (p.length / 2 - 0.12), + y: 0.08, + z: p.depth / 2 - 0.08, + size: [0.035, p.height - 0.13, 0.035], + }); + } + return bin.build("kitchen.island"); + }, +}); + +type WardrobeParams = { width: number; depth: number; height: number; doors: number }; + +/** Full-height closed storage with a recessed plinth and quiet vertical pulls. */ +export const storageWardrobe = defineAsset({ + id: "tera:storage.wardrobe", + label: "Wardrobe", + defaults: { width: 1.8, depth: 0.58, height: 2.18, doors: 3 }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 0.75 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const body = ctx.materials.get("shelf"); + const doors = tintable(ctx, "cabinet"); + const metal = ctx.materials.get("metalTrim"); + const count = Math.max(2, Math.min(5, Math.round(p.doors))); + const doorW = (p.width - 0.05) / count; + bin.add(ctx.parts.box(), ctx.materials.get("chairBase"), { + x: 0, + y: 0.02, + z: -0.02, + size: [p.width - 0.1, 0.08, p.depth - 0.1], + }); + bin.add(ctx.parts.box(), body, { y: 0.08, size: [p.width, p.height - 0.08, p.depth] }); + for (let i = 0; i < count; i += 1) { + const x = -p.width / 2 + 0.025 + doorW * (i + 0.5); + bin.add(ctx.parts.box(), doors, { + x, + y: 0.11, + z: p.depth / 2 + 0.006, + size: [doorW - 0.014, p.height - 0.16, 0.018], + }); + bin.add(ctx.parts.box(), metal, { + x: x + doorW * 0.34, + y: p.height * 0.46, + z: p.depth / 2 + 0.02, + size: [0.014, 0.28, 0.014], + }); + } + return bin.build("storage.wardrobe"); + }, +}); + +type StoolParams = { diameter: number; seatHeight: number; back: boolean }; + +/** A kitchen-height stool. It faces -Z so a seat address can share its yaw. */ +export const seatStool = defineAsset({ + id: "tera:seat.stool", + label: "Counter stool", + defaults: { diameter: 0.4, seatHeight: 0.68, back: true }, + + footprint(p) { + return { width: p.diameter + 0.1, depth: p.diameter + 0.12, height: p.back ? 0.96 : p.seatHeight, clearance: 0.35 }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const metal = ctx.materials.get("chairBase"); + const textile = tintable(ctx, "chairFabric"); + for (let i = 0; i < 4; i += 1) { + const yaw = Math.PI / 4 + i * Math.PI / 2; + bin.add(ctx.parts.rod(), metal, { + x: Math.sin(yaw) * p.diameter * 0.34, + z: Math.cos(yaw) * p.diameter * 0.34, + size: [0.025, p.seatHeight - 0.06, 0.025], + }); + } + bin.add(ctx.parts.cylinder(20), textile, { + y: p.seatHeight - 0.06, + size: [p.diameter, 0.1, p.diameter], + }); + if (p.back) { + bin.add(ctx.parts.rod(), metal, { + x: -p.diameter * 0.34, + y: p.seatHeight - 0.02, + z: p.diameter * 0.34, + size: [0.022, 0.28, 0.022], + }); + bin.add(ctx.parts.rod(), metal, { + x: p.diameter * 0.34, + y: p.seatHeight - 0.02, + z: p.diameter * 0.34, + size: [0.022, 0.28, 0.022], + }); + // Keep the back and circular seat in the same indexed primitive family. + // `roundedBox` is non-indexed; mixing it with the indexed cylinder would + // make BufferGeometryUtils reject the material bin and drop every fabric + // part from the finished stool. + bin.add(ctx.parts.box(), textile, { + y: p.seatHeight + 0.18, + z: p.diameter * 0.34, + size: [p.diameter * 0.82, 0.2, 0.06], + }); + } + return bin.build("seat.stool"); + }, +}); + +type FloorLightParams = { height: number; reach: number; shadeDiameter: number }; + +/** An arced floor lamp. Geometry glows; the office lighting rig remains the only light owner. */ +export const lightFloor = defineAsset({ + id: "tera:light.floor", + label: "Floor lamp", + defaults: { height: 1.55, reach: 0.42, shadeDiameter: 0.32 }, + + footprint(p) { + return { width: Math.max(0.42, p.shadeDiameter), depth: p.reach + p.shadeDiameter / 2, height: p.height }; + }, + + build(p, ctx) { + const body = new MeshBin(); + const glow = new MeshBin(); + const metal = ctx.materials.get("lightHousing"); + body.add(ctx.parts.cylinder(20), ctx.materials.get("chairBase"), { + size: [0.36, 0.035, 0.36], + }); + body.add(ctx.parts.rod(), metal, { y: 0.035, size: [0.025, p.height - 0.18, 0.025] }); + body.add(ctx.parts.rod(), metal, { + y: p.height - 0.18, + z: -p.reach / 2, + size: [0.025, 0.025, p.reach], + }); + body.add(ctx.parts.cone(20), metal, { + y: p.height - 0.4, + z: -p.reach, + size: [p.shadeDiameter, 0.24, p.shadeDiameter], + pitch: Math.PI, + }); + glow.add(ctx.parts.disc(20), ctx.materials.get("lightDiffuser"), { + y: p.height - 0.395, + z: -p.reach, + size: [p.shadeDiameter * 0.86, 1, p.shadeDiameter * 0.86], + pitch: Math.PI, + }); + const group = new THREE.Group(); + group.name = "light.floor"; + group.add(body.build("light.floor:body")); + group.add(glow.build("light.floor:glow", { castShadow: false, receiveShadow: false })); + return group; + }, +}); diff --git a/src/assets/office/index.ts b/src/assets/office/index.ts index cf2dd07..1b8101f 100644 --- a/src/assets/office/index.ts +++ b/src/assets/office/index.ts @@ -17,7 +17,7 @@ * somebody walks through a wall — leave the collider with no gap where the door * is. * - * Seventeen assets is not a furniture catalogue and is not trying to be. It is + * Twenty-five assets is not a furniture catalogue and is not trying to be. It is * the set that gets a real floor plate looking like an office: somewhere to * work, somewhere to sit, somewhere to meet, somewhere to put things, something * to look at, something alive, and light. @@ -26,6 +26,15 @@ import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts"; import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts"; import { plantPotted, plantTall } from "./greenery.ts"; +import { + bedPlatform, + kitchenIsland, + kitchenRun, + lightFloor, + seatStool, + sofaModular, + storageWardrobe, +} from "./habitat.ts"; import { lightPendant, lightTroffer } from "./lighting.ts"; import { robotOptimus } from "./optimus.ts"; import { screenMonitor, screenWallDisplay } from "./screens.ts"; @@ -53,6 +62,13 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [ robotOptimus, rug, whiteboard, + bedPlatform, + sofaModular, + kitchenRun, + kitchenIsland, + storageWardrobe, + seatStool, + lightFloor, ]; /** Register the built-in catalogue into a registry. Defaults to the shared one. */ @@ -81,4 +97,11 @@ export { tableMeeting, tableSide, whiteboard, + bedPlatform, + sofaModular, + kitchenRun, + kitchenIsland, + storageWardrobe, + seatStool, + lightFloor, }; diff --git a/src/main.ts b/src/main.ts index 73b32bb..6a2b1c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,10 +53,11 @@ import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_PRESENCE_PALETTE, - samplePresenceAt, + samplePresenceForOfficeAt, sampleRoutesFor, } from "./adapters/sample.ts"; -import { OFFICE_SITES } from "./offices/sites.ts"; +import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts"; +import { activityRobotsForOffice } from "./offices/runtime.ts"; import { authFetch } from "./session.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; @@ -213,11 +214,17 @@ function journeyToCity(city: JourneyCity): void { * a second pack eagerly imported would put its furniture in the entry bundle * for every visitor who never opens it. */ -const OFFICES: { id: string; label: string; load: () => Promise<{ default: Office }> }[] = [ - { id: "lumbridge-hq", label: "Lumbridge HQ", load: () => import("./offices/lumbridge-hq.ts") }, - { id: "frontier-valley", label: "Frontier Valley", load: () => import("./offices/frontier-valley.ts") }, - { id: "mateo-court", label: "Mateo Court", load: () => import("./offices/mateo-court.ts") }, -]; +const OFFICE_LOADERS: Readonly Promise<{ default: Office }>>> = { + "lumbridge-hq": () => import("./offices/lumbridge-hq.ts"), + "frontier-valley": () => import("./offices/frontier-valley.ts"), + "mateo-court": () => import("./offices/mateo-court.ts"), +}; + +const OFFICES = OFFICE_SITES.map((entry) => ({ + ...entry, + label: entry.name, + load: OFFICE_LOADERS[entry.id], +})); const canvas = document.querySelector("#scene"); if (!canvas) throw new Error("#scene canvas missing"); @@ -299,7 +306,7 @@ const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({ id: `office:${entry.id}`, label: entry.name, colorKey: "office", - blurb: `${entry.site.label ?? "An office"} — click to walk in`, + blurb: `${entry.entryCopy} · ${entry.status}`, lat: entry.site.lat, lng: entry.site.lng, // Hand-typed from the street grid, like every other coordinate here. Not a @@ -450,7 +457,7 @@ let officeMaterials: MaterialRegistry | null = null; * memoised forever, which is the one-line difference from the arrangement that * could only ever hold one office. */ -let officeId = OFFICES[0]?.id ?? "lumbridge-hq"; +let officeId: string = OFFICES[0]?.id ?? "lumbridge-hq"; /** * The office's own sky, when its pack says where it stands. * @@ -1263,15 +1270,10 @@ async function enterOffice() { ...(pack.site ? {} : { background: 0x11161c }), ...(pack.site ? { lighting: officeLighting(pack.site) } : {}), ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}), - // Two per floor, whatever floors this pack has — so the two-storey tower - // gets four and the single-storey hangar gets two, without either pack - // having to know about robots. Derived from the levels rather than - // written down, because a pack that gains a storey should not need an - // edit here to be staffed. - robots: pack.levels.flatMap((level) => [ - { levelId: level.id, id: `${level.id}-a` }, - { levelId: level.id, id: `${level.id}-b` }, - ]), + // Environment-authored activity, not inferred headcount. These stable + // specs keep active HQs intentionally sparse and give the robot-jobs + // runtime ids, seeds and job sets it can adopt without a migration. + robots: activityRobotsForOffice(pack), depth, materials, // Ignored entirely at `"public"` depth, where no layer is built to colour. @@ -1527,7 +1529,7 @@ function watchOccupancy() { if (!scene || scene.depth !== "full") return; presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => { livePresence = body?.people ?? null; - presenceIsSample = livePresence === null; + presenceIsSample = false; // The scene may have been torn down between a request going out and coming // back — a city switch disposes the office — and writing people into a // disposed layer is a use-after-free with a friendly name. The watch's own @@ -1564,7 +1566,9 @@ function stopWatchingOccupancy() { */ function applyPresence() { if (!office || office.depth !== "full") return; - const people = livePresence ?? samplePresenceAt(currentInstant()); + const fallback = samplePresenceForOfficeAt(officePack?.id ?? officeId, currentInstant()); + const people = livePresence ?? fallback; + presenceIsSample = livePresence === null && fallback.length > 0; office.setPresence(people); officePlan?.setPresence(people); } @@ -1627,18 +1631,26 @@ function renderCityPicker() { if (!cityNav) return; cityNav.replaceChildren(); const entries = inside - ? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId })) - : CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId })); + ? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId, status: o.status })) + : CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId, status: null })); for (const entry of entries) { const b = document.createElement("button"); // `aria-pressed` rather than a class, because these buttons choose exactly // one active board. The stylesheet keys off the attribute // so the visual state and the announced state cannot drift apart. - b.className = "city"; + b.className = entry.status ? "city office-choice" : "city"; b.type = "button"; b.setAttribute("aria-pressed", String(entry.active)); - b.textContent = entry.label; + const name = document.createElement("span"); + name.textContent = entry.label; + b.append(name); + if (entry.status) { + const status = document.createElement("span"); + status.className = "office-choice__status"; + status.textContent = entry.status; + b.append(status); + } b.addEventListener("click", () => { if (inside) void switchOffice(entry.id); else switchCity(entry.id); @@ -1719,14 +1731,19 @@ function renderLegend() { blurb.hidden = !active?.description; } const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? ""; + const selectedOffice = OFFICES.find((entry) => entry.id === officeId) ?? OFFICES[0]; if (title) title.textContent = inside ? officeName() : cityLabel; if (subtitle) { - subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; + subtitle.textContent = inside + ? `Tera · ${selectedOffice?.status ?? "building"} environment` + : "Tera · Lumbridge Simulate"; } if (enterButton) { - // One label for everyone. The door is open at both tiers; what differs is - // what is behind it, and that is the badge's job to say, not the button's. - enterButton.textContent = inside ? "← Back to the city" : "Enter the office →"; + enterButton.textContent = inside + ? "← Back to the city" + : selectedOffice?.status === "active" + ? `Open ${selectedOffice.label} →` + : `Preview ${selectedOffice?.label ?? "environment"} · building →`; } const walking = inside && (office?.walker?.active() ?? false); const exploring = !inside && (city.actorActive() ?? false); diff --git a/src/offices/lumbridge-hq.ts b/src/offices/lumbridge-hq.ts index 0210f19..1905f7c 100644 --- a/src/offices/lumbridge-hq.ts +++ b/src/offices/lumbridge-hq.ts @@ -1,83 +1,24 @@ /** - * Lumbridge HQ — the reference office pack, and the file you copy. + * SF HQ · Studio — a compact live/work apartment in San Francisco. * - * Pure data against `src/interiors/types.ts`. One level, fifteen rooms, three - * hundred props and seventy-six seats, on a 34 x 18 m floor plate: a - * lobby, an open desk floor, three meeting rooms at three different sizes, a - * strip of focus booths, a kitchen, a lounge, a quiet room, a workshop, a server - * room, a store, and the circulation that ties them together. + * This is deliberately not a speculative corporate floor. It is a twelve by + * nine metre studio used as a small AI and robotics headquarters: two serious + * workstations, a demo wall, a kitchen that can host a conversation, a place to + * sleep, and a front door a possessed actor actually arrives beside. * - * **This file is the dev kit.** It is not here because Lumbridge needs an office - * in a repo; it is here because a stranger who clones this should be able to - * open one file, recognise a floor plan, change six numbers and have their own - * office. So the dimensions are real ones — 2.8 m ceilings, 0.9 m doors, 1.6 m - * desks, 1.8 m corridors — and the reasoning is on the page next to the numbers - * rather than in a design document nobody reads. `src/offices/README.md` is the - * companion: it explains the format, this explains the choices. - * - * The engine still knows nothing. A room called "Alcatraz" is a polygon with a - * label, `colorKey: "focus"` is an opaque string, and seat `eng-04` is a spot on - * the floor with a name. Nothing here tells the renderer that anybody works in - * this building — see ARCHITECTURE.md §3.3, and the note on `Presence` in - * `interiors/types.ts`, which is the same rule one level in. - * - * ### Two audiences, and why this pack barely uses the second one - * - * A handful of items below carry `audience: "private"`, which means a public - * build — `createOfficeScene(office, { depth: "public" })`, the office an - * anonymous visitor gets — does not construct them. There are seven of them: two - * zones and five objects in the server room. Everything else in this file is - * built at both depths, and that is the honest answer for this pack rather than - * a gap in it. The difference between the public building and the signed-in one - * is **the people**, and the people are not in this file at all. - * - * That is the whole reason a pack can be published. `Presence` binds by seat id - * and arrives from somewhere else, so the geometry and the occupancy are two - * separate acts and only one of them is happening here. Read the note on - * `Audience` in `interiors/types.ts` before reaching for the field, especially - * the part that says it is a UI tier and not a security boundary: **this file is - * bundled into the static build**, so a self-hoster who marks their real floor - * plan private has published their real floor plan with an extra step. The - * building here is fabricated. If yours is not, the thing that keeps a stranger - * out is your API, not this field. - * - * ### The coordinate frame - * - * Metres, `1 unit = 1 m`, the floor on the XZ plane with +Y up. The origin is - * the **north-west corner** of the slab, +X runs east and +Z runs south, so in - * plan view — looking down, +X to the right — +Z goes *down the page* and the - * glazed façade along `z = 0` is at the top. - * - * Calling one edge "north" is a convenience for reading this file and nothing - * more. An `Office` has no orientation on the earth and no sun; it is not a - * city. If you rotate the whole building in your head, the only thing that - * changes is the vocabulary in these comments. - * - * ### Which way things face - * - * `Yaw` is `object.rotation.y`: zero points at −Z, and the angle increases - * counter-clockwise seen from above. An asset's **front is at −Z and its working - * side at +Z** (`src/assets/office/common.ts`), which combine into one rule that - * covers every prop in this file: - * - * > A prop takes the yaw of *the wall it backs onto*, and a seat takes the yaw - * > of *the direction the occupant looks*. - * - * So a shelf against the north wall is `NORTH`, a locker against the east wall - * is `EAST`, and someone at a desk that also reads `NORTH` is looking north at - * it. Both halves fall out of the same convention, which is why a `DeskBank` - * can hand one `rotation` to a desk, its chair and the seat between them. + * The stable `lumbridge-hq` id is retained because it is an address used by + * journey snapshots, presence, media grants and self-hosted deployments. The + * public name and the authored environment are allowed to become more honest + * without invalidating those addresses. */ import type { AssetId, - Audience, DeskBank, Level, Office, Opening, Outline, - Point2, Prop, Room, Seat, @@ -88,191 +29,33 @@ import type { } from "../interiors/types.ts"; import { LUMBRIDGE_HQ_SITE } from "./sites.ts"; -// ---- The floor plate ------------------------------------------------------ +// ---- Frame ---------------------------------------------------------------- -/** - * The building, in a handful of numbers. Everything else in this file is - * measured off these, so a self-hoster with a different slab starts here. - * - * **The plate is in two pieces and they are different kinds of thing.** - * - * `BLOCK_E` at 34 m is the east edge of the original block — 34 x 18 m, 612 m², - * a plausible single floor of a mid-rise: deep enough for a row of rooms behind - * a corridor, shallow enough that the desks still get daylight. Go much deeper - * and the middle of the plate becomes a place nobody wants to sit, which is a - * real constraint and not a rendering one. Every room, wall and seat authored - * before the wing existed is measured against this line, which is why it keeps a - * name of its own rather than being folded into `WIDTH`. - * - * `WIDTH` at 48 m is the whole plate, block plus wing. The 14 m east of the - * block is one room — see `COMMONS` — and it is deliberately not laid out on the - * block's grid. A second row of cellular offices would have added area without - * adding anything you could feel; a double-height volume you walk into changes - * what the building is like to be in, which is the thing that was actually - * wanted. - */ -const BLOCK_E = 34.0; -const WIDTH = 48.0; -const DEPTH = 18.0; +const WIDTH = 12; +const DEPTH = 9; +const FACE = 0.2; +const CEILING = 2.85; +const EXT = 0.24; +const INT = 0.12; -/** Floor to underside of ceiling. 2.8 m is an ordinary commercial storey. */ -const CEILING = 2.8; - -/** - * The gap between one storey's ceiling and the next one's floor. - * - * In a real building this is 1.4 m of structure, ducts, trays and raised floor, - * and you never see it. Here it is **ten times that**, and the exaggeration is - * deliberate and is the whole reason it has a name of its own. - * - * The brief was to be able to see the space between the floors and eventually to - * run things through it — cable trays, fibre, the drops that land on the ceiling - * of the floor below and the floor of the one above. At 1.4 m there is nowhere - * to put any of that and nothing to look at; at 14 m the interstitial is a - * storey in its own right, the two slabs read as two separate places, and the - * commons becomes a genuine shaft rather than a tall room. - * - * It is frankly not architecture. A 16.8 m floor-to-floor is a hangar, the stair - * in the east wing is no longer a stair anybody could climb, and the gallery - * looks down from the height of a five-storey building. That is an accepted - * cost of a deliberately unreal building, and **this is the one number to change - * if it reads as too much** — everything else in the file is measured off it. - */ -const PLENUM = 14.0; - -/** - * Floor to floor. Not the same as `CEILING` and the difference is not slack: it - * is the room plus the interstitial above it. - * - * `Plan` adds this to every coordinate on level 2 exactly once, so both levels - * are authored in their own floor's frame and neither has to know about the - * other. - */ -const STOREY = CEILING + PLENUM; - -/** - * The commons is open to both storeys, so its walls are two rooms tall. - * - * Stated as the sum rather than as `7.0` because it *is* the sum: change the - * storey height and the void has to follow, and a hard-coded number here would - * be the one thing that quietly did not. - */ -const COMMONS_HEIGHT = STOREY + CEILING; - -/** - * The booths get a lower lid than the room they stand in. That is what makes a - * booth feel like a booth rather than a cupboard with the top cut off, and it - * is the reason `Room.ceiling` is a per-room override at all. - */ -const BOOTH_CEILING = 2.3; - -/** - * Exterior walls are thicker than partitions and say so; the level's default - * (0.12 m) covers the thirty-odd internal ones. A wall that does not name a - * thickness inherits the level's, which is the whole point of the defaults — - * the interesting wall should be the one that stands out in the source. - */ -const EXT_THICKNESS = 0.25; -const INT_THICKNESS = 0.12; - -/** Half-thicknesses, used constantly to sit a prop against a wall face. */ -const EXT_FACE = EXT_THICKNESS / 2; -const INT_FACE = INT_THICKNESS / 2; - -/** - * How far the centre of a wall-hung panel sits off the wall's face. - * - * `tera:screen.wall-display` is 0.12 m deep and `tera:whiteboard` 0.10 m, and a - * prop's origin is the centre of its footprint — so half the depth plus a - * millimetre of reveal is what makes a display hang *on* a wall rather than - * half inside it. Worth naming rather than sprinkling 0.07 through the file, - * because the moment somebody registers a thicker panel over `overrides:` these - * are the two numbers that have to move with it. - */ -const DISPLAY_OFFSET = 0.07; -const BOARD_OFFSET = 0.06; - -// The lines the plan is built on. Rooms and walls are authored against the same -// numbers on purpose: a wall is centred on its line and straddles the boundary -// between the two floor slabs that meet there, so if the slab edge and the wall -// line disagree by a centimetre you get a seam you will never find again. -const SPINE_N = 10.4; // open floor / lobby / kitchen give way to the corridor -const SPINE_S = 12.2; // corridor gives way to the south rooms -const LOBBY_E = 7.2; // lobby / open desk floor -const SOCIAL_W = 25.6; // open desk floor / lounge and kitchen -const KITCHEN_N = 6.0; // lounge / kitchen, an open boundary with no wall on it -const BOOTH_N = 8.6; // front of the focus booths - -// South-room divisions, west to east. These are the only widths in the file -// that were chosen by what has to fit inside rather than by a grid. -const X_ALCATRAZ = 0.0; -const X_BERNAL = 7.2; -const X_BOLINAS = 12.4; -const X_QUIET = 16.0; -const X_WORKSHOP = 19.6; -const X_SERVER = 26.4; -const X_STORE = 30.2; - -// Booth divisions. Two metres each, sharing party walls, backing onto the -// corridor wall — three booths out of eleven metres of otherwise dead frontage. -const X_BOOTH_1 = 8.4; -const X_BOOTH_2 = 10.4; -const X_BOOTH_3 = 12.4; -const X_BOOTH_E = 14.4; - -// -- The east wing ---------------------------------------------------------- -// -// Three lines, and the wing needs no more because it is one room. The stair is -// the only enclosure in it, tucked into the south-east corner where it takes -// daylight from nothing. -const STAIR_W = 42.8; // stair core / commons floor -const STAIR_N = 12.6; // stair core / commons floor -/** - * How far level 2's gallery reaches out over the commons. - * - * 4.2 m of balcony, which is enough to stand two people and a rail at without - * being a floor — past about six the void stops reading as a void and starts - * reading as a corridor with a hole in it, and the whole point of the wing is - * the volume you can see all of at once. - */ -const GALLERY_E = BLOCK_E + 4.2; - -// ---- Yaw ------------------------------------------------------------------ - -/** - * The four right angles, named for the direction a prop's *front* points. - * - * Reading them takes the one rule from the header: a thing standing against a - * wall points its front at that wall, so `NORTH` is both "the shelf on the north - * wall" and "the person looking north". There is no sign flip anywhere between - * here and `object.rotation.y`. - */ const NORTH: Yaw = 0; const EAST: Yaw = -Math.PI / 2; const SOUTH: Yaw = Math.PI; const WEST: Yaw = Math.PI / 2; -// ---- Assets --------------------------------------------------------------- +// ---- Assets and finishes -------------------------------------------------- -/** - * Every asset this pack places, named once. - * - * They are all `tera:` ids from `src/assets/office/`. Bind them to constants - * rather than repeating the strings, because the day you swap every workstation - * in the building for `acme:desk.standing` should be a one-line day — and if it - * is more than one line, you wanted the `overrides:` mechanism instead, which - * reskins `tera:desk.workstation` itself and needs no edit here at all. The - * README's "Registering your own assets" section is the longer version. - */ const DESK: AssetId = "tera:desk.workstation"; -const PEDESTAL: AssetId = "tera:desk.pedestal"; -const PARTITION: AssetId = "tera:desk.partition"; const TASK_CHAIR: AssetId = "tera:seat.task-chair"; -const LOUNGE_CHAIR: AssetId = "tera:seat.lounge"; -const MEETING_TABLE: AssetId = "tera:table.meeting"; +const BED: AssetId = "tera:bed.platform"; +const SOFA: AssetId = "tera:sofa.modular"; +const KITCHEN: AssetId = "tera:kitchen.run"; +const ISLAND: AssetId = "tera:kitchen.island"; +const WARDROBE: AssetId = "tera:storage.wardrobe"; +const STOOL: AssetId = "tera:seat.stool"; +const FLOOR_LIGHT: AssetId = "tera:light.floor"; const SIDE_TABLE: AssetId = "tera:table.side"; const SHELF: AssetId = "tera:storage.shelf"; -const LOCKER: AssetId = "tera:storage.locker"; const MONITOR: AssetId = "tera:screen.monitor"; const DISPLAY: AssetId = "tera:screen.wall-display"; const PLANT: AssetId = "tera:plant.potted"; @@ -282,1841 +65,271 @@ const TROFFER: AssetId = "tera:light.troffer"; const RUG: AssetId = "tera:rug"; const WHITEBOARD: AssetId = "tera:whiteboard"; -/** - * Surface ids, which are looser than they look. - * - * `MaterialRegistry.resolve` throws away the namespace and reads the **first - * dot-segment** as a role, through a small alias table, and falls back rather - * than throwing. So `tera:wood.plank`, `acme:wood.reclaimed` and a bare `wood` - * are all the `woodFloor` role, and a typo renders in the fallback material - * instead of failing the build. Naming them here keeps the whole palette of the - * building in one screen, which is what you actually want when you decide the - * meeting rooms should have carpet after all. - */ -const WOOD = "tera:wood.plank"; -const CARPET = "tera:carpet.loop"; -const CARPET_ACCENT = "tera:carpetAccent.broadloom"; const CONCRETE = "tera:concrete.polished"; -const TERRAZZO = "tera:tile.terrazzo"; -const RAISED_FLOOR = "tera:floorSlab.raised"; +const WOOD = "tera:wood.plank"; +const TILE = "tera:tile.terrazzo"; const PAINT = "tera:paint.matt"; -const ACCENT_PAINT = "tera:plasterAccent.deep"; +const ACCENT = "tera:plasterAccent.deep"; const GLASS = "tera:glass.curtain"; -const FELT = "tera:felt.acoustic"; -const CEILING_TILE = "tera:ceiling.tile"; -// ---- Authoring helpers ---------------------------------------------------- - -/* - * Five functions, and they all run at module load and return plain objects. - * - * The exported `Office` is still exactly what `interiors/types.ts` demands — - * JSON-serialisable data with no functions in it — and would survive - * `JSON.parse(JSON.stringify(…))` unchanged. These exist because the - * alternative is four hundred literals in which every seventh one has a typo: - * a floor of ceiling lights is a grid, a bench of monitor arms is a line, and - * the winding rule for an outline is the sort of thing a human gets right - * fourteen times and wrong on the fifteenth. - * - * A pack shipped as `.json` spells all of this out. That is fine; it is a build - * product of a file like this one. - */ - -/** - * An axis-aligned room outline, wound counter-clockwise **in plan view**. - * - * Worth being precise about, since it is the one place the frame bites: plan - * view has +Z going down the page, so a polygon that looks clockwise on paper - * has a *negative* shoelace area over (x, z). Going NW → SW → SE → NE, as this - * does, is the counter-clockwise one. `Plan` silently re-winds anything that - * arrives the other way round, so getting it wrong costs nothing — but getting - * it right means the outlines in `plan.problems` are the ones with real - * problems. - */ -function rect(x0: number, z0: number, x1: number, z1: number): Outline { +function rect(x1: number, z1: number, x2: number, z2: number): Outline { return [ - { x: x0, z: z0 }, - { x: x0, z: z1 }, { x: x1, z: z1 }, - { x: x1, z: z0 }, + { x: x1, z: z2 }, + { x: x2, z: z2 }, + { x: x2, z: z1 }, ]; } -/** - * A regular grid of points, columns first. `rows: 1` gives a line, which is how - * most of the calls below use it. - */ -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; -} - -/** - * One asset at each of `points`, with ids `${prefix}-01`, `-02`, … - * - * Same numbering scheme as `DeskBank` expansion, deliberately: two digits from - * one, in the order the points were generated, so a prop id is something you can - * work out on paper instead of by running the code. - */ -function scatter( - prefix: string, - kind: AssetId, - points: Point2[], - opts: { rotation?: Yaw; elevation?: number; colorKey?: string; audience?: Audience } = {}, -): 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, - // A run of identical props is a run of identical props at both depths. - // Nothing in the format stops a pack marking one rack of four private, but - // half a row is a stranger thing to look at than no row. - audience: opts.audience, - })); -} - -/** - * A doorway. 0.9 m x 2.1 m is a standard single leaf; the wide ones in this - * building say so at the call site. - * - * An opening with `sill: 0` that clears 1.1 m of head is what `Plan` treats as - * walkable, so this is also the thing that punches the gap in the collision - * segments. That is the whole reason doors are openings and not props. - */ -function doorway(start: number, width = 0.9, head = 2.1): Opening { +function doorway(start: number, width = 1.0, head = 2.2): Opening { return { kind: "door", start, width, sill: 0, head }; } -/** - * A window. The sill is what keeps it out of the collider — an opening a walker - * could step through is a door whatever you call it, so a glazed hole at - * ankle height would quietly become a hole in the wall you can walk through. - */ -function pane(start: number, width: number, sill = 0.9, head = 2.2): Opening { - return { kind: "window", start, width, sill, head }; +function windowAt(start: number, width: number): Opening { + return { kind: "window", start, width, sill: 0.78, head: 2.5 }; } -/** A cased opening with no leaf in it — the way you get from A to B indoors. */ -function archway(start: number, width: number, head = 2.4): Opening { - return { kind: "arch", start, width, sill: 0, head }; +function prop( + id: string, + kind: AssetId, + x: number, + z: number, + rotation: Yaw = NORTH, + extra: Omit = {}, +): Prop { + return { id, kind, position: { x, z }, rotation, ...extra }; } -// ---- Rooms ---------------------------------------------------------------- +// ---- Plan ----------------------------------------------------------------- -/** - * The fifteen slabs. - * - * A `Room` is a floor finish with a name and **implies no walls** — the walls - * are the separate list below, and the two are not derived from each other. - * That means an open plan is simply rooms with nothing standing between them: - * the lounge, the kitchen and the desk floor share three edges and only one of - * those edges carries a wall. - * - * `ceiling: null` means no ceiling at all, and nearly every room here declares - * it. The reason is the establishing viewpoint: an office you look down into - * cannot have lids on the rooms you are trying to look into. The booths, the - * server room and the store keep theirs, because those are the three rooms you - * are never meant to see inside, and a ceiling is a cheap way of saying so. - * - * Order matters in one respect: `Plan.roomAt` resolves later rooms first, so a - * room laid on top of another wins the lookup. Nothing here overlaps — the open - * floor is notched around the booths rather than passing underneath them, since - * two coplanar slabs at y = 0 is a z-fight waiting for the wrong GPU. - */ const ROOMS: Room[] = [ - // -- North band: the daylit half ------------------------------------------ { - id: "lobby", - name: "Reception", - outline: rect(0, 0, LOBBY_E, SPINE_N), + id: "kitchen", + name: "Kitchen", + outline: rect(FACE, FACE, 4.55, 3.15), + floor: TILE, + ceiling: null, + }, + { + id: "sleeping-alcove", + name: "Sleeping Alcove", + outline: rect(4.55, FACE, 8.55, 3.15), floor: WOOD, ceiling: null, }, { - id: "open-floor", - name: "The Floor", - // Notched around the booth block on its southern edge. Eight points rather - // than four, and the only non-rectangular room in the building. - outline: [ - { x: LOBBY_E, z: 0 }, - { x: LOBBY_E, z: SPINE_N }, - { x: X_BOOTH_1, z: SPINE_N }, - { x: X_BOOTH_1, z: BOOTH_N }, - { x: X_BOOTH_E, z: BOOTH_N }, - { x: X_BOOTH_E, z: SPINE_N }, - { x: SOCIAL_W, z: SPINE_N }, - { x: SOCIAL_W, z: 0 }, - ], - floor: CARPET, - ceiling: null, + id: "bath-storage", + name: "Bath / Storage", + outline: rect(8.55, FACE, WIDTH - FACE, 3.15), + floor: TILE, + ceiling: { height: CEILING, surface: PAINT }, }, { - id: "lounge", - name: "The Lounge", - outline: rect(SOCIAL_W, 0, BLOCK_E, KITCHEN_N), - floor: CARPET_ACCENT, - ceiling: null, - }, - { - id: "kitchen", - name: "The Kitchen", - outline: rect(SOCIAL_W, KITCHEN_N, BLOCK_E, SPINE_N), - floor: TERRAZZO, - ceiling: null, - }, - - // -- The booths ----------------------------------------------------------- - // Three rooms of four square metres. They are rooms and not props because - // they have walls, a door and a lid, which is the whole difference. - { - id: "booth-1", - name: "Booth 1", - outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_2, SPINE_N), - floor: CARPET_ACCENT, - ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, - }, - { - id: "booth-2", - name: "Booth 2", - outline: rect(X_BOOTH_2, BOOTH_N, X_BOOTH_3, SPINE_N), - floor: CARPET_ACCENT, - ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, - }, - { - id: "booth-3", - name: "Booth 3", - outline: rect(X_BOOTH_3, BOOTH_N, X_BOOTH_E, SPINE_N), - floor: CARPET_ACCENT, - ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, - }, - - // -- Circulation ---------------------------------------------------------- - { - id: "corridor", - name: "Circulation", - // 1.8 m clear, running the full 34 m. A corridor is a room like any other; - // giving it an id is what lets a walk-mode controller, a wayfinding overlay - // or a cleaning schedule refer to it without inventing a second concept. - outline: rect(0, SPINE_N, BLOCK_E, SPINE_S), - floor: CONCRETE, - ceiling: null, - }, - - // -- South band: the enclosed half ---------------------------------------- - { - id: "alcatraz", - name: "Alcatraz", - outline: rect(X_ALCATRAZ, SPINE_S, X_BERNAL, DEPTH), - floor: CARPET, - ceiling: null, - }, - { - id: "bernal", - name: "Bernal", - outline: rect(X_BERNAL, SPINE_S, X_BOLINAS, DEPTH), - floor: CARPET, - ceiling: null, - }, - { - id: "bolinas", - name: "Bolinas", - outline: rect(X_BOLINAS, SPINE_S, X_QUIET, DEPTH), - floor: CARPET, - ceiling: null, - }, - { - id: "lands-end", - name: "Lands End", - outline: rect(X_QUIET, SPINE_S, X_WORKSHOP, DEPTH), - floor: CARPET_ACCENT, - ceiling: null, - }, - { - id: "workshop", - name: "Dogpatch", - outline: rect(X_WORKSHOP, SPINE_S, X_SERVER, DEPTH), - floor: CONCRETE, - ceiling: null, - }, - { - id: "server-room", - name: "Farallon", - // A raised floor and a lid, because this is the one room in the building - // whose finishes are doing a job rather than making an impression. - outline: rect(X_SERVER, SPINE_S, X_STORE, DEPTH), - floor: RAISED_FLOOR, - ceiling: { height: 2.6, surface: CEILING_TILE }, - }, - { - id: "store", - name: "Facilities", - outline: rect(X_STORE, SPINE_S, BLOCK_E, DEPTH), - floor: CONCRETE, - ceiling: { height: 2.6, surface: CEILING_TILE }, - }, - - // -- The east wing -------------------------------------------------------- - /** - * One room, fourteen metres by eighteen, open to both storeys. - * - * `ceiling: null` is doing something different here from everywhere else in - * this file. Elsewhere it means "take the lid off so the establishing shot can - * see in" — a rendering convenience for a room that has a ceiling in reality. - * Here there genuinely is no ceiling at 2.8 m, because level 2's slab stops at - * `BLOCK_E` and this volume runs all the way to the roof. The walls around it - * carry `COMMONS_HEIGHT` to say so. - * - * It is deliberately *not* subdivided. Fourteen metres would take three more - * meeting rooms and a run of desks, and that would have been more floor area - * doing exactly what the block already does. A room you can see the whole of, - * with a tree in it and a balcony over one edge, is the thing the building did - * not have. - */ - { - id: "commons", - name: "The Commons", - // Notched around the stair rather than passing underneath it, for the same - // reason the open floor is notched around the booths: two coplanar slabs at - // y = 0 is a z-fight waiting for the wrong GPU. Wound the way `rect` winds — - // down the west side, east along the south, back up — which is - // counter-clockwise in plan view with +Z down the page. - outline: [ - { x: BLOCK_E, z: 0 }, - { x: BLOCK_E, z: DEPTH }, - { x: STAIR_W, z: DEPTH }, - { x: STAIR_W, z: STAIR_N }, - { x: WIDTH, z: STAIR_N }, - { x: WIDTH, z: 0 }, - ], - floor: TERRAZZO, - ceiling: null, - }, - { - id: "stair-core", - name: "The Stair", - // The one enclosure in the wing, in the corner that gets no daylight from - // the two glazed sides. A stair is the right thing to put where nobody - // wants to sit. - outline: rect(STAIR_W, STAIR_N, WIDTH, DEPTH), + id: "live-work", + name: "Live / Work Studio", + outline: rect(FACE, 3.15, WIDTH - FACE, DEPTH - FACE), floor: CONCRETE, ceiling: null, }, ]; -// ---- Walls ---------------------------------------------------------------- - -/** - * Twenty-eight segments, and every hole in the building. - * - * A wall belongs to no room; it stands where it is put, from `from` to `to`, - * centred on that line. `Opening.start` is measured **from the `from` end**, so - * the direction a wall is written in is the direction its openings are measured - * in — which is why every wall below is written west-to-east or north-to-south, - * with no exceptions. Reversing one and forgetting is how a door ends up at the - * wrong end of a room. - */ const WALLS: Wall[] = [ - // -- The envelope --------------------------------------------------------- { id: "ext-north", - // The daylight side. Six 4 m ribbon windows on 1.2 m piers: enough glass - // that the desk floor reads as a place with a view, enough wall that the - // building still has corners. - from: { x: 0, z: 0 }, - to: { x: BLOCK_E, z: 0 }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [ - pane(1.2, 4.0, 0.75, 2.35), - pane(6.4, 4.0, 0.75, 2.35), - pane(11.6, 4.0, 0.75, 2.35), - pane(16.8, 4.0, 0.75, 2.35), - pane(22.0, 4.0, 0.75, 2.35), - pane(27.2, 4.0, 0.75, 2.35), - ], + from: { x: FACE, z: FACE }, + to: { x: WIDTH - FACE, z: FACE }, + thickness: EXT, + openings: [windowAt(0.8, 2.2), windowAt(4.0, 2.4), windowAt(8.0, 2.5)], }, - { - id: "commons-west-glazed", - // Full-height glazing as a *wall with a glass surface*, not as one enormous - // opening. This is the distinction the format turns on: an opening is a hole - // and a hole is something you can see and sometimes walk through, whereas a - // curtain wall is a solid you happen to be able to see through. Making it an - // opening would hand the collider a 12 m gap and put the lounge in the - // atrium. - // - // It was `ext-east` and it was the outside of the building. The wing put a - // room on the far side of it, so the same glass now looks into a - // double-height space instead of onto a street — which is a better thing for - // a lounge to look at and cost nothing but a rename. The two openings are - // new: a wall you cannot walk through is a wing nobody visits. - from: { x: BLOCK_E, z: 0 }, - to: { x: BLOCK_E, z: SPINE_S }, - thickness: EXT_THICKNESS, - surface: GLASS, - openings: [ - archway(2.6, 2.4), // out of the kitchen - archway(9.0, 3.0), // off the corridor, the main way through - ], - }, - { - id: "commons-west-solid", - from: { x: BLOCK_E, z: SPINE_S }, - to: { x: BLOCK_E, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: PAINT, - }, - - // -- The east wing's envelope --------------------------------------------- - // - // Two storeys tall, which is what makes the commons a volume rather than a - // tall room: `Wall.height` overrides the level's, and the level's is one - // storey because every other wall on it is. { id: "ext-east", - from: { x: WIDTH, z: 0 }, - to: { x: WIDTH, z: DEPTH }, - thickness: EXT_THICKNESS, - height: COMMONS_HEIGHT, - surface: GLASS, - }, - { - id: "wing-north", - from: { x: BLOCK_E, z: 0 }, - to: { x: WIDTH, z: 0 }, - thickness: EXT_THICKNESS, - height: COMMONS_HEIGHT, - surface: GLASS, - }, - { - id: "wing-south", - from: { x: BLOCK_E, z: DEPTH }, - to: { x: WIDTH, z: DEPTH }, - thickness: EXT_THICKNESS, - height: COMMONS_HEIGHT, - surface: PAINT, - // The wing's own way out, which is what lets the stair be an escape stair - // rather than a feature. `start` is measured from the wall's `from` end, so - // an 1.8 m pair starting at 11.6 finishes at 13.4 with 600 mm to spare — - // 12.4 ran 200 mm past the end and `Plan` dropped the whole opening. - openings: [doorway(11.6, 1.8, 2.4)], - }, - - // -- The stair core ------------------------------------------------------- - { - id: "stair-west", - from: { x: STAIR_W, z: STAIR_N }, - to: { x: STAIR_W, z: DEPTH }, - height: COMMONS_HEIGHT, - openings: [doorway(1.2, 1.2, 2.4)], - }, - { - id: "stair-north", - from: { x: STAIR_W, z: STAIR_N }, - to: { x: WIDTH, z: STAIR_N }, - height: COMMONS_HEIGHT, + from: { x: WIDTH - FACE, z: FACE }, + to: { x: WIDTH - FACE, z: DEPTH - FACE }, + thickness: EXT, + openings: [windowAt(3.7, 2.2)], }, { id: "ext-south", - // Written west-to-east like everything else, so the openings read left to - // right on the plan. One window per room that deserves one; the server room - // (26.4 → 30.2) deliberately gets none. - from: { x: 0, z: DEPTH }, - to: { x: BLOCK_E, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [ - pane(1.0, 4.4), - pane(7.8, 3.8), - pane(12.8, 2.6), - pane(16.4, 2.6), - pane(20.2, 5.4), - doorway(32.0), // the escape stair, through the store - ], + from: { x: FACE, z: DEPTH - FACE }, + to: { x: WIDTH - FACE, z: DEPTH - FACE }, + thickness: EXT, + openings: [windowAt(6.8, 3.4)], }, { id: "ext-west", - from: { x: 0, z: 0 }, - to: { x: 0, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [ - // The way in, from the lift lobby that is not part of this pack. A 1.8 m - // pair of leaves and a 2.4 m head, because an entrance that reads like an - // internal door is the first thing that makes a model feel like a model. - doorway(4.0, 1.8, 2.4), - pane(7.4, 2.4), - ], + from: { x: FACE, z: FACE }, + to: { x: FACE, z: DEPTH - FACE }, + thickness: EXT, + openings: [windowAt(1.0, 2.0), doorway(6.8, 1.15, 2.35)], }, - // -- Internal, north band ------------------------------------------------- + // The bath is the only fully enclosed room. Its door opens directly into the + // studio and the clear approach to it is asserted in `office.test.ts`. { - id: "lobby-east", - from: { x: LOBBY_E, z: 0 }, - to: { x: LOBBY_E, z: SPINE_N }, - surface: ACCENT_PAINT, - openings: [archway(6.4, 2.4)], + id: "bath-west", + from: { x: 8.55, z: FACE }, + to: { x: 8.55, z: 3.15 }, + openings: [doorway(1.72, 0.92)], }, { - id: "kitchen-west", - // The kitchen's only wall. You can also reach it by walking round through - // the lounge, which is exactly how real offices work and is why the lounge - // has no walls at all. - from: { x: SOCIAL_W, z: KITCHEN_N }, - to: { x: SOCIAL_W, z: SPINE_N }, - openings: [archway(1.6, 1.8)], + id: "bath-south", + from: { x: 8.55, z: 3.15 }, + to: { x: WIDTH - FACE, z: 3.15 }, }, + // A transparent shower screen gives the bath an interior silhouette without + // adding a decorative prop that the walk collider would ignore. { - id: "spine-north", - // One 34 m wall with three holes in it, rather than five walls that have to - // agree about where they meet. The stretch from 8.4 to 14.4 is the back of - // the focus booths and is solid for that reason. - from: { x: 0, z: SPINE_N }, - to: { x: BLOCK_E, z: SPINE_N }, - openings: [ - doorway(1.6), // lobby - archway(16.0, 3.0), // the main way off the desk floor - doorway(31.6), // kitchen - ], - }, - - // -- The focus booths ----------------------------------------------------- - // Seven short walls at 2.3 m — a head above a standing person and well below - // the ceiling, so the booths read as furniture-scale objects standing on the - // floor rather than as rooms carved out of it. Their fourth side is - // `spine-north`, which they share with the corridor: a wall belongs to no - // room, so nothing needs to be said about that here. - { - id: "booth-w", - from: { x: X_BOOTH_1, z: BOOTH_N }, - to: { x: X_BOOTH_1, z: SPINE_N }, - height: BOOTH_CEILING, - surface: FELT, - }, - { - id: "booth-p1", - from: { x: X_BOOTH_2, z: BOOTH_N }, - to: { x: X_BOOTH_2, z: SPINE_N }, - height: BOOTH_CEILING, - surface: FELT, - }, - { - id: "booth-p2", - from: { x: X_BOOTH_3, z: BOOTH_N }, - to: { x: X_BOOTH_3, z: SPINE_N }, - height: BOOTH_CEILING, - surface: FELT, - }, - { - id: "booth-e", - from: { x: X_BOOTH_E, z: BOOTH_N }, - to: { x: X_BOOTH_E, z: SPINE_N }, - height: BOOTH_CEILING, - surface: FELT, - }, - { - id: "booth-1-front", - from: { x: X_BOOTH_1, z: BOOTH_N }, - to: { x: X_BOOTH_2, z: BOOTH_N }, - height: BOOTH_CEILING, - surface: FELT, - openings: [doorway(0.55)], - }, - { - id: "booth-2-front", - from: { x: X_BOOTH_2, z: BOOTH_N }, - to: { x: X_BOOTH_3, z: BOOTH_N }, - height: BOOTH_CEILING, - surface: FELT, - openings: [doorway(0.55)], - }, - { - id: "booth-3-front", - from: { x: X_BOOTH_3, z: BOOTH_N }, - to: { x: X_BOOTH_E, z: BOOTH_N }, - height: BOOTH_CEILING, - surface: FELT, - openings: [doorway(0.55)], - }, - - // -- Room fronts on the corridor ------------------------------------------ - // One wall per room rather than one long wall with seven doors, because these - // are the walls whose *surface* differs: the two glazed meeting rooms are the - // reason you can tell from the corridor whether a room is free. - { - id: "front-alcatraz", - from: { x: X_ALCATRAZ, z: SPINE_S }, - to: { x: X_BERNAL, z: SPINE_S }, - // A vision panel rather than a glass wall — the board room is the one room - // people want to be able to close. Sill at 0.9 keeps it out of the collider. - openings: [pane(1.0, 3.2, 0.9, 2.3), doorway(5.6)], - }, - { - id: "front-bernal", - from: { x: X_BERNAL, z: SPINE_S }, - to: { x: X_BOLINAS, z: SPINE_S }, + id: "bath-screen", + from: { x: 10.25, z: 0.48 }, + to: { x: 10.25, z: 2.05 }, + thickness: 0.06, + height: 2.05, surface: GLASS, - openings: [doorway(0.6)], }, + // A short felt-backed divider turns the bed into an alcove while preserving + // the open plan and a 1.35 m route around its foot. { - id: "front-bolinas", - from: { x: X_BOLINAS, z: SPINE_S }, - to: { x: X_QUIET, z: SPINE_S }, - surface: GLASS, - openings: [doorway(0.5)], - }, - { - id: "front-lands-end", - from: { x: X_QUIET, z: SPINE_S }, - to: { x: X_WORKSHOP, z: SPINE_S }, - openings: [doorway(1.4)], - }, - { - id: "front-workshop", - from: { x: X_WORKSHOP, z: SPINE_S }, - to: { x: X_SERVER, z: SPINE_S }, - // 1.6 m, because things arrive here on trolleys. - openings: [doorway(2.0, 1.6)], - }, - { - id: "front-server", - from: { x: X_SERVER, z: SPINE_S }, - to: { x: X_STORE, z: SPINE_S }, - openings: [doorway(0.9)], - }, - { - id: "front-store", - from: { x: X_STORE, z: SPINE_S }, - to: { x: BLOCK_E, z: SPINE_S }, - openings: [doorway(1.5)], - }, - - // -- South band cross walls ----------------------------------------------- - // No openings: you get into every one of these rooms from the corridor and - // nowhere else, which is what makes the corridor worth having. - { - id: "cross-bernal", - from: { x: X_BERNAL, z: SPINE_S }, - to: { x: X_BERNAL, z: DEPTH }, - }, - { - id: "cross-bolinas", - from: { x: X_BOLINAS, z: SPINE_S }, - to: { x: X_BOLINAS, z: DEPTH }, - }, - { - id: "cross-lands-end", - from: { x: X_QUIET, z: SPINE_S }, - to: { x: X_QUIET, z: DEPTH }, - }, - { - id: "cross-workshop", - from: { x: X_WORKSHOP, z: SPINE_S }, - to: { x: X_WORKSHOP, z: DEPTH }, - }, - { - id: "cross-server", - from: { x: X_SERVER, z: SPINE_S }, - to: { x: X_SERVER, z: DEPTH }, - }, - { - id: "cross-store", - from: { x: X_STORE, z: SPINE_S }, - to: { x: X_STORE, z: DEPTH }, + id: "sleep-divider", + from: { x: 4.55, z: FACE }, + to: { x: 4.55, z: 1.9 }, + thickness: INT, + height: 1.45, + surface: ACCENT, }, ]; -// ---- Desk banks ----------------------------------------------------------- - -/** - * Thirty-nine desks in five declarations. - * - * `Plan` expands each of these into a desk prop, a chair prop and a seat per - * station, with ids you can predict without running anything: seats are - * `${seatPrefix ?? id}-01` counting along each row and then down the rows, and - * the props are `${id}-desk-01` and `${id}-chair-01`. Seat `eng-04` is the - * fourth desk in the front row of the window bench, today and after the next - * six edits to this file, which is the property a `Presence` needs. - * - * ### Why every bench is two rows and not four - * - * `facingRows` turns the first row of each pair around so a pair shares a run - * of desktop — a bench, rather than two rows of people looking at the back of - * each other's heads. It does that at the bank's single `rowPitch`, and the - * pitch that makes two rows meet back-to-back (0.85 m, a desk deep plus a cable - * trough) is nothing like the pitch you need between one bench and the next - * (2.4 m of chair, aisle and chair). One bank cannot express both, so a second - * bench is a second bank. That is a real limit of the format and not an - * oversight; the alternative is a per-pair pitch field that exists to describe - * one furniture layout. - * - * The four floor banks sit in two rows of two with a 1.3 m aisle up the middle, - * and the workshop's is a single row of three against the south wall. - */ const DESK_BANKS: DeskBank[] = [ { - id: "eng", + id: "sf-agent", desk: DESK, chair: TASK_CHAIR, - origin: { x: 9.2, z: 1.9 }, + origin: { x: 2.15, z: 4.45 }, rotation: NORTH, - columns: 6, - rows: 2, - pitch: 1.7, // 1.6 m desks with a 0.1 m gap - rowPitch: 0.85, - facingRows: true, - seatOffset: 0.6, - }, - { - id: "ops", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 9.2, z: 5.6 }, - rotation: NORTH, - columns: 6, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - seatOffset: 0.6, - }, - { - id: "design", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 20.6, z: 1.9 }, - rotation: NORTH, - columns: 3, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - seatOffset: 0.6, - }, - { - id: "sales", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 20.6, z: 5.6 }, - rotation: NORTH, - columns: 3, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - seatOffset: 0.6, - }, - { - id: "lab", - desk: DESK, - chair: TASK_CHAIR, - // Against the workshop's south wall, so the bench runs east-to-west and the - // columns march the other way: at `rotation: SOUTH` the bank's local +X is - // world −X, which is why the origin is the *east* end of the run. Rotate a - // bank and the origin stays station (1, 1); it does not become a corner of - // the building. - origin: { x: 24.8, z: 17.4 }, - rotation: SOUTH, - columns: 3, + columns: 2, rows: 1, - pitch: 1.8, - seatOffset: 0.6, + pitch: 1.82, + rowPitch: 1, + seatOffset: 0.62, + pose: "sit", }, ]; -// ---- Seats ---------------------------------------------------------------- - -/** - * The thirty-nine seats a `DeskBank` cannot generate: chairs round a table, - * chairs in a lounge, one chair behind a reception desk. - * - * A seat is an **address**, not a chair — the chair is a separate prop that - * happens to be at the same coordinate, and a room with no chairs in it can - * still have seats. Ids are meant to be stable across edits to this file for - * the same reason street numbers are stable across repainting the house: a - * `Presence` arriving from a private API says `bernal-04` and nothing else, and - * it has no way to notice that the table moved 200 mm. - * - * The `facing` is where the occupant looks, which for a chair at a table is - * across it. - */ const SEATS: Seat[] = [ - // Reception - { id: "reception-01", position: { x: 4.0, z: 4.9 }, facing: WEST, pose: "sit" }, - - // The lobby's waiting pair - { id: "lobby-01", position: { x: 4.4, z: 7.35 }, facing: SOUTH, pose: "sit" }, - { id: "lobby-02", position: { x: 4.4, z: 9.05 }, facing: NORTH, pose: "sit" }, - - // Focus booths. One seat each, which is the point of them. - { id: "booth-01", position: { x: 9.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, - { id: "booth-02", position: { x: 11.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, - { id: "booth-03", position: { x: 13.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, - - // Alcatraz — ten round a 4.8 m table, five a side, ends left clear so the - // people at them can see the display on the west wall. - { id: "alcatraz-01", position: { x: 1.8, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "alcatraz-02", position: { x: 2.7, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "alcatraz-03", position: { x: 3.6, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "alcatraz-04", position: { x: 4.5, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "alcatraz-05", position: { x: 5.4, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "alcatraz-06", position: { x: 1.8, z: 16.0 }, facing: NORTH, pose: "sit" }, - { id: "alcatraz-07", position: { x: 2.7, z: 16.0 }, facing: NORTH, pose: "sit" }, - { id: "alcatraz-08", position: { x: 3.6, z: 16.0 }, facing: NORTH, pose: "sit" }, - { id: "alcatraz-09", position: { x: 4.5, z: 16.0 }, facing: NORTH, pose: "sit" }, - { id: "alcatraz-10", position: { x: 5.4, z: 16.0 }, facing: NORTH, pose: "sit" }, - - // Bernal — six, three a side of a table turned through ninety degrees. - { id: "bernal-01", position: { x: 8.8, z: 14.1 }, facing: EAST, pose: "sit" }, - { id: "bernal-02", position: { x: 8.8, z: 15.0 }, facing: EAST, pose: "sit" }, - { id: "bernal-03", position: { x: 8.8, z: 15.9 }, facing: EAST, pose: "sit" }, - { id: "bernal-04", position: { x: 10.8, z: 14.1 }, facing: WEST, pose: "sit" }, - { id: "bernal-05", position: { x: 10.8, z: 15.0 }, facing: WEST, pose: "sit" }, - { id: "bernal-06", position: { x: 10.8, z: 15.9 }, facing: WEST, pose: "sit" }, - - // Bolinas — four. The smallest room that is still worth booking. - { id: "bolinas-01", position: { x: 13.2, z: 14.45 }, facing: EAST, pose: "sit" }, - { id: "bolinas-02", position: { x: 13.2, z: 15.55 }, facing: EAST, pose: "sit" }, - { id: "bolinas-03", position: { x: 15.2, z: 14.45 }, facing: WEST, pose: "sit" }, - { id: "bolinas-04", position: { x: 15.2, z: 15.55 }, facing: WEST, pose: "sit" }, - - // Lands End, the quiet room - { id: "quiet-01", position: { x: 17.8, z: 14.0 }, facing: SOUTH, pose: "sit" }, - { id: "quiet-02", position: { x: 17.8, z: 16.0 }, facing: NORTH, pose: "sit" }, - - // The lounge, four chairs round a low table - { id: "lounge-01", position: { x: 29.6, z: 1.7 }, facing: SOUTH, pose: "sit" }, - { id: "lounge-02", position: { x: 29.6, z: 4.3 }, facing: NORTH, pose: "sit" }, - { id: "lounge-03", position: { x: 28.2, z: 3.0 }, facing: EAST, pose: "sit" }, - { id: "lounge-04", position: { x: 31.0, z: 3.0 }, facing: WEST, pose: "sit" }, - - // The kitchen: three at the island, two by the glass. `pose: "stand"` at the - // island because people perch there, and the pose is the only thing that tells - // a consumer how tall an occupant should be drawn. - { id: "kitchen-01", position: { x: 28.2, z: 8.6 }, facing: NORTH, pose: "stand" }, - { id: "kitchen-02", position: { x: 29.0, z: 8.6 }, facing: NORTH, pose: "stand" }, - { id: "kitchen-03", position: { x: 29.8, z: 8.6 }, facing: NORTH, pose: "stand" }, - { id: "kitchen-04", position: { x: 32.6, z: 6.9 }, facing: SOUTH, pose: "sit" }, - { id: "kitchen-05", position: { x: 32.6, z: 8.6 }, facing: NORTH, pose: "sit" }, + { id: "sf-island-01", position: { x: 1.9, z: 3.35 }, facing: NORTH, pose: "sit" }, + { id: "sf-island-02", position: { x: 3.0, z: 3.35 }, facing: NORTH, pose: "sit" }, + { id: "sf-lounge-01", position: { x: 8.15, z: 7.55 }, facing: NORTH, pose: "sit" }, ]; -// ---- Props ---------------------------------------------------------------- - -/** - * Everything that is not a wall, a floor or a desk bank. - * - * Grouped by room and in the order you would walk through the building, because - * that is the order in which you will want to change them. Ids carry their room - * as a prefix so that a prop id is legible on its own — `alcatraz-display` tells - * you where to look; `prop-142` does not. - * - * ### On monitors, and the thing this pack deliberately does not do - * - * There are thirty-nine desks here and no monitors on any of them, which at - * first looks like an omission. `DeskBank` places a desk and a chair and no - * third thing, so monitors would be thirty-nine hand-written props that have to - * be kept in step with a bank you are going to move next week. - * - * The format's answer is the asset override. Register - * `acme:desk.workstation` with `overrides: "tera:desk.workstation"`, build a - * desk with a monitor on it, and every station in every bank in every pack has - * one — with no edit to this file, and no fork. That is the mechanism working - * as designed, and it is why the monitors that *are* here are the ones that - * belong to a place rather than to a desk: reception, and the two meeting rooms. - */ const PROPS: Prop[] = [ - // -- Reception ------------------------------------------------------------ - // Set back four metres from the entrance and turned to face it. The desk's - // working side is at its local +Z, so at `WEST` the receptionist is on its - // east flank looking back at the door. - { id: "lobby-desk", kind: DESK, position: { x: 3.4, z: 4.9 }, rotation: WEST }, - { id: "lobby-chair", kind: TASK_CHAIR, position: { x: 4.0, z: 4.9 }, rotation: WEST }, - { - id: "lobby-monitor", - kind: MONITOR, - position: { x: 3.4, z: 5.2 }, - rotation: WEST, - // Standing on the desktop. Desk assets are 0.73 m to the deck, and a monitor - // is authored on the floor like everything else, so the pack supplies the - // height rather than the asset assuming one. - elevation: 0.73, - }, - { id: "lobby-pedestal", kind: PEDESTAL, position: { x: 3.4, z: 3.8 }, rotation: WEST }, + // Entry: clear 1.2 m turning circle, one landing shelf and a plant. The first + // viewpoint focuses at x=1.0,z=7.75, so nothing is allowed to drift into it. + prop("entry-shelf", SHELF, 0.65, 5.75, WEST), + prop("entry-plant", PLANT, 1.25, 8.2), - // The waiting pair, on a rug, under two pendants. - { id: "lobby-rug", kind: RUG, position: { x: 4.4, z: 8.2 }, rotation: NORTH }, - { id: "lobby-sofa-n", kind: LOUNGE_CHAIR, position: { x: 4.4, z: 7.35 }, rotation: SOUTH }, - { id: "lobby-sofa-s", kind: LOUNGE_CHAIR, position: { x: 4.4, z: 9.05 }, rotation: NORTH }, - { id: "lobby-table", kind: SIDE_TABLE, position: { x: 4.4, z: 8.2 }, rotation: NORTH }, - { id: "lobby-shelf", kind: SHELF, position: { x: 1.2, z: SPINE_N - INT_FACE - 0.18 }, rotation: SOUTH }, - { id: "lobby-tree-1", kind: TREE, position: { x: 0.8, z: 8.9 }, rotation: NORTH }, - { id: "lobby-tree-2", kind: TREE, position: { x: 6.6, z: 1.0 }, rotation: NORTH }, - ...scatter("lobby-pendant", PENDANT, grid(3.9, 8.2, 2, 1, 1.0, 0), { elevation: CEILING }), - ...scatter("lobby-light", TROFFER, grid(1.4, 1.4, 3, 3, 2.4, 3.0), { elevation: CEILING }), + // Two-place agent bench. Media keeps the legacy `lobby-monitor` id so hosted + // screen grants survive this content rewrite. + prop("lobby-monitor", MONITOR, 2.15, 4.45, NORTH, { elevation: 0.73 }), + prop("agent-monitor-b", MONITOR, 3.97, 4.45, NORTH, { elevation: 0.73 }), + prop("agent-tree", TREE, 5.15, 4.15), + prop("agent-light-a", TROFFER, 2.2, 4.6, NORTH, { elevation: CEILING }), + prop("agent-light-b", TROFFER, 4.0, 4.6, NORTH, { elevation: CEILING }), - // -- The open desk floor -------------------------------------------------- - // A screen down the spine of each bench, one per station. 1.4 m against a - // 1.7 m pitch leaves a gap you can talk through, which is the entire argument - // for a 0.45 m screen over a 1.2 m one. - ...scatter("eng-screen", PARTITION, grid(9.2, 2.325, 6, 1, 1.7, 0)), - ...scatter("ops-screen", PARTITION, grid(9.2, 6.025, 6, 1, 1.7, 0)), - ...scatter("design-screen", PARTITION, grid(20.6, 2.325, 3, 1, 1.7, 0)), - ...scatter("sales-screen", PARTITION, grid(20.6, 6.025, 3, 1, 1.7, 0)), + // Real kitchen silhouettes, not office lockers standing in for cabinetry. + prop("kitchen-run", KITCHEN, 2.35, 0.65, NORTH), + prop("kitchen-island", ISLAND, 2.45, 2.55, NORTH), + prop("kitchen-stool-a", STOOL, 1.9, 3.35, NORTH, { seat: "sf-island-01" }), + prop("kitchen-stool-b", STOOL, 3.0, 3.35, NORTH, { seat: "sf-island-02" }), + prop("kitchen-pendant-a", PENDANT, 1.95, 2.55, NORTH, { elevation: CEILING }), + prop("kitchen-pendant-b", PENDANT, 2.95, 2.55, NORTH, { elevation: CEILING }), + prop("kitchen-plant", PLANT, 4.05, 2.25), - // Pedestals park at the aisle ends rather than under the desks, which is - // where they end up in a building where people move seats. - ...scatter("open-pedestal", PEDESTAL, [ - { x: 18.75, z: 1.9 }, - { x: 18.75, z: 5.6 }, - { x: 25.2, z: 1.9 }, - { x: 25.2, z: 5.6 }, - ]), + // Sleeping alcove and closed storage. Both face the studio so the domestic + // half stays legible from the arrival view rather than hiding behind walls. + prop("studio-bed", BED, 6.45, 1.35, NORTH), + prop("bed-side", SIDE_TABLE, 7.75, 1.2), + prop("bed-light", FLOOR_LIGHT, 7.95, 2.15, SOUTH), + prop("studio-wardrobe", WARDROBE, 8.22, 1.3, WEST), + prop("bath-shelf", SHELF, 11.1, 2.55, EAST), + prop("bath-plant", PLANT, 9.25, 2.55), - // Lockers and a whiteboard along the lobby wall — the one wall on this floor - // with nothing on the other side of it. At `WEST` a locker's 1.2 m face turns - // to run along Z, so they stack down the wall at 1.3 m centres. - ...scatter("open-locker", LOCKER, grid(LOBBY_E + INT_FACE + 0.25, 1.0, 1, 3, 0, 1.3), { - rotation: WEST, - }), - { - id: "open-whiteboard", - kind: WHITEBOARD, - position: { x: LOBBY_E + INT_FACE + BOARD_OFFSET, z: 5.2 }, - rotation: WEST, - }, - { - id: "open-display", - kind: DISPLAY, - // On the corridor wall, clear of the 3 m archway at x 16–19. - position: { x: 21.0, z: SPINE_N - INT_FACE - DISPLAY_OFFSET }, - rotation: SOUTH, - }, - ...scatter("open-sill-plant", PLANT, grid(8.6, 0.55, 5, 1, 3.4, 0)), - { id: "open-tree-1", kind: TREE, position: { x: 19.2, z: 3.8 }, rotation: NORTH }, - { id: "open-tree-2", kind: TREE, position: { x: 19.2, z: 9.6 }, rotation: NORTH }, - { id: "open-tree-3", kind: TREE, position: { x: 25.0, z: 9.6 }, rotation: NORTH }, - ...scatter("open-light", TROFFER, grid(8.8, 1.5, 6, 3, 3.2, 3.6), { elevation: CEILING }), - - // -- Focus booths --------------------------------------------------------- - // Desk against the corridor wall, occupant facing it. Three booths, three - // identical fit-outs, one line each. - ...scatter("booth-desk", DESK, grid(9.4, 9.9, 3, 1, 2.0, 0), { rotation: SOUTH }), - ...scatter("booth-chair", TASK_CHAIR, grid(9.4, 9.3, 3, 1, 2.0, 0), { rotation: SOUTH }), - ...scatter("booth-light", TROFFER, grid(9.4, 9.5, 3, 1, 2.0, 0), { elevation: BOOTH_CEILING }), - - // -- Circulation ---------------------------------------------------------- - ...scatter("corridor-shelf", SHELF, grid(22.6, SPINE_N + INT_FACE + 0.18, 2, 1, 1.0, 0)), - { id: "corridor-plant-w", kind: PLANT, position: { x: 0.7, z: 11.3 }, rotation: NORTH }, - { id: "corridor-plant-e", kind: PLANT, position: { x: 33.3, z: 11.3 }, rotation: NORTH }, - ...scatter("corridor-light", TROFFER, grid(2.0, 11.3, 9, 1, 3.6, 0), { elevation: CEILING }), - - // -- The lounge ----------------------------------------------------------- - // Four chairs facing a low table, which is the arrangement people actually - // sit in; a row of chairs against a window is furniture nobody uses. - { id: "lounge-rug", kind: RUG, position: { x: 29.6, z: 3.0 }, rotation: NORTH }, - { id: "lounge-chair-n", kind: LOUNGE_CHAIR, position: { x: 29.6, z: 1.7 }, rotation: SOUTH }, - { id: "lounge-chair-s", kind: LOUNGE_CHAIR, position: { x: 29.6, z: 4.3 }, rotation: NORTH }, - { id: "lounge-chair-w", kind: LOUNGE_CHAIR, position: { x: 28.2, z: 3.0 }, rotation: EAST }, - { id: "lounge-chair-e", kind: LOUNGE_CHAIR, position: { x: 31.0, z: 3.0 }, rotation: WEST }, - { id: "lounge-table", kind: SIDE_TABLE, position: { x: 29.6, z: 3.0 }, rotation: NORTH }, - { id: "lounge-tree-1", kind: TREE, position: { x: 33.2, z: 1.0 }, rotation: NORTH }, - { id: "lounge-tree-2", kind: TREE, position: { x: 26.2, z: 5.2 }, rotation: NORTH }, - ...scatter("lounge-pendant", PENDANT, grid(29.0, 3.0, 2, 1, 1.2, 0), { elevation: CEILING }), - ...scatter("lounge-light", TROFFER, grid(27.0, 1.4, 3, 2, 2.8, 3.0), { elevation: CEILING }), - - // -- The kitchen ---------------------------------------------------------- - // A meeting table doing duty as an island. There is no `tera:kitchen.island` - // and there should not be: seventeen assets is the set that gets a floor - // plate looking like an office, and the honest way to get an island is to - // register one in your own namespace. - { id: "kitchen-island", kind: MEETING_TABLE, position: { x: 29.0, z: 7.4 }, rotation: NORTH }, - { id: "kitchen-island-plant", kind: PLANT, position: { x: 29.0, z: 7.4 }, rotation: NORTH, elevation: 0.74 }, - ...scatter("kitchen-stool", TASK_CHAIR, grid(28.2, 8.6, 3, 1, 0.8, 0), { rotation: NORTH }), - ...scatter("kitchen-counter", LOCKER, grid(26.5, SPINE_N - INT_FACE - 0.25, 3, 1, 1.3, 0), { - rotation: SOUTH, - }), - { id: "kitchen-shelf", kind: SHELF, position: { x: 30.6, z: SPINE_N - INT_FACE - 0.18 }, rotation: SOUTH }, - { id: "kitchen-seat-n", kind: LOUNGE_CHAIR, position: { x: 32.6, z: 6.9 }, rotation: SOUTH }, - { id: "kitchen-seat-s", kind: LOUNGE_CHAIR, position: { x: 32.6, z: 8.6 }, rotation: NORTH }, - { id: "kitchen-table", kind: SIDE_TABLE, position: { x: 32.6, z: 7.75 }, rotation: NORTH }, - ...scatter("kitchen-pendant", PENDANT, grid(28.4, 7.4, 2, 1, 1.2, 0), { elevation: CEILING }), - ...scatter("kitchen-light", TROFFER, grid(27.0, 7.0, 3, 2, 2.8, 2.4), { elevation: CEILING }), - - // -- Alcatraz, the board room --------------------------------------------- - // Two 2.4 m tables end to end. A `Prop` carries no parameters — it is an id - // and a placement — so the 4.8 m table this room wants is two of the 2.4 m - // one, and the alternative is registering `acme:table.board` at the length - // you want. Both are one line; only one of them is in this file. - { id: "alcatraz-table-w", kind: MEETING_TABLE, position: { x: 2.4, z: 15.0 }, rotation: NORTH }, - { id: "alcatraz-table-e", kind: MEETING_TABLE, position: { x: 4.8, z: 15.0 }, rotation: NORTH }, - ...scatter("alcatraz-chair-n", TASK_CHAIR, grid(1.8, 14.0, 5, 1, 0.9, 0), { rotation: SOUTH }), - ...scatter("alcatraz-chair-s", TASK_CHAIR, grid(1.8, 16.0, 5, 1, 0.9, 0), { rotation: NORTH }), - { - id: "alcatraz-display", - kind: DISPLAY, - // The west wall is the one blank wall in the room: the south wall has the - // window, the north wall has the vision panel and the door. Rooms get laid - // out around where a screen can go more often than anyone admits. - position: { x: EXT_FACE + DISPLAY_OFFSET, z: 15.0 }, - rotation: WEST, - }, - { - id: "alcatraz-whiteboard", - kind: WHITEBOARD, - position: { x: X_BERNAL - INT_FACE - BOARD_OFFSET, z: 15.0 }, - rotation: EAST, - }, - { id: "alcatraz-tree", kind: TREE, position: { x: 6.6, z: 17.2 }, rotation: NORTH }, - ...scatter("alcatraz-pendant", PENDANT, grid(2.0, 15.0, 3, 1, 1.6, 0), { elevation: CEILING }), - ...scatter("alcatraz-light", TROFFER, grid(1.6, 13.2, 3, 2, 2.4, 3.2), { elevation: CEILING }), - - // -- Bernal, six people --------------------------------------------------- - // The table turns through ninety degrees so its 2.4 m length runs down the - // room. `rotation: WEST` on a table is not the table facing anywhere — it is - // just a right angle, and the yaw convention has no separate vocabulary for - // things without a front. - { id: "bernal-table", kind: MEETING_TABLE, position: { x: 9.8, z: 15.0 }, rotation: WEST }, - ...scatter("bernal-chair-w", TASK_CHAIR, grid(8.8, 14.1, 1, 3, 0, 0.9), { rotation: EAST }), - ...scatter("bernal-chair-e", TASK_CHAIR, grid(10.8, 14.1, 1, 3, 0, 0.9), { rotation: WEST }), - { - id: "bernal-display", - kind: DISPLAY, - position: { x: X_BERNAL + INT_FACE + DISPLAY_OFFSET, z: 15.0 }, - rotation: WEST, - }, - { id: "bernal-tree", kind: TREE, position: { x: 11.8, z: 17.2 }, rotation: NORTH }, - ...scatter("bernal-pendant", PENDANT, grid(9.8, 14.2, 1, 2, 0, 1.6), { elevation: CEILING }), - ...scatter("bernal-light", TROFFER, grid(8.4, 13.4, 2, 2, 2.8, 3.2), { elevation: CEILING }), - - // -- Bolinas, four people ------------------------------------------------- - { id: "bolinas-table", kind: MEETING_TABLE, position: { x: 14.2, z: 15.0 }, rotation: WEST }, - ...scatter("bolinas-chair-w", TASK_CHAIR, grid(13.2, 14.45, 1, 2, 0, 1.1), { rotation: EAST }), - ...scatter("bolinas-chair-e", TASK_CHAIR, grid(15.2, 14.45, 1, 2, 0, 1.1), { rotation: WEST }), - { - id: "bolinas-whiteboard", - kind: WHITEBOARD, - position: { x: X_QUIET - INT_FACE - BOARD_OFFSET, z: 15.0 }, - rotation: EAST, - }, - { id: "bolinas-plant", kind: PLANT, position: { x: 12.9, z: 17.3 }, rotation: NORTH }, - ...scatter("bolinas-pendant", PENDANT, grid(14.2, 15.0, 1, 1, 0, 0), { elevation: CEILING }), - ...scatter("bolinas-light", TROFFER, grid(13.2, 13.4, 2, 2, 2.0, 3.2), { elevation: CEILING }), - - // -- Lands End, the quiet room -------------------------------------------- - // No table you can put a laptop on, on purpose. - { id: "quiet-rug", kind: RUG, position: { x: 17.8, z: 15.0 }, rotation: NORTH }, - { id: "quiet-chair-n", kind: LOUNGE_CHAIR, position: { x: 17.8, z: 14.0 }, rotation: SOUTH }, - { id: "quiet-chair-s", kind: LOUNGE_CHAIR, position: { x: 17.8, z: 16.0 }, rotation: NORTH }, - { id: "quiet-table", kind: SIDE_TABLE, position: { x: 17.8, z: 15.0 }, rotation: NORTH }, - { id: "quiet-shelf", kind: SHELF, position: { x: 16.7, z: SPINE_S + INT_FACE + 0.18 }, rotation: NORTH }, - { id: "quiet-tree", kind: TREE, position: { x: 19.0, z: 17.2 }, rotation: NORTH }, - ...scatter("quiet-pendant", PENDANT, grid(17.8, 15.0, 1, 1, 0, 0), { elevation: CEILING }), - ...scatter("quiet-light", TROFFER, grid(16.9, 13.4, 2, 2, 1.8, 3.2), { elevation: CEILING }), - - // -- Dogpatch, the workshop ----------------------------------------------- - // The bench is the `lab` desk bank; everything here is what goes round it. - ...scatter("shop-shelf", SHELF, grid(24.6, SPINE_S + INT_FACE + 0.18, 2, 1, 1.0, 0)), - ...scatter("shop-locker", LOCKER, grid(X_SERVER - INT_FACE - 0.25, 13.4, 1, 2, 0, 1.3), { - rotation: EAST, - }), - { - id: "shop-whiteboard", - kind: WHITEBOARD, - position: { x: X_WORKSHOP + INT_FACE + BOARD_OFFSET, z: 15.0 }, - rotation: WEST, - }, - { id: "shop-tree", kind: TREE, position: { x: 20.3, z: 14.0 }, rotation: NORTH }, - ...scatter("shop-light", TROFFER, grid(20.6, 13.4, 3, 2, 2.4, 3.2), { elevation: CEILING }), - - // -- Farallon, the server room -------------------------------------------- - // Two rows of cabinets facing each other across a 2.4 m aisle, which is the - // hot-aisle arrangement and also the only way two rows of anything read as - // deliberate. A storage locker is not a rack, but it is a 1.2 x 0.5 x 1.8 m - // box with a front and a back, and at this scale that is a rack. - // - // The five objects in this room are the pack's one worked example of - // `audience: "private"`. The room, its raised floor, its walls and its lid are - // all still built at public depth — the *architecture* is not the secret — but - // what is standing in it is not shown to a stranger. How many cabinets an - // organisation runs and which way the aisle faces is the kind of detail that - // is worth nothing to a visitor and something to somebody else, and the room - // already keeps its ceiling for the same reason, which is the pack saying the - // same thing twice in two vocabularies. - // - // At public depth this leaves a lit, empty, raised-floor room, which is an - // honest picture of a room you are not being shown the inside of. The lights - // stay: a dark hole in a floor plan reads as a rendering fault, not as - // discretion. - ...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), { - rotation: NORTH, - audience: "private", - }), - ...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), { - rotation: SOUTH, - audience: "private", - }), - { - id: "mdf-shelf", - kind: SHELF, - position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 }, - rotation: SOUTH, - audience: "private", - }, - ...scatter("mdf-light", TROFFER, grid(27.6, 14.0, 2, 2, 2.0, 2.4), { elevation: 2.6 }), - - // -- Facilities ----------------------------------------------------------- - ...scatter("fac-locker", LOCKER, grid(X_STORE + INT_FACE + 0.25, 13.2, 1, 4, 0, 1.3), { - rotation: WEST, - }), - ...scatter("fac-shelf", SHELF, grid(BLOCK_E - EXT_FACE - 0.2, 13.4, 1, 2, 0, 1.0), { - rotation: EAST, - }), - ...scatter("fac-light", TROFFER, grid(32.2, 14.0, 1, 2, 0, 2.4), { elevation: 2.6 }), - - // -- The Commons ---------------------------------------------------------- - // - // Furnished sparsely on purpose. The wing's whole argument is the volume, and - // a fourteen-metre room with forty objects in it is a fourteen-metre room you - // cannot see. Four trees, two clusters of seating, one long table people - // actually work at, and nothing along the glass. - ...scatter("commons-tree", TREE, [ - { x: 36.6, z: 3.2 }, - { x: 45.4, z: 4.6 }, - { x: 37.4, z: 15.0 }, - { x: 44.2, z: 9.8 }, - ]), - { - id: "commons-rug", - kind: RUG, - position: { x: 39.4, z: 5.0 }, - rotation: NORTH, - colorKey: "social", - }, - // A ring rather than a row: seating that faces itself is seating people sit - // in, and seating in a line against a wall is seating people walk past. - ...scatter("commons-chair", LOUNGE_CHAIR, [ - { x: 38.0, z: 4.2 }, - { x: 40.8, z: 4.2 }, - { x: 38.0, z: 6.0 }, - { x: 40.8, z: 6.0 }, - ]), - { id: "commons-table-01", kind: SIDE_TABLE, position: { x: 39.4, z: 5.1 }, rotation: NORTH }, - /** - * The long table, on the axis of the archway off the corridor. - * - * Placed where somebody walking out of the block arrives, which is the only - * placement rule this room follows. `tera:table.meeting` at its default 2.4 m - * is the longest table in the kit; two of them end to end read as one refectory - * table, which is what a commons wants and what the kit has no single asset for. - */ - { id: "commons-long-01", kind: MEETING_TABLE, position: { x: 39.8, z: 10.4 }, rotation: NORTH }, - { id: "commons-long-02", kind: MEETING_TABLE, position: { x: 39.8, z: 12.9 }, rotation: NORTH }, - ...scatter("commons-perch", TASK_CHAIR, [ - { x: 38.6, z: 10.4 }, - { x: 41.0, z: 10.4 }, - { x: 38.6, z: 12.9 }, - { x: 41.0, z: 12.9 }, - ]), - { - id: "commons-display", - kind: DISPLAY, - // On the stair core's north face, which is the one solid surface in the wing - // that everybody walking through it is already looking at. - position: { x: 45.4, z: STAIR_N - INT_FACE - DISPLAY_OFFSET }, - rotation: SOUTH, - elevation: 1.1, - }, - /** - * Pendants at 19 m, hanging into the void from the roof rather than from a - * ceiling at 2.8 m that is not there. - * - * This is the one place in the pack where the ceiling-fixture exception earns - * its keep: `tera:light.pendant` is authored with its origin at the mounting - * plane and all its geometry below, so a number here is a real height and not - * an offset from a lid nobody built. - */ - ...scatter("commons-pendant", PENDANT, grid(37.2, 4.0, 3, 3, 4.2, 5.4), { - elevation: COMMONS_HEIGHT - 0.6, - }), + // Lounge: one properly articulated sofa, a low table and a demo display on + // the north-facing wall. `open-display` is the other stable media address. + prop("lounge-rug", RUG, 8.15, 6.75, NORTH, { scale: [1.35, 1, 1.25], colorKey: "social" }), + prop("lounge-sofa", SOFA, 8.15, 7.72, NORTH, { seat: "sf-lounge-01", colorKey: "social" }), + prop("lounge-table", SIDE_TABLE, 8.15, 6.45), + prop("open-display", DISPLAY, 9.65, 3.23, SOUTH), + prop("demo-whiteboard", WHITEBOARD, 11.65, 5.6, WEST), + prop("lounge-floor-light", FLOOR_LIGHT, 10.55, 7.65, EAST), + prop("lounge-tree", TREE, 11.05, 7.9), + prop("lounge-plant", PLANT, 6.4, 7.95), ]; -// ---- Zones ---------------------------------------------------------------- - -/** - * Four labelled regions of floor, and nothing else. - * - * A zone has no behaviour, no geometry and no meaning the engine can see. It is - * an area with a name and an opaque `colorKey`, so that something outside this - * repo can highlight it, filter against it or count what is inside it. Whether - * "eng" is a team, a cost centre or a colour scheme is not the engine's - * business — the same rule as `Marker.colorKey`, which is why there is no - * `kind` field to be tempted by. - * - * ### Two of them are private, and it is the names that make them so - * - * The engine cannot tell these four apart, but a reader can. "Social" and - * "Focus" describe what the floor is *for*: anyone standing in the lounge can - * see that it is the lounge, and a public visitor learning that the three - * glass-lidded boxes are the focus booths has learned nothing they could not - * have guessed from the plan. - * - * "Engineering" and "Studio" describe who *sits* there, and that is a different - * kind of fact. It is org chart drawn on a floor: how many desks each function - * has, where they are relative to each other, which corner grew last quarter. - * Nobody signed in is surprised by it and nobody anonymous is owed it, which is - * exactly the line `audience` exists to draw — so the two team zones are not - * built at public depth and the two spatial ones are. - * - * This is the granularity the field is for. The alternative anybody reaches for - * first is a single `private: true` on the whole floorplan, and it is useless: - * a building is not private or public, the labels on it are. - */ const ZONES: Zone[] = [ - { - id: "zone-eng", - name: "Engineering", - outline: rect(8.0, 0.6, 18.9, 7.6), - colorKey: "team-a", - audience: "private", - }, - { - id: "zone-studio", - name: "Studio", - outline: rect(19.4, 0.6, 25.2, 7.6), - colorKey: "team-b", - audience: "private", - }, - { id: "zone-social", name: "Social", outline: rect(SOCIAL_W, 0, BLOCK_E, SPINE_N), colorKey: "social" }, - { id: "zone-focus", name: "Focus", outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_E, SPINE_N), colorKey: "focus" }, - // Spatial rather than organisational, like "Social" and "Focus" and unlike the - // two team zones — anybody standing in the wing can see it is the commons. - { - id: "zone-commons", - name: "Commons", - outline: rect(BLOCK_E, 0, WIDTH, DEPTH), - colorKey: "social", - }, + { id: "zone-entry", name: "Entry", outline: rect(FACE, 6.7, 1.75, DEPTH - FACE), colorKey: "social" }, + { id: "zone-agent-bench", name: "Agent Bench", outline: rect(1.1, 3.5, 5.15, 5.6), colorKey: "focus" }, + { id: "zone-lounge", name: "Demo Lounge", outline: rect(6.1, 3.4, 11.35, 8.45), colorKey: "social" }, + { id: "zone-home", name: "Living Space", outline: rect(FACE, FACE, 8.55, 3.15), colorKey: "team-a" }, ]; -// ---- Level 2 --------------------------------------------------------------- - -/** - * The upper storey: a studio, a library, three rooms and a balcony. - * - * **Every coordinate below is in level 2's own frame**, with the floor at zero. - * `Plan` adds `STOREY` to all of them exactly once when it resolves the level, - * which is what lets this section be read without holding 16.8 in your head, and - * what lets both levels sit in one scene group at the origin. - * - * It stops at `BLOCK_E`. The wing below is open to the roof, so there is no slab - * over it — only `l2-gallery`, a 4.2 m balcony that reaches out over the void so - * the commons can be looked *down into* rather than merely walked through. A - * double-height room nobody can stand above is a tall room; the balcony is what - * makes it a volume. - * - * The programme is deliberately the opposite of level 1's. Downstairs is - * fifteen rooms, thirty-nine desks and a corridor to tie them together; up here - * is one long studio, one library, and three rooms for the things that cannot - * happen at a desk. A second floor that repeated the first would have doubled - * the area and added nothing to look at. - */ - -// The lines level 2 is built on. It shares the block's north band / corridor / -// south band rhythm, because the structure below does not move, and divides the -// bands differently because the programme is different. -const L2_STUDIO_W = 6.4; // landing / studio -const L2_LIBRARY_W = 25.2; // studio / library -const L2_MEET_A_E = 8.4; // the two meeting rooms -const L2_MEET_B_E = 15.2; // meeting room / project room -const L2_PROJECT_E = 24.6; // project room / plant - -const L2_ROOMS: Room[] = [ - // -- North band ----------------------------------------------------------- - { - id: "l2-landing", - name: "The Landing", - // Where the stair arrives, and the only part of this floor that is - // circulation rather than a place to be. - outline: rect(0, 0, L2_STUDIO_W, SPINE_N), - floor: TERRAZZO, - ceiling: null, - }, - { - id: "l2-studio", - name: "The Studio", - // Nineteen metres of unbroken north-lit floor, which is the one thing the - // block downstairs cannot offer: level 1's equivalent run is interrupted by - // the lobby wall and the booths. - outline: rect(L2_STUDIO_W, 0, L2_LIBRARY_W, SPINE_N), - floor: CARPET, - ceiling: null, - }, - { - id: "l2-library", - name: "The Library", - outline: rect(L2_LIBRARY_W, 0, BLOCK_E, SPINE_N), - floor: WOOD, - ceiling: null, - }, - - // -- The corridor --------------------------------------------------------- - { - id: "l2-corridor", - name: "Corridor", - outline: rect(0, SPINE_N, BLOCK_E, SPINE_S), - floor: CARPET_ACCENT, - ceiling: null, - }, - - // -- South band ----------------------------------------------------------- - { - id: "l2-meet-a", - name: "Tiburon", - outline: rect(0, SPINE_S, L2_MEET_A_E, DEPTH), - floor: CARPET_ACCENT, - ceiling: null, - }, - { - id: "l2-meet-b", - name: "Angel Island", - outline: rect(L2_MEET_A_E, SPINE_S, L2_MEET_B_E, DEPTH), - floor: CARPET_ACCENT, - ceiling: null, - }, - { - id: "l2-project", - name: "The Project Room", - // Concrete and no soft finishes: the room you are allowed to stick things to - // the walls of. The workshop downstairs is its equivalent and reads the same - // way for the same reason. - outline: rect(L2_MEET_B_E, SPINE_S, L2_PROJECT_E, DEPTH), - floor: CONCRETE, - ceiling: null, - }, - { - id: "l2-plant", - name: "Plant", - // The one room up here with a lid on it, and the same argument as the server - // room below: you are never meant to see inside, and a ceiling says so more - // cheaply than a sign. - outline: rect(L2_PROJECT_E, SPINE_S, BLOCK_E, DEPTH), - floor: RAISED_FLOOR, - ceiling: { height: 2.6, surface: CEILING_TILE }, - }, - - // -- Over the void -------------------------------------------------------- - { - id: "l2-gallery", - name: "The Gallery", - outline: rect(BLOCK_E, 2.0, GALLERY_E, 16.0), - floor: WOOD, - ceiling: null, - }, -]; - -const L2_WALLS: Wall[] = [ - // -- The envelope, repeated ------------------------------------------------ - // - // The same lines as level 1, because the structure below does not move. The - // openings are not the same, and that is the point of restating the walls - // rather than sharing them: this floor wants glass where the floor below wants - // a pier, and a format in which two storeys had to agree about their windows - // would be a format that could only describe one storey. - { - id: "l2-ext-north", - from: { x: 0, z: 0 }, - to: { x: BLOCK_E, z: 0 }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [ - pane(1.2, 4.0, 0.75, 2.35), - pane(6.4, 4.0, 0.75, 2.35), - pane(11.6, 4.0, 0.75, 2.35), - pane(16.8, 4.0, 0.75, 2.35), - pane(22.0, 4.0, 0.75, 2.35), - pane(27.2, 4.0, 0.75, 2.35), - ], - }, - { - id: "l2-ext-west", - from: { x: 0, z: 0 }, - to: { x: 0, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [pane(3.0, 3.4), pane(13.4, 2.6)], - }, - { - id: "l2-ext-south", - from: { x: 0, z: DEPTH }, - to: { x: BLOCK_E, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: PAINT, - openings: [pane(1.4, 5.2), pane(9.4, 4.4), pane(16.0, 6.6), pane(25.4, 3.0)], - }, - - // -- The edge of the void -------------------------------------------------- - /** - * The east wall of the block, at level 2, with the gallery on the far side. - * - * Glazed for the same reason level 1's is: the studio looks into the commons, - * and a solid wall here would make the balcony a thing you have to go and find - * rather than a thing you can see. - */ - { - id: "l2-block-east", - from: { x: BLOCK_E, z: 0 }, - to: { x: BLOCK_E, z: DEPTH }, - thickness: EXT_THICKNESS, - surface: GLASS, - openings: [archway(6.0, 3.0), archway(12.0, 2.4)], - }, - /** - * The balustrade: a 1.1 m wall around the three open edges of the gallery. - * - * A wall and not a prop, and this is the load-bearing reason. `Plan` derives - * the walk-mode collider from the wall list, so a balustrade authored as - * furniture is a balcony you can walk off — and the drop is 4.2 m onto - * terrazzo. An opening is a way through iff `sill <= 0 and head >= 1.1`; this - * has no openings at all, which is exactly what a balustrade is. - */ - { - id: "l2-gallery-north", - from: { x: BLOCK_E, z: 2.0 }, - to: { x: GALLERY_E, z: 2.0 }, - height: 1.1, - surface: GLASS, - }, - { - id: "l2-gallery-east", - from: { x: GALLERY_E, z: 2.0 }, - to: { x: GALLERY_E, z: 16.0 }, - height: 1.1, - surface: GLASS, - }, - { - id: "l2-gallery-south", - from: { x: BLOCK_E, z: 16.0 }, - to: { x: GALLERY_E, z: 16.0 }, - height: 1.1, - surface: GLASS, - }, - - // -- Internal -------------------------------------------------------------- - { - id: "l2-landing-east", - from: { x: L2_STUDIO_W, z: 0 }, - to: { x: L2_STUDIO_W, z: SPINE_N }, - surface: ACCENT_PAINT, - openings: [archway(5.6, 2.6)], - }, - { - id: "l2-library-west", - from: { x: L2_LIBRARY_W, z: 0 }, - to: { x: L2_LIBRARY_W, z: SPINE_N }, - openings: [archway(6.2, 2.4)], - }, - { - id: "l2-spine-north", - // One wall with three holes, the same arrangement as the floor below and for - // the same reason: five walls that have to agree about where they meet is - // five chances to be a centimetre out. - from: { x: 0, z: SPINE_N }, - to: { x: BLOCK_E, z: SPINE_N }, - openings: [doorway(2.4), archway(14.0, 3.0), doorway(29.0)], - }, - { - id: "l2-spine-south", - from: { x: 0, z: SPINE_S }, - to: { x: BLOCK_E, z: SPINE_S }, - openings: [ - doorway(3.2), // Tiburon - doorway(10.4), // Angel Island - doorway(18.0, 1.8, 2.4), // the project room, wide enough to carry things through - doorway(28.4), // plant - ], - }, - { - id: "l2-meet-divide", - from: { x: L2_MEET_A_E, z: SPINE_S }, - to: { x: L2_MEET_A_E, z: DEPTH }, - }, - { - id: "l2-project-west", - from: { x: L2_MEET_B_E, z: SPINE_S }, - to: { x: L2_MEET_B_E, z: DEPTH }, - // A vision panel rather than a window: it looks at the corridor's neighbour - // room, so the sill keeps it out of the collider and the head keeps it out - // of anybody's eyeline from a seat. - openings: [pane(1.2, 2.4, 1.0, 2.2)], - }, - { - id: "l2-plant-west", - from: { x: L2_PROJECT_E, z: SPINE_S }, - to: { x: L2_PROJECT_E, z: DEPTH }, - }, -]; - -/** - * Two benches of twelve, facing each other across the studio. - * - * Seat ids are `studio-01` … `studio-24`, restarting at 01 as every bank's do. - * They are unique building-wide because the prefix is — `eng`, `ops`, `design`, - * `sales` and `lab` are all downstairs — which is the property that matters, - * since a `Presence` binds to a seat id and ids are unique per kind across the - * whole building rather than per level. - */ -const L2_DESK_BANKS: DeskBank[] = [ - { - id: "studio", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 8.4, z: 1.9 }, - rotation: 0, - columns: 6, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - }, - { - // A second bench is a second bank. One `rowPitch` cannot express both the - // 0.85 m that makes two rows meet back to back and the 2.4 m of chair, - // aisle and chair between one bench and the next — that is a real limit of - // the format and the reason every bench in this file is its own bank. - id: "studio-b", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 8.4, z: 6.1 }, - rotation: 0, - columns: 6, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - seatPrefix: "studio-b", - }, -]; - -const L2_SEATS: Seat[] = [ - // The library: four at the long table, two in the window. - { id: "library-01", position: { x: 27.4, z: 3.0 }, facing: EAST, pose: "sit" }, - { id: "library-02", position: { x: 27.4, z: 4.6 }, facing: EAST, pose: "sit" }, - { id: "library-03", position: { x: 30.2, z: 3.0 }, facing: WEST, pose: "sit" }, - { id: "library-04", position: { x: 30.2, z: 4.6 }, facing: WEST, pose: "sit" }, - { id: "library-05", position: { x: 28.0, z: 8.4 }, facing: NORTH, pose: "sit" }, - { id: "library-06", position: { x: 31.0, z: 8.4 }, facing: NORTH, pose: "sit" }, - - // Tiburon, eight around a 2.4 m table. - { id: "tiburon-01", position: { x: 3.0, z: 14.0 }, facing: EAST, pose: "sit" }, - { id: "tiburon-02", position: { x: 3.0, z: 15.2 }, facing: EAST, pose: "sit" }, - { id: "tiburon-03", position: { x: 3.0, z: 16.4 }, facing: EAST, pose: "sit" }, - { id: "tiburon-04", position: { x: 5.4, z: 14.0 }, facing: WEST, pose: "sit" }, - { id: "tiburon-05", position: { x: 5.4, z: 15.2 }, facing: WEST, pose: "sit" }, - { id: "tiburon-06", position: { x: 5.4, z: 16.4 }, facing: WEST, pose: "sit" }, - { id: "tiburon-07", position: { x: 4.2, z: 13.4 }, facing: SOUTH, pose: "sit" }, - { id: "tiburon-08", position: { x: 4.2, z: 17.0 }, facing: NORTH, pose: "sit" }, - - // Angel Island, six. - { id: "angel-01", position: { x: 10.4, z: 14.4 }, facing: EAST, pose: "sit" }, - { id: "angel-02", position: { x: 10.4, z: 15.8 }, facing: EAST, pose: "sit" }, - { id: "angel-03", position: { x: 12.8, z: 14.4 }, facing: WEST, pose: "sit" }, - { id: "angel-04", position: { x: 12.8, z: 15.8 }, facing: WEST, pose: "sit" }, - { id: "angel-05", position: { x: 11.6, z: 13.8 }, facing: SOUTH, pose: "sit" }, - { id: "angel-06", position: { x: 11.6, z: 16.6 }, facing: NORTH, pose: "sit" }, - - // The project room stands up. `pose: "stand"` is the only thing that tells a - // consumer how tall to draw an occupant, and people do not sit at a wall of - // work in progress. - { id: "project-01", position: { x: 17.4, z: 14.6 }, facing: NORTH, pose: "stand" }, - { id: "project-02", position: { x: 19.0, z: 14.6 }, facing: NORTH, pose: "stand" }, - { id: "project-03", position: { x: 20.6, z: 14.6 }, facing: NORTH, pose: "stand" }, - - // The gallery, looking down into the commons. - { id: "gallery-01", position: { x: 36.0, z: 5.0 }, facing: EAST, pose: "sit" }, - { id: "gallery-02", position: { x: 36.0, z: 6.6 }, facing: EAST, pose: "sit" }, - { id: "gallery-03", position: { x: 36.0, z: 12.0 }, facing: EAST, pose: "sit" }, -]; - -const L2_PROPS: Prop[] = [ - // -- The landing ---------------------------------------------------------- - { id: "l2-landing-plant", kind: TREE, position: { x: 2.2, z: 2.4 }, rotation: NORTH }, - ...scatter("l2-landing-locker", LOCKER, grid(0 + EXT_FACE + 0.25, 5.0, 1, 3, 0, 1.3), { - rotation: WEST, - }), - ...scatter("l2-landing-light", PENDANT, grid(3.2, 3.0, 1, 3, 0, 3.0), { elevation: CEILING }), - - // -- The studio ----------------------------------------------------------- - // - // A monitor per station is not written out here, and deliberately: thirty-nine - // monitor props that have to be kept in step with a bank somebody will move - // next week is the failure mode the README's "Registering your own assets" - // section exists to prevent. Register a desk that builds a desk with a monitor - // on it and every station in every bank in the building gets one. - ...scatter("l2-studio-ped", PEDESTAL, grid(8.4, 3.4, 6, 1, 1.7, 0)), - ...scatter("l2-studio-part", PARTITION, grid(9.25, 2.3, 5, 1, 1.7, 0)), - ...scatter("l2-studio-part-b", PARTITION, grid(9.25, 6.5, 5, 1, 1.7, 0)), - ...scatter("l2-studio-light", TROFFER, grid(8.0, 2.0, 5, 3, 3.4, 3.2), { elevation: CEILING }), - { id: "l2-studio-board", kind: WHITEBOARD, position: { x: 20.0, z: SPINE_N - INT_FACE - BOARD_OFFSET }, rotation: SOUTH, elevation: 1.0 }, - ...scatter("l2-studio-plant", PLANT, [ - { x: 7.2, z: 9.4 }, - { x: 24.2, z: 9.4 }, - { x: 24.2, z: 1.2 }, - ]), - - // -- The library ---------------------------------------------------------- - ...scatter("l2-library-shelf", SHELF, grid(BLOCK_E - EXT_FACE - 0.2, 1.4, 1, 6, 0, 1.0), { - rotation: EAST, - }), - { id: "l2-library-table", kind: MEETING_TABLE, position: { x: 28.8, z: 3.8 }, rotation: NORTH }, - ...scatter("l2-library-chair", TASK_CHAIR, [ - { x: 27.4, z: 3.0 }, - { x: 27.4, z: 4.6 }, - { x: 30.2, z: 3.0 }, - { x: 30.2, z: 4.6 }, - ]), - { id: "l2-library-rug", kind: RUG, position: { x: 29.6, z: 8.0 }, rotation: NORTH, colorKey: "focus" }, - ...scatter("l2-library-lounge", LOUNGE_CHAIR, [ - { x: 28.0, z: 8.4 }, - { x: 31.0, z: 8.4 }, - ]), - { id: "l2-library-side", kind: SIDE_TABLE, position: { x: 29.5, z: 8.4 }, rotation: NORTH }, - ...scatter("l2-library-light", PENDANT, grid(27.4, 3.8, 2, 1, 2.8, 0), { elevation: CEILING }), - - // -- Tiburon and Angel Island --------------------------------------------- - { id: "l2-tiburon-table", kind: MEETING_TABLE, position: { x: 4.2, z: 15.2 }, rotation: NORTH }, - ...scatter("l2-tiburon-chair", TASK_CHAIR, [ - { x: 3.0, z: 14.0 }, - { x: 3.0, z: 15.2 }, - { x: 3.0, z: 16.4 }, - { x: 5.4, z: 14.0 }, - { x: 5.4, z: 15.2 }, - { x: 5.4, z: 16.4 }, - { x: 4.2, z: 13.4 }, - { x: 4.2, z: 17.0 }, - ]), - { - id: "l2-tiburon-display", - kind: DISPLAY, - position: { x: 4.2, z: SPINE_S + INT_FACE + DISPLAY_OFFSET }, - rotation: NORTH, - elevation: 1.15, - }, - ...scatter("l2-tiburon-light", TROFFER, grid(2.8, 14.4, 2, 2, 2.8, 2.0), { elevation: CEILING }), - - { id: "l2-angel-table", kind: MEETING_TABLE, position: { x: 11.6, z: 15.2 }, rotation: NORTH }, - ...scatter("l2-angel-chair", TASK_CHAIR, [ - { x: 10.4, z: 14.4 }, - { x: 10.4, z: 15.8 }, - { x: 12.8, z: 14.4 }, - { x: 12.8, z: 15.8 }, - { x: 11.6, z: 13.8 }, - { x: 11.6, z: 16.6 }, - ]), - { - id: "l2-angel-board", - kind: WHITEBOARD, - position: { x: 11.6, z: SPINE_S + INT_FACE + BOARD_OFFSET }, - rotation: NORTH, - elevation: 1.0, - }, - ...scatter("l2-angel-light", TROFFER, grid(10.4, 14.6, 2, 2, 2.4, 2.0), { elevation: CEILING }), - - // -- The project room ----------------------------------------------------- - ...scatter("l2-project-board", WHITEBOARD, grid(17.4, SPINE_S + INT_FACE + BOARD_OFFSET, 3, 1, 3.0, 0), { - rotation: NORTH, - elevation: 1.0, - }), - { id: "l2-project-table", kind: MEETING_TABLE, position: { x: 19.8, z: 16.4 }, rotation: NORTH }, - ...scatter("l2-project-shelf", SHELF, grid(L2_PROJECT_E - INT_FACE - 0.2, 14.0, 1, 2, 0, 1.1), { - rotation: EAST, - }), - ...scatter("l2-project-light", TROFFER, grid(17.0, 14.4, 3, 2, 3.2, 2.4), { elevation: CEILING }), - - // -- Plant ---------------------------------------------------------------- - ...scatter("l2-plant-shelf", SHELF, grid(BLOCK_E - EXT_FACE - 0.2, 14.0, 1, 3, 0, 1.1), { - rotation: EAST, - audience: "private", - }), - ...scatter("l2-plant-light", TROFFER, grid(26.4, 14.8, 2, 1, 2.4, 0), { elevation: 2.6 }), - - // -- The gallery ---------------------------------------------------------- - // - // Furnished as a place to sit rather than as a route, which is what a balcony - // over a double-height room is for. Everything is set back from the - // balustrade: a chair against a glass rail is a chair nobody chooses. - ...scatter("l2-gallery-chair", LOUNGE_CHAIR, [ - { x: 36.0, z: 5.0 }, - { x: 36.0, z: 6.6 }, - { x: 36.0, z: 12.0 }, - ], { rotation: EAST }), - ...scatter("l2-gallery-side", SIDE_TABLE, [ - { x: 36.0, z: 5.8 }, - { x: 36.0, z: 12.8 }, - ]), - ...scatter("l2-gallery-plant", PLANT, [ - { x: 35.2, z: 3.0 }, - { x: 35.2, z: 15.0 }, - ]), -]; - -const L2_ZONES: Zone[] = [ - // Spatial, so both audiences get them — the same rule the level below follows. - { - id: "l2-zone-studio", - name: "Studio", - outline: rect(L2_STUDIO_W, 0, L2_LIBRARY_W, SPINE_N), - colorKey: "team-b", - audience: "private", - }, - { - id: "l2-zone-quiet", - name: "Quiet", - outline: rect(L2_LIBRARY_W, 0, BLOCK_E, SPINE_N), - colorKey: "focus", - }, - { - id: "l2-zone-gallery", - name: "Gallery", - outline: rect(BLOCK_E, 2.0, GALLERY_E, 16.0), - colorKey: "social", - }, -]; - -// ---- Viewpoints ----------------------------------------------------------- - -/** - * Five poses, and the first one is where you arrive. - * - * `viewpoints[0]` is the arrival pose. `interiors/types.ts` suggests putting - * reception there, and this pack does not: arriving inside a room before you - * have seen the shape of the building is disorienting in a way that a plan view - * is not, so the establishing shot goes first and reception is second. That is a - * choice a pack gets to make, which is the point of the field being an order - * rather than an id. - * - * `distance` is metres from the target to the camera and `height` is metres - * above this level's floor. An office is not a city: 30 m is the whole building - * and 6 m is standing on a mezzanine, where the equivalent city numbers are in - * the hundreds. - */ const VIEWPOINTS: Viewpoint[] = [ { - id: "floor", + id: "arrival", number: "01", - label: "The Whole Floor", - shortLabel: "The Floor", + label: "The Front Door", + shortLabel: "Front Door", levelId: "level-1", - // Aimed at the middle of the *whole plate* rather than of the block, and - // pulled back far enough to hold 48 m of it. Both numbers moved when the - // wing did: an establishing shot that still framed 34 m put the commons - // half off the right-hand edge, which is a poor introduction to the largest - // room in the building. - // Higher and further back than it was, because the building got taller - // rather than wider: at a 14 m interstitial the roof is 19.6 m up, and a - // camera at 19 m was level with it and looking at the parapet. - focus: { at: { x: 24.0, z: 9.0 }, distance: 58, height: 34, rotation: 0.55 }, + focus: { at: { x: 1.02, z: 7.68 }, distance: 11.8, height: 6.2, rotation: 2.35 }, description: - "Forty-eight metres by eighteen, two storeys a long way apart, glazed along the north edge and open to the roof at the east end. Everything in this building is somewhere in this frame.", - }, - { - id: "reception", - number: "02", - label: "Reception", - shortLabel: "Reception", - levelId: "level-1", - focus: { at: { x: 3.8, z: 5.4 }, distance: 8.5, height: 2.0, rotation: 1.9 }, - description: - "Four metres in from the entrance, looking back at the door. The desk faces the way you came in, which is the only thing a reception desk has to do.", - }, - { - id: "desks", - number: "03", - label: "The Desk Floor", - shortLabel: "Desks", - levelId: "level-1", - focus: { at: { x: 16.4, z: 4.0 }, distance: 14, height: 4.4, rotation: 0.35 }, - description: - "Thirty-six seats in four benches, two rows each, facing each other across a shared run of desktop. The window bench is eng-01 through eng-12.", - }, - { - id: "alcatraz", - number: "04", - label: "Alcatraz", - shortLabel: "Alcatraz", - levelId: "level-1", - focus: { at: { x: 3.6, z: 15.2 }, distance: 6.5, height: 2.3, rotation: 5.6 }, - description: - "The board room: ten seats, a vision panel onto the corridor and a window onto whatever is south of here. The largest of the three meeting rooms.", - }, - { - id: "kitchen", - number: "05", - label: "The Kitchen", - shortLabel: "Kitchen", - levelId: "level-1", - focus: { at: { x: 29.8, z: 7.6 }, distance: 8.0, height: 2.6, rotation: 2.6 }, - description: - "The social corner, where the lounge and the kitchen share a glazed edge and the only wall between them and the desk floor is the one with the archway in it.", - }, - { - id: "commons", - number: "06", - label: "The Commons", - shortLabel: "Commons", - levelId: "level-1", - // Low and looking up the length of the wing, because the thing worth seeing - // here is the height. An establishing shot from above would show a big empty - // floor and none of what makes it big. - focus: { at: { x: 41.0, z: 9.0 }, distance: 15, height: 3.2, rotation: 2.1 }, - description: - "Fourteen metres of floor open all the way to the roof, with the gallery cutting across one side of it. The lounge and the kitchen look into this through the glass that used to be the outside wall.", + "The way in: a clear threshold with the two-place agent bench ahead, the demo lounge across the room, and the domestic half visible beyond it.", }, { id: "studio", - number: "07", - label: "The Studio", + number: "02", + label: "The Whole Studio", shortLabel: "Studio", - levelId: "level-2", - focus: { at: { x: 15.6, z: 4.6 }, distance: 15, height: 4.6, rotation: 0.35 }, + levelId: "level-1", + focus: { at: { x: 6.0, z: 4.5 }, distance: 16.5, height: 9.2, rotation: 0.65 }, description: - "Level 2: nineteen metres of unbroken north-lit floor and twenty-four seats in two benches. The one run downstairs cannot offer, because the lobby wall and the booths interrupt it.", + "Twelve metres by nine: one compact live/work headquarters, not a speculative corporate floor. Work, demos, cooking and sleep all fit in the same honest frame.", }, { - id: "gallery", - number: "08", - label: "The Gallery", - shortLabel: "Gallery", - levelId: "level-2", - // Standing at the rail rather than behind it, which is the pose that shows - // there is something underneath. - focus: { at: { x: 37.0, z: 9.0 }, distance: 9, height: 1.8, rotation: 1.4 }, + id: "agent-bench", + number: "03", + label: "The Agent Bench", + shortLabel: "Agent Bench", + levelId: "level-1", + focus: { at: { x: 3.05, z: 4.7 }, distance: 6.4, height: 2.5, rotation: 0.25 }, description: - "The balcony over the commons, four metres of it, with a glass balustrade and nothing below for another seventeen. The reason the void is a volume rather than a tall room.", + "Two workstations, two media-capable monitors and a clear aisle behind the chairs: enough space to build agents without pretending a hundred people sit here.", + }, + { + id: "demo-lounge", + number: "04", + label: "The Demo Lounge", + shortLabel: "Demo Lounge", + levelId: "level-1", + focus: { at: { x: 8.2, z: 6.2 }, distance: 7.2, height: 2.65, rotation: 2.9 }, + description: + "A real sofa facing the shared demo wall. This is where a screen session becomes something people can sit together and inspect.", + }, + { + id: "kitchen-home", + number: "05", + label: "The Kitchen and Alcove", + shortLabel: "Home", + levelId: "level-1", + focus: { at: { x: 4.4, z: 1.75 }, distance: 7.5, height: 3.0, rotation: 3.5 }, + description: + "A complete kitchen wall and island beside the sleeping alcove. The headquarters is also a home, and the environment says so without euphemism.", }, ]; -// ---- The pack ------------------------------------------------------------- - -const LEVEL_1: Level = { +const LEVEL: Level = { id: "level-1", - name: "Level 1", + name: "Studio", elevation: 0, wallHeight: CEILING, - wallThickness: INT_THICKNESS, + wallThickness: INT, wallSurface: PAINT, floorplan: { rooms: ROOMS, @@ -2128,63 +341,21 @@ const LEVEL_1: Level = { }, }; -/** - * The upper storey, at `STOREY` above the ground floor. - * - * `elevation` is floor-to-*floor* and not floor-to-ceiling, which is the number - * people get wrong: 2.8 m would put level 2's slab in level 1's ceiling void. - * `Plan` adds this to every coordinate on the level exactly once, so the - * `L2_*` section above is authored with its own floor at zero and neither storey - * has to know the other's height. - */ -const LEVEL_2: Level = { - id: "level-2", - name: "Level 2", - elevation: STOREY, - wallHeight: CEILING, - wallThickness: INT_THICKNESS, - wallSurface: PAINT, - floorplan: { - rooms: L2_ROOMS, - walls: L2_WALLS, - props: L2_PROPS, - deskBanks: L2_DESK_BANKS, - seats: L2_SEATS, - zones: L2_ZONES, - }, -}; - export const LUMBRIDGE_HQ: Office = { id: "lumbridge-hq", - name: "Lumbridge HQ", - levels: [LEVEL_1, LEVEL_2], + name: "SF HQ · Studio", + levels: [LEVEL], viewpoints: VIEWPOINTS, - /** - * High in a downtown tower, on the same board the city view draws. - * - * The coordinate is a block in the Transbay cluster, typed by hand from the - * street grid — not geocoded, for the reason CONTRACT.md §8 gives at length. - * `elevation` is what makes it a *tower*: 188 m is around the forty-eighth - * floor, so the horizon sits a long way down and the fog outside the glass is - * the fog you look across a city at rather than through. - * - * `heading: 205` turns the glazed façade — `ext-north`, the wall this pack has - * always called the daylight side — to face south-south-west. That is the one - * number that decides whether the sun ever actually enters the building, and - * pointing the glass at the afternoon is the whole reason the field exists. - */ + // Coordinates remain the existing hand-authored approximation. No street + // address is claimed or inferred by this environment rewrite. site: LUMBRIDGE_HQ_SITE, meta: { description: - "The reference office: two levels around a double-height commons, twenty-six rooms, a hundred and twenty-six seats. Copy this file, change the numbers, keep the seat ids.", + "A compact 12 × 9 metre San Francisco live/work studio: two agent workstations, a demo lounge, kitchen, sleeping alcove and bath/storage.", author: "Lumbridge", - // 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 is artistic output, so - // this one says CC0 and means it: take the plan, take the seat ids, take the - // whole building, and owe nobody anything. license: "CC0-1.0", - version: "1.0.0", - updated: "2026-08-04", + version: "2.0.0", + updated: "2026-08-19", }, }; diff --git a/src/offices/mateo-court.ts b/src/offices/mateo-court.ts index 15fc53c..ef53fe1 100644 --- a/src/offices/mateo-court.ts +++ b/src/offices/mateo-court.ts @@ -241,8 +241,11 @@ const PEDESTAL: AssetId = "tera:desk.pedestal"; const PARTITION: AssetId = "tera:desk.partition"; const TASK_CHAIR: AssetId = "tera:seat.task-chair"; const LOUNGE_CHAIR: AssetId = "tera:seat.lounge"; +const STOOL: AssetId = "tera:seat.stool"; const MEETING_TABLE: AssetId = "tera:table.meeting"; const SIDE_TABLE: AssetId = "tera:table.side"; +const KITCHEN: AssetId = "tera:kitchen.run"; +const ISLAND: AssetId = "tera:kitchen.island"; const SHELF: AssetId = "tera:storage.shelf"; const LOCKER: AssetId = "tera:storage.locker"; const MONITOR: AssetId = "tera:screen.monitor"; @@ -392,7 +395,7 @@ const ROOMS: Room[] = [ // -- The street range, west to east --------------------------------------- { id: "press", - name: "The Press", + name: "Media Studio", // The messy end, on the corner, with a concrete floor and its own door out // of the front office wide enough to carry a sheet of ply through. outline: rect(0, 0, PRESS_E, COURT_N), @@ -401,7 +404,7 @@ const ROOMS: Room[] = [ }, { id: "lobby", - name: "The Front", + name: "Front Door", // Reception, and the only room in the building with a door on the paseo and // a door on the yard. Somebody arriving is met here and then walked out // into the middle of the block, which is the introduction the plan wants to @@ -412,7 +415,7 @@ const ROOMS: Room[] = [ }, { id: "paseo", - name: "The Paseo", + name: "Entry Passage", // A passage cut straight through the street range: three metres wide and // eight and a half from the pavement to the yard, counting the brick at the // street end. It is the only way into this building that is not a goods @@ -424,14 +427,14 @@ const ROOMS: Room[] = [ }, { id: "mess", - name: "The Mess", + name: "Kitchen · Commons", outline: rect(PASEO_E, 0, MESS_E, COURT_N), floor: TERRAZZO, ceiling: null, }, { id: "hewitt", - name: "Hewitt", + name: "Review Room", // The big meeting room, in the corner where two walls have no openings at // all — the party wall east and the range wall south. The meeting rooms in // this pack take their names from the streets of the district, the way the @@ -444,14 +447,14 @@ const ROOMS: Room[] = [ // -- The west range ------------------------------------------------------- { id: "willow", - name: "Willow", + name: "Project Room", outline: rect(0, COURT_N, COURT_W, WILLOW_S), floor: CARPET_ACCENT, ceiling: null, }, { id: "violet", - name: "Violet", + name: "Quiet Room", // The quiet room. No table you can put a laptop on, on purpose. outline: rect(0, WILLOW_S, COURT_W, COURT_S), floor: CARPET_ACCENT, @@ -461,7 +464,7 @@ const ROOMS: Room[] = [ // -- The east range ------------------------------------------------------- { id: "shop", - name: "The Shop", + name: "Robotics Lab", // Fabrication, with a four-metre roller shutter onto the yard. That shutter // is the reason the yard is paved rather than planted: work comes out of // this room and is finished in the middle of the building, which is a thing @@ -474,7 +477,7 @@ const ROOMS: Room[] = [ // -- The south range ------------------------------------------------------ { id: "works", - name: "The Works", + name: "Agent Floor", // Thirty-one metres of desk floor along the back of the block, glazed onto // the yard on one side and onto the alley on the other. The longest room // here, and the only one that is more than one thing at once. @@ -484,7 +487,7 @@ const ROOMS: Room[] = [ }, { id: "store", - name: "Facilities", + name: "Equipment Storage", outline: rect(STORE_W, COURT_S, WIDTH, DEPTH), floor: CONCRETE, ceiling: { height: 3.0, surface: CEILING_TILE }, @@ -504,7 +507,7 @@ const ROOMS: Room[] = [ */ { id: "court", - name: "The Yard", + name: "Courtyard Yard", outline: [ { x: COURT_W, z: STAIR_S }, { x: COURT_W, z: COURT_S }, @@ -755,7 +758,7 @@ const WALLS: Wall[] = [ // ---- Desk banks ----------------------------------------------------------- /** - * Twenty-eight desks in two declarations, both in the works. + * Twelve desks in one declaration on the agent floor. * * `Plan` expands each into a desk prop, a chair prop and a seat per station, * with ids that are contractual: `works-a-01` … `works-a-14` counting along each @@ -763,9 +766,9 @@ const WALLS: Wall[] = [ * furniture. A `Presence` binds to `works-a-04` and has no way to notice that * the bench moved 200 mm, which is the property the whole scheme exists for. * - * A second bench is a second bank, for the reason both other packs record: one - * `rowPitch` cannot be both the 0.85 m that makes two rows meet back to back and - * the 1.95 m of chair, aisle and chair between one bench and the next. + * The rest of the long south range stays open for robots, rolling prototypes + * and review. Capacity is a plan decision here, not every square metre filled + * with a generated workstation. */ const DESK_BANKS: DeskBank[] = [ { @@ -774,35 +777,20 @@ const DESK_BANKS: DeskBank[] = [ chair: TASK_CHAIR, origin: { x: 3.4, z: 19.2 }, rotation: NORTH, - columns: 7, + columns: 6, rows: 2, pitch: 1.7, // 1.6 m desks with a 100 mm gap rowPitch: 0.85, facingRows: true, seatOffset: 0.6, }, - { - id: "works-b", - desk: DESK, - chair: TASK_CHAIR, - origin: { x: 3.4, z: 23.2 }, - rotation: NORTH, - columns: 7, - rows: 2, - pitch: 1.7, - rowPitch: 0.85, - facingRows: true, - seatOffset: 0.6, - }, ]; // ---- Seats ---------------------------------------------------------------- /** - * The forty-one seats on this level that a `DeskBank` cannot generate: chairs - * round tables, two people waiting in reception, four standing at a pair of - * worktables — and eight at a table in the open air, which is the entry the - * other two packs have nothing like. + * Nine deliberate ground-level addresses outside the generated agent bench. + * They describe useful activity points, not a fabricated headcount. * * A seat is an address and not a chair. The prefixes here (`front`, `mess`, * `hewitt`, `willow`, `violet`, `shop`, `press`, `yard`) are disjoint from both @@ -811,70 +799,22 @@ const DESK_BANKS: DeskBank[] = [ * the hard way, and staying disjoint costs nothing. */ const SEATS: Seat[] = [ - // Reception, and the pair waiting for somebody to come and get them. + // One host position inside the front door. { id: "front-01", position: { x: 13.6, z: 5.0 }, facing: EAST, pose: "sit" }, - { id: "front-02", position: { x: 11.6, z: 6.6 }, facing: EAST, pose: "sit" }, - { id: "front-03", position: { x: 13.2, z: 6.6 }, facing: WEST, pose: "sit" }, - // The press. Four standing at two worktables, because nobody sits down to cut - // a mount board — `pose` is the only thing that tells a consumer how tall to - // draw an occupant. - { id: "press-01", position: { x: 3.8, z: 4.4 }, facing: NORTH, pose: "stand" }, - { id: "press-02", position: { x: 5.4, z: 4.4 }, facing: NORTH, pose: "stand" }, - { id: "press-03", position: { x: 3.8, z: 5.4 }, facing: SOUTH, pose: "stand" }, - { id: "press-04", position: { x: 5.4, z: 5.4 }, facing: SOUTH, pose: "stand" }, + // Two standing capture/review marks in the media studio. + { id: "media-01", position: { x: 3.8, z: 4.4 }, facing: NORTH, pose: "stand" }, + { id: "media-02", position: { x: 5.4, z: 4.4 }, facing: NORTH, pose: "stand" }, - // The mess: three perched at the island, two at the table by the glass. - { id: "mess-01", position: { x: 22.2, z: 4.2 }, facing: NORTH, pose: "stand" }, - { id: "mess-02", position: { x: 23.0, z: 4.2 }, facing: NORTH, pose: "stand" }, - { id: "mess-03", position: { x: 23.8, z: 4.2 }, facing: NORTH, pose: "stand" }, - { id: "mess-04", position: { x: 20.2, z: 6.6 }, facing: EAST, pose: "sit" }, - { id: "mess-05", position: { x: 23.0, z: 6.6 }, facing: WEST, pose: "sit" }, + // Two stools at the kitchen island. + { id: "commons-01", position: { x: 22.6, z: 4.2 }, facing: NORTH, pose: "sit" }, + { id: "commons-02", position: { x: 23.5, z: 4.2 }, facing: NORTH, pose: "sit" }, - // Hewitt — ten round a 4.8 m table, five a side, both ends left clear so the - // people at them can see the display on the party wall. - { id: "hewitt-01", position: { x: 29.4, z: 3.2 }, facing: SOUTH, pose: "sit" }, - { id: "hewitt-02", position: { x: 30.4, z: 3.2 }, facing: SOUTH, pose: "sit" }, - { id: "hewitt-03", position: { x: 31.4, z: 3.2 }, facing: SOUTH, pose: "sit" }, - { id: "hewitt-04", position: { x: 32.4, z: 3.2 }, facing: SOUTH, pose: "sit" }, - { id: "hewitt-05", position: { x: 33.4, z: 3.2 }, facing: SOUTH, pose: "sit" }, - { id: "hewitt-06", position: { x: 29.4, z: 5.2 }, facing: NORTH, pose: "sit" }, - { id: "hewitt-07", position: { x: 30.4, z: 5.2 }, facing: NORTH, pose: "sit" }, - { id: "hewitt-08", position: { x: 31.4, z: 5.2 }, facing: NORTH, pose: "sit" }, - { id: "hewitt-09", position: { x: 32.4, z: 5.2 }, facing: NORTH, pose: "sit" }, - { id: "hewitt-10", position: { x: 33.4, z: 5.2 }, facing: NORTH, pose: "sit" }, - - // Willow, six. - { id: "willow-01", position: { x: 3.4, z: 9.7 }, facing: SOUTH, pose: "sit" }, - { id: "willow-02", position: { x: 4.4, z: 9.7 }, facing: SOUTH, pose: "sit" }, - { id: "willow-03", position: { x: 5.4, z: 9.7 }, facing: SOUTH, pose: "sit" }, - { id: "willow-04", position: { x: 3.4, z: 11.7 }, facing: NORTH, pose: "sit" }, - { id: "willow-05", position: { x: 4.4, z: 11.7 }, facing: NORTH, pose: "sit" }, - { id: "willow-06", position: { x: 5.4, z: 11.7 }, facing: NORTH, pose: "sit" }, - - // Violet, the quiet room: two chairs and nothing else. - { id: "violet-01", position: { x: 4.4, z: 14.4 }, facing: SOUTH, pose: "sit" }, - { id: "violet-02", position: { x: 4.4, z: 16.2 }, facing: NORTH, pose: "sit" }, - - // The shop, standing at the benches. - { id: "shop-01", position: { x: 30.2, z: 11.4 }, facing: NORTH, pose: "stand" }, - { id: "shop-02", position: { x: 31.8, z: 11.4 }, facing: NORTH, pose: "stand" }, - { id: "shop-03", position: { x: 31.0, z: 14.0 }, facing: NORTH, pose: "stand" }, - - /** - * The yard table. Eight seats outdoors, which is the pack's whole argument in - * one declaration: a seat is a spot on the floor with a name, the format never - * asked whether the floor had a roof over it, and a building in this climate - * puts a third of its meeting capacity in the middle of the block. - */ - { id: "yard-01", position: { x: 17.2, z: 12.4 }, facing: SOUTH, pose: "sit" }, - { id: "yard-02", position: { x: 18.4, z: 12.4 }, facing: SOUTH, pose: "sit" }, - { id: "yard-03", position: { x: 19.6, z: 12.4 }, facing: SOUTH, pose: "sit" }, - { id: "yard-04", position: { x: 20.8, z: 12.4 }, facing: SOUTH, pose: "sit" }, - { id: "yard-05", position: { x: 17.2, z: 14.4 }, facing: NORTH, pose: "sit" }, - { id: "yard-06", position: { x: 18.4, z: 14.4 }, facing: NORTH, pose: "sit" }, - { id: "yard-07", position: { x: 19.6, z: 14.4 }, facing: NORTH, pose: "sit" }, - { id: "yard-08", position: { x: 20.8, z: 14.4 }, facing: NORTH, pose: "sit" }, + // Four places in the review room, all with a clean line to the stable screen. + { id: "review-01", position: { x: 29.8, z: 3.2 }, facing: SOUTH, pose: "sit" }, + { id: "review-02", position: { x: 32.6, z: 3.2 }, facing: SOUTH, pose: "sit" }, + { id: "review-03", position: { x: 29.8, z: 5.2 }, facing: NORTH, pose: "sit" }, + { id: "review-04", position: { x: 32.6, z: 5.2 }, facing: NORTH, pose: "sit" }, ]; // ---- Props ---------------------------------------------------------------- @@ -953,10 +893,9 @@ const PROPS: Prop[] = [ ...scatter("press-light", TROFFER, grid(1.6, 1.6, 3, 3, 3.2, 2.6), { elevation: GROUND }), // -- The Mess ------------------------------------------------------------- - // A meeting table doing duty as an island, exactly as the reference pack's - // kitchen does. There is no `tera:kitchen.island` and there should not be; the - // honest way to get one is to register it in your own namespace. - { id: "mess-island", kind: MEETING_TABLE, position: { x: 23.0, z: 3.0 }, rotation: NORTH }, + // A complete but restrained commons: one authored run, one island, two stools. + { id: "mess-kitchen-run", kind: KITCHEN, position: { x: 25.7, z: 1.15 }, rotation: NORTH }, + { id: "mess-island", kind: ISLAND, position: { x: 23.0, z: 3.0 }, rotation: NORTH }, { id: "mess-island-plant", kind: PLANT, @@ -964,13 +903,7 @@ const PROPS: Prop[] = [ rotation: NORTH, elevation: 0.74, }, - ...scatter("mess-stool", TASK_CHAIR, grid(22.2, 4.2, 3, 1, 0.8, 0), { rotation: NORTH }), - // The counters run down the wall the building's only internal door on this - // range is in, not under the street windows: a 1.8 m locker in front of a - // window with a 1 m sill is a wall with a window nobody can see out of. - ...scatter("mess-counter", LOCKER, grid(MESS_E - INT_FACE - 0.25, 1.4, 1, 3, 0, 1.3), { - rotation: EAST, - }), + ...scatter("mess-stool", STOOL, grid(22.6, 4.2, 2, 1, 0.9, 0), { rotation: NORTH }), { id: "mess-shelf", kind: SHELF, @@ -985,10 +918,9 @@ const PROPS: Prop[] = [ ...scatter("mess-light", TROFFER, grid(20.6, 2.0, 3, 2, 2.6, 3.4), { elevation: GROUND }), // -- Hewitt --------------------------------------------------------------- - { id: "hewitt-table-w", kind: MEETING_TABLE, position: { x: 30.4, z: 4.2 }, rotation: NORTH }, - { id: "hewitt-table-e", kind: MEETING_TABLE, position: { x: 32.8, z: 4.2 }, rotation: NORTH }, - ...scatter("hewitt-chair-n", TASK_CHAIR, grid(29.4, 3.2, 5, 1, 1.0, 0), { rotation: SOUTH }), - ...scatter("hewitt-chair-s", TASK_CHAIR, grid(29.4, 5.2, 5, 1, 1.0, 0), { rotation: NORTH }), + { id: "hewitt-table", kind: MEETING_TABLE, position: { x: 31.2, z: 4.2 }, rotation: NORTH }, + ...scatter("hewitt-chair-n", TASK_CHAIR, grid(29.8, 3.2, 2, 1, 2.8, 0), { rotation: SOUTH }), + ...scatter("hewitt-chair-s", TASK_CHAIR, grid(29.8, 5.2, 2, 1, 2.8, 0), { rotation: NORTH }), { id: "hewitt-display", kind: DISPLAY, @@ -1009,8 +941,8 @@ const PROPS: Prop[] = [ // -- Willow --------------------------------------------------------------- { id: "willow-table", kind: MEETING_TABLE, position: { x: 4.4, z: 10.7 }, rotation: NORTH }, - ...scatter("willow-chair-n", TASK_CHAIR, grid(3.4, 9.7, 3, 1, 1.0, 0), { rotation: SOUTH }), - ...scatter("willow-chair-s", TASK_CHAIR, grid(3.4, 11.7, 3, 1, 1.0, 0), { rotation: NORTH }), + ...scatter("willow-chair-n", TASK_CHAIR, grid(3.4, 9.7, 2, 1, 2.0, 0), { rotation: SOUTH }), + ...scatter("willow-chair-s", TASK_CHAIR, grid(3.4, 11.7, 2, 1, 2.0, 0), { rotation: NORTH }), { id: "willow-display", kind: DISPLAY, @@ -1063,15 +995,12 @@ const PROPS: Prop[] = [ // -- The Works ------------------------------------------------------------ // A screen down the spine of each bench: 1.4 m against a 1.7 m pitch leaves a // gap you can talk through, which is the entire argument for a 0.45 m screen. - ...scatter("works-a-screen", PARTITION, grid(3.4, 19.625, 7, 1, 1.7, 0)), - ...scatter("works-b-screen", PARTITION, grid(3.4, 23.625, 7, 1, 1.7, 0)), + ...scatter("works-a-screen", PARTITION, grid(3.4, 19.625, 6, 1, 1.7, 0)), // Pedestals park at the ends of the benches rather than under the desks, which // is where they end up in a building where people move seats. ...scatter("works-pedestal", PEDESTAL, [ { x: 2.0, z: 19.6 }, - { x: 2.0, z: 23.6 }, - { x: 14.8, z: 19.6 }, - { x: 14.8, z: 23.6 }, + { x: 13.2, z: 19.6 }, ]), ...scatter("works-locker", LOCKER, grid(1.4, COURT_S + INT_FACE + 0.25, 3, 1, 1.3, 0)), { @@ -1127,10 +1056,9 @@ const PROPS: Prop[] = [ * light this space in the evening are on the loggia above, hung from a soffit * that exists. */ - { id: "yard-table-w", kind: MEETING_TABLE, position: { x: 18.0, z: 13.4 }, rotation: NORTH }, - { id: "yard-table-e", kind: MEETING_TABLE, position: { x: 20.4, z: 13.4 }, rotation: NORTH }, - ...scatter("yard-chair-n", TASK_CHAIR, grid(17.2, 12.4, 4, 1, 1.2, 0), { rotation: SOUTH }), - ...scatter("yard-chair-s", TASK_CHAIR, grid(17.2, 14.4, 4, 1, 1.2, 0), { rotation: NORTH }), + { id: "yard-table", kind: MEETING_TABLE, position: { x: 19.2, z: 13.4 }, rotation: NORTH }, + ...scatter("yard-chair-n", TASK_CHAIR, grid(18.0, 12.4, 2, 1, 2.4, 0), { rotation: SOUTH }), + ...scatter("yard-chair-s", TASK_CHAIR, grid(18.0, 14.4, 2, 1, 2.4, 0), { rotation: NORTH }), ...scatter("yard-tree", TREE, [ { x: 11.4, z: 12.6 }, { x: 11.4, z: 16.2 }, @@ -1158,19 +1086,18 @@ const PROPS: Prop[] = [ * twice. */ const ZONES: Zone[] = [ - { id: "zone-yard", name: "The Yard", outline: rect(COURT_W, COURT_N, COURT_E, COURT_S), colorKey: "social" }, + { id: "zone-yard", name: "Courtyard Yard", outline: rect(COURT_W, COURT_N, COURT_E, COURT_S), colorKey: "social" }, { id: "zone-fabrication", - name: "Fabrication", + name: "Robotics Lab", outline: rect(COURT_E, COURT_N, WIDTH, COURT_S), colorKey: "focus", }, { id: "zone-crew", - name: "Crew", + name: "Agent Floor", outline: rect(2.0, 18.4, 15.6, 24.8), colorKey: "team-a", - audience: "private", }, ]; @@ -1195,7 +1122,7 @@ const ZONES: Zone[] = [ const L2_ROOMS: Room[] = [ { id: "loft", - name: "The Loft", + name: "Model Loft", // Twenty-six metres of north-lit floor over the street range, with the // paseo tunnelling underneath it. The one room in this building that could // not exist on the ground floor, because on the ground floor the way in @@ -1206,14 +1133,14 @@ const L2_ROOMS: Room[] = [ }, { id: "palmetto", - name: "Palmetto", + name: "Project Review", outline: rect(0, LOGGIA_N, LOGGIA_W, JESSE_N), floor: CARPET_ACCENT, ceiling: null, }, { id: "jesse", - name: "Jesse", + name: "Materials Room", // The project room: concrete, three boards along the end wall, and nowhere // comfortable to sit down. Its equivalent downstairs is the press, and both // rooms read the same way for the same reason. @@ -1237,7 +1164,7 @@ const L2_ROOMS: Room[] = [ */ { id: "loggia", - name: "The Loggia", + name: "Model Walk", outline: [ { x: LOGGIA_W, z: LOGGIA_N }, { x: LOGGIA_W, z: COURT_S }, @@ -1367,19 +1294,17 @@ const L2_WALLS: Wall[] = [ ]; /** - * Twelve desks upstairs, in one bank. Seat ids are `loft-a-01` … `loft-a-12`, - * restarting at 01 as every bank's do and unique building-wide because the - * prefix is. + * Two workstations upstairs for model and environment review. */ const L2_DESK_BANKS: DeskBank[] = [ { id: "loft-a", desk: DESK, chair: TASK_CHAIR, - origin: { x: 8.4, z: 1.9 }, + origin: { x: 8.4, z: 2.3 }, rotation: NORTH, - columns: 6, - rows: 2, + columns: 2, + rows: 1, pitch: 1.7, rowPitch: 0.85, facingRows: true, @@ -1388,34 +1313,16 @@ const L2_DESK_BANKS: DeskBank[] = [ ]; const L2_SEATS: Seat[] = [ - // Palmetto, six round a 2.4 m table. - { id: "palmetto-01", position: { x: 2.6, z: 7.9 }, facing: SOUTH, pose: "sit" }, - { id: "palmetto-02", position: { x: 3.6, z: 7.9 }, facing: SOUTH, pose: "sit" }, - { id: "palmetto-03", position: { x: 4.6, z: 7.9 }, facing: SOUTH, pose: "sit" }, - { id: "palmetto-04", position: { x: 2.6, z: 9.9 }, facing: NORTH, pose: "sit" }, - { id: "palmetto-05", position: { x: 3.6, z: 9.9 }, facing: NORTH, pose: "sit" }, - { id: "palmetto-06", position: { x: 4.6, z: 9.9 }, facing: NORTH, pose: "sit" }, - - // Jesse. Standing at the boards, like the reference pack's project room. - { id: "jesse-01", position: { x: 1.4, z: 16.4 }, facing: SOUTH, pose: "stand" }, - { id: "jesse-02", position: { x: 3.4, z: 16.4 }, facing: SOUTH, pose: "stand" }, - { id: "jesse-03", position: { x: 5.4, z: 16.4 }, facing: SOUTH, pose: "stand" }, - - // The loggia, looking down into the yard. Set back from the rail, because a - // chair against a balustrade is a chair nobody chooses. + // One optional review perch, set back from the open edge. { id: "loggia-01", position: { x: 8.2, z: 12.0 }, facing: EAST, pose: "sit" }, - { id: "loggia-02", position: { x: 8.2, z: 13.6 }, facing: EAST, pose: "sit" }, - { id: "loggia-03", position: { x: 12.0, z: 6.9 }, facing: SOUTH, pose: "sit" }, ]; const L2_PROPS: Prop[] = [ // -- The Loft ------------------------------------------------------------- - ...scatter("loft-screen", PARTITION, grid(8.4, 2.325, 6, 1, 1.7, 0)), + ...scatter("loft-screen", PARTITION, grid(8.4, 2.325, 2, 1, 1.7, 0)), ...scatter("loft-pedestal", PEDESTAL, [ { x: 7.0, z: 1.4 }, { x: 7.0, z: 3.2 }, - { x: 18.4, z: 1.4 }, - { x: 18.4, z: 3.2 }, ]), ...scatter("loft-locker", LOCKER, grid(LOFT_E - EXT_FACE - 0.25, 1.4, 1, 3, 0, 1.3), { rotation: EAST, @@ -1442,8 +1349,8 @@ const L2_PROPS: Prop[] = [ // -- Palmetto ------------------------------------------------------------- { id: "palmetto-table", kind: MEETING_TABLE, position: { x: 3.6, z: 8.9 }, rotation: NORTH }, - ...scatter("palmetto-chair-n", TASK_CHAIR, grid(2.6, 7.9, 3, 1, 1.0, 0), { rotation: SOUTH }), - ...scatter("palmetto-chair-s", TASK_CHAIR, grid(2.6, 9.9, 3, 1, 1.0, 0), { rotation: NORTH }), + ...scatter("palmetto-chair-n", TASK_CHAIR, grid(2.6, 7.9, 2, 1, 2.0, 0), { rotation: SOUTH }), + ...scatter("palmetto-chair-s", TASK_CHAIR, grid(2.6, 9.9, 2, 1, 2.0, 0), { rotation: NORTH }), { id: "palmetto-display", kind: DISPLAY, @@ -1469,9 +1376,7 @@ const L2_PROPS: Prop[] = [ // courtyard for one to hang from. ...scatter("loggia-chair", LOUNGE_CHAIR, [ { x: 8.2, z: 12.0 }, - { x: 8.2, z: 13.6 }, ], { rotation: EAST }), - { id: "loggia-chair-n", kind: LOUNGE_CHAIR, position: { x: 12.0, z: 6.9 }, rotation: SOUTH }, { id: "loggia-side", kind: SIDE_TABLE, position: { x: 8.2, z: 12.8 }, rotation: NORTH }, ...scatter("loggia-plant", PLANT, [ { x: 8.9, z: 9.0 }, @@ -1486,7 +1391,7 @@ const L2_PROPS: Prop[] = [ const L2_ZONES: Zone[] = [ { id: "zone-loggia", - name: "The Loggia", + name: "Model Walk", outline: rect(LOGGIA_W, LOGGIA_N, COURT_W, COURT_S), colorKey: "social", }, @@ -1515,68 +1420,68 @@ const VIEWPOINTS: Viewpoint[] = [ { id: "paseo", number: "01", - label: "The Paseo", - shortLabel: "Paseo", + label: "The Front Door", + shortLabel: "Front Door", levelId: "level-1", focus: { at: { x: 18.1, z: 9.0 }, distance: 8, height: 1.7, rotation: Math.PI }, description: - "Standing under the street arch looking down the passage into the yard. Three metres wide and eight and a half from the pavement to the yard, and the only way into this building anybody arrives by — everything else here is on the other side of it.", + "Arrival under the street arch, looking down the entry passage into the courtyard. The route stays clear from the front door to every ground-floor workspace.", }, { id: "block", number: "02", - label: "The Whole Block", - shortLabel: "The Block", + label: "The LA Office", + shortLabel: "LA Office", levelId: "level-1", // Aimed at the middle of the yard rather than the middle of the plate, // because the middle of the plate is the thing that is not there. focus: { at: { x: 18.0, z: 13.0 }, distance: 44, height: 26, rotation: 0.5 }, description: - "Thirty-six metres by twenty-six with a hole in the middle of it. A ring of rooms never more than nine and a half metres deep on the long sides, two storeys on two sides and one on the other two, and the courtyard doing the job a corridor does in every other pack in this directory.", + "The complete LA HQ: a low brick ring around an open courtyard, with the agent floor, robotics lab, media studio, review room and commons visible as one working environment.", }, { id: "yard", number: "03", - label: "The Yard", - shortLabel: "Yard", + label: "Courtyard Review", + shortLabel: "Courtyard", levelId: "level-2", // From the loggia's west arm, looking east across the void at the far range. // On level 2, so `height` is measured from the walkway rather than the // pavers five metres below it. focus: { at: { x: 18.0, z: 12.0 }, distance: 9.6, height: 1.7, rotation: -Math.PI / 2 }, description: - "From the walkway, looking across the middle of the block. Sixteen metres of open air with a table in it, the shop's shutter on the far side, and nothing overhead — the one room in this office that is not a room.", + "From the model walk, across the open-air review yard toward the robotics lab. Four loose chairs keep the courtyard useful without turning circulation into an auditorium.", }, { id: "works", number: "04", - label: "The Works", - shortLabel: "Works", + label: "Agent Floor", + shortLabel: "Agent Floor", levelId: "level-1", focus: { at: { x: 8.6, z: 21.6 }, distance: 12, height: 4.0, rotation: 1.2 }, description: - "Twenty-eight seats in two benches along the back of the block, glazed onto the yard on one side and the alley on the other. The bench nearer the yard is works-a-01 through works-a-14.", + "Twelve agent workstations in one focused bench, leaving the rest of the long floor clear for rolling prototypes, activity robots and changing environment work.", }, { id: "shop", number: "05", - label: "The Shop", - shortLabel: "Shop", + label: "Robotics Lab", + shortLabel: "Robotics Lab", levelId: "level-1", // Standing in the yard looking east through the open shutter. focus: { at: { x: 31.0, z: 13.0 }, distance: 9, height: 2.6, rotation: 4.9 }, description: - "Four metres of roller shutter onto the courtyard, with the benches inside it and the party wall behind them. Work comes out of this room and gets finished in the middle of the building.", + "The robotics lab opens through a four-metre shutter onto the yard, keeping benches, storage and test circulation connected without inventing a separate facility.", }, { id: "loft", number: "06", - label: "The Loft", - shortLabel: "Loft", + label: "Model Loft", + shortLabel: "Model Loft", levelId: "level-2", focus: { at: { x: 12.0, z: 3.0 }, distance: 13, height: 4.0, rotation: 1.4 }, description: - "Twenty-six metres of unbroken floor over the street range, with the paseo tunnelling under the far end of it. Twelve seats, north light on one side and the loggia on the other.", + "Two model-review workstations on the north-lit upper floor, beside boards, display surfaces and the open model walk above the yard.", }, ]; @@ -1626,7 +1531,7 @@ const LEVEL_2: Level = { export const MATEO_COURT: Office = { id: "mateo-court", - name: "Mateo Court", + name: "LA HQ · Office", levels: [LEVEL_1, LEVEL_2], viewpoints: VIEWPOINTS, /** @@ -1639,11 +1544,11 @@ export const MATEO_COURT: Office = { site: MATEO_COURT_SITE, meta: { description: - "A courtyard block in the Arts District: a ring of rooms round a yard with the roof off, an outdoor stair and an open walkway over it. The third pack, and the one where the circulation is outside.", + "Lumbridge's LA HQ office: an authored courtyard workplace with a front door, twelve-seat agent floor, robotics lab, media studio, four-seat review room, kitchen commons, yard and two-seat model loft.", author: "Lumbridge", license: "CC0-1.0", - version: "1.0.0", - updated: "2026-08-07", + version: "2.0.0", + updated: "2026-08-19", }, }; diff --git a/src/offices/runtime.ts b/src/offices/runtime.ts new file mode 100644 index 0000000..16d85d3 --- /dev/null +++ b/src/offices/runtime.ts @@ -0,0 +1,52 @@ +/** + * Small, deterministic runtime facts for the shipped office environments. + * + * Activity robots are authored here rather than inferred from storey count. A + * floor is geometry, not a staffing instruction: the SF studio should have one + * useful machine, LA has two distinct work areas, and a building environment + * still under construction should not wake a synthetic workforce merely + * because it has levels. The extra job metadata is ignored by today's wander + * controller but is deliberately carried beside `RobotSpec`, ready for the + * robot-jobs runtime without changing stable robot ids. + */ + +import type { RobotSpec } from "../interiors/robots.ts"; +import type { Office } from "../interiors/types.ts"; +import type { ShippedOfficeId } from "./sites.ts"; + +export type OfficeRobotJobSet = "studio-support" | "office-operations" | "model-review"; + +export interface OfficeActivityRobotSpec extends RobotSpec { + id: string; + /** Stable per-robot seed for a future job scheduler or replay harness. */ + seed: number; + jobSet: OfficeRobotJobSet; +} + +const OFFICE_ACTIVITY_ROBOTS: Readonly> = { + "lumbridge-hq": [ + { id: "sf-studio-activity-01", levelId: "level-1", seed: 0x53464851, jobSet: "studio-support" }, + ], + "frontier-valley": [], + "mateo-court": [ + { id: "la-office-activity-01", levelId: "level-1", seed: 0x4c414851, jobSet: "office-operations" }, + { id: "la-office-activity-02", levelId: "level-2", seed: 0x4c414852, jobSet: "model-review" }, + ], +}; + +/** + * Resolve fresh specs and discard any whose authored level is absent. + * + * The level check keeps downloaded or locally modified packs safe, while fresh + * objects stop one consumer's scheduler state from mutating the next scene or + * determinism test. + */ +export function activityRobotsForOffice( + office: Pick, +): OfficeActivityRobotSpec[] { + const specs = OFFICE_ACTIVITY_ROBOTS[office.id as ShippedOfficeId] ?? []; + const levels = new Set(office.levels.map((level) => level.id)); + return specs + .filter((spec) => levels.has(spec.levelId)) + .map((spec) => ({ ...spec })); +} diff --git a/src/offices/sites.ts b/src/offices/sites.ts index ca7f6cd..d7de07e 100644 --- a/src/offices/sites.ts +++ b/src/offices/sites.ts @@ -100,8 +100,51 @@ export const MATEO_COURT_SITE: OfficeSite = { * 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 }, +export const SHIPPED_OFFICE_IDS = ["lumbridge-hq", "frontier-valley", "mateo-court"] as const; + +export type ShippedOfficeId = (typeof SHIPPED_OFFICE_IDS)[number]; +export type OfficeStatus = "active" | "building"; + +/** + * The public, eager metadata for one shipped environment. + * + * `status` is about the authored runtime, not a lease or a headcount. `active` + * means the environment is ready to enter; it never implies that people are in + * the building. `entryCopy` is intentionally operational and contains no + * invented street address or staffing claim. + */ +export interface OfficeDirectoryEntry { + id: ShippedOfficeId; + name: string; + status: OfficeStatus; + entryCopy: string; + site: OfficeSite; +} + +export const OFFICE_SITES: readonly OfficeDirectoryEntry[] = [ + { + id: "lumbridge-hq", + name: "SF HQ · Studio", + status: "active", + entryCopy: "Enter the compact live/work studio", + site: LUMBRIDGE_HQ_SITE, + }, + { + id: "frontier-valley", + name: "Frontier Valley", + status: "building", + entryCopy: "Preview the hangar environment in progress", + site: FRONTIER_VALLEY_SITE, + }, + { + id: "mateo-court", + name: "LA HQ · Office", + status: "active", + entryCopy: "Enter the courtyard office and robotics lab", + site: MATEO_COURT_SITE, + }, ]; + +export function officeDirectoryEntry(id: string): OfficeDirectoryEntry | undefined { + return OFFICE_SITES.find((entry) => entry.id === id); +} diff --git a/src/test/office.test.ts b/src/test/office.test.ts index b5b1ad1..dda7bbd 100644 --- a/src/test/office.test.ts +++ b/src/test/office.test.ts @@ -118,25 +118,21 @@ describe("the reference pack resolves cleanly", () => { * and setting it to the ceiling height is the classic way to bury one storey's * slab inside the one below. */ - it("stacks the storeys floor-to-floor, not floor-to-ceiling", () => { - const [first, second] = plan.levels; - assert.ok(first && second, "both storeys should resolve"); - assert.equal(first.id, "level-1"); - assert.equal(second.id, "level-2"); - assert.equal(first.floorY, 0); - - const ceiling = LUMBRIDGE_HQ.levels[0]?.wallHeight ?? 0; - assert.ok( - second.floorY > ceiling, - `level 2's slab at ${second.floorY} m is inside level 1, whose ceiling is at ${ceiling} m`, - ); + it("is an honest twelve-by-nine metre single-level studio", () => { + assert.deepEqual(plan.levels.map((level) => [level.id, level.floorY]), [["level-1", 0]]); + assert.deepEqual(LUMBRIDGE_HQ.levels[0]?.floorplan.rooms[3]?.outline, [ + { x: 0.2, z: 3.15 }, + { x: 0.2, z: 8.8 }, + { x: 11.8, z: 8.8 }, + { x: 11.8, z: 3.15 }, + ]); }); it("keeps every viewpoint on a level that exists", () => { // `Plan` drops a viewpoint whose level does not resolve, so this is really // an assertion that none were dropped — which the count makes visible in a // way `problems` being empty already implies but does not state. - assert.equal(plan.viewpoints.length, 8); + assert.equal(plan.viewpoints.length, 5); const levels = new Set(plan.levels.map((l) => l.id)); for (const v of plan.viewpoints) assert.ok(levels.has(v.levelId), `${v.id} is on nothing`); }); @@ -163,7 +159,8 @@ describe("seat ids", () => { */ it("expand the desk banks to the documented ids", () => { const ids = new Set(seats.map((s) => s.id)); - for (const id of ["eng-01", "eng-12", "studio-01", "studio-12", "studio-b-01"]) { + assert.equal(seats.length, 5); + for (const id of ["sf-agent-01", "sf-agent-02", "sf-island-01", "sf-island-02", "sf-lounge-01"]) { assert.ok(ids.has(id), `${id} is missing`); } }); @@ -186,8 +183,7 @@ describe("every room is reachable on foot", () => { /** Where somebody arrives: the entrance downstairs, the landing upstairs. */ const ENTRY: Record = { - "level-1": { x: 1.2, z: 4.9 }, - "level-2": { x: 2.4, z: 4.0 }, + "level-1": { x: 1.02, z: 7.68 }, }; function reachableRooms(levelId: string): Set { @@ -257,24 +253,12 @@ describe("every room is reachable on foot", () => { * the only place the difference between a balustrade and a handrail-shaped prop * is observable. A prop would leave the edge open and nothing would look wrong. */ -describe("the gallery balustrade", () => { - const OUTSIDE = 0.6; - - it("stops a walker at all three open edges", () => { - const edges: [string, { x: number; z: number }, { x: number; z: number }][] = [ - // North edge, at z = 2.0: standing on the gallery, stepping north. - ["north", { x: 36.0, z: 2.6 }, { x: 36.0, z: 2.0 - OUTSIDE }], - // East edge, at x = 38.2: stepping out over the commons. - ["east", { x: 37.6, z: 9.0 }, { x: 38.2 + OUTSIDE, z: 9.0 }], - // South edge, at z = 16.0. - ["south", { x: 36.0, z: 15.4 }, { x: 36.0, z: 16.0 + OUTSIDE }], - ]; - for (const [name, from, to] of edges) { - assert.ok( - plan.blocked("level-2", from, to, 0.3), - `the ${name} edge of the gallery lets a walker off a 16.8 m drop`, - ); - } +describe("the studio arrival", () => { + it("starts inside the live/work room with collision-clear movement", () => { + const arrival = LUMBRIDGE_HQ.viewpoints[0]?.focus.at; + assert.ok(arrival); + assert.equal(plan.roomAt("level-1", arrival)?.id, "live-work"); + assert.equal(plan.blocked("level-1", arrival, { x: arrival.x + 0.6, z: arrival.z }, 0.3), false); }); /** @@ -282,8 +266,11 @@ describe("the gallery balustrade", () => { * balcony you cannot fall off and also cannot reach is a balcony nobody has * noticed is broken. */ - it("does not seal the gallery off from the studio", () => { - assert.ok(!plan.blocked("level-2", { x: 33.0, z: 7.4 }, { x: 35.0, z: 7.4 }, 0.3)); + it("keeps all five authored views meaningful and distinct", () => { + assert.deepEqual( + LUMBRIDGE_HQ.viewpoints.map((view) => view.id), + ["arrival", "studio", "agent-bench", "demo-lounge", "kitchen-home"], + ); }); }); @@ -293,38 +280,26 @@ describe("the gallery balustrade", () => { * `Wall` carries a height; get the second wrong and the void has a lid at 2.8 m * that you cannot see but the light rig can. */ -describe("the commons is open to both storeys", () => { - it("has no ceiling", () => { +describe("the studio programme", () => { + it("names every promised live/work zone without pretending it is a tower floor", () => { const level1 = plan.levels.find((l) => l.id === "level-1"); - const commons = level1?.rooms.find((r) => r.id === "commons"); - assert.ok(commons, "the commons is missing"); - assert.equal(commons.ceiling, null); + assert.deepEqual( + level1?.rooms.map((room) => room.name), + ["Kitchen", "Sleeping Alcove", "Bath / Storage", "Live / Work Studio"], + ); }); - it("is enclosed to two storeys rather than one", () => { - const [level1, level2] = plan.levels; - assert.ok(level1 && level2); - - // The height the void *should* be, derived rather than typed: everything up - // to level 2's slab, plus level 2's own room. That is what "open to both - // storeys" means, and it stays true whatever the interstitial is set to. - const ceiling = LUMBRIDGE_HQ.levels[1]?.wallHeight ?? 0; - const expected = level2.floorY + ceiling; - - // `solid` only. The splitting pass emits a run per piece, so a wall with a - // door in it also yields a `lintel` over the opening — correct, and not the - // wall's height. - const wingWalls = level1.runs.filter( - (run) => run.wallId.startsWith("wing-") && run.role === "solid", - ); - assert.ok(wingWalls.length > 0, "the wing has no walls"); - for (const run of wingWalls) { - assert.equal( - run.top - run.bottom, - expected, - `${run.wallId} is ${run.top - run.bottom} m tall, not the ${expected} m the void needs`, - ); - } + it("uses the complete original habitat kit", () => { + const kinds = new Set((LUMBRIDGE_HQ.levels[0]?.floorplan.props ?? []).map((prop) => prop.kind)); + for (const id of [ + "tera:bed.platform", + "tera:sofa.modular", + "tera:kitchen.run", + "tera:kitchen.island", + "tera:storage.wardrobe", + "tera:seat.stool", + "tera:light.floor", + ]) assert.ok(kinds.has(id), `${id} is not placed in SF HQ`); }); }); @@ -530,6 +505,17 @@ describe("the office site table", () => { assert.equal(entry.site, pack.site, `${pack.id} has a site of its own`); } }); + + it("publishes truthful environment names and runtime status", () => { + assert.deepEqual( + OFFICE_SITES.map(({ id, name, status }) => ({ id, name, status })), + [ + { id: "lumbridge-hq", name: "SF HQ · Studio", status: "active" }, + { id: "frontier-valley", name: "Frontier Valley", status: "building" }, + { id: "mateo-court", name: "LA HQ · Office", status: "active" }, + ], + ); + }); }); /** @@ -571,6 +557,15 @@ describe("the Mateo Court pack", () => { assert.deepEqual(mine.filter((id) => others.has(id)), []); }); + it("caps the authored office at twenty-four useful seat addresses", () => { + const ids = mc.allSeats().map((seat) => seat.id); + assert.equal(ids.length, 24); + for (const id of ["works-a-01", "works-a-12", "loft-a-01", "loft-a-02", "review-04", "loggia-01"]) { + assert.ok(ids.includes(id), `${id} is missing`); + } + assert.equal(ids.some((id) => id.startsWith("works-b-")), false); + }); + /** * **The check that matters most in this pack.** * @@ -613,7 +608,7 @@ describe("the Mateo Court pack", () => { }); it("stands its upper-floor occupants on the deck", () => { - for (const id of ["loggia-01", "palmetto-01"]) { + for (const id of ["loggia-01", "loft-a-01"]) { assert.equal(mc.seat(id)?.y, 5, `${id} is not on the deck`); } }); diff --git a/src/test/officeHabitat.test.ts b/src/test/officeHabitat.test.ts new file mode 100644 index 0000000..9950606 --- /dev/null +++ b/src/test/officeHabitat.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; +import { createAssetContext, kit } from "../assets/kit.ts"; +import { MaterialRegistry } from "../assets/materials.ts"; +import "../assets/office/index.ts"; + +const HABITAT_IDS = [ + "tera:bed.platform", + "tera:sofa.modular", + "tera:kitchen.run", + "tera:kitchen.island", + "tera:storage.wardrobe", + "tera:seat.stool", + "tera:light.floor", +] as const; + +function seeded(): () => number { + let value = 0x12345678; + return () => { + value = (Math.imul(value, 1664525) + 1013904223) >>> 0; + return value / 0x1_0000_0000; + }; +} + +function disposeObject(object: THREE.Object3D): void { + object.traverse((child) => { + if (child instanceof THREE.Mesh) child.geometry.dispose(); + }); +} + +describe("procedural habitat assets", () => { + it("registers finite metre-scale footprints for the complete studio kit", () => { + for (const id of HABITAT_IDS) { + assert.equal(kit.has(id), true, `${id} is not registered`); + const footprint = kit.footprintOf(id); + for (const value of [footprint.width, footprint.depth, footprint.height]) { + assert.ok(Number.isFinite(value) && value > 0 && value < 5, `${id} has invalid footprint ${value}`); + } + } + }); + + it("builds deterministic, non-placeholder geometry with finite bounds", () => { + const materials = new MaterialRegistry({ quality: "low" }); + try { + for (const id of HABITAT_IDS) { + const first = kit.build(id, createAssetContext({ materials, rand: seeded() })); + const second = kit.build(id, createAssetContext({ materials, rand: seeded() })); + assert.equal(first.userData.assetId, id); + assert.notEqual(first.userData.missing, true); + const firstBox = new THREE.Box3().setFromObject(first); + const secondBox = new THREE.Box3().setFromObject(second); + const encode = (box: THREE.Box3) => [...box.min.toArray(), ...box.max.toArray()]; + assert.deepEqual(encode(firstBox), encode(secondBox), `${id} is not deterministic`); + assert.ok(encode(firstBox).every(Number.isFinite), `${id} has non-finite bounds`); + assert.ok(firstBox.max.y > 0, `${id} has no visible height`); + disposeObject(first); + disposeObject(second); + } + } finally { + materials.dispose(); + } + }); +}); diff --git a/src/test/officeRuntime.test.ts b/src/test/officeRuntime.test.ts new file mode 100644 index 0000000..468ccad --- /dev/null +++ b/src/test/officeRuntime.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { samplePresenceAt, samplePresenceForOfficeAt } from "../adapters/sample.ts"; +import FRONTIER_VALLEY from "../offices/frontier-valley.ts"; +import LUMBRIDGE_HQ from "../offices/lumbridge-hq.ts"; +import MATEO_COURT from "../offices/mateo-court.ts"; +import { activityRobotsForOffice } from "../offices/runtime.ts"; + +describe("office-keyed runtime activity", () => { + it("authors one deterministic SF robot, two LA robots, and none in a building preview", () => { + assert.deepEqual(activityRobotsForOffice(LUMBRIDGE_HQ), [ + { id: "sf-studio-activity-01", levelId: "level-1", seed: 0x53464851, jobSet: "studio-support" }, + ]); + assert.deepEqual(activityRobotsForOffice(MATEO_COURT), [ + { id: "la-office-activity-01", levelId: "level-1", seed: 0x4c414851, jobSet: "office-operations" }, + { id: "la-office-activity-02", levelId: "level-2", seed: 0x4c414852, jobSet: "model-review" }, + ]); + assert.deepEqual(activityRobotsForOffice(FRONTIER_VALLEY), []); + }); + + it("returns defensive specs and drops a robot when a local pack removes its level", () => { + const first = activityRobotsForOffice(MATEO_COURT); + first[0]!.id = "mutated"; + assert.equal(activityRobotsForOffice(MATEO_COURT)[0]?.id, "la-office-activity-01"); + assert.deepEqual(activityRobotsForOffice({ id: MATEO_COURT.id, levels: [MATEO_COURT.levels[0]!] }), [ + { id: "la-office-activity-01", levelId: "level-1", seed: 0x4c414851, jobSet: "office-operations" }, + ]); + }); +}); + +describe("office-keyed presence fallback", () => { + const workingHour = new Date(2026, 7, 19, 11, 0, 0); + + it("never fabricates people at either active HQ", () => { + assert.deepEqual(samplePresenceForOfficeAt("lumbridge-hq", workingHour), []); + assert.deepEqual(samplePresenceForOfficeAt("mateo-court", workingHour), []); + }); + + it("retains the legacy roster only as an explicit renderer fixture", () => { + assert.ok(samplePresenceAt(workingHour).length > 0); + }); +});