@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.
- package/config/config.development.js +44 -0
- package/config/config.production.js +18 -0
- package/dist-server/index.d.ts +7 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
- package/dist-server/service/adapters/dxf-adapter.js +220 -0
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +185 -0
- package/dist-server/service/ai/types.js +378 -0
- package/dist-server/service/ai/types.js.map +1 -0
- package/dist-server/service/converters/generic-to.d.ts +31 -0
- package/dist-server/service/converters/generic-to.js +84 -0
- package/dist-server/service/converters/generic-to.js.map +1 -0
- package/dist-server/service/extraction/contracts.d.ts +112 -0
- package/dist-server/service/extraction/contracts.js +3 -0
- package/dist-server/service/extraction/contracts.js.map +1 -0
- package/dist-server/service/extraction/index.d.ts +17 -0
- package/dist-server/service/extraction/index.js +62 -0
- package/dist-server/service/extraction/index.js.map +1 -0
- package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
- package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
- package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
- package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
- package/dist-server/service/extraction/layout-refiner.js +88 -0
- package/dist-server/service/extraction/layout-refiner.js.map +1 -0
- package/dist-server/service/extraction/pipeline.d.ts +11 -0
- package/dist-server/service/extraction/pipeline.js +124 -0
- package/dist-server/service/extraction/pipeline.js.map +1 -0
- package/dist-server/service/extraction/source-router.d.ts +17 -0
- package/dist-server/service/extraction/source-router.js +40 -0
- package/dist-server/service/extraction/source-router.js.map +1 -0
- package/dist-server/service/extraction/stubs.d.ts +18 -0
- package/dist-server/service/extraction/stubs.js +38 -0
- package/dist-server/service/extraction/stubs.js.map +1 -0
- package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
- package/dist-server/service/extraction/vector-geometry.js +21 -0
- package/dist-server/service/extraction/vector-geometry.js.map +1 -0
- package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
- package/dist-server/service/extraction/vlm-recognizer.js +78 -0
- package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
- package/dist-server/service/import-session/import-session-resolver.js +263 -0
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
- package/dist-server/service/import-session/import-session.d.ts +28 -0
- package/dist-server/service/import-session/import-session.js +152 -0
- package/dist-server/service/import-session/import-session.js.map +1 -0
- package/dist-server/service/import-session/import-worker.d.ts +1 -0
- package/dist-server/service/import-session/import-worker.js +121 -0
- package/dist-server/service/import-session/import-worker.js.map +1 -0
- package/dist-server/service/import-session/index.d.ts +8 -0
- package/dist-server/service/import-session/index.js +19 -0
- package/dist-server/service/import-session/index.js.map +1 -0
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +26 -0
- package/dist-server/service/index.js +60 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/pipeline/index.d.ts +66 -0
- package/dist-server/service/pipeline/index.js +127 -0
- package/dist-server/service/pipeline/index.js.map +1 -0
- package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
- package/dist-server/service/pipeline/stage2-mapping.js +231 -0
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
- package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
- package/dist-server/service/pipeline/stage3-board.js +108 -0
- package/dist-server/service/pipeline/stage3-board.js.map +1 -0
- package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
- package/dist-server/service/pipeline/stage4-binding.js +28 -0
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
- package/dist-server/service/registry/index.d.ts +27 -0
- package/dist-server/service/registry/index.js +108 -0
- package/dist-server/service/registry/index.js.map +1 -0
- package/dist-server/service/types/index.d.ts +152 -0
- package/dist-server/service/types/index.js +3 -0
- package/dist-server/service/types/index.js.map +1 -0
- package/dist-server/service/types/registry.d.ts +175 -0
- package/dist-server/service/types/registry.js +3 -0
- package/dist-server/service/types/registry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +35 -0
- package/probe/gcp-vision-probe.mjs +114 -0
- package/probe/opencv-geometry-probe.py +98 -0
- package/server/index.ts +7 -0
- package/server/service/adapters/dxf-adapter.ts +230 -0
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +619 -0
- package/server/service/converters/generic-to.test.ts +105 -0
- package/server/service/converters/generic-to.ts +121 -0
- package/server/service/extraction/contracts.ts +142 -0
- package/server/service/extraction/extraction.test.ts +374 -0
- package/server/service/extraction/index.ts +51 -0
- package/server/service/extraction/label-scale-calibrator.ts +124 -0
- package/server/service/extraction/layout-refiner.ts +104 -0
- package/server/service/extraction/pipeline.ts +163 -0
- package/server/service/extraction/source-router.ts +45 -0
- package/server/service/extraction/stubs.ts +46 -0
- package/server/service/extraction/vector-geometry.ts +26 -0
- package/server/service/extraction/vlm-recognizer.ts +87 -0
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +245 -0
- package/server/service/import-session/import-session.ts +141 -0
- package/server/service/import-session/import-worker.ts +132 -0
- package/server/service/import-session/index.ts +24 -0
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +117 -0
- package/server/service/pipeline/index.ts +184 -0
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +270 -0
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +179 -0
- package/server/service/pipeline/stage4-binding.ts +31 -0
- package/server/service/registry/index.ts +127 -0
- package/server/service/types/index.ts +169 -0
- package/server/service/types/registry.ts +186 -0
- package/things-factory.config.js +1 -0
- 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
|
+
})
|