Spaces: the inside of the world, and a sun that is actually where it should be
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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Desks: the workstation, the pedestal that lives under it, and the screen that
|
||||
* separates it from the next one.
|
||||
*
|
||||
* `desk.workstation` is the anchor of the library. It is the asset a `DeskBank`
|
||||
* repeats, it is the one every self-hoster will override first, and its
|
||||
* dimensions are what the rest of the furniture is sized against: a 730 mm
|
||||
* working height, a 1.6 × 0.8 m desktop, and a person sitting at +Z facing −Z.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { clamp, panelSlab, slab, tintable } from "./common.ts";
|
||||
|
||||
type WorkstationParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
/** Working height of the desktop, metres. 1.05 or so for a standing desk. */
|
||||
height: number;
|
||||
/**
|
||||
* `"loop"` is the cantilever frame most office desks actually have — a foot
|
||||
* bar, two uprights and a top rail at each end. `"post"` is four legs, which
|
||||
* reads as a table and is here for the rooms where that is wanted.
|
||||
*/
|
||||
legs: "loop" | "post";
|
||||
/** The panel across the far edge. Tintable; this is the desk's colour key. */
|
||||
modesty: boolean;
|
||||
};
|
||||
|
||||
const TOP_THICKNESS = 0.03;
|
||||
|
||||
export const deskWorkstation = defineAsset<WorkstationParams>({
|
||||
id: "tera:desk.workstation",
|
||||
label: "Workstation",
|
||||
defaults: { width: 1.6, depth: 0.8, height: 0.73, legs: "loop", modesty: true },
|
||||
|
||||
footprint(p) {
|
||||
// The clearance is a chair pulled out, not a chair tucked in: 900 mm is
|
||||
// what a person needs to stand up and leave without moving the desk.
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const frame = ctx.materials.get("deskFrame");
|
||||
const deckY = p.height - TOP_THICKNESS;
|
||||
|
||||
slab(bin, ctx, ctx.materials.get("deskSurface"), {
|
||||
y: deckY,
|
||||
width: p.width,
|
||||
depth: p.depth,
|
||||
thickness: TOP_THICKNESS,
|
||||
});
|
||||
|
||||
const legX = p.width / 2 - 0.09;
|
||||
const legZ = p.depth / 2 - 0.11;
|
||||
|
||||
if (p.legs === "post") {
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) {
|
||||
bin.add(P.rod(), frame, {
|
||||
x: sx * legX,
|
||||
z: sz * legZ,
|
||||
size: [0.055, deckY, 0.055],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const barDepth = p.depth - 0.18;
|
||||
for (const sx of [-1, 1]) {
|
||||
const x = sx * legX;
|
||||
bin.add(P.box(), frame, { x, size: [0.07, 0.045, barDepth] });
|
||||
bin.add(P.box(), frame, { x, y: deckY - 0.05, size: [0.07, 0.05, barDepth] });
|
||||
for (const sz of [-1, 1]) {
|
||||
bin.add(P.box(), frame, {
|
||||
x,
|
||||
z: sz * legZ,
|
||||
size: [0.05, deckY - 0.05, 0.05],
|
||||
});
|
||||
}
|
||||
}
|
||||
// The spine between the two end frames. Without it a cantilever desk
|
||||
// looks like two separate trestles that happen to be under one board.
|
||||
bin.add(P.box(), frame, {
|
||||
y: deckY - 0.19,
|
||||
size: [Math.max(0.2, p.width - 0.28), 0.055, 0.055],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.modesty) {
|
||||
const height = clamp(deckY - 0.3, 0.14, 0.4);
|
||||
panelSlab(bin, ctx, tintable(ctx, "partitionFabric"), {
|
||||
y: deckY - 0.05 - height,
|
||||
z: -(p.depth / 2 - 0.08),
|
||||
width: p.width - 0.24,
|
||||
height,
|
||||
thickness: 0.018,
|
||||
});
|
||||
}
|
||||
|
||||
return bin.build("desk.workstation");
|
||||
},
|
||||
});
|
||||
|
||||
type PedestalParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
drawers: number;
|
||||
/** Castors, for the pedestal that gets rolled out and sat on. */
|
||||
mobile: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The under-desk drawer unit. Its fronts are at +Z — the same side the person
|
||||
* is on — so a pack gives it the same rotation as the desk it belongs to and
|
||||
* the drawers open toward the chair.
|
||||
*/
|
||||
export const deskPedestal = defineAsset<PedestalParams>({
|
||||
id: "tera:desk.pedestal",
|
||||
label: "Desk pedestal",
|
||||
defaults: { width: 0.42, depth: 0.6, height: 0.6, drawers: 3, mobile: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.5 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const carcass = ctx.materials.get("cabinet");
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
|
||||
const lift = p.mobile ? 0.05 : 0.02;
|
||||
const bodyH = p.height - lift;
|
||||
bin.add(P.box(), carcass, { y: lift, size: [p.width, bodyH, p.depth] });
|
||||
|
||||
if (p.mobile) {
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) {
|
||||
bin.add(P.cylinder(8), trim, {
|
||||
x: sx * (p.width / 2 - 0.07),
|
||||
z: sz * (p.depth / 2 - 0.07),
|
||||
size: [0.05, lift, 0.05],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = Math.max(1, Math.round(p.drawers));
|
||||
const pitch = (bodyH - 0.03) / count;
|
||||
const face = tintable(ctx, "cabinet");
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = lift + 0.015 + i * pitch;
|
||||
bin.add(P.box(), face, {
|
||||
y,
|
||||
z: p.depth / 2,
|
||||
size: [p.width - 0.03, pitch - 0.012, 0.02],
|
||||
});
|
||||
// A recessed pull rather than a handle: a D-handle at this scale is four
|
||||
// more parts and reads as a smudge from any distance you see a pedestal.
|
||||
bin.add(P.box(), trim, {
|
||||
y: y + pitch - 0.05,
|
||||
z: p.depth / 2 + 0.012,
|
||||
size: [p.width * 0.42, 0.014, 0.012],
|
||||
});
|
||||
}
|
||||
|
||||
return bin.build("desk.pedestal");
|
||||
},
|
||||
});
|
||||
|
||||
type PartitionParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
thickness: number;
|
||||
/** Floor-standing feet. Off by default: most of these clamp to a desk. */
|
||||
feet: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A fabric screen. Authored standing on the floor like everything else, so the
|
||||
* desk-mounted case is `elevation: 0.73` in the pack rather than a `mount`
|
||||
* parameter here — the same screen clamps to a desk, stands on the floor and
|
||||
* caps a bench run, and only the pack knows which.
|
||||
*
|
||||
* The fabric is the tintable part.
|
||||
*/
|
||||
export const deskPartition = defineAsset<PartitionParams>({
|
||||
id: "tera:desk.partition",
|
||||
label: "Desk partition",
|
||||
defaults: { width: 1.4, height: 0.45, thickness: 0.04, feet: false },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.feet ? 0.34 : p.thickness + 0.02, height: p.height };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const frame = ctx.materials.get("partitionFrame");
|
||||
|
||||
panelSlab(bin, ctx, tintable(ctx, "partitionFabric"), {
|
||||
y: 0,
|
||||
width: p.width - 0.03,
|
||||
height: p.height,
|
||||
thickness: p.thickness,
|
||||
});
|
||||
|
||||
bin.add(P.box(), frame, {
|
||||
y: p.height - 0.018,
|
||||
size: [p.width, 0.018, p.thickness + 0.012],
|
||||
});
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), frame, {
|
||||
x: sx * (p.width / 2 - 0.008),
|
||||
size: [0.016, p.height, p.thickness + 0.012],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.feet) {
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), frame, {
|
||||
x: sx * (p.width / 2 - 0.1),
|
||||
size: [0.05, 0.02, 0.32],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("desk.partition");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Plants. A small one for a desk or a sill, and a tall one for a corner.
|
||||
*
|
||||
* Leaves are single quads in a double-sided `foliage` material rather than
|
||||
* modelled solids: forty cards is forty quads, a modelled leaf is a hundred
|
||||
* triangles each, and at the distance an office plant is ever seen the two look
|
||||
* the same. They are laid out on the golden angle, which is what stops a ring of
|
||||
* cards from reading as a ring, plus a little jitter from `ctx.rand` — seeded
|
||||
* per prop, so the plant on the third desk is the same plant on every reload.
|
||||
*
|
||||
* Neither takes a `colorKey`. A plant is the colour a plant is.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { clamp, jitter } from "./common.ts";
|
||||
|
||||
/** ~137.5°, the angle a real plant puts between successive leaves. */
|
||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
type PottedParams = {
|
||||
/** Overall height including the pot. */
|
||||
height: number;
|
||||
potDiameter: number;
|
||||
leaves: number;
|
||||
};
|
||||
|
||||
export const plantPotted = defineAsset<PottedParams>({
|
||||
id: "tera:plant.potted",
|
||||
label: "Potted plant",
|
||||
defaults: { height: 0.6, potDiameter: 0.28, leaves: 16 },
|
||||
|
||||
footprint(p) {
|
||||
// A plant is wider than its pot. The spread is what a passer-by brushes,
|
||||
// so it is the spread that layout should be told about.
|
||||
const spread = Math.max(p.potDiameter, (p.height - p.potDiameter * 0.6) * 0.9);
|
||||
return { width: spread, depth: spread, height: p.height };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const pot = ctx.materials.get("planter");
|
||||
const leaf = ctx.materials.get("foliage");
|
||||
|
||||
const potH = clamp(p.height * 0.42, 0.1, 0.36);
|
||||
bin.add(P.cylinder(14), pot, { size: [p.potDiameter * 0.72, potH, p.potDiameter * 0.72] });
|
||||
bin.add(P.cylinder(14), pot, {
|
||||
y: potH - 0.03,
|
||||
size: [p.potDiameter, 0.03, p.potDiameter],
|
||||
});
|
||||
bin.add(P.disc(14), pot, {
|
||||
y: potH - 0.012,
|
||||
size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9],
|
||||
});
|
||||
|
||||
const count = Math.max(3, Math.round(p.leaves));
|
||||
const reach = p.height - potH;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count;
|
||||
const length = reach * (0.55 + 0.45 * (1 - t)) * (0.85 + ctx.rand() * 0.3);
|
||||
bin.add(P.panel(), leaf, {
|
||||
y: potH - 0.02,
|
||||
size: [length * 0.34, length, 1],
|
||||
yaw: i * GOLDEN_ANGLE + jitter(ctx.rand, 0.2),
|
||||
// Outer leaves lean further out; the middle ones stand up. Pitch runs
|
||||
// in the yawed frame, so this is a lean along whichever way it faces.
|
||||
pitch: 0.25 + t * 0.8 + jitter(ctx.rand, 0.12),
|
||||
});
|
||||
}
|
||||
|
||||
return bin.build("plant.potted");
|
||||
},
|
||||
});
|
||||
|
||||
type TallParams = {
|
||||
height: number;
|
||||
potDiameter: number;
|
||||
/** Whorls of leaves up the trunk. Three is a dracaena, one is a palm. */
|
||||
tiers: number;
|
||||
};
|
||||
|
||||
/** Pitch of the lowest whorl and of the highest. The bottom droops, the top stands. */
|
||||
const TALL_DROOP = 1.35;
|
||||
const TALL_CROWN = 0.6;
|
||||
|
||||
/**
|
||||
* The one piece of arithmetic `footprint` and `build` have to agree on.
|
||||
*
|
||||
* `footprint` may not build geometry, so it cannot measure the plant; if it
|
||||
* guesses instead, the number layout uses and the shape in the room drift
|
||||
* apart. Written the first time, they had — the stated height was a fifth
|
||||
* taller than the plant, because a leaf at 60° from vertical contributes
|
||||
* `cos 60°` of its length and not all of it.
|
||||
*/
|
||||
function tallCanopy(p: TallParams): {
|
||||
potHeight: number;
|
||||
trunk: number;
|
||||
leaf: number;
|
||||
spread: number;
|
||||
} {
|
||||
const potHeight = clamp(p.height * 0.26, 0.24, 0.55);
|
||||
const trunk = (p.height - potHeight) * 0.55;
|
||||
const rise = p.height - potHeight - trunk;
|
||||
// The top whorl starts a third of the way up the canopy and reaches the rest
|
||||
// of the way with the vertical component of one leaf.
|
||||
const leaf = (rise * 0.66) / Math.cos(TALL_CROWN);
|
||||
return {
|
||||
potHeight,
|
||||
trunk,
|
||||
leaf,
|
||||
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)),
|
||||
};
|
||||
}
|
||||
|
||||
export const plantTall = defineAsset<TallParams>({
|
||||
id: "tera:plant.tall",
|
||||
label: "Tall plant",
|
||||
defaults: { height: 1.8, potDiameter: 0.44, tiers: 3 },
|
||||
|
||||
footprint(p) {
|
||||
const { spread } = tallCanopy(p);
|
||||
return { width: spread, depth: spread, height: p.height };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const pot = ctx.materials.get("planter");
|
||||
const leaf = ctx.materials.get("foliage");
|
||||
|
||||
const { potHeight: potH, trunk: trunkH, leaf: leafLen } = tallCanopy(p);
|
||||
bin.add(P.cylinder(16), pot, { size: [p.potDiameter * 0.78, potH, p.potDiameter * 0.78] });
|
||||
bin.add(P.cylinder(16), pot, {
|
||||
y: potH - 0.04,
|
||||
size: [p.potDiameter, 0.04, p.potDiameter],
|
||||
});
|
||||
bin.add(P.disc(16), pot, {
|
||||
y: potH - 0.015,
|
||||
size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9],
|
||||
});
|
||||
|
||||
// Two trunk segments with a slight kink. A single straight rod reads as a
|
||||
// pole with leaves stapled to it.
|
||||
const lean = jitter(ctx.rand, 0.05);
|
||||
bin.add(P.rod(), pot, { y: potH - 0.05, size: [0.06, trunkH * 0.6, 0.06], roll: lean });
|
||||
bin.add(P.rod(), pot, {
|
||||
x: -Math.sin(lean) * trunkH * 0.6,
|
||||
y: potH - 0.05 + trunkH * 0.6,
|
||||
size: [0.05, trunkH * 0.5, 0.05],
|
||||
roll: -lean * 0.6,
|
||||
});
|
||||
|
||||
// Whorls from the droop at the bottom to the crown at the top, stacked over
|
||||
// the last third of the canopy. The lowest blades arch over and hang, which
|
||||
// is the silhouette that makes this read as a plant rather than a sheaf.
|
||||
const tiers = Math.max(1, Math.round(p.tiers));
|
||||
const rise = p.height - potH - trunkH;
|
||||
let n = 0;
|
||||
for (let tier = 0; tier < tiers; tier++) {
|
||||
const t = tiers === 1 ? 1 : tier / (tiers - 1);
|
||||
const y = potH + trunkH + rise * 0.34 * t;
|
||||
const blades = 7 - tier;
|
||||
for (let i = 0; i < blades; i++) {
|
||||
bin.add(P.panel(), leaf, {
|
||||
y,
|
||||
size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1],
|
||||
yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25),
|
||||
pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("plant.tall");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The `tera:` office catalogue — every built-in asset, in one list.
|
||||
*
|
||||
* Importing this module registers all of them into the shared `kit`, which is
|
||||
* what `kit.ts` says that registry is for. A self-hoster who wants their own
|
||||
* registry instead calls `registerOfficeAssets(mine)`; one who wants ours plus
|
||||
* theirs registers `acme:desk.standing` with `overrides: "tera:desk.workstation"`
|
||||
* afterwards and every desk in every pack becomes theirs, with no fork.
|
||||
*
|
||||
* ### What is deliberately not here
|
||||
*
|
||||
* There is no `shell.wall`, `shell.door` or `shell.window` (CONTRACT.md §2).
|
||||
* Walls are the `Floorplan`'s wall runs and a door or a window is an `Opening`
|
||||
* punched out of one; `Plan` hands each solid run left over to the `wallRun`
|
||||
* part in `parts.ts`. Shipping door props beside door-shaped holes would put
|
||||
* every opening in the scene twice, or — worse, because it is invisible until
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
|
||||
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
|
||||
import { plantPotted, plantTall } from "./greenery.ts";
|
||||
import { lightPendant, lightTroffer } from "./lighting.ts";
|
||||
import { screenMonitor, screenWallDisplay } from "./screens.ts";
|
||||
import { seatLounge, seatTaskChair } from "./seating.ts";
|
||||
import { storageLocker, storageShelf } from "./storage.ts";
|
||||
import { rug, whiteboard } from "./surfaces.ts";
|
||||
import { tableMeeting, tableSide } from "./tables.ts";
|
||||
|
||||
export const OFFICE_ASSETS: readonly AnyAsset[] = [
|
||||
deskWorkstation,
|
||||
deskPedestal,
|
||||
deskPartition,
|
||||
seatTaskChair,
|
||||
seatLounge,
|
||||
tableMeeting,
|
||||
tableSide,
|
||||
storageShelf,
|
||||
storageLocker,
|
||||
screenMonitor,
|
||||
screenWallDisplay,
|
||||
plantPotted,
|
||||
plantTall,
|
||||
lightPendant,
|
||||
lightTroffer,
|
||||
rug,
|
||||
whiteboard,
|
||||
];
|
||||
|
||||
/** Register the built-in catalogue into a registry. Defaults to the shared one. */
|
||||
export function registerOfficeAssets(registry: AssetRegistry = kit): AssetRegistry {
|
||||
return registry.registerAll(OFFICE_ASSETS);
|
||||
}
|
||||
|
||||
registerOfficeAssets();
|
||||
|
||||
export {
|
||||
deskPartition,
|
||||
deskPedestal,
|
||||
deskWorkstation,
|
||||
lightPendant,
|
||||
lightTroffer,
|
||||
plantPotted,
|
||||
plantTall,
|
||||
rug,
|
||||
screenMonitor,
|
||||
screenWallDisplay,
|
||||
seatLounge,
|
||||
seatTaskChair,
|
||||
storageLocker,
|
||||
storageShelf,
|
||||
tableMeeting,
|
||||
tableSide,
|
||||
whiteboard,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Luminaires: a pendant and a recessed troffer.
|
||||
*
|
||||
* These are the two assets in the library whose datum is the ceiling rather
|
||||
* than the floor. Their origin is the **mounting plane** and all of their
|
||||
* geometry is 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 of a room they have never seen. `footprint().height` is the
|
||||
* total drop.
|
||||
*
|
||||
* They emit no light. The office's lighting is a fixed rig owned by the scene
|
||||
* (CONTRACT.md §4), and a hundred fixtures each carrying a `PointLight` is both
|
||||
* the wrong owner and, past about four shadow-casting lights, the end of the
|
||||
* frame budget. What a fixture contributes is a glowing `lightDiffuser`, which
|
||||
* is what you actually see.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
|
||||
/**
|
||||
* A diffuser is the one thing in the office that should never cast a shadow —
|
||||
* it is the thing the light is coming out of, and a lamp that shadows the room
|
||||
* beneath it looks broken in a way nobody can name.
|
||||
*/
|
||||
function litGroup(name: string, body: MeshBin, glow: MeshBin): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
group.add(body.build(`${name}:body`));
|
||||
group.add(glow.build(`${name}:glow`, { castShadow: false, receiveShadow: false }));
|
||||
return group;
|
||||
}
|
||||
|
||||
type PendantParams = {
|
||||
/** Mounting plane to the bottom of the shade. */
|
||||
drop: number;
|
||||
shadeDiameter: number;
|
||||
shadeHeight: number;
|
||||
};
|
||||
|
||||
export const lightPendant = defineAsset<PendantParams>({
|
||||
id: "tera:light.pendant",
|
||||
label: "Pendant lamp",
|
||||
defaults: { drop: 0.9, shadeDiameter: 0.34, shadeHeight: 0.22 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.shadeDiameter, depth: p.shadeDiameter, height: p.drop };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const body = new MeshBin();
|
||||
const glow = new MeshBin();
|
||||
const housing = ctx.materials.get("lightHousing");
|
||||
|
||||
// A ceiling rose, then the flex, then the shade. The rose matters more than
|
||||
// it should: a cord that stops dead at the ceiling plane reads as a
|
||||
// modelling mistake from the first time anybody looks up.
|
||||
body.add(P.cylinder(12), housing, { y: -0.028, size: [0.1, 0.028, 0.1] });
|
||||
|
||||
const cord = Math.max(0.02, p.drop - p.shadeHeight);
|
||||
body.add(P.rod(), housing, { y: -cord, size: [0.012, cord, 0.012] });
|
||||
// The cone's wide end is its base, so dropped to the bottom of the shade it
|
||||
// is already the right way up for a pendant.
|
||||
body.add(P.cone(20), housing, {
|
||||
y: -p.drop,
|
||||
size: [p.shadeDiameter, p.shadeHeight, p.shadeDiameter],
|
||||
});
|
||||
glow.add(P.disc(20), ctx.materials.get("lightDiffuser"), {
|
||||
y: -p.drop + 0.006,
|
||||
size: [p.shadeDiameter * 0.9, 1, p.shadeDiameter * 0.9],
|
||||
// A floor-plane disc faces up. Flipped, it faces the room.
|
||||
pitch: Math.PI,
|
||||
});
|
||||
|
||||
return litGroup("light.pendant", body, glow);
|
||||
},
|
||||
});
|
||||
|
||||
type TrofferParams = {
|
||||
length: number;
|
||||
width: number;
|
||||
/** How far the housing hangs below the mounting plane. */
|
||||
housingDepth: number;
|
||||
};
|
||||
|
||||
export const lightTroffer = defineAsset<TrofferParams>({
|
||||
id: "tera:light.troffer",
|
||||
label: "Ceiling troffer",
|
||||
defaults: { length: 1.2, width: 0.3, housingDepth: 0.08 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.length, depth: p.width, height: p.housingDepth };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const body = new MeshBin();
|
||||
const glow = new MeshBin();
|
||||
|
||||
const tray = Math.max(0.02, p.housingDepth - 0.02);
|
||||
body.add(P.box(), ctx.materials.get("lightHousing"), {
|
||||
y: -tray,
|
||||
size: [p.length, tray, p.width],
|
||||
});
|
||||
// The frame is four bars rather than a slab behind the diffuser: from below
|
||||
// — the only angle a recessed fitting is ever seen from — a slab is
|
||||
// invisible and the bars are the whole of what reads as a light fitting.
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
const bar = 0.02;
|
||||
for (const sz of [-1, 1]) {
|
||||
body.add(P.box(), trim, {
|
||||
y: -p.housingDepth,
|
||||
z: (sz * (p.width - bar)) / 2,
|
||||
size: [p.length, bar, bar],
|
||||
});
|
||||
body.add(P.box(), trim, {
|
||||
x: (sz * (p.length - bar)) / 2,
|
||||
y: -p.housingDepth,
|
||||
size: [bar, bar, p.width - 2 * bar],
|
||||
});
|
||||
}
|
||||
glow.add(P.box(), ctx.materials.get("lightDiffuser"), {
|
||||
y: -p.housingDepth + 0.002,
|
||||
size: [p.length - 2 * bar, 0.018, p.width - 2 * bar],
|
||||
});
|
||||
|
||||
return litGroup("light.troffer", body, glow);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Screens: the one on a desk and the one on a wall.
|
||||
*
|
||||
* Both have their glass at +Z, facing the person, and both are authored with
|
||||
* their origin on the floor. The monitor's foot sits at `y = 0` and a pack
|
||||
* raises it onto a desk with `Prop.elevation`; the wall display carries its own
|
||||
* `mount` height, because how high a display hangs is a property of the display
|
||||
* and not of the room it is in.
|
||||
*
|
||||
* Neither takes a `colorKey`. A screen is bezel and glass, and there is no part
|
||||
* of it that anybody wants to be the colour of a team.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { alongFacing } from "./common.ts";
|
||||
|
||||
type MonitorParams = {
|
||||
/** Bezel width, metres. 0.56 is a 24-inch panel. */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Floor of the stand to the bottom of the bezel. */
|
||||
standHeight: number;
|
||||
/** Radians the panel leans back. */
|
||||
tilt: number;
|
||||
};
|
||||
|
||||
export const screenMonitor = defineAsset<MonitorParams>({
|
||||
id: "tera:screen.monitor",
|
||||
label: "Monitor",
|
||||
defaults: { width: 0.56, height: 0.34, standHeight: 0.14, tilt: 0.07 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: 0.19, height: p.standHeight + p.height + 0.02 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
const bezel = ctx.materials.get("screenBezel");
|
||||
|
||||
bin.add(P.box(), trim, { size: [p.width * 0.4, 0.016, 0.15] });
|
||||
// The neck runs a few centimetres past the bottom of the bezel, so the
|
||||
// joint is hidden behind the panel however far it is tilted.
|
||||
bin.add(P.box(), trim, { y: 0.01, z: -0.02, size: [0.055, p.standHeight + 0.07, 0.045] });
|
||||
|
||||
const baseY = p.standHeight + 0.02;
|
||||
const pitch = -p.tilt;
|
||||
const front = alongFacing(pitch, 0.014);
|
||||
bin.add(P.roundedBox(0.03), bezel, {
|
||||
y: baseY,
|
||||
size: [p.width, p.height, 0.024],
|
||||
pitch,
|
||||
});
|
||||
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
|
||||
y: baseY + 0.012 + front.y,
|
||||
z: front.z,
|
||||
size: [p.width - 0.018, p.height - 0.026, 1],
|
||||
pitch,
|
||||
});
|
||||
|
||||
return bin.build("screen.monitor");
|
||||
},
|
||||
});
|
||||
|
||||
type WallDisplayParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Floor to the bottom edge of the screen. */
|
||||
mount: number;
|
||||
};
|
||||
|
||||
export const screenWallDisplay = defineAsset<WallDisplayParams>({
|
||||
id: "tera:screen.wall-display",
|
||||
label: "Wall display",
|
||||
defaults: { width: 1.62, height: 0.94, mount: 0.86 },
|
||||
|
||||
footprint(p) {
|
||||
// The depth is the whole assembly off the wall face, bracket included, so a
|
||||
// pack can push the prop `depth / 2` off the wall and have it sit flush.
|
||||
return { width: p.width, depth: 0.12, height: p.mount + p.height };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
|
||||
bin.add(P.box(), ctx.materials.get("metalTrim"), {
|
||||
y: p.mount + p.height / 2 - 0.16,
|
||||
z: -0.045,
|
||||
size: [0.44, 0.32, 0.04],
|
||||
});
|
||||
bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), {
|
||||
y: p.mount,
|
||||
size: [p.width, p.height, 0.05],
|
||||
});
|
||||
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
|
||||
y: p.mount + 0.014,
|
||||
z: 0.027,
|
||||
size: [p.width - 0.024, p.height - 0.028, 1],
|
||||
});
|
||||
|
||||
return bin.build("screen.wall-display");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Chairs. Two of them: the one at a desk and the one you wait in.
|
||||
*
|
||||
* Both face −Z, which puts the backrest at +Z — a chair at yaw zero has its
|
||||
* occupant looking the same way a desk at yaw zero does, and that is the whole
|
||||
* reason `DeskBank` can hand one rotation to both.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { tintable } from "./common.ts";
|
||||
|
||||
type TaskChairParams = {
|
||||
width: number;
|
||||
/** Height of the seat pan, metres. */
|
||||
seatHeight: number;
|
||||
backHeight: number;
|
||||
arms: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A five-star task chair. The fabric is the tintable part, which is why a
|
||||
* `colorKey` on a chair reads as upholstery and not as a coloured base.
|
||||
*/
|
||||
export const seatTaskChair = defineAsset<TaskChairParams>({
|
||||
id: "tera:seat.task-chair",
|
||||
label: "Task chair",
|
||||
defaults: { width: 0.5, seatHeight: 0.46, backHeight: 0.56, arms: true },
|
||||
|
||||
footprint(p) {
|
||||
// The star base is the widest part of a task chair and it is wider than the
|
||||
// seat. 0.64 m is a 320 mm arm, which is a real chair.
|
||||
const span = Math.max(0.64, p.width + 0.14);
|
||||
return { width: span, depth: span, height: p.seatHeight + p.backHeight, clearance: 0.3 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const base = ctx.materials.get("chairBase");
|
||||
const shell = ctx.materials.get("chairShell");
|
||||
const fabric = tintable(ctx, "chairFabric");
|
||||
|
||||
const reach = 0.28;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const yaw = (i * Math.PI * 2) / 5;
|
||||
const sin = Math.sin(yaw);
|
||||
const cos = Math.cos(yaw);
|
||||
bin.add(P.box(), base, {
|
||||
x: sin * reach * 0.5,
|
||||
y: 0.035,
|
||||
z: cos * reach * 0.5,
|
||||
size: [0.05, 0.03, reach],
|
||||
yaw,
|
||||
});
|
||||
bin.add(P.cylinder(8), base, {
|
||||
x: sin * reach,
|
||||
z: cos * reach,
|
||||
size: [0.055, 0.048, 0.055],
|
||||
});
|
||||
}
|
||||
|
||||
const panY = p.seatHeight - 0.08;
|
||||
bin.add(P.rod(), base, { y: 0.05, size: [0.06, panY - 0.05, 0.06] });
|
||||
// The parts sharing a material must all be indexed or all not be, or the
|
||||
// merge fails and drops the material entirely (see `common.ts`). Extruded
|
||||
// `roundedBox` carries no index, so the shell keeps to plain boxes and the
|
||||
// soft parts — arm pads included — go in with the fabric.
|
||||
bin.add(P.box(), shell, { y: panY - 0.04, size: [p.width * 0.6, 0.05, 0.24] });
|
||||
bin.add(P.roundedBox(0.07), fabric, { y: panY, size: [p.width, 0.08, 0.48] });
|
||||
|
||||
// The backrest leans back by rotating about its own base, so the lumbar
|
||||
// stays where the spine is and only the shoulders move.
|
||||
const lean = 0.13;
|
||||
bin.add(P.box(), shell, { y: panY, z: 0.2, size: [0.08, 0.14, 0.14] });
|
||||
bin.add(P.roundedBox(0.06), fabric, {
|
||||
y: p.seatHeight + 0.04,
|
||||
z: 0.21,
|
||||
size: [p.width - 0.05, p.backHeight - 0.04, 0.06],
|
||||
pitch: lean,
|
||||
});
|
||||
|
||||
if (p.arms) {
|
||||
for (const sx of [-1, 1]) {
|
||||
const x = sx * (p.width / 2 + 0.02);
|
||||
bin.add(P.box(), shell, { x, y: panY, size: [0.03, 0.19, 0.03] });
|
||||
bin.add(P.roundedBox(0.08), fabric, {
|
||||
x,
|
||||
y: panY + 0.19,
|
||||
size: [0.06, 0.025, 0.24],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("seat.task-chair");
|
||||
},
|
||||
});
|
||||
|
||||
type LoungeParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
seatHeight: number;
|
||||
backHeight: number;
|
||||
arms: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A low armchair for a breakout or a reception. `width: 1.6, arms: true` is a
|
||||
* two-seat sofa and looks like one, which is why there is no separate sofa
|
||||
* asset — the difference between the two is one number.
|
||||
*
|
||||
* The upholstery is the tintable part.
|
||||
*/
|
||||
export const seatLounge = defineAsset<LoungeParams>({
|
||||
id: "tera:seat.lounge",
|
||||
label: "Lounge chair",
|
||||
defaults: { width: 0.84, depth: 0.82, seatHeight: 0.4, backHeight: 0.76, arms: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.backHeight, clearance: 0.5 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const base = ctx.materials.get("chairBase");
|
||||
const shell = ctx.materials.get("chairShell");
|
||||
const cushion = tintable(ctx, "upholstery");
|
||||
|
||||
const foot = 0.08;
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) {
|
||||
bin.add(P.rod(), base, {
|
||||
x: sx * (p.width / 2 - 0.1),
|
||||
z: sz * (p.depth / 2 - 0.1),
|
||||
size: [0.045, foot, 0.045],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cushionH = 0.16;
|
||||
const seatY = p.seatHeight - cushionH;
|
||||
bin.add(P.box(), shell, {
|
||||
y: foot,
|
||||
size: [p.width - 0.12, seatY - foot, p.depth - 0.14],
|
||||
});
|
||||
bin.add(P.roundedBox(0.07), cushion, {
|
||||
y: seatY,
|
||||
z: 0.02,
|
||||
size: [p.width - (p.arms ? 0.3 : 0.08), cushionH, p.depth - 0.2],
|
||||
});
|
||||
|
||||
// The back cushion leans, and it is the lean that stops a lounge chair from
|
||||
// reading as a cardboard box with a pillow on it.
|
||||
bin.add(P.roundedBox(0.07), cushion, {
|
||||
y: seatY + 0.02,
|
||||
z: p.depth / 2 - 0.12,
|
||||
size: [p.width - (p.arms ? 0.3 : 0.08), p.backHeight - seatY - 0.02, 0.16],
|
||||
pitch: 0.12,
|
||||
});
|
||||
|
||||
if (p.arms) {
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.roundedBox(0.09), cushion, {
|
||||
x: sx * (p.width / 2 - 0.07),
|
||||
y: foot,
|
||||
size: [0.14, p.seatHeight + 0.18 - foot, p.depth - 0.1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("seat.lounge");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Storage: an open shelf unit and a bank of lockers.
|
||||
*
|
||||
* Both are used from +Z and have a solid back at −Z, so a pack stands one
|
||||
* against a wall by giving it the rotation that turns its back to the wall —
|
||||
* the same rotation it would give a person standing in front of it.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { clamp, jitter, tintable } from "./common.ts";
|
||||
|
||||
type ShelfParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
/** Open bays, not boards. Four bays is five boards. */
|
||||
shelves: number;
|
||||
/** Fill the bays with books. Seeded from `ctx.rand`, so it is stable. */
|
||||
books: boolean;
|
||||
};
|
||||
|
||||
const BOARD = 0.02;
|
||||
|
||||
/** An open shelf unit. The boards are the tintable part. */
|
||||
export const storageShelf = defineAsset<ShelfParams>({
|
||||
id: "tera:storage.shelf",
|
||||
label: "Shelf unit",
|
||||
defaults: { width: 0.9, depth: 0.35, height: 1.6, shelves: 4, books: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.6 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const board = tintable(ctx, "shelf");
|
||||
const bays = Math.max(1, Math.round(p.shelves));
|
||||
const bayH = (p.height - (bays + 1) * BOARD) / bays;
|
||||
const inner = p.width - 2 * BOARD;
|
||||
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), board, {
|
||||
x: sx * (p.width - BOARD) / 2,
|
||||
size: [BOARD, p.height, p.depth],
|
||||
});
|
||||
}
|
||||
bin.add(P.box(), ctx.materials.get("cabinet"), {
|
||||
z: -(p.depth / 2 - 0.006),
|
||||
size: [inner, p.height, 0.012],
|
||||
});
|
||||
|
||||
for (let i = 0; i <= bays; i++) {
|
||||
bin.add(P.box(), board, {
|
||||
y: i * (bayH + BOARD),
|
||||
size: [inner, BOARD, p.depth],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.books) {
|
||||
// Books are three materials and nothing else, so a full wall of shelving
|
||||
// is three more merged meshes rather than three hundred.
|
||||
const spines = [
|
||||
ctx.materials.get("paper"),
|
||||
ctx.materials.get("accent"),
|
||||
ctx.materials.get("cabinet"),
|
||||
];
|
||||
for (let i = 0; i < bays; i++) {
|
||||
const shelfY = i * (bayH + BOARD) + BOARD;
|
||||
let x = -inner / 2 + 0.015;
|
||||
while (x < inner / 2 - 0.06) {
|
||||
if (ctx.rand() < 0.14) {
|
||||
x += 0.04 + ctx.rand() * 0.08;
|
||||
continue;
|
||||
}
|
||||
const w = 0.018 + ctx.rand() * 0.038;
|
||||
const h = bayH * (0.62 + ctx.rand() * 0.26);
|
||||
const material = spines[Math.floor(ctx.rand() * spines.length)] ?? spines[0];
|
||||
if (!material) break;
|
||||
bin.add(P.box(), material, {
|
||||
x: x + w / 2,
|
||||
y: shelfY,
|
||||
z: 0.02 + jitter(ctx.rand, 0.015),
|
||||
size: [w, h, clamp(p.depth * 0.6, 0.12, 0.26)],
|
||||
roll: jitter(ctx.rand, 0.03),
|
||||
});
|
||||
x += w + 0.004;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("storage.shelf");
|
||||
},
|
||||
});
|
||||
|
||||
type LockerParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
columns: number;
|
||||
/** Doors stacked per column. Two is the usual personal-locker bank. */
|
||||
tiers: number;
|
||||
};
|
||||
|
||||
/** A bank of lockers. The doors are the tintable part. */
|
||||
export const storageLocker = defineAsset<LockerParams>({
|
||||
id: "tera:storage.locker",
|
||||
label: "Locker bank",
|
||||
defaults: { width: 1.2, depth: 0.5, height: 1.8, columns: 3, tiers: 2 },
|
||||
|
||||
footprint(p) {
|
||||
// A door has to swing, and a locker with a metre of nothing in front of it
|
||||
// is the difference between a corridor and a corridor you can use.
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const carcass = ctx.materials.get("cabinet");
|
||||
const door = tintable(ctx, "cabinet");
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
|
||||
const plinth = 0.08;
|
||||
bin.add(P.box(), trim, { y: 0, size: [p.width - 0.04, plinth, p.depth - 0.04] });
|
||||
bin.add(P.box(), carcass, {
|
||||
y: plinth,
|
||||
size: [p.width, p.height - plinth, p.depth],
|
||||
});
|
||||
|
||||
const columns = Math.max(1, Math.round(p.columns));
|
||||
const tiers = Math.max(1, Math.round(p.tiers));
|
||||
const cellW = p.width / columns;
|
||||
const cellH = (p.height - plinth) / tiers;
|
||||
|
||||
for (let c = 0; c < columns; c++) {
|
||||
const x = -p.width / 2 + cellW * (c + 0.5);
|
||||
for (let t = 0; t < tiers; t++) {
|
||||
const y = plinth + cellH * t + 0.008;
|
||||
bin.add(P.box(), door, {
|
||||
x,
|
||||
y,
|
||||
z: p.depth / 2,
|
||||
size: [cellW - 0.016, cellH - 0.016, 0.022],
|
||||
});
|
||||
// A vertical pull on the leading edge, plus the vent slot that is the
|
||||
// one detail that makes a painted box read as a locker.
|
||||
bin.add(P.box(), trim, {
|
||||
x: x + cellW / 2 - 0.05,
|
||||
y: y + cellH * 0.32,
|
||||
z: p.depth / 2 + 0.016,
|
||||
size: [0.016, cellH * 0.3, 0.016],
|
||||
});
|
||||
for (let s = 0; s < 3; s++) {
|
||||
bin.add(P.box(), trim, {
|
||||
x,
|
||||
y: y + cellH - 0.09 + s * 0.022,
|
||||
z: p.depth / 2 + 0.012,
|
||||
size: [cellW * 0.4, 0.008, 0.006],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("storage.locker");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The two flat things: a rug on the floor and a board on the wall.
|
||||
*
|
||||
* Neither is part of the shell. A room's floor finish and its walls come from
|
||||
* the `Floorplan` (CONTRACT.md §2); a rug is a prop laid on top of whatever the
|
||||
* room's floor already is, which is exactly how a rug works.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { panelSlab, tintable } from "./common.ts";
|
||||
|
||||
type RugParams = {
|
||||
width: number;
|
||||
depth: number;
|
||||
/** Pile thickness. Small, but not zero — a rug at zero z-fights the floor. */
|
||||
pile: number;
|
||||
/** A plain band of the base carpet around the tinted field. */
|
||||
border: boolean;
|
||||
};
|
||||
|
||||
/** The pile is the tintable part; the border, when there is one, is not. */
|
||||
export const rug = defineAsset<RugParams>({
|
||||
id: "tera:rug",
|
||||
label: "Rug",
|
||||
defaults: { width: 2.4, depth: 1.7, pile: 0.014, border: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.pile };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const bin = new MeshBin();
|
||||
const field = tintable(ctx, "carpetAccent");
|
||||
const edge = ctx.materials.get("carpet");
|
||||
|
||||
bin.add(ctx.parts.box(), field, { size: [p.width, p.pile, p.depth] });
|
||||
|
||||
if (p.border) {
|
||||
const band = Math.min(0.12, Math.min(p.width, p.depth) * 0.08);
|
||||
bin.add(ctx.parts.metricQuad(p.width, p.depth), edge, { y: p.pile + 0.0006 });
|
||||
bin.add(ctx.parts.metricQuad(p.width - band * 2, p.depth - band * 2), field, {
|
||||
y: p.pile + 0.0012,
|
||||
});
|
||||
} else {
|
||||
bin.add(ctx.parts.metricQuad(p.width, p.depth), field, { y: p.pile + 0.0006 });
|
||||
}
|
||||
|
||||
return bin.build("rug", { castShadow: false });
|
||||
},
|
||||
});
|
||||
|
||||
type WhiteboardParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Floor to the bottom edge of the writing surface. */
|
||||
mount: number;
|
||||
tray: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A wall-hung board. Authored standing on the floor with the writing surface
|
||||
* from `mount` up, so a pack places it against the wall and never has to work
|
||||
* out how high a whiteboard goes.
|
||||
*
|
||||
* The −Z face is skipped: it is against a wall, and drawing it would put a
|
||||
* second sheet of whiteboard texture into the merge for a surface nobody can
|
||||
* ever see.
|
||||
*/
|
||||
export const whiteboard = defineAsset<WhiteboardParams>({
|
||||
id: "tera:whiteboard",
|
||||
label: "Whiteboard",
|
||||
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: 0.1, height: p.mount + p.height };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
|
||||
panelSlab(bin, ctx, ctx.materials.get("whiteboard"), {
|
||||
y: p.mount,
|
||||
width: p.width - 0.05,
|
||||
height: p.height - 0.05,
|
||||
thickness: 0.022,
|
||||
faces: "front",
|
||||
});
|
||||
|
||||
const bar = 0.025;
|
||||
for (const s of [-1, 1]) {
|
||||
bin.add(P.box(), trim, {
|
||||
y: p.mount + (s < 0 ? 0 : p.height - bar),
|
||||
size: [p.width, bar, 0.03],
|
||||
});
|
||||
bin.add(P.box(), trim, {
|
||||
x: (s * (p.width - bar)) / 2,
|
||||
y: p.mount,
|
||||
size: [bar, p.height, 0.03],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.tray) {
|
||||
bin.add(P.box(), trim, {
|
||||
y: p.mount - 0.03,
|
||||
z: 0.035,
|
||||
size: [p.width * 0.55, 0.016, 0.07],
|
||||
});
|
||||
const pens = ctx.materials.get("accent");
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Rolled a quarter turn, a rod lies along −X from where it is placed.
|
||||
bin.add(P.rod(), pens, {
|
||||
x: 0.02 + i * 0.045,
|
||||
y: p.mount - 0.014,
|
||||
z: 0.04,
|
||||
size: [0.014, 0.13, 0.014],
|
||||
roll: Math.PI / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bin.build("whiteboard");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Tables. A meeting table that is either a rectangle on trestles or a round one
|
||||
* on a pedestal, and the small round one that goes between two lounge chairs.
|
||||
*
|
||||
* A table has no front, so its rotation only matters for a rectangle. Its
|
||||
* footprint is still centred on its origin like everything else.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { slab } from "./common.ts";
|
||||
|
||||
type MeetingParams = {
|
||||
/** Length along local X. For `shape: "round"` this is the diameter. */
|
||||
length: number;
|
||||
/** Depth along local Z. Ignored when round. */
|
||||
width: number;
|
||||
height: number;
|
||||
shape: "rect" | "round";
|
||||
legs: "trestle" | "post";
|
||||
};
|
||||
|
||||
const TOP = 0.04;
|
||||
|
||||
export const tableMeeting = defineAsset<MeetingParams>({
|
||||
id: "tera:table.meeting",
|
||||
label: "Meeting table",
|
||||
defaults: { length: 2.4, width: 1.2, height: 0.74, shape: "rect", legs: "trestle" },
|
||||
|
||||
footprint(p) {
|
||||
const depth = p.shape === "round" ? p.length : p.width;
|
||||
// A metre of clearance is a chair pushed back plus somebody edging past it,
|
||||
// which is what the room around a meeting table has to actually allow for.
|
||||
return { width: p.length, depth, height: p.height, clearance: 1 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const frame = ctx.materials.get("deskFrame");
|
||||
const top = ctx.materials.get("tableTop");
|
||||
const deckY = p.height - TOP;
|
||||
|
||||
if (p.shape === "round") {
|
||||
// A round top takes its map as one sheet across the whole disc, which is
|
||||
// what a veneered table looks like anyway; `metricQuad` is a rectangle and
|
||||
// has nothing to say about a circle.
|
||||
bin.add(P.cylinder(28), top, { y: deckY, size: [p.length, TOP, p.length] });
|
||||
bin.add(P.cylinder(20), frame, { y: 0.02, size: [0.14, deckY - 0.02, 0.14] });
|
||||
bin.add(P.cylinder(24), frame, { size: [p.length * 0.42, 0.03, p.length * 0.42] });
|
||||
return bin.build("table.meeting");
|
||||
}
|
||||
|
||||
slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP });
|
||||
|
||||
if (p.legs === "post") {
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) {
|
||||
bin.add(P.rod(), frame, {
|
||||
x: sx * (p.length / 2 - 0.12),
|
||||
z: sz * (p.width / 2 - 0.12),
|
||||
size: [0.07, deckY, 0.07],
|
||||
});
|
||||
}
|
||||
}
|
||||
return bin.build("table.meeting");
|
||||
}
|
||||
|
||||
const endX = Math.max(0.2, p.length / 2 - 0.36);
|
||||
for (const sx of [-1, 1]) {
|
||||
const x = sx * endX;
|
||||
bin.add(P.box(), frame, { x, size: [0.09, 0.05, p.width - 0.24] });
|
||||
bin.add(P.box(), frame, { x, y: 0.05, size: [0.1, deckY - 0.05, 0.1] });
|
||||
bin.add(P.box(), frame, { x, y: deckY - 0.09, size: [0.12, 0.09, p.width - 0.3] });
|
||||
}
|
||||
bin.add(P.box(), frame, { y: deckY - 0.24, size: [endX * 2, 0.09, 0.09] });
|
||||
|
||||
return bin.build("table.meeting");
|
||||
},
|
||||
});
|
||||
|
||||
type SideParams = {
|
||||
diameter: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/** The little round one. Coffee height by default. */
|
||||
export const tableSide = defineAsset<SideParams>({
|
||||
id: "tera:table.side",
|
||||
label: "Side table",
|
||||
defaults: { diameter: 0.5, height: 0.45 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.diameter, depth: p.diameter, height: p.height, clearance: 0.2 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const frame = ctx.materials.get("metalTrim");
|
||||
const deckY = p.height - 0.028;
|
||||
|
||||
bin.add(P.cylinder(24), ctx.materials.get("tableTop"), {
|
||||
y: deckY,
|
||||
size: [p.diameter, 0.028, p.diameter],
|
||||
});
|
||||
bin.add(P.cylinder(12), frame, { y: 0.015, size: [0.06, deckY - 0.015, 0.06] });
|
||||
bin.add(P.cylinder(20), frame, { size: [p.diameter * 0.62, 0.015, p.diameter * 0.62] });
|
||||
|
||||
return bin.build("table.side");
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user