feat: stl_cad에 transform 액션 — 외부 STL도 채팅으로 크기변경/회전/거울/이동
- 소스 없는 다운로드 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>
This commit is contained in:
@@ -180,6 +180,53 @@ export function translateStlZInPlace(stlPath: string, dz: number): { ok: true }
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 STL을 크기변경·회전·거울·이동해 재출력(2026-09-25 "transform" 액션) — 소스 없는
|
||||
// 외부 STL(다운로드 파일)도 채팅에서 편집할 수 있게. 회전·거울·배율은 부품 bbox 중심
|
||||
// 기준으로 먹힌다(원점 기준이 아님 — 모델이 화면 밖으로 흩어지지 않게). 법선·와인딩은
|
||||
// OpenSCAD가 재출력하면서 올바르게 다시 만든다(거울 시 면 방향 뒤집힘 포함).
|
||||
export async function transformStl(opts: {
|
||||
stlPath: string; outPath: string;
|
||||
scale?: number; rx?: number; ry?: number; rz?: number;
|
||||
mirror?: '' | 'x' | 'y' | 'z';
|
||||
dx?: number; dy?: number; dz?: number;
|
||||
}): Promise<{ ok: true; bbox: StlBBox } | { ok: false; detail: string }> {
|
||||
const { stlPath, outPath } = opts;
|
||||
const scale = opts.scale && opts.scale > 0 ? opts.scale : 1;
|
||||
const rx = opts.rx || 0, ry = opts.ry || 0, rz = opts.rz || 0;
|
||||
const mirror = opts.mirror || '';
|
||||
const dx = opts.dx || 0, dy = opts.dy || 0, dz = opts.dz || 0;
|
||||
let bbox: StlBBox;
|
||||
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; }
|
||||
const cx = (bbox.min[0] + bbox.max[0]) / 2;
|
||||
const cy = (bbox.min[1] + bbox.max[1]) / 2;
|
||||
const cz = (bbox.min[2] + bbox.max[2]) / 2;
|
||||
const chain: string[] = [];
|
||||
if (dx || dy || dz) chain.push(`translate([${dx},${dy},${dz}])`);
|
||||
chain.push(`translate([${cx},${cy},${cz}])`); // 중심을 원점으로 → 변환 → 원위치
|
||||
if (rx || ry || rz) chain.push(`rotate([${rx},${ry},${rz}])`);
|
||||
if (mirror) chain.push(`mirror([${mirror === 'x' ? '1,0,0' : mirror === 'y' ? '0,1,0' : '0,0,1'}])`);
|
||||
if (scale !== 1) chain.push(`scale([${scale},${scale},${scale}])`);
|
||||
chain.push(`translate([${-cx},${-cy},${-cz}])`);
|
||||
const body = `${chain.join(' ')}import(${JSON.stringify(stlPath)});\n`;
|
||||
// 제자리 덮어쓰기(outPath === stlPath)일 때 import 입력과 출력이 같은 파일이 되지 않게
|
||||
// 항상 임시 STL로 뽑고 rename으로 교체한다(원자적).
|
||||
const tmpOut = outPath.replace(/\.stl$/i, `.transform-tmp-${Date.now()}.stl`);
|
||||
const scadPath = tmpOut.replace(/\.stl$/i, '.scad');
|
||||
fs.writeFileSync(scadPath, body);
|
||||
fs.mkdirSync(path.dirname(tmpOut), { recursive: true });
|
||||
const render = await runOpenscad(['-o', tmpOut, '--export-format=binstl', scadPath], 120_000);
|
||||
try { fs.unlinkSync(scadPath); } catch { /* noop */ }
|
||||
if (!render.ok) {
|
||||
try { fs.unlinkSync(tmpOut); } catch { /* noop */ }
|
||||
return { ok: false, detail: render.detail };
|
||||
}
|
||||
if (!fs.existsSync(tmpOut) || fs.statSync(tmpOut).size < 100) {
|
||||
return { ok: false, detail: '변환은 끝났는데 결과 STL 파일이 비정상입니다.' };
|
||||
}
|
||||
fs.renameSync(tmpOut, outPath);
|
||||
return { ok: true, bbox: readStlBBox(outPath) };
|
||||
}
|
||||
|
||||
// 카메라 회전값(OpenSCAD 컨벤션: rot=(0,0,0)이 -Z 방향을 내려보는 탑뷰) — 오늘 렌더해보며 확인함.
|
||||
export const VIEW_ROTATIONS: Record<string, [number, number, number]> = {
|
||||
top: [0, 0, 0],
|
||||
|
||||
+45
-3
@@ -8,7 +8,7 @@ import { buildImageMarkdown, isPathInsideDir } from './image.js';
|
||||
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, translateStlZInPlace } from './stl-cad-core.js';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis, translateStlZInPlace, transformStl } from './stl-cad-core.js';
|
||||
import { renderThreeView, fmtMm } from './stl-three-view.js';
|
||||
|
||||
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
|
||||
@@ -177,7 +177,7 @@ export const stlCadTool = {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
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).' },
|
||||
action: { type: 'string', enum: ['info', 'render', 'three_view', 'add_hole', 'cut', 'boolean', 'transform'], 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). transform: 크기변경/회전/거울/이동(stl_path, output_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 단일보다 이쪽이 권장.' },
|
||||
@@ -193,7 +193,16 @@ export const stlCadTool = {
|
||||
d: { type: 'number', description: '(cut 필수) 박스 깊이 Z(mm)' },
|
||||
op: { type: 'string', enum: ['union', 'difference', 'intersection'], description: '(boolean 필수) 합집합/차집합(stl_path에서 second_path를 깎아냄)/교집합. 두 STL은 원래 좌표계에서 겹쳐짐(같은 기준으로 설계된 부품에 맞음).' },
|
||||
second_path: { type: 'string', description: '(boolean 필수) 두 번째 STL의 워크스페이스 기준 상대경로.' },
|
||||
output_path: { type: 'string', description: '(add_hole/cut/boolean 필수) 결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함)' },
|
||||
scale: { type: 'number', description: '(transform, 선택) 균일 배율. 기본 1' },
|
||||
rx: { type: 'number', description: '(transform, 선택) X축 회전(도). 회전·거울·배율은 부품 bbox 중심 기준으로 먹는다' },
|
||||
ry: { type: 'number', description: '(transform, 선택) Y축 회전(도)' },
|
||||
rz: { type: 'number', description: '(transform, 선택) Z축 회전(도)' },
|
||||
mirror: { type: 'string', enum: ['x', 'y', 'z'], description: '(transform, 선택) 해당 축으로 거울 반전' },
|
||||
dx: { type: 'number', description: '(transform, 선택) X 이동(mm)' },
|
||||
dy: { type: 'number', description: '(transform, 선택) Y 이동(mm)' },
|
||||
dz: { type: 'number', description: '(transform, 선택) Z 이동(mm)' },
|
||||
align_bottom: { type: 'boolean', description: '(transform, 선택) true면 변환 후 바닥을 z=0에 붙인다' },
|
||||
output_path: { type: 'string', description: '(add_hole/cut/boolean 필수, transform 선택) 결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함). transform에서 생략하면 stl_path 파일을 제자리 덮어쓴다.' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -269,6 +278,39 @@ export const stlCadTool = {
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'transform') {
|
||||
const scale = Number.isFinite(Number(args?.scale)) && Number(args.scale) > 0 ? Number(args.scale) : 1;
|
||||
const rx = Number(args?.rx) || 0, ry = Number(args?.ry) || 0, rz = Number(args?.rz) || 0;
|
||||
const mirror = (['x', 'y', 'z'].includes(args?.mirror) ? args.mirror : '') as '' | 'x' | 'y' | 'z';
|
||||
const dx = Number(args?.dx) || 0, dy = Number(args?.dy) || 0, dz = Number(args?.dz) || 0;
|
||||
const alignBottom = args?.align_bottom === true;
|
||||
if (scale === 1 && !rx && !ry && !rz && !mirror && !dx && !dy && !dz && !alignBottom) {
|
||||
return { success: false, error: '변환 내용이 없습니다 — scale, rx/ry/rz(도), mirror(x/y/z), dx/dy/dz(mm), align_bottom 중 하나 이상 지정하세요.' };
|
||||
}
|
||||
const outPathArg = String(args?.output_path || '').trim();
|
||||
let outPath: string;
|
||||
if (outPathArg) {
|
||||
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
|
||||
if (!/\.stl$/i.test(outPath)) return { success: false, error: 'output_path는 .stl로 끝나야 합니다.' };
|
||||
} else {
|
||||
outPath = stlPath; // 제자리 덮어쓰기 — transformStl이 임시파일→rename으로 원자적 처리
|
||||
}
|
||||
const result = await transformStl({ stlPath, outPath, scale, rx, ry, rz, mirror, dx, dy, dz });
|
||||
if (!result.ok) return { success: false, error: `변환 실패: ${result.detail}` };
|
||||
const done: string[] = [];
|
||||
if (scale !== 1) done.push(`배율 ×${scale}`);
|
||||
if (rx || ry || rz) done.push(`회전 (${rx}°, ${ry}°, ${rz}°)`);
|
||||
if (mirror) done.push(`거울 ${mirror}축`);
|
||||
if (dx || dy || dz) done.push(`이동 (${dx}, ${dy}, ${dz})`);
|
||||
let stdout = `변환 완료: ${done.join(', ')}\n결과 bbox: X[${fmtMm(result.bbox.min[0])}, ${fmtMm(result.bbox.max[0])}] Y[${fmtMm(result.bbox.min[1])}, ${fmtMm(result.bbox.max[1])}] Z[${fmtMm(result.bbox.min[2])}, ${fmtMm(result.bbox.max[2])}] mm`;
|
||||
if (alignBottom && result.bbox.min[2] < -0.05) {
|
||||
const sh = translateStlZInPlace(outPath, -result.bbox.min[2]);
|
||||
if (sh.ok) stdout += '\n바닥을 z=0에 붙였습니다.';
|
||||
}
|
||||
stdout += outPathArg ? `\n결과: ${outPathArg}` : `\n(제자리 덮어씀: ${args?.stl_path})`;
|
||||
return { success: true, stdout };
|
||||
}
|
||||
|
||||
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,92 @@
|
||||
// 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 */ } });
|
||||
});
|
||||
Reference in New Issue
Block a user