feat: 3D 모델 저장소 연동 — model_search/model_download (Printables·MakerWorld·Thingiverse)

- model_search: 3사이트 통합 검색(Thingiverse는 토큰 필요, Printables/MakerWorld는 익명 검색 실측 확인)
- model_download: 슬라이싱 가능한 STL/3MF 중 최대 파일 1개 워크스페이스 저장(Printables getDownloadLink 익명 동작, Thingiverse /v2/files download, MakerWorld는 Bambu 토큰 필요)
- print3d_model에 stl_path 추가 — model_download로 받아둔 파일 바로 슬라이싱
- 토큰은 vault secret(print3d.*_token/cookie), Thingiverse App Token 등록 완료
- 게이트: ws_ 세션 항상 + 메인 채팅은 3D 키워드

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-23 11:43:03 +09:00
co-authored by Claude Code
parent 4d6afc10c8
commit 5a571f592e
7 changed files with 703 additions and 26 deletions
+4
View File
@@ -336,6 +336,10 @@ const SECRET_FIELD_MAP: Array<[string[], string]> = [
[['legal', 'courtlistener_api_key'], 'legal.courtlistener_api_key'],
[['writer', 'providers', 'google_books', 'apiKey'], 'writer.providers.google_books.apiKey'],
[['writer', 'providers', 'aladin', 'apiKey'], 'writer.providers.aladin.apiKey'],
// 3D 모델 저장소 (model-search.ts, 2026-09-23)
[['print3d', 'printables_cookie'], 'print3d.printables_cookie'],
[['print3d', 'makerworld_token'], 'print3d.makerworld_token'],
[['print3d', 'thingiverse_token'], 'print3d.thingiverse_token'],
];
function deepGet(obj: any, keys: string[]): string | undefined {
+34 -2
View File
@@ -1106,12 +1106,13 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
type: 'function',
function: {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
description: 'STL 3D모델 URL(stl_url) 또는 워크스페이스 파일(stl_path)을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체는 model_search(작업실 세션) 또는 web_search로 먼저 찾고, 확보한 직접 다운로드 URL을 stl_url로 넘기거나 model_download로 받아둔 파일을 stl_path로 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
parameters: {
type: 'object', required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
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 우선.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
@@ -1122,6 +1123,37 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
name: 'model_search',
description: '3D 모델 저장소(Thingiverse/Printables/MakerWorld)를 통합 검색한다. "608ZZ 베어링 마운트 STL 찾아줘", "메카넘휠 모델 검색" 같은 요청에 사용. 영문 검색어가 가장 잘 맞는다(한글 제목은 거의 없음). 결과에서 마음에 드는 모델의 id를 model_download(site + model_id)에 넣어 워크스페이스로 받는다. Thingiverse는 토큰(print3d.thingiverse_token)이 없으면 스킵되고, MakerWorld 다운로드는 별도 토큰이 필요하지만 검색은 토큰 없이도 된다.',
parameters: {
type: 'object', required: ['query'],
properties: {
query: { type: 'string', description: '(필수) 검색어. 영문 권장 — 세 사이트 모두 영문 제목 매칭이 가장 잘 됨(예: "mecanum wheel", "608 bearing mount")' },
site: { type: 'string', enum: ['all', 'printables', 'makerworld', 'thingiverse'], description: '(선택) 검색할 사이트. 기본 all — 설정된 사이트 전부에서 검색해 합친다. 특정 사이트를 지목당하거나 다른 사이트에서 적당한 게 없을 때만 좁힐 것.' },
limit: { type: 'number', description: '(선택) 사이트당 최대 결과 수. 기본 5' },
},
},
},
},
{
type: 'function',
function: {
name: 'model_download',
description: 'model_search 결과에서 골라낸 3D 모델(Printables/MakerWorld/Thingiverse)을 워크스페이스로 내려받는다. 슬라이싱 가능한 STL/3MF 중 가장 큰 파일 1개를 저장하고 나머지 파일은 웹에서 직접 받으라고 안내한다. 받은 파일은 print3d_model(action="prepare", stl_path=...)로 바로 슬라이싱할 수 있다. Thingiverse/MakerWorld는 각각 토큰(print3d.thingiverse_token / print3d.makerworld_token)이 필요하고, Printables는 토큰 없이도 대부분 받을 수 있다.',
parameters: {
type: 'object', required: ['site', 'model_id'],
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/<프로젝트명>/에 저장' },
filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
},
},
},
},
{
type: 'function',
function: {
+4
View File
@@ -388,6 +388,10 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false;
if (name === 'nvr_snapshot' && !hasCctvKeyword) return false;
if (name === 'print3d_model' && !hasPrint3dKeyword) return false;
// model_search/model_download — 09-23: 3D 모델 저장소(Printables/MakerWorld/Thingiverse)
// 검색·다운로드. 작업실 세션(ws_)에선 항상 노출, 메인 채팅에선 print3d_model과 같은
// 3D 키워드에서만 열어둔다(stl 클러스터의 "작업실 전용"과 print3d_model의 키워드 게이트 중간).
if ((name === 'model_search' || name === 'model_download') && !isWorkshopAppSession && !hasPrint3dKeyword) return false;
// 09-22: 사용자 요청 "작업실 전용으로 하자" — 이 셋 다 워크숍 세션(ws_)에서만 노출.
// 원래 stl_cad는 키워드만 맞으면 메인 채팅/다른 앱에서도 쓰였는데(k2-app.html 웹 UI
// 패널과는 별개 경로라 안 건드려도 됨 — routes-cad.ts 직접 호출, 이 게이트 무관),
+488
View File
@@ -0,0 +1,488 @@
import path from 'path';
import fs from 'fs';
import { ToolResult } from '../types.js';
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';
// 3D 모델 저장소 검색+다운로드 (2026-09-23) — Printables / MakerWorld / Thingiverse.
// 세 곳 모두 공식 API가 없거나(Printables/MakerWorld) 응용토큰이 필요한(Thingiverse) 사이라
// 이 파일 하나에 클라이언트를 모은다. 실측(2026-09-23 curl):
// - Printables: api.printables.com/graphql/ 익명 검색·상세·getDownloadLink 모두 동작(로그인 불필요)
// - MakerWorld: api.bambulab.com/v1/search-service/select/design2 익명 검색 동작.
// 다운로드는 Bambu Cloud 토큰(makerworld_token, ~90일 만료) 필요 — 미설정 시 검색만 가능
// - Thingiverse: api.thingiverse.com 공식 REST + Bearer 토큰(앱 등록 시 발급).
// 검색·파일목록·다운로드 전부 토큰 필요
//
// 키가 없어도 되는 범위가 많으므로 "키 미설정"은 에러가 아니라 스킵+진단으로 처리한다
// (web.ts runSearchProviders의 프로바이더 캐스케이드와 같은 우아한 저하 방식).
export type ModelSite = 'printables' | 'makerworld' | 'thingiverse';
const ALL_SITES: ModelSite[] = ['thingiverse', 'printables', 'makerworld'];
export interface ModelResult {
site: ModelSite;
id: string;
name: string;
author: string;
likes: number;
downloads: number;
url: string;
fileCount: number;
}
// ─── 키/토큰 해석 (news.ts getApiKey 패턴: resolveSecret → vault 폴백) ───────
function getConfigKey(site: ModelSite): string {
if (site === 'printables') return 'print3d.printables_cookie';
if (site === 'makerworld') return 'print3d.makerworld_token';
return 'print3d.thingiverse_token';
}
function getSiteKey(site: ModelSite): string | undefined {
try {
const cm = getConfig();
const cfg = cm.getConfig() as any;
const dotted = getConfigKey(site);
const [a, b] = dotted.split('.');
const resolved = cm.resolveSecret(cfg?.[a]?.[b]);
if (resolved) return resolved;
const vault = getVault(cm.getConfigDir());
return vault.get(dotted, 'model-search:key')?.expose();
} catch {}
return undefined;
}
// ─── 순수 함수 (단위 테스트 대상 — news.ts resolveNewsParams 추출 패턴) ──────
export function parseModelRef(site: ModelSite, ref: string): { id: string } | { error: string } {
const s = String(ref || '').trim();
if (!s) return { error: 'model_id가 필요합니다.' };
if (/^\d+$/.test(s)) return { id: s };
// URL 붙여넣기 허용: printables.com/model/123-slug, makerworld.com/en/models/123, thingiverse.com/thing:123
try {
const u = new URL(s.startsWith('http') ? s : `https://${s}`);
const host = u.hostname.replace(/^www\./, '');
const parts = u.pathname.split('/').filter(Boolean);
if (host === 'printables.com') {
const idx = parts.indexOf('model') !== -1 ? parts.indexOf('model') : parts.indexOf('print');
if (idx !== -1 && /^\d+/.test(parts[idx + 1] || '')) return { id: (parts[idx + 1].match(/^\d+/) as any)[0] };
} else if (host === 'makerworld.com') {
const idx = parts.indexOf('models');
if (idx !== -1 && /^\d+/.test(parts[idx + 1] || '')) return { id: (parts[idx + 1].match(/^\d+/) as any)[0] };
} else if (host === 'thingiverse.com') {
const thing = parts.find((p) => p.startsWith('thing:'));
if (thing) return { id: thing.split(':')[1] };
const idx = parts.indexOf('things');
if (idx !== -1 && /^\d+$/.test(parts[idx + 1] || '')) return { id: parts[idx + 1] };
}
return { error: `${host} URL에서 모델 id를 찾지 못했습니다. model_search 결과의 id나 모델 페이지 URL을 넣어주세요.` };
} catch {
return { error: `model_id를 해석하지 못했습니다: ${s}` };
}
}
// 검색 대상 결정 — site 미지정이면 전부 시도. 키 유무는 검색(익명 가능)엔 무관하고
// Thingiverse 검색만 토큰 필요라 keys에서 판단한다.
export function resolveSitePlan(
args: any,
keys: { printables?: string; makerworld?: string; thingiverse?: string }
): { attempt: ModelSite[]; skipped: Array<{ site: ModelSite; reason: string }> } {
const skipped: Array<{ site: ModelSite; reason: string }> = [];
const explicit = String(args?.site || 'all').trim().toLowerCase();
if (explicit !== 'all' && ALL_SITES.includes(explicit as ModelSite)) {
const site = explicit as ModelSite;
if (site === 'thingiverse' && !keys.thingiverse) {
return { attempt: [], skipped: [{ site, reason: 'Thingiverse는 토큰이 필요합니다 (print3d.thingiverse_token 미설정)' }] };
}
return { attempt: [site], skipped };
}
const attempt: ModelSite[] = [];
for (const site of ALL_SITES) {
if (site === 'thingiverse' && !keys.thingiverse) {
skipped.push({ site, reason: '토큰 미설정 (print3d.thingiverse_token) — Thingiverse는 익명 검색 불가' });
continue;
}
attempt.push(site);
}
return { attempt, skipped };
}
export function mapThingiverseResults(data: any): ModelResult[] {
const hits = Array.isArray(data?.hits) ? data.hits : [];
return hits.map((h: any) => ({
site: 'thingiverse' as const,
id: String(h?.id ?? ''),
name: String(h?.name || '(제목 없음)'),
author: String(h?.creator?.name || ''),
likes: Number(h?.like_count) || 0,
downloads: Number(h?.download_count) || 0,
url: String(h?.public_url || `https://www.thingiverse.com/thing:${h?.id ?? ''}`),
fileCount: 0,
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
}
export function mapPrintablesResults(data: any): ModelResult[] {
const items = Array.isArray(data?.items) ? data.items : [];
return items.map((p: any) => ({
site: 'printables' as const,
id: String(p?.id ?? ''),
name: String(p?.name || '(제목 없음)'),
author: String(p?.user?.publicUsername || ''),
likes: Number(p?.likesCount) || 0,
downloads: Number(p?.downloadCount) || 0,
url: `https://www.printables.com/model/${p?.id ?? ''}`,
fileCount: Number(p?.filesCount) || 0,
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
}
export function mapMakerWorldResults(data: any): ModelResult[] {
const hits = Array.isArray(data?.hits) ? data.hits : [];
return hits.map((h: any) => ({
site: 'makerworld' as const,
id: String(h?.id ?? ''),
name: String(h?.title || '(제목 없음)'),
author: String(h?.designCreator?.name || ''),
likes: Number(h?.likeCount) || 0,
downloads: Number(h?.downloadCount) || 0,
url: `https://makerworld.com/en/models/${h?.id ?? ''}`,
fileCount: 0,
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
}
export function formatModelResults(
results: ModelResult[],
skipped: Array<{ site: ModelSite; reason: string }>,
query: string
): string {
if (results.length === 0) {
const skipNote = skipped.length ? `\n스킵됨: ${skipped.map((s) => `${s.site}(${s.reason})`).join(', ')}` : '';
return `"${query}" 검색 결과가 없습니다.${skipNote}\n영문 검색어를 시도해 보세요(세 사이트 모두 영문 제목 매칭이 가장 잘 됩니다).`;
}
const lines: string[] = [`"${query}" 검색 결과 ${results.length}건:`];
for (let i = 0; i < results.length; i++) {
const r = results[i];
const stats: string[] = [];
if (r.likes) stats.push(`좋아요 ${r.likes.toLocaleString()}`);
if (r.downloads) stats.push(`다운로드 ${r.downloads.toLocaleString()}`);
if (r.fileCount) stats.push(`파일 ${r.fileCount}개`);
lines.push(`${i + 1}. [${r.site}] ${r.name} — ${r.author || '작성자 미상'}${stats.length ? ` (${stats.join(', ')})` : ''}\n id: ${r.id} · ${r.url}`);
}
if (skipped.length) {
lines.push(`\n스킵됨: ${skipped.map((s) => `${s.site}(${s.reason})`).join(', ')}`);
}
lines.push('\n다운로드하려면 model_download(site + model_id)를 호출하세요. 슬라이싱·출력까지 하려면 다운로드 후 print3d_model(prepare, stl_path=...)로 이어가면 됩니다.');
return lines.join('\n');
}
// 사이트별 다운로드 실패 진단 — 401/403은 토큰 만료/미설정 안내로 변환.
export function downloadErrorDetail(site: ModelSite, status: number, body: string): string {
const base = `${site} 다운로드 실패: HTTP ${status}`;
if (site === 'thingiverse' && (status === 401 || status === 403)) {
return `${base} — Thingiverse 토큰이 만료됐거나 유효하지 않습니다. print3d.thingiverse_token을 갱신하세요(발급: thingiverse.com/apps/create → App Token).`;
}
if (site === 'makerworld' && (status === 401 || status === 403 || status === 418)) {
return `${base} — Bambu Cloud 토큰이 만료됐거나 미설정입니다. MakerWorld 토큰(AAB_ 시작, 약 90일 유효)을 재발급해 print3d.makerworld_token에 넣으세요. 418은 캡차 챌린지입니다(시간이 지나 재시도).`;
}
if (site === 'printables' && (status === 401 || status === 403)) {
return `${base} — 로그인이 필요한 모델일 수 있습니다. Printables 세션 쿠키(sessionid=...)를 print3d.printables_cookie에 넣고 재시도하세요.`;
}
return `${base} ${body.slice(0, 200)}`;
}
// 파일 목록에서 슬라이싱 가능한(.stl/.3mf) 최적 파일 1개를 고른다 — 큰 것 우선.
export function pickSlicableFile<T extends { name: string; size: number; id?: any }>(files: T[]): T | null {
const ok = files.filter((f) => /\.(stl|3mf)$/i.test(String(f?.name || '')));
if (!ok.length) return null;
return ok.sort((a, b) => (Number(b.size) || 0) - (Number(a.size) || 0))[0];
}
// ─── 네트워크 ────────────────────────────────────────────────────────────────
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
async function jsonFetch(url: string, init: RequestInit = {}, timeoutMs = 20_000): Promise<{ status: number; json: any; text: string }> {
const res = await fetch(url, {
...init,
headers: { 'User-Agent': UA, ...(init.headers || {}) },
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
let json: any = null;
try { json = JSON.parse(text); } catch {}
return { status: res.status, json, text };
}
// Thingiverse 검색 — 공식 REST. GET /search/{term}/ (Bearer 토큰 필수, 실측 확인)
async function searchThingiverse(token: string, query: string, limit: number): Promise<ModelResult[]> {
const { status, json } = await jsonFetch(
`https://api.thingiverse.com/search/${encodeURIComponent(query)}/?per_page=${limit}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (status === 401 || status === 403) throw new Error(downloadErrorDetail('thingiverse', status, ''));
if (!status || status >= 400) throw new Error(downloadErrorDetail('thingiverse', status, ''));
return mapThingiverseResults(json);
}
// Printables 검색 — 내부 GraphQL(searchPrints2), 익명 동작(실측 확인).
async function searchPrintables(query: string, limit: number): Promise<ModelResult[]> {
const body = {
operationName: 'SearchModels',
query: `query SearchModels($query: String!, $limit: Int) { result: searchPrints2(query: $query, printType: print, limit: $limit, ordering: best_match) { items { id name slug likesCount downloadCount filesCount user { publicUsername } } } }`,
variables: { query, limit },
};
const { status, json, text } = await jsonFetch('https://api.printables.com/graphql/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (status >= 400 || !json?.data?.result) throw new Error(`Printables 검색 실패: HTTP ${status} ${text.slice(0, 200)}`);
return mapPrintablesResults(json.data.result);
}
// MakerWorld 검색 — api.bambulab.com search-service, 익명 동작(실측 확인).
// makerworld.com 직접 접근은 Cloudflare에 막히므로 절대 직접 긁지 말 것.
async function searchMakerWorld(query: string, limit: number): Promise<ModelResult[]> {
const url = `https://api.bambulab.com/v1/search-service/select/design2?keyword=${encodeURIComponent(query)}&pageIndex=1&pageSize=${limit}&sort=DEFAULT`;
const { status, json } = await jsonFetch(url);
if (status >= 400 || !json?.hits) throw new Error(`MakerWorld 검색 실패: HTTP ${status}`);
return mapMakerWorldResults(json);
}
// ─── 다운로드 URL 해석 ───────────────────────────────────────────────────────
interface ResolvedDownload { url: string; headers: Record<string, string>; ext: string; name: string; totalFiles: number }
// Thingiverse: GET /things/{id}/files → download_url(/v2/files/{id}/download, Bearer 필요)
async function resolveThingiverseDownload(id: string, token: string): Promise<ResolvedDownload> {
const { status, json, text } = await jsonFetch(
`https://api.thingiverse.com/things/${id}/files`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (status >= 400) throw new Error(downloadErrorDetail('thingiverse', status, text));
const files = Array.isArray(json) ? json : [];
const pick = pickSlicableFile(files.map((f: any) => ({ id: f.id, name: String(f?.name || ''), size: Number(f?.size) || 0, download_url: String(f?.download_url || '') })));
if (!pick || !(pick as any).download_url) {
const exts = files.map((f: any) => path.extname(String(f?.name || '')).toLowerCase()).filter(Boolean).join(', ');
throw new Error(`Thingiverse thing ${id}에 슬라이싱 가능한 STL/3MF 파일이 없습니다${exts ? ` (있는 파일 형식: ${exts})` : ''} — 웹에서 직접 받아야 합니다.`);
}
const url = (pick as any).download_url.startsWith('http') ? (pick as any).download_url : `https://api.thingiverse.com${(pick as any).download_url}`;
return {
url,
headers: { Authorization: `Bearer ${token}` },
ext: path.extname((pick as any).name).toLowerCase(),
name: String((pick as any).name),
totalFiles: files.length,
};
}
// Printables: GraphQL print(id) 파일목록 → getDownloadLink mutation(익명 동작, 실측 확인).
// 주의: stls 리스트에 .stp(Step) 파일도 섞여 있음 — 슬라이싱 가능한 stl/3mf만 고름.
async function resolvePrintablesDownload(id: string, cookie?: string): Promise<ResolvedDownload> {
const detailBody = {
operationName: 'PrintDetail',
query: `query PrintDetail($id: ID!) { print(id: $id) { id name stls { id name fileSize } gcodes { id name fileSize } } }`,
variables: { id },
};
const d = await jsonFetch('https://api.printables.com/graphql/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(detailBody),
});
if (d.status >= 400 || !d.json?.data?.print) throw new Error(`Printables 모델 조회 실패: HTTP ${d.status} ${d.text.slice(0, 200)}`);
const files: any[] = [
...(d.json.data.print.stls || []),
...(d.json.data.print.gcodes || []),
].map((f: any) => ({ id: String(f.id), name: String(f.name || ''), size: Number(f.fileSize) || 0 }));
const pick = pickSlicableFile(files);
if (!pick) {
const exts = [...new Set(files.map((f: any) => path.extname(f.name).toLowerCase()).filter(Boolean))].join(', ');
throw new Error(`Printables 모델 ${id}에 슬라이싱 가능한 STL/3MF 파일이 없습니다${exts ? ` (있는 파일 형식: ${exts})` : ''} — 웹에서 직접 받아야 합니다.`);
}
const fileType = /\.3mf$/i.test(pick.name) ? '3mf' : 'stl';
const dlBody = {
operationName: 'GetDownloadLink',
query: `mutation GetDownloadLink($id: ID!, $modelId: ID!, $fileType: DownloadFileTypeEnum!, $source: DownloadSourceEnum!) { getDownloadLink(id: $id, printId: $modelId, fileType: $fileType, source: $source) { ok errors { field } output { link } } }`,
variables: { id: pick.id, modelId: id, fileType, source: 'model_detail' },
};
const dl = await jsonFetch('https://api.printables.com/graphql/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dlBody),
});
const link = dl.json?.data?.getDownloadLink?.output?.link;
if (!link) throw new Error(downloadErrorDetail('printables', dl.status, dl.text.slice(0, 200)));
return { url: link, headers: cookie ? { Cookie: cookie } : {}, ext: path.extname(pick.name).toLowerCase(), name: pick.name, totalFiles: files.length };
}
// MakerWorld: 디자인 상세(익명) → 인스턴스 목록 → Bambu Cloud 토큰으로 presigned URL 발급.
// (YASTL #51 / Bambuddy 문서의 역공학 플로우 — 토큰 없으면 1단계까지만 가능)
async function resolveMakerWorldDownload(id: string, token: string): Promise<ResolvedDownload> {
const detail = await jsonFetch(`https://api.bambulab.com/v1/design-service/design/${id}`);
if (detail.status >= 400) throw new Error(`MakerWorld 모델 조회 실패: HTTP ${detail.status}`);
const modelId = detail.json?.design?.modelId || detail.json?.modelId || detail.json?.data?.modelId;
const inst = await jsonFetch(`https://api.bambulab.com/v1/design-service/design/${id}/instances`);
const hits = Array.isArray(inst.json?.hits) ? inst.json.hits : (Array.isArray(inst.json?.data?.hits) ? inst.json.data.hits : []);
const profileId = hits[0]?.id || hits[0]?.profileId || hits[0]?._id;
if (!modelId || !profileId) throw new Error(`MakerWorld 모델 ${id}의 파일 인스턴스를 찾지 못했습니다 (API 응답 구조가 변경된 것 같습니다) — 웹에서 직접 받아야 합니다.`);
const prof = await jsonFetch(
`https://api.bambulab.com/v1/iot-service/api/user/profile/${profileId}?model_id=${encodeURIComponent(String(modelId))}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (prof.status >= 400) throw new Error(downloadErrorDetail('makerworld', prof.status, prof.text.slice(0, 200)));
// presigned URL 필드명이 버전마다 달라서(url/download_url/file_url/s3_url) 유연하게 찾는다.
const findUrl = (o: any, depth = 0): string | null => {
if (!o || typeof o !== 'object' || depth > 3) return null;
for (const k of ['url', 'download_url', 'downloadUrl', 'file_url', 'fileUrl', 's3_url']) {
if (typeof o[k] === 'string' && o[k].startsWith('http')) return o[k];
}
for (const v of Object.values(o)) {
const found = findUrl(v, depth + 1);
if (found) return found;
}
return null;
};
const url = findUrl(prof.json);
if (!url) throw new Error(`MakerWorld presigned URL을 응답에서 찾지 못했습니다 — 토큰이 만료됐거나 권한이 없을 수 있습니다. ${downloadErrorDetail('makerworld', prof.status, '')}`);
return { url, headers: {}, ext: '.3mf', name: `makerworld_${id}.3mf`, totalFiles: hits.length };
}
// ─── 인증 다운로드 (print3d.ts downloadStl의 스니프/캡 로직 재사용 형태) ─────
async function authedDownload(url: string, headers: Record<string, string>, outPath: string): Promise<{ ok: true; size: number } | { ok: false; detail: string }> {
let res: Response;
try {
res = await fetch(url, { headers, redirect: 'follow', signal: AbortSignal.timeout(60_000) });
} catch (e: any) {
return { ok: false, detail: `다운로드 실패: ${String(e?.message || e)}` };
}
if (!res.ok || !res.body) return { ok: false, detail: `다운로드 실패: HTTP ${res.status}` };
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — 모델 파일이 아닌 것 같습니다.' };
if (buf.length > 100 * 1024 * 1024) return { ok: false, detail: '파일이 100MB를 넘어 너무 큽니다.' };
const head = buf.subarray(0, 200).toString('utf8').toLowerCase();
if (head.includes('<html') || head.includes('<!doctype')) {
return { ok: false, detail: '내려받은 내용이 모델 파일이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드일 수 있습니다).' };
}
fs.writeFileSync(outPath, buf);
return { ok: true, size: buf.length };
}
// ─── 도구 ────────────────────────────────────────────────────────────────────
export const modelSearchTool = {
name: 'model_search',
description: '3D 모델 저장소(Thingiverse/Printables/MakerWorld)를 통합 검색한다. "608ZZ 베어링 마운트 STL 찾아줘", "메카넘휠 모델 검색" 같은 요청에 사용. 영문 검색어가 가장 잘 맞는다(한글 제목은 거의 없음). 결과에서 마음에 드는 모델의 id를 model_download(site + model_id)에 넣어 워크스페이스로 받는다. Thingiverse는 토큰(print3d.thingiverse_token)이 없으면 스킵되고, MakerWorld 다운로드는 별도 토큰이 필요하지만 검색은 토큰 없이도 된다.',
schema: { query: 'string', site: 'string', limit: 'number' },
jsonSchema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: '(필수) 검색어. 영문 권장 — 세 사이트 모두 영문 제목 매칭이 가장 잘 됨(예: "mecanum wheel", "608 bearing mount")' },
site: { type: 'string', enum: ['all', 'printables', 'makerworld', 'thingiverse'], description: '(선택) 검색할 사이트. 기본 all — 설정된 사이트 전부에서 검색해 합친다. 특정 사이트를 지목당하거나 다른 사이트에서 적당한 게 없을 때만 좁힐 것.' },
limit: { type: 'number', description: '(선택) 사이트당 최대 결과 수. 기본 5' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const query = String(args?.query || '').trim();
if (!query) return { success: false, error: 'query가 필요합니다.' };
const limit = Math.min(10, Math.max(1, Number(args?.limit) || 5));
const keys = {
printables: getSiteKey('printables'),
makerworld: getSiteKey('makerworld'),
thingiverse: getSiteKey('thingiverse'),
};
const plan = resolveSitePlan(args, keys);
if (plan.attempt.length === 0) {
return { success: false, error: `검색 가능한 사이트가 없습니다. ${plan.skipped.map((s) => `${s.site}: ${s.reason}`).join('; ')}` };
}
const results: ModelResult[] = [];
for (const site of plan.attempt) {
try {
const r = site === 'thingiverse'
? await searchThingiverse(keys.thingiverse!, query, limit)
: site === 'printables'
? await searchPrintables(query, limit)
: await searchMakerWorld(query, limit);
results.push(...r);
if (site === 'makerworld') console.log(`[model-search] ${site}: ${r.length}건 (키: 검색은 무관)`);
} catch (e: any) {
console.error(`[model-search] ${site} 실패:`, e?.message || e);
plan.skipped.push({ site, reason: String(e?.message || e).slice(0, 120) });
}
}
return { success: results.length > 0 || plan.skipped.length < plan.attempt.length, stdout: formatModelResults(results, plan.skipped, query) };
},
};
export const modelDownloadTool = {
name: 'model_download',
description: 'model_search 결과에서 골라낸 3D 모델(Printables/MakerWorld/Thingiverse)을 워크스페이스로 내려받는다. 슬라이싱 가능한 STL/3MF 중 가장 큰 파일 1개를 저장하고 나머지 파일은 웹에서 직접 받으라고 안내한다. 받은 파일은 print3d_model(action="prepare", stl_path=...)로 바로 슬라이싱할 수 있다. Thingiverse/MakerWorld는 각각 토큰(print3d.thingiverse_token / print3d.makerworld_token)이 필요하고, Printables는 토큰 없이도 대부분 받을 수 있다.',
schema: { site: 'string', model_id: 'string', project_name: 'string', filename: 'string' },
jsonSchema: {
type: 'object',
required: ['site', 'model_id'],
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/<프로젝트명>/에 저장' },
filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const site = String(args?.site || '').trim().toLowerCase() as ModelSite;
if (!ALL_SITES.includes(site)) return { success: false, error: `site는 printables / makerworld / thingiverse 중 하나여야 합니다.` };
const ref = parseModelRef(site, String(args?.model_id || ''));
if ('error' in ref) return { success: false, error: ref.error };
const id = ref.id;
const key = getSiteKey(site);
if (site === 'thingiverse' && !key) {
return { success: false, error: 'Thingiverse 다운로드에는 토큰이 필요합니다(print3d.thingiverse_token). 발급: thingiverse.com/apps/create → App Token.' };
}
if (site === 'makerworld' && !key) {
return { success: false, error: 'MakerWorld 다운로드에는 Bambu Cloud 토큰이 필요합니다(print3d.makerworld_token, AAB_ 시작, 약 90일 유효). 검색만은 model_search로 토큰 없이 가능합니다.' };
}
const workspacePath = getWorkspacePath(args);
const baseDir = path.join(workspacePath, 'print3d');
const projectNameArg = String(args?.project_name || '').trim();
let outDir = path.join(baseDir, 'model-search');
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 디렉토리 밖입니다.' };
}
fs.mkdirSync(outDir, { recursive: true });
let dl: ResolvedDownload;
try {
dl = site === 'thingiverse' ? await resolveThingiverseDownload(id, key!)
: site === 'makerworld' ? await resolveMakerWorldDownload(id, key!)
: await resolvePrintablesDownload(id, key);
} catch (e: any) {
return { success: false, error: String(e?.message || e) };
}
const customName = String(args?.filename || '').trim().replace(/[\/\\\0]/g, '_').trim();
const baseName = customName || `${site}_${id}_${dl.name.replace(/\.[^.]+$/, '').replace(/[^\w가-힣.-]+/g, '_').slice(0, 80)}`;
const outPath = path.join(outDir, `${baseName}${dl.ext || '.stl'}`);
if (!isPathInsideDir(workspacePath, outPath)) return { success: false, error: '저장 경로가 워크스페이스 밖입니다.' };
const result = await authedDownload(dl.url, dl.headers, outPath);
if (!result.ok) return { success: false, error: result.detail };
console.log(`[model-download] ${site}:${id} → ${outPath} (${result.size} bytes)`);
const relPath = path.relative(workspacePath, outPath);
const multiNote = dl.totalFiles > 1 ? `\n- 이 모델은 파일 ${dl.totalFiles}개 중 슬라이싱 가능한 가장 큰 STL/3MF 1개만 받았습니다. 나머지 부품이 필요하면 모델 페이지(${site === 'printables' ? `https://www.printables.com/model/${id}` : site === 'makerworld' ? `https://makerworld.com/en/models/${id}` : `https://www.thingiverse.com/thing:${id}`})에서 직접 받아 워크스페이스에 넣어주세요.` : '';
return {
success: true,
stdout: `다운로드 완료: **${relPath}** (${(result.size / 1024 / 1024).toFixed(1)}MB, ${dl.name})${multiNote}\n\n바로 슬라이싱하려면 print3d_model(action="prepare", stl_path="${relPath}")을 호출하세요.`,
};
},
};
+52 -24
View File
@@ -12,7 +12,8 @@ import {
// "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 print3d/<프로젝트명>/ 하위폴더에
// 저장한다(2026-09-20) — project_name 없이 부르면 예전처럼 print3d/ 바로 아래(공용). 프로젝트
// 이름 매칭은 workshop-project.ts의 findProjectIndex와 동일한 부분일치 규칙을 그대로 따른다.
function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string } | { ok: false; error: string } {
// 09-23: model-search.ts(model_download)에서도 재사용하므로 export.
export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: 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));
@@ -27,7 +28,8 @@ function findWorkshopProjectName(workspacePath: string, query: string): { ok: tr
}
// 프로젝트 이름을 폴더 세그먼트로 — 슬래시/역슬래시/NUL만 막고 나머지(한글 포함)는 그대로 둔다.
function sanitizeFolderName(name: string): string {
// 09-23: model-search.ts에서도 재사용하므로 export.
export function sanitizeFolderName(name: string): string {
const cleaned = name.replace(/[\/\\\0]/g, '_').trim().replace(/^\.+/, '');
return cleaned || 'project';
}
@@ -65,14 +67,15 @@ async function downloadStl(url: string, outPath: string): Promise<{ ok: true } |
export const print3dModelTool = {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지. 이 부품이 "작업실" 대시보드의 특정 프로젝트(로봇, 3D프린팅 등) 것이면 project_name을 같이 넘겨서 그 프로젝트 하위폴더에 정리할 것.',
schema: { action: 'string', stl_url: 'string' },
description: 'STL 3D모델 URL(stl_url) 또는 워크스페이스 파일(stl_path)을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체는 model_search(작업실 세션) 또는 web_search로 먼저 찾고, 확보한 직접 다운로드 URL을 stl_url로 넘기거나 model_download로 받아둔 파일을 stl_path로 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지. 이 부품이 "작업실" 대시보드의 특정 프로젝트(로봇, 3D프린팅 등) 것이면 project_name을 같이 넘겨서 그 프로젝트 하위폴더에 정리할 것.',
schema: { action: 'string', stl_url: 'string', stl_path: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
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/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
@@ -117,7 +120,10 @@ export const print3dModelTool = {
}
const stlUrl = String(args?.stl_url || '').trim();
if (!stlUrl) return { success: false, error: 'stl_url이 필요합니다.' };
// 09-23: model_download로 워크스페이스에 받아둔 파일을 바로 슬라이싱하는 경로 —
// stl_path가 있으면 stl_url 없이 진행(다운로드 단계를 건너뛰고 그 파일을 그대로 사용).
const stlPathArg = String(args?.stl_path || '').trim();
if (!stlUrl && !stlPathArg) return { success: false, error: 'stl_url 또는 stl_path 중 하나는 필요합니다.' };
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return { success: false, error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}). "sudo apt-get install -y prusa-slicer" 필요.` };
@@ -133,26 +139,48 @@ export const print3dModelTool = {
const workspacePath = getWorkspacePath(args);
const baseDir = path.join(workspacePath, 'print3d');
const projectNameArg = String(args?.project_name || '').trim();
let outDir = baseDir;
let projectFolderName = '';
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 디렉토리 밖입니다.' };
}
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
// 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';
const stlPath = path.join(outDir, `model_${stamp}${modelExt}`);
const projectNameArg = String(args?.project_name || '').trim();
let projectFolderName = '';
let outDir = baseDir;
let stlPath: string;
if (stlPathArg) {
const resolved = path.resolve(workspacePath, stlPathArg);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'stl_path가 워크스페이스 밖을 가리킵니다.' };
if (!fs.existsSync(resolved)) return { success: false, error: `stl_path 파일이 없습니다: ${stlPathArg}` };
const ext = path.extname(resolved).toLowerCase();
if (ext !== '.stl' && ext !== '.3mf') return { success: false, error: `stl_path는 .stl 또는 .3mf 파일이어야 합니다(지금: ${ext}).` };
stlPath = resolved;
// gcode는 모델 파일 옆에 두는 게 자연스럽다 — stl_path 상위폴더에 저장.
// (project_name을 명시 줬다면 그쪽이 우선 — 모델 파일이 프로젝트 폴더 밖에 있을 수 있음)
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 디렉토리 밖입니다.' };
} else {
outDir = path.dirname(resolved);
}
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
} else {
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 디렉토리 밖입니다.' };
}
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { 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}`);
const dl = await downloadStl(stlUrl, stlPath);
if (!dl.ok) return { success: false, error: dl.detail };
const dl = await downloadStl(stlUrl, stlPath);
if (!dl.ok) return { success: false, error: dl.detail };
}
const gcodeFilename = `print3d_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
+4
View File
@@ -32,6 +32,7 @@ import { cmsHospitalCompareTool } from './cms.js';
import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { modelSearchTool, modelDownloadTool } from './model-search.js';
import { workshopProjectTool } from './workshop-project.js';
import { stlCadTool, scadToStlTool, writeScadTool, scadAnimateTool } from './stl-cad.js';
@@ -240,6 +241,9 @@ class ToolRegistry {
// 집 CCTV 한 프레임 캡처 → 비전 모델이 직접 확인 (감시카메라/마당/현관/택배 확인 요청)
this.registerSafe(nvrSnapshotTool);
this.registerSafe(print3dModelTool);
// 3D 모델 저장소 검색+다운로드 (Printables/MakerWorld/Thingiverse, 2026-09-23)
this.registerSafe(modelSearchTool);
this.registerSafe(modelDownloadTool);
this.registerSafe(workshopProjectTool);
this.registerSafe(stlCadTool);
this.registerSafe(scadToStlTool);
+117
View File
@@ -0,0 +1,117 @@
// model-search.ts 순수함수 단위 테스트 (2026-09-23)
// 네트워크는 건드리지 않는다 — 사이트 클라이언트의 응답 매핑/파싱만 검증.
// 픽스처는 2026-09-23 실측 curl 응답(축약)을 그대로 쓴다.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseModelRef, resolveSitePlan, mapThingiverseResults, mapPrintablesResults,
mapMakerWorldResults, formatModelResults, downloadErrorDetail, pickSlicableFile,
} from '../src/tools/model-search.js';
test('parseModelRef: 숫자 id 그대로 통과', () => {
assert.deepEqual(parseModelRef('printables', '1138497'), { id: '1138497' });
assert.deepEqual(parseModelRef('thingiverse', '2473'), { id: '2473' });
});
test('parseModelRef: 모델 페이지 URL 해석 (3 사이트)', () => {
assert.deepEqual(parseModelRef('printables', 'https://www.printables.com/model/1138497-mecanum-wheel'), { id: '1138497' });
assert.deepEqual(parseModelRef('printables', 'printables.com/print/952992'), { id: '952992' });
assert.deepEqual(parseModelRef('makerworld', 'https://makerworld.com/en/models/716411'), { id: '716411' });
assert.deepEqual(parseModelRef('thingiverse', 'https://www.thingiverse.com/thing:2473'), { id: '2473' });
assert.deepEqual(parseModelRef('thingiverse', 'thingiverse.com/things/2473'), { id: '2473' });
});
test('parseModelRef: 가비지는 에러', () => {
assert.ok('error' in parseModelRef('printables', ''));
assert.ok('error' in parseModelRef('printables', 'https://example.com/thing/123'));
assert.ok('error' in parseModelRef('makerworld', 'not-a-url'));
});
test('resolveSitePlan: Thingiverse는 토큰 없으면 스킵, 나머지는 익명 가능', () => {
const plan = resolveSitePlan({}, {});
assert.deepEqual(plan.attempt, ['printables', 'makerworld']);
assert.equal(plan.skipped.length, 1);
assert.equal(plan.skipped[0].site, 'thingiverse');
assert.ok(plan.skipped[0].reason.includes('thingiverse_token'));
});
test('resolveSitePlan: 토큰 있으면 3개 전부 시도', () => {
const plan = resolveSitePlan({}, { thingiverse: 'tok' });
assert.deepEqual(plan.attempt, ['thingiverse', 'printables', 'makerworld']);
assert.equal(plan.skipped.length, 0);
});
test('resolveSitePlan: site 지정이 그 사이트만', () => {
const plan = resolveSitePlan({ site: 'printables' }, {});
assert.deepEqual(plan.attempt, ['printables']);
// 지정 사이트에 토큰이 필요한데 없으면 전부 스킵
const plan2 = resolveSitePlan({ site: 'thingiverse' }, {});
assert.deepEqual(plan2.attempt, []);
assert.equal(plan2.skipped[0].site, 'thingiverse');
});
test('mapThingiverseResults: 실측 응답(축약) 매핑', () => {
const mapped = mapThingiverseResults({
total: 221,
hits: [{
id: 2473, name: 'Mecanum Wheel MK2', like_count: 1323,
public_url: 'https://www.thingiverse.com/thing:2473',
creator: { name: 'Zaggo' },
}],
});
assert.equal(mapped.length, 1);
assert.equal(mapped[0].site, 'thingiverse');
assert.equal(mapped[0].id, '2473');
assert.equal(mapped[0].likes, 1323);
assert.equal(mapped[0].author, 'Zaggo');
assert.equal(mapped[0].url, 'https://www.thingiverse.com/thing:2473');
});
test('mapPrintablesResults: 실측 응답 매핑 + 빈 필드 허용', () => {
const mapped = mapPrintablesResults({
items: [
{ id: '1138497', name: 'Mecanum Wheel', slug: 'mecanum-wheel', likesCount: 104, downloadCount: 366, filesCount: 6, user: { publicUsername: 'Evolv3D' } },
{ id: '952992', name: 'x', user: {} },
],
});
assert.equal(mapped.length, 2);
assert.equal(mapped[0].fileCount, 6);
assert.equal(mapped[1].likes, 0);
assert.equal(mapped[1].author, '');
});
test('mapMakerWorldResults: 실측 응답 매핑', () => {
const mapped = mapMakerWorldResults({
hits: [{ id: 716411, title: 'Omnidirectional Ball Caster Wheel', likeCount: 1591, downloadCount: 2521, designCreator: { name: 'Claymuh' } }],
});
assert.equal(mapped[0].id, '716411');
assert.equal(mapped[0].site, 'makerworld');
assert.equal(mapped[0].likes, 1591);
});
test('formatModelResults: 스킵 진단 라인 포함', () => {
const out = formatModelResults([], [{ site: 'thingiverse', reason: '토큰 미설정' }], 'mecanum wheel');
assert.ok(out.includes('스킵됨: thingiverse'));
assert.ok(out.includes('영문'));
});
test('downloadErrorDetail: 사이트별 토큰 만료 안내', () => {
assert.ok(downloadErrorDetail('thingiverse', 401, '').includes('thingiverse_token'));
assert.ok(downloadErrorDetail('makerworld', 401, '').includes('90일'));
assert.ok(downloadErrorDetail('makerworld', 418, '').includes('캡차'));
assert.ok(downloadErrorDetail('printables', 403, '').includes('printables_cookie'));
assert.ok(downloadErrorDetail('printables', 500, 'oops').includes('HTTP 500'));
});
test('pickSlicableFile: stl/3mf만, 큰 것 우선, .stp 배제', () => {
// 09-23 실측: Printables stls 리스트에 .stp 파일이 섞여 온다
const pick = pickSlicableFile([
{ name: 'mecanum-wheel-Part1.stp', size: 189000 },
{ name: 'mecanum-wheel-Part2.stp', size: 10699 },
{ name: 'small.stl', size: 5000 },
{ name: 'big.3mf', size: 9000 },
{ name: 'biggest.stl', size: 12000 },
]);
assert.equal(pick?.name, 'biggest.stl');
assert.equal(pickSlicableFile([{ name: 'a.stp', size: 100 }, { name: 'b.jpg', size: 50 }]), null);
});