"""물 소재의 세 대표 3D 장면만 작성한다. 물리 시뮬레이션이 아니다.""" from pathlib import Path import hashlib import math import shutil import bpy from mathutils import Vector ROOT = Path(__file__).resolve().parents[3] OUT = ROOT / "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/generated-source/preview.blend" if OUT.exists(): history=OUT.parent/'history' history.mkdir(exist_ok=True) snapshot=history/(hashlib.sha256(OUT.read_bytes()).hexdigest()+'.blend') if not snapshot.exists(): shutil.copy2(OUT,snapshot) bpy.ops.wm.read_factory_settings(use_empty=True) def material(name, color, roughness=.48, metallic=0): mat = bpy.data.materials.new(name) mat.diffuse_color = (*color, 1) mat.use_nodes = True bsdf = mat.node_tree.nodes.get("Principled BSDF") bsdf.inputs["Base Color"].default_value = (*color, 1) bsdf.inputs["Roughness"].default_value = roughness bsdf.inputs["Metallic"].default_value = metallic return mat O = material("O_Coral", (.74, .16, .11)) H = material("H_Ivory", (.91, .84, .64)) SOLID = material("OH_Navy", (.025, .06, .13)) DASH = material("HO_Gold", (.83, .47, .045), .4) WATER = material("Water_Blue", (.06, .42, .66), .25, .08) CREAM = material("Studio_Cream", (.78, .73, .62), .8) GHOST = material("Water_Concept_Cutaway", (.25, .62, .76), .7) BACKDROP = bpy.data.materials.new('Cream_Emission_Backdrop') BACKDROP.use_nodes = True nodes = BACKDROP.node_tree.nodes nodes.clear() emission = nodes.new('ShaderNodeEmission') emission.inputs['Color'].default_value = (.88,.82,.70,1) output = nodes.new('ShaderNodeOutputMaterial') BACKDROP.node_tree.links.new(emission.outputs[0],output.inputs['Surface']) def point(obj, target): obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('-Z', 'Y').to_euler() def scene(name, bounds, camera_position, scale): s = bpy.context.scene if len(bpy.data.scenes) == 1 and not bpy.context.scene.objects else bpy.data.scenes.new(name) s.name = name bpy.context.window.scene = s s.frame_start, s.frame_end = bounds s.render.engine = "BLENDER_EEVEE" s.render.fps = 50 s.render.resolution_x, s.render.resolution_y = 1080, 1920 s.render.resolution_percentage = 100 s.eevee.taa_render_samples = 64 s.view_settings.view_transform = 'Standard' world = bpy.data.worlds.new(name + "World") world.use_nodes = True world.node_tree.nodes['Background'].inputs[0].default_value = (.78, .74, .65, 1) world.node_tree.nodes['Background'].inputs[1].default_value = .3 s.world = world cam = bpy.data.objects.new(name+"Camera", bpy.data.cameras.new(name+"Camera")) s.collection.objects.link(cam) cam.location = camera_position point(cam, (0, 0, 0)) cam.data.type = 'ORTHO' cam.data.ortho_scale = scale s.camera = cam bpy.ops.mesh.primitive_plane_add(size=30,location=-cam.location.normalized()*6) backdrop=bpy.context.object backdrop.name=name+'CreamBackdrop' backdrop.rotation_euler=cam.rotation_euler backdrop.data.materials.append(BACKDROP) backdrop['role']='background' for label, location, energy, size in (("Key", (-4,-6,7), 250, 5), ("Fill", (4,-2,3), 150, 4), ("Rim", (0,4,5), 200, 4)): light = bpy.data.objects.new(name+label, bpy.data.lights.new(name+label, 'AREA')) light.location = location light.data.energy, light.data.shape, light.data.size = energy, 'DISK', size point(light, (0,0,0)) s.collection.objects.link(light) return s def sphere(name, position, radius, mat, parent=None, segments=32, rings=16): bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, radius=radius, location=position) obj = bpy.context.object obj.name = name obj.data.materials.append(mat) for polygon in obj.data.polygons: polygon.use_smooth = True obj.parent = parent return obj def cylinder(name, a, b, radius, mat, parent=None): a, b = Vector(a), Vector(b) bpy.ops.mesh.primitive_cylinder_add(vertices=16, radius=radius, depth=(b-a).length, location=(a+b)/2) obj = bpy.context.object obj.name = name obj.rotation_euler = (b-a).to_track_quat('Z','Y').to_euler() obj.data.materials.append(mat) obj.parent = parent for polygon in obj.data.polygons: polygon.use_smooth = True return obj def molecule(name, location, rotation=0, size=1): root = bpy.data.objects.new(name, None) bpy.context.scene.collection.objects.link(root) root.location = location root.rotation_euler.z = rotation root.scale = (size,)*3 root['role'] = 'molecule' atoms = [] for suffix, pos, radius, mat, element in (("O", (0,0,0), .36,O,'O'), ("H1", (.72,0,.52), .23,H,'H'), ("H2",(-.72,0,.52), .23,H,'H')): atom = sphere(name+"_"+suffix,pos,radius,mat,root) atom['role'], atom['element'] = 'atom', element atoms.append(atom) for i, h in enumerate(atoms[1:]): bond = cylinder(name+"_OH"+str(i),(0,0,0),h.location,.065,SOLID,root) bond['role'] = 'covalent' return root, atoms def dash(name, hydrogen, oxygen, frames=None): # 점선의 끝점은 실제 서로 다른 분자의 H/O 월드 좌표다. scene = bpy.context.scene keys = frames or [scene.frame_start] segments = [] for i in range(6): obj = cylinder(name+str(i),(0,0,0),(0,0,.1),.045,DASH) obj['role'], obj['hydrogen'], obj['oxygen'] = 'hydrogen_bond', hydrogen.name, oxygen.name segments.append(obj) for frame in keys: scene.frame_set(frame) bpy.context.view_layer.update() a, b = hydrogen.matrix_world.translation, oxygen.matrix_world.translation direction = b-a for i, obj in enumerate(segments): t1, t2 = .18+i*.11, .18+i*.11+.062 obj.location = a+direction*((t1+t2)/2) obj.rotation_euler = direction.to_track_quat('Z','Y').to_euler() obj.scale.z = direction.length*(t2-t1)/.1 for path in ('location','rotation_euler','scale'): obj.keyframe_insert(path,frame=frame) return segments pair = scene("BondPair", (905,1637), (0,-12,6), 11.5) a, aa = molecule("PairA", (-1.8,0,-.3)) b, ba = molecule("PairB", (1.65,.15,.45), .3) aa[1]['label'] = '한 분자의 Hδ+' ba[0]['label'] = '이웃 분자의 Oδ−' for frame, x in ((905,1.65),(1018,1.65),(1118,.85),(1220,.85),(1637,.85)): b.location.x = x b.keyframe_insert('location',frame=frame) dash("Pair_HO_Dash_", aa[1], ba[0], [905,1018,1118,1220,1637]) interface = scene("Interface", (2469,3173), (0,-14,6), 13) # 실제 물 영역 전체를 표시하되 앞면·윗면을 열어 내부 분자를 보이는 절개 설명 모형. low, high = (-2.85,-.85,-3.15), (2.85,1.35,.80) vertices=[(x,y,z) for z in (low[2],high[2]) for y in (low[1],high[1]) for x in (low[0],high[0])] mesh=bpy.data.meshes.new('WaterCutawayMesh') mesh.from_pydata(vertices,[],[(0,2,3,1),(2,6,7,3),(0,4,6,2),(1,3,7,5)]) mesh.update() body=bpy.data.objects.new('WaterCutawayBody',mesh) interface.collection.objects.link(body) body.data.materials.append(GHOST) body['role']='cutaway_water' body['representation']='앞면·윗면이 열린 물 체적의 설명용 절개' positions = [(-1.8,-.1,.25),(.4,.2,.18),(1.95,.45,.25),(-2,.3,-1.5),(-.65,.15,-1.15),(.9,.3,-1.6),(2,.35,-1.3),(-1.6,.3,-2.5),(.2,.4,-2.45),(1.6,.1,-2.6)] mols=[] for i, pos in enumerate(positions): root, atoms = molecule('Liquid'+str(i),pos,(-.4+i*.37),.58) mols.append((root,atoms)) for frame, delta in ((2469,0),(2807,0),(2907,.10*math.sin(i+1)),(3051,.05*math.cos(i)),(3173,0)): root.location.x = pos[0]+delta root.keyframe_insert('location',frame=frame) for i,j in ((0,1),(1,2),(3,4),(3,7),(4,5),(4,7),(5,6),(5,8),(8,9)): segments = dash('LiquidDash'+str(i)+'_'+str(j)+'_',mols[i][1][1],mols[j][1][0],[2469,2807,2907,3051,3173]) if (i,j) in ((3,4),(3,7)): for segment in segments: for frame, active in ((2469, j==4),(2807,j==4),(2907,j==7),(3051,j==7),(3173,j==7)): segment.scale.x = segment.scale.y = 1 if active else .001 segment.keyframe_insert('scale',frame=frame) mols[4][0]['label']='물 안쪽' mols[1][0]['label']='물과 공기의 경계' for name, center, frames in (("InteriorFocus",positions[4],((2469,.01),(2807,1),(2857,.01))), ("SurfaceFocus",positions[1],((2807,.01),(2907,1),(3173,1)))): bpy.ops.mesh.primitive_torus_add(major_segments=48, minor_segments=10, location=(center[0],-.32,center[2]), rotation=(math.pi/2,0,0), major_radius=.74, minor_radius=.033) ring=bpy.context.object ring.name=name ring.data.materials.append(DASH) ring['role']='explanatory_highlight' for frame, scale in frames: ring.scale=(scale,)*3 ring.keyframe_insert('scale',frame=frame) stretch = scene("DropletStretch", (3173,4077), (0,-12,5), 10.5) drop=sphere('Droplet',(0,0,0),1.3,WATER,segments=128,rings=64) drop['role']='same_volume_water' drop['label']='같은 물의 양 · 설명용 모형' # 단순 driver는 --disable-autoexec 상태에서도 평가된다. 매 프레임 det(scale)=1. expression="1+0.85*min(max((frame-3173)/200,0),1)" drop.driver_add('scale',0).driver.expression=expression for axis in (1,2): drop.driver_add('scale',axis).driver.expression="("+expression+")**(-0.5)" OUT.parent.mkdir(parents=True,exist_ok=True) for s in bpy.data.scenes: s.frame_set(s.frame_start) bpy.ops.wm.save_as_mainfile(filepath=str(OUT)) print('PREVIEW_SOURCE',OUT)