@things-factory/board-import 10.0.0-beta.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/config/config.development.js +44 -0
  2. package/config/config.production.js +18 -0
  3. package/dist-server/index.d.ts +7 -0
  4. package/dist-server/index.js +11 -0
  5. package/dist-server/index.js.map +1 -0
  6. package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
  7. package/dist-server/service/adapters/dxf-adapter.js +206 -0
  8. package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
  9. package/dist-server/service/ai/types.d.ts +93 -0
  10. package/dist-server/service/ai/types.js +169 -0
  11. package/dist-server/service/ai/types.js.map +1 -0
  12. package/dist-server/service/converters/generic-to.d.ts +16 -0
  13. package/dist-server/service/converters/generic-to.js +78 -0
  14. package/dist-server/service/converters/generic-to.js.map +1 -0
  15. package/dist-server/service/import-session/import-session-resolver.d.ts +18 -0
  16. package/dist-server/service/import-session/import-session-resolver.js +135 -0
  17. package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
  18. package/dist-server/service/import-session/import-session.d.ts +28 -0
  19. package/dist-server/service/import-session/import-session.js +152 -0
  20. package/dist-server/service/import-session/import-session.js.map +1 -0
  21. package/dist-server/service/import-session/import-worker.d.ts +1 -0
  22. package/dist-server/service/import-session/import-worker.js +79 -0
  23. package/dist-server/service/import-session/import-worker.js.map +1 -0
  24. package/dist-server/service/import-session/index.d.ts +6 -0
  25. package/dist-server/service/import-session/index.js +12 -0
  26. package/dist-server/service/import-session/index.js.map +1 -0
  27. package/dist-server/service/index.d.ts +22 -0
  28. package/dist-server/service/index.js +47 -0
  29. package/dist-server/service/index.js.map +1 -0
  30. package/dist-server/service/pipeline/index.d.ts +33 -0
  31. package/dist-server/service/pipeline/index.js +58 -0
  32. package/dist-server/service/pipeline/index.js.map +1 -0
  33. package/dist-server/service/pipeline/stage2-mapping.d.ts +14 -0
  34. package/dist-server/service/pipeline/stage2-mapping.js +173 -0
  35. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
  36. package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
  37. package/dist-server/service/pipeline/stage3-board.js +53 -0
  38. package/dist-server/service/pipeline/stage3-board.js.map +1 -0
  39. package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
  40. package/dist-server/service/pipeline/stage4-binding.js +28 -0
  41. package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
  42. package/dist-server/service/registry/index.d.ts +27 -0
  43. package/dist-server/service/registry/index.js +108 -0
  44. package/dist-server/service/registry/index.js.map +1 -0
  45. package/dist-server/service/types/index.d.ts +117 -0
  46. package/dist-server/service/types/index.js +3 -0
  47. package/dist-server/service/types/index.js.map +1 -0
  48. package/dist-server/service/types/registry.d.ts +175 -0
  49. package/dist-server/service/types/registry.js +3 -0
  50. package/dist-server/service/types/registry.js.map +1 -0
  51. package/dist-server/tsconfig.tsbuildinfo +1 -0
  52. package/package.json +34 -0
  53. package/server/index.ts +7 -0
  54. package/server/service/adapters/dxf-adapter.ts +217 -0
  55. package/server/service/ai/types.ts +284 -0
  56. package/server/service/converters/generic-to.ts +98 -0
  57. package/server/service/import-session/import-session-resolver.ts +113 -0
  58. package/server/service/import-session/import-session.ts +141 -0
  59. package/server/service/import-session/import-worker.ts +88 -0
  60. package/server/service/import-session/index.ts +8 -0
  61. package/server/service/index.ts +96 -0
  62. package/server/service/pipeline/index.ts +89 -0
  63. package/server/service/pipeline/stage2-mapping.ts +195 -0
  64. package/server/service/pipeline/stage3-board.ts +114 -0
  65. package/server/service/pipeline/stage4-binding.ts +31 -0
  66. package/server/service/registry/index.ts +127 -0
  67. package/server/service/types/index.ts +133 -0
  68. package/server/service/types/registry.ts +186 -0
  69. package/things-factory.config.js +1 -0
  70. package/tsconfig.json +9 -0
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@things-factory/board-import",
3
+ "version": "10.0.0-beta.64",
4
+ "main": "dist-server/index.js",
5
+ "things-factory": true,
6
+ "author": "heartyoh",
7
+ "description": "도면(CAD/이미지/IFC) → Operato Board (2D + 3D) → 데이터 바인딩 통합 import 파이프라인 (백엔드 코어)",
8
+ "license": "MIT",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "@things-factory:registry": "https://registry.npmjs.org"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hatiolab/things-factory.git",
16
+ "directory": "packages/board-import"
17
+ },
18
+ "scripts": {
19
+ "build": "npm run clean:server && tsc --p tsconfig.json",
20
+ "build:server": "npm run clean:server && tsc",
21
+ "clean:server": "rm -rf dist-server",
22
+ "clean": "npm run clean:server"
23
+ },
24
+ "dependencies": {
25
+ "@operato/graphql": "^10.0.0-beta.1",
26
+ "@things-factory/ai-client-base": "^10.0.0-beta.64",
27
+ "@things-factory/attachment-base": "^10.0.0-beta.57",
28
+ "@things-factory/auth-base": "^10.0.0-beta.57",
29
+ "@things-factory/env": "^10.0.0-beta.19",
30
+ "@things-factory/shell": "^10.0.0-beta.53",
31
+ "dxf-parser": "^1.1.2"
32
+ },
33
+ "gitHead": "8a2729cb8c838ed8fc3c7921fa67f7279c2d13b1"
34
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @things-factory/board-import — 도면(CAD/이미지/IFC) → Board Model 백엔드 코어.
3
+ *
4
+ * Phase 1: DXF 결정적 파이프라인 (AI 미사용).
5
+ * Phase 2+: GraphQL/REST 엔드포인트, AI 통합.
6
+ */
7
+ export * from './service/index.js'
@@ -0,0 +1,217 @@
1
+ /**
2
+ * DXF Format Adapter — DXF 텍스트 파일을 UniversalEntity 목록으로 정규화.
3
+ *
4
+ * 백엔드 환경(Node.js)에서 동작. 입력은 string / Buffer / ArrayBuffer / Uint8Array 모두 허용.
5
+ *
6
+ * 구현 범위 (Phase 1):
7
+ * - INSERT (블록 참조) → kind='shape', attribs.name 추출
8
+ * - LWPOLYLINE / POLYLINE → kind='path'
9
+ * - CIRCLE → kind='shape', attribs.shape='circle'
10
+ * - LINE → kind='path' (단일 선분)
11
+ * - TEXT / MTEXT → kind='text'
12
+ * - 레이어, 좌표, 회전 추출
13
+ * - $INSUNITS 단위 추출
14
+ *
15
+ * 보류 (Phase 2+):
16
+ * - HATCH, SPLINE, ELLIPSE 의 정밀 변환
17
+ * - 블록 정의 안의 entities 깊이 펼치기
18
+ * - 외부 참조 (XREF)
19
+ */
20
+ import DxfParser from 'dxf-parser'
21
+
22
+ import type {
23
+ AdapterCapabilities,
24
+ AdapterInput,
25
+ BoundingBox,
26
+ FormatAdapter,
27
+ ParseOptions,
28
+ ParseResult,
29
+ UniversalEntity
30
+ } from '../types/index.js'
31
+
32
+ const DXF_INSUNITS_TO_UNITS: Record<number, ParseResult['units']> = {
33
+ 0: 'unknown',
34
+ 1: 'inch',
35
+ 4: 'mm',
36
+ 5: 'cm',
37
+ 6: 'm'
38
+ }
39
+
40
+ export class DxfAdapter implements FormatAdapter {
41
+ readonly format = 'dxf'
42
+ readonly supportedExtensions = ['.dxf']
43
+ readonly capabilities: AdapterCapabilities = {
44
+ has2D: true,
45
+ has3D: false,
46
+ hasSemanticLabels: false,
47
+ hasTextLabels: true,
48
+ hasAttributes: true
49
+ }
50
+
51
+ async detect(file: AdapterInput, filename?: string): Promise<boolean> {
52
+ if (filename && filename.toLowerCase().endsWith('.dxf')) return true
53
+ const head = readHead(file, 256)
54
+ return /\b0\s*\n\s*SECTION\b/.test(head) || /^\s*999/.test(head)
55
+ }
56
+
57
+ async parse(file: AdapterInput, options: ParseOptions = {}): Promise<ParseResult> {
58
+ const text = readText(file)
59
+ const parser = new DxfParser()
60
+ const dxf = parser.parseSync(text)
61
+
62
+ if (!dxf) throw new Error('DXF 파싱 실패')
63
+
64
+ const entities: UniversalEntity[] = []
65
+ let uidCounter = 0
66
+ const nextUid = () => `dxf-${++uidCounter}`
67
+
68
+ const excludeLayers = new Set((options.excludeLayers || []).map(l => l.toLowerCase()))
69
+ const maxEntities = options.maxEntities ?? 100000
70
+
71
+ for (const e of dxf.entities || []) {
72
+ if (entities.length >= maxEntities) break
73
+
74
+ const layer = (e as any).layer || ''
75
+ if (excludeLayers.has(layer.toLowerCase())) continue
76
+
77
+ const ue = entityToUniversal(e, layer, nextUid())
78
+ if (ue) entities.push(ue)
79
+ }
80
+
81
+ const bbox = computeBoundingBox(entities)
82
+ const units = DXF_INSUNITS_TO_UNITS[(dxf.header as any)?.$INSUNITS ?? 0]
83
+
84
+ return {
85
+ entities,
86
+ metadata: {
87
+ format: 'dxf',
88
+ formatVersion: (dxf.header as any)?.$ACADVER,
89
+ ...((dxf.header as any) || {})
90
+ },
91
+ bbox,
92
+ units
93
+ }
94
+ }
95
+ }
96
+
97
+ // ── DXF entity → UniversalEntity ─────────────────────────────────────
98
+
99
+ function entityToUniversal(e: any, layer: string, uid: string): UniversalEntity | null {
100
+ const type = e.type as string
101
+
102
+ switch (type) {
103
+ case 'INSERT': {
104
+ const x = e.position?.x ?? 0
105
+ const y = e.position?.y ?? 0
106
+ const width = (e.xScale ?? 1) * 1
107
+ const height = (e.yScale ?? 1) * 1
108
+ return {
109
+ uid,
110
+ kind: 'shape',
111
+ position: { x, y },
112
+ size: { width, height },
113
+ rotation: e.rotation ?? 0,
114
+ bbox: { min: { x, y }, max: { x: x + width, y: y + height } },
115
+ attribs: {
116
+ name: e.name,
117
+ layer,
118
+ shape: 'rect'
119
+ }
120
+ }
121
+ }
122
+
123
+ case 'CIRCLE': {
124
+ const cx = e.center?.x ?? 0
125
+ const cy = e.center?.y ?? 0
126
+ const r = e.radius ?? 0
127
+ return {
128
+ uid,
129
+ kind: 'shape',
130
+ position: { x: cx - r, y: cy - r },
131
+ size: { width: 2 * r, height: 2 * r },
132
+ bbox: { min: { x: cx - r, y: cy - r }, max: { x: cx + r, y: cy + r } },
133
+ attribs: { layer, shape: 'circle' }
134
+ }
135
+ }
136
+
137
+ case 'LWPOLYLINE':
138
+ case 'POLYLINE': {
139
+ const verts: { x: number; y: number }[] = (e.vertices || []).map((v: any) => ({ x: v.x, y: v.y }))
140
+ if (verts.length === 0) return null
141
+ const xs = verts.map(v => v.x)
142
+ const ys = verts.map(v => v.y)
143
+ const min = { x: Math.min(...xs), y: Math.min(...ys) }
144
+ const max = { x: Math.max(...xs), y: Math.max(...ys) }
145
+ return {
146
+ uid,
147
+ kind: 'path',
148
+ position: { x: min.x, y: min.y },
149
+ size: { width: max.x - min.x, height: max.y - min.y },
150
+ bbox: { min, max },
151
+ attribs: { layer, shape: 'polyline', vertices: verts, closed: !!e.shape }
152
+ }
153
+ }
154
+
155
+ case 'LINE': {
156
+ const x1 = e.vertices?.[0]?.x ?? 0
157
+ const y1 = e.vertices?.[0]?.y ?? 0
158
+ const x2 = e.vertices?.[1]?.x ?? 0
159
+ const y2 = e.vertices?.[1]?.y ?? 0
160
+ const min = { x: Math.min(x1, x2), y: Math.min(y1, y2) }
161
+ const max = { x: Math.max(x1, x2), y: Math.max(y1, y2) }
162
+ return {
163
+ uid,
164
+ kind: 'path',
165
+ position: { x: min.x, y: min.y },
166
+ size: { width: max.x - min.x, height: max.y - min.y },
167
+ bbox: { min, max },
168
+ attribs: { layer, shape: 'polyline', vertices: [{ x: x1, y: y1 }, { x: x2, y: y2 }] }
169
+ }
170
+ }
171
+
172
+ case 'TEXT':
173
+ case 'MTEXT': {
174
+ const x = e.position?.x ?? e.startPoint?.x ?? 0
175
+ const y = e.position?.y ?? e.startPoint?.y ?? 0
176
+ const text = e.text ?? e.string ?? ''
177
+ const h = e.textHeight ?? e.height ?? 1
178
+ return {
179
+ uid,
180
+ kind: 'text',
181
+ position: { x, y },
182
+ bbox: { min: { x, y }, max: { x: x + h * (text.length || 1), y: y + h } },
183
+ attribs: { layer, text }
184
+ }
185
+ }
186
+
187
+ default:
188
+ return null
189
+ }
190
+ }
191
+
192
+ // ── 유틸 ─────────────────────────────────────────────────────────────
193
+
194
+ function computeBoundingBox(entities: UniversalEntity[]): BoundingBox {
195
+ if (entities.length === 0) return { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
196
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
197
+ for (const e of entities) {
198
+ if (e.bbox.min.x < minX) minX = e.bbox.min.x
199
+ if (e.bbox.min.y < minY) minY = e.bbox.min.y
200
+ if (e.bbox.max.x > maxX) maxX = e.bbox.max.x
201
+ if (e.bbox.max.y > maxY) maxY = e.bbox.max.y
202
+ }
203
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } }
204
+ }
205
+
206
+ function readText(file: AdapterInput): string {
207
+ if (typeof file === 'string') return file
208
+ if (Buffer.isBuffer(file)) return file.toString('utf-8')
209
+ if (file instanceof Uint8Array) return Buffer.from(file).toString('utf-8')
210
+ if (file instanceof ArrayBuffer) return Buffer.from(new Uint8Array(file)).toString('utf-8')
211
+ throw new Error('Unsupported DXF input type')
212
+ }
213
+
214
+ function readHead(file: AdapterInput, maxBytes: number): string {
215
+ const text = readText(file)
216
+ return text.substring(0, maxBytes)
217
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * BoardImportAI — board-import 의 **도메인 AI 인터페이스**.
3
+ *
4
+ * Provider 추상화 (chat/JSON/vision) 는 `@things-factory/ai-client-base` 의 `AIClient` 가 담당.
5
+ * 이 모듈은 board-import 의 도메인 메서드 (컨벤션 추론, Few-shot 분류, 도면 이미지 파싱,
6
+ * 자연어 → ImportRule) 만 정의하고, 기본 구현은 base AIClient 위에 LLM 프롬프트로 만든다.
7
+ *
8
+ * 사용 예:
9
+ * ```ts
10
+ * import { createAIClient } from '@things-factory/ai-client-base'
11
+ * import { setAIClient, DefaultBoardImportAI } from '@things-factory/board-import'
12
+ *
13
+ * const base = createAIClient({ provider: 'anthropic', apiKey: '...' })
14
+ * setAIClient(new DefaultBoardImportAI(base))
15
+ * ```
16
+ *
17
+ * Phase 1-2 결정적 파이프라인은 AI 미사용. 위 등록은 Phase 3+ 메서드 활성화용.
18
+ */
19
+ import { parseJSONResponse, type AIClient as BaseAIClient } from '@things-factory/ai-client-base'
20
+ import type { ComponentCategory, ImportRule } from '../types/registry.js'
21
+ import type { UniversalEntity } from '../types/index.js'
22
+
23
+ export interface BoardImportAI {
24
+ /** 식별자 — 보통 base.id 와 동일 (provider:model) */
25
+ readonly id: string
26
+
27
+ /**
28
+ * 미매칭 entity 묶음을 보고 컨벤션 규칙을 추론.
29
+ * 예: "STK_001, STK_002 가 stocker 인 것 같습니다" → 정규식 규칙 제안
30
+ */
31
+ inferConventions?(
32
+ entities: UniversalEntity[],
33
+ options?: ConventionInferenceOptions
34
+ ): Promise<RuleSuggestion[]>
35
+
36
+ /**
37
+ * 사용자가 일부 entity 를 라벨링했을 때, 나머지 미라벨 entity 의 카테고리·type 을 추론.
38
+ * Few-shot 분류.
39
+ */
40
+ classifyEntities?(
41
+ labeled: LabeledEntity[],
42
+ unlabeled: UniversalEntity[],
43
+ options?: ClassificationOptions
44
+ ): Promise<EntityClassification[]>
45
+
46
+ /**
47
+ * 도면 이미지(PNG/JPG/PDF 페이지) 에서 객체를 검출하여 UniversalEntity 로 변환.
48
+ * Phase 3 (이미지 어댑터) 에서 사용.
49
+ */
50
+ parseFromImage?(
51
+ image: Buffer | Uint8Array,
52
+ options?: ImageParseOptions
53
+ ): Promise<UniversalEntity[]>
54
+
55
+ /**
56
+ * 자연어 매핑 정의 ("STK는 stocker, BUF는 buffer") → ImportRule[] 로 변환.
57
+ */
58
+ parseNaturalLanguageMapping?(
59
+ text: string,
60
+ availableTypes: string[]
61
+ ): Promise<ImportRule[]>
62
+ }
63
+
64
+ // ── 옵션 / 결과 타입 ────────────────────────────────────────────────
65
+
66
+ export interface ConventionInferenceOptions {
67
+ /** 알려진 도메인 type 후보 (LLM 이 이 안에서 고르도록) */
68
+ knownTypes?: string[]
69
+ /** 카테고리 후보 */
70
+ categories?: ComponentCategory[]
71
+ /** 최대 제안 개수 */
72
+ maxSuggestions?: number
73
+ }
74
+
75
+ export interface RuleSuggestion {
76
+ /** 자동 생성된 ImportRule 후보 */
77
+ rule: ImportRule
78
+ /** 0..1 신뢰도 */
79
+ confidence: number
80
+ /** 추론 근거 */
81
+ reasoning?: string
82
+ }
83
+
84
+ export interface ClassificationOptions {
85
+ knownTypes?: string[]
86
+ categories?: ComponentCategory[]
87
+ }
88
+
89
+ export interface LabeledEntity {
90
+ entity: UniversalEntity
91
+ type: string
92
+ category?: ComponentCategory
93
+ }
94
+
95
+ export interface EntityClassification {
96
+ entityUid: string
97
+ type: string
98
+ category: ComponentCategory
99
+ confidence: number
100
+ }
101
+
102
+ export interface ImageParseOptions {
103
+ /** 자연어 컨텍스트. 예: "factory floor plan with AGV paths" */
104
+ context?: string
105
+ categories?: ComponentCategory[]
106
+ }
107
+
108
+ // ── 기본 구현 — base AIClient 위에 LLM 프롬프트로 ────────────────────
109
+
110
+ export class DefaultBoardImportAI implements BoardImportAI {
111
+ readonly id: string
112
+ constructor(private base: BaseAIClient) {
113
+ this.id = base.id
114
+ }
115
+
116
+ async inferConventions(
117
+ entities: UniversalEntity[],
118
+ options: ConventionInferenceOptions = {}
119
+ ): Promise<RuleSuggestion[]> {
120
+ const sample = entities.slice(0, 100).map(e => ({
121
+ uid: e.uid,
122
+ name: e.attribs.name,
123
+ layer: e.attribs.layer,
124
+ kind: e.kind,
125
+ shape: e.attribs.shape
126
+ }))
127
+ const knownTypes = options.knownTypes ?? []
128
+ const categories = options.categories ?? []
129
+ const maxSuggestions = options.maxSuggestions ?? 5
130
+
131
+ const prompt = `You analyze CAD/IFC drawing entities and infer naming/layer conventions for an import pipeline.
132
+
133
+ Unmatched entities (sample):
134
+ ${JSON.stringify(sample, null, 2)}
135
+
136
+ Available component types: ${knownTypes.length ? knownTypes.join(', ') : '(any)'}
137
+ Available categories: ${categories.length ? categories.join(', ') : '(any)'}
138
+
139
+ Suggest up to ${maxSuggestions} convention rules. For each, return:
140
+ - rule.componentType: chosen type
141
+ - rule.category: one of available categories (or 'unknown')
142
+ - rule.match.cad.blockNamePattern: regex pattern source string (e.g. "^STK_")
143
+ - rule.match.cad.layer: optional regex source string
144
+ - rule.build.idStrategy: 'fromAttribute' or 'auto'
145
+ - confidence: 0..1
146
+ - reasoning: short explanation`
147
+
148
+ const raw = await this.base.generateJSON<any[]>(prompt, { systemPrompt: SYSTEM_HINT })
149
+ return Array.isArray(raw) ? raw.map(r => ({ ...r, rule: hydrateRuleRegexes(r.rule) })) : []
150
+ }
151
+
152
+ async classifyEntities(
153
+ labeled: LabeledEntity[],
154
+ unlabeled: UniversalEntity[],
155
+ options: ClassificationOptions = {}
156
+ ): Promise<EntityClassification[]> {
157
+ const examples = labeled.map(l => ({
158
+ name: l.entity.attribs.name,
159
+ layer: l.entity.attribs.layer,
160
+ kind: l.entity.kind,
161
+ type: l.type,
162
+ category: l.category
163
+ }))
164
+ const targets = unlabeled.map(e => ({
165
+ uid: e.uid,
166
+ name: e.attribs.name,
167
+ layer: e.attribs.layer,
168
+ kind: e.kind
169
+ }))
170
+
171
+ const prompt = `Few-shot entity classification.
172
+
173
+ Labeled examples:
174
+ ${JSON.stringify(examples, null, 2)}
175
+
176
+ Available types: ${(options.knownTypes ?? []).join(', ') || '(any)'}
177
+ Available categories: ${(options.categories ?? []).join(', ') || '(any)'}
178
+
179
+ Classify these unlabeled entities. Return JSON array of:
180
+ - entityUid
181
+ - type
182
+ - category
183
+ - confidence: 0..1
184
+
185
+ Targets:
186
+ ${JSON.stringify(targets, null, 2)}`
187
+
188
+ const raw = await this.base.generateJSON<EntityClassification[]>(prompt, { systemPrompt: SYSTEM_HINT })
189
+ return Array.isArray(raw) ? raw : []
190
+ }
191
+
192
+ async parseFromImage(
193
+ image: Buffer | Uint8Array,
194
+ options: ImageParseOptions = {}
195
+ ): Promise<UniversalEntity[]> {
196
+ if (!this.base.vision) {
197
+ throw new Error(`AI client (${this.base.id}) does not support vision input`)
198
+ }
199
+ const prompt = `Detect distinct objects in this drawing image.${
200
+ options.context ? '\nContext: ' + options.context : ''
201
+ }${
202
+ options.categories ? '\nPossible categories: ' + options.categories.join(', ') : ''
203
+ }
204
+
205
+ Return JSON array of UniversalEntity-like objects:
206
+ - uid: string
207
+ - kind: 'shape' | 'path' | 'text'
208
+ - position: {x, y}
209
+ - size: {width, height}
210
+ - bbox: {min:{x,y}, max:{x,y}}
211
+ - attribs: {name?, semanticType?, text?, layer?}`
212
+
213
+ const text = await this.base.vision(image as Buffer, prompt)
214
+ return parseJSONResponse<UniversalEntity[]>(text)
215
+ }
216
+
217
+ async parseNaturalLanguageMapping(
218
+ text: string,
219
+ availableTypes: string[]
220
+ ): Promise<ImportRule[]> {
221
+ const prompt = `Convert the following user description into ImportRule[] entries for the board-import pipeline.
222
+
223
+ Description: "${text}"
224
+ Available component types: ${availableTypes.join(', ')}
225
+
226
+ Return JSON array of:
227
+ - componentType
228
+ - category (one of: container, vehicle, path, region, equipment, io, marker, structure, unknown)
229
+ - match.cad.blockNamePattern: regex source string (e.g. "^STK_")
230
+ - match.cad.layer: optional regex source string
231
+ - build.idStrategy: 'fromAttribute' or 'auto'`
232
+
233
+ const raw = await this.base.generateJSON<any[]>(prompt, { systemPrompt: SYSTEM_HINT })
234
+ return Array.isArray(raw) ? raw.map(hydrateRuleRegexes) : []
235
+ }
236
+ }
237
+
238
+ // ── 유틸 ─────────────────────────────────────────────────────────────
239
+
240
+ const SYSTEM_HINT =
241
+ 'You are a CAD/IFC drawing analysis assistant. Output strict JSON only — no commentary, no markdown.'
242
+
243
+ /**
244
+ * LLM 응답의 regex pattern 문자열 → 실제 RegExp.
245
+ * 보통 "^STK_" 같은 source string 으로 응답.
246
+ */
247
+ function hydrateRuleRegexes<T extends { match?: any }>(rule: T): T {
248
+ if (!rule || !rule.match) return rule
249
+ const cad = rule.match.cad
250
+ if (cad) {
251
+ const newCad = { ...cad }
252
+ if (typeof newCad.blockNamePattern === 'string') {
253
+ try {
254
+ newCad.blockNamePattern = new RegExp(newCad.blockNamePattern)
255
+ } catch {
256
+ // invalid regex — 패턴 무시
257
+ delete newCad.blockNamePattern
258
+ }
259
+ }
260
+ if (typeof newCad.layer === 'string') {
261
+ const m = newCad.layer.match(/^\/(.+)\/([a-z]*)$/)
262
+ if (m) {
263
+ try { newCad.layer = new RegExp(m[1], m[2]) } catch { /* keep as string */ }
264
+ }
265
+ }
266
+ rule.match = { ...rule.match, cad: newCad }
267
+ }
268
+ return rule
269
+ }
270
+
271
+ // ── Provider 등록 ──────────────────────────────────────────────────
272
+
273
+ let currentClient: BoardImportAI | undefined
274
+
275
+ export function setAIClient(client: BoardImportAI | undefined): void {
276
+ currentClient = client
277
+ }
278
+
279
+ export function getAIClient(): BoardImportAI | undefined {
280
+ return currentClient
281
+ }
282
+
283
+ /** Deprecated alias — 기존 코드 호환용. 새로 작성 시 BoardImportAI 사용. */
284
+ export type AIClient = BoardImportAI
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GenericObject ↔ 도메인 컴포넌트 변환.
3
+ *
4
+ * Import 시 매칭되지 않은 entity 는 GenericObject 로 보드에 배치된다.
5
+ * 사용자가 사후에 모델러 UI 에서 "Convert to Stocker" 등 액션으로 호출.
6
+ *
7
+ * 변환 시:
8
+ * - 좌표/크기/회전 보존
9
+ * - source (원본 entity attribs) 보존
10
+ * - 등록된 ImportRule.build 의 defaultState / idStrategy 적용
11
+ */
12
+ import { find3DPreset, getImportRules } from '../registry/index.js'
13
+ import type { BoardComponent } from '../pipeline/stage3-board.js'
14
+ import type { ComponentCategory } from '../types/registry.js'
15
+ import { GENERIC_OBJECT_TYPE } from '../pipeline/stage2-mapping.js'
16
+
17
+ export interface ConvertOptions {
18
+ scopes?: string[]
19
+ /** 새 컴포넌트의 ID. 미지정 시 ImportRule 의 idStrategy 적용 또는 자동 생성 */
20
+ id?: string
21
+ }
22
+
23
+ /**
24
+ * GenericObject 를 지정된 도메인 type 으로 변환한다.
25
+ * 기존 좌표/크기/회전/source 보존.
26
+ */
27
+ export function convertGenericTo(
28
+ generic: BoardComponent,
29
+ toType: string,
30
+ options: ConvertOptions = {}
31
+ ): BoardComponent {
32
+ if (generic.type !== GENERIC_OBJECT_TYPE) {
33
+ throw new Error(`Source component is not GenericObject (type=${generic.type})`)
34
+ }
35
+
36
+ const rules = getImportRules(options.scopes)
37
+ const rule = rules.find(r => r.componentType === toType)
38
+
39
+ const category: ComponentCategory = rule?.category ?? 'unknown'
40
+ const newId = options.id ?? generic.id
41
+
42
+ const preset = find3DPreset(toType, category, options.scopes)
43
+ const threeD = {
44
+ geometry: preset.geometry,
45
+ ...(preset.material && { material: preset.material }),
46
+ origin: preset.origin ?? 'bottom'
47
+ }
48
+
49
+ const { strokeStyle, strokeDashArray, fillStyle, label, ...preservedState } = generic
50
+
51
+ return {
52
+ ...preservedState,
53
+ category,
54
+ type: toType,
55
+ id: newId,
56
+ threeD,
57
+ ...(rule?.build.defaultState || {}),
58
+ /**
59
+ * 변환 추적 — 되돌리기 가능하게 source 보존.
60
+ * `convertedFrom` 키는 board-modeller UI 에서 "되돌리기" 액션 활성화 단서.
61
+ */
62
+ convertedFrom: {
63
+ type: GENERIC_OBJECT_TYPE,
64
+ previousState: { strokeStyle, strokeDashArray, fillStyle, label }
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 변환된 컴포넌트를 다시 GenericObject 로 되돌린다.
71
+ * convertedFrom 정보가 있어야 가능.
72
+ */
73
+ export function revertToGeneric(
74
+ component: BoardComponent,
75
+ options: ConvertOptions = {}
76
+ ): BoardComponent {
77
+ const previous = (component as any).convertedFrom?.previousState
78
+ if (!previous) {
79
+ throw new Error('이 컴포넌트는 GenericObject 에서 변환된 것이 아닙니다 (convertedFrom 없음).')
80
+ }
81
+
82
+ const preset = find3DPreset(GENERIC_OBJECT_TYPE, 'unknown', options.scopes)
83
+ const threeD = {
84
+ geometry: preset.geometry,
85
+ ...(preset.material && { material: preset.material }),
86
+ origin: preset.origin ?? 'bottom'
87
+ }
88
+
89
+ const { convertedFrom, ...rest } = component as any
90
+
91
+ return {
92
+ ...rest,
93
+ category: 'unknown',
94
+ type: GENERIC_OBJECT_TYPE,
95
+ threeD,
96
+ ...previous
97
+ }
98
+ }