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,73 @@
|
||||
ARTISTIC OUTPUT — CC0 1.0 PUBLIC DOMAIN DEDICATION
|
||||
==================================================
|
||||
|
||||
This file states an additional grant made by the copyright holders of the
|
||||
material described below. It is not a property of this directory, and it does
|
||||
not attach to anything by virtue of where a file sits: it is a dedication those
|
||||
holders have made, recorded here so that a downstream consumer can rely on it.
|
||||
|
||||
|
||||
WHAT IS DEDICATED
|
||||
-----------------
|
||||
|
||||
The copyright holders of the material under src/assets/ dedicate the ARTISTIC
|
||||
OUTPUT of that material to the public domain under the Creative Commons CC0 1.0
|
||||
Universal Public Domain Dedication.
|
||||
|
||||
"Artistic output" means the visual and geometric result of running this code:
|
||||
the meshes, geometry, textures, materials, colour palettes and images it
|
||||
produces, and any file exported from them. Tera's assets are procedural, so
|
||||
the art has no existence as a committed file — it is computed at runtime. That
|
||||
is precisely why the dedication has to name the output rather than a set of
|
||||
paths. A mesh generated from this library is CC0, whether it was generated in
|
||||
this repository, in a self-hoster's deployment, or in an unrelated project that
|
||||
imported one function.
|
||||
|
||||
To the extent possible under law, those copyright holders have waived all
|
||||
copyright and related or neighbouring rights to that artistic output. The work
|
||||
is published from the United States.
|
||||
|
||||
The full text of CC0 1.0 is at:
|
||||
|
||||
https://creativecommons.org/publicdomain/zero/1.0/legalcode
|
||||
|
||||
Where a waiver is not possible under applicable law, the copyright holders
|
||||
grant to each person affected a royalty-free, non-transferable,
|
||||
non-sublicensable, non-exclusive, irrevocable and unconditional licence to
|
||||
exercise their rights in the artistic output for any purpose and by any means,
|
||||
for the duration of those rights. Where even that is not possible, they waive
|
||||
any right to assert those rights against any use of the artistic output.
|
||||
|
||||
No warranty of any kind is given, and no rights in any trademark, patent or
|
||||
right of publicity of any person are licensed or waived here.
|
||||
|
||||
|
||||
WHAT IS NOT
|
||||
-----------
|
||||
|
||||
The SOURCE CODE under src/assets/ — the TypeScript modules, the registry, the
|
||||
parameterisation, the algorithms — remains licensed under the Apache License,
|
||||
Version 2.0, along with the rest of this repository. See LICENSE, and NOTICE
|
||||
for the notices Apache 2.0 requires.
|
||||
|
||||
The two grants sit on top of each other and neither replaces the other. Use the
|
||||
code, and Apache 2.0 applies with its NOTICE obligation. Use a mesh that came
|
||||
out of the code, and CC0 applies with no obligation at all. The second is the
|
||||
point of the arrangement: geometry ought to be able to leave this project
|
||||
without dragging an attribution requirement into somebody else's asset pipeline.
|
||||
|
||||
|
||||
WHY IT IS SAID HERE AND NOW
|
||||
---------------------------
|
||||
|
||||
This file landed in the same commit as the first file under src/assets/, before
|
||||
any asset existed to relicense. Art is close to impossible to relicense once
|
||||
contributors exist, and a library of desks, chairs, partitions and floor
|
||||
finishes accumulates contributors quickly. See CONTRACT.md §3.1.
|
||||
|
||||
The corresponding inbound grant is in CONTRIBUTING.md and is STANDING rather
|
||||
than per-contribution. Apache 2.0 §5 supplies a default inbound=outbound grant
|
||||
for Apache-2.0 only; there is no default inbound CC0. Without that standing
|
||||
grant, one merged contribution whose author never said the words would leave
|
||||
that contribution Apache-only and make this file's claim false — and, because
|
||||
copyright cannot be taken back, unfixable.
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* The asset registry: what ids exist, what they build, and how a self-hoster
|
||||
* replaces one without forking the repo.
|
||||
*
|
||||
* There is **one** registry, not two. `Prop.kind` in an office pack is an
|
||||
* `AssetId` and resolves here (CONTRACT.md §3); there is no separate table of
|
||||
* things you are allowed to put in a room. An office pack is data, this is the
|
||||
* code that turns data into geometry, and the arrow only points one way —
|
||||
* `interiors/types.ts` declares `AssetId` as a loose `string` precisely so that
|
||||
* a pack can be parsed, validated and stored without importing a mesh library.
|
||||
*
|
||||
* ### Overrides are the point
|
||||
*
|
||||
* `tera:` is the namespace this repo ships. Somebody running their own office
|
||||
* registers `acme:desk.standing` with `overrides: "tera:desk.workstation"`, and
|
||||
* every desk in the reference pack becomes theirs — no fork, no edit to the
|
||||
* pack, no patch to keep rebasing. That is the difference between an open-source
|
||||
* asset library and an open-source asset library people actually use.
|
||||
*
|
||||
* ### Nothing throws
|
||||
*
|
||||
* An unregistered id builds a placeholder box instead of an exception. An office
|
||||
* pack with one typo in it should still open, and a missing desk should look
|
||||
* like a missing desk rather than a blank screen — the failure has to be visible
|
||||
* in the room, because that is where the person who can fix it is looking.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { MaterialQuality, MaterialRegistry } from "./materials.ts";
|
||||
import type { InteriorPalette } from "./palette.ts";
|
||||
import { MeshBin, parts, type PartBin } from "./parts.ts";
|
||||
|
||||
/**
|
||||
* A namespaced id — `"tera:desk.workstation"`, `"acme:desk.standing"`.
|
||||
*
|
||||
* The template type is enforced where assets are *registered*, so a typo in a
|
||||
* built-in is caught by the compiler. Lookups take a plain `string`, because
|
||||
* the ids arriving from an office pack are strings that came out of JSON and no
|
||||
* amount of type declaration makes them anything else. Validation is `get`
|
||||
* returning `undefined`, not a cast.
|
||||
*/
|
||||
export type AssetId = `${string}:${string}`;
|
||||
|
||||
/** What an asset's parameters may be. JSON values, for the same reason a pack is. */
|
||||
export type AssetParamValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| readonly AssetParamValue[];
|
||||
|
||||
export type AssetParams = Readonly<Record<string, AssetParamValue>>;
|
||||
|
||||
/**
|
||||
* How much room an asset needs, in metres, in its own local frame — X across,
|
||||
* Z deep, Y tall, with the origin on the floor at the centre of the footprint.
|
||||
*
|
||||
* Separate from `build` because layout wants the numbers without the geometry:
|
||||
* a desk bank spacing itself, a room checking a sofa fits, an editor drawing a
|
||||
* plan view. Building 1,200 objects to find out how big they are is the kind of
|
||||
* thing that is fine until the day it is not.
|
||||
*/
|
||||
export interface Footprint {
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
/**
|
||||
* Metres of floor the asset wants kept clear in front of it — a chair to pull
|
||||
* out, a drawer to open. Advisory; nothing enforces it.
|
||||
*/
|
||||
clearance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything an asset builder is handed.
|
||||
*
|
||||
* Note what is *not* here: no `Office`, no `Level`, no room, no seat, no
|
||||
* neighbours. An asset builds one object in its own local frame and knows
|
||||
* nothing about where it is going, which is what keeps assets independently
|
||||
* writable and independently testable.
|
||||
*/
|
||||
export interface AssetContext {
|
||||
materials: MaterialRegistry;
|
||||
/** The shared unit-primitive bin. Almost always the module-level `parts`. */
|
||||
parts: PartBin;
|
||||
palette: InteriorPalette;
|
||||
quality: MaterialQuality;
|
||||
/**
|
||||
* Deterministic randomness for this instance — book angles, cushion sag, the
|
||||
* rotation of a mug. Seeded per prop by whoever builds the context, so the
|
||||
* office looks the same on every reload. A world that reshuffles itself
|
||||
* between visits is a lava lamp.
|
||||
*/
|
||||
rand: () => number;
|
||||
/** The placed instance's opaque palette key, if it had one. */
|
||||
colorKey?: string;
|
||||
/**
|
||||
* Resolve an opaque `colorKey` to a colour, or `undefined` for "no opinion".
|
||||
*
|
||||
* Supplied by the caller exactly as `MarkerPalette` is, and for the same
|
||||
* reason: the library will never learn that a colour key means a status, a
|
||||
* team or a company (ARCHITECTURE.md §3.3).
|
||||
*/
|
||||
colorFor?: (key: string) => number | undefined;
|
||||
/** Build another asset — how a composite places a child. */
|
||||
build: (id: string, params?: AssetParams) => THREE.Object3D;
|
||||
}
|
||||
|
||||
/**
|
||||
* One asset: an id, its parameters, how big it is, and how to build it.
|
||||
*
|
||||
* `footprint` and `build` are method shorthand rather than arrow properties so
|
||||
* that a concretely-parameterised def is assignable to the erased one the
|
||||
* registry stores. That is bivariance, and it is the intended amount of
|
||||
* looseness here — the registry cannot know every asset's parameter type and
|
||||
* should not pretend to.
|
||||
*/
|
||||
export interface AssetDef<P extends AssetParams = AssetParams> {
|
||||
id: AssetId;
|
||||
/**
|
||||
* The built-in id this asset stands in for. Registering with this set means
|
||||
* every request for the overridden id resolves here instead.
|
||||
*/
|
||||
overrides?: AssetId;
|
||||
/** Human-readable, for editors and error messages. Never rendered. */
|
||||
label?: string;
|
||||
defaults: P;
|
||||
footprint(params: P): Footprint;
|
||||
build(params: P, ctx: AssetContext): THREE.Object3D;
|
||||
}
|
||||
|
||||
/**
|
||||
* An asset with its parameter type erased — what the registry stores and hands
|
||||
* back. The `any` is deliberate and confined to this line: the registry cannot
|
||||
* know every asset's parameter type, and pretending otherwise with a union
|
||||
* would mean editing a central file every time somebody adds a chair.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyAsset = AssetDef<any>;
|
||||
|
||||
/** Identity helper that keeps a def's parameter type inferred at the call site. */
|
||||
export function defineAsset<P extends AssetParams>(def: AssetDef<P>): AssetDef<P> {
|
||||
return def;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_FOOTPRINT: Footprint = { width: 0.6, depth: 0.6, height: 0.6 };
|
||||
|
||||
export class AssetRegistry {
|
||||
private readonly defs = new Map<string, AnyAsset>();
|
||||
/** overridden id -> the id that replaced it. */
|
||||
private readonly overriddenBy = new Map<string, AssetId>();
|
||||
private readonly warned = new Set<string>();
|
||||
|
||||
/**
|
||||
* Later registrations of the same id win, so a self-hoster can re-register a
|
||||
* built-in outright as well as override it. Both are supported because they
|
||||
* mean different things: re-registering replaces one asset, overriding
|
||||
* redirects one id at another asset that keeps its own name.
|
||||
*/
|
||||
register<P extends AssetParams>(def: AssetDef<P>): this {
|
||||
this.defs.set(def.id, def as AnyAsset);
|
||||
if (def.overrides) this.overriddenBy.set(def.overrides, def.id);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerAll(defs: readonly AnyAsset[]): this {
|
||||
for (const def of defs) this.register(def);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the override chain to the id that will actually be built.
|
||||
*
|
||||
* The visited set is not paranoia: `acme:a` overriding `tera:b` while
|
||||
* `acme:b` overrides `tera:a` is a plausible thing for two half-finished
|
||||
* packs to do between them, and an infinite loop inside a scene build is a
|
||||
* hung tab with no message in it.
|
||||
*/
|
||||
resolveId(id: string): string {
|
||||
let current = id;
|
||||
const seen = new Set<string>([current]);
|
||||
for (;;) {
|
||||
const next = this.overriddenBy.get(current);
|
||||
if (!next || seen.has(next)) return current;
|
||||
seen.add(next);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): AnyAsset | undefined {
|
||||
return this.defs.get(this.resolveId(id));
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.get(id) !== undefined;
|
||||
}
|
||||
|
||||
/** Every registered id, including ones only reachable as an override target. */
|
||||
ids(): AssetId[] {
|
||||
return [...this.defs.keys()] as AssetId[];
|
||||
}
|
||||
|
||||
/** Size without building. Unknown ids give the placeholder's size. */
|
||||
footprintOf(id: string, params?: AssetParams): Footprint {
|
||||
const def = this.get(id);
|
||||
if (!def) return PLACEHOLDER_FOOTPRINT;
|
||||
return def.footprint({ ...def.defaults, ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one asset. The returned object's origin sits on the floor at the
|
||||
* centre of its footprint, facing -Z at yaw zero, matching `Yaw` in
|
||||
* `interiors/types.ts`.
|
||||
*/
|
||||
build(id: string, ctx: AssetContext, params?: AssetParams): THREE.Object3D {
|
||||
const def = this.get(id);
|
||||
if (!def) return this.placeholder(id, ctx);
|
||||
const object = def.build({ ...def.defaults, ...params }, ctx);
|
||||
object.userData.assetId = def.id;
|
||||
return object;
|
||||
}
|
||||
|
||||
/**
|
||||
* A box, in the accent colour, where the asset should have been.
|
||||
*
|
||||
* Warned once per id rather than once per instance — a pack referring to a
|
||||
* missing desk 120 times should say so once.
|
||||
*/
|
||||
private placeholder(id: string, ctx: AssetContext): THREE.Object3D {
|
||||
if (!this.warned.has(id)) {
|
||||
this.warned.add(id);
|
||||
console.warn(`[tera/assets] no asset registered for "${id}" — drawing a placeholder`);
|
||||
}
|
||||
const bin = new MeshBin();
|
||||
const { width, depth, height } = PLACEHOLDER_FOOTPRINT;
|
||||
bin.box(ctx.materials.get("accent"), { size: [width, height, depth] });
|
||||
const group = bin.build("placeholder");
|
||||
group.userData.assetId = id;
|
||||
group.userData.missing = true;
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry the built-in `tera:` assets register into and the one an office
|
||||
* uses unless it is handed another. One registry per page is the normal case;
|
||||
* the class is exported for tests and for anyone rendering two differently
|
||||
* skinned worlds side by side.
|
||||
*/
|
||||
export const kit = new AssetRegistry();
|
||||
|
||||
export interface AssetContextOptions {
|
||||
materials: MaterialRegistry;
|
||||
registry?: AssetRegistry;
|
||||
parts?: PartBin;
|
||||
rand?: () => number;
|
||||
colorKey?: string;
|
||||
colorFor?: (key: string) => number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up an `AssetContext`, including the recursive `build` a composite asset
|
||||
* uses to place its children. Callers should not assemble one by hand — the
|
||||
* recursion is the fiddly part and it belongs in one place.
|
||||
*/
|
||||
export function createAssetContext(options: AssetContextOptions): AssetContext {
|
||||
const registry = options.registry ?? kit;
|
||||
const ctx: AssetContext = {
|
||||
materials: options.materials,
|
||||
parts: options.parts ?? parts,
|
||||
palette: options.materials.palette,
|
||||
quality: options.materials.quality,
|
||||
rand: options.rand ?? Math.random,
|
||||
colorKey: options.colorKey,
|
||||
colorFor: options.colorFor,
|
||||
build: (id, params) => registry.build(id, ctx, params),
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour an asset should use for a tintable part: the caller's answer for
|
||||
* this instance's `colorKey`, or the role's palette colour when there is no key
|
||||
* or the caller has no opinion about it.
|
||||
*/
|
||||
export function tintFor(ctx: AssetContext, fallbackRole: keyof InteriorPalette): number {
|
||||
const key = ctx.colorKey;
|
||||
const resolved = key !== undefined ? ctx.colorFor?.(key) : undefined;
|
||||
return resolved ?? ctx.palette[fallbackRole];
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Every surface in the library, keyed on what it *is* rather than what colour
|
||||
* it happens to be.
|
||||
*
|
||||
* `SurfaceRole` is a closed union on purpose. A registry keyed on strings would
|
||||
* let an asset ask for `"grey"` and get one, and then the day somebody wants a
|
||||
* warm office there would be forty files to edit and no list of what to edit.
|
||||
* Keyed on roles, the whole appearance of the world is `palette.ts` plus the
|
||||
* table below, and an asset that asks for `deskSurface` gets whatever a desk
|
||||
* surface is *here* — which is what makes eighteen independently-written asset
|
||||
* builders look like one library.
|
||||
*
|
||||
* The roles are named after the object, never after the finish: `partitionFabric`
|
||||
* and not `blueFelt`, `screenDisplay` and not `black`. Same rule as
|
||||
* `Marker.colorKey` one level in (ARCHITECTURE.md §3.3) — this module renders
|
||||
* surfaces and takes no position on what they mean.
|
||||
*
|
||||
* There is exactly one `THREE.Material` per role per registry, and every mesh in
|
||||
* the office shares it. That sharing is half of the draw-call budget; the other
|
||||
* half is `parts.ts` merging geometry per material.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { DEFAULT_INTERIOR_PALETTE, type InteriorPalette } from "./palette.ts";
|
||||
import { TextureBin, type TextureKind } from "./textures.ts";
|
||||
|
||||
/**
|
||||
* The closed set of surfaces this library knows how to be.
|
||||
*
|
||||
* Adding a role is a three-line change — here, in `ROLE_SPECS`, and in
|
||||
* `ROLE_SHIFTS` in `palette.ts` — and the compiler will not let you forget the
|
||||
* third.
|
||||
*/
|
||||
export type SurfaceRole =
|
||||
// Floors
|
||||
| "floorSlab"
|
||||
| "carpet"
|
||||
| "carpetAccent"
|
||||
| "woodFloor"
|
||||
| "polishedConcrete"
|
||||
| "tile"
|
||||
// Ceilings
|
||||
| "ceilingTile"
|
||||
| "ceilingBaffle"
|
||||
// The vertical shell
|
||||
| "plaster"
|
||||
| "plasterAccent"
|
||||
| "skirting"
|
||||
| "glazing"
|
||||
| "glazingFrame"
|
||||
| "doorLeaf"
|
||||
// Partitions
|
||||
| "partitionFabric"
|
||||
| "partitionFrame"
|
||||
// Furniture
|
||||
| "deskSurface"
|
||||
| "deskFrame"
|
||||
| "tableTop"
|
||||
| "cabinet"
|
||||
| "shelf"
|
||||
| "chairShell"
|
||||
| "chairFabric"
|
||||
| "chairBase"
|
||||
| "upholstery"
|
||||
// Fittings
|
||||
| "metalTrim"
|
||||
| "screenBezel"
|
||||
| "screenDisplay"
|
||||
| "lightHousing"
|
||||
| "lightDiffuser"
|
||||
| "whiteboard"
|
||||
// Objects
|
||||
| "foliage"
|
||||
| "planter"
|
||||
| "paper"
|
||||
| "accent";
|
||||
|
||||
/**
|
||||
* `low` is flat Lambert with no maps — the same material class the city uses,
|
||||
* and the setting that makes an office open on an integrated GPU. `medium` and
|
||||
* `high` are physically-shaded and differ only in texture resolution.
|
||||
*/
|
||||
export type MaterialQuality = "low" | "medium" | "high";
|
||||
|
||||
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
|
||||
|
||||
interface RoleSpec {
|
||||
/** 0 = mirror, 1 = chalk. Ignored at `low` quality. */
|
||||
roughness: number;
|
||||
/** Ignored at `low` quality. */
|
||||
metalness: number;
|
||||
texture?: TextureKind;
|
||||
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
|
||||
glow?: number;
|
||||
/** Opacity below 1 makes the material transparent. */
|
||||
opacity?: number;
|
||||
/** Leaf cards and glass want both faces. */
|
||||
doubleSided?: boolean;
|
||||
}
|
||||
|
||||
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
floorSlab: { roughness: 0.9, metalness: 0, texture: "polishedConcrete" },
|
||||
carpet: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
|
||||
carpetAccent: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
|
||||
woodFloor: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
|
||||
polishedConcrete: { roughness: 0.4, metalness: 0.05, texture: "polishedConcrete" },
|
||||
tile: { roughness: 0.3, metalness: 0, texture: "tileGrid" },
|
||||
|
||||
ceilingTile: { roughness: 0.95, metalness: 0, texture: "ceilingTile" },
|
||||
ceilingBaffle: { roughness: 0.9, metalness: 0, texture: "fabricWeave" },
|
||||
|
||||
plaster: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
|
||||
plasterAccent: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
|
||||
skirting: { roughness: 0.6, metalness: 0 },
|
||||
// Glass writes no depth. With it on, anything behind a window disappears
|
||||
// depending on which mesh the sorter happens to draw first, and a meeting
|
||||
// room made of glass is exactly the case where that is most visible.
|
||||
glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
|
||||
glazingFrame: { roughness: 0.35, metalness: 0.7 },
|
||||
doorLeaf: { roughness: 0.6, metalness: 0 },
|
||||
|
||||
partitionFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
|
||||
partitionFrame: { roughness: 0.4, metalness: 0.6 },
|
||||
|
||||
deskSurface: { roughness: 0.45, metalness: 0, texture: "woodPlank" },
|
||||
deskFrame: { roughness: 0.4, metalness: 0.65 },
|
||||
tableTop: { roughness: 0.4, metalness: 0, texture: "woodPlank" },
|
||||
cabinet: { roughness: 0.6, metalness: 0.05 },
|
||||
shelf: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
|
||||
chairShell: { roughness: 0.55, metalness: 0.05 },
|
||||
chairFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
|
||||
chairBase: { roughness: 0.35, metalness: 0.75 },
|
||||
upholstery: { roughness: 0.92, metalness: 0, texture: "fabricWeave" },
|
||||
|
||||
metalTrim: { roughness: 0.3, metalness: 0.85 },
|
||||
screenBezel: { roughness: 0.5, metalness: 0.2 },
|
||||
screenDisplay: { roughness: 0.2, metalness: 0, glow: 0.4 },
|
||||
lightHousing: { roughness: 0.4, metalness: 0.5 },
|
||||
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
|
||||
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
|
||||
|
||||
foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
|
||||
planter: { roughness: 0.7, metalness: 0 },
|
||||
paper: { roughness: 0.9, metalness: 0 },
|
||||
accent: { roughness: 0.6, metalness: 0.1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Authored `SurfaceId` strings to roles.
|
||||
*
|
||||
* An office pack carries `SurfaceId` — a loose namespaced string like
|
||||
* `"tera:carpet.loop"` — because a pack is data and must not depend on this
|
||||
* module to be parsed or stored (`interiors/types.ts`). Resolution happens here,
|
||||
* once, and unknown ids fall back rather than throwing: a pack with one typo in
|
||||
* it should still open, the same way an unregistered `AssetId` gets a
|
||||
* placeholder box.
|
||||
*
|
||||
* The general rule is that the first dot-segment after the namespace is the
|
||||
* role, so `tera:carpet.loop`, `tera:carpet.broadloom` and a self-hoster's
|
||||
* `acme:carpet.whatever` all land on `carpet` for free. This table is only for
|
||||
* the names where that reads badly.
|
||||
*/
|
||||
const SURFACE_ALIASES: Record<string, SurfaceRole> = {
|
||||
paint: "plaster",
|
||||
plasterboard: "plaster",
|
||||
wall: "plaster",
|
||||
wood: "woodFloor",
|
||||
timber: "woodFloor",
|
||||
concrete: "polishedConcrete",
|
||||
glass: "glazing",
|
||||
ceiling: "ceilingTile",
|
||||
felt: "partitionFabric",
|
||||
fabric: "partitionFabric",
|
||||
laminate: "deskSurface",
|
||||
steel: "metalTrim",
|
||||
metal: "metalTrim",
|
||||
aluminium: "metalTrim",
|
||||
screen: "screenDisplay",
|
||||
plant: "foliage",
|
||||
};
|
||||
|
||||
const ROLE_NAMES = new Set(Object.keys(ROLE_SPECS));
|
||||
|
||||
export interface MaterialRegistryOptions {
|
||||
palette?: InteriorPalette;
|
||||
quality?: MaterialQuality;
|
||||
/**
|
||||
* Share a bin with another registry — two registries in one page (an office
|
||||
* being previewed beside the one you are in) should not draw the carpet
|
||||
* twice. The registry disposes only a bin it made itself.
|
||||
*/
|
||||
textures?: TextureBin;
|
||||
}
|
||||
|
||||
export class MaterialRegistry {
|
||||
readonly palette: InteriorPalette;
|
||||
readonly quality: MaterialQuality;
|
||||
readonly textures: TextureBin;
|
||||
|
||||
private readonly ownsTextures: boolean;
|
||||
private readonly base = new Map<SurfaceRole, SurfaceMaterial>();
|
||||
private readonly ghosts = new Map<SurfaceRole, SurfaceMaterial>();
|
||||
private readonly tints = new Map<string, SurfaceMaterial>();
|
||||
|
||||
constructor(options: MaterialRegistryOptions = {}) {
|
||||
this.palette = options.palette ?? DEFAULT_INTERIOR_PALETTE;
|
||||
this.quality = options.quality ?? "high";
|
||||
this.ownsTextures = options.textures === undefined;
|
||||
this.textures = options.textures ?? new TextureBin(this.quality);
|
||||
}
|
||||
|
||||
/** The one shared material for a role. Do not mutate it. */
|
||||
get(role: SurfaceRole): SurfaceMaterial {
|
||||
const hit = this.base.get(role);
|
||||
if (hit) return hit;
|
||||
const made = this.create(role, this.palette[role]);
|
||||
made.name = role;
|
||||
this.base.set(role, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* A translucent copy of a role, for the wall-occlusion fade — the walls
|
||||
* between the camera and where you are looking go ghost rather than being
|
||||
* hidden, so the floorplan stays readable from outside.
|
||||
*
|
||||
* The map is dropped deliberately: carpet grain at 18% opacity is visual
|
||||
* noise on top of whatever it is supposed to be letting you see. Depth
|
||||
* writing goes with it, for the same reason glazing does not write depth.
|
||||
*/
|
||||
ghostOf(role: SurfaceRole): SurfaceMaterial {
|
||||
const hit = this.ghosts.get(role);
|
||||
if (hit) return hit;
|
||||
const ghost = this.get(role).clone();
|
||||
ghost.name = `${role}:ghost`;
|
||||
ghost.map = null;
|
||||
ghost.transparent = true;
|
||||
ghost.opacity = 0.18;
|
||||
ghost.depthWrite = false;
|
||||
ghost.side = THREE.FrontSide;
|
||||
this.ghosts.set(role, ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/**
|
||||
* A role recoloured for one instance — what an asset calls once the caller's
|
||||
* palette has turned a `Prop.colorKey` into a number. Cached, because a
|
||||
* hundred chairs in three colours should still be three materials.
|
||||
*/
|
||||
tinted(role: SurfaceRole, color: number): SurfaceMaterial {
|
||||
const key = `${role}:${color.toString(16)}`;
|
||||
const hit = this.tints.get(key);
|
||||
if (hit) return hit;
|
||||
const made = this.create(role, color);
|
||||
made.name = key;
|
||||
this.tints.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
|
||||
*
|
||||
* `undefined` in gives `fallback` too, so a caller can pass an optional field
|
||||
* straight through: `materials.resolve(room.floor, "carpet")`.
|
||||
*/
|
||||
resolve(surface: string | undefined, fallback: SurfaceRole): SurfaceRole {
|
||||
if (!surface) return fallback;
|
||||
const local = surface.includes(":") ? surface.slice(surface.indexOf(":") + 1) : surface;
|
||||
const head = local.split(".")[0] ?? "";
|
||||
if (ROLE_NAMES.has(head)) return head as SurfaceRole;
|
||||
return SURFACE_ALIASES[head] ?? fallback;
|
||||
}
|
||||
|
||||
/** Convenience for the common `resolve` then `get`. */
|
||||
forSurface(surface: string | undefined, fallback: SurfaceRole): SurfaceMaterial {
|
||||
return this.get(this.resolve(surface, fallback));
|
||||
}
|
||||
|
||||
private create(role: SurfaceRole, color: number): SurfaceMaterial {
|
||||
const spec = ROLE_SPECS[role];
|
||||
const map = spec.texture ? this.textures.get(spec.texture) : null;
|
||||
const transparent = spec.opacity !== undefined && spec.opacity < 1;
|
||||
|
||||
const shared = {
|
||||
color,
|
||||
map,
|
||||
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
|
||||
transparent,
|
||||
opacity: spec.opacity ?? 1,
|
||||
depthWrite: !transparent,
|
||||
emissive: spec.glow ? color : 0x000000,
|
||||
emissiveIntensity: spec.glow ?? 0,
|
||||
};
|
||||
|
||||
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
...shared,
|
||||
roughness: spec.roughness,
|
||||
metalness: spec.metalness,
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const m of this.base.values()) m.dispose();
|
||||
for (const m of this.ghosts.values()) m.dispose();
|
||||
for (const m of this.tints.values()) m.dispose();
|
||||
this.base.clear();
|
||||
this.ghosts.clear();
|
||||
this.tints.clear();
|
||||
if (this.ownsTextures) this.textures.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The interior palette, derived from the city's rather than invented beside it.
|
||||
*
|
||||
* The problem this solves is that an office and the city it stands in are two
|
||||
* scenes built by different code, and "they look like they belong together" is
|
||||
* the kind of property that survives exactly as long as one person is holding
|
||||
* both files open. So it is not left to taste: every `SurfaceRole` declares an
|
||||
* **HSL shift from a named entry of the city's `ScenePalette`**, and the
|
||||
* interior palette is computed from whatever the city actually ships. Recolour
|
||||
* `DEFAULT_PALETTE`, or hand a city its own `palette` override, and the offices
|
||||
* inside it move with it.
|
||||
*
|
||||
* ### The band
|
||||
*
|
||||
* Every derived role is clamped back into the city's own saturation and
|
||||
* lightness band — the min and max across the city palette's ten entries —
|
||||
* before it is returned. That clamp is the mechanism; the shifts are only
|
||||
* allowed to move a colour *within* the range the city already occupies, so no
|
||||
* office can be more chromatic than the world outside it.
|
||||
*
|
||||
* Lightness gets one declared concession, `LIGHTNESS_HEADROOM`. A city seen
|
||||
* from two kilometres up has no analogue for a black screen bezel or a sheet of
|
||||
* paper, and clamping the darkest role to San Francisco's darkest hillside
|
||||
* (L≈0.39) produced an office with no shadow in it and no white in it either.
|
||||
* The headroom is a single number applied to both ends, stated here, rather
|
||||
* than a per-role escape hatch — the moment roles can opt out of the band
|
||||
* individually, the band stops being a constraint and goes back to being a
|
||||
* vibe. Saturation gets no such concession and is clamped hard.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { DEFAULT_PALETTE } from "../engine/terrain.ts";
|
||||
import type { ScenePalette } from "../engine/types.ts";
|
||||
import type { SurfaceRole } from "./materials.ts";
|
||||
|
||||
/** How far outside the city's lightness range an interior role may sit. */
|
||||
export const LIGHTNESS_HEADROOM = 0.14;
|
||||
|
||||
/** One role's derivation: a city colour, and how far to move it. */
|
||||
export interface RoleShift {
|
||||
/** Which entry of the city palette this role descends from. */
|
||||
from: keyof ScenePalette;
|
||||
/** Hue shift in degrees. Wraps. */
|
||||
dh: number;
|
||||
/** Saturation shift, absolute, in 0..1. */
|
||||
ds: number;
|
||||
/** Lightness shift, absolute, in 0..1. */
|
||||
dl: number;
|
||||
}
|
||||
|
||||
export type InteriorPalette = Record<SurfaceRole, number>;
|
||||
|
||||
/**
|
||||
* The derivation table.
|
||||
*
|
||||
* Read it as a sentence: carpet is the city's upland green-grey, nudged warm
|
||||
* and taken down a tenth; glazing is the horizon sky, barely touched, because
|
||||
* glass seen from inside is the sky. Where a role has no obvious ancestor it
|
||||
* descends from `flats`, which is the city's most neutral colour and the right
|
||||
* parent for anything that wants to be quiet.
|
||||
*/
|
||||
export const ROLE_SHIFTS: Record<SurfaceRole, RoleShift> = {
|
||||
// Floors
|
||||
floorSlab: { from: "flats", dh: 0, ds: 0, dl: -0.06 },
|
||||
carpet: { from: "upland", dh: 8, ds: 0.01, dl: -0.1 },
|
||||
carpetAccent: { from: "park", dh: -6, ds: 0.04, dl: -0.06 },
|
||||
woodFloor: { from: "sand", dh: -8, ds: 0.1, dl: -0.1 },
|
||||
polishedConcrete: { from: "flats", dh: 4, ds: -0.01, dl: -0.02 },
|
||||
tile: { from: "shore", dh: 6, ds: -0.01, dl: 0.14 },
|
||||
|
||||
// Ceilings
|
||||
ceilingTile: { from: "sand", dh: 6, ds: -0.05, dl: 0.22 },
|
||||
ceilingBaffle: { from: "upland", dh: 10, ds: 0, dl: -0.14 },
|
||||
|
||||
// The vertical shell
|
||||
plaster: { from: "shore", dh: 4, ds: -0.02, dl: 0.2 },
|
||||
plasterAccent: { from: "park", dh: -10, ds: 0.03, dl: 0.02 },
|
||||
skirting: { from: "upland", dh: 0, ds: 0, dl: -0.16 },
|
||||
glazing: { from: "skyHorizon", dh: -6, ds: 0.02, dl: -0.02 },
|
||||
glazingFrame: { from: "flats", dh: 6, ds: 0, dl: -0.22 },
|
||||
doorLeaf: { from: "sand", dh: -6, ds: 0.04, dl: -0.12 },
|
||||
|
||||
// Partitions
|
||||
partitionFabric: { from: "sea", dh: 6, ds: -0.1, dl: 0.02 },
|
||||
partitionFrame: { from: "flats", dh: 2, ds: 0, dl: -0.18 },
|
||||
|
||||
// Furniture
|
||||
deskSurface: { from: "sand", dh: -4, ds: 0.02, dl: -0.02 },
|
||||
deskFrame: { from: "upland", dh: 6, ds: -0.01, dl: -0.24 },
|
||||
tableTop: { from: "sand", dh: -10, ds: 0.06, dl: -0.08 },
|
||||
cabinet: { from: "shore", dh: 2, ds: 0, dl: 0.06 },
|
||||
shelf: { from: "sand", dh: -6, ds: 0.03, dl: -0.04 },
|
||||
chairShell: { from: "flats", dh: 6, ds: 0, dl: -0.26 },
|
||||
chairFabric: { from: "sea", dh: 10, ds: -0.06, dl: -0.1 },
|
||||
chairBase: { from: "upland", dh: 0, ds: -0.02, dl: -0.28 },
|
||||
upholstery: { from: "lake", dh: 14, ds: -0.08, dl: -0.04 },
|
||||
|
||||
// Fittings
|
||||
metalTrim: { from: "flats", dh: 0, ds: -0.02, dl: 0.06 },
|
||||
screenBezel: { from: "upland", dh: 4, ds: -0.02, dl: -0.3 },
|
||||
screenDisplay: { from: "sea", dh: 4, ds: -0.12, dl: -0.18 },
|
||||
lightHousing: { from: "shore", dh: 4, ds: -0.02, dl: 0.1 },
|
||||
lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 },
|
||||
whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 },
|
||||
|
||||
// Objects
|
||||
foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 },
|
||||
planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 },
|
||||
paper: { from: "sand", dh: 4, ds: -0.06, dl: 0.24 },
|
||||
accent: { from: "sea", dh: -6, ds: 0.08, dl: 0 },
|
||||
};
|
||||
|
||||
/** The saturation and lightness range a derived role must land in. */
|
||||
export interface Band {
|
||||
minS: number;
|
||||
maxS: number;
|
||||
minL: number;
|
||||
maxL: number;
|
||||
}
|
||||
|
||||
/** The saturation and lightness range the city palette actually occupies. */
|
||||
function bandOf(city: ScenePalette): Band {
|
||||
const scratch = new THREE.Color();
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
let minS = Infinity;
|
||||
let maxS = -Infinity;
|
||||
let minL = Infinity;
|
||||
let maxL = -Infinity;
|
||||
for (const hex of Object.values(city)) {
|
||||
scratch.setHex(hex).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
if (hsl.s < minS) minS = hsl.s;
|
||||
if (hsl.s > maxS) maxS = hsl.s;
|
||||
if (hsl.l < minL) minL = hsl.l;
|
||||
if (hsl.l > maxL) maxL = hsl.l;
|
||||
}
|
||||
return {
|
||||
minS,
|
||||
maxS,
|
||||
minL: Math.max(0, minL - LIGHTNESS_HEADROOM),
|
||||
maxL: Math.min(1, maxL + LIGHTNESS_HEADROOM),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
|
||||
/** Apply one shift to one city colour and clamp the result into the band. */
|
||||
export function applyShift(hex: number, shift: RoleShift, band: Band): number {
|
||||
const color = new THREE.Color(hex);
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
// Both ends of this must name the colour space. `getHSL` defaults to the
|
||||
// working space (linear-sRGB) while `setHSL` defaults to sRGB, so leaving
|
||||
// them implicit reads a colour in one space and writes it back in another —
|
||||
// which silently crushed every derived lightness toward black the first time
|
||||
// this was written.
|
||||
color.getHSL(hsl, THREE.SRGBColorSpace);
|
||||
const h = (((hsl.h + shift.dh / 360) % 1) + 1) % 1;
|
||||
const s = clamp(hsl.s + shift.ds, band.minS, band.maxS);
|
||||
const l = clamp(hsl.l + shift.dl, band.minL, band.maxL);
|
||||
return color.setHSL(h, s, l, THREE.SRGBColorSpace).getHex(THREE.SRGBColorSpace);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior palette for a city palette.
|
||||
*
|
||||
* Pass `paletteFor(world)` to get the palette of a city that overrides some of
|
||||
* `DEFAULT_PALETTE`; pass nothing for the reference one.
|
||||
*/
|
||||
export function derivePalette(city: ScenePalette = DEFAULT_PALETTE): InteriorPalette {
|
||||
const band = bandOf(city);
|
||||
const out = {} as InteriorPalette;
|
||||
for (const key of Object.keys(ROLE_SHIFTS) as SurfaceRole[]) {
|
||||
const shift = ROLE_SHIFTS[key];
|
||||
out[key] = applyShift(city[shift.from], shift, band);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette every office gets unless it is told otherwise — the one derived
|
||||
* from the engine's own `DEFAULT_PALETTE`. Computed once at module load, which
|
||||
* is thirty-five colour conversions and not worth deferring.
|
||||
*/
|
||||
export const DEFAULT_INTERIOR_PALETTE: InteriorPalette = derivePalette(DEFAULT_PALETTE);
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* The shared bin of unit primitives every asset is built out of.
|
||||
*
|
||||
* Two jobs, and they are the same job seen from two ends.
|
||||
*
|
||||
* **Draw calls.** The reference office is about 1,200 objects. Built naively —
|
||||
* one `THREE.Mesh` per box, one `BoxGeometry` per mesh — that is 1,200 draw
|
||||
* calls and about as many geometries, and it drops a laptop to single-figure
|
||||
* frame rates before a single desk has anything on it. Built out of this bin it
|
||||
* is roughly thirty: every asset composes *cached* unit geometries placed by a
|
||||
* scaled local matrix, and `MeshBin` merges everything sharing a material into
|
||||
* one buffer. Thirty draw calls is one per `SurfaceRole` actually used.
|
||||
*
|
||||
* **Coherence.** Eighteen people writing eighteen asset builders produce
|
||||
* eighteen dialects unless they are all reaching for the same shapes. A desk leg
|
||||
* and a chair column that are both `rod()` scaled differently look related in a
|
||||
* way that two hand-tuned cylinders never quite do. The bin is the library's
|
||||
* accent.
|
||||
*
|
||||
* ### Conventions
|
||||
*
|
||||
* Assets are authored in **metres, 1 unit = 1 m** (CONTRACT.md §3), and every
|
||||
* unit part is 1 m in each dimension with its **base on y = 0**, centred in X
|
||||
* and Z. So `scale(0.8, 0.73, 0.6)` on a `box()` is a 800 × 600 desktop 730 mm
|
||||
* off the floor, and nothing has to remember whether a primitive is
|
||||
* origin-centred or base-centred. The exceptions are stated on each method.
|
||||
*
|
||||
* Unit parts carry 0..1 UVs and therefore stretch under scale. That is fine for
|
||||
* the roles that carry no texture map, and it is why the two parts that *do*
|
||||
* meet textured surfaces — `metricQuad` and `wallRun` — generate their UVs in
|
||||
* metres instead.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import { TEXTURE_TILE_METRES } from "./textures.ts";
|
||||
|
||||
/** Quantise a dimension to millimetres, so near-identical runs share a cache slot. */
|
||||
function mm(v: number): number {
|
||||
return Math.round(v * 1000);
|
||||
}
|
||||
|
||||
export class PartBin {
|
||||
private readonly cache = new Map<string, THREE.BufferGeometry>();
|
||||
|
||||
private memo(key: string, make: () => THREE.BufferGeometry): THREE.BufferGeometry {
|
||||
const hit = this.cache.get(key);
|
||||
if (hit) return hit;
|
||||
const made = make();
|
||||
made.name = key;
|
||||
this.cache.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
// ---- Solids -------------------------------------------------------------
|
||||
|
||||
/** 1 m cube, base on the floor. The workhorse. */
|
||||
box(): THREE.BufferGeometry {
|
||||
return this.memo("box", () => new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* A box with rounded vertical corners and bevelled top and bottom — cushions,
|
||||
* chair shells, monitor bodies, anything moulded.
|
||||
*
|
||||
* `radius` is a *fraction of the unit*, and it does not survive non-uniform
|
||||
* scaling: a 0.06 rounded box scaled to 2 × 0.1 × 1 has visibly oval corners
|
||||
* on two sides. Ask for a radius near the one you will end up with, or use
|
||||
* `box()` and accept the sharp edge.
|
||||
*/
|
||||
roundedBox(radius = 0.06): THREE.BufferGeometry {
|
||||
const bevel = Math.min(0.24, Math.max(0.01, radius));
|
||||
return this.memo(`rounded:${mm(bevel)}`, () => {
|
||||
const half = 0.5 - bevel;
|
||||
const r = Math.min(half * 0.98, bevel * 2);
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-half + r, -half);
|
||||
shape.lineTo(half - r, -half);
|
||||
shape.quadraticCurveTo(half, -half, half, -half + r);
|
||||
shape.lineTo(half, half - r);
|
||||
shape.quadraticCurveTo(half, half, half - r, half);
|
||||
shape.lineTo(-half + r, half);
|
||||
shape.quadraticCurveTo(-half, half, -half, half - r);
|
||||
shape.lineTo(-half, -half + r);
|
||||
shape.quadraticCurveTo(-half, -half, -half + r, -half);
|
||||
|
||||
// Extrusion runs along +Z and the bevel overhangs both ends, so the solid
|
||||
// spans -bevel..1-bevel before it is stood up and dropped onto the floor.
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: 1 - 2 * bevel,
|
||||
bevelEnabled: true,
|
||||
bevelSize: bevel,
|
||||
bevelThickness: bevel,
|
||||
bevelSegments: 2,
|
||||
curveSegments: 4,
|
||||
});
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
geo.translate(0, bevel, 0);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
});
|
||||
}
|
||||
|
||||
/** Unit-diameter cylinder, base on the floor. */
|
||||
cylinder(segments = 16): THREE.BufferGeometry {
|
||||
return this.memo(`cyl:${segments}`, () =>
|
||||
new THREE.CylinderGeometry(0.5, 0.5, 1, segments).translate(0, 0.5, 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A six-sided cylinder. Legs, columns, pen barrels — anything thin enough
|
||||
* that nobody will count the sides, which is most of the office.
|
||||
*/
|
||||
rod(): THREE.BufferGeometry {
|
||||
return this.cylinder(6);
|
||||
}
|
||||
|
||||
/** Unit-diameter cone, base on the floor. */
|
||||
cone(segments = 12): THREE.BufferGeometry {
|
||||
return this.memo(`cone:${segments}`, () =>
|
||||
new THREE.ConeGeometry(0.5, 1, segments).translate(0, 0.5, 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Unit-diameter sphere, resting on the floor rather than centred on it. */
|
||||
sphere(segments = 16): THREE.BufferGeometry {
|
||||
return this.memo(`sph:${segments}`, () =>
|
||||
new THREE.SphereGeometry(0.5, segments, Math.max(4, segments >> 1)).translate(0, 0.5, 0),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Surfaces -----------------------------------------------------------
|
||||
|
||||
/** 1 × 1 plane lying in XZ, facing up. Centred, not base-offset. */
|
||||
quad(): THREE.BufferGeometry {
|
||||
return this.memo("quad", () => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2));
|
||||
}
|
||||
|
||||
/** 1 × 1 plane standing in XY, facing +Z, base on the floor. */
|
||||
panel(): THREE.BufferGeometry {
|
||||
return this.memo("panel", () => new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0));
|
||||
}
|
||||
|
||||
/** Unit-diameter disc lying in XZ, facing up. */
|
||||
disc(segments = 24): THREE.BufferGeometry {
|
||||
return this.memo(`disc:${segments}`, () =>
|
||||
new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A floor-plane rectangle of a given size in metres, with **UVs in metres**
|
||||
* so a carpet reads the same size in a 3 m booth and a 30 m floor plate.
|
||||
*
|
||||
* This is the part to use under any textured surface. `quad()` scaled to the
|
||||
* same size would smear one tile of carpet across the whole room.
|
||||
*/
|
||||
metricQuad(width: number, depth: number): THREE.BufferGeometry {
|
||||
return this.memo(`mq:${mm(width)}:${mm(depth)}`, () => {
|
||||
const geo = new THREE.PlaneGeometry(width, depth).rotateX(-Math.PI / 2);
|
||||
const uv = geo.getAttribute("uv");
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
uv.setXY(
|
||||
i,
|
||||
(uv.getX(i) * width) / TEXTURE_TILE_METRES,
|
||||
(uv.getY(i) * depth) / TEXTURE_TILE_METRES,
|
||||
);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
return geo;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One solid run of wall: a box `length` long, `height` tall and `thickness`
|
||||
* deep, running along local +X with its base on the floor and centred on its
|
||||
* line.
|
||||
*
|
||||
* This is the part `Plan` hands each piece of wall left over after its
|
||||
* openings are punched out (CONTRACT.md §2). There is deliberately no door or
|
||||
* window asset to go with it — shipping both a door prop and a door-shaped
|
||||
* hole puts every opening in the scene twice, or leaves the collider with no
|
||||
* gap where the door is.
|
||||
*
|
||||
* Each face gets UVs in metres over its own two extents, so the finish does
|
||||
* not stretch on a long wall or squash on the reveal at its end.
|
||||
*/
|
||||
wallRun(length: number, height: number, thickness: number): THREE.BufferGeometry {
|
||||
return this.memo(`wall:${mm(length)}:${mm(height)}:${mm(thickness)}`, () => {
|
||||
const geo = new THREE.BoxGeometry(length, height, thickness);
|
||||
// BoxGeometry emits its faces in a fixed order — +X, -X, +Y, -Y, +Z, -Z —
|
||||
// four vertices each at the default one segment per side.
|
||||
const extents: [number, number][] = [
|
||||
[thickness, height],
|
||||
[thickness, height],
|
||||
[length, thickness],
|
||||
[length, thickness],
|
||||
[length, height],
|
||||
[length, height],
|
||||
];
|
||||
const uv = geo.getAttribute("uv");
|
||||
for (let face = 0; face < 6; face++) {
|
||||
const extent = extents[face] ?? [1, 1];
|
||||
for (let v = 0; v < 4; v++) {
|
||||
const i = face * 4 + v;
|
||||
uv.setXY(
|
||||
i,
|
||||
(uv.getX(i) * extent[0]) / TEXTURE_TILE_METRES,
|
||||
(uv.getY(i) * extent[1]) / TEXTURE_TILE_METRES,
|
||||
);
|
||||
}
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
return geo.translate(0, height / 2, 0);
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const geo of this.cache.values()) geo.dispose();
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bin every asset uses. Module-level and shared on purpose — a second bin
|
||||
* is a second copy of every geometry, and the whole argument for the bin is
|
||||
* that there is one of each.
|
||||
*/
|
||||
export const parts = new PartBin();
|
||||
|
||||
// ---- Placement ------------------------------------------------------------
|
||||
|
||||
export interface Placement {
|
||||
/** Metres. `y` is the base of the part, matching the unit convention. */
|
||||
x?: number;
|
||||
y?: number;
|
||||
z?: number;
|
||||
/** Metres in each axis. A number scales all three. */
|
||||
size?: number | [number, number, number];
|
||||
/** Yaw about +Y, radians, three.js sense. */
|
||||
yaw?: number;
|
||||
/** Pitch about +X and roll about +Z, for the rare tilted part. */
|
||||
pitch?: number;
|
||||
roll?: number;
|
||||
}
|
||||
|
||||
const scratchPosition = new THREE.Vector3();
|
||||
const scratchQuaternion = new THREE.Quaternion();
|
||||
const scratchEuler = new THREE.Euler();
|
||||
const scratchScale = new THREE.Vector3();
|
||||
|
||||
export function placementMatrix(p: Placement, into = new THREE.Matrix4()): THREE.Matrix4 {
|
||||
const size = p.size ?? 1;
|
||||
scratchPosition.set(p.x ?? 0, p.y ?? 0, p.z ?? 0);
|
||||
scratchEuler.set(p.pitch ?? 0, p.yaw ?? 0, p.roll ?? 0, "YXZ");
|
||||
scratchQuaternion.setFromEuler(scratchEuler);
|
||||
if (typeof size === "number") scratchScale.set(size, size, size);
|
||||
else scratchScale.set(size[0], size[1], size[2]);
|
||||
return into.compose(scratchPosition, scratchQuaternion, scratchScale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects transformed parts and emits **one mesh per material**.
|
||||
*
|
||||
* This is where the draw-call budget is actually spent. An asset builder adds
|
||||
* forty boxes across five materials and gets a `THREE.Group` of five meshes
|
||||
* back; a `Plan` that pours a whole floor's props into one bin gets five meshes
|
||||
* for the floor. Merging costs a geometry clone per part during the build and
|
||||
* nothing afterwards, which is the right trade for something built once and
|
||||
* looked at for an hour.
|
||||
*
|
||||
* The cost of merging is that the parts stop being individually addressable —
|
||||
* you cannot move one chair after the fact. Anything that has to move on its own
|
||||
* (a door leaf, a hovering label, a selected prop) belongs in its own object
|
||||
* rather than in a bin.
|
||||
*/
|
||||
export class MeshBin {
|
||||
private readonly groups = new Map<THREE.Material, THREE.BufferGeometry[]>();
|
||||
private readonly matrix = new THREE.Matrix4();
|
||||
|
||||
/** Add a part under a transform. The geometry is cloned, never mutated. */
|
||||
add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): this {
|
||||
const clone = geometry.clone();
|
||||
clone.applyMatrix4(placementMatrix(place, this.matrix));
|
||||
const list = this.groups.get(material);
|
||||
if (list) list.push(clone);
|
||||
else this.groups.set(material, [clone]);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `add(parts.box(), …)`, which is most of what any asset does. */
|
||||
box(material: THREE.Material, place: Placement): this {
|
||||
return this.add(parts.box(), material, place);
|
||||
}
|
||||
|
||||
/** Number of parts waiting to be merged. Handy in an asset's own tests. */
|
||||
get size(): number {
|
||||
let n = 0;
|
||||
for (const list of this.groups.values()) n += list.length;
|
||||
return n;
|
||||
}
|
||||
|
||||
build(
|
||||
name = "parts",
|
||||
options: { castShadow?: boolean; receiveShadow?: boolean } = {},
|
||||
): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
for (const [material, list] of this.groups) {
|
||||
const merged = list.length === 1 ? list[0] : mergeGeometries(list, false);
|
||||
if (!merged) continue;
|
||||
if (list.length > 1) for (const geo of list) geo.dispose();
|
||||
const mesh = new THREE.Mesh(merged, material);
|
||||
mesh.name = `${name}:${material.name || "material"}`;
|
||||
mesh.castShadow = options.castShadow ?? true;
|
||||
mesh.receiveShadow = options.receiveShadow ?? true;
|
||||
group.add(mesh);
|
||||
}
|
||||
this.groups.clear();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Every surface texture in the library, drawn at runtime on a 2D canvas.
|
||||
*
|
||||
* Nothing here is fetched, imported or base64'd, and that is a licensing
|
||||
* decision before it is a graphics one (CONTRACT.md §3): a repo that ships no
|
||||
* binary art has nothing in it whose provenance anyone has to take on trust.
|
||||
*
|
||||
* Three properties are load-bearing and easy to lose:
|
||||
*
|
||||
* 1. **Textures are neutral, not coloured.** Each one is drawn near-white and
|
||||
* modulates *downward*, so `material.color` supplies the hue and the map
|
||||
* supplies only the grain. One `carpetLoop` texture therefore serves every
|
||||
* palette. Baking the colour in would have made the cache key
|
||||
* `kind × colour` and given a self-hoster who recolours their world eight
|
||||
* new uploads to the GPU for no visible gain.
|
||||
* 2. **They tile.** Value noise is not periodic, so a naively-drawn 2 m tile
|
||||
* shows a hard seam every 2 m across a floor. `tileableNoise` blends the
|
||||
* four wrapped samples so the edges match; see the comment there.
|
||||
* 3. **They are cheap to build.** The noise is evaluated on a coarse grid and
|
||||
* bilinearly upsampled rather than per-pixel — four `fbm` calls per pixel
|
||||
* at 512² is about sixteen million `Math.sin` calls and roughly a second
|
||||
* of blocked main thread, which is not a price a floor is worth.
|
||||
*
|
||||
* The noise itself is the engine's — `fbm` and `seededRandom` are imported from
|
||||
* `engine/world.ts` rather than reimplemented, so the city and the office are
|
||||
* grained by the same function.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { fbm, seededRandom } from "../engine/world.ts";
|
||||
|
||||
/**
|
||||
* How many metres one repeat of a texture covers.
|
||||
*
|
||||
* Geometry that carries a textured surface must therefore generate UVs in
|
||||
* metres divided by this — `PartBin.metricQuad` and `PartBin.wallRun` do, which
|
||||
* is why a 12 m wall and a 3 m wall show the same size of grain. Unit
|
||||
* primitives with 0..1 UVs will stretch, and are only used for roles that carry
|
||||
* no map.
|
||||
*/
|
||||
export const TEXTURE_TILE_METRES = 2;
|
||||
|
||||
export type TextureKind =
|
||||
| "carpetLoop"
|
||||
| "woodPlank"
|
||||
| "polishedConcrete"
|
||||
| "ceilingTile"
|
||||
| "plasterPaint"
|
||||
| "fabricWeave"
|
||||
| "tileGrid"
|
||||
| "whiteboard";
|
||||
|
||||
export type TextureQuality = "low" | "medium" | "high";
|
||||
|
||||
/** `low` draws nothing at all: the materials fall back to flat colour. */
|
||||
const RESOLUTION: Record<TextureQuality, number> = { low: 0, medium: 256, high: 512 };
|
||||
|
||||
// ---- Noise ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A tileable noise field, sampled on a coarse grid.
|
||||
*
|
||||
* `fbm` is not periodic, so the four-way blend below is what makes the left
|
||||
* edge equal the right edge: each point is mixed with its wrapped neighbours
|
||||
* weighted by how close it is to them, which is exactly zero contribution in
|
||||
* the middle of the tile and a perfect match at the seam.
|
||||
*/
|
||||
function tileableNoise(res: number, scale: number, offset: number): Float32Array {
|
||||
const field = new Float32Array(res * res);
|
||||
for (let y = 0; y < res; y++) {
|
||||
const v = y / res;
|
||||
for (let x = 0; x < res; x++) {
|
||||
const u = x / res;
|
||||
const a = fbm(offset + u * scale, offset + v * scale);
|
||||
const b = fbm(offset + (u - 1) * scale, offset + v * scale);
|
||||
const c = fbm(offset + u * scale, offset + (v - 1) * scale);
|
||||
const d = fbm(offset + (u - 1) * scale, offset + (v - 1) * scale);
|
||||
const top = a * (1 - u) + b * u;
|
||||
const bottom = c * (1 - u) + d * u;
|
||||
field[y * res + x] = top * (1 - v) + bottom * v;
|
||||
}
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
|
||||
function sampleField(field: Float32Array, res: number, u: number, v: number): number {
|
||||
const fx = u * res;
|
||||
const fy = v * res;
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
const xa = ((x0 % res) + res) % res;
|
||||
const ya = ((y0 % res) + res) % res;
|
||||
const xb = (xa + 1) % res;
|
||||
const yb = (ya + 1) % res;
|
||||
const a = field[ya * res + xa] ?? 0;
|
||||
const b = field[ya * res + xb] ?? 0;
|
||||
const c = field[yb * res + xa] ?? 0;
|
||||
const d = field[yb * res + xb] ?? 0;
|
||||
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply the canvas down by a noise field.
|
||||
*
|
||||
* `amount` is the depth of the darkest dip, as a fraction. Everything stays at
|
||||
* or below the colour already on the canvas, which is what keeps the map a
|
||||
* tint-preserving multiplier rather than something that lightens a dark
|
||||
* palette back toward white.
|
||||
*/
|
||||
function grain(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
size: number,
|
||||
scale: number,
|
||||
amount: number,
|
||||
offset: number,
|
||||
): void {
|
||||
const res = 64;
|
||||
const field = tileableNoise(res, scale, offset);
|
||||
const image = ctx.getImageData(0, 0, size, size);
|
||||
const data = image.data;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const n = sampleField(field, res, x / size, y / size);
|
||||
// fbm's four octaves land in roughly 0..0.94 with a mean near 0.47.
|
||||
const k = 1 - amount * Math.min(1, Math.max(0, n / 0.94));
|
||||
const i = (y * size + x) * 4;
|
||||
data[i] = (data[i] ?? 0) * k;
|
||||
data[i + 1] = (data[i + 1] ?? 0) * k;
|
||||
data[i + 2] = (data[i + 2] ?? 0) * k;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
}
|
||||
|
||||
// ---- The drawings ---------------------------------------------------------
|
||||
|
||||
type Draw = (ctx: CanvasRenderingContext2D, size: number) => void;
|
||||
|
||||
const DRAW: Record<TextureKind, Draw> = {
|
||||
/** Loop pile: dense fine speckle, plus the faint rows a loop carpet lays in. */
|
||||
carpetLoop(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 26, 0.16, 3.1);
|
||||
grain(ctx, size, 90, 0.1, 11.7);
|
||||
const rand = seededRandom(0x9e11);
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.035)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let y = 0; y < size; y += 4) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + rand() * 1.5);
|
||||
ctx.lineTo(size, y + rand() * 1.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/** Boards along +U, with grain stretched hard along the board. */
|
||||
woodPlank(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 4, 0.1, 21.4);
|
||||
const rand = seededRandom(0x7a03);
|
||||
const boards = 5;
|
||||
const pitch = size / boards;
|
||||
// Grain lines run along the board, which is the whole reason wood reads as
|
||||
// wood; a rotationally symmetric noise reads as stone.
|
||||
ctx.lineWidth = 1;
|
||||
for (let b = 0; b < boards; b++) {
|
||||
const y0 = b * pitch;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const y = y0 + rand() * pitch;
|
||||
ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.05})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
for (let x = 0; x <= size; x += size / 8) {
|
||||
ctx.lineTo(x, y + Math.sin(x / 37 + b * 2.3) * 1.6);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.12)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y0);
|
||||
ctx.lineTo(size, y0);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/** Power-floated slab: broad mottle and a scatter of exposed aggregate. */
|
||||
polishedConcrete(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 6, 0.09, 5.9);
|
||||
grain(ctx, size, 40, 0.05, 31.2);
|
||||
const rand = seededRandom(0x51c0);
|
||||
for (let i = 0; i < size * 1.5; i++) {
|
||||
const r = 0.5 + rand() * 1.4;
|
||||
ctx.fillStyle = `rgba(0,0,0,${0.03 + rand() * 0.06})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rand() * size, rand() * size, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
/** Mineral fibre tile: a 600 mm grid — one tile per 600 mm at a 2 m repeat. */
|
||||
ceilingTile(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 70, 0.07, 13.3);
|
||||
const rand = seededRandom(0x0ce1);
|
||||
for (let i = 0; i < size * 3; i++) {
|
||||
ctx.fillStyle = `rgba(0,0,0,${0.05 + rand() * 0.08})`;
|
||||
ctx.fillRect(rand() * size, rand() * size, 1, 1);
|
||||
}
|
||||
// 2 m of repeat covers a little over three 600 mm tiles; three is the
|
||||
// number that tiles cleanly, and nobody counts ceiling tiles.
|
||||
const cells = 3;
|
||||
const pitch = size / cells;
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.16)";
|
||||
ctx.lineWidth = Math.max(1, size / 256);
|
||||
for (let i = 0; i < cells; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * pitch, 0);
|
||||
ctx.lineTo(i * pitch, size);
|
||||
ctx.moveTo(0, i * pitch);
|
||||
ctx.lineTo(size, i * pitch);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/** Emulsion over plasterboard: almost nothing, which is the point. */
|
||||
plasterPaint(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 9, 0.045, 8.8);
|
||||
grain(ctx, size, 120, 0.03, 27.6);
|
||||
},
|
||||
|
||||
/** Upholstery weave: two crossed sets of threads, low contrast. */
|
||||
fabricWeave(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 34, 0.1, 17.2);
|
||||
ctx.lineWidth = 1;
|
||||
const pitch = Math.max(2, Math.round(size / 128));
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.05)";
|
||||
for (let x = 0; x < size; x += pitch) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, size);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.07)";
|
||||
for (let y = 0; y < size; y += pitch) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(size, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/** Square tile with a grout line — kitchens, WCs, entrance mats. */
|
||||
tileGrid(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 14, 0.05, 4.4);
|
||||
const cells = 4;
|
||||
const pitch = size / cells;
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.2)";
|
||||
ctx.lineWidth = Math.max(2, size / 128);
|
||||
for (let i = 0; i < cells; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * pitch, 0);
|
||||
ctx.lineTo(i * pitch, size);
|
||||
ctx.moveTo(0, i * pitch);
|
||||
ctx.lineTo(size, i * pitch);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/** A wiped-down board: faint ghosting, no writing. */
|
||||
whiteboard(ctx, size) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
grain(ctx, size, 5, 0.03, 9.1);
|
||||
const rand = seededRandom(0x0b0a);
|
||||
ctx.lineCap = "round";
|
||||
for (let i = 0; i < 14; i++) {
|
||||
ctx.strokeStyle = `rgba(0,0,0,${0.012 + rand() * 0.018})`;
|
||||
ctx.lineWidth = 4 + rand() * 10;
|
||||
const y = rand() * size;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rand() * size * 0.4, y);
|
||||
ctx.lineTo(size * 0.5 + rand() * size * 0.5, y + (rand() - 0.5) * 20);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---- The bin --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Draws each texture at most once and hands out the same `THREE.Texture` to
|
||||
* every material that wants it.
|
||||
*
|
||||
* `get` returns `null` rather than throwing when there is no canvas to draw on.
|
||||
* That happens for real: the server workspace and the CI typecheck run under
|
||||
* Node, and an asset module that explodes on import there would make the
|
||||
* zero-config boot in CONTRACT.md §5.1 impossible to test.
|
||||
*/
|
||||
export class TextureBin {
|
||||
readonly quality: TextureQuality;
|
||||
private readonly cache = new Map<TextureKind, THREE.Texture | null>();
|
||||
|
||||
constructor(quality: TextureQuality = "high") {
|
||||
this.quality = quality;
|
||||
}
|
||||
|
||||
get(kind: TextureKind): THREE.Texture | null {
|
||||
const hit = this.cache.get(kind);
|
||||
if (hit !== undefined) return hit;
|
||||
const texture = this.draw(kind);
|
||||
this.cache.set(kind, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
private draw(kind: TextureKind): THREE.Texture | null {
|
||||
const size = RESOLUTION[this.quality];
|
||||
if (size === 0 || typeof document === "undefined") return null;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
DRAW[kind](ctx, size);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.name = kind;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 4;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const texture of this.cache.values()) texture?.dispose();
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user