- 소스 없는 다운로드 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>
92 lines
5.5 KiB
TypeScript
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 */ } });
|
|
}); |