blender-house-library.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966
  1. """Build a small, deterministic architectural asset library with Blender 5.x.
  2. Run from the workspace (no add-ons, textures, downloads, or app server required):
  3. blender --background --factory-startup --python-exit-code 1 --python tools/city-massing/blender-house-library.py
  4. blender --background --factory-startup --python-exit-code 1 --python tools/city-massing/blender-house-library.py -- --samples 48
  5. Only this script's sibling house-library directory is written by default.
  6. Authoring coordinates: metres, Z up, +Y front, footprint centred, ground at Z=0.
  7. Each runtime object is one mesh composed of individually closed solids. Detail
  8. solids deliberately intersect; these are instancing assets, not boolean-unioned
  9. construction CAD. Optional foundations are separate, strictly below ground.
  10. """
  11. import argparse
  12. import json
  13. import math
  14. import random
  15. import struct
  16. import sys
  17. import zlib
  18. from pathlib import Path
  19. import bpy
  20. import bmesh
  21. from mathutils import Vector
  22. SEED = 20260918
  23. RNG = random.Random(SEED)
  24. PALETTE = {}
  25. MATERIALS = []
  26. CUBE_FACES = [(0, 3, 2, 1), (0, 1, 5, 4), (1, 2, 6, 5),
  27. (2, 3, 7, 6), (3, 0, 4, 7), (4, 5, 6, 7)]
  28. def arguments():
  29. parser = argparse.ArgumentParser(description=__doc__)
  30. parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parent / 'house-library')
  31. parser.add_argument('--samples', type=int, default=40)
  32. parser.add_argument('--skip-render', action='store_true')
  33. return parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
  34. def linear(v):
  35. return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
  36. def material(name, color, roughness, metallic=0.0):
  37. rgb = tuple(linear(int(color[i:i + 2], 16) / 255) for i in (0, 2, 4))
  38. mat = bpy.data.materials.new(name)
  39. mat.use_nodes = True
  40. mat.diffuse_color = (*rgb, 1.0)
  41. mat.use_backface_culling = True
  42. shader = mat.node_tree.nodes.get('Principled BSDF')
  43. shader.inputs['Base Color'].default_value = (*rgb, 1.0)
  44. shader.inputs['Roughness'].default_value = roughness
  45. shader.inputs['Metallic'].default_value = metallic
  46. mat['palette_hex_srgb'] = '#' + color
  47. PALETTE[name] = len(MATERIALS)
  48. MATERIALS.append(mat)
  49. def make_materials():
  50. for entry in [
  51. ('Plaster_Cream', 'E4DAC4', 0.87),
  52. ('Plaster_Ivory', 'F2ECDD', 0.86),
  53. ('Plaster_Sand', 'C2AF96', 0.88),
  54. ('Reveal_Shadow', '8D8B80', 0.91),
  55. ('Stone_Base', 'A1A39C', 0.89),
  56. ('Concrete_Warm', 'C8C7BD', 0.90),
  57. ('Roof_Terracotta', '98634A', 0.84),
  58. ('Roof_Terra_Light', 'AC7758', 0.86),
  59. ('Roof_Slate', '596264', 0.82),
  60. ('Roof_Slate_Light', '697272', 0.83),
  61. ('Frame_Ivory', 'DEDCD0', 0.49, 0.15),
  62. ('Metal_Charcoal', '475052', 0.48, 0.48),
  63. ('Glass_Blue', '617E88', 0.24, 0.20),
  64. ('Glass_Soft', '91A3A3', 0.30, 0.12),
  65. ('Glass_Dark', '405F6D', 0.24, 0.20),
  66. ('Door_Walnut', '715747', 0.68),
  67. ('Roof_Membrane', '777F7D', 0.93),
  68. ]:
  69. material(*entry)
  70. def cube_vertices(center, size):
  71. x, y, z = center
  72. a, b, c = (s / 2 for s in size)
  73. return [(x - a, y - b, z - c), (x + a, y - b, z - c),
  74. (x + a, y + b, z - c), (x - a, y + b, z - c),
  75. (x - a, y - b, z + c), (x + a, y - b, z + c),
  76. (x + a, y + b, z + c), (x - a, y + b, z + c)]
  77. def face_point(side, u, depth, z, center=(0, 0)):
  78. x, y = center
  79. if side == 'front':
  80. return (x + u, y + depth, z)
  81. if side == 'right':
  82. return (x + depth, y - u, z)
  83. if side == 'back':
  84. return (x - u, y - depth, z)
  85. return (x - depth, y + u, z)
  86. class Geometry:
  87. """Accumulate closed pieces, tagging dispensable small detail for LOD1."""
  88. def __init__(self):
  89. self.pieces = []
  90. def solid(self, vertices, faces, mat, tiny=False):
  91. indices = [PALETTE[m] for m in mat] if isinstance(mat, list) else [PALETTE[mat]] * len(faces)
  92. self.pieces.append((vertices, faces, indices, tiny))
  93. def box(self, center, size, mat, tiny=False):
  94. assert min(size) > 0, (center, size)
  95. self.solid(cube_vertices(center, size), CUBE_FACES, mat, tiny)
  96. def fbox(self, side, u, depth, z, width, thickness, height, mat, center=(0, 0), tiny=False):
  97. vertices = [face_point(side, *v, center) for v in cube_vertices((u, depth, z), (width, thickness, height))]
  98. self.solid(vertices, CUBE_FACES, mat, tiny)
  99. def ring(self, side, u, depth, z, width, height, border, thickness, mat, center=(0, 0)):
  100. vertices = []
  101. for d in (depth - thickness / 2, depth + thickness / 2):
  102. for w, h in ((width, height), (width - 2 * border, height - 2 * border)):
  103. vertices += [face_point(side, u + a * w / 2, d, z + b * h / 2, center)
  104. for a, b in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
  105. faces = []
  106. for i in range(4):
  107. j = (i + 1) % 4
  108. faces += [(i, j, 8 + j, 8 + i), (4 + i, 12 + i, 12 + j, 4 + j),
  109. (i, 4 + i, 4 + j, j), (8 + i, 8 + j, 12 + j, 12 + i)]
  110. self.solid(vertices, faces, mat)
  111. def object(self, name, collection, lod=0):
  112. vertices, faces, indices = [], [], []
  113. for verts, polys, mats, tiny in self.pieces:
  114. if lod and tiny:
  115. continue
  116. offset = len(vertices)
  117. vertices.extend(verts)
  118. faces.extend(tuple(offset + i for i in poly) for poly in polys)
  119. indices.extend(mats)
  120. mesh = bpy.data.meshes.new(name + '_Mesh')
  121. mesh.from_pydata(vertices, [], faces)
  122. mesh.update()
  123. for mat in MATERIALS:
  124. mesh.materials.append(mat)
  125. for poly, index in zip(mesh.polygons, indices):
  126. poly.material_index = index
  127. bm = bmesh.new()
  128. bm.from_mesh(mesh)
  129. bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
  130. # Coalesce the coplanar window-wall grid, preserving reveal geometry.
  131. bmesh.ops.dissolve_limit(bm, angle_limit=0.00001, verts=list(bm.verts),
  132. edges=list(bm.edges), delimit={'MATERIAL'}, use_dissolve_boundaries=False)
  133. bm.to_mesh(mesh)
  134. bm.free()
  135. mesh.update()
  136. obj = bpy.data.objects.new(name, mesh)
  137. collection.objects.link(obj)
  138. return obj
  139. def pocket_wall(geo, side, width, depth, height, openings, mat, center=(0, 0), base=0):
  140. """A watertight wall with actual recessed window wells (no alpha planes)."""
  141. thickness, recess = 0.32, 0.18
  142. xs = sorted(set([-width / 2, width / 2] + [v for o in openings for v in (o['u'] - o['w'] / 2, o['u'] + o['w'] / 2)]))
  143. zs = sorted(set([base, base + height] + [v for o in openings for v in (o['z'] - o['h'] / 2, o['z'] + o['h'] / 2)]))
  144. assert xs[0] >= -width / 2 and xs[-1] <= width / 2
  145. assert zs[0] >= base and zs[-1] <= base + height
  146. vertices, faces, mats, cache = [], [], [], {}
  147. def vertex(u, d, z):
  148. key = (round(u, 8), round(d, 8), round(z, 8))
  149. if key not in cache:
  150. cache[key] = len(vertices)
  151. vertices.append(face_point(side, u, d, z, center))
  152. return cache[key]
  153. def quad(points, material_name=mat):
  154. faces.append(tuple(vertex(*p) for p in points))
  155. mats.append(material_name)
  156. depths = []
  157. for k in range(len(zs) - 1):
  158. row = []
  159. for j in range(len(xs) - 1):
  160. u, z = (xs[j] + xs[j + 1]) / 2, (zs[k] + zs[k + 1]) / 2
  161. inside = any(abs(u - o['u']) < o['w'] / 2 - 1e-8 and abs(z - o['z']) < o['h'] / 2 - 1e-8 for o in openings)
  162. row.append(depth - recess if inside else depth)
  163. depths.append(row)
  164. inner = depth - thickness
  165. for k, row in enumerate(depths):
  166. z0, z1 = zs[k:k + 2]
  167. for j, d in enumerate(row):
  168. u0, u1 = xs[j:j + 2]
  169. quad([(u0, d, z0), (u0, d, z1), (u1, d, z1), (u1, d, z0)], 'Reveal_Shadow' if d < depth else mat)
  170. quad([(u0, inner, z0), (u1, inner, z0), (u1, inner, z1), (u0, inner, z1)])
  171. if j == 0:
  172. quad([(u0, inner, z0), (u0, inner, z1), (u0, d, z1), (u0, d, z0)])
  173. if j == len(row) - 1:
  174. quad([(u1, inner, z0), (u1, d, z0), (u1, d, z1), (u1, inner, z1)])
  175. if k == 0:
  176. quad([(u0, inner, z0), (u0, d, z0), (u1, d, z0), (u1, inner, z0)])
  177. if k == len(depths) - 1:
  178. quad([(u0, inner, z1), (u1, inner, z1), (u1, d, z1), (u0, d, z1)])
  179. if j and d != row[j - 1]:
  180. prev = row[j - 1]
  181. quad([(u0, d, z0), (u0, prev, z0), (u0, prev, z1), (u0, d, z1)], 'Reveal_Shadow')
  182. if k and d != depths[k - 1][j]:
  183. prev = depths[k - 1][j]
  184. quad([(u0, d, z0), (u1, d, z0), (u1, prev, z0), (u0, prev, z0)], 'Reveal_Shadow')
  185. geo.solid(vertices, faces, mats)
  186. def opening(u, z, w=1.8, h=1.65, door=False):
  187. return {'u': u, 'z': z, 'w': w, 'h': h, 'door': door}
  188. def window(geo, side, depth, op, center=(0, 0)):
  189. u, z, w, h = (op[k] for k in ('u', 'z', 'w', 'h'))
  190. is_door = op.get('door', False)
  191. frame = 'Metal_Charcoal' if is_door else 'Frame_Ivory'
  192. geo.ring(side, u, depth + 0.008, z, w + 0.10, h + 0.10, 0.095, 0.11, frame, center)
  193. cols = 2 if w >= 1.35 else 1
  194. pane_w = (w - 0.12) / cols
  195. for c in range(cols):
  196. pane_u = u + (c - (cols - 1) / 2) * pane_w
  197. glass = RNG.choice(['Glass_Blue', 'Glass_Blue', 'Glass_Soft', 'Glass_Dark'])
  198. geo.fbox(side, pane_u, depth - 0.095, z, pane_w - 0.035, 0.028, h - 0.12, glass, center)
  199. if cols > 1:
  200. geo.fbox(side, u, depth - 0.016, z, 0.052, 0.068, h - 0.09, frame, center)
  201. geo.fbox(side, u, depth - 0.012, z + h * 0.20, w - 0.08, 0.071, 0.048, frame, center, tiny=True)
  202. if is_door:
  203. geo.fbox(side, u, depth - 0.047, z - h * 0.29, w - 0.12, 0.056, h * 0.30, 'Door_Walnut', center)
  204. for sign in (-1, 1):
  205. geo.fbox(side, u + sign * 0.10, depth + 0.055, z - 0.04, 0.027, 0.065, 0.27, 'Metal_Charcoal', center, tiny=True)
  206. else:
  207. geo.fbox(side, u, depth + 0.07, z - h / 2 - 0.08, w + 0.24, 0.29, 0.095, 'Concrete_Warm', center)
  208. geo.fbox(side, u, depth + 0.023, z + h / 2 + 0.085, w + 0.18, 0.14, 0.07, 'Plaster_Ivory', center, tiny=True)
  209. def building(parts, width, depth, height, openings, center=(0, 0), base=0, mat='Plaster_Cream'):
  210. core, glazing = parts['Structure'], parts['Windows']
  211. core.box((*center, base + height / 2), (width - 0.64, depth - 0.64, height), mat)
  212. for side in ('front', 'back', 'right', 'left'):
  213. span, d = (width, depth / 2) if side in ('front', 'back') else (depth - 0.64, width / 2)
  214. ops = openings.get(side, [])
  215. pocket_wall(core, side, span, d, height, ops, mat, center, base)
  216. for op in ops:
  217. window(glazing, side, d, op, center)
  218. def belt(geo, width, depth, z, height=0.12, mat='Plaster_Ivory', center=(0, 0), front_gaps=()):
  219. for side in ('front', 'back', 'left', 'right'):
  220. span, d = (width, depth / 2) if side in ('front', 'back') else (depth, width / 2)
  221. cursor = -(span + 0.10) / 2
  222. gaps = sorted(front_gaps) if side == 'front' else []
  223. for left, right in gaps + [((span + 0.10) / 2, (span + 0.10) / 2)]:
  224. if left > cursor:
  225. geo.fbox(side, (cursor + left) / 2, d + 0.025, z, left - cursor, 0.13, height, mat, center)
  226. cursor = right
  227. def rail(geo, side, u, depth, floor, width, height=0.95, center=(0, 0), spacing=0.22):
  228. for z, thick in ((floor + 0.13, 0.048), (floor + height, 0.065)):
  229. geo.fbox(side, u, depth, z, width, 0.058, thick, 'Metal_Charcoal', center)
  230. count = max(2, math.ceil(width / spacing))
  231. for i in range(count + 1):
  232. geo.fbox(side, u - width / 2 + width * i / count, depth, floor + height / 2,
  233. 0.027, 0.034, height - 0.055, 'Metal_Charcoal', center, tiny=(i % 2 == 1))
  234. def balcony(geo, side, u, depth, floor, width, reach=1.0, center=(0, 0), parapet=False, spacing=0.22):
  235. geo.fbox(side, u, depth + reach / 2 - 0.025, floor - 0.08, width, reach + 0.12, 0.16, 'Concrete_Warm', center)
  236. outer = depth + reach - 0.06
  237. if parapet:
  238. geo.fbox(side, u, outer, floor + 0.17, width, 0.10, 0.34, 'Plaster_Ivory', center)
  239. rail(geo, side, u, outer, floor, width - 0.10, center=center, spacing=spacing)
  240. # Side rails are also true, closed rectangular bars.
  241. for sign in (-1, 1):
  242. for z in (floor + 0.13, floor + 0.95):
  243. geo.fbox(side, u + sign * (width / 2 - 0.045), depth + reach / 2, z, 0.055, reach - 0.10, 0.055, 'Metal_Charcoal', center)
  244. n = max(2, math.ceil(reach / spacing))
  245. for i in range(n):
  246. geo.fbox(side, u + sign * (width / 2 - 0.045), depth + (i + 0.5) * reach / n,
  247. floor + 0.48, 0.034, 0.027, 0.88, 'Metal_Charcoal', center, tiny=(i % 2 == 1))
  248. def gable(geo, width, depth, eave, rise, mat, center=(0, 0), roof=False):
  249. """Extruded closed gable; roof uses a six-sided closed thick profile."""
  250. x, y = center
  251. if roof:
  252. t = 0.18
  253. profile = [(-depth / 2, eave - t), (0, eave + rise - t), (depth / 2, eave - t),
  254. (depth / 2, eave), (0, eave + rise), (-depth / 2, eave)]
  255. else:
  256. profile = [(-depth / 2, eave), (depth / 2, eave), (0, eave + rise)]
  257. vertices = [(x + a, y + b, z) for a in (-width / 2, width / 2) for b, z in profile]
  258. n = len(profile)
  259. faces = [tuple(reversed(range(n))), tuple(range(n, n * 2))]
  260. faces += [(i, (i + 1) % n, (i + 1) % n + n, i + n) for i in range(n)]
  261. geo.solid(vertices, faces, mat)
  262. def pitched_roof(parts, width, depth, eave, rise, slate=False, center=(0, 0)):
  263. geo = parts['Roof']
  264. mat = 'Roof_Slate' if slate else 'Roof_Terracotta'
  265. light = 'Roof_Slate_Light' if slate else 'Roof_Terra_Light'
  266. rw, rd = width + 0.74, depth + 0.72
  267. # Match the gable to the underside of the overhanging roof, then overlap by
  268. # 25 mm. A flat soffit closes the small eave gap above the vertical walls.
  269. gable_base = eave - 0.13 + rise * (1 - depth / rd) + 0.025
  270. gable(parts['Structure'], width, depth, gable_base, rise * depth / rd, 'Plaster_Ivory', center)
  271. parts['Structure'].box((*center, (eave - 0.03 + gable_base) / 2),
  272. (width, depth, gable_base - eave + 0.07), 'Plaster_Ivory')
  273. gable(geo, rw, rd, eave + 0.05, rise, mat, center, roof=True)
  274. cx, cy = center
  275. # Low-profile ridge cap and tile courses; the roof shell itself has no holes.
  276. geo.box((cx, cy, eave + rise + 0.073), (rw + 0.08, 0.21, 0.13), light)
  277. slope = rise / (rd / 2)
  278. course_count = math.ceil((rd / 2) / 0.34)
  279. for sign in (-1, 1):
  280. for i in range(1, course_count):
  281. d = i * rd / 2 / course_count
  282. z = eave + 0.05 + rise - d * slope + 0.008
  283. geo.box((cx, cy + sign * d, z), (rw, 0.045, 0.025), light, tiny=True)
  284. # Raised tile ribs give directional texture without huge geometry counts.
  285. for k in range(math.ceil(rw / 0.48)):
  286. x = cx - rw / 2 + (k + 0.5) * rw / math.ceil(rw / 0.48)
  287. y0, y1 = 0.09 * sign, (rd / 2 - 0.06) * sign
  288. z0 = eave + 0.05 + rise - abs(y0) * slope + 0.024
  289. z1 = eave + 0.05 + rise - abs(y1) * slope + 0.024
  290. beam(geo, (x, cy + y0, z0), (x, cy + y1, z1), 0.018, 0.027, light, tiny=True)
  291. geo.box((cx, cy + sign * (rd / 2 - 0.035), eave - 0.01), (rw, 0.095, 0.11), 'Metal_Charcoal')
  292. def beam(geo, start, end, width, height, mat, tiny=False):
  293. a, b = Vector(start), Vector(end)
  294. direction = (b - a).normalized()
  295. axis = Vector((1, 0, 0)) if abs(direction.x) < 0.8 else Vector((0, 1, 0))
  296. cross = direction.cross(axis).normalized()
  297. side = cross.cross(direction).normalized()
  298. center = (a + b) / 2
  299. vertices = [tuple(center + side * v[0] + cross * v[1] + direction * v[2])
  300. for v in cube_vertices((0, 0, 0), (width, height, (b - a).length))]
  301. geo.solid(vertices, CUBE_FACES, mat, tiny)
  302. def canopy(geo, u, depth, z, width=2.4, reach=1.0, center=(0, 0)):
  303. geo.fbox('front', u, depth + reach / 2, z, width, reach + 0.12, 0.11, 'Metal_Charcoal', center)
  304. for sign in (-1, 1):
  305. beam(geo, (center[0] + u + sign * width * 0.36, center[1] + depth + 0.02, z - 0.55),
  306. (center[0] + u + sign * width * 0.36, center[1] + depth + reach * 0.8, z - 0.07),
  307. 0.055, 0.055, 'Metal_Charcoal')
  308. def ac_unit(geo, side, u, depth, z, center=(0, 0)):
  309. geo.fbox(side, u, depth + 0.19, z, 0.72, 0.36, 0.48, 'Plaster_Ivory', center, tiny=True)
  310. for k in range(4):
  311. geo.fbox(side, u - 0.11, depth + 0.38, z - 0.15 + k * 0.10, 0.41, 0.022, 0.022, 'Metal_Charcoal', center, tiny=True)
  312. def new_parts():
  313. return {name: Geometry() for name in ('Structure', 'Windows', 'Balconies', 'Roof', 'Details')}
  314. def make_house(floors=3):
  315. p = new_parts()
  316. height = floors * 3.05
  317. ops = {side: [] for side in ('front', 'back', 'right', 'left')}
  318. for floor in range(floors):
  319. base = floor * 3.05
  320. ops['front'] += [opening(-3.85, base + 1.66, 2.05),
  321. opening(-0.45, base + (1.20 if floor == 0 else 1.69), 1.50, 2.30 if floor == 0 else 1.65, floor == 0),
  322. opening(3.45, base + (1.67 if floor == 0 else 1.27), 2.15, 1.65 if floor == 0 else 2.24)]
  323. ops['back'] += [opening(u, base + 1.67, 1.65) for u in (-3.85, 0, 3.85)]
  324. for side in ('left', 'right'):
  325. ops[side] += [opening(u, base + 1.67, 1.75) for u in (-2.40, 2.40)]
  326. building(p, 12, 9, height, ops)
  327. for f in range(1, floors + 1):
  328. belt(p['Details'], 12, 9, min(f * 3.05, height - 0.02))
  329. belt(p['Details'], 12, 9, 0.20, 0.40, 'Stone_Base', front_gaps=[(-1.27, 0.37)])
  330. for x in (-5.93, 5.93):
  331. p['Details'].box((x, 4.535, height / 2 + 0.18), (0.20, 0.13, height - 0.42), 'Plaster_Ivory')
  332. for f in range(1, floors):
  333. balcony(p['Balconies'], 'front', 3.45, 4.50, f * 3.05 + 0.015, 3.05, 1.12)
  334. canopy(p['Details'], -0.45, 4.5, 2.65, 2.45, 1.15)
  335. pitched_roof(p, 12, 9, height, 2.05)
  336. for x in (-5.67, 5.67):
  337. p['Details'].box((x, -4.58, height / 2), (0.085, 0.085, height - 0.15), 'Metal_Charcoal', tiny=True)
  338. for f in range(1, floors):
  339. ac_unit(p['Details'], 'back', 1.40, 4.5, f * 3.05 + 0.65)
  340. # Two louvred attic vents on the gable ends.
  341. for side in ('left', 'right'):
  342. p['Details'].fbox(side, 0, 6.015, height + 0.65, 0.65, 0.07, 0.48, 'Metal_Charcoal')
  343. for k in range(4):
  344. p['Details'].fbox(side, 0, 6.06, height + 0.50 + k * 0.10, 0.66, 0.03, 0.034, 'Frame_Ivory', tiny=True)
  345. return p
  346. def make_slab(floors=6):
  347. p = new_parts()
  348. height = floors * 3.05
  349. ops = {side: [] for side in ('front', 'back', 'right', 'left')}
  350. for floor in range(floors):
  351. base = floor * 3.05
  352. for u in (-12, -8, -2, 2, 8, 12):
  353. tall = floor > 0 and u in (-12, -2, 8)
  354. ops['front'].append(opening(u, base + (1.29 if tall else 1.70), 1.95, 2.25 if tall else 1.64))
  355. ops['back'].append(opening(-u, base + 1.68, 1.75, 1.62))
  356. for u in (-5, 5):
  357. ops['front'].append(opening(u, base + (1.23 if floor == 0 else 1.65), 1.18, 2.34 if floor == 0 else 1.97, floor == 0))
  358. ops['back'].append(opening(u, base + 1.70, 0.95, 1.70))
  359. for side in ('left', 'right'):
  360. ops[side] += [opening(u, base + 1.69, 1.75) for u in (-3.3, 0, 3.3)]
  361. building(p, 30, 12, height, ops, mat='Plaster_Ivory')
  362. belt(p['Details'], 30, 12, 0.28, 0.56, 'Stone_Base', front_gaps=[(-5.67, -4.33), (4.33, 5.67)])
  363. for f in range(1, floors + 1):
  364. belt(p['Details'], 30, 12, min(f * 3.05, height - 0.04), 0.14, 'Concrete_Warm')
  365. # Vertical stair strips stop and restart around actual staircase windows.
  366. for u in (-5, 5):
  367. for x in (u - 0.92, u + 0.92):
  368. p['Details'].fbox('front', x, 6.022, height / 2, 0.35, 0.12, height, 'Plaster_Sand')
  369. canopy(p['Details'], u, 6, 2.65, 2.2, 1.10)
  370. for f in range(1, floors):
  371. for u in (-10, 0, 10):
  372. balcony(p['Balconies'], 'front', u, 6, f * 3.05 + 0.01, 6.02, 1.02, parapet=True)
  373. pitched_roof(p, 30, 12, height, 1.8, slate=True)
  374. for u in (-10, 0, 10):
  375. p['Roof'].box((u, 0.9, height + 1.74), (0.68, 0.60, 0.85), 'Plaster_Sand')
  376. p['Roof'].box((u, 0.9, height + 2.20), (0.82, 0.74, 0.12), 'Roof_Slate')
  377. for f in range(floors):
  378. for u in (-13.5, -3.6, 6.4):
  379. ac_unit(p['Details'], 'back', u, 6, f * 3.05 + 0.65)
  380. for u in (-14.75, 14.75):
  381. p['Details'].fbox('back', u, 6.08, height / 2, 0.095, 0.095, height - 0.06, 'Metal_Charcoal', tiny=True)
  382. return p
  383. def make_midrise(floors=10):
  384. p = new_parts()
  385. lower_floors = floors - 2
  386. setback_z, roof_z = lower_floors * 3.05, floors * 3.05
  387. ops = {side: [] for side in ('front', 'back', 'right', 'left')}
  388. for floor in range(lower_floors):
  389. base = floor * 3.05
  390. for u in (-7, -3.6, 0, 3.6, 7):
  391. stair = u == 0
  392. tall = floor > 0 and abs(u) == 7
  393. ops['front'].append(opening(u, base + (1.25 if tall or (stair and floor == 0) else 1.7),
  394. 1.5 if stair else 2.10, 2.30 if tall or (stair and floor == 0) else 1.75, stair and floor == 0))
  395. ops['back'].append(opening(u, base + 1.7, 1.35 if stair else 1.95, 1.70))
  396. for side in ('left', 'right'):
  397. ops[side] += [opening(u, base + 1.7, 2.02, 1.75) for u in (-5.1, 0, 5.1)]
  398. building(p, 20, 16, setback_z, ops)
  399. upper = {side: [] for side in ('front', 'back', 'right', 'left')}
  400. for floor in range(2):
  401. base = setback_z + floor * 3.05
  402. for side in ('front', 'back'):
  403. upper[side] += [opening(u, base + 1.67, 2.05, 1.75) for u in (-5.8, -1.9, 1.9, 5.8)]
  404. for side in ('left', 'right'):
  405. upper[side] += [opening(u, base + 1.67, 2.00, 1.75) for u in (-4.5, 0, 4.5)]
  406. center = (-0.8, -0.8)
  407. building(p, 16.6, 14.4, 6.1, upper, center, setback_z, 'Plaster_Ivory')
  408. belt(p['Details'], 20, 16, 0.30, 0.60, 'Stone_Base', front_gaps=[(-0.83, 0.83)])
  409. for f in range(1, lower_floors + 1):
  410. belt(p['Details'], 20, 16, min(f * 3.05, setback_z - 0.04), 0.13, 'Concrete_Warm')
  411. for f in range(1, lower_floors):
  412. for u in (-5.3, 5.3):
  413. balcony(p['Balconies'], 'front', u, 8, f * 3.05 + 0.01, 5.45, 1.10, parapet=True)
  414. for u in (-9.9, 9.9):
  415. p['Details'].fbox('front', u, 8.03, setback_z / 2, 0.23, 0.16, setback_z, 'Plaster_Sand')
  416. for u in (-1.16, 1.16):
  417. p['Details'].fbox('front', u, 8.025, setback_z / 2, 0.21, 0.13, setback_z, 'Plaster_Sand')
  418. canopy(p['Details'], 0, 8, 2.70, 3.15, 1.22)
  419. # Usable setback terrace and modest rooftop plant room, all closed volumes.
  420. p['Roof'].box((0, 0, setback_z + 0.02), (20.15, 16.15, 0.16), 'Roof_Membrane')
  421. rail(p['Balconies'], 'front', 0, 8.015, setback_z + 0.10, 19.90)
  422. rail(p['Balconies'], 'right', 0, 10.015, setback_z + 0.10, 15.85)
  423. belt(p['Details'], 16.6, 14.4, setback_z + 3.05, 0.12, center=center)
  424. p['Roof'].box((*center, roof_z + 0.01), (16.8, 14.6, 0.20), 'Roof_Membrane')
  425. for side in ('front', 'back', 'left', 'right'):
  426. span, d = (16.85, 7.28) if side in ('front', 'back') else (14.45, 8.28)
  427. p['Roof'].fbox(side, 0, d, roof_z + 0.35, span, 0.17, 0.68, 'Plaster_Ivory', center)
  428. p['Roof'].fbox(side, 0, d, roof_z + 0.70, span + 0.08, 0.24, 0.07, 'Roof_Slate', center)
  429. p['Roof'].box((-2.1, -2.1, roof_z + 0.80), (5.0, 4.4, 1.55), 'Plaster_Sand')
  430. p['Roof'].box((-2.1, -2.1, roof_z + 1.62), (5.25, 4.65, 0.16), 'Roof_Slate')
  431. for f in range(lower_floors):
  432. for u in (-8.6, 1.4, 8.6):
  433. ac_unit(p['Details'], 'back', u, 8, f * 3.05 + 0.66)
  434. return p
  435. def make_wide_midrise(floors=10):
  436. """28 x 24 m module: add facade bays, never stretch the 20 x 16 m windows."""
  437. p = new_parts()
  438. lower_floors = floors - 2
  439. setback_z, roof_z = lower_floors * 3.05, floors * 3.05
  440. front_bays = (-10.8, -7.2, -3.6, 0, 3.6, 7.2, 10.8)
  441. side_bays = (-8.4, -4.2, 0, 4.2, 8.4)
  442. ops = {side: [] for side in ('front', 'back', 'right', 'left')}
  443. for floor in range(lower_floors):
  444. base = floor * 3.05
  445. for u in front_bays:
  446. door = floor == 0 and u == 0
  447. balcony_door = floor > 0 and abs(u) == 7.2
  448. ops['front'].append(opening(u, base + (1.25 if door or balcony_door else 1.70),
  449. 1.8 if door else 2.05, 2.30 if door or balcony_door else 1.72, door))
  450. ops['back'].append(opening(u, base + 1.70, 1.95, 1.72))
  451. for side in ('left', 'right'):
  452. ops[side] += [opening(u, base + 1.70, 2.05, 1.72) for u in side_bays]
  453. building(p, 28, 24, setback_z, ops)
  454. upper = {side: [] for side in ('front', 'back', 'right', 'left')}
  455. center = (-0.8, -0.8)
  456. for floor in range(2):
  457. base = setback_z + floor * 3.05
  458. for side in ('front', 'back'):
  459. upper[side] += [opening(u, base + 1.70, 2.05, 1.72) for u in (-9, -5.4, -1.8, 1.8, 5.4, 9)]
  460. for side in ('left', 'right'):
  461. upper[side] += [opening(u, base + 1.70, 2.05, 1.72) for u in side_bays]
  462. building(p, 24.6, 22.4, 6.1, upper, center, setback_z, 'Plaster_Ivory')
  463. # Keep broad-building budget focused on actual windows and modest balconies.
  464. p['Windows'].pieces = [piece for piece in p['Windows'].pieces if not piece[3]]
  465. belt(p['Details'], 28, 24, 0.30, 0.60, 'Stone_Base', front_gaps=[(-0.99, 0.99)])
  466. for floor in range(1, lower_floors + 1):
  467. belt(p['Details'], 28, 24, min(floor * 3.05, setback_z - 0.04), 0.12, 'Concrete_Warm')
  468. for floor in range(1, lower_floors):
  469. for u in (-7.2, 7.2):
  470. balcony(p['Balconies'], 'front', u, 12, floor * 3.05 + 0.01, 3.25, 1.02, parapet=True, spacing=0.38)
  471. for u in (-13.9, -1.25, 1.25, 13.9):
  472. p['Details'].fbox('front', u, 12.03, setback_z / 2, 0.20, 0.14, setback_z, 'Plaster_Sand')
  473. canopy(p['Details'], 0, 12, 2.70, 3.15, 1.20)
  474. p['Roof'].box((0, 0, setback_z + 0.02), (28.15, 24.15, 0.16), 'Roof_Membrane')
  475. rail(p['Balconies'], 'front', 0, 12.015, setback_z + 0.1, 27.90, spacing=0.38)
  476. rail(p['Balconies'], 'right', 0, 14.015, setback_z + 0.1, 23.85, spacing=0.38)
  477. belt(p['Details'], 24.6, 22.4, setback_z + 3.05, 0.12, center=center)
  478. p['Roof'].box((*center, roof_z + 0.01), (24.8, 22.6, 0.20), 'Roof_Membrane')
  479. for side in ('front', 'back', 'left', 'right'):
  480. span, d = (24.85, 11.28) if side in ('front', 'back') else (22.45, 12.28)
  481. p['Roof'].fbox(side, 0, d, roof_z + 0.35, span, 0.17, 0.68, 'Plaster_Ivory', center)
  482. p['Roof'].fbox(side, 0, d, roof_z + 0.70, span + 0.08, 0.24, 0.07, 'Roof_Slate', center)
  483. p['Roof'].box((-3, -2.1, roof_z + 0.80), (5, 4.4, 1.55), 'Plaster_Sand')
  484. p['Roof'].box((-3, -2.1, roof_z + 1.62), (5.25, 4.65, 0.16), 'Roof_Slate')
  485. return p
  486. def collection(name, parent):
  487. col = bpy.data.collections.new(name)
  488. parent.children.link(col)
  489. return col
  490. def join_meshes(objects, name, col):
  491. vertices, faces, indices = [], [], []
  492. for obj in objects:
  493. offset = len(vertices)
  494. vertices += [tuple(v.co) for v in obj.data.vertices]
  495. faces += [tuple(offset + i for i in p.vertices) for p in obj.data.polygons]
  496. indices += [p.material_index for p in obj.data.polygons]
  497. mesh = bpy.data.meshes.new(name + '_Mesh')
  498. mesh.from_pydata(vertices, [], faces)
  499. mesh.update()
  500. for mat in MATERIALS:
  501. mesh.materials.append(mat)
  502. for face, idx in zip(mesh.polygons, indices):
  503. face.material_index = idx
  504. obj = bpy.data.objects.new(name, mesh)
  505. col.objects.link(obj)
  506. return obj
  507. def validate_mesh(obj):
  508. mesh = obj.data
  509. mesh.calc_loop_triangles()
  510. degenerate = sum(t.area <= 1e-10 for t in mesh.loop_triangles)
  511. bm = bmesh.new()
  512. bm.from_mesh(mesh)
  513. bm.faces.ensure_lookup_table()
  514. bad_edges = sum(not edge.is_manifold for edge in bm.edges)
  515. bad_vertices = sum(not vertex.is_manifold for vertex in bm.verts)
  516. inconsistent = sum(edge.is_manifold and not edge.is_contiguous for edge in bm.edges)
  517. zero_faces = sum(face.calc_area() <= 1e-10 for face in bm.faces)
  518. unseen = set(bm.faces)
  519. volumes = []
  520. while unseen:
  521. seed = unseen.pop()
  522. stack, shell = [seed], [seed]
  523. while stack:
  524. face = stack.pop()
  525. for edge in face.edges:
  526. for neighbor in edge.link_faces:
  527. if neighbor in unseen:
  528. unseen.remove(neighbor)
  529. stack.append(neighbor)
  530. shell.append(neighbor)
  531. volume = 0.0
  532. for face in shell:
  533. verts = [v.co for v in face.verts]
  534. a = verts[0]
  535. for i in range(1, len(verts) - 1):
  536. volume += a.dot(verts[i].cross(verts[i + 1])) / 6.0
  537. volumes.append(volume)
  538. bm.free()
  539. negative = sum(v < -1e-9 for v in volumes)
  540. zero_volume = sum(abs(v) <= 1e-9 for v in volumes)
  541. bounds = [[round(min(v.co[i] for v in mesh.vertices), 5) for i in range(3)],
  542. [round(max(v.co[i] for v in mesh.vertices), 5) for i in range(3)]]
  543. report = {'vertices': len(mesh.vertices), 'triangles': len(mesh.loop_triangles),
  544. 'closed_shells': len(volumes), 'nonmanifold_edges': bad_edges, 'nonmanifold_vertices': bad_vertices,
  545. 'inconsistent_winding_edges': inconsistent, 'negative_volume_shells': negative,
  546. 'zero_volume_shells': zero_volume, 'zero_area_faces': zero_faces,
  547. 'degenerate_triangles': degenerate, 'bounds_m': bounds,
  548. 'dimensions_m': [round(bounds[1][i] - bounds[0][i], 5) for i in range(3)],
  549. 'materials': sorted({mesh.materials[p.material_index].name for p in mesh.polygons})}
  550. errors = {k: report[k] for k in ('nonmanifold_edges', 'nonmanifold_vertices', 'inconsistent_winding_edges',
  551. 'negative_volume_shells', 'zero_volume_shells', 'zero_area_faces', 'degenerate_triangles') if report[k]}
  552. if errors:
  553. raise RuntimeError(f'{obj.name}: invalid geometry: {errors}')
  554. return report
  555. def glb_summary(path):
  556. data = path.read_bytes()
  557. magic, version, total = struct.unpack_from('<4sII', data)
  558. assert magic == b'glTF' and version == 2 and total == len(data)
  559. size, chunk = struct.unpack_from('<II', data, 12)
  560. assert chunk == 0x4E4F534A
  561. doc = json.loads(data[20:20 + size].decode('utf-8'))
  562. triangles = sum(doc['accessors'][p['indices']]['count'] // 3
  563. for mesh in doc['meshes'] for p in mesh['primitives'])
  564. assert all(not m.get('doubleSided', False) for m in doc.get('materials', []))
  565. assert len(doc['meshes']) == 1
  566. assert all(p.get('mode', 4) == 4 for mesh in doc['meshes'] for p in mesh['primitives'])
  567. assert not doc.get('images') and all('uri' not in b for b in doc['buffers'])
  568. bin_size, bin_kind = struct.unpack_from('<II', data, 20 + size)
  569. assert bin_kind == 0x004E4942
  570. binary = data[28 + size:28 + size + bin_size]
  571. def accessor(index):
  572. acc = doc['accessors'][index]
  573. view = doc['bufferViews'][acc['bufferView']]
  574. components = {'SCALAR': 1, 'VEC3': 3}[acc['type']]
  575. fmt = '<' + {5123: 'H', 5125: 'I', 5126: 'f'}[acc['componentType']] * components
  576. stride = view.get('byteStride', struct.calcsize(fmt))
  577. start = view.get('byteOffset', 0) + acc.get('byteOffset', 0)
  578. return [struct.unpack_from(fmt, binary, start + i * stride) for i in range(acc['count'])]
  579. degenerate, wrong_normals = 0, 0
  580. for primitive in doc['meshes'][0]['primitives']:
  581. positions = [Vector(v) for v in accessor(primitive['attributes']['POSITION'])]
  582. normals = [Vector(v) for v in accessor(primitive['attributes']['NORMAL'])]
  583. indices = [v[0] for v in accessor(primitive['indices'])]
  584. assert len(indices) % 3 == 0 and all(0 <= i < len(positions) for i in indices)
  585. assert all(math.isfinite(v) for point in positions for v in point)
  586. assert all(abs(n.length - 1) < 1e-4 for n in normals)
  587. for k in range(0, len(indices), 3):
  588. a, b, c = indices[k:k + 3]
  589. cross = (positions[b] - positions[a]).cross(positions[c] - positions[a])
  590. if cross.length <= 2e-10:
  591. degenerate += 1
  592. elif any(cross.dot(normals[i]) <= 0 for i in (a, b, c)):
  593. wrong_normals += 1
  594. assert degenerate == 0 and wrong_normals == 0, (path.name, degenerate, wrong_normals)
  595. return {'file': path.name, 'bytes': len(data), 'mesh_count': len(doc['meshes']),
  596. 'triangles': triangles, 'nodes': [n.get('name') for n in doc['nodes']],
  597. 'materials': [m.get('name') for m in doc.get('materials', [])], 'all_double_sided_false': True,
  598. 'decoded_buffer_checks': {'degenerate_triangles': degenerate, 'triangles_opposing_vertex_normals': wrong_normals,
  599. 'indices_in_range': True, 'finite_positions': True, 'unit_normals': True}}
  600. def export_glb(obj, path):
  601. bpy.ops.object.select_all(action='DESELECT')
  602. obj.hide_set(False)
  603. obj.select_set(True)
  604. bpy.context.view_layer.objects.active = obj
  605. bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True,
  606. export_yup=True, export_apply=True, export_extras=True,
  607. export_cameras=False, export_lights=False, export_animations=False,
  608. export_materials='EXPORT', export_texcoords=False, export_normals=True)
  609. obj.select_set(False)
  610. return glb_summary(path)
  611. def light(scene, name, kind, location, energy, size):
  612. data = bpy.data.lights.new(name, kind)
  613. data.energy = energy
  614. if kind == 'AREA':
  615. data.shape = 'DISK'
  616. data.size = size
  617. else:
  618. data.angle = size
  619. obj = bpy.data.objects.new(name, data)
  620. scene.collection.objects.link(obj)
  621. obj.location = location
  622. obj.rotation_euler = (Vector((0, 0, 8)) - obj.location).to_track_quat('-Z', 'Y').to_euler()
  623. return obj
  624. def camera_text(scene, camera, text, x, y, size, name, font):
  625. curve = bpy.data.curves.new(name, 'FONT')
  626. curve.body, curve.size = text, size
  627. curve.space_character = 1.10
  628. if font:
  629. curve.font = font
  630. if not font.packed_file:
  631. font.pack()
  632. obj = bpy.data.objects.new(name, curve)
  633. scene.collection.objects.link(obj)
  634. obj.parent = camera
  635. obj.location = (x, y, -2.0)
  636. mat = bpy.data.materials.get('Preview_Ink')
  637. if not mat:
  638. mat = bpy.data.materials.new('Preview_Ink')
  639. mat.use_nodes = True
  640. nodes = mat.node_tree.nodes
  641. nodes.clear()
  642. emission = nodes.new('ShaderNodeEmission')
  643. emission.inputs['Color'].default_value = (0.035, 0.055, 0.06, 1)
  644. output = nodes.new('ShaderNodeOutputMaterial')
  645. mat.node_tree.links.new(emission.outputs[0], output.inputs['Surface'])
  646. curve.materials.append(mat)
  647. return obj
  648. def preview_scene(obj, entry, index, samples):
  649. scene = bpy.data.scenes.new('Preview_' + entry['id'])
  650. scene.unit_settings.system = 'METRIC'
  651. scene.unit_settings.scale_length = 1.0
  652. instance = bpy.data.objects.new('Preview_' + obj.name, obj.data)
  653. scene.collection.objects.link(instance)
  654. scene.render.engine = 'CYCLES'
  655. scene.cycles.device = 'CPU'
  656. scene.cycles.samples = samples
  657. scene.cycles.use_denoising = True
  658. scene.cycles.seed = SEED
  659. scene.render.resolution_x, scene.render.resolution_y = 840, 1080
  660. scene.render.resolution_percentage = 100
  661. scene.render.image_settings.file_format = 'PNG'
  662. scene.render.image_settings.color_mode = 'RGBA'
  663. scene.render.image_settings.color_depth = '8'
  664. scene.view_settings.view_transform = 'AgX'
  665. scene.world = bpy.data.worlds.new('Preview_World_' + entry['id'])
  666. scene.world.use_nodes = True
  667. scene.world.node_tree.nodes['Background'].inputs[0].default_value = (0.75, 0.82, 0.87, 1)
  668. scene.world.node_tree.nodes['Background'].inputs[1].default_value = 0.65
  669. ground = bpy.data.materials.get('Preview_Ground')
  670. if not ground:
  671. ground = bpy.data.materials.new('Preview_Ground')
  672. ground.use_nodes = True
  673. ground.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = (0.77, 0.79, 0.76, 1)
  674. ground.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = 0.95
  675. mesh = bpy.data.meshes.new('Preview_Ground_Mesh_' + entry['id'])
  676. mesh.from_pydata([(-200, -200, -0.012), (200, -200, -0.012), (200, 200, -0.012), (-200, 200, -0.012)], [], [(0, 1, 2, 3)])
  677. floor = bpy.data.objects.new('Preview_Ground_' + entry['id'], mesh)
  678. floor.data.materials.append(ground)
  679. scene.collection.objects.link(floor)
  680. light(scene, 'Preview_Sun_' + entry['id'], 'SUN', (-30, 45, 60), 2.6, 0.12)
  681. light(scene, 'Preview_Fill_' + entry['id'], 'AREA', (25, 15, 42), 1400, 30)
  682. camera_data = bpy.data.cameras.new('Preview_Camera_' + entry['id'])
  683. camera = bpy.data.objects.new(camera_data.name, camera_data)
  684. scene.collection.objects.link(camera)
  685. scene.camera = camera
  686. low, high = entry['validation']['bounds_m']
  687. target = Vector(((low[0] + high[0]) / 2, (low[1] + high[1]) / 2, high[2] * 0.50))
  688. camera.location = target + Vector((1.0, 1.7, 1.0)).normalized() * 120
  689. camera.rotation_euler = (target - camera.location).to_track_quat('-Z', 'Y').to_euler()
  690. camera_data.type = 'ORTHO'
  691. rotation = camera.rotation_euler.to_matrix().transposed()
  692. corners = [rotation @ (Vector((x, y, z)) - target) for x in (low[0], high[0]) for y in (low[1], high[1]) for z in (0, high[2])]
  693. aspect = scene.render.resolution_x / scene.render.resolution_y
  694. span_x = max(c.x for c in corners) - min(c.x for c in corners)
  695. span_y = max(c.y for c in corners) - min(c.y for c in corners)
  696. camera_data.ortho_scale = max(span_x / aspect * 1.18, span_y * 1.42)
  697. scale = camera_data.ortho_scale * aspect
  698. font_path = Path('C:/Windows/Fonts/segoeui.ttf')
  699. font = bpy.data.fonts.load(str(font_path), check_existing=True) if font_path.exists() else None
  700. left, top = -scale * 0.45, scale / aspect * 0.445
  701. camera_text(scene, camera, f'{index:02d} {entry["display_name"].upper()}', left, top, scale / 35, 'Preview_Title_' + entry['id'], font)
  702. footprint = entry['nominal_footprint_m']
  703. subtitle = f'{footprint[0]} x {footprint[1]} m / {entry["floors"]} floors / {high[2]:.1f} m'
  704. camera_text(scene, camera, subtitle, left, top - scale * 0.052, scale / 53, 'Preview_Subtitle_' + entry['id'], font)
  705. camera_text(scene, camera, f'{entry["validation"]["triangles"]:,} triangles | LOD 0', left, -scale / aspect * 0.434, scale / 55, 'Preview_Footer_' + entry['id'], font)
  706. camera_text(scene, camera, 'HOUSE LIBRARY / PROTOTYPE 01', left, -scale / aspect * 0.466, scale / 77, 'Preview_Caption_' + entry['id'], font)
  707. return scene
  708. def png_rows(path):
  709. """Read our 8-bit RGBA renders losslessly, without external image packages."""
  710. data = path.read_bytes()
  711. assert data[:8] == b'\x89PNG\r\n\x1a\n'
  712. pos, compressed = 8, bytearray()
  713. width = height = channels = None
  714. while pos < len(data):
  715. length = struct.unpack_from('>I', data, pos)[0]
  716. kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + length]
  717. if kind == b'IHDR':
  718. width, height, bits, mode, _, _, interlace = struct.unpack('>IIBBBBB', body)
  719. assert bits == 8 and mode == 6 and interlace == 0
  720. channels = 4
  721. elif kind == b'IDAT':
  722. compressed.extend(body)
  723. pos += 12 + length
  724. raw, stride = zlib.decompress(compressed), width * channels
  725. rows, previous = [], bytearray(stride)
  726. for y in range(height):
  727. filter_type = raw[y * (stride + 1)]
  728. row = bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
  729. for i in range(stride):
  730. a = row[i - channels] if i >= channels else 0
  731. b, c = previous[i], previous[i - channels] if i >= channels else 0
  732. if filter_type == 1:
  733. prediction = a
  734. elif filter_type == 2:
  735. prediction = b
  736. elif filter_type == 3:
  737. prediction = (a + b) // 2
  738. elif filter_type == 4:
  739. p = a + b - c
  740. distances = (abs(p - a), abs(p - b), abs(p - c))
  741. prediction = (a, b, c)[distances.index(min(distances))]
  742. elif filter_type == 0:
  743. prediction = 0
  744. else:
  745. raise ValueError(f'Unknown PNG filter {filter_type}')
  746. row[i] = (row[i] + prediction) & 255
  747. rows.append(bytes(row))
  748. previous = row
  749. return width, height, rows
  750. def contact_sheet(paths, output):
  751. panels = [png_rows(path) for path in paths]
  752. width, height = sum(p[0] for p in panels), panels[0][1]
  753. assert all(p[1] == height for p in panels)
  754. payload = b''.join(b'\x00' + b''.join(p[2][y] for p in panels) for y in range(height))
  755. def chunk(kind, body):
  756. return struct.pack('>I', len(body)) + kind + body + struct.pack('>I', zlib.crc32(kind + body) & 0xffffffff)
  757. output.write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0))
  758. + chunk(b'sRGB', b'\x00') + chunk(b'IDAT', zlib.compress(payload, 6)) + chunk(b'IEND', b''))
  759. def main():
  760. args = arguments()
  761. assert args.samples > 0
  762. out = args.output.resolve()
  763. out.mkdir(parents=True, exist_ok=True)
  764. bpy.ops.wm.read_factory_settings(use_empty=True)
  765. source_scene = bpy.context.scene
  766. source_scene.name = 'Library_Sources'
  767. source_scene.unit_settings.system = 'METRIC'
  768. source_scene.unit_settings.scale_length = 1.0
  769. bpy.context.preferences.filepaths.save_version = 0
  770. make_materials()
  771. source_root = collection('HL_Source_Collections', source_scene.collection)
  772. joined_col = collection('HL_Joined_LOD0', source_scene.collection)
  773. lod_col = collection('HL_Joined_LOD1', source_scene.collection)
  774. foundation_col = collection('HL_Optional_Foundations_Below_Zero', source_scene.collection)
  775. specs = [('house', 'Gabled house', 'House', [12, 9], 3, make_house),
  776. ('slab', 'Apartment slab', 'Slab', [30, 12], 6, make_slab),
  777. ('midrise', 'Stepped midrise', 'Midrise', [20, 16], 10, make_midrise),
  778. ('house-2f', 'Gabled house 2F', 'House', [12, 9], 2, make_house),
  779. ('house-4f', 'Gabled house 4F', 'House', [12, 9], 4, make_house),
  780. ('slab-4f', 'Apartment slab 4F', 'Slab', [30, 12], 4, make_slab),
  781. ('midrise-8f', 'Stepped midrise 8F', 'Midrise', [20, 16], 8, make_midrise),
  782. ('midrise-wide', 'Wide midrise', 'Midrise', [28, 24], 10, make_wide_midrise)]
  783. entries, preview_scenes = [], []
  784. for index, (slug, title, kind, footprint, floors, build) in enumerate(specs, 1):
  785. stem = f'HL_{kind}_{footprint[0]}x{footprint[1]}_{floors}F'
  786. print(f'BUILD {slug}', flush=True)
  787. src = collection('HL_SOURCE_' + slug, source_root)
  788. RNG.seed(SEED + sum(ord(c) for c in slug))
  789. parts = build(floors)
  790. source_objects = [geometry.object('SRC_' + slug + '__' + name, src) for name, geometry in parts.items()]
  791. source_reports = {obj.name: validate_mesh(obj) for obj in source_objects}
  792. joined = join_meshes(source_objects, stem + '_LOD0', joined_col)
  793. low_parts = [geometry.object('TMP_' + slug + '_' + name, lod_col, lod=1) for name, geometry in parts.items()]
  794. low = join_meshes(low_parts, stem + '_LOD1', lod_col)
  795. for temp in low_parts:
  796. mesh = temp.data
  797. bpy.data.objects.remove(temp, do_unlink=True)
  798. bpy.data.meshes.remove(mesh)
  799. report, low_report = validate_mesh(joined), validate_mesh(low)
  800. assert report['triangles'] <= 35000, (slug, report['triangles'])
  801. assert low_report['triangles'] < report['triangles']
  802. assert abs(report['bounds_m'][0][2]) < 1e-6, report['bounds_m']
  803. for obj, lod in ((joined, 0), (low, 1)):
  804. obj['archetype'], obj['lod'], obj['floors'] = slug, lod, floors
  805. obj['nominal_width_m'], obj['nominal_depth_m'] = footprint
  806. obj['ground_z_m'], obj['authoring_up'], obj['authoring_front'] = 0.0, '+Z', '+Y'
  807. obj['seed'] = SEED
  808. glb = export_glb(joined, out / (slug + '.glb'))
  809. low_glb = export_glb(low, out / (slug + '-lod1.glb'))
  810. assert glb['triangles'] == report['triangles']
  811. assert low_glb['triangles'] == low_report['triangles']
  812. footing_geo = Geometry()
  813. footing_geo.box((0, 0, -0.275), (footprint[0] + 0.16, footprint[1] + 0.16, 0.50), 'Stone_Base')
  814. footing = footing_geo.object(stem + '_Foundation', foundation_col)
  815. footing_report = validate_mesh(footing)
  816. assert footing_report['bounds_m'][1][2] < 0
  817. footing_glb = export_glb(footing, out / (slug + '-foundation.glb'))
  818. entry = {'id': slug, 'display_name': title, 'floors': floors, 'nominal_footprint_m': footprint,
  819. 'ground_z_m': 0, 'joined_object': joined.name, 'blend_object_names': [joined.name, low.name, footing.name],
  820. 'source_collection': src.name, 'source_objects': list(source_reports),
  821. 'validation': report, 'source_validation': source_reports, 'glb': glb,
  822. 'lod1': {'joined_object': low.name, 'validation': low_report, 'glb': low_glb,
  823. 'omits': ['roof tile ribs and courses', 'alternate railing pickets', 'window transoms and lintels', 'AC units and downpipes', 'small door handles']},
  824. 'foundation': {'object': footing.name, 'included_in_main_glb': False, 'validation': footing_report, 'glb': footing_glb}}
  825. entries.append(entry)
  826. if index <= 3 or slug == 'midrise-wide':
  827. preview_scenes.append((entry, preview_scene(joined, entry, index, args.samples)))
  828. print(f'VALID {slug}: LOD0={report["triangles"]}, LOD1={low_report["triangles"]}, closed_shells={report["closed_shells"]}', flush=True)
  829. source_root.hide_viewport = True
  830. source_root.hide_render = True
  831. lod_col.hide_viewport = True
  832. lod_col.hide_render = True
  833. foundation_col.hide_viewport = True
  834. foundation_col.hide_render = True
  835. # All linkable source assets retain identity transforms at the local origin.
  836. for obj in joined_col.objects:
  837. obj.hide_set(obj.name != entries[0]['joined_object'])
  838. preview_paths = []
  839. if not args.skip_render:
  840. for entry, scene in preview_scenes:
  841. path = out / ('preview-' + entry['id'] + '.png')
  842. scene.render.filepath = str(path)
  843. bpy.context.window.scene = scene
  844. print('RENDER ' + entry['id'], flush=True)
  845. bpy.ops.render.render(write_still=True)
  846. preview_paths.append(path)
  847. contact_sheet(preview_paths[:3], out / 'preview.png')
  848. bpy.context.window.scene = preview_scenes[0][1]
  849. bpy.ops.wm.save_as_mainfile(filepath=str(out / 'house-library.blend'))
  850. doc = {'schema_version': 1, 'library': 'county-residential-prototype-01', 'seed': SEED,
  851. 'blender_version': bpy.app.version_string, 'blend_file': 'house-library.blend',
  852. 'authoring_coordinates': {'units': 'metres', 'unit_scale': 1, 'up': '+Z', 'front': '+Y',
  853. 'origin': 'nominal footprint centre at ground Z=0', 'transforms': 'identity'},
  854. 'gltf_coordinates': {'up': '+Y', 'front': '-Z', 'from_blender_xyz': ['x', 'z', '-y'],
  855. 'note': 'Standard glTF axis conversion is already applied. Blender import restores Z-up; do not rotate twice.'},
  856. 'recommended_scale_xyz': {'x': [0.85, 1.15], 'y': [0.85, 1.15], 'z': [0.90, 1.12]},
  857. 'assembly': 'Link joined_object from the blend, or import its GLB. Do not instance source collections together with joined objects. Foundation is opt-in below-ground geometry.',
  858. 'validation_method': 'Shell-level Blender bmesh checks: all edges and vertices manifold, contiguous winding, positive signed volume per connected shell; zero-area faces and degenerate loop triangles rejected. Decoded GLB POSITION/NORMAL/index buffers checked for finite positions, unit normals, valid indices, zero degenerate triangles and agreement of face winding with vertex normals. Export also checked for one mesh, embedded data, triangle count, and backface culling. Components intersect intentionally; no boolean-union or inter-component intersection-free certification.',
  859. 'materials': [{'name': m.name, 'base_color_srgb': m['palette_hex_srgb'],
  860. 'roughness': m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value,
  861. 'metallic': m.node_tree.nodes['Principled BSDF'].inputs['Metallic'].default_value,
  862. 'doubleSided': False} for m in MATERIALS],
  863. 'preview': {'file': 'preview.png' if not args.skip_render else None,
  864. 'individual_files': [p.name for p in preview_paths], 'engine': 'Cycles', 'device': 'CPU',
  865. 'samples': args.samples, 'contact_sheet_size_px': [2520, 1080],
  866. 'note': 'Three independently framed actual camera renders; panel scales differ to show architecture.'},
  867. 'limits': ['First reusable asset prototype; not a whole-city model or surveyed architecture.',
  868. 'Joined meshes contain intersecting but individually closed solids, not a boolean-unioned interior model.',
  869. 'Opaque tinted PBR glazing avoids alpha sorting and interior dependencies.',
  870. 'No textures or external downloads. Material names are shared across all archetypes and LODs.',
  871. 'No Cesium/runtime integration, terrain seating, or georeferencing is performed by this script.'],
  872. 'archetypes': entries}
  873. (out / 'library.json').write_text(json.dumps(doc, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  874. print('LIBRARY_COMPLETE ' + str(out), flush=True)
  875. if __name__ == '__main__':
  876. main()