diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index 96f2c0f..fa1ed1b 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -2,9 +2,11 @@ import express, { Express, Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import { getUserWorkspace } from '../../config/config'; +import { isPathInsideDir } from '../../tools/image'; import { - PRUSA_SLICER_BIN, MATERIAL_PRESETS, buildBaseArgs, runPrusaSlicer, uploadToMoonraker, - parsePrusaTime, FAST_SPEED_ARGS, + PRUSA_SLICER_BIN, runPrusaSlicer, uploadToMoonraker, + parsePrusaTime, + K2_BASE_INI, parseIni, buildProfileIni, profileSliceArgs, } from '../../tools/k2-slicer-core'; // K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만 @@ -18,6 +20,21 @@ const K2_CAM_PORT = 8000; const K2_MOONRAKER_HOST = '192.168.0.114'; const K2_MOONRAKER_PORT = 7125; +// 저장 프로파일 디렉토리(/print3d/profiles/)와 이름 검증. `__current`는 +// 에디터가 임시로 현재 편집값을 저장하는 세션 프로파일. +const PROFILE_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/; +function profilesDir(workspacePath: string): string { + return path.join(workspacePath, 'print3d', 'profiles'); +} +function profilePath(workspacePath: string, name: string): string { + const p = path.join(profilesDir(workspacePath), `${name}.ini`); + if (!isPathInsideDir(profilesDir(workspacePath), p)) throw new Error('경로가 프로파일 디렉토리 밖입니다.'); + return p; +} +function validProfileName(name: string): boolean { + return PROFILE_NAME_RE.test(name) && name !== '__current'; +} + export function registerK2Routes(app: Express): void { // Moonraker 상태(print_stats/virtual_sdcard)를 대시보드 위젯용으로 요약해서 반환 — // 브라우저가 K2 LAN IP를 직접 못 치니(HTTPS 페이지에서 mixed-content) 서버가 대신 조회. @@ -65,11 +82,82 @@ export function registerK2Routes(app: Express): void { } }); - // 웹 UI(k2-app.html)의 "슬라이싱" 패널 — STL을 로컬 PC에서 직접 업로드해서 슬라이싱+K2 - // 업로드까지 한번에 한다(print3d_model 채팅도구와 로직 공유, k2-slicer-core.ts). 이 - // 리포는 별도 multipart 파서 라이브러리가 없어서(routes-doctor-cases.ts처럼 직접 - // 파싱하는 대신), 설정값은 쿼리스트링으로 받고 바디는 STL 원본 바이트 그대로 받는다 - // (WOPI 콜백과 같은 express.raw({type:()=>true}) 패턴). + // ── 슬라이싱 프로파일 라우트(풀 에디터용) ────────────────────────────────── + // 베이스 프로파일값(에디터 초기 채움용). start_gcode/end_gcode는 canonical 강제값이라 + // 클라이언트가 받아도 편집에 노출하지 않는다. + app.get('/api/k2/profile/base', (req: Request, res: Response) => { + if (!(req as any).user) return res.status(401).json({ error: 'Unauthorized' }); + res.json({ values: parseIni(K2_BASE_INI) }); + }); + + // 저장 프로파일 목록. + app.get('/api/k2/profiles', (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + try { + const dir = profilesDir(getUserWorkspace(user.username)); + const names: string[] = []; + if (fs.existsSync(dir)) { + for (const f of fs.readdirSync(dir)) { + if (f.endsWith('.ini')) names.push(f.slice(0, -4)); + } + } + res.json({ profiles: names.filter((n) => n !== '__current').sort() }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } + }); + + // 저장 프로파일 불러오기(머지된 ini 파싱값). + app.get('/api/k2/profiles/:name', (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + const name = String(req.params.name || ''); + if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름.' }); + try { + const p = profilePath(getUserWorkspace(user.username), name); + if (!fs.existsSync(p)) return res.status(404).json({ error: '프로파일이 없습니다.' }); + res.json({ values: parseIni(fs.readFileSync(p, 'utf8')) }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } + }); + + // 프로파일 저장(오버라이드 맵 → 머지 ini 파일). __current도 허용. + app.post('/api/k2/profiles/:name', express.json({ limit: '256kb' }), (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + const name = String(req.params.name || ''); + if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름(영문/숫자/밑줄/하이픈, 최대 64자).' }); + const overrides = (req.body && typeof req.body === 'object' && req.body.overrides && typeof req.body.overrides === 'object') ? req.body.overrides : null; + if (!overrides) return res.status(400).json({ error: 'overrides 객체가 필요합니다.' }); + try { + const dir = profilesDir(getUserWorkspace(user.username)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(profilePath(getUserWorkspace(user.username), name), buildProfileIni(overrides), 'utf8'); + res.json({ success: true, name }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } + }); + + app.delete('/api/k2/profiles/:name', (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + const name = String(req.params.name || ''); + if (!validProfileName(name)) return res.status(400).json({ error: '잘못된 프로파일 이름.' }); + try { + const p = profilePath(getUserWorkspace(user.username), name); + if (fs.existsSync(p)) fs.unlinkSync(p); + res.json({ success: true }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } + }); + + // 웹 UI(k2-app.html) 풀 프로파일 에디터의 슬라이싱 — 저장된 프로파일(--load )로 + // 슬라이싱+K2 업로드. 바디는 STL 원본 바이트, 설정은 ?profile=&qty= 쿼리로. + // (print3d_model 채팅도구는 buildBaseArgs 하드코딩 경로를 그대로 쓴다 — 동작 변경 회피.) app.post('/api/k2/slice', express.raw({ type: () => true, limit: '100mb' }), async (req: Request, res: Response) => { const user = (req as any).user; if (!user) return res.status(401).json({ error: 'Unauthorized' }); @@ -82,20 +170,24 @@ export function registerK2Routes(app: Express): void { return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` }); } - const materialKey = String(req.query.material || 'PLA').toUpperCase(); - const material = MATERIAL_PRESETS[materialKey]; - if (!material) return res.status(400).json({ error: `지원하지 않는 소재: ${materialKey}. PLA 또는 PETG.` }); - - const layerHeight = Number.isFinite(Number(req.query.layer_height)) ? Number(req.query.layer_height) : 0.2; - const infillPercent = Number.isFinite(Number(req.query.infill_percent)) ? Number(req.query.infill_percent) : 15; - const wallCount = 2; - const speed = req.query.speed === 'fast' ? 'fast' : 'normal'; - const support = req.query.support === '1' || req.query.support === 'true'; + const profileName = String(req.query.profile || '__current'); + if (!validProfileName(profileName) && profileName !== '__current') { + return res.status(400).json({ error: '잘못된 프로파일 이름.' }); + } + // 소재는 프로파일 ini의 filament_type/temperature 값에서 읽는다(에디터에서 설정). + // 응답 표시용 라벨만 여기서 뽑는다. // 여러 파츠를 한 판에 합치는 --merge는 이 헤드리스 환경에서 세그폴트가 나서(2026-09-20 // 확인) 지원 안 함 — 같은 STL 반복 출력만 --duplicate로 지원(크래시 없음, 검증됨). const qty = Math.max(1, Math.min(20, Number.isFinite(Number(req.query.qty)) ? Math.round(Number(req.query.qty)) : 1)); const workspacePath = getUserWorkspace(user.username); + const profPath = profilePath(workspacePath, profileName); + if (!fs.existsSync(profPath)) { + return res.status(400).json({ error: `프로파일 '${profileName}'이 없습니다. 먼저 저장하거나 기본값으로 슬라이싱하세요.` }); + } + const profValues = parseIni(fs.readFileSync(profPath, 'utf8')); + const materialKey = String(profValues.filament_type || 'PLA').toUpperCase(); + const outDir = path.join(workspacePath, 'print3d'); fs.mkdirSync(outDir, { recursive: true }); const stamp = Date.now(); @@ -106,12 +198,8 @@ export function registerK2Routes(app: Express): void { const gcodePath = path.join(outDir, gcodeFilename); const psArgs = [ - ...buildBaseArgs({ material, layerHeight, infillPercent, wallCount }), - ...(support ? ['--support-material'] : []), - ...(speed === 'fast' ? FAST_SPEED_ARGS : []), + ...profileSliceArgs(profPath, gcodePath, stlPath), ...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []), - '-o', gcodePath, - stlPath, ]; const slice = await runPrusaSlicer(psArgs, 5 * 60_000); diff --git a/src/tools/k2-slicer-core.ts b/src/tools/k2-slicer-core.ts index 4e1a186..eb7f6c8 100644 --- a/src/tools/k2-slicer-core.ts +++ b/src/tools/k2-slicer-core.ts @@ -132,3 +132,203 @@ export const FAST_SPEED_ARGS = [ '--infill-speed', '200', '--solid-infill-speed', '150', '--top-solid-infill-speed', '100', '--travel-speed', '300', '--bridge-speed', '30', ]; + +// ── 프로파일 기반 슬라이싱(--load ) ─────────────────────────────────────── +// 2026-09-20 추가. 위 buildBaseArgs 하드코딩 방식은 노출 파라미터가 한정되는 구조적 +// 한계가 있어, 웹 UI의 풀 프로필 에디터(k2-app.html)는 PrusaSlicer의 --load +// 모드로 전환. 베이스 ini를 TS 문자열로 임베드(빌드 에셋 복사 이슈 회피). +// +// 주의: k2_config.ini(워크스페이스) 원본의 start_gcode는 M140/M104/START_PRINT 3줄이지만, +// buildStartGcode()에 있는 K2 프라이밍 시퀀스(G1 Z3, Y150, E15 라인 등 13줄)가 실제 +// 출력에 필수다. 그래서 K2_START_GCODE_PLACEHOLDER에 프라이밍 전체를 placeholder형 +//([first_layer_temperature] 등)으로 박아두고, buildProfileIni가 매번 start_gcode/end_gcode를 +// 이 canonical 값으로 강제 덮어쓴다 — 에디터에서 start_gcode 편집 불가(너무 고급). + +// K2_START_GCODE_PLACEHOLDER의 후행 M104 S는 실제 출력 온도([temperature])를 따름 +// (first_layer 온도는 START_PRINT 매크로에 넘김). 원래 buildStartGcode는 first_layer== +// temperature로 같은 값을 썼으므로 동등. +const K2_START_GCODE_PLACEHOLDER = [ + 'M140 S0', + 'M104 S0', + 'START_PRINT EXTRUDER_TEMP=[first_layer_temperature] BED_TEMP=[first_layer_bed_temperature]', + 'T0', + 'M104 S[temperature]', + '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'); + +// 큐레이션된 베이스 프로파일 — 약 60개 핵심 키. PrusaSlicer 기본값에 맡겨도 되는 +// machine_max_* 등은 제외. 기본값은 "보통" 속도·서포트 끔(buildBaseArgs 경로와 동작 일치) +// 으로 잡고, "속성" 프리셋은 에디터 오버라이드로 적용. +export const K2_BASE_INI = `# K2 콤보 베이스 슬라이싱 프로파일 (homeclaw 큐레이션, PrusaSlicer 2.9.4) +bed_shape = 0x0,260x0,260x260,0x260 +max_print_height = 260 +nozzle_diameter = 0.4 +filament_diameter = 1.75 +filament_type = PLA +gcode_flavor = klipper +extrusion_multiplier = 1 + +# 레이어/벽/인필 +layer_height = 0.2 +first_layer_height = 0.2 +perimeters = 2 +fill_density = 15% +fill_pattern = grid +top_fill_pattern = monotonic +bottom_fill_pattern = monotonic +top_solid_layers = 3 +bottom_solid_layers = 3 +fill_angle = 45 +infill_overlap = 25% + +# 온도(PLA 기본) +temperature = 220 +first_layer_temperature = 220 +bed_temperature = 50 +first_layer_bed_temperature = 50 + +# 속도(보통) +perimeter_speed = 60 +external_perimeter_speed = 50 +small_perimeter_speed = 25 +infill_speed = 80 +solid_infill_speed = 80 +top_solid_infill_speed = 40 +travel_speed = 130 +bridge_speed = 30 +first_layer_speed = 30 +gap_fill_speed = 20 +max_print_speed = 80 + +# 서포트(끔) +support_material = 0 +support_material_pattern = rectilinear +support_material_style = grid +support_material_threshold = 0 +support_material_buildplate_only = 0 +support_material_spacing = 2.5 +support_material_interface_layers = 3 +support_material_speed = 60 +support_material_xy_spacing = 50% +support_material_with_sheath = 1 +support_material_closing_radius = 2 +overhangs = 1 + +# 브림/래프트/스커트 +brim_width = 0 +brim_type = outer_only +brim_separation = 0 +raft_layers = 0 +raft_expansion = 1.5 +skirts = 1 +skirt_distance = 6 +skirt_height = 1 +min_skirt_length = 0 + +# 팬/냉각 +cooling = 1 +fan_below_layer_time = 60 +min_fan_speed = 35 +max_fan_speed = 100 +disable_fan_first_layers = 3 +full_fan_speed_layer = 0 +fan_always_on = 0 +bridge_fan_speed = 100 +slowdown_below_layer_time = 5 +min_print_speed = 10 + +# 표면 품질 +ironing = 0 +ironing_type = top +ironing_flowrate = 15% +ironing_spacing = 0.1 +ironing_speed = 15 +seam_position = aligned +fuzzy_skin = none +fuzzy_skin_thickness = 0.3 +fuzzy_skin_point_dist = 0.8 +elefant_foot_compensation = 0 +xy_size_compensation = 0 +thick_bridges = 1 +ensure_vertical_shell_thickness = enabled +thin_walls = 1 +gap_fill_enabled = 1 + +# 리트랙션 +retract_length = 0.8 +retract_speed = 40 +retract_lift = 0 +retract_before_travel = 2 +retract_layer_change = 0 +deretract_speed = 0 + +# 고급 +perimeter_generator = arachne +variable_layer_height = 1 +spiral_vase = 0 +single_extruder_multi_material = 0 +wipe = 0 +complete_objects = 0 +extra_perimeters = 1 +avoid_crossing_perimeters = 0 + +# G-code(start/end은 buildProfileIni가 canonical값으로 강제 덮어쓰므로 여기 값은 무시됨) +start_gcode = ${K2_START_GCODE_PLACEHOLDER} +end_gcode = END_PRINT +before_layer_gcode = +layer_gcode = +color_change_gcode = M600 +pause_print_gcode = M601 +template_custom_gcode = +output_filename_format = [input_filename_base].gcode +binary_gcode = 0 +gcode_comments = 0 +`; + +// ini 텍스트를 key→value 맵으로 파싱(# 주석·빈 줄 무시). +export function parseIni(text: string): Record { + const out: Record = {}; + for (const line of text.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#') || t.startsWith(';')) continue; + const eq = t.indexOf('='); + if (eq < 0) continue; + const k = t.slice(0, eq).trim(); + const v = t.slice(eq + 1).trim(); + if (k) out[k] = v; + } + return out; +} + +// 베이스 ini에 오버라이드를 머지한 ini 텍스트 생성. start_gcode/end_gcode는 항상 +// canonical값으로 강제(에디터가 건드릴 수 없는 키). 오버라이드 값은 이미 ini 포맷 +// 문자열(퍼센트·1/0 등 호출부가 포맷팅)이어야 한다. +export function buildProfileIni(overrides: Record): string { + const base = parseIni(K2_BASE_INI); + for (const [k, v] of Object.entries(overrides)) { + if (!k) continue; + // start_gcode/end_gcode 편집은 에디터에 노출되지 않지만, 만약 들어오면 무시. + if (k === 'start_gcode' || k === 'end_gcode') continue; + base[k] = v; + } + // canonical G-code 강제 주입. + base.start_gcode = K2_START_GCODE_PLACEHOLDER; + base.end_gcode = END_GCODE; + // key를 알파벳순으로 정렬해 deterministic한 출력(디버그·diff 용이). + return Object.keys(base).sort().map((k) => `${k} = ${base[k]}`).join('\n') + '\n'; +} + +// --load 방식 슬라이싱의 CLI 인자. 중복(--duplicate)은 호출부가 덧붙임. +export function profileSliceArgs(profileIniPath: string, gcodePath: string, stlPath: string): string[] { + return ['--load', profileIniPath, '--export-gcode', '-o', gcodePath, stlPath]; +} diff --git a/web-ui/html/k2-app.html b/web-ui/html/k2-app.html index 5b6ea28..c4befe3 100644 --- a/web-ui/html/k2-app.html +++ b/web-ui/html/k2-app.html @@ -30,20 +30,22 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .err.show{display:flex;} .err a{color:var(--brand);} -/* ── 슬라이싱 모달 ── */ +/* ── 슬라이싱 프로파일 에디터 모달 ── */ .slice-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:50;align-items:center;justify-content:center;padding:16px;} .slice-overlay.show{display:flex;} -.slice-modal{width:100%;max-width:380px;max-height:90vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;} +.slice-modal{width:100%;max-width:720px;max-height:92vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;} .slice-modal h2{font-size:14px;margin:0 0 12px;} .slice-field{margin-bottom:10px;} .slice-field label{display:block;font-size:11px;color:var(--muted);margin-bottom:3px;} -.slice-field input[type=file],.slice-field input[type=number],.slice-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;} +.slice-field input[type=file],.slice-field input[type=number],.slice-field input[type=text],.slice-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;} .slice-field input:focus,.slice-field select:focus{border-color:var(--brand);} .slice-row{display:flex;gap:8px;} .slice-row .slice-field{flex:1;} -.slice-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);margin-bottom:10px;} -.slice-btn{width:100%;background:var(--brand);border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;} +.slice-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);margin-bottom:8px;} +.slice-btn{background:var(--brand);border:none;border-radius:8px;padding:9px 14px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;} .slice-btn:disabled{opacity:.5;cursor:not-allowed;} +.slice-btn.ghost{background:none;border:1.5px solid var(--line);color:var(--muted);font-weight:600;padding:6px 10px;font-size:11px;} +.slice-btn.ghost:hover{border-color:var(--brand);color:var(--brand);} .slice-close{float:right;background:none;border:none;font-size:16px;color:var(--muted);cursor:pointer;} .slice-result{margin-top:12px;padding:10px;background:var(--panel-2);border-radius:8px;font-size:12px;line-height:1.7;display:none;} .slice-result.show{display:block;} @@ -51,6 +53,19 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .slice-err.show{display:block;} .start-btn{width:100%;margin-top:8px;background:#16a34a;border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;} .start-btn:disabled{opacity:.5;cursor:not-allowed;} +.prof-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:12px;padding:10px;background:var(--panel-2);border-radius:8px;} +.prof-bar select{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;flex:1;min-width:120px;} +.prof-bar select:focus{border-color:var(--brand);} +.sec{border:1px solid var(--line);border-radius:8px;margin-bottom:8px;} +.sec>summary{cursor:pointer;padding:8px 12px;font-size:12px;font-weight:700;list-style:none;display:flex;align-items:center;gap:6px;} +.sec>summary::-webkit-details-marker{display:none;} +.sec>summary::before{content:'▸';color:var(--muted);transition:.15s;font-size:10px;} +.sec[open]>summary::before{content:'▾';} +.sec>summary:hover{color:var(--brand);} +.sec-body{padding:8px 12px 4px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;} +@media(max-width:600px){.sec-body{grid-template-columns:1fr 1fr;}.slice-modal{max-width:100%;}} +.sec-body .slice-field{margin-bottom:8px;} +.sec-tag{font-size:10px;color:var(--muted);background:var(--panel);padding:1px 6px;border-radius:4px;border:1px solid var(--line);margin-left:auto;font-weight:400;} /* ── CAD(STL 확인) 모달 ── */ .cad-modal{max-width:520px;} @@ -98,45 +113,31 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
-

🔪 STL 슬라이싱 → K2 업로드

-
- - +

🔧 슬라이싱 프로파일 에디터

+
+ 프로파일 + + + + +
-
-
- - -
-
- - +
+
+
+
+ + +
+
+ + +
+ +
+
-
-
- - -
-
- - -
-
-
- - -
- - -
-
@@ -247,11 +248,107 @@ function connectCam(){ function reconnectCam(){ connectCam(); } connectCam(); -// ── 슬라이싱 모달 ────────────────────────────────────────────────────────────── +// ── 슬라이싱 프로파일 에디터 ─────────────────────────────────────────────────── +// 2026-09-20: 단순 6파라미터 모달을 풀 프로파일 에디터로 교체. 서버는 --load +// 모드(/api/k2/profile/* + /api/k2/slice?profile=). 베이스값은 /api/k2/profile/base에서 +// 받아 필드에 채우고, 편집값은 __current 임시 프로파일로 저장 뒤 슬라이싱. + +// 소재 온도 프리셋(서버 MATERIAL_PRESETS와 동일값). +const MATERIAL_TEMP_PRESETS={ + PLA:{temperature:220,first_layer_temperature:220,bed_temperature:50,first_layer_bed_temperature:50,filament_type:'PLA'}, + PETG:{temperature:235,first_layer_temperature:235,bed_temperature:75,first_layer_bed_temperature:75,filament_type:'PETG'}, +}; +// 속도 프리셋 — 보통(PrusaSlicer 기본 근접) / 속성(K2 실측, FAST_SPEED_ARGS와 동일). +const SPEED_PRESETS={ + normal:{perimeter_speed:60,external_perimeter_speed:50,infill_speed:80,solid_infill_speed:80,top_solid_infill_speed:40,travel_speed:130,bridge_speed:30,first_layer_speed:30}, + fast:{perimeter_speed:150,external_perimeter_speed:100,infill_speed:200,solid_infill_speed:150,top_solid_infill_speed:100,travel_speed:300,bridge_speed:30,first_layer_speed:30}, +}; +const SPEED_KEYS=Object.keys(SPEED_PRESETS.normal); + +// 파라미터 스키마 — 섹션별. iniKey는 PrusaSlicer ini 키. virtual 필드는 ini에 안 쓰이고 +// 다른 필드들을 일괄 설정하는 프리셋 선택자. pct=true면 UI 숫자에 % 붙여 ini값 생성. +const PROF_SCHEMA=[ + {title:'기본',open:true,fields:[ + {key:'layer_height',label:'레이어 높이(mm)',type:'number',min:0.08,max:0.4,step:0.02}, + {key:'first_layer_height',label:'첫 레이어 높이(mm)',type:'number',min:0.1,max:0.6,step:0.05}, + {key:'perimeters',label:'벽 수',type:'number',min:1,max:8,step:1}, + {key:'fill_density',label:'인필(%)',type:'number',min:0,max:100,step:5,pct:true}, + {key:'fill_pattern',label:'인필 패턴',type:'select',opts:[['grid','그리드'],['gyroid','자이로이드'],['cubic','큐빅'],['triangles','삼각'],['honeycomb','허니콤'],['rectilinear','평행'],['monotonic','모노토닉']]}, + {key:'top_solid_layers',label:'윗면 층 수',type:'number',min:1,max:10,step:1}, + {key:'bottom_solid_layers',label:'바닥 층 수',type:'number',min:1,max:10,step:1}, + {key:'__speed_preset',label:'속도 프리셋',type:'select',virtual:true,opts:[['normal','보통'],['fast','속성(약 40% 단축)'],['custom','사용자정의']]}, + ]}, + {title:'소재·온도',fields:[ + {key:'__material',label:'소재 프리셋',type:'select',virtual:true,opts:[['PLA','PLA'],['PETG','PETG']]}, + {key:'filament_type',label:'필라멘트 종류',type:'select',opts:[['PLA','PLA'],['PETG','PETG'],['ABS','ABS'],['TPU','TPU']]}, + {key:'temperature',label:'노즐 온도(℃)',type:'number',min:150,max:290,step:1}, + {key:'first_layer_temperature',label:'첫 층 노즐 온도(℃)',type:'number',min:150,max:290,step:1}, + {key:'bed_temperature',label:'베드 온도(℃)',type:'number',min:0,max:120,step:1}, + {key:'first_layer_bed_temperature',label:'첫 층 베드 온도(℃)',type:'number',min:0,max:120,step:1}, + ]}, + {title:'서포트',fields:[ + {key:'support_material',label:'서포트 사용',type:'check'}, + {key:'support_material_style',label:'서포트 스타일',type:'select',opts:[['grid','그리드'],['organic','유기(트리)'],['tree','트리(구형)'],['snake','스네이크']]}, + {key:'support_material_threshold',label:'오버행 임계각(°)',type:'number',min:0,max:90,step:1}, + {key:'support_material_buildplate_only',label:'바닥면 전용',type:'check'}, + {key:'support_material_spacing',label:'서포트 간격(mm)',type:'number',min:0.5,max:10,step:0.5}, + {key:'support_material_interface_layers',label:'접촉면 층 수',type:'number',min:0,max:5,step:1}, + {key:'support_material_pattern',label:'서포트 패턴',type:'select',opts:[['rectilinear','평행'],['grid','그리드'],['honeycomb','허니콤'],['triangles','삼각']]}, + ]}, + {title:'브림/래프트/스커트',fields:[ + {key:'brim_width',label:'브림 폭(mm)',type:'number',min:0,max:20,step:0.5}, + {key:'brim_type',label:'브림 종류',type:'select',opts:[['outer_only','바깥만'],['inner_only','안쪽만'],['outer_and_inner','양쪽'],['no_brim','없음']]}, + {key:'raft_layers',label:'래프트 층 수',type:'number',min:0,max:10,step:1}, + {key:'skirts',label:'스커트 수',type:'number',min:0,max:5,step:1}, + {key:'skirt_distance',label:'스커트 거리(mm)',type:'number',min:0,max:20,step:1}, + ]}, + {title:'속도(상세, mm/s)',fields:[ + {key:'perimeter_speed',label:'외벽 속도',type:'number',min:10,max:300,step:5}, + {key:'external_perimeter_speed',label:'가장 바깥벽',type:'number',min:10,max:200,step:5}, + {key:'infill_speed',label:'인필 속도',type:'number',min:10,max:300,step:5}, + {key:'solid_infill_speed',label:'솔리드 인필',type:'number',min:10,max:300,step:5}, + {key:'top_solid_infill_speed',label:'윗면 인필',type:'number',min:10,max:200,step:5}, + {key:'travel_speed',label:'이동 속도',type:'number',min:50,max:500,step:10}, + {key:'bridge_speed',label:'브릿지 속도',type:'number',min:10,max:80,step:5}, + {key:'first_layer_speed',label:'첫 층 속도',type:'number',min:5,max:80,step:1}, + ]}, + {title:'팬/냉각',fields:[ + {key:'cooling',label:'층간 냉각',type:'check'}, + {key:'fan_below_layer_time',label:'팬 켜기 임계(초)',type:'number',min:0,max:120,step:1}, + {key:'min_fan_speed',label:'최소 팬(%)',type:'number',min:0,max:100,step:5,pct:true}, + {key:'max_fan_speed',label:'최대 팬(%)',type:'number',min:0,max:100,step:5,pct:true}, + {key:'disable_fan_first_layers',label:'처음 N층 팬 끔',type:'number',min:0,max:10,step:1}, + ]}, + {title:'표면 품질',fields:[ + {key:'ironing',label:'아이어닝(다림질)',type:'check'}, + {key:'ironing_type',label:'아이어닝 대상',type:'select',opts:[['top','윗면만'],['all','모든 솔리드'],['top_and_bottom','위아래'],['no','없음']]}, + {key:'seam_position',label:'심(이음) 위치',type:'select',opts:[['aligned','정렬'],['rear','뒤'],['random','무작위'],['nearest','가장 가까움']]}, + {key:'fuzzy_skin',label:'퍼지 스킨',type:'select',opts:[['none','없음'],['external','외부'],['all','전부']]}, + {key:'elefant_foot_compensation',label:'코끼리발 보정(mm)',type:'number',min:0,max:0.5,step:0.05}, + {key:'xy_size_compensation',label:'XY 크기 보정(mm)',type:'number',min:-1,max:1,step:0.05}, + ]}, + {title:'리트랙션',fields:[ + {key:'retract_length',label:'리트랙트 길이(mm)',type:'number',min:0,max:8,step:0.1}, + {key:'retract_speed',label:'리트랙트 속도(mm/s)',type:'number',min:10,max:120,step:5}, + {key:'retract_lift',label:'리트랙트 Z들기(mm)',type:'number',min:0,max:3,step:0.1}, + {key:'retract_before_travel',label:'이동 전 리트랙트(mm)',type:'number',min:0,max:10,step:0.5}, + ]}, + {title:'고급',fields:[ + {key:'perimeter_generator',label:'외벽 생성기',type:'select',opts:[['arachne','아라크네(가변폭)'],['classic','클래식']]}, + {key:'thick_bridges',label:'두꺼운 브릿지',type:'check'}, + {key:'ensure_vertical_shell_thickness',label:'수직껍질 두께 보장',type:'select',opts:[['enabled','켜기'],['disabled','끄기']]}, + {key:'variable_layer_height',label:'적응형 층높이',type:'check'}, + ]}, +]; + +let profBaseValues={}; // /api/k2/profile/base 파싱값 +let profLoaded=false; // base 로드 + 섹션 렌더링 완료 여부 + function openSliceModal(){ document.getElementById('slice-overlay').classList.add('show'); document.getElementById('slice-err').classList.remove('show'); document.getElementById('slice-result').classList.remove('show'); + if(!profLoaded){ profInit(); } else { profRefreshList(); } } function closeSliceModal(){ document.getElementById('slice-overlay').classList.remove('show'); } @@ -261,6 +358,178 @@ function fmtDurationSec(sec){ return m+'분'; } +function fieldId(key){ return 'prof-'+key; } + +function fieldInputHtml(f){ + const id=fieldId(f.key); + if(f.type==='check'){ + return ``; + } + let ctrl=''; + if(f.type==='select'){ + const opts=f.opts.map(([v,l])=>``).join(''); + ctrl=``; + }else{ + const rng=`min="${f.min??''}" max="${f.max??''}" step="${f.step??''}"`; + ctrl=``; + } + return `
${ctrl}
`; +} + +function renderSections(){ + const root=document.getElementById('prof-sections'); + root.innerHTML=PROF_SCHEMA.map(s=>{ + const body=s.fields.map(fieldInputHtml).join(''); + return `
${s.title}
${body}
`; + }).join(''); + // 속도 프리셋 변경 → 속도 필드 일괄 설정; 속도 필드 수동 변경 → preset=custom. + const presetEl=document.getElementById(fieldId('__speed_preset')); + if(presetEl) presetEl.addEventListener('change',()=>{ + const v=presetEl.value; if(v==='custom')return; + const p=SPEED_PRESETS[v]; if(!p)return; + for(const k of SPEED_KEYS){ const el=document.getElementById(fieldId(k)); if(el) el.value=p[k]; } + }); + for(const k of SPEED_KEYS){ + const el=document.getElementById(fieldId(k)); + if(el) el.addEventListener('input',()=>{ + const pe=document.getElementById(fieldId('__speed_preset')); if(pe) pe.value='custom'; + }); + } + // 소재 프리셋 → 온도 4종 + filament_type 설정. + const matEl=document.getElementById(fieldId('__material')); + if(matEl) matEl.addEventListener('change',()=>{ + const p=MATERIAL_TEMP_PRESETS[matEl.value]; if(!p)return; + for(const k of Object.keys(p)){ const el=document.getElementById(fieldId(k)); if(!el)continue; + el.value=p[k]; + } + }); +} + +function setFieldValue(key,raw){ + const f=findField(key); if(!f)return; + const el=document.getElementById(fieldId(key)); if(!el)return; + if(f.type==='check'){ el.checked=String(raw)==='1'||String(raw).toLowerCase()==='true'; } + else if(f.pct){ el.value=parseFloat(String(raw))||0; } + else if(f.type==='number'){ el.value=parseFloat(String(raw)); } + else { el.value=String(raw); } +} +function findField(key){ + for(const s of PROF_SCHEMA) for(const f of s.fields) if(f.key===key) return f; + return null; +} +// 편집값 → ini 포맷 문자열. virtual 필드는 제외. +function getIniValue(f){ + const el=document.getElementById(fieldId(f.key)); if(!el)return null; + if(f.virtual)return null; + if(f.type==='check') return el.checked?'1':'0'; + if(f.pct) return (parseFloat(el.value)||0)+'%'; + return String(el.value); +} +// 스키마 순회하며 base와 다른 값만 오버라이드 맵으로 수집. +function collectOverrides(){ + const ov={}; + for(const s of PROF_SCHEMA) for(const f of s.fields){ + if(f.virtual)continue; + const v=getIniValue(f); if(v==null)continue; + if(String(profBaseValues[f.key]??'')!==String(v)) ov[f.key]=v; + } + return ov; +} +// ini 값 맵(또는 base)을 필드에 채움. virtual 프리셋 필드도 추정 채움. +function applyValues(values){ + for(const s of PROF_SCHEMA) for(const f of s.fields){ + if(f.virtual)continue; + if(f.key in values) setFieldValue(f.key,values[f.key]); + } + // 속도 프리셋 추정: 8개 값이 어느 프리셋과 일치하는지. + const pe=document.getElementById(fieldId('__speed_preset')); + if(pe){ + let matched='custom'; + for(const [name,p] of Object.entries(SPEED_PRESETS)){ + if(SPEED_KEYS.every(k=>String(document.getElementById(fieldId(k))?.value)===String(p[k]))){ matched=name; break; } + } + pe.value=matched; + } + // 소재 프리셋 추정: filament_type 기준. + const me=document.getElementById(fieldId('__material')); + if(me){ const ft=document.getElementById(fieldId('filament_type'))?.value||'PLA'; me.value=MATERIAL_TEMP_PRESETS[ft]?ft:'PLA'; } +} + +async function profInit(){ + renderSections(); + try{ + const r=await fetch('/api/k2/profile/base',{headers:authH()}); + const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); + profBaseValues=d.values||{}; + applyValues(profBaseValues); + profLoaded=true; + profRefreshList(); + }catch(e){ + document.getElementById('slice-err').textContent='베이스 프로파일 로드 실패: '+e.message; + document.getElementById('slice-err').classList.add('show'); + } +} + +async function profRefreshList(){ + const sel=document.getElementById('prof-select'); + try{ + const r=await fetch('/api/k2/profiles',{headers:authH()}); + const d=await r.json(); const list=d.profiles||[]; + const cur=sel.value; + sel.innerHTML=''+list.map(n=>``).join(''); + if(cur) sel.value=cur; + }catch(e){ /* 무시 */ } +} + +function profResetBase(){ + applyValues(profBaseValues); + document.getElementById('prof-select').value=''; + document.getElementById('slice-err').classList.remove('show'); +} + +async function profLoadSelected(){ + const name=document.getElementById('prof-select').value; + const errEl=document.getElementById('slice-err'); + errEl.classList.remove('show'); + if(!name){ applyValues(profBaseValues); return; } + try{ + const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{headers:authH()}); + const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); + applyValues(d.values||{}); + }catch(e){ + errEl.textContent='불러오기 실패: '+e.message; errEl.classList.add('show'); + } +} + +async function profSaveAs(){ + const name=prompt('저장할 프로파일 이름(영문/숫자/밑줄/하이픈):'); + if(!name)return; + const errEl=document.getElementById('slice-err'); errEl.classList.remove('show'); + const ov=collectOverrides(); + try{ + const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify({overrides:ov})}); + const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); + await profRefreshList(); + document.getElementById('prof-select').value=name; + }catch(e){ + errEl.textContent='저장 실패: '+e.message; errEl.classList.add('show'); + } +} + +async function profDeleteSelected(){ + const name=document.getElementById('prof-select').value; + if(!name)return; + if(!confirm('프로파일 "'+name+'"을 삭제할까요?'))return; + const errEl=document.getElementById('slice-err'); errEl.classList.remove('show'); + try{ + const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{method:'DELETE',headers:authH()}); + const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); + await profRefreshList(); + }catch(e){ + errEl.textContent='삭제 실패: '+e.message; errEl.classList.add('show'); + } +} + async function runSlice(){ const fileInput=document.getElementById('slice-file'); const file=fileInput.files[0]; @@ -274,15 +543,14 @@ async function runSlice(){ btn.disabled=true; btn.textContent='슬라이싱 중... (몇 분 걸릴 수 있음)'; const qty=document.getElementById('slice-qty').value; - const material=document.getElementById('slice-material').value; - const layerHeight=document.getElementById('slice-layer').value; - const infill=document.getElementById('slice-infill').value; - const speed=document.getElementById('slice-speed').value; - const support=document.getElementById('slice-support').checked?'1':'0'; - - const qs=new URLSearchParams({qty,material,layer_height:layerHeight,infill_percent:infill,speed,support}); + const ov=collectOverrides(); try{ + // 1) 현재 편집값을 __current 임시 프로파일로 저장. + const sv=await fetch('/api/k2/profiles/__current',{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify({overrides:ov})}); + const sd=await sv.json(); if(!sv.ok||!sd.success) throw new Error(sd.error||('HTTP '+sv.status)); + // 2) 그 프로파일로 슬라이싱. + const qs=new URLSearchParams({profile:'__current',qty}); const buf=await file.arrayBuffer(); const r=await fetch('/api/k2/slice?'+qs.toString(),{ method:'POST', @@ -294,7 +562,7 @@ async function runSlice(){ const timeStr=d.printTimeSec!=null?fmtDurationSec(d.printTimeSec):'알 수 없음'; resultEl.innerHTML= '✅ K2에 업로드 완료: '+d.gcodeFilename+'
'+ - '예상 출력시간: '+timeStr+' (수량 '+d.qty+'개)
'+ + '예상 출력시간: '+timeStr+' (수량 '+d.qty+'개 · 소재 '+d.material+')
'+ ''+ '
⚠️ 시작 후 K2 터치스크린에서 "필라멘트 삽입" 프롬프트가 뜨면 직접 눌러야 급지됩니다(원격 시작은 이 확인을 대신 못 눌러줌).
'; resultEl.classList.add('show');