verify-roof-geometry.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. """Exercise the actual Blender building generator, without running a city export.
  2. blender --background --factory-startup --python-exit-code 1 --python tools/city-massing/verify-roof-geometry.py
  3. """
  4. import ast
  5. import math
  6. import sys
  7. from pathlib import Path
  8. import bpy
  9. import bmesh
  10. from mathutils import Vector
  11. from mathutils.geometry import tessellate_polygon
  12. source = Path(__file__).with_name('assemble-cad-bim.py')
  13. module = ast.parse(source.read_text(encoding='utf-8'))
  14. # Load the real function definitions; city intake and export are deliberately not executed.
  15. functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'massing_triangles',
  16. 'poly_prism', 'prism_volume_sign'}
  17. namespace = {'bpy': bpy, 'bmesh': bmesh, 'math': math, 'Vector': Vector,
  18. 'tessellate_polygon': tessellate_polygon, 'ARCHITECTURE': '--legacy' not in sys.argv}
  19. exec(compile(ast.Module(body=[node for node in module.body
  20. if isinstance(node, ast.FunctionDef) and node.name in functions],
  21. type_ignores=[]), str(source), 'exec'), namespace)
  22. materials = [bpy.data.materials.new(name) for name in ('Wall', 'Roof', 'Window', 'Door')]
  23. fixtures = [
  24. ('rectangle', [(0, 0), (12, 0), (12, 8), (0, 8)]),
  25. ('concave-L', [(0, 0), (10, 0), (10, 3), (4, 3), (4, 10), (0, 10)]),
  26. ('clockwise', [(0, 0), (0, 8), (12, 8), (12, 0)]),
  27. ('thin', [(0, 0), (12, 0), (12, 0.5), (0, 0.5)]),
  28. ('centimetre-backtrack', [(-881.93, 844.99), (-881.94, 844.99), (-874.09, 842.72),
  29. (-876.51, 834.71), (-884.25, 837.04)]),
  30. ]
  31. failures = []
  32. for name, ring in fixtures:
  33. for level in ('lod1', 'lod0'):
  34. outline = namespace['as_ccw'](ring)
  35. bottom, top = 140.0, 156.4
  36. obj = namespace['poly_prism'](outline, bottom, top, materials[0], materials[1],
  37. (6, 4, 150), 2, level, materials[2], materials[3])
  38. mesh = obj.data
  39. mesh.calc_loop_triangles()
  40. top_faces = [face for face in mesh.polygons if all(
  41. abs(mesh.vertices[i].co.z + obj.location.z - top) < 0.001 for i in face.vertices)]
  42. bottom_faces = [face for face in mesh.polygons if all(
  43. abs(mesh.vertices[i].co.z + obj.location.z - bottom) < 0.001 for i in face.vertices)]
  44. expected = abs(namespace['ring_area'](outline))
  45. area = sum(face.area for face in top_faces if face.normal.z > 0.99 and face.material_index == 1)
  46. bottom_area = sum(face.area for face in bottom_faces if face.normal.z < -0.99)
  47. print(f'ROOF_GEOMETRY {name}/{level}: upwardRoofArea={area:.3f}, expected={expected:.3f}, bottomArea={bottom_area:.3f}', flush=True)
  48. if abs(area - expected) > 0.001 or abs(bottom_area - expected) > 0.001:
  49. failures.append(f'{name}/{level}: roof or bottom cap missing/reversed')
  50. if namespace['ARCHITECTURE']:
  51. door_faces = [face for face in mesh.polygons if face.material_index == 3 and len(face.vertices) == 4
  52. and min(mesh.vertices[i].co.z + obj.location.z for i in face.vertices) < 151]
  53. door_vertices = {i for face in door_faces for i in face.vertices}
  54. if not door_vertices or min(mesh.vertices[i].co.z + obj.location.z for i in door_vertices) < 150:
  55. failures.append(f'{name}/{level}: entrance is buried below seated floor')
  56. longest = max(range(len(outline)), key=lambda i: math.dist(outline[i], outline[(i + 1) % len(outline)]))
  57. a, b = outline[longest], outline[(longest + 1) % len(outline)]
  58. length = math.dist(a, b)
  59. projected = [(mesh.vertices[i].co.x * (b[0]-a[0]) + mesh.vertices[i].co.y * (b[1]-a[1])) / length
  60. for i in door_vertices]
  61. if projected and max(projected) - min(projected) > 1.6:
  62. failures.append(f'{name}/{level}: entrance spans an implausible facade fraction')
  63. # Boundary edges on the solid shell must be closed; facade quads can have their own edges.
  64. edit = bmesh.new()
  65. edit.from_mesh(mesh)
  66. edit.verts.ensure_lookup_table()
  67. shell_open = sum(not edge.is_manifold for edge in edit.edges
  68. if all(vertex.index < 2 * len(outline) for vertex in edge.verts))
  69. edit.free()
  70. if shell_open:
  71. failures.append(f'{name}/{level}: {shell_open} open shell edges')
  72. bpy.data.objects.remove(obj, do_unlink=True)
  73. bpy.data.meshes.remove(mesh)
  74. if failures:
  75. raise AssertionError('; '.join(failures))
  76. print(f'ROOF_GEOMETRY passed: {len(fixtures) * 2} cases', flush=True)