feat: 부품 바닥 z=0 규칙 — 컴파일 자동 정렬 + 편집기 정렬 버튼

- 계기: 로봇팔 부품들이 center=true 모델링이라 바닥이 z<0으로 내려가 편집기에서
  파묻혀 보이고 좌표 감각이 어긋남(2026-09-25 사용자 지적, CAD 전부 실측 확인).
- .scad 작성 규칙에 7번 추가: 인쇄용 부품 바닥은 z=0(프롬프트 규칙문+SHORT).
- scad_to_stl/compile: 컴파일 후 bbox min z<-0.05면 바이너리 z 평행이동으로
  자동 정렬하고 안내 문구 출력(translateStlZInPlace, 원자적 재기록).
- STL 편집기에 '바닥 z=0' 버튼 — 외부 STL도 처리(현재 회전·배율 상태의
  월드 bbox 최저점을 0으로). 테스트 3건 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 18:58:14 +09:00
co-authored by Claude Code
parent 7e22a3074f
commit 1125f3afb9
5 changed files with 132 additions and 2 deletions
+2 -1
View File
@@ -21,11 +21,12 @@ export const SCAD_ASSEMBLY_RULES = [
'4) 인쇄용 부품 export가 필요하면 조립 블록 뒤에 `else if (PART == "부품이름") …` 분기를 추가한다.',
'5) 각 모듈 바로 위 주석 한 줄은 그 부품의 한국어 이름(22자 이내, 마침표 없이) — 조립 설명서 부품 이름으로 쓰인다.',
'6) 새 .scad 소스는 workshop/<프로젝트id>/SCAD/ 에, STL 출력은 CAD/ 에 저장한다.',
'7) 인쇄용 부품의 바닥은 z=0 평면에 붙인다 — center=true 대신 `translate([0,0,h/2]) cube([w,d,h])` 식으로 바닥 기준으로 놓는다(부품 모듈 기준, 조립 자세 asm_ 모듈은 예외). 컴파일러가 바닥이 -인 걸 감지하면 자동으로 z=0 올림하니 출력에 "-로 정렬됨" 안내가 보이면 소스를 고칠 것.',
].join('\n');
// 도구 설명(write_scad)용 한 줄 요약
export const SCAD_RULES_SHORT =
'조립체(부품 3개 이상)면 부품마다 조립 자세 모듈 asm_<이름>()(안에서 translate/rotate)을 만들고 파일 끝에 `PART = "all"; if (PART == "all") { asm_a(); asm_b(); … }` 조립 블록(모듈 호출만, 조립 순서대로)을 둘 것 — 조립 설명서가 이 블록을 읽는다. 참고용 형상은 ref_ 접두어. 저장 후 규칙 위반이 있으면 안내가 나오니 그대로 고칠 것.';
'조립체(부품 3개 이상)면 부품마다 조립 자세 모듈 asm_<이름>()(안에서 translate/rotate)을 만들고 파일 끝에 `PART = "all"; if (PART == "all") { asm_a(); asm_b(); … }` 조립 블록(모듈 호출만, 조립 순서대로)을 둘 것 — 조립 설명서가 이 블록을 읽는다. 부품 형상의 바닥은 z=0에 붙일 것(center=true 금지 — 컴파일러가 자동으로 z=0 올림). 참고용 형상은 ref_ 접두어. 저장 후 규칙 위반이 있으면 안내가 나오니 그대로 고칠 것.';
// 프로젝트 안의 새 .scad는 SCAD/ 로: workshop/<id>/파일.scad, workshop/<id>/CAD/파일.scad, workshop/<id>/print/파일.scad → workshop/<id>/SCAD/파일.scad.
// 사용자가 직접 만든 하위 폴더(예: workshop/<id>/실험/x.scad)나 이미 SCAD/ 인 경로는 그대로 둔다.
+28
View File
@@ -152,6 +152,34 @@ export function readStlBBox(filePath: string): StlBBox {
return { min, max, triangleCount };
}
// 모든 정점의 z에 dz를 더해 원자적으로 다시 쓴다 — "부품 바닥 z=0" 정렬용(2026-09-25).
// 평행이동이라 법선벡터는 방향이 변하지 않으므로 그대로 둔다. ASCII STL은 readStlBBox와
// 같은 크기 공식 검사로 걸러진다.
export function translateStlZInPlace(stlPath: string, dz: number): { ok: true } | { ok: false; detail: string } {
try {
const buf = fs.readFileSync(stlPath);
if (buf.length < 84) return { ok: false, detail: 'STL 파일이 너무 작거나 손상되었습니다.' };
const triangleCount = buf.readUInt32LE(80);
if (buf.length !== 84 + triangleCount * 50) return { ok: false, detail: 'ASCII STL이거나 손상된 바이너리 STL — z 정렬은 바이너리 STL만 지원합니다.' };
let offset = 84;
for (let i = 0; i < triangleCount; i++) {
offset += 12; // 법선벡터 스킵
for (let v = 0; v < 3; v++) {
const off = offset + 8; // 정점의 z float
buf.writeFloatLE(buf.readFloatLE(off) + dz, off);
offset += 12;
}
offset += 2; // attribute byte count
}
const tmp = stlPath + '.zshift-tmp';
fs.writeFileSync(tmp, buf);
fs.renameSync(tmp, stlPath);
return { ok: true };
} catch (e: any) {
return { ok: false, detail: String(e?.message || e) };
}
}
// 카메라 회전값(OpenSCAD 컨벤션: rot=(0,0,0)이 -Z 방향을 내려보는 탑뷰) — 오늘 렌더해보며 확인함.
export const VIEW_ROTATIONS: Record<string, [number, number, number]> = {
top: [0, 0, 0],
+14 -1
View File
@@ -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 } from './stl-cad-core.js';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis, translateStlZInPlace } from './stl-cad-core.js';
import { renderThreeView, fmtMm } from './stl-three-view.js';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
@@ -147,6 +147,19 @@ async function compileScadToStl(opts: {
const partsLine = (availableParts.length && !part) ? `\n- 이 파일의 PART 부품: ${availableParts.join(', ')}` : '';
let stdout = `**${path.basename(outPath)}** 생성됨${partLine}\n${formatAnalysisBrief(analysis)}\n- 저장 위치: ${outPathArg}${partsLine}`;
// 작성 규칙(2026-09-25): 인쇄용 부품의 바닥은 z=0. 컴파일 결과가 -로 내려가 있으면(주로
// center=true 모델링) 자동으로 올려 붙이고 그 사실을 알린다 — 좌표 감각 유지 + 편집기에서
// 부품이 바닥에 파묻혀 보이는 문제 방지. 조립체(PART=all)를 통째로 컴파일한 경우에도
// 베드 위로 올리는 건 출력용으로는 항상 옳다.
if (analysis.min[2] < -0.05) {
const shifted = translateStlZInPlace(outPath, -analysis.min[2]);
if (shifted.ok) {
stdout += `\n- ⚠️ 부품 바닥이 z=${analysis.min[2].toFixed(1)}mm로 내려가 있어 z=0에 붙여 저장했습니다(작성 규칙: 부품 바닥 z=0). .scad에서 center=true로 모델링했다면 앞으로 바닥 기준으로 쓰세요.`;
} else {
stdout += `\n- ⚠️ 부품 바닥이 z=${analysis.min[2].toFixed(1)}mm로 내려가 있습니다(자동 정렬 실패: ${shifted.detail}).`;
}
}
if (opts.preview) {
const pngPath = previewPngPath(workspacePath, outPath, `iso_${Date.now()}`); // 프로젝트 STL이면 사진/ 폴더
fs.mkdirSync(path.dirname(pngPath), { recursive: true });
+76
View File
@@ -0,0 +1,76 @@
// "부품 바닥 z=0" 기능 테스트 (2026-09-25).
// ① translateStlZInPlace — 바이너리 STL z 평행이동(원자적 재기록)
// ② compileScadToStl 자동 정렬 — center=true 소스도 컴파일하면 바닥이 z=0에 붙는지
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, translateStlZInPlace } from '../src/tools/stl-cad-core';
describe('translateStlZInPlace — STL z 평행이동', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'stl-zalign-'));
const stl = path.join(tmp, 'part.stl');
test.before(() => {
// center=true 큐브(120×80×45) — z가 [-22.5, 22.5]로 내려가 있는 전형적인 케이스
const scad = path.join(tmp, 't.scad');
fs.writeFileSync(scad, 'cube([120,80,45],center=true);\n');
const r = spawnSync(OPENSCAD_BIN, ['-o', stl, '--export-format=binstl', scad], { timeout: 60_000 });
assert.equal(r.status, 0, `STL 생성 실패: ${r.stderr}`);
});
test('z=+22.5 이동 시 바닥이 0에 붙고 x/y는 불변', () => {
const before = readStlBBox(stl);
assert.ok(Math.abs(before.min[2] - (-22.5)) < 1e-4);
const r = translateStlZInPlace(stl, 22.5);
assert.ok(r.ok, `이동 실패: ${'detail' in r ? r.detail : ''}`);
const after = readStlBBox(stl);
assert.ok(Math.abs(after.min[2]) < 1e-4, `바닥이 0이 아님: ${after.min[2]}`);
assert.ok(Math.abs(after.max[2] - 45) < 1e-4);
assert.equal(after.triangleCount, before.triangleCount, '삼각형 수가 변함');
// x/y 불변
assert.ok(Math.abs(after.min[0] - before.min[0]) < 1e-4 && Math.abs(after.max[1] - before.max[1]) < 1e-4);
// 임시파일 없음(원자적 교체)
assert.deepEqual(fs.readdirSync(tmp).filter(f => f.includes('.zshift-tmp')), []);
});
test('ASCII STL은 명확한 에러', () => {
const ascii = path.join(tmp, 'ascii.stl');
fs.writeFileSync(ascii, 'solid x\n' + ' '.repeat(100) + '\nendsolid x\n');
const r = translateStlZInPlace(ascii, 1);
assert.ok(!r.ok);
assert.match('detail' in r ? r.detail : '', /ASCII/);
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* noop */ } });
});
describe('compileScadToStl — 컴파일 후 바닥 자동 z=0 정렬', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scad-compile-'));
const ws = tmp; // 워크스페이스 = 임시 디렉토리
test('center=true 부품도 컴파일 결과 바닥이 z=0 + 안내 문구', { timeout: 120_000 }, async () => {
const scad = path.join(ws, 'SCAD', 'bracket.scad');
fs.mkdirSync(path.dirname(scad), { recursive: true });
// 규칙을 일부러 어기는 소스(center=true) — 컴파일러가 잡아 올려야 한다
fs.writeFileSync(scad, 'PART="part";\nmodule bracket(){difference(){cube([60,40,20],center=true);translate([0,0,50])cylinder(h=20,d=6,$fn=24);}}\nif(PART=="part")bracket();\n');
const { compileScadToStl } = await import('../src/tools/stl-cad');
// compileScadToStl은 export가 안 된 내부 함수 — stl_cad 도구의 compile 액션으로 검증
const { stlCadTool } = await import('../src/tools/stl-cad');
const r = await stlCadTool.execute({
action: 'compile',
scad_path: 'SCAD/bracket.scad',
output_path: 'CAD/bracket.stl',
_workspacePath: ws,
});
assert.ok(r.success, `컴파일 실패: ${'error' in r ? r.error : ''}`);
assert.match(String(r.stdout), /z=0/, '자동 정렬 안내가 없음');
const bb = readStlBBox(path.join(ws, 'CAD', 'bracket.stl'));
assert.ok(Math.abs(bb.min[2]) < 1e-4, `바닥이 0이 아님: ${bb.min[2]}`);
assert.ok(Math.abs(bb.max[2] - 20) < 1e-4, `높이가 20이 아님: ${bb.max[2]}`);
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* noop */ } });
});
+12
View File
@@ -253,6 +253,16 @@
mesh.position.set(-cx, -cz, cy); // native 중심을 원점으로 → three position = -중심(원본 프레임)
refreshPanel();
}
function dropToBed() {
// 바닥 z=0 정렬(2026-09-25) — 원본 STL이 center=true로 만들어져 z<0까지 내려가 있을 때.
// 외부 STL도 그대로 처리: 현재 월드 bbox 최저점(three Y)을 0으로 — 회전·배율 상태도 반영.
if (!mesh) return;
mesh.updateMatrixWorld(true);
tmpBox.setFromObject(mesh);
if (!Number.isFinite(tmpBox.min.y)) return;
mesh.position.y += -tmpBox.min.y;
refreshPanel();
}
// ── 구멍 위치 마커 ────────────────────────────────────────────────────
// hole 입력(native x,y,z) → three 마커 위치 (x, z, -y).
@@ -483,6 +493,7 @@
<button class="ce-mini" onclick="window.__cadMirror('y')">거울Y</button>
<button class="ce-mini" onclick="window.__cadMirror('z')">거울Z</button>
<button class="ce-mini" onclick="window.__cadCenter()">중앙정렬</button>
<button class="ce-mini" onclick="window.__cadBed()">바닥 z=0</button>
</div>
</div>
@@ -632,6 +643,7 @@
window.__cadFlip = flipAxis;
window.__cadMirror = mirrorAxis;
window.__cadCenter = centerModel;
window.__cadBed = dropToBed;
window.__cadPick = () => {
if (!mesh) { setErr('모델을 먼저 로드하세요.'); return; }
pickMode = true;