Compare commits

...
2 Commits
Author SHA1 Message Date
kimandClaude Code 2a72388f9c fix: 웹에서 mp4/mp3 재생이 아니라 다운로드로 떨어지던 문제
- /api/files 라우트가 영상·오디오를 attachment + application/octet-stream으로
  서빙해서 브라우저가 재생 대신 다운로드만 했던 것(2026-09-25 사용자 실측).
- 영상·오디오 MIME 추가(video/mp4, video/webm, audio/mpeg 등) + inline 처리.
- Range 요청(206 Partial) 지원 — 영상 시킹 시 처음부터 전체 재수신 안 함.
- 회귀 확인: PDF inline 뷰어, PPTX 강제 다운로드 기존 동작 유지.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:01:35 +09:00
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
6 changed files with 388 additions and 8 deletions
+5 -1
View File
@@ -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');
+30 -4
View File
@@ -494,6 +494,10 @@ const IMAGE_TYPES: Record<string, string> = {
// 보내면 브라우저가 latin1로 해석해 작업실 개요/readme.md의 한글이 전부 깨져 보였음.
'.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.csv': 'text/csv; charset=utf-8', '.html': 'text/html; charset=utf-8', '.md': 'text/plain; charset=utf-8',
// 영상·오디오 — octet-stream으로 보내면 브라우저가 재생을 포기하고 다운로드만 한다.
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
};
// Code-editor sessions whose files live in the browser's local folder (File System
@@ -2976,10 +2980,11 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
const ext = path.extname(filePath).toLowerCase();
const contentType = IMAGE_TYPES[ext] || 'application/octet-stream';
const filename = path.basename(filePath);
// Force download for non-image files (pptx, pdf, xlsx, docx, zip, etc.)
// PDF/txt/csv: inline (browser viewer); other binary files: attachment (force download)
const inlineExts = ['.pdf', '.txt', '.csv', '.md'];
const downloadExts = ['.pptx', '.xlsx', '.xls', '.docx', '.doc', '.zip', '.mp4', '.mp3'];
// Force download for non-previewable files (pptx, xlsx, docx, zip, etc.)
// PDF/txt/csv: inline (browser viewer); 영상·오디오(mp4/mp3/…)도 inline — attachment로
// 밀면 <video>/<audio> 재생 대신 무조건 다운로드로 떨어진다(2026-09-25 사용자 실측).
const inlineExts = ['.pdf', '.txt', '.csv', '.md', '.mp4', '.m4v', '.webm', '.mov', '.mkv', '.avi', '.mp3', '.m4a', '.wav', '.ogg'];
const downloadExts = ['.pptx', '.xlsx', '.xls', '.docx', '.doc', '.zip'];
if (inlineExts.includes(ext)) {
const encodedFilename = encodeURIComponent(filename);
res.setHeader('Content-Disposition', `inline; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`);
@@ -2989,9 +2994,30 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Accept-Ranges', 'bytes');
// Use createReadStream instead of sendFile for cross-platform reliability (Express 5)
try {
const stat = fs.statSync(filePath);
// Range 요청(영상/오디오 시킹) — 206 부분 응답. 없으면 재생은 돼도 시킹할 때마다 처음부터 전체 재수신.
const rangeReq = String((req as any).headers?.range || '');
const rm = /^bytes=(\d*)-(\d*)$/.exec(rangeReq);
if (rm && stat.size > 0) {
let start = rm[1] === '' ? Math.max(0, stat.size - parseInt(rm[2], 10)) : parseInt(rm[1], 10);
const end = (rm[1] !== '' && rm[2] !== '') ? Math.min(parseInt(rm[2], 10), stat.size - 1) : stat.size - 1;
if (!Number.isFinite(start) || start > end || start >= stat.size) {
res.status(416); res.setHeader('Content-Range', `bytes */${stat.size}`); return;
}
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
res.setHeader('Content-Length', end - start + 1);
const rs = fs.createReadStream(filePath, { start, end });
rs.on('error', (streamErr: any) => {
console.error('[files] stream error:', streamErr.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
});
rs.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
const stream = fs.createReadStream(filePath);
stream.on('error', (streamErr: any) => {
+4 -2
View File
@@ -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
View File
@@ -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);
+248
View File
@@ -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 */ }
}
}
+87
View File
@@ -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 */ } });
});