176 lines
11 KiB
Python
176 lines
11 KiB
Python
"""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"])
|