Files

219 lines
9.4 KiB
Python

"""물 소재의 세 대표 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)