feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+196
-6
@@ -69,6 +69,11 @@ export type SurfaceRole =
|
||||
| "lightHousing"
|
||||
| "lightDiffuser"
|
||||
| "whiteboard"
|
||||
// Devices
|
||||
| "deviceShell"
|
||||
| "deviceMesh"
|
||||
| "deviceIndicator"
|
||||
| "screenContent"
|
||||
// Objects
|
||||
| "foliage"
|
||||
| "planter"
|
||||
@@ -82,6 +87,11 @@ export type SurfaceRole =
|
||||
*/
|
||||
export type MaterialQuality = "low" | "medium" | "high";
|
||||
|
||||
/**
|
||||
* `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no
|
||||
* arm of its own here — but it is worth knowing it is in the union, because
|
||||
* `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`.
|
||||
*/
|
||||
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
|
||||
|
||||
interface RoleSpec {
|
||||
@@ -92,10 +102,50 @@ interface RoleSpec {
|
||||
texture?: TextureKind;
|
||||
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
|
||||
glow?: number;
|
||||
/**
|
||||
* Emit through `texture` rather than flat across the surface.
|
||||
*
|
||||
* Only meaningful with a `texture` that carries content rather than grain, and
|
||||
* `screenContent` is the only such role. It is the difference between a
|
||||
* monitor and a light box: with a flat `glow` the whole panel emits and the
|
||||
* drawn interface is a pattern printed on a lamp, and with the map bound to
|
||||
* `emissiveMap` the lit pixels emit and the chrome around them does not.
|
||||
*/
|
||||
emissiveFromMap?: boolean;
|
||||
/**
|
||||
* A coverage map, and the threshold a fragment has to clear to be drawn.
|
||||
*
|
||||
* Cutout, not blend. `alphaTest` discards below the threshold and leaves the
|
||||
* material opaque, so a leaf still writes depth, still sorts like solid
|
||||
* geometry and still casts a correctly-shaped shadow — three's depth material
|
||||
* copies `alphaMap` and `alphaTest` across for exactly this. Making foliage
|
||||
* `transparent` instead would buy a soft edge and cost the shadow, the depth
|
||||
* write and the sort order, on the one class of object there are hundreds of.
|
||||
*/
|
||||
alphaTexture?: TextureKind;
|
||||
alphaTest?: number;
|
||||
/** Opacity below 1 makes the material transparent. */
|
||||
opacity?: number;
|
||||
/** Leaf cards and glass want both faces. */
|
||||
doubleSided?: boolean;
|
||||
/**
|
||||
* Refract through the surface instead of blending over it.
|
||||
*
|
||||
* `MeshPhysicalMaterial`'s transmission is the difference between glass and a
|
||||
* grey film: it takes the *lit* colour of what is behind the surface, tints it
|
||||
* by `color`, bends it by `ior` over `thickness`, and — the part that actually
|
||||
* sells it — leaves a specular highlight and an environment reflection on top
|
||||
* that a 22%-opacity blend cannot have. `roughness` becomes frosting rather
|
||||
* than a matte grey, which is what a fritted partition wants.
|
||||
*
|
||||
* It costs a copy of the render target per transmissive draw, which is why it
|
||||
* is `medium` and `high` only and why exactly one role uses it.
|
||||
*/
|
||||
transmission?: number;
|
||||
/** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */
|
||||
ior?: number;
|
||||
/** Metres of glass the refraction is integrated over. Only read with `transmission`. */
|
||||
thickness?: number;
|
||||
}
|
||||
|
||||
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
@@ -115,7 +165,31 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
// 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 },
|
||||
//
|
||||
// That reasoning survives the move to transmission unchanged, and it has to be
|
||||
// said out loud because three.js *encourages* the opposite: a transmissive
|
||||
// material is drawn in the transmission pass and the usual advice is to let it
|
||||
// write depth. Here it must not. An office is a box of glass boxes — a meeting
|
||||
// room seen through a corridor screen through an external window is three
|
||||
// sheets deep — and depth-writing glass makes whichever sheet the sorter
|
||||
// reached first erase the other two. The `opacity` stays as well: it is what
|
||||
// `low` quality falls back to, and it is what keeps the frame visible against
|
||||
// the glass in the ghosted wall-occlusion copy.
|
||||
glazing: {
|
||||
roughness: 0.05,
|
||||
// Was 0.1, and had to go: three.js scales transmission by `1 - metalness`
|
||||
// because a metal is opaque by definition, so a tenth of metalness is a
|
||||
// tenth of the glass quietly turned back into a mirror.
|
||||
metalness: 0,
|
||||
opacity: 0.22,
|
||||
doubleSided: true,
|
||||
transmission: 0.92,
|
||||
ior: 1.5,
|
||||
// Millimetres, not metres of solid glass: `thickness` scales the volumetric
|
||||
// tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way
|
||||
// this parameter goes wrong.
|
||||
thickness: 0.006,
|
||||
},
|
||||
glazingFrame: { roughness: 0.35, metalness: 0.7 },
|
||||
doorLeaf: { roughness: 0.6, metalness: 0 },
|
||||
|
||||
@@ -139,7 +213,38 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
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 },
|
||||
/**
|
||||
* The four device roles.
|
||||
*
|
||||
* `deviceMesh` is a grille or a windscreen — the perforated part — and it is
|
||||
* double-sided because you can see through it to the inside of the housing at
|
||||
* a glancing angle, which is most of what makes a speaker look like a speaker.
|
||||
* `deviceIndicator` is the only role in the table with a `glow` of 1: an LED
|
||||
* is a light source rather than a lit surface, and under the tone curve
|
||||
* `stage.ts` now runs, a full-strength emissive reads as a lamp instead of
|
||||
* saturating to the same white as the housing beside it.
|
||||
*/
|
||||
deviceShell: { roughness: 0.42, metalness: 0.28 },
|
||||
deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true },
|
||||
deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 },
|
||||
screenContent: {
|
||||
roughness: 0.18,
|
||||
metalness: 0,
|
||||
texture: "screenUI",
|
||||
glow: 0.9,
|
||||
emissiveFromMap: true,
|
||||
},
|
||||
|
||||
// A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts
|
||||
// for why that is worth a texture channel, and `alphaTest` at 0.5 for why the
|
||||
// threshold sits in the middle of a hard-edged drawing rather than at its toe.
|
||||
foliage: {
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
doubleSided: true,
|
||||
alphaTexture: "leafAlpha",
|
||||
alphaTest: 0.5,
|
||||
},
|
||||
planter: { roughness: 0.7, metalness: 0 },
|
||||
paper: { roughness: 0.9, metalness: 0 },
|
||||
accent: { roughness: 0.6, metalness: 0.1 },
|
||||
@@ -227,6 +332,11 @@ export class MaterialRegistry {
|
||||
* 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.
|
||||
*
|
||||
* `alphaMap` is deliberately *not* dropped with it. The colour map is
|
||||
* decoration and the coverage map is shape — a ghosted leaf with its cutout
|
||||
* removed is not a faint leaf, it is the flat green shard the cutout exists to
|
||||
* get rid of, at 18% opacity.
|
||||
*/
|
||||
ghostOf(role: SurfaceRole): SurfaceMaterial {
|
||||
const hit = this.ghosts.get(role);
|
||||
@@ -234,6 +344,16 @@ export class MaterialRegistry {
|
||||
const ghost = this.get(role).clone();
|
||||
ghost.name = `${role}:ghost`;
|
||||
ghost.map = null;
|
||||
// The relief goes with the colour map and for the same reason. It also has
|
||||
// to: a normal map on a surface that is 82% see-through is a lighting cue
|
||||
// for a surface nobody is being asked to look at.
|
||||
if ("normalMap" in ghost) ghost.normalMap = null;
|
||||
// A ghost is a hint, not a window. Leaving transmission on would put the
|
||||
// occlusion fade — which exists to be cheap and is redrawn as the camera
|
||||
// moves — through the transmission pass and its render-target copy.
|
||||
if ("transmission" in ghost) {
|
||||
(ghost as THREE.MeshPhysicalMaterial).transmission = 0;
|
||||
}
|
||||
ghost.transparent = true;
|
||||
ghost.opacity = 0.18;
|
||||
ghost.depthWrite = false;
|
||||
@@ -257,6 +377,38 @@ export class MaterialRegistry {
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* A role drawn with a different layout of its own texture.
|
||||
*
|
||||
* Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them),
|
||||
* and this exists because of a constraint one layer up rather than a wish for
|
||||
* variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per
|
||||
* kind**, so a screen asset cannot roll for a layout per instance. Variety has
|
||||
* to arrive as a parameter, from a pack authoring separate batches, which
|
||||
* means it has to arrive as a separate material — one material per layout, all
|
||||
* of them cached here, and the draw-call cost is one call per layout actually
|
||||
* used rather than one per screen.
|
||||
*
|
||||
* `color` is optional so the common case reads `variant(role, n)`; pass one to
|
||||
* get a tinted layout, which is the same shape `tinted` offers.
|
||||
*/
|
||||
variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial {
|
||||
const texture = ROLE_SPECS[role].texture;
|
||||
const count = texture ? this.textures.variants(texture) : 1;
|
||||
const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
|
||||
const hue = color ?? this.palette[role];
|
||||
// Variant 0 with the role's own colour *is* the base material. Minting a
|
||||
// second identical one would be a second draw call for the same picture.
|
||||
if (index === 0 && color === undefined) return this.get(role);
|
||||
const key = `${role}:${hue.toString(16)}:${index}`;
|
||||
const hit = this.tints.get(key);
|
||||
if (hit) return hit;
|
||||
const made = this.create(role, hue, index);
|
||||
made.name = key;
|
||||
this.tints.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
|
||||
*
|
||||
@@ -276,27 +428,65 @@ export class MaterialRegistry {
|
||||
return this.get(this.resolve(surface, fallback));
|
||||
}
|
||||
|
||||
private create(role: SurfaceRole, color: number): SurfaceMaterial {
|
||||
private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
|
||||
const spec = ROLE_SPECS[role];
|
||||
const map = spec.texture ? this.textures.get(spec.texture) : null;
|
||||
const map = spec.texture ? this.textures.get(spec.texture, variant) : null;
|
||||
const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null;
|
||||
// Relief comes from the same kind as the colour, and the bin answers `null`
|
||||
// for the kinds that have none — a whiteboard and a display are flat, and
|
||||
// `low` quality has no maps at all. No role opts in separately: a surface
|
||||
// either has a texture or it does not, and asking for the grain without the
|
||||
// relief that produced it is not a combination worth spelling.
|
||||
const normalMap = spec.texture ? this.textures.normal(spec.texture) : null;
|
||||
const transparent = spec.opacity !== undefined && spec.opacity < 1;
|
||||
|
||||
const shared = {
|
||||
color,
|
||||
map,
|
||||
// `alphaTest` is only set when there is a map to test against. Left on
|
||||
// with a null `alphaMap` at `low` quality it would test the material's
|
||||
// flat opacity of 1 against the threshold on every fragment — which
|
||||
// passes, but compiles a branch into the shader for nothing.
|
||||
alphaMap,
|
||||
alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0,
|
||||
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
|
||||
transparent,
|
||||
opacity: spec.opacity ?? 1,
|
||||
depthWrite: !transparent,
|
||||
emissive: spec.glow ? color : 0x000000,
|
||||
// White rather than the role's colour when the map is doing the emitting:
|
||||
// `emissive` multiplies `emissiveMap`, so anything but white would tint
|
||||
// the drawn interface a second time on top of `color` already tinting it.
|
||||
emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000,
|
||||
emissiveMap: spec.emissiveFromMap ? map : null,
|
||||
emissiveIntensity: spec.glow ?? 0,
|
||||
};
|
||||
|
||||
// `low` is flat Lambert: no maps, no roughness, no transmission. The
|
||||
// `normalMap` is not merely unused there — `MeshLambertMaterial` does have
|
||||
// one, but the whole point of `low` is to compile the cheap shader.
|
||||
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
|
||||
const physical = {
|
||||
...shared,
|
||||
normalMap,
|
||||
roughness: spec.roughness,
|
||||
metalness: spec.metalness,
|
||||
};
|
||||
|
||||
if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical);
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
...physical,
|
||||
transmission: spec.transmission,
|
||||
ior: spec.ior ?? 1.5,
|
||||
thickness: spec.thickness ?? 0.01,
|
||||
// Transmission carries the see-through, so the blend must not do it a
|
||||
// second time. Left transparent at 0.22 the sheet would be four fifths
|
||||
// invisible *and* refracting the fifth that was left, which reads as a
|
||||
// smear rather than as glass. `depthWrite` stays false regardless — see
|
||||
// the note on the role.
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
* 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.
|
||||
* In practice: `roundedBox` and `roundedBoxOf` are `ExtrudeGeometry` and carry
|
||||
* 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.
|
||||
*
|
||||
* `slab`'s `chamfer` option is the one place that rule had to be worked around
|
||||
* rather than worked with, and `nonIndexed` below is how. A chamfered desktop
|
||||
* wants an extruded body *and* a metric top face in the same material, and those
|
||||
* two are on opposite sides of the rule; converting the quad is four vertices of
|
||||
* cost and keeps the whole desktop in one merge.
|
||||
*
|
||||
* ### Light fixtures emit no light
|
||||
*
|
||||
@@ -54,10 +60,35 @@
|
||||
* shadow-casting lights, the end of the frame budget.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { tintFor, type AssetContext } from "../kit.ts";
|
||||
import type { SurfaceMaterial, SurfaceRole } from "../materials.ts";
|
||||
import type { MeshBin } from "../parts.ts";
|
||||
|
||||
/**
|
||||
* A de-indexed copy of one of the shared parts, memoised per source geometry.
|
||||
*
|
||||
* `mergeGeometries` refuses a mixture of indexed and non-indexed inputs, so a
|
||||
* material carrying an `ExtrudeGeometry` cannot also carry a `PlaneGeometry`.
|
||||
* Rather than give up either the chamfer or the metric UVs, the quad is
|
||||
* converted once and cached.
|
||||
*
|
||||
* A `WeakMap` rather than a `Map` because the key is a geometry owned by
|
||||
* `PartBin`: if the bin is ever disposed and rebuilt, the derived copies become
|
||||
* unreachable with their sources instead of pinning a disposed buffer forever.
|
||||
*/
|
||||
const NON_INDEXED = new WeakMap<THREE.BufferGeometry, THREE.BufferGeometry>();
|
||||
|
||||
function nonIndexed(source: THREE.BufferGeometry): THREE.BufferGeometry {
|
||||
if (source.getIndex() === null) return source;
|
||||
const hit = NON_INDEXED.get(source);
|
||||
if (hit) return hit;
|
||||
const made = source.toNonIndexed();
|
||||
made.name = `${source.name || "part"}:flat`;
|
||||
NON_INDEXED.set(source, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -84,16 +115,59 @@ export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial
|
||||
* 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.
|
||||
*
|
||||
* ### `chamfer`, and why three millimetres is worth a whole code path
|
||||
*
|
||||
* A sharp arris is the single most reliable tell that a surface was rendered
|
||||
* rather than built. Every real desktop, worktop and shelf board has a small
|
||||
* radius on its edge, and what that radius does is catch a *line* of specular
|
||||
* highlight along the whole length of the board — one bright edge that separates
|
||||
* the top from the front and tells you where the object stops. Without it a
|
||||
* desktop and the wall behind it meet in a hard colour change and the desk reads
|
||||
* as a decal.
|
||||
*
|
||||
* Three to six millimetres is the range; past that it starts reading as a
|
||||
* moulded plastic table. `roundedBoxOf` takes the radius in metres and applies it
|
||||
* after the proportions are known, which is the only way to get a circular
|
||||
* corner on a board that is fifty times wider than it is thick.
|
||||
*
|
||||
* It is opt-in rather than the default because it changes the primitive class of
|
||||
* the whole material (see the header): every other part an asset draws in the
|
||||
* same material has to become an extrusion too, and for a shelf board carrying
|
||||
* books that trade is not worth making.
|
||||
*
|
||||
* `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 },
|
||||
s: {
|
||||
x?: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
thickness: number;
|
||||
/** Edge radius in metres. 0.003–0.006 for a board; omit for a sharp edge. */
|
||||
chamfer?: number;
|
||||
},
|
||||
): void {
|
||||
const x = s.x ?? 0;
|
||||
const z = s.z ?? 0;
|
||||
const chamfer = s.chamfer ?? 0;
|
||||
if (chamfer > 0) {
|
||||
bin.add(ctx.parts.roundedBoxOf(s.width, s.thickness, s.depth, chamfer), material, {
|
||||
x,
|
||||
y: s.y,
|
||||
z,
|
||||
});
|
||||
bin.add(nonIndexed(ctx.parts.metricQuad(s.width - chamfer * 2, s.depth - chamfer * 2)), material, {
|
||||
x,
|
||||
y: s.y + s.thickness + 0.0006,
|
||||
z,
|
||||
});
|
||||
return;
|
||||
}
|
||||
bin.add(ctx.parts.box(), material, {
|
||||
x,
|
||||
y: s.y,
|
||||
|
||||
@@ -46,11 +46,15 @@ export const deskWorkstation = defineAsset<WorkstationParams>({
|
||||
const frame = ctx.materials.get("deskFrame");
|
||||
const deckY = p.height - TOP_THICKNESS;
|
||||
|
||||
// 4 mm on the edge of the desktop. `deskSurface` is used by nothing else in
|
||||
// this asset, which is what makes the chamfer affordable here — see the
|
||||
// primitive-class note on `slab`.
|
||||
slab(bin, ctx, ctx.materials.get("deskSurface"), {
|
||||
y: deckY,
|
||||
width: p.width,
|
||||
depth: p.depth,
|
||||
thickness: TOP_THICKNESS,
|
||||
chamfer: 0.004,
|
||||
});
|
||||
|
||||
const legX = p.width / 2 - 0.09;
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* The two pieces of hardware the smart-device layer drives: a desk microphone
|
||||
* and a desk monitor speaker.
|
||||
*
|
||||
* These are not props with a light glued on. They are the *instruments* the
|
||||
* studio simulation observes and commands — a mic that can be muted and gained,
|
||||
* a speaker that can be turned up and played through — so they are modelled to
|
||||
* be looked at from the distance somebody sits from their own desk, which is
|
||||
* about sixty centimetres. At that range a speaker with a painted-on grille is a
|
||||
* lie you can see, which is why the grille here is slats with gaps between them
|
||||
* and the driver is a real cone in a real surround.
|
||||
*
|
||||
* ### The id convention is load-bearing
|
||||
*
|
||||
* `<namespace>:device.<kind>.<placement>` — `tera:device.mic.desk`,
|
||||
* `tera:device.speaker.desk`. `deviceKindOfAssetId()` in `src/devices/types.ts`
|
||||
* parses the kind straight back out of the id, and the device API refuses a
|
||||
* declaration whose asset kind disagrees with its declared kind. That is what
|
||||
* lets a self-hoster register `acme:device.mic.boom` and have it read as a
|
||||
* microphone for free, with no table to edit and no fork; and it is why anything
|
||||
* not matching the pattern is treated as "not device hardware" rather than as a
|
||||
* silently mis-typed device.
|
||||
*
|
||||
* ### Every device exposes a sub-object named `indicator`
|
||||
*
|
||||
* The device render layer (`src/interiors/devices.ts`) tints one part of each
|
||||
* device per state — powered, muted, idle — by swapping the material on it with
|
||||
* `materials.tinted("deviceIndicator", colour)`, which reaches both `color` and
|
||||
* `emissive`. It finds that part by **name**, not by material and not by index:
|
||||
* a name survives a self-hoster's override, a material does not (two devices
|
||||
* sharing `deviceIndicator` would both light up), and an index does not survive
|
||||
* anybody adding a part.
|
||||
*
|
||||
* So the contract is exactly: `object.getObjectByName("indicator")` returns a
|
||||
* group holding the LED and nothing else. It is a separate group rather than a
|
||||
* separate mesh because `MeshBin.build` names its meshes after their material,
|
||||
* and the layer above should not have to know what material an LED happens to
|
||||
* be made of.
|
||||
*
|
||||
* ### Roles, and the primitive-class rule
|
||||
*
|
||||
* `deviceShell` is the moulded housing, `deviceMesh` the perforated parts —
|
||||
* grille, basket, windscreen — and `deviceIndicator` the LED. `common.ts` warns
|
||||
* that every part under one material must be all-indexed or all-non-indexed or
|
||||
* `mergeGeometries` silently drops it; both assets here are built entirely from
|
||||
* indexed primitives (`box`, `cylinder`, `rod`, `cone`, `sphere`, `disc`), which
|
||||
* makes that rule impossible to break by accident rather than merely remembered.
|
||||
*
|
||||
* Neither takes a `colorKey`. A studio microphone is the colour a studio
|
||||
* microphone is, and the one part that changes colour changes it because of
|
||||
* *state*, which is the device layer's business and not a pack's.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { defineAsset, type AssetContext, type AssetId } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
|
||||
/** The built-in device ids, in the order `src/devices` expects to find them. */
|
||||
export const DEVICE_ASSET_IDS: readonly AssetId[] = [
|
||||
"tera:device.mic.desk",
|
||||
"tera:device.speaker.desk",
|
||||
];
|
||||
|
||||
/** The name the device render layer looks up to find the tintable LED. */
|
||||
export const DEVICE_INDICATOR_NAME = "indicator";
|
||||
|
||||
/**
|
||||
* Assemble a device: its static hardware, plus the one sub-object that changes
|
||||
* colour, under the agreed name.
|
||||
*
|
||||
* The LED bin is built with shadows off in both directions. A four-millimetre
|
||||
* emissive dot has nothing meaningful to cast and nothing meaningful to receive,
|
||||
* and leaving it in the shadow pass costs a draw call in every cascade for a
|
||||
* part that is smaller than a shadow-map texel at any sensible resolution.
|
||||
*/
|
||||
function deviceGroup(name: string, hardware: MeshBin, led: MeshBin): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
group.add(hardware.build(name));
|
||||
const indicator = led.build(DEVICE_INDICATOR_NAME, {
|
||||
castShadow: false,
|
||||
receiveShadow: false,
|
||||
});
|
||||
indicator.name = DEVICE_INDICATOR_NAME;
|
||||
group.add(indicator);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring of `count` short bars around the Y axis at `radius`.
|
||||
*
|
||||
* Used for the shock mount's elastic suspension and for the speaker's driver
|
||||
* surround. Worth a helper rather than three copies because getting the local
|
||||
* yaw wrong produces a ring of bars that all face the same way, which reads as a
|
||||
* mistake rather than as a detail.
|
||||
*/
|
||||
function ringOfBars(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
material: Parameters<MeshBin["add"]>[1],
|
||||
r: {
|
||||
count: number;
|
||||
radius: number;
|
||||
y: number;
|
||||
size: [number, number, number];
|
||||
pitch?: number;
|
||||
phase?: number;
|
||||
},
|
||||
): void {
|
||||
for (let i = 0; i < r.count; i++) {
|
||||
const yaw = (i / r.count) * Math.PI * 2 + (r.phase ?? 0);
|
||||
bin.add(ctx.parts.box(), material, {
|
||||
x: Math.sin(yaw) * r.radius,
|
||||
z: Math.cos(yaw) * r.radius,
|
||||
y: r.y,
|
||||
size: r.size,
|
||||
yaw,
|
||||
pitch: r.pitch ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Microphone -----------------------------------------------------------
|
||||
|
||||
type MicParams = {
|
||||
/** Length of the capsule body itself, metres. A large-diaphragm condenser. */
|
||||
bodyLength: number;
|
||||
/** Diameter of the weighted desk base. */
|
||||
baseDiameter: number;
|
||||
/** Floor of the base to the centre of the capsule. */
|
||||
standHeight: number;
|
||||
/** Radians the capsule leans back from vertical, toward the person at +Z. */
|
||||
tilt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A shock-mounted desk condenser.
|
||||
*
|
||||
* Four things carry it, in the order they matter at desk distance:
|
||||
*
|
||||
* 1. **The shock mount.** A microphone hanging inside a ring on visible elastic
|
||||
* is the single most recognisable thing about a studio desk, and it is the
|
||||
* part that says "this is a real microphone" before any of the rest resolves.
|
||||
* 2. **The basket.** A grille with gaps in it, not a painted cylinder — eight
|
||||
* vertical wires and two hoops, in the double-sided `deviceMesh` role, so you
|
||||
* see through it to the shaded inside of the head at a glancing angle.
|
||||
* 3. **The windscreen**, as a foam sphere pushed over the top of the basket. It
|
||||
* is what breaks the hard cylinder silhouette.
|
||||
* 4. **The mute LED**, on the front of the body under the basket, where the
|
||||
* hardware button is on almost every one of these.
|
||||
*
|
||||
* Faces −Z at yaw zero like everything else, which puts the front of the capsule
|
||||
* and the LED at +Z, toward the person (`common.ts`).
|
||||
*/
|
||||
export const deviceMicDesk = defineAsset<MicParams>({
|
||||
id: "tera:device.mic.desk",
|
||||
label: "Desk microphone",
|
||||
defaults: { bodyLength: 0.09, baseDiameter: 0.12, standHeight: 0.21, tilt: 0.16 },
|
||||
|
||||
footprint(p) {
|
||||
// The shock mount is wider than the base and the capsule leans out of it, so
|
||||
// the footprint is the ring's swept width rather than the base's diameter.
|
||||
// 1.5 × the body, measured off the built mesh rather than guessed: the ring
|
||||
// is `bodyLength × 0.66` in radius and the suspension bars stick out past it,
|
||||
// and the foam windscreen is wider again than the basket it is pushed over.
|
||||
const ring = p.bodyLength * 1.5;
|
||||
const height = p.standHeight + p.bodyLength * 0.95 + 0.05;
|
||||
return { width: Math.max(p.baseDiameter, ring), depth: ring + 0.04, height, clearance: 0.12 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const led = new MeshBin();
|
||||
const shell = ctx.materials.get("deviceShell");
|
||||
const grille = ctx.materials.get("deviceMesh");
|
||||
const metal = ctx.materials.get("metalTrim");
|
||||
|
||||
// ---- The weighted base. Two discs and a fillet: a microphone base is heavy
|
||||
// and low, and a straight cylinder reads as a cotton reel.
|
||||
bin.add(P.cylinder(20), shell, { size: [p.baseDiameter, 0.014, p.baseDiameter] });
|
||||
bin.add(P.cylinder(20), shell, {
|
||||
y: 0.014,
|
||||
size: [p.baseDiameter * 0.82, 0.012, p.baseDiameter * 0.82],
|
||||
});
|
||||
bin.add(P.cylinder(16), metal, {
|
||||
y: 0.026,
|
||||
size: [p.baseDiameter * 0.34, 0.01, p.baseDiameter * 0.34],
|
||||
});
|
||||
|
||||
// ---- The post, and the yoke that carries the ring.
|
||||
const ringY = p.standHeight;
|
||||
bin.add(P.rod(), metal, { y: 0.03, size: [0.014, ringY - 0.03 - 0.01, 0.014] });
|
||||
const ringR = p.bodyLength * 0.66;
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), metal, {
|
||||
x: sx * ringR * 0.7,
|
||||
y: ringY - 0.055,
|
||||
size: [0.008, 0.075, 0.008],
|
||||
roll: sx * 0.32,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The shock-mount ring. Twelve short bars around the circle rather than
|
||||
// a torus: `parts.ts` has no torus, and twelve boxes is both cheaper and, at
|
||||
// this size, indistinguishable from one.
|
||||
ringOfBars(bin, ctx, metal, {
|
||||
count: 12,
|
||||
radius: ringR,
|
||||
y: ringY - 0.006,
|
||||
size: [ringR * 0.58, 0.012, 0.009],
|
||||
pitch: 0,
|
||||
});
|
||||
// The elastic. Six lines from the ring in to the body, alternating up and
|
||||
// down the capsule the way a real suspension is strung.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const yaw = (i / 6) * Math.PI * 2 + 0.26;
|
||||
const lift = i % 2 === 0 ? 0.022 : -0.022;
|
||||
bin.add(P.box(), metal, {
|
||||
x: (Math.sin(yaw) * ringR) / 2,
|
||||
z: (Math.cos(yaw) * ringR) / 2,
|
||||
y: ringY + lift * 0.5,
|
||||
size: [ringR, 0.004, 0.004],
|
||||
yaw: yaw + Math.PI / 2,
|
||||
roll: lift > 0 ? 0.42 : -0.42,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The capsule. Built about the ring's centre and tilted back, so the
|
||||
// whole head — body, basket, windscreen, LED — leans as one piece.
|
||||
const tilt = p.tilt;
|
||||
const dia = p.bodyLength * 0.54;
|
||||
const lean = (d: number): { y: number; z: number } => ({
|
||||
y: Math.cos(tilt) * d,
|
||||
z: Math.sin(tilt) * d,
|
||||
});
|
||||
|
||||
const bodyBase = lean(-p.bodyLength * 0.5);
|
||||
bin.add(P.cylinder(18), shell, {
|
||||
y: ringY + bodyBase.y,
|
||||
z: bodyBase.z,
|
||||
size: [dia, p.bodyLength * 0.62, dia],
|
||||
pitch: -tilt,
|
||||
});
|
||||
// A collar where the body meets the head. Every one of these has one and it
|
||||
// is what stops the capsule reading as a single extruded tube.
|
||||
const collar = lean(p.bodyLength * 0.1);
|
||||
bin.add(P.cylinder(18), metal, {
|
||||
y: ringY + collar.y,
|
||||
z: collar.z,
|
||||
size: [dia * 1.08, 0.006, dia * 1.08],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The basket: two hoops and eight wires, with a dome on top.
|
||||
const headBase = lean(p.bodyLength * 0.12);
|
||||
const headLength = p.bodyLength * 0.5;
|
||||
const headDia = dia * 1.12;
|
||||
for (const at of [0.12, 0.62]) {
|
||||
const hoop = lean(p.bodyLength * (0.12 + at * 0.5));
|
||||
bin.add(P.cylinder(18), metal, {
|
||||
y: ringY + hoop.y,
|
||||
z: hoop.z,
|
||||
size: [headDia * 1.02, 0.004, headDia * 1.02],
|
||||
pitch: -tilt,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const yaw = (i / 8) * Math.PI * 2;
|
||||
// The wires stand in the head's own tilted frame, so they are placed at
|
||||
// the head's base and rotated with it rather than around the world Y.
|
||||
const offset = headDia * 0.5;
|
||||
bin.add(P.box(), grille, {
|
||||
x: Math.sin(yaw) * offset,
|
||||
y: ringY + headBase.y + Math.cos(yaw) * offset * Math.sin(tilt),
|
||||
z: headBase.z + Math.cos(yaw) * offset * Math.cos(tilt),
|
||||
size: [0.0035, headLength, 0.0035],
|
||||
pitch: -tilt,
|
||||
});
|
||||
}
|
||||
const domeAt = lean(p.bodyLength * 0.12 + headLength);
|
||||
bin.add(P.sphere(14), grille, {
|
||||
y: ringY + domeAt.y - headDia * 0.25,
|
||||
z: domeAt.z,
|
||||
size: [headDia, headDia * 0.5, headDia],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The foam windscreen, pushed over the basket. Slightly bigger than the
|
||||
// head and slightly squashed, because foam is.
|
||||
const foamAt = lean(p.bodyLength * 0.3);
|
||||
bin.add(P.sphere(16), grille, {
|
||||
y: ringY + foamAt.y - headDia * 0.62,
|
||||
z: foamAt.z,
|
||||
size: [headDia * 1.24, headDia * 1.34, headDia * 1.24],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The mute LED, on the front of the body below the basket.
|
||||
const ledAt = lean(-p.bodyLength * 0.12);
|
||||
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
|
||||
y: ringY + ledAt.y,
|
||||
z: ledAt.z + dia * 0.5,
|
||||
size: [0.008, 0.003, 0.008],
|
||||
pitch: Math.PI / 2 - tilt,
|
||||
});
|
||||
|
||||
return deviceGroup("device.mic.desk", bin, led);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Speaker --------------------------------------------------------------
|
||||
|
||||
type SpeakerParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
/** Slats across the grille. Fewer reads as a radiator, more as a solid panel. */
|
||||
slats: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact powered monitor speaker.
|
||||
*
|
||||
* The grille is the reason this asset exists in geometry rather than in a
|
||||
* texture. `deviceMesh` is double-sided (`materials.ts`) precisely so that the
|
||||
* gaps between the slats show the shaded inside of the cabinet behind them, and
|
||||
* that parallax — slats in front, driver behind, dark cabinet behind that — is
|
||||
* the entire difference between a speaker and a box with stripes on it. It costs
|
||||
* a dozen boxes.
|
||||
*
|
||||
* The bass port is a real hole in the same sense: a recessed dark tube on the
|
||||
* front baffle rather than a black circle drawn on it.
|
||||
*
|
||||
* The cabinet is `roundedBoxOf` at its finished size, so the 4 mm chamfer round
|
||||
* its edges is genuinely circular instead of an ellipse stretched by a
|
||||
* non-uniform scale — which is the whole reason that method exists.
|
||||
*/
|
||||
export const deviceSpeakerDesk = defineAsset<SpeakerParams>({
|
||||
id: "tera:device.speaker.desk",
|
||||
label: "Desk monitor speaker",
|
||||
defaults: { width: 0.14, height: 0.22, depth: 0.17, slats: 11 },
|
||||
|
||||
footprint(p) {
|
||||
// The isolation pad under it is a touch wider than the cabinet, and the
|
||||
// grille stands a few millimetres proud of the baffle at +Z.
|
||||
return {
|
||||
width: p.width + 0.012,
|
||||
depth: p.depth + 0.018,
|
||||
height: p.height + 0.016,
|
||||
clearance: 0.1,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const led = new MeshBin();
|
||||
const shell = ctx.materials.get("deviceShell");
|
||||
const mesh = ctx.materials.get("deviceMesh");
|
||||
const metal = ctx.materials.get("metalTrim");
|
||||
|
||||
// ---- The isolation pad. A monitor on a desk stands on foam, and the 16 mm
|
||||
// of shadow under the cabinet is what stops it looking stuck to the desktop.
|
||||
const pad = 0.014;
|
||||
bin.add(P.box(), ctx.materials.get("upholstery"), {
|
||||
y: 0,
|
||||
size: [p.width + 0.012, pad, p.depth + 0.006],
|
||||
pitch: 0,
|
||||
});
|
||||
|
||||
// ---- The cabinet, and the baffle recessed into its front face.
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, p.depth, 0.005), shell, { y: pad, size: 1 });
|
||||
const baffleZ = p.depth / 2 - 0.008;
|
||||
bin.add(P.roundedBoxOf(p.width - 0.014, p.height - 0.014, 0.01, 0.004), shell, {
|
||||
y: pad + 0.007,
|
||||
z: baffleZ - 0.004,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
// ---- The woofer: surround ring, cone, dust cap. The cone points at the
|
||||
// person, so it is a `cone()` rolled over — its base is its wide end.
|
||||
const wooferY = pad + p.height * 0.36;
|
||||
const wooferR = Math.min(p.width * 0.36, p.height * 0.24);
|
||||
bin.add(P.cylinder(20), metal, {
|
||||
y: wooferY,
|
||||
z: baffleZ,
|
||||
size: [wooferR * 2.2, 0.006, wooferR * 2.2],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
bin.add(P.cone(20), mesh, {
|
||||
y: wooferY,
|
||||
z: baffleZ - 0.024,
|
||||
size: [wooferR * 1.9, 0.026, wooferR * 1.9],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
// The dust cap rides on `metalTrim` rather than on `deviceShell`: the shell
|
||||
// is all `roundedBoxOf` in this asset and a sphere is indexed, and mixing the
|
||||
// two under one material makes `mergeGeometries` drop the whole cabinet.
|
||||
bin.add(P.sphere(12), metal, {
|
||||
y: wooferY,
|
||||
z: baffleZ - 0.004,
|
||||
size: [wooferR * 0.7, wooferR * 0.5, wooferR * 0.7],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The tweeter, in its own shallow waveguide.
|
||||
const tweeterY = pad + p.height * 0.78;
|
||||
const tweeterR = wooferR * 0.42;
|
||||
bin.add(P.cylinder(16), metal, {
|
||||
y: tweeterY,
|
||||
z: baffleZ,
|
||||
size: [tweeterR * 2.6, 0.005, tweeterR * 2.6],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
bin.add(P.sphere(12), mesh, {
|
||||
y: tweeterY,
|
||||
z: baffleZ - 0.002,
|
||||
size: [tweeterR * 1.6, tweeterR * 1.2, tweeterR * 1.6],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The bass port: a tube sunk into the baffle between the drivers.
|
||||
const portY = pad + p.height * 0.16;
|
||||
bin.add(P.cylinder(14), mesh, {
|
||||
y: portY,
|
||||
z: baffleZ - 0.03,
|
||||
size: [wooferR * 0.62, 0.034, wooferR * 0.62],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The grille. Horizontal slats standing proud of the baffle, with real
|
||||
// gaps between them — the point of the whole asset.
|
||||
const slats = Math.max(3, Math.round(p.slats));
|
||||
const span = p.height - 0.03;
|
||||
const pitch = span / slats;
|
||||
for (let i = 0; i < slats; i++) {
|
||||
bin.add(P.box(), mesh, {
|
||||
y: pad + 0.015 + i * pitch,
|
||||
z: baffleZ + 0.004,
|
||||
size: [p.width - 0.018, pitch * 0.52, 0.004],
|
||||
});
|
||||
}
|
||||
// Two uprights holding the slats, so the grille is a frame and not a stack.
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), mesh, {
|
||||
x: (sx * (p.width - 0.018)) / 2,
|
||||
y: pad + 0.015,
|
||||
z: baffleZ + 0.004,
|
||||
size: [0.005, span, 0.005],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The power LED, bottom-centre under the grille.
|
||||
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
|
||||
y: pad + 0.008,
|
||||
z: baffleZ + 0.006,
|
||||
size: [0.006, 0.003, 0.006],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
|
||||
return deviceGroup("device.speaker.desk", bin, led);
|
||||
},
|
||||
});
|
||||
|
||||
/** Both device assets, for `index.ts` and for anything registering a subset. */
|
||||
export const DEVICE_ASSETS = [deviceMicDesk, deviceSpeakerDesk] as const;
|
||||
+146
-19
@@ -1,23 +1,131 @@
|
||||
/**
|
||||
* 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.
|
||||
* ### The shard problem, and what fixed it
|
||||
*
|
||||
* Neither takes a `colorKey`. A plant is the colour a plant is.
|
||||
* Every leaf in here used to be a bare `P.panel()` — an untextured rectangle in
|
||||
* an opaque green material. That is fine in a thumbnail and catastrophic at eye
|
||||
* height, and the live LA studio proved it: walk into the courtyard and the
|
||||
* corner planting fills a third of the frame with flat green shards. It was the
|
||||
* worst-looking asset in the product and no amount of lighting work was ever
|
||||
* going to fix it, because the problem is the *silhouette* — a rectangle has the
|
||||
* wrong outline no matter how it is shaded.
|
||||
*
|
||||
* The fix is a coverage map. `foliage` now carries the `leafAlpha` cutout with
|
||||
* `alphaTest 0.5` (`materials.ts`), so the leaf-shaped part of each quad is
|
||||
* drawn and the rest is discarded. Three things follow from that, and all three
|
||||
* are why this is a cutout rather than a blend:
|
||||
*
|
||||
* - the leaf still writes depth and still sorts like solid geometry, so a plant
|
||||
* in front of a window does not have to be drawn in a particular order;
|
||||
* - the shadow it casts is leaf-shaped, because three's depth material copies
|
||||
* `alphaMap` and `alphaTest` across;
|
||||
* - it survives `low` quality, because `leafAlpha` carries a resolution floor.
|
||||
* "No maps" at low quality is a statement about *shading* cost; a cutout is
|
||||
* one fetch and a discard, and the alternative at low quality is not a
|
||||
* cheaper plant, it is the shard again.
|
||||
*
|
||||
* ### What the geometry still has to do
|
||||
*
|
||||
* A cutout only works if the quad's UVs run along the leaf. `leafAlpha` is drawn
|
||||
* tip-at-top with the stem at the bottom, so **+V must run from the base of the
|
||||
* quad to its tip**, which is exactly what `P.panel()` gives (0..1 over a
|
||||
* standing rectangle, base on the floor) — the panel was never the problem, the
|
||||
* missing map was.
|
||||
*
|
||||
* Beyond that, two changes make a card read as a frond rather than as a
|
||||
* postcard with a leaf printed on it. Blades are **curved**, by splitting each
|
||||
* one into two or three cards that each pick up a little more pitch, so the leaf
|
||||
* arches instead of standing dead straight; and each blade gets a small **roll**
|
||||
* so it is not edge-on flat to its own stem. Both are geometry the alpha map
|
||||
* cannot supply, and both cost one extra quad per leaf at most.
|
||||
*
|
||||
* Leaves stay cards rather than modelled solids: a modelled leaf is a hundred
|
||||
* triangles and forty of them is a plant nobody can afford in a room that also
|
||||
* has furniture in it.
|
||||
*
|
||||
* Neither asset takes a `colorKey`. A plant is the colour a plant is.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { defineAsset, type AssetContext } from "../kit.ts";
|
||||
import type { SurfaceMaterial } from "../materials.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));
|
||||
|
||||
/**
|
||||
* One arching blade, as `segments` cards laid end to end.
|
||||
*
|
||||
* Exported because `studio.ts` plants a courtyard trough out of the same
|
||||
* blades: two files drawing foliage two different ways is how a library starts
|
||||
* looking like two libraries.
|
||||
*
|
||||
* Each card starts where the last one ended and carries a little more pitch, so
|
||||
* the blade curves over instead of leaving in a straight line. The arithmetic is
|
||||
* the only fiddly part: a card placed with `pitch` has its base at the given
|
||||
* point and its tip `length` away along the pitched direction, and pitch here
|
||||
* runs in the *yawed* frame — so `advance` steps along the plant's local −Z
|
||||
* (outward, in the blade's own yaw) and up by whatever the pitch leaves.
|
||||
*
|
||||
* `pitch` is measured from vertical, matching the rest of the file: 0 stands
|
||||
* straight up and π/2 lies flat.
|
||||
*/
|
||||
export function leafBlade(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
material: SurfaceMaterial,
|
||||
b: {
|
||||
x?: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
/** Total length along the blade. */
|
||||
length: number;
|
||||
/** Width of the widest card. Tapers toward the tip. */
|
||||
width: number;
|
||||
yaw: number;
|
||||
/** Pitch at the base, radians from vertical. */
|
||||
pitch: number;
|
||||
/** How much further the blade has arched over by its tip. */
|
||||
droop: number;
|
||||
roll?: number;
|
||||
segments: number;
|
||||
},
|
||||
): void {
|
||||
const segments = Math.max(1, Math.round(b.segments));
|
||||
const step = b.length / segments;
|
||||
const sin = Math.sin(b.yaw);
|
||||
const cos = Math.cos(b.yaw);
|
||||
// Base of the current card, in the plant's own frame.
|
||||
let x = b.x ?? 0;
|
||||
let y = b.y;
|
||||
let z = b.z ?? 0;
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const t = i / segments;
|
||||
const pitch = b.pitch + b.droop * t * t;
|
||||
// Cards narrow toward the tip; the last one is roughly half the first.
|
||||
const width = b.width * (1 - 0.42 * t);
|
||||
bin.add(ctx.parts.panel(), material, {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
size: [width, step, 1],
|
||||
yaw: b.yaw,
|
||||
pitch,
|
||||
roll: b.roll ?? 0,
|
||||
});
|
||||
// Where this card's tip is: `step` along the pitched direction, resolved
|
||||
// back into the plant's frame through the blade's yaw.
|
||||
const rise = Math.cos(pitch) * step;
|
||||
const reach = Math.sin(pitch) * step;
|
||||
y += rise;
|
||||
x -= sin * reach;
|
||||
z -= cos * reach;
|
||||
}
|
||||
}
|
||||
|
||||
type PottedParams = {
|
||||
/** Overall height including the pot. */
|
||||
height: number;
|
||||
@@ -49,6 +157,8 @@ export const plantPotted = defineAsset<PottedParams>({
|
||||
y: potH - 0.03,
|
||||
size: [p.potDiameter, 0.03, p.potDiameter],
|
||||
});
|
||||
// The soil. A disc rather than nothing: without it you see straight down
|
||||
// into an open cylinder from the dollhouse camera.
|
||||
bin.add(P.disc(14), pot, {
|
||||
y: potH - 0.012,
|
||||
size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9],
|
||||
@@ -59,13 +169,16 @@ export const plantPotted = defineAsset<PottedParams>({
|
||||
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, {
|
||||
leafBlade(bin, ctx, leaf, {
|
||||
y: potH - 0.02,
|
||||
size: [length * 0.34, length, 1],
|
||||
length,
|
||||
width: length * 0.42,
|
||||
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),
|
||||
// Outer leaves lean further out; the middle ones stand up.
|
||||
pitch: 0.22 + t * 0.7 + jitter(ctx.rand, 0.12),
|
||||
droop: 0.42 + t * 0.3,
|
||||
roll: jitter(ctx.rand, 0.2),
|
||||
segments: 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,8 +194,10 @@ type TallParams = {
|
||||
};
|
||||
|
||||
/** 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;
|
||||
const TALL_DROOP = 1.2;
|
||||
const TALL_CROWN = 0.5;
|
||||
/** Extra arch a blade picks up between its base and its tip. */
|
||||
const TALL_ARCH = 0.5;
|
||||
|
||||
/**
|
||||
* The one piece of arithmetic `footprint` and `build` have to agree on.
|
||||
@@ -92,6 +207,10 @@ const TALL_CROWN = 0.6;
|
||||
* 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.
|
||||
*
|
||||
* The blades now arch as well as lean, so the effective angle used here is the
|
||||
* *mid-blade* one — base pitch plus a third of the arch — which is the honest
|
||||
* average of a curve that starts at one angle and finishes at another.
|
||||
*/
|
||||
function tallCanopy(p: TallParams): {
|
||||
potHeight: number;
|
||||
@@ -102,14 +221,16 @@ function tallCanopy(p: TallParams): {
|
||||
const potHeight = clamp(p.height * 0.26, 0.24, 0.55);
|
||||
const trunk = (p.height - potHeight) * 0.55;
|
||||
const rise = p.height - potHeight - trunk;
|
||||
const crownAngle = TALL_CROWN + TALL_ARCH / 3;
|
||||
const droopAngle = TALL_DROOP + TALL_ARCH / 3;
|
||||
// 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);
|
||||
const leaf = (rise * 0.66) / Math.cos(crownAngle);
|
||||
return {
|
||||
potHeight,
|
||||
trunk,
|
||||
leaf,
|
||||
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)),
|
||||
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(droopAngle)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,11 +283,17 @@ export const plantTall = defineAsset<TallParams>({
|
||||
const y = potH + trunkH + rise * 0.34 * t;
|
||||
const blades = 7 - tier;
|
||||
for (let i = 0; i < blades; i++) {
|
||||
bin.add(P.panel(), leaf, {
|
||||
leafBlade(bin, ctx, leaf, {
|
||||
y,
|
||||
size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1],
|
||||
length: leafLen * (0.85 + ctx.rand() * 0.3),
|
||||
width: leafLen * 0.3,
|
||||
yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25),
|
||||
pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15),
|
||||
droop: TALL_ARCH,
|
||||
roll: jitter(ctx.rand, 0.22),
|
||||
// Three cards on the long blades of a corner plant: this is the one
|
||||
// the camera gets closest to, and it is the one that was broken.
|
||||
segments: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,9 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
|
||||
defaults: { width: 3.6, depth: 0.62, counterHeight: 0.91, height: 2.16, bays: 5 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 1 };
|
||||
// The worktop oversails the carcass by 50 mm at the front, which is what a
|
||||
// worktop does and what the stated depth was missing.
|
||||
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 1 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -195,11 +197,14 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
|
||||
size: [0.012, 0.12, 0.012],
|
||||
});
|
||||
}
|
||||
// The worktop's front edge is at hand height and is the one edge in a
|
||||
// kitchen you actually touch, so it gets the full 6 mm.
|
||||
slab(bin, ctx, top, {
|
||||
y: p.counterHeight - 0.045,
|
||||
width: p.width,
|
||||
depth: p.depth + 0.05,
|
||||
thickness: 0.045,
|
||||
chamfer: 0.006,
|
||||
});
|
||||
|
||||
// Backsplash and upper units stop short over the sink to create a focal bay.
|
||||
@@ -282,6 +287,7 @@ export const kitchenIsland = defineAsset<IslandParams>({
|
||||
width: p.length,
|
||||
depth: p.depth,
|
||||
thickness: 0.05,
|
||||
chamfer: 0.006,
|
||||
});
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), {
|
||||
@@ -304,7 +310,10 @@ export const storageWardrobe = defineAsset<WardrobeParams>({
|
||||
defaults: { width: 1.8, depth: 0.58, height: 2.18, doors: 3 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.75 };
|
||||
// Doors and pulls stand proud of the carcass at +Z, and the stated depth has
|
||||
// to include them or a wardrobe pushed flush to a wall puts its handles
|
||||
// through the plaster.
|
||||
return { width: p.width, depth: p.depth + 0.03, height: p.height, clearance: 0.75 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -349,7 +358,16 @@ export const seatStool = defineAsset<StoolParams>({
|
||||
defaults: { diameter: 0.4, seatHeight: 0.68, back: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.diameter + 0.1, depth: p.diameter + 0.12, height: p.back ? 0.96 : p.seatHeight, clearance: 0.35 };
|
||||
// The back reaches 380 mm above the seat pan, and the height has to be
|
||||
// derived from `seatHeight` rather than hard-coded: it was 0.96, which was
|
||||
// right for the default 0.68 seat and 100 mm short of the geometry, and
|
||||
// wrong by an arbitrary amount for any other seat height a pack asked for.
|
||||
return {
|
||||
width: p.diameter + 0.1,
|
||||
depth: p.diameter + 0.12,
|
||||
height: p.back ? p.seatHeight + 0.38 : p.seatHeight,
|
||||
clearance: 0.35,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -404,7 +422,18 @@ export const lightFloor = defineAsset<FloorLightParams>({
|
||||
defaults: { height: 1.55, reach: 0.42, shadeDiameter: 0.32 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: Math.max(0.42, p.shadeDiameter), depth: p.reach + p.shadeDiameter / 2, height: p.height };
|
||||
// The one asset in the kit that is genuinely not centred on its own origin:
|
||||
// the base is under the column and the shade cantilevers out to −Z over it.
|
||||
// The footprint has to enclose both, so it is the reach plus half a shade in
|
||||
// front and the base's own radius behind — which is 180 mm the stated depth
|
||||
// used to be missing, and 180 mm is enough to push a floor lamp through a
|
||||
// wall.
|
||||
const base = 0.18;
|
||||
return {
|
||||
width: Math.max(0.42, p.shadeDiameter),
|
||||
depth: p.reach + p.shadeDiameter / 2 + base,
|
||||
height: p.height,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
|
||||
@@ -17,14 +17,35 @@
|
||||
* somebody walks through a wall — leave the collider with no gap where the door
|
||||
* is.
|
||||
*
|
||||
* Twenty-five assets is not a furniture catalogue and is not trying to be. It is
|
||||
* Forty 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.
|
||||
*
|
||||
* ### Why the count keeps going up, and why more *instances* would not have done
|
||||
*
|
||||
* Fifteen of these arrived at once — thirteen studio props and two devices — and
|
||||
* the reason is a property of `furnish.ts` rather than an appetite for
|
||||
* furniture. Props are batched by **(asset, colorKey)** and every instance in a
|
||||
* batch is geometrically identical, `ctx.rand` included. So a room that looks
|
||||
* sparse cannot be fixed by placing more of what is already there: ten more
|
||||
* shelves are the same shelf with the same books on it in ten places. Apparent
|
||||
* density is a function of how many distinct *kinds* a floor uses, and that
|
||||
* makes the length of this list the lever a pack author actually has.
|
||||
*
|
||||
* The catalogue is now three groups:
|
||||
*
|
||||
* - the **office kit** (`desks`, `seating`, `tables`, `storage`, `screens`,
|
||||
* `lighting`, `surfaces`, `greenery`) — a floor plate of desks and meetings;
|
||||
* - the **habitat kit** (`habitat.ts`) — a studio home rather than an office;
|
||||
* - the **studio kit** (`studio.ts`) and the **devices** (`devices.ts`) — a
|
||||
* working production floor: a courtyard, a robotics lab, a model loft, and
|
||||
* the two instruments the smart-device layer drives.
|
||||
*/
|
||||
|
||||
import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
|
||||
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
|
||||
import { DEVICE_ASSETS, deviceMicDesk, deviceSpeakerDesk } from "./devices.ts";
|
||||
import { plantPotted, plantTall } from "./greenery.ts";
|
||||
import {
|
||||
bedPlatform,
|
||||
@@ -40,6 +61,22 @@ import { robotOptimus } from "./optimus.ts";
|
||||
import { screenMonitor, screenWallDisplay } from "./screens.ts";
|
||||
import { seatLounge, seatTaskChair } from "./seating.ts";
|
||||
import { storageLocker, storageShelf } from "./storage.ts";
|
||||
import {
|
||||
STUDIO_ASSETS,
|
||||
acousticBaffle,
|
||||
benchLab,
|
||||
benchSlat,
|
||||
cameraTripod,
|
||||
canopyParasol,
|
||||
cartTool,
|
||||
caseStack,
|
||||
dividerSlat,
|
||||
dockRobot,
|
||||
lightSoftbox,
|
||||
planterTrough,
|
||||
rackEquipment,
|
||||
shelfWall,
|
||||
} from "./studio.ts";
|
||||
import { rug, whiteboard } from "./surfaces.ts";
|
||||
import { tableMeeting, tableSide } from "./tables.ts";
|
||||
|
||||
@@ -69,6 +106,8 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [
|
||||
storageWardrobe,
|
||||
seatStool,
|
||||
lightFloor,
|
||||
...STUDIO_ASSETS,
|
||||
...DEVICE_ASSETS,
|
||||
];
|
||||
|
||||
/** Register the built-in catalogue into a registry. Defaults to the shared one. */
|
||||
@@ -104,4 +143,22 @@ export {
|
||||
storageWardrobe,
|
||||
seatStool,
|
||||
lightFloor,
|
||||
planterTrough,
|
||||
benchSlat,
|
||||
canopyParasol,
|
||||
benchLab,
|
||||
rackEquipment,
|
||||
cartTool,
|
||||
dockRobot,
|
||||
caseStack,
|
||||
lightSoftbox,
|
||||
cameraTripod,
|
||||
acousticBaffle,
|
||||
dividerSlat,
|
||||
shelfWall,
|
||||
deviceMicDesk,
|
||||
deviceSpeakerDesk,
|
||||
};
|
||||
|
||||
export { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "./devices.ts";
|
||||
export { STUDIO_ASSET_IDS } from "./studio.ts";
|
||||
|
||||
@@ -58,11 +58,25 @@
|
||||
* darkens their screen surrounds darkens the robot's face, which is the right
|
||||
* coupling rather than a coincidental one.
|
||||
*
|
||||
* `metalTrim` was tried as a third material for the joint barrels and dropped.
|
||||
* The office rig carries no environment map, so a `metalness: 0.85` role has
|
||||
* nothing to reflect and renders as a dull dark grey — indistinguishable from
|
||||
* `metalTrim` was tried as a third material for the joint barrels and dropped,
|
||||
* and then taken back up. The original reasoning was sound and is no longer
|
||||
* true, so it is worth recording both halves rather than quietly deleting one:
|
||||
* the office rig carried **no environment map**, so a `metalness: 0.85` role had
|
||||
* nothing to reflect and rendered as a dull dark grey — indistinguishable from
|
||||
* `screenBezel` at ten metres — while costing another mesh in nine of the eleven
|
||||
* groups. Two materials, eighteen meshes.
|
||||
* groups.
|
||||
*
|
||||
* `engine/environmentRig.ts` now supplies one. A metal barrel therefore picks up
|
||||
* the room around it and reads as a machined surface rather than as a darker
|
||||
* patch of plastic, which is the whole difference between "a robot" and "a
|
||||
* figurine of a robot" at the distance somebody stands next to one.
|
||||
*
|
||||
* The cost was re-scoped rather than re-accepted, though. `metalTrim` is used
|
||||
* **only for the four joint barrels** — hip axle, shoulder, knee, elbow — so it
|
||||
* appears in four groups rather than nine, and the figure is three materials and
|
||||
* twenty-two meshes rather than three materials and twenty-seven. The sole, the
|
||||
* ankle and the visor stay `screenBezel`, because a sole is rubber, an ankle is a
|
||||
* gap and a visor is glass, and none of the three wants a specular ring on it.
|
||||
*
|
||||
* ### The indexed/non-indexed rule bites here harder than anywhere else
|
||||
*
|
||||
@@ -245,6 +259,12 @@ export interface OptimusRig {
|
||||
interface Skin {
|
||||
shell: SurfaceMaterial;
|
||||
frame: SurfaceMaterial;
|
||||
/**
|
||||
* The joint barrels, and nothing else. Kept separate from `frame` so that
|
||||
* widening its use is a deliberate act with a visible cost in the mesh count
|
||||
* rather than a one-character change — see the header.
|
||||
*/
|
||||
metal: SurfaceMaterial;
|
||||
}
|
||||
|
||||
/** A point in whichever joint frame the emitter is drawing into. */
|
||||
@@ -315,7 +335,7 @@ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
// is the gap rule 4 in the header is about and the one the hip did not have.
|
||||
// Shorten it again and the hip goes back to one unbroken pale mass from the
|
||||
// waist to the knee, which is what a mannequin looks like.
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,7 +405,7 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
// robot and 80 draw calls for a crowd of four instead of 72. Eight draw
|
||||
// calls for a 24 mm band of dark under a cap that already reads as a
|
||||
// separate piece is not the trade. A fatter drum is free.
|
||||
barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
|
||||
barrel(bin, P, s.metal, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +622,7 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
*/
|
||||
function emitShin(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const drop = OPTIMUS.kneeY - OPTIMUS.ankleY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
|
||||
|
||||
bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] });
|
||||
|
||||
@@ -707,7 +727,7 @@ const DIGITS = [
|
||||
*/
|
||||
function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void {
|
||||
const drop = OPTIMUS.elbowY - OPTIMUS.wristY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] });
|
||||
// Wider than the palm below it and narrower than the forearm above, in that
|
||||
// order. At 0.062 it was narrower than both, which put a 6 mm slot of
|
||||
@@ -779,6 +799,7 @@ export function buildOptimus(ctx: AssetContext): OptimusRig {
|
||||
const skin: Skin = {
|
||||
shell: ctx.materials.get("paper"),
|
||||
frame: ctx.materials.get("screenBezel"),
|
||||
metal: ctx.materials.get("metalTrim"),
|
||||
};
|
||||
|
||||
const root = new THREE.Group();
|
||||
|
||||
+162
-19
@@ -7,14 +7,110 @@
|
||||
* `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.
|
||||
* ### A screen is three surfaces, not one
|
||||
*
|
||||
* Both of these used to be a bezel with a flat `screenDisplay` panel glued to
|
||||
* it, and on the live site every display in the building was one uniform glowing
|
||||
* rectangle. Under the tone curve `stage.ts` now runs it was worse than uniform:
|
||||
* a 40%-emissive white panel clips, so a monitor, a wall display and a whiteboard
|
||||
* were all the same shade of blown-out white.
|
||||
*
|
||||
* The fix is to separate the three things a display physically is:
|
||||
*
|
||||
* - **`screenBezel`** — the moulded surround, a lit surface like any other;
|
||||
* - **`screenDisplay`** — the *dark* panel, the black border of glass around the
|
||||
* active area and what an off screen looks like;
|
||||
* - **`screenContent`** — the active area, carrying the `screenUI` drawing as
|
||||
* both `map` and `emissiveMap`. That second binding is the whole point: with
|
||||
* a flat glow the drawn interface is a pattern printed on a lamp, and with the
|
||||
* map on `emissiveMap` the lit pixels emit and the dark chrome between them
|
||||
* does not. It is the difference between a monitor and a light box.
|
||||
*
|
||||
* ### Where the layout comes from, and why it cannot be random
|
||||
*
|
||||
* `screenUI` draws `SCREEN_UI_VARIANTS` different layouts, and a wall of screens
|
||||
* showing the same one is the same defect one level down. But an asset cannot
|
||||
* roll for a layout per instance: `furnish.ts` batches props **per kind** and
|
||||
* draws `ctx.rand` once for the whole batch, so twelve monitors in one batch
|
||||
* would roll once between them and get one layout anyway.
|
||||
*
|
||||
* So the layout arrives as *authored data*: it is derived from the prop's
|
||||
* `colorKey`, which is already part of the batch key. A pack that writes
|
||||
* `colorKey: "ui-b"` on half its monitors gets two batches, two materials and
|
||||
* two layouts, and gets them deterministically — the same pack renders the same
|
||||
* wall of screens on every reload. `colorKey` is opaque here in exactly the way
|
||||
* `ARCHITECTURE.md` §3.3 requires: this file will never learn that `"ui-b"` means
|
||||
* anything, it only hashes it.
|
||||
*
|
||||
* Neither asset tints. A screen is bezel and glass, and there is no part of it
|
||||
* anybody wants to be the colour of a team.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { defineAsset, type AssetContext } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { alongFacing } from "./common.ts";
|
||||
|
||||
/**
|
||||
* The layout this instance's batch shows, from its `colorKey`.
|
||||
*
|
||||
* FNV-1a, the same four lines `furnish.ts` uses to seed a batch, and for the
|
||||
* same reason: any stable string-to-number would do and this one needs no
|
||||
* dependency. An absent key gives layout 0 rather than a random one, so a pack
|
||||
* that says nothing gets the plainest screen rather than an arbitrary one.
|
||||
*/
|
||||
function layoutFor(ctx: AssetContext): number {
|
||||
const key = ctx.colorKey;
|
||||
if (!key) return 0;
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dark panel and the lit content face, as two quads a fraction of a
|
||||
* millimetre apart.
|
||||
*
|
||||
* The dark one is the full active-area rectangle and the lit one is inset by the
|
||||
* black border every panel has, so the content never runs to the edge of the
|
||||
* glass — which is the single cue that separates a screen from a sheet of paper
|
||||
* with a picture on it. `screenContent` is `DoubleSide`-free and faces +Z, so
|
||||
* both quads are drawn only from the front.
|
||||
*/
|
||||
function displayFace(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
face: {
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pitch?: number;
|
||||
/** Black border between the glass edge and the drawn content, metres. */
|
||||
border: number;
|
||||
},
|
||||
): void {
|
||||
const pitch = face.pitch ?? 0;
|
||||
const dark = ctx.materials.get("screenDisplay");
|
||||
const lit = ctx.materials.variant("screenContent", layoutFor(ctx));
|
||||
|
||||
bin.add(ctx.parts.panel(), dark, {
|
||||
y: face.y,
|
||||
z: face.z,
|
||||
size: [face.width, face.height, 1],
|
||||
pitch,
|
||||
});
|
||||
const proud = alongFacing(pitch, 0.0015);
|
||||
bin.add(ctx.parts.panel(), lit, {
|
||||
y: face.y + face.border + proud.y,
|
||||
z: face.z + proud.z,
|
||||
size: [face.width - face.border * 2, face.height - face.border * 2, 1],
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
|
||||
type MonitorParams = {
|
||||
/** Bezel width, metres. 0.56 is a 24-inch panel. */
|
||||
width: number;
|
||||
@@ -40,23 +136,57 @@ export const screenMonitor = defineAsset<MonitorParams>({
|
||||
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] });
|
||||
// `metalTrim` here is all `roundedBoxOf` and `screenBezel` is all
|
||||
// `roundedBoxOf` too — see the indexed/non-indexed rule in `common.ts`. A
|
||||
// 4 mm chamfer on the foot is what makes it catch a highlight along its edge
|
||||
// instead of reading as a printed rectangle on the desk.
|
||||
bin.add(P.roundedBoxOf(p.width * 0.42, 0.014, 0.16, 0.004), trim, {
|
||||
size: 1,
|
||||
z: -0.008,
|
||||
});
|
||||
// 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] });
|
||||
bin.add(P.roundedBoxOf(0.052, p.standHeight + 0.07, 0.042, 0.008), trim, {
|
||||
y: 0.01,
|
||||
z: -0.02,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
const baseY = p.standHeight + 0.02;
|
||||
const pitch = -p.tilt;
|
||||
const front = alongFacing(pitch, 0.014);
|
||||
bin.add(P.roundedBox(0.03), bezel, {
|
||||
const front = alongFacing(pitch, 0.013);
|
||||
|
||||
// The shell, and a shallower housing behind it. A monitor is not a slab: it
|
||||
// is a thin panel with the electronics in a bulge behind the middle, and
|
||||
// that bulge is what its silhouette from three-quarters is made of.
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, 0.022, 0.006), bezel, {
|
||||
y: baseY,
|
||||
size: [p.width, p.height, 0.024],
|
||||
size: 1,
|
||||
pitch,
|
||||
});
|
||||
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
|
||||
y: baseY + 0.012 + front.y,
|
||||
const back = alongFacing(pitch, -0.02);
|
||||
bin.add(P.roundedBoxOf(p.width * 0.6, p.height * 0.55, 0.026, 0.01), bezel, {
|
||||
y: baseY + p.height * 0.22 + back.y,
|
||||
z: back.z,
|
||||
size: 1,
|
||||
pitch,
|
||||
});
|
||||
|
||||
displayFace(bin, ctx, {
|
||||
y: baseY + 0.011 + front.y,
|
||||
z: front.z,
|
||||
size: [p.width - 0.018, p.height - 0.026, 1],
|
||||
width: p.width - 0.016,
|
||||
height: p.height - 0.024,
|
||||
pitch,
|
||||
border: 0.008,
|
||||
});
|
||||
|
||||
// Standby light, bottom-right of the chin as it is on almost every panel.
|
||||
bin.add(P.box(), ctx.materials.get("deviceIndicator"), {
|
||||
x: p.width * 0.36,
|
||||
y: baseY + 0.005 + front.y,
|
||||
z: front.z + 0.002,
|
||||
size: [0.012, 0.004, 0.004],
|
||||
pitch,
|
||||
});
|
||||
|
||||
@@ -85,20 +215,33 @@ export const screenWallDisplay = defineAsset<WallDisplayParams>({
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const bezel = ctx.materials.get("screenBezel");
|
||||
|
||||
// The bracket. Boxes, and `metalTrim` uses nothing else in this asset.
|
||||
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],
|
||||
z: -0.05,
|
||||
size: [0.44, 0.32, 0.03],
|
||||
});
|
||||
bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), {
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), ctx.materials.get("metalTrim"), {
|
||||
x: sx * 0.19,
|
||||
y: p.mount + p.height / 2 - 0.16,
|
||||
z: -0.028,
|
||||
size: [0.05, 0.3, 0.026],
|
||||
});
|
||||
}
|
||||
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, 0.042, 0.008), bezel, {
|
||||
y: p.mount,
|
||||
size: [p.width, p.height, 0.05],
|
||||
size: 1,
|
||||
});
|
||||
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],
|
||||
displayFace(bin, ctx, {
|
||||
y: p.mount + 0.012,
|
||||
z: 0.023,
|
||||
width: p.width - 0.022,
|
||||
height: p.height - 0.024,
|
||||
border: 0.01,
|
||||
});
|
||||
|
||||
return bin.build("screen.wall-display");
|
||||
|
||||
@@ -40,10 +40,14 @@ export const storageShelf = defineAsset<ShelfParams>({
|
||||
const bayH = (p.height - (bays + 1) * BOARD) / bays;
|
||||
const inner = p.width - 2 * BOARD;
|
||||
|
||||
// Every part in the `shelf` material is a `roundedBoxOf`, uprights included,
|
||||
// because the primitive class has to be uniform across a material and a
|
||||
// chamfered board beside a sharp upright would look like a mistake anyway.
|
||||
// A 2.5 mm radius: shelf boards are thin and anything larger reads as a
|
||||
// moulded plastic unit rather than as a 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.roundedBoxOf(BOARD, p.height, p.depth, 0.0025), board, {
|
||||
x: (sx * (p.width - BOARD)) / 2,
|
||||
});
|
||||
}
|
||||
bin.add(P.box(), ctx.materials.get("cabinet"), {
|
||||
@@ -52,9 +56,8 @@ export const storageShelf = defineAsset<ShelfParams>({
|
||||
});
|
||||
|
||||
for (let i = 0; i <= bays; i++) {
|
||||
bin.add(P.box(), board, {
|
||||
bin.add(P.roundedBoxOf(inner, BOARD, p.depth, 0.0025), board, {
|
||||
y: i * (bayH + BOARD),
|
||||
size: [inner, BOARD, p.depth],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,7 +115,11 @@ export const storageLocker = defineAsset<LockerParams>({
|
||||
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 };
|
||||
//
|
||||
// The stated depth includes the doors and their pulls, which stand 24 mm
|
||||
// proud of the carcass at +Z. It did not, and a bank of lockers pushed
|
||||
// flush to a wall by its own footprint put its handles through the plaster.
|
||||
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 0.9 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { panelSlab, tintable } from "./common.ts";
|
||||
import { jitter, panelSlab, tintable } from "./common.ts";
|
||||
|
||||
type RugParams = {
|
||||
width: number;
|
||||
@@ -56,6 +56,8 @@ type WhiteboardParams = {
|
||||
/** Floor to the bottom edge of the writing surface. */
|
||||
mount: number;
|
||||
tray: boolean;
|
||||
/** Sticky notes and abstract marker strokes on the face. */
|
||||
worked: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,11 +68,35 @@ type WhiteboardParams = {
|
||||
* 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.
|
||||
*
|
||||
* ### Why there is anything on it
|
||||
*
|
||||
* The live site shows this as "a large blank white rectangle", and it is the
|
||||
* biggest single flat surface in a meeting room. The `whiteboard` texture draws
|
||||
* faint ghosting from previous wipes, which is right and is not enough: what
|
||||
* makes a board read as *used* is objects on it that catch their own light —
|
||||
* sticky notes standing a millimetre proud, and strokes with a shadow under
|
||||
* them.
|
||||
*
|
||||
* So `worked` adds relief rather than more texture. Two millimetres is enough to
|
||||
* cast a hairline shadow under the rig's key light, which is what separates a
|
||||
* note stuck to a board from a coloured rectangle printed on one.
|
||||
*
|
||||
* Everything on it is **abstract**: rectangles and strokes, no glyphs, no words,
|
||||
* no diagrams of anything in particular. That is `ARCHITECTURE.md` §3.1 —
|
||||
* legible content on a board is either somebody's real work or a convincing
|
||||
* imitation of it, and neither belongs in an Apache-2.0 repo. Read from two
|
||||
* metres it says "a team used this room", which is the whole job.
|
||||
*
|
||||
* It is seeded from `ctx.rand`, so every board in one batch carries the same
|
||||
* notes in the same places. That is the price `furnish.ts` documents and it is
|
||||
* paid knowingly; a pack that wants two different boards authors two batches
|
||||
* with different `colorKey`s, which is the same seam the screens use.
|
||||
*/
|
||||
export const whiteboard = defineAsset<WhiteboardParams>({
|
||||
id: "tera:whiteboard",
|
||||
label: "Whiteboard",
|
||||
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true },
|
||||
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true, worked: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: 0.1, height: p.mount + p.height };
|
||||
@@ -102,6 +128,77 @@ export const whiteboard = defineAsset<WhiteboardParams>({
|
||||
});
|
||||
}
|
||||
|
||||
if (p.worked) {
|
||||
// The writing surface, inset from the frame — the same rectangle the
|
||||
// panel above occupies, minus its border.
|
||||
const faceX = p.width - 0.14;
|
||||
const faceY = p.height - 0.14;
|
||||
const originX = -faceX / 2;
|
||||
const originY = p.mount + 0.06;
|
||||
const face = 0.012;
|
||||
|
||||
// Two columns of sticky notes. A grid rather than a scatter, because a
|
||||
// board that has been worked on has structure on it and a scatter reads as
|
||||
// confetti.
|
||||
const notes = ctx.materials.get("accent");
|
||||
const pale = ctx.materials.get("paper");
|
||||
for (let column = 0; column < 3; column++) {
|
||||
const rows = 2 + Math.floor(ctx.rand() * 2);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const size = 0.07 + ctx.rand() * 0.02;
|
||||
bin.add(P.box(), ctx.rand() < 0.55 ? notes : pale, {
|
||||
x: originX + faceX * (0.62 + column * 0.13) + jitter(ctx.rand, 0.008),
|
||||
y: originY + faceY * (0.62 - row * 0.19) + jitter(ctx.rand, 0.008),
|
||||
z: face,
|
||||
size: [size, size, 0.002],
|
||||
roll: jitter(ctx.rand, 0.06),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Marker work on the left two thirds: a few boxes and the strokes joining
|
||||
// them. Thin dark slabs, standing proud enough to catch an edge highlight.
|
||||
const ink = ctx.materials.get("screenBezel");
|
||||
const boxes: [number, number][] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bx = originX + faceX * (0.1 + i * 0.16);
|
||||
const by = originY + faceY * (0.28 + (i % 2) * 0.3);
|
||||
boxes.push([bx, by]);
|
||||
const w = 0.16 + ctx.rand() * 0.06;
|
||||
const h = 0.09 + ctx.rand() * 0.03;
|
||||
// Four strokes rather than a filled rectangle: a drawn box is an
|
||||
// outline, and a solid one reads as a sticker.
|
||||
for (const sy of [-1, 1]) {
|
||||
bin.add(P.box(), ink, { x: bx, y: by + (sy * h) / 2, z: face, size: [w, 0.006, 0.002] });
|
||||
}
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), ink, { x: bx + (sx * w) / 2, y: by, z: face, size: [0.006, h, 0.002] });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < boxes.length - 1; i++) {
|
||||
const from = boxes[i];
|
||||
const to = boxes[i + 1];
|
||||
if (!from || !to) continue;
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
bin.add(P.box(), ink, {
|
||||
x: from[0] + dx / 2,
|
||||
y: from[1] + dy / 2,
|
||||
z: face,
|
||||
size: [Math.hypot(dx, dy), 0.005, 0.002],
|
||||
roll: Math.atan2(dy, dx),
|
||||
});
|
||||
}
|
||||
// One underlined heading bar across the top, which is what the eye reads
|
||||
// as "this board has a subject" without anything being legible.
|
||||
bin.add(P.box(), ink, {
|
||||
x: originX + faceX * 0.26,
|
||||
y: originY + faceY * 0.88,
|
||||
z: face,
|
||||
size: [faceX * 0.4, 0.008, 0.002],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.tray) {
|
||||
bin.add(P.box(), trim, {
|
||||
y: p.mount - 0.03,
|
||||
|
||||
@@ -51,7 +51,16 @@ export const tableMeeting = defineAsset<MeetingParams>({
|
||||
return bin.build("table.meeting");
|
||||
}
|
||||
|
||||
slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP });
|
||||
// A 5 mm edge radius. A 2.4 m board is the longest specular highlight in the
|
||||
// room and it is worth having; the round top a few lines above already has a
|
||||
// circular edge for free, which is why only this branch asks for one.
|
||||
slab(bin, ctx, top, {
|
||||
y: deckY,
|
||||
width: p.length,
|
||||
depth: p.width,
|
||||
thickness: TOP,
|
||||
chamfer: 0.005,
|
||||
});
|
||||
|
||||
if (p.legs === "post") {
|
||||
for (const sx of [-1, 1]) {
|
||||
|
||||
+54
-2
@@ -33,8 +33,36 @@ 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;
|
||||
/**
|
||||
* How far outside the city's lightness range an interior role may sit.
|
||||
*
|
||||
* ### Why this is 0.22 and not 0.14
|
||||
*
|
||||
* It moved when `stage.ts` took the renderer off `NoToneMapping`. The old value
|
||||
* was set against a renderer that clipped at linear 1.0, and under a clipping
|
||||
* renderer the top of the range is not a range at all — every role above about
|
||||
* 0.9 albedo, lit by a sun the atmosphere drives past 2.3, displayed as exactly
|
||||
* the same white. Widening the band would have bought darker darks and, at the
|
||||
* other end, more roles indistinguishable from each other. So the number was
|
||||
* held down, and roles that wanted to be genuinely dark — `screenBezel` at
|
||||
* L≈0.24, `chairBase`, `deviceShell` — were clamped up into a mid-grey they did
|
||||
* not want to be.
|
||||
*
|
||||
* ACES's shoulder gives the top two stops back: linear 1.0, 2.0 and 4.0 now
|
||||
* display at about 0.90, 0.95 and 0.98 and stay separable all the way up. With
|
||||
* the top recoverable, the band can be widened for the sake of the bottom
|
||||
* without the top collapsing, and 0.22 is what lets the darkest roles reach
|
||||
* L≈0.17 — a charcoal, which is what a screen bezel and a microphone body
|
||||
* actually are.
|
||||
*
|
||||
* One asymmetry worth knowing, since this is documented as one number applied to
|
||||
* both ends. `bandOf` clamps `maxL` at 1.0, and the city palette's lightest
|
||||
* entry (`skyHorizon`, L≈0.89) already reached that ceiling at 0.14. So in
|
||||
* practice this number only ever bites at the dark end. It is still written as
|
||||
* one number, because the moment it becomes two somebody will tune them
|
||||
* independently and the band stops meaning anything.
|
||||
*/
|
||||
export const LIGHTNESS_HEADROOM = 0.22;
|
||||
|
||||
/** One role's derivation: a city colour, and how far to move it. */
|
||||
export interface RoleShift {
|
||||
@@ -103,6 +131,30 @@ export const ROLE_SHIFTS: Record<SurfaceRole, RoleShift> = {
|
||||
lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 },
|
||||
whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 },
|
||||
|
||||
// Devices
|
||||
//
|
||||
// A desk microphone and a monitor speaker are the two darkest objects in a
|
||||
// studio and they are dark for a reason that is not styling: a hot LED and a
|
||||
// level meter have to read against their own body from three metres away. The
|
||||
// shell therefore goes as far down as the widened band allows (L≈0.20), and
|
||||
// the grille sits a hair under it so the two do not merge into one silhouette.
|
||||
//
|
||||
// `deviceIndicator` is the one role here whose *default* colour barely
|
||||
// matters. It descends from the city's park green at high lightness, which is
|
||||
// a credible "powered, idle" lamp, but the device render layer tints it per
|
||||
// state through `MaterialRegistry.tinted()` and that path bypasses the band
|
||||
// entirely. Authoring a saturated red here instead would not survive `bandOf`
|
||||
// anyway — saturation is clamped hard, by design, and an LED is exactly the
|
||||
// kind of thing that would talk somebody into softening that rule.
|
||||
deviceShell: { from: "flats", dh: 2, ds: -0.03, dl: -0.4 },
|
||||
deviceMesh: { from: "upland", dh: 6, ds: -0.04, dl: -0.34 },
|
||||
deviceIndicator: { from: "park", dh: -6, ds: 0.06, dl: 0.18 },
|
||||
// Near-white on purpose: `screenContent` carries the one texture in the
|
||||
// library that is *not* neutral (see `screenUI` in textures.ts), so this
|
||||
// colour has to get out of its way. A mid-grey here would multiply the drawn
|
||||
// interface down into mud.
|
||||
screenContent: { from: "shore", dh: 2, ds: -0.05, dl: 0.3 },
|
||||
|
||||
// Objects
|
||||
foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 },
|
||||
planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 },
|
||||
|
||||
+94
-37
@@ -63,57 +63,93 @@ export class PartBin {
|
||||
* 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.
|
||||
* `radius` is a fraction of the unit and is only correct while the part stays
|
||||
* cubic, because a corner is round in *object* space and a non-uniform scale
|
||||
* turns a circle into an ellipse. That is not a caveat you can design around
|
||||
* — almost nothing in an office is a cube — and it is why `seating.ts` gave up
|
||||
* and went back to sharp boxes.
|
||||
*
|
||||
* **Use `roundedBoxOf` instead**, which takes the finished metres and gets a
|
||||
* genuinely circular corner at any proportion. This one stays for the parts
|
||||
* that really are cubic, and because it is the cache entry `roundedBoxOf(1, 1,
|
||||
* 1, r)` resolves to anyway.
|
||||
*/
|
||||
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);
|
||||
return this.roundedBoxOf(1, 1, 1, radius);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
/**
|
||||
* The same moulded box, authored at its finished size in metres.
|
||||
*
|
||||
* The corner radius is in **metres** and is applied after the proportions are
|
||||
* known, so a 1.6 × 0.05 × 0.9 desk return gets a 12 mm round on all four
|
||||
* corners rather than a 12 mm round on two of them and a 380 mm oval on the
|
||||
* others. Place it with `size: 1` — the geometry is already the right size,
|
||||
* and scaling it is what this method exists to avoid.
|
||||
*
|
||||
* The radius is clamped to a fifth of the shortest side. Past that the bevel
|
||||
* eats the extrusion (a 0.5 radius on a 0.9-thick shelf has no flat left to
|
||||
* extrude) and `ExtrudeGeometry` starts emitting self-intersecting caps.
|
||||
*/
|
||||
roundedBoxOf(width: number, height: number, depth: number, radius = 0.06): THREE.BufferGeometry {
|
||||
const shortest = Math.max(0.002, Math.min(width, height, depth));
|
||||
const bevel = Math.min(shortest * 0.2, Math.max(0.001, radius));
|
||||
return this.memo(
|
||||
`rounded:${mm(width)}:${mm(height)}:${mm(depth)}:${mm(bevel)}`,
|
||||
() => {
|
||||
// The shape is drawn in the extruder's XY and the extrusion runs along
|
||||
// its +Z; the `rotateX` below maps that to width × depth on the floor
|
||||
// with the extrusion standing up as height.
|
||||
const halfX = width / 2 - bevel;
|
||||
const halfY = depth / 2 - bevel;
|
||||
const r = Math.min(halfX * 0.98, halfY * 0.98, bevel * 2);
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-halfX + r, -halfY);
|
||||
shape.lineTo(halfX - r, -halfY);
|
||||
shape.quadraticCurveTo(halfX, -halfY, halfX, -halfY + r);
|
||||
shape.lineTo(halfX, halfY - r);
|
||||
shape.quadraticCurveTo(halfX, halfY, halfX - r, halfY);
|
||||
shape.lineTo(-halfX + r, halfY);
|
||||
shape.quadraticCurveTo(-halfX, halfY, -halfX, halfY - r);
|
||||
shape.lineTo(-halfX, -halfY + r);
|
||||
shape.quadraticCurveTo(-halfX, -halfY, -halfX + r, -halfY);
|
||||
|
||||
// The bevel overhangs both ends of the extrusion, so the solid spans
|
||||
// -bevel..height-bevel before it is stood up and dropped onto the floor.
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: height - 2 * bevel,
|
||||
bevelEnabled: true,
|
||||
bevelSize: bevel,
|
||||
bevelThickness: bevel,
|
||||
bevelSegments: 3,
|
||||
curveSegments: 6,
|
||||
});
|
||||
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 {
|
||||
cylinder(segments = 20): 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
|
||||
* An eight-sided cylinder. Legs, columns, pen barrels — anything thin enough
|
||||
* that nobody will count the sides, which is most of the office.
|
||||
*
|
||||
* Six read as a hexagon on a chair column at desk distance, and the office
|
||||
* measures 44,754 triangles against a 550,000 budget: eight is four extra
|
||||
* triangles on the commonest part in the library and there is nowhere for the
|
||||
* saving to go.
|
||||
*/
|
||||
rod(): THREE.BufferGeometry {
|
||||
return this.cylinder(6);
|
||||
return this.cylinder(8);
|
||||
}
|
||||
|
||||
/** Unit-diameter cone, base on the floor. */
|
||||
@@ -143,7 +179,7 @@ export class PartBin {
|
||||
}
|
||||
|
||||
/** Unit-diameter disc lying in XZ, facing up. */
|
||||
disc(segments = 24): THREE.BufferGeometry {
|
||||
disc(segments = 32): THREE.BufferGeometry {
|
||||
return this.memo(`disc:${segments}`, () =>
|
||||
new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2),
|
||||
);
|
||||
@@ -294,6 +330,27 @@ export class MeshBin {
|
||||
return this.add(parts.box(), material, place);
|
||||
}
|
||||
|
||||
/**
|
||||
* A moulded box with a true corner radius at whatever proportions `place.size`
|
||||
* asks for — the drop-in replacement for `box()` on anything that is not a
|
||||
* sawn edge.
|
||||
*
|
||||
* The size is spent on the *geometry* rather than on the placement matrix,
|
||||
* which is the whole trick: `parts.roundedBox()` scaled to 1.4 × 0.06 × 0.7
|
||||
* has 42 mm corners on two sides and 3 mm on the others, and looking at that
|
||||
* is why `seating.ts` reverted to sharp boxes. Everything else about the
|
||||
* placement — position, yaw, pitch, roll — is passed through untouched.
|
||||
*
|
||||
* The cost is a cache entry per distinct size rather than one for the whole
|
||||
* library, so this is for the parts a reader will see the silhouette of, not
|
||||
* for a hundred randomised trinkets.
|
||||
*/
|
||||
rounded(material: THREE.Material, place: Placement, radius = 0.02): this {
|
||||
const size = place.size ?? 1;
|
||||
const [w, h, d] = typeof size === "number" ? [size, size, size] : size;
|
||||
return this.add(parts.roundedBoxOf(w, h, d, radius), material, { ...place, size: 1 });
|
||||
}
|
||||
|
||||
/** Number of parts waiting to be merged. Handy in an asset's own tests. */
|
||||
get size(): number {
|
||||
let n = 0;
|
||||
|
||||
+936
-42
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
export {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
LUMBRIDGE_EV_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
buildLumbridgeEV,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
createModelXPaintPool,
|
||||
disposeModelX,
|
||||
disposeModelXPaintPool,
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
|
||||
+1196
-274
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user