배경: 테스트 프로젝트 로봇팔은 부품이 로컬 좌표 모듈이고 조립 자세를 robot_arm() 하나가 translate/rotate로 만드는 구조라, 조립 초안이 전체+하위 모듈을 중복으로 잡고 하위 모듈이 원점에 그려져 단계 강조가 안 보였다. 규칙(src/tools/scad-conventions.ts): 1) 부품마다 조립 자세 모듈 asm_<이름>()(모듈 안에서 translate/rotate) 2) 파일 끝 조립 블록 PART == "all" — 모듈 호출만, 조립 순서대로 3) 참고용 형상은 ref_ 접두어(조립 설명서에서 제외) 4) 모듈 위 주석 한 줄 = 한국어 부품 이름 5) 새 .scad는 SCAD/, STL은 CAD/ 적용 지점: - 작업실 채팅 컨텍스트에 규칙 전문, write_scad 도구 설명(모델이 보는 정의 2곳)에 요약 - write_scad가 저장 직후 lint(조립 블록 없음 / 블록 안에 변환·형상·반복문 / 부품 1개뿐)를 응답에 덧붙여 모델이 스스로 고치게 함 - 프로젝트 안의 새 .scad를 SCAD/ 로 자동 보정(CAD/·루트·print/ → SCAD/, 사용자 폴더는 존중)하고 이동 사실을 안내 파서: 조립 블록 이름 별칭(all/assembly/full/whole/complete), ref_ 고스트 접두어, 한 줄에 압축된 module 인식, getAssemblyRegion 분리 초안 실패/경고 문구에 "채팅에 조립 블록을 만들어줘" 안내 테스트 24건 추가(lint 표, 경로 보정, 별칭, write_scad 실제 OpenSCAD 통합) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
6.2 KiB
TypeScript
116 lines
6.2 KiB
TypeScript
// 조립 설명서 호환 .scad 작성 규칙(scad-conventions.ts) — lint, 경로 보정, write_scad 통합.
|
|
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 { lintScadAssembly, normalizeWorkshopScadPath, formatLint, SCAD_ASSEMBLY_RULES, SCAD_RULES_SHORT } from '../src/tools/scad-conventions';
|
|
import { writeScadTool } from '../src/tools/stl-cad';
|
|
|
|
const GOOD = `
|
|
H = 30;
|
|
// 베이스 판
|
|
module base(){ cube([40,40,5]); }
|
|
// 기둥
|
|
module post(x){ translate([x,0,5]) cube([5,5,H]); }
|
|
module asm_base(){ base(); }
|
|
module asm_post_l(){ post(0); }
|
|
module asm_post_r(){ post(35); }
|
|
module ref_floor(){ %cube([50,50,1]); }
|
|
PART = "all";
|
|
if (PART == "all") {
|
|
asm_base();
|
|
asm_post_l();
|
|
asm_post_r();
|
|
ref_floor();
|
|
} else if (PART == "base") base();
|
|
`;
|
|
const NO_BLOCK = `module a(){cube(1);} module b(){cube(2);} module c(){cube(3);}\ntranslate([0,0,1]) a();\nb();\nc();`;
|
|
const hasTools = spawnSync('openscad', ['--version']).status === 0;
|
|
|
|
describe('lintScadAssembly', () => {
|
|
test('규칙을 지킨 파일은 위반 없음(ref_ 고스트/else if 부품 분기 허용)', () => {
|
|
const l = lintScadAssembly(GOOD);
|
|
assert.equal(l.applicable, true);
|
|
assert.deepEqual(l.issues, []);
|
|
assert.equal(formatLint(l), '');
|
|
});
|
|
test('모듈이 3개 미만인 단순 파일은 대상이 아님', () => {
|
|
assert.deepEqual(lintScadAssembly('module a(){cube(1);}\nmodule b(){cube(2);}\na(); b();'), { applicable: false, issues: [] });
|
|
});
|
|
test('조립 블록이 없으면 위반', () => {
|
|
const l = lintScadAssembly(NO_BLOCK);
|
|
assert.equal(l.applicable, true);
|
|
assert.equal(l.issues.length, 1);
|
|
assert.match(l.issues[0], /조립 블록이 없습니다/);
|
|
assert.match(formatLint(l), /조립 설명서 호환 규칙 위반/);
|
|
});
|
|
test('블록 안에 변환/형상/반복문이 직접 있으면 위반', () => {
|
|
for (const inner of ['translate([0,0,5]) asm_a();', 'asm_a(); cylinder(r=1,h=2);', 'for (i=[0:2]) asm_a();', 'color("red") asm_a();', 'asm_a(); asm_b(); %asm_c();']) {
|
|
const src = `module asm_a(){cube(1);} module asm_b(){cube(2);} module asm_c(){cube(3);}\nPART="all";\nif (PART == "all") { ${inner} asm_b(); }`;
|
|
const l = lintScadAssembly(src);
|
|
assert.ok(l.issues.some(i => /모듈 호출이 아닌 코드/.test(i)), `"${inner}" → ${JSON.stringify(l.issues)}`);
|
|
}
|
|
});
|
|
test('블록의 부품이 1개뿐이면 위반(전체를 한 모듈로 부르는 구조)', () => {
|
|
const src = 'module p(){cube(1);} module q(){cube(2);} module robot(){p();q();}\nPART="all";\nif (PART == "all") { robot(); }';
|
|
const l = lintScadAssembly(src);
|
|
assert.ok(l.issues.some(i => /부품이 1개 이하/.test(i)), JSON.stringify(l.issues));
|
|
});
|
|
test('"assembly" 별칭 블록도 인정', () => {
|
|
assert.deepEqual(lintScadAssembly(GOOD.replace('PART == "all"', 'PART == "assembly"')).issues, []);
|
|
});
|
|
});
|
|
|
|
describe('normalizeWorkshopScadPath — 프로젝트 안의 새 .scad는 SCAD/ 로', () => {
|
|
const cases: [string, string, boolean][] = [
|
|
['workshop/p1/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
|
|
['workshop/p1/CAD/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
|
|
['workshop/p1/print/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
|
|
['workshop/p1/SCAD/rig.scad', 'workshop/p1/SCAD/rig.scad', false],
|
|
['workshop/p1/실험/rig.scad', 'workshop/p1/실험/rig.scad', false], // 사용자가 만든 폴더는 존중
|
|
['rig.scad', 'rig.scad', false], // 프로젝트 밖은 그대로
|
|
['print3d/x/rig.scad', 'print3d/x/rig.scad', false],
|
|
['./workshop/p1/CAD/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
|
|
];
|
|
for (const [from, to, moved] of cases) test(`${from} → ${to}`, () => assert.deepEqual(normalizeWorkshopScadPath(from), { path: to, moved }));
|
|
});
|
|
|
|
test('규칙 문구에 핵심 요소가 들어 있다(프롬프트/도구 설명이 이 상수를 그대로 씀)', () => {
|
|
for (const kw of ['asm_', 'PART == "all"', 'ref_', 'SCAD/']) assert.ok(SCAD_ASSEMBLY_RULES.includes(kw), kw);
|
|
for (const kw of ['asm_', 'PART == "all"', 'ref_']) assert.ok(SCAD_RULES_SHORT.includes(kw), kw);
|
|
});
|
|
|
|
describe('write_scad 통합(실제 OpenSCAD)', { skip: !hasTools && 'openscad 없음' }, () => {
|
|
const ws = () => fs.mkdtempSync(path.join(os.tmpdir(), 'wscad-'));
|
|
test('CAD/에 쓰려 해도 SCAD/에 저장 + 이동 안내 + 규칙 준수 파일은 위반 경고 없음', async () => {
|
|
const dir = ws();
|
|
const r = await writeScadTool.execute({ scad_path: 'workshop/p1/CAD/rig.scad', content: GOOD, _workspacePath: dir });
|
|
assert.equal(r.success, true, r.error);
|
|
assert.ok(fs.existsSync(path.join(dir, 'workshop/p1/SCAD/rig.scad')));
|
|
assert.ok(!fs.existsSync(path.join(dir, 'workshop/p1/CAD/rig.scad')));
|
|
assert.match(r.stdout || '', /폴더 규칙에 따라 "workshop\/p1\/CAD\/rig\.scad" 대신 "workshop\/p1\/SCAD\/rig\.scad"/);
|
|
assert.doesNotMatch(r.stdout || '', /조립 설명서 호환 규칙 위반/);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
test('조립 블록이 없는 조립체는 저장은 되되 위반 안내가 붙어 모델이 고치게 한다', async () => {
|
|
const dir = ws();
|
|
const r = await writeScadTool.execute({ scad_path: 'workshop/p1/SCAD/rig.scad', content: NO_BLOCK, _workspacePath: dir });
|
|
assert.equal(r.success, true, r.error);
|
|
assert.doesNotMatch(r.stdout || '', /폴더 규칙에 따라/);
|
|
assert.match(r.stdout || '', /조립 설명서 호환 규칙 위반/);
|
|
assert.match(r.stdout || '', /조립 블록이 없습니다/);
|
|
assert.ok(fs.existsSync(path.join(dir, 'workshop/p1/SCAD/rig.scad')));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
test('프로젝트 밖 경로는 옛 동작 그대로(보정·위반 안내 없음, 단순 파일)', async () => {
|
|
const dir = ws();
|
|
const r = await writeScadTool.execute({ scad_path: 'notes/simple.scad', content: 'cube(5);', _workspacePath: dir });
|
|
assert.equal(r.success, true, r.error);
|
|
assert.ok(fs.existsSync(path.join(dir, 'notes/simple.scad')));
|
|
assert.doesNotMatch(r.stdout || '', /폴더 규칙|규칙 위반/);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|