1
0

feat: add active SF and LA HQ environments

This commit is contained in:
2026-08-19 02:51:17 -07:00
parent afa285bd5c
commit 0557a26e6b
14 changed files with 1096 additions and 2312 deletions
+441
View File
@@ -0,0 +1,441 @@
/**
* The small-space habitat kit.
*
* Offices prove the scene format with desks and meeting rooms. A studio home
* needs a different handful of silhouettes before it reads honestly: a bed is
* not a wide desk, a kitchen is not a row of lockers, and two lounge chairs do
* not become a sofa because they touch. These assets stay inside the same
* primitive grammar and material palette as the office kit, so they add no
* binary art, texture download, or second visual language.
*
* As with every asset in this directory, source is Apache-2.0 and the generated
* artistic output is dedicated under CC0-1.0 by `src/assets/LICENSE-ART`.
*/
import * as THREE from "three";
import { defineAsset } from "../kit.ts";
import { MeshBin } from "../parts.ts";
import { panelSlab, slab, tintable } from "./common.ts";
type BedParams = {
width: number;
length: number;
platformHeight: number;
headboardHeight: number;
};
/** A low platform bed. The headboard is at local -Z and the foot faces +Z. */
export const bedPlatform = defineAsset<BedParams>({
id: "tera:bed.platform",
label: "Platform bed",
defaults: { width: 1.52, length: 2.03, platformHeight: 0.28, headboardHeight: 0.92 },
footprint(p) {
return { width: p.width, depth: p.length, height: p.headboardHeight, clearance: 0.7 };
},
build(p, ctx) {
const bin = new MeshBin();
const wood = ctx.materials.get("shelf");
const textile = tintable(ctx, "upholstery");
const linen = ctx.materials.get("paper");
const frameH = Math.max(0.12, p.platformHeight);
const mattressH = 0.2;
// A recessed plinth gives the platform a shadow line instead of a box-on-floor read.
bin.add(ctx.parts.box(), ctx.materials.get("chairBase"), {
y: 0.035,
size: [p.width - 0.22, 0.07, p.length - 0.18],
});
slab(bin, ctx, wood, {
y: 0.09,
width: p.width,
depth: p.length,
thickness: frameH - 0.09,
});
bin.add(ctx.parts.roundedBox(0.09), textile, {
y: frameH,
z: 0.03,
size: [p.width - 0.12, mattressH, p.length - 0.12],
});
panelSlab(bin, ctx, wood, {
y: 0,
z: -p.length / 2 + 0.035,
width: p.width,
height: p.headboardHeight,
thickness: 0.07,
faces: "front",
});
// Two pillows, separated enough that the silhouette survives the dollhouse view.
for (const sx of [-1, 1]) {
bin.add(ctx.parts.roundedBox(0.12), linen, {
x: sx * p.width * 0.23,
y: frameH + mattressH + 0.015,
z: -p.length / 2 + 0.38,
size: [p.width * 0.38, 0.11, 0.48],
yaw: sx * 0.035,
});
}
return bin.build("bed.platform");
},
});
type SofaParams = {
width: number;
depth: number;
seatHeight: number;
backHeight: number;
modules: number;
};
/** A low modular sofa with individually readable seat and back cushions. */
export const sofaModular = defineAsset<SofaParams>({
id: "tera:sofa.modular",
label: "Modular sofa",
defaults: { width: 2.2, depth: 0.9, seatHeight: 0.42, backHeight: 0.82, modules: 3 },
footprint(p) {
return { width: p.width, depth: p.depth, height: p.backHeight, clearance: 0.7 };
},
build(p, ctx) {
const bin = new MeshBin();
const P = ctx.parts;
const base = ctx.materials.get("chairShell");
const textile = tintable(ctx, "upholstery");
const modules = Math.max(2, Math.min(4, Math.round(p.modules)));
const innerW = p.width - 0.28;
const moduleW = innerW / modules;
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
bin.add(P.rod(), ctx.materials.get("chairBase"), {
x: sx * (p.width / 2 - 0.12),
z: sz * (p.depth / 2 - 0.12),
size: [0.04, 0.08, 0.04],
});
}
}
bin.add(P.box(), base, { y: 0.08, size: [innerW, p.seatHeight - 0.15, p.depth - 0.14] });
for (let i = 0; i < modules; i += 1) {
const x = -innerW / 2 + moduleW * (i + 0.5);
bin.add(P.roundedBox(0.07), textile, {
x,
y: p.seatHeight - 0.12,
z: -0.04,
size: [moduleW - 0.025, 0.14, p.depth - 0.25],
});
bin.add(P.roundedBox(0.09), textile, {
x,
y: p.seatHeight + 0.015,
z: p.depth / 2 - 0.15,
size: [moduleW - 0.035, p.backHeight - p.seatHeight, 0.17],
pitch: 0.1,
});
}
for (const sx of [-1, 1]) {
bin.add(P.roundedBox(0.09), textile, {
x: sx * (p.width / 2 - 0.07),
y: 0.08,
size: [0.14, p.seatHeight + 0.18, p.depth - 0.1],
});
}
return bin.build("sofa.modular");
},
});
type KitchenRunParams = {
width: number;
depth: number;
counterHeight: number;
height: number;
bays: number;
};
/** A complete compact kitchen wall: base units, worktop, sink, backsplash and uppers. */
export const kitchenRun = defineAsset<KitchenRunParams>({
id: "tera:kitchen.run",
label: "Kitchen wall",
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 };
},
build(p, ctx) {
const bin = new MeshBin();
const P = ctx.parts;
const cabinet = tintable(ctx, "cabinet");
const top = ctx.materials.get("tableTop");
const metal = ctx.materials.get("metalTrim");
const bays = Math.max(3, Math.min(7, Math.round(p.bays)));
const bayW = p.width / bays;
bin.add(P.box(), ctx.materials.get("chairBase"), {
y: 0.02,
z: -0.035,
size: [p.width - 0.08, 0.08, p.depth - 0.12],
});
for (let i = 0; i < bays; i += 1) {
const x = -p.width / 2 + bayW * (i + 0.5);
bin.add(P.box(), cabinet, {
x,
y: 0.1,
z: -0.03,
size: [bayW - 0.018, p.counterHeight - 0.15, p.depth - 0.06],
});
bin.add(P.box(), metal, {
x: x + bayW * 0.34,
y: p.counterHeight * 0.55,
z: p.depth / 2 + 0.006,
size: [0.012, 0.12, 0.012],
});
}
slab(bin, ctx, top, {
y: p.counterHeight - 0.045,
width: p.width,
depth: p.depth + 0.05,
thickness: 0.045,
});
// Backsplash and upper units stop short over the sink to create a focal bay.
panelSlab(bin, ctx, ctx.materials.get("tile"), {
y: p.counterHeight,
z: -p.depth / 2 + 0.012,
width: p.width,
height: 0.62,
thickness: 0.024,
faces: "front",
});
const upperY = 1.48;
for (let i = 0; i < bays; i += 1) {
if (i === Math.floor(bays / 2)) continue;
const x = -p.width / 2 + bayW * (i + 0.5);
bin.add(P.box(), cabinet, {
x,
y: upperY,
z: -p.depth / 2 + 0.17,
size: [bayW - 0.018, p.height - upperY, 0.34],
});
bin.add(P.box(), metal, {
x: x + bayW * 0.34,
y: upperY + 0.2,
z: -p.depth / 2 + 0.345,
size: [0.012, 0.13, 0.012],
});
}
const sinkX = -p.width / 2 + bayW * (Math.floor(bays / 2) + 0.5);
bin.add(P.box(), metal, {
x: sinkX,
y: p.counterHeight + 0.003,
size: [bayW * 0.62, 0.018, p.depth * 0.5],
});
bin.add(P.rod(), metal, {
x: sinkX,
y: p.counterHeight + 0.02,
z: -0.12,
size: [0.025, 0.24, 0.025],
});
bin.add(P.rod(), metal, {
x: sinkX,
y: p.counterHeight + 0.24,
z: -0.04,
size: [0.025, 0.025, 0.16],
});
return bin.build("kitchen.run");
},
});
type IslandParams = {
length: number;
depth: number;
height: number;
overhang: number;
};
/** A storage island with a real seating overhang on its +Z side. */
export const kitchenIsland = defineAsset<IslandParams>({
id: "tera:kitchen.island",
label: "Kitchen island",
defaults: { length: 2.2, depth: 0.9, height: 0.92, overhang: 0.24 },
footprint(p) {
return { width: p.length, depth: p.depth, height: p.height, clearance: 0.8 };
},
build(p, ctx) {
const bin = new MeshBin();
const cabinetDepth = Math.max(0.42, p.depth - p.overhang);
bin.add(ctx.parts.box(), tintable(ctx, "cabinet"), {
y: 0.06,
z: -p.overhang / 2,
size: [p.length - 0.12, p.height - 0.12, cabinetDepth],
});
slab(bin, ctx, ctx.materials.get("tableTop"), {
y: p.height - 0.05,
width: p.length,
depth: p.depth,
thickness: 0.05,
});
for (const sx of [-1, 1]) {
bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), {
x: sx * (p.length / 2 - 0.12),
y: 0.08,
z: p.depth / 2 - 0.08,
size: [0.035, p.height - 0.13, 0.035],
});
}
return bin.build("kitchen.island");
},
});
type WardrobeParams = { width: number; depth: number; height: number; doors: number };
/** Full-height closed storage with a recessed plinth and quiet vertical pulls. */
export const storageWardrobe = defineAsset<WardrobeParams>({
id: "tera:storage.wardrobe",
label: "Wardrobe",
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 };
},
build(p, ctx) {
const bin = new MeshBin();
const body = ctx.materials.get("shelf");
const doors = tintable(ctx, "cabinet");
const metal = ctx.materials.get("metalTrim");
const count = Math.max(2, Math.min(5, Math.round(p.doors)));
const doorW = (p.width - 0.05) / count;
bin.add(ctx.parts.box(), ctx.materials.get("chairBase"), {
x: 0,
y: 0.02,
z: -0.02,
size: [p.width - 0.1, 0.08, p.depth - 0.1],
});
bin.add(ctx.parts.box(), body, { y: 0.08, size: [p.width, p.height - 0.08, p.depth] });
for (let i = 0; i < count; i += 1) {
const x = -p.width / 2 + 0.025 + doorW * (i + 0.5);
bin.add(ctx.parts.box(), doors, {
x,
y: 0.11,
z: p.depth / 2 + 0.006,
size: [doorW - 0.014, p.height - 0.16, 0.018],
});
bin.add(ctx.parts.box(), metal, {
x: x + doorW * 0.34,
y: p.height * 0.46,
z: p.depth / 2 + 0.02,
size: [0.014, 0.28, 0.014],
});
}
return bin.build("storage.wardrobe");
},
});
type StoolParams = { diameter: number; seatHeight: number; back: boolean };
/** A kitchen-height stool. It faces -Z so a seat address can share its yaw. */
export const seatStool = defineAsset<StoolParams>({
id: "tera:seat.stool",
label: "Counter stool",
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 };
},
build(p, ctx) {
const bin = new MeshBin();
const metal = ctx.materials.get("chairBase");
const textile = tintable(ctx, "chairFabric");
for (let i = 0; i < 4; i += 1) {
const yaw = Math.PI / 4 + i * Math.PI / 2;
bin.add(ctx.parts.rod(), metal, {
x: Math.sin(yaw) * p.diameter * 0.34,
z: Math.cos(yaw) * p.diameter * 0.34,
size: [0.025, p.seatHeight - 0.06, 0.025],
});
}
bin.add(ctx.parts.cylinder(20), textile, {
y: p.seatHeight - 0.06,
size: [p.diameter, 0.1, p.diameter],
});
if (p.back) {
bin.add(ctx.parts.rod(), metal, {
x: -p.diameter * 0.34,
y: p.seatHeight - 0.02,
z: p.diameter * 0.34,
size: [0.022, 0.28, 0.022],
});
bin.add(ctx.parts.rod(), metal, {
x: p.diameter * 0.34,
y: p.seatHeight - 0.02,
z: p.diameter * 0.34,
size: [0.022, 0.28, 0.022],
});
// Keep the back and circular seat in the same indexed primitive family.
// `roundedBox` is non-indexed; mixing it with the indexed cylinder would
// make BufferGeometryUtils reject the material bin and drop every fabric
// part from the finished stool.
bin.add(ctx.parts.box(), textile, {
y: p.seatHeight + 0.18,
z: p.diameter * 0.34,
size: [p.diameter * 0.82, 0.2, 0.06],
});
}
return bin.build("seat.stool");
},
});
type FloorLightParams = { height: number; reach: number; shadeDiameter: number };
/** An arced floor lamp. Geometry glows; the office lighting rig remains the only light owner. */
export const lightFloor = defineAsset<FloorLightParams>({
id: "tera:light.floor",
label: "Floor lamp",
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 };
},
build(p, ctx) {
const body = new MeshBin();
const glow = new MeshBin();
const metal = ctx.materials.get("lightHousing");
body.add(ctx.parts.cylinder(20), ctx.materials.get("chairBase"), {
size: [0.36, 0.035, 0.36],
});
body.add(ctx.parts.rod(), metal, { y: 0.035, size: [0.025, p.height - 0.18, 0.025] });
body.add(ctx.parts.rod(), metal, {
y: p.height - 0.18,
z: -p.reach / 2,
size: [0.025, 0.025, p.reach],
});
body.add(ctx.parts.cone(20), metal, {
y: p.height - 0.4,
z: -p.reach,
size: [p.shadeDiameter, 0.24, p.shadeDiameter],
pitch: Math.PI,
});
glow.add(ctx.parts.disc(20), ctx.materials.get("lightDiffuser"), {
y: p.height - 0.395,
z: -p.reach,
size: [p.shadeDiameter * 0.86, 1, p.shadeDiameter * 0.86],
pitch: Math.PI,
});
const group = new THREE.Group();
group.name = "light.floor";
group.add(body.build("light.floor:body"));
group.add(glow.build("light.floor:glow", { castShadow: false, receiveShadow: false }));
return group;
},
});
+24 -1
View File
@@ -17,7 +17,7 @@
* somebody walks through a wall — leave the collider with no gap where the door
* is.
*
* Seventeen assets is not a furniture catalogue and is not trying to be. It is
* Twenty-five 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.
@@ -26,6 +26,15 @@
import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
import { plantPotted, plantTall } from "./greenery.ts";
import {
bedPlatform,
kitchenIsland,
kitchenRun,
lightFloor,
seatStool,
sofaModular,
storageWardrobe,
} from "./habitat.ts";
import { lightPendant, lightTroffer } from "./lighting.ts";
import { robotOptimus } from "./optimus.ts";
import { screenMonitor, screenWallDisplay } from "./screens.ts";
@@ -53,6 +62,13 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [
robotOptimus,
rug,
whiteboard,
bedPlatform,
sofaModular,
kitchenRun,
kitchenIsland,
storageWardrobe,
seatStool,
lightFloor,
];
/** Register the built-in catalogue into a registry. Defaults to the shared one. */
@@ -81,4 +97,11 @@ export {
tableMeeting,
tableSide,
whiteboard,
bedPlatform,
sofaModular,
kitchenRun,
kitchenIsland,
storageWardrobe,
seatStool,
lightFloor,
};