Files
homeclaw/tests/stl-transform.test.ts
kimandClaude Code 3761390d8e feat: stl_cad에 transform 액션 — 외부 STL도 채팅으로 크기변경/회전/거울/이동
- 소스 없는 다운로드 STL은 구멍/컷/불리언만 채팅 편집이 되고 크기·회전은 웹
  편집기에 의존했음(2026-09-25 사용자 요청으로 추가).
- transformStl: OpenSCAD import+변환 체인으로 재출력 — 법선·와인딩(거울 시 면
  방향)을 OpenSCAD가 올바르게 재생성. 회전·거울·배율은 bbox 중심 기준이라 모델이
  흩어지지 않음. 제자리 덮어쓰기(output_path 생략)도 임시파일→rename 원자적 처리.
  align_bottom 옵션으로 바닥 z=0 정렬.
- 함정 기록: OpenSCAD mirror([x,y,z])는 축이 아니라 법선 벡터 — [-1,1,1]은 x축
  거울이 아니라 기울어진 평면 반전(테스트로 발견·수정, 축 거울은 [1,0,0] 형태).
- 테스트 6건 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 19:30:48 +09:00

92 lines
5.5 KiB
TypeScript

// stl_cad "transform" 액션 테스트 (2026-09-25) — 소스 없는 외부 STL의 크기변경/회전/거울/이동.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { OPENSCAD_BIN, readStlBBox } from '../src/tools/stl-cad-core';
import { stlCadTool } from '../src/tools/stl-cad';
describe('stl_cad transform — 외부 STL 변형', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'stl-transform-'));
const stl = path.join(ws, 'part.stl');
test.before(() => {
// 비대칭 부품: 바닥 큐브(40×20×10) + 위에 원통(지름 10, 높이 5) — z:[0,15]
const scad = path.join(ws, 'f.scad');
fs.writeFileSync(scad, 'union(){cube([40,20,10]);translate([10,10,10])cylinder(h=5,d=10,$fn=32);}\n');
const r = spawnSync(OPENSCAD_BIN, ['-o', stl, '--export-format=binstl', scad], { timeout: 60_000 });
assert.equal(r.status, 0, `fixture 생성 실패: ${r.stderr}`);
});
test('배율 ×2 — 크기 2배, 중심 유지', { timeout: 120_000 }, async () => {
const before = readStlBBox(stl);
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'part.stl', scale: 2, output_path: 'part_x2.stl', _workspacePath: ws });
assert.ok(r.success, `실패: ${'error' in r ? r.error : ''}`);
const after = readStlBBox(path.join(ws, 'part_x2.stl'));
const sc = [after.size?.[0] ?? 0];
assert.ok(Math.abs((after.max[0] - after.min[0]) - (before.max[0] - before.min[0]) * 2) < 0.1, 'x 크기 2배 아님');
assert.ok(Math.abs((after.max[2] - after.min[2]) - (before.max[2] - before.min[2]) * 2) < 0.1, 'z 크기 2배 아님');
const bc = [(before.min[0] + before.max[0]) / 2, (before.min[2] + before.max[2]) / 2];
const ac = [(after.min[0] + after.max[0]) / 2, (after.min[2] + after.max[2]) / 2];
assert.ok(Math.abs(bc[0] - ac[0]) < 0.1 && Math.abs(bc[1] - ac[1]) < 0.1, '중심이 이동함(bbox 중심 기준 변환 아님)');
void sc;
});
test('Z축 90° 회전 — X/Y 폭이 맞바뀌고 중심 유지', { timeout: 120_000 }, async () => {
const before = readStlBBox(stl);
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'part.stl', rz: 90, output_path: 'part_rot.stl', _workspacePath: ws });
assert.ok(r.success);
const after = readStlBBox(path.join(ws, 'part_rot.stl'));
const w0 = before.max[0] - before.min[0], d0 = before.max[1] - before.min[1];
assert.ok(Math.abs((after.max[0] - after.min[0]) - d0) < 0.1, '회전 후 X폭이 옛 Y깊이와 다름');
assert.ok(Math.abs((after.max[1] - after.min[1]) - w0) < 0.1, '회전 후 Y깊이가 옛 X폭과 다름');
assert.ok(Math.abs((after.max[2] - after.min[2]) - (before.max[2] - before.min[2])) < 0.1, 'Z는 불변이어야 함');
});
test('거울 X — 중심 유지, 치수 보존', { timeout: 120_000 }, async () => {
const before = readStlBBox(stl);
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'part.stl', mirror: 'x', output_path: 'part_mir.stl', _workspacePath: ws });
assert.ok(r.success);
const after = readStlBBox(path.join(ws, 'part_mir.stl'));
assert.ok(Math.abs((after.max[0] - after.min[0]) - (before.max[0] - before.min[0])) < 0.1);
// 비대칭 원통(x=10 쪽)이 반대편(30 쪽)으로 갔는지 — z 상단 슬라이스는 어려우니 bbox 중심만 확인
assert.ok(Math.abs((after.min[0] + after.max[0]) / 2 - (before.min[0] + before.max[0]) / 2) < 0.1);
});
test('align_bottom — center=true 모델도 바닥이 0에 붙음', { timeout: 120_000 }, async () => {
const scad = path.join(ws, 'c.scad');
fs.writeFileSync(scad, 'cube([30,30,20],center=true);\n');
const c = path.join(ws, 'centered.stl');
const rr = spawnSync(OPENSCAD_BIN, ['-o', c, '--export-format=binstl', scad], { timeout: 60_000 });
assert.equal(rr.status, 0);
fs.copyFileSync(c, path.join(ws, 'centered_copy.stl'));
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'centered_copy.stl', align_bottom: true, _workspacePath: ws });
assert.ok(r.success);
const after = readStlBBox(path.join(ws, 'centered_copy.stl'));
assert.ok(Math.abs(after.min[2]) < 1e-4, `바닥이 0이 아님: ${after.min[2]}`);
assert.ok(Math.abs((after.max[2] - after.min[2]) - 20) < 0.1);
assert.match(String(r.stdout), /z=0에 붙였습니다/);
});
test('output_path 생략 시 제자리 덮어쓰기', { timeout: 120_000 }, async () => {
fs.copyFileSync(stl, path.join(ws, 'inplace.stl'));
const before = readStlBBox(path.join(ws, 'inplace.stl'));
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'inplace.stl', scale: 3, _workspacePath: ws });
assert.ok(r.success);
assert.match(String(r.stdout), /제자리 덮어씀/);
const after = readStlBBox(path.join(ws, 'inplace.stl'));
assert.ok(Math.abs((after.max[0] - after.min[0]) - (before.max[0] - before.min[0]) * 3) < 0.1);
// 임시파일 잔존 없음
assert.deepEqual(fs.readdirSync(ws).filter(f => f.includes('.transform-tmp')), []);
});
test('변환 내용이 없으면 에러', async () => {
const r = await stlCadTool.execute({ action: 'transform', stl_path: 'part.stl', _workspacePath: ws });
assert.ok(!r.success);
assert.match('error' in r ? r.error : '', /변환 내용이 없습니다/);
});
test.after(() => { try { fs.rmSync(ws, { recursive: true, force: true }); } catch { /* noop */ } });
});