@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.
Files changed (83) hide show
  1. package/dist-server/index.d.ts +1 -1
  2. package/dist-server/index.js +1 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
  5. package/dist-server/service/adapters/dxf-adapter.js +18 -4
  6. package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
  7. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  8. package/dist-server/service/adapters/image-adapter.js +420 -0
  9. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  10. package/dist-server/service/ai/types.d.ts +96 -4
  11. package/dist-server/service/ai/types.js +221 -12
  12. package/dist-server/service/ai/types.js.map +1 -1
  13. package/dist-server/service/converters/generic-to.d.ts +17 -8
  14. package/dist-server/service/converters/generic-to.js +44 -44
  15. package/dist-server/service/converters/generic-to.js.map +1 -1
  16. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  17. package/dist-server/service/import-session/import-actions.js +98 -0
  18. package/dist-server/service/import-session/import-actions.js.map +1 -0
  19. package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
  20. package/dist-server/service/import-session/import-session-resolver.js +166 -38
  21. package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
  22. package/dist-server/service/import-session/import-worker.js +55 -13
  23. package/dist-server/service/import-session/import-worker.js.map +1 -1
  24. package/dist-server/service/import-session/index.d.ts +5 -3
  25. package/dist-server/service/import-session/index.js +16 -9
  26. package/dist-server/service/import-session/index.js.map +1 -1
  27. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  28. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  29. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  30. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  31. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  32. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  33. package/dist-server/service/import-tools.d.ts +10 -0
  34. package/dist-server/service/import-tools.js +227 -0
  35. package/dist-server/service/import-tools.js.map +1 -0
  36. package/dist-server/service/index.d.ts +22 -19
  37. package/dist-server/service/index.js +46 -36
  38. package/dist-server/service/index.js.map +1 -1
  39. package/dist-server/service/pipeline/index.d.ts +42 -9
  40. package/dist-server/service/pipeline/index.js +92 -23
  41. package/dist-server/service/pipeline/index.js.map +1 -1
  42. package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
  43. package/dist-server/service/pipeline/stage2-mapping.js +84 -26
  44. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
  45. package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
  46. package/dist-server/service/pipeline/stage3-board.js +65 -10
  47. package/dist-server/service/pipeline/stage3-board.js.map +1 -1
  48. package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
  49. package/dist-server/service/pipeline/stage4-binding.js +2 -2
  50. package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
  51. package/dist-server/service/registry/index.d.ts +1 -1
  52. package/dist-server/service/registry/index.js.map +1 -1
  53. package/dist-server/service/types/index.d.ts +35 -0
  54. package/dist-server/service/types/index.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +7 -6
  57. package/server/index.ts +1 -1
  58. package/server/service/adapters/dxf-adapter.ts +17 -4
  59. package/server/service/adapters/image-adapter.test.ts +545 -0
  60. package/server/service/adapters/image-adapter.ts +464 -0
  61. package/server/service/ai/types.ts +354 -19
  62. package/server/service/converters/generic-to.test.ts +91 -0
  63. package/server/service/converters/generic-to.ts +66 -49
  64. package/server/service/import-session/import-actions.test.ts +185 -0
  65. package/server/service/import-session/import-actions.ts +164 -0
  66. package/server/service/import-session/import-session-resolver.ts +171 -39
  67. package/server/service/import-session/import-worker.ts +56 -12
  68. package/server/service/import-session/index.ts +19 -3
  69. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  70. package/server/service/import-session/materialize-from-session.ts +158 -0
  71. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  72. package/server/service/import-session/suggest-board-name.ts +279 -0
  73. package/server/service/import-tools.test.ts +137 -0
  74. package/server/service/import-tools.ts +255 -0
  75. package/server/service/index.ts +35 -18
  76. package/server/service/pipeline/index.ts +118 -23
  77. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  78. package/server/service/pipeline/stage2-mapping.ts +102 -27
  79. package/server/service/pipeline/stage3-board.test.ts +133 -0
  80. package/server/service/pipeline/stage3-board.ts +76 -11
  81. package/server/service/pipeline/stage4-binding.ts +2 -2
  82. package/server/service/registry/index.ts +1 -1
  83. package/server/service/types/index.ts +36 -0
