1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/offices
karti b25f217e3e feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California.

**THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.**
On today's live store the SoCal board contains 22 incidents. Every one has NULL
acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that
is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that
contains no other warm colour, so one glyph would be the most salient object on
the board and twenty-two would spend its credibility permanently.

`acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)`
returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day,
three correct answers. The empty board is a deliverable, not a fallback: it says
"No active fire on this board — CAL FIRE and WFIGS, just now", states that 21
records were gated and why, lists the largest fires burning OUTSIDE the frame
with distances, and counts the hot pixels it is deliberately not drawing.

**The privacy leak is structurally impossible rather than carefully avoided.**
cloud-1 serves a projection; the four home-relative columns never leave that box.
`observations.threat` was the one that nearly got through — it is
`(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so
with acreage and containment public it inverts to a distance circle around a
house and three fires give an intersection. A grep of the built bundle for
distance_km, bearing_deg, threat, 7762 and the street name returns nothing.

**Deliberately not used, and both would have produced a confident wrong answer:**
the store's `air` table retains only the last parameter of each poll, so all 78
rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive
Groups" — haze driven off it would clear the sky during a smoke event. And
`weather` is written only inside the NWS alerts loop, so a quiet day stores no
wind at all. Tera's own per-region NWS wind is already correct and already what
the clouds drift on.

Satellite detections are drawn as evidence and never as incidents. The permanent
industrial heat source 4.7 km from the owner's house is flagged persistent and
dropped, asserted by a test that first proves it is present in the fixture.
MODIS integer confidence and VIIRS string confidence are branched on `sat`.

**The LA office is a twin.** Its entire authored second storey — Model Loft,
Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable
on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5,
verified against the real pack rather than a synthetic plan. Its two studio
devices read real hardware through a field-allowlisted bridge: mute, volume and
reachability only. Never level, because there is no passive level upstream and
obtaining one would record a room with people in it. Never dB, because upstream
is gainPct across four different native scales. The bridge refuses all writes.

Fixed at its root: an anonymous visitor was getting permanently at-rest
instruments backing off against a 401. The tier moves into `createDeviceSource`,
so anon gets the living simulator three file headers already promised.

**Item 8 is closed, not fixed, and the correction is the point.** The Bay Area
"stutter" was GPU power management — the card sat at 500 MHz of 2725 through
every run that reproduced it, 4096/2048/1024/256 shadow maps all render in
1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then
16.7. The allowance is removed and the cell is back to 16.7. Geometry is the
gate; frame time is advisory.

Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056
triangles were the second submission of the same buildings into the shadow pass.
Mobile now has its own triangle caps and bay-area mobile draws 1,266,096.

Also: bridges and the freeway corridor light up at night as emission, not lights
— 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single
change that made US-101 legible was moving its edge lines from the lit material
to the unlit one: retroreflective paint, the argument the SFO night frame already
makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts,
because a rural interurban corridor genuinely is unlit.

Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with
no cap raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:01:11 -07:00
..

Authoring an office

An office pack is one JSON-shaped object describing the inside of a building: floor slabs, walls, holes in the walls, furniture, seats, and a few camera poses. src/interiors/types.ts is the contract — it is short, it is commented, and it wins any argument with this document. lumbridge-hq.ts in this directory is the small worked example — one storey, four rooms, every core feature — and copying it is the intended way to start. mateo-court.ts is the large one: two storeys, sixteen rooms, seat bindings, device declarations and a courtyard, and it is the file to read when you want to see a feature used in anger rather than demonstrated.

Nothing in a pack requires an account, a key or a network. If you can run the repo you can author an office, and if you can author an office you can hand someone the file.

The shape of it

import type { Office } from "../interiors/types.ts";

export const ACME_HQ: Office = {
  id: "acme-hq",
  name: "Acme HQ",
  levels: [
    {
      id: "level-1",
      name: "Level 1",
      elevation: 0,      // floor height above the office origin
      wallHeight: 2.8,   // the storey's default wall top
      wallThickness: 0.12,
      wallSurface: "tera:paint.matt",
      floorplan: {
        rooms: [...],    // required
        walls: [...],    // required
        props: [...],    // optional, read as [] when absent
        deskBanks: [...],
        seats: [...],
        zones: [...],
        devices: [...],  // smart hardware; see "Devices" below
      },
    },
  ],
  viewpoints: [...],     // viewpoints[0] is where you arrive
  meta: { author: "you", license: "CC0-1.0" },
};

Office must stay strictly JSON-serialisable: no functions, no classes, no Date, no THREE types. A pack you hand-write as a .ts module and a pack that arrives as a .json body over HTTP have to be literally the same thing, because the server (GET /api/v1/offices/:id) serves exactly this object inside an OfficeDoc.

Helper functions in your source file are fine — lumbridge-hq.ts uses five, and they all run at module load and return plain objects. The rule is about the value, not the file.

The coordinate frame

Metres, 1 unit = 1 m. The floor is the XZ plane with +Y up, which is three.js's convention with no conversion anywhere.

"Plan view" throughout means looking down at that plane with +X to the right and +Z down the page. That is the one thing worth internalising, because +Z going down is what makes the winding rule counter-intuitive:

  • Outlines are implicitly closed — do not repeat the first point.
  • Author them counter-clockwise in plan view, which is NW → SW → SE → NE for a rectangle, and which has a negative shoelace area over (x, z).
  • Get it wrong and nothing breaks: Plan silently re-winds a reversed polygon.

By default an office has no orientation on the earth, and calling an edge "north" is a convenience for reading your own file. A pack with no site gets no Atmosphere, no sun and no sky (CONTRACT.md §4); interior lighting is a fixed rig owned by the scene, and that is a supported, permanent state — you can author a whole building without owning a coordinate.

Say where the building stands and the convenience becomes a fact:

site: {
  lat: 37.7756,
  lng: -122.3186,
  elevation: 4,      // metres above the ground OUTSIDE, not above sea level
  heading: 0,        // compass bearing, in degrees, that the pack's Z points along
  label: "Alameda Point",
},

Then the pack gets the real sun for that place at the app's clock, a sky, and a horizon elevation metres below the level-0 floor. heading is the field that matters most and the easiest to leave wrong: it is what decides which of your walls the light actually comes through. 0 means your "north" really is north.

elevation is the other one worth thinking about, because it is what the horizon is measured from — the difference between an office on the 48th floor and a shed on an airfield is one number, and it is this one. All three shipped packs are worked examples: lumbridge-hq.ts is 188 m up and rotated 205°, frontier-valley.ts is 4 m up and square to the compass, and mateo-court.ts is 1.2 m up — a loading dock — and turned 36° onto the 1781 pueblo grid that downtown Los Angeles still follows.

Where the car goes

A sited building may also say where a vehicle stands outside it:

arrival: {
  levelId: "level-1",
  position: { x: 22.6, z: -3.4 },   // metres, in YOUR plan frame
  rotation: -Math.PI / 2,           // Yaw: which way the car points
  kind: "vehicle-stall",
  label: "Mateo Street kerb",
},

src/engine/officeExterior.ts builds an apron and a vehicle there. The position is in the pack's own frame, the same one the walls are in — which is why a stall on the street side of a façade authored at z = 0 has a negative z, and why you can see it is outside the building without converting anything.

Put it on ground that is genuinely outside: Plan checks the level, the kind and that the numbers are numbers, and deliberately does not rule on whether the stall is inside the footprint, because a covered undercroft and a courtyard are things a pack might legitimately mean. src/test/packs/arrivalAnchors.test.ts is where the three shipped packs assert that theirs are on the street, the podium kerb and the apron.

One trap, and it is lumbridge-hq's: a pack whose level-0 floor is 188 m above the ground outside has no pavement to park on at all. Its stall is authored beside its own front door because the plan frame is the only frame a pack has, and what "outside" means vertically for a tower is the exterior layer's decision. If your building is up in the air, say so in a comment where the next person will find it.

Nothing here is geocoded and nothing can be. These are numbers you type, like every other number in a pack — see CONTRACT.md §8 for why a coordinate's provenance is a licensing question in this repo.

Levels, and how somebody gets between two of them

A Level is a storey: an elevation (floor-to-floor, not floor-to-ceiling), a default wall height, and its own floorplan in its own frame with its slab at zero. Plan adds the elevation to every coordinate on the level exactly once, so you author an upper floor without holding 5 m in your head.

The way between two storeys is a Transition

A Level on its own is a place the camera can fly to. To make it a place somebody can walk to, author a Transition on the Office — not on either floorplan, because it is the one record in this format that is a fact about two storeys at once and putting it on either would make the other one's copy a restatement.

transitions: [{
  id: "stair",
  kind: "stair",              // or "lift"
  width: 1.2,
  surface: STEEL,
  lower: {
    levelId: "level-1",
    footprint: [/* a small patch of floor at the bottom */],
    landing: { x: 10.2, z: 9.2 },
  },
  upper: {
    levelId: "level-2",
    footprint: [/* the gap in the balustrade at the top */],
    landing: { x: 9.25, z: 10.4 },
  },
  // Optional. Absent means one straight flight between the two landings.
  legs: [
    { to: { x: 14.1, z: 9.2 }, rise: 1 },   // a flight
    { to: { x: 14.1, z: 10.4 }, rise: 0 },  // a half landing
    { to: { x: 9.25, z: 10.4 }, rise: 1 },  // and the flight back
  ],
}]

Standing on a footprint is the input. There is no key to press, which is the single most important thing to know when you size one. A footprint the size of your whole stair means that walking under the flight takes you up it; the right size is the bottom couple of metres, and at the top, the gap you arrive through and nothing else.

That upper footprint is doing a second job that is easy to miss. A gap in a balustrade is an unguarded edge, and the moment an upper floor is walkable somebody will walk off it. A footprint covering the gap catches them and sends them downstairs, which is what the gap is for.

rise on a leg is that leg's share of the total climb, not a height. The height is already known exactly — it is the difference between the two levels' elevations — and stating it twice is how a pack ends up with a staircase that does not reach its own landing. A flat half landing is rise: 0. Plan normalises the shares, so two flights written { rise: 1 } and { rise: 1 } mean half each.

You do not author treads. src/interiors/shell.ts builds them from this record: it divides each climbing leg into a whole number of ~178 mm risers and lays a tread on each, which is why mateo-court's 2.5 m flights come out at the fourteen risers its own comment always claimed. The drawn flight and the flight the walk controller climbs are the same list of points, so a staircase nobody can climb is not expressible.

Three things Plan will refuse, all of which drop the transition whole rather than in pieces — half a staircase is a hole in a collider:

  • a footprint outside its own level's bounds, or with a wall running through it. A wall along its edge is fine and expected: stairs go against walls.
  • a landing outside its own footprint, or pressed so close to a wall that a walker of the default radius could not stand on it.
  • an upper that is not actually above lower.

Two things it will not do for you:

  • The treads do not collide. Plan derives collision from the wall list alone, so like every prop in every pack, a flight can be walked through at its low end. That is the engine's existing physics rather than a property of this record.
  • It will not find a route. A footprint you cannot walk to is a way up nobody reaches. Check it the way src/test/packs/mateoContent.test.ts does.

Still worth doing, transition or no transition: give every level a viewpoint of its own, so nothing up there is unreachable by every means at once.

Rooms are slabs. Walls are segments.

This is the load-bearing idea, and the thing most people get backwards on the first attempt.

A Room is a floor finish with a name and a polygon. It implies no walls. A Wall is a separate straight segment that belongs to no room and stands wherever you put it. The two lists are not derived from each other.

Consequences worth stating out loud:

  • An open plan is simply rooms with nothing standing between them. In the reference pack the lounge, the kitchen and the desk floor share three edges and only one of those edges carries a wall.
  • A room can be a corridor, a lift lobby, a zone of different carpet. Give it an id if you ever want to name it.
  • Rooms may overlap and may leave gaps. Plan.roomAt resolves later rooms first, so a room declared after another wins the lookup where they cross. Overlapping is legal but every shipped pack avoids it — two coplanar slabs at the same height is a z-fight waiting for the wrong GPU, so mateo-court notches its courtyard around the stair standing in it rather than laying one slab over the other.

Rooms and walls should be authored against the same numbers. A wall is centred on its line and straddles the boundary between the two slabs meeting there. If the slab edge says 10.4 and the wall line says 10.42 you get a seam you will never find again.

Ceilings

ceiling omitted gives a ceiling at the level's wallHeight. ceiling: null means no ceiling at all — an atrium, a void, or a room you want to look down into. { height, surface } overrides one room.

An office you look down into from an establishing viewpoint cannot have lids on the rooms you are trying to see, so most of both large packs declare ceiling: null. The rooms that keep theirs are the ones you are never meant to see inside — the bath and storage in the SF studio, the equipment store in the LA one — and a ceiling is a cheap way of saying so.

mateo-court uses the field in a third sense that is worth knowing about: its courtyard and its stair are ceiling: null because they are outside, and there is nothing above them at any height. Nothing in the format had to change for that; the field already said what was needed. One consequence is worth carrying, though: a room with no ceiling is a room with nothing to hang a light fitting from, and a ceiling grid over an open courtyard is a lighting plan for a different building.

Doors and windows are openings, not props

There is no tera:shell.door. A door is a 1-D interval punched out of a wall:

{ kind: "door", start: 5.6, width: 0.9, sill: 0, head: 2.1 }

start is measured from the wall's from end, along the wall, in metres. That is the whole of the horizontal placement, which is why an opening can never drift off its wall. It also means the direction you write a wall in is the direction its openings are measured in — author every wall consistently (the reference pack goes west-to-east and north-to-south, without exception) or you will eventually put a door at the wrong end of a room.

sill and head are metres above the level's floor, and both are required. There are deliberately no per-kind defaults: a contract where the numbers you read are not the numbers you get is worse than a contract that makes you type sill: 0. Typical values:

kind sill head note
door 0 2.1 0.9 m wide for a single leaf, 1.8 m for a pair
arch 0 2.4 a cased opening with no leaf
window 0.9 2.2 punched window
window 0.75 2.35 ribbon glazing on a façade

The passability rule, which is the whole reason for this design

The pass that splits a wall around its openings has to run anyway to produce the solid runs you can see. Running it once also produces the walk-mode collision segments for free, with gaps in exactly the places you can walk through.

An opening is a way through iff sill <= 0 and head >= 1.1 m. There is no per-kind special-casing: a door passes, an arch passes, a window with a 0.9 m sill does not, a serving hatch does not. Which means a glazed opening at ankle height quietly becomes a hole in your wall that people walk through, so keep sills honest.

Plan.blocked(levelId, from, to, radius) is the test to use from a walk controller; do not re-derive the inflation yourself.

Glass walls versus window openings

Full-height glazing is a wall with a glass surface, not one enormous opening:

{ id: "ext-east", from: { x: 34, z: 0 }, to: { x: 34, z: 12.2 }, surface: "tera:glass.curtain" }

An opening is a hole, and a hole is something you can sometimes walk through. A curtain wall is a solid you happen to be able to see through. Modelling one as the other hands the collider a twelve-metre gap and puts your lounge on the pavement.

Desk banks

Hundreds of hand-written desk literals is not a file anybody edits twice. A DeskBank is one declaration that Plan expands into a desk prop, a chair prop and a seat per station:

{
  id: "eng",
  desk: "tera:desk.workstation",
  chair: "tera:seat.task-chair",
  origin: { x: 9.2, z: 1.9 },   // centre of station (1, 1), before rotation
  rotation: 0,
  columns: 6, rows: 2,
  pitch: 1.7,                   // centre-to-centre across
  rowPitch: 0.85,               // centre-to-centre down; defaults to `pitch`
  facingRows: true,
  seatOffset: 0.6,              // desk centre to seat; defaults to 0.6
  pose: "sit",                  // defaults to "sit"
  seatPrefix: undefined,        // defaults to `id`
}

The grid is laid out in the bank's own frame — columns along local +X, rows along local +Z — then rotated by rotation about origin. Rotating a bank does not move the origin to a corner of the building; the origin stays station (1, 1). At rotation: Math.PI the bank's local +X is world X, so the origin is the east end of the run.

The generated ids are contractual, because a Presence binds to a seat id and you have to be able to predict it without running anything. Stations are numbered from 1, along each row and then down the rows, zero-padded to two:

  • seat ${seatPrefix ?? id}-01, -02, …
  • desk prop ${id}-desk-01, chair prop ${id}-chair-01

So the bank above gives you eng-01eng-12, and eng-04 is the fourth desk in the front row.

Why every bench in the reference pack is two rows

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. A second bench is a second bank. That is a real limit of the format, not an oversight.

Seat ids restart at 01 in every bank, so two banks that want to share a numbering series cannot; give them distinct prefixes (eng, ops, design) rather than trying.

A bank places a desk and a chair and no third thing. If you want a monitor on every desk, do not write thirty-nine monitor props that have to be kept in step with a bank you will move next week — register your own desk asset (see below) that builds a desk with a monitor on it, and every station in every bank in every pack gets one.

Seats

{ id: "bernal-04", position: { x: 10.8, z: 14.1 }, facing: Math.PI / 2, pose: "sit" }

A seat is an address, not a chair. The chair is a separate prop that happens to be at the same coordinate; a room with no chairs in it can still have seats, and a chair with nobody's name on it needs no seat.

This is the most important thing in the format. A Presence — somebody at a desk — binds to a seatId and never to a coordinate, and never appears in a pack. The pack knows where eng-04 is; a private API knows who is sitting in it; neither knows the other. That is what lets occupancy be private data behind authentication while the geometry stays public, open-source and copyable. If a presence carried an {x, z}, publishing the building and publishing the people would be the same act, and one of them could never be published at all.

So: keep seat ids stable across edits, for the same reason street numbers survive repainting the house. Move the table 200 mm; do not renumber the seats.

facing is where the occupant looks. pose is "sit" or "stand" and is the only thing that tells a consumer how tall to draw an occupant — people perch at a kitchen island, so those seats are "stand".

Props, and which way things face

{ id: "lobby-monitor", kind: "tera:screen.monitor", position: { x: 3.4, z: 5.2 },
  rotation: Math.PI / 2, elevation: 0.73, scale: 1, colorKey: "accent", seat: "reception-01" }

kind is an AssetId. The prop registry and the asset registry are the same registry — there is no separate table of things you are allowed to put in a room, and an unregistered id resolves to a placeholder box rather than throwing, so one typo does not stop the office opening.

position is on the floor plane; elevation is metres above the level's floor and is omitted for the ninety per cent of things that stand on it. colorKey is an opaque palette key resolved by the consuming app, exactly as Pin.colorKey is — the engine will never learn that "focus" means a quiet booth. seat binds a prop to a seat id as an address, which is what lets an occupancy layer dim the empty chairs without knowing which mesh is which.

The yaw convention

Yaw is radians about +Y. Zero faces Z, and the angle increases counter-clockwise seen from above. That is exactly object.rotation.y, and nothing anywhere converts it — there is no sign flip between your file and the scene graph. (The city's District.gridAngle uses degrees clockwise from north, which reads better for a map; interiors deliberately does not.)

An asset is built with its origin at the centre of its footprint on the floor, facing Z at yaw zero, and its working side at +Z — drawer fronts, the open front of a shelf, a monitor's glass, a whiteboard's writing face — with the solid back of anything that stands against a wall at Z.

Those combine into one rule that covers every prop:

A prop takes the yaw of the wall it backs onto. A seat takes the yaw of the direction the occupant looks.

A shelf against the north wall is 0. A locker against the east wall is -Math.PI / 2. A desk against the south wall is Math.PI, and the person at it is also Math.PI, looking south at it. This is why a DeskBank can hand one rotation to its desk, its chair and the seat between them.

The one exception: ceiling fixtures

tera:light.pendant and tera:light.troffer are authored with their origin at the mounting plane and all their geometry below it. Write elevation: 2.8 and you get a fitting hanging from a 2.8 m ceiling, rather than a fitting whose author had to know your ceiling height. Everything else, including things that stand on a desk or hang on a wall, is authored on the floor.

Light fittings emit no light. The interior rig belongs to the scene (CONTRACT.md §4).

Wall-mounted things

A prop's origin is the centre of its footprint, so a panel hangs on a wall only if you place its centre half its own depth off the wall's face: wallLine + thickness / 2 + depth / 2. tera:screen.wall-display is 0.12 m deep and tera:whiteboard is 0.10 m. Name those offsets as constants; you will use them a dozen times.

Devices

A pack may declare smart hardware — a mic, a speaker — on its floorplan:

devices: [
  {
    id: "la-front-mic",
    kind: "mic",
    label: "Front desk mic",
    assetId: "tera:device.mic.desk",
    anchor: { levelId: "level-1", propId: "front-mic", roomId: "lobby", seatId: "front-01" },
    capabilities: CANONICAL_CAPABILITIES.mic,
    provenance: "simulated",
    disclosure: "Simulated studio hardware. These readings are demonstration data…",
  },
],

src/devices/types.ts owns the type and is worth reading; four things about it matter when you are authoring one.

A device has no coordinate. anchor.propId is required and it is the position: the device derives its transform from that prop's, plus an optional offset in the prop's own frame for the few centimetres between a desk's origin and the top of a mic stand. Nudge the desk and the mic goes with it, because there was never a second number to forget. Same rule Prop.seat follows for chairs, and the same one a pack follows by importing its site instead of restating the coordinates.

The anchor prop is the hardware. Its kind is the device asset — <namespace>:device.<kind>.<placement>, so tera:device.mic.desk and a self-hoster's acme:device.mic.boom both read as a mic with nothing registered. Plan drops a declaration whose anchor prop is device hardware of the wrong kind: a mic bolted to a speaker is not a rendering mistake, it is a command routed to the wrong instrument.

capabilities should be CANONICAL_CAPABILITIES[kind]. The device panel builds its controls by walking that array and the arena's observation width is the sum of them, so two studios describing a mic differently changes the shape of an RL observation without anybody editing the arena.

disclosure is mandatory and it is checked. A declaration with provenance: "simulated" whose disclosure does not contain the word is reported as a problem and dropped — the same check RobotOperationsDefinition gets, for the same reason. A level meter that moves, with nothing beside it saying where the number came from, is a claim about a real room.

What a device is doing — powered, muted, its level in dBFS — is a DeviceState and never appears in a pack. It arrives over the API from a route that can refuse an anonymous caller, exactly as Presence does. A declaration is a description of a room and is safe to publish; a reading is not, and the split is the whole design.

How full a room should be, and the trap in the answer

The number to aim at is 0.26 non-light props per square metre, building-wide, with no room over 20 m² below 0.15. Both shipped studios clear it: lumbridge-hq sits at 0.28 over 100 m², mateo-court at 0.29 over 1246 m². src/test/packs/mateoContent.test.ts measures it if you want the exact method — it counts prop centres by Plan.roomAt, and it excludes every tera:light.* because a ceiling grid will satisfy any prop count you like while leaving the floor bare. Mateo Court's first version proved that: ninety-eight of its props were troffers, two of the grids hung in rooms declared ceiling: null, and it looked empty from every viewpoint it had.

But the ratio is the easy half, and on its own it is a lie. furnish.ts batches props by (asset, colorKey) and draws ctx.rand once per batch, so every instance of a kind is geometrically identical — the same seeded jitter, the same books on the same shelf, the same leaves on the same plant. Ten more shelves in a room are one shelf drawn ten times. So:

Apparent density is a function of distinct kinds, not of prop count. A room that looks thin does not get better when you copy what is already in it.

Two consequences for how you fill a room:

  • Reach for a kind you have not used yet before you reach for a second copy. Mateo Court's courtyard went from 13 props of 6 kinds to 44 of 13, and it is the second number that changed what it looks like. Twelve of the assets in src/assets/office/studio.ts exist because of exactly that room.
  • If the kind you need is not in the catalogue, write it. defineAsset plus a registerAll is a smaller change than it looks, src/assets/office/studio.ts is a worked example of a dozen of them, and a colorKey on an existing kind will not stand in for it — the colour is in the batch key, so two tints of one asset are two batches of the same geometry, which is better than one and is not a new object.

A useful floor for a big room is seven distinct kinds over 40 m², which is what the reference pack's one large room manages. Below that a room reads as a pattern rather than a place, whatever the prop count says.

Zones

A named region of floor with an opaque colorKey, no behaviour and no effect on geometry. A consuming app 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, which is why there is no kind field to be tempted by.

Viewpoints

{ id: "floor", label: "The Whole Floor", shortLabel: "The Floor", number: "01",
  description: "…", levelId: "level-1",
  focus: { at: { x: 17, z: 9 }, distance: 32, height: 14, rotation: 0.55 } }

distance is metres from the target to the camera; height is metres above the 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.

viewpoints[0] is the arrival pose. Reception is the obvious choice; the reference pack puts the establishing shot there instead, on the grounds that arriving inside a room before you have seen the shape of the building is disorienting. Either is fine — that is a choice a pack gets to make, which is why the field is an order and not an id.

Surfaces

Room.floor, Wall.surface, RoomCeiling.surface and Level.wallSurface take a SurfaceId string. MaterialRegistry.resolve throws away the namespace, reads the first dot-segment as a role name, tries a small alias table, and falls back rather than throwing:

tera:carpet.loop        -> carpet
acme:carpet.broadloom   -> carpet
tera:wood.plank         -> woodFloor   (via the alias table)
tera:glass.curtain      -> glazing     (alias)
tera:carpetAccent.x     -> carpetAccent (an exact role name)
tera:nonsense.at.all    -> the caller's fallback

The closed list of roles lives in src/assets/materials.ts. Aliases today include paint, plasterboard, wall, wood, timber, concrete, glass, ceiling, felt, fabric, laminate, steel, metal, aluminium, screen, plant.

There is deliberately no per-instance tint on a surface, unlike Prop.colorKey. One blue meeting room is a material — register acme:paint.blue and point the wall at it. One red chair in a row of grey ones is genuinely an instance.

Registering your own assets instead of forking

You do not fork this repo to change what a desk looks like. Register your own asset with an overrides: pointing at the built-in id:

import { defineAsset, kit } from "../assets/kit.ts";

type StandingParams = { width: number; depth: number; height: number };

kit.register(defineAsset<StandingParams>({
  id: "acme:desk.standing",
  overrides: "tera:desk.workstation",   // ← every reference to the tera id resolves here
  label: "Standing desk",
  defaults: { width: 1.6, depth: 0.8, height: 1.05 },
  footprint(p) {
    return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 };
  },
  build(p, ctx) {
    /* compose cached unit primitives from ctx.parts into a MeshBin */
  },
}));

Declare the parameter type as a type alias and not an interface: an interface has no implicit index signature and will not satisfy AssetParams.

Every pack in the world that says tera:desk.workstation — including the reference office, unmodified — now draws yours. That is the mechanism working as designed: reskin, do not fork. Point an id at your own namespace in a pack only when you want that one building to differ.

Everything is procedural. A mesh is a function composing cached unit primitives; a texture is drawn on a 2D canvas from seeded noise. No binary art is ever committed to src/ — no .glb, no .png, no fonts, no logos. This is the same property that gives the city zero asset-licensing exposure and it is worth more than the quality ceiling it costs. Your own binary assets are your business: public/props/, public/kits/ and public/offices/ are hard-exempt from the repo's binary gate and gitignored as self-hoster space.

If you contribute an asset back, CONTRIBUTING.md and src/assets/LICENSE-ART are the terms: Apache-2.0 on the code, with the artistic output additionally dedicated under CC0-1.0.

Validation: what Plan forgives and what it drops

new Plan(office) resolves the whole thing once and never throws. Every complaint lands in plan.problems, so a pack can be asserted on in a test without capturing console output. In a dev build it also warns.

Dropped (the offending item disappears, the rest of the pack survives):

  • self-intersecting or degenerate room outlines, zero-length walls
  • an opening that runs past either end of its wall, or overlaps another opening
  • head <= sill
  • a desk bank with fewer than one station, or a non-positive pitch
  • a viewpoint on a level that does not exist
  • duplicate ids — ids are unique per kind and building-wide, not per level, because a Presence binds to a seat id and an occupancy layer dims a prop id, so both have to mean one thing in the building. Later loses.
  • a device whose anchor names a prop that does not exist, sits on another level, or is device hardware of a different kind; and one whose own declaration is invalid — no label, a kind its assetId disagrees with, no capabilities, or a simulated provenance whose disclosure does not say so
  • an arrival anchor on a level that does not exist, of an unknown kind, or with a position that is not a number

Repaired (silently, and recorded):

  • an outline hand-closed with a duplicated first point
  • an outline wound the wrong way
  • a negative sill clamped to the floor; a head above the wall clamped down
  • a prop bound to an unknown seat id — the binding is cleared, the prop stays
  • a device naming an unknown room or an unknown seat — the address is cleared, the device stays and the room is re-derived from where its hardware stands

Missing required arrays read as [], because a pack that arrived over HTTP has been through no type checker.

Opening ids are "${wallId}#${authoredIndex}" using the authored index, so dropping one bad opening does not renumber its siblings.

Shipping a pack

  • In the browser. Import it and hand it to Plan. The reference office is the default; that is the zero-config path and it needs nothing else.
  • Over HTTP. Drop the JSON in the server's office directory and it is served at GET /api/v1/offices/:id wrapped in an OfficeDoc ({ id, name, floor, visibility, updated? }, defined in src/server/wire.ts). A pack that omits visibility is treated as private, and a private office returns 404, not 403, so the endpoint cannot be used to enumerate what exists.

A checklist before you call it done

  1. new Plan(office).problems is empty — at both depths, "full" and "public". They are different builds and only one of them is what a visitor gets.
  2. Every room you can walk into has a door or arch with sill: 0 reaching at least 1.1 m of head. Walk the graph, or spot-check with Plan.blocked.
  3. No window opening has sill: 0 unless you meant a doorway.
  4. Seat ids are the ones you are willing to live with for a year, and every seat somebody is meant to occupy has a prop bound to it with seat:.
  5. Corridors are at least 1.2 m clear, doors 0.9 m, desks 1.41.6 m. Numbers a person would recognise are the whole difference between a floor plan and a diagram.
  6. JSON.parse(JSON.stringify(office)) deep-equals the office. The usual way to fail this is a helper that writes elevation: opts.elevation unconditionally: { elevation: undefined } and {} are different objects and only one of them survives the wire. Spread optional fields, do not assign them.
  7. Every room worth looking at has a viewpoint whose focus.at lands inside it, and viewpoints[0] is somewhere a person can stand — it is the walk spawn as well as the arrival camera.
  8. At least 0.26 non-light props/m² building-wide, no room over 20 m² below 0.15, and — the one that matters — at least seven distinct kinds in every room over 40 m². See "How full a room should be" above for why the second number is the real one.
  9. Nothing binary landed under src/.