@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
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@things-factory/board-import",
3
+ "version": "10.0.0-beta.108",
4
+ "main": "dist-server/index.js",
5
+ "things-factory": true,
6
+ "author": "heartyoh",
7
+ "description": "도면(CAD/이미지/IFC) → Operato Board (2D + 3D) → 데이터 바인딩 통합 import 파이프라인 (백엔드 코어)",
8
+ "license": "MIT",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "@things-factory:registry": "https://registry.npmjs.org"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hatiolab/things-factory.git",
16
+ "directory": "packages/board-import"
17
+ },
18
+ "scripts": {
19
+ "build": "npm run clean:server && tsc --p tsconfig.json",
20
+ "build:server": "npm run clean:server && tsc",
21
+ "clean:server": "rm -rf dist-server",
22
+ "clean": "npm run clean:server"
23
+ },
24
+ "dependencies": {
25
+ "@operato/graphql": "^10.0.0-beta.1",
26
+ "@things-factory/ai-client-base": "^10.0.0-beta.92",
27
+ "@things-factory/attachment-base": "^10.0.0-beta.108",
28
+ "@things-factory/auth-base": "^10.0.0-beta.108",
29
+ "@things-factory/board-service": "^10.0.0-beta.108",
30
+ "@things-factory/env": "^10.0.0-beta.92",
31
+ "@things-factory/shell": "^10.0.0-beta.108",
32
+ "dxf-parser": "^1.1.2"
33
+ },
34
+ "gitHead": "7de47925ebd3f39dc232d4dbfa43f9687f3603c4"
35
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * GCP Cloud Vision 프로브 — 도면 이미지에서 객체검출(박스) + OCR(라벨+박스) 추출.
3
+ *
4
+ * 목적: board 생성 레이아웃 추출에 GCP Vision 이 실제로 무엇을 주는지 *기준선* 측정.
5
+ * - OBJECT_LOCALIZATION : common-object(가구 등) + normalized 박스 → 위치 후보
6
+ * - DOCUMENT_TEXT_DETECTION : 라벨 텍스트 + 위치 → 식별/앵커
7
+ * - LABEL_DETECTION : 장면 라벨(맥락)
8
+ * 기하(벽/선/사각)는 Vision 이 안 줌 — 그건 OpenCV 영역(별도). 본 프로브는 Vision 의 몫만 확인.
9
+ *
10
+ * 사전 준비 (사용자 환경 — 키는 직접 발급):
11
+ * 1) GCP 콘솔에서 프로젝트 선택 → "Cloud Vision API" 사용 설정(Enable).
12
+ * 2) APIs & Services → Credentials → "API key" 생성 (Vision API 로 제한 권장).
13
+ * 3) export GOOGLE_VISION_API_KEY="발급키"
14
+ *
15
+ * 실행 (Node 18+ — 내장 fetch 사용, npm install 불필요):
16
+ * node packages/board-import/probe/gcp-vision-probe.mjs [이미지경로]
17
+ * 기본 경로: /Users/super/Downloads/download.jpg
18
+ *
19
+ * 출력: 콘솔 요약(객체/텍스트/라벨) + 원본응답 JSON(<이미지>.vision.json).
20
+ */
21
+ import { readFileSync, writeFileSync } from 'node:fs'
22
+
23
+ const imgPath = process.argv[2] || '/Users/super/Downloads/download.jpg'
24
+ const apiKey = process.env.GOOGLE_VISION_API_KEY || process.env.GCP_API_KEY
25
+
26
+ if (!apiKey) {
27
+ console.error('✗ GOOGLE_VISION_API_KEY (또는 GCP_API_KEY) 환경변수가 필요합니다.')
28
+ console.error(' 예: export GOOGLE_VISION_API_KEY="AIza..."')
29
+ process.exit(1)
30
+ }
31
+
32
+ let content
33
+ try {
34
+ content = readFileSync(imgPath).toString('base64')
35
+ } catch (e) {
36
+ console.error(`✗ 이미지를 읽을 수 없습니다: ${imgPath}\n ${e.message}`)
37
+ process.exit(1)
38
+ }
39
+
40
+ const body = {
41
+ requests: [
42
+ {
43
+ image: { content },
44
+ features: [
45
+ { type: 'OBJECT_LOCALIZATION', maxResults: 50 },
46
+ { type: 'DOCUMENT_TEXT_DETECTION' },
47
+ { type: 'LABEL_DETECTION', maxResults: 20 }
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+
53
+ console.log(`▶ Vision 호출: ${imgPath} (${Math.round(content.length / 1365)} KB base64)\n`)
54
+
55
+ const res = await fetch(`https://vision.googleapis.com/v1/images:annotate?key=${apiKey}`, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(body)
59
+ })
60
+
61
+ const json = await res.json()
62
+ const apiErr = json.error || json.responses?.[0]?.error
63
+ if (!res.ok || apiErr) {
64
+ console.error('✗ API 에러:', JSON.stringify(apiErr || json, null, 2))
65
+ console.error(' (Vision API 미사용설정 / 키 제한 / 결제 미설정 등을 확인하세요.)')
66
+ process.exit(1)
67
+ }
68
+
69
+ const r = json.responses[0]
70
+ const bbox = v => {
71
+ const pts = v || []
72
+ const xs = pts.map(p => p.x ?? 0)
73
+ const ys = pts.map(p => p.y ?? 0)
74
+ return pts.length
75
+ ? `[${Math.min(...xs)},${Math.min(...ys)} → ${Math.max(...xs)},${Math.max(...ys)}]`
76
+ : '[?]'
77
+ }
78
+
79
+ // 1) 객체 검출 — 위치 후보 (normalized 0..1)
80
+ const objs = r.localizedObjectAnnotations || []
81
+ console.log(`=== OBJECT_LOCALIZATION (${objs.length}) — normalized 0..1 ===`)
82
+ for (const o of objs.sort((a, b) => b.score - a.score)) {
83
+ console.log(` ${String(o.name).padEnd(18)} score=${o.score.toFixed(2)} ${bbox(o.boundingPoly?.normalizedVertices)}`)
84
+ }
85
+ if (!objs.length) console.log(' (객체 검출 없음 — 저해상도/추상 도면이면 흔함)')
86
+
87
+ // 2) OCR — 라벨 텍스트 + 위치
88
+ console.log(`\n=== DOCUMENT_TEXT_DETECTION ===`)
89
+ const fullText = r.fullTextAnnotation?.text?.trim()
90
+ console.log(fullText ? ` 전체텍스트:\n${fullText.split('\n').map(l => ' ' + l).join('\n')}` : ' (텍스트 없음)')
91
+ const words = []
92
+ for (const page of r.fullTextAnnotation?.pages || [])
93
+ for (const block of page.blocks || [])
94
+ for (const para of block.paragraphs || [])
95
+ for (const word of para.words || []) {
96
+ const t = (word.symbols || []).map(s => s.text).join('')
97
+ words.push({ t, box: word.boundingBox?.normalizedVertices || word.boundingBox?.vertices })
98
+ }
99
+ console.log(` 단어 수: ${words.length}`)
100
+ for (const w of words.slice(0, 40)) console.log(` "${w.t}" ${bbox(w.box)}`)
101
+
102
+ // 3) 라벨 — 맥락
103
+ const labels = r.labelAnnotations || []
104
+ console.log(`\n=== LABELS (${labels.length}) ===`)
105
+ for (const l of labels) console.log(` ${l.description} (${l.score.toFixed(2)})`)
106
+
107
+ // 원본 응답 저장
108
+ const out = imgPath.replace(/\.[^.]+$/, '') + '.vision.json'
109
+ writeFileSync(out, JSON.stringify(json, null, 2))
110
+ console.log(`\n✓ raw 응답 → ${out}`)
111
+ console.log('\n해석 가이드:')
112
+ console.log(' - 객체 박스가 가구/설비를 잡으면 → 위치 후보로 활용 (VLM 좌표추정 대체).')
113
+ console.log(' - 텍스트+박스가 있으면 → 컴포넌트 식별/앵커 (산업도면 ID 에서 진가).')
114
+ console.log(' - 벽/선/사각 기하는 여기 안 나옴 → OpenCV 로 별도 추출해야 함.')
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ OpenCV 기하 프로브 — 도면 이미지에서 *벽/선·사각·객체 footprint* 를 추출.
4
+
5
+ 목적: 레이아웃의 핵심(기하)을 VLM 좌표추정이 아니라 CV 로 얼마나 뽑을 수 있는지 *기준선* 측정.
6
+ GCP Vision(객체/OCR) 이 못 주는 부분 — 선·사각·연결성분 — 을 담당. 둘을 합쳐 하이브리드.
7
+
8
+ 로컬 실행, 외부 전송/키 없음 (프라이버시 안전).
9
+
10
+ 사전 준비:
11
+ pip install opencv-python numpy
12
+
13
+ 실행:
14
+ python packages/board-import/probe/opencv-geometry-probe.py [이미지경로]
15
+ 기본 경로: /Users/super/Downloads/download.jpg
16
+
17
+ 출력:
18
+ - 콘솔 요약(이미지 크기 / 선 수 / 사각 후보 수 + 상위 박스 좌표)
19
+ - 시각 오버레이 PNG(<이미지>_cv_overlay.png) — 검출 선(초록)·사각(빨강)을 원본 위에 그림.
20
+ ★ 이 오버레이를 눈으로 보고 "CV 가 이 도면에서 기하를 의미있게 잡는지" 판단.
21
+ """
22
+ import sys
23
+ import os
24
+
25
+ try:
26
+ import cv2
27
+ import numpy as np
28
+ except ImportError:
29
+ sys.exit("✗ opencv-python / numpy 가 필요합니다: pip install opencv-python numpy")
30
+
31
+ img_path = sys.argv[1] if len(sys.argv) > 1 else "/Users/super/Downloads/download.jpg"
32
+ img = cv2.imread(img_path)
33
+ if img is None:
34
+ sys.exit(f"✗ 이미지를 읽을 수 없습니다: {img_path}")
35
+
36
+ h0, w0 = img.shape[:2]
37
+ print(f"▶ 입력: {img_path} ({w0}x{h0})")
38
+
39
+ # 저해상도면 검출이 어렵다 — 업스케일(보간)로 선을 살림. (배율은 가독 위해 ~900px 목표.)
40
+ scale = max(1, round(900 / max(w0, h0)))
41
+ if scale > 1:
42
+ img = cv2.resize(img, (w0 * scale, h0 * scale), interpolation=cv2.INTER_CUBIC)
43
+ print(f" 저해상도 → {scale}x 업스케일 ({img.shape[1]}x{img.shape[0]})")
44
+ h, w = img.shape[:2]
45
+
46
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
47
+
48
+ # ── 1. 선(벽/경로) 검출 — Canny + 확률적 Hough ───────────────────────────────
49
+ edges = cv2.Canny(gray, 40, 120, apertureSize=3)
50
+ min_len = max(20, w // 25)
51
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=50,
52
+ minLineLength=min_len, maxLineGap=8)
53
+ lines = lines if lines is not None else []
54
+ print(f"\n=== 선(LINES) — {len(lines)} 개 (minLen={min_len}) ===")
55
+
56
+ # ── 2. 사각/객체 footprint — 임계 + 윤곽 → bounding rect ──────────────────────
57
+ # 청사진(밝은 선/짙은 배경)·일반 도면 모두 커버하도록 Otsu 양방향 시도 후 더 풍부한 쪽 선택.
58
+ def contours_from(bin_img):
59
+ cnts, _ = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
60
+ return cnts
61
+
62
+ _, th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
63
+ cands_a = contours_from(th)
64
+ cands_b = contours_from(cv2.bitwise_not(th))
65
+ cnts = cands_a if len(cands_a) >= len(cands_b) else cands_b
66
+
67
+ area_min = (w * h) * 0.0008 # 너무 작은 잡음 제거
68
+ area_max = (w * h) * 0.5 # 전체 프레임 같은 거대 윤곽 제거
69
+ rects = []
70
+ for c in cnts:
71
+ a = cv2.contourArea(c)
72
+ if a < area_min or a > area_max:
73
+ continue
74
+ x, y, ww, hh = cv2.boundingRect(c)
75
+ rects.append((x, y, ww, hh, a))
76
+ rects.sort(key=lambda r: -r[4])
77
+ print(f"\n=== 사각 후보(RECTS) — {len(rects)} 개 (area {area_min:.0f}~{area_max:.0f}px²) ===")
78
+ for x, y, ww, hh, a in rects[:25]:
79
+ # 원본 좌표계로 환산해 보고
80
+ print(f" x={x//scale:4d} y={y//scale:4d} w={ww//scale:4d} h={hh//scale:4d} (area={a/scale/scale:.0f})")
81
+
82
+ # ── 3. 시각 오버레이 저장 ──────────────────────────────────────────────────────
83
+ overlay = img.copy()
84
+ for ln in lines:
85
+ x1, y1, x2, y2 = ln[0]
86
+ cv2.line(overlay, (x1, y1), (x2, y2), (0, 200, 0), 1)
87
+ for x, y, ww, hh, a in rects:
88
+ cv2.rectangle(overlay, (x, y), (x + ww, y + hh), (0, 0, 255), 1)
89
+
90
+ base, _ = os.path.splitext(img_path)
91
+ out = base + "_cv_overlay.png"
92
+ cv2.imwrite(out, overlay)
93
+ print(f"\n✓ 오버레이 → {out}")
94
+ print("\n해석 가이드:")
95
+ print(" - 초록 선이 벽/경로를 따라가면 → Hough 로 구조선 추출 가능.")
96
+ print(" - 빨강 사각이 방/가구/설비 footprint 에 맞으면 → 위치·크기 후보로 활용.")
97
+ print(" - 어수선하면(저해상도/스타일) → 전처리 강화 또는 고해상도 입력 필요.")
98
+ print(" - 이 기하 + GCP Vision(객체/OCR 라벨) + VLM(의미) = 하이브리드.")
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @things-factory/board-import — 도면(CAD/이미지/IFC) → Board Model 백엔드 코어.
3
+ *
4
+ * Phase 1: DXF 결정적 파이프라인 (AI 미사용).
5
+ * Phase 2+: GraphQL/REST 엔드포인트, AI 통합.
6
+ */
7
+ export * from './service/index'
@@ -0,0 +1,230 @@
1
+ /**
2
+ * DXF Format Adapter — DXF 텍스트 파일을 UniversalEntity 목록으로 정규화.
3
+ *
4
+ * 백엔드 환경(Node.js)에서 동작. 입력은 string / Buffer / ArrayBuffer / Uint8Array 모두 허용.
5
+ *
6
+ * 구현 범위 (Phase 1):
7
+ * - INSERT (블록 참조) → kind='shape', attribs.name 추출
8
+ * - LWPOLYLINE / POLYLINE → kind='path'
9
+ * - CIRCLE → kind='shape', attribs.shape='circle'
10
+ * - LINE → kind='path' (단일 선분)
11
+ * - TEXT / MTEXT → kind='text'
12
+ * - 레이어, 좌표, 회전 추출
13
+ * - $INSUNITS 단위 추출
14
+ *
15
+ * 보류 (Phase 2+):
16
+ * - HATCH, SPLINE, ELLIPSE 의 정밀 변환
17
+ * - 블록 정의 안의 entities 깊이 펼치기
18
+ * - 외부 참조 (XREF)
19
+ */
20
+ import DxfParser from 'dxf-parser'
21
+
22
+ import type {
23
+ AdapterCapabilities,
24
+ AdapterInput,
25
+ BoundingBox,
26
+ FormatAdapter,
27
+ ParseOptions,
28
+ ParseResult,
29
+ UniversalEntity
30
+ } from '../types/index'
31
+
32
+ const DXF_INSUNITS_TO_UNITS: Record<number, ParseResult['units']> = {
33
+ 0: 'unknown',
34
+ 1: 'inch',
35
+ 4: 'mm',
36
+ 5: 'cm',
37
+ 6: 'm'
38
+ }
39
+
40
+ export class DxfAdapter implements FormatAdapter {
41
+ readonly format = 'dxf'
42
+ readonly supportedExtensions = ['.dxf']
43
+ readonly capabilities: AdapterCapabilities = {
44
+ has2D: true,
45
+ has3D: false,
46
+ hasSemanticLabels: false,
47
+ hasTextLabels: true,
48
+ hasAttributes: true
49
+ }
50
+
51
+ async detect(file: AdapterInput, filename?: string): Promise<boolean> {
52
+ if (filename) {
53
+ const lower = filename.toLowerCase()
54
+ if (lower.endsWith('.dxf')) return true
55
+ // 명백한 non-DXF 확장자는 content peek 자체를 시도하지 않음.
56
+ // 잘못된 입력 타입(Promise/Stream 등) 이 흘러도 detect 가 throw 하지 않게 가드.
57
+ if (/\.(jpe?g|png|gif|webp|bmp|svg|pdf|ifc|gltf|glb|json|xml|csv|xlsx?|docx?)$/.test(lower)) {
58
+ return false
59
+ }
60
+ }
61
+ try {
62
+ const head = readHead(file, 256)
63
+ return /\b0\s*\n\s*SECTION\b/.test(head) || /^\s*999/.test(head)
64
+ } catch {
65
+ // 입력 타입을 텍스트로 못 읽으면 DXF 가 아니다 — false 로 흘려보내 다음 어댑터에 양보.
66
+ return false
67
+ }
68
+ }
69
+
70
+ async parse(file: AdapterInput, options: ParseOptions = {}): Promise<ParseResult> {
71
+ const text = readText(file)
72
+ const parser = new DxfParser()
73
+ const dxf = parser.parseSync(text)
74
+
75
+ if (!dxf) throw new Error('DXF 파싱 실패')
76
+
77
+ const entities: UniversalEntity[] = []
78
+ let uidCounter = 0
79
+ const nextUid = () => `dxf-${++uidCounter}`
80
+
81
+ const excludeLayers = new Set((options.excludeLayers || []).map(l => l.toLowerCase()))
82
+ const maxEntities = options.maxEntities ?? 100000
83
+
84
+ for (const e of dxf.entities || []) {
85
+ if (entities.length >= maxEntities) break
86
+
87
+ const layer = (e as any).layer || ''
88
+ if (excludeLayers.has(layer.toLowerCase())) continue
89
+
90
+ const ue = entityToUniversal(e, layer, nextUid())
91
+ if (ue) entities.push(ue)
92
+ }
93
+
94
+ const bbox = computeBoundingBox(entities)
95
+ const units = DXF_INSUNITS_TO_UNITS[(dxf.header as any)?.$INSUNITS ?? 0]
96
+
97
+ return {
98
+ entities,
99
+ metadata: {
100
+ format: 'dxf',
101
+ formatVersion: (dxf.header as any)?.$ACADVER,
102
+ ...((dxf.header as any) || {})
103
+ },
104
+ bbox,
105
+ units
106
+ }
107
+ }
108
+ }
109
+
110
+ // ── DXF entity → UniversalEntity ─────────────────────────────────────
111
+
112
+ function entityToUniversal(e: any, layer: string, uid: string): UniversalEntity | null {
113
+ const type = e.type as string
114
+
115
+ switch (type) {
116
+ case 'INSERT': {
117
+ const x = e.position?.x ?? 0
118
+ const y = e.position?.y ?? 0
119
+ const width = (e.xScale ?? 1) * 1
120
+ const height = (e.yScale ?? 1) * 1
121
+ return {
122
+ uid,
123
+ kind: 'shape',
124
+ position: { x, y },
125
+ size: { width, height },
126
+ rotation: e.rotation ?? 0,
127
+ bbox: { min: { x, y }, max: { x: x + width, y: y + height } },
128
+ attribs: {
129
+ name: e.name,
130
+ layer,
131
+ shape: 'rect'
132
+ }
133
+ }
134
+ }
135
+
136
+ case 'CIRCLE': {
137
+ const cx = e.center?.x ?? 0
138
+ const cy = e.center?.y ?? 0
139
+ const r = e.radius ?? 0
140
+ return {
141
+ uid,
142
+ kind: 'shape',
143
+ position: { x: cx - r, y: cy - r },
144
+ size: { width: 2 * r, height: 2 * r },
145
+ bbox: { min: { x: cx - r, y: cy - r }, max: { x: cx + r, y: cy + r } },
146
+ attribs: { layer, shape: 'circle' }
147
+ }
148
+ }
149
+
150
+ case 'LWPOLYLINE':
151
+ case 'POLYLINE': {
152
+ const verts: { x: number; y: number }[] = (e.vertices || []).map((v: any) => ({ x: v.x, y: v.y }))
153
+ if (verts.length === 0) return null
154
+ const xs = verts.map(v => v.x)
155
+ const ys = verts.map(v => v.y)
156
+ const min = { x: Math.min(...xs), y: Math.min(...ys) }
157
+ const max = { x: Math.max(...xs), y: Math.max(...ys) }
158
+ return {
159
+ uid,
160
+ kind: 'path',
161
+ position: { x: min.x, y: min.y },
162
+ size: { width: max.x - min.x, height: max.y - min.y },
163
+ bbox: { min, max },
164
+ attribs: { layer, shape: 'polyline', vertices: verts, closed: !!e.shape }
165
+ }
166
+ }
167
+
168
+ case 'LINE': {
169
+ const x1 = e.vertices?.[0]?.x ?? 0
170
+ const y1 = e.vertices?.[0]?.y ?? 0
171
+ const x2 = e.vertices?.[1]?.x ?? 0
172
+ const y2 = e.vertices?.[1]?.y ?? 0
173
+ const min = { x: Math.min(x1, x2), y: Math.min(y1, y2) }
174
+ const max = { x: Math.max(x1, x2), y: Math.max(y1, y2) }
175
+ return {
176
+ uid,
177
+ kind: 'path',
178
+ position: { x: min.x, y: min.y },
179
+ size: { width: max.x - min.x, height: max.y - min.y },
180
+ bbox: { min, max },
181
+ attribs: { layer, shape: 'polyline', vertices: [{ x: x1, y: y1 }, { x: x2, y: y2 }] }
182
+ }
183
+ }
184
+
185
+ case 'TEXT':
186
+ case 'MTEXT': {
187
+ const x = e.position?.x ?? e.startPoint?.x ?? 0
188
+ const y = e.position?.y ?? e.startPoint?.y ?? 0
189
+ const text = e.text ?? e.string ?? ''
190
+ const h = e.textHeight ?? e.height ?? 1
191
+ return {
192
+ uid,
193
+ kind: 'text',
194
+ position: { x, y },
195
+ bbox: { min: { x, y }, max: { x: x + h * (text.length || 1), y: y + h } },
196
+ attribs: { layer, text }
197
+ }
198
+ }
199
+
200
+ default:
201
+ return null
202
+ }
203
+ }
204
+
205
+ // ── 유틸 ─────────────────────────────────────────────────────────────
206
+
207
+ function computeBoundingBox(entities: UniversalEntity[]): BoundingBox {
208
+ if (entities.length === 0) return { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
209
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
210
+ for (const e of entities) {
211
+ if (e.bbox.min.x < minX) minX = e.bbox.min.x
212
+ if (e.bbox.min.y < minY) minY = e.bbox.min.y
213
+ if (e.bbox.max.x > maxX) maxX = e.bbox.max.x
214
+ if (e.bbox.max.y > maxY) maxY = e.bbox.max.y
215
+ }
216
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } }
217
+ }
218
+
219
+ function readText(file: AdapterInput): string {
220
+ if (typeof file === 'string') return file
221
+ if (Buffer.isBuffer(file)) return file.toString('utf-8')
222
+ if (file instanceof Uint8Array) return Buffer.from(file).toString('utf-8')
223
+ if (file instanceof ArrayBuffer) return Buffer.from(new Uint8Array(file)).toString('utf-8')
224
+ throw new Error('Unsupported DXF input type')
225
+ }
226
+
227
+ function readHead(file: AdapterInput, maxBytes: number): string {
228
+ const text = readText(file)
229
+ return text.substring(0, maxBytes)
230
+ }