diff --git a/scripts/migrate-workshop-layout.ts b/scripts/migrate-workshop-layout.ts new file mode 100644 index 0000000..3169614 --- /dev/null +++ b/scripts/migrate-workshop-layout.ts @@ -0,0 +1,109 @@ +/** + * 작업실 프로젝트 파일 폴더 정돈 스크립트(2026-09-25). + * npx tsx scripts/migrate-workshop-layout.ts [--user <이름>] # 계획만 출력(dry-run, 기본) + * npx tsx scripts/migrate-workshop-layout.ts --apply [--user <이름>] # 실제 이동 + 프로젝트 데이터 경로 갱신 + * npx tsx scripts/migrate-workshop-layout.ts --undo # 마지막 이동 되돌리기 + * 규칙은 src/gateway/routes/workshop-layout.ts. 이동만 하고 삭제/덮어쓰기는 하지 않는다(충돌은 건너뛰고 보고). + * --apply는 되돌리기용 manifest를 /.smallclaw/ 에 남긴다. + */ +import fs from 'fs'; +import path from 'path'; +import { getConfig } from '../src/config/config'; +import { loadWorkshop, saveWorkshop } from '../src/gateway/routes/workshop-storage'; +import { planProjectMoves, planPrint3dMoves, pruneEmptyDirs, projectDir, renameTextPaths, MovePlan, MoveConflict } from '../src/gateway/routes/workshop-layout'; + +const args = process.argv.slice(2); +const apply = args.includes('--apply'); +const undoIdx = args.indexOf('--undo'); +const userIdx = args.indexOf('--user'); + +function workspaceOf(user: string): string { + return path.join(getConfig().getConfigDir(), 'users', user, 'workspace'); +} + +if (undoIdx >= 0) { + const mf = JSON.parse(fs.readFileSync(args[undoIdx + 1], 'utf-8')); + let n = 0; + for (const m of [...mf.moves].reverse() as MovePlan[]) { + const from = path.join(projectDir(mf.workspace, m.project), m.to); + const to = path.join(projectDir(mf.workspace, m.project), m.from); + if (!fs.existsSync(from) || fs.existsSync(to)) { console.log(`건너뜀: ${m.project}/${m.to}`); continue; } + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.renameSync(from, to); n++; + } + for (const m of [...(mf.print3d || [])].reverse() as { from: string; to: string }[]) { + const from = path.join(mf.workspace, m.to), to = path.join(mf.workspace, m.from); + if (!fs.existsSync(from) || fs.existsSync(to)) continue; + fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); n++; + } + // 프로젝트 데이터(assembly.scad 경로)도 원복 + const data = loadWorkshop(mf.workspace); + for (const p of data.projects) if (p.assembly && mf.scadRemap?.[p.id]) p.assembly.scad = mf.scadRemap[p.id].from; + saveWorkshop(mf.workspace, data); + console.log(`되돌림: 파일 ${n}개 (빈 폴더는 필요 시 다시 생성됨)`); + process.exit(0); +} + +const users = userIdx >= 0 ? [args[userIdx + 1]] : fs.readdirSync(path.join(getConfig().getConfigDir(), 'users')); +for (const user of users) { + const ws = workspaceOf(user); + if (!fs.existsSync(path.join(ws, '.smallclaw', 'workshop.json'))) continue; + const data = loadWorkshop(ws); + const allMoves: MovePlan[] = []; + const allConflicts: MoveConflict[] = []; + for (const p of data.projects) { + if (!fs.existsSync(projectDir(ws, p.id))) continue; + const { moves, conflicts } = planProjectMoves(ws, p.id); + allMoves.push(...moves); allConflicts.push(...conflicts); + } + console.log(`\n=== 사용자 ${user}: 이동 ${allMoves.length}개, 충돌 ${allConflicts.length}개 ${apply ? '(적용)' : '(계획만 — dry-run)'}`); + let cur = ''; + for (const m of allMoves) { + if (m.project !== cur) { cur = m.project; console.log(`\n[${data.projects.find(p => p.id === cur)?.name ?? cur}] (${cur})`); } + console.log(` ${m.from} → ${m.to}`); + } + for (const c of allConflicts) console.log(` ⚠ 충돌 건너뜀: ${c.project}/${c.from} → ${c.to} (${c.reason})`); + const p3 = planPrint3dMoves(ws); + if (p3.length) { + console.log(`\n[워크스페이스 print3d/] 이동 ${p3.length}개`); + for (const m of p3) console.log(` ${m.from} → ${m.to}`); + } + if (!apply || (!allMoves.length && !p3.length)) continue; + + // 1) 파일 이동(같은 파일시스템 rename — 용량과 무관하게 즉시, 내용 그대로) + const done: MovePlan[] = []; + for (const m of allMoves) { + const root = projectDir(ws, m.project); + const to = path.join(root, m.to); + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.renameSync(path.join(root, m.from), to); + done.push(m); + } + // 2) .scad 경로 참조(조립 명세) + 문서 문구 갱신 + const scadRemap: Record = {}; + for (const p of data.projects) { + if (p.assembly?.scad) { + const prefix = `workshop/${p.id}/`; + const mv = done.find(m => m.project === p.id && prefix + m.from === p.assembly!.scad); + if (mv) { scadRemap[p.id] = { from: p.assembly.scad, to: prefix + mv.to }; p.assembly.scad = prefix + mv.to; } + } + if (p.description) p.description = renameTextPaths(p.description); + if (p.notes) p.notes = renameTextPaths(p.notes); + } + saveWorkshop(ws, data); // 저장 때 이전 상태가 이력에 자동 보존된다 + // 3) 비게 된 폴더 정리(빈 폴더만) + for (const p of data.projects) { + const removed = fs.existsSync(projectDir(ws, p.id)) ? pruneEmptyDirs(projectDir(ws, p.id)) : []; + if (removed.length) console.log(` 빈 폴더 정리 [${p.id}]: ${removed.join(', ')}`); + } + for (const m of p3) { + const to = path.join(ws, m.to); + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.renameSync(path.join(ws, m.from), to); + } + const oldMS = path.join(ws, 'print3d', 'model-search'); + if (fs.existsSync(oldMS)) pruneEmptyDirs(oldMS), (fs.readdirSync(oldMS).length === 0 && fs.rmdirSync(oldMS)); + const manifest = path.join(ws, '.smallclaw', `workshop-layout-migration-${Date.now()}.json`); + fs.writeFileSync(manifest, JSON.stringify({ workspace: ws, at: new Date().toISOString(), moves: done, print3d: p3, scadRemap }, null, 2)); + console.log(`\n완료 — 되돌리기: npx tsx scripts/migrate-workshop-layout.ts --undo ${manifest}`); +} diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index 734638b..2085e34 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -1112,7 +1112,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { properties: { action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' }, stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' }, - stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, + stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/모델검색/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' }, infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' }, wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' }, @@ -1148,7 +1148,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { properties: { site: { type: 'string', enum: ['printables', 'makerworld', 'thingiverse'], description: '(필수) 어느 사이트의 모델인지 — model_search 결과의 [site] 태그 값' }, model_id: { type: 'string', description: '(필수) model_search 결과의 id. 모델 페이지 URL을 그대로 붙여넣어도 해석됨(예: printables.com/model/1138497, thingiverse.com/thing:2473)' }, - project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' }, + project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에 저장, 생략하면 print3d/일회성/' }, filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 __<모델명>.' }, }, }, diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index c9cde12..6997e80 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -283,16 +283,12 @@ async function handleChat( }; walk(projDir, 0); if (scadFiles.length > 0) { - // 09-22: 사용자 지적 — scad_to_stl 결과가 CAD/ 바로 밑에 널브러지지 말고 print/ - // 서브폴더에 정리돼야 보기 좋다(이 프로젝트도, proj_scan_dental도 이미 그 관례를 씀 - // — CAD/print/=인쇄 대상, CAD/reference/=참고용 비인쇄 모델). 단, part 없이(전체 - // 조립본, 시각화용 — 고스트/모터 등 비인쇄 요소까지 다 포함돼 인쇄 대상이 아님) - // 만드는 경우는 예외 — 그건 CAD/ 폴더 바로 밑에 저장(사용자 지시, 09-22). - scadHint = ' scad_to_stl로 STL을 만들 때: part를 지정해서(부품 하나만) 만드는 ' + - '거면 output_path는 특별한 지시가 없는 한 그 .scad 파일이 있는 폴더 밑 print/ ' + - '서브폴더에 저장할 것(예: ".../CAD/x.scad" → ".../CAD/print/x.stl", 폴더 없으면 ' + - '새로 생겨도 됨). part 없이(전체 조립본) 만드는 거면 대신 그 .scad 파일과 같은 ' + - '폴더(CAD/ 바로 밑)에 저장할 것 — print/에 넣지 말 것(인쇄 대상이 아니라 시각화용).'; + // 09-25 폴더 규칙 통일(workshop-layout.ts): STL은 그 .scad와 "같은 폴더"에 나란히 저장한다(사용자 지시 — + // 옛 09-22 규칙이던 CAD/print/ 하위폴더 분리는 폐지). 인쇄와 무관한 참고 모델만 CAD/참고/ 로 분리해 + // "참고용을 인쇄 대상으로 착각"하던 문제(09-22)를 계속 막는다. + scadHint = ' scad_to_stl로 STL을 만들 때 output_path는 특별한 지시가 없는 한 그 .scad 파일과 "같은 폴더"에 ' + + '저장할 것(예: ".../CAD/rig.scad" → 부품 하나는 ".../CAD/base_plate.stl", 전체 조립본은 ".../CAD/rig.stl"). ' + + 'print/ 같은 하위폴더를 새로 만들지 말 것. 인쇄 대상이 아닌 참고용 모델(구매 부품 형상 등)만 ".../CAD/참고/"에 둔다.'; } if (scadFiles.length === 1) { scadHint += ` 이 프로젝트엔 .scad 파일이 "${scadFiles[0]}" 하나뿐이다 — 사용자가 ` + @@ -352,6 +348,7 @@ async function handleChat( } catch { /* noop */ } return `[작업실(workshop) 세션 — 지금 사용자가 보고 있는 프로젝트: "${proj.name}" (id: ${proj.id}). ` + `이 프로젝트의 파일(STL/scad/사진 등)은 워크스페이스 기준 workshop/${proj.id}/ 아래에만 있다. ` + + `폴더 규칙: CAD/=.scad 원본과 그 STL(같은 폴더), CAD/참고/=비인쇄 참고 모델, 출력/=G-code, 사진/=사진·렌더 이미지, 영상/=영상. 새 파일은 이 규칙대로 저장할 것. ` + `파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` + `사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.` + `도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` + diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index ff9fae1..89af72d 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -1,4 +1,5 @@ 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'; @@ -211,7 +212,8 @@ export function registerK2Routes(app: Express): void { const profValues = parseIni(fs.readFileSync(profPath, 'utf8')); const materialKey = String(profValues.filament_type || 'PLA').toUpperCase(); - const outDir = path.join(workspacePath, 'print3d'); + // 웹 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}`); diff --git a/src/gateway/routes/workshop-assembly.ts b/src/gateway/routes/workshop-assembly.ts index 4840899..1f9bbc5 100644 --- a/src/gateway/routes/workshop-assembly.ts +++ b/src/gateway/routes/workshop-assembly.ts @@ -32,7 +32,7 @@ export interface AssemblyStep { tools: string[]; } export interface Assembly { - /** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/print/rig.scad") */ + /** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/rig.scad") */ scad: string; view: { rx: number; rz: number }; /** 분해도 간격 배율(offset × 이 값). 기본 1 */ diff --git a/src/gateway/routes/workshop-layout.ts b/src/gateway/routes/workshop-layout.ts new file mode 100644 index 0000000..8d65780 --- /dev/null +++ b/src/gateway/routes/workshop-layout.ts @@ -0,0 +1,181 @@ +/** + * workshop-layout.ts + * 작업실 프로젝트 파일 폴더 규칙(2026-09-25) — 프로젝트마다 제멋대로였던 폴더(CAD/print, stls/, 루트 나열, print3d/<프로젝트명>/ …)를 + * 하나의 규칙으로 통일한다. 도구(print3d_model/model_download/write_scad 안내)와 이동 스크립트가 같은 상수를 쓴다. + * + * workshop/<프로젝트id>/ + * CAD/ .scad 원본 + 그 .scad에서 뽑은 STL(같은 폴더에 나란히) + * 참고/ 비인쇄 참고 모델(카메라·프로젝터 형상 등) — 인쇄용과 섞여 "인쇄 대상으로 착각"하던 문제(09-22) 방지 + * 다운로드/ model_download로 받은 외부 모델 + * <외부세트>/ 받아 온 모델 묶음(예: SO101/Follower)은 자기 이름 폴더 그대로 + * 출력/ 슬라이싱 결과 G-code + * 사진/ 사진·렌더 이미지 + * 영상/ mp4 등 + * 조립설명서/ (자동 생성) + * 개요/ (옛 자동 생성 readme — 그대로 둠) + * print3d/ 프로젝트에 안 묶인 일회성 3D 파일 + * K2웹슬라이서/ 웹 UI에서 슬라이싱한 임시 파일 + * 모델검색/ 프로젝트 없이 받은 model_download 결과 + * 일회성/ 프로젝트 없이 print3d_model로 받아 슬라이싱한 파일 + * profiles/ 슬라이서 프로파일 + */ +import fs from 'fs'; +import path from 'path'; + +export const LAYOUT = { + cad: 'CAD', + reference: 'CAD/참고', + downloads: 'CAD/다운로드', + out: '출력', + photos: '사진', + videos: '영상', + manual: '조립설명서', + overview: '개요', +} as const; + +export const PRINT3D_DIRS = { root: 'print3d', k2Web: 'print3d/K2웹슬라이서', modelSearch: 'print3d/모델검색', adhoc: 'print3d/일회성', profiles: 'print3d/profiles' } as const; + +const CAD_EXT = /\.(scad|stl|3mf|step|stp|iges|igs|obj|dxf)$/i; +const IMAGE_EXT = /\.(png|jpe?g|webp|gif|bmp|heic)$/i; +const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi)$/i; +const GCODE_EXT = /\.(gcode|gco)$/i; + +// 예전 관례로 만들어진 "정돈 대상" 폴더. 그 밖에 사용자가 직접 만든 폴더(예: "참고자료", "견적")는 건드리지 않는다. +const LEGACY_TREES = new Set(['print', 'stls', 'stl', 'models', 'model', 'reference', 'renders']); + +export function projectDir(workspace: string, projectId: string): string { + return path.join(workspace, 'workshop', projectId); +} +export function cadDir(workspace: string, projectId: string): string { + return path.join(projectDir(workspace, projectId), LAYOUT.cad); +} + +// 프로젝트 폴더 안 상대경로(/ 구분) → 규칙에 맞는 새 상대경로. 이미 규칙에 맞거나 손댈 대상이 아니면 null. +export function targetForFile(rel: string): string | null { + const segs = rel.split('/').filter(Boolean); + if (segs.length === 0) return null; + const name = segs[segs.length - 1]; + const dirs = segs.slice(0, -1); + const top = dirs[0] ?? ''; + if (top.startsWith('.') || top === LAYOUT.manual || top === LAYOUT.overview) return null; + + const kind = CAD_EXT.test(name) ? 'cad' : IMAGE_EXT.test(name) ? 'image' : VIDEO_EXT.test(name) ? 'video' : GCODE_EXT.test(name) ? 'gcode' : null; + if (!kind) return null; + + // 이미 규칙 폴더에 있는지(하위 폴더 포함) — 종류가 맞으면 그대로 + if (kind === 'cad' && top === LAYOUT.cad && !LEGACY_TREES.has(dirs[1] ?? '')) return null; + if (kind === 'image' && top === LAYOUT.photos) return null; + if (kind === 'video' && top === LAYOUT.videos) return null; + if (kind === 'gcode' && top === LAYOUT.out) return null; + + // 정돈 대상인가: 루트, CAD 바로 아래/그 안의 옛 하위폴더, 또는 옛 관례 트리(print/ stls/ reference/ …) 아래 + const inCadLegacy = top === LAYOUT.cad && (LEGACY_TREES.has(dirs[1] ?? '') || dirs.length === 1); + const inLegacyTree = LEGACY_TREES.has(top); + const atRoot = dirs.length === 0; + if (!(atRoot || inCadLegacy || inLegacyTree)) return null; + + if (kind === 'image') return `${LAYOUT.photos}/${name}`; + if (kind === 'video') return `${LAYOUT.videos}/${name}`; + if (kind === 'gcode') return `${LAYOUT.out}/${name}`; + + // CAD 종류: 옛 트리 접두어를 벗기고 reference → 참고. print/ 는 "같이 저장" 원칙에 따라 접두어만 제거. + let inner = top === LAYOUT.cad ? dirs.slice(1) : atRoot ? [] : dirs.slice(); + if (inner.length && inner[0] === 'print') inner = inner.slice(1); + else if (inner.length && (inner[0] === 'reference' || inner[0] === 'renders')) inner = [LAYOUT.reference.split('/')[1], ...inner.slice(1)]; + else if (inner.length && ['stls', 'stl', 'models', 'model'].includes(inner[0])) inner = inner.slice(1); + return [LAYOUT.cad, ...inner, name].join('/'); +} + +export interface MovePlan { project: string; from: string; to: string } +export interface MoveConflict { project: string; from: string; to: string; reason: string } + +function listFiles(dir: string, base = ''): string[] { + const out: string[] = []; + let entries: fs.Dirent[] = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const rel = base ? `${base}/${e.name}` : e.name; + if (e.isDirectory()) out.push(...listFiles(path.join(dir, e.name), rel)); + else if (e.isFile()) out.push(rel); + } + return out; +} + +// 한 프로젝트 폴더의 이동 계획. 같은 도착지로 가는 파일이 둘이거나 도착지에 다른 파일이 이미 있으면 충돌로 빼고(덮어쓰지 않는다) 알린다. +export function planProjectMoves(workspace: string, projectId: string): { moves: MovePlan[]; conflicts: MoveConflict[] } { + const root = projectDir(workspace, projectId); + const moves: MovePlan[] = []; + const conflicts: MoveConflict[] = []; + const claimed = new Map(); + for (const rel of listFiles(root).sort()) { + const to = targetForFile(rel); + if (!to || to === rel) continue; + const existing = path.join(root, to); + if (fs.existsSync(existing)) { conflicts.push({ project: projectId, from: rel, to, reason: '도착 위치에 같은 이름 파일이 이미 있음' }); continue; } + if (claimed.has(to)) { conflicts.push({ project: projectId, from: rel, to, reason: `같은 이름이 ${claimed.get(to)}에서도 옴` }); continue; } + claimed.set(to, rel); + moves.push({ project: projectId, from: rel, to }); + } + return { moves, conflicts }; +} + +// 이동 후 비게 된 폴더 정리(빈 폴더만 rmdir — 파일이 하나라도 있으면 남긴다). +export function pruneEmptyDirs(root: string): string[] { + const removed: string[] = []; + const walk = (dir: string): boolean => { + let empty = true; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) { if (!walk(path.join(dir, e.name))) empty = false; } + else empty = false; + } + if (empty && dir !== root) { try { fs.rmdirSync(dir); removed.push(path.relative(root, dir)); return true; } catch { return false; } } + return empty; + }; + try { walk(root); } catch { /* noop */ } + return removed; +} + +// 프로젝트 데이터의 문서 문구/경로 갱신: 옛 폴더 이름 → 새 이름(텍스트 안내용) 과 이동한 .scad 경로. +export const TEXT_RENAMES: [RegExp, string][] = [ + [/CAD\/print\//g, 'CAD/'], + [/CAD\/print\b/g, 'CAD'], + [/CAD\/reference\//g, 'CAD/참고/'], + [/CAD\/reference\b/g, 'CAD/참고'], + [/CAD\/렌더/g, '사진'], +]; +export function renameTextPaths(text: string): string { + let t = text; + for (const [re, rep] of TEXT_RENAMES) t = t.replace(re, rep); + return t; +} + +// 워크스페이스 단위 print3d/ 정돈(프로젝트에 안 묶인 파일): 루트에 흩어진 k2ui_* → K2웹슬라이서/, model-search/ → 모델검색/, +// 루트의 그 밖의 STL/3MF/G-code → 일회성/, 워크스페이스 루트의 k2_config.ini → print3d/profiles/. +export function planPrint3dMoves(workspace: string): { from: string; to: string }[] { + const out: { from: string; to: string }[] = []; + const root = path.join(workspace, PRINT3D_DIRS.root); + if (fs.existsSync(root)) { + for (const e of fs.readdirSync(root, { withFileTypes: true })) { + if (e.isFile() && /^k2ui_\d+\.(stl|3mf|gcode)$/i.test(e.name)) out.push({ from: `print3d/${e.name}`, to: `${PRINT3D_DIRS.k2Web}/${e.name}` }); + else if (e.isFile() && (CAD_EXT.test(e.name) || GCODE_EXT.test(e.name))) out.push({ from: `print3d/${e.name}`, to: `${PRINT3D_DIRS.adhoc}/${e.name}` }); + else if (e.isDirectory() && e.name === 'model-search') { + for (const f of listFiles(path.join(root, e.name))) out.push({ from: `print3d/model-search/${f}`, to: `${PRINT3D_DIRS.modelSearch}/${f}` }); + } + } + } + if (fs.existsSync(path.join(workspace, 'k2_config.ini'))) out.push({ from: 'k2_config.ini', to: `${PRINT3D_DIRS.profiles}/k2_config.ini` }); + return out.filter(m => !fs.existsSync(path.join(workspace, m.to))); +} + +// 도구가 파일을 저장할 폴더를 정하는 단일 지점(print3d_model / model_download / K2 웹 슬라이서가 공유). +export function saveDirs(workspace: string, projectId?: string): { modelDir: string; outDir: string; downloadDir: string } { + if (projectId) { + const root = projectDir(workspace, projectId); + return { modelDir: path.join(root, LAYOUT.downloads), outDir: path.join(root, LAYOUT.out), downloadDir: path.join(root, LAYOUT.downloads) }; + } + return { + modelDir: path.join(workspace, PRINT3D_DIRS.adhoc), outDir: path.join(workspace, PRINT3D_DIRS.adhoc), + downloadDir: path.join(workspace, PRINT3D_DIRS.modelSearch), + }; +} diff --git a/src/tools/model-search.ts b/src/tools/model-search.ts index f1d6625..4e607aa 100644 --- a/src/tools/model-search.ts +++ b/src/tools/model-search.ts @@ -5,7 +5,8 @@ import { getConfig } from '../config/config.js'; import { getVault } from '../security/vault.js'; import { getWorkspacePath } from '../config/paths.js'; import { isPathInsideDir } from './image.js'; -import { findWorkshopProjectName, sanitizeFolderName } from './print3d.js'; +import { findWorkshopProjectName } from './print3d.js'; +import { saveDirs } from '../gateway/routes/workshop-layout.js'; // 3D 모델 저장소 검색+다운로드 (2026-09-23) — Printables / MakerWorld / Thingiverse. // 세 곳 모두 공식 API가 없거나(Printables/MakerWorld) 응용토큰이 필요한(Thingiverse) 사이라 @@ -430,7 +431,7 @@ export const modelDownloadTool = { properties: { site: { type: 'string', enum: ['printables', 'makerworld', 'thingiverse'], description: '(필수) 어느 사이트의 모델인지 — model_search 결과의 [site] 태그 값' }, model_id: { type: 'string', description: '(필수) model_search 결과의 id. 모델 페이지 URL을 그대로 붙여넣어도 해석됨(예: printables.com/model/1138497, thingiverse.com/thing:2473)' }, - project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' }, + project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 workshop/<프로젝트id>/CAD/다운로드/에 저장(생략하면 print3d/모델검색/)' }, filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 __<모델명>.' }, }, additionalProperties: false, @@ -451,14 +452,14 @@ export const modelDownloadTool = { } const workspacePath = getWorkspacePath(args); - const baseDir = path.join(workspacePath, 'print3d'); + // 저장 위치(workshop-layout.ts): 프로젝트가 있으면 workshop//CAD/다운로드/, 없으면 print3d/모델검색/ const projectNameArg = String(args?.project_name || '').trim(); - let outDir = path.join(baseDir, 'model-search'); + let outDir = saveDirs(workspacePath).downloadDir; if (projectNameArg) { const found = findWorkshopProjectName(workspacePath, projectNameArg); if (!found.ok) return { success: false, error: found.error }; - outDir = path.join(baseDir, sanitizeFolderName(found.name)); - if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + outDir = saveDirs(workspacePath, found.id).downloadDir; + if (!isPathInsideDir(workspacePath, outDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' }; } fs.mkdirSync(outDir, { recursive: true }); diff --git a/src/tools/print3d.ts b/src/tools/print3d.ts index 81b938a..78e2918 100644 --- a/src/tools/print3d.ts +++ b/src/tools/print3d.ts @@ -6,16 +6,17 @@ import { isPathInsideDir } from './image.js'; import { loadWorkshop } from '../gateway/routes/workshop-storage.js'; import { registerPrintJob } from '../gateway/workshop-print-watch.js'; import { sessionProjectId } from './workshop-project.js'; +import { saveDirs } from '../gateway/routes/workshop-layout.js'; import { K2_HOST, K2_MOONRAKER_PORT, PRUSA_SLICER_BIN, MATERIAL_PRESETS, buildBaseArgs, runPrusaSlicer, uploadToMoonraker, parsePrusaTime, fmtDuration, } from './k2-slicer-core.js'; -// "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 print3d/<프로젝트명>/ 하위폴더에 -// 저장한다(2026-09-20) — project_name 없이 부르면 예전처럼 print3d/ 바로 아래(공용). 프로젝트 +// "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에 +// 저장한다(2026-09-20) → 09-25 폴더 규칙 통일(workshop-layout.ts)로 workshop//CAD/다운로드·출력/ 로 바뀜. project_name 없으면 print3d/일회성/. 프로젝트 // 이름 매칭은 workshop-project.ts의 findProjectIndex와 동일한 부분일치 규칙을 그대로 따른다. // 09-23: model-search.ts(model_download)에서도 재사용하므로 export. -export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string } | { ok: false; error: string } { +export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string; id: string } | { ok: false; error: string } { const data = loadWorkshop(workspacePath); const q = query.trim().toLowerCase(); const matches = data.projects.filter((pr) => pr.name.toLowerCase().includes(q)); @@ -26,7 +27,7 @@ export function findWorkshopProjectName(workspacePath: string, query: string): { if (matches.length > 1) { return { ok: false, error: `"${query}"에 여러 프로젝트가 일치합니다: ${matches.map((p) => p.name).join(', ')} — 더 구체적으로 지정하세요.` }; } - return { ok: true, name: matches[0].name }; + return { ok: true, name: matches[0].name, id: matches[0].id }; } // 프로젝트 이름을 폴더 세그먼트로 — 슬래시/역슬래시/NUL만 막고 나머지(한글 포함)는 그대로 둔다. @@ -77,8 +78,8 @@ export const print3dModelTool = { properties: { action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' }, stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' }, - stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, - project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 print3d/<프로젝트명>/ 하위폴더에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' }, + stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/모델검색/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, + project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' }, layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' }, infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' }, wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' }, @@ -159,11 +160,13 @@ export const print3dModelTool = { const wallCount = Number.isFinite(Number(args?.wall_count)) ? Math.max(1, Math.round(Number(args.wall_count))) : 2; const workspacePath = getWorkspacePath(args); - const baseDir = path.join(workspacePath, 'print3d'); + // 저장 위치 규칙(workshop-layout.ts): 프로젝트가 있으면 내려받은 모델 → workshop//CAD/다운로드/, 슬라이싱 결과 G-code → + // workshop//출력/. 프로젝트가 없으면 print3d/일회성/(모델 파일 옆에 G-code를 두는 stl_path 경로는 그대로). const stamp = Date.now(); const projectNameArg = String(args?.project_name || '').trim(); let projectFolderName = ''; - let outDir = baseDir; + let projectId = ''; + let { outDir, modelDir } = saveDirs(workspacePath); // outDir=G-code 저장 폴더, modelDir=URL로 받은 모델 저장 폴더 let stlPath: string; if (stlPathArg) { const resolved = path.resolve(workspacePath, stlPathArg); @@ -177,9 +180,9 @@ export const print3dModelTool = { if (projectNameArg) { const found = findWorkshopProjectName(workspacePath, projectNameArg); if (!found.ok) return { success: false, error: found.error }; - projectFolderName = found.name; - outDir = path.join(baseDir, sanitizeFolderName(projectFolderName)); - if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + projectFolderName = found.name; projectId = found.id; + outDir = saveDirs(workspacePath, found.id).outDir; + if (!isPathInsideDir(workspacePath, outDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' }; } else { outDir = path.dirname(resolved); } @@ -188,16 +191,17 @@ export const print3dModelTool = { if (projectNameArg) { const found = findWorkshopProjectName(workspacePath, projectNameArg); if (!found.ok) return { success: false, error: found.error }; - projectFolderName = found.name; - outDir = path.join(baseDir, sanitizeFolderName(projectFolderName)); - if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + projectFolderName = found.name; projectId = found.id; + ({ outDir, modelDir } = saveDirs(workspacePath, found.id)); + if (!isPathInsideDir(workspacePath, outDir) || !isPathInsideDir(workspacePath, modelDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' }; } if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + if (!fs.existsSync(modelDir)) fs.mkdirSync(modelDir, { recursive: true }); // 2026-09-21: 3MF 지원 — URL 확장자가 .3mf면 그대로 3mf로 저장(PrusaSlicer CLI가 네이티브 // 지원, 확장자로 자동판별). 그 외엔 기존처럼 .stl로 저장(URL에 확장자 없어도 안전한 기본값). const urlExt = (() => { try { return path.extname(new URL(stlUrl).pathname).toLowerCase(); } catch { return ''; } })(); const modelExt = urlExt === '.3mf' ? '.3mf' : '.stl'; - stlPath = path.join(outDir, `model_${stamp}${modelExt}`); + stlPath = path.join(modelDir, `model_${stamp}${modelExt}`); const dl = await downloadStl(stlUrl, stlPath); if (!dl.ok) return { success: false, error: dl.detail }; @@ -225,7 +229,7 @@ export const print3dModelTool = { const timeStr = printTimeSec != null ? fmtDuration(printTimeSec) : '알 수 없음'; const materialNote = material.verified ? '' : ' (⚠️ 온도값은 검증되지 않은 일반 추정치입니다)'; - const projectNote = projectFolderName ? `\n- 작업실 프로젝트: "${projectFolderName}" (print3d/${projectFolderName}/ 폴더에 저장)` : ''; + const projectNote = projectFolderName ? `\n- 작업실 프로젝트: "${projectFolderName}" (모델: workshop/${projectId}/CAD/다운로드/, G-code: workshop/${projectId}/출력/)` : ''; return { success: true, stdout: `K2 콤보에 업로드 완료: **${gcodeFilename}**\n` + diff --git a/src/tools/workshop-project.ts b/src/tools/workshop-project.ts index d4f6faf..e57e354 100644 --- a/src/tools/workshop-project.ts +++ b/src/tools/workshop-project.ts @@ -138,7 +138,7 @@ export const workshopProjectTool = { query: { type: 'string', description: '(check_price 선택) part_name 대신 직접 검색어를 줄 때' }, confirm: { type: 'boolean', description: '(delete_project 필수) 반드시 true — 사용자가 삭제를 명시적으로 요청한 경우에만' }, ts: { type: 'number', description: '(restore_history 필수) list_history가 준 이력 시각(ts)' }, - scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/CAD/print/rig.scad"). 생략하면 현재 조립 명세의 scad.' }, + scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/CAD/rig.scad"). 생략하면 현재 조립 명세의 scad.' }, assembly: { type: 'object', description: '(set_assembly 필수) 조립 설명서 명세 전체(부분 수정도 전체를 다시 넣을 것 — 먼저 get_assembly로 현재 값 확인). {scad, view:{rx,rz}, explode, parts:[{id,name,call,offset:[x,y,z],bom,qty}], steps:[{title,desc,partIds:[부품 id],fasteners:[{name,qty}],tools:[문자열]}]}. parts[].id는 짧은 영문/숫자(예: "base"), steps[].partIds가 참조한다. call은 .scad에 있는 조립 모듈 한 개의 호출("base_plate()", "camera(80)") — 따옴표/세미콜론 금지.', diff --git a/tests/workshop-layout.test.ts b/tests/workshop-layout.test.ts new file mode 100644 index 0000000..af2749f --- /dev/null +++ b/tests/workshop-layout.test.ts @@ -0,0 +1,160 @@ +// 작업실 파일 폴더 규칙(workshop-layout.ts) + 이동 스크립트(apply/undo) 테스트. +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { targetForFile, planProjectMoves, pruneEmptyDirs, renameTextPaths, saveDirs, planPrint3dMoves } from '../src/gateway/routes/workshop-layout'; + +describe('targetForFile — 폴더 규칙', () => { + const cases: [string, string | null][] = [ + // 인쇄용 STL은 .scad와 같은 폴더(CAD/) + ['CAD/print/base_plate.stl', 'CAD/base_plate.stl'], + ['CAD/print/scanner-rig.scad', 'CAD/scanner-rig.scad'], + ['print/base.stl', 'CAD/base.stl'], + ['robot.scad', 'CAD/robot.scad'], + ['robot.stl', 'CAD/robot.stl'], + // 참고용은 분리 유지(09-22 "인쇄 대상으로 착각" 방지) + ['CAD/reference/cam_left.stl', 'CAD/참고/cam_left.stl'], + ['reference/x.stl', 'CAD/참고/x.stl'], + // 받아 온 모델 묶음은 자기 폴더 유지, stls/ 접두어만 제거 + ['stls/SO101/Leader/a.stl', 'CAD/SO101/Leader/a.stl'], + // 이미지/영상/G-code + ['CAD/assembly.png', '사진/assembly.png'], + ['CAD/reference/assembly.png', '사진/assembly.png'], + ['iso.png', '사진/iso.png'], + ['robot_2min.mp4', '영상/robot_2min.mp4'], + ['out.gcode', '출력/out.gcode'], + // 이미 규칙에 맞음 / 손대지 않는 것 + ['CAD/base.stl', null], + ['CAD/참고/x.stl', null], + ['CAD/SO101/Leader/a.stl', null], + ['CAD/다운로드/m.stl', null], + ['사진/a.png', null], + ['영상/a.mp4', null], + ['출력/a.gcode', null], + ['조립설명서/step-01.png', null], + ['개요/readme.md', null], + ['.hidden/a.stl', null], + ['메모.txt', null], + ['data.csv', null], + // 사용자가 직접 만든 폴더는 존중 + ['견적/부품도.png', null], + ['참고자료/a.stl', null], + ]; + for (const [from, to] of cases) test(`${from} → ${to ?? '(그대로)'}`, () => assert.equal(targetForFile(from), to)); + test('멱등: 결과를 다시 넣으면 그대로', () => { + for (const [, to] of cases) if (to) assert.equal(targetForFile(to), null, to); + }); +}); + +describe('문서 문구 갱신', () => { + test('옛 폴더 이름을 새 이름으로', () => { + assert.equal(renameTextPaths('CAD/print/ 와 CAD/reference/ 그리고 CAD/print, CAD/reference, CAD/렌더'), 'CAD/ 와 CAD/참고/ 그리고 CAD, CAD/참고, 사진'); + }); +}); + +function tmpWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'layout-')); + const ws = path.join(dir, '.smallclaw', 'users', 'tester', 'workspace'); + const put = (rel: string, body = 'x') => { const p = path.join(ws, 'workshop', 'p1', rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); }; + put('CAD/print/rig.scad', 'scad-1'); put('CAD/print/a.stl', 'stl-a'); put('CAD/reference/cam.stl', 'ref'); put('CAD/reference/asm.png', 'png'); + put('demo.mp4', 'v'); put('stls/SO/x.stl', 'x'); put('개요/readme.md', 'r'); put('조립설명서/overview.png', 'o'); put('견적/a.png', 'keep'); + fs.mkdirSync(path.join(ws, '.smallclaw'), { recursive: true }); + fs.writeFileSync(path.join(ws, '.smallclaw', 'workshop.json'), JSON.stringify({ + activeProjectId: 'p1', + projects: [{ id: 'p1', name: 'P', parts: [], phases: [], notes: 'CAD/print/ 에 있음', description: 'CAD/reference/ 는 참고용', + assembly: { scad: 'workshop/p1/CAD/print/rig.scad', view: { rx: 60, rz: 35 }, explode: 1, autoRender: true, parts: [], steps: [] } }], + })); + return dir; +} +const tree = (root: string, base = ''): string[] => fs.readdirSync(path.join(root, base), { withFileTypes: true }).flatMap(e => { + const rel = base ? `${base}/${e.name}` : e.name; + return e.isDirectory() ? tree(root, rel) : [rel]; +}).sort(); + +describe('planProjectMoves / 이동 스크립트', () => { + test('충돌은 덮어쓰지 않고 건너뛴다', () => { + const dir = tmpWorkspace(); + const ws = path.join(dir, '.smallclaw/users/tester/workspace'); + fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/a.stl'), 'existing'); // CAD/print/a.stl 의 도착지에 이미 존재 + const { moves, conflicts } = planProjectMoves(ws, 'p1'); + assert.ok(conflicts.some(c => c.from === 'CAD/print/a.stl')); + assert.ok(!moves.some(m => m.from === 'CAD/print/a.stl')); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('pruneEmptyDirs: 빈 폴더만 지운다', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-')); + fs.mkdirSync(path.join(dir, 'a/b'), { recursive: true }); fs.mkdirSync(path.join(dir, 'c'), { recursive: true }); fs.writeFileSync(path.join(dir, 'c/f.txt'), 'x'); + const removed = pruneEmptyDirs(dir); + assert.deepEqual(removed.sort(), ['a', 'a/b']); + assert.ok(fs.existsSync(path.join(dir, 'c/f.txt'))); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('dry-run은 아무것도 안 바꾸고, --apply로 이동+경로 갱신, --undo로 원복(내용 보존)', () => { + const dir = tmpWorkspace(); + const ws = path.join(dir, '.smallclaw/users/tester/workspace'); + const proj = path.join(ws, 'workshop/p1'); + const before = tree(proj); + const env = { ...process.env, SMALLCLAW_DATA_DIR: dir }; + const run = (...a: string[]) => spawnSync('npx', ['tsx', 'scripts/migrate-workshop-layout.ts', '--user', 'tester', ...a], { cwd: path.join(__dirname, '..'), env, encoding: 'utf-8' }); + + const dry = run(); + assert.match(dry.stdout, /dry-run/); + assert.deepEqual(tree(proj), before, 'dry-run이 파일을 바꿈'); + + const app = run('--apply'); + assert.equal(app.status, 0, app.stderr); + assert.deepEqual(tree(proj), [ + 'CAD/SO/x.stl', 'CAD/a.stl', 'CAD/rig.scad', 'CAD/참고/cam.stl', '견적/a.png', '개요/readme.md', '사진/asm.png', '영상/demo.mp4', '조립설명서/overview.png', + ].sort()); + assert.equal(fs.readFileSync(path.join(proj, 'CAD/rig.scad'), 'utf-8'), 'scad-1'); // 내용 그대로 + const data = JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8')); + assert.equal(data.projects[0].assembly.scad, 'workshop/p1/CAD/rig.scad'); + assert.equal(data.projects[0].notes, 'CAD/ 에 있음'); + assert.equal(fs.existsSync(path.join(proj, 'CAD/print')), false, '빈 폴더 정리'); + assert.equal(fs.existsSync(path.join(proj, 'stls')), false); + + const again = run('--apply'); // 멱등: 두 번째는 이동할 게 없다 + assert.match(again.stdout, /이동 0개/); + + const manifest = fs.readdirSync(path.join(ws, '.smallclaw')).find(f => f.startsWith('workshop-layout-migration-'))!; + const undo = run('--undo', path.join(ws, '.smallclaw', manifest)); + assert.match(undo.stdout, /되돌림/); + assert.deepEqual(tree(proj), before); + assert.equal(JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8')).projects[0].assembly.scad, 'workshop/p1/CAD/print/rig.scad'); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('저장 폴더 결정(saveDirs) / print3d 정돈', () => { + test('프로젝트가 있으면 모델→CAD/다운로드, G-code→출력 / 없으면 print3d 하위', () => { + const p = saveDirs('/w', 'proj_a'); + assert.equal(p.modelDir, '/w/workshop/proj_a/CAD/다운로드'); + assert.equal(p.outDir, '/w/workshop/proj_a/출력'); + assert.equal(p.downloadDir, '/w/workshop/proj_a/CAD/다운로드'); + const n = saveDirs('/w'); + assert.equal(n.modelDir, '/w/print3d/일회성'); + assert.equal(n.outDir, '/w/print3d/일회성'); + assert.equal(n.downloadDir, '/w/print3d/모델검색'); + }); + + test('planPrint3dMoves: k2ui_*→K2웹슬라이서, model-search→모델검색, 루트 잔여→일회성, k2_config.ini→profiles, 기존 하위폴더/프로파일은 그대로', () => { + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'p3d-')); + const put = (rel: string) => { const f = path.join(ws, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'x'); }; + put('print3d/k2ui_123.stl'); put('print3d/k2ui_123.gcode'); put('print3d/loose.stl'); put('print3d/model-search/a.stl'); + put('print3d/esp32_camera/case.stl'); put('print3d/profiles/__current.ini'); put('k2_config.ini'); + const m = planPrint3dMoves(ws).map(x => `${x.from} → ${x.to}`).sort(); + assert.deepEqual(m, [ + 'k2_config.ini → print3d/profiles/k2_config.ini', + 'print3d/k2ui_123.gcode → print3d/K2웹슬라이서/k2ui_123.gcode', + 'print3d/k2ui_123.stl → print3d/K2웹슬라이서/k2ui_123.stl', + 'print3d/loose.stl → print3d/일회성/loose.stl', + 'print3d/model-search/a.stl → print3d/모델검색/a.stl', + ].sort()); + fs.rmSync(ws, { recursive: true, force: true }); + }); +}); diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index 365c99c..4e48fde 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -1632,10 +1632,14 @@ async function loadFiles(){ renderFiles(); } +// 프로젝트 파일 폴더 규칙(서버 workshop-layout.ts와 같은 이름): 비어 있어도 업로드 대상으로 고를 수 있게 권장 폴더를 항상 보여준다. +const STD_FOLDERS=['CAD','CAD/참고','출력','사진','영상']; +function folderRank(f){const i=STD_FOLDERS.indexOf(f);return i>=0?i:(f.startsWith('CAD/')?1.5:100);} function renderFolderSelect(){ const sel=document.getElementById('upload-folder-select'); const prev=sel.value; - sel.innerHTML=''+filesState.folders.map(f=>``).join(''); + const all=[...new Set([...STD_FOLDERS,...filesState.folders])].filter(f=>f!=='조립설명서').sort((a,b)=>folderRank(a)-folderRank(b)||a.localeCompare(b)); + sel.innerHTML=''+all.map(f=>``).join(''); if([...sel.options].some(o=>o.value===prev))sel.value=prev; } @@ -1656,7 +1660,7 @@ function renderFiles(){ } const keys=[...groups.keys()].sort((a,b)=>{ if(a==='')return 1; if(b==='')return -1; - return a.localeCompare(b); + return folderRank(a)-folderRank(b)||a.localeCompare(b); // 규칙 폴더(CAD→참고→출력→사진→영상)를 먼저 }); container.innerHTML=keys.map(key=>{ const files=groups.get(key)||[];