- 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>
87 lines
3.9 KiB
TypeScript
87 lines
3.9 KiB
TypeScript
// 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 */ } });
|
||
}); |