@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73
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/dist-server/index.d.ts +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
- package/dist-server/service/adapters/dxf-adapter.js +18 -4
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +96 -4
- package/dist-server/service/ai/types.js +221 -12
- package/dist-server/service/ai/types.js.map +1 -1
- package/dist-server/service/converters/generic-to.d.ts +17 -8
- package/dist-server/service/converters/generic-to.js +44 -44
- package/dist-server/service/converters/generic-to.js.map +1 -1
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
- package/dist-server/service/import-session/import-session-resolver.js +166 -38
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
- package/dist-server/service/import-session/import-worker.js +55 -13
- package/dist-server/service/import-session/import-worker.js.map +1 -1
- package/dist-server/service/import-session/index.d.ts +5 -3
- package/dist-server/service/import-session/index.js +16 -9
- package/dist-server/service/import-session/index.js.map +1 -1
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +22 -19
- package/dist-server/service/index.js +46 -36
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/pipeline/index.d.ts +42 -9
- package/dist-server/service/pipeline/index.js +92 -23
- package/dist-server/service/pipeline/index.js.map +1 -1
- package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
- package/dist-server/service/pipeline/stage2-mapping.js +84 -26
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
- package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
- package/dist-server/service/pipeline/stage3-board.js +65 -10
- package/dist-server/service/pipeline/stage3-board.js.map +1 -1
- package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
- package/dist-server/service/pipeline/stage4-binding.js +2 -2
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
- package/dist-server/service/registry/index.d.ts +1 -1
- package/dist-server/service/registry/index.js.map +1 -1
- package/dist-server/service/types/index.d.ts +35 -0
- package/dist-server/service/types/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/server/index.ts +1 -1
- package/server/service/adapters/dxf-adapter.ts +17 -4
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +354 -19
- package/server/service/converters/generic-to.test.ts +91 -0
- package/server/service/converters/generic-to.ts +66 -49
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +171 -39
- package/server/service/import-session/import-worker.ts +56 -12
- package/server/service/import-session/index.ts +19 -3
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +35 -18
- package/server/service/pipeline/index.ts +118 -23
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +102 -27
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +76 -11
- package/server/service/pipeline/stage4-binding.ts +2 -2
- package/server/service/registry/index.ts +1 -1
- package/server/service/types/index.ts +36 -0
package/server/service/index.ts
CHANGED
|
@@ -2,30 +2,40 @@
|
|
|
2
2
|
import {
|
|
3
3
|
entities as ImportSessionEntities,
|
|
4
4
|
resolverClasses as ImportSessionResolverClasses
|
|
5
|
-
} from './import-session/index
|
|
6
|
-
export * from './import-session/index
|
|
5
|
+
} from './import-session/index'
|
|
6
|
+
export * from './import-session/index'
|
|
7
7
|
|
|
8
8
|
export const entities = [...ImportSessionEntities]
|
|
9
9
|
export const schema = { resolverClasses: [...ImportSessionResolverClasses] }
|
|
10
10
|
|
|
11
11
|
// Pipeline (high-level)
|
|
12
|
-
export { importBoard } from './pipeline/index
|
|
13
|
-
export type { ImportOptions, ImportResult } from './pipeline/index
|
|
12
|
+
export { importBoard } from './pipeline/index'
|
|
13
|
+
export type { ImportOptions, ImportResult } from './pipeline/index'
|
|
14
14
|
|
|
15
15
|
// Stage 별 (low-level)
|
|
16
|
-
export {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export
|
|
16
|
+
export {
|
|
17
|
+
applyMapping,
|
|
18
|
+
GENERIC_OBJECT_TYPE,
|
|
19
|
+
DEFAULT_CATEGORY_FALLBACK
|
|
20
|
+
} from './pipeline/stage2-mapping'
|
|
21
|
+
export { assembleBoard } from './pipeline/stage3-board'
|
|
22
|
+
export { applyDataBinding } from './pipeline/stage4-binding'
|
|
23
|
+
export type { BoardModel, BoardComponent, AssembleOptions } from './pipeline/stage3-board'
|
|
24
|
+
export type { MappingOptions } from './pipeline/stage2-mapping'
|
|
25
|
+
export type { BindingOptions } from './pipeline/stage4-binding'
|
|
22
26
|
|
|
23
27
|
// Converters
|
|
24
|
-
export {
|
|
25
|
-
|
|
28
|
+
export {
|
|
29
|
+
convertGenericTo, // deprecated alias — keep for backward-compat
|
|
30
|
+
convertPlaceholderTo,
|
|
31
|
+
isPlaceholderType,
|
|
32
|
+
CORE_PLACEHOLDER_TYPES
|
|
33
|
+
} from './converters/generic-to'
|
|
34
|
+
export type { ConvertOptions } from './converters/generic-to'
|
|
26
35
|
|
|
27
36
|
// Adapters
|
|
28
|
-
export { DxfAdapter } from './adapters/dxf-adapter
|
|
37
|
+
export { DxfAdapter } from './adapters/dxf-adapter'
|
|
38
|
+
export { ImageAdapter } from './adapters/image-adapter'
|
|
29
39
|
|
|
30
40
|
// Registry
|
|
31
41
|
export {
|
|
@@ -41,7 +51,7 @@ export {
|
|
|
41
51
|
clearSubscriptionRules,
|
|
42
52
|
clear3DPresets,
|
|
43
53
|
clearAllRegistries
|
|
44
|
-
} from './registry/index
|
|
54
|
+
} from './registry/index'
|
|
45
55
|
|
|
46
56
|
// Types — Universal Entity
|
|
47
57
|
export type {
|
|
@@ -60,7 +70,7 @@ export type {
|
|
|
60
70
|
AdapterInput,
|
|
61
71
|
FormatAdapter,
|
|
62
72
|
AdapterCapabilities
|
|
63
|
-
} from './types/index
|
|
73
|
+
} from './types/index'
|
|
64
74
|
|
|
65
75
|
// Types — Registry
|
|
66
76
|
export type {
|
|
@@ -76,14 +86,21 @@ export type {
|
|
|
76
86
|
SubscriptionRule,
|
|
77
87
|
MappingResult,
|
|
78
88
|
MappingWarning
|
|
79
|
-
} from './types/registry
|
|
89
|
+
} from './types/registry'
|
|
80
90
|
|
|
81
91
|
// AI domain interface (Phase 3+) — provider 추상화는 @things-factory/ai-client-base
|
|
82
92
|
export {
|
|
83
93
|
setAIClient,
|
|
84
94
|
getAIClient,
|
|
85
95
|
DefaultBoardImportAI
|
|
86
|
-
} from './ai/types
|
|
96
|
+
} from './ai/types'
|
|
97
|
+
|
|
98
|
+
// LLM tool 노출 — board-ai 의 chat 흐름이 자동 dispatch 가능하도록 등록.
|
|
99
|
+
// import 시 module side-effect 로 registerToolCategory 발동. 명시 호출도 가능.
|
|
100
|
+
export {
|
|
101
|
+
registerBoardImportTools,
|
|
102
|
+
BOARD_IMPORT_TOOL_CATEGORY
|
|
103
|
+
} from './import-tools'
|
|
87
104
|
export type {
|
|
88
105
|
BoardImportAI,
|
|
89
106
|
AIClient, // deprecated alias for BoardImportAI
|
|
@@ -93,4 +110,4 @@ export type {
|
|
|
93
110
|
LabeledEntity,
|
|
94
111
|
EntityClassification,
|
|
95
112
|
ImageParseOptions
|
|
96
|
-
} from './ai/types
|
|
113
|
+
} from './ai/types'
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* const result = await importBoard({ file: buf, filename: 'plant.dxf', scopes: ['fmsim'] })
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
|
-
import { DxfAdapter } from '../adapters/dxf-adapter
|
|
13
|
-
import
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
12
|
+
import { DxfAdapter } from '../adapters/dxf-adapter'
|
|
13
|
+
import { ImageAdapter } from '../adapters/image-adapter'
|
|
14
|
+
import type { AdapterInput, BoundingBox, FormatAdapter, ParseOptions, ParseResult } from '../types/index'
|
|
15
|
+
import type { MappingResult } from '../types/registry'
|
|
16
|
+
import { applyMapping } from './stage2-mapping'
|
|
17
|
+
import { assembleBoard, type AssembleOptions, type BoardModel } from './stage3-board'
|
|
18
|
+
import { applyDataBinding } from './stage4-binding'
|
|
18
19
|
|
|
19
20
|
export interface ImportOptions {
|
|
20
21
|
file: AdapterInput
|
|
@@ -41,34 +42,128 @@ export interface ImportResult {
|
|
|
41
42
|
parse: ParseResult
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 변환 시안 (variant) — AI 가 같은 도면을 다른 관점으로 해석한 결과. 사용자가 review 단계에서
|
|
47
|
+
* 시각 비교 후 선택. parse 가 multi-variant 응답하면 (현재 ImageAdapter), 각 variant 마다
|
|
48
|
+
* 독립 mapping + assemble. DXF 같은 single-variant adapter 는 1 boardModel 만.
|
|
49
|
+
*/
|
|
50
|
+
export interface VariantResult {
|
|
51
|
+
id: string
|
|
52
|
+
label: string
|
|
53
|
+
description?: string
|
|
54
|
+
/** 보드 정체성 — name 자동 추천 기반. */
|
|
55
|
+
subjectName?: string
|
|
56
|
+
/** 보드 정체성 — description 자동 추천 기반. */
|
|
57
|
+
subjectDescription?: string
|
|
58
|
+
/** AI 의 의도 narrative — review 카드 표시 (transient). */
|
|
59
|
+
importStrategy?: string
|
|
60
|
+
boardModel: BoardModel
|
|
61
|
+
/** mapping 통계 (시안별 — entities 가 다르므로 stats 도 다름). */
|
|
62
|
+
stats: MappingResult['stats']
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ImportVariantsResult {
|
|
66
|
+
variants: VariantResult[]
|
|
67
|
+
/** 각 시안의 mapping 결과 묶음. variants[i] 와 같은 인덱스. */
|
|
68
|
+
mappings: MappingResult[]
|
|
69
|
+
parse: ParseResult
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Adapter detection 순서가 중요: 정밀 형식 (DXF) 먼저, 폴백 / 비정형 (Image) 나중.
|
|
73
|
+
// detect() 가 filename 확장자 우선이라 충돌은 거의 없지만, 시그니처 모호한 경우 (예:
|
|
74
|
+
// 확장자 없는 binary stream) 도 정밀 형식이 먼저 잡히는 게 안전.
|
|
75
|
+
//
|
|
76
|
+
// import-tools.ts 의 listImportAdapters builder 가 본 배열을 inspect — 외부 export.
|
|
77
|
+
export const DEFAULT_ADAPTERS: FormatAdapter[] = [new DxfAdapter(), new ImageAdapter()]
|
|
45
78
|
|
|
46
79
|
export async function importBoard(opts: ImportOptions): Promise<ImportResult> {
|
|
80
|
+
// 단일 변형 — multi-variant adapter 라도 첫 시안만 반환. back-compat.
|
|
81
|
+
const result = await importBoardVariants(opts)
|
|
82
|
+
const first = result.variants[0]
|
|
83
|
+
if (!first) {
|
|
84
|
+
throw new Error('Import produced no variants.')
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
boardModel: first.boardModel,
|
|
88
|
+
mapping: result.mappings[0],
|
|
89
|
+
parse: result.parse
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 다중 시안 import — parse 1번 (비싼 VLM/DXF), 시안별 mapping + assemble.
|
|
95
|
+
*
|
|
96
|
+
* AI multi-variant adapter (ImageAdapter) 는 parse.variants 에 N 시안 반환 → 각각 mapping +
|
|
97
|
+
* assemble. Single-variant adapter (DxfAdapter) 는 parse.variants 미존재 → 단일 시안 1 boardModel.
|
|
98
|
+
*/
|
|
99
|
+
export async function importBoardVariants(opts: ImportOptions): Promise<ImportVariantsResult> {
|
|
47
100
|
const adapter = opts.adapter ?? (await pickAdapter(opts.file, opts.filename))
|
|
48
101
|
if (!adapter) throw new Error('지원되지 않는 도면 형식이거나 어댑터를 식별할 수 없습니다.')
|
|
49
102
|
|
|
50
103
|
const parsed = await adapter.parse(opts.file, opts.parseOptions)
|
|
51
|
-
const
|
|
52
|
-
|
|
104
|
+
const importMeta = {
|
|
105
|
+
format: adapter.format,
|
|
106
|
+
sourceBbox: parsed.bbox,
|
|
107
|
+
units: parsed.units,
|
|
108
|
+
importedAt: new Date().toISOString()
|
|
109
|
+
}
|
|
53
110
|
const assembleOptions: AssembleOptions = {
|
|
54
111
|
scopes: opts.scopes,
|
|
55
112
|
normalizeOrigin: opts.normalizeOrigin ?? true,
|
|
56
113
|
flipY: opts.flipY ?? true,
|
|
57
114
|
scale: opts.scale ?? 1,
|
|
58
|
-
importMeta
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
importMeta
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// multi-variant adapter — parse.variants 가 존재
|
|
119
|
+
if (Array.isArray(parsed.variants) && parsed.variants.length > 0) {
|
|
120
|
+
const variants: VariantResult[] = []
|
|
121
|
+
const mappings: MappingResult[] = []
|
|
122
|
+
for (const v of parsed.variants) {
|
|
123
|
+
// 각 시안의 entities 로 mapping (시안별로 다름)
|
|
124
|
+
const variantParsed: ParseResult = {
|
|
125
|
+
...parsed,
|
|
126
|
+
entities: v.entities,
|
|
127
|
+
bbox: v.bbox ?? parsed.bbox
|
|
128
|
+
}
|
|
129
|
+
const mapping = applyMapping(variantParsed, { scopes: opts.scopes })
|
|
130
|
+
mappings.push(mapping)
|
|
131
|
+
let boardModel = assembleBoard(mapping.components, variantParsed.bbox, assembleOptions)
|
|
132
|
+
if (opts.applyBinding !== false) {
|
|
133
|
+
boardModel = applyDataBinding(boardModel, { scopes: opts.scopes })
|
|
134
|
+
}
|
|
135
|
+
variants.push({
|
|
136
|
+
id: v.id,
|
|
137
|
+
label: v.label,
|
|
138
|
+
description: v.description,
|
|
139
|
+
subjectName: v.subjectName,
|
|
140
|
+
subjectDescription: v.subjectDescription,
|
|
141
|
+
importStrategy: v.importStrategy,
|
|
142
|
+
boardModel,
|
|
143
|
+
stats: mapping.stats
|
|
144
|
+
})
|
|
63
145
|
}
|
|
146
|
+
return { variants, mappings, parse: parsed }
|
|
64
147
|
}
|
|
65
|
-
let boardModel = assembleBoard(mapping.components, parsed.bbox, assembleOptions)
|
|
66
148
|
|
|
149
|
+
// single-variant adapter (DXF 등) — 단일 boardModel
|
|
150
|
+
const mapping = applyMapping(parsed, { scopes: opts.scopes })
|
|
151
|
+
let boardModel = assembleBoard(mapping.components, parsed.bbox, assembleOptions)
|
|
67
152
|
if (opts.applyBinding !== false) {
|
|
68
153
|
boardModel = applyDataBinding(boardModel, { scopes: opts.scopes })
|
|
69
154
|
}
|
|
70
|
-
|
|
71
|
-
|
|
155
|
+
return {
|
|
156
|
+
variants: [
|
|
157
|
+
{
|
|
158
|
+
id: 'default',
|
|
159
|
+
label: 'default',
|
|
160
|
+
boardModel,
|
|
161
|
+
stats: mapping.stats
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
mappings: [mapping],
|
|
165
|
+
parse: parsed
|
|
166
|
+
}
|
|
72
167
|
}
|
|
73
168
|
|
|
74
169
|
async function pickAdapter(
|
|
@@ -81,9 +176,9 @@ async function pickAdapter(
|
|
|
81
176
|
return undefined
|
|
82
177
|
}
|
|
83
178
|
|
|
84
|
-
export { applyMapping } from './stage2-mapping
|
|
85
|
-
export { assembleBoard } from './stage3-board
|
|
86
|
-
export { applyDataBinding } from './stage4-binding
|
|
87
|
-
export type { BoardModel, BoardComponent, AssembleOptions } from './stage3-board
|
|
88
|
-
export type { MappingOptions } from './stage2-mapping
|
|
89
|
-
export type { BindingOptions } from './stage4-binding
|
|
179
|
+
export { applyMapping } from './stage2-mapping'
|
|
180
|
+
export { assembleBoard } from './stage3-board'
|
|
181
|
+
export { applyDataBinding } from './stage4-binding'
|
|
182
|
+
export type { BoardModel, BoardComponent, AssembleOptions } from './stage3-board'
|
|
183
|
+
export type { MappingOptions } from './stage2-mapping'
|
|
184
|
+
export type { BindingOptions } from './stage4-binding'
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2 매핑의 카테고리 폴백 회귀 테스트.
|
|
3
|
+
*
|
|
4
|
+
* - 코어 기본 폴백: 모든 카테고리 → placeholder
|
|
5
|
+
* - DEFAULT_CATEGORY_FALLBACK 의 매핑 정확성
|
|
6
|
+
* - categoryFallback override (도메인 패키지의 GenericTransport 등) 가 적용되는지
|
|
7
|
+
* - rule 매치된 entity 는 폴백을 거치지 않음 (회귀 방지)
|
|
8
|
+
* - placeholder draft state 가 convertPlaceholderTo 가 strip 하는 키 set 과 일치 (라운드트립)
|
|
9
|
+
*/
|
|
10
|
+
import { applyMapping, DEFAULT_CATEGORY_FALLBACK, GENERIC_OBJECT_TYPE } from './stage2-mapping'
|
|
11
|
+
import { clearImportRules, registerImportRules } from '../registry/index'
|
|
12
|
+
import type { ParseResult, UniversalEntity } from '../types/index'
|
|
13
|
+
|
|
14
|
+
function makeEntity(uid: string, attribs: Record<string, any> = {}): UniversalEntity {
|
|
15
|
+
return {
|
|
16
|
+
uid,
|
|
17
|
+
kind: 'shape',
|
|
18
|
+
position: { x: 0, y: 0 },
|
|
19
|
+
size: { width: 100, height: 100 },
|
|
20
|
+
bbox: { min: { x: 0, y: 0 }, max: { x: 100, y: 100 } },
|
|
21
|
+
attribs
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsedFrom(entities: UniversalEntity[]): ParseResult {
|
|
26
|
+
return {
|
|
27
|
+
entities,
|
|
28
|
+
metadata: { format: 'test' },
|
|
29
|
+
bbox: { min: { x: 0, y: 0 }, max: { x: 1000, y: 1000 } }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => clearImportRules())
|
|
34
|
+
|
|
35
|
+
describe('DEFAULT_CATEGORY_FALLBACK', () => {
|
|
36
|
+
test('모든 카테고리는 placeholder', () => {
|
|
37
|
+
for (const val of Object.values(DEFAULT_CATEGORY_FALLBACK)) {
|
|
38
|
+
expect(val).toBe(GENERIC_OBJECT_TYPE)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('applyMapping — 카테고리 폴백', () => {
|
|
44
|
+
test('categoryHint 없으면 GenericObject 로 (기존 동작)', () => {
|
|
45
|
+
const r = applyMapping(parsedFrom([makeEntity('e1', { name: 'whatever' })]))
|
|
46
|
+
expect(r.stats.matched).toBe(0)
|
|
47
|
+
expect(r.stats.generic).toBe(1)
|
|
48
|
+
expect(r.components[0].type).toBe(GENERIC_OBJECT_TYPE)
|
|
49
|
+
expect(r.components[0].category).toBe('unknown')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('vehicle 힌트 → placeholder', () => {
|
|
53
|
+
const r = applyMapping(
|
|
54
|
+
parsedFrom([makeEntity('v1', { name: 'TX001', categoryHint: 'vehicle' })])
|
|
55
|
+
)
|
|
56
|
+
expect(r.components[0].type).toBe(GENERIC_OBJECT_TYPE)
|
|
57
|
+
expect(r.components[0].category).toBe('vehicle')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('path 힌트 → placeholder (2D)', () => {
|
|
61
|
+
const r = applyMapping(
|
|
62
|
+
parsedFrom([makeEntity('p1', { layer: 'CONVEYOR', categoryHint: 'path' })])
|
|
63
|
+
)
|
|
64
|
+
expect(r.components[0].type).toBe('placeholder')
|
|
65
|
+
expect(r.components[0].category).toBe('path')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('region 힌트 → placeholder (2D)', () => {
|
|
69
|
+
const r = applyMapping(parsedFrom([makeEntity('z1', { categoryHint: 'region' })]))
|
|
70
|
+
expect(r.components[0].type).toBe('placeholder')
|
|
71
|
+
expect(r.components[0].category).toBe('region')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('equipment 힌트 → placeholder', () => {
|
|
75
|
+
const r = applyMapping(parsedFrom([makeEntity('m1', { categoryHint: 'equipment' })]))
|
|
76
|
+
expect(r.components[0].type).toBe(GENERIC_OBJECT_TYPE)
|
|
77
|
+
expect(r.components[0].category).toBe('equipment')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('알 수 없는 힌트 (string 으로 들어왔지만 매핑 없음) → unknown 폴백', () => {
|
|
81
|
+
const r = applyMapping(parsedFrom([makeEntity('x1', { categoryHint: 'wat' as any })]))
|
|
82
|
+
expect(r.components[0].type).toBe(GENERIC_OBJECT_TYPE)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('applyMapping — categoryFallback override', () => {
|
|
87
|
+
test('vehicle → GenericTransport 로 override', () => {
|
|
88
|
+
const r = applyMapping(parsedFrom([makeEntity('v1', { categoryHint: 'vehicle' })]), {
|
|
89
|
+
categoryFallback: { vehicle: 'GenericTransport' }
|
|
90
|
+
})
|
|
91
|
+
expect(r.components[0].type).toBe('GenericTransport')
|
|
92
|
+
expect(r.components[0].category).toBe('vehicle')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('override 안 한 카테고리는 기본값 그대로 (path → placeholder)', () => {
|
|
96
|
+
const r = applyMapping(parsedFrom([makeEntity('p1', { categoryHint: 'path' })]), {
|
|
97
|
+
categoryFallback: { vehicle: 'GenericTransport' }
|
|
98
|
+
})
|
|
99
|
+
expect(r.components[0].type).toBe('placeholder')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('applyMapping — rule 매치 회귀', () => {
|
|
104
|
+
test('rule 매치된 entity 는 폴백 미적용 — 카테고리 힌트가 있어도', () => {
|
|
105
|
+
registerImportRules('test', [
|
|
106
|
+
{
|
|
107
|
+
componentType: 'Stocker',
|
|
108
|
+
category: 'container',
|
|
109
|
+
match: { cad: { layer: 'STK' } },
|
|
110
|
+
build: {}
|
|
111
|
+
}
|
|
112
|
+
])
|
|
113
|
+
const r = applyMapping(
|
|
114
|
+
parsedFrom([makeEntity('s1', { layer: 'STK', categoryHint: 'vehicle' })])
|
|
115
|
+
)
|
|
116
|
+
// 매치 → Stocker. 힌트 무시.
|
|
117
|
+
expect(r.stats.matched).toBe(1)
|
|
118
|
+
expect(r.stats.generic).toBe(0)
|
|
119
|
+
expect(r.components[0].type).toBe('Stocker')
|
|
120
|
+
expect(r.components[0].category).toBe('container')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('applyMapping — placeholder draft state', () => {
|
|
125
|
+
test('placeholder draft state 는 label/hint 만 — fillStyle/strokeStyle 미설정', () => {
|
|
126
|
+
// Shape.drawFill/drawStroke 가 state.fillStyle/strokeStyle 가 없으면 자체 가드로 skip 한다.
|
|
127
|
+
// renderPlaceholder 가 theme 으로 자체 시각화하므로 state 에 둘 필요 없음. 두면
|
|
128
|
+
// postrender 가 stale path 에 추가 적용해 보드 전체로 시각 leak 되는 회귀.
|
|
129
|
+
const r = applyMapping(
|
|
130
|
+
parsedFrom([
|
|
131
|
+
makeEntity('p1', { name: 'PATH-A', layer: 'CONV', categoryHint: 'path' })
|
|
132
|
+
])
|
|
133
|
+
)
|
|
134
|
+
const c = r.components[0]
|
|
135
|
+
expect(c.state.fillStyle).toBeUndefined()
|
|
136
|
+
expect(c.state.strokeStyle).toBeUndefined()
|
|
137
|
+
expect(c.state.strokeDashArray).toBeUndefined()
|
|
138
|
+
expect(c.state.placeholderLabel).toBe('PATH-A')
|
|
139
|
+
expect(c.state.placeholderHint).toBe('CONV')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('vehicle placeholder 도 동일 — label/hint 만', () => {
|
|
143
|
+
const r = applyMapping(
|
|
144
|
+
parsedFrom([makeEntity('v1', { name: 'TX001', categoryHint: 'vehicle' })])
|
|
145
|
+
)
|
|
146
|
+
const c = r.components[0]
|
|
147
|
+
expect(c.state.fillStyle).toBeUndefined()
|
|
148
|
+
expect(c.state.strokeStyle).toBeUndefined()
|
|
149
|
+
expect(c.state.placeholderLabel).toBe('TX001')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('applyMapping — 컴포넌트 ID 부여', () => {
|
|
154
|
+
test('VLM attribs.name 이 있으면 그것이 ID — 운영 식별자 보존', async () => {
|
|
155
|
+
const r = applyMapping(
|
|
156
|
+
parsedFrom([
|
|
157
|
+
makeEntity('e1', { name: 'STK_001', categoryHint: 'container' }),
|
|
158
|
+
makeEntity('e2', { name: 'AGV-3', categoryHint: 'vehicle' })
|
|
159
|
+
])
|
|
160
|
+
)
|
|
161
|
+
expect(r.components[0].id).toBe('STK_001')
|
|
162
|
+
expect(r.components[1].id).toBe('AGV-3')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('attribs.name 없으면 ID 미부여 (의미 없는 시퀀스 ID 부여 안 함)', async () => {
|
|
166
|
+
const r = applyMapping(
|
|
167
|
+
parsedFrom([
|
|
168
|
+
makeEntity('e1', { categoryHint: 'vehicle' }),
|
|
169
|
+
makeEntity('e2', { categoryHint: 'vehicle' })
|
|
170
|
+
])
|
|
171
|
+
)
|
|
172
|
+
expect(r.components[0].id).toBeUndefined()
|
|
173
|
+
expect(r.components[1].id).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('attribs.name 의 특수문자 정규화 — 공백 → 하이픈, 영숫자/하이픈/언더스코어만', async () => {
|
|
177
|
+
const r = applyMapping(parsedFrom([makeEntity('e1', { name: 'My Stocker #1!' })]))
|
|
178
|
+
// "My Stocker #1!" → 공백 → '-' → "My-Stocker-#1!" → 특수문자 제거 → "My-Stocker-1"
|
|
179
|
+
expect(r.components[0].id).toBe('My-Stocker-1')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('중복 attribs.name → 두번째는 ID 미부여 (운영 ID 충돌 회피)', async () => {
|
|
183
|
+
const r = applyMapping(
|
|
184
|
+
parsedFrom([
|
|
185
|
+
makeEntity('e1', { name: 'duplicate' }),
|
|
186
|
+
makeEntity('e2', { name: 'duplicate' })
|
|
187
|
+
])
|
|
188
|
+
)
|
|
189
|
+
expect(r.components[0].id).toBe('duplicate')
|
|
190
|
+
// 두 번째는 의미 있는 ID 부여 불가능 → undefined
|
|
191
|
+
expect(r.components[1].id).toBeUndefined()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('applyMapping — emitGeneric=false', () => {
|
|
196
|
+
test('카테고리 힌트 있어도 폴백 자체가 비활성이면 skip', () => {
|
|
197
|
+
const r = applyMapping(parsedFrom([makeEntity('v1', { categoryHint: 'vehicle' })]), {
|
|
198
|
+
emitGeneric: false
|
|
199
|
+
})
|
|
200
|
+
expect(r.stats.skipped).toBe(1)
|
|
201
|
+
expect(r.stats.generic).toBe(0)
|
|
202
|
+
expect(r.components.length).toBe(0)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -2,27 +2,54 @@
|
|
|
2
2
|
* Stage 2 — Universal Entities → Component Drafts.
|
|
3
3
|
* 등록된 ImportRule 들을 entity 마다 평가하여 컴포넌트 type 결정.
|
|
4
4
|
*/
|
|
5
|
-
import { getImportRules } from '../registry/index
|
|
6
|
-
import type { ParseResult, UniversalEntity } from '../types/index
|
|
5
|
+
import { getImportRules } from '../registry/index'
|
|
6
|
+
import type { ParseResult, UniversalEntity } from '../types/index'
|
|
7
7
|
import type {
|
|
8
|
+
ComponentCategory,
|
|
8
9
|
ComponentDraft,
|
|
9
10
|
IdStrategy,
|
|
10
11
|
ImportMatcher,
|
|
11
12
|
MappingResult,
|
|
12
13
|
MappingWarning
|
|
13
|
-
} from '../types/registry
|
|
14
|
+
} from '../types/registry'
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 매핑되지 않은 entity 가 변환되는 placeholder 도메인 type.
|
|
18
|
+
*
|
|
19
|
+
* 반드시 things-scene 의 `Component.register('placeholder', Placeholder)` 에서 쓰는
|
|
20
|
+
* **kebab-case** 와 일치해야 한다.
|
|
21
|
+
*/
|
|
22
|
+
export const GENERIC_OBJECT_TYPE = 'placeholder'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 카테고리 힌트 → fallback placeholder type 의 코어 기본 매핑.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_CATEGORY_FALLBACK: Record<ComponentCategory, string> = {
|
|
28
|
+
vehicle: 'placeholder',
|
|
29
|
+
equipment: 'placeholder',
|
|
30
|
+
container: 'placeholder',
|
|
31
|
+
io: 'placeholder',
|
|
32
|
+
structure: 'placeholder',
|
|
33
|
+
path: 'placeholder',
|
|
34
|
+
region: 'placeholder',
|
|
35
|
+
marker: 'placeholder',
|
|
36
|
+
unknown: GENERIC_OBJECT_TYPE
|
|
37
|
+
}
|
|
17
38
|
|
|
18
39
|
export interface MappingOptions {
|
|
19
40
|
/** 어떤 scope 의 규칙을 사용할지 (생략 시 전부) */
|
|
20
41
|
scopes?: string[]
|
|
21
42
|
/**
|
|
22
|
-
* 매칭 안 된 entity 를
|
|
43
|
+
* 매칭 안 된 entity 를 placeholder 로 변환할지 여부 (default: true).
|
|
23
44
|
* false 면 buffer 에서 제외되고 stats.skipped 로 카운트.
|
|
24
45
|
*/
|
|
25
46
|
emitGeneric?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* 카테고리 힌트(`entity.attribs.categoryHint`) → fallback placeholder type override.
|
|
49
|
+
* `DEFAULT_CATEGORY_FALLBACK` 와 머지되어 사용. 도메인 패키지가 자기 추상 type 을
|
|
50
|
+
* 사용하고 싶을 때 명시 (예: `{ vehicle: 'GenericTransport' }`).
|
|
51
|
+
*/
|
|
52
|
+
categoryFallback?: Partial<Record<ComponentCategory, string>>
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
export function applyMapping(parsed: ParseResult, options: MappingOptions = {}): MappingResult {
|
|
@@ -36,6 +63,7 @@ export function applyMapping(parsed: ParseResult, options: MappingOptions = {}):
|
|
|
36
63
|
|
|
37
64
|
const usedIds = new Map<string, number>()
|
|
38
65
|
const emitGeneric = options.emitGeneric !== false
|
|
66
|
+
const fallbackTable = { ...DEFAULT_CATEGORY_FALLBACK, ...(options.categoryFallback || {}) }
|
|
39
67
|
|
|
40
68
|
for (const entity of parsed.entities) {
|
|
41
69
|
const rule = rules.find(r => matchRule(r.match, entity))
|
|
@@ -45,7 +73,9 @@ export function applyMapping(parsed: ParseResult, options: MappingOptions = {}):
|
|
|
45
73
|
stats.skipped++
|
|
46
74
|
continue
|
|
47
75
|
}
|
|
48
|
-
const
|
|
76
|
+
const hint = (entity.attribs.categoryHint as ComponentCategory | undefined) || 'unknown'
|
|
77
|
+
const fallbackType = fallbackTable[hint] || fallbackTable.unknown
|
|
78
|
+
const draft = makeGenericDraft(entity, usedIds, fallbackType, hint)
|
|
49
79
|
components.push(draft)
|
|
50
80
|
stats.generic++
|
|
51
81
|
continue
|
|
@@ -81,21 +111,37 @@ export function applyMapping(parsed: ParseResult, options: MappingOptions = {}):
|
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
/**
|
|
84
|
-
* 매칭되지 않은 entity 를
|
|
114
|
+
* 매칭되지 않은 entity 를 placeholder draft 로 변환.
|
|
85
115
|
* 좌표·크기·원본 attribs 모두 보존하여 사후 변환 가능하게 한다.
|
|
116
|
+
*
|
|
117
|
+
* - `type`: 카테고리 힌트 fallback 매핑으로 결정 (기본 GenericObject).
|
|
118
|
+
* - `category`: 힌트 그대로 (없으면 'unknown') — 3D Preset 폴백에 사용됨.
|
|
119
|
+
* - state: placeholderLabel / placeholderHint 만 — 시각 표현은 컴포넌트 자체 render 가 담당.
|
|
120
|
+
* things-scene 의 GenericObject 의 renderPlaceholder 가 theme.fill / theme.stroke
|
|
121
|
+
* 를 자체 적용하므로 state.fillStyle / strokeStyle / strokeDashArray 를 두면 Shape.postrender
|
|
122
|
+
* 가 그것을 추가로 적용해 시각 leak (보드 전체에 stale path 가 칠해지는 회귀) 가 발생.
|
|
86
123
|
*/
|
|
87
|
-
function makeGenericDraft(
|
|
88
|
-
|
|
124
|
+
function makeGenericDraft(
|
|
125
|
+
entity: UniversalEntity,
|
|
126
|
+
usedIds: Map<string, number>,
|
|
127
|
+
type: string = GENERIC_OBJECT_TYPE,
|
|
128
|
+
category: ComponentCategory = 'unknown'
|
|
129
|
+
): ComponentDraft {
|
|
130
|
+
const id = resolveId('auto', entity, type, usedIds)
|
|
131
|
+
const placeholderLabel =
|
|
132
|
+
entity.attribs.name || entity.attribs.layer || entity.attribs.text || ''
|
|
133
|
+
const placeholderHint = entity.attribs.layer || entity.attribs.name
|
|
134
|
+
|
|
135
|
+
const baseState: Record<string, any> = {
|
|
136
|
+
placeholderLabel,
|
|
137
|
+
...(placeholderHint ? { placeholderHint } : {})
|
|
138
|
+
}
|
|
139
|
+
|
|
89
140
|
return {
|
|
90
|
-
category
|
|
91
|
-
type
|
|
141
|
+
category,
|
|
142
|
+
type,
|
|
92
143
|
id,
|
|
93
|
-
state:
|
|
94
|
-
strokeStyle: '#888888',
|
|
95
|
-
strokeDashArray: '4 4',
|
|
96
|
-
fillStyle: 'rgba(128,128,128,0.15)',
|
|
97
|
-
label: entity.attribs.name || entity.attribs.layer || entity.attribs.text || ''
|
|
98
|
-
},
|
|
144
|
+
state: baseState,
|
|
99
145
|
source: entity
|
|
100
146
|
}
|
|
101
147
|
}
|
|
@@ -157,12 +203,27 @@ function matchString(pattern: string | RegExp, value: string): boolean {
|
|
|
157
203
|
|
|
158
204
|
// ── ID 부여 전략 ──────────────────────────────────────────────────
|
|
159
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 컴포넌트 ID 부여.
|
|
208
|
+
*
|
|
209
|
+
* **정책: 의미 있는 ID 만 부여한다.** 의미 없는 시퀀스 (`generic-object-1`, `STK_002`) 는
|
|
210
|
+
* 사용자/시스템 혼동만 야기하고 운영 가치가 없다 — 차라리 미부여 (undefined) 가 낫다.
|
|
211
|
+
*
|
|
212
|
+
* - **fromAttribute / fromAdjacentText / fromIfcGlobalId**: 명시 strategy — 해당 출처
|
|
213
|
+
* 사용. 없으면 undefined.
|
|
214
|
+
* - **auto / default**: VLM 이 도면 텍스트 라벨을 추출해 attribs.name 에 채웠으면 그것 사용.
|
|
215
|
+
* 없으면 undefined.
|
|
216
|
+
* - **custom**: 호출자 정의. 빈 결과면 undefined.
|
|
217
|
+
*
|
|
218
|
+
* 중복 ID 는 두 번째부터 undefined (이미 운영 ID 로 정확히 일치하는 게 있으니 새로 부여하면
|
|
219
|
+
* 잘못된 동일성 매칭 야기).
|
|
220
|
+
*/
|
|
160
221
|
function resolveId(
|
|
161
222
|
strategy: IdStrategy,
|
|
162
223
|
entity: UniversalEntity,
|
|
163
|
-
|
|
224
|
+
_type: string,
|
|
164
225
|
usedIds: Map<string, number>
|
|
165
|
-
): string {
|
|
226
|
+
): string | undefined {
|
|
166
227
|
let id: string | undefined
|
|
167
228
|
|
|
168
229
|
if (typeof strategy === 'object' && 'custom' in strategy) {
|
|
@@ -180,16 +241,30 @@ function resolveId(
|
|
|
180
241
|
break
|
|
181
242
|
case 'auto':
|
|
182
243
|
default:
|
|
183
|
-
|
|
244
|
+
// VLM 이 도면 텍스트 라벨 추출했으면 그것. 없으면 ID 미부여.
|
|
245
|
+
id = entity.attribs.name
|
|
184
246
|
}
|
|
185
247
|
}
|
|
186
248
|
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
usedIds.set(id, (usedIds.get(id) ?? 0) + 1)
|
|
249
|
+
if (id) id = sanitizeId(id)
|
|
250
|
+
|
|
251
|
+
// 중복 회피 — 이미 사용된 ID 면 undefined (의미 없는 자동 변형 부여 X)
|
|
252
|
+
if (id && usedIds.has(id)) {
|
|
253
|
+
return undefined
|
|
193
254
|
}
|
|
255
|
+
if (id) usedIds.set(id, 1)
|
|
194
256
|
return id
|
|
195
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* ID 정규화 — 알파넘 + 하이픈/언더스코어 만 허용. 공백 → 하이픈, 다른 특수문자 제거.
|
|
261
|
+
* 빈 결과면 undefined.
|
|
262
|
+
*/
|
|
263
|
+
function sanitizeId(raw: string): string | undefined {
|
|
264
|
+
const cleaned = raw
|
|
265
|
+
.trim()
|
|
266
|
+
.replace(/\s+/g, '-')
|
|
267
|
+
.replace(/[^a-zA-Z0-9_\-]/g, '')
|
|
268
|
+
.slice(0, 64)
|
|
269
|
+
return cleaned || undefined
|
|
270
|
+
}
|