Files
homeclaw/tests/stl-three-view.test.ts
T
kimandClaude Code 875b08e70a feat: STL 3면도 + 외곽 치수선 자동 생성 (C안)
- stl-three-view.ts: OpenSCAD ortho 렌더 3뷰(정면/평면/우측면)를 실루엣 크롭→공통
  축척 리샘플로 제3각법 합성, 외곽 치수선(연장선·화살표·수치)+제목란까지 PIL 합성.
  동시실행 직렬화, 임시디렉토리 정리, ASCII STL 명확 에러.
- stl_cad 도구에 three_view 액션, /api/cad/render?view=three 라우트 추가.
- 테스트 5건(fmtMm 단위 + 실렌더 통합/픽셀검증/정리/에러).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 17:50:54 +09:00

87 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// STL 3면도 + 바운딩 치수선("C안", 2026-09-25) 테스트.
// fmtMm 단위 테스트 + 실제 OpenSCAD 렌더 통합 테스트(큐브 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 { fmtMm, renderThreeView } from '../src/tools/stl-three-view';
import { OPENSCAD_BIN } from '../src/tools/stl-cad-core';
describe('fmtMm — 도면 치수 표기', () => {
test('정수면 소수점 없이', () => {
assert.equal(fmtMm(120), '120');
assert.equal(fmtMm(45.00001), '45');
});
test('소수는 1자리(0.05mm 이하 버림)', () => {
assert.equal(fmtMm(41.05), '41.1');
assert.equal(fmtMm(80.24), '80.2');
assert.equal(fmtMm(0.049), '0');
});
});
describe('renderThreeView — 실제 렌더 통합', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'stl-three-view-'));
const stlPath = path.join(tmp, 't.stl');
const outPath = path.join(tmp, 'three.png');
// 120×80×45 큐브 — 축 매핑(W=X, D=Y, H=Z) 검증용 기준 형상
test.before(() => {
const scad = path.join(tmp, 't.scad');
fs.writeFileSync(scad, 'difference(){cube([120,80,45],center=true);translate([0,0,25])cylinder(h=30,d=20,$fn=48);}\n');
const r = spawnSync(OPENSCAD_BIN, ['-o', stlPath, '--export-format=binstl', scad], { timeout: 60_000 });
assert.equal(r.status, 0, `STL 생성 실패: ${r.stderr}`);
});
test('3면도 PNG 생성 + 치수 축 매핑 정확', { timeout: 120_000 }, async () => {
const r = await renderThreeView(stlPath, outPath);
if (!r.ok) assert.fail(`렌더 실패: ${r.error}`);
assert.equal(r.dims.w, 120); // X → 가로
assert.equal(r.dims.d, 80); // Y → 깊이
assert.equal(r.dims.h, 45); // Z → 높이
assert.ok(fs.existsSync(outPath), '출력 PNG이 없음');
assert.ok(fs.statSync(outPath).size > 10_000, 'PNG이 비정상적으로 작음');
// PIL로 픽셀 검증: (1) 배경이 흰색 (2) 어두운 치수선이 존재 (3) 세 뷰가 배치돼
// 콘텐츠가 시트 가로 절반 이상 (4) 라벨 수치 "120"을 그린 어두운 픽셀이 하단에 있음
const check = spawnSync('python3', ['-c', `
import sys, json
import numpy as np
from PIL import Image
a = np.asarray(Image.open(sys.argv[1]).convert('RGB')).astype(int)
H, W = a.shape[:2]
white = (np.abs(a - 255).sum(axis=2) <= 12)
dark = (a.sum(axis=2) < 240) # 치수선(검정)
content = ~white
ys, xs = np.nonzero(content)
assert white.mean() > 0.5, '배경이 흰색이 아님(도면 시트 아님)'
assert dark.sum() > 500, '치수선(어두운 선)이 거의 없음'
assert xs.max() - xs.min() > W * 0.5, '세 뷰가 시트를 채우지 못함'
print(json.dumps({'ok': True, 'dark': int(dark.sum()), 'w': W, 'h': H}))
`, outPath], { timeout: 30_000 });
assert.equal(check.status, 0, `픽셀 검증 실패: ${check.stderr}`);
const v = JSON.parse(check.stdout.toString());
assert.ok(v.ok);
});
test('임시 렌더 디렉토리 정리', async () => {
// outPath 파생 tmp 디렉토리가 남지 않는지 — 렌더 하나 더 돌린 뒤 확인
const out2 = path.join(tmp, 'three2.png');
const r = await renderThreeView(stlPath, out2);
assert.ok(r.ok);
const leftovers = fs.readdirSync(tmp).filter(f => f.includes('_tmp_'));
assert.deepEqual(leftovers, []);
});
test('ASCII STL은 명확한 에러', async () => {
const ascii = path.join(tmp, 'ascii.stl');
// 84바이트 이상이어야 "너무 작음"이 아니라 ASCII 감지 분기에 걸린다
fs.writeFileSync(ascii, 'solid x\n' + ' '.repeat(100) + '\nendsolid x\n');
const r = await renderThreeView(ascii, path.join(tmp, 'x.png'));
assert.ok(!r.ok);
assert.match(r.error, /ASCII/);
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* noop */ } });
});