@@ -2,30 +2,40 @@
2
2
  import {
3
3
  entities as ImportSessionEntities,
4
4
  resolverClasses as ImportSessionResolverClasses
5
- } from './import-session/index.js'
6
- export * from './import-session/index.js'
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.js'
13
- export type { ImportOptions, ImportResult } from './pipeline/index.js'
12
+ export { importBoard } from './pipeline/index'
13
+ export type { ImportOptions, ImportResult } from './pipeline/index'
14
14
 
15
15
  // Stage 별 (low-level)
16
- export { applyMapping, GENERIC_OBJECT_TYPE } from './pipeline/stage2-mapping.js'
17
- export { assembleBoard } from './pipeline/stage3-board.js'
18
- export { applyDataBinding } from './pipeline/stage4-binding.js'
19
- export type { BoardModel, BoardComponent, AssembleOptions } from './pipeline/stage3-board.js'
20
- export type { MappingOptions } from './pipeline/stage2-mapping.js'
21
- export type { BindingOptions } from './pipeline/stage4-binding.js'
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 { convertGenericTo, revertToGeneric } from './converters/generic-to.js'
25
- export type { ConvertOptions } from './converters/generic-to.js'
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.js'
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.js'
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.js'
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.js'
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.js'
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.js'
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.js'
13
- import type { AdapterInput, FormatAdapter, ParseOptions, ParseResult } from '../types/index.js'
14
- import type { MappingResult } from '../types/registry.js'
15
- import { applyMapping } from './stage2-mapping.js'
16
- import { assembleBoard, type AssembleOptions, type BoardModel } from './stage3-board.js'
17
- import { applyDataBinding } from './stage4-binding.js'
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
- const DEFAULT_ADAPTERS: FormatAdapter[] = [new DxfAdapter()]
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 mapping = applyMapping(parsed, { scopes: opts.scopes })
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
- format: adapter.format,
60
- sourceBbox: parsed.bbox,
61
- units: parsed.units,
62
- importedAt: new Date().toISOString()
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
- return { boardModel, mapping, parse: parsed }
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.js'
85
- export { assembleBoard } from './stage3-board.js'
86
- export { applyDataBinding } from './stage4-binding.js'
87
- export type { BoardModel, BoardComponent, AssembleOptions } from './stage3-board.js'
88
- export type { MappingOptions } from './stage2-mapping.js'
89
- export type { BindingOptions } from './stage4-binding.js'
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.js'
6
- import type { ParseResult, UniversalEntity } from '../types/index.js'
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.js'
14
+ } from '../types/registry'
14
15
 
15
- /** 매핑되지 않은 entity 가 변환되는 placeholder 도메인 type. */
16
- export const GENERIC_OBJECT_TYPE = 'GenericObject'
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 를 GenericObject 로 변환할지 여부 (default: true).
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 draft = makeGenericDraft(entity, usedIds)
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 를 GenericObject draft 로 변환.
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(entity: UniversalEntity, usedIds: Map<string, number>): ComponentDraft {
88
- const id = resolveId('auto', entity, GENERIC_OBJECT_TYPE, usedIds)
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: 'unknown',
91
- type: GENERIC_OBJECT_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
- type: string,
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
- id = undefined
244
+ // VLM 이 도면 텍스트 라벨 추출했으면 그것. 없으면 ID 미부여.
245
+ id = entity.attribs.name
184
246
  }
185
247
  }
186
248
 
187
- if (!id) {
188
- const seq = (usedIds.get(type) ?? 0) + 1
189
- usedIds.set(type, seq)
190
- id = `${type.toUpperCase()}_${String(seq).padStart(3, '0')}`
191
- } else {
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
+ }