"""Blender 내부에서 실제 원본·구조·평가 부피를 검사한다.""" import argparse import hashlib import json from pathlib import Path import sys import bpy import bmesh import numpy as np from mathutils import Vector from bpy_extras.object_utils import world_to_camera_view def containment(scene): """전체 프레임의 실제 mesh 정점을 물 영역·화면 윤곽과 대조한다.""" bpy.context.window.scene = scene body = scene.objects['WaterCutawayBody'] deps = bpy.context.evaluated_depsgraph_get() evaluated = body.evaluated_get(deps) mesh = evaluated.to_mesh() water = np.array([tuple(evaluated.matrix_world @ v.co) for v in mesh.vertices]) evaluated.to_mesh_clear() low, high = water.min(axis=0), water.max(axis=0) # 실제 물 표시 mesh에서 경계를 얻고 중심·별도 메타데이터만으로 판정하지 않는다. origin = world_to_camera_view(scene,scene.camera,Vector((0,0,0))) projection = np.array([tuple(world_to_camera_view(scene,scene.camera,Vector(b))-origin)[:2] for b in ((1,0,0),(0,1,0),(0,0,1))]) offset = np.array(tuple(origin)[:2]) screen = (water @ projection + offset) * (1080,1920) points = sorted(set(map(tuple,screen))) def cross(a,b,c): return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]) lower, upper = [], [] for chain, vertices in ((lower,points),(upper,reversed(points))): for p in vertices: while len(chain)>=2 and cross(chain[-2],chain[-1],p)<=0: chain.pop() chain.append(p) hull = np.array(lower[:-1]+upper[:-1]) minimum = np.full(3,np.inf) pixel_margin = float('inf') vertex_count = 0 for logical in range(2468,3173): scene.frame_set(logical+1) deps = bpy.context.evaluated_depsgraph_get() for obj in scene.objects: role = obj.get('role') if role not in ('atom','covalent','hydrogen_bond') or (role=='hydrogen_bond' and obj.scale.x<=.0011): continue evaluated = obj.evaluated_get(deps) mesh = evaluated.to_mesh() local = np.array([tuple(v.co) for v in mesh.vertices]) transform = np.array(evaluated.matrix_world) vertices = local @ transform[:3,:3].T + transform[:3,3] evaluated.to_mesh_clear() margin = np.minimum(vertices-low,high-vertices).min(axis=0) assert margin.min()>=.03, f'물 영역 밖 또는 여백 부족: F{logical} {obj.name}, 축 여백 {margin.tolist()}' minimum = np.minimum(minimum,margin) projected = (vertices @ projection + offset) * (1080,1920) assert (projected.min(axis=0)>0).all() and (projected.max(axis=0)<(1080,1920)).all(), '분자 화면 잘림' for a,b in zip(hull,np.roll(hull,-1,axis=0)): edge = b-a distance = (edge[0]*(projected[:,1]-a[1])-edge[1]*(projected[:,0]-a[0]))/np.linalg.norm(edge) assert distance.min()>=0, f'파란 물 영역 투영 밖: F{logical} {obj.name}' pixel_margin = min(pixel_margin,float(distance.min())) vertex_count += len(vertices) # 앞면과 윗면은 실제 열린 절개이며 물의 고체 껍질을 만들지 않는다. assert len(body.data.polygons)==4, '물 절개 면 수 불일치' for face in body.data.polygons: points=np.array([tuple(body.matrix_world @ body.data.vertices[i].co) for i in face.vertices]) assert not np.allclose(points[:,1],low[1]) and not np.allclose(points[:,2],high[2]), '앞면·윗면 절개가 닫힘' return dict(logical_frame_start=2468,logical_frame_end=3172,frames=705,water_bounds=[low.tolist(),high.tolist()],minimum_axis_margin=minimum.tolist(),minimum_projected_margin_px=pixel_margin,evaluated_vertices=vertex_count,mesh_roles=['atom','covalent','active_hydrogen_bond'],open_faces=['front','top']) 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 water_containment = containment(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 {"water_containment":water_containment,"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"])