규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유): workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/ print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/ 인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지) - 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고), --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등 - 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성), model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거 - 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내 - 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬 - 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
import express, { Express, Request, Response } from 'express';
|
|
import { PRINT3D_DIRS } from './workshop-layout';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { getUserWorkspace } from '../../config/config';
|
|
import { isPathInsideDir } from '../../tools/image';
|
|
import {
|
|
PRUSA_SLICER_BIN, runPrusaSlicer, uploadToMoonraker,
|
|
parsePrusaTime, parsePrusaFilament,
|
|
K2_BASE_INI, parseIni, buildProfileIni, profileSliceArgs,
|
|
} from '../../tools/k2-slicer-core';
|
|
|
|
// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만
|
|
// 서빙한다(:8000/call/webrtc_local). 홈클로 자체는 https://ai.applecherry.net로
|
|
// 서빙되므로, 그 HTTPS 페이지에서 이 HTTP 엔드포인트로 직접 fetch(POST)하면
|
|
// 브라우저의 mixed-content 정책에 막혀 요청 자체가 안 나간다(주소창 "주의 요함" +
|
|
// 카메라 화면 검은 채로 멈춤, 2026-09-18 발견). 그래서 이 엔드포인트를 우리 서버가
|
|
// 대신 호출해주는 얇은 프록시로 우회한다 — 서버-서버 호출은 스킴 제약이 없다.
|
|
const K2_CAM_HOST = '192.168.0.114';
|
|
const K2_CAM_PORT = 8000;
|
|
const K2_MOONRAKER_HOST = '192.168.0.114';
|
|
const K2_MOONRAKER_PORT = 7125;
|
|
|
|
// 저장 프로파일 디렉토리(<workspace>/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';
|
|
}
|
|
// 슬라이싱 결과 gcode 파일명 검증 — print3d/ 디렉토리 안의 단일 파일명만 허용(슬래시/.. 거부).
|
|
const GCODE_NAME_RE = /^[A-Za-z0-9_\-]+\.gcode$/;
|
|
function gcodePath(workspacePath: string, filename: string): string {
|
|
if (!GCODE_NAME_RE.test(filename)) throw new Error('잘못된 gcode 파일명.');
|
|
const p = path.join(workspacePath, 'print3d', filename);
|
|
const dir = path.join(workspacePath, 'print3d');
|
|
if (!isPathInsideDir(dir, p)) throw new Error('경로가 print3d 디렉토리 밖입니다.');
|
|
return p;
|
|
}
|
|
|
|
export function registerK2Routes(app: Express): void {
|
|
// Moonraker 상태(print_stats/virtual_sdcard)를 대시보드 위젯용으로 요약해서 반환 —
|
|
// 브라우저가 K2 LAN IP를 직접 못 치니(HTTPS 페이지에서 mixed-content) 서버가 대신 조회.
|
|
app.get('/api/k2/status', async (req: Request, res: Response) => {
|
|
const user = (req as any).user;
|
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
|
try {
|
|
const r = await fetch(
|
|
`http://${K2_MOONRAKER_HOST}:${K2_MOONRAKER_PORT}/printer/objects/query?print_stats&virtual_sdcard`,
|
|
{ signal: AbortSignal.timeout(8_000) },
|
|
);
|
|
if (!r.ok) return res.status(502).json({ error: `Moonraker ${r.status}` });
|
|
const d: any = await r.json();
|
|
const ps = d?.result?.status?.print_stats || {};
|
|
const sd = d?.result?.status?.virtual_sdcard || {};
|
|
res.json({
|
|
online: true,
|
|
state: ps.state || 'unknown',
|
|
filename: ps.filename || '',
|
|
progress: Math.round((sd.progress || 0) * 100),
|
|
printDurationSec: Math.round(ps.print_duration || 0),
|
|
});
|
|
} catch (e: any) {
|
|
res.json({ online: false, error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// 클라이언트가 'text/plain'으로 보내는데 서버 전역엔 json/urlencoded 파서만 등록돼
|
|
// 있어(server.ts) req.body가 안 채워짐 — 이 라우트에서만 원본 바디를 텍스트로 받는다.
|
|
app.post('/api/k2/webrtc-signal', express.text({ type: () => true, limit: '2mb' }), async (req: Request, res: Response) => {
|
|
const user = (req as any).user;
|
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
|
try {
|
|
const body = typeof req.body === 'string' ? req.body : String(req.body ?? '');
|
|
const r = await fetch(`http://${K2_CAM_HOST}:${K2_CAM_PORT}/call/webrtc_local`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await r.text();
|
|
res.status(r.status).type('text/plain').send(text);
|
|
} catch (e: any) {
|
|
res.status(502).json({ error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// ── 슬라이싱 프로파일 라우트(풀 에디터용) ──────────────────────────────────
|
|
// 베이스 프로파일값(에디터 초기 채움용). 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 <ini>)로
|
|
// 슬라이싱+K2 업로드. 바디는 STL 원본 바이트, 설정은 ?profile=<name>&qty=<N> 쿼리로.
|
|
// (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' });
|
|
|
|
const buf: Buffer = req.body;
|
|
if (!Buffer.isBuffer(buf) || buf.length < 100) {
|
|
return res.status(400).json({ error: '모델 파일이 비어있거나 너무 작습니다.' });
|
|
}
|
|
// 2026-09-21: 3MF 지원 추가 — PrusaSlicer CLI가 3MF를 네이티브로 지원해서(확장자로 자동판별)
|
|
// 그냥 .3mf로 저장해서 넘기면 됨. 3MF는 클라이언트에서 미리보기/배치 편집 없이 원본 그대로
|
|
// 통째로 보내는 방식(Three.js STLLoader로는 3MF를 못 읽어서 바로 편집기에 못 올림).
|
|
const format = String(req.query.format || 'stl').toLowerCase();
|
|
if (format !== 'stl' && format !== '3mf') {
|
|
return res.status(400).json({ error: `지원하지 않는 형식: ${format} (stl 또는 3mf)` });
|
|
}
|
|
const modelExt = format === '3mf' ? '.3mf' : '.stl';
|
|
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
|
|
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
|
|
}
|
|
|
|
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
|
|
// 확인) 지원 안 함. 대신 웹 UI의 Cura식 배치 도구가 인스턴스들을 단일 STL로 구워(bake)
|
|
// 보내면, arrange=0 일 때 --dont-arrange 로 원본 XY 좌표를 그대로 보존해 슬라이싱한다
|
|
// (2026-09-20 실측: 큐브 STL 0..20 → --dont-arrange 시 베드 0..20 유지, 재중앙화 안 됨).
|
|
// arrange=0 이면 인스턴스가 이미 복제까지 구워져 들어오므로 --duplicate는 끈다.
|
|
const arrange = String(req.query.arrange || '1');
|
|
const dontArrange = arrange === '0' || arrange === 'false';
|
|
const qty = dontArrange ? 1 : 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();
|
|
|
|
// 웹 UI 슬라이싱 임시 파일은 print3d/K2웹슬라이서/ (프로젝트에 안 묶인 파일 — workshop-layout.ts)
|
|
const outDir = path.join(workspacePath, PRINT3D_DIRS.k2Web);
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const stamp = Date.now();
|
|
const stlPath = path.join(outDir, `k2ui_${stamp}${modelExt}`);
|
|
fs.writeFileSync(stlPath, buf);
|
|
|
|
const gcodeFilename = `k2ui_${stamp}.gcode`;
|
|
const gcodePath = path.join(outDir, gcodeFilename);
|
|
|
|
const psArgs = [
|
|
...profileSliceArgs(profPath, gcodePath, stlPath),
|
|
...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []),
|
|
...(dontArrange ? ['--dont-arrange'] : []),
|
|
];
|
|
|
|
const slice = await runPrusaSlicer(psArgs, 5 * 60_000);
|
|
if (!slice.ok) return res.status(500).json({ error: `슬라이싱 실패: ${slice.detail}` });
|
|
if (!fs.existsSync(gcodePath) || fs.statSync(gcodePath).size < 100) {
|
|
return res.status(500).json({ error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' });
|
|
}
|
|
|
|
const gcodeText = fs.readFileSync(gcodePath, 'utf8');
|
|
const printTimeSec = parsePrusaTime(gcodeText);
|
|
const filament = parsePrusaFilament(gcodeText);
|
|
|
|
// upload=1일 때만 Moonraker에 업로드(하위호환). 기본은 슬라이스만 — Cura 스타일 GUI가
|
|
// 툴패스 미리보기를 보여준 뒤 사용자가 명시적으로 업로드 버튼을 누르도록 흐름 분리.
|
|
const doUpload = req.query.upload === '1' || req.query.upload === 'true';
|
|
if (doUpload) {
|
|
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
|
|
if (!up.ok) return res.status(502).json({ error: `${up.detail} (슬라이싱은 성공, G-code는 서버에 있음)` });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
gcodeFilename,
|
|
printTimeSec,
|
|
filamentMm: filament.mm,
|
|
filamentCm3: filament.cm3,
|
|
uploaded: doUpload,
|
|
material: materialKey,
|
|
qty,
|
|
});
|
|
});
|
|
|
|
// 3MF → STL 변환(PrusaSlicer --export-stl, 네이티브 지원이라 파싱 직접 안 함). 슬라이서 웹UI가
|
|
// 3MF 선택 시 이걸 먼저 태워서 기존 STL 미리보기/배치편집 파이프라인을 그대로 쓸 수 있게 한다
|
|
// (Three.js STLLoader가 3MF를 못 읽어서 별도 패스스루가 아니라 변환 경로로 감, 2026-09-21).
|
|
app.post('/api/k2/convert-3mf-to-stl', 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' });
|
|
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
|
|
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
|
|
}
|
|
const buf: Buffer = req.body;
|
|
if (!Buffer.isBuffer(buf) || buf.length < 100) {
|
|
return res.status(400).json({ error: '3MF 파일이 비어있거나 너무 작습니다.' });
|
|
}
|
|
|
|
const stamp = Date.now();
|
|
const tmpDir = os.tmpdir();
|
|
const inPath = path.join(tmpDir, `k2_3mf_${stamp}.3mf`);
|
|
const outPath = path.join(tmpDir, `k2_3mf_${stamp}.stl`);
|
|
try { fs.writeFileSync(inPath, buf); }
|
|
catch (e: any) { return res.status(500).json({ error: '임시 3MF 쓰기 실패: ' + String(e?.message || e) }); }
|
|
|
|
const conv = await runPrusaSlicer(['--export-stl', '-o', outPath, inPath], 2 * 60_000);
|
|
cleanupTemp(inPath);
|
|
if (!conv.ok) { cleanupTemp(outPath); return res.status(500).json({ error: `3MF 변환 실패: ${conv.detail}` }); }
|
|
|
|
try {
|
|
const outBuf = fs.readFileSync(outPath);
|
|
cleanupTemp(outPath);
|
|
res.type('application/octet-stream').send(outBuf);
|
|
} catch (e: any) {
|
|
cleanupTemp(outPath);
|
|
res.status(500).json({ error: '변환된 STL 읽기 실패: ' + String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// 생성된 gcode를 Moonraker에 업로드(슬라이스와 분리된 명시적 전송 단계).
|
|
app.post('/api/k2/upload', express.json(), async (req: Request, res: Response) => {
|
|
const user = (req as any).user;
|
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
|
const filename = String(req.body?.filename || '').trim();
|
|
if (!filename) return res.status(400).json({ error: 'filename이 필요합니다.' });
|
|
try {
|
|
const p = gcodePath(getUserWorkspace(user.username), filename);
|
|
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
|
|
const up = await uploadToMoonraker(p, filename);
|
|
if (!up.ok) return res.status(502).json({ error: up.detail });
|
|
res.json({ success: true, filename });
|
|
} catch (e: any) {
|
|
res.status(400).json({ error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// 슬라이싱 결과 gcode를 브라우저로 스트리밍 — Cura 스타일 툴패스 미리보기용.
|
|
app.get('/api/k2/gcode/:filename', (req: Request, res: Response) => {
|
|
const user = (req as any).user;
|
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
|
const filename = String(req.params.filename || '');
|
|
try {
|
|
const p = gcodePath(getUserWorkspace(user.username), filename);
|
|
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
|
|
// 워크스페이스 경로가 /srv/homeclaw/.smallclaw/... 인데, send 라이브러리(res.sendFile
|
|
// 베이스)의 dotfiles 기본값이 'ignore'라 .smallclaw 같은 도트파일 디렉토리 아래 파일을
|
|
// 404 "Not Found"로 씹어버림(2026-09-20 확인). 명시적으로 'allow'로 풀어야 스트리밍됨.
|
|
res.type('text/plain').sendFile(p, { dotfiles: 'allow' });
|
|
} catch (e: any) {
|
|
res.status(400).json({ error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// 웹 UI에서 출력 시작 — Moonraker가 LAN 평문 HTTP라 브라우저가 직접 못 치므로(mixed-content) 프록시.
|
|
app.post('/api/k2/print-start', express.json(), async (req: Request, res: Response) => {
|
|
const user = (req as any).user;
|
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
|
const filename = String(req.body?.filename || '').trim();
|
|
if (!filename) return res.status(400).json({ error: 'filename이 필요합니다.' });
|
|
try {
|
|
const r = await fetch(`http://${K2_MOONRAKER_HOST}:${K2_MOONRAKER_PORT}/printer/print/start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename }),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!r.ok) return res.status(502).json({ error: `출력 시작 실패: HTTP ${r.status} ${await r.text().catch(() => '')}` });
|
|
res.json({ success: true });
|
|
} catch (e: any) {
|
|
res.status(502).json({ error: `출력 시작 실패: ${String(e?.message || e)}` });
|
|
}
|
|
});
|
|
}
|
|
|
|
function cleanupTemp(p: string): void {
|
|
try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* noop */ }
|
|
}
|