fix: scad_animate 카메라 자동 프레이밍 — autocenter/viewall 의존 제거

--autocenter --viewall이 오프스크린 CLI 렌더에서 모델을 프레임에
신뢰성 있게 못 담는다(실측: 구석에 작게 나오거나 아예 밖으로 나가
"노란 빈 화면" MP4가 됨). renderStlPng와 동일 방식으로, 대표 프레임
($t=0.5)을 STL로 컴파일해 실측 바운딩박스 중심+최대길이×3 거리의
명시적 김발 카메라를 계산해서 프레임마다 넘긴다. 사용자가 camera를
주면 그대로 쓰고, 프루브 컴파일 실패 시에만 옛 autocenter 폴백.
배경색도 기본 Cornfield(노란색 — "빈 화면"으로 오독됨) 대신
Tomorrow로 통일.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 21:23:36 +09:00
co-authored by Claude Code
parent 2ee133b183
commit b0a3d427aa
2 changed files with 22 additions and 3 deletions
+1 -1
View File
@@ -1235,7 +1235,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
output_path: { type: 'string', description: '결과 MP4를 저장할 워크스페이스 기준 상대경로(.mp4로 끝나야 함).' },
frames: { type: 'number', description: '(선택) 프레임 수 — 기본 24, 최대 120. $t는 0부터 (1-1/frames)까지 균등 간격.' },
fps: { type: 'number', description: '(선택) MP4 프레임레이트 — 기본 10(1초~2.4초 분량). 부드러운 동작이면 15~24.' },
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 --autocenter --viewall로 전체를 알아서 담는다. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 모델 실측(중간 프레임을 STL로 컴파일해 바운딩박스 측정) 기준으로 자동 프레이밍한다 — 굳이 줄 필요 없음. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
imgsize: { type: 'string', description: "(선택) 프레임 크기 'W,H' — 기본 '640,640'. MP4 인코딩 특성상 짝수만 유효(홀수는 자동으로 짝수로 내림)." },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — scad_to_stl과 동일(-D 전달).' },
},
+21 -2
View File
@@ -453,7 +453,7 @@ export const scadAnimateTool = {
output_path: { type: 'string', description: '결과 MP4를 저장할 워크스페이스 기준 상대경로(.mp4로 끝나야 함).' },
frames: { type: 'number', description: '(선택) 프레임 수 — 기본 24, 최대 120. $t는 0부터 (1-1/frames)까지 균등 간격.' },
fps: { type: 'number', description: '(선택) MP4 프레임레이트 — 기본 10(1초~2.4초 분량). 부드러운 동작이면 15~24.' },
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 --autocenter --viewall로 전체를 알아서 담는다. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
camera: { type: 'string', description: "(선택) OpenSCAD --camera 김발값 — 'tx,ty,tz,rx,ry,rz,dist' 형식(예: '0,0,50,70,0,315,480'). 생략하면 모델 실측(중간 프레임을 STL로 컴파일해 바운딩박스 측정) 기준으로 자동 프레이밍한다 — 굳이 줄 필요 없음. 고정 카메라에서 관절만 움직이는 게 보통 보기 좋다." },
imgsize: { type: 'string', description: "(선택) 프레임 크기 'W,H' — 기본 '640,640'. MP4 인코딩 특성상 짝수만 유효(홀수는 자동으로 짝수로 내림)." },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — scad_to_stl과 동일(-D 전달).' },
},
@@ -484,7 +484,7 @@ export const scadAnimateTool = {
const w = Math.max(2, Math.floor(Number(sizeM?.[1] || 640) / 2) * 2);
const h = Math.max(2, Math.floor(Number(sizeM?.[2] || 640) / 2) * 2);
// --camera 값은 OpenSCAD 문법 그대로 통과 — 숫자 7개 CSV인지 정도만 검사해 오타를 일찍 잡는다.
const cameraArg = String(args?.camera || '').trim();
let cameraArg = String(args?.camera || '').trim();
if (cameraArg && !/^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(cameraArg.replace(/\s+/g, ''))) {
return { success: false, error: 'camera는 "tx,ty,tz,rx,ry,rz,dist" 숫자 7개 형식이어야 합니다 (예: "0,0,50,70,0,315,480").' };
}
@@ -492,6 +492,24 @@ export const scadAnimateTool = {
// 프레임 저장소는 워크스페이스 밖(/tmp) — 성공/실패와 무관하게 finally에서 통째로 정리.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scad-anim-'));
try {
// 카메라 자동 결정 — --autocenter/--viewall은 오프스크린 CLI 렌더에서 모델을 프레임에
// 신뢰성 있게 못 담는다(2026-09-22 실측: 모델이 구석에 작게 나오거나 아예 밖으로 나가
// "노란 빈 화면" MP4가 됨). renderStlPng와 같은 방식으로, 대표 프레임($t=0.5)을 STL로
// 컴파일해 실측 바운딩박스 중심+거리로 명시적 김발 카메라를 계산한다. 애니메이션 중
// 관절이 벌어지는 만큼 여유를 두고 dist는 최대길이×3.
if (!cameraArg) {
const probeStl = path.join(tmpDir, 'probe.stl');
const probe = await runOpenscad(['-D', '$t=0.5', ...buildDefineArgs(args?.defines), '-o', probeStl, '--export-format=binstl', scadPath], 120_000);
if (probe.ok && fs.existsSync(probeStl) && fs.statSync(probeStl).size >= 84) {
try {
const bbox = readStlBBox(probeStl);
const center = [0, 1, 2].map(i => (bbox.min[i] + bbox.max[i]) / 2);
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
const dist = Math.max(50, maxExtent * 3);
cameraArg = `${center.map(n => n.toFixed(2)).join(',')},65,0,315,${dist.toFixed(1)}`;
} catch { /* 자동 프레이밍 실패 — 아래 폴백(--autocenter --viewall)로 렌더 시도 */ }
}
}
for (let i = 0; i < frames; i++) {
const t = (i / frames).toFixed(4);
const framePath = path.join(tmpDir, `frame_${String(i + 1).padStart(4, '0')}.png`);
@@ -500,6 +518,7 @@ export const scadAnimateTool = {
...buildDefineArgs(args?.defines),
...(cameraArg ? [`--camera=${cameraArg.replace(/\s+/g, '')}`] : ['--autocenter', '--viewall']),
'--imgsize=' + w + ',' + h,
'--colorscheme=Tomorrow',
'-o', framePath,
scadPath,
], 90_000);