verify-library.py 4.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. """Read-only audit of the SAVED blend and GLBs; writes verification.json only.
  2. blender --background house-library.blend --python verify-library.py
  3. The caller must open this directory's house-library.blend before running.
  4. """
  5. import importlib.util
  6. import json
  7. import sys
  8. from pathlib import Path
  9. import bpy
  10. import bmesh
  11. sys.dont_write_bytecode = True
  12. from mathutils import Matrix
  13. root = Path(__file__).resolve().parent
  14. spec = importlib.util.spec_from_file_location('house_generator', root.parent / 'blender-house-library.py')
  15. generator = importlib.util.module_from_spec(spec)
  16. spec.loader.exec_module(generator)
  17. manifest = json.loads((root / 'library.json').read_text(encoding='utf-8'))
  18. assert Path(bpy.data.filepath).resolve() == (root / manifest['blend_file']).resolve()
  19. assert all(scene.unit_settings.system == 'METRIC' and scene.unit_settings.scale_length == 1 for scene in bpy.data.scenes)
  20. assert len({m.name for m in bpy.data.materials if m.name in {entry['name'] for entry in manifest['materials']}}) == len(manifest['materials'])
  21. checked = []
  22. for entry in manifest['archetypes']:
  23. assert entry['source_collection'] in bpy.data.collections
  24. for name, expected in entry['source_validation'].items():
  25. assert generator.validate_mesh(bpy.data.objects[name]) == expected, name
  26. variants = [(entry['joined_object'], entry['validation'], entry['glb']),
  27. (entry['lod1']['joined_object'], entry['lod1']['validation'], entry['lod1']['glb']),
  28. (entry['foundation']['object'], entry['foundation']['validation'], entry['foundation']['glb'])]
  29. for name, expected, exported in variants:
  30. obj = bpy.data.objects[name]
  31. assert obj.type == 'MESH' and obj.matrix_world == Matrix.Identity(4), name
  32. assert not obj.modifiers, name
  33. assert generator.validate_mesh(obj) == expected, name
  34. assert generator.glb_summary(root / exported['file']) == exported, name
  35. assert all(m.use_backface_culling for m in obj.data.materials), name
  36. assert all(m is bpy.data.materials[m.name] for m in obj.data.materials), name
  37. checked.append(name)
  38. assert entry['validation']['bounds_m'][0][2] == 0
  39. assert entry['validation']['triangles'] <= 35000
  40. assert entry['foundation']['validation']['bounds_m'][1][2] < 0
  41. for filename in manifest['preview']['individual_files'] + [manifest['preview']['file']]:
  42. assert (root / filename).is_file() and (root / filename).stat().st_size > 10000
  43. # Negative controls mutate disposable copies in memory, never the saved library.
  44. negative_controls = {}
  45. for mutation in ('deleted_roof_face', 'inverted_roof_shell'):
  46. original = bpy.data.objects['SRC_house__Roof']
  47. mutant = bpy.data.objects.new('NEGATIVE_' + mutation, original.data.copy())
  48. bm = bmesh.new()
  49. bm.from_mesh(mutant.data)
  50. bm.faces.ensure_lookup_table()
  51. if mutation == 'deleted_roof_face':
  52. bmesh.ops.delete(bm, geom=[bm.faces[0]], context='FACES_ONLY')
  53. else:
  54. first = bm.faces[0]
  55. shell, stack = {first}, [first]
  56. while stack:
  57. for edge in stack.pop().edges:
  58. for neighbor in edge.link_faces:
  59. if neighbor not in shell:
  60. shell.add(neighbor)
  61. stack.append(neighbor)
  62. bmesh.ops.reverse_faces(bm, faces=list(shell))
  63. bm.to_mesh(mutant.data)
  64. bm.free()
  65. try:
  66. generator.validate_mesh(mutant)
  67. except RuntimeError as error:
  68. message = str(error)
  69. assert ('nonmanifold_edges' if mutation == 'deleted_roof_face' else 'negative_volume_shells') in message
  70. negative_controls[mutation] = {'status': 'correctly_rejected', 'reason': message}
  71. else:
  72. raise AssertionError('Validator accepted negative control: ' + mutation)
  73. finally:
  74. mesh = mutant.data
  75. bpy.data.objects.remove(mutant, do_unlink=True)
  76. bpy.data.meshes.remove(mesh)
  77. result = {'status': 'passed', 'negative_controls': negative_controls,
  78. 'blender': bpy.app.version_string, 'archetypes': len(manifest['archetypes']),
  79. 'source_meshes': sum(len(a['source_objects']) for a in manifest['archetypes']),
  80. 'runtime_and_foundation_meshes_checked': len(checked), 'objects': checked,
  81. 'checks': ['Saved source collections and named objects resolve', 'Metric unit scale and identity object transforms',
  82. 'Shell edge/vertex manifoldness, positive signed volumes and contiguous winding',
  83. 'No zero-area faces or degenerate Blender triangles', 'One mesh per GLB and triangle counts match',
  84. 'Decoded GLB indices/positions/normals checked', 'Shared Blender materials and backface culling',
  85. 'Ground Z=0; foundations strictly below zero; <=35000 triangles', 'Preview files exist']}
  86. (root / 'verification.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
  87. print(json.dumps(result, indent=2), flush=True)