refactor: print3d_model 슬라이서를 CuraEngine에서 PrusaSlicer로 교체

CuraEngine CLI는 구조적으로 예상 출력시간 헤더가 항상 더미값(6666)만 남는
한계가 있어(33b5a25 참고) PrusaSlicer(apt, gcode-flavor=klipper 네이티브
지원)로 교체. DISPLAY 없이도 --export-gcode가 완전 헤드리스로 동작함을
확인했고, 슬라이싱 후 파일에 실제 "estimated printing time"이 직접
기록되므로 stderr 파싱 없이 파일에서 바로 읽으면 됨. K2 프로파일/시작-매크로
값은 그대로 유지. 더 이상 쓰지 않는 resources/cura/*.def.json 제거.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 13:29:05 +09:00
co-authored by Claude Sonnet 5
parent 33b5a258b2
commit cd4843d480
3 changed files with 57 additions and 8420 deletions
-257
View File
@@ -1,257 +0,0 @@
{
"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
+57 -62
View File
@@ -5,10 +5,18 @@ 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의 확인 패턴과 동일) — 재료/시간 낭비 위험이 있는 물리적
// 동작이라 자동시작 금지.
// 2026-09-20: STL URL을 받아 슬라이싱하고 K2의 Moonraker(192.168.0.114:7125)에 업로드한다.
// 실제 출력 시작은 반드시 confirm:true 재호출을 거쳐야 함(schedule_job의 확인 패턴과 동일)
// — 재료/시간 낭비 위험이 있는 물리적 동작이라 자동시작 금지.
//
// 슬라이서 = PrusaSlicer(apt prusa-slicer, 클로서버 설치, "with GUI support" 빌드지만
// DISPLAY 없이도 --export-gcode CLI는 완전히 헤드리스로 동작 확인됨).
// 처음엔 CuraEngine(apt cura-engine)을 썼으나 예상 출력시간이 항상 6666초로 나오는 버그가
// 있었음 — CuraEngine 소스 확인 결과 CLI 백엔드(CommandLine::isSequential()==true)가
// 슬라이싱 "시작 전"에 헤더를 미리 써버리고 끝난 뒤 실제값으로 되돌아가 덮어쓰는 코드가
// 없어서 항상 하드코딩된 더미값만 남는 구조적 한계였음(확률적 버그 아님). PrusaSlicer는
// 슬라이싱이 끝난 뒤 파일 끝부분에 실제 "estimated printing time"을 직접 써주므로 이 문제가
// 없다.
//
// 프린터 프로파일 값은 추측이 아니라 K2에 이미 올라가 있던 Creality Print 슬라이싱 샘플
// (server/files/gcodes/3DBench_PLA_22m.gcode)의 실제 헤더/설정 주석에서 그대로 뽑아온 것:
@@ -20,12 +28,7 @@ import { getWorkspacePath } from '../config/paths.js';
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');
const PRUSA_SLICER_BIN = '/usr/bin/prusa-slicer';
interface MaterialPreset { nozzleTemp: number; bedTemp: number; verified: boolean; }
// PLA는 K2 실제 슬라이싱 샘플에서 그대로 확인된 값(검증됨). PETG는 검증 안 된 일반적인
@@ -36,8 +39,8 @@ const MATERIAL_PRESETS: Record<string, MaterialPreset> = {
};
function buildStartGcode(nozzleTemp: number, bedTemp: number): string {
// K2 실제 샘플 machine_start_gcode 그대로(OrcaSlicer식 대괄호 플레이스홀더만 실제 값으로
// 치환 — CuraEngine은 그 문법을 모르므로 직접 값을 박아넣어야 함).
// K2 실제 샘플 machine_start_gcode 그대로(OrcaSlicer/PrusaSlicer식 대괄호 플레이스홀더
// 자리에 실제 값을 직접 박아넣음).
return [
'M140 S0',
'M104 S0',
@@ -66,6 +69,15 @@ function fmtDuration(sec: number): string {
return `${m}분`;
}
// PrusaSlicer가 파일 끝에 남기는 "estimated printing time (normal mode) = 1h 5m 30s" 형식을 초로 변환.
function parsePrusaTime(text: string): number | null {
const m = text.match(/estimated printing time \(normal mode\) = (?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s)?/);
if (!m) return null;
const h = Number(m[1] || 0), min = Number(m[2] || 0), s = Number(m[3] || 0);
if (!h && !min && !s) return null;
return h * 3600 + min * 60 + s;
}
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 형식이 잘못됐습니다.' }; }
@@ -89,21 +101,20 @@ async function downloadStl(url: string, outPath: string): Promise<{ ok: true } |
return { ok: true };
}
function runCuraEngine(args: string[], timeoutMs: number): Promise<{ ok: true; stdout: string; stderr: string } | { ok: false; detail: string }> {
function runPrusaSlicer(args: string[], timeoutMs: number): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
const proc = spawn(CURA_ENGINE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
// DISPLAY가 실려있으면 GUI 초기화를 시도할 수 있어 명시적으로 제거하고 완전 헤드리스로 강제.
const env = { ...process.env };
delete env.DISPLAY;
const proc = spawn(PRUSA_SLICER_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString('utf8'); });
// 실패시 에러메시지용으로 뒤쪽만 있으면 되지만, 성공시엔 "Print time (s): N" 요약 줄을
// 파싱해야 하므로(아래 CLI 헤더 버그 설명 참고) 여기서는 자르지 않고 전체 보존한다.
proc.stderr?.on('data', (d: Buffer) => { stderr += 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, stderr });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `CuraEngine exit ${code}` });
if (code === 0) resolve({ ok: true });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `prusa-slicer exit ${code}` });
});
});
}
@@ -178,11 +189,8 @@ export const print3dModelTool = {
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" 필요.` };
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return { success: false, error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}). "sudo apt-get install -y prusa-slicer" 필요.` };
}
const materialKey = String(args?.material || 'PLA').toUpperCase();
@@ -206,51 +214,38 @@ export const print3dModelTool = {
const gcodePath = path.join(outDir, gcodeFilename);
const startGcode = buildStartGcode(material.nozzleTemp, material.bedTemp);
const curaArgs = [
'slice', '-v',
'-j', FDMPRINTER_DEF,
const psArgs = [
'--export-gcode',
'--gcode-flavor', 'klipper',
// 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,
'--bed-shape', '0x0,260x0,260x260,0x260',
'--max-print-height', '260',
'--nozzle-diameter', '0.4',
'--filament-diameter', '1.75',
'--retract-length', '0.8', '--retract-speed', '40',
'--layer-height', String(layerHeight), '--first-layer-height', String(layerHeight),
'--perimeters', String(wallCount),
'--fill-density', `${infillPercent}%`, '--fill-pattern', 'grid',
'--skirts', '1',
'--temperature', String(material.nozzleTemp), '--first-layer-temperature', String(material.nozzleTemp),
'--bed-temperature', String(material.bedTemp), '--first-layer-bed-temperature', String(material.bedTemp),
'--start-gcode', startGcode,
'--end-gcode', END_GCODE,
'-o', gcodePath,
stlPath,
];
const slice = await runCuraEngine(curaArgs, 5 * 60_000);
const slice = await runPrusaSlicer(psArgs, 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 파일이 비정상입니다.' };
}
// 주의: G-code 파일 자체의 ";TIME:" 헤더는 CLI 모드에서 절대 신뢰할 수 없다 — CuraEngine
// CLI(CommandLine 백엔드)는 슬라이싱 "시작 전"에 헤더를 이미 써버리고(isSequential()이
// 항상 true라 processStartingCode에서 print_time 없이 getFileHeader 호출) 끝난 뒤에도
// 되돌아가 덮어쓰는 코드가 CuraEngine 소스 어디에도 없다 — 그래서 항상 코드에 하드코딩된
// 더미값(gcodeExport.cpp의 `;TIME:6666`)이 그대로 남는다(2026-09-20 소스 확인, 5.0.0).
// 실제 값은 슬라이싱이 끝난 뒤 stderr(verbose 로그, -v 필요)에 "Print time (s): N"으로만
// 찍히므로 거기서 파싱해야 한다.
const timeMatch = slice.stderr.match(/Print time \(s\):\s*(\d+)/);
const printTimeSec = timeMatch ? Number(timeMatch[1]) : null;
// PrusaSlicer는 슬라이싱이 끝난 뒤 파일 마지막 부분(전체 설정값 덤프보다 앞)에 실제
// 예상 출력시간을 직접 써준다 — CuraEngine처럼 시작 전에 더미값을 박아넣고 끝나지 않는
// 구조가 아니다. 뒤따르는 설정 덤프가 수백 줄로 커서 tail을 너무 짧게 자르면 이 줄을
// 놓치므로 파일 전체에서 찾는다.
const printTimeSec = parsePrusaTime(fs.readFileSync(gcodePath, 'utf8'));
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return { success: false, error: `${up.detail} (슬라이싱은 성공, G-code는 ${gcodePath}에 있음)` };