verify-roof-geometry.py 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  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. from pathlib import Path
  7. import bpy
  8. import bmesh
  9. from mathutils import Vector
  10. from mathutils.geometry import tessellate_polygon
  11. source = Path(__file__).with_name('assemble-cad-bim.py')
  12. module = ast.parse(source.read_text(encoding='utf-8'))
  13. # Load the real function definitions; city intake and export are deliberately not executed.
  14. functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'massing_triangles',
  15. 'poly_prism', 'prism_volume_sign'}
  16. namespace = {'bpy': bpy, 'bmesh': bmesh, 'math': math, 'Vector': Vector,
  17. 'tessellate_polygon': tessellate_polygon}
  18. exec(compile(ast.Module(body=[node for node in module.body
  19. if isinstance(node, ast.FunctionDef) and node.name in functions],
  20. type_ignores=[]), str(source), 'exec'), namespace)
  21. materials = [bpy.data.materials.new(name) for name in ('Wall', 'Roof', 'Window', 'Door')]
  22. fixtures = [
  23. ('rectangle', [(0, 0), (12, 0), (12, 8), (0, 8)]),
  24. ('concave-L', [(0, 0), (10, 0), (10, 3), (4, 3), (4, 10), (0, 10)]),
  25. ('clockwise', [(0, 0), (0, 8), (12, 8), (12, 0)]),
  26. ('thin', [(0, 0), (12, 0), (12, 0.5), (0, 0.5)]),
  27. ]
  28. failures = []
  29. for name, ring in fixtures:
  30. for level in ('lod1', 'lod0'):
  31. outline = namespace['as_ccw'](ring)
  32. bottom, top = 140.0, 156.4
  33. obj = namespace['poly_prism'](outline, bottom, top, materials[0], materials[1],
  34. (6, 4, 150), 2, level, materials[2], materials[3])
  35. mesh = obj.data
  36. mesh.calc_loop_triangles()
  37. top_faces = [face for face in mesh.polygons if all(
  38. abs(mesh.vertices[i].co.z + obj.location.z - top) < 0.001 for i in face.vertices)]
  39. bottom_faces = [face for face in mesh.polygons if all(
  40. abs(mesh.vertices[i].co.z + obj.location.z - bottom) < 0.001 for i in face.vertices)]
  41. expected = abs(namespace['ring_area'](outline))
  42. area = sum(face.area for face in top_faces if face.normal.z > 0.99 and face.material_index == 1)
  43. bottom_area = sum(face.area for face in bottom_faces if face.normal.z < -0.99)
  44. print(f'ROOF_GEOMETRY {name}/{level}: upwardRoofArea={area:.3f}, expected={expected:.3f}, bottomArea={bottom_area:.3f}', flush=True)
  45. if abs(area - expected) > 0.001 or abs(bottom_area - expected) > 0.001:
  46. failures.append(f'{name}/{level}: roof or bottom cap missing/reversed')
  47. # Boundary edges on the solid shell must be closed; facade quads can have their own edges.
  48. edit = bmesh.new()
  49. edit.from_mesh(mesh)
  50. edit.verts.ensure_lookup_table()
  51. shell_open = sum(not edge.is_manifold for edge in edit.edges
  52. if all(vertex.index < 2 * len(outline) for vertex in edge.verts))
  53. edit.free()
  54. if shell_open:
  55. failures.append(f'{name}/{level}: {shell_open} open shell edges')
  56. bpy.data.objects.remove(obj, do_unlink=True)
  57. bpy.data.meshes.remove(mesh)
  58. if failures:
  59. raise AssertionError('; '.join(failures))
  60. print(f'ROOF_GEOMETRY passed: {len(fixtures) * 2} cases', flush=True)