@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,374 @@
1
+ /**
2
+ * 하이브리드 추출 — 단위 테스트.
3
+ *
4
+ * 커버:
5
+ * - GridSnapLayoutRefiner: x/y 클러스터 정렬, 3-클러스터, bbox 평행이동, grid 스냅,
6
+ * 빈배열, 원본 비파괴, size 없는 엔티티 autoTolerance 폴백.
7
+ * - source-router: 확장자별 detectSourceKind, register/get/clear.
8
+ * - extractHybrid(mock 추출기): fuse 라벨앵커(가장 가까운 라벨), 스케일 변환, 다중 시안,
9
+ * refiner 적용.
10
+ * - extractHybrid(에러): 미등록 set → throw, stub(CV) → 미구현 throw.
11
+ * - extractHybrid(벡터/DXF): DxfAdapter 흡수 무손실(layer/kind) + 단일 시안.
12
+ */
13
+ import {
14
+ GridSnapLayoutRefiner,
15
+ VlmRecognizer,
16
+ LabelScaleCalibrator,
17
+ extractHybrid,
18
+ registerDefaultExtractors,
19
+ registerExtractorSet,
20
+ clearExtractorSets,
21
+ detectSourceKind,
22
+ getExtractorSet
23
+ } from './index'
24
+ import type {
25
+ GeometricPrimitive,
26
+ Label,
27
+ ScaleResult,
28
+ VariantDraft,
29
+ GeometryExtractor,
30
+ LabelExtractor,
31
+ ScaleCalibrator,
32
+ VariantStrategy
33
+ } from './index'
34
+ import type { UniversalEntity } from '../types/index'
35
+ import type { AIClient } from '@things-factory/ai-client-base'
36
+
37
+ // ── helpers / mocks ──────────────────────────────────────────────────────────
38
+ function ent(uid: string, x: number, y: number, w = 10, h = 10): UniversalEntity {
39
+ return {
40
+ uid,
41
+ kind: 'shape',
42
+ position: { x, y },
43
+ size: { width: w, height: h },
44
+ bbox: { min: { x, y }, max: { x: x + w, y: y + h } },
45
+ attribs: {}
46
+ }
47
+ }
48
+ const rectPrim = (x: number, y: number, w: number, h: number): GeometricPrimitive => ({
49
+ kind: 'rect',
50
+ bbox: { min: { x, y }, max: { x: x + w, y: y + h } }
51
+ })
52
+ const label = (text: string, x: number, y: number, w = 4, h = 4): Label => ({
53
+ text,
54
+ bbox: { min: { x, y }, max: { x: x + w, y: y + h } }
55
+ })
56
+ const mockGeom = (prims: GeometricPrimitive[]): GeometryExtractor => ({ id: 'mock-geom', extract: async () => prims })
57
+ const mockOcr = (labels: Label[]): LabelExtractor => ({ id: 'mock-ocr', extract: async () => labels })
58
+ const mockScale = (s: ScaleResult): ScaleCalibrator => ({ id: 'mock-scale', calibrate: async () => s })
59
+ const mockVariants = (drafts: VariantDraft[]): VariantStrategy => ({ id: 'mock-var', interpret: async () => drafts })
60
+ const mockClient = (gen: (prompt: string) => any): AIClient =>
61
+ ({
62
+ id: 'mock:test',
63
+ chat: async () => '',
64
+ generateJSON: async (prompt: any) => gen(typeof prompt === 'string' ? prompt : JSON.stringify(prompt))
65
+ } as AIClient)
66
+ const prim = (w = 50, h = 80): GeometricPrimitive => ({ kind: 'rect', bbox: { min: { x: 0, y: 0 }, max: { x: w, y: h } } })
67
+
68
+ beforeEach(() => clearExtractorSets()) // 전역 레지스트리 격리
69
+
70
+ // ── GridSnapLayoutRefiner ──────────────────────────────────────────────────────
71
+ describe('GridSnapLayoutRefiner', () => {
72
+ it('근접 x 좌표를 클러스터 평균으로 정렬', () => {
73
+ const out = new GridSnapLayoutRefiner({ alignTolerance: 5 }).refine([ent('a', 10, 0), ent('b', 12, 0), ent('c', 90, 0)])
74
+ expect(out[0].position.x).toBeCloseTo(11)
75
+ expect(out[1].position.x).toBeCloseTo(11)
76
+ expect(out[2].position.x).toBeCloseTo(90)
77
+ })
78
+
79
+ it('y 좌표도 동일하게 정렬', () => {
80
+ const out = new GridSnapLayoutRefiner({ alignTolerance: 5 }).refine([ent('a', 0, 100), ent('b', 0, 103), ent('c', 0, 200)])
81
+ expect(out[0].position.y).toBeCloseTo(101.5)
82
+ expect(out[1].position.y).toBeCloseTo(101.5)
83
+ expect(out[2].position.y).toBeCloseTo(200)
84
+ })
85
+
86
+ it('3개 클러스터 각각 평균으로 (단일연결)', () => {
87
+ const out = new GridSnapLayoutRefiner({ alignTolerance: 3 }).refine([
88
+ ent('a', 0, 0), ent('b', 2, 0), ent('c', 50, 0), ent('d', 51, 0), ent('e', 100, 0)
89
+ ])
90
+ expect(out[0].position.x).toBeCloseTo(1)
91
+ expect(out[1].position.x).toBeCloseTo(1)
92
+ expect(out[2].position.x).toBeCloseTo(50.5)
93
+ expect(out[3].position.x).toBeCloseTo(50.5)
94
+ expect(out[4].position.x).toBeCloseTo(100)
95
+ })
96
+
97
+ it('정렬 시 bbox 평행이동 + size 보존', () => {
98
+ const out = new GridSnapLayoutRefiner({ alignTolerance: 5 }).refine([ent('a', 10, 0, 20, 20), ent('b', 14, 0, 20, 20)])
99
+ expect(out[0].position.x).toBeCloseTo(12)
100
+ expect(out[0].bbox.min.x).toBeCloseTo(12)
101
+ expect(out[0].bbox.max.x).toBeCloseTo(32)
102
+ expect(out[0].size!.width).toBe(20)
103
+ })
104
+
105
+ it('grid 스냅 (align off)', () => {
106
+ const out = new GridSnapLayoutRefiner({ align: false, grid: 10 }).refine([ent('a', 7, 23)])
107
+ expect(out[0].position).toEqual({ x: 10, y: 20 })
108
+ })
109
+
110
+ it('빈 배열 안전 + 원본 비파괴(clone)', () => {
111
+ expect(new GridSnapLayoutRefiner().refine([])).toEqual([])
112
+ const src = [ent('a', 10, 0)]
113
+ new GridSnapLayoutRefiner({ grid: 100 }).refine(src)
114
+ expect(src[0].position.x).toBe(10)
115
+ })
116
+
117
+ it('size 없는 엔티티 — autoTolerance 좌표 spread 폴백', () => {
118
+ const noSize = (uid: string, x: number): UniversalEntity => ({
119
+ uid, kind: 'text', position: { x, y: 0 }, bbox: { min: { x, y: 0 }, max: { x, y: 0 } }, attribs: {}
120
+ })
121
+ // spread=1000 → tol=2% =20. {0,5} 묶이고 1000 독립.
122
+ const out = new GridSnapLayoutRefiner().refine([noSize('a', 0), noSize('b', 5), noSize('c', 1000)])
123
+ expect(out[0].position.x).toBeCloseTo(2.5)
124
+ expect(out[1].position.x).toBeCloseTo(2.5)
125
+ expect(out[2].position.x).toBeCloseTo(1000)
126
+ })
127
+ })
128
+
129
+ // ── source-router ──────────────────────────────────────────────────────────────
130
+ describe('source-router', () => {
131
+ it('detectSourceKind — 확장자별 분류', async () => {
132
+ expect(await detectSourceKind('', 'a.dxf')).toBe('vector')
133
+ expect(await detectSourceKind('', 'a.svg')).toBe('vector')
134
+ expect(await detectSourceKind('', 'a.ifc')).toBe('vector')
135
+ expect(await detectSourceKind('', 'a.pdf')).toBe('vector')
136
+ expect(await detectSourceKind('', 'a.png')).toBe('raster-topdown')
137
+ expect(await detectSourceKind('', 'a.JPG')).toBe('raster-topdown') // 대소문자 무관
138
+ expect(await detectSourceKind('', 'a.zip')).toBe('unknown')
139
+ expect(await detectSourceKind('')).toBe('unknown') // 파일명 없음
140
+ })
141
+
142
+ it('register/get/clear', () => {
143
+ expect(getExtractorSet('photo')).toBeUndefined()
144
+ registerExtractorSet('photo', () => ({ geometry: mockGeom([]) }))
145
+ expect(getExtractorSet('photo')?.geometry.id).toBe('mock-geom')
146
+ clearExtractorSets()
147
+ expect(getExtractorSet('photo')).toBeUndefined()
148
+ })
149
+ })
150
+
151
+ // ── extractHybrid (mock 추출기로 우리 로직 검증) ──────────────────────────────────
152
+ describe('extractHybrid — 융합/스케일/시안', () => {
153
+ it('fuse: 각 프리미티브가 *가장 가까운* 라벨로 name 앵커', async () => {
154
+ registerExtractorSet('raster-topdown', () => ({
155
+ geometry: mockGeom([rectPrim(0, 0, 10, 10), rectPrim(100, 100, 10, 10)]),
156
+ labels: mockOcr([label('STK_01', 1, 1), label('STK_02', 101, 101)])
157
+ }))
158
+ const res = await extractHybrid('x', {}, 'plan.png')
159
+ expect(res.sourceKind).toBe('raster-topdown')
160
+ expect(res.entities.map(e => e.attribs.name)).toEqual(['STK_01', 'STK_02'])
161
+ })
162
+
163
+ it('scale: unitsPerPixel/origin 으로 위치·크기·bbox 변환', async () => {
164
+ registerExtractorSet('raster-topdown', () => ({
165
+ geometry: mockGeom([rectPrim(10, 20, 30, 40)]),
166
+ scale: mockScale({ unitsPerPixel: 2, origin: { x: 10, y: 20 } })
167
+ }))
168
+ const res = await extractHybrid('x', {}, 'plan.png')
169
+ const e = res.entities[0]
170
+ expect(e.position).toEqual({ x: 0, y: 0 }) // (10-10,20-20)*2
171
+ expect(e.size).toEqual({ width: 60, height: 80 }) // 30*2, 40*2
172
+ expect(e.bbox.max).toEqual({ x: 60, y: 80 })
173
+ })
174
+
175
+ it('variants: 전략이 N 시안 → variants 보존, entities=variants[0]', async () => {
176
+ registerExtractorSet('raster-topdown', () => ({
177
+ geometry: mockGeom([rectPrim(0, 0, 10, 10)]),
178
+ variants: mockVariants([
179
+ { id: 'eq', label: '장비', entities: [ent('a', 0, 0)] },
180
+ { id: 'tr', label: '운송', entities: [ent('b', 5, 5)] },
181
+ { id: 'rg', label: '구역', entities: [ent('c', 9, 9)] }
182
+ ])
183
+ }))
184
+ const res = await extractHybrid('x', {}, 'plan.png')
185
+ expect(res.variants.map(v => v.id)).toEqual(['eq', 'tr', 'rg'])
186
+ expect(res.entities[0].uid).toBe('a') // default = variants[0]
187
+ })
188
+
189
+ it('refiner 가 시안 entities 에 적용됨', async () => {
190
+ registerExtractorSet('raster-topdown', () => ({
191
+ geometry: mockGeom([rectPrim(7, 7, 10, 10)]),
192
+ refiner: new GridSnapLayoutRefiner({ align: false, grid: 10 })
193
+ }))
194
+ const res = await extractHybrid('x', {}, 'plan.png')
195
+ expect(res.entities[0].position).toEqual({ x: 10, y: 10 }) // 7→10 grid snap
196
+ })
197
+
198
+ it('recognizer 가 bare 프리미티브에 semanticType 부여 (라벨 신호 전달)', async () => {
199
+ registerExtractorSet('raster-topdown', () => ({
200
+ geometry: mockGeom([rectPrim(0, 0, 50, 80)]),
201
+ labels: mockOcr([label('STK_001', 1, 1)]),
202
+ recognizer: new VlmRecognizer(mockClient(() => ({ componentType: 'stocker', confidence: 0.9 })))
203
+ }))
204
+ const res = await extractHybrid('x', {}, 'plan.png')
205
+ expect(res.entities[0].attribs.name).toBe('STK_001')
206
+ expect(res.entities[0].attribs.semanticType).toBe('stocker')
207
+ expect(res.entities[0].attribs.confidence).toBeCloseTo(0.9)
208
+ })
209
+
210
+ it('실 LabelScaleCalibrator 가 치수 라벨로 entities 스케일', async () => {
211
+ registerExtractorSet('raster-topdown', () => ({
212
+ geometry: mockGeom([rectPrim(0, 0, 200, 100)]),
213
+ labels: mockOcr([label('5000mm', 0, 0)]),
214
+ scale: new LabelScaleCalibrator()
215
+ }))
216
+ const res = await extractHybrid('x', {}, 'plan.png')
217
+ // 5000mm / extent 200px = 25 mm/px → 200px*25 = 5000
218
+ expect(res.entities[0].size!.width).toBeCloseTo(5000)
219
+ expect(res.entities[0].size!.height).toBeCloseTo(2500)
220
+ })
221
+ })
222
+
223
+ // ── VlmRecognizer (mock AIClient) ────────────────────────────────────────────────
224
+ describe('VlmRecognizer', () => {
225
+ it('응답을 componentType/confidence/attribs 로 매핑', async () => {
226
+ const r = new VlmRecognizer(mockClient(() => ({ componentType: 'stocker', confidence: 0.9, attribs: { capacity: 10 } })))
227
+ expect(await r.recognize(prim())).toEqual({ componentType: 'stocker', confidence: 0.9, attribs: { capacity: 10 } })
228
+ })
229
+
230
+ it('카탈로그 그라운딩 — 카탈로그 밖 타입 거부(undefined) + 신뢰도 상한 0.3', async () => {
231
+ const r = new VlmRecognizer(mockClient(() => ({ componentType: 'unicorn', confidence: 0.95 })))
232
+ const out = await r.recognize(prim(), { catalog: ['stocker', 'conveyor'] })
233
+ expect(out.componentType).toBeUndefined()
234
+ expect(out.confidence).toBeLessThanOrEqual(0.3)
235
+ })
236
+
237
+ it('카탈로그 안 타입은 유지', async () => {
238
+ const r = new VlmRecognizer(mockClient(() => ({ componentType: 'conveyor', confidence: 0.8 })))
239
+ const out = await r.recognize(prim(), { catalog: ['stocker', 'conveyor'] })
240
+ expect(out.componentType).toBe('conveyor')
241
+ expect(out.confidence).toBeCloseTo(0.8)
242
+ })
243
+
244
+ it('confidence 기본 0.5 + 범위 클램프(0..1)', async () => {
245
+ expect((await new VlmRecognizer(mockClient(() => ({ componentType: 'x' }))).recognize(prim())).confidence).toBe(0.5)
246
+ expect((await new VlmRecognizer(mockClient(() => ({ componentType: 'x', confidence: 9 }))).recognize(prim())).confidence).toBe(1)
247
+ expect((await new VlmRecognizer(mockClient(() => ({ componentType: 'x', confidence: -2 }))).recognize(prim())).confidence).toBe(0)
248
+ })
249
+
250
+ it('componentType null/빈문자 → undefined + 신뢰도 상한', async () => {
251
+ const out = await new VlmRecognizer(mockClient(() => ({ componentType: null, confidence: 0.9 }))).recognize(prim())
252
+ expect(out.componentType).toBeUndefined()
253
+ expect(out.confidence).toBeLessThanOrEqual(0.3)
254
+ })
255
+
256
+ it('AIClient 없으면 명확히 throw', async () => {
257
+ await expect(new VlmRecognizer().recognize(prim())).rejects.toThrow(/AIClient/)
258
+ })
259
+
260
+ it('프롬프트에 region 신호(라벨·크기) + 카탈로그 포함', async () => {
261
+ let captured = ''
262
+ const r = new VlmRecognizer(
263
+ mockClient(p => {
264
+ captured = p
265
+ return { componentType: 'stocker' }
266
+ })
267
+ )
268
+ const region: GeometricPrimitive = { kind: 'rect', bbox: { min: { x: 0, y: 0 }, max: { x: 50, y: 80 } }, meta: { label: 'STK_001' } }
269
+ await r.recognize(region, { catalog: ['stocker'] })
270
+ expect(captured).toContain('STK_001')
271
+ expect(captured).toContain('50x80')
272
+ expect(captured).toContain('stocker')
273
+ })
274
+
275
+ it('벡터 엔티티의 attribs.name 을 라벨 신호로 사용', async () => {
276
+ let captured = ''
277
+ const r = new VlmRecognizer(
278
+ mockClient(p => {
279
+ captured = p
280
+ return { componentType: 'dock' }
281
+ })
282
+ )
283
+ const e = ent('e', 0, 0)
284
+ e.attribs.name = 'DOCK_A'
285
+ await r.recognize({ kind: 'rect', bbox: e.bbox, entity: e })
286
+ expect(captured).toContain('DOCK_A')
287
+ })
288
+ })
289
+
290
+ // ── LabelScaleCalibrator ─────────────────────────────────────────────────────────
291
+ describe('LabelScaleCalibrator', () => {
292
+ const calib = new LabelScaleCalibrator()
293
+
294
+ it('명시 reference 최우선 → unitsPerPixel=units/pixels, 단위/origin 반영', async () => {
295
+ const r = await calib.calibrate([], [rectPrim(10, 20, 50, 50)], { scaleReference: { pixels: 100, units: 10, unit: 'm' } })
296
+ expect(r.unitsPerPixel).toBeCloseTo(0.1)
297
+ expect(r.units).toBe('m')
298
+ expect(r.origin).toEqual({ x: 10, y: 20 })
299
+ })
300
+
301
+ it('치수 라벨 휴리스틱 — maxDim(mm) ÷ 최대 픽셀범위', async () => {
302
+ // extent=200, 5000mm → 25 mm/px, units mm
303
+ const r = await calib.calibrate([label('5000mm', 0, 0)], [rectPrim(0, 0, 200, 100)])
304
+ expect(r.unitsPerPixel).toBeCloseTo(25)
305
+ expect(r.units).toBe('mm')
306
+ })
307
+
308
+ it('단위 추론 — dominant 단위로 환산', async () => {
309
+ // extent=100, dims {1m,2m,30cm} → dominant m, maxMm 2000 → 20mm/px → 0.02 m/px
310
+ const r = await calib.calibrate(
311
+ [label('1m', 0, 0), label('2m', 0, 0), label('30cm', 0, 0)],
312
+ [rectPrim(0, 0, 100, 100)]
313
+ )
314
+ expect(r.units).toBe('m')
315
+ expect(r.unitsPerPixel).toBeCloseTo(0.02)
316
+ })
317
+
318
+ it('치수/ref 없음 → 폴백(unitsPerPixel=1, units=unknown) + origin', async () => {
319
+ const r = await calib.calibrate([], [rectPrim(5, 5, 10, 10)])
320
+ expect(r).toEqual({ unitsPerPixel: 1, origin: { x: 5, y: 5 }, units: 'unknown' })
321
+ })
322
+
323
+ it('비치수 라벨(STK_001) 은 무시 → 폴백', async () => {
324
+ const r = await calib.calibrate([label('STK_001', 0, 0), label('AGV-3', 0, 0)], [rectPrim(0, 0, 50, 50)])
325
+ expect(r.unitsPerPixel).toBe(1)
326
+ expect(r.units).toBe('unknown')
327
+ })
328
+
329
+ it('프리미티브 없으면 origin {0,0}', async () => {
330
+ const r = await calib.calibrate([], [])
331
+ expect(r.origin).toEqual({ x: 0, y: 0 })
332
+ })
333
+ })
334
+
335
+ // ── extractHybrid 에러/스텁 경로 ──────────────────────────────────────────────────
336
+ describe('extractHybrid — 에러 경로', () => {
337
+ it('미등록 소스 → 명확한 throw', async () => {
338
+ await expect(extractHybrid('x', {}, 'plan.png')).rejects.toThrow(/미등록/)
339
+ })
340
+
341
+ it('미지원 확장자 → unknown → throw', async () => {
342
+ await expect(extractHybrid('x', {}, 'a.zip')).rejects.toThrow(/'unknown'/)
343
+ })
344
+
345
+ it('stub(CvGeometry) → 미구현 throw (실수로 live 붙으면 즉시 드러남)', async () => {
346
+ registerDefaultExtractors() // raster-topdown 은 CvGeometryExtractor(stub)
347
+ await expect(extractHybrid('x', {}, 'plan.png')).rejects.toThrow(/미구현/)
348
+ })
349
+ })
350
+
351
+ // ── extractHybrid 벡터(DXF) end-to-end ─────────────────────────────────────────
352
+ describe('extractHybrid — 벡터(DXF) 경로', () => {
353
+ const DXF = [
354
+ '0', 'SECTION', '2', 'ENTITIES',
355
+ '0', 'LINE', '8', 'WALL', '10', '0', '20', '0', '11', '100', '21', '0',
356
+ '0', 'LINE', '8', 'WALL', '10', '0', '20', '0', '11', '0', '21', '50',
357
+ '0', 'ENDSEC', '0', 'EOF'
358
+ ].join('\n')
359
+
360
+ beforeEach(() => registerDefaultExtractors())
361
+
362
+ it('sourceKind=vector', async () => {
363
+ expect((await extractHybrid(DXF, {}, 'test.dxf')).sourceKind).toBe('vector')
364
+ })
365
+
366
+ it('DXF LINE 2개 → 무손실(layer/kind 보존) + 단일 default 시안', async () => {
367
+ const res = await extractHybrid(DXF, {}, 'test.dxf')
368
+ expect(res.entities.length).toBe(2)
369
+ expect(res.entities.every(e => e.attribs.layer === 'WALL')).toBe(true)
370
+ expect(res.entities.every(e => e.kind === 'path')).toBe(true)
371
+ expect(res.variants.length).toBe(1)
372
+ expect(res.variants[0].id).toBe('default')
373
+ })
374
+ })
@@ -0,0 +1,51 @@
1
+ /*
2
+ * 하이브리드 추출 모듈 (Phase Board-AI §3.8) — barrel.
3
+ *
4
+ * 순수 *추가* 모듈. 기존 어댑터/파이프라인(ImageAdapter, stage1-4)은 무변경.
5
+ * 본격 테스트 단계에서 나머지 stub → 실 impl 로 교체하고 ImageAdapter 를 흡수.
6
+ *
7
+ * 현재 실구현: VectorGeometryExtractor(DXF), GridSnapLayoutRefiner, VlmRecognizer(AI client),
8
+ * LabelScaleCalibrator. 아직 stub: CvGeometryExtractor, CloudOcrLabelExtractor.
9
+ */
10
+ export * from './contracts'
11
+ export * from './source-router'
12
+ export * from './pipeline'
13
+ export { VectorGeometryExtractor } from './vector-geometry'
14
+ export { GridSnapLayoutRefiner, type LayoutRefineOptions } from './layout-refiner'
15
+ export { VlmRecognizer } from './vlm-recognizer'
16
+ export { LabelScaleCalibrator } from './label-scale-calibrator'
17
+ export { CvGeometryExtractor, CloudOcrLabelExtractor, IdentityLayoutRefiner } from './stubs'
18
+
19
+ import { registerExtractorSet } from './source-router'
20
+ import { VectorGeometryExtractor } from './vector-geometry'
21
+ import { GridSnapLayoutRefiner } from './layout-refiner'
22
+ import { VlmRecognizer } from './vlm-recognizer'
23
+ import { LabelScaleCalibrator } from './label-scale-calibrator'
24
+ import { CvGeometryExtractor, CloudOcrLabelExtractor } from './stubs'
25
+
26
+ /**
27
+ * 기본 추출기 묶음 등록. *명시 호출 시에만* 등록 (side-effect 없음).
28
+ * - vector : VectorGeometryExtractor(실) + GridSnapLayoutRefiner(실) → 지금 동작.
29
+ * - raster-topdown / photo : VlmRecognizer·LabelScaleCalibrator·GridSnapLayoutRefiner 는 실,
30
+ * CV(geometry)/OCR 은 아직 stub. CvGeometry stub 이 먼저 throw 하므로 raster 경로 전체는
31
+ * CV 구현 후 동작.
32
+ * 본격 테스트 단계에서 남은 stub(CV/OCR)을 실 impl 로 교체.
33
+ */
34
+ export function registerDefaultExtractors(): void {
35
+ registerExtractorSet('vector', () => ({
36
+ geometry: new VectorGeometryExtractor(),
37
+ refiner: new GridSnapLayoutRefiner()
38
+ }))
39
+ registerExtractorSet('raster-topdown', () => ({
40
+ geometry: new CvGeometryExtractor(),
41
+ labels: new CloudOcrLabelExtractor(),
42
+ recognizer: new VlmRecognizer(),
43
+ scale: new LabelScaleCalibrator(),
44
+ refiner: new GridSnapLayoutRefiner()
45
+ }))
46
+ registerExtractorSet('photo', () => ({
47
+ geometry: new CvGeometryExtractor(),
48
+ recognizer: new VlmRecognizer(),
49
+ refiner: new GridSnapLayoutRefiner()
50
+ }))
51
+ }
@@ -0,0 +1,124 @@
1
+ /*
2
+ * LabelScaleCalibrator — 픽셀 좌표를 실제 단위로 변환할 스케일을 도출 (Phase Board-AI §3.8).
3
+ *
4
+ * 우선순위:
5
+ * 1) 명시 reference (ctx.scaleReference: 알려진 픽셀 span = 실제 길이) — 가장 정확.
6
+ * 2) 치수 라벨 휴리스틱 — 가장 큰 치수값(mm 환산) ÷ 레이아웃 최대 픽셀 범위. ballpark.
7
+ * (정밀 px↔real 페어링엔 치수선 기하가 필요 — 추후. 여기선 근사.)
8
+ * 3) 폴백 — 항등 스케일(unitsPerPixel=1), 단위는 라벨에서 추론 or 'unknown'.
9
+ *
10
+ * origin 은 항상 프리미티브 묶음의 좌상단(min 코너) → 좌표를 0 기준 상대화.
11
+ * 순수·결정적 (외부 호출 없음).
12
+ */
13
+ import type { ScaleCalibrator, ScaleResult, GeometricPrimitive, Label, ExtractionContext } from './contracts'
14
+
15
+ type Unit = 'mm' | 'cm' | 'm' | 'inch'
16
+ const UNIT_TO_MM: Record<Unit, number> = { mm: 1, cm: 10, m: 1000, inch: 25.4 }
17
+
18
+ interface Dim {
19
+ value: number
20
+ unit: Unit
21
+ }
22
+
23
+ export class LabelScaleCalibrator implements ScaleCalibrator {
24
+ readonly id = 'label-scale'
25
+
26
+ async calibrate(
27
+ labels: Label[],
28
+ primitives: GeometricPrimitive[],
29
+ ctx?: ExtractionContext
30
+ ): Promise<ScaleResult> {
31
+ const origin = layoutOrigin(primitives)
32
+ const dims = parseDimensions(labels)
33
+ const labelUnit = dominantUnit(dims)
34
+
35
+ // 1) 명시 reference 최우선.
36
+ const ref = ctx?.scaleReference
37
+ if (ref && ref.pixels > 0 && Number.isFinite(ref.units)) {
38
+ return {
39
+ unitsPerPixel: ref.units / ref.pixels,
40
+ origin,
41
+ units: normUnit(ref.unit) ?? labelUnit ?? 'unknown'
42
+ }
43
+ }
44
+
45
+ // 2) 치수 라벨 휴리스틱.
46
+ const extent = layoutExtentPx(primitives)
47
+ if (dims.length > 0 && extent > 0 && labelUnit) {
48
+ const maxMm = Math.max(...dims.map(d => d.value * UNIT_TO_MM[d.unit]))
49
+ const mmPerPixel = maxMm / extent
50
+ return { unitsPerPixel: mmPerPixel / UNIT_TO_MM[labelUnit], origin, units: labelUnit }
51
+ }
52
+
53
+ // 3) 폴백.
54
+ return { unitsPerPixel: 1, origin, units: labelUnit ?? 'unknown' }
55
+ }
56
+ }
57
+
58
+ // ── helpers ──────────────────────────────────────────────────────────────────
59
+ function layoutOrigin(primitives: GeometricPrimitive[]): { x: number; y: number } {
60
+ if (!primitives.length) return { x: 0, y: 0 }
61
+ let minX = Infinity
62
+ let minY = Infinity
63
+ for (const p of primitives) {
64
+ if (p.bbox.min.x < minX) minX = p.bbox.min.x
65
+ if (p.bbox.min.y < minY) minY = p.bbox.min.y
66
+ }
67
+ return { x: minX, y: minY }
68
+ }
69
+
70
+ function layoutExtentPx(primitives: GeometricPrimitive[]): number {
71
+ if (!primitives.length) return 0
72
+ let minX = Infinity
73
+ let minY = Infinity
74
+ let maxX = -Infinity
75
+ let maxY = -Infinity
76
+ for (const p of primitives) {
77
+ minX = Math.min(minX, p.bbox.min.x)
78
+ minY = Math.min(minY, p.bbox.min.y)
79
+ maxX = Math.max(maxX, p.bbox.max.x)
80
+ maxY = Math.max(maxY, p.bbox.max.y)
81
+ }
82
+ return Math.max(maxX - minX, maxY - minY)
83
+ }
84
+
85
+ /** "5000mm" / "12.5 m" / '300cm' 같은 치수 라벨 파싱 (단위 필수 — 무단위 숫자는 무시). */
86
+ function parseDimensions(labels: Label[]): Dim[] {
87
+ const out: Dim[] = []
88
+ for (const l of labels) {
89
+ const m = /^\s*(\d+(?:[.,]\d+)?)\s*(mm|cm|m|in|inch|")\s*$/i.exec(l.text ?? '')
90
+ if (!m) continue
91
+ const value = parseFloat(m[1].replace(',', '.'))
92
+ if (!Number.isFinite(value) || value <= 0) continue
93
+ const u = normUnit(m[2])
94
+ if (u) out.push({ value, unit: u })
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** 가장 빈번한 단위 (동률이면 먼저 본 것). */
100
+ function dominantUnit(dims: Dim[]): Unit | undefined {
101
+ if (!dims.length) return undefined
102
+ const count = new Map<Unit, number>()
103
+ for (const d of dims) count.set(d.unit, (count.get(d.unit) ?? 0) + 1)
104
+ let best: Unit | undefined
105
+ let bestN = 0
106
+ for (const d of dims) {
107
+ const n = count.get(d.unit)!
108
+ if (n > bestN) {
109
+ bestN = n
110
+ best = d.unit
111
+ }
112
+ }
113
+ return best
114
+ }
115
+
116
+ function normUnit(u?: string): Unit | undefined {
117
+ if (!u) return undefined
118
+ const s = u.toLowerCase()
119
+ if (s === 'mm') return 'mm'
120
+ if (s === 'cm') return 'cm'
121
+ if (s === 'm') return 'm'
122
+ if (s === 'in' || s === 'inch' || s === '"') return 'inch'
123
+ return undefined
124
+ }
@@ -0,0 +1,104 @@
1
+ /*
2
+ * GridSnapLayoutRefiner — 추출된 엔티티 레이아웃을 *정리*. Phase Board-AI §3.8 의 LayoutRefiner.
3
+ * 모든 경로(벡터·CV·VLM) 공용 품질 레버.
4
+ *
5
+ * - align : 근접한 left/top 좌표를 단일연결 클러스터로 묶어 평균으로 스냅 → 행/열 정렬.
6
+ * - grid : (옵션) 위치를 grid step 으로 스냅.
7
+ *
8
+ * 순수·결정적 (같은 입력 → 같은 출력). 크기는 보존, 위치 이동분만큼 bbox 평행이동.
9
+ */
10
+ import type { UniversalEntity } from '../types/index'
11
+ import type { LayoutRefiner, ExtractionContext } from './contracts'
12
+
13
+ export interface LayoutRefineOptions {
14
+ /** 근접 좌표 정렬 (default true). */
15
+ align?: boolean
16
+ /** 정렬 클러스터 허용오차. 미지정 시 엔티티 크기 중앙값으로 자동. */
17
+ alignTolerance?: number
18
+ /** >0 이면 위치를 이 step 으로 스냅. */
19
+ grid?: number
20
+ }
21
+
22
+ export class GridSnapLayoutRefiner implements LayoutRefiner {
23
+ readonly id = 'grid-snap'
24
+
25
+ constructor(private readonly opts: LayoutRefineOptions = {}) {}
26
+
27
+ refine(entities: UniversalEntity[], _ctx?: ExtractionContext): UniversalEntity[] {
28
+ if (entities.length === 0) return entities
29
+ const out = entities.map(cloneEntity)
30
+
31
+ if (this.opts.align !== false) {
32
+ const tol = this.opts.alignTolerance ?? autoTolerance(out)
33
+ const xs = clusterSnap(out.map(e => e.position.x), tol)
34
+ const ys = clusterSnap(out.map(e => e.position.y), tol)
35
+ out.forEach((e, i) => moveTo(e, xs[i], ys[i]))
36
+ }
37
+
38
+ const g = this.opts.grid
39
+ if (g && g > 0) {
40
+ out.forEach(e => moveTo(e, Math.round(e.position.x / g) * g, Math.round(e.position.y / g) * g))
41
+ }
42
+ return out
43
+ }
44
+ }
45
+
46
+ /** 위치를 (nx,ny)로 옮기고 bbox 도 같은 delta 로 평행이동 (size 보존). */
47
+ function moveTo(e: UniversalEntity, nx: number, ny: number): void {
48
+ const dx = nx - e.position.x
49
+ const dy = ny - e.position.y
50
+ if (dx === 0 && dy === 0) return
51
+ e.position = { x: nx, y: ny }
52
+ e.bbox = {
53
+ min: { x: e.bbox.min.x + dx, y: e.bbox.min.y + dy },
54
+ max: { x: e.bbox.max.x + dx, y: e.bbox.max.y + dy }
55
+ }
56
+ }
57
+
58
+ /** 단일연결 클러스터링 — 정렬 후 인접 간격 ≤ tol 이면 한 클러스터, 클러스터 평균으로 스냅. */
59
+ function clusterSnap(vals: number[], tol: number): number[] {
60
+ const order = vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v)
61
+ const snapped = new Array<number>(vals.length)
62
+ const flush = (s: number, e: number) => {
63
+ let sum = 0
64
+ for (let k = s; k < e; k++) sum += order[k].v
65
+ const mean = sum / (e - s)
66
+ for (let k = s; k < e; k++) snapped[order[k].i] = mean
67
+ }
68
+ let start = 0
69
+ for (let k = 1; k < order.length; k++) {
70
+ if (order[k].v - order[k - 1].v > tol) {
71
+ flush(start, k)
72
+ start = k
73
+ }
74
+ }
75
+ flush(start, order.length)
76
+ return snapped
77
+ }
78
+
79
+ /** 자동 허용오차 — 엔티티 크기 중앙값의 40%. 크기 없으면 좌표 spread 의 2%. */
80
+ function autoTolerance(entities: UniversalEntity[]): number {
81
+ const dims: number[] = []
82
+ for (const e of entities) if (e.size) dims.push(e.size.width, e.size.height)
83
+ if (dims.length) return 0.4 * median(dims)
84
+ const xs = entities.map(e => e.position.x)
85
+ const ys = entities.map(e => e.position.y)
86
+ const spread = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys))
87
+ return 0.02 * (spread || 1)
88
+ }
89
+
90
+ function median(arr: number[]): number {
91
+ const s = [...arr].sort((a, b) => a - b)
92
+ const m = Math.floor(s.length / 2)
93
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
94
+ }
95
+
96
+ function cloneEntity(e: UniversalEntity): UniversalEntity {
97
+ return {
98
+ ...e,
99
+ position: { ...e.position },
100
+ size: e.size ? { ...e.size } : undefined,
101
+ bbox: { min: { ...e.bbox.min }, max: { ...e.bbox.max } },
102
+ attribs: { ...e.attribs }
103
+ }
104
+ }