물 분자 전체를 실제 물 영역 안에 포함한 r3 시제품 검증

This commit is contained in:
NINI
2026-10-10 01:30:59 +09:00
parent b1d697e320
commit ee3d18be33
20 changed files with 2165 additions and 43 deletions
@@ -1,6 +1,6 @@
# 물 소재 3D 시제품 작성 경계
- 이 폴더는 물 영상의 세 대표 장면을 재현하는 bpy 코드다. 범용 실행기는 `tools/blender/run.py`를 사용하고 수정하지 않는다.
- 원본·렌더는 자기 `08-scenes/r2` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
- 실제 O1H2·V자·두 종류 결합·동적 이웃·닫힌 mesh 부피와 `--disable-autoexec` 평가를 검사한다. 물리 시뮬레이션으로 주장하지 않는다.
- 원본·렌더는 자기 `08-scenes/r3` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
- 전체 Interface 범위의 평가 mesh 정점·물 경계·화면 투영 포함과 실제 O1H2·V자·두 종류 결합·동적 이웃·닫힌 mesh 부피와 `--disable-autoexec` 평가를 검사한다. 물리 시뮬레이션으로 주장하지 않는다.
- draft 품질 조정은 별도 렌더 시도를 보존한다. 제출 후 결과 변경은 새 revision으로 진행하며 새 scenes 승인 전 정식 10–12를 실행하지 않는다.
@@ -13,6 +13,6 @@
& '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`는 현재 초안에 실제 증거만 추가한다. 완료된 결과는 새 버전에서 작업한다.
작성 순서는 새 draft 재현용이다. `prepare_artifacts.py`는 대표 미디어가 빈 08/09 초안을 작성하므로 제출한 r3에서 재실행하지 않는다. `render_preview.py`는 현재 초안에 실제 증거만 추가한다. 완료된 결과는 새 버전에서 작업한다.
`test_model.py`는 없는 파일·Scene 누락·H 원자 누락·부피 driver 오류의 실제 RED와 원본 GREEN을 확인한다. 부피는 시작·중간 세 곳·전체 컷 끝을 실제 mesh로 평가한다. 실제 렌더는 기존 실행기의 source hash·Scene·profile·frame 검사, PNG/MP4 probe 및 전체 디코드를 거친다. 큰 원본·렌더·실패 시도는 해당 r2의 좁은 `.gitignore`로 보존하고 작은 검증 JSON에 복사할 파일·해시를 기록한다.
`test_model.py`는 없는 파일·Scene 누락·H 원자 누락·부피 driver 오류·원자 중심은 유지하고 mesh 정점만 물 밖에 둔 fixture·기존 r2 원본의 실제 RED와 수정 원본 GREEN을 확인한다. 포함 관계 실패 문구도 검사·기록한다. Interface 전체 논리 F2468–3172의 실제 원자·내부 실선·활성 점선 mesh 정점을 실제 물 mesh 경계와 camera 투영 윤곽에 대조한다. 최소 축 여백 0.03을 요구한다. 물 모형은 앞면·윗면이 실제 열린 절개이며 벽·고체 껍질이 아니다. 부피는 시작·중간 세 곳·전체 컷 끝을 실제 mesh로 평가한다. 실제 렌더는 기존 실행기의 source hash·Scene·profile·frame 검사, PNG/MP4 probe 및 전체 디코드를 거친다. 큰 원본·렌더·실패 시도는 해당 r3의 좁은 `.gitignore`로 보존하고 작은 검증 JSON에 복사할 파일·해시를 기록한다.
@@ -7,7 +7,7 @@ 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"
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)
@@ -164,16 +164,17 @@ for frame, x in ((905,1.65),(1018,1.65),(1118,.85),(1220,.85),(1637,.85)):
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)
# 실제 물 영역 전체를 표시하되 앞면·윗면을 열어 내부 분자를 보이는 절개 설명 모형.
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'
bevel=body.modifiers.new('SoftCutawayEdges','BEVEL')
bevel.width=.055
bevel.segments=3
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):
@@ -5,7 +5,7 @@ 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'
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3'
artifact=json.loads((BASE/'artifact.json').read_text(encoding='utf-8'))
results=[]
for sample in artifact['payload']['preview']['samples']:
@@ -44,9 +44,9 @@ def read(stage):
def write(stage, payload, inputs):
folder = PROJECT / f'stages/{stage}/r2'
folder = PROJECT / f'stages/{stage}/r3'
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)
body = dict(schema_version='1.1',project_id=PROJECT.name,stage_id=stage,revision=3,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')
@@ -81,8 +81,8 @@ if __name__ == '__main__':
'새 이웃과의 H···O 연결이 보이며 분자마다 연결 수·방향이 다르다. 영구 끈·정지 격자는 없다.',
'불규칙한 집단을 유지하며 분자 사이의 응집이 거시적 물의 뭉침에 기여함을 다음 경계 모형으로 연결한다.'
]),
(14,17,'Interface','bg-interface','실제 대표 Scene Interface. 3D 체적을 옆면·뒤쪽만 남긴 잘라낸 설명 모형, 카메라 (0,-14,6), ortho_scale=13. 색 경계에 고체막을 추가하지 않는다.',[
'아래쪽 청색 물 체적과 위쪽 공기 공간이 보이며 앞쪽 절개에 불규칙한 물 분자가 있다. 공기 이름은 추후 합성한다.',
(14,17,'Interface','bg-interface','실제 대표 Scene Interface. 실제 물 경계 x=[-2.85,2.85], y=[-0.85,1.35], z=[-3.15,0.80] 안에 전체 원자·결합을 포함하고 앞면·윗면을 연 절개 설명 모형, 카메라 (0,-14,6), ortho_scale=13. 색 경계에 고체막을 추가하지 않는다.',[
'아래쪽 청색 물 체적과 위쪽 공기 공간이 보이며 실제 청색 물 영역 안에 불규칙한 물 분자가 모두 포함된다. 공기 이름은 추후 합성한다.',
'물-공기 경계를 유지하고 안쪽 Liquid4 주변의 다양한 이웃이 보이도록 근접 강조를 시작한다.',
'Blender F2807에서 Liquid4 안쪽 강조고리 scale=1, Liquid1 표면 강조고리 scale=0.01. 안쪽은 위·아래·옆에 이웃 물 분자가 있다.',
'Blender F3051에서 안쪽 강조는 작아지고 Liquid1 표면 강조고리 scale=1. 표면보다 위 공기 쪽에는 액체 물 이웃을 배치하지 않는다; 별도 막은 없다.',
@@ -129,13 +129,13 @@ if __name__ == '__main__':
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)])
write('08-scenes',dict(scenes=scenes,connections=connections,preview=dict(scene_file=dict(path=f'projects/{PROJECT.name}/stages/08-scenes/r3/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-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)])
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',3)])
@@ -6,7 +6,7 @@ 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')
BASE = Path('projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3')
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')
@@ -37,6 +37,7 @@ def run(args, records):
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--kind',choices=['image','video'],required=True)
parser.add_argument('--cut',choices=['c07','c16','c18'])
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 []
@@ -45,13 +46,15 @@ if __name__=='__main__':
record.pop('stderr',None)
artifact=json.loads((ROOT/ARTIFACT).read_text(encoding='utf-8'))
for cut,scene,obj,start,end,boundary in PLAN:
if args.cut and args.cut!=cut:
continue
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)
rendered=run(common+['render','--mode','preview','--project',PROJECT,'--artifact',ARTIFACT,'--output-revision','3','--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':
@@ -1,29 +1,32 @@
"""없는 원본·잘못된 Scene·원자 수·부피를 실제 Blender로 거부하는지 검사한다."""
"""원본 부재·구조·물 영역 포함·부피 오류를 실제 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'
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3'
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):
def execute(args, expected, reason=None):
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 []))
if reason:
assert reason in result.stderr, (reason,result.stderr)
records.append(dict(command=command,exit_code=result.returncode,expected_exit_code=expected,matched_reason=reason,assertion_errors=[line for line in result.stderr.splitlines() if line.startswith('AssertionError:')]))
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'")]:
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'"),('invalid-containment',"bpy.data.objects['Liquid9_H1'].data.vertices[0].co.x=10")]:
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(fixture),'--out',str(BASE/'generated-source/invalid-result.json')],1,'물 영역 밖 또는 여백 부족' if name=='invalid-containment' else None)
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(BASE.parent/'r2/generated-source/preview.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')
(BASE/'model-test-results.json').write_text(json.dumps(dict(result='GREEN',negative_cases=6,positive_cases=1,executions=records),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('실제 실패 사례 6개 RED, 수정 원본 GREEN')
@@ -13,11 +13,13 @@ def read(path):
if __name__=='__main__':
a=read(PROJECT/'stages/08-scenes/r2/artifact.json')
b=read(PROJECT/'stages/09-entities/r2/artifact.json')
a=read(PROJECT/'stages/08-scenes/r3/artifact.json')
b=read(PROJECT/'stages/09-entities/r3/artifact.json')
schema=read(ROOT/'contracts/artifact.schema.json')
jsonschema.validate(a,schema)
jsonschema.validate(b,schema)
assert a["revision"]==b["revision"]==3
assert {"stage_id":"08-scenes","revision":3} in b["inputs"]
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
@@ -41,6 +43,12 @@ if __name__=='__main__':
preview=a['payload']['preview']
source=preview['scene_file']
assert source['verified'] and hashlib.sha256((ROOT/source['path']).read_bytes()).hexdigest()==source['sha256']
assert (ROOT/source['path']).is_relative_to(PROJECT/'stages/08-scenes/r3')
model=read(PROJECT/'stages/08-scenes/r3/model-check.json')
assert model['source_sha256']==source['sha256']
containment=model['water_containment']
assert containment['frames']==705 and containment['logical_frame_start']==2468 and containment['logical_frame_end']==3172
assert min(containment['minimum_axis_margin'])>=.03 and containment['minimum_projected_margin_px']>0
samples=preview['samples']
assert len(samples)==6 and len({s['id'] for s in samples})==6
assets=[dict(path=source['path'],sha256=source['sha256'])]
@@ -48,7 +56,7 @@ if __name__=='__main__':
for sample in samples:
media=sample['media']
path=ROOT/media['path']
assert path.is_relative_to(PROJECT/'stages/08-scenes/r2') and media['verified']
assert path.is_relative_to(PROJECT/'stages/08-scenes/r3') 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']
@@ -64,12 +72,12 @@ if __name__=='__main__':
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')
motion=read(PROJECT/'stages/08-scenes/r3/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')
(PROJECT/'stages/08-scenes/r3/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')
@@ -6,9 +6,71 @@ from pathlib import Path
import sys
import bpy
import bmesh
import numpy as np
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view
def containment(scene):
"""전체 프레임의 실제 mesh 정점을 물 영역·화면 윤곽과 대조한다."""
bpy.context.window.scene = scene
body = scene.objects['WaterCutawayBody']
deps = bpy.context.evaluated_depsgraph_get()
evaluated = body.evaluated_get(deps)
mesh = evaluated.to_mesh()
water = np.array([tuple(evaluated.matrix_world @ v.co) for v in mesh.vertices])
evaluated.to_mesh_clear()
low, high = water.min(axis=0), water.max(axis=0)
# 실제 물 표시 mesh에서 경계를 얻고 중심·별도 메타데이터만으로 판정하지 않는다.
origin = world_to_camera_view(scene,scene.camera,Vector((0,0,0)))
projection = np.array([tuple(world_to_camera_view(scene,scene.camera,Vector(b))-origin)[:2] for b in ((1,0,0),(0,1,0),(0,0,1))])
offset = np.array(tuple(origin)[:2])
screen = (water @ projection + offset) * (1080,1920)
points = sorted(set(map(tuple,screen)))
def cross(a,b,c):
return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
lower, upper = [], []
for chain, vertices in ((lower,points),(upper,reversed(points))):
for p in vertices:
while len(chain)>=2 and cross(chain[-2],chain[-1],p)<=0:
chain.pop()
chain.append(p)
hull = np.array(lower[:-1]+upper[:-1])
minimum = np.full(3,np.inf)
pixel_margin = float('inf')
vertex_count = 0
for logical in range(2468,3173):
scene.frame_set(logical+1)
deps = bpy.context.evaluated_depsgraph_get()
for obj in scene.objects:
role = obj.get('role')
if role not in ('atom','covalent','hydrogen_bond') or (role=='hydrogen_bond' and obj.scale.x<=.0011):
continue
evaluated = obj.evaluated_get(deps)
mesh = evaluated.to_mesh()
local = np.array([tuple(v.co) for v in mesh.vertices])
transform = np.array(evaluated.matrix_world)
vertices = local @ transform[:3,:3].T + transform[:3,3]
evaluated.to_mesh_clear()
margin = np.minimum(vertices-low,high-vertices).min(axis=0)
assert margin.min()>=.03, f'물 영역 밖 또는 여백 부족: F{logical} {obj.name}, 축 여백 {margin.tolist()}'
minimum = np.minimum(minimum,margin)
projected = (vertices @ projection + offset) * (1080,1920)
assert (projected.min(axis=0)>0).all() and (projected.max(axis=0)<(1080,1920)).all(), '분자 화면 잘림'
for a,b in zip(hull,np.roll(hull,-1,axis=0)):
edge = b-a
distance = (edge[0]*(projected[:,1]-a[1])-edge[1]*(projected[:,0]-a[0]))/np.linalg.norm(edge)
assert distance.min()>=0, f'파란 물 영역 투영 밖: F{logical} {obj.name}'
pixel_margin = min(pixel_margin,float(distance.min()))
vertex_count += len(vertices)
# 앞면과 윗면은 실제 열린 절개이며 물의 고체 껍질을 만들지 않는다.
assert len(body.data.polygons)==4, '물 절개 면 수 불일치'
for face in body.data.polygons:
points=np.array([tuple(body.matrix_world @ body.data.vertices[i].co) for i in face.vertices])
assert not np.allclose(points[:,1],low[1]) and not np.allclose(points[:,2],high[2]), '앞면·윗면 절개가 닫힘'
return dict(logical_frame_start=2468,logical_frame_end=3172,frames=705,water_bounds=[low.tolist(),high.tolist()],minimum_axis_margin=minimum.tolist(),minimum_projected_margin_px=pixel_margin,evaluated_vertices=vertex_count,mesh_roles=['atom','covalent','active_hydrogen_bond'],open_faces=['front','top'])
def verify(source):
assert source.is_file(), "원본 파일 없음"
bpy.ops.wm.open_mainfile(filepath=str(source))
@@ -52,6 +114,7 @@ def verify(source):
assert (pair.objects["PairB"].matrix_world.translation - before).length > .1, "실제 3D 이동 없음"
interface = bpy.data.scenes['Interface']
bpy.context.window.scene = interface
water_containment = containment(interface)
connection_sets, neighborhoods = [], []
for frame in (2807,2907,3051):
interface.frame_set(frame)
@@ -99,7 +162,7 @@ def verify(source):
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}
return {"water_containment":water_containment,"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__":