feat: write_scad 도구 추가 + 전체조립/부품 저장위치 분리 + PART 오타 흡수

write_scad — .scad 신규작성/수정 전용 도구(사용자 요청: "그래도 툴 하나
만들어주자"). python_eval의 open(path,'w') 우회는 os.makedirs가 막혀있어
폴더 없으면 실패하는 등 신뢰도가 낮았음. 저장 직후 실제 OpenSCAD 컴파일까지
해서 문법오류/빈 형상을 그 자리에서 알려주고 모델이 고쳐서 재시도하게 함.
registry.ts+build-tools.ts(실제 스키마) 둘 다 등록, tool-scope.ts 게이트 추가.
직접 테스트(정상/문법오류)+실채팅 테스트 통과.

PART 값 오타 흡수 — stl_cad의 compile 액션과 scad_to_stl 둘 다: scad 파일에
없는 PART 문자열을 넘기면 OpenSCAD가 에러 없이 조용히 빈 STL을 만드는 함정이
있었음(실측: 워크숍 채팅이 "tilt_arm"을 "Tilt Arm"/"tiltarm"/"tilt-arm"으로
반복해서 틀려 빈 STL만 생성). scad 소스에서 PART 분기 문자열을 미리 뽑아
대소문자/공백/하이픈 차이를 정규화해서 매칭하고, 그래도 안 맞으면 openscad를
돌리지도 않고 사용 가능한 PART 값 목록을 바로 에러로 돌려줌.

전체조립 vs 부품 저장위치 분리 — handle-chat.ts의 workshopProjectCtx 힌트:
part 지정해서 부품 하나만 만들 때는 CAD/print/에, part 없이 전체조립본
(시각화용, 인쇄 대상 아님) 만들 때는 CAD/ 바로 밑에 저장하도록 구분.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 17:17:58 +09:00
co-authored by Claude Code
parent 7485192e4d
commit bb6a64bff4
5 changed files with 125 additions and 8 deletions
+18
View File
@@ -1191,6 +1191,24 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
// .scad 신규작성/수정 전용 도구 — 2026-09-22 추가(사용자 요청: "그래도 툴 하나 만들어
// 주자" — python_eval의 open(path,'w') 우회로도 되긴 하는데 os.makedirs가 막혀있어
// 폴더 없으면 실패하는 등 신뢰도가 낮았음). 저장 직후 실제 OpenSCAD 컴파일까지 해서
// 문법오류/빈 형상을 그 자리에서 알려준다.
name: 'write_scad',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것.',
parameters: {
type: 'object', required: ['scad_path', 'content'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로, .scad로 끝나야 함. 폴더가 없으면 자동 생성된다.' },
content: { type: 'string', description: 'OpenSCAD 소스 코드 전체(파일 전체 내용을 매번 새로 씀 — 부분 수정 아님).' },
},
},
},
},
{
type: 'function',
function: {
+8 -4
View File
@@ -283,10 +283,14 @@ async function handleChat(
if (scadFiles.length > 0) {
// 09-22: 사용자 지적 — scad_to_stl 결과가 CAD/ 바로 밑에 널브러지지 말고 print/
// 서브폴더에 정리돼야 보기 좋다(이 프로젝트도, proj_scan_dental도 이미 그 관례를 씀
// — CAD/print/=인쇄 대상, CAD/reference/=참고용 비인쇄 모델).
scadHint = ' scad_to_stl로 STL을 만들 때 output_path는 특별한 지시가 없으면 그 .scad ' +
'파일이 있는 폴더 밑 print/ 서브폴더에 저장할 것(예: ".../CAD/x.scad" → ' +
'".../CAD/print/x.stl", 폴더 없으면 새로 생겨도 됨) — CAD/ 바로 밑에 흩어놓지 말 것.';
// — CAD/print/=인쇄 대상, CAD/reference/=참고용 비인쇄 모델). 단, part 없이(전체
// 조립본, 시각화용 — 고스트/모터 등 비인쇄 요소까지 다 포함돼 인쇄 대상이 아님)
// 만드는 경우는 예외 — 그건 CAD/ 폴더 바로 밑에 저장(사용자 지시, 09-22).
scadHint = ' scad_to_stl로 STL을 만들 때: part를 지정해서(부품 하나만) 만드는 ' +
'거면 output_path는 특별한 지시가 없는 한 그 .scad 파일이 있는 폴더 밑 print/ ' +
'서브폴더에 저장할 것(예: ".../CAD/x.scad" → ".../CAD/print/x.stl", 폴더 없으면 ' +
'새로 생겨도 됨). part 없이(전체 조립본) 만드는 거면 대신 그 .scad 파일과 같은 ' +
'폴더(CAD/ 바로 밑)에 저장할 것 — print/에 넣지 말 것(인쇄 대상이 아니라 시각화용).';
}
if (scadFiles.length === 1) {
scadHint += ` 이 프로젝트엔 .scad 파일이 "${scadFiles[0]}" 하나뿐이다 — 사용자가 ` +
+1 -1
View File
@@ -392,7 +392,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false;
if (name === 'nvr_snapshot' && !hasCctvKeyword) return false;
if (name === 'print3d_model' && !hasPrint3dKeyword) return false;
if ((name === 'stl_cad' || name === 'scad_to_stl') && !hasStlCadKeyword) return false;
if ((name === 'stl_cad' || name === 'scad_to_stl' || name === 'write_scad') && !hasStlCadKeyword) return false;
if (name === 'workshop_project' && !isWorkshopAppSession && !hasWorkshopKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
+2 -1
View File
@@ -33,7 +33,7 @@ import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { workshopProjectTool } from './workshop-project.js';
import { stlCadTool, scadToStlTool } from './stl-cad.js';
import { stlCadTool, scadToStlTool, writeScadTool } from './stl-cad.js';
export interface Tool {
name: string;
@@ -243,6 +243,7 @@ class ToolRegistry {
this.registerSafe(workshopProjectTool);
this.registerSafe(stlCadTool);
this.registerSafe(scadToStlTool);
this.registerSafe(writeScadTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)
+96 -2
View File
@@ -14,6 +14,31 @@ import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad } fr
// 번거롭다는 피드백으로 웹 UI(k2-app.html "🔧 STL 확인" 패널, routes-cad.ts)도 추가함 —
// 핵심 로직은 stl-cad-core.ts에 있고 이 파일과 라우트가 공유한다.
// scad 소스에서 `PART == "xxx"` 형태의 분기 문자열을 전부 뽑는다. 이게 있는 파일(이
// 프로젝트의 scanner-*.scad류)에 -D 'PART="..."'로 안 맞는 값을 넘기면 OpenSCAD는 에러 없이
// 그냥 빈 형상을 렌더한다("Current top level object is empty") — exit code까지 0이라 조용히
// 실패한다. 2026-09-22 실측: 모델이 "tilt_arm"을 "Tilt Arm"/"tiltarm"/"tilt-arm"으로 살짝
// 틀리게 넘겨서 반복적으로 빈 STL을 만듦. 대소문자/공백/하이픈 차이는 여기서 흡수하고,
// 그래도 안 맞으면 openscad를 아예 돌리지 않고 사용 가능한 값을 바로 알려준다.
function extractScadPartNames(scadSource: string): string[] {
const names = new Set<string>();
const re = /PART\s*==\s*"([^"]+)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(scadSource))) names.add(m[1]);
return [...names];
}
function resolvePartName(scadPath: string, requested: string): { part: string; error?: string } {
let scadSource: string;
try { scadSource = fs.readFileSync(scadPath, 'utf8'); } catch { return { part: requested }; }
const available = extractScadPartNames(scadSource);
if (!available.length || available.includes(requested)) return { part: requested };
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, '');
const target = norm(requested);
const match = available.find((a) => norm(a) === target);
if (match) return { part: match };
return { part: requested, error: `part="${requested}"는 이 scad 파일에 없습니다(빈 STL만 나옵니다). 이 파일의 PART 값: ${available.join(', ')}` };
}
function resolveWorkspacePath(workspacePath: string, relPath: string): string {
const resolved = path.resolve(workspacePath, relPath);
if (!isPathInsideDir(workspacePath, resolved)) {
@@ -69,7 +94,12 @@ export const stlCadTool = {
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로 끝나야 합니다.' };
const part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
let part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
if (part) {
const resolved = resolvePartName(scadPath, part);
if (resolved.error) return { success: false, error: resolved.error };
part = resolved.part;
}
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
'-o', outPath,
@@ -186,7 +216,12 @@ export const scadToStlTool = {
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로 끝나야 합니다.' };
const part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
let part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
if (part) {
const resolved = resolvePartName(scadPath, part);
if (resolved.error) return { success: false, error: resolved.error };
part = resolved.part;
}
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
'-o', outPath,
@@ -211,3 +246,62 @@ export const scadToStlTool = {
};
},
};
// .scad 파일을 새로 쓰거나 덮어쓰는 도구 — 2026-09-22 추가. 이전엔 전용 도구가 없어서
// python_eval의 open(path,'w')로 우회해서 썼는데(막힌 os.makedirs 때문에 실패하는 경우도
// 있었음), 저장 직후 실제로 OpenSCAD로 컴파일까지 해봐서 문법 오류/빈 형상을 그 자리에서
// 잡아준다 — 경량모델이 잘못된 scad를 쓰고도 모르는 채로 넘어가는 걸 막는다(다른 stl_cad류
// 도구들과 같은 이유로 전용·단일목적으로 만듦, action enum 없음).
export const writeScadTool = {
name: 'write_scad',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 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로 끝나야 함. 폴더가 없으면 자동 생성된다.' },
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로 끝나야 합니다.' };
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가 비어있습니다.' };
try {
fs.mkdirSync(path.dirname(scadPath), { recursive: true });
fs.writeFileSync(scadPath, content, 'utf8');
} catch (e: any) { return { success: false, error: `파일 쓰기 실패: ${String(e?.message || e)}` }; }
if (!fs.existsSync(OPENSCAD_BIN)) {
return { success: true, stdout: `저장됨: ${scadPathArg} (OpenSCAD가 없어서 문법 검증은 생략됨)` };
}
const tmpOut = scadPath.replace(/\.scad$/i, `.__validate_${Date.now()}.stl`);
const run = await runOpenscad(['-o', tmpOut, '--export-format=binstl', scadPath], 60_000);
const validOutput = fs.existsSync(tmpOut) && fs.statSync(tmpOut).size >= 84;
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
if (!run.ok) {
return {
success: false,
error: `저장은 됐지만(${scadPathArg}) OpenSCAD 문법 오류가 있습니다: ${run.detail}\n\n` +
`위 오류를 보고 content를 고쳐서 같은 scad_path로 write_scad를 다시 호출할 것.`,
};
}
if (!validOutput) {
return {
success: true,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나 좌표가 잘못됐을 수 있음) — 확인 필요.`,
};
}
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과(정상 컴파일 확인됨).` };
},
};