d464459838
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
/**
|
||
* The conventions every asset in this directory agrees on, and the three
|
||
* helpers that would otherwise be copied into seventeen files.
|
||
*
|
||
* ### Which way an asset faces
|
||
*
|
||
* An asset is built in its own frame with the **origin at the centre of its
|
||
* footprint, on the floor**, and at yaw zero it faces **−Z** — the same sense as
|
||
* `Yaw` in `interiors/types.ts`, which is `object.rotation.y` with no
|
||
* conversion. "Faces −Z" here means what it means for a person: the direction
|
||
* the thing is pointed, not the side you see. A desk, the chair pulled up to it
|
||
* and the person in the chair therefore all carry **one rotation**, which is
|
||
* what lets `DeskBank` hand the same `rotation` to its desk and its chair.
|
||
*
|
||
* The consequence, and it is worth stating because it is the opposite of what
|
||
* you might guess: the *used* side of an asset is at **+Z**, because that is
|
||
* where the user is. Drawer fronts, the open front of a shelf, the face of a
|
||
* monitor and the writing side of a whiteboard all point +Z, and the back of a
|
||
* thing that stands against a wall is at −Z.
|
||
*
|
||
* ### Two things that are not on the floor
|
||
*
|
||
* `light.pendant` and `light.troffer` hang from a ceiling, and a ceiling is
|
||
* their datum the way the floor is everything else's. They are authored with
|
||
* the origin at the **mounting plane** and their geometry below it, `y ≤ 0`, so
|
||
* a pack writes `elevation: 2.9` and gets a lamp hanging at 2.9 m rather than a
|
||
* lamp whose author had to know the ceiling height. `footprint().height` is the
|
||
* total drop. Nothing else in the library does this.
|
||
*
|
||
* Anything that stands on a desk or hangs on a wall — a monitor, a wall display,
|
||
* a whiteboard — is still authored on the floor. The monitor's foot sits at
|
||
* `y = 0` and the pack raises it with `Prop.elevation`; the wall-mounted things
|
||
* carry their own `mount` parameter, because the height of a whiteboard is a
|
||
* property of the whiteboard and not of the room.
|
||
*
|
||
* ### One rule that will bite you
|
||
*
|
||
* Every part an asset puts under a given material must be **either all indexed
|
||
* or all non-indexed**. `mergeGeometries` refuses a mixture, `MeshBin` treats
|
||
* the refusal as "skip this material", and the result is not an error but a
|
||
* chair with no shell on it — which is a lot harder to notice than a crash.
|
||
*
|
||
* In practice: `roundedBox` is an `ExtrudeGeometry` and carries no index, while
|
||
* every other part in `parts.ts` does. So a material is a rounded material or a
|
||
* boxy one, and where that forces a choice the honest fix is to move the part
|
||
* to the material it belongs to anyway — a task chair's arm pads are upholstery
|
||
* as readily as they are shell.
|
||
*
|
||
* ### Light fixtures emit no light
|
||
*
|
||
* A luminaire here is geometry with a glowing diffuser and nothing else. The
|
||
* office's lighting is a fixed rig owned by the scene (CONTRACT.md §4); a
|
||
* hundred props each adding a `PointLight` is both the wrong owner and, at four
|
||
* shadow-casting lights, the end of the frame budget.
|
||
*/
|
||
|
||
import { tintFor, type AssetContext } from "../kit.ts";
|
||
import type { SurfaceMaterial, SurfaceRole } from "../materials.ts";
|
||
import type { MeshBin } from "../parts.ts";
|
||
|
||
/**
|
||
* The material for the one part of an asset that answers to `Prop.colorKey` —
|
||
* a chair's fabric, a locker's doors, a rug's pile. Every asset names its
|
||
* tintable role in its own comment; there is at most one per asset, because
|
||
* "the blue meeting room" wants one thing to be blue and not six.
|
||
*
|
||
* Falling back to the shared role material rather than `tinted(role,
|
||
* palette[role])` is not a micro-optimisation: an identical-but-distinct
|
||
* material is a second merge bucket and a second draw call on every instance,
|
||
* for a colour nobody can tell apart from the one next to it.
|
||
*/
|
||
export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial {
|
||
const color = tintFor(ctx, role);
|
||
return color === ctx.palette[role] ? ctx.materials.get(role) : ctx.materials.tinted(role, color);
|
||
}
|
||
|
||
/**
|
||
* A horizontal slab — a desktop, a tabletop, a shelf board — with the grain the
|
||
* right size on the face you actually look at.
|
||
*
|
||
* The body is a scaled unit box, whose 0..1 UVs stretch; the top face is a
|
||
* `metricQuad`, whose UVs are in metres. Without the second part a 1.6 m desk
|
||
* and a 2.4 m table would each show exactly one repeat of the wood and read as
|
||
* two different materials (see the UV note in `parts.ts`). The quad sits 0.6 mm
|
||
* proud of the box so the two never z-fight.
|
||
*
|
||
* `y` is the underside of the slab.
|
||
*/
|
||
export function slab(
|
||
bin: MeshBin,
|
||
ctx: AssetContext,
|
||
material: SurfaceMaterial,
|
||
s: { x?: number; y: number; z?: number; width: number; depth: number; thickness: number },
|
||
): void {
|
||
const x = s.x ?? 0;
|
||
const z = s.z ?? 0;
|
||
bin.add(ctx.parts.box(), material, {
|
||
x,
|
||
y: s.y,
|
||
z,
|
||
size: [s.width, s.thickness, s.depth],
|
||
});
|
||
bin.add(ctx.parts.metricQuad(s.width, s.depth), material, {
|
||
x,
|
||
y: s.y + s.thickness + 0.0006,
|
||
z,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* A standing panel — a partition, a modesty panel, a board — with metric UVs on
|
||
* the faces.
|
||
*
|
||
* The only trick is how a floor-plane `metricQuad` is stood up: pitching it by
|
||
* +π/2 sends its up-normal to +Z and its depth extent to Y, which gives a
|
||
* vertical rectangle whose UVs are still in metres. `panel()` would have been
|
||
* shorter and would have smeared one tile of felt across a 1.4 m screen.
|
||
*
|
||
* `y` is the bottom edge; the panel is centred on `z`.
|
||
*/
|
||
export function panelSlab(
|
||
bin: MeshBin,
|
||
ctx: AssetContext,
|
||
material: SurfaceMaterial,
|
||
s: {
|
||
x?: number;
|
||
y: number;
|
||
z?: number;
|
||
width: number;
|
||
height: number;
|
||
thickness: number;
|
||
/** `"front"` skips the −Z face, for a panel hung flat against a wall. */
|
||
faces?: "both" | "front";
|
||
},
|
||
): void {
|
||
const x = s.x ?? 0;
|
||
const z = s.z ?? 0;
|
||
bin.add(ctx.parts.box(), material, { x, y: s.y, z, size: [s.width, s.height, s.thickness] });
|
||
|
||
const face = ctx.parts.metricQuad(s.width, s.height);
|
||
const yMid = s.y + s.height / 2;
|
||
bin.add(face, material, {
|
||
x,
|
||
y: yMid,
|
||
z: z + s.thickness / 2 + 0.0006,
|
||
pitch: Math.PI / 2,
|
||
});
|
||
if (s.faces !== "front") {
|
||
bin.add(face, material, {
|
||
x,
|
||
y: yMid,
|
||
z: z - s.thickness / 2 - 0.0006,
|
||
pitch: -Math.PI / 2,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Where a point `d` in front of a pitched part ends up.
|
||
*
|
||
* `Placement` applies its offset in the parent frame and its rotation about the
|
||
* part's own base, so gluing a screen to a tilted bezel by writing `z: 0.013`
|
||
* leaves the screen poking through the top of the bezel by `height × sin(tilt)`.
|
||
* Rotating the offset first is the fix, and it is small enough that doing it by
|
||
* hand twice would have been two chances to get the sign wrong.
|
||
*/
|
||
export function alongFacing(pitch: number, d: number): { y: number; z: number } {
|
||
return { y: -d * Math.sin(pitch), z: d * Math.cos(pitch) };
|
||
}
|
||
|
||
/** Symmetric jitter of ±`amount`, for the small deliberate untidiness. */
|
||
export function jitter(rand: () => number, amount: number): number {
|
||
return (rand() - 0.5) * 2 * amount;
|
||
}
|
||
|
||
export function clamp(v: number, lo: number, hi: number): number {
|
||
return v < lo ? lo : v > hi ? hi : v;
|
||
}
|