@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,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Format Adapter — 이미지 (PNG/JPG/WebP/GIF) → UniversalEntity 목록.
|
|
3
|
+
*
|
|
4
|
+
* VLM (Vision LLM) 의존. board-import 의 BoardImportAI.parseFromImage 가 핵심 작업을
|
|
5
|
+
* 담당하므로 이 어댑터는 그 위에 얇은 wrapper. AI client 가 등록되지 않았거나 vision 미지원
|
|
6
|
+
* provider 면 parse 시점에 명확한 에러로 실패한다 (detect 는 통과 — 외부 입력 식별만).
|
|
7
|
+
*
|
|
8
|
+
* zero-to-twin 메인 트랙 (조감도/사진 업로드 → 보드) 의 첫 어댑터.
|
|
9
|
+
*/
|
|
10
|
+
import { getAIClient } from '../ai/types'
|
|
11
|
+
import type {
|
|
12
|
+
AdapterCapabilities,
|
|
13
|
+
AdapterInput,
|
|
14
|
+
BoundingBox,
|
|
15
|
+
FormatAdapter,
|
|
16
|
+
ParseOptions,
|
|
17
|
+
ParseResult,
|
|
18
|
+
UniversalEntity
|
|
19
|
+
} from '../types/index'
|
|
20
|
+
|
|
21
|
+
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']
|
|
22
|
+
|
|
23
|
+
export interface ImageParseOptions extends ParseOptions {
|
|
24
|
+
/** 자연어 컨텍스트 — VLM 에 함께 전달. 예: "factory floor plan with AGV paths and stockers" */
|
|
25
|
+
context?: string
|
|
26
|
+
/** 후보 카테고리 — VLM 이 그 안에서 라벨링하도록 가이드 */
|
|
27
|
+
categories?: string[]
|
|
28
|
+
/**
|
|
29
|
+
* 이미지 dimension — VLM 프롬프트가 픽셀 좌표계를 명시하기 위해 사용. ImageAdapter 가
|
|
30
|
+
* 헤더에서 읽어 자동 주입한다. 호출자가 직접 부를 때도 명시 가능.
|
|
31
|
+
*/
|
|
32
|
+
imageWidth?: number
|
|
33
|
+
imageHeight?: number
|
|
34
|
+
/**
|
|
35
|
+
* 사용자 자유서술 hint — 도면 설명, 강조 부분, 카테고리 가이드 등.
|
|
36
|
+
* VLM 의 view type 분류 + entity 추출 양쪽에 추가 컨텍스트로 합류.
|
|
37
|
+
*/
|
|
38
|
+
userPrompt?: string
|
|
39
|
+
/**
|
|
40
|
+
* View type 명시 (강제). 미지정 시 ImageAdapter 가 1-pass classify 로 자동 결정.
|
|
41
|
+
* - 'top-down' : orthographic 도면 / 평면도 (픽셀 좌표 그대로)
|
|
42
|
+
* - 'perspective-3d' : 3D 렌더 / 조감도 / 등각투상 (floor plan 추론)
|
|
43
|
+
* - 'photo' : 실사진 (floor plan 추론)
|
|
44
|
+
*/
|
|
45
|
+
viewType?: 'top-down' | 'perspective-3d' | 'photo'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ImageAdapter implements FormatAdapter {
|
|
49
|
+
readonly format = 'image'
|
|
50
|
+
readonly supportedExtensions = IMAGE_EXTENSIONS
|
|
51
|
+
readonly capabilities: AdapterCapabilities = {
|
|
52
|
+
has2D: true,
|
|
53
|
+
has3D: false,
|
|
54
|
+
hasSemanticLabels: true, // VLM 이 의미 라벨링
|
|
55
|
+
hasTextLabels: true, // VLM 이 텍스트 인식
|
|
56
|
+
hasAttributes: false // CAD 의 명시적 속성 없음
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async detect(file: AdapterInput, filename?: string): Promise<boolean> {
|
|
60
|
+
if (filename && IMAGE_EXTENSIONS.some(ext => filename.toLowerCase().endsWith(ext))) return true
|
|
61
|
+
return detectImageMagic(file)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async parse(file: AdapterInput, options: ImageParseOptions = {}): Promise<ParseResult> {
|
|
65
|
+
const ai = getAIClient()
|
|
66
|
+
if (!ai) {
|
|
67
|
+
throw new Error('Image import requires a registered BoardImportAI (call setAIClient first).')
|
|
68
|
+
}
|
|
69
|
+
if (!ai.parseFromImage) {
|
|
70
|
+
throw new Error('Registered BoardImportAI does not implement parseFromImage (no VLM support).')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buffer = toBuffer(file)
|
|
74
|
+
if (!buffer) {
|
|
75
|
+
throw new Error('Image input must be a Buffer / ArrayBuffer / Uint8Array.')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 이미지 dimension 을 미리 알아내야 VLM 에 좌표계를 명시적으로 지시할 수 있다.
|
|
79
|
+
// 미지정 시 LLM 이 0..1 정규화 좌표로 응답해 stage3 가 1px 도형을 그리는 회귀가 발생.
|
|
80
|
+
const dims = readImageDimensions(buffer)
|
|
81
|
+
|
|
82
|
+
const raw = await ai.parseFromImage(buffer, {
|
|
83
|
+
context: options.context,
|
|
84
|
+
categories: options.categories as any,
|
|
85
|
+
imageWidth: dims?.width,
|
|
86
|
+
imageHeight: dims?.height,
|
|
87
|
+
userPrompt: options.userPrompt,
|
|
88
|
+
viewType: options.viewType
|
|
89
|
+
} as any)
|
|
90
|
+
|
|
91
|
+
// 응답 흡수 — 3 가지 형태 다 수용:
|
|
92
|
+
// 1) { variants: [...] } — 권장 (multi-variant)
|
|
93
|
+
// 2) { entities: [...], ... } — 구버전 single-variant
|
|
94
|
+
// 3) [ entity, ... ] — 가장 구버전 (entities only)
|
|
95
|
+
const viewType: 'top-down' | 'perspective-3d' | 'photo' = options.viewType
|
|
96
|
+
? options.viewType
|
|
97
|
+
: raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
98
|
+
? ((raw as any).viewType ?? 'top-down')
|
|
99
|
+
: 'top-down'
|
|
100
|
+
const confidence: number =
|
|
101
|
+
raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
102
|
+
? Number((raw as any).confidence ?? 0.5)
|
|
103
|
+
: 0.5
|
|
104
|
+
const reasoning: string | undefined =
|
|
105
|
+
raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
106
|
+
? (raw as any).reasoning
|
|
107
|
+
: undefined
|
|
108
|
+
|
|
109
|
+
const rawVariants: any[] = (() => {
|
|
110
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
111
|
+
if (Array.isArray((raw as any).variants) && (raw as any).variants.length > 0) {
|
|
112
|
+
return (raw as any).variants
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray((raw as any).entities)) {
|
|
115
|
+
// 구버전 single-variant 응답 — 단일 시안으로 감쌈
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
id: 'default',
|
|
119
|
+
label: 'default',
|
|
120
|
+
entities: (raw as any).entities,
|
|
121
|
+
subjectName: (raw as any).subjectName,
|
|
122
|
+
subjectDescription: (raw as any).subjectDescription,
|
|
123
|
+
importStrategy: (raw as any).importStrategy
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(raw)) {
|
|
129
|
+
return [{ id: 'default', label: 'default', entities: raw as any[] }]
|
|
130
|
+
}
|
|
131
|
+
return []
|
|
132
|
+
})()
|
|
133
|
+
|
|
134
|
+
// 시안별 entity 정규화 + 좌표 보정 + grid fallback — 시안 무관 동일 적용.
|
|
135
|
+
const usedGridLayout = viewType === 'perspective-3d' || viewType === 'photo'
|
|
136
|
+
const denormDim = viewType === 'top-down' ? dims : { width: 1000, height: 1000 }
|
|
137
|
+
|
|
138
|
+
const normalizedVariants = rawVariants.map((v: any) => {
|
|
139
|
+
let entities = (Array.isArray(v.entities) ? v.entities : []).map((e: any, i: number) =>
|
|
140
|
+
normalizeEntity(e, i)
|
|
141
|
+
)
|
|
142
|
+
entities = denormalizeIfFractional(entities, denormDim)
|
|
143
|
+
if (usedGridLayout) entities = layoutInGrid(entities)
|
|
144
|
+
const variantBbox = computeOuterBbox(entities)
|
|
145
|
+
return {
|
|
146
|
+
id: typeof v.id === 'string' ? v.id : 'variant',
|
|
147
|
+
label: typeof v.label === 'string' ? v.label : 'variant',
|
|
148
|
+
description: typeof v.description === 'string' ? v.description : undefined,
|
|
149
|
+
entities,
|
|
150
|
+
bbox: variantBbox,
|
|
151
|
+
subjectName: typeof v.subjectName === 'string' ? v.subjectName : undefined,
|
|
152
|
+
subjectDescription:
|
|
153
|
+
typeof v.subjectDescription === 'string' ? v.subjectDescription : undefined,
|
|
154
|
+
importStrategy: typeof v.importStrategy === 'string' ? v.importStrategy : undefined
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// default = 첫 variant. 빈 배열일 때 fallback.
|
|
159
|
+
const defaultVariant = normalizedVariants[0]
|
|
160
|
+
const entities = defaultVariant?.entities ?? []
|
|
161
|
+
const subjectName = defaultVariant?.subjectName
|
|
162
|
+
const subjectDescription = defaultVariant?.subjectDescription
|
|
163
|
+
const importStrategy = defaultVariant?.importStrategy
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
entities,
|
|
167
|
+
variants: normalizedVariants,
|
|
168
|
+
metadata: {
|
|
169
|
+
format: 'image',
|
|
170
|
+
source: 'vlm',
|
|
171
|
+
aiClientId: ai.id,
|
|
172
|
+
imageWidth: dims?.width,
|
|
173
|
+
imageHeight: dims?.height,
|
|
174
|
+
viewType,
|
|
175
|
+
...(usedGridLayout ? { layoutMethod: 'grid' as const } : {}),
|
|
176
|
+
viewTypeConfidence: confidence,
|
|
177
|
+
...(reasoning ? { viewTypeReasoning: reasoning } : {}),
|
|
178
|
+
...(importStrategy ? { importStrategy } : {}),
|
|
179
|
+
...(subjectName ? { subjectName } : {}),
|
|
180
|
+
...(subjectDescription ? { subjectDescription } : {}),
|
|
181
|
+
...(options.userPrompt ? { userPrompt: options.userPrompt } : {})
|
|
182
|
+
},
|
|
183
|
+
bbox: computeOuterBbox(entities),
|
|
184
|
+
units: 'unknown'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* PNG / JPEG / WebP / GIF / BMP 헤더에서 width/height 추출. dep 없이 inline 처리.
|
|
191
|
+
* 실패 시 undefined — 호출자가 프롬프트에 dimension 을 포함하지 않게 한다.
|
|
192
|
+
*/
|
|
193
|
+
function readImageDimensions(buf: Buffer): { width: number; height: number } | undefined {
|
|
194
|
+
if (!buf || buf.length < 26) return undefined
|
|
195
|
+
// PNG: 8-byte signature + IHDR (length:4, type:4, width:4, height:4)
|
|
196
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
197
|
+
const width = buf.readUInt32BE(16)
|
|
198
|
+
const height = buf.readUInt32BE(20)
|
|
199
|
+
return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : undefined
|
|
200
|
+
}
|
|
201
|
+
// JPEG: scan for SOFn (Start Of Frame) markers — 0xFFC0..0xFFCF except DHT/JPG/DAC
|
|
202
|
+
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
|
203
|
+
let i = 2
|
|
204
|
+
while (i + 9 < buf.length) {
|
|
205
|
+
if (buf[i] !== 0xff) {
|
|
206
|
+
i++
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
// skip filler 0xFF
|
|
210
|
+
while (i < buf.length && buf[i] === 0xff) i++
|
|
211
|
+
const marker = buf[i]
|
|
212
|
+
i++
|
|
213
|
+
if (marker === 0xd8 || marker === 0xd9) continue // SOI/EOI
|
|
214
|
+
if (i + 1 >= buf.length) return undefined
|
|
215
|
+
const segLen = buf.readUInt16BE(i)
|
|
216
|
+
// SOFn (baseline=C0, progressive=C2, etc) — exclude DHT(C4), JPG(C8), DAC(CC)
|
|
217
|
+
if (
|
|
218
|
+
marker >= 0xc0 &&
|
|
219
|
+
marker <= 0xcf &&
|
|
220
|
+
marker !== 0xc4 &&
|
|
221
|
+
marker !== 0xc8 &&
|
|
222
|
+
marker !== 0xcc
|
|
223
|
+
) {
|
|
224
|
+
if (i + 7 >= buf.length) return undefined
|
|
225
|
+
const height = buf.readUInt16BE(i + 3)
|
|
226
|
+
const width = buf.readUInt16BE(i + 5)
|
|
227
|
+
return { width, height }
|
|
228
|
+
}
|
|
229
|
+
i += segLen
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
// GIF: bytes 6-9 (LE width/height)
|
|
234
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) {
|
|
235
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }
|
|
236
|
+
}
|
|
237
|
+
// BMP: width@18 (LE), height@22 (LE)
|
|
238
|
+
if (buf[0] === 0x42 && buf[1] === 0x4d) {
|
|
239
|
+
return { width: buf.readUInt32LE(18), height: Math.abs(buf.readInt32LE(22)) }
|
|
240
|
+
}
|
|
241
|
+
// WebP (VP8/VP8L/VP8X) — 30+ bytes for dimension info
|
|
242
|
+
if (
|
|
243
|
+
buf[0] === 0x52 &&
|
|
244
|
+
buf[1] === 0x49 &&
|
|
245
|
+
buf[2] === 0x46 &&
|
|
246
|
+
buf[3] === 0x46 &&
|
|
247
|
+
buf[8] === 0x57 &&
|
|
248
|
+
buf[9] === 0x45 &&
|
|
249
|
+
buf[10] === 0x42 &&
|
|
250
|
+
buf[11] === 0x50 &&
|
|
251
|
+
buf.length >= 30
|
|
252
|
+
) {
|
|
253
|
+
// VP8 lossy: chunk header at offset 12, dims at offset 26 (14-bit each)
|
|
254
|
+
if (buf[12] === 0x56 && buf[13] === 0x50 && buf[14] === 0x38 && buf[15] === 0x20) {
|
|
255
|
+
const width = buf.readUInt16LE(26) & 0x3fff
|
|
256
|
+
const height = buf.readUInt16LE(28) & 0x3fff
|
|
257
|
+
return { width, height }
|
|
258
|
+
}
|
|
259
|
+
// VP8L lossless: dims at offset 21 (14-bit packed)
|
|
260
|
+
if (buf[12] === 0x56 && buf[13] === 0x50 && buf[14] === 0x38 && buf[15] === 0x4c) {
|
|
261
|
+
const b0 = buf[21]
|
|
262
|
+
const b1 = buf[22]
|
|
263
|
+
const b2 = buf[23]
|
|
264
|
+
const b3 = buf[24]
|
|
265
|
+
const width = 1 + (((b1 & 0x3f) << 8) | b0)
|
|
266
|
+
const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
|
|
267
|
+
return { width, height }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Perspective view 의 entity 들을 격자로 균등 배치한다.
|
|
275
|
+
*
|
|
276
|
+
* VLM 은 perspective 좌표 → floor plan 좌표 변환이 불가능에 가깝다 (학습 데이터 부재 +
|
|
277
|
+
* 명시적 3D 세계 모델 없음). screen 좌표를 그대로 깔면 "perspective 가 평평해진" 결과.
|
|
278
|
+
* 차라리 좌표를 무시하고 격자로 깔고 사용자가 직접 옮기는 게 정직하다.
|
|
279
|
+
*
|
|
280
|
+
* 정책:
|
|
281
|
+
* - 각 entity 의 size 는 보존 (VLM 이 추정한 상대 크기는 비교적 신뢰 가능)
|
|
282
|
+
* - 격자 col 수 = ceil(sqrt(N)), row 수 = ceil(N/cols)
|
|
283
|
+
* - tile 간격 = max(entity 평균 width/height) * 1.4
|
|
284
|
+
* - 보드 우상단부터 row-major 로 배치
|
|
285
|
+
*/
|
|
286
|
+
function layoutInGrid(entities: UniversalEntity[]): UniversalEntity[] {
|
|
287
|
+
if (entities.length === 0) return entities
|
|
288
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(entities.length)))
|
|
289
|
+
|
|
290
|
+
// tile 크기 결정 — entity 평균 size 의 1.4 배. size 미지정이면 80 plan-unit.
|
|
291
|
+
const sizes = entities.filter(e => e.size).map(e => e.size!)
|
|
292
|
+
const avgW = sizes.length ? sizes.reduce((s, v) => s + v.width, 0) / sizes.length : 80
|
|
293
|
+
const avgH = sizes.length ? sizes.reduce((s, v) => s + v.height, 0) / sizes.length : 80
|
|
294
|
+
const tileW = Math.max(80, avgW) * 1.4
|
|
295
|
+
const tileH = Math.max(80, avgH) * 1.4
|
|
296
|
+
|
|
297
|
+
return entities.map((e, i) => {
|
|
298
|
+
const col = i % cols
|
|
299
|
+
const row = Math.floor(i / cols)
|
|
300
|
+
const x = col * tileW
|
|
301
|
+
const y = row * tileH
|
|
302
|
+
const w = e.size?.width ?? 80
|
|
303
|
+
const h = e.size?.height ?? 80
|
|
304
|
+
return {
|
|
305
|
+
...e,
|
|
306
|
+
position: { x, y },
|
|
307
|
+
size: { width: w, height: h },
|
|
308
|
+
bbox: { min: { x, y }, max: { x: x + w, y: y + h } }
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 모든 entity 의 좌표/크기가 [0..2] 범위에 들어가면 normalized 로 간주 (LLM 의 흔한 응답 패턴),
|
|
315
|
+
* image dimension 으로 곱해 픽셀 좌표로 보정. dim 이 없으면 그대로 반환.
|
|
316
|
+
*/
|
|
317
|
+
function denormalizeIfFractional(
|
|
318
|
+
entities: UniversalEntity[],
|
|
319
|
+
dims: { width: number; height: number } | undefined
|
|
320
|
+
): UniversalEntity[] {
|
|
321
|
+
if (!dims || entities.length === 0) return entities
|
|
322
|
+
const isAllFractional = entities.every(e => {
|
|
323
|
+
const xs = [e.position.x, e.bbox.min.x, e.bbox.max.x, (e.size?.width ?? 0)]
|
|
324
|
+
const ys = [e.position.y, e.bbox.min.y, e.bbox.max.y, (e.size?.height ?? 0)]
|
|
325
|
+
return [...xs, ...ys].every(v => Number.isFinite(v) && v >= 0 && v <= 2)
|
|
326
|
+
})
|
|
327
|
+
if (!isAllFractional) return entities
|
|
328
|
+
const sx = dims.width
|
|
329
|
+
const sy = dims.height
|
|
330
|
+
return entities.map(e => ({
|
|
331
|
+
...e,
|
|
332
|
+
position: { x: e.position.x * sx, y: e.position.y * sy },
|
|
333
|
+
size: e.size ? { width: e.size.width * sx, height: e.size.height * sy } : undefined,
|
|
334
|
+
bbox: {
|
|
335
|
+
min: { x: e.bbox.min.x * sx, y: e.bbox.min.y * sy },
|
|
336
|
+
max: { x: e.bbox.max.x * sx, y: e.bbox.max.y * sy }
|
|
337
|
+
}
|
|
338
|
+
}))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── 헬퍼 ─────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 매직 바이트로 이미지 형식 감지. filename 이 없거나 확장자가 모호한 경우 fallback.
|
|
345
|
+
* 외부 reference 가 placeholder 로 응답되는 시나리오 (예: GLB top-view fetch 실패) 도
|
|
346
|
+
* 같은 binary 검사로 식별 가능.
|
|
347
|
+
*/
|
|
348
|
+
function detectImageMagic(file: AdapterInput): boolean {
|
|
349
|
+
const buf = toBuffer(file)
|
|
350
|
+
if (!buf || buf.length < 8) return false
|
|
351
|
+
|
|
352
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
353
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return true
|
|
354
|
+
// JPEG: FF D8 FF
|
|
355
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return true
|
|
356
|
+
// GIF: 47 49 46 38
|
|
357
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return true
|
|
358
|
+
// BMP: 42 4D
|
|
359
|
+
if (buf[0] === 0x42 && buf[1] === 0x4d) return true
|
|
360
|
+
// WebP: RIFF....WEBP
|
|
361
|
+
if (
|
|
362
|
+
buf[0] === 0x52 &&
|
|
363
|
+
buf[1] === 0x49 &&
|
|
364
|
+
buf[2] === 0x46 &&
|
|
365
|
+
buf[3] === 0x46 &&
|
|
366
|
+
buf.length >= 12 &&
|
|
367
|
+
buf[8] === 0x57 &&
|
|
368
|
+
buf[9] === 0x45 &&
|
|
369
|
+
buf[10] === 0x42 &&
|
|
370
|
+
buf[11] === 0x50
|
|
371
|
+
) {
|
|
372
|
+
return true
|
|
373
|
+
}
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* VLM 응답 entity 의 결손 필드 복구. uid 누락 → 인덱스 기반, bbox 누락 → position+size 로 합성,
|
|
379
|
+
* size/position 비숫자 값 → 0 fallback. 후속 stage2 가 안전하게 진행되도록 보호.
|
|
380
|
+
*/
|
|
381
|
+
function normalizeEntity(e: any, index: number): UniversalEntity {
|
|
382
|
+
const uid = typeof e?.uid === 'string' && e.uid ? e.uid : `img-${index}`
|
|
383
|
+
const kind = e?.kind ?? 'shape'
|
|
384
|
+
const position =
|
|
385
|
+
e?.position && Number.isFinite(e.position.x) && Number.isFinite(e.position.y)
|
|
386
|
+
? { x: e.position.x, y: e.position.y }
|
|
387
|
+
: { x: 0, y: 0 }
|
|
388
|
+
const size =
|
|
389
|
+
e?.size && Number.isFinite(e.size.width) && Number.isFinite(e.size.height)
|
|
390
|
+
? { width: e.size.width, height: e.size.height }
|
|
391
|
+
: undefined
|
|
392
|
+
|
|
393
|
+
let bbox: BoundingBox
|
|
394
|
+
if (
|
|
395
|
+
e?.bbox?.min &&
|
|
396
|
+
e?.bbox?.max &&
|
|
397
|
+
Number.isFinite(e.bbox.min.x) &&
|
|
398
|
+
Number.isFinite(e.bbox.min.y) &&
|
|
399
|
+
Number.isFinite(e.bbox.max.x) &&
|
|
400
|
+
Number.isFinite(e.bbox.max.y)
|
|
401
|
+
) {
|
|
402
|
+
bbox = { min: { x: e.bbox.min.x, y: e.bbox.min.y }, max: { x: e.bbox.max.x, y: e.bbox.max.y } }
|
|
403
|
+
} else {
|
|
404
|
+
const w = size?.width ?? 0
|
|
405
|
+
const h = size?.height ?? 0
|
|
406
|
+
bbox = {
|
|
407
|
+
min: { x: position.x, y: position.y },
|
|
408
|
+
max: { x: position.x + w, y: position.y + h }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// bbox 가 position+size 와 일관되지 않으면 (VLM 이 lossy 한 bbox 를 줌) 일관성 보장.
|
|
413
|
+
// size 가 있을 때 bbox 는 항상 position..(position+size) 를 cover 해야 stage3-board 의
|
|
414
|
+
// 보드 크기 산정 / flipY 계산이 올바르다. 안 그러면 컴포넌트가 root 외곽으로 spill.
|
|
415
|
+
if (size) {
|
|
416
|
+
if (bbox.min.x > position.x) bbox.min.x = position.x
|
|
417
|
+
if (bbox.min.y > position.y) bbox.min.y = position.y
|
|
418
|
+
if (bbox.max.x < position.x + size.width) bbox.max.x = position.x + size.width
|
|
419
|
+
if (bbox.max.y < position.y + size.height) bbox.max.y = position.y + size.height
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
uid,
|
|
424
|
+
kind,
|
|
425
|
+
position,
|
|
426
|
+
size,
|
|
427
|
+
rotation: typeof e?.rotation === 'number' ? e.rotation : undefined,
|
|
428
|
+
bbox,
|
|
429
|
+
attribs: e?.attribs && typeof e.attribs === 'object' ? { ...e.attribs } : {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 모든 entity 의 bbox 를 합쳐 외부 bbox 산출. 빈 입력 / 무효값은 (0,0)-(0,0) fallback.
|
|
435
|
+
*/
|
|
436
|
+
function computeOuterBbox(entities: UniversalEntity[]): BoundingBox {
|
|
437
|
+
if (entities.length === 0) return { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
|
|
438
|
+
let minX = Infinity
|
|
439
|
+
let minY = Infinity
|
|
440
|
+
let maxX = -Infinity
|
|
441
|
+
let maxY = -Infinity
|
|
442
|
+
for (const e of entities) {
|
|
443
|
+
if (Number.isFinite(e.bbox.min.x) && e.bbox.min.x < minX) minX = e.bbox.min.x
|
|
444
|
+
if (Number.isFinite(e.bbox.min.y) && e.bbox.min.y < minY) minY = e.bbox.min.y
|
|
445
|
+
if (Number.isFinite(e.bbox.max.x) && e.bbox.max.x > maxX) maxX = e.bbox.max.x
|
|
446
|
+
if (Number.isFinite(e.bbox.max.y) && e.bbox.max.y > maxY) maxY = e.bbox.max.y
|
|
447
|
+
}
|
|
448
|
+
if (!Number.isFinite(minX)) {
|
|
449
|
+
return { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
|
|
450
|
+
}
|
|
451
|
+
return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function toBuffer(file: AdapterInput): Buffer | undefined {
|
|
455
|
+
if (Buffer.isBuffer(file)) return file
|
|
456
|
+
if (file instanceof Uint8Array) return Buffer.from(file)
|
|
457
|
+
if (file instanceof ArrayBuffer) return Buffer.from(new Uint8Array(file))
|
|
458
|
+
if (typeof file === 'string') {
|
|
459
|
+
// 이미지 파싱은 string 입력을 의미 있게 처리할 방법이 없음 (base64 가정은 위험).
|
|
460
|
+
// 명시적으로 binary 만 수용 — string 은 undefined 로 떨어져 parse 단계에서 명확한 에러.
|
|
461
|
+
return undefined
|
|
462
|
+
}
|
|
463
|
+
return undefined
|
|
464
|
+
}
|