feat: 작업실 파일 폴더 규칙 통일 — STL을 .scad와 같은 폴더에, 프로젝트별 제각각 구조 정돈

규칙(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>
This commit is contained in:
kim
2026-09-25 09:37:55 +09:00
co-authored by Claude Sonnet 5
parent c4807c9400
commit ba193eec3c
11 changed files with 497 additions and 39 deletions
+109
View File
@@ -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 <manifest.json> # 마지막 이동 되돌리기
* 규칙은 src/gateway/routes/workshop-layout.ts. 이동만 하고 삭제/덮어쓰기는 하지 않는다(충돌은 건너뛰고 보고).
* --apply는 되돌리기용 manifest를 <workspace>/.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<string, { from: string; to: string }> = {};
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}`);
}
+2 -2
View File
@@ -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: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
},
},
+7 -10
View File
@@ -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>/)는 ` +
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.` +
`도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` +
+3 -1
View File
@@ -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}`);
+1 -1
View File
@@ -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 */
+181
View File
@@ -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<string, string>();
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),
};
}
+7 -6
View File
@@ -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: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
},
additionalProperties: false,
@@ -451,14 +452,14 @@ export const modelDownloadTool = {
}
const workspacePath = getWorkspacePath(args);
const baseDir = path.join(workspacePath, 'print3d');
// 저장 위치(workshop-layout.ts): 프로젝트가 있으면 workshop/<id>/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 });
+20 -16
View File
@@ -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/<id>/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/<id>/CAD/다운로드/, 슬라이싱 결과 G-code →
// workshop/<id>/출력/. 프로젝트가 없으면 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` +
+1 -1
View File
@@ -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)") — 따옴표/세미콜론 금지.',
+160
View File
@@ -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 });
});
});
+6 -2
View File
@@ -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='<option value="">(폴더 없음)</option>'+filesState.folders.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}</option>`).join('');
const all=[...new Set([...STD_FOLDERS,...filesState.folders])].filter(f=>f!=='조립설명서').sort((a,b)=>folderRank(a)-folderRank(b)||a.localeCompare(b));
sel.innerHTML='<option value="">(폴더 없음)</option>'+all.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}${STD_FOLDERS.includes(f)&&!filesState.folders.includes(f)?' (권장)':''}</option>`).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)||[];