diff --git a/blender/scripts/build_noir.py b/blender/scripts/build_noir.py new file mode 100644 index 000000000..4cd315ace --- /dev/null +++ b/blender/scripts/build_noir.py @@ -0,0 +1,409 @@ +"""The detective office's furniture, modelled by this script and exported to +src/client/models/noir.glb for src/client/world/office/noir.ts, which puts it round the office +floor: a banker's lamp, a rotary telephone, a typewriter, a stack of case files, a filing cabinet, +a venetian blind and a desk blotter. The shared helpers are in aokit.py and the conventions in +blender/README.md. + +Headless, from the repo root (`-- --shots` also writes a review sheet per piece and one of them all +side by side): + + blender --background --factory-startup --python blender/scripts/build_noir.py [-- --shots] + +Each piece is one object and a root of its own, so the office can place any of them by itself. +Every piece stands on the floor (or on a desk) at its origin and faces forward like every model: +-Y here, which the exporter turns into +Z. Nothing in this model moves on its own, so every piece +is a single joined object, one draw call per material. + +The object and material names are a contract with world/office/noir.ts and +tests/noir-model.test.ts, so rename them in all three places. Nothing here is textured: the office +paints every material with its own toon one by name (world/models.ts), and the model is exported +without UVs on purpose, so nothing has to survive mergeByMaterial() (which drops them). + +Sizes keep out of the office's way: the lamp, phone, typewriter, files and blotter stand on a desk +0.78 m high and 2.2 by 1.1 m (DESK_SIZE) without hiding the laptop anchor or the worker; the +cabinet is a standard 1.32 m four-drawer one that fits against a wall; the blind is cut for the +office's 3 by 2.2 m windows (WINDOWS in shared/layout.ts). +""" +import bpy, bmesh, math, os, sys +from mathutils import Matrix, Vector + +# Run headless, Blender doesn't put this folder on the import path. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import aokit as ao + +# Preview colours only: world/office/noir.ts paints every material by name with the same colours. +COLORS = { + "Brass": "#b08d3f", + "Shade": "#1f6b4a", + "Bakelite": "#1b1b1e", + "Steel": "#3f4a44", + "WoodWarm": "#5a3a22", + "Paper": "#e8dfc8", + "Folder": "#b99b62", + "Leather": "#4a2f22", + "Slat": "#cfc4ad", + "Cord": "#8a7a5c", + "Ink": "#101418", +} + +PIECES = ("desk_lamp", "rotary_phone", "typewriter", "file_stack", "filing_cabinet", "venetian_blind", "desk_blotter") + + +def material(name): + return ao.material(name, COLORS[name]) + + +# ---- Building an object from shapes -------------------------------------------------------------- + +class Shapes: + """One object's shapes in one bmesh, a material slot per material. Each shape's faces are + shaded flat or smooth as it says, and finish() leaves them that way.""" + + def __init__(self): + self.bm = bmesh.new() + self.mats = [] + + def slot(self, mat): + if mat not in self.mats: + self.mats.append(mat) + return self.mats.index(mat) + + def add(self, mat, build, *args, smooth=False, **kw): + """Adds a shape: `build(bm, *args, **kw)` is one of aokit's shape functions.""" + bm = self.bm + start = len(bm.faces) + build(bm, *args, **kw) + bm.faces.ensure_lookup_table() + for f in bm.faces[start:]: + f.smooth = smooth + f.material_index = self.slot(mat) + + def centre(self): + """Moves everything across so the middle of its footprint is at the origin.""" + xs = [v.co.x for v in self.bm.verts] + ys = [v.co.y for v in self.bm.verts] + bmesh.ops.translate(self.bm, vec=(-(min(xs) + max(xs)) / 2, -(min(ys) + max(ys)) / 2, 0.0), verts=self.bm.verts[:]) + + def finish(self, name, sharp=None): + """The object, its origin at the scene's origin. With `sharp` (radians), edges sharper than + that stay sharp and the rest shade smooth across.""" + me = bpy.data.meshes.new(name) + self.bm.to_mesh(me) + self.bm.free() + for m in self.mats: + me.materials.append(material(m)) + if sharp is not None: + me.set_sharp_from_angle(angle=sharp) + me.validate() + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + return ob + + +# ---- The banker's lamp ---------------------------------------------------------------------------- +# +# A 1940s banker's lamp: a weighted brass disc, a stem with a collar, and a half-cylinder green +# glass shade over the top, its open side down. 0.28 m tall, so it stands on the back corner of a +# desk without reaching the worker. Shade faces +Y (forward), the way the office's lamps do. + +LAMP_H = 0.28 +SHADE_R = 0.072 +SHADE_LEN = 0.24 + + +def half_shell(bm, r_out, r_in, length, center, segs=18): + """A half-cylinder shell open along the bottom, for a lamp shade: an outer and an inner arc + swept along Y and joined by an annulus at each end, so it is a closed solid with a hollow + underside. One of the shapes a Shapes.add() takes, so it fills `bm` itself.""" + start = len(bm.verts) + arcs = [] + for y in (-length / 2, length / 2): + outer = [bm.verts.new((r_out * math.cos(math.pi * i / segs), y, r_out * math.sin(math.pi * i / segs))) for i in range(segs + 1)] + inner = [bm.verts.new((r_in * math.cos(math.pi * i / segs), y, r_in * math.sin(math.pi * i / segs))) for i in range(segs + 1)] + arcs.append((outer, inner)) + (o0, i0), (o1, i1) = arcs + for k in range(segs): + bm.faces.new((o0[k], o0[k + 1], o1[k + 1], o1[k])) + bm.faces.new((i0[k], i0[k + 1], i1[k + 1], i1[k])) + bm.faces.new((o0[k], o0[k + 1], i0[k + 1], i0[k])) + bm.faces.new((o1[k], o1[k + 1], i1[k + 1], i1[k])) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + bm.verts.ensure_lookup_table() + ao._place(bm, bm.verts[start:], center, (0, 0, 0)) + + +def desk_lamp(): + s = Shapes() + # Weighted base: a shallow brass disc with a stepped rim. + s.add("Brass", ao.lathe, [(0.0, 0.0), (0.088, 0.0), (0.092, 0.008), (0.086, 0.018), (0.03, 0.022), (0.0, 0.022)], + center=(0, 0, 0), segs=28) + # The stem, with a collar where the shade hangs from it. + s.add("Brass", ao.cylinder, (0, 0, 0.02), (0, 0, LAMP_H - SHADE_R * 0.4), 0.011, segs=14) + s.add("Brass", ao.lathe, [(0.0, 0.0), (0.026, 0.0), (0.028, 0.012), (0.014, 0.02), (0.0, 0.02)], + center=(0, 0, LAMP_H - SHADE_R * 0.5), segs=18) + # The green glass shade: a half cylinder lying along Y over the top of the stem, open side down. + s.add("Shade", half_shell, SHADE_R, SHADE_R - 0.007, SHADE_LEN, (0, 0, LAMP_H - SHADE_R)) + # A pull chain hanging at the back of the shade. + s.add("Brass", ao.cylinder, (0, -SHADE_LEN / 2 + 0.02, LAMP_H - SHADE_R - 0.01), + (0, -SHADE_LEN / 2 + 0.02, LAMP_H - SHADE_R - 0.06), 0.0035, segs=8) + s.add("Brass", ao.ellipsoid, (0, -SHADE_LEN / 2 + 0.02, LAMP_H - SHADE_R - 0.065), (0.008, 0.008, 0.008), segs=10, rings=7) + return s.finish("desk_lamp", sharp=0.6) + + +# ---- The rotary telephone ------------------------------------------------------------------------- +# +# A black bakelite desk phone: a wedge body, a fingerwheel in a brass ring set into the front of the +# dial plate, and a handset lying on its cradle across the back. 0.22 wide, 0.17 deep, 0.23 tall. + +PHONE_W, PHONE_D, PHONE_H = 0.24, 0.2, 0.24 + + +def rotary_phone(): + s = Shapes() + # A low bakelite body, wider at the foot, with the dial let into the top of it at the front. + s.add("Bakelite", ao.box, (0, 0.012, 0.0375), (PHONE_W, PHONE_D, 0.075), bevel=0.016) + # The dial plate, sloping down toward whoever sits at the desk so the wheel reads from across + # the room, and set high enough that the whole fingerwheel clears the body. + plate_y, plate_z, tilt = -0.05, 0.1, math.radians(42) + up = Vector((0, -math.sin(tilt), math.cos(tilt))) + plate = Vector((0, plate_y, plate_z)) + s.add("Bakelite", ao.box, tuple(plate), (0.15, 0.078, 0.016), bevel=0.006, rot=(tilt, 0, 0)) + + def on_plate(u, v, lift=0.0): + """A point (u, v) across the dial plate's own face, `lift` along its up.""" + return plate + Vector((u, 0, 0)) + Vector((0, math.cos(tilt), math.sin(tilt))) * v + up * lift + + # The brass fingerwheel ring, and the dark dial let in behind it. + s.add("Brass", ao.cylinder, on_plate(0, 0, 0.006), on_plate(0, 0, 0.017), 0.05, segs=28) + s.add("Bakelite", ao.cylinder, on_plate(0, 0, 0.009), on_plate(0, 0, 0.012), 0.042, segs=28) + # Its ten finger holes, in the plate's own plane so they sit on the wheel. + for i in range(10): + a = math.radians(-46 + i * 27.5) + c = on_plate(0.031 * math.sin(a), 0.031 * math.cos(a), 0.012) + s.add("Bakelite", ao.cylinder, c, c + up * 0.004, 0.0065, segs=8) + # The cradle at the back and the handset lying in it: two ear pieces on a curved grip. + s.add("Bakelite", ao.box, (0, 0.062, 0.09), (0.17, 0.05, 0.03), bevel=0.01) + for sx in (-1, 1): + s.add("Bakelite", ao.ellipsoid, (sx * 0.095, 0.062, 0.117), (0.036, 0.03, 0.028), segs=16, rings=11) + s.add("Bakelite", ao.box, (0, 0.062, 0.141), (0.15, 0.03, 0.024), bevel=0.011) + # The cord, coiled once at the back and dropping off to the side. + s.add("Bakelite", ao.torus, (0, 0.096, 0.05), 0.028, 0.007, rot=(math.radians(90), 0, 0), n=18, m=6) + s.add("Bakelite", ao.cylinder, (0.02, 0.096, 0.05), (0.07, 0.13, 0.012), 0.007, segs=8) + s.centre() + return s.finish("rotary_phone", sharp=0.7) + + +# ---- The typewriter -------------------------------------------------------------------------------- +# +# A 1940s portable: a dark body, a bank of brass keys, a carriage with a sheet of paper in it and two +# roller knobs. 0.42 wide, 0.34 deep, 0.3 tall with the paper up. + +TYPE_W, TYPE_D = 0.42, 0.34 + + +def typewriter(): + s = Shapes() + # The body, stepped: the case, then the sloping keybed over it. + s.add("Bakelite", ao.box, (0, 0.01, 0.055), (TYPE_W, TYPE_D, 0.11), bevel=0.016) + s.add("Bakelite", ao.box, (0, -0.045, 0.115), (TYPE_W * 0.97, TYPE_D * 0.68, 0.05), bevel=0.012, + rot=(math.radians(7), 0, 0)) + # Four rows of brass keys on the bed, the front row longest. + for row, (ry, rz, n, rad) in enumerate(( + (-0.135, 0.128, 10, 0.0085), + (-0.095, 0.132, 10, 0.0085), + (-0.055, 0.137, 9, 0.0085), + (-0.018, 0.143, 8, 0.0085), + )): + for i in range(n): + x = (i - (n - 1) / 2) * 0.036 + s.add("Brass", ao.cylinder, (x, ry, rz), (x, ry, rz + 0.011), rad, segs=10) + # The space bar, a long brass bar across the front. + s.add("Brass", ao.cylinder, (-0.15, -0.168, 0.126), (0.15, -0.168, 0.126), 0.008, segs=10) + # The carriage: a roller across the back with a sheet of paper in it. + s.add("Steel", ao.cylinder, (-TYPE_W / 2 + 0.02, 0.135, 0.175), (TYPE_W / 2 - 0.02, 0.135, 0.175), 0.019, segs=16) + for sx in (-1, 1): + s.add("Steel", ao.cylinder, (sx * (TYPE_W / 2 - 0.006), 0.135, 0.175), (sx * (TYPE_W / 2 + 0.018), 0.135, 0.175), 0.026, segs=14) + # The sheet, standing up out of the carriage and leaning back a little. + s.add("Paper", ao.box, (0, 0.148, 0.26), (0.24, 0.006, 0.17), bevel=0.002, rot=(math.radians(-9), 0, 0)) + # The paper bail and its two arms. + s.add("Steel", ao.cylinder, (-0.11, 0.13, 0.213), (0.11, 0.13, 0.213), 0.004, segs=8) + for sx in (-1, 1): + s.add("Steel", ao.cylinder, (sx * 0.115, 0.132, 0.176), (sx * 0.115, 0.132, 0.213), 0.004, segs=8) + s.centre() + return s.finish("typewriter", sharp=0.7) + + +# ---- The stack of case files ----------------------------------------------------------------------- +# +# A pile of manila case folders with paper in them, tied with a cotton tag, the way a 1940s detective +# office stacked what it was working on. 0.34 by 0.26 and 0.11 tall, so it stands in a desk's back +# corner clear of the laptop. + + +def file_stack(): + s = Shapes() + # Four folders, each a little bigger and lower than the one on it, turned a few degrees out. + for i in range(4): + z = 0.012 + i * 0.026 + w, d = 0.34 - i * 0.012, 0.26 - i * 0.01 + s.add("Folder", ao.box, (0, 0, z), (w, d, 0.026), bevel=0.004, rot=(0, 0, math.radians(-6 + i * 4))) + # The paper edge showing out of the folder's open side. + s.add("Paper", ao.box, (0.004 * i, 0, z + 0.004), (w - 0.03, d - 0.02, 0.017), bevel=0.002, + rot=(0, 0, math.radians(-6 + i * 4))) + # The cotton tag round the middle of the pile, and its little brass eyelet. + s.add("Cord", ao.box, (0, 0, 0.066), (0.36, 0.055, 0.006), bevel=0.002) + s.add("Brass", ao.cylinder, (0.05, -0.028, 0.066), (0.05, -0.028, 0.072), 0.009, segs=12) + s.centre() + return s.finish("file_stack", sharp=0.6) + + +# ---- The filing cabinet ---------------------------------------------------------------------------- +# +# A four-drawer steel cabinet, the kind every municipal office had: a dark green-grey body, proud +# drawer fronts, brass pulls and label holders. 0.47 wide, 0.62 deep, 1.32 tall, standing on a +# plinth, so it goes against a wall like any other piece of furniture. + +CAB_W, CAB_D, CAB_H = 0.47, 0.62, 1.32 +DRAWER_H = 0.285 + + +def filing_cabinet(): + s = Shapes() + # The body and its plinth, so it reads as standing rather than floating. + s.add("Steel", ao.box, (0, 0, CAB_H / 2 + 0.03), (CAB_W, CAB_D, CAB_H - 0.06), bevel=0.012) + s.add("Steel", ao.box, (0, 0, 0.03), (CAB_W - 0.04, CAB_D - 0.04, 0.06), bevel=0.006) + # Four drawer fronts, each proud of the body, with a brass pull and a label holder above it. + front = -CAB_D / 2 + for i in range(4): + z = 0.09 + i * DRAWER_H + DRAWER_H / 2 + s.add("Steel", ao.box, (0, front - 0.012, z), (CAB_W - 0.03, 0.024, DRAWER_H - 0.02), bevel=0.006) + # The pull: a brass bar on two posts. + s.add("Brass", ao.box, (0, front - 0.042, z - 0.06), (0.11, 0.018, 0.022), bevel=0.006) + for sx in (-1, 1): + s.add("Brass", ao.cylinder, (sx * 0.048, front - 0.028, z - 0.06), (sx * 0.048, front - 0.038, z - 0.06), 0.007, segs=10) + # The label holder, a brass frame with a card in it. + s.add("Brass", ao.box, (0, front - 0.026, z + 0.062), (0.15, 0.008, 0.05), bevel=0.003) + s.add("Paper", ao.box, (0, front - 0.031, z + 0.062), (0.126, 0.004, 0.032)) + # Deliberately not centred on its footprint: the office stands this against a wall, so its back + # (its model's -z, without the brass pulls) is what sits on the wall, at exactly -CAB_D / 2. + return s.finish("filing_cabinet", sharp=0.7) + + +# ---- The venetian blind --------------------------------------------------------------------------- +# +# Slats on two cords under a head rail, tilted down a few degrees, with the tilt wand hanging at +# the left. Cut for the office's windows: 3 m wide, 2.2 m down from the rail. The head rail is at +# the top (z = drop) and the slats hang from it, so the office hangs it at a window's head and it +# falls to the sill. The blind is one object, the slats already tilted: nothing animates them. + + +def venetian_blind(): + s = Shapes() + w, drop = 3.0, 2.2 + rail_z = drop + # The head rail and its end caps. + s.add("WoodWarm", ao.box, (0, 0, rail_z - 0.035), (w, 0.07, 0.07), bevel=0.008) + for sx in (-1, 1): + s.add("WoodWarm", ao.box, (sx * (w / 2 - 0.01), 0, rail_z - 0.035), (0.03, 0.075, 0.075), bevel=0.006) + # The slats, close enough together to read as a blind and covering the whole drop. + n = 28 + for i in range(n): + z = rail_z - 0.1 - i * (drop - 0.15) / n + s.add("Slat", ao.box, (0, 0, z), (w - 0.03, 0.048, 0.007), bevel=0.002, rot=(math.radians(24), 0, 0)) + # The ladder cords down both ends and the lift cords through the middle. + for sx in (-1, 1): + s.add("Cord", ao.cylinder, (sx * (w / 2 - 0.02), 0.016, rail_z - 0.06), (sx * (w / 2 - 0.02), 0.016, 0.05), 0.0025, segs=6) + s.add("Cord", ao.cylinder, (sx * (w / 2 - 0.02), -0.016, rail_z - 0.06), (sx * (w / 2 - 0.02), -0.016, 0.05), 0.0025, segs=6) + for i in range(4): + x = (i - 1.5) * (w / 5) + s.add("Cord", ao.cylinder, (x, 0.022, rail_z - 0.06), (x, 0.022, 0.05), 0.0022, segs=6) + # The tilt wand hanging at the left, with its cord. + s.add("WoodWarm", ao.cylinder, (-w / 2 + 0.06, 0.03, rail_z - 0.06), (-w / 2 + 0.06, 0.03, rail_z - 0.46), 0.008, segs=10) + s.add("Cord", ao.cylinder, (-w / 2 + 0.06, 0.03, rail_z - 0.06), (-w / 2 + 0.055, 0.03, rail_z - 0.09), 0.0022, segs=6) + # The blind hangs from its rail, so its top is at the origin. + bmesh.ops.translate(s.bm, vec=(0, 0, -drop), verts=s.bm.verts[:]) + return s.finish("venetian_blind", sharp=0.7) + + +# ---- The desk blotter ----------------------------------------------------------------------------- +# +# A leather blotter with brass corner rules and an inkwell: what makes a desk top read as a +# detective's rather than a work surface. 1.0 by 0.62, so it takes most of a 2.2 by 1.1 m desk top. + +BLOT_W, BLOT_D = 1.0, 0.62 + + +def desk_blotter(): + s = Shapes() + # The pad, thin, with its edge rolled over. + s.add("Leather", ao.box, (0, 0, 0.007), (BLOT_W, BLOT_D, 0.014), bevel=0.005) + # Brass corner rules on two corners, the way a blotter is held down. + for sx, sy in ((-1, -1), (1, -1)): + s.add("Brass", ao.box, (sx * (BLOT_W / 2 - 0.075), sy * (BLOT_D / 2 - 0.075), 0.016), + (0.15, 0.028, 0.006), bevel=0.002, rot=(0, 0, math.radians(45 * sx * sy))) + # The inkwell, a squat glass bottle with a brass collar, at the back left. + s.add("Ink", ao.lathe, [(0.0, 0.0), (0.038, 0.0), (0.04, 0.05), (0.026, 0.062), (0.026, 0.07), (0.0, 0.07)], + center=(-BLOT_W / 2 + 0.11, BLOT_D / 2 - 0.1, 0.014), segs=20) + s.add("Brass", ao.cylinder, (-BLOT_W / 2 + 0.11, BLOT_D / 2 - 0.1, 0.082), + (-BLOT_W / 2 + 0.11, BLOT_D / 2 - 0.1, 0.09), 0.028, segs=18) + s.centre() + return s.finish("desk_blotter", sharp=0.7) + + +# ---- The build ------------------------------------------------------------------------------------ + +def build(): + ao.clear() + made = [desk_lamp(), rotary_phone(), typewriter(), file_stack(), filing_cabinet(), venetian_blind(), desk_blotter()] + for name, ob in zip(PIECES, made): + print(f"{name}: {ao.tris(ob)} tris, {len(ob.data.materials)} materials") + return ao.export("noir") + + +# ---- Review renders ------------------------------------------------------------------------------- + +def only(name, lineup=False): + """Shows just the piece `name` in the review renders, or with `lineup` all of them in a row + along x.""" + def setup(): + x = 0.0 + for piece in PIECES: + ob = bpy.data.objects[piece] + ob.hide_render = not (lineup or piece == name) + ob.location = (x, 0, 0) if lineup else (0, 0, 0) + x += 0.5 + return setup + + +def review(): + bpy.context.scene.display.shading.show_backface_culling = True + views = ("tq", "front", "side", "top") + # How close to look at each piece, and where: the small desk things need to be near. + framing = { + "desk_lamp": ((0, 0, 0.17), 0.62), + "rotary_phone": ((0, 0, 0.11), 0.6), + "typewriter": ((0, 0, 0.16), 0.95), + "file_stack": ((0, 0, 0.06), 0.62), + "filing_cabinet": ((0, 0, 0.66), 2.5), + "venetian_blind": ((0, 0, -1.1), 3.4), + "desk_blotter": ((0, 0, 0.03), 1.5), + } + paths = [] + for name in PIECES: + target, dist = framing[name] + paths.append(ao.sheet(f"noir_{name}", [(only(name), v) for v in (*views, "back")], + cell=(420, 420), target=target, dist=dist)) + paths.append(ao.sheet("noir_lineup", [(only(None, lineup=True), v) for v in ("front", "tq", "back")], + cell=(900, 420), target=(1.5, 0, 0.3), dist=4.4)) + for piece in PIECES: + ob = bpy.data.objects[piece] + ob.location = (0, 0, 0) + ob.hide_render = False + return paths + + +if __name__ == "__main__" and bpy.app.background: + print("wrote:", build()) + if "--shots" in ao.args(): + for path in review(): + print("sheet:", path) \ No newline at end of file diff --git a/src/client/lab/props.ts b/src/client/lab/props.ts index 20823c8de..33b8c1ea9 100644 --- a/src/client/lab/props.ts +++ b/src/client/lab/props.ts @@ -11,14 +11,14 @@ // Once it has drawn, window.__ready holds each prop's size, triangles, draw calls and material names. import * as THREE from 'three'; -import { DESKS } from '../../shared/layout'; +import { DESKS, DESK_SIZE } from '../../shared/layout'; import { buildCabinet } from '../features/cabinet/world'; import { supercar } from '../features/cars/world'; import { buildGong } from '../features/gong/world'; import { buildJukebox } from '../features/jukebox/world'; import { buildKitchen } from '../world/kitchen'; import { preloadModels } from '../world/models'; -import { DESK_BOOKS, FLOOR_PLANTS, buildDesk, coffeeTable, deskBooks, deskMug, loungeCouch, plant, pouf } from '../world/office'; +import { DESK_BOOKS, FLOOR_PLANTS, buildDesk, coffeeTable, deskBooks, deskBlotter, deskLamp, deskMug, fileStack, filingCabinet, loungeCouch, plant, pouf, rotaryPhone, typewriter, venetianBlind } from '../world/office'; import { toon } from '../world/toon'; import { ready, stage } from './stage'; @@ -76,6 +76,43 @@ const SHOW: Record Shown> = { [loungeCouch(), coffeeTable(), pouf('#06d6a0'), pouf('#ffd166')].forEach((o, i) => object.add(o.translateX(at[i]))); return { object }; }, + noir_desk: () => { + // The detective desk as the office dresses it: a desk with its own top, a typewriter in the back + // corner its knick-knack leaves free, and the banker's lamp (see world/office/noir.ts). The desk + // is moved to the origin first, so the props can be placed on it in its own frame. + const object = new THREE.Group(); + const desk = buildDesk(DESKS[0], 0, toon('#7a5330')); + desk.group.position.set(0, 0, 0); + desk.group.rotation.y = 0; + object.add(desk.group); + object.add(deskBlotter().translateY(DESK_SIZE.height)); + object.add(typewriter().translateX(-0.84).translateZ(-0.32).translateY(DESK_SIZE.height)); + object.add(deskLamp().translateX(-0.88).translateZ(0.3).translateY(DESK_SIZE.height)); + object.add(fileStack().translateX(0.9).translateZ(-0.36).translateY(DESK_SIZE.height)); + return { object }; + }, + noir_cabinets: () => { + // Two filing cabinets with what stands on them, as the office puts them against a wall. + const object = new THREE.Group(); + const cabinet = filingCabinet(); + object.add(cabinet.clone()); + const withPhone = filingCabinet(); + withPhone.position.x = 1.2; + object.add(withPhone); + const phone = rotaryPhone(); + phone.position.set(1.2, 1.32, 0); + phone.rotation.y = Math.PI; + object.add(phone); + return { object }; + }, + noir_blind: () => ({ object: venetianBlind() }), + noir_row: () => { + // Everything at desk scale in a row, the way the lineup shot has it. + const object = new THREE.Group(); + const at = [0, 0.5, 1.1, 1.7, 2.4]; + [deskLamp(), rotaryPhone(), typewriter(), fileStack(), deskBlotter()].forEach((o, i) => object.add(o.translateX(at[i]))); + return { object }; + }, lambo: () => ({ object: supercar('lambo', '#ffd166').root }), ferrari: () => ({ object: supercar('ferrari', '#ef476f').root }), }; diff --git a/src/client/models/noir.glb b/src/client/models/noir.glb new file mode 100644 index 000000000..ff78d4a35 Binary files /dev/null and b/src/client/models/noir.glb differ diff --git a/src/client/world/models.ts b/src/client/world/models.ts index 51568aaea..5724cd611 100644 --- a/src/client/world/models.ts +++ b/src/client/world/models.ts @@ -10,6 +10,7 @@ import dogPomeranianUrl from '../models/dog-pomeranian.glb?url'; import dogShibaUrl from '../models/dog-shiba.glb?url'; import kitchenUrl from '../models/kitchen.glb?url'; import loungeUrl from '../models/lounge.glb?url'; +import noirUrl from '../models/noir.glb?url'; import plantsUrl from '../models/plants.glb?url'; import { toon } from './toon'; @@ -27,6 +28,7 @@ const MODELS = { desk_props: { url: deskPropsUrl, preload: true }, kitchen: { url: kitchenUrl, preload: true }, lounge: { url: loungeUrl, preload: true }, + noir: { url: noirUrl, preload: true }, plants: { url: plantsUrl, preload: true }, } satisfies Record; diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..870126612 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -25,6 +25,7 @@ import { balcony } from './balcony'; import { downstairs } from './ground'; import { wing } from './wing'; import { beanbags, desks, kiosks } from './seats'; +import { noir } from './noir'; import { meetingRoom } from './meeting-room'; import { loft } from './loft'; import type { Fixture, Gives, Site } from './fixture'; @@ -49,6 +50,7 @@ function floorPlan() { plug, tower, desks, + noir, beanbags, kiosks, boards, diff --git a/src/client/world/office/index.ts b/src/client/world/office/index.ts index a1b4bb3d4..75a4e1fd5 100644 --- a/src/client/world/office/index.ts +++ b/src/client/world/office/index.ts @@ -3,4 +3,5 @@ export { buildOffice } from './build'; export { DESK_BOOKS, FLOOR_PLANTS, coffeeTable, deskBooks, deskMug, loungeCouch, plant, plantLeaves, pouf, type PlantSpecies } from './props'; export { buildDesk, vacancyMarker } from './seats'; +export { deskBlotter, deskLamp, fileStack, filingCabinet, noir, rotaryPhone, typewriter, venetianBlind } from './noir'; export type { WingView } from './wing'; diff --git a/src/client/world/office/materials.ts b/src/client/world/office/materials.ts index f917b689c..619a5d647 100644 --- a/src/client/world/office/materials.ts +++ b/src/client/world/office/materials.ts @@ -11,12 +11,12 @@ export const PALETTE = { floorAlt: FLOOR_PALETTES[0].floorAlt, wall: FLOOR_PALETTES[0].wall, wallTrim: FLOOR_PALETTES[0].trim, - desk: '#f7f3ea', - deskLeg: '#3d405b', - wood: '#c98b5a', + desk: '#6f4a2a', + deskLeg: '#2b2119', + wood: '#7a5330', cork: '#d8a86a', - chairs: ['#ff8a5b', '#5bc0eb', '#9bc53d', '#b388eb', '#ffb400', '#f7aef8'], - rugs: ['#bde0fe', '#ffd6a5', '#caffbf', '#ffc6ff'], + chairs: ['#7a2e2e', '#4a5d3a', '#6b4a2f', '#3a3f4a', '#2f4858', '#8a6a4a'], + rugs: ['#b8a888', '#9a8b6f', '#7d8a72', '#a08d7a'], plant: '#5fb760', plantDark: '#3f8f45', pot: '#e76f51', @@ -94,20 +94,31 @@ export function onWall(side: Side, u: number): { x: number; z: number; rotY: num } } -/** Chunky planks in a floor's colors. */ +/** + * Worn parquet in a floor's colors: square blocks of planks laid alternately along and across, the + * way an office floor is laid, with the gaps between them in the floor's seam color. Two blocks to + * the tile, so one tile covers four by four metres. + */ export function paintPlanks(c: HTMLCanvasElement, p: FloorPalette) { const g = c.getContext('2d')!; g.fillStyle = p.floor; g.fillRect(0, 0, 512, 512); - for (let row = 0; row < 8; row++) { - const offset = (row % 2) * 128; - for (let col = -1; col < 3; col++) { - const x = col * 256 + offset; - g.fillStyle = (row + col) % 3 === 0 ? p.floorAlt : p.floor; - g.fillRect(x + 2, row * 64 + 2, 252, 60); + const block = 128; + const plank = 32; + for (let by = 0; by < 512 / block; by++) { + for (let bx = 0; bx < 512 / block; bx++) { + // Every other block is laid the other way round, so the pattern reads as a parquet floor. + const across = (bx + by) % 2 === 0; + for (let i = 0; i < block / plank; i++) { + // A plank every so often is the floor's other color, worn a shade lighter. + g.fillStyle = (bx * 3 + by * 5 + i) % 4 === 0 ? p.floorAlt : p.floor; + const at = i * plank; + g.fillRect(bx * block + (across ? at : 0) + 1, by * block + (across ? 0 : at) + 1, across ? plank - 2 : block - 2, across ? block - 2 : plank - 2); + g.fillStyle = p.seam; + if (across) g.fillRect(bx * block + at, by * block, 2, block); + else g.fillRect(bx * block, by * block + at, block, 2); + } } - g.fillStyle = p.seam; - g.fillRect(0, row * 64, 512, 3); } } diff --git a/src/client/world/office/noir.ts b/src/client/world/office/noir.ts new file mode 100644 index 000000000..1c7eee15f --- /dev/null +++ b/src/client/world/office/noir.ts @@ -0,0 +1,241 @@ +import * as THREE from 'three'; +import { DESK_SIZE, FLOOR, WINDOWS } from '../../../shared/layout'; +import { mesh, toon } from '../toon'; +import { palette, piece } from '../models'; +import type { Collider } from '../types'; +import type { Fixture } from './fixture'; +import { PALETTE, onWall } from './materials'; + +// The detective office: the furniture of a 1940s municipal squad room, modelled in Blender +// (blender/scripts/build_noir.py) and put round the floor here. A banker's lamp on every desk, a +// typewriter in whichever back corner the desk's own knick-knack leaves free, a stack of case files, +// filing cabinets along the walls with a telephone on some of them, a venetian blind in every window +// and a dark wood wainscot round the room. +// +// Nothing here changes a seat, a collider the workers walk round, an interactable or a desk id: the +// props go in the gaps the desks already leave (see the anchors in seats.ts's buildDesk), and the +// only new colliders are the filing cabinets, which stand against walls out of the walking. A model +// that didn't load leaves an empty group, so the office opens without any of it (see piece()). + +// Preview colours only in Blender; these are the ones the office paints each name with. The +// WoodWarm rail takes the office's own wood, so the blinds match the frames and the stair. +const NOIR_COLORS = { + Brass: '#a8823c', + Shade: '#1f6b4a', + Bakelite: '#1b1b1e', + Steel: '#3f4a44', + WoodWarm: PALETTE.wood, + Paper: '#e8dfc8', + Folder: '#b99b62', + Leather: '#4a2f22', + Slat: '#cfc4ad', + Cord: '#8a7a5c', + Ink: '#101418', +}; +const paintNoir = palette(NOIR_COLORS); + +/** + * The green glass of a banker's lamp, lit from inside: the office's one warm point in a dark room, + * and the only material here that isn't flat. Each lamp gets its own, so they can be lit one by one. + */ +function shadeMaterial(): THREE.Material { + return toon(NOIR_COLORS.Shade, { emissive: '#2f7d52' }); +} + +/** A banker's lamp: a brass base and stem under a green glass shade, 0.28 m tall. */ +export function deskLamp(): THREE.Object3D { + return piece('noir', 'desk_lamp', (name) => (name === 'Shade' ? shadeMaterial() : paintNoir(name))); +} + +/** A bakelite rotary telephone with a brass fingerwheel, 0.24 m wide. */ +export function rotaryPhone(): THREE.Object3D { + return piece('noir', 'rotary_phone', paintNoir); +} + +/** A portable typewriter with a sheet of paper in its carriage, 0.42 m wide. */ +export function typewriter(): THREE.Object3D { + return piece('noir', 'typewriter', paintNoir); +} + +/** A pile of manila case folders with paper in them, 0.34 by 0.26 m. */ +export function fileStack(): THREE.Object3D { + return piece('noir', 'file_stack', paintNoir); +} + +/** A four-drawer steel filing cabinet, 0.47 by 0.62 m and 1.32 m tall, drawers facing +z. */ +export function filingCabinet(): THREE.Object3D { + return piece('noir', 'filing_cabinet', paintNoir); +} + +/** A venetian blind hanging from its head rail: 3 m wide, falling 2.2 m from the origin. */ +export function venetianBlind(): THREE.Object3D { + return piece('noir', 'venetian_blind', paintNoir); +} + +/** A leather desk blotter with brass corner rules and an inkwell, 1.0 by 0.62 m. */ +export function deskBlotter(): THREE.Object3D { + return piece('noir', 'desk_blotter', paintNoir); +} + +/** How high a filing cabinet is, and how deep: its collider, and how far off the wall it stands. */ +const CABINET = { width: 0.47, depth: 0.62, height: 1.32, off: 0.31 } as const; + +/** + * Where the cabinets stand: along the west and south walls, in the stretches that are free of + * windows, the kitchen, the ladder, the machine monitor and the way out. Each entry is the + * cabinet's middle on the floor, and the turn that faces it into the room (0 faces +z, as every + * model does). A phone or a pile of files rides on top of the ones with one. + */ +const CABINET_SPOTS: readonly { x: number; z: number; rotY: number; on: 'phone' | 'files' | null }[] = [ + // West wall: north of the first window, then south of the last one and clear of the exit door. + { x: FLOOR.minX + CABINET.off, z: -11.8, rotY: Math.PI / 2, on: 'phone' }, + { x: FLOOR.minX + CABINET.off, z: 8.2, rotY: Math.PI / 2, on: null }, + { x: FLOOR.minX + CABINET.off, z: 10.2, rotY: Math.PI / 2, on: 'files' }, + // South wall: the stretches between the kitchen, the windows and the balcony doors. + { x: -11.5, z: FLOOR.maxZ - CABINET.off, rotY: Math.PI, on: 'phone' }, + { x: -7.1, z: FLOOR.maxZ - CABINET.off, rotY: Math.PI, on: null }, + { x: -6.3, z: FLOOR.maxZ - CABINET.off, rotY: Math.PI, on: 'files' }, +]; + +/** Where the case files stand that don't go on a desk: on a cabinet top, and on the floor beside one. */ +const FLOOR_STACKS: readonly { x: number; z: number; rotY: number }[] = [ + { x: -17.69, z: -11.95, rotY: 0.2 }, + { x: -17.69, z: 10.45, rotY: -0.3 }, +]; + +/** How high the wood panelling round the room comes, and how thick it stands off the wall. */ +const WAINSCOT = { height: 1.05, thick: 0.05 } as const; + +/** + * The dark wood panelling round the room: a boarded dado to chair-rail height, with a rail along the + * top of it. It stands against the inside of the four walls, so it is never walked into and takes no + * collider. It goes up as far as the panelling only: above it the cream wall carries on, and the + * boards and windows sit on that. + */ +function wainscot(): THREE.Group { + const g = new THREE.Group(); + const wood = toon(PALETTE.desk); + const rail = toon(PALETTE.wood); + const { minX, maxX, minZ, maxZ } = FLOOR; + const w = maxX - minX; + const d = maxZ - minZ; + const t = WAINSCOT.thick; + const h = WAINSCOT.height; + for (const [len, x, z, ry] of [ + [w, 0, minZ + t / 2, 0], + [w, 0, maxZ - t / 2, 0], + [d, minX + t / 2, 0, Math.PI / 2], + [d, maxX - t / 2, 0, Math.PI / 2], + ] as const) { + const panel = mesh(new THREE.BoxGeometry(len, h, t), wood, x, h / 2, z, false); + panel.rotation.y = ry; + g.add(panel); + // The chair rail along the top, standing a little proud of the panelling. + const cap = mesh(new THREE.BoxGeometry(len, 0.06, t + 0.03), rail, x, h + 0.03, z, false); + cap.rotation.y = ry; + g.add(cap); + } + return g; +} + +/** + * A venetian blind in the office window `o`, hanging from its head just inside the glass and falling + * to the sill. Built along x with the window's outside toward +z, the way windowIn() does it, then + * turned onto its wall (see onWall). + */ +function blindIn(o: (typeof WINDOWS)[number]): THREE.Group { + const blind = venetianBlind(); + blind.position.set(0, o.y1, 0.04); + blind.rotation.y = Math.PI; + const at = onWall(o.wall, o.u); + const g = new THREE.Group(); + g.add(blind); + g.position.set(at.x, 0, at.z); + g.rotation.y = at.rotY; + return g; +} + +/** + * A file stack's footprint, for its collider on the floor. + */ +const STACK = { width: 0.36, depth: 0.28, height: 0.12 } as const; + +/** The office floor's detective furniture: the props on the desks, the cabinets, the blinds and the panelling. */ +export const noir: Fixture = (site) => { + // On every desk: a blotter covering the surface, a banker's lamp at the front left (where a typist's + // lamp goes), and a typewriter in whichever back corner the desk's own knick-knack has left free. + // The laptop (0.78 m × 1.3 = 1.01 m wide) lands on top of the blotter in the centre; the blotter's + // edges poke out on both sides, the way a detective's desk pad does. The case files go on the cabinets + // and on the floor instead of on the desk, since the laptop takes the middle of the top and whoever + // dances when a pull request merges stands at (0.72, 0.18). + for (const def of site.desks.keys()) { + const view = site.desks.get(def); + if (!view || view.def.beanbag || view.def.station) continue; + const { height } = DESK_SIZE; + const index = Number(def.replace('desk-', '')) - 1; + const blotter = deskBlotter(); + blotter.position.set(0, height, 0); + view.group.add(blotter); + const lamp = deskLamp(); + lamp.position.set(-0.88, height, 0.3); + view.group.add(lamp); + // A mug or books stand at the back right (index % 3 of 0 or 2) and a plant at the back left (1), + // so the typewriter takes the other back corner. + const right = index % 3 === 1; + const writer = typewriter(); + writer.position.set(right ? 0.84 : -0.84, height, -0.32); + writer.rotation.y = right ? -0.22 : 0.22; + view.group.add(writer); + } + + // The filing cabinets against the walls, each with its collider so nobody walks into it, and a + // telephone or a pile of files on the ones that have one. + const cabinets = new THREE.Group(); + const colliders: Collider[] = []; + for (const spot of CABINET_SPOTS) { + const cabinet = filingCabinet(); + cabinet.position.set(spot.x, 0, spot.z); + cabinet.rotation.y = spot.rotY; + cabinets.add(cabinet); + if (spot.on === 'phone') { + const phone = rotaryPhone(); + phone.position.set(spot.x, CABINET.height, spot.z); + phone.rotation.y = spot.rotY + Math.PI; + cabinets.add(phone); + } else if (spot.on === 'files') { + const files = fileStack(); + files.position.set(spot.x, CABINET.height, spot.z); + files.rotation.y = spot.rotY; + cabinets.add(files); + } + // Its footprint, turned the way it faces: a quarter turn at a time. + const along = Math.abs(Math.sin(spot.rotY)) > 0.5; + const hw = (along ? CABINET.depth : CABINET.width) / 2; + const hd = (along ? CABINET.width : CABINET.depth) / 2; + colliders.push({ minX: spot.x - hw, maxX: spot.x + hw, minZ: spot.z - hd, maxZ: spot.z + hd, top: CABINET.height }); + // The stretch of wall it stands against, so a picture never hangs over it. + // The cabinet faces into the room (its drawers toward the room centre), so the wall it backs + // against is the opposite direction from where it faces: π/2 faces east → back to west wall, + // π faces north → back to south wall, 3π/2 (or -π/2) faces west → back to east wall, 0 → north. + const wall: 'north' | 'south' | 'east' | 'west' = + spot.rotY === Math.PI / 2 ? 'west' + : spot.rotY === Math.PI ? 'south' + : spot.rotY === -Math.PI / 2 || spot.rotY === (3 * Math.PI) / 2 ? 'east' + : 'north'; + site.wall(wall, wall === 'east' || wall === 'west' ? spot.z : spot.x, CABINET.height / 2, CABINET.width + 0.1, CABINET.height); + } + + const group = new THREE.Group(); + group.add(cabinets, wainscot()); + // The case files on the floor beside two of the cabinets, where they were set down. + for (const spot of FLOOR_STACKS) { + const files = fileStack(); + files.position.set(spot.x, 0, spot.z); + files.rotation.y = spot.rotY; + cabinets.add(files); + colliders.push({ minX: spot.x - STACK.width / 2, maxX: spot.x + STACK.width / 2, minZ: spot.z - STACK.depth / 2, maxZ: spot.z + STACK.depth / 2, top: STACK.height }); + } + // A blind in every window at floor level: the loft's two are up in the loft, out of this room. + for (const o of WINDOWS) if (o.y1 <= 4) group.add(blindIn(o)); + return { group, colliders }; +}; \ No newline at end of file diff --git a/src/client/world/office/seats.ts b/src/client/world/office/seats.ts index 64e0ecb30..460510b82 100644 --- a/src/client/world/office/seats.ts +++ b/src/client/world/office/seats.ts @@ -45,7 +45,7 @@ export function buildDesk(def: DeskDef, index: number, trimMat: THREE.Material): group.rotation.y = def.rotY; const { width, depth, height } = DESK_SIZE; group.add(mesh(roundedBox(width - 0.06, 0.08, depth - 0.04, 0.08), toon(PALETTE.desk), 0, height - 0.04, 0)); - const legMat = toon('#8d99ae'); + const legMat = toon(PALETTE.deskLeg); for (const sx of [-1, 1]) { for (const sz of [-1, 1]) { group.add(mesh(new THREE.CylinderGeometry(0.035, 0.035, height - 0.08, 8), legMat, sx * (width / 2 - 0.14), (height - 0.08) / 2, sz * (depth / 2 - 0.12))); diff --git a/tests/noir-model.test.ts b/tests/noir-model.test.ts new file mode 100644 index 000000000..ac2e33032 --- /dev/null +++ b/tests/noir-model.test.ts @@ -0,0 +1,240 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Box3, Quaternion, Vector3 } from 'three'; +import { BALCONY_DOOR, DESK_SIZE, EXIT_DOOR, LADDER, WINDOWS } from '../src/shared/layout'; +import { openModel } from './glb'; + +// noir.glb (exported by blender/scripts/build_noir.py) against what world/office/noir.ts counts on: the +// seven pieces by name, each a root of its own standing at its origin and facing +z as modelled, the +// materials it paints, and the footprints that keep them on the desks they go on and clear of the +// laptop, the dancing place and the knick-knack each desk already has. + +const PIECES = ['desk_lamp', 'rotary_phone', 'typewriter', 'file_stack', 'filing_cabinet', 'venetian_blind', 'desk_blotter']; + +/** NOIR_COLORS in world/office/noir.ts, which is every material the model may carry (palette() paints a name it has no color for magenta). */ +const PAINTED = ['Brass', 'Shade', 'Bakelite', 'Steel', 'WoodWarm', 'Paper', 'Folder', 'Leather', 'Slat', 'Cord', 'Ink']; + +const props = openModel('noir'); +const { gltf, nodes, byName } = props; + +type Primitive = { attributes: Record; material?: number; indices?: number }; +const primitives = (name: string) => (gltf.meshes[nodes[byName(name)].mesh ?? -1]?.primitives ?? []) as Primitive[]; +const materialOf = (p: Primitive) => gltf.materials?.[p.material ?? -1]?.name ?? ''; + +/** A piece's bounds, from its mesh's primitives (only those in `materials`, if given). */ +function boundsOf(name: string, materials?: string[]): Box3 { + const i = byName(name); + const box = new Box3(); + assert.ok(primitives(name).length, `${name} has a mesh`); + for (const p of primitives(name)) { + if (materials && !materials.includes(materialOf(p))) continue; + const a = gltf.accessors[p.attributes.POSITION]; + box.union(new Box3(new Vector3().fromArray(a.min!), new Vector3().fromArray(a.max!)).applyMatrix4(props.worldMatrix(i))); + } + return box; +} + +const trianglesOf = (name: string) => primitives(name).reduce((n, p) => n + gltf.accessors[p.indices!].count / 3, 0); + +const near = (a: number, b: number, tolerance = 0.005) => Math.abs(a - b) <= tolerance; +const fmt = (v: Vector3) => v.toArray().map((n) => n.toFixed(3)).join(', '); + +test('each piece is a root node of its own, at the origin and facing +z as modelled, named once', () => { + const names = nodes.map((n) => n.name ?? ''); + assert.deepEqual([...names].sort(), [...PIECES].sort(), 'only the pieces, each named once'); + for (const name of PIECES) { + const i = byName(name); + assert.equal(props.parentName(i), undefined, `${name} hangs from nothing`); + const { at, turn } = props.placed(i); + assert.ok(at.length() < 1e-4, `${name} is at ${fmt(at)}`); + assert.ok(turn.angleTo(new Quaternion()) < 1e-4, `${name} isn't turned`); + } +}); + +test('its materials are the ones world/office/noir.ts paints, and only those', () => { + const names = props.materials(); + for (const m of PAINTED) assert.ok(names.includes(m), `a material called ${m}`); + for (const m of names) assert.ok(PAINTED.includes(m), `${m} isn't a material the code knows (it would come out magenta)`); +}); + +test('every piece that stands somewhere has its footprint centred on its origin and sits on it', () => { + // The blind hangs from its rail instead, so its origin is the rail at the top of the drop; the + // cabinet is stood against a wall, so its back rather than its middle is the fixed edge. + const NOT_CENTRED = ['venetian_blind', 'filing_cabinet']; + for (const name of PIECES.filter((n) => !NOT_CENTRED.includes(n))) { + const box = boundsOf(name); + const middle = box.getCenter(new Vector3()); + assert.ok(Math.hypot(middle.x, middle.z) < 5e-3, `${name}'s footprint is centred on its origin (${fmt(middle)})`); + assert.ok(near(box.min.y, 0, 5e-3), `${name} stands on the floor at its origin (${box.min.y.toFixed(4)})`); + } + // The cabinet's back, without the brass pulls, is what the office puts on the wall. + assert.ok(near(boundsOf('filing_cabinet').min.z, -0.31, 5e-3), `its back is at ${boundsOf('filing_cabinet').min.z.toFixed(4)}`); +}); + +test("the banker's lamp is a lamp: a weighted base, a stem and a green shade over the top", () => { + const lamp = boundsOf('desk_lamp'); + const shade = boundsOf('desk_lamp', ['Shade']); + const brass = boundsOf('desk_lamp', ['Brass']); + const size = lamp.getSize(new Vector3()); + assert.ok(lamp.max.y > 0.26 && lamp.max.y < 0.3, `the lamp is ${lamp.max.y.toFixed(3)} m tall`); + // The shade is the green glass, up on top and clear of the base: the lamp reads by its colour. + assert.ok(shade.max.y > 0.2, `the shade is up at ${shade.max.y.toFixed(3)}`); + assert.ok(shade.min.y > lamp.min.y + 0.15, `the shade is clear of the base (from ${shade.min.y.toFixed(3)})`); + // The shade is modelled lying along the desk (the z the office's forward is), so it is longer + // there than it is across, and it overhangs the base it stands on. + assert.ok(size.z > 0.22 && size.z < 0.27, `the shade runs ${size.z.toFixed(3)} m along`); + assert.ok(size.z > size.x, `it overhangs the ${(brass.max.x - brass.min.x).toFixed(3)} m base`); + // The brass base is wide and low, and the stem between the two is thin. + assert.ok(brass.max.x - brass.min.x > 0.14, `the base is ${(brass.max.x - brass.min.x).toFixed(3)} across`); +}); + +test('the telephone and the typewriter are the sizes a desk takes, and both stand on it', () => { + const phone = boundsOf('rotary_phone').getSize(new Vector3()); + assert.ok(phone.x > 0.2 && phone.x < 0.28, `the phone is ${phone.x.toFixed(3)} m wide`); + // A bakelite desk phone stands about 0.15 m: the handset rides on the cradle on top of it. + assert.ok(phone.y > 0.13 && phone.y < 0.2, `the phone is ${phone.y.toFixed(3)} m tall`); + const writer = boundsOf('typewriter').getSize(new Vector3()); + assert.ok(writer.x > 0.38 && writer.x < 0.46, `the typewriter is ${writer.x.toFixed(3)} m wide`); + // The paper stands up out of the carriage, so it is the tallest thing on the desk. + assert.ok(writer.y > 0.28 && writer.y < 0.4, `the typewriter with its paper is ${writer.y.toFixed(3)} m tall`); + assert.ok(boundsOf('typewriter', ['Paper']).max.y > 0.28, 'the sheet of paper is up in the carriage'); +}); + +test('the filing cabinet is a standard four-drawer one, drawers facing +z, standing 1.32 m', () => { + const box = boundsOf('filing_cabinet'); + const size = box.getSize(new Vector3()); + assert.ok(near(size.x, 0.47, 0.02), `${size.x.toFixed(3)} m wide`); + // The body is 0.62 m deep and the brass pulls stand about 0.05 m proud of it. + assert.ok(size.z > 0.62 && size.z < 0.7, `${size.z.toFixed(3)} m deep, the pulls proud of a 0.62 m body`); + assert.ok(near(box.max.y, 1.32, 0.02), `${box.max.y.toFixed(3)} m tall`); + // The brass label holders and pulls are all on the front, so +z is the front. + const brass = boundsOf('filing_cabinet', ['Brass']); + assert.ok(brass.max.z > box.max.z - 0.1, `the brass is on the front (to ${brass.max.z.toFixed(3)} of ${box.max.z.toFixed(3)})`); + assert.ok(brass.min.z > box.min.z, 'none of it is behind the cabinet'); + // Four drawers: four label holders, each its own island of brass up the front. + assert.ok(boundsOf('filing_cabinet', ['Paper']).max.z > box.min.z, 'the drawer labels are on the front too'); +}); + +test('the blind hangs from its rail: the rail at the top, the slats falling below the origin', () => { + const blind = boundsOf('venetian_blind'); + const slats = boundsOf('venetian_blind', ['Slat']); + const rail = boundsOf('venetian_blind', ['WoodWarm']); + // The office hangs it at a window's head (WINDOWS' y1), so the rail is the top of the model. + // The rail's end caps stand a couple of millimetres proud of it. + assert.ok(blind.max.y <= 5e-3, `the rail is at the top of the model (${blind.max.y.toFixed(4)})`); + assert.ok(blind.min.y < -2, `it falls ${Math.abs(blind.min.y).toFixed(2)} m from the rail`); + // The wood is the rail and the tilt wand that hangs off it, so the rail itself is its top. + assert.ok(rail.max.y > slats.max.y, 'the slats hang below the rail'); + assert.ok(slats.max.y < 0 && slats.min.y < -1.5, `the slats fall from ${slats.max.y.toFixed(2)} to ${slats.min.y.toFixed(2)}`); + assert.ok(slats.max.y > blind.min.y, 'the slats stop short of the bottom of the cords'); + // Cut for the office's windows: 3 m wide, filling the 2.2 m between their sill and head. + const size = blind.getSize(new Vector3()); + assert.ok(near(size.x, 3, 0.05), `${size.x.toFixed(3)} m wide`); + const win = WINDOWS[0]; + assert.ok(Math.abs(blind.min.y - (win.y0 - win.y1)) < 0.25, `it spans the window's ${(win.y1 - win.y0).toFixed(2)} m drop`); +}); + +test('the blotter is a desk pad, thin, and it fits the desk it goes on', () => { + const pad = boundsOf('desk_blotter'); + const size = pad.getSize(new Vector3()); + assert.ok(size.x <= DESK_SIZE.width, `${size.x.toFixed(3)} m is no wider than the desk (${DESK_SIZE.width})`); + assert.ok(size.z <= DESK_SIZE.depth, `${size.z.toFixed(3)} m is no deeper than the desk (${DESK_SIZE.depth})`); + assert.ok(size.y < 0.12, `it is ${size.y.toFixed(3)} m thick, a pad and not a box`); +}); + +test('where noir.ts puts them they stay on the desk, clear of the laptop and the dancing place', () => { + // The desk's own frame, as buildDesk() sets it out: x along it, z out toward the chair. + const half = { x: DESK_SIZE.width / 2 - 0.03, z: DESK_SIZE.depth / 2 - 0.02 }; + // The laptop is 0.78 m wide and stands at 1.3 times (features/workers/laptop.ts), so it takes the + // middle of the top and the noir props have to stay out of it. + const LAPTOP_HALF = (0.78 * 1.3) / 2; + const ON_DESK = [ + ['desk_lamp', { x: -0.88, z: 0.3 }], + ['typewriter', { x: -0.84, z: -0.32 }], + ] as const; + for (const [name, at] of ON_DESK) { + const box = boundsOf(name); + const minX = at.x + box.min.x; + const maxX = at.x + box.max.x; + const minZ = at.z + box.min.z; + const maxZ = at.z + box.max.z; + const where = `${name} runs x ${minX.toFixed(3)} to ${maxX.toFixed(3)}, z ${minZ.toFixed(3)} to ${maxZ.toFixed(3)}`; + assert.ok(minX > -half.x && maxX < half.x, `${where}: on the desk across`); + assert.ok(minZ > -half.z && maxZ < half.z, `${where}: on the desk front to back`); + assert.ok(maxX < -LAPTOP_HALF || minX > LAPTOP_HALF, `${where}: clear of the laptop (which reaches ${LAPTOP_HALF.toFixed(3)})`); + // Whoever dances when a pull request merges stands at (0.72, 0.18), up front. + assert.ok(maxX < 0.72 - 0.25 || minX > 0.72 + 0.25 || maxZ < 0.18 - 0.25 || minZ > 0.18 + 0.25, `${where}: clear of the dancing place`); + } + // The typewriter stands in the back corner its desk's knick-knack leaves free (seats.ts's buildDesk): + // a mug or books at x 0.84 (index % 3 of 0 or 2), a plant at x -0.85 (1). + for (const right of [false, true]) { + const box = boundsOf('typewriter'); + const minX = (right ? 0.84 : -0.84) + box.min.x; + const maxX = (right ? 0.84 : -0.84) + box.max.x; + assert.ok(right ? minX > LAPTOP_HALF : maxX < -LAPTOP_HALF, `the typewriter in the ${right ? 'right' : 'left'} back corner runs x ${minX.toFixed(3)} to ${maxX.toFixed(3)}`); + // Clear of the knick-knack in the other back corner is automatic; what matters is its own side: + // a mug at x 0.85 and books at x 0.84 both reach about 0.13 in, and a plant at x -0.85 about 0.1. + const knick = right ? -1 : 1; + assert.ok(right ? minX > 0.5 : maxX < -0.5, `it is well over on its own side of the desk (${knick > 0 ? 'left' : 'right'})`); + } +}); + +test('the cabinets stand against the walls, back on the wall and inside the room', () => { + const box = boundsOf('filing_cabinet'); + const size = box.getSize(new Vector3()); + const spots = [ + { x: -18 + 0.31, z: -11.8, rotY: Math.PI / 2, wall: 'west' as const }, + { x: -18 + 0.31, z: 8.2, rotY: Math.PI / 2, wall: 'west' as const }, + { x: -18 + 0.31, z: 10.2, rotY: Math.PI / 2, wall: 'west' as const }, + { x: -11.5, z: 13 - 0.31, rotY: Math.PI, wall: 'south' as const }, + { x: -7.1, z: 13 - 0.31, rotY: Math.PI, wall: 'south' as const }, + { x: -6.3, z: 13 - 0.31, rotY: Math.PI, wall: 'south' as const }, + ]; + const WALL_AT = { west: -18, east: 18, south: 13, north: -13 } as const; + // Where the model's own x and z land in the room once it is turned by rotY about y. + const turned = (mx: number, mz: number, r: number): [number, number] => [mx * Math.cos(r) + mz * Math.sin(r), -mx * Math.sin(r) + mz * Math.cos(r)]; + for (const spot of spots) { + const corners = [box.min.x, box.max.x].flatMap((mx) => [box.min.z, box.max.z].map((mz) => turned(mx, mz, spot.rotY))); + const along = corners.map(([dx]) => spot.x + dx); + const across = corners.map(([, dz]) => spot.z + dz); + const wall = WALL_AT[spot.wall]; + const eastWest = spot.wall === 'west' || spot.wall === 'east'; + // The face nearest the wall has to be against it, inside the room and not through it, and the + // brass pulls (its front) have to be on the far side of that, facing into the room. + const near = eastWest ? Math.min(...along) : Math.max(...across); + const far = eastWest ? Math.max(...along) : Math.min(...across); + assert.ok(near >= wall - 1e-3, `at ${spot.wall} its back is inside the room at ${near.toFixed(4)}, not through the wall (${wall})`); + assert.ok(near - wall < 0.06, `at ${spot.wall} it stands ${(near - wall).toFixed(3)} off the wall, close against it`); + assert.ok(Math.abs(far - wall) > Math.abs(near - wall), `at ${spot.wall} its front (the brass pulls) faces into the room`); + // And the whole thing stays inside the room the other way, along the wall. + const span = eastWest ? across : along; + assert.ok(Math.min(...span) > -13.01 && Math.max(...span) < 13.01, `along the wall it runs ${Math.min(...span).toFixed(2)} to ${Math.max(...span).toFixed(2)}`); + } + // Nothing stands in a window: the south windows are at u -14, -9 and 1, the west ones at -9, -3 and 3. + for (const spot of spots) { + const along = spot.wall === 'west' || spot.wall === 'east'; + for (const w of WINDOWS) { + if (w.wall !== spot.wall) continue; + const u = along ? spot.z : spot.x; + assert.ok(Math.abs(u - w.u) > w.width / 2 + 0.3, `a cabinet at ${spot.wall} u ${u.toFixed(2)} is clear of the window at u ${w.u}`); + } + } + // Nor across a way out of the room, or in front of the ladder up the west wall. + for (const spot of spots) { + for (const d of [EXIT_DOOR, BALCONY_DOOR]) { + if (d.wall !== spot.wall) continue; + const along = d.wall === 'west' || d.wall === 'east'; + const u = along ? spot.z : spot.x; + assert.ok(Math.abs(u - d.u) > d.width / 2 + 0.4, `a cabinet at ${d.wall} u ${u.toFixed(2)} is clear of the door at u ${d.u}`); + } + if (spot.wall !== 'west') continue; + assert.ok(Math.abs(spot.z - LADDER.z) > LADDER.width / 2 + 0.4, `a cabinet at west z ${spot.z} is clear of the ladder at z ${LADDER.z}`); + } +}); + +test('each piece is cheap: a prop a few thousand triangles at most, the blind under 7000', () => { + for (const name of PIECES) { + const limit = name === 'venetian_blind' ? 7000 : name === 'filing_cabinet' || name === 'typewriter' || name === 'rotary_phone' ? 3200 : 2000; + assert.ok(trianglesOf(name) <= limit, `${name}: ${trianglesOf(name)} triangles (limit ${limit})`); + } +}); \ No newline at end of file