@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.
- package/config/config.development.js +44 -0
- package/config/config.production.js +18 -0
- package/dist-server/index.d.ts +7 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
- package/dist-server/service/adapters/dxf-adapter.js +206 -0
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +93 -0
- package/dist-server/service/ai/types.js +169 -0
- package/dist-server/service/ai/types.js.map +1 -0
- package/dist-server/service/converters/generic-to.d.ts +16 -0
- package/dist-server/service/converters/generic-to.js +78 -0
- package/dist-server/service/converters/generic-to.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +18 -0
- package/dist-server/service/import-session/import-session-resolver.js +135 -0
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
- package/dist-server/service/import-session/import-session.d.ts +28 -0
- package/dist-server/service/import-session/import-session.js +152 -0
- package/dist-server/service/import-session/import-session.js.map +1 -0
- package/dist-server/service/import-session/import-worker.d.ts +1 -0
- package/dist-server/service/import-session/import-worker.js +79 -0
- package/dist-server/service/import-session/import-worker.js.map +1 -0
- package/dist-server/service/import-session/index.d.ts +6 -0
- package/dist-server/service/import-session/index.js +12 -0
- package/dist-server/service/import-session/index.js.map +1 -0
- package/dist-server/service/index.d.ts +22 -0
- package/dist-server/service/index.js +47 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/pipeline/index.d.ts +33 -0
- package/dist-server/service/pipeline/index.js +58 -0
- package/dist-server/service/pipeline/index.js.map +1 -0
- package/dist-server/service/pipeline/stage2-mapping.d.ts +14 -0
- package/dist-server/service/pipeline/stage2-mapping.js +173 -0
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
- package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
- package/dist-server/service/pipeline/stage3-board.js +53 -0
- package/dist-server/service/pipeline/stage3-board.js.map +1 -0
- package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
- package/dist-server/service/pipeline/stage4-binding.js +28 -0
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
- package/dist-server/service/registry/index.d.ts +27 -0
- package/dist-server/service/registry/index.js +108 -0
- package/dist-server/service/registry/index.js.map +1 -0
- package/dist-server/service/types/index.d.ts +117 -0
- package/dist-server/service/types/index.js +3 -0
- package/dist-server/service/types/index.js.map +1 -0
- package/dist-server/service/types/registry.d.ts +175 -0
- package/dist-server/service/types/registry.js +3 -0
- package/dist-server/service/types/registry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +34 -0
- package/server/index.ts +7 -0
- package/server/service/adapters/dxf-adapter.ts +217 -0
- package/server/service/ai/types.ts +284 -0
- package/server/service/converters/generic-to.ts +98 -0
- package/server/service/import-session/import-session-resolver.ts +113 -0
- package/server/service/import-session/import-session.ts +141 -0
- package/server/service/import-session/import-worker.ts +88 -0
- package/server/service/import-session/index.ts +8 -0
- package/server/service/index.ts +96 -0
- package/server/service/pipeline/index.ts +89 -0
- package/server/service/pipeline/stage2-mapping.ts +195 -0
- package/server/service/pipeline/stage3-board.ts +114 -0
- package/server/service/pipeline/stage4-binding.ts +31 -0
- package/server/service/registry/index.ts +127 -0
- package/server/service/types/index.ts +133 -0
- package/server/service/types/registry.ts +186 -0
- package/things-factory.config.js +1 -0
- 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
|
+
}
|
package/server/index.ts
ADDED
|
@@ -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
|
+
}
|