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

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 (137) hide show
  1. package/config/config.development.js +44 -0
  2. package/config/config.production.js +18 -0
  3. package/dist-server/index.d.ts +7 -0
  4. package/dist-server/index.js +11 -0
  5. package/dist-server/index.js.map +1 -0
  6. package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
  7. package/dist-server/service/adapters/dxf-adapter.js +220 -0
  8. package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
  9. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  10. package/dist-server/service/adapters/image-adapter.js +420 -0
  11. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  12. package/dist-server/service/ai/types.d.ts +185 -0
  13. package/dist-server/service/ai/types.js +378 -0
  14. package/dist-server/service/ai/types.js.map +1 -0
  15. package/dist-server/service/converters/generic-to.d.ts +31 -0
  16. package/dist-server/service/converters/generic-to.js +84 -0
  17. package/dist-server/service/converters/generic-to.js.map +1 -0
  18. package/dist-server/service/extraction/contracts.d.ts +112 -0
  19. package/dist-server/service/extraction/contracts.js +3 -0
  20. package/dist-server/service/extraction/contracts.js.map +1 -0
  21. package/dist-server/service/extraction/index.d.ts +17 -0
  22. package/dist-server/service/extraction/index.js +62 -0
  23. package/dist-server/service/extraction/index.js.map +1 -0
  24. package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
  25. package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
  26. package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
  27. package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
  28. package/dist-server/service/extraction/layout-refiner.js +88 -0
  29. package/dist-server/service/extraction/layout-refiner.js.map +1 -0
  30. package/dist-server/service/extraction/pipeline.d.ts +11 -0
  31. package/dist-server/service/extraction/pipeline.js +124 -0
  32. package/dist-server/service/extraction/pipeline.js.map +1 -0
  33. package/dist-server/service/extraction/source-router.d.ts +17 -0
  34. package/dist-server/service/extraction/source-router.js +40 -0
  35. package/dist-server/service/extraction/source-router.js.map +1 -0
  36. package/dist-server/service/extraction/stubs.d.ts +18 -0
  37. package/dist-server/service/extraction/stubs.js +38 -0
  38. package/dist-server/service/extraction/stubs.js.map +1 -0
  39. package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
  40. package/dist-server/service/extraction/vector-geometry.js +21 -0
  41. package/dist-server/service/extraction/vector-geometry.js.map +1 -0
  42. package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
  43. package/dist-server/service/extraction/vlm-recognizer.js +78 -0
  44. package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
  45. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  46. package/dist-server/service/import-session/import-actions.js +98 -0
  47. package/dist-server/service/import-session/import-actions.js.map +1 -0
  48. package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
  49. package/dist-server/service/import-session/import-session-resolver.js +263 -0
  50. package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
  51. package/dist-server/service/import-session/import-session.d.ts +28 -0
  52. package/dist-server/service/import-session/import-session.js +152 -0
  53. package/dist-server/service/import-session/import-session.js.map +1 -0
  54. package/dist-server/service/import-session/import-worker.d.ts +1 -0
  55. package/dist-server/service/import-session/import-worker.js +121 -0
  56. package/dist-server/service/import-session/import-worker.js.map +1 -0
  57. package/dist-server/service/import-session/index.d.ts +8 -0
  58. package/dist-server/service/import-session/index.js +19 -0
  59. package/dist-server/service/import-session/index.js.map +1 -0
  60. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  61. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  62. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  63. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  64. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  65. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  66. package/dist-server/service/import-tools.d.ts +10 -0
  67. package/dist-server/service/import-tools.js +227 -0
  68. package/dist-server/service/import-tools.js.map +1 -0
  69. package/dist-server/service/index.d.ts +26 -0
  70. package/dist-server/service/index.js +60 -0
  71. package/dist-server/service/index.js.map +1 -0
  72. package/dist-server/service/pipeline/index.d.ts +66 -0
  73. package/dist-server/service/pipeline/index.js +127 -0
  74. package/dist-server/service/pipeline/index.js.map +1 -0
  75. package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
  76. package/dist-server/service/pipeline/stage2-mapping.js +231 -0
  77. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
  78. package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
  79. package/dist-server/service/pipeline/stage3-board.js +108 -0
  80. package/dist-server/service/pipeline/stage3-board.js.map +1 -0
  81. package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
  82. package/dist-server/service/pipeline/stage4-binding.js +28 -0
  83. package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
  84. package/dist-server/service/registry/index.d.ts +27 -0
  85. package/dist-server/service/registry/index.js +108 -0
  86. package/dist-server/service/registry/index.js.map +1 -0
  87. package/dist-server/service/types/index.d.ts +152 -0
  88. package/dist-server/service/types/index.js +3 -0
  89. package/dist-server/service/types/index.js.map +1 -0
  90. package/dist-server/service/types/registry.d.ts +175 -0
  91. package/dist-server/service/types/registry.js +3 -0
  92. package/dist-server/service/types/registry.js.map +1 -0
  93. package/dist-server/tsconfig.tsbuildinfo +1 -0
  94. package/package.json +35 -0
  95. package/probe/gcp-vision-probe.mjs +114 -0
  96. package/probe/opencv-geometry-probe.py +98 -0
  97. package/server/index.ts +7 -0
  98. package/server/service/adapters/dxf-adapter.ts +230 -0
  99. package/server/service/adapters/image-adapter.test.ts +545 -0
  100. package/server/service/adapters/image-adapter.ts +464 -0
  101. package/server/service/ai/types.ts +619 -0
  102. package/server/service/converters/generic-to.test.ts +105 -0
  103. package/server/service/converters/generic-to.ts +121 -0
  104. package/server/service/extraction/contracts.ts +142 -0
  105. package/server/service/extraction/extraction.test.ts +374 -0
  106. package/server/service/extraction/index.ts +51 -0
  107. package/server/service/extraction/label-scale-calibrator.ts +124 -0
  108. package/server/service/extraction/layout-refiner.ts +104 -0
  109. package/server/service/extraction/pipeline.ts +163 -0
  110. package/server/service/extraction/source-router.ts +45 -0
  111. package/server/service/extraction/stubs.ts +46 -0
  112. package/server/service/extraction/vector-geometry.ts +26 -0
  113. package/server/service/extraction/vlm-recognizer.ts +87 -0
  114. package/server/service/import-session/import-actions.test.ts +185 -0
  115. package/server/service/import-session/import-actions.ts +164 -0
  116. package/server/service/import-session/import-session-resolver.ts +245 -0
  117. package/server/service/import-session/import-session.ts +141 -0
  118. package/server/service/import-session/import-worker.ts +132 -0
  119. package/server/service/import-session/index.ts +24 -0
  120. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  121. package/server/service/import-session/materialize-from-session.ts +158 -0
  122. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  123. package/server/service/import-session/suggest-board-name.ts +279 -0
  124. package/server/service/import-tools.test.ts +137 -0
  125. package/server/service/import-tools.ts +255 -0
  126. package/server/service/index.ts +117 -0
  127. package/server/service/pipeline/index.ts +184 -0
  128. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  129. package/server/service/pipeline/stage2-mapping.ts +270 -0
  130. package/server/service/pipeline/stage3-board.test.ts +133 -0
  131. package/server/service/pipeline/stage3-board.ts +179 -0
  132. package/server/service/pipeline/stage4-binding.ts +31 -0
  133. package/server/service/registry/index.ts +127 -0
  134. package/server/service/types/index.ts +169 -0
  135. package/server/service/types/registry.ts +186 -0
  136. package/things-factory.config.js +1 -0
  137. package/tsconfig.json +9 -0
