feat: .scad 소스를 프로젝트 SCAD/ 폴더로 분리 (STL은 CAD/ 유지) + 렌더 미리보기는 사진/

사용자 지시(".scad 폴더를 따로"): 변형 .scad가 많은 프로젝트(로봇팔 등)에서 STL과 섞이면 고르기 어려움
- 규칙: workshop/<id>/SCAD/(.scad 소스만) · CAD/(STL 등 모델, CAD/참고, CAD/다운로드) · 출력/ · 사진/ · 영상/ · 조립설명서/
  옛 "STL을 .scad와 같은 폴더" 규칙은 폐지. 사용자가 만든 폴더의 .scad는 건드리지 않음
- 이동 스크립트가 .scad→SCAD/ 처리(조립 명세의 scad 경로도 갱신). 09-25 papa 적용: .scad 12개 + 렌더 PNG 5개 이동, 무손실 검증
- stl_cad 렌더 미리보기 PNG를 프로젝트 STL이면 사진/ 에 저장(CAD/에 이미지가 다시 쌓이던 문제)
- 채팅 프롬프트/도구 안내: 새 .scad는 SCAD/, STL 출력은 CAD/. 파일 탭 권장 폴더에 SCAD 추가
- 테스트: 규칙 표 갱신, apply/undo 왕복, previewPngPath

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 10:38:41 +09:00
co-authored by Claude Sonnet 5
parent 86d541d928
commit 2bdc5563dc
8 changed files with 59 additions and 24 deletions
+7 -7
View File
@@ -283,12 +283,12 @@ async function handleChat(
};
walk(projDir, 0);
if (scadFiles.length > 0) {
// 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/참고/"에 둔다.';
// 09-25 폴더 규칙(workshop-layout.ts): .scad 소스는 SCAD/, 그 STL 출력은 CAD/ (사용자 지시로 .scad를 따로 분리 — 옛
// "STL을 .scad와 같은 폴더" 규칙 폐지). 인쇄와 무관한 참고 모델만 CAD/참고/ 로 분리해 "참고용을 인쇄 대상으로
// 착각"하던 문제(09-22)를 계속 막는다.
scadHint = ' scad_to_stl로 STL을 만들 때 output_path는 특별한 지시가 없는 한 워크스페이스의 그 프로젝트 CAD/ 폴더에 ' +
'저장할 것(예: ".../SCAD/rig.scad" → 부품 하나는 ".../CAD/base_plate.stl", 전체 조립본은 ".../CAD/rig.stl"). ' +
'새 .scad 소스 파일(write_scad)은 프로젝트의 SCAD/ 폴더에 저장할 것. 인쇄 대상이 아닌 참고용 모델(구매 부품 형상 등)만 ".../CAD/참고/"에 둔다.';
}
if (scadFiles.length === 1) {
scadHint += ` 이 프로젝트엔 .scad 파일이 "${scadFiles[0]}" 하나뿐이다 — 사용자가 ` +
@@ -348,7 +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, 사진/=사진·렌더 이미지, 영상/=영상. 새 파일은 이 규칙대로 저장할 것. ` +
`폴더 규칙: SCAD/=.scad 소스만, CAD/=STL 등 모델 파일, CAD/참고/=비인쇄 참고 모델, 출력/=G-code, 사진/=사진·렌더 이미지, 영상/=영상. 새 파일은 이 규칙대로 저장할 것. ` +
`파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` +
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.` +
`도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` +
+1 -1
View File
@@ -32,7 +32,7 @@ export interface AssemblyStep {
tools: string[];
}
export interface Assembly {
/** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/rig.scad") */
/** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/SCAD/rig.scad") */
scad: string;
view: { rx: number; rz: number };
/** 분해도 간격 배율(offset × 이 값). 기본 1 */
+22 -4
View File
@@ -4,7 +4,8 @@
* 하나의 규칙으로 통일한다. 도구(print3d_model/model_download/write_scad 안내)와 이동 스크립트가 같은 상수를 쓴다.
*
* workshop/<프로젝트id>/
* CAD/ .scad 원본 + 그 .scad에서 뽑은 STL(같은 폴더에 나란히)
* SCAD/ .scad 소스만(2026-09-25 사용자 지시로 CAD/에서 분리 — 변형 파일이 많아 STL과 섞이면 고르기 어려움)
* CAD/ STL 등 모델 파일(.scad에서 뽑은 인쇄용 STL)
* 참고/ 비인쇄 참고 모델(카메라·프로젝터 형상 등) — 인쇄용과 섞여 "인쇄 대상으로 착각"하던 문제(09-22) 방지
* 다운로드/ model_download로 받은 외부 모델
* <외부세트>/ 받아 온 모델 묶음(예: SO101/Follower)은 자기 이름 폴더 그대로
@@ -23,6 +24,7 @@ import fs from 'fs';
import path from 'path';
export const LAYOUT = {
scad: 'SCAD',
cad: 'CAD',
reference: 'CAD/참고',
downloads: 'CAD/다운로드',
@@ -35,7 +37,8 @@ export const LAYOUT = {
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 SCAD_EXT = /\.scad$/i;
const CAD_EXT = /\.(stl|3mf|step|stp|iges|igs|obj|dxf)$/i; // .scad는 별도(SCAD/)
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;
@@ -59,21 +62,23 @@ export function targetForFile(rel: string): string | null {
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;
const kind = SCAD_EXT.test(name) ? 'scad' : 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 === 'scad' && top === LAYOUT.scad) 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 inCadLegacy = top === LAYOUT.cad && (LEGACY_TREES.has(dirs[1] ?? '') || dirs.length === 1 || kind === 'scad');
const inLegacyTree = LEGACY_TREES.has(top);
const atRoot = dirs.length === 0;
if (!(atRoot || inCadLegacy || inLegacyTree)) return null;
if (kind === 'scad') return `${LAYOUT.scad}/${name}`;
if (kind === 'image') return `${LAYOUT.photos}/${name}`;
if (kind === 'video') return `${LAYOUT.videos}/${name}`;
if (kind === 'gcode') return `${LAYOUT.out}/${name}`;
@@ -179,3 +184,16 @@ export function saveDirs(workspace: string, projectId?: string): { modelDir: str
downloadDir: path.join(workspace, PRINT3D_DIRS.modelSearch),
};
}
// STL 렌더 미리보기 PNG 저장 위치: 작업실 프로젝트 안의 STL이면 그 프로젝트의 사진/ 폴더로(모델 폴더 CAD/에 이미지가 쌓이지 않게),
// 그 밖(print3d/ 등)이면 예전처럼 STL 옆.
export function previewPngPath(workspace: string, stlAbsPath: string, suffix: string): string {
const base = path.basename(stlAbsPath).replace(/\.stl$/i, '');
const wsRoot = path.join(path.resolve(workspace), 'workshop') + path.sep;
const abs = path.resolve(stlAbsPath);
if (abs.startsWith(wsRoot)) {
const projectId = abs.slice(wsRoot.length).split(path.sep)[0];
if (projectId && !projectId.startsWith('.')) return path.join(wsRoot, projectId, LAYOUT.photos, `${base}_${suffix}.png`);
}
return path.join(path.dirname(abs), `${base}_${suffix}.png`);
}
+5 -2
View File
@@ -6,6 +6,7 @@ import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown, isPathInsideDir } from './image.js';
import { autoRenderForScad } from './workshop-assembly-render.js';
import { previewPngPath } from '../gateway/routes/workshop-layout.js';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis } from './stl-cad-core.js';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
@@ -145,7 +146,8 @@ async function compileScadToStl(opts: {
let stdout = `**${path.basename(outPath)}** 생성됨${partLine}\n${formatAnalysisBrief(analysis)}\n- 저장 위치: ${outPathArg}${partsLine}`;
if (opts.preview) {
const pngPath = outPath.replace(/\.stl$/i, `_iso_${Date.now()}.png`);
const pngPath = previewPngPath(workspacePath, outPath, `iso_${Date.now()}`); // 프로젝트 STL이면 사진/ 폴더
fs.mkdirSync(path.dirname(pngPath), { recursive: true });
const render = await renderStlPng(outPath, 'iso', pngPath);
if (render.ok) stdout += `\n\n${buildImageMarkdown(pngPath, workspacePath)}`;
}
@@ -231,7 +233,8 @@ export const stlCadTool = {
const mdParts: string[] = [];
for (const view of views) {
const v = String(view || 'iso');
const pngPath = stlPath.replace(/\.stl$/i, `_${v}_${Date.now()}.png`);
const pngPath = previewPngPath(workspacePath, stlPath, `${v}_${Date.now()}`); // 프로젝트 STL이면 사진/ 폴더
fs.mkdirSync(path.dirname(pngPath), { recursive: true });
const render = await renderStlPng(stlPath, v, pngPath);
if (!render.ok) return { success: false, error: `렌더링 실패(${v}): ${render.detail}` };
mdParts.push(buildImageMarkdown(pngPath, workspacePath));
+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/rig.scad"). 생략하면 현재 조립 명세의 scad.' },
scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/SCAD/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)") — 따옴표/세미콜론 금지.',
+21 -7
View File
@@ -5,15 +5,16 @@ 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';
import { targetForFile, planProjectMoves, pruneEmptyDirs, renameTextPaths, saveDirs, planPrint3dMoves, previewPngPath } from '../src/gateway/routes/workshop-layout';
describe('targetForFile — 폴더 규칙', () => {
const cases: [string, string | null][] = [
// 인쇄용 STL은 .scad와 같은 폴더(CAD/)
// .scad 소스는 SCAD/ (사용자 지시로 STL과 분리), STL 등 모델 파일은 CAD/
['CAD/print/base_plate.stl', 'CAD/base_plate.stl'],
['CAD/print/scanner-rig.scad', 'CAD/scanner-rig.scad'],
['CAD/print/scanner-rig.scad', 'SCAD/scanner-rig.scad'],
['CAD/rig.scad', 'SCAD/rig.scad'],
['print/base.stl', 'CAD/base.stl'],
['robot.scad', 'CAD/robot.scad'],
['robot.scad', 'SCAD/robot.scad'],
['robot.stl', 'CAD/robot.stl'],
// 참고용은 분리 유지(09-22 "인쇄 대상으로 착각" 방지)
['CAD/reference/cam_left.stl', 'CAD/참고/cam_left.stl'],
@@ -28,6 +29,8 @@ describe('targetForFile — 폴더 규칙', () => {
['out.gcode', '출력/out.gcode'],
// 이미 규칙에 맞음 / 손대지 않는 것
['CAD/base.stl', null],
['SCAD/a.scad', null],
['견적/x.scad', null], // 사용자가 만든 폴더의 .scad도 존중
['CAD/참고/x.stl', null],
['CAD/SO101/Leader/a.stl', null],
['CAD/다운로드/m.stl', null],
@@ -109,11 +112,11 @@ describe('planProjectMoves / 이동 스크립트', () => {
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',
'CAD/SO/x.stl', 'CAD/a.stl', 'SCAD/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'); // 내용 그대로
assert.equal(fs.readFileSync(path.join(proj, 'SCAD/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].assembly.scad, 'workshop/p1/SCAD/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);
@@ -158,3 +161,14 @@ describe('저장 폴더 결정(saveDirs) / print3d 정돈', () => {
fs.rmSync(ws, { recursive: true, force: true });
});
});
describe('previewPngPath — STL 렌더 미리보기 저장 위치', () => {
test('작업실 프로젝트의 STL이면 그 프로젝트 사진/ 폴더로', () => {
assert.equal(previewPngPath('/w', '/w/workshop/proj_a/CAD/base.stl', 'iso_1'), '/w/workshop/proj_a/사진/base_iso_1.png');
assert.equal(previewPngPath('/w', '/w/workshop/proj_a/CAD/참고/cam.stl', 'top_2'), '/w/workshop/proj_a/사진/cam_top_2.png');
});
test('프로젝트 밖(print3d 등)이면 STL 옆에', () => {
assert.equal(previewPngPath('/w', '/w/print3d/일회성/x.stl', 'iso_1'), '/w/print3d/일회성/x_iso_1.png');
assert.equal(previewPngPath('/w', '/w/other/y.stl', 'front_3'), '/w/other/y_front_3.png');
});
});
+1 -1
View File
@@ -122,7 +122,7 @@ function renderAssembly(){
</div>
<div class="asm-row">
<span style="font-size:10px;color:var(--muted)">경로 직접 입력</span>
<input type="text" id="asm-scad" style="flex:1;min-width:220px;font-size:11px" placeholder="목록에 없는 파일이면 워크스페이스 기준 경로를 직접 입력 (예: workshop/<프로젝트id>/CAD/xxx.scad)" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
<input type="text" id="asm-scad" style="flex:1;min-width:220px;font-size:11px" placeholder="목록에 없는 파일이면 워크스페이스 기준 경로를 직접 입력 (예: workshop/<프로젝트id>/SCAD/xxx.scad)" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
</div>
<div class="asm-row">
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
+1 -1
View File
@@ -35,7 +35,7 @@ async function loadFiles(){
}
// 프로젝트 파일 폴더 규칙(서버 workshop-layout.ts와 같은 이름): 비어 있어도 업로드 대상으로 고를 수 있게 권장 폴더를 항상 보여준다.
const STD_FOLDERS=['CAD','CAD/참고','출력','사진','영상'];
const STD_FOLDERS=['SCAD','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');