diff --git a/docs/APP.md b/docs/APP.md index f4b7cc6..934bea0 100644 --- a/docs/APP.md +++ b/docs/APP.md @@ -35,9 +35,8 @@ A renderer failure only loses the picture: the HUD keeps working. | File | Role | |---|---| | `plan.ts` | The `World` as places: lots, blocks, parks. `PUBLIC` and `NEED_LOOKS` pick models for public buildings. Pure, tested | -| `models.ts` | Builds a building from kit or realistic pieces; construction sites, damage, ruins (`shell()`) | -| `kit.ts` | Loads the low-poly kit (`public/city-kit.glb`, from `blender/city_kit.py`), the fallback for anything missing | -| `real.ts` | Loads realistic models; `DEFS` (size, set-back, kit style taken over), `PROPS`, materials and shaders | +| `models.ts` | Builds buildings from model pieces, their yards, construction sites, damage, ruins (`shell()`), parks | +| `real.ts` | Loads the models, all or none; `DEFS` (size, set-back, the plan style drawn), `PROPS`, materials and shaders | | `scene.ts` | Sky, sun by local time, shadows, bloom, camera; carries the performance work below | | `terrain.ts`, `land.ts` | Hills, river, woods (instanced, with impostors from `impostor.ts`) | | `traffic.ts` | Cars and pedestrians | @@ -47,8 +46,10 @@ A renderer failure only loses the picture: the HUD keeps working. Each model is a Python module in `ui/blender/real/` on the shared `lib.py` (textures, geometry helpers, AO bake, export). `bun run models` writes the -git-ignored `ui/public/models/`. A model replaces a kit style; models of -the same kind and style are siblings and each building picks one by seed. +git-ignored `ui/public/models/`. They are all the city draws: every style +in `STYLES` needs a model (`plan.test.ts` checks), and a missing one stops +the city with an error. Models of the same kind and style are siblings and +each building picks one by seed. **Rules for every model**, so it stays cheap to draw: @@ -101,14 +102,18 @@ are blueprints and their prices. Props, each one glb of named pieces: `cars` (`vehicles.py`), `trees`, `street`, `bridge`, `civic` (water tower, crane, scaffold, rubble, park pieces, people). Water in the Hamlet and Town is the civic water tower. +Plan parks and the square are laid out in code (`park()` and `utility()` in +`models.ts`) from these props on the shared ground textures (pave, ashlar, +gravel, grass). ### Model packs Models ship as their own GitHub release, `models-` (a prerelease, never "latest"). `ui/src-tauri/models.json` lists each file's size and SHA-256 and is compiled into the app, which downloads into -`~/.local/share/deepwork/assets//`, refuses any mismatch, and draws the -kit until the pack is whole. The download is done by the app as the user, +`~/.local/share/deepwork/assets//`, refuses any mismatch, and shows +the HUD and the download, with no city, until the pack is whole. A GPU +without BC7 gets no city. The download is done by the app as the user, never by the daemon. `` hashes the file list, so unchanged models download nothing. The pack's glbs are gzipped (`pack.ts`); the loader inflates them, and reads a plain local build as it is. Publishing steps: [DEVELOPMENT.md](DEVELOPMENT.md#model-packs). @@ -167,7 +172,7 @@ Open items are in [ROADMAP.md](ROADMAP.md#rendering). - **3D city, generated assets.** A flat 2D skyline was tried and rejected: "nothing to be proud of, nothing to lose". Visuals must make loss hurt. -- **Realistic over low-poly**; the kit stays only as a fallback. +- **Realistic models only**, with no low-poly fallback: until the pack is there, no city. - American and European styles mixed; real-world sizes per model. - **Daylight from a warm sun and a sky hemisphere**, not a flat ambient: an ambient lights every face alike and flattened the models (`light()` in diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cbe396b..98a9122 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -45,7 +45,6 @@ shadow filter; `&leaves=0` turns off the leaf pre-pass and backlight. In the con ### Models ```sh -bun run kit # low-poly kit -> public/city-kit.glb (committed) bun run models # every realistic model -> public/models/ (git-ignored, minutes) bun run models c1 --no-tex # one model, skip the shared texture library bun run textures # redo BC7 compression only (needs basisu) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b5270ef..c02f85d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,9 +17,10 @@ area, if anything a reader needs changed. 1. **Use it for real** and tune: coin prices, build hours, shiny odds, escalation stages, nudges. Add real `[[routines]]`. -2. **Models**: O2 or O3 (the default order builds six identical O1 towers - at 50-98 h), then a second workshop (timber mill or brewery; S4 is the - only one, so the mayor never chooses). +2. **Models**: O2 or O3, then L2 (offices and towers are each one design, + so ~200 of the plan's ~350 h build O1 or L1 over and over), then a + second workshop (timber mill or brewery; S4 is the only one, so the + mayor never chooses) and a third downtown shop (only S1 and S2 fit). 3. **The master plan's real layout** in `ui/src/city/plan.ts`: streets, districts, reserved ground for squares and landmarks; a test that its lot ids match `plan.rs`. Then L5 city hall grows to its half block, and diff --git a/ui/.gitignore b/ui/.gitignore index 97aa848..72690ec 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,5 +1,4 @@ node_modules/ dist/ -# Generated by blender/real/build.py (bun run models); the app falls back to -# the low-poly kit without them +# Generated by blender/real/build.py (bun run models); the city needs them /public/models/ diff --git a/ui/blender/city_kit.py b/ui/blender/city_kit.py deleted file mode 100644 index 50daa1b..0000000 --- a/ui/blender/city_kit.py +++ /dev/null @@ -1,2545 +0,0 @@ -# Builds the city kit: modular building pieces and trees with baked ambient -# occlusion, exported as one glTF binary for the 3D city (ui/src/city/kit.ts). -# -# blender -b --factory-startup -P ui/blender/city_kit.py -- ui/public/city-kit.glb [--preview out.png] -# -# Conventions the app relies on: -# - One object per piece, at the origin. Blender +Z is up and the front faces -# -Y; in glTF / three.js that is +Y up and the front facing +Z. -# - Buildings are stacks: a ground piece, floor pieces (one storey each, open -# top and bottom but for a dark cap) and a roof piece. Footprints are the -# nominal sizes below; the app scales x / z to each building's lot. -# - Material names are roles (wall, trim, window, ...). The app picks the real -# material per building and damage, so colours here only serve the preview. -# - Each window pane maps to one cell of a CELLS x CELLS window atlas -# (TEXCOORD_0); the app shifts cells per floor so lit windows differ. -# - COLOR_0 is the baked ambient occlusion (linear, multiplied in). -# - Custom properties (glTF extras) are anchors in three.js coordinates. -import math -import random -import sys - -import bmesh -import bpy -from mathutils import Matrix, Vector -from mathutils.bvhtree import BVHTree - -argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] -OUT = argv[0] if argv else "city-kit.glb" -PREVIEW = argv[argv.index("--preview") + 1] if "--preview" in argv else None - -CELLS = 32 -Z = Vector((0, 0, 1)) - -# Nominal footprints (W along x, D along y) and storey heights; keep in step -# with NOMINAL in kit.ts and shape() in plan.ts. -COTTAGE = (6.75, 6.25, 3.2) -SHOP = (8.75, 8.0, 3.4) -APT = (9.15, 9.15, 3.1) -OFFICE = (9.6, 9.6, 3.5) -LANDMARK = (10.4, 3.5) -LM_TIERS = (1.0, 0.78, 0.56) - -PREVIEW_COLORS = { - "wall": (0.82, 0.74, 0.62), "trim": (0.86, 0.84, 0.79), "stone": (0.6, 0.58, 0.54), - "window": (0.1, 0.14, 0.2), "glass": (0.16, 0.24, 0.32), "frame": (0.93, 0.93, 0.9), - "roof": (0.5, 0.2, 0.15), "deck": (0.3, 0.3, 0.31), "accent": (0.6, 0.14, 0.11), - "canvas": (0.92, 0.9, 0.84), "sign": (1.0, 0.75, 0.35), "board": (0.08, 0.08, 0.1), - "shopfront": (0.85, 0.7, 0.45), "lobby": (0.85, 0.78, 0.6), "crownlight": (1.0, 0.8, 0.45), - "door": (0.28, 0.16, 0.09), "metal": (0.5, 0.52, 0.55), "dark": (0.16, 0.15, 0.14), - "spandrel": (0.1, 0.12, 0.15), "gold": (0.85, 0.6, 0.2), "brick": (0.48, 0.22, 0.15), - "wood": (0.42, 0.27, 0.17), "leaf": (0.22, 0.42, 0.16), "bark": (0.28, 0.18, 0.11), - "paint": (0.95, 0.95, 0.9), "railglass": (0.28, 0.36, 0.42), "crane": (0.9, 0.65, 0.08), - "concrete": (0.6, 0.6, 0.58), "grate": (0.3, 0.31, 0.32), "birch": (0.88, 0.86, 0.82), - "blossom": (0.95, 0.65, 0.75), "led": (0.8, 0.9, 1.0), "body": (0.7, 0.1, 0.1), - "carglass": (0.08, 0.1, 0.13), "tyre": (0.05, 0.05, 0.05), "chrome": (0.8, 0.8, 0.82), - "headlight": (1, 0.95, 0.8), "taillight": (0.8, 0.05, 0.05), "taxisign": (1, 0.85, 0.2), - "steel": (0.25, 0.4, 0.35), "rock": (0.5, 0.48, 0.44), - "water": (0.25, 0.45, 0.55), - "skin": (0.8, 0.6, 0.45), "shirt": (0.2, 0.4, 0.7), "pants": (0.15, 0.15, 0.2), "hair": (0.15, 0.1, 0.05), -} -CELL_ROLES = {"window", "glass"} - - -class Mod: - """One kit piece under construction.""" - - def __init__(self, name, ground=False, ao=3.0): - self.name = name - self.ground = ground # occlusion sees a ground plane at z = 0 - self.ao = ao # occlusion ray length - self.bm = bmesh.new() - self.uv = self.bm.loops.layers.uv.verify() - self.roles = [] - self.extras = {} - self.r = random.Random(name) - MODS.append(self) - - def mi(self, role): - if role not in self.roles: - self.roles.append(role) - return self.roles.index(role) - - def anchor(self, key, p): - """A point for the app, converted to three.js axes.""" - self.extras[key] = [round(p[0], 3), round(p[2], 3), round(-p[1], 3)] - - def face(self, role, pts, out=None, cell=False, smooth=False): - f = self.bm.faces.new([self.bm.verts.new(p) for p in pts]) - f.material_index = self.mi(role) - f.smooth = smooth - f.normal_update() - if out is not None and f.normal.dot(Vector(out)) < 0: - f.normal_flip() - if cell: - self.cell(f) - return f - - def cell(self, f): - """Maps a pane onto a random cell of the window atlas, upright.""" - n = f.normal - right = Z.cross(n) - if right.length < 1e-6: - right = Vector((1, 0, 0)) - right.normalize() - up = n.cross(right) - us = [v.co.dot(right) for v in f.verts] - vs = [v.co.dot(up) for v in f.verts] - u0, u1, v0, v1 = min(us), max(us), min(vs), max(vs) - i, j = self.r.randrange(CELLS), self.r.randrange(CELLS) - for loop in f.loops: - a = (loop.vert.co.dot(right) - u0) / max(u1 - u0, 1e-6) - b = (loop.vert.co.dot(up) - v0) / max(v1 - v0, 1e-6) - loop[self.uv].uv = ((i + 0.04 + 0.92 * a) / CELLS, (j + 0.04 + 0.92 * b) / CELLS) - - def hexa(self, role, corner, skip="", bevel=0.0, smooth=False, cell=False): - """A six-sided solid from corner(xi, yi, zi). Faces: w/e (x), n/s (y), b/t (z).""" - vs = {} - for xi in (0, 1): - for yi in (0, 1): - for zi in (0, 1): - vs[xi, yi, zi] = self.bm.verts.new(corner(xi, yi, zi)) - c = sum((v.co for v in vs.values()), Vector()) / 8 - sides = { - "w": [(0, 0, 0), (0, 1, 0), (0, 1, 1), (0, 0, 1)], - "e": [(1, 0, 0), (1, 1, 0), (1, 1, 1), (1, 0, 1)], - "n": [(0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)], - "s": [(0, 1, 0), (1, 1, 0), (1, 1, 1), (0, 1, 1)], - "b": [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], - "t": [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)], - } - faces = [] - for k, idx in sides.items(): - if k in skip: - continue - f = self.bm.faces.new([vs[i] for i in idx]) - f.material_index = self.mi(role) - f.smooth = smooth - f.normal_update() - if f.normal.dot(f.calc_center_median() - c) < 0: - f.normal_flip() - faces.append(f) - for v in vs.values(): - if not v.link_faces: - self.bm.verts.remove(v) - if bevel > 0: - edges = [e for e in {e for f in faces for e in f.edges} if len(e.link_faces) == 2] - bmesh.ops.bevel(self.bm, geom=edges, offset=bevel, segments=1, affect="EDGES", profile=0.5, clamp_overlap=True) - if cell: - for f in faces: - self.cell(f) - return faces - - def box(self, role, x0, y0, z0, x1, y1, z1, **kw): - return self.hexa(role, lambda a, b, c: ((x0, x1)[a], (y0, y1)[b], (z0, z1)[c]), **kw) - - def mbox(self, role, M, sx, sy, sz, **kw): - """A box of size sx, sy, sz centred on the origin, transformed by M.""" - return self.hexa(role, lambda a, b, c: M @ Vector(((a - 0.5) * sx, (b - 0.5) * sy, (c - 0.5) * sz)), **kw) - - def _faces_of(self, verts, role, smooth): - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = self.mi(role) - side = abs(f.normal.z) < 0.7 - f.smooth = smooth and side - if smooth: - for f in faces: - for e in f.edges: - if len(e.link_faces) == 2 and e.link_faces[0].smooth != e.link_faces[1].smooth: - e.smooth = False - return faces - - def cyl(self, role, x, y, z0, r0, r1, h, seg=10, smooth=True, M=None, jitter=0.0): - M = (M or Matrix()) @ Matrix.Translation((x, y, z0 + h / 2)) - verts = bmesh.ops.create_cone(self.bm, cap_ends=True, cap_tris=False, segments=seg, radius1=r0, radius2=r1, depth=h, matrix=M)["verts"] - for v in verts if jitter else (): - v.co += Vector((self.r.uniform(-1, 1), self.r.uniform(-1, 1), self.r.uniform(-0.6, 0.6))) * jitter - faces = self._faces_of(verts, role, smooth) - for f in faces: - f.normal_update() - return faces - - def strut(self, role, a, b, t=0.08): - """A square bar from a to b.""" - a, b = Vector(a), Vector(b) - d = (b - a).normalized() - u = d.orthogonal().normalized() - w = d.cross(u) - return self.hexa(role, lambda i, j, k: (a, b)[i] + u * ((j - 0.5) * t) + w * ((k - 0.5) * t)) - - def blob(self, role, c, s, sub=1, jitter=0.0): - M = Matrix.Translation(c) @ Matrix.Diagonal((*s, 1)) - verts = bmesh.ops.create_icosphere(self.bm, subdivisions=sub, radius=1.0, matrix=M)["verts"] - for v in verts: - v.co += Vector((self.r.uniform(-1, 1), self.r.uniform(-1, 1), self.r.uniform(-1, 1))) * jitter - faces = self._faces_of(verts, role, False) - for f in faces: - f.normal_update() - return faces - - -class Side: - """One side of a W x D footprint: u runs left to right seen from outside, - v up from z0, k outwards (negative is into the wall).""" - - def __init__(self, m, side, W, D, z0=0.0, at=(0.0, 0.0)): - self.m = m - o, self.u, self.n, self.L = { - "n": (Vector((-W / 2, -D / 2, 0)), Vector((1, 0, 0)), Vector((0, -1, 0)), W), - "e": (Vector((W / 2, -D / 2, 0)), Vector((0, 1, 0)), Vector((1, 0, 0)), D), - "s": (Vector((W / 2, D / 2, 0)), Vector((-1, 0, 0)), Vector((0, 1, 0)), W), - "w": (Vector((-W / 2, D / 2, 0)), Vector((0, -1, 0)), Vector((-1, 0, 0)), D), - }[side] - self.o = o + Z * z0 + Vector((at[0], at[1], 0)) - - def P(self, u, v, k=0.0): - return self.o + self.u * u + Z * v + self.n * k - - def box(self, role, u0, u1, v0, v1, k0, k1, skip="", bevel=0.0): - """Skip letters: w/e the u ends, n the face against the wall, s the front, b/t.""" - return self.m.hexa(role, lambda a, b, c: self.P((u0, u1)[a], (v0, v1)[c], (k0, k1)[b]), skip, bevel) - - def quad(self, role, u0, u1, v0, v1, k=0.0, cell=False): - return self.m.face(role, [self.P(u0, v0, k), self.P(u1, v0, k), self.P(u1, v1, k), self.P(u0, v1, k)], out=self.n, cell=cell) - - def wall(self, role, v0, v1, holes=(), u0=0.0, u1=None, k=0.0): - """A flat wall with rectangular holes (u0, u1, v0, v1, ...).""" - u1 = self.L if u1 is None else u1 - us = sorted({u0, u1, *[h[0] for h in holes], *[h[1] for h in holes]}) - vs = sorted({v0, v1, *[h[2] for h in holes], *[h[3] for h in holes]}) - for a, b in zip(us, us[1:]): - for c, d in zip(vs, vs[1:]): - uc, vc = (a + b) / 2, (c + d) / 2 - if any(h[0] < uc < h[1] and h[2] < vc < h[3] for h in holes): - continue - self.quad(role, a, b, c, d, k) - - def opening(self, h, depth, pane="window", reveal="wall"): - """Reveals into a hole and the pane at its back.""" - u0, u1, v0, v1 = h[:4] - P, d, m = self.P, -depth, self.m - m.face(reveal, [P(u0, v0), P(u1, v0), P(u1, v0, d), P(u0, v0, d)], out=Z) - m.face(reveal, [P(u0, v1), P(u1, v1), P(u1, v1, d), P(u0, v1, d)], out=-Z) - m.face(reveal, [P(u0, v0), P(u0, v1), P(u0, v1, d), P(u0, v0, d)], out=self.u) - m.face(reveal, [P(u1, v0), P(u1, v1), P(u1, v1, d), P(u1, v0, d)], out=-self.u) - if pane: - self.quad(pane, u0, u1, v0, v1, d, cell=pane in CELL_ROLES) - - -def spaced(L, n, w, margin=0.0): - step = (L - 2 * margin) / n - return [(margin + step * (i + 0.5) - w / 2, margin + step * (i + 0.5) + w / 2) for i in range(n)] - - -def punched(m, W, D, z0, H, layout, role="wall", depth=0.16, reveal="wall", sill="trim", lintel="trim", pane="window", at=(0.0, 0.0)): - """Four walls with punched openings: layout maps side -> holes - (u0, u1, v0, v1[, pane role]).""" - sides = {} - for side, holes in layout.items(): - sd = sides[side] = Side(m, side, W, D, z0, at) - sd.wall(role, 0, H, holes) - for h in holes: - p = h[4] if len(h) > 4 else pane - sd.opening(h, depth, pane=p, reveal=reveal) - if p not in CELL_ROLES: - continue - if sill and h[2] > 0.1: - sd.box(sill, h[0] - 0.1, h[1] + 0.1, h[2] - 0.09, h[2], 0, 0.13, skip="n") - if lintel: - sd.box(lintel, h[0] - 0.12, h[1] + 0.12, h[3], h[3] + 0.17, 0, 0.05, skip="n") - return sides - - -def band(m, W, D, z0, z1, out, role="trim", bevel=0.03): - m.box(role, -W / 2 - out, -D / 2 - out, z0, W / 2 + out, D / 2 + out, z1, bevel=bevel) - - -def ring(m, W, D, z0, z1, t, role="wall", out=0.0, bevel=0.0): - """A parapet: four walls t thick whose outer faces sit `out` past the footprint.""" - x, y = W / 2 + out, D / 2 + out - m.box(role, -x, -y, z0, x, -y + t, z1, bevel=bevel) - m.box(role, -x, y - t, z0, x, y, z1, bevel=bevel) - m.box(role, -x, -y + t, z0, -x + t, y - t, z1, bevel=bevel) - m.box(role, x - t, -y + t, z0, x, y - t, z1, bevel=bevel) - - -def cap(m, W, D, z): - """Dark top of a storey: seen only when a broken building ends there.""" - m.face("deck", [(-W / 2, -D / 2, z), (W / 2, -D / 2, z), (W / 2, D / 2, z), (-W / 2, D / 2, z)], out=Z) - - -def roof_deck(m, W, D, z, t=0.3): - m.box("deck", -W / 2 + t, -D / 2 + t, z - 0.1, W / 2 - t, D / 2 - t, z, skip="b") - - -def ac_unit(m, x, y, z, s=1.0): - m.box("metal", x - 0.7 * s, y - 0.55 * s, z, x + 0.7 * s, y + 0.55 * s, z + 0.9 * s, bevel=0.03) - m.cyl("dark", x, y, z + 0.9 * s, 0.38 * s, 0.38 * s, 0.06, seg=12, smooth=False) - - -def vent(m, x, y, z, h=0.9): - m.cyl("metal", x, y, z, 0.12, 0.12, h, seg=8) - m.cyl("metal", x, y, z + h, 0.2, 0.05, 0.18, seg=8) - - -# ---- cottages --------------------------------------------------------------- - - -def gable(m, W, D, rise, oe=0.4, og=0.3, t=0.16, axis="x", wall="wall", role="roof"): - """A gable roof on a W x D top at z = 0, ridge along `axis`.""" - if axis == "y": - W, D = D, W - sw = lambda x, y, z: Vector((y, x, z)) # noqa: E731 - else: - sw = lambda x, y, z: Vector((x, y, z)) # noqa: E731 - hd = D / 2 - slope = rise / hd - ze = -oe * slope - for s in (-1, 1): - n = Vector((0, s * slope, 1)).normalized() - - def corner(a, b, c, s=s, n=n): - x = (-W / 2 - og, W / 2 + og)[a] - p = Vector((x, s * (hd + oe), ze)) if b == 0 else Vector((x, 0, rise)) - if c == 0: - p = p - n * t - return sw(*p) - - m.hexa(role, corner) - for x in (-W / 2, W / 2): - m.face(wall, [sw(x, -hd, 0), sw(x, hd, 0), sw(x, 0, rise - 0.12)], out=sw(x, 0, 0)) - m.hexa("trim", lambda a, b, c: sw((-W / 2 - og - 0.02, W / 2 + og + 0.02)[a], (-0.16, 0.16)[b], (rise - 0.05, rise + 0.1)[c])) - # Barge boards along the gable edges. - for x in (-W / 2 - og, W / 2 + og): - for s in (-1, 1): - m.hexa( - "trim", - lambda a, b, c, x=x, s=s: sw( - x + (a - 0.5) * 0.08, - s * (hd + oe) * (1 - b), - (ze + (rise - ze) * b) - 0.28 + c * 0.3, - ), - ) - - -def hip(m, W, D, rise, o=0.38, t=0.18, role="roof"): - """A hipped roof (W >= D) on a top at z = 0.""" - hw, hd = W / 2 + o, D / 2 + o - slope = rise / (D / 2) - ze = -o * slope - r = max(0.0, W / 2 - D / 2) - E = [Vector(p) for p in [(-hw, -hd, ze), (hw, -hd, ze), (hw, hd, ze), (-hw, hd, ze)]] - R0, R1 = Vector((-r, 0, rise)), Vector((r, 0, rise)) - below = Vector((0, 0, ze - 2)) - for pts in ([E[0], E[1], R1, R0], [E[1], E[2], R1], [E[2], E[3], R0, R1], [E[3], E[0], R0]): - c = sum(pts, Vector()) / len(pts) - m.face(role, pts, out=c - below) - lo = [e - Z * t for e in E] - for i in range(4): - a, b = E[i], E[(i + 1) % 4] - mid = (a + b) / 2 - m.face("trim", [a, b, lo[(i + 1) % 4], lo[i]], out=Vector((mid.x, mid.y, 0))) - m.face("trim", lo, out=-Z) - m.box("trim", -r - 0.12, -0.12, rise - 0.06, r + 0.12, 0.12, rise + 0.08) - - -def chimney(m, x, y, top): - m.box("brick", x - 0.36, y - 0.36, 0.2, x + 0.36, y + 0.36, top, skip="b") - m.box("trim", x - 0.46, y - 0.46, top, x + 0.46, y + 0.46, top + 0.14, bevel=0.02) - m.cyl("dark", x - 0.12, y, top + 0.14, 0.1, 0.1, 0.3, seg=8) - m.cyl("dark", x + 0.14, y, top + 0.14, 0.1, 0.1, 0.22, seg=8) - m.anchor("smoke", (x, y, top + 0.6)) - - -def cottage_body(v): - W, D, H = COTTAGE - m = Mod(f"cottage_body_{v}", ground=True) - m.box("stone", -W / 2 - 0.06, -D / 2 - 0.06, 0, W / 2 + 0.06, D / 2 + 0.06, 0.5, skip="b", bevel=0.02) - win = lambda c, w=1.05: (c - w / 2, c + w / 2, 1.05, 2.4) # noqa: E731 - dc = {"a": W / 2, "b": W * 0.68, "c": W / 2}[v] - door = (dc - 0.5, dc + 0.5, 0.5, 2.6, "door") - front = { - "a": [win(W * 0.2), door, win(W * 0.8)], - "b": [win(W * 0.17), win(W * 0.4), door, win(W * 0.88, 0.75)], - "c": [win(W * 0.18, 1.3), door, win(W * 0.82, 1.3)], - }[v] - side = [win(D / 2)] if v != "b" else [win(D * 0.3), win(D * 0.7)] - back = [win(W * 0.25), win(W * 0.75)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, depth=0.14, reveal="frame", lintel=None) - n = sides["n"] - for h in front: - if len(h) > 4: - continue - if v == "a": # shutters - n.box("accent", h[0] - 0.42, h[0] - 0.05, h[2], h[3], 0, 0.05, skip="n") - n.box("accent", h[1] + 0.05, h[1] + 0.42, h[2], h[3], 0, 0.05, skip="n") - if v == "b": # flower boxes - n.box("wood", h[0] - 0.05, h[1] + 0.05, h[2] - 0.38, h[2] - 0.09, 0, 0.3, skip="n") - for i in range(3): - u = h[0] + (h[1] - h[0]) * (i + 0.5) / 3 - m.blob("leaf", n.P(u, h[2] - 0.02, 0.16), (0.2, 0.16, 0.14), sub=1, jitter=0.03) - # Door: steps, a fanlight frame, a canopy (a, b) or a porch (c). - n.box("stone", dc - 0.75, dc + 0.75, 0, 0.25, 0, 0.95, skip="n") - n.box("stone", dc - 0.75, dc + 0.75, 0.25, 0.5, 0, 0.5, skip="n") - n.box("frame", dc - 0.62, dc + 0.62, 2.6, 2.75, 0, 0.08, skip="n") - if v == "c": - u0, u1 = W * 0.22, W * 0.78 - n.box("wood", u0, u1, 0.38, 0.5, 0, 1.9, skip="n") - for u in (u0 + 0.1, u1 - 0.1): - n.box("frame", u - 0.09, u + 0.09, 0.5, 2.85, 1.7, 1.88) - n.box("frame", u0, u1, 2.85, 2.95, 1.7, 1.9, skip="n") - n.m.hexa("roof", lambda a, b, c: n.P((u0 - 0.2, u1 + 0.2)[a], (3.15, 2.85)[b] + c * 0.12, (0, 2.1)[b])) - else: - n.box("trim", dc - 0.85, dc + 0.85, 2.82, 2.94, 0, 0.75, skip="n", bevel=0.02) - for u in (dc - 0.7, dc + 0.7): - n.box("trim", u - 0.05, u + 0.05, 2.45, 2.82, 0, 0.5, skip="n") - band(m, W, D, H - 0.34, H - 0.12, 0.06, "trim", bevel=0.0) - cap(m, W, D, H) - - -def cottage_roof(v): - W, D, _ = COTTAGE - m = Mod(f"cottage_roof_{v}") - if v == "a": - gable(m, W, D, 2.5, axis="x") - chimney(m, W * 0.24, D * 0.16, 2.9) - elif v == "b": - hip(m, W, D, 2.2) - chimney(m, -W * 0.22, -D * 0.12, 2.6) - else: - gable(m, W, D, 2.7, oe=0.35, og=0.45, axis="y") - # A round-ish attic window in the front gable. - sd = Side(m, "n", W, D) - c = W / 2 - sd.box("frame", c - 0.45, c + 0.45, 0.75, 1.65, 0, 0.06) - sd.quad("window", c - 0.33, c + 0.33, 0.87, 1.53, 0.065, cell=True) - chimney(m, -W * 0.28, D * 0.2, 3.0) - return m - - -# ---- shops ------------------------------------------------------------------ - -GLYPHS = [ - "111101111101101", "110101110101110", "111100100100111", "110101101101110", "111100110100111", - "111100110100100", "111100101101111", "101101111101101", "111010010010111", "100100100100111", - "101111111101101", "111101101101111", "111101111100100", "111101111110101", "111100111001111", - "111010010010010", "101101101101111", "101101101101010", "101101111111101", "101101010101101", -] - - -def lettering(m, sd, uc, vc, k, n, px=0.075): - """A row of n block letters centred on (uc, vc), glowing at night.""" - width = n * 4 * px - px - for i in range(n): - g = GLYPHS[m.r.randrange(len(GLYPHS))] - u0 = uc - width / 2 + i * 4 * px - for row in range(5): - bits = g[row * 3 : row * 3 + 3] - col = 0 - while col < 3: - if bits[col] == "1": - end = col - while end + 1 < 3 and bits[end + 1] == "1": - end += 1 - v1 = vc + 2.5 * px - row * px - sd.box("sign", u0 + col * px, u0 + (end + 1) * px, v1 - px, v1, k, k + 0.05, skip="n") - col = end + 1 - else: - col += 1 - - -def awning(m, sd, u0, u1, top, drop, depth, stripes=True): - n = max(2, round((u1 - u0) / 0.45)) - for i in range(n): - a = u0 + (u1 - u0) * i / n - b = u0 + (u1 - u0) * (i + 1) / n - role = "accent" if i % 2 == 0 or not stripes else "canvas" - m.face(role, [sd.P(a, top), sd.P(b, top), sd.P(b, top - drop, depth), sd.P(a, top - drop, depth)], out=sd.n + Z) - sd.quad(role, a, b, top - drop - 0.28, top - drop, depth) - for u, s in ((u0, -1), (u1, 1)): - m.face("accent", [sd.P(u, top), sd.P(u, top - drop, depth), sd.P(u, top - drop - 0.28, depth)], out=sd.u * s) - - -def shop_ground(v): - W, D, H = SHOP - m = Mod(f"shop_ground_{v}", ground=True) - n = Side(m, "n", W, D) - if v == "a": - dc = W * 0.72 - holes = [(0.6, dc - 0.8, 0.55, 2.55, "shopfront"), (dc - 0.6, dc + 0.6, 0.0, 2.55, "door"), (dc + 0.8, W - 0.6, 0.55, 2.55, "shopfront")] - else: - dc = W / 2 - holes = [ - (0.6, dc - 1.3, 0.5, 2.55, "shopfront"), (dc - 1.1, dc - 0.2, 0, 2.55, "door"), - (dc + 0.2, dc + 1.1, 0, 2.55, "door"), (dc + 1.3, W - 0.6, 0.5, 2.55, "shopfront"), - ] - n.wall("wall", 0, H, holes) - for h in holes: - n.opening(h, 0.3, pane=h[4], reveal="metal") - if h[4] == "shopfront": - k = max(1, round((h[1] - h[0]) / 1.3)) - for i in range(1, k): - u = h[0] + (h[1] - h[0]) * i / k - n.box("metal", u - 0.04, u + 0.04, h[2], h[3], -0.3, -0.25, skip="n") - n.box("metal", h[0], h[1], 2.05, 2.11, -0.3, -0.25, skip="n") - n.box("stone", h[0] - 0.08, h[1] + 0.08, 0, h[2], 0, 0.06, skip="n") - else: - n.box("metal", h[0], h[1], 2.05, 2.11, -0.3, -0.25, skip="n") - for u0, u1 in ((0, 0.42), (W - 0.42, W)): - n.box("trim", u0, u1, 0, H, 0, 0.14, skip="n", bevel=0.02) - if v == "b": - n.box("trim", dc - 0.18, dc + 0.18, 2.55, H, 0, 0.14, skip="n") - # Fascia sign with lettering; awnings below it. - n.box("board", 0.45, W - 0.45, 2.72, 3.28, 0, 0.1, skip="n") - if v == "a": - lettering(m, n, W / 2, 3.0, 0.1, 6) - awning(m, n, 0.6, W - 0.6, 2.68, 0.55, 1.5) - else: - lettering(m, n, W * 0.27, 3.0, 0.1, 4) - lettering(m, n, W * 0.73, 3.0, 0.1, 4) - awning(m, n, 0.6, dc - 1.25, 2.68, 0.5, 1.3) - awning(m, n, dc + 1.25, W - 0.6, 2.68, 0.5, 1.3, stripes=False) - # A blade sign on the corner. - n.box("board", W - 0.3, W - 0.22, 1.6, 2.6, 0.15, 1.0) - n.box("sign", W - 0.33, W - 0.19, 1.75, 2.45, 0.3, 0.85) - sides = punched( - m, W, D, 0, H, - {"e": [(D * 0.6 - 0.5, D * 0.6 + 0.5, 1.4, 2.5)], "s": [(W * 0.3 - 0.5, W * 0.3 + 0.5, 0, 2.2, "door"), (W * 0.7 - 0.6, W * 0.7 + 0.6, 1.2, 2.5)], "w": [(D * 0.4 - 0.5, D * 0.4 + 0.5, 1.4, 2.5)]}, - depth=0.15, - ) - sides["s"].box("metal", W * 0.3 - 0.7, W * 0.3 + 0.7, 2.35, 2.45, 0, 0.7, skip="n") - m.box("stone", -W / 2 - 0.04, -D / 2 + 0.01, 0, W / 2 + 0.04, D / 2 + 0.04, 0.35, skip="bn") - cap(m, W, D, H) - - -def quoins(m, W, D, z0, H, role="trim", t=0.05): - """Alternating corner stones, wrapping each corner.""" - n = max(1, round(H / 0.8)) - h = H / n - for sx in (-1, 1): - for sy in (-1, 1): - x, y = sx * W / 2, sy * D / 2 - for i in range(n): - a, b = (0.6, 0.32) if i % 2 == 0 else (0.32, 0.6) - z1, z2 = z0 + i * h, z0 + (i + 1) * h - 0.05 - xs, ys = sorted((x - sx * a, x + sx * t)), sorted((y, y + sy * t)) - m.box(role, xs[0], ys[0], z1, xs[1], ys[1], z2) - xs, ys = sorted((x, x + sx * t)), sorted((y - sy * b, y)) - m.box(role, xs[0], ys[0], z1, xs[1], ys[1], z2) - - -def shop_floor(v): - W, D, H = SHOP - m = Mod(f"shop_floor_{v}") - if v == "a": - front = [(a, b, 0.75, 2.65) for a, b in spaced(W, 3, 1.2)] - else: - front = [] - for a, b in spaced(W, 2, 2.6): - front += [(a, a + 1.05, 0.75, 2.65), (b - 1.05, b, 0.75, 2.65)] - side = [(a, b, 0.8, 2.6) for a, b in spaced(D, 2, 1.1)] - back = [(a, b, 0.8, 2.6) for a, b in spaced(W, 3, 1.0)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, depth=0.2) - band(m, W, D, 0, 0.2, 0.08) - if v == "a": - quoins(m, W, D, 0.2, H - 0.2) - else: - n = sides["n"] - for u in (0.2, W / 2, W - 0.2): - n.box("trim", u - 0.2, u + 0.2, 0.2, H, 0, 0.12, skip="nbt") - for h in front: # keystones - c = (h[0] + h[1]) / 2 - n.box("trim", c - 0.12, c + 0.12, h[3] + 0.05, h[3] + 0.32, 0, 0.09, skip="n") - cap(m, W, D, H) - - -def shop_roof(v): - W, D, _ = SHOP - m = Mod(f"shop_roof_{v}") - band(m, W, D, 0, 0.28, 0.12) - band(m, W, D, 0.28, 0.5, 0.34, bevel=0.05) - for side, L in (("n", W), ("e", D), ("s", W), ("w", D)): - sd = Side(m, side, W, D) - k = round(L / 0.7) - for i in range(k): - u = L * (i + 0.5) / k - sd.box("trim", u - 0.07, u + 0.07, 0.06, 0.28, 0.12, 0.3, skip="nt") - ring(m, W, D, 0.5, 1.2, 0.25, "wall") - ring(m, W, D, 1.2, 1.32, 0.36, "trim", out=0.06, bevel=0.02) - roof_deck(m, W, D, 0.62) - if v == "b": - sd = Side(m, "n", W, D) - c = W / 2 - sd.box("wall", c - 2.2, c + 2.2, 1.2, 2.0, -0.25, 0) - sd.box("trim", c - 2.3, c + 2.3, 2.0, 2.12, -0.3, 0.06, bevel=0.02) - tri = [sd.P(c - 2.3, 2.12, 0.04), sd.P(c + 2.3, 2.12, 0.04), sd.P(c, 2.9, 0.04)] - back = [p - sd.n * 0.3 for p in tri] - m.face("trim", tri, out=sd.n) - m.face("trim", back, out=-sd.n) - for a, b in ((0, 2), (2, 1)): - m.face("trim", [tri[a], tri[b], back[b], back[a]], out=Z + sd.u * (1 if a == 2 else -1)) - sd.box("board", c - 1.0, c + 1.0, 1.35, 1.85, 0, 0.04, skip="n") - ac_unit(m, W * 0.18, D * 0.2, 0.62) - vent(m, -W * 0.3, D * 0.25, 0.62) - else: - ac_unit(m, -W * 0.2, D * 0.1, 0.62) - ac_unit(m, W * 0.15, D * 0.22, 0.62, 0.8) - vent(m, W * 0.3, -D * 0.2, 0.62) - vent(m, W * 0.36, -D * 0.1, 0.62, 0.6) - m.box("metal", -W * 0.1, -D * 0.3, 0.62, W * 0.12, -D * 0.12, 0.85) - m.hexa("glass", lambda a, b, c: (-W * 0.08 + a * W * 0.18, -D * 0.28 + b * D * 0.14, 0.85 + c * (0.12 + 0.25 * b)), cell=False) - - -# ---- apartments ------------------------------------------------------------- - - -def apt_ground(v=None, wall="wall"): - W, D, H = APT - m = Mod("apt_ground" if v is None else f"apt_ground_{v}", ground=True) - dc = W / 2 - door = (dc - 0.85, dc + 0.85, 0, 2.45, "door") - win = lambda c: (c - 0.55, c + 0.55, 1.0, 2.4) # noqa: E731 - front = [win(W * 0.12), win(W * 0.3), door, win(W * 0.7), win(W * 0.88)] - sides = punched( - m, W, D, 0, H, - {"n": front, "e": [win(a + 0.55) for a, _ in spaced(D, 3, 1.1)], "s": [win(a + 0.55) for a, _ in spaced(W, 4, 1.1)], "w": [win(a + 0.55) for a, _ in spaced(D, 3, 1.1)]}, - role=wall, depth=0.18, lintel=None, - ) - n = sides["n"] - # Recessed entrance: deeper reveal in stone, glass door with a transom. - n.opening(door, 0.0, pane=None, reveal="stone") - n.box("stone", door[0] - 0.25, door[1] + 0.25, 0, 2.7, 0, 0.12, skip="n") - n.box("metal", door[0], door[1], 2.0, 2.06, -0.17, -0.12, skip="n") - n.box("metal", dc - 0.03, dc + 0.03, 0, 2.0, -0.17, -0.12, skip="n") - n.box("trim", dc - 1.5, dc + 1.5, 2.7, 2.9, 0, 1.6, skip="n", bevel=0.03) - n.box("shopfront", dc - 0.15, dc + 0.15, 2.55, 2.7, 1.2, 1.5) - n.box("stone", door[0] - 0.4, door[1] + 0.4, 0, 0.15, 0, 1.2, skip="n") - for side, sd in sides.items(): - if side == "n": - sd.box("stone", 0, door[0] - 0.25, 0, 0.75, 0, 0.06, skip="n") - sd.box("stone", door[1] + 0.25, sd.L, 0, 0.75, 0, 0.06, skip="n") - else: - sd.box("stone", 0, sd.L, 0, 0.75, 0, 0.06, skip="n") - band(m, W, D, H - 0.2, H, 0.07) - cap(m, W, D, H) - - -def balcony(sd, uc, w=2.6, depth=1.25, glass=False): - sd.box("trim", uc - w / 2, uc + w / 2, -0.02, 0.16, 0, depth, skip="n", bevel=0.03) - panel = "railglass" if glass else "trim" - t = 0.04 if glass else 0.1 - sd.box(panel, uc - w / 2, uc + w / 2, 0.16, 1.05, depth - t, depth, skip="b") - for u in (uc - w / 2, uc + w / 2 - t): - sd.box(panel, u, u + t, 0.16, 1.05, 0, depth - t, skip="bn") - if glass: - sd.box("metal", uc - w / 2 - 0.02, uc + w / 2 + 0.02, 1.05, 1.1, depth - 0.07, depth + 0.02, skip="") - - -def apt_floor(v): - W, D, H = APT - m = Mod(f"apt_floor_{v}") - win = lambda c, w=1.1: (c - w / 2, c + w / 2, 0.8, 2.4) # noqa: E731 - fr = lambda c: (c - 0.5, c + 0.5, 0.0, 2.4) # noqa: E731 - if v == "a": - front = [win(W * 0.1), fr(W * 0.33), fr(W * 0.67), win(W * 0.9)] - else: - front = [win(W * 0.12), (W * 0.36 - 0.5, W * 0.36 + 0.5, 0.25, 2.4), (W * 0.64 - 0.5, W * 0.64 + 0.5, 0.25, 2.4), win(W * 0.88)] - side = [win(a + 0.55) for a, _ in spaced(D, 3, 1.1)] - back = [win(a + 0.5, 1.0) for a, _ in spaced(W, 4, 1.0)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, depth=0.16, lintel=None) - band(m, W, D, 0, 0.18, 0.05) - n = sides["n"] - if v == "a": - balcony(n, W * 0.3, w=2.8) - balcony(n, W * 0.7, w=2.8, glass=True) - else: - for h in front[1:3]: # Juliet balconies - n.box("metal", h[0] - 0.08, h[1] + 0.08, 1.0, 1.06, 0, 0.3, skip="n") - n.box("metal", h[0] - 0.08, h[1] + 0.08, 0.2, 0.26, 0, 0.3, skip="n") - for u in (h[0] - 0.08, h[1] + 0.02): - n.box("metal", u, u + 0.06, 0.2, 1.06, 0, 0.3, skip="nbt") - for u in (W * 0.22, W * 0.5, W * 0.78): - n.box("trim", u - 0.15, u + 0.15, 0.18, H, 0, 0.1, skip="nbt") - cap(m, W, D, H) - - -def stair_house(m, x, y, z, w=2.4, d=2.6, h=2.6): - m.box("wall", x - w / 2, y - d / 2, z, x + w / 2, y + d / 2, z + h, skip="b") - m.box("trim", x - w / 2 - 0.15, y - d / 2 - 0.15, z + h, x + w / 2 + 0.15, y + d / 2 + 0.15, z + h + 0.18, bevel=0.03) - sd = Side(m, "n", w, d, z) - sd.o = sd.o + Vector((x, y, 0)) - sd.quad("door", w / 2 - 0.45, w / 2 + 0.45, 0, 2.05, 0.01) - - -def apt_roof(v, wall="wall"): - W, D, _ = APT - m = Mod(f"apt_roof_{v}") - ring(m, W, D, 0, 0.9, 0.22, wall) - ring(m, W, D, 0.9, 1.02, 0.32, "trim", out=0.05, bevel=0.02) - roof_deck(m, W, D, 0.12) - if v in "ac": - x, y = W * 0.2, D * 0.12 - for lx, ly in ((-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7), (0.7, 0.7)): - m.box("dark", x + lx - 0.07, y + ly - 0.07, 0.12, x + lx + 0.07, y + ly + 0.07, 1.4) - m.box("dark", x - 0.85, y - 0.85, 1.3, x + 0.85, y + 0.85, 1.42) - m.cyl("wood", x, y, 1.42, 1.1, 1.1, 1.9, seg=14) - for z in (1.8, 2.5, 3.1): - m.cyl("dark", x, y, z, 1.13, 1.13, 0.06, seg=14) - m.cyl("roof", x, y, 3.32, 1.2, 0.08, 0.75, seg=14, smooth=False) - stair_house(m, -W * 0.22, -D * 0.12, 0.12) - ac_unit(m, -W * 0.25, D * 0.28, 0.12, 0.8) - else: - stair_house(m, W * 0.24, D * 0.2, 0.12, w=2.2, d=2.4) - # Roof garden: planters with shrubs and a pergola. - for i, (x, y) in enumerate(((-W * 0.3, -D * 0.3), (-W * 0.05, -D * 0.3), (W * 0.2, -D * 0.3))): - m.box("wood", x - 0.9, y - 0.4, 0.12, x + 0.9, y + 0.4, 0.6, bevel=0.02) - for j in range(3): - m.blob("leaf", (x - 0.55 + j * 0.55, y, 0.75), (0.38, 0.34, 0.32), sub=1, jitter=0.06) - for x in (-W * 0.35, -W * 0.05): - for y in (D * 0.05, D * 0.3): - m.box("wood", x - 0.07, y - 0.07, 0.12, x + 0.07, y + 0.07, 2.3) - for i in range(6): - y = D * 0.02 + i * 0.4 - m.box("wood", -W * 0.38, y - 0.04, 2.3, -W * 0.02, y + 0.04, 2.42) - vent(m, W * 0.3, -D * 0.25, 0.12) - - -# ---- offices ---------------------------------------------------------------- - - -def curtain(sd, H, sill=0.65, cells=None): - L = sd.L - n = cells or max(2, round(L / 1.6)) - sd.quad("spandrel", 0, L, 0, sill) - for i in range(n): - sd.quad("glass", L * i / n, L * (i + 1) / n, sill, H, cell=True) - for i in range(n + 1): - u = L * i / n - w = 0.14 if i in (0, n) else 0.07 - sd.box("metal", max(0, u - w), min(L, u + w), 0, H, 0, 0.18, skip="nbt") - sd.box("metal", 0, L, sill - 0.05, sill + 0.03, 0, 0.1, skip="n") - - -def office_ground(): - W, D, H = OFFICE - m = Mod("office_ground", ground=True) - n = Side(m, "n", W, D) - hole = (0.7, W - 0.7, 0, H - 0.45) - n.wall("stone", 0, H, [hole]) - n.opening(hole, 0.45, pane=None, reveal="stone") - k = 6 - for i in range(k): - a = hole[0] + (hole[1] - hole[0]) * i / k - b = hole[0] + (hole[1] - hole[0]) * (i + 1) / k - n.quad("lobby", a, b, 0, hole[3], -0.45) - n.box("metal", a - 0.04, a + 0.04, 0, hole[3], -0.45, -0.38, skip="n") - n.box("metal", hole[0], hole[1], 2.4, 2.46, -0.45, -0.38, skip="n") - m.cyl("railglass", 0, -D / 2 + 0.45 - 0.5, 0, 0.95, 0.95, 2.4, seg=16) - m.cyl("metal", 0, -D / 2 + 0.45 - 0.5, 2.4, 1.0, 1.0, 0.15, seg=16) - n.box("metal", W / 2 - 2.4, W / 2 + 2.4, 2.75, 2.95, 0, 2.4, skip="n", bevel=0.03) - n.box("shopfront", W / 2 - 2.2, W / 2 + 2.2, 2.7, 2.75, 0.2, 2.2) - for side in ("e", "s", "w"): - sd = Side(m, side, W, D) - holes = [(a, b, 0.4, H - 0.6) for a, b in spaced(sd.L, 4, 1.1)] - sd.wall("stone", 0, H, holes) - for h in holes: - sd.opening(h, 0.25, pane="glass", reveal="stone") - for sx in (-1, 1): - for sy in (-1, 1): - m.box("stone", sx * W / 2 - 0.45, sy * D / 2 - 0.45, 0, sx * W / 2 + 0.45, sy * D / 2 + 0.45, H, skip="b", bevel=0.03) - band(m, W, D, H - 0.3, H, 0.1, "metal") - cap(m, W, D, H) - - -def office_floor(v): - W, D, H = OFFICE - m = Mod(f"office_floor_{v}") - for side in "nesw": - sd = Side(m, side, W, D) - if v == "a": - curtain(sd, H) - else: - L = sd.L - sd.box("wall", 0, L, 0, 1.0, 0, 0.12, skip="n") - sd.quad("wall", 0, L, H - 0.12, H, 0.0) - n = 4 - for i in range(n): - sd.quad("glass", L * i / n, L * (i + 1) / n, 1.0, H - 0.12, -0.12, cell=True) - sd.m.face("wall", [sd.P(0, H - 0.12), sd.P(L, H - 0.12), sd.P(L, H - 0.12, -0.12), sd.P(0, H - 0.12, -0.12)], out=-Z) - for i in range(n + 1): - u = L * i / n - sd.box("metal", max(0, u - 0.05), min(L, u + 0.05), 1.0, H - 0.12, -0.12, 0.0, skip="nbt") - for i in range(1, 2 * n): - u = L * i / (2 * n) - sd.box("metal", u - 0.04, u + 0.04, 1.0, H, 0.0, 0.5, skip="nb") - cap(m, W, D, H) - - -def office_setback(): - W, D, _ = OFFICE - m = Mod("office_setback") - m.box("metal", -W / 2 - 0.15, -D / 2 - 0.15, 0, W / 2 + 0.15, D / 2 + 0.15, 0.5, bevel=0.05) - ring(m, W, D, 0.5, 1.4, 0.05, "railglass", out=0.05) - ring(m, W, D, 1.4, 1.47, 0.09, "metal", out=0.07) - for x, y in ((-W * 0.4, -D * 0.4), (W * 0.4, -D * 0.4), (-W * 0.4, D * 0.4), (W * 0.4, D * 0.4)): - m.box("stone", x - 0.35, y - 0.35, 0.5, x + 0.35, y + 0.35, 0.95, bevel=0.02) - m.blob("leaf", (x, y, 1.25), (0.45, 0.45, 0.5), sub=1, jitter=0.06) - - -def office_roof(v): - W, D, _ = OFFICE - m = Mod(f"office_roof_{v}") - ring(m, W, D, 0, 1.0, 0.18, "metal") - roof_deck(m, W, D, 0.1, 0.18) - if v == "a": - # Plant room with louvres, an antenna and a dish. - w, d, h = W * 0.55, D * 0.5, 2.6 - m.box("wall", -w / 2, -d / 2, 0.1, w / 2, d / 2, 0.1 + h, skip="b") - for side in "nesw": - sd = Side(m, side, w, d, 0.1) - for i in range(6): - v0 = 0.6 + i * 0.28 - sd.box("dark", 0.3, sd.L - 0.3, v0, v0 + 0.16, 0, 0.06, skip="n") - m.box("metal", -w / 2 - 0.1, -d / 2 - 0.1, 0.1 + h, w / 2 + 0.1, d / 2 + 0.1, 0.3 + h, bevel=0.03) - x = w * 0.3 - m.cyl("metal", x, 0, 0.3 + h, 0.16, 0.06, 9, seg=8) - for z in (3.0, 5.5): - m.box("metal", x - 0.5, -0.04, 0.3 + h + z, x + 0.5, 0.04, 0.38 + h + z) - m.anchor("beacon", (x, 0, 0.3 + h + 9.2)) - dish = Matrix.Translation((-W * 0.3, D * 0.3, 1.0)) @ Matrix.Rotation(-0.7, 4, "X") - m.cyl("trim", 0, 0, 0, 0.75, 0.15, 0.4, seg=12, M=dish) - m.box("metal", -W * 0.3 - 0.06, D * 0.3 - 0.06, 0.1, -W * 0.3 + 0.06, D * 0.3 + 0.06, 1.0) - ac_unit(m, -W * 0.28, -D * 0.32, 0.1, 0.8) - else: - # Helipad on a raised deck, plant room behind. - m.box("metal", -W * 0.35, -D * 0.38, 0.1, W * 0.35, D * 0.18, 0.9) - m.cyl("deck", 0, -D * 0.1, 0.9, 3.0, 3.0, 0.12, seg=24, smooth=False) - m.cyl("paint", 0, -D * 0.1, 1.02, 2.75, 2.75, 0.02, seg=24, smooth=False) - m.cyl("deck", 0, -D * 0.1, 1.04, 2.55, 2.55, 0.02, seg=24, smooth=False) - for x0, x1, y0, y1 in ((-0.9, -0.6, -1.0, 1.0), (0.6, 0.9, -1.0, 1.0), (-0.6, 0.6, -0.15, 0.15)): - m.box("paint", x0, -D * 0.1 + y0, 1.06, x1, -D * 0.1 + y1, 1.08) - m.box("wall", -W * 0.3, D * 0.22, 0.1, W * 0.3, D * 0.45, 2.4, skip="b") - m.box("metal", -W * 0.32, D * 0.2, 2.4, W * 0.32, D * 0.47, 2.55) - m.anchor("beacon", (W * 0.3, D * 0.45, 2.85)) - for x in (-W * 0.15, W * 0.12): - ac_unit(m, x, D * 0.33, 2.55, 0.7) - - -# ---- landmark --------------------------------------------------------------- - - -def lm_bays(sd, H, n, cw=0.7, pw=0.32, sill=0.95, pane="glass"): - """Art deco bays: deep windows between piers, dark spandrels below.""" - L = sd.L - bay = (L - 2 * cw) / n - holes = [(cw + i * bay + pw / 2, cw + (i + 1) * bay - pw / 2, 0.0, H - 0.35) for i in range(n)] - sd.wall("wall", 0, H, holes) - for h in holes: - sd.opening(h, 0.28, pane=None, reveal="wall") - sd.quad("spandrel", h[0], h[1], 0, sill, -0.28) - sd.quad(pane, h[0], h[1], sill, h[3], -0.28, cell=True) - for i in range(1, n): - u = cw + i * bay - sd.box("trim", u - pw / 2, u + pw / 2, 0, H, 0, 0.2, skip="nbt") - sd.box("trim", 0, cw, 0, H, 0, 0.3, skip="nbt") - sd.box("trim", L - cw, L, 0, H, 0, 0.3, skip="nbt") - - -def lm_ground(): - S, H = LANDMARK - m = Mod("lm_ground", ground=True) - n = Side(m, "n", S, S) - portal = (S / 2 - 1.6, S / 2 + 1.6, 0, H - 0.25) - n.wall("stone", 0, H, [portal]) - n.opening(portal, 0.6, pane=None, reveal="gold") - n.quad("lobby", portal[0], portal[1], 0, portal[3], -0.6) - for i in range(1, 4): - u = portal[0] + (portal[1] - portal[0]) * i / 4 - n.box("gold", u - 0.05, u + 0.05, 0, portal[3], -0.6, -0.52, skip="n") - n.box("gold", portal[0] - 0.3, portal[1] + 0.3, portal[3], portal[3] + 0.25, 0, 0.12, skip="n") - for u in (portal[0] - 0.3, portal[1]): - n.box("gold", u, u + 0.3, 0, portal[3], 0, 0.12, skip="n") - for u in (1.2, S - 1.2): - n.box("stone", u - 0.6, u + 0.6, 0, 0.45, 0, 0.3, skip="n", bevel=0.03) - m.cyl("dark", 0, 0, 0, 0.05, 0.05, 1.0, seg=6, M=Matrix.Translation(n.P(u, 0.45, 0.15))) - n.box("shopfront", u - 0.18, u + 0.18, 1.45, 1.8, 0.0, 0.3) - for side in ("e", "s", "w"): - lm_bays(Side(m, side, S, S), H, 5, pane="glass") - for side in "nesw": - sd = Side(m, side, S, S) - sd.box("stone", 0, sd.L, 0, 1.0, 0, 0.12, skip="n") - band(m, S, S, H - 0.3, H, 0.18, "stone") - cap(m, S, S, H) - - -def lm_floor(t): - S0, H = LANDMARK - S = S0 * LM_TIERS[t - 1] - m = Mod(f"lm_floor_{t}") - for side in "nesw": - lm_bays(Side(m, side, S, S), H, (5, 4, 3)[t - 1]) - cap(m, S, S, H) - - -def finial(m, x, y, z, h=1.3, r=0.25): - m.box("gold", x - r, y - r, z, x + r, y + r, z + 0.25) - m.cyl("gold", x, y, z + 0.25, r * 0.9, 0.0, h, seg=4, smooth=False, M=Matrix.Translation((x, y, 0)) @ Matrix.Rotation(math.pi / 4, 4, "Z") @ Matrix.Translation((-x, -y, 0))) - - -def lm_ledge(t): - S0, _ = LANDMARK - S = S0 * LM_TIERS[t - 1] - m = Mod(f"lm_ledge_{t}") - band(m, S, S, -0.1, 0.35, 0.28, "trim", bevel=0.05) - band(m, S, S, 0.35, 0.55, 0.14, "gold", bevel=0.02) - ring(m, S, S, 0.55, 1.1, 0.3, "wall") - ring(m, S, S, 1.1, 1.22, 0.4, "trim", out=0.05, bevel=0.02) - roof_deck(m, S, S, 0.65) - for sx in (-1, 1): - for sy in (-1, 1): - finial(m, sx * (S / 2 - 0.15), sy * (S / 2 - 0.15), 1.22) - - -def lm_crown(): - S0, _ = LANDMARK - S = S0 * LM_TIERS[2] - m = Mod("lm_crown") - band(m, S, S, -0.1, 0.35, 0.25, "trim", bevel=0.05) - band(m, S, S, 0.35, 0.55, 0.12, "gold", bevel=0.02) - # Lantern storey: tall lit windows framed in gold. - a = S * 0.86 - for side in "nesw": - sd = Side(m, side, a, a, 0.55) - holes = [(u0, u1, 0.4, 2.9) for u0, u1 in spaced(a, 3, 0.8)] - sd.wall("wall", 0, 3.3, holes) - for h in holes: - sd.opening(h, 0.2, pane="crownlight", reveal="gold") - c = (h[0] + h[1]) / 2 - tri = [sd.P(h[0] - 0.1, h[3], 0.02), sd.P(h[1] + 0.1, h[3], 0.02), sd.P(c, h[3] + 0.32, 0.02)] - m.face("gold", tri, out=sd.n) - band(m, a, a, 3.85, 4.1, 0.12, "gold", bevel=0.02) - for sx in (-1, 1): - for sy in (-1, 1): - finial(m, sx * (a / 2 - 0.05), sy * (a / 2 - 0.05), 4.1, h=1.0, r=0.2) - # Stepped crown with sunburst chevrons. - b = a * 0.74 - m.box("wall", -b / 2, -b / 2, 4.1, b / 2, b / 2, 6.6, skip="b") - for side in "nesw": - sd = Side(m, side, b, b, 4.1) - c = b / 2 - for i, (w, h, role) in enumerate(((b * 0.48, 2.3, "gold"), (b * 0.34, 1.75, "board"), (b * 0.22, 1.2, "gold"), (b * 0.1, 0.7, "crownlight"))): - k = 0.02 + i * 0.015 - m.face(role, [sd.P(c - w, 0.05, k), sd.P(c + w, 0.05, k), sd.P(c, h + 0.05, k)], out=sd.n) - band(m, b, b, 6.6, 6.8, 0.1, "gold", bevel=0.02) - c3 = b * 0.66 - m.box("gold", -c3 / 2, -c3 / 2, 6.8, c3 / 2, c3 / 2, 8.2, skip="b", bevel=0.04) - rot = Matrix.Rotation(math.pi / 4, 4, "Z") - m.cyl("gold", 0, 0, 8.2, c3 * 0.62, 0.12, 6.5, seg=8, smooth=False, M=rot) - m.cyl("metal", 0, 0, 14.5, 0.14, 0.04, 9.0, seg=8) - m.blob("gold", (0, 0, 14.4), (0.3, 0.3, 0.3), sub=1) - m.anchor("beacon", (0, 0, 23.7)) - - -# ---- more styles -------------------------------------------------------------- - - -def cottage_body_d(): - """Stone-based cottage with a bay window.""" - W, D, H = COTTAGE - m = Mod("cottage_body_d", ground=True) - m.box("stone", -W / 2 - 0.06, -D / 2 - 0.06, 0, W / 2 + 0.06, D / 2 + 0.06, 0.5, skip="b", bevel=0.02) - win = lambda c, w=1.0: (c - w / 2, c + w / 2, 1.05, 2.4) # noqa: E731 - dc = W * 0.76 - front = [(dc - 0.5, dc + 0.5, 0.5, 2.6, "door")] - side = [win(D * 0.3), win(D * 0.7)] - back = [win(W * 0.2), win(W * 0.5), win(W * 0.8)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, depth=0.14, reveal="frame", lintel=None) - # The bay: three glazed faces on a stone base, its own little roof. - bx, bw, bd = -W / 2 + W * 0.32, 2.3, 0.8 - at = (bx, -D / 2 - bd / 2) - m.box("stone", bx - bw / 2 - 0.05, -D / 2 - bd - 0.05, 0, bx + bw / 2 + 0.05, -D / 2, 0.55, skip="bs", bevel=0.02) - punched(m, bw, bd, 0.55, 2.2, {"n": [(0.2, bw - 0.2, 0.25, 1.9)], "e": [(0.15, bd - 0.15, 0.25, 1.9)], "w": [(0.15, bd - 0.15, 0.25, 1.9)]}, - depth=0.08, reveal="frame", lintel=None, sill=None, at=at) - m.box("trim", bx - bw / 2 - 0.12, -D / 2 - bd - 0.12, 2.75, bx + bw / 2 + 0.12, -D / 2, 2.87, bevel=0.02) - m.hexa("roof", lambda a, b, c: (bx + (a - 0.5) * (bw + 0.2 - c * 0.5 * b * 0), (-D / 2 - bd - 0.15, -D / 2)[b], 2.87 + c * 0.12 + b * 0.35)) - n = sides["n"] - n.box("stone", dc - 0.75, dc + 0.75, 0, 0.25, 0, 0.95, skip="n") - n.box("stone", dc - 0.75, dc + 0.75, 0.25, 0.5, 0, 0.5, skip="n") - n.box("trim", dc - 0.85, dc + 0.85, 2.82, 2.94, 0, 0.75, skip="n", bevel=0.02) - m.cyl("shopfront", 0, 0, 0, 0.09, 0.09, 0.25, seg=8, M=Matrix.Translation(n.P(dc + 0.75, 2.1, 0.12))) - band(m, W, D, H - 0.34, H - 0.12, 0.06, "trim", bevel=0.0) - cap(m, W, D, H) - - -def cottage_roof_d(): - """A gambrel roof with two dormers on the front.""" - W, D, _ = COTTAGE - m = Mod("cottage_roof_d") - rise, oe, og, t = 2.7, 0.35, 0.3, 0.16 - hd = D / 2 - ky, kz = 0.45 * hd, 0.72 * rise - s1 = kz / (hd - ky) - for s in (-1, 1): - for (y0, z0), (y1, z1) in (((hd + oe, -oe * s1), (ky, kz)), ((ky, kz), (0, rise))): - Y0, Y1 = s * y0, s * y1 - n = Vector((0, -(z1 - z0), Y1 - Y0)).normalized() - if n.z < 0: - n = -n - - def corner(a, b, c, Y0=Y0, Y1=Y1, z0=z0, z1=z1, n=n): - p = Vector(((-W / 2 - og, W / 2 + og)[a], (Y0, Y1)[b], (z0, z1)[b])) - return p - n * t if c == 0 else p - - m.hexa("roof", corner) - for x in (-W / 2, W / 2): - m.face("wall", [(x, -hd, 0), (x, -ky, kz), (x, 0, rise - 0.1), (x, ky, kz), (x, hd, 0)], out=(x, 0, 0)) - m.box("trim", -W / 2 - og - 0.02, -0.16, rise - 0.05, W / 2 + og + 0.02, 0.16, rise + 0.1) - for x in (-W * 0.22, W * 0.22): - yf = -hd * 0.78 - m.box("wall", x - 0.6, yf, 0.2, x + 0.6, -hd * 0.25, 1.55, skip="b") - m.box("frame", x - 0.42, yf - 0.05, 0.62, x + 0.42, yf, 1.4) - m.face("window", [(x - 0.32, yf - 0.06, 0.72), (x + 0.32, yf - 0.06, 0.72), (x + 0.32, yf - 0.06, 1.3), (x - 0.32, yf - 0.06, 1.3)], out=(0, -1, 0), cell=True) - m.face("wall", [(x - 0.6, yf, 1.55), (x + 0.6, yf, 1.55), (x, yf, 1.95)], out=(0, -1, 0)) - for sx in (-1, 1): - m.hexa("roof", lambda a, b, c, sx=sx, x=x, yf=yf: (x + sx * 0.78 * (1 - b), (yf - 0.18, -hd * 0.2)[a], 1.45 + 0.55 * b - 0.1 * (1 - c))) - chimney(m, W * 0.3, hd * 0.25, 3.1) - - -def fanlight(m, sd, u0, u1, v, ry): - c, rx = (u0 + u1) / 2, (u1 - u0) / 2 - for role, grow, k in (("trim", 0.12, 0.02), ("shopfront", 0.0, 0.04)): - pts = [sd.P(c + (rx + grow) * math.cos(math.pi * i / 10), v + (ry + grow) * math.sin(math.pi * i / 10), k) for i in range(11)] - m.face(role, pts, out=sd.n) - - -def umbrella_table(m, sd, u, k, role): - p = sd.P(u, 0, k) - m.cyl("metal", p.x, p.y, 0, 0.04, 0.04, 0.72, seg=6) - m.cyl("trim", p.x, p.y, 0.72, 0.42, 0.42, 0.04, seg=12, smooth=False) - m.cyl("metal", p.x, p.y, 0.76, 0.03, 0.03, 1.5, seg=6) - m.cyl(role, p.x, p.y, 2.0, 1.05, 0.05, 0.42, seg=8, smooth=False) - for du in (-0.6, 0.6): - q = sd.P(u + du, 0, k) - m.box("wood", q.x - 0.18, q.y - 0.18, 0.42, q.x + 0.18, q.y + 0.18, 0.47) - for lx in (-0.14, 0.14): - m.box("metal", q.x + lx - 0.02, q.y - 0.02, 0, q.x + lx + 0.02, q.y + 0.02, 0.42) - - -def shop_ground_c(): - """A corner café: arched windows and tables out front.""" - W, D, H = SHOP - m = Mod("shop_ground_c", ground=True) - n = Side(m, "n", W, D) - dc = W / 2 - holes = [(0.7, dc - 1.0, 0.45, 2.2, "shopfront"), (dc - 0.6, dc + 0.6, 0, 2.2, "door"), (dc + 1.0, W - 0.7, 0.45, 2.2, "shopfront")] - n.wall("wall", 0, H, holes) - for h in holes: - n.opening(h, 0.25, pane=h[4], reveal="trim") - fanlight(m, n, h[0], h[1], 2.2, 0.5) - if h[4] == "shopfront": - n.box("wood", h[0], h[1], 0.75, 0.8, -0.25, -0.2, skip="n") - n.box("board", 0.5, W - 0.5, 2.92, 3.32, 0, 0.08, skip="n") - lettering(m, n, W / 2, 3.12, 0.08, 5, px=0.065) - for u0, u1 in ((0, 0.4), (W - 0.4, W)): - n.box("trim", u0, u1, 0, H, 0, 0.14, skip="n", bevel=0.02) - for i, u in enumerate((W * 0.16, W * 0.35, W * 0.65, W * 0.84)): - umbrella_table(m, n, u, 1.25, "accent" if i % 2 == 0 else "canvas") - for u in (0.35, W - 0.35): - p = n.P(u, 0, 0.45) - m.box("wood", p.x - 0.3, p.y - 0.3, 0, p.x + 0.3, p.y + 0.3, 0.5, bevel=0.02) - m.blob("leaf", (p.x, p.y, 0.85), (0.38, 0.38, 0.45), sub=2, jitter=0.05) - punched( - m, W, D, 0, H, - {"e": [(D * 0.6 - 0.5, D * 0.6 + 0.5, 1.4, 2.5)], "s": [(W * 0.3 - 0.5, W * 0.3 + 0.5, 0, 2.2, "door"), (W * 0.7 - 0.6, W * 0.7 + 0.6, 1.2, 2.5)], "w": [(D * 0.4 - 0.5, D * 0.4 + 0.5, 1.4, 2.5)]}, - depth=0.15, - ) - m.box("stone", -W / 2 - 0.04, -D / 2 + 0.01, 0, W / 2 + 0.04, D / 2 + 0.04, 0.35, skip="bn") - cap(m, W, D, H) - - -def shop_floor_c(): - """Upper storey with an oriel window.""" - W, D, H = SHOP - m = Mod("shop_floor_c") - front = [(a, b, 0.75, 2.65) for a, b in ((W * 0.13 - 0.55, W * 0.13 + 0.55), (W * 0.87 - 0.55, W * 0.87 + 0.55))] - side = [(a, b, 0.8, 2.6) for a, b in spaced(D, 2, 1.1)] - back = [(a, b, 0.8, 2.6) for a, b in spaced(W, 3, 1.0)] - punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, depth=0.2) - band(m, W, D, 0, 0.2, 0.08) - bw, bd = 3.0, 0.75 - at = (0, -D / 2 - bd / 2) - punched(m, bw, bd, 0.45, 2.5, {"n": [(0.3, 1.4, 0.3, 2.1), (1.6, 2.7, 0.3, 2.1)], "e": [(0.15, bd - 0.15, 0.3, 2.1)], "w": [(0.15, bd - 0.15, 0.3, 2.1)]}, - depth=0.1, sill=None, lintel=None, at=at) - m.hexa("trim", lambda a, b, c: ((a - 0.5) * (bw + 0.1 - (1 - c) * 0.6), (-D / 2 - bd - 0.05 + (1 - c) * 0.45, -D / 2)[b], 0.15 + c * 0.3)) - m.box("trim", -bw / 2 - 0.12, -D / 2 - bd - 0.12, 2.95, bw / 2 + 0.12, -D / 2, 3.1, bevel=0.03) - cap(m, W, D, H) - - -def apt_floor_c(): - """Brick walk-up storey with a fire escape.""" - W, D, H = APT - m = Mod("apt_floor_c") - win = lambda c, w=1.0: (c - w / 2, c + w / 2, 0.75, 2.4) # noqa: E731 - front = [win(W * f) for f in (0.13, 0.37, 0.63, 0.87)] - side = [win(a + 0.5) for a, _ in spaced(D, 3, 1.0)] - back = [win(a + 0.5) for a, _ in spaced(W, 4, 1.0)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": back, "w": side}, role="brick", depth=0.18) - n = sides["n"] - u0, u1, k1 = W * 0.24, W * 0.76, 1.15 - n.box("grate", u0, u1, 0.02, 0.1, 0, k1, skip="n") - n.box("dark", u0, u1, 1.0, 1.05, k1 - 0.05, k1, skip="n") - for u in (u0, u1 - 0.05): - n.box("dark", u, u + 0.05, 1.0, 1.05, 0, k1, skip="n") - for u in (u0, (u0 + u1) / 2, u1 - 0.04): - n.box("dark", u, u + 0.04, 0.1, 1.0, k1 - 0.04, k1, skip="nbt") - n.m.hexa("grate", lambda a, b, c: n.P((W * 0.3, W * 0.68)[a], (0.1, H)[a] + c * 0.08, (0.25, 0.85)[b])) - for k in (0.25, 0.85): - m.strut("dark", n.P(W * 0.3, 0.9, k), n.P(W * 0.68, H + 0.8, k), 0.04) - cap(m, W, D, H) - - -def office_floor_c(): - """Concrete storey with deep punched windows.""" - W, D, H = OFFICE - m = Mod("office_floor_c") - for side in "nesw": - sd = Side(m, side, W, D) - holes = [(a, b, 0.75, H - 0.55) for a, b in spaced(sd.L, 5, 1.2)] - sd.wall("wall", 0, H, holes) - for h in holes: - sd.opening(h, 0.5, pane="glass", reveal="wall") - sd.box("wall", 0, sd.L, H - 0.3, H, 0, 0.25, skip="n") - cap(m, W, D, H) - - -def lm2_ground(): - S, H = LANDMARK - m = Mod("lm2_ground", ground=True) - inset = 0.7 - for side in "nesw": - sd = Side(m, side, S - 2 * inset, S - 2 * inset) - L, n = sd.L, 6 - for i in range(n): - sd.quad("lobby", L * i / n, L * (i + 1) / n, 0, H - 0.4) - for i in range(n + 1): - sd.box("metal", max(0, L * i / n - 0.05), min(L, L * i / n + 0.05), 0, H - 0.4, 0, 0.1, skip="nbt") - for sx in (-1, 1): - for sy in (-1, 1): - x, y = sx * (S / 2 - 0.35), sy * (S / 2 - 0.35) - m.box("metal", x - 0.35, y - 0.35, 0, x + 0.35, y + 0.35, H, skip="b", bevel=0.04) - m.box("metal", -S / 2, -S / 2, H - 0.45, S / 2, S / 2, H, bevel=0.03) - Side(m, "n", S, S).box("metal", S / 2 - 3, S / 2 + 3, 2.6, 2.8, 0, 2.6, skip="n", bevel=0.03) - Side(m, "n", S, S).box("led", S / 2 - 2.8, S / 2 + 2.8, 2.56, 2.6, 0.2, 2.4) - m.box("deck", -S / 2 + inset, -S / 2 + inset, H - 0.5, S / 2 - inset, S / 2 - inset, H - 0.45, skip="t") - - -def lm2_floor(t): - S0, H = LANDMARK - S = S0 * LM_TIERS[t - 1] - m = Mod(f"lm2_floor_{t}") - n = (6, 5, 4)[t - 1] - for side in "nesw": - sd = Side(m, side, S, S) - L = sd.L - sd.quad("spandrel", 0, L, 0, 0.45) - for i in range(n): - sd.quad("glass", L * i / n, L * (i + 1) / n, 0.45, H, cell=True) - for i in range(1, n): - u = L * i / n - sd.box("metal", u - 0.04, u + 0.04, 0, H, 0, 0.42, skip="nbt") - sd.box("metal", 0, 0.2, 0, H, 0, 0.2, skip="nbt") - sd.box("metal", L - 0.2, L, 0, H, 0, 0.2, skip="nbt") - band(m, S, S, 0, 0.12, 0.06, "metal", bevel=0.0) - cap(m, S, S, H) - - -def lm2_ledge(t): - S0, _ = LANDMARK - S = S0 * LM_TIERS[t - 1] - m = Mod(f"lm2_ledge_{t}") - band(m, S, S, -0.1, 0.35, 0.15, "metal", bevel=0.03) - ring(m, S, S, 0.35, 1.3, 0.05, "railglass", out=0.1) - ring(m, S, S, 1.3, 1.36, 0.09, "metal", out=0.12) - roof_deck(m, S, S, 0.45, 0.05) - for x, y in ((-1, -1), (1, -1), (-1, 1), (1, 1), (0, -1), (0, 1), (-1, 0), (1, 0)): - px, py = x * (S / 2 - 0.6), y * (S / 2 - 0.6) - m.box("concrete", px - 0.32, py - 0.32, 0.45, px + 0.32, py + 0.32, 0.85, bevel=0.02) - m.cyl("bark", px, py, 0.85, 0.06, 0.04, 0.6, seg=5) - m.blob("leaf", (px, py, 1.65), (0.5, 0.5, 0.6), sub=2, jitter=0.07) - - -def lm2_crown(): - S0, _ = LANDMARK - S = S0 * LM_TIERS[2] - m = Mod("lm2_crown") - band(m, S, S, -0.1, 0.3, 0.12, "metal", bevel=0.03) - m.box("led", -S / 2 - 0.03, -S / 2 - 0.03, 0.3, S / 2 + 0.03, S / 2 + 0.03, 0.55) - a, b, h = S, S * 0.32, 7.5 - m.hexa("railglass", lambda i, j, k: ((i - 0.5) * (a, b)[k], (j - 0.5) * (a, b)[k], 0.55 + k * h), skip="b") - for sx in (-1, 1): - for sy in (-1, 1): - m.strut("metal", (sx * a / 2, sy * a / 2, 0.55), (sx * b / 2, sy * b / 2, 0.55 + h), 0.14) - for z in (2.5, 4.6): - k = (z - 0.55) / h - w = a + (b - a) * k + 0.06 - m.box("led", -w / 2, -w / 2, z, w / 2, w / 2, z + 0.12) - m.box("metal", -b / 2 - 0.1, -b / 2 - 0.1, 0.55 + h, b / 2 + 0.1, b / 2 + 0.1, 0.85 + h, bevel=0.03) - m.cyl("metal", 0, 0, 0.85 + h, 0.22, 0.04, 11.0, seg=8) - m.cyl("led", 0, 0, 3.5 + h, 0.2, 0.2, 0.25, seg=8) - m.anchor("beacon", (0, 0, 0.85 + h + 11.3)) - - -# ---- skyline styles ----------------------------------------------------------- -# Building types picked per building by `style` in plan.ts: a modern house and -# an A-frame cabin (grade 0), a diner and a brick factory (grade 1), a row of -# brownstone townhouses (grade 2), a round glass office tower (grade 3), and a -# twisting supertall and a TV tower (grade 4). Park furniture follows. - -FACTORY_H = 5.0 -TAU = 2 * math.pi - - -def around(r, a, z): - return (r * math.cos(a), r * math.sin(a), z) - - -def ringwall(m, role, r0, r1, z0, z1, seg, cell=False, a0=0.0): - """Outward faces of a (tapered) cylinder, one quad per segment.""" - for i in range(seg): - a, b = a0 + TAU * i / seg, a0 + TAU * (i + 1) / seg - mid = (a + b) / 2 - m.face(role, [around(r0, a, z0), around(r0, b, z0), around(r1, b, z1), around(r1, a, z1)], - out=(math.cos(mid), math.sin(mid), (r0 - r1) / max(z1 - z0, 1e-6)), cell=cell) - - -def disk(m, role, r, z, seg, up=True): - m.face(role, [around(r, TAU * i / seg, z) for i in range(seg)], out=Z if up else -Z) - - -def annulus(m, role, r0, r1, z, seg, up=True): - for i in range(seg): - a, b = TAU * i / seg, TAU * (i + 1) / seg - m.face(role, [around(r0, a, z), around(r0, b, z), around(r1, b, z), around(r1, a, z)], out=Z if up else -Z) - - -def mullions(m, role, r, z0, z1, seg, t=0.08, out=0.06, a0=0.0, every=1): - for i in range(0, seg, every): - a = a0 + TAU * i / seg - c = Matrix.Translation(around(r + out / 2, a, (z0 + z1) / 2)) @ Matrix.Rotation(a, 4, "Z") - m.mbox(role, c, out + 0.04, t, z1 - z0) - - -def octagon(a, c): - """A square of half-size a with corners cut by c, counter-clockwise.""" - return [(a - c, -a), (a, -a + c), (a, a - c), (a - c, a), (-a + c, a), (-a, a - c), (-a, -a + c), (-a + c, -a)] - - -def prism_sides(m, role, pts, z0, z1, cells=0, grow=0.0): - """Side faces of a prism on a convex outline; cells > 0 splits each face into panes.""" - n = len(pts) - for i in range(n): - (x0, y0), (x1, y1) = pts[i], pts[(i + 1) % n] - nx, ny = y1 - y0, -(x1 - x0) - ln = math.hypot(nx, ny) - nx, ny = nx / ln, ny / ln - k = max(1, round(math.hypot(x1 - x0, y1 - y0) / cells)) if cells else 1 - for j in range(k): - ta, tb = j / k, (j + 1) / k - pa = (x0 + (x1 - x0) * ta + nx * grow, y0 + (y1 - y0) * ta + ny * grow) - pb = (x0 + (x1 - x0) * tb + nx * grow, y0 + (y1 - y0) * tb + ny * grow) - m.face(role, [(*pa, z0), (*pb, z0), (*pb, z1), (*pa, z1)], out=(nx, ny, 0), cell=bool(cells) and role in CELL_ROLES) - - -def prism(m, role, pts, z0, z1, grow=0.0, top=True, bottom=False): - prism_sides(m, role, pts, z0, z1, grow=grow) - g = [(x * (1 + grow / max(abs(x), abs(y), 1e-6)), y * (1 + grow / max(abs(x), abs(y), 1e-6))) for x, y in pts] - if top: - m.face(role, [(x, y, z1) for x, y in g], out=Z) - if bottom: - m.face(role, [(x, y, z0) for x, y in g], out=-Z) - - -# Grade 0: a modern house with a garage, and an A-frame cabin. - - -def cottage_body_e(): - W, D, H = COTTAGE - m = Mod("cottage_body_e", ground=True) - m.box("concrete", -W / 2 - 0.05, -D / 2 - 0.05, 0, W / 2 + 0.05, D / 2 + 0.05, 0.3, skip="b") - g0 = W * 0.64 - front = [(0.35, 2.9, 0.35, 2.9, "glass"), (3.25, 4.15, 0.3, 2.6, "door"), (g0, W - 0.3, 0.3, 2.55, "metal")] - sides = punched( - m, W, D, 0, H, - {"n": front, "e": [(D * 0.55, D - 0.5, 1.2, 2.4)], "s": [(0.4, W * 0.55, 0.3, 2.9, "glass"), (W * 0.7, W - 0.6, 1.3, 2.5)], "w": [(0.6, D * 0.45, 0.35, 2.9, "glass")]}, - depth=0.12, reveal="dark", sill=None, lintel=None, - ) - n = sides["n"] - # Garage door panels and a timber screen in front of the big window. - for i in range(1, 5): - v = 0.3 + 2.25 * i / 5 - n.box("dark", g0, W - 0.3, v - 0.03, v + 0.02, -0.12, -0.1, skip="n") - for i in range(8): - u = 0.45 + i * 0.3 - n.box("wood", u, u + 0.08, 0.35, 2.9, 0.25, 0.33) - n.box("wood", 0.35, 2.9, 2.9, 3.0, 0.0, 0.34, skip="n") - n.box("wood", 3.0, 4.4, 0.3, H, 0.0, 0.06, skip="nbt") - n.box("concrete", 3.0, 4.4, 0.0, 0.3, 0.0, 1.1, skip="n") - n.box("concrete", g0 - 0.1, W - 0.2, 0.0, 0.08, 0.0, 2.4, skip="n") - m.cyl("shopfront", 0, 0, 0, 0.07, 0.07, 0.3, seg=8, M=Matrix.Translation(n.P(4.3, 2.3, 0.1))) - cap(m, W, D, H) - - -def cottage_roof_e(): - W, D, _ = COTTAGE - m = Mod("cottage_roof_e") - # Thin white slab cantilevered over the entrance; a timber-clad box on top. - m.box("paint", -W / 2 - 0.25, -D / 2 - 1.3, 0, W / 2 + 0.25, D / 2 + 0.25, 0.32, bevel=0.02) - w, d = W * 0.58, D * 0.72 - x0 = -W / 2 + 0.2 - m.box("wood", x0, -D / 2 + 0.3, 0.32, x0 + w, -D / 2 + 0.3 + d, 2.9, skip="b") - sd = Side(m, "n", w, d, 0.32, at=(x0 + w / 2, -D / 2 + 0.3 + d / 2)) - sd.quad("glass", 0.3, w - 0.3, 0.7, 2.3, 0.01, cell=True) - sd.box("dark", 0.25, w - 0.25, 0.62, 0.7, 0, 0.06) - sd = Side(m, "e", w, d, 0.32, at=(x0 + w / 2, -D / 2 + 0.3 + d / 2)) - sd.quad("glass", 0.4, d * 0.55, 0.7, 2.3, 0.01, cell=True) - m.box("paint", x0 - 0.15, -D / 2 + 0.15, 2.9, x0 + w + 0.15, -D / 2 + 0.45 + d, 3.12, bevel=0.02) - # Solar panels on the lower roof. - for i in range(3): - x = W * 0.12 + i * 0.95 - M = Matrix.Translation((x, D * 0.1, 0.62)) @ Matrix.Rotation(0.45, 4, "X") - m.mbox("spandrel", M, 0.85, 1.6, 0.05) - m.box("metal", x - 0.04, D * 0.1 + 0.4, 0.32, x + 0.04, D * 0.1 + 0.48, 0.85) - ring(m, W * 0.42, D, 0.32, 0.5, 0.06, "paint", out=0.0) - return m - - -def cottage_body_f(): - W, D, H = COTTAGE - m = Mod("cottage_body_f", ground=True) - m.box("stone", -W / 2 - 0.05, -D / 2 - 0.05, 0, W / 2 + 0.05, D / 2 + 0.05, 0.45, skip="b", bevel=0.02) - dc = W / 2 - front = [(0.5, dc - 0.7, 0.45, 2.9, "glass"), (dc - 0.55, dc + 0.55, 0.45, 2.7, "door"), (dc + 0.7, W - 0.5, 0.45, 2.9, "glass")] - side = [(D * 0.3 - 0.5, D * 0.3 + 0.5, 1.1, 2.2), (D * 0.7 - 0.5, D * 0.7 + 0.5, 1.1, 2.2)] - sides = punched(m, W, D, 0, H, {"n": front, "e": side, "s": [(W / 2 - 0.5, W / 2 + 0.5, 1.1, 2.3)], "w": side}, role="wood", depth=0.1, reveal="frame", lintel=None) - # Log courses. - for sd in sides.values(): - for i in range(1, 9): - v = 0.45 + i * 0.32 - sd.box("bark", 0, sd.L, v - 0.03, v, 0, 0.04, skip="n") - # A deck across the front with a rail. - n = sides["n"] - n.box("wood", -0.3, W + 0.3, 0.3, 0.45, 0, 1.6, skip="n") - for u in (-0.2, W / 4, W * 3 / 4, W + 0.2): - n.box("wood", u - 0.06, u + 0.06, 0.45, 1.4, 1.42, 1.54) - n.box("wood", -0.25, dc - 0.8, 1.3, 1.4, 1.42, 1.54) - n.box("wood", dc + 0.8, W + 0.25, 1.3, 1.4, 1.42, 1.54) - n.box("wood", dc - 0.7, dc + 0.7, 0.0, 0.15, 1.6, 2.2) - cap(m, W, D, H) - - -def cottage_roof_f(): - """A steep A-frame whose eaves come down to the deck, glazed gable.""" - W, D, _ = COTTAGE - m = Mod("cottage_roof_f") - rise = 4.6 - gable(m, W, D, rise, oe=1.25, og=0.55, t=0.22, axis="y", wall="wood") - sd = Side(m, "n", W, D) - c = W / 2 - tri = lambda k, s: [sd.P(c - s * W / 2, 0.15, k), sd.P(c + s * W / 2, 0.15, k), sd.P(c, 0.15 + s * (rise - 0.4), k)] # noqa: E731 - m.face("glass", tri(0.03, 0.78), out=sd.n, cell=True) - for u in (c - 0.9, c, c + 0.9): - h = (rise - 0.4) * (1 - abs(u - c) / (W / 2 * 0.78)) + 0.15 - sd.box("frame", u - 0.05, u + 0.05, 0.15, h, 0.03, 0.09) - sd.box("frame", c - W / 2 * 0.62, c + W / 2 * 0.62, 1.3, 1.38, 0.03, 0.09) - m.cyl("dark", W * 0.18, D * 0.2, 0.8, 0.14, 0.14, 3.6, seg=8) - m.cyl("dark", W * 0.18, D * 0.2, 4.4, 0.24, 0.05, 0.25, seg=8) - m.anchor("smoke", (W * 0.18, D * 0.2, 4.9)) - return m - - -# Grade 1: a diner and a brick factory. - - -def shop_ground_d(): - W, D, H = SHOP - m = Mod("shop_ground_d", ground=True) - dc = W * 0.3 - front = [(0.3, dc - 0.7, 0.95, 2.45, "shopfront"), (dc - 0.6, dc + 0.6, 0.0, 2.45, "door"), (dc + 0.7, W - 0.3, 0.95, 2.45, "shopfront")] - sides = punched( - m, W, D, 0, H, - {"n": front, "e": [(0.3, D * 0.62, 0.95, 2.45, "shopfront")], "s": [(W * 0.7 - 0.5, W * 0.7 + 0.5, 0, 2.2, "door"), (W * 0.3 - 0.6, W * 0.3 + 0.6, 1.3, 2.3)], "w": [(D * 0.5 - 0.5, D * 0.5 + 0.5, 1.3, 2.3)]}, - depth=0.12, reveal="metal", sill=None, lintel=None, - ) - for side, sd in sides.items(): - L = sd.L - sd.box("accent", 0, L, 0.0, 0.95, 0, 0.05, skip="n") - sd.box("metal", 0, L, 0.88, 0.98, 0, 0.09, skip="n", bevel=0.02) - sd.box("metal", 0, L, 2.45, 2.55, 0, 0.09, skip="n", bevel=0.02) - sd.box("accent", 0, L, 2.8, 3.05, 0, 0.06, skip="n") - sd.box("metal", 0, L, 3.05, 3.12, 0, 0.08, skip="n") - n = sides["n"] - for h in front: - if h[4] == "shopfront": - k = max(1, round((h[1] - h[0]) / 1.0)) - for i in range(1, k): - u = h[0] + (h[1] - h[0]) * i / k - n.box("metal", u - 0.03, u + 0.03, h[2], h[3], -0.12, -0.08, skip="n") - # Entrance canopy and a pylon sign on the corner. - n.box("metal", dc - 1.1, dc + 1.1, 2.6, 2.75, 0, 1.4, bevel=0.03) - n.box("sign", dc - 1.05, dc + 1.05, 2.56, 2.6, 0.1, 1.3) - px, py = W / 2 + 0.6, -D / 2 - 0.8 - m.cyl("metal", px, py, 0, 0.12, 0.12, 5.6, seg=8) - m.box("board", px - 0.15, py - 0.85, 4.0, px + 0.15, py + 0.85, 6.6, bevel=0.05) - for s in (-1, 1): - sd = Side(m, "e" if s > 0 else "w", 0.3, 1.7, 4.0, at=(px, py)) - lettering(m, sd, 0.85, 1.75, 0.0, 3, px=0.09) - sd.box("sign", 0.1, 1.6, 0.15, 0.25, 0.0, 0.04) - sd.box("sign", 0.1, 1.6, 2.4, 2.5, 0.0, 0.04) - m.box("concrete", -W / 2 - 0.05, -D / 2 - 0.05, 0, W / 2 + 0.05, D / 2 + 0.05, 0.12, skip="b") - cap(m, W, D, H) - - -def shop_roof_d(): - W, D, _ = SHOP - m = Mod("shop_roof_d") - band(m, W, D, 0, 0.25, 0.1, "metal") - ring(m, W, D, 0.25, 0.7, 0.15, "accent") - roof_deck(m, W, D, 0.2, 0.15) - # A billboard on legs facing the street. - sd = Side(m, "n", W, D, 0.2) - for u in (W * 0.25, W * 0.75): - sd.box("metal", u - 0.07, u + 0.07, 0.0, 1.4, -1.6, -1.45) - m.strut("metal", sd.P(u, 0.0, -2.6), sd.P(u, 1.4, -1.55), 0.08) - sd.box("board", W * 0.12, W * 0.88, 1.4, 3.0, -1.6, -1.4, bevel=0.03) - sd.box("accent", W * 0.12, W * 0.88, 1.4, 1.6, -1.4, -1.36) - lettering(m, sd, W / 2, 2.35, -1.4, 6, px=0.1) - ac_unit(m, -W * 0.25, D * 0.25, 0.2) - vent(m, W * 0.3, D * 0.3, 0.2, h=1.2) - return m - - -def factory_ground(): - W, D, _ = SHOP - H = FACTORY_H - m = Mod("factory_ground", ground=True) - tall = lambda c, w=1.3: (c - w / 2, c + w / 2, 1.3, 4.2) # noqa: E731 - front = [(0.5, 3.5, 1.0, 4.0, "metal"), (4.1, 5.0, 0, 2.3, "door"), tall(6.2), tall(7.8, 1.1)] - side = [tall(a + 0.65) for a, _ in spaced(D, 3, 1.3)] - back = [tall(a + 0.65) for a, _ in spaced(W, 4, 1.3)] - layout = {"n": front, "e": side, "s": back, "w": side} - sides = punched(m, W, D, 0, H, layout, role="brick", depth=0.22, reveal="brick", sill="stone", lintel="stone") - for key, sd in sides.items(): - for h in layout[key]: - p = h[4] if len(h) > 4 else "window" - if p != "window": - continue - for i in range(1, 3): - u = h[0] + (h[1] - h[0]) * i / 3 - sd.box("dark", u - 0.03, u + 0.03, h[2], h[3], -0.22, -0.17, skip="n") - for i in range(1, 4): - v = h[2] + (h[3] - h[2]) * i / 4 - sd.box("dark", h[0], h[1], v - 0.03, v + 0.03, -0.22, -0.17, skip="n") - sd.box("stone", 0, sd.L, 0, 0.5, 0, 0.06, skip="n") - # Brick pilasters. - for u in (0.0, sd.L): - sd.box("brick", max(0, u - 0.3), min(sd.L, u + 0.3), 0, H, 0, 0.15, skip="nb") - n = sides["n"] - # Roller door slats, a loading dock with bumpers, and a company sign. - for i in range(1, 10): - v = 1.0 + 3.0 * i / 10 - n.box("dark", 0.5, 3.5, v - 0.02, v + 0.02, -0.22, -0.19, skip="n") - n.box("concrete", 0.3, 3.7, 0, 1.0, 0, 1.8, skip="n") - for u in (0.7, 3.3): - n.box("crane", u - 0.15, u + 0.15, 0.25, 0.9, 1.8, 1.95) - n.box("metal", 0.3, 3.7, 4.15, 4.3, 0, 1.0, bevel=0.02) - n.box("board", 4.0, 8.4, 4.45, 4.95, 0, 0.12, skip="n") - lettering(m, n, 6.2, 4.7, 0.12, 6, px=0.075) - n.box("metal", 3.95, 5.15, 2.4, 2.5, 0, 0.8, skip="n") - cap(m, W, D, H) - - -def factory_roof(): - W, D, _ = SHOP - m = Mod("factory_roof") - band(m, W, D, 0, 0.3, 0.08, "stone") - teeth, h = 3, 1.9 - for i in range(teeth): - y0 = -D / 2 + D * i / teeth - y1 = y0 + D / teeth - m.face("metal", [(-W / 2, y0, 0.3), (W / 2, y0, 0.3), (W / 2, y1, 0.3 + h), (-W / 2, y1, 0.3 + h)], out=(0, -1, 1.2)) - k = 5 - for j in range(k): - x0, x1 = -W / 2 + W * j / k, -W / 2 + W * (j + 1) / k - m.face("window", [(x0, y1, 0.3), (x1, y1, 0.3), (x1, y1, 0.3 + h), (x0, y1, 0.3 + h)], out=(0, 1, 0), cell=True) - m.box("dark", x1 - 0.04, y1, 0.3, x1 + 0.04, y1 + 0.06, 0.3 + h) - m.box("dark", -W / 2, y1, 0.3 + h - 0.08, W / 2, y1 + 0.08, 0.3 + h + 0.04) - for x in (-W / 2, W / 2): - m.face("brick", [(x, y0, 0.3), (x, y1, 0.3), (x, y1, 0.3 + h)], out=(x, 0, 0)) - # Smokestack with banding; a tank and ventilators. - sx, sy = W * 0.33, D * 0.32 - m.cyl("brick", sx, sy, 0.3, 0.75, 0.5, 12.5, seg=12) - for z in (3.0, 7.0, 11.0): - r = 0.75 - (0.25 * (z - 0.3) / 12.5) - m.cyl("dark", sx, sy, z, r + 0.06, r + 0.06, 0.3, seg=12, smooth=False) - m.cyl("dark", sx, sy, 12.8, 0.6, 0.6, 0.5, seg=12, smooth=False) - m.anchor("smoke", (sx, sy, 13.6)) - tx, ty = -W * 0.3, -D * 0.05 - for lx, ly in ((-0.6, -0.6), (0.6, -0.6), (-0.6, 0.6), (0.6, 0.6)): - m.box("dark", tx + lx - 0.06, ty + ly - 0.06, 0.3 + h, tx + lx + 0.06, ty + ly + 0.06, 3.2 + h) - m.cyl("metal", tx, ty, 3.2 + h, 1.05, 1.05, 2.2, seg=14) - m.cyl("metal", tx, ty, 5.4 + h, 1.1, 0.15, 0.7, seg=14, smooth=False) - for x in (-W * 0.05, W * 0.1): - vent(m, x, -D * 0.38, 0.3 + h * 0.25, h=1.0) - return m - - -# Grade 2: a row of three brownstone townhouses. - - -def town_ground(): - W, D, H = APT - m = Mod("town_ground", ground=True) - uw = W / 3 - n = Side(m, "n", W, D) - for i, role in enumerate(("brick", "wall", "brick")): - u0 = i * uw - dc = u0 + uw * 0.32 - door = (dc - 0.5, dc + 0.5, 0.9, 2.85, "door") - win = (u0 + uw * 0.72 - 0.45, u0 + uw * 0.72 + 0.45, 1.15, 2.65) - low = (u0 + uw * 0.72 - 0.4, u0 + uw * 0.72 + 0.4, 0.15, 0.65) - n.wall(role, 0, H, [door, win, low], u0, u0 + uw) - n.opening(door, 0.2, pane="door", reveal="stone") - n.opening(win, 0.16, pane="window", reveal=role) - n.opening(low, 0.12, pane="window", reveal=role) - n.box("stone", win[0] - 0.1, win[1] + 0.1, win[2] - 0.09, win[2], 0, 0.13, skip="n") - n.box("stone", win[0] - 0.12, win[1] + 0.12, win[3], win[3] + 0.22, 0, 0.06, skip="n") - # Stoop: steps up to the raised door, with a rail either side. - for j in range(4): - n.box("stone", dc - 0.6, dc + 0.6, 0, 0.9 * (4 - j) / 4, 0, 0.35 * (j + 1) + 0.1, skip="n") - for u in (dc - 0.62, dc + 0.56): - n.m.strut("metal", n.P(u + 0.03, 1.8, 0.1), n.P(u + 0.03, 1.0, 1.45), 0.05) - n.box("metal", u, u + 0.06, 0.25, 1.0, 1.42, 1.48) - n.box("trim", dc - 0.65, dc + 0.65, 2.85, 3.0, 0, 0.18, skip="n", bevel=0.02) - n.box("stone", u0, u0 + uw, 0, 0.8, 0, 0.05, skip="n") - for i in range(4): - u = i * uw - n.box("trim", max(0, u - 0.1), min(W, u + 0.1), 0, H, 0, 0.1, skip="nb") - sides = punched( - m, W, D, 0, H, - {"e": [(a, b, 1.1, 2.5) for a, b in spaced(D, 2, 0.9)], "s": [(a, b, 1.1, 2.5) for a, b in spaced(W, 3, 0.9)], "w": [(a, b, 1.1, 2.5) for a, b in spaced(D, 2, 0.9)]}, - role="brick", depth=0.15, sill="stone", lintel="stone", - ) - band(m, W, D, H - 0.18, H, 0.06, "stone") - cap(m, W, D, H) - return sides - - -def town_floor(): - W, D, H = APT - m = Mod("town_floor") - uw = W / 3 - n = Side(m, "n", W, D) - for i, role in enumerate(("brick", "wall", "brick")): - u0 = i * uw - if i == 1: - # The middle house has a bay window. - n.wall(role, 0, H, [], u0, u0 + uw) - b0, b1, bd = u0 + 0.4, u0 + uw - 0.4, 0.65 - n.box(role, b0, b1, 0.2, H - 0.05, 0, bd, skip="n") - for a, b in spaced(b1 - b0, 2, 0.85): - n.quad("window", b0 + a, b0 + b, 0.75, 2.5, bd + 0.01, cell=True) - n.box("trim", b0 + a - 0.06, b0 + b + 0.06, 0.66, 0.75, bd, bd + 0.1) - n.box("trim", b0 - 0.05, b1 + 0.05, H - 0.3, H - 0.05, 0, bd + 0.08) - else: - holes = [(u0 + uw * c - 0.45, u0 + uw * c + 0.45, 0.75, 2.5) for c in (0.3, 0.72)] - n.wall(role, 0, H, holes, u0, u0 + uw) - for h in holes: - n.opening(h, 0.16, pane="window", reveal=role) - n.box("stone", h[0] - 0.1, h[1] + 0.1, h[2] - 0.09, h[2], 0, 0.13, skip="n") - n.box("stone", h[0] - 0.12, h[1] + 0.12, h[3], h[3] + 0.22, 0, 0.06, skip="n") - c = (h[0] + h[1]) / 2 - n.box("stone", c - 0.1, c + 0.1, h[3] + 0.04, h[3] + 0.3, 0, 0.09, skip="n") - for i in range(4): - u = i * uw - n.box("trim", max(0, u - 0.1), min(W, u + 0.1), 0, H, 0, 0.1, skip="nb") - punched( - m, W, D, 0, H, - {"e": [(a, b, 0.8, 2.4) for a, b in spaced(D, 2, 0.9)], "s": [(a, b, 0.8, 2.4) for a, b in spaced(W, 3, 0.9)], "w": [(a, b, 0.8, 2.4) for a, b in spaced(D, 2, 0.9)]}, - role="brick", depth=0.15, sill="stone", lintel="stone", - ) - band(m, W, D, 0, 0.14, 0.04, "stone", bevel=0.0) - cap(m, W, D, H) - - -def town_roof(): - W, D, _ = APT - m = Mod("town_roof") - uw = W / 3 - band(m, W, D, 0, 0.3, 0.1, "trim") - band(m, W, D, 0.3, 0.45, 0.3, "trim", bevel=0.04) - n = Side(m, "n", W, D) - k = round(W / 0.6) - for i in range(k): - u = W * (i + 0.5) / k - n.box("trim", u - 0.06, u + 0.06, 0.05, 0.3, 0.1, 0.28, skip="nt") - # Mansard roofs with a dormer each, chimneys on the party walls. - t, rise = 0.95, 2.1 - m.hexa("roof", lambda a, b, c: ((-1, 1)[a] * (W / 2 - 0.05 - c * t), (-1, 1)[b] * (D / 2 - 0.05 - c * t), 0.45 + c * rise), skip="b") - for i in range(3): - uc = (i + 0.5) * uw - n.box("trim", uc - 0.55, uc + 0.55, 0.75, 2.05, -0.55, 0.12) - n.quad("window", uc - 0.38, uc + 0.38, 0.95, 1.85, 0.121, cell=True) - n.m.hexa("roof", lambda a, b, c, uc=uc: n.P(uc + (a - 0.5) * 1.35, 2.05 + c * 0.14 + b * 0.25, 0.22 - b * 0.9)) - for i in (1, 2): - u = i * uw - W / 2 - m.box("brick", u - 0.35, D * 0.18, 0.45, u + 0.35, D * 0.18 + 0.7, 3.4) - m.box("stone", u - 0.42, D * 0.18 - 0.07, 3.4, u + 0.42, D * 0.18 + 0.77, 3.55) - for dx in (-0.15, 0.15): - m.cyl("dark", u + dx, D * 0.18 + 0.35, 3.55, 0.08, 0.08, 0.25, seg=6) - m.anchor("smoke", (uw - W / 2, D * 0.18 + 0.35, 4.1)) - return m - - -# Grade 3: a round glass office tower. - -RSEG = 20 - - -def round_ground(): - W, _, H = OFFICE - R = W / 2 - m = Mod("round_ground", ground=True) - ringwall(m, "lobby", R - 0.6, R - 0.6, 0.15, H - 0.45, RSEG) - mullions(m, "metal", R - 0.6, 0.15, H - 0.45, RSEG, t=0.06, out=0.05) - for i in range(0, RSEG, 2): - x, y, _ = around(R - 0.15, TAU * (i + 0.5) / RSEG, 0) - m.cyl("stone", x, y, 0, 0.22, 0.22, H - 0.4, seg=8) - m.cyl("metal", 0, 0, H - 0.45, R + 0.05, R + 0.05, 0.45, seg=RSEG * 2, smooth=False) - m.cyl("stone", 0, 0, 0, R + 0.1, R + 0.1, 0.15, seg=RSEG * 2, smooth=False) - # A canopy over the doors, lit underneath. - m.box("metal", -2.2, -R - 1.6, 2.7, 2.2, -R + 0.5, 2.9, bevel=0.03) - m.box("shopfront", -2.0, -R - 1.5, 2.66, 2.0, -R + 0.4, 2.7) - for x in (-1.9, 1.9): - m.cyl("metal", x, -R - 1.35, 0.15, 0.08, 0.08, 2.55, seg=8) - disk(m, "deck", R, H, RSEG * 2) - - -def round_floor(): - W, _, H = OFFICE - R = W / 2 - m = Mod("round_floor") - ringwall(m, "spandrel", R, R, 0, 0.6, RSEG) - ringwall(m, "glass", R - 0.03, R - 0.03, 0.6, H, RSEG, cell=True) - mullions(m, "metal", R - 0.03, 0.0, H, RSEG, t=0.07, out=0.07) - m.cyl("metal", 0, 0, 0, R + 0.1, R + 0.1, 0.12, seg=RSEG * 2, smooth=False) - disk(m, "deck", R, H, RSEG * 2) - - -def round_setback(): - W, _, _ = OFFICE - R = W / 2 - m = Mod("round_setback") - m.cyl("metal", 0, 0, 0, R + 0.15, R + 0.15, 0.5, seg=RSEG * 2, smooth=False) - ringwall(m, "railglass", R + 0.05, R + 0.05, 0.5, 1.4, RSEG * 2) - m.cyl("metal", 0, 0, 1.4, R + 0.1, R + 0.1, 0.06, seg=RSEG * 2, smooth=False) - for i in range(8): - x, y, _ = around(R * 0.84, TAU * (i + 0.5) / 8, 0) - m.cyl("stone", x, y, 0.5, 0.4, 0.35, 0.45, seg=8) - m.blob("leaf", (x, y, 1.25), (0.48, 0.48, 0.5), sub=1, jitter=0.06) - - -def round_roof(): - W, _, _ = OFFICE - R = W / 2 - m = Mod("round_roof") - ringwall(m, "metal", R + 0.05, R + 0.05, 0, 1.1, RSEG * 2) - annulus(m, "metal", R - 0.2, R + 0.05, 1.1, RSEG * 2) - disk(m, "deck", R - 0.2, 0.15, RSEG * 2) - # A glass crown ringed with light, and a mast. - ringwall(m, "railglass", R * 0.82, R * 0.42, 1.1, 6.5, RSEG) - mullions(m, "metal", R * 0.62, 1.1, 6.5, RSEG, t=0.08, out=0.04, every=2) - for z, r in ((2.2, R * 0.76), (4.0, R * 0.62)): - m.cyl("led", 0, 0, z, r + 0.04, r + 0.04, 0.12, seg=RSEG * 2, smooth=False) - m.cyl("metal", 0, 0, 6.5, R * 0.44, R * 0.44, 0.3, seg=RSEG, smooth=False) - m.cyl("metal", 0, 0, 6.8, 0.25, 0.05, 9.0, seg=8) - m.anchor("beacon", (0, 0, 16.0)) - return m - - -# Grade 4: a twisting supertall (the app turns each storey a little) and a TV tower. - -TW_A, TW_C = 4.7, 1.5 - - -def lm3_ground(): - S, H = LANDMARK - m = Mod("lm3_ground", ground=True) - oc = octagon(TW_A - 0.6, TW_C - 0.25) - prism_sides(m, "lobby", oc, 0.15, H - 0.4, cells=1.6) - for x, y in octagon(TW_A - 0.1, TW_C): - m.cyl("metal", x, y, 0, 0.22, 0.22, H - 0.3, seg=8) - prism(m, "metal", octagon(TW_A + 0.1, TW_C + 0.05), H - 0.4, H, top=True) - prism(m, "stone", octagon(TW_A + 0.5, TW_C + 0.2), 0, 0.15, top=True) - m.box("metal", -2.6, -TW_A - 2.0, 2.8, 2.6, -TW_A + 0.4, 3.0, bevel=0.04) - m.box("led", -2.5, -TW_A - 1.95, 2.76, 2.5, -TW_A + 0.3, 2.8) - - -def lm3_floor(): - _, H = LANDMARK - m = Mod("lm3_floor") - oc = octagon(TW_A, TW_C) - prism_sides(m, "spandrel", oc, 0.22, 0.6) - prism_sides(m, "glass", oc, 0.6, H, cells=1.55) - # White slab edges make the twist read from afar. - prism(m, "paint", octagon(TW_A + 0.22, TW_C + 0.09), 0, 0.22, top=False, bottom=False) - m.face("paint", [(x, y, 0.22) for x, y in octagon(TW_A + 0.22, TW_C + 0.09)], out=Z) - for x, y in oc: - m.box("metal", x - 0.06, y - 0.06, 0.22, x + 0.06, y + 0.06, H) - m.face("deck", [(x, y, H) for x, y in oc], out=Z) - - -def lm3_crown(): - m = Mod("lm3_crown") - prism(m, "paint", octagon(TW_A + 0.22, TW_C + 0.09), 0, 0.3, top=False) - prism_sides(m, "railglass", octagon(TW_A, TW_C), 0.3, 3.2) - m.face("deck", [(x, y, 0.5) for x, y in octagon(TW_A, TW_C)], out=Z) - # Fins rise from the corners and lean in to the spire. - for x, y in octagon(TW_A, TW_C): - m.strut("metal", (x, y, 0.3), (x * 0.25, y * 0.25, 11.0), 0.2) - for z, k in ((3.2, 0.79), (6.5, 0.55)): - prism(m, "led", octagon(TW_A * k + 0.05, TW_C * k), z, z + 0.18, top=False) - m.cyl("metal", 0, 0, 0.5, 1.4, 0.9, 11.0, seg=8, smooth=False) - m.cyl("metal", 0, 0, 11.5, 0.3, 0.04, 12.0, seg=8) - m.cyl("led", 0, 0, 13.0, 0.26, 0.26, 0.3, seg=8) - m.anchor("beacon", (0, 0, 23.8)) - return m - - -TV_R = 1.65 - - -def tvt_base(): - S, H = LANDMARK - m = Mod("tvt_base", ground=True) - R = S / 2 - 0.3 - ringwall(m, "lobby", R - 0.4, R - 0.4, 0.2, H - 0.5, 16) - mullions(m, "metal", R - 0.4, 0.2, H - 0.5, 16, t=0.08, out=0.06) - m.cyl("concrete", 0, 0, H - 0.5, R + 0.1, R + 0.1, 0.5, seg=32, smooth=False) - m.cyl("stone", 0, 0, 0, R + 0.4, R + 0.4, 0.2, seg=32, smooth=False) - disk(m, "deck", R, H, 32) - m.cyl("concrete", 0, 0, H, TV_R * 1.55, TV_R, 3.5, seg=16) - m.box("metal", -2.0, -R - 1.4, 2.6, 2.0, -R + 0.4, 2.8, bevel=0.03) - m.box("shopfront", -1.9, -R - 1.35, 2.56, 1.9, -R + 0.3, 2.6) - - -def tvt_shaft(): - _, H = LANDMARK - m = Mod("tvt_shaft") - m.cyl("concrete", 0, 0, 0, TV_R, TV_R, H, seg=16) - # Lift shafts glazed front and back: lit lines up the tower at night. - for s in (-1, 1): - y = s * (TV_R + 0.02) - m.face("window", [(-0.28, y, 0), (0.28, y, 0), (0.28, y, H), (-0.28, y, H)], out=(0, s, 0), cell=True) - m.box("metal", -0.36, y - 0.05, 0, -0.28, y + 0.05, H) - m.box("metal", 0.28, y - 0.05, 0, 0.36, y + 0.05, H) - - -def tvt_pod(): - """The observation pod, two storeys tall; the shaft runs through it.""" - m = Mod("tvt_pod") - R = 5.6 - ringwall(m, "concrete", TV_R + 0.1, R, 0.0, 2.4, 24) - annulus(m, "concrete", TV_R, TV_R + 0.1, 0.0, 24, up=False) - ringwall(m, "glass", R, R + 0.35, 2.4, 4.6, 24, cell=True) - mullions(m, "metal", R + 0.17, 2.4, 4.6, 24, t=0.08, out=0.06) - m.cyl("metal", 0, 0, 4.6, R + 0.55, R + 0.55, 0.4, seg=48, smooth=False) - ringwall(m, "window", R - 1.1, R - 1.1, 5.0, 6.4, 24, cell=True) - annulus(m, "metal", R - 1.1, R + 0.55, 5.0, 48) - ringwall(m, "railglass", R + 0.45, R + 0.45, 5.0, 5.9, 48) - ringwall(m, "concrete", R - 1.0, TV_R + 0.2, 6.4, 7.2, 24) - m.cyl("led", 0, 0, 4.2, R + 0.4, R + 0.4, 0.1, seg=48, smooth=False) - - -def tvt_spire(): - m = Mod("tvt_spire") - m.cyl("concrete", 0, 0, 0, TV_R, TV_R * 0.8, 3.0, seg=16) - m.cyl("metal", 0, 0, 3.0, 2.6, 2.6, 0.35, seg=24, smooth=False) - ringwall(m, "railglass", 2.5, 2.5, 3.35, 4.2, 24) - m.cyl("paint", 0, 0, 3.35, 0.8, 0.45, 10.0, seg=10) - for i in range(6): - z = 4.5 + i * 1.6 - r = 0.8 - 0.35 * (z - 3.35) / 10.0 + 0.03 - m.cyl("accent", 0, 0, z, r, r - 0.04, 0.8, seg=10, smooth=False) - m.cyl("metal", 0, 0, 13.35, 0.28, 0.06, 13.0, seg=8) - m.anchor("beacon", (0, 0, 26.5)) - return m - - -# ---- parks ------------------------------------------------------------------ - - -def fountain(): - m = Mod("fountain", ground=True, ao=2.0) - m.cyl("stone", 0, 0, 0, 3.2, 3.2, 0.25, seg=16, smooth=False) - ringwall(m, "stone", 2.6, 2.6, 0.25, 0.75, 16) - ringwall(m, "stone", 2.3, 2.3, 0.75, 0.25, 16) - annulus(m, "stone", 2.3, 2.6, 0.75, 16) - disk(m, "water", 2.3, 0.6, 16) - m.cyl("stone", 0, 0, 0.6, 0.45, 0.3, 1.4, seg=10) - m.cyl("stone", 0, 0, 1.9, 0.3, 1.25, 0.35, seg=14, smooth=False) - disk(m, "water", 1.15, 2.22, 14) - m.cyl("stone", 0, 0, 2.25, 0.18, 0.12, 0.8, seg=8) - m.cyl("stone", 0, 0, 3.0, 0.15, 0.5, 0.2, seg=10, smooth=False) - m.cyl("water", 0, 0, 3.2, 0.12, 0.02, 1.2, seg=8) - for i in range(8): - a = TAU * i / 8 - x, y, _ = around(0.95, a, 0) - m.strut("water", (x * 0.3, y * 0.3, 2.3), (x * 1.6, y * 1.6, 0.6), 0.07) - - -def bandstand(): - m = Mod("bandstand", ground=True, ao=2.0) - m.cyl("stone", 0, 0, 0, 3.2, 3.2, 0.6, seg=6, smooth=False) - m.cyl("wood", 0, 0, 0.6, 3.0, 3.0, 0.06, seg=6, smooth=False) - for i in range(6): - x, y, _ = around(2.75, TAU * i / 6, 0) - m.cyl("paint", x, y, 0.6, 0.1, 0.1, 2.7, seg=6) - x2, y2, _ = around(2.75, TAU * (i + 1) / 6, 0) - if i != 4: - m.strut("paint", (x, y, 1.45), (x2, y2, 1.45), 0.07) - m.cyl("roof", 0, 0, 3.3, 3.5, 0.3, 1.7, seg=6, smooth=False) - m.cyl("paint", 0, 0, 3.2, 3.5, 3.5, 0.12, seg=6, smooth=False) - m.cyl("gold", 0, 0, 5.0, 0.15, 0.02, 0.8, seg=6) - - -def bench(): - m = Mod("bench", ground=True, ao=0.6) - for x in (-0.75, 0.75): - m.box("metal", x - 0.04, -0.25, 0, x + 0.04, 0.25, 0.45) - m.box("metal", x - 0.04, 0.2, 0.45, x + 0.04, 0.26, 0.9) - for i in range(3): - y = -0.22 + i * 0.15 - m.box("wood", -0.9, y, 0.45, 0.9, y + 0.12, 0.5) - for z in (0.6, 0.78): - m.box("wood", -0.9, 0.24, z, 0.9, 0.29, z + 0.1) - - -def goal(): - m = Mod("goal", ground=True, ao=1.0) - w, h, d = 7.3, 2.44, 1.8 - for x in (-w / 2, w / 2): - m.box("paint", x - 0.06, -0.06, 0, x + 0.06, 0.06, h) - m.strut("paint", (x, 0, h), (x, d, 0), 0.05) - m.box("paint", x - 0.03, d - 0.03, 0, x + 0.03, d + 0.03, 0.06) - m.box("paint", -w / 2 - 0.06, -0.06, h, w / 2 + 0.06, 0.06, h + 0.12) - m.box("paint", -w / 2, d - 0.03, 0, w / 2, d + 0.03, 0.06) - - -def bleachers(): - """A small stand of seats beside the pitch, 14 m long.""" - m = Mod("bleachers", ground=True, ao=2.0) - L = 14.0 - for i in range(5): - y0 = i * 0.8 - m.box("concrete", -L / 2, y0, 0, L / 2, y0 + 0.8, 0.45 * (i + 1)) - m.box("accent", -L / 2 + 0.2, y0 + 0.1, 0.45 * (i + 1), L / 2 - 0.2, y0 + 0.4, 0.45 * (i + 1) + 0.25) - for x in (-L / 2 + 0.2, 0, L / 2 - 0.2): - m.box("metal", x - 0.08, 4.0, 0, x + 0.08, 4.16, 4.6) - m.box("roof", -L / 2 - 0.3, -0.6, 4.6, L / 2 + 0.3, 4.4, 4.75) - - -# ---- tower crane ------------------------------------------------------------ - -CR = "crane" - - -def crane_base(): - m = Mod("crane_base", ground=True, ao=1.5) - m.box("concrete", -1.7, -1.7, 0, 1.7, 1.7, 0.6, skip="b", bevel=0.04) - for sx in (-1, 1): - m.box("concrete", sx * 1.25 - 0.4, -1.3, 0.6, sx * 1.25 + 0.4, 1.3, 1.1, bevel=0.03) - for sx in (-1, 1): - for sy in (-1, 1): - m.strut(CR, (sx * 0.75, sy * 0.75, 0.6), (sx * 1.2, sy * 1.2, 0.6), 0.12) - - -def crane_mast(): - m = Mod("crane_mast", ao=1.0) - h, a = 3.0, 0.75 - cs = [(-a, -a), (a, -a), (a, a), (-a, a)] - for x, y in cs: - m.box(CR, x - 0.07, y - 0.07, 0, x + 0.07, y + 0.07, h, skip="bt") - for i in range(4): - (x0, y0), (x1, y1) = cs[i], cs[(i + 1) % 4] - m.strut(CR, (x0, y0, 0.08), (x1, y1, h - 0.08), 0.06) - m.strut(CR, (x0, y0, h - 0.06), (x1, y1, h - 0.06), 0.06) - - -def crane_slew(): - """Turntable, cab and tower head; turns with the jib.""" - m = Mod("crane_slew", ao=1.5) - m.cyl("dark", 0, 0, 0, 1.0, 1.0, 0.35, seg=16) - m.box(CR, -1.1, -0.9, 0.35, 1.1, 0.9, 0.8, bevel=0.03) - # Operator's cab on the front side, glazed on three faces. - m.box("trim", -0.2, -2.1, -0.6, 1.2, -0.95, 0.75, bevel=0.04) - m.face("carglass", [(-0.21, -2.0, -0.4), (-0.21, -1.05, -0.4), (-0.21, -1.05, 0.6), (-0.21, -2.0, 0.6)], out=(-1, 0, 0)) - m.face("carglass", [(-0.1, -2.11, -0.4), (1.1, -2.11, -0.4), (1.1, -2.11, 0.6), (-0.1, -2.11, 0.6)], out=(0, -1, 0)) - m.face("carglass", [(1.21, -2.0, 0.0), (1.21, -1.05, 0.0), (1.21, -1.05, 0.6), (1.21, -2.0, 0.6)], out=(1, 0, 0)) - apex = (0, 0, 5.2) - for x, y in ((-0.8, -0.6), (0.8, -0.6), (0.8, 0.6), (-0.8, 0.6)): - m.strut(CR, (x, y, 0.8), apex, 0.12) - m.strut(CR, (-0.5, -0.38, 2.4), (0.5, -0.38, 2.4), 0.06) - m.strut(CR, (-0.5, 0.38, 2.4), (0.5, 0.38, 2.4), 0.06) - m.box(CR, -0.25, -0.25, 5.0, 0.25, 0.25, 5.45) - m.anchor("apex", apex) - m.anchor("beacon", (0, 0, 5.75)) - - -def crane_jib(): - """One 3 m section of the jib, from x = 0 to x = -3.""" - m = Mod("crane_jib", ao=1.0) - w, h, L = 0.55, 1.25, 3.0 - m.strut(CR, (0, -w, 0), (-L, -w, 0), 0.11) - m.strut(CR, (0, w, 0), (-L, w, 0), 0.11) - m.strut(CR, (0, 0, h), (-L, 0, h), 0.11) - for y in (-w, w): - m.strut(CR, (0, y, 0), (-L / 2, 0, h), 0.06) - m.strut(CR, (-L / 2, 0, h), (-L, y, 0), 0.06) - m.strut(CR, (0, -w, 0), (0, w, 0), 0.06) - m.strut(CR, (0, -w, 0), (-L, w, 0), 0.05) - - -def crane_jib_tip(): - m = Mod("crane_jib_tip", ao=1.0) - w, h = 0.55, 1.25 - for a in ((0, -w, 0), (0, w, 0), (0, 0, h)): - m.strut(CR, a, (-1.2, 0, 0.3), 0.08) - m.box(CR, -1.4, -0.2, 0.1, -1.0, 0.2, 0.5) - m.anchor("beacon", (-1.2, 0, 0.75)) - - -def crane_counter(): - """Counter-jib with walkway, winch and counterweights (x from 0.8 to 9.5).""" - m = Mod("crane_counter", ao=1.2) - for y in (-0.6, 0.6): - m.box(CR, 0.8, y - 0.08, -0.25, 9.5, y + 0.08, 0.05) - m.strut(CR, (0.8, y * 1.1, 1.0), (9.5, y * 1.1, 1.0), 0.04) - for x in (1.0, 3.0, 5.0, 7.0, 9.3): - m.strut(CR, (x, y * 1.1, 0.05), (x, y * 1.1, 1.0), 0.04) - for x in (1.5, 3.5, 5.5, 7.5): - m.strut(CR, (x, -0.6, -0.1), (x, 0.6, -0.1), 0.06) - m.box("grate", 0.8, -0.55, 0.0, 7.6, 0.55, 0.05) - m.box("dark", 4.6, -0.5, 0.05, 6.4, 0.5, 1.0, bevel=0.03) - m.cyl("metal", 0, 0, -0.45, 0.35, 0.35, 0.9, seg=12, M=Matrix.Translation((4.2, 0, 0.45)) @ Matrix.Rotation(math.pi / 2, 4, "X")) - for i in range(3): - x0 = 7.75 + i * 0.58 - m.box("concrete", x0, -0.85, -1.9, x0 + 0.52, 0.85, 0.05, bevel=0.03) - m.anchor("end", (9.3, 0, 0.05)) - - -def crane_trolley(): - m = Mod("crane_trolley", ao=0.8) - m.box(CR, -0.55, -0.72, -0.45, 0.55, 0.72, -0.12, bevel=0.03) - for x in (-0.35, 0.35): - for y in (-0.55, 0.55): - m.cyl("dark", 0, 0, -0.06, 0.12, 0.12, 0.12, seg=8, M=Matrix.Translation((x, y, -0.05)) @ Matrix.Rotation(math.pi / 2, 4, "X")) - - -def crane_hook(): - """Hook block; its origin is where the hoist rope ends.""" - m = Mod("crane_hook", ao=0.8) - m.box(CR, -0.3, -0.22, -0.9, 0.3, 0.22, 0, bevel=0.04) - m.cyl("dark", 0, 0, -0.26, 0.25, 0.25, 0.52, seg=12, M=Matrix.Translation((0, 0, -0.35)) @ Matrix.Rotation(math.pi / 2, 4, "X")) - pts = [(0, 0, -0.9), (0, 0, -1.35), (0.12, 0, -1.6), (0.32, 0, -1.55), (0.36, 0, -1.35)] - for a, b in zip(pts, pts[1:]): - m.strut("dark", a, b, 0.09) - - -def i_beam(m, x0, x1, y, z): - m.box("metal", x0, y - 0.15, z, x1, y + 0.15, z + 0.04) - m.box("metal", x0, y - 0.02, z + 0.04, x1, y + 0.02, z + 0.36) - m.box("metal", x0, y - 0.15, z + 0.36, x1, y + 0.15, z + 0.4) - - -def crane_load(): - """Steel beams in slings; origin at the hook.""" - m = Mod("crane_load", ao=1.0) - for y in (-0.18, 0.18): - for z in (-2.45, -2.05): - i_beam(m, -1.6, 1.6, y, z) - for x in (-1.1, 1.1): - m.box("dark", x - 0.05, -0.38, -2.5, x + 0.05, 0.38, -1.6) - for y in (-0.36, 0.36): - m.strut("dark", (0, 0, -1.55), (x, y, -1.65), 0.035) - - -# ---- traffic ---------------------------------------------------------------- - - -def wheel(m, x, y, r=0.33, w=0.24): - M = Matrix.Translation((x, y, r)) @ Matrix.Rotation(math.pi / 2, 4, "Y") - m.cyl("tyre", 0, 0, -w / 2, r, r, w, seg=10, M=M) - m.cyl("chrome", 0, 0, -w / 2 - 0.01, r * 0.55, r * 0.55, w + 0.02, seg=8, smooth=False, M=M) - - -def lights(m, L, Wd, z, s=1.0): - for sx in (-1, 1): - x = sx * (Wd / 2 - 0.32 * s) - m.box("headlight", x - 0.2 * s, -L / 2 - 0.03, z, x + 0.2 * s, -L / 2 + 0.05, z + 0.14 * s) - m.box("taillight", x - 0.18 * s, L / 2 - 0.05, z, x + 0.18 * s, L / 2 + 0.03, z + 0.14 * s) - - -def car(name, L, Wd, body_h, c0, c1, roof_h=0.5, sign=False): - """A car: front at -y, cabin glazed from y = c0 to c1.""" - m = Mod(name, ground=True, ao=1.0) - x = Wd / 2 - m.box("body", -x, -L / 2, 0.3, x, L / 2, body_h, bevel=0.07) - top0, top1 = c0 + 0.55, c1 - 0.3 - m.hexa("carglass", lambda a, b, c: ((a - 0.5) * (Wd - 0.12 - c * 0.22), (c0, c1)[b] + c * (top0 - c0, top1 - c1)[b], body_h + c * roof_h), skip="b") - m.box("body", -(Wd - 0.36) / 2, top0 - 0.02, body_h + roof_h, (Wd - 0.36) / 2, top1 + 0.02, body_h + roof_h + 0.06, bevel=0.02) - for y in (-L / 2, L / 2): - s = -1 if y < 0 else 1 - m.box("dark", -x - 0.02, min(y, y + s * 0.08), 0.28, x + 0.02, max(y, y + s * 0.08), 0.46) - lights(m, L, Wd, body_h - 0.24) - for sy in (-1, 1): - for sx in (-1, 1): - wheel(m, sx * (x - 0.1), sy * (L / 2 - 0.78)) - if sign: - m.box("taxisign", -0.3, (top0 + top1) / 2 - 0.12, body_h + roof_h + 0.06, 0.3, (top0 + top1) / 2 + 0.12, body_h + roof_h + 0.26, bevel=0.02) - - -def van(): - m = Mod("car_van", ground=True, ao=1.0) - L, Wd = 4.9, 1.95 - x = Wd / 2 - m.box("body", -x, -L / 2, 0.32, x, L / 2, 2.05, bevel=0.09) - m.face("carglass", [(-x + 0.12, -L / 2 - 0.01, 1.2), (x - 0.12, -L / 2 - 0.01, 1.2), (x - 0.15, -L / 2 - 0.01, 1.9), (-x + 0.15, -L / 2 - 0.01, 1.9)], out=(0, -1, 0)) - for sx in (-1, 1): - m.face("carglass", [(sx * (x + 0.01), -L / 2 + 0.25, 1.2), (sx * (x + 0.01), -L / 2 + 1.3, 1.2), (sx * (x + 0.01), -L / 2 + 1.3, 1.85), (sx * (x + 0.01), -L / 2 + 0.25, 1.85)], out=(sx, 0, 0)) - for y in (-L / 2, L / 2): - s = -1 if y < 0 else 1 - m.box("dark", -x - 0.02, min(y, y + s * 0.08), 0.3, x + 0.02, max(y, y + s * 0.08), 0.5) - lights(m, L, Wd, 0.8) - for sy in (-1, 1): - for sx in (-1, 1): - wheel(m, sx * (x - 0.1), sy * (L / 2 - 0.85), r=0.36) - - -def bus(): - m = Mod("bus", ground=True, ao=1.2) - L, Wd = 10.5, 2.5 - x = Wd / 2 - m.box("body", -x, -L / 2, 0.38, x, L / 2, 3.05, bevel=0.12) - for sx in (-1, 1): - m.face("carglass", [(sx * (x + 0.01), -L / 2 + 1.4, 1.55), (sx * (x + 0.01), L / 2 - 0.6, 1.55), (sx * (x + 0.01), L / 2 - 0.6, 2.6), (sx * (x + 0.01), -L / 2 + 1.4, 2.6)], out=(sx, 0, 0)) - m.face("carglass", [(-x + 0.1, -L / 2 - 0.01, 1.0), (x - 0.1, -L / 2 - 0.01, 1.0), (x - 0.1, -L / 2 - 0.01, 2.55), (-x + 0.1, -L / 2 - 0.01, 2.55)], out=(0, -1, 0)) - m.face("carglass", [(-0.95, L / 2 + 0.01, 1.8), (0.95, L / 2 + 0.01, 1.8), (0.95, L / 2 + 0.01, 2.6), (-0.95, L / 2 + 0.01, 2.6)], out=(0, 1, 0)) - m.box("led", -0.9, -L / 2 - 0.04, 2.65, 0.9, -L / 2 + 0.02, 2.92) - lights(m, L, Wd, 0.75, 1.2) - for y in (-L / 2 + 2.0, L / 2 - 2.3): - for sx in (-1, 1): - wheel(m, sx * (x - 0.15), y, r=0.5, w=0.32) - - -def person(): - m = Mod("person", ground=True, ao=0.5) - for x in (-1, 1): - m.box("pants", x * 0.02 if x > 0 else -0.17, -0.09, 0.0, 0.17 if x > 0 else -0.02, 0.09, 0.86) - m.box("shirt", x * 0.3 - 0.05 * x - 0.045, -0.07, 0.82, x * 0.3 - 0.05 * x + 0.045, 0.07, 1.4) - m.box("skin", x * 0.25 - 0.04, -0.05, 0.7, x * 0.25 + 0.04, 0.05, 0.82) - m.box("shirt", -0.2, -0.12, 0.84, 0.2, 0.12, 1.42, bevel=0.03) - m.box("skin", -0.05, -0.05, 1.42, 0.05, 0.05, 1.48) - m.blob("skin", (0, 0, 1.6), (0.11, 0.12, 0.14), sub=1) - m.blob("hair", (0, 0.025, 1.66), (0.12, 0.125, 0.1), sub=1) - - -# ---- countryside ------------------------------------------------------------ -# Cheap pieces drawn thousands of times around the city: keep them to a few -# dozen triangles. - - -def forest_broadleaf(): - m = Mod("forest_a", ground=True, ao=2.5) - m.cyl("bark", 0, 0, 0, 0.22, 0.14, 2.2, seg=5, smooth=False) - m.blob("leaf", (0, 0, 3.2), (1.6, 1.6, 1.35), sub=1, jitter=0.18) - m.blob("leaf", (0.5, 0.3, 4.2), (1.05, 1.05, 0.9), sub=1, jitter=0.12) - - -def forest_conifer(): - m = Mod("forest_b", ground=True, ao=2.5) - m.cyl("bark", 0, 0, 0, 0.2, 0.12, 1.2, seg=5, smooth=False) - m.cyl("leaf", 0, 0, 0.9, 1.7, 0.1, 3.0, seg=7, smooth=False, jitter=0.12) - m.cyl("leaf", 0, 0, 2.9, 1.15, 0.0, 2.8, seg=7, smooth=False, M=Matrix.Rotation(0.4, 4, "Z"), jitter=0.08) - - -def forest_emergent(): - """A tall rainforest tree: bare trunk, wide flat crown above the canopy.""" - m = Mod("forest_c", ground=True, ao=3.0) - m.cyl("bark", 0, 0, 0, 0.35, 0.16, 7.5, seg=6, smooth=False) - for end in ((1.6, 0.4, 7.6), (-1.4, 0.6, 7.8), (0.2, -1.5, 7.5)): - branch(m, "bark", (0, 0, 6.2), end, 0.12) - for c, s in (((0, 0, 8.3), (2.6, 2.4, 0.9)), ((1.5, 0.5, 7.9), (1.4, 1.3, 0.7)), ((-1.3, 0.7, 8.0), (1.5, 1.4, 0.7)), ((0.2, -1.4, 7.8), (1.3, 1.3, 0.65))): - m.blob("leaf", c, s, sub=1, jitter=0.15) - - -def forest_bush(): - """Undergrowth and round crowns that fill the canopy.""" - m = Mod("forest_d", ground=True, ao=2.0) - m.cyl("bark", 0, 0, 0, 0.16, 0.1, 1.0, seg=5, smooth=False) - for c, s in (((0, 0, 1.5), (1.5, 1.4, 1.2)), ((1.0, 0.4, 1.1), (1.0, 0.9, 0.85)), ((-0.9, -0.3, 1.2), (1.0, 1.0, 0.9))): - m.blob("leaf", c, s, sub=1, jitter=0.14) - - -def forest_far(): - """A clump of canopy for distant woods: no trunk, two blobs.""" - m = Mod("forest_e", ground=True, ao=2.5) - m.blob("leaf", (0, 0, 1.6), (1.7, 1.6, 1.3), sub=1, jitter=0.2) - m.blob("leaf", (0.7, 0.4, 2.4), (1.1, 1.1, 0.9), sub=1, jitter=0.15) - - -def rock(name, seed_scale): - m = Mod(name, ground=True, ao=1.5) - m.blob("rock", (0, 0, 0.35 * seed_scale[2]), seed_scale, sub=1, jitter=0.18) - - -def bridge(): - """A steel truss road bridge, 44 m along x, deck top at road level.""" - m = Mod("bridge", ao=3.0) - L, W, H, panels = 44.0, 11.0, 5.5, 11 - m.box("concrete", -L / 2, -W / 2, -0.8, L / 2, W / 2, 0.06, bevel=0.04) - m.face("deck", [(-L / 2, -W / 2 + 1.4, 0.065), (L / 2, -W / 2 + 1.4, 0.065), (L / 2, W / 2 - 1.4, 0.065), (-L / 2, W / 2 - 1.4, 0.065)], out=Z) - for sy in (-1, 1): - m.box("concrete", -L / 2, min(sy * W / 2, sy * (W / 2 - 1.4)), 0.06, L / 2, max(sy * W / 2, sy * (W / 2 - 1.4)), 0.24) - # Under the deck: girders and cross beams. - for y in (-W / 2 + 1.5, 0, W / 2 - 1.5): - m.box("steel", -L / 2, y - 0.2, -1.6, L / 2, y + 0.2, -0.8) - step = L / panels - for sy in (-1, 1): - y = sy * (W / 2 - 0.15) - m.box("steel", -L / 2, y - 0.2, 0.0, L / 2, y + 0.2, 0.55) - m.box("steel", -L / 2 + step, y - 0.2, H - 0.45, L / 2 - step, y + 0.2, H) - for i in range(panels): - x0, x1 = -L / 2 + i * step, -L / 2 + (i + 1) * step - top0, top1 = max(-L / 2 + step, min(L / 2 - step, x0)), max(-L / 2 + step, min(L / 2 - step, x1)) - if i == 0: - m.strut("steel", (x0, y, 0.3), (x1, y, H - 0.2), 0.32) - elif i == panels - 1: - m.strut("steel", (x0, y, H - 0.2), (x1, y, 0.3), 0.32) - else: - a, b = ((x0, y, 0.3), (x1, y, H - 0.2)) if i % 2 else ((x0, y, H - 0.2), (x1, y, 0.3)) - m.strut("steel", a, b, 0.24) - m.strut("steel", (x1, y, 0.3), (x1, y, H - 0.2), 0.16) - m.strut("metal", (-L / 2, y - sy * 0.35, 1.0), (L / 2, y - sy * 0.35, 1.0), 0.08) - for i in range(1, panels): - x = -L / 2 + i * step - m.strut("steel", (x, -W / 2 + 0.15, H - 0.2), (x, W / 2 - 0.15, H - 0.2), 0.18) - if i < panels - 1: - m.strut("steel", (x, -W / 2 + 0.15, H - 0.2), (x + step, W / 2 - 0.15, H - 0.2), 0.1) - for sx in (-1, 1): - x = sx * (L / 2 - step) - m.box("steel", x - 0.25, -W / 2 + 0.1, H - 1.4, x + 0.25, W / 2 - 0.1, H - 0.45) - # Stone abutments down into the banks. - x0, x1 = sorted((sx * L / 2, sx * (L / 2 - 3.5))) - m.box("stone", x0, -W / 2 - 0.6, -6.0, x1, W / 2 + 0.6, -0.8, skip="b", bevel=0.05) - for sy in (-1, 1): - m.box("stone", sx * L / 2 - 0.5, sy * (W / 2 + 0.1) - 0.5, 0.0, sx * L / 2 + 0.5, sy * (W / 2 + 0.1) + 0.5, 1.6, bevel=0.04) - m.cyl("metal", sx * L / 2, sy * (W / 2 + 0.1), 1.6, 0.06, 0.06, 2.2, seg=6) - m.box("shopfront", sx * L / 2 - 0.18, sy * (W / 2 + 0.1) - 0.18, 3.8, sx * L / 2 + 0.18, sy * (W / 2 + 0.1) + 0.18, 4.15) - - -# ---- trees ------------------------------------------------------------------ - - -def trunk(m, h, r=0.17, role="bark"): - m.cyl(role, 0, 0, 0, r, r * 0.6, h, seg=7) - - -def branch(m, role, a, b, r=0.07): - a, b = Vector(a), Vector(b) - d = b - a - M = Matrix.Translation(a) @ d.to_track_quat("Z", "Y").to_matrix().to_4x4() - m.cyl(role, 0, 0, 0, r, r * 0.6, d.length, seg=5, M=M) - - -def tree_round(): - m = Mod("tree_a", ground=True, ao=2.0) - trunk(m, 2.4) - branch(m, "bark", (0, 0, 1.6), (0.8, 0.3, 2.6)) - branch(m, "bark", (0, 0, 1.8), (-0.7, -0.3, 2.8)) - for c, r in (((0, 0, 3.3), 1.3), ((0.8, 0.35, 2.75), 0.95), ((-0.75, -0.2, 2.85), 1.0), ((0.1, -0.8, 2.9), 0.9), ((-0.15, 0.6, 3.85), 0.85)): - m.blob("leaf", c, (r, r, r * 0.88), sub=2, jitter=0.11) - - -def tree_conifer(): - m = Mod("tree_b", ground=True, ao=2.0) - trunk(m, 1.2, 0.15) - for i, (r, z) in enumerate(((1.55, 0.7), (1.3, 1.6), (1.05, 2.45), (0.8, 3.25), (0.52, 4.0))): - m.cyl("leaf", 0, 0, z, r, 0.05, 1.5 - i * 0.12, seg=10, smooth=False, M=Matrix.Rotation(i * 0.6, 4, "Z"), jitter=0.1) - - -def tree_poplar(): - m = Mod("tree_c", ground=True, ao=2.0) - trunk(m, 1.6, 0.14) - m.blob("leaf", (0, 0, 3.4), (0.95, 0.95, 2.4), sub=2, jitter=0.12) - m.blob("leaf", (0.25, 0.1, 2.2), (0.7, 0.7, 1.1), sub=2, jitter=0.08) - - -def tree_bush(): - m = Mod("tree_d", ground=True, ao=2.0) - trunk(m, 1.6, 0.2) - branch(m, "bark", (0, 0, 1.3), (0.9, 0.4, 2.1)) - branch(m, "bark", (0, 0, 1.4), (-0.8, -0.4, 2.2)) - for c, s in (((0, 0, 2.55), (1.6, 1.5, 0.95)), ((0.95, 0.45, 2.25), (1.0, 0.9, 0.72)), ((-0.85, -0.5, 2.35), (1.1, 1.0, 0.78)), ((0.1, 0.2, 3.15), (1.0, 1.0, 0.68))): - m.blob("leaf", c, s, sub=2, jitter=0.1) - - -def tree_birch(): - m = Mod("tree_e", ground=True, ao=2.0) - trunk(m, 3.8, 0.11, "birch") - branch(m, "birch", (0, 0, 2.2), (0.6, 0.2, 3.2), 0.05) - branch(m, "birch", (0, 0, 2.6), (-0.5, -0.3, 3.5), 0.05) - for c, r in (((0, 0, 4.1), 0.7), ((0.6, 0.25, 3.4), 0.55), ((-0.5, -0.3, 3.6), 0.6), ((0.1, 0.45, 3.0), 0.5), ((-0.2, -0.1, 4.7), 0.45)): - m.blob("leaf", c, (r, r, r * 1.2), sub=2, jitter=0.08) - - -def tree_cherry(): - m = Mod("tree_f", ground=True, ao=2.0) - trunk(m, 1.5, 0.18) - for end in ((1.1, 0.3, 2.3), (-1.0, 0.4, 2.4), (0.2, -1.0, 2.3), (0.1, 0.5, 2.8)): - branch(m, "bark", (0, 0, 1.3), end, 0.08) - for c, s in (((0, 0, 2.7), (1.5, 1.5, 0.85)), ((1.1, 0.35, 2.45), (0.9, 0.85, 0.6)), ((-1.0, 0.4, 2.55), (0.95, 0.9, 0.62)), ((0.2, -0.95, 2.45), (0.85, 0.85, 0.6)), ((0.0, 0.2, 3.25), (0.9, 0.9, 0.55))): - m.blob("blossom", c, s, sub=2, jitter=0.1) - - -# ---- ambient occlusion and export -------------------------------------------- - - -def hemisphere(n): - """Cosine-weighted directions about +Z (Hammersley points).""" - out = [] - for i in range(n): - u = (i + 0.5) / n - bits, x, f = i, 0.0, 0.5 - while bits: - x += f * (bits & 1) - bits >>= 1 - f *= 0.5 - r, a = math.sqrt(u), 2 * math.pi * x - out.append(Vector((r * math.cos(a), r * math.sin(a), math.sqrt(max(0.0, 1 - u))))) - return out - - -DIRS = hemisphere(64) - - -def bake_ao(m): - bm = m.bm - bm.normal_update() - probe = bm.copy() - if m.ground: - g = [probe.verts.new(p) for p in [(-80, -80, -0.002), (80, -80, -0.002), (80, 80, -0.002), (-80, 80, -0.002)]] - probe.faces.new(g) - tree = BVHTree.FromBMesh(probe) - probe.free() - layer = bm.loops.layers.float_color.new("Color") - R = m.ao - for f in bm.faces: - n = f.normal - c = f.calc_center_median() - t = n.orthogonal().normalized() - b = n.cross(t) - for loop in f.loops: - p = loop.vert.co.lerp(c, 0.03) + n * 0.003 - occ = 0.0 - for d in DIRS: - hit = tree.ray_cast(p, t * d.x + b * d.y + n * d.z, R) - if hit[0] is not None: - occ += 1.0 - hit[3] / R - ao = 1.0 - 1.1 * occ / len(DIRS) - ao = max(0.25, min(1.0, ao)) ** 1.3 - loop[layer] = (ao, ao, ao, 1.0) - - -def material(role): - mt = bpy.data.materials.get(role) - if mt: - return mt - mt = bpy.data.materials.new(role) - nt = mt.node_tree - if nt is None: - mt.use_nodes = True - nt = mt.node_tree - bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED") - col = nt.nodes.new("ShaderNodeVertexColor") - col.layer_name = "Color" - mul = nt.nodes.new("ShaderNodeVectorMath") - mul.operation = "MULTIPLY" - mul.inputs[0].default_value = PREVIEW_COLORS.get(role, (0.8, 0.8, 0.8)) - nt.links.new(col.outputs["Color"], mul.inputs[1]) - nt.links.new(mul.outputs[0], bsdf.inputs["Base Color"]) - if role in ("window", "glass", "railglass", "metal", "gold"): - bsdf.inputs["Roughness"].default_value = 0.15 if role != "gold" else 0.3 - bsdf.inputs["Metallic"].default_value = 0.6 - if role in ("sign", "shopfront", "lobby", "crownlight"): - bsdf.inputs["Emission Color"].default_value = (*PREVIEW_COLORS[role], 1) - bsdf.inputs["Emission Strength"].default_value = 1.5 - return mt - - -def finish(m): - bmesh.ops.dissolve_degenerate(m.bm, dist=1e-5, edges=m.bm.edges[:]) - bake_ao(m) - me = bpy.data.meshes.new(m.name) - m.bm.to_mesh(me) - m.bm.free() - for r in m.roles: - me.materials.append(material(r)) - ob = bpy.data.objects.new(m.name, me) - bpy.context.scene.collection.objects.link(ob) - for k, v in m.extras.items(): - ob[k] = v - attr = me.color_attributes.get("Color") - me.color_attributes.active_color = attr - me.color_attributes.render_color_index = me.color_attributes.active_color_index - tris = sum(len(p.vertices) - 2 for p in me.polygons) - print(f" {m.name:18s} {tris:6d} tris {', '.join(m.roles)}") - return ob - - -MODS = [] - - -def build(): - for v in "abc": - cottage_body(v) - cottage_roof(v) - for v in "ab": - shop_ground(v) - shop_floor(v) - shop_roof(v) - apt_floor(v) - apt_roof(v) - office_floor(v) - office_roof(v) - apt_ground() - apt_ground("c", wall="brick") - apt_floor_c() - apt_roof("c", wall="brick") - cottage_body_d() - cottage_roof_d() - shop_ground_c() - shop_floor_c() - office_floor_c() - lm2_ground() - for t in (1, 2, 3): - lm2_floor(t) - lm2_ledge(1) - lm2_ledge(2) - lm2_crown() - crane_base() - crane_mast() - crane_slew() - crane_jib() - crane_jib_tip() - crane_counter() - crane_trolley() - crane_hook() - crane_load() - for name, L, Wd, bh, c0, c1, sign in (("car_sedan", 4.4, 1.8, 0.95, -0.9, 1.25, False), ("car_hatch", 3.8, 1.75, 0.95, -0.7, 1.6, False), ("car_taxi", 4.5, 1.82, 0.95, -0.9, 1.3, True)): - car(name, L, Wd, bh, c0, c1, sign=sign) - van() - bus() - person() - office_ground() - office_setback() - lm_ground() - for t in (1, 2, 3): - lm_floor(t) - lm_ledge(1) - lm_ledge(2) - lm_crown() - tree_round() - tree_conifer() - tree_poplar() - tree_bush() - tree_birch() - tree_cherry() - forest_broadleaf() - forest_conifer() - forest_emergent() - forest_bush() - forest_far() - rock("rock_a", (1.2, 1.0, 0.8)) - rock("rock_b", (0.9, 1.3, 1.1)) - bridge() - cottage_body_e() - cottage_roof_e() - cottage_body_f() - cottage_roof_f() - shop_ground_d() - shop_roof_d() - factory_ground() - factory_roof() - town_ground() - town_floor() - town_roof() - round_ground() - round_floor() - round_setback() - round_roof() - lm3_ground() - lm3_floor() - lm3_crown() - tvt_base() - tvt_shaft() - tvt_pod() - tvt_spire() - fountain() - bandstand() - bench() - goal() - bleachers() - - -def preview(objs, path): - """A Cycles render of assembled buildings, to check the kit by eye.""" - scene = bpy.context.scene - col = bpy.data.collections.new("preview") - scene.collection.children.link(col) - - def put(name, x, y, z, sx=1.0, sy=1.0): - o = bpy.data.objects.new(name + "_p", objs[name].data) - o.location = (x, y, z) - o.scale = (sx, sy, 1) - col.objects.link(o) - - def tower(x, ground, floor, roof, n, fh): - put(ground, x, 0, 0) - for f in range(1, n): - put(floor[f % len(floor)], x, 0, f * fh) - put(roof, x, 0, n * fh) - - put("cottage_body_a", -40, 0, 0) - put("cottage_roof_a", -40, 0, COTTAGE[2]) - put("cottage_body_d", -40, 12, 0) - put("cottage_roof_d", -40, 12, COTTAGE[2]) - put("cottage_body_b", -40, -12, 0) - put("cottage_roof_b", -40, -12, COTTAGE[2]) - tower(-28, "shop_ground_a", ["shop_floor_a"], "shop_roof_a", 3, SHOP[2]) - put("shop_ground_c", -28, -12, 0) - put("shop_floor_c", -28, -12, SHOP[2]) - put("shop_roof_b", -28, -12, 2 * SHOP[2]) - tower(-16, "apt_ground", ["apt_floor_a"], "apt_roof_a", 6, APT[2]) - put("apt_ground_c", -16, -12, 0) - for f in range(1, 5): - put("apt_floor_c", -16, -12, f * APT[2]) - put("apt_roof_c", -16, -12, 5 * APT[2]) - fh = OFFICE[2] - put("office_ground", -3, 0, 0) - for f in range(1, 9): - put("office_floor_a", -3, 0, f * fh) - put("office_setback", -3, 0, 9 * fh) - for f in range(9, 13): - put("office_floor_a", -3, 0, f * fh, 0.78, 0.78) - put("office_roof_a", -3, 0, 13 * fh, 0.78, 0.78) - put("office_ground", -3, -12, 0) - for f in range(1, 7): - put("office_floor_b", -3, -12, f * fh) - put("office_roof_b", -3, -12, 7 * fh) - S, fh = LANDMARK - put("lm_ground", 12, 0, 0) - z = fh - for t, n in ((1, 9), (2, 6), (3, 4)): - for _ in range(n - (1 if t == 1 else 0)): - put(f"lm_floor_{t}", 12, 0, z) - z += fh - if t < 3: - put(f"lm_ledge_{t}", 12, 0, z) - put("lm_crown", 12, 0, z) - for i, name in enumerate(["tree_a", "tree_b", "tree_c", "tree_d"]): - put(name, -46 + i * 4, -24, 0) - - bpy.ops.mesh.primitive_plane_add(size=300, location=(0, 0, 0)) - ground = bpy.context.object - gm = bpy.data.materials.new("ground_p") - gb = next(n for n in gm.node_tree.nodes if n.type == "BSDF_PRINCIPLED") - gb.inputs["Base Color"].default_value = (0.3, 0.42, 0.22, 1) - ground.data.materials.append(gm) - sun = bpy.data.objects.new("sun", bpy.data.lights.new("sun", "SUN")) - sun.data.energy = 4 - sun.rotation_euler = (math.radians(50), math.radians(10), math.radians(-35)) - col.objects.link(sun) - world = bpy.data.worlds.new("w") - scene.world = world - bg = world.node_tree.nodes.get("Background") if world.node_tree else None - if bg is None: - world.use_nodes = True - bg = world.node_tree.nodes["Background"] - bg.inputs["Color"].default_value = (0.55, 0.65, 0.8, 1) - bg.inputs["Strength"].default_value = 0.8 - cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")) - col.objects.link(cam) - aim = bpy.data.objects.new("aim", None) - col.objects.link(aim) - track = cam.constraints.new("TRACK_TO") - track.target = aim - scene.camera = cam - scene.render.engine = "CYCLES" - scene.cycles.samples = 48 - scene.cycles.use_denoising = False - scene.render.resolution_x, scene.render.resolution_y = 1400, 900 - stem = path.rsplit(".", 1)[0] - for tag, loc, target, lens in ( - ("wide", (-75, -80, 55), (-8, 0, 18), 32), - ("near", (-52, -34, 14), (-30, -4, 5), 35), - ("top", (0, -40, 70), (3, 0, 45), 40), - ): - cam.location, aim.location, cam.data.lens = loc, target, lens - scene.render.filepath = f"{stem}-{tag}.png" - bpy.ops.render.render(write_still=True) - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - build() - print(f"city kit: {len(MODS)} pieces") - objs = {m.name: finish(m) for m in MODS} - bpy.ops.export_scene.gltf( - filepath=OUT, - export_format="GLB", - export_extras=True, - export_yup=True, - export_texcoords=True, - export_normals=True, - export_vertex_color="ACTIVE", - export_all_vertex_colors=False, - export_materials="EXPORT", - export_image_format="NONE", - ) - print(f"wrote {OUT}") - if PREVIEW: - preview(objs, PREVIEW) - - -main() diff --git a/ui/blender/real/bridge.py b/ui/blender/real/bridge.py index f125188..dfe5251 100644 --- a/ui/blender/real/bridge.py +++ b/ui/blender/real/bridge.py @@ -1,7 +1,6 @@ # The river bridge for the realistic city: a riveted steel Pratt through -# truss carrying the road out of town, replacing the kit's truss. Same -# footprint as the kit's: 44 m along x, 11 m wide, the road deck at the -# origin's height, stone abutments down into the banks. +# truss carrying the road out of town: 44 m along x, 11 m wide, the road +# deck at the origin's height, stone abutments down into the banks. # # Two trusses (built-up chords, I-section verticals, flat-bar diagonals, # gusset plates at the joints) stand on the deck's edges; portal frames diff --git a/ui/blender/real/build.py b/ui/blender/real/build.py index ad1c406..91c49cf 100644 --- a/ui/blender/real/build.py +++ b/ui/blender/real/build.py @@ -6,8 +6,7 @@ # Writes tex/*.png (the shared texture library; --no-tex skips it, but a # model's own textures are always made with it), and per model .glb # (pieces as nodes, materials named by role) and _ao.png (AO baked on -# its lightmap UVs). The output is git-ignored; the app falls back to the -# low-poly kit for any model that is missing. +# its lightmap UVs). The output is git-ignored; the city needs every model. import importlib import os import sys diff --git a/ui/blender/real/civic.py b/ui/blender/real/civic.py index 514508c..ce433fa 100644 --- a/ui/blender/real/civic.py +++ b/ui/blender/real/civic.py @@ -1,5 +1,4 @@ -# Civic props for the realistic city: what the kit still drew in low poly -# round the buildings. The water tower (the Hamlet's water need), the tower +# Civic props for the realistic city, round the buildings. The water tower (the Hamlet's water need), the tower # crane and scaffolding of construction sites, the rubble of a destroyed # lot, the parks' fountain, bandstand, goals and stand, and the people. # @@ -7,9 +6,8 @@ # water_tower an elevated steel tank on four battered legs, 20.6 m # crane_base, crane_mast, crane_slew, crane_jib, crane_jib_tip, # crane_counter, crane_trolley, crane_hook, crane_load -# the kit's tower crane at the kit's sizes, so its anchors -# (apex, beacon, end) still hold: the app takes them from -# the kit and the geometry from here +# a tower crane; its anchors (apex, beacon, end) are +# CRANE in models.ts: move them with the geometry # scaffold one bay of tube-and-fitting scaffold, 2.4 m along x, one # 2 m lift tall (the app stretches it to a storey), deck at # its foot; inner standards at y = 0.65, outer at y = -0.15 @@ -17,7 +15,7 @@ # rubble_wall a broken brick wall, 3.2 m long, standing on its foot # rubble_slab a broken floor slab with its rebar out, 2.6 x 1.8 m # rubble_beam a steel I-beam bent in the fire, 4.6 m -# fountain, bandstand, goal, bleachers the parks' pieces, kit sizes +# fountain, bandstand, goal, bleachers the parks' pieces # person a pedestrian, 1.75 m (roles skin, hair, shirt, pants: # the app colours each one) # Roles: tankpaint, tankband, crane, scaffboard, char, galv, dark, grate, concrete, trim, carglass, diff --git a/ui/blender/real/street.py b/ui/blender/real/street.py index 8a89d4b..9db7073 100644 --- a/ui/blender/real/street.py +++ b/ui/blender/real/street.py @@ -1,6 +1,5 @@ # Street furniture for the realistic city: lights, traffic signals, fences -# and walls, and the small things on a pavement. Replaces the kit's box -# lamps and the plain boxes the app drew for fences. +# and walls, and the small things on a pavement. # # Pieces (base at the origin; the "front", -y here and +z in the app, is the # side that faces the street): diff --git a/ui/blender/real/trees.py b/ui/blender/real/trees.py index 966e06c..f6b5ef4 100644 --- a/ui/blender/real/trees.py +++ b/ui/blender/real/trees.py @@ -1,5 +1,4 @@ -# Trees, bushes and hedges for the realistic city, replacing the kit's -# low-poly blobs. Real sizes: street and garden trees are 5-12 m tall. +# Trees, bushes and hedges for the realistic city. Real sizes: street and garden trees are 5-12 m tall. # # Trunks and branches are tapered tubes grown recursively from a seed, down # to a level of twigs and cut short at the crown's surface; the leaves are @@ -621,8 +620,8 @@ def _species(): ("shrub", lambda m, R: bush(m, R, 0.7, 1.0, 130, 0.36)), ("flowers", lambda m, R: bush(m, R, 0.65, 0.55, 110, 0.3, role="bloom")), ("hedge", lambda m, R: hedge(m, R)), - # The woods: light trees at the kit's own sizes (land.ts scales them - # as it scaled the kit's forest_a-e): broadleaf, conifer, rainforest + # The woods: light trees at a base size land.ts scales per instance + # (up to a tree, or a clump far out): broadleaf, conifer, rainforest # giant, undergrowth, and a far clump standing for several trees. ("forest_a", lambda m, R: light_tree(m, R, 5.0, 1.6, 1.8, 44, 1.1)), ("forest_b", lambda m, R: light_conifer(m, R, 5.6, 1.7)), diff --git a/ui/blender/real/vehicles.py b/ui/blender/real/vehicles.py index 2cc2b41..288e1d8 100644 --- a/ui/blender/real/vehicles.py +++ b/ui/blender/real/vehicles.py @@ -1,6 +1,5 @@ # Vehicles for the realistic city: the cars that park on drives and in -# bays and drive round the blocks (ui/src/city/traffic.ts), replacing the -# low-poly kit's four cars and bus. +# bays and drive round the blocks (ui/src/city/traffic.ts). # # Built the way car models are: a coarse cage of cross-sections along the # car (underbody, rocker, side, shoulder, belt, roof rail, roof), smoothed diff --git a/ui/package.json b/ui/package.json index d80f280..4422095 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,6 @@ "build": "tsc --noEmit && vite build", "test": "bun test", "tauri": "tauri", - "kit": "blender -b --factory-startup -P blender/city_kit.py -- public/city-kit.glb", "models": "blender -b --factory-startup -P blender/real/build.py -- public/models && bun scripts/textures.ts", "textures": "bun scripts/textures.ts", "pack": "bun scripts/pack.ts" diff --git a/ui/public/city-kit.glb b/ui/public/city-kit.glb deleted file mode 100644 index e130854..0000000 Binary files a/ui/public/city-kit.glb and /dev/null differ diff --git a/ui/scripts/pack.ts b/ui/scripts/pack.ts index 2c3bbaf..6caf01a 100644 --- a/ui/scripts/pack.ts +++ b/ui/scripts/pack.ts @@ -9,7 +9,7 @@ // // The pack is what the app needs on a GPU with BC7 (every GPU Mesa drives // with OpenGL 4.2 or later): the glb files, the BC7 textures and bc7.json. -// No PNGs; a GPU without BC7 keeps the low-poly kit. The glbs are gzipped +// No PNGs, so a GPU without BC7 shows no city. The glbs are gzipped // here (31 MB to 10; the app's loader inflates them), the BC7 files already are. // // is a hash of the file list with each file's sum, so an unchanged pack diff --git a/ui/src-tauri/src/models.rs b/ui/src-tauri/src/models.rs index 052a54a..cd56340 100644 --- a/ui/src-tauri/src/models.rs +++ b/ui/src-tauri/src/models.rs @@ -5,7 +5,7 @@ //! are fetched as the user (never by the daemon), checked against the //! manifest, and kept in `~/.local/share/deepwork/assets//`; the page //! reads them through the `models://` scheme. Until they are all there the -//! city is drawn with the embedded low-poly kit. +//! app shows the HUD and the download, without the city. //! //! `DEEPWORK_MODELS=` serves a local directory instead (unchecked), for //! trying a pack before it is published: `DEEPWORK_MODELS=ui/public/models`. @@ -48,7 +48,7 @@ struct Entry { #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case", tag = "state")] pub enum Status { - /// No pack in this build: the kit only. + /// No pack in this build: no city. None, Downloading { done: u64, diff --git a/ui/src/city/fx.ts b/ui/src/city/fx.ts index a30f797..c32c7b0 100644 --- a/ui/src/city/fx.ts +++ b/ui/src/city/fx.ts @@ -1,6 +1,5 @@ // Particles (smoke, fire, dust, sparks) and falling debris. import * as THREE from "three"; -import { puff } from "./textures"; interface P { x: number; y: number; z: number; @@ -257,3 +256,20 @@ export class Fx { }); } } + +/** A soft round sprite for particles. */ +function puff(): THREE.Texture { + const c = document.createElement("canvas"); + c.width = c.height = 64; + const g = c.getContext("2d")!; + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, "rgba(255,255,255,1)"); + grad.addColorStop(0.25, "rgba(255,255,255,0.55)"); + grad.addColorStop(0.6, "rgba(255,255,255,0.15)"); + grad.addColorStop(1, "rgba(255,255,255,0)"); + g.fillStyle = grad; + g.fillRect(0, 0, 64, 64); + const t = new THREE.CanvasTexture(c); + t.colorSpace = THREE.SRGBColorSpace; + return t; +} diff --git a/ui/src/city/kit.ts b/ui/src/city/kit.ts deleted file mode 100644 index 6097f9f..0000000 --- a/ui/src/city/kit.ts +++ /dev/null @@ -1,52 +0,0 @@ -// City theme: the kit of building pieces and trees made in Blender -// (ui/blender/city_kit.py writes public/city-kit.glb). Each piece is split -// by material role ("wall", "window", ...); models.ts stacks pieces into -// buildings and picks the real materials. Geometry carries baked ambient -// occlusion as vertex colours and window panes as cells of the window atlas. -import * as THREE from "three"; -import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; -import type { Kind } from "./plan"; - -export interface Piece { - parts: { role: string; geo: THREE.BufferGeometry }[]; - /** Points marked in the kit (smoke, beacon), piece-local. */ - anchors: Record; -} - -/** Footprint (x, z) each kind's pieces are modelled at; must match city_kit.py. */ -export const NOMINAL: Record = { - cottage: [6.75, 6.25], - shop: [8.75, 8], - apartment: [9.15, 9.15], - office: [9.6, 9.6], - landmark: [10.4, 10.4], -}; - -let pieces: Map | null = null; - -export async function loadKit(url = `${import.meta.env.BASE_URL}city-kit.glb`): Promise { - if (pieces) return; - const gltf = await new GLTFLoader().loadAsync(url); - const out = new Map(); - for (const node of gltf.scene.children) { - node.updateMatrixWorld(true); - const parts: Piece["parts"] = []; - node.traverse((o) => { - const mesh = o as THREE.Mesh; - if (!mesh.isMesh) return; - parts.push({ role: (mesh.material as THREE.Material).name, geo: mesh.geometry.clone().applyMatrix4(mesh.matrixWorld) }); - }); - const anchors: Piece["anchors"] = {}; - for (const [k, v] of Object.entries(node.userData)) { - if (Array.isArray(v) && v.length === 3) anchors[k] = new THREE.Vector3(v[0], v[1], v[2]); - } - out.set(node.name, { parts, anchors }); - } - pieces = out; -} - -export function piece(name: string): Piece { - const p = pieces?.get(name); - if (!p) throw new Error(`city kit: no piece "${name}"`); - return p; -} diff --git a/ui/src/city/land.ts b/ui/src/city/land.ts index 987e8aa..3506da8 100644 --- a/ui/src/city/land.ts +++ b/ui/src/city/land.ts @@ -1,14 +1,13 @@ // City theme: the countryside meshes, from terrain.ts. Smooth terrain // coloured by height and slope (meadow, forest floor, river sand, rock, // snow) with grass and stone detail near the eye, one water plane that -// fills the river and the lake, forests instanced from the trees model (or -// the kit) and boulders of their own (rocks()): lush broadleaf and +// fills the river and the lake, forests instanced from the trees model and +// boulders of their own (rocks()): lush broadleaf and // rainforest giants in the lowlands, conifers higher up, bare rock above the // tree line. import * as THREE from "three"; import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js"; -import { piece } from "./kit"; -import { hasReal, realMat, realPiece, tex } from "./real"; +import { realMat, realPiece, tex } from "./real"; import { IMPOSTOR_NEAR, Impostors, nearOnly } from "./impostor"; import { PITCH_X, PITCH_Z, rng, type CityPlan } from "./plan"; import { LAKE, RADIUS, ROAD_Z, WATER, fbm, noise, smoothstep, terrain, type Land as Shape } from "./terrain"; @@ -46,7 +45,6 @@ export class Land { private rockMat: THREE.MeshStandardMaterial | null = null; private rockGeos: [THREE.BufferGeometry, THREE.BufferGeometry][] = []; private waterMat: THREE.MeshStandardMaterial; - private forestMats = new Map(); /** The real woods near the camera, in tiles (see cull()). */ private tiles: { group: THREE.Group; box: THREE.Box3 }[] = []; private nearMats = new Map(); @@ -56,11 +54,11 @@ export class Land { } /** Rebuild for a city of a new size. */ - build(p: CityPlan, imps: Impostors | null = null) { + build(p: CityPlan, imps: Impostors) { this.group.clear(); this.tiles = []; const land = (this.shape = terrain(p.bounds)); - if (!this.groundDetail && hasReal("trees")) { + if (!this.groundDetail) { this.groundDetail = true; detail(this.ground); } @@ -77,7 +75,6 @@ export class Land { setNight(night: number) { this.ground.color.setRGB(1 - 0.55 * night, 1 - 0.55 * night, 1 - 0.42 * night); this.waterMat.color.setHex(0x3d6e86).lerp(C(0x0a1626), night * 0.85); - for (const m of this.forestMats.values()) m.color.setRGB(1 - 0.5 * night, 1 - 0.5 * night, 1 - 0.4 * night); } private terrainMesh(land: Shape): THREE.Mesh { @@ -158,17 +155,8 @@ export class Land { return mesh; } - private mat(role: string): THREE.MeshStandardMaterial { - let m = this.forestMats.get(role); - if (!m) { - m = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.92, vertexColors: true, flatShading: true }); - this.forestMats.set(role, m); - } - return m; - } - /** Woods and boulders, instanced per model part and per sector so the camera can cull them. */ - private forest(land: Shape, p: CityPlan, imps: Impostors | null) { + private forest(land: Shape, p: CityPlan, imps: Impostors) { const r = rng(0xf0e57); /** s scales x / z, sy height: distant clumps are wide so the canopy closes. */ type Inst = { x: number; y: number; z: number; s: number; sy: number; ry: number; color: number }; @@ -272,8 +260,6 @@ export class Land { const v = new THREE.Vector3(); const sc = new THREE.Vector3(); const col = new THREE.Color(); - // The realistic woods (trees model) draw the kit's forest pieces when loaded. - const real = hasReal("trees"); const white = new THREE.Color(0xffffff); const place = (mesh: THREE.InstancedMesh, list: Inst[], color: (t: Inst) => THREE.Color) => { list.forEach((t, i) => { @@ -286,12 +272,11 @@ export class Land { mesh.receiveShadow = true; return mesh; }; - // Realistic leaves are textured: the kit's greens only tint them, lightened. + // Leaves are textured: the palette greens only tint them, lightened. const tint = (t: Inst) => col.setHex(t.color).lerp(white, 0.45); for (const [key, list] of buckets) { const model = key.split(":")[0]; - const realistic = real && model.startsWith("forest_"); - if (realistic && imps) { + if (model.startsWith("forest_")) { // trees: far trees are impostors (impostor.ts), one InstancedMesh a // model a sector that hides the trees within IMPOSTOR_NEAR of the // camera; those are drawn real, from tiles small enough that only @@ -325,7 +310,7 @@ export class Land { continue; } if (model.startsWith("rock_")) { - // Boulders of their own (rocks()), not the kit's low-poly ones. + // Boulders of their own (rocks()). // Full detail round the town, a light one (about 500 triangles) beyond. const [hi, lo] = this.rocks()[model === "rock_a" ? 0 : 1]; const tint = (t: Inst) => col.setHex(ROCK_TINTS[Math.floor(Math.abs(t.ry * 1000)) % ROCK_TINTS.length]); @@ -335,24 +320,13 @@ export class Land { if (far.length) this.group.add(place(new THREE.InstancedMesh(lo, this.rockMat!, far.length), far, tint)); continue; } - const parts = realistic ? realPiece("trees", model) : piece(model).parts; - for (const { role, geo } of parts) { - const leafy = role === "woods" || role === "firs"; - const material = realistic ? (realMat("trees", role, { wall: -1, accent: 0, roof: 0, signs: 0 }, 0, "forest") as THREE.MeshStandardMaterial) : this.mat(role === "leaf" ? "leaf" : role); - this.group.add( - place(new THREE.InstancedMesh(geo, material, list.length), list, (t) => - realistic ? (leafy ? tint(t) : white) : col.setHex(role === "leaf" ? t.color : role === "bark" ? 0x5a4030 : 0x8a8478), - ), - ); - } } } /** - * Two boulders (rock_a, rock_b at the kit's sizes, about 1 m): a dense - * sphere pushed about by noise and cut by a few cleavage planes, smooth - * shaded, darker in its hollows, with stone on it when the library has - * loaded (triplanar, as boulders have no UVs). + * Two boulders (rock_a, rock_b, about 1 m): a dense sphere pushed about + * by noise and cut by a few cleavage planes, smooth shaded, darker in its + * hollows, with stone on it (triplanar, as boulders have no UVs). */ private rocks(): [THREE.BufferGeometry, THREE.BufferGeometry][] { if (this.rockGeos.length) return this.rockGeos; @@ -361,7 +335,7 @@ export class Land { [rock(19, [0.9, 1.1, 1.3], 8), rock(19, [0.9, 1.1, 1.3], 4)], ]; const m = (this.rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92 })); - if (hasReal("trees")) { + { const stone = tex("stone_col"); m.onBeforeCompile = (sh) => { sh.uniforms.tStone = { value: stone }; @@ -399,7 +373,7 @@ const ROCK_TINTS = [0x9a948a, 0x8a8478, 0xa49c90, 0x7c786e, 0x948c80]; const noise3 = (x: number, y: number, z: number) => (noise(x, y) + noise(y + 31.7, z - 7.3) + noise(z + 57.1, x + 13.9)) / 3; /** - * A boulder with radii `size` (x, height, z) like the kit's, base at y = 0: an icosphere + * A boulder with radii `size` (x, height, z) base at y = 0: an icosphere * (`detail` 8 is about 1,600 triangles) pushed out by noise, flattened by a few cleavage planes and * sunk at the bottom; vertex colours darken its hollows and cracks. */ diff --git a/ui/src/city/models.ts b/ui/src/city/models.ts index 851166e..16da324 100644 --- a/ui/src/city/models.ts +++ b/ui/src/city/models.ts @@ -1,23 +1,15 @@ -// City theme, step 2: 3D models for the plan's specs. Buildings and trees -// are stacked from the Blender kit (kit.ts) with materials picked per -// building and damage, or from a realistic model (real.ts) for the styles -// one replaces; lots, wreckage, rubble and the construction site are built -// here from boxes. Geometry is merged per material to keep draw calls low. +// City theme, step 2: 3D models for the plan's specs. Buildings, trees, +// cars and props are pieces of the realistic models (real.ts), stacked and +// placed per building and damage; lots, wreckage and the construction site +// are laid out here. Geometry is merged per material to keep draw calls low. import * as THREE from "three"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; -import { NOMINAL, piece } from "./kit"; import { BLOCK_X, BLOCK_Z, LOT, lot, rng, type ParkSpec, type SiteSpec, type Spec } from "./plan"; -import { CAR_PAINTS, DEFS, fountainSpray, groundMat, hasReal, realMat, realPiece, type Paint, type RealDef, waterMat } from "./real"; -import { ATLAS, panes, type Pane } from "./textures"; +import { CAR_PAINTS, DEFS, fountainSpray, groundMat, realMat, realPiece, type Paint, type RealDef, waterMat } from "./real"; export const WALLS = [0xe8d9c0, 0xd9c2a5, 0xc9d3d8, 0xe6c9b5, 0xb9c4b0, 0xd8d0e0, 0xcfb59b, 0xe9e4da]; export const GLASS = [0x7f9bb3, 0x6d8a99, 0x8fa3b8, 0x5d7488, 0x9ab0a8, 0x7a8aa8, 0x8a98b0, 0x6f8c9c]; export const ACCENTS = [0xb5483a, 0x3a6fb5, 0x3a9a6a, 0xc9902a, 0x7a4ab5, 0x2f3a4a]; -/** Cottage roofs, by accent: terracotta, slate, moss, clay, plum, charcoal. */ -const ROOFS = [0xa8503c, 0x56606e, 0x5e6e4a, 0xb8764a, 0x6e4a5a, 0x3e4044]; -const LEAVES = [0x4f7a3a, 0x5d8a42, 0x3f6a34, 0x6a8f3e, 0x48703f]; -const BLOSSOMS = [0xf2a5c0, 0xf7c6d6, 0xe88aa8, 0xf5e0ea]; -const BRICKS = [0x8a4e3c, 0x9a5a44, 0x7a4636, 0xa0664a, 0x6e3e30, 0x94604c]; /** Things the scene animates on a model. Positions are model-local. */ export interface ModelInfo { @@ -33,10 +25,8 @@ export interface ModelInfo { jibBeacons?: THREE.Vector3[]; } -const UP = new THREE.Vector3(0, 1, 0); /** Lots, pavements and park lawns sit at kerb height. */ export const KERB = 0.2; -const CAR_PAINT = [0xa8acb0, 0x2a2e36, 0xa83a32, 0x2f5a8a, 0xbcb8b0, 0x5a6a4a, 0x8a6a3a, 0x3a3e44]; const shared = new Map(); /** The one material of every yard (see Parts' tint). */ @@ -72,12 +62,8 @@ export class Parts { */ constructor(private tint?: THREE.Material) {} - /** - * `cells` moves window panes to another cell of the kit's atlas; `shift` - * adds to UV x, which for realistic glass gives its windows new ids (other - * rooms, curtains and lights). - */ - add(material: THREE.Material, g: THREE.BufferGeometry, x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale?: THREE.Vector3, cells?: [number, number], shift = 0) { + /** `shift` adds to UV x, which for glass gives its windows new ids (other rooms, curtains and lights). */ + add(material: THREE.Material, g: THREE.BufferGeometry, x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale?: THREE.Vector3, shift = 0) { this.q.setFromEuler(this.e.set(rx, ry, rz)); this.m.compose(new THREE.Vector3(x, y, z), this.q, scale ?? new THREE.Vector3(1, 1, 1)); const geo = g.index ? g.toNonIndexed() : g.clone(); @@ -96,10 +82,6 @@ export class Parts { geo.deleteAttribute("uv"); material = this.tint; } - if (cells) { - const uv = geo.getAttribute("uv") as THREE.BufferAttribute; - for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) + cells[0] / ATLAS, uv.getY(i) + cells[1] / ATLAS); - } if (shift) { const uv = geo.getAttribute("uv") as THREE.BufferAttribute; for (let i = 0; i < uv.count; i++) uv.setX(i, uv.getX(i) + shift); @@ -135,295 +117,21 @@ export class Parts { } } -function shade(color: number, damage: number): THREE.Color { - return new THREE.Color(color).multiplyScalar([1, 0.78, 0.5][Math.min(2, damage)]); -} - -// ---- kit materials ----------------------------------------------------------- - -interface RoleLook { - color: number; - rough?: number; - metal?: number; - emissive?: number; - night?: number; - double?: boolean; -} - -/** Roles whose look does not depend on the building. */ -const ROLES: Record = { - trim: { color: 0xe6dfd2, rough: 0.75 }, - stone: { color: 0xa9a094, rough: 0.9 }, - frame: { color: 0xf3f0ea, rough: 0.6 }, - deck: { color: 0x505256, rough: 0.95 }, - canvas: { color: 0xf2ede0, rough: 0.9, double: true }, - board: { color: 0x1d2024, rough: 0.6 }, - shopfront: { color: 0x2a323c, rough: 0.2, metal: 0.4, emissive: 0xffd9a0, night: 0.9 }, - lobby: { color: 0x2a323c, rough: 0.2, metal: 0.4, emissive: 0xffe2b8, night: 0.5 }, - crownlight: { color: 0x8a6a3a, rough: 0.3, metal: 0.3, emissive: 0xffc870, night: 1.6 }, - door: { color: 0x4a2f20, rough: 0.6 }, - metal: { color: 0x8c9298, rough: 0.4, metal: 0.6 }, - dark: { color: 0x2e2c2a, rough: 0.9 }, - gold: { color: 0xd9a441, rough: 0.3, metal: 0.85, emissive: 0xffb84a, night: 0.9 }, - brick: { color: 0x8a4e3c, rough: 0.9 }, - wood: { color: 0x6e4a32, rough: 0.85 }, - bark: { color: 0x5a4030, rough: 1 }, - paint: { color: 0xf0e8d0, rough: 0.7 }, - railglass: { color: 0x5a7080, rough: 0.15, metal: 0.4 }, - crane: { color: 0xe8b020, rough: 0.5, metal: 0.3 }, - concrete: { color: 0x9a9890, rough: 0.95 }, - grate: { color: 0x4a4c50, rough: 0.6, metal: 0.5 }, - birch: { color: 0xe8e4dc, rough: 0.9 }, - carglass: { color: 0x1a2028, rough: 0.1, metal: 0.6 }, - steel: { color: 0x3e5a52, rough: 0.5, metal: 0.5 }, - led: { color: 0x9ab0c8, rough: 0.3, metal: 0.2, emissive: 0xd8ecff, night: 2 }, - water: { color: 0x4a86a0, rough: 0.08, metal: 0.3 }, - tyre: { color: 0x1a1a1a, rough: 0.9 }, - chrome: { color: 0xc8ccd0, rough: 0.3, metal: 0.8 }, - headlight: { color: 0xf0ecd8, rough: 0.3 }, - taillight: { color: 0x8a1810, rough: 0.4 }, - taxisign: { color: 0xf2c12e, rough: 0.5 }, -}; - -const kitShared = new Map(); - -/** A kit material: lit by the baked occlusion in the vertex colours. */ -function kitMat(look: RoleLook, damage: number): THREE.MeshStandardMaterial { - const key = `${JSON.stringify(look)}:${damage}`; - let m = kitShared.get(key); - if (!m) { - m = new THREE.MeshStandardMaterial({ - color: shade(look.color, damage), - roughness: look.rough ?? 0.8, - metalness: look.metal ?? 0, - vertexColors: true, - side: look.double ? THREE.DoubleSide : THREE.FrontSide, - }); - // Burnt-out shops and lobbies stay dark. - if (look.emissive !== undefined && damage < 2) { - m.emissive.setHex(look.emissive); - m.emissiveIntensity = 0; - m.userData.night = look.night; - } - kitShared.set(key, m); - } - return m; -} - -/** Window panes: one atlas cell each, lit at night, smashed when broken. */ -function paneMat(kind: Pane, color: number, damage: number): THREE.MeshStandardMaterial { - const key = `pane:${kind}:${color}:${damage}`; - let m = kitShared.get(key); - if (!m) { - const set = panes(kind); - const broken = damage >= 2; - const glass = kind === "glass"; - m = new THREE.MeshStandardMaterial({ - map: broken ? set.brokenMap : set.map, - emissiveMap: broken ? set.brokenEmissive : set.emissive, - emissive: 0xffffff, - emissiveIntensity: 0, - color: shade(color, damage), - roughness: glass ? 0.12 : 0.25, - metalness: glass ? 0.6 : 0.2, - envMapIntensity: glass ? 1.4 : 1, - vertexColors: true, - }); - m.userData.night = damage === 1 ? 0.35 : 0.6; - kitShared.set(key, m); - } - return m; -} - -type Look = (role: string) => THREE.MeshStandardMaterial; - -/** The plain material for a kit role (bridges, street furniture). */ -export const kitLook: Look = (role) => kitMat(ROLES[role] ?? ROLES.metal, 0); - -/** Materials for a building's roles; night-lit ones are collected in info. */ -function looks(s: Spec, info: ModelInfo): Look { - const office = s.kind === "office"; - const wall = office ? new THREE.Color(GLASS[s.wall]).lerp(new THREE.Color(0xe0e0dc), 0.7).getHex() : WALLS[s.wall]; - const byRole: Record = { - wall: { color: wall, rough: 0.85 }, - roof: { color: ROOFS[s.accent], rough: 0.8 }, - accent: { color: ACCENTS[s.accent], rough: 0.7, double: true }, - sign: { color: ACCENTS[s.accent], emissive: new THREE.Color(ACCENTS[s.accent]).lerp(new THREE.Color(0xffffff), 0.35).getHex(), night: 1.8 }, - spandrel: { color: new THREE.Color(office ? GLASS[s.wall] : 0x4a4e58).multiplyScalar(0.45).getHex(), rough: 0.3, metal: 0.5 }, - leaf: { color: LEAVES[s.seed % LEAVES.length], rough: 0.9 }, - brick: { color: BRICKS[s.wall % BRICKS.length], rough: 0.9 }, - }; - const glass = office ? GLASS[s.wall] : s.kind === "landmark" ? 0xb4c0cc : 0xa8bccc; - const seen = new Set(); - return (role) => { - const m = - role === "window" ? paneMat("window", 0xffffff, s.damage) - : role === "glass" ? paneMat("glass", glass, s.damage) - : kitMat(byRole[role] ?? ROLES[role] ?? { color: 0xff00ff }, s.damage); - if (m.userData.night && !seen.has(m)) { - seen.add(m); - info.night.push(m); - } - return m; - }; -} - -/** Puts a kit piece on the stack at height y, scaled to the footprint and turned by ry. */ -function put(p: Parts, name: string, y: number, sx: number, sz: number, look: Look, r: () => number, ry = 0) { - const cells: [number, number] = [Math.floor(r() * ATLAS), Math.floor(r() * ATLAS)]; - const scale = new THREE.Vector3(sx, 1, sz); - for (const { role, geo } of piece(name).parts) { - p.add(look(role), geo, 0, y, 0, 0, ry, 0, scale, role === "window" || role === "glass" ? cells : undefined); - } - return piece(name).anchors; -} - -/** - * Stacks the first `floors` storeys of a building from the kit, and its roof - * if `roof`. Returns the height reached, the footprint there, and anchors - * (piece-local, already moved onto the stack). - */ -function stack(p: Parts, s: Spec, look: Look, floors: number, roof: boolean) { - const r = rng(s.seed * 13 + 7); - const variant = (n: number) => "abcd"[Math.floor(r() * n)]; - const [W0, D0] = NOMINAL[s.kind]; - const sx = s.w / W0; - const sz = s.d / D0; - const fh = s.floorH; - const anchors: Record = {}; - let k = 1; - const place = (name: string, y: number, ry = 0) => { - for (const [key, a] of Object.entries(put(p, name, y, sx * k, sz * k, look, r, ry))) { - anchors[key] = new THREE.Vector3(a.x * sx * k, a.y + y, a.z * sz * k).applyAxisAngle(UP, ry); - } - }; - switch (s.style) { - case "modern": - case "aframe": { - const v = s.style === "modern" ? "e" : "f"; - if (floors > 0) place(`cottage_body_${v}`, 0); - if (roof) place(`cottage_roof_${v}`, fh); - return { top: floors * fh, w: s.w, d: s.d, anchors }; - } - case "diner": - case "substation": - case "factory": { - const [g, rf] = s.style === "diner" ? ["shop_ground_d", "shop_roof_d"] : ["factory_ground", "factory_roof"]; - if (floors > 0) place(g, 0); - if (roof) place(rf, fh); - return { top: floors * fh, w: s.w, d: s.d, anchors }; - } - case "townhouse": { - for (let f = 0; f < floors; f++) place(f === 0 ? "town_ground" : "town_floor", f * fh); - if (roof) place("town_roof", floors * fh); - return { top: floors * fh, w: s.w, d: s.d, anchors }; - } - case "round": { - const lower = Math.max(1, Math.round(s.floors * 0.68)); - for (let f = 0; f < floors; f++) { - if (f === lower) { - place("round_setback", f * fh); - k = 0.78; - } - place(f === 0 ? "round_ground" : "round_floor", f * fh); - } - if (roof) place("round_roof", floors * fh); - return { top: floors * fh, w: s.w * k, d: s.d * k, anchors }; - } - case "twist": { - // Each storey turns a little and the tower tapers: a quarter turn in all. - const step = Math.PI / 2 / s.floors; - const at = (f: number) => 1 - 0.14 * (f / s.floors); - for (let f = 0; f < floors; f++) { - k = f === 0 ? 1 : at(f); - place(f === 0 ? "lm3_ground" : "lm3_floor", f * fh, f === 0 ? 0 : f * step); - } - k = at(floors); - if (roof) place("lm3_crown", floors * fh, floors * step); - return { top: floors * fh, w: s.w * k * 0.9, d: s.d * k * 0.9, anchors }; - } - case "tvtower": { - // A concrete shaft with the observation pod three quarters up. - const pod = Math.round(s.floors * 0.74); - for (let f = 0; f < floors; f++) { - if (f === 0) place("tvt_base", 0); - else if (f === pod) place("tvt_pod", f * fh); - else if (f !== pod + 1) place("tvt_shaft", f * fh); - } - if (roof) place("tvt_spire", floors * fh); - k = 0.32; - return { top: floors * fh, w: s.w * k, d: s.d * k, anchors }; - } - } - switch (s.kind) { - case "cottage": { - const v = variant(4); - if (floors > 0) place(`cottage_body_${v}`, 0); - if (roof) place(`cottage_roof_${v}`, fh); - break; - } - case "shop": - case "apartment": { - const shop = s.kind === "shop"; - const pre = shop ? "shop" : "apt"; - const fv = variant(3); - const rv = variant(2); - // Brick walk-ups (apartment c) have their own ground storey and roof. - const ground = shop ? `shop_ground_${fv}` : fv === "c" ? "apt_ground_c" : "apt_ground"; - for (let f = 0; f < floors; f++) place(f === 0 ? ground : `${pre}_floor_${fv}`, f * fh); - if (roof) place(!shop && fv === "c" ? "apt_roof_c" : `${pre}_roof_${rv}`, floors * fh); - break; - } - case "office": { - const fv = variant(3); - const rv = variant(2); - const lower = Math.max(1, Math.round(s.floors * 0.68)); - for (let f = 0; f < floors; f++) { - if (f === lower) { - place("office_setback", f * fh); - k = 0.78; - } - place(f === 0 ? "office_ground" : `office_floor_${fv}`, f * fh); - } - if (roof) place(`office_roof_${rv}`, floors * fh); - break; - } - case "landmark": { - // Art deco stone and gold, or a modern glass tower with sky gardens. - const pre = s.style === "glass" ? "lm2" : "lm"; - const e1 = Math.round(s.floors * 0.5); - const e2 = Math.round(s.floors * 0.8); - let tier = 1; - for (let f = 0; f < floors; f++) { - const t = f < e1 ? 1 : f < e2 ? 2 : 3; - if (t !== tier) place(`${pre}_ledge_${tier}`, f * fh); - tier = t; - place(f === 0 ? `${pre}_ground` : `${pre}_floor_${t}`, f * fh); - } - if (roof) place(`${pre}_crown`, floors * fh); - // Landmark tiers are modelled at their own sizes; k is only the top's. - k = [1, 0.78, 0.56][tier - 1]; - break; - } - } - return { top: floors * fh, w: s.w * k, d: s.d * k, anchors }; -} - -// ---- realistic models ----------------------------------------------------------- +// ---- buildings ------------------------------------------------------------------- /** - * The realistic model that draws a spec, if it is loaded. A model takes over - * the kit style it replaces (docs/APP.md) as a whole, until its siblings - * exist to take their own variants of it; siblings (C1 and C2 for the - * classic cottage) share the style, picked per building from its seed. + * The model that draws a spec: its blueprint's, else one of its style's, + * picked per building from its seed (siblings such as C1 and C2 share the + * classic cottage). Null only for the town's lots built in code (water + * tower, square, park: utility()). */ export function realDef(s: Spec): RealDef | null { - if (s.model) return DEFS[s.model] && hasReal(s.model) ? DEFS[s.model] : null; - const defs = Object.values(DEFS).filter((d) => d.kind === s.kind && d.style === s.style && hasReal(d.id)); + if (s.model) return DEFS[s.model] ?? null; + const defs = Object.values(DEFS).filter((d) => d.kind === s.kind && d.style === s.style); return defs.length ? defs[Math.floor(rng(s.seed * 29 + 3)() * defs.length)] : null; } -/** A spec sized and placed for its realistic model (real-world sizes, not the kit's); others unchanged. */ +/** A spec sized and placed for its model (real-world sizes); the town's own lots unchanged. */ export function fitReal(s: T): T { const def = realDef(s); if (!def) return s; @@ -440,11 +148,11 @@ function putReal(p: Parts, def: RealDef, piece: string, paint: Paint, damage: nu for (const { role, geo } of realPiece(def.id, piece)) { const m = realMat(def.id, role, paint, damage); if (info && m.userData.night && !info.night.includes(m as THREE.MeshStandardMaterial)) info.night.push(m as THREE.MeshStandardMaterial); - p.add(m, geo, x, y, z, 0, ry, 0, scale, undefined, role.endsWith("glass") ? shift : 0); + p.add(m, geo, x, y, z, 0, ry, 0, scale, role.endsWith("glass") ? shift : 0); } } -/** Stacks a realistic model's pieces on the lot (at kerb height), like stack() for the kit. */ +/** Stacks a model's pieces on the lot (at kerb height). */ function stackReal(p: Parts, s: Spec, def: RealDef, floors: number, roof: boolean, info: ModelInfo) { const r = rng(s.seed * 13 + 11); const paint = paintOf(s); @@ -465,7 +173,7 @@ function picket(p: Parts, def: RealDef, s: Spec, x0: number, x1: number, z: numb putReal(p, def, "fence_post", paint, 0, null, x1, KERB, z); } -/** The lot round a realistic model; like yard() for the kit. */ +/** The lot round a model: what its kind of building has in front and out back. */ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const cz = -s.yard; const front = LOT / 2 - s.yard; @@ -478,8 +186,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { p.box(paving, LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); const bin = mat(0x2a4a34, { roughness: 0.6 }); for (let i = 0; i < 3; i++) p.box(bin, 0.7, 1.1, 0.7, half - 0.6 - i * 0.85, KERB, back + 0.7); - if (hasReal("street")) fenceRun(p, "wall_brick", half, back + 0.12, -half, back + 0.12, 1.5); - else p.box(mat(0x7a4636, { roughness: 0.95 }), LOT - 0.4, 1.6, 0.25, 0, KERB, back + 0.12); + fenceRun(p, "wall_brick", half, back + 0.12, -half, back + 0.12, 1.5); break; } case "a2": { @@ -498,17 +205,13 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { x0 = Math.max(x0, b); } if (half - x0 > 0.3) runs.push([x0, half]); - if (hasReal("street")) { - for (const [a, b] of runs) fenceRun(p, "fence_iron", a, fz, b, fz, 0.85); - } else for (const [a, b] of runs) p.box(mat(0x2a2c2e, { metalness: 0.5 }), b - a, 0.9, 0.06, (a + b) / 2, KERB, fz); + for (const [a, b] of runs) fenceRun(p, "fence_iron", a, fz, b, fz, 0.85); const bin = mat(0x3a3e40, { roughness: 0.55, metalness: 0.3 }); for (const [x, z] of [[-5.1, 4.0], [-4.6, 4.0], [0.0, 4.0], [2.6, 4.0]]) p.box(bin, 0.55, 0.95, 0.55, x, KERB, z); for (const x of [-6.8, -5.5, 1.4, 4.2, 5.4]) if (r() < 0.75) shrub(p, x, fz - 0.6, s.seed * 5 + x * 3, KERB, 0.55); tree(p, 4.4, fz - 1.4, 0.45, s.seed + 1, KERB, ["young", "linden", "cherry"]); - if (hasReal("street")) { - for (const x of [-2.6, 2.6]) fenceRun(p, "fence_panel", x, -6.8, x, back + 0.1); - fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); - } + for (const x of [-2.6, 2.6]) fenceRun(p, "fence_panel", x, -6.8, x, back + 0.1); + fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); break; } case "c1": { @@ -552,7 +255,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { for (const x of [2.2, 3.2]) if (r() < 0.8) shrub(p, x, 4.55, s.seed * 7 + x * 3, KERB, 0.7); tree(p, -half + 1.4, fz - 1.4, 0.6, s.seed + 1, KERB, ["cherry", "birch", "maple", "cherry"]); const yb = -s.d / 2 + 0.6; // the back wall - if (hasReal("street")) for (const x of [-half + 0.1, half - 0.1]) fenceRun(p, "fence_panel", x, yb, x, back + 0.6); + for (const x of [-half + 0.1, half - 0.1]) fenceRun(p, "fence_panel", x, yb, x, back + 0.6); hedge(p, LOT - 0.4, KERB + 1.5, 0.7, 0, 0, back + 0.55, s.seed); tree(p, -half + 2.2, back + 1.6, 0.7, s.seed + 2, KERB, ["young", "cherry", "linden"]); break; @@ -584,10 +287,8 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const dx = half - 1.7; p.box(mat(0x9e978c, { roughness: 1 }), 2.8, KERB + 0.03, front - cz - 0.6, dx, 0, (front + cz + 0.6) / 2); parked(p, dx, 0.2, 0, s.seed + 3); - if (hasReal("street")) { - fenceRun(p, "fence_rail", -half + 0.2, fz, -0.6, fz); - fenceRun(p, "fence_rail", 0.6, fz, dx - 1.6, fz); - } + fenceRun(p, "fence_rail", -half + 0.2, fz, -0.6, fz); + fenceRun(p, "fence_rail", 0.6, fz, dx - 1.6, fz); const pines = ["spruce", "pine", "spruce", "birch", "pine"]; tree(p, -half + 1.3, front - 2.4, 0.95, s.seed + 1, KERB, pines); tree(p, -half + 1.6, back + 2.0, 1.1, s.seed + 2, KERB, pines); @@ -632,7 +333,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // ambulance bay on the right. p.box(lawn(), LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); p.box(paving, LOT - 0.4, KERB + 0.01, front - 4.5, 0, 0, (front + 4.5) / 2); - const asphalt = hasReal("street") ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); p.box(asphalt, half - 6.5, KERB + 0.012, 8, (half + 6.5) / 2, 0, 0.4); const line = mat(0xd8b83a, { roughness: 0.7 }); for (const z of [-3.2, 4.0]) p.box(line, half - 6.6, KERB + 0.02, 0.1, (half + 6.6) / 2, 0, z); @@ -646,7 +347,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // asphalt playground round the sides and back, hopscotch on it. p.box(lawn(), LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); p.box(paving, 2.6, KERB + 0.01, front - 5.0, 0, 0, (front + 5.0) / 2); - const asphalt = hasReal("street") ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); p.box(asphalt, LOT - 0.4, KERB + 0.005, 5.5 - back, 0, 0, (5.5 + back) / 2); const chalk = mat(0xe8e4d8, { roughness: 0.8 }); for (let i = 0; i < 6; i++) { @@ -654,10 +355,8 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { if (i % 3 === 1) for (const dx of [-0.31, 0.31]) p.box(chalk, 0.5, KERB + 0.012, 0.5, -7.25 + dx, 0, z); else p.box(chalk, 0.5, KERB + 0.012, 0.5, -7.25, 0, z); } - if (hasReal("street")) { - fenceRun(p, "fence_iron", -half, front - 0.3, -1.4, front - 0.3, 1.1); - fenceRun(p, "fence_iron", 1.4, front - 0.3, half, front - 0.3, 1.1); - } + fenceRun(p, "fence_iron", -half, front - 0.3, -1.4, front - 0.3, 1.1); + fenceRun(p, "fence_iron", 1.4, front - 0.3, half, front - 0.3, 1.1); const pole = mat(0xe8e6e0, { roughness: 0.4, metalness: 0.4 }); p.add(pole, new THREE.CylinderGeometry(0.04, 0.07, 9, 10), 4.6, KERB + 4.5, front - 1.2); p.add(mat(0xc9a24e, { roughness: 0.3, metalness: 1 }), new THREE.SphereGeometry(0.1, 10, 8), 4.6, KERB + 9.05, front - 1.2); @@ -674,17 +373,15 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { for (const dx of [-1.7, 1.7]) p.box(yellow, 0.12, KERB + 0.01, front - s.d / 2 - 0.3, x + dx, 0, (front + s.d / 2) / 2); for (let i = 0; i < 4; i++) p.box(yellow, 3.3, KERB + 0.01, 0.14, x, 0, s.d / 2 + 0.5 + i * 0.75); } - if (hasReal("street")) { - fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); - fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, -s.d / 2, 1.8); - } + fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); + fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, -s.d / 2, 1.8); break; } case "s3": { // An asphalt forecourt to the pavement with a car drawn up beside the // steps and shrubs either side of them; behind, a concrete service yard // round the kitchen with bins, a grease drum and gas bottles, fenced. - const asphalt = hasReal("street") ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); p.box(asphalt, LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); const line = mat(0xe8e4d8, { roughness: 0.7 }); for (const x of [-6.9, -1.7]) p.box(line, 0.12, KERB + 0.01, 2.2, x, 0, front - 2.0); @@ -697,7 +394,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { p.add(mat(0x3a5a8a, { roughness: 0.5, metalness: 0.3 }), new THREE.CylinderGeometry(0.29, 0.29, 0.88, 14), -half + 0.8, KERB + 0.44, back + 0.7); const gas = mat(0xe8e6e0, { roughness: 0.4, metalness: 0.2 }); for (const x of [-5.2, -4.6]) p.add(gas, new THREE.CapsuleGeometry(0.19, 0.95, 4, 12), x, KERB + 0.67, -3.0); - if (hasReal("street")) fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); + fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); break; } case "s4": { @@ -718,14 +415,9 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const yardZ = -5.2; // the back wall p.box(mat(0x4a5a3e, { roughness: 0.6, metalness: 0.4 }), 3.4, 1.3, 1.7, -3.2, KERB, (yardZ + back) / 2, 0.04); p.box(mat(0x5a4a3e, { roughness: 0.8, metalness: 0.5 }), 1.6, 0.4, 0.9, 2.6, KERB, (yardZ + back) / 2, 0.3); - if (hasReal("street")) { - fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); - fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, yardZ, 1.8); - fenceRun(p, "wall_brick", half - 0.12, yardZ, half - 0.12, back + 0.12, 1.8); - } else { - const wall = mat(0x7a4636, { roughness: 0.95 }); - p.box(wall, LOT - 0.4, 1.8, 0.25, 0, KERB, back + 0.12); - } + fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); + fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, yardZ, 1.8); + fenceRun(p, "wall_brick", half - 0.12, yardZ, half - 0.12, back + 0.12, 1.8); break; } case "a3": { @@ -745,7 +437,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const backWall = -s.d / 2 + 0.3; p.box(paving, 1.6, KERB + 0.02, backWall - back, 0, 0, (backWall + back) / 2); hedge(p, LOT - 0.4, KERB + 1.2, 0.7, 0, 0, back + 0.5, s.seed + 3); - if (hasReal("street")) furniture(p, "bench", -3.5, KERB, back + 1.6, Math.PI); + furniture(p, "bench", -3.5, KERB, back + 1.6, Math.PI); break; } case "o1": { @@ -756,9 +448,9 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const fz = s.d / 2; for (const sx of [-1, 1]) { planter(p, sx * 5.6, front - 1.3, s.seed + (sx > 0 ? 1 : 2)); - if (hasReal("street")) furniture(p, "bench", sx * 3.3, KERB, front - 1.3, 0); + furniture(p, "bench", sx * 3.3, KERB, front - 1.3, 0); } - if (hasReal("street")) for (let i = 0; i < 5; i++) furniture(p, "bollard", -2.4 + i * 1.2, KERB, front - 0.45); + for (let i = 0; i < 5; i++) furniture(p, "bollard", -2.4 + i * 1.2, KERB, front - 0.45); p.box(mat(0x6a6a6c, { roughness: 0.6 }), 4.4, 0.02, 1.6, 0, KERB, fz + 0.9); const bin = mat(0x2a3a4a, { roughness: 0.6 }); for (let i = 0; i < 3; i++) p.box(bin, 1.2, 1.3, 1.0, -half + 1.0 + i * 1.3, KERB, back + 0.9); @@ -768,7 +460,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // Granite paving to the pavement, heritage lamps either side of the // portal; behind, the bins by the service door. p.box(mat(0x9c9890, { roughness: 0.6 }), LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); - if (hasReal("street")) for (const sx of [-1, 1]) furniture(p, "lamp_heritage", sx * 4.5, KERB, front - 0.4, 0); + for (const sx of [-1, 1]) furniture(p, "lamp_heritage", sx * 4.5, KERB, front - 0.4, 0); const bin = mat(0x2a3a4a, { roughness: 0.6 }); for (let i = 0; i < 3; i++) p.box(bin, 1.0, 1.2, 0.5, -half + 1.0 + i * 1.1, KERB, back + 0.3); break; @@ -779,16 +471,14 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // cars stand (white and dark blue) by a brick wall. p.box(paving, LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); const yardZ = -s.d / 2; - const asphalt = hasReal("street") ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); p.box(asphalt, LOT - 0.4, KERB + 0.01, yardZ - back, 0, 0, (yardZ + back) / 2); const line = mat(0xe8e4d8, { roughness: 0.7 }); for (const x of [-4.6, -1.6, 1.4, 4.4]) p.box(line, 0.1, KERB + 0.02, 2.2, x, 0, (yardZ + back) / 2); parked(p, -3.1, (yardZ + back) / 2, Math.PI / 2, s.seed + 3, KERB, { name: "estate", paint: 0 }); parked(p, 2.9, (yardZ + back) / 2, Math.PI / 2, s.seed + 4, KERB, { name: "sedan", paint: 5 }); - if (hasReal("street")) { - fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); - for (const sx of [-1, 1]) furniture(p, "bollard", sx * 1.8, KERB, front - 0.45); - } + fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.8); + for (const sx of [-1, 1]) furniture(p, "bollard", sx * 1.8, KERB, front - 0.45); break; } case "e3": { @@ -796,7 +486,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // it; lawns down the sides and behind, a tree at each back corner. p.box(lawn(), LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); p.box(mat(0x9c9890, { roughness: 0.7 }), LOT - 0.4, KERB + 0.01, front - 4.0, 0, 0, (front + 4.0) / 2); - if (hasReal("street")) for (const sx of [-1, 1]) { + for (const sx of [-1, 1]) { furniture(p, "lamp_heritage", sx * 6.4, KERB, front - 0.5, 0); furniture(p, "bench", sx * 5.6, KERB, 4.6, 0); } @@ -808,13 +498,13 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { // under the emergency canopy (h2_hospital.py, x 2.3 to 7.8) with an // ambulance drawn up, a drop-off line; a service road behind. p.box(paving, LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); - const asphalt = hasReal("street") ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); const dz = 1.5; // the podium's front, building-local (CAN / 2) p.box(asphalt, half - 2.2, KERB + 0.012, front - dz, (half + 2.2) / 2, 0, (front + dz) / 2); p.box(mat(0xd8382a, { roughness: 0.7 }), half - 2.4, KERB + 0.02, 0.12, (half + 2.4) / 2, 0, dz + 0.3); parked(p, 5.2, (front + dz) / 2 + 0.2, 0, s.seed + 3, KERB, { name: "van", paint: 0 }); p.box(asphalt, LOT - 0.4, KERB + 0.01, Math.max(0.5, -s.d / 2 - back), 0, 0, (-s.d / 2 + back) / 2); - if (hasReal("street")) for (const x of [-6.6, -4.6]) furniture(p, "bench", x, KERB, front - 1.2, 0); + for (const x of [-6.6, -4.6]) furniture(p, "bench", x, KERB, front - 1.2, 0); break; } case "p2": { @@ -824,7 +514,7 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { const coal = mat(0x1c1c1e, { roughness: 0.95 }); p.add(coal, new THREE.ConeGeometry(2.2, 1.6, 9), -4.5, KERB + 0.8, -s.d / 2 + 0.2); p.add(coal, new THREE.ConeGeometry(1.6, 1.1, 8), -1.8, KERB + 0.55, -s.d / 2 + 0.6); - if (hasReal("street")) fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); + fenceRun(p, "fence_panel", half, back + 0.1, -half, back + 0.1); break; } case "w2": { @@ -849,15 +539,9 @@ function realYard(p: Parts, rp: Parts, s: Spec, def: RealDef, r: () => number) { for (let i = 0; i < 2; i++) p.box(bin, 0.7, 1.1, 0.7, -half + 0.6 + i * 0.85, KERB, yardZ - 0.6); p.box(mat(0x3a4048, { roughness: 0.6, metalness: 0.3 }), 1.6, 1.3, 1.1, -half + 3.0, KERB, yardZ - 0.75); parked(p, 2.2 + r() * 1.5, (yardZ + back) / 2 + 0.2, Math.PI / 2, s.seed + 3); - if (hasReal("street")) { - fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.45); - fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, yardZ, 1.45); - fenceRun(p, "wall_brick", half - 0.12, yardZ, half - 0.12, back + 0.12, 1.45); - } else { - const wall = mat(0x7a4636, { roughness: 0.95 }); - p.box(wall, LOT - 0.4, 1.5, 0.25, 0, KERB, back + 0.12); - for (const sx of [-1, 1]) p.box(wall, 0.25, 1.5, yardZ - back, sx * (half - 0.12), KERB, (yardZ + back) / 2); - } + fenceRun(p, "wall_brick", half - 0.12, back + 0.12, -half + 0.12, back + 0.12, 1.45); + fenceRun(p, "wall_brick", -half + 0.12, back + 0.12, -half + 0.12, yardZ, 1.45); + fenceRun(p, "wall_brick", half - 0.12, yardZ, half - 0.12, back + 0.12, 1.45); break; } } @@ -878,8 +562,7 @@ function wreckTop(p: Parts, w: number, d: number, y: number, color: number, r: ( } } -const TREES = ["tree_a", "tree_a", "tree_b", "tree_b", "tree_c", "tree_d", "tree_d", "tree_e", "tree_f"]; -/** Realistic species, by how common they are in town. */ +/** Tree species, by how common they are in town. */ export const SPECIES = ["maple", "linden", "oak", "maple", "linden", "birch", "cherry", "young", "spruce", "oak", "birch", "maple", "pine", "poplar", "linden", "cherry"]; const SHRUBS = ["bush", "bush", "bush_wide", "shrub", "flowers"]; @@ -892,20 +575,18 @@ const tintOf = (i: number): Paint => ({ wall: i, accent: 0, roof: 0, signs: 0 }) */ const LAWN_TINT = 0xbcae88; -/** A lawn: mown grass from the realistic library when loaded, else flat green. */ +/** Loose gravel or sand (paths, a pond's edge, a running track), tinted, from the shared texture library. */ +function gravelMat(color: number, tile = 2.4): THREE.Material { + return groundMat("gravel", tile, { roughness: 1, normal: 0.7, color }); +} + +/** A lawn: mown grass. */ export function lawn(): THREE.Material { - return hasReal("trees") ? groundMat("grass", 2.2, { roughness: 0.95, normal: 0.8, color: LAWN_TINT }) : mat(0x6f9a4f, { roughness: 1 }); + return groundMat("grass", 2.2, { roughness: 0.95, normal: 0.8, color: LAWN_TINT }); } -/** - * A clipped hedge where a box would be (w along x, d along z, from y up to - * y + h): realistic segments stretched to fit when loaded, else the box. - */ +/** A clipped hedge filling a box (w along x, d along z, from y up to y + h): segments stretched to fit. */ export function hedge(p: Parts, w: number, h: number, d: number, x: number, y: number, z: number, seed: number) { - if (!hasReal("trees")) { - p.box(mat(0x3f6a34, { flatShading: true }), w, h, d, x, y, z); - return; - } const alongX = w >= d; const L = alongX ? w : d; const D = alongX ? d : w; @@ -918,7 +599,7 @@ export function hedge(p: Parts, w: number, h: number, d: number, x: number, y: n } } -/** A piece of the realistic street furniture; `phase` ("a" / "b") picks a crossing's signal cycle. */ +/** A piece of street furniture; `phase` ("a" / "b") picks a crossing's signal cycle. */ export function furniture(p: Parts, name: string, x: number, y: number, z: number, ry = 0, scale?: THREE.Vector3, phase = "") { for (const { role, geo } of realPiece("street", name)) p.add(realMat("street", role, tintOf(0), 0, phase), geo, x, y, z, 0, ry, 0, scale); } @@ -947,9 +628,8 @@ export function fenceRun(p: Parts, name: string, x0: number, z0: number, x1: num } } -/** A garden shrub (nothing without the realistic trees: the kit has none). */ +/** A garden shrub. */ export function shrub(p: Parts, x: number, z: number, seed: number, y = KERB, s = 1) { - if (!hasReal("trees")) return; const r = rng(Math.floor(seed)); const name = SHRUBS[Math.floor(r() * SHRUBS.length)]; const k = s * (0.8 + r() * 0.4); @@ -959,59 +639,26 @@ export function shrub(p: Parts, x: number, z: number, seed: number, y = KERB, s for (const { role, geo } of realPiece("trees", name)) p.add(realMat("trees", role, tint, 0), geo, x, y, z, 0, ry, 0, scale); } -/** A tree; `s` was the kit's scale (0.35-1.2), which picks the realistic tree's size too; `species` the realistic ones to pick from. */ +/** A tree; `s` (0.35-1.2) picks its size, `species` what it may be. */ export function tree(p: Parts, x: number, z: number, s: number, seed: number, y = 0, species = SPECIES) { const r = rng(seed); - if (hasReal("trees")) { - const name = species[Math.floor(r() * species.length)]; - const k = 0.5 + 0.55 * s; - const scale = new THREE.Vector3(k, k * (0.88 + r() * 0.24), k); - const ry = r() * Math.PI * 2; - const tint = tintOf(Math.floor(r() * 6)); - for (const { role, geo } of realPiece("trees", name)) p.add(realMat("trees", role, tint, 0), geo, x, y, z, 0, ry, 0, scale); - return; - } - const name = TREES[Math.floor(r() * TREES.length)]; - const leaf = kitMat({ color: LEAVES[Math.floor(r() * LEAVES.length)], rough: 0.9 }, 0); - const roles: Record = { - leaf, - blossom: kitMat({ color: BLOSSOMS[Math.floor(r() * BLOSSOMS.length)], rough: 0.9 }, 0), - birch: kitMat(ROLES.birch, 0), - bark: kitMat(ROLES.bark, 0), - }; - const scale = new THREE.Vector3(s, s * (0.85 + r() * 0.3), s); + const name = species[Math.floor(r() * species.length)]; + const k = 0.5 + 0.55 * s; + const scale = new THREE.Vector3(k, k * (0.88 + r() * 0.24), k); const ry = r() * Math.PI * 2; - for (const { role, geo } of piece(name).parts) p.add(roles[role] ?? roles.bark, geo, x, y, z, 0, ry, 0, scale); + const tint = tintOf(Math.floor(r() * 6)); + for (const { role, geo } of realPiece("trees", name)) p.add(realMat("trees", role, tint, 0), geo, x, y, z, 0, ry, 0, scale); } -/** Realistic cars that park on drives and in bays, by how common they are. */ +/** Cars that park on drives and in bays, by how common they are. */ const PARKED = ["hatch", "sedan", "estate", "suv", "hatch", "sedan", "suv", "coupe", "pickup", "van", "estate", "hatch"]; -/** A parked car, nose toward +z before ry: a realistic one if they are loaded, else from the traffic kit. */ +/** A parked car, nose toward +z before ry. */ export function parked(p: Parts, x: number, z: number, ry: number, seed: number, y = KERB, pick?: { name?: string; paint?: number }) { const r = rng(seed); - if (hasReal("cars")) { - const name = pick?.name ?? PARKED[Math.floor(r() * PARKED.length)]; - const paint: Paint = { wall: pick?.paint ?? Math.floor(r() * CAR_PAINTS.length), accent: 0, roof: 0, signs: 0 }; - for (const { role, geo } of realPiece("cars", name)) p.add(realMat("cars", role, paint, 0), geo, x, y, z, 0, ry, 0); - return; - } - const model = ["car_sedan", "car_hatch", "car_sedan", "car_van"][Math.floor(r() * 4)]; - const body = kitMat({ color: CAR_PAINT[Math.floor(r() * CAR_PAINT.length)], rough: 0.5, metal: 0.15 }, 0); - for (const { role, geo } of piece(model).parts) p.add(role === "body" ? body : kitMat(ROLES[role] ?? ROLES.dark, 0), geo, x, y, z, 0, ry, 0); -} - -/** Parking bays: a strip of asphalt with lines and some cars, centred on (x, z), bays along x. */ -function parking(p: Parts, x: number, z: number, w: number, d: number, fill: number, seed: number) { - const r = rng(seed); - p.box(mat(0x45474b, { roughness: 0.95 }), w, 0.04, d, x, KERB, z); - const bays = Math.floor(w / 2.8); - const line = mat(0xe8e6de, { roughness: 0.7 }); - for (let i = 0; i <= bays; i++) p.box(line, 0.12, 0.05, Math.min(5, d - 0.4), x - (bays * 2.8) / 2 + i * 2.8, KERB, z - d / 2 + Math.min(5, d - 0.4) / 2 + 0.2); - for (let i = 0; i < bays; i++) { - if (r() > fill) continue; - parked(p, x - (bays * 2.8) / 2 + (i + 0.5) * 2.8, z - d / 2 + 2.6, 0, seed * 31 + i); - } + const name = pick?.name ?? PARKED[Math.floor(r() * PARKED.length)]; + const paint: Paint = { wall: pick?.paint ?? Math.floor(r() * CAR_PAINTS.length), accent: 0, roof: 0, signs: 0 }; + for (const { role, geo } of realPiece("cars", name)) p.add(realMat("cars", role, paint, 0), geo, x, y, z, 0, ry, 0); } const planter = (p: Parts, x: number, z: number, seed: number, s = 0.55) => { @@ -1020,113 +667,8 @@ const planter = (p: Parts, x: number, z: number, seed: number, s = 0.55) => { }; /** - * The lot around a building: lawn or paving, a way in from the street, and - * what the type needs out back (a garden, parking, a loading yard). The - * building's centre is the origin and its front faces +z. - */ -function yard(p: Parts, s: Spec, r: () => number) { - const cz = -s.yard; - const front = LOT / 2 - s.yard; - const back = -LOT / 2 - s.yard; - const grass = lawn(); - const paving = mat(0xbab4a8, { roughness: 0.9 }); - const concrete = mat(0xa29e96, { roughness: 0.95 }); - const half = LOT / 2 - 0.2; - const ground = (m: THREE.Material) => p.box(m, LOT - 0.4, KERB, LOT - 0.4, 0, 0, cz); - const path = (x: number, w = 1.4) => p.box(paving, w, KERB + 0.03, front - s.d / 2, x, 0, (front + s.d / 2) / 2); - switch (s.kind) { - case "cottage": { - ground(grass); - const side = r() < 0.5 ? -1 : 1; - // A drive up to the garage (modern) or along the side of the house, a - // car on it, and a path to the door. - const modern = s.style === "modern"; - const dx = modern ? s.w * 0.3 : side * (s.w / 2 + 2.2); - const dw = modern ? 2.6 : 2.4; - const dEnd = modern ? s.d / 2 : -s.d / 4; - p.box(concrete, dw, KERB + 0.03, front - dEnd, dx, 0, (front + dEnd) / 2); - parked(p, dx, modern ? s.d / 2 + 2.6 : 0.4, 0, s.seed + 3); - const px = modern ? s.w * 0.05 : 0; - path(px, 1.2); - // Hedge along the front, broken for the path and the drive. - const gaps = [[dx - dw / 2 - 0.1, dx + dw / 2 + 0.1], [px - 0.7, px + 0.7]].sort((a, b) => a[0] - b[0]); - let x0 = -half; - for (const [g0, g1] of [...gaps, [half, half]]) { - if (g0 > x0 + 0.4) hedge(p, g0 - x0, KERB + 0.75, 0.6, (x0 + g0) / 2, 0, front - 0.5, s.seed); - x0 = Math.max(x0, g1); - } - // Garden: trees, maybe a shed, and a fence round the back. - tree(p, -side * (s.w / 2 + 2.0), s.d / 2 + 1.8, 0.7, s.seed + 1, KERB); - tree(p, (r() - 0.5) * s.w, back + 2.2, 0.85, s.seed + 2, KERB); - if (r() < 0.6) p.box(mat(0x7a5a3e), 1.6, 2.0, 1.4, -side * (half - 1.2), KERB, back + 1.2); - const run = LOT / 2 + s.yard; - if (hasReal("street")) { - // Close-board or post-and-rail round the back garden. - const kind = r() < 0.6 ? "fence_panel" : "fence_rail"; - const h = kind === "fence_panel" ? 0.85 : 1; - fenceRun(p, kind, half, back + 0.25, -half, back + 0.25, h); - fenceRun(p, kind, -half, back + 0.25, -half, back + 0.25 + run, h); - fenceRun(p, kind, half, back + 0.25 + run, half, back + 0.25, h); - } else { - const fence = mat(0xe8e0d0, { roughness: 0.8 }); - p.box(fence, LOT - 0.4, 1.1, 0.08, 0, KERB, back + 0.25); - for (const sx of [-1, 1]) p.box(fence, 0.08, 1.1, run, sx * half, KERB, back + run / 2 + 0.25); - } - break; - } - case "shop": { - ground(s.style === "factory" ? concrete : paving); - if (s.style === "factory") { - // Loading yard out back: pallets, a skip and a van at the dock. - parked(p, -s.w * 0.25, s.d / 2 + 2.8, 0, s.seed + 7); - const wood = mat(0x8a6a40, { roughness: 0.9 }); - for (let i = 0; i < 6; i++) p.box(wood, 1.2, 0.9 + r() * 0.9, 1.0, -half + 1 + (i % 3) * 1.4, KERB, back + 1 + Math.floor(i / 3) * 1.3); - p.box(mat(0x3a6a3a, { roughness: 0.7, metalness: 0.3 }), 3.2, 1.4, 1.8, half - 2, KERB, back + 1.3); - p.box(mat(0x2a2c2e, { metalness: 0.5 }), LOT - 0.4, 2.0, 0.06, 0, KERB, back + 0.1); - } else { - // Shoppers' parking behind, benches and planters at the front. - parking(p, 0, back + 3.2, LOT - 1, 5.8, 0.7, s.seed + 5); - planter(p, -s.w / 2 + 0.4, front - 0.9, s.seed + 11, 0.45); - planter(p, s.w / 2 - 0.4, front - 0.9, s.seed + 12, 0.45); - } - break; - } - case "apartment": { - ground(grass); - path(0, s.style === "townhouse" ? s.w : 2); - if (s.style !== "townhouse") { - tree(p, -s.w / 2 + 0.8, front - 1.2, 0.6, s.seed + 1, KERB); - tree(p, s.w / 2 - 0.8, front - 1.2, 0.6, s.seed + 2, KERB); - } else { - if (hasReal("street")) fenceRun(p, "fence_iron", -half, front - 0.3, half, front - 0.3, 0.75); - else p.box(mat(0x2a2c2e, { metalness: 0.5 }), LOT - 0.4, 0.9, 0.06, 0, KERB, front - 0.3); - } - parking(p, 0, back + 3.2, LOT - 1, 5.8, 0.8, s.seed + 5); - break; - } - case "office": { - ground(paving); - // A plaza out front with trees in planters, a smoking-area bench at the back. - for (const sx of [-1, 1]) { - planter(p, sx * (s.w / 2 + 1.6), front - 1.3, s.seed + (sx > 0 ? 1 : 2)); - planter(p, sx * (s.w / 2 + 1.6), cz, s.seed + (sx > 0 ? 3 : 4)); - } - p.box(mat(0x8f8a80, { roughness: 0.9 }), s.w * 0.7, 0.05, front - s.d / 2 - 0.6, 0, KERB, (front + s.d / 2) / 2); - parking(p, 0, back + 3.0, LOT - 1, 5.6, 0.85, s.seed + 5); - break; - } - case "landmark": { - ground(mat(0xc8c0b0, { roughness: 0.85 })); - for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) planter(p, sx * (half - 1.1), sz * (half - 1.1) + cz, s.seed + sx * 3 + sz, 0.5); - p.box(mat(0xd9a441, { roughness: 0.3, metalness: 0.85 }), LOT - 3, 0.05, 0.25, 0, KERB, front - 0.6); - break; - } - } -} - -/** - * Stand-ins for buildings the town provides until they have models: a water - * tower, and a square or a park on one lot. Local frame: the lot's centre is + * The town's lots built here rather than modelled: a water tower, and a + * square or a park on one lot. Local frame: the lot's centre is * `yard` behind the origin, the street in front (+z). */ function utility(s: Spec): { group: THREE.Group; info: ModelInfo } { @@ -1136,55 +678,27 @@ function utility(s: Spec): { group: THREE.Group; info: ModelInfo } { const info: ModelInfo = { height: 4, emitters: [], beacons: [], night: [], debris: 0x7a7570 }; const cz = -s.yard; const half = LOT / 2 - 0.4; - if (s.style === "water" && hasReal("civic")) { + if (s.style === "water") { // An elevated steel tank (civic.py), 22 m to its vent; broken, it leans. p.box(lawn(), LOT - 0.4, KERB + 0.04, LOT - 0.4, 0, 0, cz); const lean = s.damage >= 2 ? 0.07 : 0; civic(p, "water_tower", 0, KERB, cz, { rx: lean, rz: lean * 0.6, damage: s.damage }); - if (hasReal("street")) fenceRun(p, "fence_rail", -half, cz + half, half, cz + half, 0.9); + fenceRun(p, "fence_rail", -half, cz + half, half, cz + half, 0.9); info.height = KERB + 21.9; info.beacons.push(new THREE.Vector3(0, KERB + 22.0, cz)); - } else if (s.style === "water") { - p.box(lawn(), LOT - 0.4, KERB + 0.04, LOT - 0.4, 0, 0, cz); - const steel = mat(0x8c96a0, { metalness: 0.6, roughness: 0.45 }); - const tank = mat(s.damage ? 0x6a6e70 : 0xc8ccd0, { metalness: 0.5, roughness: 0.4 }); - const legH = s.damage >= 2 ? 9 : 13; - for (const [lx, lz] of [[-2.4, -2.4], [2.4, -2.4], [-2.4, 2.4], [2.4, 2.4]]) p.box(steel, 0.35, legH, 0.35, lx, KERB, cz + lz); - for (const y of [4, 8.5]) { - p.box(steel, 5.2, 0.15, 0.15, 0, KERB + y, cz - 2.4); - p.box(steel, 5.2, 0.15, 0.15, 0, KERB + y, cz + 2.4); - p.box(steel, 0.15, 0.15, 5.2, -2.4, KERB + y, cz); - p.box(steel, 0.15, 0.15, 5.2, 2.4, KERB + y, cz); - } - const top = KERB + legH; - p.cyl(tank, 3.4, 3.4, 4.4, 0, top, cz, 24); - p.add(tank, new THREE.ConeGeometry(3.6, 1.8, 24), 0, top + 4.4 + 0.9, cz); - p.box(steel, 0.5, top + 4.4, 0.1, 0, 0, cz + 3.45); - if (hasReal("street")) fenceRun(p, "fence_rail", -half, cz + half, half, cz + half, 0.9); - info.height = top + 6.2; - info.beacons.push(new THREE.Vector3(0, top + 6.3, cz)); } else { const park = s.style === "park"; - p.box(park ? lawn() : mat(0xb9b2a6, { roughness: 0.9 }), LOT - 0.4, KERB + 0.04, LOT - 0.4, 0, 0, cz); - if (park) p.box(mat(0xc8bfae, { roughness: 1 }), 2.2, KERB + 0.06, LOT - 0.4, 0, 0, cz); - if (hasReal("civic")) { - const water = (role: string) => (role === "water" ? waterMat({ amp: 0.5, scale: 2.5, ring: [s.x + cz * Math.sin(s.rot), s.z + cz * Math.cos(s.rot)] }) : undefined); - civic(p, "fountain", 0, KERB, cz, { scale: new THREE.Vector3(0.85, 0.85, 0.85), swap: water }); - if (s.damage === 0) p.extra(fountainSpray(0, KERB, cz, 0.85)); - } else { - const stone = mat(0xd8d2c4, { roughness: 0.8 }); - const water = mat(0x4f7d93, { metalness: 0.3, roughness: 0.15 }); - p.cyl(stone, 2.6, 2.8, 0.6, 0, KERB, cz, 24); - p.cyl(water, 2.3, 2.3, 0.08, 0, KERB + 0.5, cz, 24); - p.cyl(stone, 0.35, 0.5, 1.8, 0, KERB + 0.6, cz, 10); - p.cyl(stone, 0.9, 0.2, 0.3, 0, KERB + 2.4, cz, 12); - } + p.box(park ? lawn() : groundMat("pave", 3.6, { roughness: 0.88, color: 0xe6e0d4 }), LOT - 0.4, KERB + 0.04, LOT - 0.4, 0, 0, cz); + if (park) p.box(gravelMat(0xd8ccb0), 2.2, KERB + 0.06, LOT - 0.4, 0, 0, cz); + const water = (role: string) => (role === "water" ? waterMat({ amp: 0.5, scale: 2.5, ring: [s.x + cz * Math.sin(s.rot), s.z + cz * Math.cos(s.rot)] }) : undefined); + civic(p, "fountain", 0, KERB, cz, { scale: new THREE.Vector3(0.85, 0.85, 0.85), swap: water }); + if (s.damage === 0) p.extra(fountainSpray(0, KERB, cz, 0.85)); const n = park ? 6 : 4; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2 + 0.4; tree(p, Math.cos(a) * 5.6, cz + Math.sin(a) * 5.6, 0.8 + r() * 0.3, Math.floor(r() * 1e6), KERB); } - if (!park && hasReal("street")) for (const x of [-4.5, 4.5]) furniture(p, "bench", x, KERB, cz + 3.6); + if (!park) for (const x of [-4.5, 4.5]) furniture(p, "bench", x, KERB, cz + 3.6); info.height = 6; } if (s.damage >= 1) info.emitters.push({ pos: new THREE.Vector3(0, info.height * 0.6, cz), kind: "smoke", rate: s.damage >= 2 ? 6 : 2 }); @@ -1207,22 +721,21 @@ export function building(s: Spec): { group: THREE.Group; info: ModelInfo } { const g = new THREE.Group(); const p = new Parts(); const r = rng(s.seed * 31 + 5); - const info: ModelInfo = { height: s.height, emitters: [], beacons: [], night: [], debris: realDef(s)?.debris ?? WALLS[s.wall] }; - const look = looks(s, info); + const def = realDef(s); + if (!def) throw new Error(`no model for a ${s.kind} (${s.style})`); + const info: ModelInfo = { height: s.height, emitters: [], beacons: [], night: [], debris: def.debris }; const broken = s.damage >= 2; - const def = realDef(s); const yp = new Parts(YARD); - if (def) realYard(yp, p, s, def, r); - else yard(yp, s, r); + realYard(yp, p, s, def, r); yp.into(g); // Heavy damage takes the top quarter off, roof and all. const floors = broken ? Math.max(1, Math.round(s.floors * (0.72 + r() * 0.1))) : s.floors; - const top = def ? stackReal(p, s, def, floors, !broken, info) : stack(p, s, look, floors, !broken); + const top = stackReal(p, s, def, floors, !broken, info); if (top.anchors.smoke) info.emitters.push({ pos: top.anchors.smoke, kind: "smoke", rate: s.damage ? 0 : s.style === "factory" || s.style === "powerstation" ? 1.4 : 0.25 }); if (top.anchors.beacon) info.beacons.push(top.anchors.beacon); - if (broken) wreckTop(p, top.w, top.d, top.top, def?.debris ?? (s.kind === "office" ? GLASS[s.wall] : WALLS[s.wall]), r, def ? THREE.MathUtils.clamp(top.top / 20, 0.4, 1) : 1); + if (broken) wreckTop(p, top.w, top.d, top.top, def.debris, r, THREE.MathUtils.clamp(top.top / 20, 0.4, 1)); if (s.shiny && !broken) finial(p, top.top); if (s.damage >= 1) { @@ -1264,7 +777,7 @@ function shell(s: Spec, def: RealDef): { group: THREE.Group; info: ModelInfo } { const floors = s.floors > 1 ? Math.max(1, Math.round(s.floors * (0.5 + r() * 0.12))) : 1; const top = stackReal(p, burnt, def, floors, false, info); info.height = top.top; - if (hasReal("civic")) { + { // The broken top: ragged stumps of wall along each edge (masonry) or // charred joists and rafters standing out of it (timber and glass). const paint: Paint = { wall: s.wall, accent: s.accent, roof: 0, signs: 0 }; @@ -1293,10 +806,10 @@ function shell(s: Spec, def: RealDef): { group: THREE.Group; info: ModelInfo } { civic(p, "rubble_beam", wx + (r() - 0.5) * ww * 0.7, y + 0.2 * k, wz + (r() - 0.5) * wd * 0.7, { ry: r() * Math.PI * 2, rx: (r() - 0.5) * 1.2, rz: (r() - 0.5) * 1.2, scale: new THREE.Vector3(k, k, k), damage: 2 }); } for (let i = 0; i < 2; i++) civic(p, "rubble_slab", wx + (r() - 0.5) * ww * 0.5, y + 0.1, wz + (r() - 0.5) * wd * 0.5, { ry: r() * Math.PI * 2, rx: (r() - 0.5) * 0.8, damage: 1 }); - } else wreckTop(p, top.w, top.d, top.top, def.debris, r, THREE.MathUtils.clamp(top.top / 14, 0.5, 1.1)); + } // Scorch round the walls, and what fell from above heaped against them. p.add(mat(0x1a1714, { transparent: true, opacity: 0.7, depthWrite: false }), new THREE.CircleGeometry(Math.max(s.w, s.d) * 0.75, 16), 0, KERB + 0.02, 0, -Math.PI / 2); - if (hasReal("civic")) { + { const paint: Paint = { wall: s.wall, accent: s.accent, roof: 0, signs: 0 }; const pile = 0.9 + s.grade * 0.4; const sides: [number, number, number][] = [ @@ -1330,7 +843,7 @@ export function rubble(s: Spec): { group: THREE.Group; info: ModelInfo } { const r = rng(s.seed * 7 + 1); const pile = 1.4 + s.grade * 0.9; p.box(mat(0x4a4642), LOT - 0.4, KERB + 0.04, LOT - 0.4, 0, 0, -s.yard); - if (hasReal("civic")) { + { // A heap of broken masonry (civic.py) with what is left standing or // thrown about on it: wall stumps in the building's brick, broken // slabs with their rebar out, bent beams; more of each for bigger buildings. @@ -1353,27 +866,6 @@ export function rubble(s: Spec): { group: THREE.Group; info: ModelInfo } { const d = r() * 0.7; civic(p, "rubble_beam", Math.cos(a) * R * d, on(d) + 0.2, Math.sin(a) * R * d, { ry: r() * Math.PI * 2, rx: (r() - 0.5) * 0.5, damage: 2 }); } - p.into(g); - g.position.set(s.x, 0, s.z); - g.rotation.y = s.rot; - const info: ModelInfo = { height: pile, emitters: [{ pos: new THREE.Vector3(0, pile * 0.6, 0), kind: "smoke", rate: 0.8 }], beacons: [], night: [], debris: WALLS[s.wall] }; - return { group: g, info }; - } - p.add(mat(0x5d5852, { flatShading: true, roughness: 1 }), new THREE.ConeGeometry(s.w * 0.62, pile, 9, 1), 0, pile / 2, 0, 0, r(), 0); - const conc = mat(0x7a7570, { roughness: 1, flatShading: true }); - const wall = mat(new THREE.Color(WALLS[s.wall]).multiplyScalar(0.55).getHex(), { flatShading: true }); - const burnt = mat(0x221e1c, { roughness: 1 }); - for (let i = 0; i < 26 + s.grade * 8; i++) { - const a = r() * Math.PI * 2; - const dist = r() * s.w * 0.6; - const sz = 0.4 + r() * (1 + s.grade * 0.4); - const y = Math.max(0, pile * (1 - dist / (s.w * 0.62)) - sz * 0.6); - const m = [conc, conc, wall, burnt][Math.floor(r() * 4)]; - p.box(m, sz, sz * (0.3 + r() * 0.6), sz * (0.5 + r() * 0.8), Math.cos(a) * dist, y, Math.sin(a) * dist, r() * 3, r(), r()); - } - const steel = mat(0x2a2422, { metalness: 0.5, roughness: 0.6 }); - for (let i = 0; i < 4 + s.grade * 2; i++) { - p.box(steel, 0.15, 2 + r() * 4, 0.15, (r() - 0.5) * s.w, 0, (r() - 0.5) * s.d, r() * 3, (r() - 0.5) * 1.8, (r() - 0.5) * 1.8); } p.into(g); g.position.set(s.x, 0, s.z); @@ -1382,6 +874,13 @@ export function rubble(s: Spec): { group: THREE.Group; info: ModelInfo } { return { group: g, info }; } +/** Points on the crane's pieces (civic.py), piece-local: the tie apex, beacons, the counter-jib's end. */ +const CRANE: Record> = { + crane_slew: { apex: new THREE.Vector3(0, 5.2, 0), beacon: new THREE.Vector3(0, 5.75, 0) }, + crane_jib_tip: { beacon: new THREE.Vector3(-1.2, 0.75, 0) }, + crane_counter: { end: new THREE.Vector3(9.3, 0.05, 0) }, +}; + /** Construction site: built floors, scaffolding, a blueprint of the goal and a tower crane. */ export function site(s: SiteSpec): { group: THREE.Group; info: ModelInfo; jib: THREE.Group; ghost: THREE.Mesh } { const g = new THREE.Group(); @@ -1403,18 +902,14 @@ export function site(s: SiteSpec): { group: THREE.Group; info: ModelInfo; jib: T // Lower floors already finished: the real building, rising floor by floor. const clad = Math.max(0, built - 2); const def = realDef(s); - if (clad > 0) { - if (def) stackReal(p, s, def, clad, false, info); - else stack(p, s, looks(s, info), clad, false); - } + if (clad > 0 && def) stackReal(p, s, def, clad, false, info); // Scaffolding one floor above the work. - const scaff = mat(s.surrendering ? 0xc0392b : 0xd08a2a, { metalness: 0.4, roughness: 0.5 }); const sh = builtH + s.floorH; const gap = 0.8; const W = s.w + gap * 2; const D = s.d + gap * 2; - if (hasReal("civic")) { + { // Tube-and-fitting bays (civic.py), 2.4 m stretched to fit each face, // one lift a storey; red tubes while the site is being surrendered. const red = s.surrendering ? mat(0xc0392b, { metalness: 0.4, roughness: 0.5 }) : undefined; @@ -1429,22 +924,6 @@ export function site(s: SiteSpec): { group: THREE.Group; info: ModelInfo; jib: T for (let y = 0; y < sh - 0.01; y += s.floorH) civic(p, "scaffold", ox + dx * t, y, oz + dz * t, { ry, scale, swap }); } } - } else for (const [len, axis] of [[W, "x"], [D, "z"]] as const) { - const n = Math.max(2, Math.round(len / 2.4)); - for (let i = 0; i <= n; i++) { - const t = -len / 2 + (len * i) / n; - for (const side of [-1, 1]) { - const [x, z] = axis === "x" ? [t, (side * D) / 2] : [(side * W) / 2, t]; - p.box(scaff, 0.09, sh, 0.09, x, 0, z); - } - } - } - if (!hasReal("civic")) for (let y = s.floorH; y <= sh + 0.01; y += s.floorH) { - for (const side of [-1, 1]) { - p.box(scaff, W, 0.08, 0.08, 0, y, (side * D) / 2); - p.box(scaff, 0.08, 0.08, D, (side * W) / 2, y, 0); - p.box(mat(0x8a6a40), W, 0.06, 0.6, 0, y - 0.05, side * (D / 2 - 0.3)); - } } p.into(g); @@ -1458,14 +937,11 @@ export function site(s: SiteSpec): { group: THREE.Group; info: ModelInfo; jib: T ghost.add(edges); g.add(ghost); - // Tower crane from the kit: base and mast sections, then a jib that turns + // Tower crane (civic.py): base and mast sections, then a jib that turns // with its cab, counter-jib, pendant ties, trolley, hook and a load of steel. - // The anchors (apex, beacons, the counter-jib's end) are the kit's; the - // realistic crane (civic.py) is built to the same sizes. const crane = (p: Parts, name: string, x: number, y: number, z: number) => { - if (hasReal("civic")) civic(p, name, x, y, z); - else for (const { role, geo } of piece(name).parts) p.add(kitMat(ROLES[role] ?? ROLES.metal, 0), geo, x, y, z); - return piece(name).anchors; + civic(p, name, x, y, z); + return CRANE[name] ?? {}; }; const cp = new Parts(); const mx = s.w / 2 + 2.8; @@ -1514,24 +990,16 @@ export function site(s: SiteSpec): { group: THREE.Group; info: ModelInfo; jib: T // ---- parks -------------------------------------------------------------------- -const PARK_ROLES: Record = { ...ROLES, roof: { color: 0x56606e, rough: 0.8 }, accent: { color: 0x3a6fb5, rough: 0.7 } }; - function prop(p: Parts, name: string, x: number, z: number, ry = 0, s = 1, y = KERB) { const scale = new THREE.Vector3(s, s, s); - if (name === "bench" && hasReal("street")) return furniture(p, "bench", x, y, z, ry); + if (name === "bench") return furniture(p, "bench", x, y, z, ry); // A fountain's water moves: calm small ripples, rings from where it lands, streaks down the jet and streams. const water = (role: string) => (role === "water" ? waterMat({ amp: 0.5, scale: 2.5, ring: [x, z] }) : undefined); - if (hasReal("civic")) return civic(p, name, x, y, z, { ry, scale, swap: water }); - for (const { role, geo } of piece(name).parts) p.add(role === "water" ? waterMat({ amp: 0.5, scale: 2.5, ring: [x, z] }) : kitMat(PARK_ROLES[role] ?? ROLES.metal, 0), geo, x, y, z, 0, ry, 0, scale); + civic(p, name, x, y, z, { ry, scale, swap: water }); } -/** A park lamp: a post with a lantern, lit by `bulb` at night. */ -export function lampPost(p: Parts, pole: THREE.Material, bulb: THREE.Material, x: number, z: number, h = 3.6) { - if (hasReal("street")) return furniture(p, "lamp_heritage", x, KERB, z); - p.cyl(pole, 0.07, 0.1, h, x, KERB, z, 6); - p.box(bulb, 0.36, 0.5, 0.36, x, KERB + h, z); - p.box(pole, 0.5, 0.08, 0.5, x, KERB + h + 0.5, z); -} +/** A park lamp, lit at night. */ +const lampPost = (p: Parts, x: number, z: number) => furniture(p, "lamp_heritage", x, KERB, z); /** * A park block in place of a district, in world coordinates: a plaza with a @@ -1542,17 +1010,17 @@ export function park(p: Parts, k: ParkSpec, pole: THREE.Material, bulb: THREE.Ma const { x: X, z: Z } = k; const W = BLOCK_X; const D = BLOCK_Z; - const lawn_ = hasReal("trees") ? lawn() : mat(0x6a9a48, { roughness: 1 }); - const paving = hasReal("street") ? groundMat("pave", 3.6, { roughness: 0.88, color: 0xf2eee6 }) : mat(0xc4bcae, { roughness: 0.9 }); + const lawn_ = lawn(); + const paving = groundMat("pave", 3.6, { roughness: 0.88, color: 0xf2eee6 }); const flat = (m: THREE.Material, g: THREE.BufferGeometry, x: number, z: number, y = KERB + 0.02) => p.add(m, g, X + x, y, Z + z, -Math.PI / 2); const bench = (x: number, z: number, ry: number) => prop(p, "bench", X + x, Z + z, ry); switch (k.kind) { case "plaza": { p.box(paving, W, KERB, D, X, 0, Z); - flat(mat(0x9a8e7e, { roughness: 0.9 }), new THREE.CircleGeometry(10, 32), 0, 0); - flat(mat(0xd8d0c0, { roughness: 0.9 }), new THREE.RingGeometry(10, 10.8, 32), 0, 0, KERB + 0.03); + flat(groundMat("ashlar", 2.4, { roughness: 0.85, color: 0xd4c8b4 }), new THREE.CircleGeometry(10, 48), 0, 0); + flat(groundMat("ashlar", 2.4, { roughness: 0.8, color: 0xf0ebe0 }), new THREE.RingGeometry(10, 10.8, 48), 0, 0, KERB + 0.03); prop(p, "fountain", X, Z, 0, 1.45); - if (hasReal("civic")) p.extra(fountainSpray(X, KERB, Z, 1.45)); + p.extra(fountainSpray(X, KERB, Z, 1.45)); for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2 + Math.PI / 8; // Benches face the fountain (a bench's front is +z before turning). @@ -1563,13 +1031,13 @@ export function park(p: Parts, k: ParkSpec, pole: THREE.Material, bulb: THREE.Ma p.box(lawn_, 12, KERB + 0.12, 9, X + x, 0, Z + z); for (let i = 0; i < 5; i++) tree(p, X + x + (r() - 0.5) * 9, Z + z + (r() - 0.5) * 6, 0.35 + r() * 0.2, k.seed + 40 + i + x, KERB + 0.1); } - for (const [x, z] of [[-14, -6], [14, -6], [-14, 6], [14, 6]]) lampPost(p, pole, bulb, X + x, Z + z); + for (const [x, z] of [[-14, -6], [14, -6], [-14, 6], [14, 6]]) lampPost(p, X + x, Z + z); break; } case "park": { p.box(lawn_, W, KERB, D, X, 0, Z); // A loop path round the lawn, with a cut through the middle. - const path = mat(0xc9b896, { roughness: 1 }); + const path = gravelMat(0xe0d0a8); const ax = W / 2 - 6; const az = D / 2 - 4.5; const n = 36; @@ -1577,14 +1045,14 @@ export function park(p: Parts, k: ParkSpec, pole: THREE.Material, bulb: THREE.Ma ring.forEach(([x0, z0], i) => { const [x1, z1] = ring[(i + 1) % n]; p.box(path, Math.hypot(x1 - x0, z1 - z0) + 0.6, KERB + 0.03, 2.2, X + (x0 + x1) / 2, 0, Z + (z0 + z1) / 2, -Math.atan2(z1 - z0, x1 - x0)); - if (i % 6 === 3) lampPost(p, pole, bulb, X + x0 * 1.09, Z + z0 * 1.14); + if (i % 6 === 3) lampPost(p, X + x0 * 1.09, Z + z0 * 1.14); if (i % 6 === 0) bench(x0 * 0.9, z0 * 0.86, Math.atan2(x0, z0)); }); p.box(path, 2.2, KERB + 0.03, D, X + 4, 0, Z); // A pond with a sandy edge, and the bandstand across the lawn. const pond = { x: -12, z: 1, rx: 10, rz: 5.5 }; const sand = new THREE.CircleGeometry(1, 28).scale(pond.rx + 0.9, pond.rz + 0.9, 1); - flat(mat(0xb8a87c, { roughness: 1 }), sand, pond.x, pond.z, KERB + 0.025); + flat(gravelMat(0xd8c490), sand, pond.x, pond.z, KERB + 0.025); flat(waterMat({ amp: 0.9, scale: 1.7, color: 0x1d3c3a }), new THREE.CircleGeometry(1, 28).scale(pond.rx, pond.rz, 1), pond.x, pond.z, KERB + 0.04); prop(p, "bandstand", X + 16, Z - 1); // Trees wherever the paths, the pond and the bandstand leave room. @@ -1604,9 +1072,11 @@ export function park(p: Parts, k: ParkSpec, pole: THREE.Material, bulb: THREE.Ma p.box(lawn_, W, KERB, D, X, 0, Z); const pz = -3; const [pw, pd] = [46, 22]; - p.box(mat(0xa85a40, { roughness: 1 }), pw + 6, KERB + 0.02, pd + 5, X, 0, Z + pz); + // A rubber-crumb surround and a mown pitch: light and dark bands of one turf. + p.box(gravelMat(0xff8a68, 1.6), pw + 6, KERB + 0.02, pd + 5, X, 0, Z + pz); for (let i = 0; i < 10; i++) { - p.box(mat(i % 2 ? 0x5e9a3e : 0x6aa646, { roughness: 1 }), pw / 10, KERB + 0.04, pd, X - pw / 2 + (i + 0.5) * (pw / 10), 0, Z + pz); + const turf = groundMat("grass", 1.8, { roughness: 0.95, normal: 0.6, color: i % 2 ? 0xa8c888 : 0xc4dca0 }); + p.box(turf, pw / 10, KERB + 0.04, pd, X - pw / 2 + (i + 0.5) * (pw / 10), 0, Z + pz); } const white = mat(0xf2f2ea, { roughness: 0.7 }); const ly = KERB + 0.045; @@ -1621,16 +1091,22 @@ export function park(p: Parts, k: ParkSpec, pole: THREE.Material, bulb: THREE.Ma prop(p, "goal", X + (sx * pw) / 2, Z + pz, -sx * Math.PI / 2); } line(0.15, pd, 0, 0); - p.add(white, new THREE.RingGeometry(4.2, 4.35, 32), X, ly + 0.02, Z + pz, -Math.PI / 2); + p.add(white, new THREE.RingGeometry(4.2, 4.35, 64), X, ly + 0.02, Z + pz, -Math.PI / 2); for (const x of [-9, 9]) prop(p, "bleachers", X + x, Z + 10.2, Math.PI); // Floodlights at the corners. for (const sx of [-1, 1]) { for (const sz of [-1, 1]) { const x = X + sx * (pw / 2 + 4); const z = Z + pz + sz * (pd / 2 + 2.2); - p.cyl(pole, 0.15, 0.25, 13, x, KERB, z, 6); - p.box(pole, 2.2, 0.2, 0.3, x, KERB + 13, z); - p.box(bulb, 2.0, 0.8, 0.25, x - sx * 0.1, KERB + 13.2, z - sz * 0.2, 0, -sz * 0.5); + // A tapered mast with a footing, and a frame of six lamps tipped at the pitch. + p.cyl(pole, 0.45, 0.5, 0.4, x, KERB, z, 16); + p.cyl(pole, 0.13, 0.24, 13, x, KERB + 0.4, z, 16); + p.box(pole, 2.4, 0.12, 0.12, x, KERB + 13.1, z); + p.box(pole, 2.4, 0.12, 0.12, x, KERB + 14.1, z); + for (const lx of [-0.8, 0, 0.8]) for (const ly of [13.35, 14.35]) { + p.box(pole, 0.62, 0.62, 0.3, x + lx, KERB + ly - 0.31, z + sz * 0.05, 0, -0.5 * sz); + p.box(bulb, 0.5, 0.5, 0.06, x + lx, KERB + ly - 0.25, z - sz * 0.13, 0, -0.5 * sz); + } } } for (let i = 0; i < 8; i++) tree(p, X + (r() < 0.5 ? -1 : 1) * (28 + r() * 3), Z + (r() - 0.5) * (D - 4), 0.7 + r() * 0.3, k.seed + i, KERB); diff --git a/ui/src/city/plan.test.ts b/ui/src/city/plan.test.ts index 3b605e5..0f15210 100644 --- a/ui/src/city/plan.test.ts +++ b/ui/src/city/plan.test.ts @@ -218,3 +218,18 @@ test("the City's power and water get their own models; the public buildings have expect(p.buildings[0].model).toBe("p2"); expect(p.buildings[0].style).toBe("powerstation"); }); + +test("every look the plan can give a building has a model", async () => { + // real.ts reads the page's URL at load; bun has none. + (globalThis as unknown as { location: object }).location ??= { search: "" }; + const { DEFS } = await import("./real"); + const { DESIGNS, NEED_LOOKS } = await import("./plan"); + const drawn = (kind: string, style: string) => Object.values(DEFS).some((d) => d.kind === kind && d.style === style); + const built = new Set(["water", "square", "park"]); + for (const kind of KINDS) for (const [style] of STYLES[kind]) expect(drawn(kind, style), `${kind} ${style}`).toBe(true); + for (const [id, look] of Object.entries(DESIGNS)) expect(DEFS[id]?.style, id).toBe(look.style); + for (const look of [...Object.values(PUBLIC), ...Object.values(NEED_LOOKS)]) { + if (built.has(look.style)) continue; + expect(look.model && DEFS[look.model]?.style, look.style).toBe(look.style); + } +}); diff --git a/ui/src/city/plan.ts b/ui/src/city/plan.ts index d7c52e1..9fdab72 100644 --- a/ui/src/city/plan.ts +++ b/ui/src/city/plan.ts @@ -21,20 +21,21 @@ export type Kind = "cottage" | "shop" | "apartment" | "office" | "landmark"; export const KINDS: Kind[] = ["cottage", "shop", "apartment", "office", "landmark"]; /** - * Building types per kind, from the seed. "classic" is the original kit - * (several variants each); the rest are the skyline styles. + * Building types per kind, from the seed, for a lot with no blueprint (the + * plan gives every designed lot one). Each is drawn by a model in DEFS + * (real.ts); plan.test.ts checks that. */ export const STYLES: Record = { cottage: [["classic", 0.55], ["modern", 0.25], ["aframe", 0.2]], shop: [["classic", 0.5], ["diner", 0.25], ["factory", 0.25]], apartment: [["classic", 0.6], ["townhouse", 0.4]], - office: [["classic", 0.6], ["round", 0.4]], - landmark: [["deco", 0.25], ["glass", 0.25], ["twist", 0.25], ["tvtower", 0.25]], + office: [["classic", 1]], + landmark: [["deco", 1]], }; /** - * What a blueprint is drawn as: the kit style its realistic model replaces - * (DEFS in real.ts), and the model itself. Kept here so the plan stays pure. + * What a blueprint is drawn as: its model's kind and style (DEFS in + * real.ts), and the model itself. Kept here so the plan stays pure. */ export const DESIGNS: Record = { c1: { kind: "cottage", style: "classic" }, @@ -53,9 +54,8 @@ export const DESIGNS: Record = { }; /** - * Buildings the town provides have no models yet: stand-ins until they do - * (docs/GAME.md). `water`, `square` and `park` are - * drawn by models.ts itself. + * How the town's buildings (docs/GAME.md) are drawn: by their model, or, + * for `water`, `square` and `park`, laid out by models.ts itself. */ export const PUBLIC: Record = { water: { kind: "shop", style: "water" }, diff --git a/ui/src/city/real.ts b/ui/src/city/real.ts index a194027..a458451 100644 --- a/ui/src/city/real.ts +++ b/ui/src/city/real.ts @@ -1,9 +1,8 @@ -// City theme: the realistic models (ui/blender/real, docs/APP.md), which -// replace the low-poly kit's styles one model at a time. `bun run models` -// writes public/models/: .glb (pieces as nodes, materials named by -// role), _ao.png (AO baked on the lightmap UVs) and tex/, the shared -// texture library. Any model that is missing falls back to the kit. Release -// builds download them instead (models.ts), so the base is set at startup. +// City theme: the models (ui/blender/real, docs/APP.md), the only thing the +// city is drawn with. `bun run models` writes public/models/: .glb +// (pieces as nodes, materials named by role), _ao.png (AO baked on the +// lightmap UVs) and tex/, the shared texture library. Release builds +// download them instead (models.ts), so the base is set at startup. // // This module loads the models and owns their materials: PBR from the // library with baked AO and world-space weathering, and window glass that @@ -41,7 +40,7 @@ const loaded = new Map(); /** How a model sits in the city: its size, how it stacks, where its chimney smokes. */ export interface RealDef { id: string; - /** The kit kind and style it takes over (docs/APP.md). */ + /** The plan's kind and style it draws (STYLES, DESIGNS in plan.ts). */ kind: Kind; style: string; /** Footprint (x, z) in metres, eaves, porch and steps included; front faces +z. */ @@ -561,7 +560,7 @@ export const DEFS: Record = { floors: (plan) => Math.max(12, plan), stack(floors, roof, _r, total) { // Three tiers, stepping back at about half and four fifths of the - // finished height (as the kit's deco tower does), a ledge piece where + // finished height, a ledge piece where // each tier ends; the crown on top. const n = Math.max(total, floors); const e1 = Math.round(n * 0.5); @@ -584,11 +583,7 @@ export const DEFS: Record = { }, }; -/** - * Models that are not buildings: the props the city is dressed with - * (vehicles, ...), each one glb of named pieces. Missing ones fall back to - * the kit's props. - */ +/** Models that are not buildings: the props the city is dressed with (vehicles, ...), each one glb of named pieces. */ export const PROPS = ["cars", "trees", "street", "bridge", "civic"]; // ---- compressed textures ------------------------------------------------------------ @@ -651,11 +646,15 @@ async function loadGlb(url: string): Promise { /** A texture to load: BC7 if it is listed (and this GPU has it), else the PNG. */ const isBc7 = (url: string) => packed.has(url.slice(BASE.length, -4)); -/** Loads every model there is; missing ones are left to the kit. Never throws. `renderer` lets textures load compressed. */ -export async function loadReal(ids = [...Object.keys(DEFS), ...PROPS], renderer?: THREE.WebGLRenderer): Promise { +/** + * Loads the models, all of them or throws: the city draws nothing else. + * `lenient` (the viewers) skips missing ones instead. `renderer` lets textures load compressed. + */ +export async function loadReal(ids = [...Object.keys(DEFS), ...PROPS], renderer?: THREE.WebGLRenderer, lenient = false): Promise { await initBc7(renderer); - // Without BC7 the pack has nothing to draw with: the kit it is. - if (!hasPng && packed.size === 0) return; + // The pack ships BC7 textures only. + if (!hasPng && packed.size === 0) throw new Error("the city's textures need BC7 compression, which this GPU lacks"); + const missing: string[] = []; const png = new THREE.TextureLoader(); const tex = { loadAsync: async (url: string): Promise => { @@ -697,11 +696,13 @@ export async function loadReal(ids = [...Object.keys(DEFS), ...PROPS], renderer? } if (id === "trees") for (const parts of pieces.values()) bendLeaves(parts); loaded.set(id, { pieces, ao }); - } catch { - // Not built (bun run models) or not downloaded yet: the kit draws this style. + } catch (e) { + console.warn("model", id, e); + missing.push(id); } }), ); + if (missing.length && !lenient) throw new Error(`models missing: ${missing.join(", ")}`); } export const hasReal = (id: string) => loaded.has(id); @@ -1393,7 +1394,7 @@ export function waterMat(o: { amp?: number; scale?: number; ring?: [number, numb /** * A fountain's spray, drawn by the GPU alone (no per-frame work): the jet * from the top falls back into the upper basin, and eight streams arc from - * the upper basin's rim into the lower one, as the kit fountain shows them. + * the upper basin's rim into the lower one. * `x, y, z` is the fountain's foot, `s` its scale. */ /** The spray's brightness, dimmed at night by setSignals. */ diff --git a/ui/src/city/scene.ts b/ui/src/city/scene.ts index 188c530..4d12dc9 100644 --- a/ui/src/city/scene.ts +++ b/ui/src/city/scene.ts @@ -19,15 +19,13 @@ import { shadowTaps, type ShadowTaps } from "./shadow"; import { onQuality, QUALITY, qualityPref, type QualitySpec } from "./quality"; import { Fx } from "./fx"; import { IMPOSTOR_NEAR, Impostors, loaded } from "./impostor"; -import { loadKit } from "./kit"; import { thumbnail } from "./thumbs"; -import { groundMat, hasReal, interiorUniforms, LEAF_OPT, leafDepth, loadReal, realMat, realPiece, setRealEnv, setSignals, setSprayScale, setWaterTime, useModels } from "./real"; +import { groundMat, interiorUniforms, LEAF_OPT, leafDepth, loadReal, realMat, realPiece, setRealEnv, setSignals, setSprayScale, setWaterTime, useModels } from "./real"; import { modelsStatus, type ModelsStatus } from "../models"; import { Traffic } from "./traffic"; import { Land } from "./land"; -import { piece } from "./kit"; import { RADIUS, ROAD_Z, WATER } from "./terrain"; -import { building, fitReal, furniture, KERB, kitLook, mat, park, Parts, rubble, site, SPECIES, tree, type ModelInfo } from "./models"; +import { building, fitReal, furniture, KERB, mat, park, Parts, rubble, site, SPECIES, tree, type ModelInfo } from "./models"; import { BLOCK_X, BLOCK_Z, LOT, lot, PAVEMENT, PITCH_X, PITCH_Z, ROAD, plan, rng, type CityPlan, type SiteSpec, type Spec } from "./plan"; const SEEN_KEY = "deepwork.seen"; @@ -373,8 +371,9 @@ export class City { // The renderer first: it picks the format compressed textures transcode to (real.ts). const city = new City(el, story); const models = (city.models = await modelsStatus()); - if (models.state === "ready") useModels(models.base, models.png ?? false); - await Promise.all([loadKit(), models.state === "ready" ? loadReal(undefined, city.renderer) : null]); + if (models.state !== "ready") throw new Error("the city's models are not downloaded yet"); + useModels(models.base, models.png ?? false); + await loadReal(undefined, city.renderer); return city; } @@ -746,12 +745,12 @@ export class City { }; // trees: past IMPOSTOR_NEAR a tile's trees are drawn as impostors // (impostor.ts); the real ones still draw the shadow map, and come back up close. - const impostors = hasReal("trees") ? this.impostors : null; - type Tile = { real: Parts; imp: Parts | null; centre: THREE.Vector3 }; + const impostors = this.impostors; + type Tile = { real: Parts; imp: Parts; centre: THREE.Vector3 }; const tiles = new Map(); const tile = (key: string, x: number, z: number) => { let t = tiles.get(key); - if (!t) tiles.set(key, (t = { real: new Parts(), imp: impostors && new Parts(), centre: new THREE.Vector3(x, 0, z) })); + if (!t) tiles.set(key, (t = { real: new Parts(), imp: new Parts(), centre: new THREE.Vector3(x, 0, z) })); return t; }; const gridTile = (x: number, z: number) => { @@ -762,17 +761,15 @@ export class City { /** A tree as tree() places it, plus its impostor. */ const treeAt = (t: Tile, x: number, z: number, s: number, seed: number, y: number) => { tree(t.real, x, z, s, seed, y); - if (t.imp) impostors!.tree(t.imp, x, z, s, seed, y, SPECIES); + impostors.tree(t.imp, x, z, s, seed, y, SPECIES); }; // Parks, empty lots and the bridge: always drawn. const parts = chunk("all", 0, 0); - // With the realistic street furniture come textured streets: asphalt, - // paving slabs, granite kerbs, drains and manhole covers. - const real = hasReal("street"); - const asphalt = real ? groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }) : mat(0x3a3c40, { roughness: 0.95 }); - const walk = real ? groundMat("pave", 3.6, { roughness: 0.88 }) : mat(0xa8a49c, { roughness: 0.9 }); + // Textured streets: asphalt, paving slabs, granite kerbs, drains and manhole covers. + const asphalt = groundMat("asphalt", 6, { roughness: 0.93, color: 0xb4b4b4 }); + const walk = groundMat("pave", 3.6, { roughness: 0.88 }); const kerbStone = mat(0xb6b2aa, { roughness: 0.8 }); - const pool = real ? lightPool() : null; + const pool = lightPool(); const iron = mat(0x2a2b2d, { roughness: 0.55, metalness: 0.5 }); const line = mat(0xe8e0c0, { roughness: 0.6 }); const pole = mat(0x3c4046, { metalness: 0.6, roughness: 0.4 }); @@ -789,17 +786,15 @@ export class City { // Pavement: a raised ring round the block, kerb on the road side. for (const sz of [-1, 1]) surf.box(walk, BLOCK_X + 2 * pw, KERB, pw, b.x, 0, b.z + sz * (BLOCK_Z / 2 + pw / 2)); for (const sx of [-1, 1]) surf.box(walk, pw, KERB, BLOCK_Z, b.x + sx * (BLOCK_X / 2 + pw / 2), 0, b.z); - if (real) { - // Granite kerbs on the road side, a drain grate by each kerb, and - // manhole covers in the carriageway. - const kx = BLOCK_X / 2 + pw - 0.09; - const kz = BLOCK_Z / 2 + pw - 0.09; - for (const sz of [-1, 1]) surf.box(kerbStone, BLOCK_X + 2 * pw, KERB + 0.004, 0.18, b.x, 0, b.z + sz * kz); - for (const sx of [-1, 1]) surf.box(kerbStone, 0.18, KERB + 0.004, BLOCK_Z + 2 * pw - 0.36, b.x + sx * kx, 0, b.z); - for (const sz of [-1, 1]) for (const t of [-0.6, 0.6]) surf.box(iron, 0.6, 0.065, 0.36, b.x + t * BLOCK_X / 2, 0, b.z + sz * (kz + 0.3)); - for (const sx of [-1, 1]) surf.box(iron, 0.36, 0.065, 0.6, b.x + sx * (kx + 0.3), 0, b.z + 4); - for (const t of [-0.35, 0.35]) surf.add(iron, new THREE.CircleGeometry(0.33, 14), b.x + t * BLOCK_X, 0.066, b.z - PITCH_Z / 2 + 1.6, -Math.PI / 2); - } + // Granite kerbs on the road side, a drain grate by each kerb, and + // manhole covers in the carriageway. + const kx = BLOCK_X / 2 + pw - 0.09; + const kz = BLOCK_Z / 2 + pw - 0.09; + for (const sz of [-1, 1]) surf.box(kerbStone, BLOCK_X + 2 * pw, KERB + 0.004, 0.18, b.x, 0, b.z + sz * kz); + for (const sx of [-1, 1]) surf.box(kerbStone, 0.18, KERB + 0.004, BLOCK_Z + 2 * pw - 0.36, b.x + sx * kx, 0, b.z); + for (const sz of [-1, 1]) for (const t of [-0.6, 0.6]) surf.box(iron, 0.6, 0.065, 0.36, b.x + t * BLOCK_X / 2, 0, b.z + sz * (kz + 0.3)); + for (const sx of [-1, 1]) surf.box(iron, 0.36, 0.065, 0.6, b.x + sx * (kx + 0.3), 0, b.z + 4); + for (const t of [-0.35, 0.35]) surf.add(iron, new THREE.CircleGeometry(0.33, 14), b.x + t * BLOCK_X, 0.066, b.z - PITCH_Z / 2 + 1.6, -Math.PI / 2); // Dashed centre lines on the roads around the block. for (let x = -PITCH_X / 2; x < PITCH_X / 2; x += 5) { surf.box(line, 2.4, 0.07, 0.25, b.x + x + 1.2, 0, b.z - PITCH_Z / 2); @@ -822,25 +817,19 @@ export class City { for (const t of [-1, -1 / 3, 1 / 3, 1]) { const x = b.x + t * (BLOCK_X / 2 - 1); const z = b.z + lz * ez; - if (real) { - // The arm reaches out over the road; at night its light pools - // on the road and pavement below (lit faces point down, so from - // the usual overhead view the pool is what shows). The pool - // stays when the lamp drops out with distance. - furniture(props, "lamp_led", x, KERB, z, lz > 0 ? 0 : Math.PI); - surf.add(pool!, new THREE.PlaneGeometry(11, 11), x, 0.08, z + lz * 1.8, -Math.PI / 2); - continue; - } - props.cyl(pole, 0.08, 0.12, 5, x, KERB, z, 6); - props.box(pole, 0.2, 0.15, 1.4, x, KERB + 4.9, z + lz * 0.6); - props.box(bulb, 0.5, 0.18, 0.5, x, KERB + 4.75, z + lz * 1.1); + // The arm reaches out over the road; at night its light pools + // on the road and pavement below (lit faces point down, so from + // the usual overhead view the pool is what shows). The pool + // stays when the lamp drops out with distance. + furniture(props, "lamp_led", x, KERB, z, lz > 0 ? 0 : Math.PI); + surf.add(pool, new THREE.PlaneGeometry(11, 11), x, 0.08, z + lz * 1.8, -Math.PI / 2); } for (const t of [-5 / 6, -1 / 2, -1 / 6, 1 / 6, 1 / 2, 5 / 6]) { treeAt(trees, b.x + t * (BLOCK_X / 2 - 1), b.z + lz * ez, 0.5, Math.floor(b.x * 31 + b.z * 17 + t * 12 + lz * 5), KERB); } } for (const sx of [-1, 1]) treeAt(trees, b.x + sx * ex, b.z, 0.5, Math.floor(b.x * 13 + b.z * 7 + sx), KERB); - if (real) this.streetFurniture(tiny, b, p.tier); + this.streetFurniture(tiny, b, p.tier); } for (const k of p.parks) park(parts, k, pole, bulb); // Empty lots: rough grass with survey stakes, waiting to be built on. @@ -848,7 +837,7 @@ export class City { const tape = mat(0xff8a2a, { emissive: 0x401800 }); const e = LOT / 2 - 1.4; for (const l of p.emptyLots) { - parts.box(real && hasReal("trees") ? groundMat("grass", 2.2, { roughness: 1, color: 0xc4b080 }) : mat(0x7a8f55, { roughness: 1 }), LOT - 0.4, KERB + 0.06, LOT - 0.4, l.x, 0, l.z); + parts.box(groundMat("grass", 2.2, { roughness: 1, color: 0xc4b080 }), LOT - 0.4, KERB + 0.06, LOT - 0.4, l.x, 0, l.z); for (const [cx, cz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) parts.box(stake, 0.15, 0.9, 0.15, l.x + cx * e, KERB, l.z + cz * e); for (const cz of [-1, 1]) parts.box(tape, 2 * e, 0.04, 0.04, l.x, KERB + 0.75, l.z + cz * e); for (const cx of [-1, 1]) parts.box(tape, 0.04, 0.04, 2 * e, l.x + cx * e, KERB + 0.75, l.z); @@ -889,39 +878,29 @@ export class City { const mesh = new THREE.Mesh(g, asphalt); mesh.receiveShadow = true; this.base.add(mesh); - if (real) { - // Lane markings over the hills: a dashed centre line, solid edge lines. - const mp: number[] = []; - const quad = (xa: number, xb: number, z0: number, z1: number) => { - const ya = ground.height(xa, ROAD_Z) + 0.07; - const yb = ground.height(xb, ROAD_Z) + 0.07; - mp.push(xa, ya, z0, xb, yb, z0, xb, yb, z1, xa, ya, z0, xb, yb, z1, xa, ya, z1); - }; - for (let x = x0; x + 3 < x1; x += 9) quad(x, x + 3, ROAD_Z - 0.07, ROAD_Z + 0.07); - for (let x = x0; x < x1; x += 4) { - const xb = Math.min(x1, x + 4); - for (const e of [-1, 1]) quad(x, xb, ROAD_Z + e * 4.05 - 0.07, ROAD_Z + e * 4.05 + 0.07); - } - const lg = new THREE.BufferGeometry(); - lg.setAttribute("position", new THREE.Float32BufferAttribute(mp, 3)); - // Wound to face up whatever the order above: draw both sides. - const marks = new THREE.Mesh(lg, mat(0xe8e4d8, { roughness: 0.7, side: THREE.DoubleSide })); - lg.computeVertexNormals(); - marks.receiveShadow = true; - this.base.add(marks); + // Lane markings over the hills: a dashed centre line, solid edge lines. + const mp: number[] = []; + const quad = (xa: number, xb: number, z0: number, z1: number) => { + const ya = ground.height(xa, ROAD_Z) + 0.07; + const yb = ground.height(xb, ROAD_Z) + 0.07; + mp.push(xa, ya, z0, xb, yb, z0, xb, yb, z1, xa, ya, z0, xb, yb, z1, xa, ya, z1); + }; + for (let x = x0; x + 3 < x1; x += 9) quad(x, x + 3, ROAD_Z - 0.07, ROAD_Z + 0.07); + for (let x = x0; x < x1; x += 4) { + const xb = Math.min(x1, x + 4); + for (const e of [-1, 1]) quad(x, xb, ROAD_Z + e * 4.05 - 0.07, ROAD_Z + e * 4.05 + 0.07); } + const lg = new THREE.BufferGeometry(); + lg.setAttribute("position", new THREE.Float32BufferAttribute(mp, 3)); + // Wound to face up whatever the order above: draw both sides. + const marks = new THREE.Mesh(lg, mat(0xe8e4d8, { roughness: 0.7, side: THREE.DoubleSide })); + lg.computeVertexNormals(); + marks.receiveShadow = true; + this.base.add(marks); }; ribbon(bx + 15, RADIUS - 1); ribbon(-RADIUS + 1, bx - 15); - if (hasReal("bridge")) { - for (const { role, geo } of realPiece("bridge", "bridge")) parts.add(realMat("bridge", role, { wall: 0, accent: 0, roof: 0, signs: 0 }, 0), geo, bx, 0, bz); - } else { - for (const { role, geo } of piece("bridge").parts) { - const m = kitLook(role); - if (m.userData.night && !this.lampMats.includes(m)) this.lampMats.push(m); - parts.add(m, geo, bx, 0, bz); - } - } + for (const { role, geo } of realPiece("bridge", "bridge")) parts.add(realMat("bridge", role, { wall: 0, accent: 0, roof: 0, signs: 0 }, 0), geo, bx, 0, bz); this.chunks = []; for (const c of chunks.values()) { const g = new THREE.Group(); diff --git a/ui/src/city/textures.ts b/ui/src/city/textures.ts deleted file mode 100644 index 92810b7..0000000 --- a/ui/src/city/textures.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Window atlas drawn on canvases at startup. Every pane of the Blender kit -// maps to one cell of an ATLAS x ATLAS grid, and buildings shift cells per -// floor, so each window gets its own look: the colour map holds glass, -// curtains and blinds by day, the emissive map the lit rooms at night. -import * as THREE from "three"; -import { rng } from "./plan"; - -/** Cells per side; must match CELLS in city_kit.py. */ -export const ATLAS = 32; -const PX = 16; -const SIZE = ATLAS * PX; - -/** Residential sash windows, or office glazing. */ -export type Pane = "window" | "glass"; - -export interface PaneSet { - map: THREE.Texture; - emissive: THREE.Texture; - /** Smashed, scorched and partly burning. */ - brokenMap: THREE.Texture; - brokenEmissive: THREE.Texture; -} - -function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] { - const c = document.createElement("canvas"); - c.width = c.height = SIZE; - return [c, c.getContext("2d")!]; -} - -function texture(c: HTMLCanvasElement): THREE.Texture { - const t = new THREE.CanvasTexture(c); - t.wrapS = t.wrapT = THREE.RepeatWrapping; - t.colorSpace = THREE.SRGBColorSpace; - // glTF UVs run top-down. - t.flipY = false; - t.anisotropy = 4; - return t; -} - -const WARM = ["#ffd38a", "#ffc46b", "#ffe2b0", "#fff0d0", "#ffb35c", "#ffcf9a"]; -const COOL = ["#fff1d8", "#ffe6c0", "#f2f0ea", "#ffd9a8", "#e8eefc"]; -const CURTAINS = ["#e8e0cc", "#d9c7a8", "#b8483a", "#4a6a8a", "#c9b07a", "#f0ece4", "#7a8a5a"]; - -function draw(kind: Pane, seed: number, broken: boolean): [HTMLCanvasElement, HTMLCanvasElement] { - const r = rng(seed); - const [mc, m] = canvas(); - const [ec, e] = canvas(); - e.fillStyle = "#000"; - e.fillRect(0, 0, SIZE, SIZE); - for (let row = 0; row < ATLAS; row++) { - for (let col = 0; col < ATLAS; col++) { - const x = col * PX; - const y = row * PX; - const smashed = broken && r() < 0.55; - // Glass: dark, with a sky reflection that fades down the pane. - const base = kind === "glass" ? 150 + r() * 60 : 34 + r() * 26; - m.fillStyle = smashed ? "#0b0908" : kind === "glass" ? `rgb(${base},${base},${base})` : `rgb(${base * 0.75},${base * 0.9},${base + 22})`; - m.fillRect(x, y, PX, PX); - if (!smashed) { - const g = m.createLinearGradient(x, y, x + PX * 0.6, y + PX); - g.addColorStop(0, `rgba(255,255,255,${kind === "glass" ? 0.35 : 0.22})`); - g.addColorStop(0.55, "rgba(255,255,255,0)"); - g.addColorStop(1, "rgba(0,0,0,0.12)"); - m.fillStyle = g; - m.fillRect(x, y, PX, PX); - } - if (smashed) { - // Jagged shards and soot. - m.fillStyle = "rgba(90,90,96,0.6)"; - for (let i = 0; i < 3; i++) { - m.beginPath(); - const sx = x + r() * PX; - m.moveTo(sx, y); - m.lineTo(sx + (r() - 0.5) * 6, y + 3 + r() * 5); - m.lineTo(sx + 3, y); - m.fill(); - } - if (r() < 0.3) { - e.fillStyle = r() < 0.5 ? "#ff5a10" : "#ff8a20"; - e.fillRect(x, y + PX * (0.25 + r() * 0.3), PX, PX); - e.fillStyle = "#ffc040"; - e.fillRect(x + 3, y + PX * 0.7, PX - 6, PX * 0.3); - } - continue; - } - const lit = r() < (broken ? 0.12 : kind === "glass" ? 0.5 : 0.58); - if (kind === "window") { - // Curtains or blinds by day, muntins on top. - const dressing = r(); - const cur = CURTAINS[Math.floor(r() * CURTAINS.length)]; - if (dressing < 0.45) { - const w = PX * (0.18 + r() * 0.2); - m.fillStyle = cur; - m.fillRect(x, y, w, PX); - m.fillRect(x + PX - w, y, w, PX); - } else if (dressing < 0.65) { - m.fillStyle = "#ddd6c8"; - m.fillRect(x, y, PX, PX * (0.2 + r() * 0.6)); - m.fillStyle = "rgba(0,0,0,0.15)"; - for (let ly = y + 2; ly < y + PX; ly += 2) m.fillRect(x, ly, PX, 0.6); - } - if (lit) { - const warm = r() < 0.08 ? "#9ec4ff" : WARM[Math.floor(r() * WARM.length)]; - const g = e.createRadialGradient(x + PX / 2, y + PX * 0.35, 1, x + PX / 2, y + PX / 2, PX * 0.75); - g.addColorStop(0, warm); - g.addColorStop(1, "rgba(120,70,20,0.85)"); - e.fillStyle = g; - e.fillRect(x, y, PX, PX); - if (dressing < 0.45) { - e.fillStyle = "rgba(0,0,0,0.55)"; - const w = PX * (0.2 + r() * 0.15); - e.fillRect(x, y, w, PX); - e.fillRect(x + PX - w, y, w, PX); - } - } - m.fillStyle = "#e8e4dc"; - m.fillRect(x + PX / 2 - 0.5, y, 1, PX); - m.fillRect(x, y + PX * 0.45, PX, 1); - e.fillStyle = "#000"; - e.fillRect(x + PX / 2 - 0.5, y, 1, PX); - e.fillRect(x, y + PX * 0.45, PX, 1); - } else { - // Offices: a ceiling-light band and desks; some floors half lit. - m.fillStyle = "rgba(0,0,0,0.18)"; - m.fillRect(x, y + PX * 0.82, PX, PX * 0.18); - if (lit) { - const c = COOL[Math.floor(r() * COOL.length)]; - const g = e.createLinearGradient(x, y, x, y + PX); - g.addColorStop(0, c); - g.addColorStop(1, "rgba(90,60,30,0.9)"); - e.fillStyle = g; - e.globalAlpha = 0.45 + r() * 0.45; - e.fillRect(x, y, PX, PX); - e.globalAlpha = 1; - e.fillStyle = c; - e.fillRect(x, y + 1, PX, 1.5); - e.fillStyle = "rgba(0,0,0,0.5)"; - for (let i = 0; i < 2; i++) e.fillRect(x + 1 + r() * (PX - 6), y + PX * 0.62, 3 + r() * 3, PX * 0.38); - } else if (r() < 0.2) { - e.fillStyle = "#1a2a40"; - e.fillRect(x, y, PX, PX); - } - } - } - } - return [mc, ec]; -} - -const cache = new Map(); - -export function panes(kind: Pane): PaneSet { - let set = cache.get(kind); - if (!set) { - const seed = kind === "glass" ? 7919 : 977; - const [m, e] = draw(kind, seed, false); - const [bm, be] = draw(kind, seed + 1, true); - set = { map: texture(m), emissive: texture(e), brokenMap: texture(bm), brokenEmissive: texture(be) }; - cache.set(kind, set); - } - return set; -} - -/** A soft round sprite for particles. */ -export function puff(): THREE.Texture { - const c = document.createElement("canvas"); - c.width = c.height = 64; - const g = c.getContext("2d")!; - const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, "rgba(255,255,255,1)"); - grad.addColorStop(0.25, "rgba(255,255,255,0.55)"); - grad.addColorStop(0.6, "rgba(255,255,255,0.15)"); - grad.addColorStop(1, "rgba(255,255,255,0)"); - g.fillStyle = grad; - g.fillRect(0, 0, 64, 64); - const t = new THREE.CanvasTexture(c); - t.colorSpace = THREE.SRGBColorSpace; - return t; -} diff --git a/ui/src/city/traffic.ts b/ui/src/city/traffic.ts index ac75294..ed4c3c1 100644 --- a/ui/src/city/traffic.ts +++ b/ui/src/city/traffic.ts @@ -1,14 +1,12 @@ // City theme: street life. Cars, taxis, vans and buses loop around each // block in the right-hand lane; pedestrians walk the pavements. Both grow // with the tier, so a metropolis is busy and a hamlet sleepy. Every part of -// a kit model is one InstancedMesh, moved every frame; headlights, tail +// a model is one InstancedMesh, moved every frame; headlights, tail // lights and signs follow the night. import * as THREE from "three"; -import { piece } from "./kit"; import { BLOCK_X, BLOCK_Z, PAVEMENT, ROAD, blockLoop, rng, type CityPlan, type Loop } from "./plan"; -import { CAR_PAINTS, hasReal, realMat, realPiece } from "./real"; +import { CAR_PAINTS, realMat, realPiece } from "./real"; -const CAR_PAINT = [0xb8252a, 0x1f4e8c, 0xe8e6e0, 0x2a2c30, 0x8a8f96, 0x2f6b4a, 0xd9a03a, 0x6a2a4a, 0x3a7fb8, 0xc8c2b4]; const BUS_PAINT = [0xc0392b, 0x2e6fb5, 0x2a9a5a]; const SHIRTS = [0xc0392b, 0x2e6fb5, 0x2a9a5a, 0xe8b020, 0xf0ece4, 0x6a4a8a, 0xe07a30, 0x30343a, 0x8ab0d0, 0xd06a8a]; const PANTS = [0x2a3040, 0x3a3a3e, 0x4a3a2a, 0x1e2a4a, 0x6a6458, 0x202020]; @@ -18,23 +16,10 @@ const HAIR = [0x1a1410, 0x3a2414, 0x6a4424, 0xb08a50, 0x8a8a88]; interface Look { color: number; rough?: number; - metal?: number; - emissive?: number; - night?: number; - /** Emissive by day too (tail lights, signs), as a share of the night level. */ - day?: number; } +/** The figure's roles; colours come per person from the palettes above. */ const LOOKS: Record = { - body: { color: 0xffffff, rough: 0.35, metal: 0.35 }, - carglass: { color: 0x1a2028, rough: 0.1, metal: 0.6 }, - dark: { color: 0x222326, rough: 0.8 }, - tyre: { color: 0x151515, rough: 0.9 }, - chrome: { color: 0xc8ccd0, rough: 0.25, metal: 0.9 }, - headlight: { color: 0xfff6e0, emissive: 0xfff2d0, night: 3 }, - taillight: { color: 0x801010, emissive: 0xff2010, night: 2.2, day: 0.15 }, - taxisign: { color: 0xffd040, emissive: 0xffd040, night: 1.6, day: 0.2 }, - led: { color: 0x402000, emissive: 0xffa020, night: 1.8, day: 0.5 }, skin: { color: 0xffffff, rough: 0.8 }, shirt: { color: 0xffffff, rough: 0.9 }, pants: { color: 0xffffff, rough: 0.9 }, @@ -94,11 +79,8 @@ interface Mover { bob: number; } -/** The kit model standing in for each realistic vehicle when those are not loaded. */ -const KIT: Record = { - hatch: "car_hatch", sedan: "car_sedan", estate: "car_sedan", suv: "car_sedan", coupe: "car_hatch", - taxi: "car_taxi", pickup: "car_van", van: "car_van", truck: "car_van", bus: "bus", -}; +/** The vehicles in traffic (pieces of cars.glb). */ +const VEHICLES = ["hatch", "sedan", "estate", "suv", "coupe", "taxi", "pickup", "van", "truck", "bus"]; /** Vehicles per block and people per block, by tier. */ const CARS = [1, 2, 3, 4]; @@ -116,12 +98,7 @@ export class Traffic { let m = this.mats.get(role); if (!m) { const l = LOOKS[role] ?? { color: 0xff00ff }; - m = new THREE.MeshStandardMaterial({ color: l.color, roughness: l.rough ?? 0.6, metalness: l.metal ?? 0, vertexColors: true }); - if (l.emissive !== undefined) { - m.emissive.setHex(l.emissive); - m.userData.night = l.night; - m.userData.day = l.day ?? 0; - } + m = new THREE.MeshStandardMaterial({ color: l.color, roughness: l.rough ?? 0.6, vertexColors: true }); this.mats.set(role, m); } return m; @@ -157,21 +134,19 @@ export class Traffic { }); const count = (m: string) => plans.filter((v) => v.model === m).length; const fleets = new Map(); - const real = hasReal("cars"); this.lamps.clear(); const realMats = (role: string) => { const m = realMat("cars", role, { wall: -1, accent: 0, roof: 0, signs: 0 }, 0, "traffic") as THREE.MeshStandardMaterial; if (m.userData.night) this.lamps.add(m); return m; }; - for (const model of Object.keys(KIT)) { + for (const model of VEHICLES) { const n = count(model); if (!n) continue; - const colours = model === "taxi" ? [0xf2c12e] : model === "bus" ? BUS_PAINT : real ? CAR_PAINTS : CAR_PAINT; - const parts = real ? realPiece("cars", model) : piece(KIT[model]).parts; + const colours = model === "taxi" ? [0xf2c12e] : model === "bus" ? BUS_PAINT : CAR_PAINTS; + const parts = realPiece("cars", model); const withBlob = [...parts, { role: "blob", geo: blobGeo(parts) }]; - if (real) fleets.set(model, new Fleet(withBlob, n, { carpaint: colours }, (role) => (role === "blob" ? this.blob : realMats(role)), r)); - else fleets.set(model, new Fleet(withBlob, n, { body: colours }, (role) => (role === "blob" ? this.blob : this.mat(role)), r)); + fleets.set(model, new Fleet(withBlob, n, { carpaint: colours }, (role) => (role === "blob" ? this.blob : realMats(role)), r)); } const slots = new Map(); // The lane next to the block: a quarter of the way across the carriageway. @@ -188,16 +163,13 @@ export class Traffic { } const walkers = p.blocks.length * PEOPLE[tier]; - // The realistic figure (civic.py) when loaded; the same roles, so the - // same palettes. These materials read the kit's baked AO from vertex - // colours, so the figure gets plain white ones. - const figure = hasReal("civic") - ? realPiece("civic", "person").map(({ role, geo }) => { - const g = geo.clone(); - g.setAttribute("color", new THREE.Float32BufferAttribute(new Float32Array(g.getAttribute("position").count * 3).fill(1), 3)); - return { role, geo: g }; - }) - : piece("person").parts; + // The figure (civic.py), tinted per person by role. Its materials take + // vertex colours, so the figure gets plain white ones. + const figure = realPiece("civic", "person").map(({ role, geo }) => { + const g = geo.clone(); + g.setAttribute("color", new THREE.Float32BufferAttribute(new Float32Array(g.getAttribute("position").count * 3).fill(1), 3)); + return { role, geo: g }; + }); const people = new Fleet(figure, walkers, { shirt: SHIRTS, pants: PANTS, skin: SKIN, hair: HAIR }, this.mat, r); const walks = p.blocks.flatMap((b) => [ blockLoop(b.x, b.z, BLOCK_X / 2 + 0.5, BLOCK_Z / 2 + 0.5, 0.8), @@ -238,7 +210,7 @@ export class Traffic { /** 0 by day, 1 at night. */ setNight(night: number) { this.blob.opacity = 0.8 - 0.45 * night; - for (const m of [...this.mats.values(), ...this.lamps]) { + for (const m of this.lamps) { if (m.userData.night) m.emissiveIntensity = m.userData.night * Math.max(night, m.userData.day ?? 0); } } diff --git a/ui/src/main.ts b/ui/src/main.ts index 71101c7..aceebd3 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -66,18 +66,18 @@ function banner(text: string | null) { el.textContent = text ?? ""; } -/** The realistic city's download, while there is one to report (models.ts). */ +/** The city's models (models.ts) until they are all there and the city is drawn. */ let models: ModelsStatus | null = null; function modelsLine(): string { const mb = (n: number) => (n / 1e6).toFixed(0); switch (models?.state) { case "downloading": - return `
Realistic city: ${mb(models.done)} of ${mb(models.total)} MB downloaded
`; + return `
Downloading the city: ${mb(models.done)} of ${mb(models.total)} MB
`; case "retrying": - return `
Realistic city: download stalled, retrying in ${models.retry_secs < 60 ? `${models.retry_secs} s` : hm(models.retry_secs)}
`; - case "ready": - return `
Realistic city downloaded.
`; + return `
City download stalled, retrying in ${models.retry_secs < 60 ? `${models.retry_secs} s` : hm(models.retry_secs)}
`; + case "none": + return `
This build has no city models.
`; default: return ""; } @@ -566,7 +566,10 @@ async function poll() { if (busy) return; busy = true; try { - if (models && models.state !== "ready") models = await modelsStatus(); + if (!skyline && models && models.state !== "ready") { + models = await modelsStatus(); + if (models.state === "ready") void startCity(); + } status = await getStatus(); const s = status.session; const key = s ? `${s.id}:${s.state}` : "none"; @@ -600,25 +603,37 @@ async function poll() { window.addEventListener("error", (e) => banner(`UI error: ${e.message}`)); window.addEventListener("unhandledrejection", (e) => banner(`UI error: ${e.reason}`)); -// The HUD works without the skyline, so a renderer failure only costs the picture. -try { - skyline = await City.create($("#stage"), story); - skyline.onHover = hover; - if (skyline.models.state === "downloading" || skyline.models.state === "retrying") models = skyline.models; - skyline.onGraduate = levelUp; - skyline.onPick = (i) => { - skyline?.look(i); - openDeed(i); - }; - if (!("__TAURI_INTERNALS__" in window)) Object.assign(window, { city: skyline }); -} catch (e) { - console.error(e); - rendererError = `Skyline unavailable: ${e}`; -} -// The city was drawn with the kit while the models downloaded; a reload draws it again with them. -$("#city").addEventListener("click", (e) => { - if ((e.target as Element).closest('[data-action="show-models"]')) location.reload(); -}); +// The HUD works without the skyline: until the models are downloaded there is +// none, and a renderer failure only costs the picture. +let starting = false; +async function startCity() { + if (skyline || starting) return; + starting = true; + try { + const city = await City.create($("#stage"), story); + city.onHover = hover; + city.onGraduate = levelUp; + city.onPick = (i) => { + skyline?.look(i); + openDeed(i); + }; + skyline = city; + models = null; + rendererError = null; + if (!("__TAURI_INTERNALS__" in window)) Object.assign(window, { city: skyline }); + if (world) { + renderCity(world); + skyline.update(world, localHour(), status?.session?.elapsed_secs ?? 0); + } + } catch (e) { + console.error(e); + rendererError = `Skyline unavailable: ${e}`; + } finally { + starting = false; + } +} +models = await modelsStatus(); +if (models.state === "ready") await startCity(); await poll(); setInterval(poll, 1000); // `VITE_FPS=1` (dev server or build) adds a frame-rate readout with the 1% low, for tuning. diff --git a/ui/src/models.ts b/ui/src/models.ts index ec26132..cfd6b18 100644 --- a/ui/src/models.ts +++ b/ui/src/models.ts @@ -1,7 +1,7 @@ // Where the realistic city's models come from (docs/APP.md, "Model // packs"). A release build downloads them on first run (the app's // src-tauri/src/models.rs) and reads them through models://; until then the -// city is drawn with the low-poly kit. The dev server and the browser mock +// HUD shows the download and no city. The dev server and the browser mock // serve public/models as it is. import { invoke } from "@tauri-apps/api/core"; diff --git a/ui/src/proto/main.ts b/ui/src/proto/main.ts index cfd1d10..cdda3f5 100644 --- a/ui/src/proto/main.ts +++ b/ui/src/proto/main.ts @@ -16,10 +16,9 @@ import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js"; -import { loadKit } from "../city/kit"; import { building, fitReal, furniture, KERB, Parts, rubble } from "../city/models"; import { LOT, PAVEMENT, type Spec } from "../city/plan"; -import { DEFS, hasReal, interiorUniforms, loadReal, PROPS, realAo, realMat, realPiece, realPieces, setRealEnv, setSignals } from "../city/real"; +import { DEFS, interiorUniforms, loadReal, PROPS, realAo, realMat, realPiece, realPieces, setRealEnv, setSignals } from "../city/real"; const q = new URLSearchParams(location.search); const hour = Number(q.get("hour") ?? 15); @@ -162,40 +161,21 @@ function street(cz: number) { g.add(m); } } - const pole = new THREE.MeshStandardMaterial({ color: 0x232527, roughness: 0.5, metalness: 0.5 }); - const bulb = new THREE.MeshStandardMaterial({ color: 0xfff2d8, emissive: 0xffd9a0, emissiveIntensity: night ? 6 : 0.2 }); - if (hasReal("street")) { - // The realistic street furniture: lamps reaching over the road, signals - // at the near corner, a hydrant and a bin. - const parts = new Parts(); - const e = pw / 2 - 0.55; - furniture(parts, "lamp_led", -pw / 2 + 0.6, KERB, cz + e, 0); - furniture(parts, "lamp_led", pw / 2 - 0.6, KERB, cz - e, Math.PI); - furniture(parts, "signal_mast", e, KERB, cz + e, Math.PI / 2, undefined, "a"); - furniture(parts, "signal_post", -e, KERB, cz - e, Math.PI, undefined, "b"); - furniture(parts, "hydrant", 3, KERB, cz + pw / 2 - 0.4); - furniture(parts, "bin", pw / 2 - 0.4, KERB, cz + 4); - parts.into(g); - g.traverse((o) => { - const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined; - if (m?.userData?.night) m.emissiveIntensity = night ? m.userData.night : 0; - }); - return g; - } - for (const [x, z] of [[pw / 2 - 0.6, cz + pw / 2 - 0.6], [-pw / 2 + 0.6, cz + pw / 2 - 0.6], [pw / 2 - 0.6, cz - pw / 2 + 0.6]]) { - const s = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.11, 5.5, 12), pole); - s.position.set(x, 2.75 + KERB, z); - s.castShadow = true; - g.add(s); - const head = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.32, 0.45, 12), bulb); - head.position.set(x, 5.6 + KERB, z); - g.add(head); - if (night) { - const l = new THREE.PointLight(0xffc98a, 18, 16, 1.6); - l.position.set(x, 5.2 + KERB, z); - g.add(l); - } - } + // The realistic street furniture: lamps reaching over the road, signals + // at the near corner, a hydrant and a bin. + const parts = new Parts(); + const e = pw / 2 - 0.55; + furniture(parts, "lamp_led", -pw / 2 + 0.6, KERB, cz + e, 0); + furniture(parts, "lamp_led", pw / 2 - 0.6, KERB, cz - e, Math.PI); + furniture(parts, "signal_mast", e, KERB, cz + e, Math.PI / 2, undefined, "a"); + furniture(parts, "signal_post", -e, KERB, cz - e, Math.PI, undefined, "b"); + furniture(parts, "hydrant", 3, KERB, cz + pw / 2 - 0.4); + furniture(parts, "bin", pw / 2 - 0.4, KERB, cz + 4); + parts.into(g); + g.traverse((o) => { + const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined; + if (m?.userData?.night) m.emissiveIntensity = night ? m.userData.night : 0; + }); return g; } @@ -253,7 +233,7 @@ function lineup() { scene.add(g); } -Promise.all([loadKit(), loadReal([id, ...PROPS], renderer)]).then(() => { +loadReal([id, ...PROPS], renderer, true).then(() => { if (props) { lineup(); frames = 0; diff --git a/ui/src/proto/town.ts b/ui/src/proto/town.ts index 48a6e3e..70ba4cc 100644 --- a/ui/src/proto/town.ts +++ b/ui/src/proto/town.ts @@ -28,7 +28,6 @@ import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js"; import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js"; import { FXAAPass } from "three/examples/jsm/postprocessing/FXAAPass.js"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; -import { loadKit } from "../city/kit"; import { building, furniture, KERB, mat, park, parked, Parts, SPECIES, tree } from "../city/models"; import { BLOCK_X, BLOCK_Z, LOT, PAVEMENT, PITCH_X, PITCH_Z, rng, type Spec } from "../city/plan"; import { IMPOSTOR_NEAR, Impostors, loaded } from "../city/impostor"; @@ -561,7 +560,7 @@ function traffic() { // ---- go ---- -Promise.all([loadKit(), loadReal([...Object.keys(DEFS), ...PROPS], renderer)]).then(async () => { +loadReal([...Object.keys(DEFS), ...PROPS], renderer, true).then(async () => { const missing = [...Object.keys(DEFS), ...PROPS].filter((id) => !hasReal(id)); if (missing.length) console.warn("not built:", missing); const ground = new Parts(); diff --git a/ui/src/style.css b/ui/src/style.css index 4cf182e..7e661e1 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -180,7 +180,6 @@ button.link:hover:not(:disabled) { background: none; text-decoration: underline; .list .num { font-variant-numeric: tabular-nums; color: var(--muted-foreground); white-space: nowrap; } .list time { flex: none; width: 104px; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } .list button.link { padding: 0; } -.models-ready button.link { padding: 0; margin-left: 4px; } .tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; background: var(--muted); color: var(--muted-foreground); } .tag.loss { background: var(--loss-soft); color: var(--loss); } .tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }