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>
This commit is contained in:
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { isPathInsideDir } from '../../tools/image';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, applyBooleanOp, findStlFiles } from '../../tools/stl-cad-core';
|
||||
import { renderThreeView } from '../../tools/stl-three-view';
|
||||
|
||||
function resolveWorkspaceFile(workspacePath: string, relPath: string): string {
|
||||
const resolved = path.resolve(workspacePath, relPath);
|
||||
@@ -58,7 +59,10 @@ export function registerCadRoutes(app: Express): void {
|
||||
|
||||
const view = String(req.query.view || 'iso');
|
||||
const pngPath = path.join(path.dirname(stlPath), `.cad_render_${Date.now()}.png`);
|
||||
const result = await renderStlPng(stlPath, view, pngPath);
|
||||
// view=three: 제작용 3면도(외곽 치수선 포함, 흰 시트) — "C안"(2026-09-25).
|
||||
const result = view === 'three'
|
||||
? await (async () => { const r = await renderThreeView(stlPath, pngPath); return r.ok ? { ok: true as const } : { ok: false as const, detail: r.error }; })()
|
||||
: await renderStlPng(stlPath, view, pngPath);
|
||||
if (!result.ok) return res.status(500).json({ error: result.detail });
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
|
||||
@@ -162,7 +162,9 @@ export const VIEW_ROTATIONS: Record<string, [number, number, number]> = {
|
||||
right: [90, 0, 270],
|
||||
};
|
||||
|
||||
export async function renderStlPng(stlPath: string, view: string, outPngPath: string, imgSize = 900): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
// colorscheme: 기본 'Tomorrow'(어두운 배경). 3면도(stl-three-view.ts)는 'Cornfield'(밝은
|
||||
// 배경)를 써서 도면처럼 흰 시트 위에 놓는다. ortho 시점은 그대로라 기존 동작에 영향 없음.
|
||||
export async function renderStlPng(stlPath: string, view: string, outPngPath: string, imgSize = 900, colorscheme = 'Tomorrow'): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
let bbox: StlBBox;
|
||||
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; }
|
||||
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
|
||||
@@ -178,7 +180,7 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
|
||||
const scadPath = outPngPath.replace(/\.png$/i, '.scad');
|
||||
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
|
||||
|
||||
const scadArgs = ['-o', outPngPath, `--imgsize=${imgSize},${imgSize}`, '--colorscheme=Tomorrow'];
|
||||
const scadArgs = ['-o', outPngPath, `--imgsize=${imgSize},${imgSize}`, `--colorscheme=${colorscheme}`];
|
||||
if (view === 'iso') {
|
||||
scadArgs.push('--autocenter', '--viewall');
|
||||
} else {
|
||||
|
||||
+14
-1
@@ -9,6 +9,7 @@ import { autoRenderForScad } from './workshop-assembly-render.js';
|
||||
import { previewPngPath } from '../gateway/routes/workshop-layout.js';
|
||||
import { SCAD_RULES_SHORT, normalizeWorkshopScadPath, lintScadAssembly, formatLint } from './scad-conventions.js';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis } from './stl-cad-core.js';
|
||||
import { renderThreeView, fmtMm } from './stl-three-view.js';
|
||||
|
||||
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
|
||||
// 검토하거나, 위치를 지정해 구멍을 뚫을 수 있게 한다. 2026-09-20: 사용자가 "FreeCAD
|
||||
@@ -163,7 +164,7 @@ export const stlCadTool = {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['info', 'render', 'add_hole', 'cut', 'boolean'], description: 'info: 치수/바운딩박스/체적/밀폐(manifold)여부/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 지정 좌표에 원통형 구멍 뚫어 새 STL로 저장(stl_path). cut: 박스 모양으로 깎아냄(stl_path). boolean: 다른 STL과 합/차/교집합(stl_path + second_path).' },
|
||||
action: { type: 'string', enum: ['info', 'render', 'three_view', 'add_hole', 'cut', 'boolean'], description: 'info: 치수/바운딩박스/체적/밀폐(manifold)여부/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). three_view: 제작용 3면도(정면/평면/우측면, 외곽 치수선 표기)를 흰 도면 시트 PNG로 생성(stl_path). add_hole: 지정 좌표에 원통형 구멍 뚫어 새 STL로 저장(stl_path). cut: 박스 모양으로 깎아냄(stl_path). boolean: 다른 STL과 합/차/교집합(stl_path + second_path).' },
|
||||
stl_path: { type: 'string', description: '(info/render/add_hole/cut/boolean 필수) 워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_camera/esp32_top.stl"). ASCII STL은 지원 안 함.' },
|
||||
view: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'], description: '(render, 선택) 단일 보는 각도. 기본 iso. views를 주면 view는 무시됨.' },
|
||||
views: { type: 'array', items: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'] }, description: '(render, 선택) 여러 각도를 한 번에 렌더 — ["iso","top","front"] 식으로 주면 각각 PNG를 만들어 모두 돌려줌(모델이 형상 전체를 파악하기 좋음). view 단일보다 이쪽이 권장.' },
|
||||
@@ -243,6 +244,18 @@ export const stlCadTool = {
|
||||
return { success: true, stdout: mdParts.join('\n\n') };
|
||||
}
|
||||
|
||||
if (action === 'three_view') {
|
||||
// 제작용 3면도(외곽 치수선 포함) — "C안"(2026-09-25). 도면은 사진/ 폴더로.
|
||||
const pngPath = previewPngPath(workspacePath, stlPath, `three_${Date.now()}`);
|
||||
fs.mkdirSync(path.dirname(pngPath), { recursive: true });
|
||||
const r = await renderThreeView(stlPath, pngPath);
|
||||
if (!r.ok) return { success: false, error: `3면도 생성 실패: ${r.error}` };
|
||||
return {
|
||||
success: true,
|
||||
stdout: `3면도 생성 완료 — 가로 ${fmtMm(r.dims.w)} × 깊이 ${fmtMm(r.dims.d)} × 높이 ${fmtMm(r.dims.h)} mm (외곽 기준, ${r.seconds}초)\n\n${buildImageMarkdown(pngPath, workspacePath)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'add_hole') {
|
||||
const x = Number(args?.x), y = Number(args?.y), z = Number(args?.z);
|
||||
const diameter = Number(args?.diameter);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* STL 3면도(제3각법: 평면도 위, 정면도 아래, 우측면도 오른쪽) + 바운딩 치수선 자동 생성
|
||||
* (2026-09-25, "C안" — FreeCAD 전환 대신 OpenSCAD 체계 안에서 제작용 도면 최소치를 뽑는다).
|
||||
*
|
||||
* 범위(사용자 합의): 외곽 치수(가로/깊이/높이)만. 구멍 지름·공차는 제외 — 그건 FreeCAD
|
||||
* (TechDraw) 영역. 체계는 workshop-assembly-render.ts와 동일: OpenSCAD ortho 렌더 후
|
||||
* python3+PIL+numpy 로 합성한다.
|
||||
*
|
||||
* 동치축 보증: 세 뷰를 같은 ortho 카메라로 렌더하고, 실루엣을 크롭한 뒤 공통 축척
|
||||
* S(px/mm)로 통일해 리샘플한다 — OpenSCAD 카메라 산식을 코드에 복제하지 않아도 치수선이
|
||||
* 형상에 정확히 붙는다. 실루엣 배경 판별은 COMPOSE_SCRIPT과 같은 "모서리(2,2) 픽셀과의
|
||||
* 차이" 방식이라 colorscheme에 의존하지 않는다.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { readStlBBox, renderStlPng } from './stl-cad-core.js';
|
||||
|
||||
export interface ThreeViewDims {
|
||||
w: number; // X(mm)
|
||||
d: number; // Y(mm)
|
||||
h: number; // Z(mm)
|
||||
}
|
||||
|
||||
// 도면 치수 표기 — 정수면 소수점 없이, 아니면 소수 1자리(0.05mm 이하 버림).
|
||||
export function fmtMm(n: number): string {
|
||||
const r = Math.round(n * 10) / 10;
|
||||
return Number.isInteger(r) ? String(r) : r.toFixed(1);
|
||||
}
|
||||
|
||||
// ── PIL 합성 스크립트(python3 -c 임베드 — workshop-assembly-render.ts와 같은 방식) ──────
|
||||
// 입력 job: { out, dims:{w,d,h}, views:{front,top,right}, labels:{w,d,h}, title, subtitle }
|
||||
// 라벨(수치 문자열)까지 job으로 넘기는 이유: 이 스크립트가 TS 템플릿 리터럴 안에 있어서
|
||||
// Python 코드에 ${} 문자열을 끼워 넣으면 인터폴레이션과 충돌하기 때문.
|
||||
const COMPOSE_SCRIPT = `
|
||||
import sys, json
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
job = json.load(open(sys.argv[1]))
|
||||
dims = job['dims']; labels = job['labels']
|
||||
W, D, H = float(dims['w']), float(dims['d']), float(dims['h'])
|
||||
|
||||
def silhouette(p):
|
||||
im = Image.open(p).convert('RGB')
|
||||
a = np.asarray(im).astype(int)
|
||||
bg = a[2, 2]
|
||||
m = (np.abs(a - bg).sum(axis=2) > 20)
|
||||
ys, xs = np.nonzero(m)
|
||||
if len(xs) < 5:
|
||||
return None
|
||||
return im.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
|
||||
|
||||
crops = {}
|
||||
for key in ('front', 'top', 'right'):
|
||||
c = silhouette(job['views'][key])
|
||||
if c is None:
|
||||
print(json.dumps({'ok': False, 'error': 'view empty: ' + key}))
|
||||
sys.exit(0)
|
||||
crops[key] = c
|
||||
|
||||
# 공통 축척 S(px/mm) — 세 뷰+치수선이 시트 상한(1500x1200) 안에 들어오게.
|
||||
M, G, O = 72, 44, 30 # 바깥 여백 / 뷰 간격 / 치수선 오프셋
|
||||
SW = (1500 - 2 * M - G - 24) / max(W + D, 1e-6)
|
||||
SH = (1200 - 2 * M - G - 26 - 30 - 90) / max(D + H, 1e-6)
|
||||
S = min(SW, SH)
|
||||
|
||||
def size_mm(a, b):
|
||||
return max(2, int(round(a * S))), max(2, int(round(b * S)))
|
||||
|
||||
crops['front'] = crops['front'].resize(size_mm(W, H), Image.LANCZOS)
|
||||
crops['top'] = crops['top'].resize(size_mm(W, D), Image.LANCZOS)
|
||||
crops['right'] = crops['right'].resize(size_mm(D, H), Image.LANCZOS)
|
||||
fw, fh = crops['front'].size
|
||||
tw, th = crops['top'].size
|
||||
rw, rh = crops['right'].size
|
||||
|
||||
tx, ty = M, M
|
||||
fx, fy = M, int(M + th + G)
|
||||
rx, ry = int(M + fw + G), fy
|
||||
content_bottom = int(fy + fh + O + 30) # 치수선+수식 아래끝
|
||||
sheet_w = int(M + fw + G + rw + M)
|
||||
sheet_h = int(content_bottom + 90) # 90 = 제목란(구분선+제목+여백)
|
||||
sheet = Image.new('RGB', (sheet_w, sheet_h), 'white')
|
||||
|
||||
sheet.paste(crops['top'], (tx, ty))
|
||||
sheet.paste(crops['front'], (fx, fy))
|
||||
sheet.paste(crops['right'], (rx, ry))
|
||||
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
LINE = (34, 34, 34)
|
||||
TEXT = (20, 20, 20)
|
||||
CAP = (110, 110, 110)
|
||||
|
||||
def font(sz):
|
||||
for fp in ('/usr/share/fonts/truetype/nanum/NanumSquareRoundB.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'):
|
||||
try:
|
||||
return ImageFont.truetype(fp, sz)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
f_dim = font(20)
|
||||
f_cap = font(15)
|
||||
f_title = font(22)
|
||||
|
||||
def arrow(x, y, dx, dy):
|
||||
L = 9
|
||||
if dx:
|
||||
draw.polygon([(x, y), (x - dx * L, y + 4), (x - dx * L, y - 4)], fill=LINE)
|
||||
else:
|
||||
draw.polygon([(x, y), (x + 4, y - dy * L), (x - 4, y - dy * L)], fill=LINE)
|
||||
|
||||
def hdim(x0, x1, y, label):
|
||||
draw.line([(x0, y), (x1, y)], fill=LINE, width=2)
|
||||
arrow(x0, y, 1, 0); arrow(x1, y, -1, 0)
|
||||
bb = draw.textbbox((0, 0), label, font=f_dim)
|
||||
draw.text(((x0 + x1) / 2 - (bb[2] - bb[0]) / 2, y - bb[3] - 8), label, fill=TEXT, font=f_dim)
|
||||
|
||||
def vdim(x, y0, y1, label):
|
||||
draw.line([(x, y0), (x, y1)], fill=LINE, width=2)
|
||||
arrow(x, y0, 0, 1); arrow(x, y1, 0, -1)
|
||||
# 세로 치수 수치는 90도 회전해 치수선 왼쪽에 쓴다(흰 배경 tmp라 그대로 붙여도 된다)
|
||||
bb = draw.textbbox((0, 0), label, font=f_dim)
|
||||
tmp = Image.new('RGB', (bb[2] - bb[0] + 6, bb[3] - bb[1] + 6), 'white')
|
||||
ImageDraw.Draw(tmp).text((3 - bb[0], 3 - bb[1]), label, fill=TEXT, font=f_dim)
|
||||
tmp = tmp.rotate(90, expand=True)
|
||||
sheet.paste(tmp, (int(x - tmp.width - 8), int((y0 + y1) / 2 - tmp.height / 2)))
|
||||
|
||||
def ext(x, y0, y1):
|
||||
draw.line([(x, y0), (x, y1)], fill=(150, 150, 150), width=1)
|
||||
|
||||
def hext(y, x0, x1):
|
||||
draw.line([(x0, y), (x1, y)], fill=(150, 150, 150), width=1)
|
||||
|
||||
# 가로(X) — 정면도 아래
|
||||
y_w = int(fy + fh + O)
|
||||
ext(fx, fy + fh, y_w + 6); ext(fx + fw, fy + fh, y_w + 6)
|
||||
hdim(fx, fx + fw, y_w, labels['w'])
|
||||
# 깊이(Y) — 우측면도 아래(정면도와 아래변이 정렬돼 치수선이 한 줄로 이어진다)
|
||||
y_d = int(ry + rh + O)
|
||||
ext(rx, ry + rh, y_d + 6); ext(rx + rw, ry + rh, y_d + 6)
|
||||
hdim(rx, rx + rw, y_d, labels['d'])
|
||||
# 높이(Z) — 정면도 왼쪽(연장선은 뷰 모서리에서 치수선까지 가로로)
|
||||
x_h = fx - O
|
||||
hext(fy, x_h - 6, fx); hext(fy + fh, x_h - 6, fx)
|
||||
vdim(x_h, fy, fy + fh, labels['h'])
|
||||
|
||||
def caption(cx, y, text):
|
||||
bb = draw.textbbox((0, 0), text, font=f_cap)
|
||||
draw.text((cx - (bb[2] - bb[0]) / 2, y), text, fill=CAP, font=f_cap)
|
||||
|
||||
caption(tx + tw / 2, ty - 24, '평면도(위에서)')
|
||||
caption(fx + fw / 2, fy - 24, '정면도')
|
||||
caption(rx + rw / 2, ry - 24, '우측면도')
|
||||
|
||||
# 제목란 — 도면 하단(구분선 + 왼쪽 제목 + 오른쪽 보기용 외곽치수·단위)
|
||||
div_y = content_bottom + 14
|
||||
draw.line([(M, div_y), (int(sheet_w - M), div_y)], fill=(170, 170, 170), width=1)
|
||||
draw.text((M, div_y + 12), job.get('title', ''), fill=TEXT, font=f_title)
|
||||
sub = job.get('subtitle', '')
|
||||
bb = draw.textbbox((0, 0), sub, font=f_cap)
|
||||
draw.text((sheet_w - M - (bb[2] - bb[0]), div_y + 16), sub, fill=(90, 90, 90), font=f_cap)
|
||||
|
||||
sheet.save(job['out'])
|
||||
print(json.dumps({'ok': True, 'scale': round(S, 3), 'sheet': [sheet.width, sheet.height]}))
|
||||
`;
|
||||
|
||||
function runCompose(jobPath: string): Promise<{ ok: true; scale: number } | { ok: false; detail: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('python3', ['-c', COMPOSE_SCRIPT, jobPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '', err = '';
|
||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString('utf8'); if (out.length > 2000) out = out.slice(-2000); });
|
||||
proc.stderr?.on('data', (d: Buffer) => { err += d.toString('utf8'); if (err.length > 3000) err = err.slice(-3000); });
|
||||
const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 60_000);
|
||||
proc.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, detail: String(e.message || e) }); });
|
||||
proc.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const r = JSON.parse(out.trim().split('\n').pop() || '{}');
|
||||
if (r.ok) return resolve({ ok: true, scale: Number(r.scale) || 0 });
|
||||
return resolve({ ok: false, detail: String(r.error || '합성 실패') });
|
||||
} catch {
|
||||
resolve({ ok: false, detail: err.split('\n').filter(Boolean).slice(-4).join(' ') || '합성 스크립트 실패' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface ThreeViewResult {
|
||||
ok: true;
|
||||
outPath: string;
|
||||
dims: ThreeViewDims;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
// 조립 렌더와 같은 이유(CPU·tmp 충돌 방지)로 동시 실행 하나로 직렬화.
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function renderThreeView(stlPath: string, outPngPath: string): Promise<ThreeViewResult | { ok: false; error: string }> {
|
||||
const run = chain.then(() => doRender(stlPath, outPngPath));
|
||||
chain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function doRender(stlPath: string, outPngPath: string): Promise<ThreeViewResult | { ok: false; error: string }> {
|
||||
const t0 = Date.now();
|
||||
let dims: ThreeViewDims;
|
||||
try {
|
||||
const bb = readStlBBox(stlPath);
|
||||
dims = { w: bb.max[0] - bb.min[0], d: bb.max[1] - bb.min[1], h: bb.max[2] - bb.min[2] };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: String(e?.message || e) };
|
||||
}
|
||||
if (!(dims.w > 0 && dims.d > 0 && dims.h > 0)) return { ok: false, error: '모델이 비어있거나 치수를 잴 수 없습니다.' };
|
||||
|
||||
fs.mkdirSync(path.dirname(outPngPath), { recursive: true });
|
||||
const tmpDir = outPngPath.replace(/\.png$/i, `_tmp_${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const views: Record<string, string> = {};
|
||||
try {
|
||||
for (const v of ['front', 'top', 'right'] as const) {
|
||||
const p = path.join(tmpDir, `${v}.png`);
|
||||
const r = await renderStlPng(stlPath, v, p, 900, 'Cornfield');
|
||||
if (!r.ok) return { ok: false, error: `렌더 실패(${v}): ${r.detail}` };
|
||||
views[v] = p;
|
||||
}
|
||||
|
||||
const jobPath = path.join(tmpDir, 'job.json');
|
||||
fs.writeFileSync(jobPath, JSON.stringify({
|
||||
out: outPngPath,
|
||||
dims,
|
||||
views,
|
||||
labels: { w: fmtMm(dims.w), d: fmtMm(dims.d), h: fmtMm(dims.h) },
|
||||
title: `3면도 — ${path.basename(stlPath)}`,
|
||||
subtitle: `가로 ${fmtMm(dims.w)} × 깊이 ${fmtMm(dims.d)} × 높이 ${fmtMm(dims.h)} mm · 치수는 외곽 기준`,
|
||||
}));
|
||||
const compose = await runCompose(jobPath);
|
||||
if (!compose.ok) return { ok: false, error: `도면 합성 실패: ${compose.detail}` };
|
||||
if (!fs.existsSync(outPngPath) || fs.statSync(outPngPath).size < 100) {
|
||||
return { ok: false, error: '합성은 끝났는데 도면 파일이 비정상입니다.' };
|
||||
}
|
||||
return { ok: true, outPath: outPngPath, dims, seconds: Math.round((Date.now() - t0) / 100) / 10 };
|
||||
} finally {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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 */ } });
|
||||
});
|
||||
Reference in New Issue
Block a user