물 영상의 검증된 3D 대표 시제품과 새 장면 설계 작성

This commit is contained in:
NINI
2026-10-10 00:42:16 +09:00
parent 28a1861e9e
commit a97a778f7a
20 changed files with 3690 additions and 15 deletions
@@ -0,0 +1,6 @@
# 물 소재 3D 시제품 작성 경계
- 이 폴더는 물 영상의 세 대표 장면을 재현하는 bpy 코드다. 범용 실행기는 `tools/blender/run.py`를 사용하고 수정하지 않는다.
- 원본·렌더는 자기 `08-scenes/r2` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
- 실제 O1H2·V자·두 종류 결합·동적 이웃·닫힌 mesh 부피와 `--disable-autoexec` 평가를 검사한다. 물리 시뮬레이션으로 주장하지 않는다.
- draft 품질 조정은 별도 렌더 시도를 보존한다. 제출 후 결과 변경은 새 revision으로 진행하며 새 scenes 승인 전 정식 10–12를 실행하지 않는다.
@@ -0,0 +1,18 @@
# 물 영상의 제한된 3D 시제품
`build_preview.py`는 BondPair·Interface·DropletStretch 세 Scene만 만든다. 불규칙한 물 분자, 실제 연결 쌍 변화, 내부/표면 강조, 같은 부피의 3D 변형을 구현한 설명 모형이다. MD·유체 시뮬레이션은 아니다. 31컷 전체 모델·최종 영상은 scenes 승인 뒤 별도 제작한다.
저장소 루트에서 기존 설치를 사용한다. Blender 논리 F는 실제 F+1, MP4는 `[start,end)`다. 대표 영상은 c07 4040ms/202프레임, c16 4880ms/244프레임, c18 4000ms/200프레임의 무음 영상이다. 끝 PNG는 exclusive 경계이며 MP4 마지막 프레임보다 20ms 뒤다.
```powershell
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --disable-autoexec --python-exit-code 1 --python projects/water-hydrogen-bond-surface-tension/blender/build_preview.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/test_model.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/prepare_artifacts.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/render_preview.py --kind image
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/render_preview.py --kind video
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/verify_design.py
```
작성 순서는 새 draft 재현용이다. `prepare_artifacts.py`는 대표 미디어가 빈 08/09 초안을 작성하므로 제출한 r2에서 재실행하지 않는다. `render_preview.py`는 현재 초안에 실제 증거만 추가한다. 완료된 결과는 새 버전에서 작업한다.
`test_model.py`는 없는 파일·Scene 누락·H 원자 누락·부피 driver 오류의 실제 RED와 원본 GREEN을 확인한다. 부피는 시작·중간 세 곳·전체 컷 끝을 실제 mesh로 평가한다. 실제 렌더는 기존 실행기의 source hash·Scene·profile·frame 검사, PNG/MP4 probe 및 전체 디코드를 거친다. 큰 원본·렌더·실패 시도는 해당 r2의 좁은 `.gitignore`로 보존하고 작은 검증 JSON에 복사할 파일·해시를 기록한다.
@@ -0,0 +1,217 @@
"""물 소재의 세 대표 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)
@@ -0,0 +1,29 @@
"""실제 영상의 시작·중간·끝 렌더를 모바일 크기로 나란히 보는 검사 보조물."""
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageStat
ROOT=Path(__file__).resolve().parents[3]
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r2'
artifact=json.loads((BASE/'artifact.json').read_text(encoding='utf-8'))
results=[]
for sample in artifact['payload']['preview']['samples']:
if sample['media']['kind']!='video':
continue
folder=(ROOT/sample['media']['path']).parent
evidence=json.loads((folder/'evidence.json').read_text(encoding='utf-8'))
frames=evidence['logical_frames']
selection=[frames[0],frames[len(frames)//2],frames[-1]]
originals=[Image.open(folder/f'frame-{frame:08}.png').convert('RGB') for frame in selection]
differences=[ImageChops.difference(originals[0],image) for image in originals[1:]]
assert all(diff.getbbox() for diff in differences), '렌더 픽셀이 동일한 정지 클립'
sheet=Image.new('RGB',(1080,640))
for index,image in enumerate(originals):
sheet.paste(image.resize((360,640),Image.Resampling.LANCZOS),(index*360,0))
path=BASE/'generated-source'/f'mobile-{sample["cut_ids"][0]}.png'
sheet.save(path)
results.append(dict(cut_id=sample['cut_ids'][0],logical_frames=selection,pixel_sha256=[hashlib.sha256(image.tobytes()).hexdigest() for image in originals],mean_absolute_pixel_change=[sum(ImageStat.Stat(diff).mean)/3 for diff in differences],mobile_view_path=path.relative_to(ROOT).as_posix(),mobile_view_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),visual_review='not_checked'))
assert len(results)==3, '세 대표 영상이 아직 없음'
(BASE/'motion-check.json').write_text(json.dumps(dict(result='GREEN',source_sha256=artifact['payload']['preview']['scene_file']['sha256'],views=results),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('MOTION_GREEN: 세 영상의 실제 시작/중간/끝 RGB 픽셀 변화')
@@ -0,0 +1,141 @@
"""실측 07을 보존하며 새 08/09의 검토 초안만 작성한다."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PROJECT = ROOT / 'projects/water-hydrogen-bond-surface-tension'
DESCRIPTIONS = [
'작은 청색 물방울의 둥근 형태를 보여주며 그 모양이 생기는 이유를 질문한다.',
'거시적인 물방울에서 물속 분자 수준의 설명으로 관심을 옮긴다.',
'물 분자가 산소 하나와 수소 둘로 이루어진 온전한 V자 구조임을 보여준다.',
'산소 쪽의 부분 음전하를 강조하며 물 분자 전체가 음이온인 것으로 표현하지 않는다.',
'수소 쪽의 부분 양전하를 산소 쪽과 구별하며 분자 전체의 중성은 유지한다.',
'한 물 분자의 수소와 이웃 물 분자의 산소가 끌림의 양 끝이라는 관계를 보여준다.',
'두 온전한 분자가 가까워지며 분자 사이 H···O 점선 길이가 줄고 내부 O–H 실선은 유지된다.',
'서로 다른 분자의 H···O 사이 끌림에 수소결합이라는 이름을 연결한다.',
'한 분자 내부의 O–H 실선과 분자 사이의 H···O 점선을 동시에 보여 두 결합을 구별한다.',
'불규칙한 액체 분자 집단으로 범위를 넓혀 수소결합이 영구적인 끈은 아님을 설명한다.',
'온전한 분자들이 소폭 움직이며 일부 이웃 사이 연결이 사라지고 새 연결이 생기는 과정을 보여준다.',
'수소결합 연결이 반복해서 바뀌어 액체의 이웃 관계가 고정된 격자가 아님을 보여준다.',
'많은 분자 사이의 끌림이 물끼리 함께 뭉치려는 응집 성질에 기여함을 연결한다.',
'청색 물 체적과 위쪽 공기가 만나는 경계를 절개 설명 모형으로 제시한다.',
'물 안쪽 분자를 강조해 여러 방향에 다른 물 분자 이웃이 있다는 환경을 보여준다.',
'강조 대상을 물 안쪽에서 표면으로 옮겨 공기 방향에는 액체 물 분자 이웃이 적다는 차이를 보여준다.',
'내부와 표면의 불규칙한 분자 배치를 유지해 액체 이웃 환경의 차이를 비교한다.',
'같은 부피의 닫힌 물방울을 길게 변형해 표면을 넓히는 과정을 입체로 보여준다.',
'같은 물의 양으로 길어진 모형을 유지하며 표면을 더 넓히는 데 에너지가 필요함을 설명한다.',
'같은 부피의 물방울이 표면 넓이를 줄이는 쪽으로 모양을 바꾸려는 경향을 연결한다.',
'길어진 물방울이 같은 부피를 유지하며 구형에 가까워지는 계획으로 표면을 줄이는 변화를 보여준다.',
'물 표면이 넓어지는 데 저항하는 성질을 표면장력이라는 이름과 연결한다.',
'길어진 물방울과 둥근 물방울의 모양을 같은 물의 양이라는 공통 조건에서 비교한다.',
'같은 부피일 때 구형이 겉면을 가장 작게 만드는 관계를 두 입체 모형으로 보여준다.',
'외부 영향이 작은 작은 물방울이 구형에 가까워지는 조건을 제시한다.',
'작은 비접촉 방울과 큰 바닥 접촉 방울을 별도 조건 예시로 구별한다.',
'바닥 접촉과 외부 영향으로 실제 물방울의 모양이 달라질 수 있음을 보여준다.',
'작은 청색 물방울로 돌아와 그 주변에 별도 고체 껍질이나 막이 없음을 보여준다.',
'분자 개념도에서 이웃 분자 사이 수소결합을 회수하며 원자와 내부 결합은 유지한다.',
'물방울과 분자 개념도를 함께 유지해 표면을 넓히기 어려운 성질을 답의 원인으로 연결한다.',
'분자 사이 끌림과 표면장력이 외부 영향이 작은 작은 물방울의 둥근 형태를 만든다는 답을 회수한다.'
]
def read(stage):
return json.loads((PROJECT / f'stages/{stage}/r1/artifact.json').read_text(encoding='utf-8'))
def write(stage, payload, inputs):
folder = PROJECT / f'stages/{stage}/r2'
folder.mkdir(parents=True, exist_ok=True)
body = dict(schema_version='1.1',project_id=PROJECT.name,stage_id=stage,revision=2,inputs=[dict(stage_id=s,revision=r) for s,r in inputs],payload=payload)
(folder/'artifact.json').write_text(json.dumps(body,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if __name__ == '__main__':
cuts = read('07-cuts')['payload']['cuts']
# 읽은 근거는 입력 참조에 포함한다. 선행 결과는 수정하지 않는다.
read('02-research')
read('03-script')
groups = [
(1,2,'OpeningDroplet','bg-droplet','정면보다 약간 위의 3D 전경, 중심 물방울; 다음 분자 확대는 새 Scene에서 제시한다.',[
'작은 청색 물방울이 크림색 공간 중앙에 구형에 가깝게 떠 있고 부드러운 하이라이트가 표면의 입체감을 보여준다.',
'작은 물방울을 유지하고 오른쪽 위에 분자 확대의 위치를 표시하는 합성 주석 앵커를 제시한다.',
'물방울과 확대 위치를 유지한 채 분자 설명으로 넘어갈 준비 상태다.'
]),
(3,5,'SingleMolecule','bg-molecules','3D 분자 근접, 약간 비스듬한 고정 카메라; 산소와 두 수소가 모두 가려지지 않는다.',[
'산호색 O 구 하나·미색 H 구 둘·남색 O–H 원통 두 개의 V자 3D 분자; 주석은 합성용 앵커만 마련한다.',
'온전한 V자 분자가 유지되고 산소 주석 앵커가 강조 위치에 있다.',
'분자 전체 중성을 유지하며 Oδ− 합성 주석 위치만 강조한다.',
'Oδ− 및 Hδ+ 합성 앵커가 모두 마련되며 원자 분리·전체 이온 표시는 없다.'
]),
(6,9,'BondPair','bg-molecules','실제 대표 Scene BondPair. 정면 위쪽 직교 카메라 (0,-12,6), ortho_scale=11.5; 내부 실선과 분자간 점선을 한 화면에 읽는다.',[
'PairA=(-1.8,0,-0.3), PairB=(1.65,0.15,0.45). 두 온전한 V자 분자의 내부 남색 실선과 서로 다른 분자 H···O 금색 분절 원통을 구별한다.',
'Blender F1018에서 PairA=(-1.8,0,-0.3), PairB=(1.65,0.15,0.45); H···O 점선의 양 끝이 실제 H/O 위치에 대응한다.',
'Blender F1220에서 PairB=(0.85,0.15,0.45)로 가까워져 H···O 점선 길이가 줄었다. 내부 O–H 실선 두 개씩은 그대로다.',
'같은 두 V자 분자·같은 카메라·금색 H···O 점선 유지; 수소결합 명칭은 합성 단계에서 별도로 배치한다.',
'같은 두 분자의 남색 내부 O–H 실선과 금색 분자간 H···O 점선이 동시에 보이고 모든 원자는 원래 분자에 남아 있다.'
]),
(10,13,'LiquidNetwork','bg-molecules','불규칙한 3D 액체 분자 집단 근접. 고정 격자 대신 깊이·방향을 달리하고 카메라는 유지한다.',[
'여러 온전한 V자 분자가 불규칙한 위치·방향에 있고 서로 다른 수의 금색 분자간 연결이 보인다.',
'분자 전체가 소폭 이동하는 동안 일부 H···O 점선 연결이 사라질 준비 상태; 내부 실선은 유지된다.',
'첫 H···O 점선 쌍이 사라지고 다른 분자 쌍의 점선이 나타난다. 원자 분리나 화학 반응은 없다.',
'새 이웃과의 H···O 연결이 보이며 분자마다 연결 수·방향이 다르다. 영구 끈·정지 격자는 없다.',
'불규칙한 집단을 유지하며 분자 사이의 응집이 거시적 물의 뭉침에 기여함을 다음 경계 모형으로 연결한다.'
]),
(14,17,'Interface','bg-interface','실제 대표 Scene Interface. 3D 체적을 옆면·뒤쪽만 남긴 잘라낸 설명 모형, 카메라 (0,-14,6), ortho_scale=13. 색 경계에 고체막을 추가하지 않는다.',[
'아래쪽 청색 물 체적과 위쪽 공기 공간이 보이며 앞쪽 절개에 불규칙한 물 분자가 있다. 공기 이름은 추후 합성한다.',
'물-공기 경계를 유지하고 안쪽 Liquid4 주변의 다양한 이웃이 보이도록 근접 강조를 시작한다.',
'Blender F2807에서 Liquid4 안쪽 강조고리 scale=1, Liquid1 표면 강조고리 scale=0.01. 안쪽은 위·아래·옆에 이웃 물 분자가 있다.',
'Blender F3051에서 안쪽 강조는 작아지고 Liquid1 표면 강조고리 scale=1. 표면보다 위 공기 쪽에는 액체 물 이웃을 배치하지 않는다; 별도 막은 없다.',
'안쪽과 표면의 환경 차이가 보이는 불규칙한 분자 배치 유지. 공기는 단순화한 공간이며 진공이나 수증기 부재 주장이 아니다.'
]),
(18,22,'DropletStretch','bg-droplet','실제 대표 Scene DropletStretch. 닫힌 청색 3D mesh, (0,-12,5) 직교 카메라 ortho_scale=10.5. 같은 물의 양·설명용 모형 합성 앵커, 표면층 없음.',[
'Blender F3173의 닫힌 Droplet mesh scale=(1,1,1); 부드러운 청색 구형 물방울. 물의 양은 같은 조건이다.',
'Blender F3373의 Droplet scale=(1.85,1/sqrt(1.85),1/sqrt(1.85)); 같은 부피를 유지하며 좌우로 길어지고 표면이 넓어지는 설명용 변형이다.',
'길어진 같은 부피 물방울 유지; 에너지 필요 주석을 추후 합성한다. 실제 힘·에너지 수치는 표시하지 않는다.',
'같은 부피 물방울이 되돌아갈 준비 상태이며 물 표면의 확장 저항을 설명한다.',
'같은 부피의 닫힌 mesh가 구형에 가깝게 복귀한다. 복귀 애니메이션은 scenes 승인 후 정식 제작한다.',
'구형에 가까운 물방울과 표면 확장 저항 설명이 남는다. 껍질·고체 표면층은 없다.'
]),
(23,24,'EqualVolumeComparison','bg-comparison','3D 비교 전경, 좌우 모형을 같은 카메라·같은 물의 양으로 비교한다. 수치·픽셀면적 비교 없음.',[
'왼쪽 길어진 닫힌 물방울과 오른쪽 구형 물방울을 동일 부피 조건으로 나란히 배치한다.',
'같은 물의 양 주석 앵커를 유지하며 두 형태의 겉면 차이를 보여준다.',
'같은 부피에서 구형의 표면적이 작다는 관계를 청색 형태와 합성 주석으로 강조한다.'
]),
(25,27,'ShapeConditions','bg-comparison','조건 예시 3D 전경, 왼쪽 작은 비접촉 방울과 오른쪽 바닥에 닿은 큰 방울. 앞 비교와 별도 Scene.',[
'작은 비접촉 물방울과 더 큰 바닥 접촉 물방울을 별도의 조건 예시로 배치한다. 이 컷은 동일 부피 비교가 아니다.',
'외부 영향이 작은 작은 물방울이 구형에 가깝고 오른쪽 방울은 바닥과 접촉해 모양이 달라지는 예시다.',
'바닥 접촉 방울의 납작한 형태가 보이며 중력·접촉면 영향의 조건 주석 앵커가 있다.',
'두 조건 예시를 유지한다. 항상 완전한 구라는 단정·접촉각이나 크기 경계값은 없다.'
]),
(28,31,'AnswerDroplet','bg-droplet','도입과 같은 크림색 3D 공간·청색 방울 전경을 새 Scene에서 재제시하고 분자 개념도를 별도 위치에 둔다.',[
'구형에 가까운 청색 물방울과 분리된 분자 개념도 위치가 보인다. 물방울 주변에 별도 껍질은 없다.',
'동일 물방울 표면을 유지하며 추가 막이 없음을 보여준다.',
'별도 분자 개념도에서 온전한 V자 분자 사이 금색 H···O 점선이 변하고 내부 남색 실선은 유지된다.',
'물방울과 분자 개념도를 유지하며 표면을 넓히기 어려운 성질을 합성 주석으로 회수한다.',
'외부 영향이 작은 작은 물방울의 둥근 형태와 수소결합·표면장력의 설명이 함께 남아 도입 질문에 답한다.'
])
]
scenes, mapping = [], {}
for start,end,name,bg,camera,states in groups:
assert len(states)==end-start+2
for n in range(start,end+1):
cut=cuts[n-1]
mapping[cut['id']]=(name,bg)
scenes.append(dict(cut_id=cut['id'],description=DESCRIPTIONS[n-1],start_state=f"Scene={name}; F{cut['start_ms']//20+1}. "+states[n-start],end_state=f"Scene={name}; F{cut['end_ms']//20+1}. "+states[n-start+1],camera=camera))
connections=[]
for left,right in zip(scenes,scenes[1:]):
same=mapping[left['cut_id']][0]==mapping[right['cut_id']][0]
if same:
assert left['end_state']==right['start_state']
connections.append(dict(from_cut_id=left['cut_id'],to_cut_id=right['cut_id'],mode='continuous' if same else 'cut',reason='동일 Scene·논리 경계 프레임·물체·카메라 상태를 공유하며 설명의 다음 변화를 잇는다.' if same else f"설명 범위·조건·시점 전환으로 {mapping[left['cut_id']][0]}에서 별도 {mapping[right['cut_id']][0]} Scene으로 전환한다."))
profile=dict(engine='BLENDER_EEVEE',device='GPU',fps=50,width=1080,height=1920,samples=64,seed=0)
write('08-scenes',dict(scenes=scenes,connections=connections,preview=dict(scene_file=dict(path=f'projects/{PROJECT.name}/stages/08-scenes/r2/generated-source/preview.blend',verified=False,sha256=None,blender_version=None),render_profile=profile,samples=[])),[('02-research',1),('03-script',1),('07-cuts',1)])
common=["references/1.png의 단순 형태·정돈된 색·정보량을 교육용 3D로 해석한다. 크림색 단순 공간·부드러운 조명·선명한 형태를 반복한다.","산호색 O·미색 H·남색 내부 O–H 실선·금색 서로 다른 분자 H···O 점선·청색 물을 일관되게 사용한다.","상하단에는 제목·자막용 빈 공간을 두고 부분전하·한국어 주석은 나중 Remotion에서 검증해 합성한다. 3D에 글자를 굽지 않는다."]
extras={
'bg-droplet':('물방울의 크림색 3D 설명 공간',["청색 물방울은 닫힌 mesh이며 별도 껍질·막을 만들지 않는다.","같은 물의 양 변형은 실제 평가 mesh 부피를 시작·중간·끝에서 검사한다. 물리 시뮬레이션으로 주장하지 않는다."]),
'bg-molecules':('분자의 3D 근접·불규칙 액체 연결 설명 공간',["온전한 물 분자마다 O1H2·V자 형태·내부 O–H 실선 두 개를 유지한다. 각도·거리·힘 수치를 표시하지 않는다.","분자간 H···O 점선은 서로 다른 분자의 원자만 연결한다. 액체는 불규칙한 이웃·변하는 연결이고 모든 분자의 연결 수를 고정하지 않는다."]),
'bg-interface':('물-공기 경계의 3D 체적·절개 설명 공간',["물 체적을 뒤쪽·옆면에 남긴 개념도이며 위 공기는 진공 주장이 아니다. 별도 고체 표면층·전체 아래 가속 화살표가 없다.","안쪽은 다양한 방향의 액체 이웃, 표면은 위쪽 액체 이웃이 적은 환경으로 구별한다. 강조고리는 설명 표식이고 물 껍질이 아니다."]),
'bg-comparison':('같은 부피 비교·조건 예시의 크림색 3D 공간',["동일 부피 비교와 외부 영향 조건 예시는 별도 Scene으로 구별한다.","바닥은 조건 예시에만 등장한다. 중력·접촉각·크기 경계·표면장력 수치를 임의로 표시하지 않는다."])
}
backgrounds=[dict(id=k,description=v[0],fixed_features=common+v[1]) for k,v in extras.items()]
write('09-entities',dict(characters=[],backgrounds=backgrounds,appearances=[dict(cut_id=c['id'],character_ids=[],background_ids=[mapping[c['id']][1]]) for c in cuts]),[('02-research',1),('03-script',1),('07-cuts',1),('08-scenes',2)])
@@ -0,0 +1,65 @@
"""기존 실행기로 세 대표 컷의 PNG/MP4를 만든다. 전 컷 제작은 하지 않는다."""
import argparse
import json
from pathlib import Path
import subprocess
from PIL import Image, ImageChops
ROOT = Path(__file__).resolve().parents[3]
BASE = Path('projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r2')
BLENDER = 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
PYTHON = 'C:/Users/baram/anaconda3/python.exe'
COMPOSITOR = Path('C:/git/ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/node_modules/@remotion/compositor-win32-x64-msvc')
SOURCE = (BASE/'generated-source/preview.blend').as_posix()
ARTIFACT = (BASE/'artifact.json').as_posix()
PROJECT = 'projects/water-hydrogen-bond-surface-tension/project.json'
PLAN = [('c07','BondPair','PairA',1017,1219,1219),('c16','Interface','WaterCutawayBody',2806,3050,3050),('c18','DropletStretch','Droplet',3172,3372,3372)]
def run(args, records):
result=subprocess.run(args,cwd=ROOT,text=True,encoding='utf-8',capture_output=True)
records.append(dict(command=args,exit_code=result.returncode))
(ROOT/BASE/'cli-commands.json').write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if result.returncode:
print(result.stdout,result.stderr,flush=True)
raise RuntimeError('실행 실패; 명령 기록과 기존 시도 보존')
parsed=json.loads(result.stdout)
records[-1]['source_sha256']=parsed['scene_file']['sha256']
records[-1]['scene_name']=parsed.get('scene_name')
if 'media' in parsed:
records[-1]['media']=parsed['media']
records[-1]['evidence_path']=(Path(parsed['media']['path']).parent/'evidence.json').as_posix()
(ROOT/BASE/'cli-commands.json').write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('EXIT_0',args[5],parsed.get('scene_name'),flush=True)
return parsed
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--kind',choices=['image','video'],required=True)
args=parser.parse_args()
record_path=ROOT/BASE/'cli-commands.json'
records=json.loads(record_path.read_text(encoding='utf-8')) if record_path.exists() else []
for record in records:
record.pop('stdout',None)
record.pop('stderr',None)
artifact=json.loads((ROOT/ARTIFACT).read_text(encoding='utf-8'))
for cut,scene,obj,start,end,boundary in PLAN:
assert not any(s['id']==cut+'-'+args.kind for s in artifact['payload']['preview']['samples']), '이미 반영한 sample을 같은 버전에서 재생성하지 않는다'
common=[PYTHON,'-B','-X','utf8','tools/blender/run.py']
inspect=run(common+['inspect','--blender',BLENDER,'--source',SOURCE,'--scene',scene,'--require-object',obj],records)
artifact['payload']['preview']['scene_file']=inspect['scene_file']
(ROOT/ARTIFACT).write_text(json.dumps(artifact,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
options=['--frame',str(boundary)] if args.kind=='image' else ['--start',str(start),'--end',str(end)]
rendered=run(common+['render','--mode','preview','--project',PROJECT,'--artifact',ARTIFACT,'--output-revision','2','--blender',BLENDER,'--source',SOURCE,'--scene',scene,'--require-object',obj,'--cut-id',cut,'--ffmpeg',str(COMPOSITOR/'ffmpeg.exe'),'--ffprobe',str(COMPOSITOR/'ffprobe.exe')]+options,records)
# 실행기의 실제 증거만 미디어 메타데이터로 사용한다.
evidence=rendered
if args.kind=='video':
frames=[ROOT/Path(evidence['media']['path']).parent/f'frame-{f:08}.png' for f in (start,start+(end-start)//2,end-1)]
pixels=[Image.open(f).convert('RGB') for f in frames]
if not (ImageChops.difference(pixels[0],pixels[1]).getbbox() and ImageChops.difference(pixels[0],pixels[2]).getbbox()):
(ROOT/Path(evidence['media']['path']).parent/'visual-failure.json').write_text(json.dumps(dict(error='실제 렌더 프레임이 정지함; 미디어 반영 금지',logical_frames=[start,start+(end-start)//2,end-1]),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
raise RuntimeError('실제 렌더 프레임이 정지함; 미디어 반영 금지')
artifact['payload']['preview']['samples'].append(dict(id=cut+'-'+args.kind,cut_ids=[cut],media=evidence['media']))
(ROOT/ARTIFACT).write_text(json.dumps(artifact,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('READY',cut,args.kind,evidence['media']['path'],flush=True)
@@ -0,0 +1,29 @@
"""없는 원본·잘못된 Scene·원자 수·부피를 실제 Blender로 거부하는지 검사한다."""
import json
from pathlib import Path
import subprocess
ROOT=Path(__file__).resolve().parents[3]
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r2'
BLENDER='C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
SOURCE=BASE/'generated-source/preview.blend'
VERIFY=ROOT/'projects/water-hydrogen-bond-surface-tension/blender/verify_model.py'
records=[]
def execute(args, expected):
command=[BLENDER,'--background','--disable-autoexec','--python-exit-code','1']+args
result=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,encoding='utf-8')
assert result.returncode==expected,(command,result.stdout,result.stderr)
records.append(dict(command=command,exit_code=result.returncode,expected_exit_code=expected,reason=result.stderr.strip().splitlines()[-1:] if result.stderr.strip() else []))
for name,edit in [('invalid-default','bpy.ops.wm.read_factory_settings(use_empty=True)'),('invalid-atoms',"bpy.data.objects.remove(bpy.data.objects['PairA_H2'],do_unlink=True)"),('invalid-volume',"bpy.data.objects['Droplet'].animation_data.drivers[1].driver.expression='1'")]:
fixture=BASE/'generated-source'/f'{name}.blend'
expression=f'import bpy; bpy.ops.wm.open_mainfile(filepath={str(SOURCE)!r}); {edit}; bpy.ops.wm.save_as_mainfile(filepath={str(fixture)!r})'
execute(['--python-expr',expression],0)
execute(['--python',str(VERIFY),'--','--source',str(fixture),'--out',str(BASE/'generated-source/invalid-result.json')],1)
execute(['--python',str(VERIFY),'--','--source',str(BASE/'generated-source/missing.blend'),'--out',str(BASE/'generated-source/invalid-result.json')],1)
execute(['--python',str(VERIFY),'--','--source',str(SOURCE),'--out',str(BASE/'model-check.json')],0)
(BASE/'model-test-results.json').write_text(json.dumps(dict(result='GREEN',negative_cases=4,positive_cases=1,executions=records),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('4 negative cases RED, real model GREEN')
@@ -0,0 +1,75 @@
"""컷·연결·새 버전 미디어와 실제 실행 증거를 검사한다."""
import hashlib
import json
from pathlib import Path
import jsonschema
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
if __name__=='__main__':
a=read(PROJECT/'stages/08-scenes/r2/artifact.json')
b=read(PROJECT/'stages/09-entities/r2/artifact.json')
schema=read(ROOT/'contracts/artifact.schema.json')
jsonschema.validate(a,schema)
jsonschema.validate(b,schema)
cuts=read(PROJECT/'stages/07-cuts/r1/artifact.json')['payload']
ids=[c['id'] for c in cuts['cuts']]
assert len(ids)==31 and len(cuts['captions'])==38 and cuts['narration_duration_ms']==110560
assert cuts['cuts'][0]['start_ms']==0 and cuts['cuts'][-1]['end_ms']==110560
assert all(0<c['end_ms']-c['start_ms']<=5000 for c in cuts['cuts'])
assert all(x['end_ms']==y['start_ms'] for x,y in zip(cuts['cuts'],cuts['cuts'][1:]))
scenes=a['payload']['scenes']
connections=a['payload']['connections']
assert [s['cut_id'] for s in scenes]==ids and len(connections)==30
assert [(c['from_cut_id'],c['to_cut_id']) for c in connections]==list(zip(ids,ids[1:]))
for left,right,connection in zip(scenes,scenes[1:],connections):
if connection['mode']=='continuous':
assert left['end_state']==right['start_state']
assert left['camera']==right['camera']
else:
assert left['start_state'].split(';')[0]!=right['start_state'].split(';')[0]
assert b['payload']['characters']==[]
assert [p['cut_id'] for p in b['payload']['appearances']]==ids
bg={g['id'] for g in b['payload']['backgrounds']}
assert all(not p['character_ids'] and set(p['background_ids'])<=bg for p in b['payload']['appearances'])
preview=a['payload']['preview']
source=preview['scene_file']
assert source['verified'] and hashlib.sha256((ROOT/source['path']).read_bytes()).hexdigest()==source['sha256']
samples=preview['samples']
assert len(samples)==6 and len({s['id'] for s in samples})==6
assets=[dict(path=source['path'],sha256=source['sha256'])]
frame_reviews=[]
for sample in samples:
media=sample['media']
path=ROOT/media['path']
assert path.is_relative_to(PROJECT/'stages/08-scenes/r2') and media['verified']
evidence_path=path.parent/'evidence.json'
evidence=read(evidence_path)
assert evidence['media']==media and evidence['scene_file']==source and evidence['render_profile']==preview['render_profile']
assert (media['width'],media['height'])==(1080,1920)
if media['kind']=='video':
cut=next(c for c in cuts['cuts'] if c['id']==sample['cut_ids'][0])
assert media['duration_ms']==cut['end_ms']-cut['start_ms']
assert evidence['logical_frames']==list(range(cut['start_ms']//20,cut['end_ms']//20))
for frame in (evidence['logical_frames'][0],evidence['logical_frames'][len(evidence['logical_frames'])//2],evidence['logical_frames'][-1]):
f=path.parent/f'frame-{frame:08}.png'
frame_reviews.append(f.relative_to(ROOT).as_posix())
assets.append(dict(path=f.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(f.read_bytes()).hexdigest()))
for f in (path,evidence_path):
assets.append(dict(path=f.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(f.read_bytes()).hexdigest()))
result=dict(result='GREEN',cuts=31,captions=38,connections=30,total_ms=110560,fps=50,total_frames=5528,profile=preview['render_profile'],source_sha256=source['sha256'],samples=6,visual_review_frames=frame_reviews,actual_assets=assets,scenes_approval='pending',formal_10_12='not_started')
motion=read(PROJECT/'stages/08-scenes/r2/motion-check.json')
assert motion['source_sha256']==source['sha256'] and len(motion['views'])==3
for view in motion['views']:
assert all(change>0 for change in view['mean_absolute_pixel_change'])
path=ROOT/view['mobile_view_path']
assert hashlib.sha256(path.read_bytes()).hexdigest()==view['mobile_view_sha256']
assets.append(dict(path=view['mobile_view_path'],sha256=view['mobile_view_sha256']))
(PROJECT/'stages/08-scenes/r2/design-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('DESIGN_GREEN: 31 cuts, 30 boundaries, 38 captions, 6 actual samples')
@@ -0,0 +1,112 @@
"""Blender 내부에서 실제 원본·구조·평가 부피를 검사한다."""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import bpy
import bmesh
from bpy_extras.object_utils import world_to_camera_view
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
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 {"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"])