@@ -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
+ })
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Stage 2 — Universal Entities → Component Drafts.
3
+ * 등록된 ImportRule 들을 entity 마다 평가하여 컴포넌트 type 결정.
4
+ */
5
+ import { getImportRules } from '../registry/index'
6
+ import type { ParseResult, UniversalEntity } from '../types/index'
7
+ import type {
8
+ ComponentCategory,
9
+ ComponentDraft,
10
+ IdStrategy,
11
+ ImportMatcher,
12
+ MappingResult,
13
+ MappingWarning
14
+ } from '../types/registry'
15
+
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
+ }
38
+
39
+ export interface MappingOptions {
40
+ /** 어떤 scope 의 규칙을 사용할지 (생략 시 전부) */
41
+ scopes?: string[]
42
+ /**
43
+ * 매칭 안 된 entity 를 placeholder 로 변환할지 여부 (default: true).
44
+ * false 면 buffer 에서 제외되고 stats.skipped 로 카운트.
45
+ */
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>>
53
+ }
54
+
55
+ export function applyMapping(parsed: ParseResult, options: MappingOptions = {}): MappingResult {
56
+ const rules = getImportRules(options.scopes)
57
+ .slice()
58
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
59
+
60
+ const components: ComponentDraft[] = []
61
+ const warnings: MappingWarning[] = []
62
+ const stats = { total: parsed.entities.length, matched: 0, generic: 0, skipped: 0 }
63
+
64
+ const usedIds = new Map<string, number>()
65
+ const emitGeneric = options.emitGeneric !== false
66
+ const fallbackTable = { ...DEFAULT_CATEGORY_FALLBACK, ...(options.categoryFallback || {}) }
67
+
68
+ for (const entity of parsed.entities) {
69
+ const rule = rules.find(r => matchRule(r.match, entity))
70
+
71
+ if (!rule) {
72
+ if (!emitGeneric) {
73
+ stats.skipped++
74
+ continue
75
+ }
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)
79
+ components.push(draft)
80
+ stats.generic++
81
+ continue
82
+ }
83
+
84
+ const id = resolveId(rule.build.idStrategy ?? 'auto', entity, rule.componentType, usedIds)
85
+ let draft: ComponentDraft = {
86
+ category: rule.category ?? 'unknown',
87
+ type: rule.componentType,
88
+ id,
89
+ state: { ...(rule.build.defaultState || {}) },
90
+ matchedBy: rule,
91
+ source: entity
92
+ }
93
+
94
+ if (rule.build.transform) {
95
+ try {
96
+ draft = rule.build.transform(entity, draft)
97
+ } catch (e) {
98
+ warnings.push({
99
+ level: 'warn',
100
+ entity,
101
+ message: `transform 실패 (${rule.componentType}): ${(e as Error).message}`
102
+ })
103
+ }
104
+ }
105
+
106
+ components.push(draft)
107
+ stats.matched++
108
+ }
109
+
110
+ return { components, stats, warnings }
111
+ }
112
+
113
+ /**
114
+ * 매칭되지 않은 entity 를 placeholder draft 로 변환.
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 가 칠해지는 회귀) 가 발생.
123
+ */
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
+
140
+ return {
141
+ category,
142
+ type,
143
+ id,
144
+ state: baseState,
145
+ source: entity
146
+ }
147
+ }
148
+
149
+ // ── 매칭 로직 ──────────────────────────────────────────────────────
150
+
151
+ function matchRule(matcher: ImportMatcher, entity: UniversalEntity): boolean {
152
+ if (matcher.cad) {
153
+ const m = matcher.cad
154
+ let ok = true
155
+ if (m.blockNamePattern && !m.blockNamePattern.test(entity.attribs.name || '')) ok = false
156
+ if (m.layer && !matchString(m.layer, entity.attribs.layer || '')) ok = false
157
+ if (m.entityKinds && !m.entityKinds.includes(entity.kind)) ok = false
158
+ if (m.textAdjacent) {
159
+ const adjacentText = (entity.neighbors || [])
160
+ .filter(r => r.relation === 'labeled-by')
161
+ .map(r => r.uid)
162
+ if (!adjacentText.some(t => m.textAdjacent!.test(t))) ok = false
163
+ }
164
+ if (ok) return true
165
+ }
166
+
167
+ if (matcher.ifc) {
168
+ const m = matcher.ifc
169
+ let ok = true
170
+ if (m.ifcClass && !matchString(m.ifcClass, entity.attribs.semanticType || '')) ok = false
171
+ if (ok) return true
172
+ }
173
+
174
+ if (matcher.geometry) {
175
+ const g = matcher.geometry
176
+ let ok = true
177
+ if (g.shape && entity.attribs.shape !== g.shape) ok = false
178
+ if (g.sizeRange && entity.size) {
179
+ const area = entity.size.width * entity.size.height
180
+ if (g.sizeRange.minArea !== undefined && area < g.sizeRange.minArea) ok = false
181
+ if (g.sizeRange.maxArea !== undefined && area > g.sizeRange.maxArea) ok = false
182
+ }
183
+ if (g.aspectRatio && entity.size) {
184
+ const ratio = entity.size.height === 0 ? 1 : entity.size.width / entity.size.height
185
+ if (g.aspectRatio.min !== undefined && ratio < g.aspectRatio.min) ok = false
186
+ if (g.aspectRatio.max !== undefined && ratio > g.aspectRatio.max) ok = false
187
+ }
188
+ if (ok) return true
189
+ }
190
+
191
+ if (matcher.image && matcher.image.labels.length > 0) {
192
+ const label = entity.attribs.vlmLabel
193
+ if (label && matcher.image.labels.includes(label)) return true
194
+ }
195
+
196
+ return false
197
+ }
198
+
199
+ function matchString(pattern: string | RegExp, value: string): boolean {
200
+ if (typeof pattern === 'string') return pattern === value
201
+ return pattern.test(value)
202
+ }
203
+
204
+ // ── ID 부여 전략 ──────────────────────────────────────────────────
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
+ */
221
+ function resolveId(
222
+ strategy: IdStrategy,
223
+ entity: UniversalEntity,
224
+ _type: string,
225
+ usedIds: Map<string, number>
226
+ ): string | undefined {
227
+ let id: string | undefined
228
+
229
+ if (typeof strategy === 'object' && 'custom' in strategy) {
230
+ id = strategy.custom(entity)
231
+ } else {
232
+ switch (strategy) {
233
+ case 'fromAttribute':
234
+ id = entity.attribs.name
235
+ break
236
+ case 'fromAdjacentText':
237
+ id = (entity.neighbors || []).find(r => r.relation === 'labeled-by')?.uid
238
+ break
239
+ case 'fromIfcGlobalId':
240
+ id = entity.attribs.ifcGlobalId
241
+ break
242
+ case 'auto':
243
+ default:
244
+ // VLM 이 도면 텍스트 라벨 추출했으면 그것. 없으면 ID 미부여.
245
+ id = entity.attribs.name
246
+ }
247
+ }
248
+
249
+ if (id) id = sanitizeId(id)
250
+
251
+ // 중복 회피 — 이미 사용된 ID 면 undefined (의미 없는 자동 변형 부여 X)
252
+ if (id && usedIds.has(id)) {
253
+ return undefined
254
+ }
255
+ if (id) usedIds.set(id, 1)
256
+ return id
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
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Stage 3 — assembleBoard 의 컴포넌트 root 외곽 spill 방지 회귀 테스트.
3
+ *
4
+ * 검증 포인트:
5
+ * - bbox 가 컴포넌트 position+size 보다 작아도 board 가 그것 cover
6
+ * - normalizeOrigin + flipY 조합에서 컴포넌트 음수 좌표 회피 (root 위쪽 spill 방지)
7
+ * - padding 으로 컴포넌트가 root 가장자리에 붙지 않음
8
+ * - 빈 board 는 1000x1000 fallback
9
+ */
10
+ import { assembleBoard } from './stage3-board'
11
+ import type { ComponentDraft } from '../types/registry'
12
+ import type { BoundingBox, UniversalEntity } from '../types/index'
13
+
14
+ function entity(uid: string, x: number, y: number, w: number, h: number): UniversalEntity {
15
+ return {
16
+ uid,
17
+ kind: 'shape',
18
+ position: { x, y },
19
+ size: { width: w, height: h },
20
+ bbox: { min: { x, y }, max: { x: x + w, y: y + h } },
21
+ attribs: {}
22
+ }
23
+ }
24
+
25
+ function draft(src: UniversalEntity): ComponentDraft {
26
+ return {
27
+ category: 'unknown',
28
+ type: 'placeholder',
29
+ id: src.uid,
30
+ state: {},
31
+ source: src
32
+ }
33
+ }
34
+
35
+ describe('assembleBoard — board 크기 산정', () => {
36
+ test('컴포넌트 모두 root 안 — left/top 모두 0 이상, right/bottom 모두 board 크기 이하', () => {
37
+ const drafts = [draft(entity('a', 0, 0, 100, 80)), draft(entity('b', 200, 100, 60, 60))]
38
+ const bbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 260, y: 160 } }
39
+ const board = assembleBoard(drafts, bbox, { normalizeOrigin: true, flipY: false, scale: 1 })
40
+
41
+ for (const c of board.components ?? []) {
42
+ expect(c.left).toBeGreaterThanOrEqual(0)
43
+ expect(c.top).toBeGreaterThanOrEqual(0)
44
+ expect(c.left + c.width).toBeLessThanOrEqual(board.width!)
45
+ expect(c.top + c.height).toBeLessThanOrEqual(board.height!)
46
+ }
47
+ })
48
+
49
+ test('bbox 가 컴포넌트 size 보다 작아도 board 가 컴포넌트 fully cover', () => {
50
+ // 의도적으로 bbox 를 entity 보다 작게 — VLM 이 lossy bbox 줬을 때 시뮬레이션
51
+ const drafts = [draft(entity('a', 0, 0, 500, 400))]
52
+ const tightBbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 100, y: 100 } }
53
+ const board = assembleBoard(drafts, tightBbox, {
54
+ normalizeOrigin: true,
55
+ flipY: false,
56
+ scale: 1
57
+ })
58
+
59
+ const c = board.components![0]
60
+ expect(c.left + c.width).toBeLessThanOrEqual(board.width!)
61
+ expect(c.top + c.height).toBeLessThanOrEqual(board.height!)
62
+ // board 는 컴포넌트 (500x400) + padding 2개 = 580 / 480 이상이어야 함
63
+ expect(board.width!).toBeGreaterThanOrEqual(500)
64
+ expect(board.height!).toBeGreaterThanOrEqual(400)
65
+ })
66
+
67
+ test('flipY=true 일 때 모든 컴포넌트 top 이 0 이상 (root 위쪽 spill 방지)', () => {
68
+ const drafts = [
69
+ draft(entity('a', 0, 0, 100, 100)),
70
+ draft(entity('b', 50, 50, 100, 100)),
71
+ draft(entity('c', 100, 200, 100, 100))
72
+ ]
73
+ const bbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 200, y: 300 } }
74
+ const board = assembleBoard(drafts, bbox, { normalizeOrigin: true, flipY: true, scale: 1 })
75
+
76
+ for (const c of board.components ?? []) {
77
+ expect(c.top).toBeGreaterThanOrEqual(0)
78
+ expect(c.top + c.height).toBeLessThanOrEqual(board.height!)
79
+ }
80
+ })
81
+
82
+ test('빈 drafts → board 1000x1000 fallback', () => {
83
+ const bbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
84
+ const board = assembleBoard([], bbox, { normalizeOrigin: true, flipY: true, scale: 1 })
85
+ // 0 + padding 80 < 1000 fallback
86
+ expect(board.width).toBe(1000)
87
+ expect(board.height).toBe(1000)
88
+ expect(board.components).toEqual([])
89
+ })
90
+
91
+ test('padding 적용 — 컴포넌트 가장자리에 정확히 맞닿지 않고 약간의 여백', () => {
92
+ const drafts = [draft(entity('a', 0, 0, 100, 80))]
93
+ const bbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 100, y: 80 } }
94
+ const board = assembleBoard(drafts, bbox, { normalizeOrigin: true, flipY: false, scale: 1 })
95
+
96
+ const c = board.components![0]
97
+ // 가장자리에 정확히 0 이 아니라 padding 만큼 안쪽
98
+ expect(c.left).toBeGreaterThan(0)
99
+ expect(c.top).toBeGreaterThan(0)
100
+ // 우측/하단도 padding 만큼 여유
101
+ expect(c.left + c.width).toBeLessThan(board.width!)
102
+ expect(c.top + c.height).toBeLessThan(board.height!)
103
+ })
104
+
105
+ test('normalizeOrigin=true 일 때 음수 entity 좌표도 root 안으로 끌어올림', () => {
106
+ // entity 가 음수 좌표 (e.g. CAD 의 임의 원점) — bbox 가 정확하면 normalizeOrigin 으로
107
+ // 0 이상으로 올려야 함.
108
+ const drafts = [draft(entity('a', -100, -50, 100, 100))]
109
+ const bbox: BoundingBox = { min: { x: -100, y: -50 }, max: { x: 0, y: 50 } }
110
+ const board = assembleBoard(drafts, bbox, { normalizeOrigin: true, flipY: false, scale: 1 })
111
+
112
+ const c = board.components![0]
113
+ expect(c.left).toBeGreaterThanOrEqual(0)
114
+ expect(c.top).toBeGreaterThanOrEqual(0)
115
+ })
116
+
117
+ test('scale 적용 — 모든 좌표/크기가 비율대로', () => {
118
+ const drafts = [draft(entity('a', 10, 20, 100, 80))]
119
+ const bbox: BoundingBox = { min: { x: 0, y: 0 }, max: { x: 200, y: 200 } }
120
+ const board = assembleBoard(drafts, bbox, {
121
+ normalizeOrigin: true,
122
+ flipY: false,
123
+ scale: 0.5
124
+ })
125
+
126
+ const c = board.components![0]
127
+ // scale=0.5 적용
128
+ expect(c.width).toBe(50)
129
+ expect(c.height).toBe(40)
130
+ // padding 은 scale 무관 (40 unit)
131
+ expect(c.left).toBe(10 * 0.5 + 40)
132
+ })
133
+ })