"""승인된 물 소재 자산을 복사해 정식 기준 원본과 애니메이션 원본을 만든다.""" import argparse import hashlib import json import math from pathlib import Path import sys import bpy from mathutils import Vector ROOT=Path(__file__).resolve().parents[3] PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension' APPROVED=PROJECT/'stages/08-scenes/r3/generated-source/preview.blend' APPROVED_HASH='ba0850a2148824cc8416704c03f14e5714ca01a16620a4eb26981e238af78c73' parser=argparse.ArgumentParser() parser.add_argument('--stage',choices=['references','images'],required=True) args=parser.parse_args(sys.argv[sys.argv.index('--')+1:]) def clone(obj,name,scene,parent=None): result=obj.copy() if obj.data: result.data=obj.data.copy() result.name=name result.animation_data_clear() result.parent=parent scene.collection.objects.link(result) return result def studio(name,base,bounds,scale): scene=bpy.data.scenes.new(name) scene.world=base.world scene.render.engine='BLENDER_EEVEE' scene.render.fps=50 scene.render.resolution_x,scene.render.resolution_y=1080,1920 scene.eevee.taa_render_samples=64 scene.view_settings.view_transform='Standard' for obj in base.objects: if obj.type in ('CAMERA','LIGHT') or obj.get('role')=='background': item=clone(obj,name+obj.type+obj.name,scene) if item.type=='CAMERA': scene.camera=item item.data.ortho_scale=scale scene.frame_start,scene.frame_end=bounds return scene def molecule(scene,name,location,size=1,rotation=0): template=bpy.data.objects['PairA'] root=clone(template,name,scene) root.location=location root.rotation_euler=(0,0,rotation) root.scale=(size,)*3 for child in template.children: clone(child,name+child.name[len('PairA'):],scene,root) return root def droplet(scene,name,location,scale): obj=clone(bpy.data.objects['Droplet'],name,scene) obj.location=location obj.scale=scale return obj def anchor(scene,name,location,label): obj=bpy.data.objects.new(name,None) scene.collection.objects.link(obj) obj.location=location obj['anchor']=label return obj def mark(obj,label): obj['anchor']=label def collection(name,objects): group=bpy.data.collections.new(name) group.use_fake_user=True for obj in objects: group.objects.link(obj) return group def animate_network(scene,prefix,start,turn,end): # 온전한 분자를 움직이고 H···O 원통을 실제 원자 위치로 갱신한다. roots=[scene.objects[prefix+str(i)] for i in range(10)] for i,root in enumerate(roots): position=root.location.copy() for frame,delta in [(start,0),(turn,.09*math.sin(i+1)),(end,.06*math.cos(i))]: root.location=position+Vector((delta,0,0)) root.keyframe_insert('location',frame=frame) template=bpy.data.objects['LiquidDash0_1_0'] pairs=[(0,1),(1,2),(3,4),(3,7),(4,5),(4,6),(4,7),(5,6),(5,8),(8,9)] for i,j in pairs: hydrogen,oxygen=scene.objects[prefix+str(i)+'_H1'],scene.objects[prefix+str(j)+'_O'] for segment in range(6): obj=clone(template,prefix+'Dash'+str(i)+'_'+str(j)+'_'+str(segment),scene) obj['hydrogen'],obj['oxygen']=hydrogen.name,oxygen.name for frame in [start,1779,turn,2257,end]: scene.frame_set(frame) bpy.context.view_layer.update() a,b=hydrogen.matrix_world.translation,oxygen.matrix_world.translation direction=b-a obj.location=a+direction*(.18+segment*.11+.031) obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler() active=not ((i,j)==(3,4) and frame>=turn) and not ((i,j)==(3,7) and frame=2257) and not ((i,j)==(4,6) and frame<2257) obj.scale=(1 if active else .001,1 if active else .001,direction.length*.062/.1) for path in ['location','rotation_euler','scale']: obj.keyframe_insert(path,frame=frame) if args.stage=='references': assert hashlib.sha256(APPROVED.read_bytes()).hexdigest()==APPROVED_HASH, '승인 원본 해시 불일치' bpy.ops.wm.open_mainfile(filepath=str(APPROVED)) pair=bpy.data.scenes['BondPair'] drop=bpy.data.scenes['DropletStretch'] interface=bpy.data.scenes['Interface'] opening=studio('OpeningDroplet',drop,(1,397),10.5) obj=droplet(opening,'OpeningWater',(0,0,0),(1,1,1)) mark(obj,'opening_drop') anchor(opening,'OpeningMoleculeInset',(1.65,0,1.7),'molecule_inset') single=studio('SingleMolecule',pair,(397,905),9) molecule(single,'SingleWater',(0,0,-.4),1.8) for suffix,label in [('O','single_O_delta_minus'),('H1','single_H_delta_plus'),('H2','single_H2_delta_plus')]: mark(single.objects['SingleWater_'+suffix],label) network=studio('LiquidNetwork',interface,(1637,2469),13) for i in range(10): old=interface.objects['Liquid'+str(i)] molecule(network,'Network'+str(i),(old.location.x,old.location.y,old.location.z+1.3),.58,old.rotation_euler.z) mark(network.objects['Network4'],'network_interior') comparison=studio('EqualVolumeComparison',drop,(4077,4370),12.5) size=.72 droplet(comparison,'ComparisonLong',(-1.65,0,0),(size*1.65,size/math.sqrt(1.65),size/math.sqrt(1.65))) droplet(comparison,'ComparisonRound',(1.65,0,0),(size,)*3) mark(comparison.objects['ComparisonLong'],'equal_volume_left') mark(comparison.objects['ComparisonRound'],'equal_volume_right') conditions=studio('ShapeConditions',drop,(4370,4889),12.5) droplet(conditions,'SmallFreeDrop',(-1.65,0,.2),(.58,)*3) # 큰 접촉 방울은 같은 부피 비교가 아닌 별도 조건 모형이다. contact=droplet(conditions,'LargeContactDrop',(1.55,0,-.275),(1.02,1.02,.75/1.3)) mark(conditions.objects['SmallFreeDrop'],'small_free_condition') mark(contact,'large_contact_condition') bpy.context.window.scene=conditions bpy.ops.mesh.primitive_cube_add(size=1,location=(1.55,0,-1.15)) floor=bpy.context.object floor.name='ContactFloor' floor.scale=(3.1,2.5,.25) floor_material=bpy.data.materials.new('ContactFloorCream') floor_material.diffuse_color=(.78,.73,.62,1) floor_material.use_nodes=True floor_material.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.78,.73,.62,1) floor_material.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8 floor.data.materials.append(floor_material) floor['role']='condition_floor' mark(floor,'contact_floor') answer=studio('AnswerDroplet',drop,(4889,5529),12.5) droplet(answer,'AnswerWater',(0,0,-.7),(1,)*3) mark(answer.objects['AnswerWater'],'answer_macro_drop') for i,position in enumerate([(-1.1,0,1.8),(0,.1,2.05),(1.1,0,1.8)]): molecule(answer,'AnswerMolecule'+str(i),position,.55,i*.2) mark(answer.objects['AnswerMolecule0_H1'],'answer_inset_H') mark(answer.objects['AnswerMolecule1_O'],'answer_inset_O') mark(answer.objects['AnswerMolecule2'],'answer_inset_extent') mark(pair.objects['PairA_H1'],'pair_H_delta_plus') mark(pair.objects['PairB_O'],'pair_O_delta_minus') mark(interface.objects['Liquid4'],'water_interior') mark(interface.objects['Liquid1'],'water_surface') anchor(interface,'InterfaceAirAnchor',(0,0,1.45),'air_region') anchor(interface,'InterfaceWaterAnchor',(0,0,-2.95),'cutaway_water_region') mark(drop.objects['Droplet'],'same_volume_drop') collection('StudioAssets',[o for s in bpy.data.scenes for o in s.objects if o.get('role')=='background']) collection('StyleAssets',[o for s in bpy.data.scenes for o in s.objects if o.get('role')=='background']) collection('DropletStudioAssets',[o for s in bpy.data.scenes if s.name in ['OpeningDroplet','DropletStretch','AnswerDroplet'] for o in s.objects if o.get('role')=='background']) collection('MoleculeStudioAssets',[o for s in bpy.data.scenes if s.name in ['SingleMolecule','BondPair','LiquidNetwork'] for o in s.objects if o.get('role')=='background']) collection('ComparisonStudioAssets',[o for s in bpy.data.scenes if s.name in ['EqualVolumeComparison','ShapeConditions'] for o in s.objects if o.get('role')=='background']) collection('MoleculeAssets',[o for o in bpy.data.objects if o.get('role') in ('molecule','atom','covalent','hydrogen_bond')]) collection('DropletAssets',[o for o in bpy.data.objects if o.get('role')=='same_volume_water']) collection('InterfaceAssets',[interface.objects['WaterCutawayBody']]) # 실제 기준 원본에도 액체 연결 자산을 구성하여 후속 원본이 새 경로만 참조하지 않게 한다. bpy.context.window.scene=network animate_network(network,'Network',1637,2019,2469) bpy.context.window.scene=answer # 답 회수에는 거시 방울과 공간적으로 분리된 분자 개념도가 공존한다. for i,j in [(0,1),(1,2)]: template=bpy.data.objects['LiquidDash0_1_0'] h,o=answer.objects['AnswerMolecule'+str(i)+'_H1'],answer.objects['AnswerMolecule'+str(j)+'_O'] bpy.context.view_layer.update() a,b=h.matrix_world.translation,o.matrix_world.translation for n in range(6): obj=clone(template,'AnswerDash'+str(i)+'_'+str(j)+'_'+str(n),answer) obj['hydrogen'],obj['oxygen']=h.name,o.name direction=b-a obj.location=a+direction*(.18+n*.11+.031) obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler() obj.scale=(1,1,direction.length*.062/.1) for scene in bpy.data.scenes: scene['approved_source_sha256']=APPROVED_HASH destination=PROJECT/'stages/10-references/r2/generated-source/references.blend' else: artifact=json.loads((PROJECT/'stages/10-references/r2/artifact.json').read_text(encoding='utf-8')) result=artifact['payload']['result'] source=ROOT/result['scene_file']['path'] assert result['scene_file']['verified'] and hashlib.sha256(source.read_bytes()).hexdigest()==result['scene_file']['sha256'] assert len(result['items'])==7 and all((ROOT/item['media']['path']).is_file() and item['media']['verified'] for item in result['items']) bpy.ops.wm.open_mainfile(filepath=str(source)) for scene in bpy.data.scenes: scene['reference_source_sha256']=result['scene_file']['sha256'] drop=bpy.data.objects['Droplet'] expression='1+0.85*min(max((frame-3173)/200,0),1)*(1-min(max((frame-3657)/196,0),1))' drop.animation_data.drivers[0].driver.expression=expression for axis in [1,2]: drop.animation_data.drivers[axis].driver.expression='('+expression+')**(-0.5)' # 액체 점선의 위치·방향을 프레임마다 실제 H/O 평가 위치에 맞춰 굽는다. network=bpy.data.scenes['LiquidNetwork'] bpy.context.window.scene=network segments=[obj for obj in network.objects if obj.get('role')=='hydrogen_bond'] for obj in segments: obj.animation_data_clear() for frame in range(1637,2470): network.frame_set(frame) bpy.context.view_layer.update() for obj in segments: h,o=network.objects[obj['hydrogen']],network.objects[obj['oxygen']] a,b=h.matrix_world.translation,o.matrix_world.translation direction=b-a segment=int(obj.name.rsplit('_',1)[1]) obj.location=a+direction*(.18+segment*.11+.031) obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler() pair=(h.parent.name,o.parent.name) first=min(max((frame-1779)/240,0),1) second=min(max((frame-2019)/238,0),1) weight={('Network3','Network4'):1-first,('Network3','Network7'):first,('Network4','Network5'):1-second,('Network4','Network6'):second}.get(pair,1) obj.scale=(max(weight,.001),max(weight,.001),direction.length*.062/.1) for path in ['location','rotation_euler','scale']: obj.keyframe_insert(path,frame=frame) # 답의 수소결합은 영구 끈이 아니며 연결이 약해졌다가 다시 나타난다. answer=bpy.data.scenes['AnswerDroplet'] for obj in answer.objects: if obj.get('role')=='hydrogen_bond': original=obj.scale.z for frame,active in [(4889,1),(5100,1),(5160,.001),(5229,1),(5529,1)]: obj.scale=(active,active,original) obj.keyframe_insert('scale',frame=frame) destination=PROJECT/'stages/11-images/r2/generated-source/animation.blend' for scene in bpy.data.scenes: bpy.context.window.scene=scene scene.frame_set(scene.frame_start) destination.parent.mkdir(parents=True,exist_ok=True) assert not destination.exists(), '제출 원본 덮어쓰기 금지' bpy.ops.wm.save_as_mainfile(filepath=str(destination)) print('정식 원본 저장',destination)