"""Blender 내부에서 실제 원본·구조·평가 부피를 검사한다.""" import argparse import hashlib import json from pathlib import Path import sys import bpy import bmesh from bpy_extras.object_utils import world_to_camera_view def verify(source): assert source.is_file(), "원본 파일 없음" bpy.ops.wm.open_mainfile(filepath=str(source)) expected = {"BondPair": (905, 1637), "Interface": (2469, 3173), "DropletStretch": (3173, 4077)} assert set(bpy.data.scenes) and set(s.name for s in bpy.data.scenes) == set(expected), "대표 Scene 누락/혼입" assert not any(d.library for d in bpy.data.objects), "외부 객체" assert not bpy.data.images and not bpy.data.fonts and not bpy.data.movieclips, "외부 자산" checks = {} for name, bounds in expected.items(): scene = bpy.data.scenes[name] bpy.context.window.scene = scene assert (scene.frame_start, scene.frame_end) == bounds, "실측 범위를 덮지 못함" assert scene.camera and scene.render.fps == 50 assert (scene.render.resolution_x, scene.render.resolution_y) == (1080, 1920) assert scene.render.engine == "BLENDER_EEVEE" molecules = [o for o in scene.objects if o.get("role") == "molecule"] for mol in molecules: atoms = [o for o in mol.children if o.get("role") == "atom"] assert sorted(o["element"] for o in atoms) == ["H", "H", "O"], "O1H2 위반" assert all(o.type == "MESH" and len(o.data.vertices) > 20 for o in atoms) assert all(o.data.materials[0].name==('O_Coral' if o['element']=='O' else 'H_Ivory') for o in atoms), '원자 색 재질 불일치' oxygen = next(o for o in atoms if o["element"] == "O") hydrogen = [o for o in atoms if o["element"] == "H"] assert (hydrogen[0].location - oxygen.location).cross(hydrogen[1].location - oxygen.location).length > .01, "V형태 아님" bonds = [o for o in mol.children if o.get("role") == "covalent"] assert len(bonds) == 2 and all(o.type == "MESH" for o in bonds), "내부 실선 두 개 누락" assert all(o.data.materials[0].name=='OH_Navy' for o in bonds), '내부 실선 재질 불일치' assert all(min((bond.location - (h.location+oxygen.location)/2).length for h in hydrogen)<.001 for bond in bonds), "실선의 O–H 위치 불일치" for segment in [o for o in scene.objects if o.get("role") == "hydrogen_bond"]: h, oxygen = (scene.objects[segment[k]] for k in ("hydrogen", "oxygen")) assert h["element"] == "H" and oxygen["element"] == "O" and h.parent != oxygen.parent, "H···O 분자간 점선 위반" assert segment.type == "MESH" and segment.dimensions.length > 0 assert segment.data.materials[0].name=='HO_Gold', '분자간 점선 재질 불일치' checks[name] = {"molecules": len(molecules), "frame_range": list(bounds)} assert checks["BondPair"]["molecules"] == 2 and checks["Interface"]["molecules"] >= 8 pair = bpy.data.scenes["BondPair"] bpy.context.window.scene = pair pair.frame_set(1018) before = pair.objects["PairB"].matrix_world.translation.copy() pair.frame_set(1117) assert (pair.objects["PairB"].matrix_world.translation - before).length > .1, "실제 3D 이동 없음" interface = bpy.data.scenes['Interface'] bpy.context.window.scene = interface connection_sets, neighborhoods = [], [] for frame in (2807,2907,3051): interface.frame_set(frame) active = sorted({(o['hydrogen'],o['oxygen']) for o in interface.objects if o.get('role')=='hydrogen_bond' and o.scale.x > .5}) connection_sets.append(active) for segment in [o for o in interface.objects if o.get('role')=='hydrogen_bond']: h, oxygen = (interface.objects[segment[k]] for k in ('hydrogen','oxygen')) a,b = h.matrix_world.translation, oxygen.matrix_world.translation assert (segment.location-a).cross(b-a).length < .002, '점선이 실제 H···O 선분에서 벗어남' counts=[] for name in ('Liquid4','Liquid1'): center=interface.objects[name].matrix_world.translation neighbors=[o.matrix_world.translation-center for o in interface.objects if o.get('role')=='molecule' and o.name!=name and (o.matrix_world.translation-center).length<2.2] counts.append(dict(neighbors=len(neighbors),above=sum(p.z>.7 for p in neighbors))) assert counts[0]['neighbors']>counts[1]['neighbors'] and counts[0]['above']>0 and counts[1]['above']==0, '내부/표면 이웃 배치 차이 없음' neighborhoods.append(counts) assert connection_sets[0] != connection_sets[-1], '실제 활성 연결 쌍이 바뀌지 않음' scene = bpy.data.scenes["DropletStretch"] bpy.context.window.scene = scene droplet = scene.objects["Droplet"] volumes, scales = [], [] for frame in (3173, 3223, 3273, 3323, 3373): scene.frame_set(frame) deps = bpy.context.evaluated_depsgraph_get() evaluated = droplet.evaluated_get(deps) mesh = evaluated.to_mesh() bm = bmesh.new() bm.from_mesh(mesh) assert all(e.is_manifold for e in bm.edges), "닫힌 mesh 아님" bm.transform(evaluated.matrix_world) volumes.append(abs(bm.calc_volume(signed=True))) scales.append(list(evaluated.scale)) bm.free() evaluated.to_mesh_clear() relative = max(abs(v / volumes[0] - 1) for v in volumes) assert relative <= .01, "중간 프레임 포함 부피 변화 1% 초과" assert abs(scales[-1][0] - scales[0][0]) > .5, "변형이 실제 평가되지 않음" anchors = {} for name, logical in (("BondPair", 1219), ("Interface", 3050), ("DropletStretch", 3372)): scene = bpy.data.scenes[name] bpy.context.window.scene = scene scene.frame_set(logical + 1) anchors[name] = {"logical_frame":logical,"labels":{}} for obj in scene.objects: if obj.get("label"): p = world_to_camera_view(scene, scene.camera, obj.matrix_world.translation) anchors[name]['labels'][obj["label"]] = {"x": round(p.x * 1080, 2), "y": round((1-p.y)*1920, 2), "depth": round(p.z, 2)} return {"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "blender_version": bpy.app.version_string, "scenes": checks, "interface_frames_blender":[2807,2907,3051], "interface_active_pairs":connection_sets,"interface_neighborhoods":neighborhoods,"volume_frames_blender": [3173,3223,3273,3323,3373], "volumes": volumes, "relative_volume_change": relative, "evaluated_scales": scales, "label_anchors": anchors, "text_baked": False, "physical_simulation": False} if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--source", required=True) parser.add_argument("--out", required=True) args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:]) result = verify(Path(args.source).resolve()) Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print("MODEL_GREEN", result["source_sha256"], result["relative_volume_change"])