feat: STL→K2 프린터 자동 슬라이싱/출력 채팅 도구(print3d_model) 추가

CuraEngine(apt cura-engine, 클로서버)로 STL을 슬라이싱해 K2 콤보(Moonraker)에
업로드하는 도구. action="prepare"(다운로드+슬라이싱+업로드, 출력은 안 함)와
action="start"(confirm=true 필수, 실제 출력 시작)로 분리 — 재료/시간 소모되는
물리적 동작이라 사용자 확인 없이는 출력 시작 안 되게 함.

프린터 프로파일(빌드볼륨/노즐/리트랙션/온도/START_PRINT·END_PRINT 매크로 호출)은
추측이 아니라 K2에 이미 있던 Creality Print 슬라이싱 샘플 gcode 헤더에서 실측값
그대로 추출. fdmprinter.def.json/fdmextruder.def.json은 설치된 CuraEngine
5.0.0과 버전 맞춰서 받아야 함(main 브랜치 최신판 쓰면 설정 스키마 불일치로
슬라이싱이 즉시 실패함 — resources/cura/에 버전 고정해서 커밋).

tool-scope.ts에 3D프린트 관련 키워드 게이트 추가(다른 니치 도구들과 동일 패턴).
end-to-end 테스트 완료(3DBenchy STL로 다운로드→슬라이싱→K2 업로드 확인, 테스트
파일은 삭제함).
This commit is contained in:
kim
2026-09-20 00:19:02 +09:00
parent 101a673827
commit fdbed6266e
6 changed files with 8647 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
{
"name": "Extruder",
"version": 2,
"metadata":
{
"type": "extruder",
"author": "Ultimaker",
"manufacturer": "Unknown",
"setting_version": 20,
"visible": false,
"position": "0"
},
"settings":
{
"machine_settings":
{
"label": "Machine",
"type": "category",
"description": "Machine specific settings",
"children":
{
"extruder_nr":
{
"label": "Extruder",
"description": "The extruder train used for printing. This is used in multi-extrusion.",
"type": "extruder",
"default_value": "0",
"settable_per_mesh": true,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_nozzle_id":
{
"label": "Nozzle ID",
"description": "The nozzle ID for an extruder train, such as \"AA 0.4\" and \"BB 0.8\".",
"type": "str",
"default_value": "unknown",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"machine_nozzle_size":
{
"label": "Nozzle Diameter",
"description": "The inner diameter of the nozzle. Change this setting when using a non-standard nozzle size.",
"unit": "mm",
"type": "float",
"default_value": 0.4,
"minimum_value": "0.001",
"maximum_value_warning": "10",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"machine_nozzle_offset_x":
{
"label": "Nozzle X Offset",
"description": "The x-coordinate of the offset of the nozzle.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_nozzle_offset_y":
{
"label": "Nozzle Y Offset",
"description": "The y-coordinate of the offset of the nozzle.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_start_code":
{
"label": "Extruder Start G-Code",
"description": "Start g-code to execute when switching to this extruder.",
"type": "str",
"default_value": "",
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_start_pos_abs":
{
"label": "Extruder Start Position Absolute",
"description": "Make the extruder starting position absolute rather than relative to the last-known location of the head.",
"type": "bool",
"default_value": false,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_start_pos_x":
{
"label": "Extruder Start Position X",
"description": "The x-coordinate of the starting position when turning the extruder on.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_start_pos_y":
{
"label": "Extruder Start Position Y",
"description": "The y-coordinate of the starting position when turning the extruder on.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_end_code":
{
"label": "Extruder End G-Code",
"description": "End g-code to execute when switching away from this extruder.",
"type": "str",
"default_value": "",
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_end_pos_abs":
{
"label": "Extruder End Position Absolute",
"description": "Make the extruder ending position absolute rather than relative to the last-known location of the head.",
"type": "bool",
"default_value": false,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_end_pos_x":
{
"label": "Extruder End Position X",
"description": "The x-coordinate of the ending position when turning the extruder off.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"machine_extruder_end_pos_y":
{
"label": "Extruder End Position Y",
"description": "The y-coordinate of the ending position when turning the extruder off.",
"type": "float",
"unit": "mm",
"default_value": 0,
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
},
"extruder_prime_pos_z":
{
"label": "Extruder Prime Z Position",
"description": "The Z coordinate of the position where the nozzle primes at the start of printing.",
"type": "float",
"unit": "mm",
"default_value": 0,
"minimum_value_warning": "0",
"maximum_value": "machine_height",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"machine_extruder_cooling_fan_number":
{
"label": "Extruder Print Cooling Fan",
"description": "The number of the print cooling fan associated with this extruder. Only change this from the default value of 0 when you have a different print cooling fan for each extruder.",
"type": "int",
"default_value": 0,
"minimum_value": "0",
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false,
"settable_globally": false
}
}
},
"platform_adhesion":
{
"label": "Build Plate Adhesion",
"type": "category",
"icon": "Adhesion",
"description": "Adhesion",
"children":
{
"extruder_prime_pos_x":
{
"label": "Extruder Prime X Position",
"description": "The X coordinate of the position where the nozzle primes at the start of printing.",
"type": "float",
"unit": "mm",
"default_value": 0,
"minimum_value_warning": "machine_nozzle_offset_x",
"maximum_value": "machine_width",
"settable_per_mesh": false,
"settable_per_extruder": true,
"enabled": false
},
"extruder_prime_pos_y":
{
"label": "Extruder Prime Y Position",
"description": "The Y coordinate of the position where the nozzle primes at the start of printing.",
"type": "float",
"unit": "mm",
"default_value": 0,
"minimum_value_warning": "machine_nozzle_offset_y",
"maximum_value_warning": "machine_depth",
"settable_per_mesh": false,
"settable_per_extruder": true,
"enabled": false
}
}
},
"material":
{
"label": "Material",
"icon": "Spool",
"description": "Material",
"type": "category",
"children":
{
"material_diameter":
{
"label": "Diameter",
"description": "Adjusts the diameter of the filament used. Match this value with the diameter of the used filament.",
"unit": "mm",
"type": "float",
"default_value": 2.85,
"minimum_value": "0.0001",
"minimum_value_warning": "0.4",
"maximum_value_warning": "3.5",
"enabled": "machine_gcode_flavor != \"UltiGCode\"",
"settable_per_mesh": false,
"settable_per_extruder": true
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -1102,6 +1102,26 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
parameters: {
type: 'object', required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
material: { type: 'string', enum: ['PLA', 'PETG'], description: '(prepare, 선택) 소재. 기본 PLA(K2 실측 온도값 검증됨). PETG는 일반적인 추정 온도라 품질 이슈 있으면 조정 필요.' },
filename: { type: 'string', description: '(start 필수) prepare가 반환한 파일명' },
confirm: { type: 'boolean', description: '(start 필수) 반드시 true — 사용자가 출력 시작을 명시적으로 승인했을 때만 true로 호출' },
},
},
},
},
{
type: 'function',
function: {
+4
View File
@@ -175,6 +175,9 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// 돌았는데, 바로 다음 "아니다 cam2" / "사진 보여줘" 후속턴엔 키워드가 없어 도구가 사라지자
// 모델이 "저는 그런 툴이 없습니다"라고 3턴 연속 답했다. 스테일 매치 비용은 작은 스키마 토큰뿐.
const hasCctvKeyword = /cctv|감시\s*카메라|카메라(로|에|를|\s|$)|웹캠|현관(?:문|앞|쪽)?|마당|주차장|차고|대문|택배|초인종|스냅샷|캡처해|누가?\s*(?:왔|있|지나)|누구\s*(?:있|왔)|밖에?\s*(?:누구|뭐|무슨)|집\s*(?:앞|밖|상황)|\bcam\s*\d|채널\s*\d.*(?:봐|보여|확인|찍)|door\s*cam|security\s*cam|who'?s?\s*(?:at\s*the\s*door|outside)/i.test(topicText);
// print3d_model — STL 슬라이싱+K2 프린터 업로드. topicText로 검사(prepare→start 확인
// 왕복이 여러 턴 걸치므로 nvr_snapshot과 같은 이유로 최근 대화 전체를 봄).
const hasPrint3dKeyword = /3d\s*프린트|3d\s*print|stl|슬라이싱|슬라이서|g-?code|출력해|프린터로|k2\s*(?:콤보|프린터)?/i.test(topicText);
// 2026-08-10: news_search was gated on the literal word "뉴스" (hasNewsKeyword below), so a
// disaster question with no news-flavored wording ("돌핀 지금 어디 있지?") never got access to
// it — even after handle-chat.ts's reminder started explicitly recommending news_search for
@@ -365,6 +368,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if (name === 'schedule_job' && !hasScheduleKeyword) return false;
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 === 'run_command' && !hasRunCommandKeyword) return false;
return true;
};
+263
View File
@@ -0,0 +1,263 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
// K2 콤보(Creality, Klipper+Moonraker)로 STL을 찾아서 바로 슬라이싱→업로드까지 자동화.
// 2026-09-20: STL URL을 받아 CuraEngine(apt cura-engine, 클로서버에 설치)으로 슬라이싱하고
// K2의 Moonraker(192.168.0.114:7125)에 업로드한다. 실제 출력 시작은 반드시 confirm:true
// 재호출을 거쳐야 함(schedule_job의 확인 패턴과 동일) — 재료/시간 낭비 위험이 있는 물리적
// 동작이라 자동시작 금지.
//
// 프린터 프로파일 값은 추측이 아니라 K2에 이미 올라가 있던 Creality Print 슬라이싱 샘플
// (server/files/gcodes/3DBench_PLA_22m.gcode)의 실제 헤더/설정 주석에서 그대로 뽑아온 것:
// nozzle_diameter=0.4, filament_diameter=1.75, nozzle_temperature=220(PLA),
// first_layer_bed_temperature=50, retraction_length=0.8/speed=40,
// machine_max_speed_x/y=800·z=5·e=50, machine_start_gcode가 Klipper 매크로
// START_PRINT/END_PRINT를 호출하는 방식(Marlin 순정 G-code가 아님 — 이 프린터는 그 매크로
// 안에서 오토레벨링 등을 처리하므로 반드시 이 매크로를 통해야 함).
const K2_HOST = '192.168.0.114';
const K2_MOONRAKER_PORT = 7125;
const CURA_ENGINE_BIN = '/usr/bin/CuraEngine';
// dist/tools/print3d.js -> ../../resources/cura (repo root의 resources/, web-ui와 같은 패턴)
const CURA_RESOURCES_DIR = path.join(__dirname, '..', '..', 'resources', 'cura');
const FDMPRINTER_DEF = path.join(CURA_RESOURCES_DIR, 'fdmprinter.def.json');
const FDMEXTRUDER_DEF = path.join(CURA_RESOURCES_DIR, 'fdmextruder.def.json');
interface MaterialPreset { nozzleTemp: number; bedTemp: number; verified: boolean; }
// PLA는 K2 실제 슬라이싱 샘플에서 그대로 확인된 값(검증됨). PETG는 검증 안 된 일반적인
// 추정치 — 품질 문제 생기면 조정 필요하다고 도구 설명에 명시해둠.
const MATERIAL_PRESETS: Record<string, MaterialPreset> = {
PLA: { nozzleTemp: 220, bedTemp: 50, verified: true },
PETG: { nozzleTemp: 235, bedTemp: 75, verified: false },
};
function buildStartGcode(nozzleTemp: number, bedTemp: number): string {
// K2 실제 샘플 machine_start_gcode 그대로(OrcaSlicer식 대괄호 플레이스홀더만 실제 값으로
// 치환 — CuraEngine은 그 문법을 모르므로 직접 값을 박아넣어야 함).
return [
'M140 S0',
'M104 S0',
`START_PRINT EXTRUDER_TEMP=${nozzleTemp} BED_TEMP=${bedTemp}`,
'T0',
`M104 S${nozzleTemp}`,
'M204 S2000',
'G1 Z3 F600',
'M83',
'G1 Y150 F12000',
'G1 X0 F12000',
'G1 Z0.2 F600',
'G1 X0 Y150 F6000',
'G1 X0 Y0 E15 F6000',
'G1 X150 Y0 E15 F6000',
'G92 E0',
'G1 Z1 F600',
].join('\n');
}
const END_GCODE = 'END_PRINT';
function fmtDuration(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
if (h > 0) return `${h}시간 ${m}분`;
return `${m}분`;
}
async function downloadStl(url: string, outPath: string): Promise<{ ok: true } | { ok: false; detail: string }> {
let u: URL;
try { u = new URL(url); } catch { return { ok: false, detail: 'URL 형식이 잘못됐습니다.' }; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, detail: 'http/https URL만 지원합니다.' };
let res: Response;
try {
res = await fetch(url, { signal: AbortSignal.timeout(30_000), redirect: 'follow' });
} catch (e: any) {
return { ok: false, detail: `다운로드 실패: ${String(e?.message || e)}` };
}
if (!res.ok || !res.body) return { ok: false, detail: `다운로드 실패: HTTP ${res.status}` };
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — STL이 아닌 것 같습니다.' };
if (buf.length > 100 * 1024 * 1024) return { ok: false, detail: '파일이 100MB를 넘어 너무 큽니다.' };
// Thingiverse 401 응답처럼 인증벽에 막혀 HTML 에러페이지가 대신 내려오는 경우를 거른다.
const head = buf.subarray(0, 200).toString('utf8').toLowerCase();
if (head.includes('<html') || head.includes('<!doctype')) {
return { ok: false, detail: '내려받은 내용이 STL이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드 링크일 수 있습니다). 다른 출처(Printables, GitHub raw 등)를 시도하세요.' };
}
fs.writeFileSync(outPath, buf);
return { ok: true };
}
function runCuraEngine(args: string[], timeoutMs: number): Promise<{ ok: true; stdout: string } | { ok: false; detail: string }> {
return new Promise((resolve) => {
const proc = spawn(CURA_ENGINE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString('utf8'); });
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString('utf8'); if (stderr.length > 4000) stderr = stderr.slice(-4000); });
const killTimer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, timeoutMs);
proc.on('error', (e) => { clearTimeout(killTimer); resolve({ ok: false, detail: String(e.message || e) }); });
proc.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0) resolve({ ok: true, stdout });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `CuraEngine exit ${code}` });
});
});
}
async function uploadToMoonraker(filePath: string, filename: string): Promise<{ ok: true } | { ok: false; detail: string }> {
try {
const buf = fs.readFileSync(filePath);
const form = new FormData();
form.append('root', 'gcodes');
form.append('file', new Blob([buf]), filename);
const res = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/server/files/upload`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return { ok: false, detail: `Moonraker 업로드 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
return { ok: true };
} catch (e: any) {
return { ok: false, detail: `Moonraker 업로드 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
}
}
export const print3dModelTool = {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
schema: { action: 'string', stl_url: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
material: { type: 'string', enum: ['PLA', 'PETG'], description: '(prepare, 선택) 소재. 기본 PLA(K2 실측 온도값 검증됨). PETG는 일반적인 추정 온도라 품질 이슈 있으면 조정 필요.' },
filename: { type: 'string', description: '(start 필수) prepare가 반환한 파일명' },
confirm: { type: 'boolean', description: '(start 필수) 반드시 true — 사용자가 출력 시작을 명시적으로 승인했을 때만 true로 호출' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const action = String(args?.action || '');
if (action === 'start') {
const filename = String(args?.filename || '').trim();
if (!filename) return { success: false, error: 'filename이 필요합니다 (prepare 결과에서 받은 파일명).' };
if (args?.confirm !== true) {
return {
success: false,
error: '출력 시작에는 사용자 확인이 필요합니다. 예상 출력시간/필라멘트 사용량을 사용자에게 보여주고 "출력 시작할까요?"라고 물어본 뒤, 사용자가 승인하면 confirm=true로 다시 호출하세요.',
};
}
try {
const res = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/printer/print/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return { success: false, error: `출력 시작 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
return { success: true, stdout: `K2 콤보에서 "${filename}" 출력을 시작했습니다.` };
} catch (e: any) {
return { success: false, error: `출력 시작 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
}
}
if (action !== 'prepare') {
return { success: false, error: 'action은 "prepare" 또는 "start"여야 합니다.' };
}
const stlUrl = String(args?.stl_url || '').trim();
if (!stlUrl) return { success: false, error: 'stl_url이 필요합니다.' };
if (!fs.existsSync(FDMPRINTER_DEF) || !fs.existsSync(FDMEXTRUDER_DEF)) {
return { success: false, error: `CuraEngine 설정 파일을 찾을 수 없습니다: ${CURA_RESOURCES_DIR}` };
}
if (!fs.existsSync(CURA_ENGINE_BIN)) {
return { success: false, error: `CuraEngine이 설치돼있지 않습니다(${CURA_ENGINE_BIN}). "sudo apt-get install -y cura-engine" 필요.` };
}
const materialKey = String(args?.material || 'PLA').toUpperCase();
const material = MATERIAL_PRESETS[materialKey];
if (!material) return { success: false, error: `지원하지 않는 소재: ${materialKey}. PLA 또는 PETG.` };
const layerHeight = Number.isFinite(Number(args?.layer_height)) ? Number(args.layer_height) : 0.2;
const infillPercent = Number.isFinite(Number(args?.infill_percent)) ? Number(args.infill_percent) : 15;
const wallCount = Number.isFinite(Number(args?.wall_count)) ? Math.max(1, Math.round(Number(args.wall_count))) : 2;
const workspacePath = getWorkspacePath(args);
const outDir = path.join(workspacePath, 'print3d');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `model_${stamp}.stl`);
const dl = await downloadStl(stlUrl, stlPath);
if (!dl.ok) return { success: false, error: dl.detail };
const gcodeFilename = `print3d_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
const startGcode = buildStartGcode(material.nozzleTemp, material.bedTemp);
const curaArgs = [
'slice', '-v',
'-j', FDMPRINTER_DEF,
// K2 콤보 실측값(3DBench_PLA_22m.gcode 헤더 주석에서 확인) — 빌드볼륨/속도/리트랙션.
'-s', 'machine_width=260', '-s', 'machine_depth=260', '-s', 'machine_height=260',
'-s', 'machine_center_is_zero=false',
'-s', 'machine_heated_bed=true',
'-s', 'machine_gcode_flavor=Marlin',
'-s', 'machine_max_feedrate_x=800', '-s', 'machine_max_feedrate_y=800',
'-s', 'machine_max_feedrate_z=5', '-s', 'machine_max_feedrate_e=50',
'-s', `machine_start_gcode=${startGcode}`,
'-s', `machine_end_gcode=${END_GCODE}`,
'-s', `layer_height=${layerHeight}`, '-s', `layer_height_0=${layerHeight}`,
'-s', `wall_line_count=${wallCount}`,
'-s', `infill_sparse_density=${infillPercent}`,
'-s', 'infill_pattern=grid',
'-s', 'adhesion_type=skirt',
'-s', `material_print_temperature=${material.nozzleTemp}`,
'-s', `material_print_temperature_layer_0=${material.nozzleTemp}`,
'-s', `material_bed_temperature=${material.bedTemp}`,
'-s', `material_bed_temperature_layer_0=${material.bedTemp}`,
'-e0', '-j', FDMEXTRUDER_DEF,
'-s', 'machine_nozzle_size=0.4',
'-s', 'material_diameter=1.75',
'-s', 'retraction_amount=0.8',
'-s', 'retraction_speed=40',
'-l', stlPath,
'-o', gcodePath,
];
const slice = await runCuraEngine(curaArgs, 5 * 60_000);
if (!slice.ok) return { success: false, error: `슬라이싱 실패: ${slice.detail}` };
if (!fs.existsSync(gcodePath) || fs.statSync(gcodePath).size < 100) {
return { success: false, error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' };
}
// CuraEngine은 "Print time (s):" 요약을 stderr(verbose 로그)에 찍고, stdout엔 아무것도
// 안 남긴다 — 대신 생성된 G-code 자체의 헤더 주석(;TIME:초)에서 직접 읽는 게 더 안정적.
const gcodeHead = fs.readFileSync(gcodePath, 'utf8').slice(0, 2000);
const timeMatch = gcodeHead.match(/;TIME:(\d+)/);
const printTimeSec = timeMatch ? Number(timeMatch[1]) : null;
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return { success: false, error: `${up.detail} (슬라이싱은 성공, G-code는 ${gcodePath}에 있음)` };
const timeStr = printTimeSec != null ? fmtDuration(printTimeSec) : '알 수 없음';
const materialNote = material.verified ? '' : ' (⚠️ 온도값은 검증되지 않은 일반 추정치입니다)';
return {
success: true,
stdout: `K2 콤보에 업로드 완료: **${gcodeFilename}**\n` +
`- 소재: ${materialKey}${materialNote}\n` +
`- 예상 출력시간: ${timeStr}\n` +
`- 설정: 레이어 ${layerHeight}mm, 벽 ${wallCount}겹, 인필 ${infillPercent}%\n\n` +
`사용자에게 위 정보를 보여주고 출력을 시작할지 확인하세요. 승인하면 print3d_model(action="start", filename="${gcodeFilename}", confirm=true)로 출력을 시작하세요.`,
};
},
};
+2
View File
@@ -31,6 +31,7 @@ import { excelReadTool, excelWriteTool } from './excel.js';
import { cmsHospitalCompareTool } from './cms.js';
import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
export interface Tool {
name: string;
@@ -236,6 +237,7 @@ class ToolRegistry {
this.registerSafe(satelliteSnapshotTool);
// 집 CCTV 한 프레임 캡처 → 비전 모델이 직접 확인 (감시카메라/마당/현관/택배 확인 요청)
this.registerSafe(nvrSnapshotTool);
this.registerSafe(print3dModelTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)