feat: 조립 설명서 호환 .scad 작성 규칙을 코드에 하드코딩 (규칙 문구·lint·SCAD/ 경로 보정)

배경: 테스트 프로젝트 로봇팔은 부품이 로컬 좌표 모듈이고 조립 자세를 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>
This commit is contained in:
kim
2026-09-25 11:18:29 +09:00
co-authored by Claude Sonnet 5
parent 2bdc5563dc
commit cedf881f2d
9 changed files with 230 additions and 18 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
import { getConfig } from '../../config/config';
import { SCAD_RULES_SHORT } from '../../tools/scad-conventions';
import { getOrchestrationConfig } from '../../orchestration/multi-agent';
import { getBrowserToolDefinitions } from '../automation/browser-tools';
import { getDesktopToolDefinitions } from '../automation/desktop-tools';
@@ -1241,11 +1242,11 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
// 폴더 없으면 실패하는 등 신뢰도가 낮았음). 저장 직후 실제 OpenSCAD 컴파일까지 해서
// 문법오류/빈 형상을 그 자리에서 알려준다.
name: 'write_scad',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 검증 통과하면 치수(X×Y×Z)/체적/삼각형수/단위힌트도 같이 보고해서 스케일 오류(예: 200mm를 0.2로 씀)를 그 자리에서 잡는다. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것.',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. [조립 설명서 규칙] ' + SCAD_RULES_SHORT + ' 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 검증 통과하면 치수(X×Y×Z)/체적/삼각형수/단위힌트도 같이 보고해서 스케일 오류(예: 200mm를 0.2로 씀)를 그 자리에서 잡는다. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것.',
parameters: {
type: 'object', required: ['scad_path', 'content'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로, .scad로 끝나야 함. 폴더가 없으면 자동 생성된다. 작업실 채팅이라면 반드시 "workshop/<프로젝트id>/파일명" 형태로 프로젝트 폴더 안에 넣을 것 — 접두어 없이 쓰면 워크스페이스 루트에 저장돼 파일 탭에 안 보인다.' },
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로, .scad로 끝나야 함. 폴더가 없으면 자동 생성된다. 작업실 채팅이라면 반드시 "workshop/<프로젝트id>/SCAD/파일명.scad" 형태로 프로젝트의 SCAD/ 폴더에 넣을 것(CAD/나 프로젝트 루트에 쓰면 SCAD/로 자동 이동됨) — 접두어 없이 쓰면 워크스페이스 루트에 저장돼 파일 탭에 안 보인다.' },
content: { type: 'string', description: 'OpenSCAD 소스 코드 전체(파일 전체 내용을 매번 새로 씀 — 부분 수정 아님).' },
},
},
+2
View File
@@ -1,4 +1,5 @@
import fs from 'fs';
import { SCAD_ASSEMBLY_RULES } from '../../tools/scad-conventions';
import path from 'path';
import { getConfig, getUserWorkspace } from '../../config/config';
import { loadWorkshop } from '../routes/workshop-storage';
@@ -349,6 +350,7 @@ async function handleChat(
return `[작업실(workshop) 세션 — 지금 사용자가 보고 있는 프로젝트: "${proj.name}" (id: ${proj.id}). ` +
`이 프로젝트의 파일(STL/scad/사진 등)은 워크스페이스 기준 workshop/${proj.id}/ 아래에만 있다. ` +
`폴더 규칙: SCAD/=.scad 소스만, CAD/=STL 등 모델 파일, CAD/참고/=비인쇄 참고 모델, 출력/=G-code, 사진/=사진·렌더 이미지, 영상/=영상. 새 파일은 이 규칙대로 저장할 것. ` +
`${SCAD_ASSEMBLY_RULES} ` +
`파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` +
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.` +
`도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` +
+12 -4
View File
@@ -64,11 +64,15 @@ export function splitTopLevelArgs(argText: string): string[] {
export interface AssemblyCall { module: string; args: string[]; raw: string; ghost: boolean }
// 참고용/투명 형상으로 추정되는 이름(조립도에 그릴 실체가 아님) — 자동 초안에선 제외하고 사용자에게 알린다.
const GHOST_NAME = /(fov|_cone|cone$|axis|plane|ghost|guide|reference|enclosure|^model$|^model_|_model$|envelope|sweep)/i;
// ref_* / *_ref 는 "참고용 형상" 접두어 규칙(scad-conventions.ts) — 예: ref_floor()
const GHOST_NAME = /(fov|_cone|cone$|axis|plane|ghost|guide|reference|(^|_)ref(_|$)|enclosure|^model$|^model_|_model$|envelope|sweep)/i;
// 조립 블록 분기 이름: 관례상 "all"이 기본이지만 "assembly"/"full" 같은 별칭도 같은 뜻으로 받는다(실제 파일에서 발견: PART == "assembly")
export const ASSEMBLY_BLOCK_NAMES = /["'](all|assembly|full|whole|complete)["']/i;
// 조립 호출 목록 추출. 조립 블록 = `if (… "all" …) { … }` 의 첫 중괄호 안, 없으면 최상위 전체.
// 모듈 정의 본문/주석은 제외. `%foo();`(고스트) `*foo();`(비활성) 같은 수식자 붙은 호출은 건너뛴다.
export function extractAssemblyCalls(source: string): { calls: AssemblyCall[]; region: 'all-block' | 'top-level' } {
// 조립 블록(또는 최상위) 본문 텍스트: 주석 제거 + 모듈 정의 본문은 공백 처리. lint(scad-conventions.ts)와 호출 추출이 공유한다.
export function getAssemblyRegion(source: string): { text: string; kind: 'all-block' | 'top-level'; modNames: Set<string> } {
const modNames = new Set(scanScadModules(source).map(m => m.name));
let text = stripComments(source);
// 모듈 정의 본문 제거(같은 길이의 공백으로 대체)
@@ -87,19 +91,23 @@ export function extractAssemblyCalls(source: string): { calls: AssemblyCall[]; r
blank(m.index + m[1].length, braceClose);
defRe.lastIndex = braceClose;
}
let region = text;
let kind: 'all-block' | 'top-level' = 'top-level';
const ifRe = /\bif\s*\(([^)]*)\)\s*\{/g;
while ((m = ifRe.exec(text))) {
if (/["']all["']/i.test(m[1])) {
if (ASSEMBLY_BLOCK_NAMES.test(m[1])) {
const open = text.indexOf('{', m.index + m[0].length - 1);
const close = matchBracket(text, open);
if (close > open) { region = text.slice(open + 1, close); kind = 'all-block'; }
break;
}
}
return { text: region, kind, modNames };
}
export function extractAssemblyCalls(source: string): { calls: AssemblyCall[]; region: 'all-block' | 'top-level' } {
const { text: region, kind, modNames } = getAssemblyRegion(source);
let m: RegExpExecArray | null;
const calls: AssemblyCall[] = [];
// 문장 시작(구분자 뒤) + 식별자 + ( — 앞에 % * # ! 수식자나 translate() 같은 변환이 붙은 호출은 매치되지 않는다.
const callRe = /(^|[;{}])\s*([A-Za-z_]\w*)\s*\(/g;
+2 -1
View File
@@ -151,7 +151,8 @@ export function assemblyHash(assembly: Assembly, scadSource: string): string {
export interface ScadModule { name: string; params: string[]; requiredParams: string[]; isPrintPart: boolean }
export function scanScadModules(source: string): ScadModule[] {
const out: ScadModule[] = [];
const re = /(^|\n)\s*module\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/g;
// 줄 시작뿐 아니라 `}` `;` `{` 뒤(한 줄에 여러 module을 압축해 쓴 파일)에서도 인식한다.
const re = /(^|[\n;{}])\s*module\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
const params = m[3].split(',').map(s => s.trim()).filter(Boolean);
+68
View File
@@ -0,0 +1,68 @@
/**
* scad-conventions.ts
* "조립 설명서 호환 .scad" 작성 규칙(2026-09-25, 사용자 지시로 하드코딩) — 규칙 문구, 검사기(lint), 저장 경로 보정.
*
* 배경: 조립 설명서는 .scad의 조립 블록이 부르는 모듈을 "부품"으로 삼는다. 부품이 로컬 좌표로만 그려지고 조립 자세는
* 하나의 큰 모듈이 translate/rotate로 만드는 구조(테스트 프로젝트 로봇팔)에선 모듈 호출만으로 조립 자세를 못 만들어서
* 단계 그림에 강조가 안 나오고 엉뚱한 위치에 그려졌다. 그래서 .scad를 새로 쓸 때부터 아래 규칙을 지키게 한다:
* - 채팅 프롬프트(작업실 세션)와 write_scad 도구 설명에 규칙 문구를 넣고,
* - write_scad가 저장 직후 이 lint로 규칙 위반을 알려 모델이 스스로 고치게 하고,
* - 프로젝트 안의 새 .scad는 SCAD/ 폴더로 보정한다.
*/
import { scanScadModules } from '../gateway/routes/workshop-assembly.js';
import { extractAssemblyCalls, getAssemblyRegion } from '../gateway/routes/workshop-assembly-draft.js';
// 모델 프롬프트용 규칙(작업실 채팅 컨텍스트에 그대로 들어간다).
export const SCAD_ASSEMBLY_RULES = [
'[조립 설명서 호환 .scad 작성 규칙 — 부품이 3개 이상인 조립체 .scad는 반드시 지킬 것]',
'1) 부품마다 "조립 자세 모듈" asm_<이름>()을 만든다 — 모듈 안에서 translate/rotate로 조립된 위치·방향에 놓는다(호출하는 쪽에서 변환하지 않는다). 부품 형상이 로컬 좌표 모듈이면 asm_ 모듈이 그걸 변환해 호출한다.',
'2) 파일 끝에 조립 블록을 둔다: `PART = "all"; if (PART == "all") { asm_a(); asm_b(); … }` — 블록 안엔 모듈 호출만(변환·형상·color 직접 사용 금지), 조립 순서(아래→위, 큰 것→작은 것)로 한 줄씩. 좌우 대칭 부품은 인자로 구분(asm_camera(80), asm_camera(-80)).',
'3) 참고용/투명/치수 표시 형상은 이름을 ref_ 로 시작한다(예: ref_floor()) — 조립 설명서에서 부품이 아닌 참고 형상으로 제외된다.',
'4) 인쇄용 부품 export가 필요하면 조립 블록 뒤에 `else if (PART == "부품이름") …` 분기를 추가한다.',
'5) 각 모듈 바로 위 주석 한 줄은 그 부품의 한국어 이름(22자 이내, 마침표 없이) — 조립 설명서 부품 이름으로 쓰인다.',
'6) 새 .scad 소스는 workshop/<프로젝트id>/SCAD/ 에, STL 출력은 CAD/ 에 저장한다.',
].join('\n');
// 도구 설명(write_scad)용 한 줄 요약
export const SCAD_RULES_SHORT =
'조립체(부품 3개 이상)면 부품마다 조립 자세 모듈 asm_<이름>()(안에서 translate/rotate)을 만들고 파일 끝에 `PART = "all"; if (PART == "all") { asm_a(); asm_b(); … }` 조립 블록(모듈 호출만, 조립 순서대로)을 둘 것 — 조립 설명서가 이 블록을 읽는다. 참고용 형상은 ref_ 접두어. 저장 후 규칙 위반이 있으면 안내가 나오니 그대로 고칠 것.';
// 프로젝트 안의 새 .scad는 SCAD/ 로: workshop/<id>/파일.scad, workshop/<id>/CAD/파일.scad, workshop/<id>/print/파일.scad → workshop/<id>/SCAD/파일.scad.
// 사용자가 직접 만든 하위 폴더(예: workshop/<id>/실험/x.scad)나 이미 SCAD/ 인 경로는 그대로 둔다.
export function normalizeWorkshopScadPath(rel: string): { path: string; moved: boolean } {
const p = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '');
const m = /^workshop\/([^/]+)\/(?:CAD\/|print\/)?([^/]+\.scad)$/i.exec(p);
if (!m) return { path: p, moved: false };
const target = `workshop/${m[1]}/SCAD/${m[2]}`;
return { path: target, moved: target !== p };
}
export interface ScadLint { applicable: boolean; issues: string[] }
// 규칙 검사. 모듈이 3개 미만인 단순 파일(부품 1~2개)은 대상이 아니다.
export function lintScadAssembly(source: string): ScadLint {
const modules = scanScadModules(source).filter(m => !m.isPrintPart);
if (modules.length < 3) return { applicable: false, issues: [] };
const issues: string[] = [];
const { text, kind, modNames } = getAssemblyRegion(source);
if (kind !== 'all-block') {
issues.push('조립 블록이 없습니다 — 파일 끝에 `PART = "all"; if (PART == "all") { asm_…(); … }` 형태로 조립 순서대로 부품 모듈만 호출하는 블록을 추가하세요.');
return { applicable: true, issues };
}
// 블록 안에서 "모듈 호출"을 지운 나머지 — 남는 게 있으면 변환/형상/반복문이 직접 쓰인 것(조립 설명서가 못 읽는다)
let rest = text;
// 모듈 호출문 `이름(인자);`만 지운다 — translate(...) asm_a(); 처럼 변환이 앞에 붙은 호출은 호출문만 지워지고 translate(...)가 남아 위반으로 잡힌다.
const callRe = /\b([A-Za-z_]\w*)\s*\(([^;{}]*)\)\s*;/g;
rest = rest.replace(callRe, (full, name: string) => (modNames.has(name) ? ' ' : full));
const leftover = rest.replace(/[\s;{}]+/g, ' ').trim();
if (leftover) issues.push(`조립 블록 안에 모듈 호출이 아닌 코드가 있습니다(변환·형상·반복문 등): "${leftover.slice(0, 70)}" — 변환은 asm_ 모듈 안으로 옮기고 블록에는 모듈 호출만 두세요.`);
const real = extractAssemblyCalls(source).calls.filter(c => !c.ghost);
if (real.length < 2) issues.push('조립 블록의 부품이 1개 이하입니다 — 조립체를 부품 단위 조립 자세 모듈(asm_*)로 나눠 각각 호출하세요(그래야 조립 설명서가 단계별로 나뉩니다).');
return { applicable: true, issues };
}
export function formatLint(lint: ScadLint): string {
if (!lint.applicable || !lint.issues.length) return '';
return '\n\n⚠️ 조립 설명서 호환 규칙 위반(조립 설명서가 이 파일을 부품 단위로 못 읽습니다):\n' +
lint.issues.map(i => `- ${i}`).join('\n') + '\n규칙: 부품마다 조립 자세 모듈 asm_<이름>() + 파일 끝 조립 블록(PART == "all", 모듈 호출만). 위를 고쳐 write_scad를 같은 경로로 다시 호출할 것.';
}
+16 -9
View File
@@ -7,6 +7,7 @@ import { getWorkspacePath } from '../config/paths.js';
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';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
@@ -354,27 +355,33 @@ export const scadToStlTool = {
// 도구들과 같은 이유로 전용·단일목적으로 만듦, action enum 없음).
export const writeScadTool = {
name: 'write_scad',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것 — 복잡한 설계가 필요하면 사용자에게 더 큰 모델(Claude Code)에게 부탁하라고 안내하는 게 낫다.',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. [조립 설명서 규칙] ' + SCAD_RULES_SHORT + ' 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것 — 복잡한 설계가 필요하면 사용자에게 더 큰 모델(Claude Code)에게 부탁하라고 안내하는 게 낫다.',
schema: { scad_path: 'string', content: 'string' },
jsonSchema: {
type: 'object',
required: ['scad_path', 'content'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로, .scad로 끝나야 함. 폴더가 없으면 자동 생성된다. 작업실 채팅이라면 반드시 "workshop/<프로젝트id>/파일명" 형태로 프로젝트 폴더 안에 넣을 것 — 접두어 없이 쓰면 워크스페이스 루트에 저장돼 파일 탭에 안 보인다.' },
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로, .scad로 끝나야 함. 폴더가 없으면 자동 생성된다. 작업실 채팅이라면 반드시 "workshop/<프로젝트id>/SCAD/파일명.scad" 형태로 프로젝트의 SCAD/ 폴더에 넣을 것(CAD/나 프로젝트 루트에 쓰면 SCAD/로 자동 이동됨) — 접두어 없이 쓰면 워크스페이스 루트에 저장돼 파일 탭에 안 보인다.' },
content: { type: 'string', description: 'OpenSCAD 소스 코드 전체(파일 전체 내용을 매번 새로 씀 — 부분 수정 아님).' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const workspacePath = getWorkspacePath(args);
const scadPathArg = String(args?.scad_path || '').trim();
if (!scadPathArg) return { success: false, error: 'scad_path가 필요합니다.' };
if (!/\.scad$/i.test(scadPathArg)) return { success: false, error: 'scad_path는 .scad로 끝나야 합니다.' };
const rawScadPathArg = String(args?.scad_path || '').trim();
if (!rawScadPathArg) return { success: false, error: 'scad_path가 필요합니다.' };
if (!/\.scad$/i.test(rawScadPathArg)) return { success: false, error: 'scad_path는 .scad로 끝나야 합니다.' };
// 폴더 규칙 하드코딩(workshop-layout.ts): 프로젝트 안의 새 .scad는 SCAD/ 로 보정한다(CAD/·프로젝트 루트·print/ 에 쓰려 하면 이동).
const norm = normalizeWorkshopScadPath(rawScadPathArg);
const scadPathArg = norm.path;
let scadPath: string;
try { scadPath = resolveWorkspacePath(workspacePath, scadPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const content = String(args?.content ?? '');
if (!content.trim()) return { success: false, error: 'content가 비어있습니다.' };
// 저장 응답에 붙일 추가 안내: 경로 보정 알림 + 조립 설명서 호환 규칙 위반(lint)
const movedNote = norm.moved ? `\n📁 폴더 규칙에 따라 "${rawScadPathArg}" 대신 "${scadPathArg}"에 저장했습니다 — 이후 이 파일은 이 경로로 부를 것(scad_to_stl 등).` : '';
const lintNote = () => movedNote + formatLint(lintScadAssembly(content));
try {
fs.mkdirSync(path.dirname(scadPath), { recursive: true });
@@ -384,7 +391,7 @@ export const writeScadTool = {
} catch (e: any) { return { success: false, error: `파일 쓰기 실패: ${String(e?.message || e)}` }; }
if (!fs.existsSync(OPENSCAD_BIN)) {
return { success: true, stdout: `저장됨: ${scadPathArg} (OpenSCAD가 없어서 문법 검증은 생략됨)` };
return { success: true, stdout: `저장됨: ${scadPathArg} (OpenSCAD가 없어서 문법 검증은 생략됨)${lintNote()}` };
}
const tmpOut = scadPath.replace(/\.scad$/i, `.__validate_${Date.now()}.stl`);
const run = await runOpenscad(['-o', tmpOut, '--export-format=binstl', scadPath], 60_000);
@@ -403,7 +410,7 @@ export const writeScadTool = {
} catch { /* noop */ }
return {
success: true,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나, PART 분기가 있는데 part를 안 줘서 매칭된 게 없을 수 있음).${partsLine}`,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나, PART 분기가 있는데 part를 안 줘서 매칭된 게 없을 수 있음).${partsLine}${lintNote()}`,
};
}
return {
@@ -423,7 +430,7 @@ export const writeScadTool = {
} catch { /* noop */ }
return {
success: true,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나 좌표가 잘못됐을 수 있음).${partsLine}`,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나 좌표가 잘못됐을 수 있음).${partsLine}${lintNote()}`,
};
}
@@ -438,7 +445,7 @@ export const writeScadTool = {
} catch { /* noop */ }
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과.${analysisLines}` };
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과.${analysisLines}${lintNote()}` };
},
};
+2 -2
View File
@@ -360,7 +360,7 @@ export async function draftAssembly(workspace: string, scadRel: string): Promise
const { calls, region } = extractAssemblyCalls(source);
const real = calls.filter(c => !c.ghost);
const excluded = [...new Set(calls.filter(c => c.ghost).map(c => c.raw))];
if (!real.length) return { ok: false, error: '조립 호출을 찾지 못했습니다 — .scad의 최상위(또는 if (PART == "all") 블록)에 조립 모듈을 호출하는 줄이 있어야 합니다.' };
if (!real.length) return { ok: false, error: '조립 호출을 찾지 못했습니다 — .scad의 최상위(또는 if (PART == "all") 블록)에 조립 모듈을 호출하는 줄이 있어야 합니다. 채팅에 "이 .scad에 조립 블록을 만들어줘"라고 하면 조립 자세 모듈(asm_*)과 조립 블록을 규칙대로 추가합니다.' };
if (real.length > 40) return { ok: false, error: `조립 호출이 ${real.length}개로 너무 많습니다(최대 40). 부품을 직접 골라 추가하세요.` };
const warnings: string[] = [];
@@ -446,7 +446,7 @@ export async function draftAssembly(workspace: string, scadRel: string): Promise
return `「${x.it.name}」을(를) 조립체에 장착합니다. (조립 좌표 중심 약 X ${c[0]}, Y ${c[1]}, Z ${c[2]} mm)`;
}).join('\n');
}
if (region === 'top-level') warnings.push('조립 블록(if (PART == "all"))이 없어 최상위 모듈 호출 전체를 부품으로 삼았습니다 — 애니메이션/데모 파일이면 부품 구성이 의도와 다를 수 있습니다.');
if (region === 'top-level') warnings.push('조립 블록(if (PART == "all"))이 없어 최상위 모듈 호출 전체를 부품으로 삼았습니다 — 애니메이션/데모 파일이거나 부품이 로컬 좌표로만 그려진 파일이면 그림이 의도와 다를 수 있습니다. 채팅에 "이 .scad에 조립 블록을 만들어줘"라고 하면 규칙대로 정리해 줍니다.');
const n = normalizeAssembly({ scad: scadRel, view: { rx: 60, rz: 35 }, explode: 1, parts, steps }, true);
if (!n.ok) return { ok: false, error: n.error };
+115
View File
@@ -0,0 +1,115 @@
// 조립 설명서 호환 .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 });
});
});
+10
View File
@@ -273,6 +273,16 @@ describe('자동 초안: 파싱', () => {
assert.deepEqual(r.calls.filter(c => c.ghost).map(c => c.module), ['ghost_cone']);
assert.deepEqual(r.calls[4].args, ['+OFF']);
});
test('조립 블록 별칭(PART == "assembly" 등)과 ref_ 접두어 고스트를 인식', () => {
const src = 'module asm_a(){cube(1);}\nmodule asm_b(){cube(2);}\nmodule ref_floor(){cube(3);}\nPART = "assembly";\nif (PART == "all" || PART == "assembly") {\n asm_a();\n asm_b();\n ref_floor();\n}\nelse if (PART == "a") asm_a();';
const r = extractAssemblyCalls(src);
assert.equal(r.region, 'all-block');
assert.deepEqual(r.calls.map(c => [c.module, c.ghost]), [['asm_a', false], ['asm_b', false], ['ref_floor', true]]);
for (const name of ['assembly', 'full', 'whole', 'complete']) {
const r2 = extractAssemblyCalls(`module a(){cube(1);}\nif (PART == "${name}") {\n a();\n}`);
assert.equal(r2.region, 'all-block', name);
}
});
test('조립 블록이 없으면 최상위 호출 전체(region=top-level), 이름 붙은 ghost 인자도 고스트', () => {
const r = extractAssemblyCalls('module a(x){cube(x);}\nmodule arm(ang, isGhost=false){cube(1);}\na(3);\narm(10);\narm(20, isGhost=true);');
assert.equal(r.region, 'top-level');