218 lines
9.1 KiB
Python
218 lines
9.1 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/r2/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)
|
|
# 옆면/뒤쪽의 청색 체적만 보이는 잘라낸 설명 모형. 경계 위 고체 막 없음.
|
|
bpy.ops.mesh.primitive_cube_add(size=1, location=(0,1.7,-1.15))
|
|
body = bpy.context.object
|
|
body.name = 'WaterCutawayBody'
|
|
body.scale = (5,1.5,3)
|
|
body.data.materials.append(GHOST)
|
|
body['role'] = 'cutaway_water'
|
|
bevel=body.modifiers.new('SoftCutawayEdges','BEVEL')
|
|
bevel.width=.055
|
|
bevel.segments=3
|
|
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)
|