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:
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)로 출력을 시작하세요.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user