commit 95fa6a452b50394ff11fba60db482dd038fe2355 Author: minsung Date: Wed Jul 15 10:58:04 2026 +0900 Initial public release: DWG/DXF 2D viewer sample Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..de76d44 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# dwg-dxf-viewer-sample + +**hmwebviewer 형제 폴더**에 분리된 DWG/DXF 2D 뷰어 샘플입니다. + +`docs/modules-dwg-dxf.html` 모듈 지도의 카드 +(`acadrust-dwg`, `dxf-parser`, `Viewer2D` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다. +`hmwebviewer` 저장소 안에 의존하지 않습니다. + +``` +D:\MYCLAUDE_PROJECT\ + hmwebviewer\ ← 본 제품 + dwg-dxf-viewer-sample\ ← 이 샘플 (형제) +``` + +--- + +## 실행 + +```bash +cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample +npm install +npm run dev +# → http://localhost:5173 +``` + +| UI | 모듈 경로 | +|----|-----------| +| **Sample DWG** | `ext2d` → **acadrust-dwg WASM** → `Viewer2D` | +| **Sample DXF** | `ext2d` → **dxf-parser + hatch + adapter** → `Viewer2D` | +| 파일 드롭 / Open | 확장자로 위 둘 중 하나 | + +모듈 지도: 브라우저에서 [`docs/modules-dwg-dxf.html`](./docs/modules-dwg-dxf.html) +조합 코드: [`src/main.ts`](./src/main.ts) + +--- + +## 레이아웃 + +``` +dwg-dxf-viewer-sample/ + docs/modules-dwg-dxf.html # 모듈 조합 레시피 HTML + public/ + fonts/NanumGothic-Regular.ttf + samples/BasicSample.dwg + samples/simple.dxf + src/ + main.ts # 모듈을 import 해서 조립 + viewer2d/ # 재사용 모듈 본체 + acadrust-dwg/ # WASM + Viewer2D.js + dwgParser.ts + parseDxf.ts + dxfAdapter.js + dxfHatchHandler.js + ext2d.ts + slugText.ts + … + package.json + vite.config.ts +``` + +--- + +## 조합 파이프라인 + +``` +File/URL + → ext2d (isDwg / isDxf) + → parseDwgBuffer | parseDxfBuffer + │ │ + └ acadrust-dwg ├ decodeDxf + ├ dxf-parser + dxfHatchHandler + └ dxfAdapter + → CadParseResult + → Viewer2D.load() (+ three, slugText/font) +``` + +--- + +## 의존성 + +| 패키지 | 역할 | +|--------|------| +| `three` | WebGL 렌더 / OrbitControls | +| `dxf-parser` | DXF 텍스트 파싱 | +| `opentype.js` | TTF 글리프 (slugText) | + +WASM(`acadrust_dwg_bg.wasm`)은 `src/viewer2d/acadrust-dwg/` 에 포함되어 있으며 +Vite `?url` import 로 로드됩니다. + +--- + +## 다른 앱에 이식 + +1. `src/viewer2d/` 폴더 복사 +2. `npm i three dxf-parser opentype.js` +3. 폰트 `public/fonts/NanumGothic-Regular.ttf` +4. Vite: `assetsInclude: ['**/*.wasm']` +5. `src/main.ts` 의 ①~④ 블록을 호스트에 붙이고 ⑤ UI만 교체 + +소스 동기화(선택): 본가 `hmwebviewer/src/viewer2d` 가 업데이트되면 해당 파일을 +이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다. + +--- + +## 라이선스 메모 + +| 조각 | 라이선스 | +|------|----------| +| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) | +| acadrust (WASM 내부) | MPL-2.0 | +| dxf-parser · three | MIT | +| NanumGothic | OFL | diff --git a/docs/modules-dwg-dxf.html b/docs/modules-dwg-dxf.html new file mode 100644 index 0000000..8b1951e --- /dev/null +++ b/docs/modules-dwg-dxf.html @@ -0,0 +1,361 @@ + + + + + +DWG/DXF · 모듈 조합 레시피 & 샘플 + + + +
+
+
compose · don’t invent
+

DWG · DXF 모듈 조합 레시피

+

+ 전체 제품이 아니라, 지도에 있는 재사용 모듈만 골라 조립하면 2D CAD 뷰어가 됩니다. + 아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다. +

+ + ▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev) + +

+ cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173 +

+
+ + +

01 조합에 쓰는 모듈 (지도 카드)

+
+ WASM 파서 + JS 모듈 · 렌더러 +
+
+
+
acadrust-dwg wasm
+
DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.
+
src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)
+
+
+
dwgParser / acadrustParser wrap
+
WASM 1회 init + parseDwgBuffer(bytes).
+
dwgParser.ts · acadrustParser.ts
+
+
+
dxf-parser npm
+
DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.
+
npm: dxf-parser
+
+
+
dxfHatchHandler patch
+
registerEntityHandler로 HATCH 보강.
+
dxfHatchHandler.js
+
+
+
dxfAdapter adapt
+
dxf-parser 출력 → Viewer2D 공통 스키마.
+
dxfAdapter.js
+
+
+
Viewer2D render
+
Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…
+
Viewer2D.js
+
+
+
three peer
+
WebGLRenderer + OrbitControls (pan/zoom).
+
npm: three ≥0.160
+
+
+
slugText + font text
+
opentype.js → GPU 텍스트. TTF를 호스트가 서빙.
+
slugText.ts · /fonts/*.ttf
+
+
+
ext2d util
+
isDwg / isDxf / is2D — 의존 zero.
+
ext2d.ts
+
+
+ + +

02 조립 파이프라인

+
+
+
+
Host
+
File · URL · Drop
+
.dwg / .dxf
+
+
+
+
ext2d
+
isDwg / isDxf
+
분기
+
+
+
+
Parser A or B
+
acadrust · dxf-parser
+
(+ hatch · adapter)
+
+
+
+
Contract
+
CadParseResult
+
공통 JSON
+
+
+
+
Viewer2D
+
.load(result)
+
three 씬
+
+
+
+ 핵심 계약: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다. + 중간은 항상 CadParseResult 하나. +
+
+ + +

03 다른 프로젝트에 옮기는 순서

+
+
+
1
+
+

모듈 소스 복사

+

+ 이 샘플의 src/viewer2d/ 전체를 복사. + npm: three, dxf-parser, opentype.js. + (원본 소스는 형제 폴더 hmwebviewer/src/viewer2d.) +

+
+
+
+
2
+
+

정적 자산

+

+ public/fonts/NanumGothic-Regular.ttf (한글 TEXT). + WASM은 소스가 ?url로 import — Vite면 assetsInclude: ['**/*.wasm']. +

+
+
+
+
3
+
+

아래 스니펫처럼 조합

+

+ 래퍼 클래스 없이 모듈을 직접 import 해도 됩니다. + 실행 가능한 전체 예: 이 프로젝트 src/main.ts. +

+
+
+
+
4
+
+

호스트 UI만 붙이기

+

+ 드롭존 · 툴바 · 레이어 패널은 모듈이 아님. + 샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음. +

+
+
+
+ + +

04 최소 조합 코드 (샘플과 동일 패턴)

+
// ① 모듈 지도 카드 → import
+import { Viewer2D } from './viewer2d/Viewer2D.js';
+import { parseDwgBuffer } from './viewer2d/dwgParser';       // acadrust-dwg
+import { parseDxfBuffer } from './viewer2d/parseDxf';         // dxf-parser+hatch+adapter
+import { isDwg, isDxf } from './viewer2d/ext2d';
+import { setDefaultFontUrl } from './viewer2d/slugText';
+
+setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
+
+// ② 렌더러
+const viewer = new Viewer2D(document.getElementById('app')!);
+
+// ③ 파서 조합
+async function parseCad(buf: ArrayBuffer, name: string) {
+  if (isDxf(name)) return parseDxfBuffer(buf);                 // dxf-parser 경로
+  if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf)); // WASM 경로
+  throw new Error('need .dwg or .dxf name');
+}
+
+// ④ 계약: parseResult → load
+async function open(file: File) {
+  const result = await parseCad(await file.arrayBuffer(), file.name);
+  viewer.load(result);
+  viewer.resize();
+  viewer.fit();
+}
+ +

05 DWG 경로 vs DXF 경로

+
+
+
.dwg 경로 wasm
+
+ parseDwgBuffer
+ → acadrustParser.init
+ → parse_dwg(bytes)
+ → CadParseResult +
+
Sample DWG 버튼으로 검증
+
+
+
.dxf 경로 js
+
+ parseDxfBuffer
+ → decodeDxf (UTF-8 / CP949…)
+ → DxfParser + HatchHandler
+ → dxfToParseResult +
+
Sample DXF 버튼으로 검증
+
+
+ +

06 샘플 위치

+
dwg-dxf-viewer-sample/   ← hmwebviewer 형제 폴더
+  src/main.ts              ← 모듈을 직접 import 해서 조립 (핵심)
+  src/viewer2d/            ← acadrust-dwg · Viewer2D · dxf-*
+  index.html               ← 툴바 + Module recipe 패널
+  public/fonts/            ← NanumGothic (slugText)
+  public/samples/          ← BasicSample.dwg · simple.dxf
+  docs/modules-dwg-dxf.html
+
+실행
+  cd ../dwg-dxf-viewer-sample   # (hmwebviewer 기준 형제)
+  npm install
+  npm run dev              → http://localhost:5173
+ +
+ DWG/DXF only · sibling of hmwebviewer + no 3D / SHP / R2 +
+
+ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..8dc5c40 --- /dev/null +++ b/index.html @@ -0,0 +1,99 @@ + + + + + + DWG/DXF · 모듈 조합 샘플 + + + +
+ +
+ + + + + + +
ready
+
+ +

Layers

+ + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..df9dee0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1102 @@ +{ + "name": "dwg-dxf-viewer-sample", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dwg-dxf-viewer-sample", + "version": "0.1.0", + "dependencies": { + "dxf-parser": "^1.1.2", + "opentype.js": "^2.0.0", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.1", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz", + "integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dxf-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz", + "integrity": "sha512-GPTumUvRkounlIazLIyJMmTWt+nlg+ksS0Hdm8jWvejmZKBTz6gvHTam76wRm4PQMma5sgKLThblQyeIJcH79Q==", + "license": "MIT", + "dependencies": { + "loglevel": "^1.7.1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c4490b --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "dwg-dxf-viewer-sample", + "private": true, + "version": "0.1.0", + "description": "Standalone sample: compose acadrust-dwg + dxf-parser + Viewer2D (sibling of hmwebviewer)", + "type": "module", + "scripts": { + "dev": "vite --port 5173 --strictPort", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "dxf-parser": "^1.1.2", + "opentype.js": "^2.0.0", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.1", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } +} diff --git a/public/docs/modules-dwg-dxf.html b/public/docs/modules-dwg-dxf.html new file mode 100644 index 0000000..8b1951e --- /dev/null +++ b/public/docs/modules-dwg-dxf.html @@ -0,0 +1,361 @@ + + + + + +DWG/DXF · 모듈 조합 레시피 & 샘플 + + + +
+
+
compose · don’t invent
+

DWG · DXF 모듈 조합 레시피

+

+ 전체 제품이 아니라, 지도에 있는 재사용 모듈만 골라 조립하면 2D CAD 뷰어가 됩니다. + 아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다. +

+ + ▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev) + +

+ cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173 +

+
+ + +

01 조합에 쓰는 모듈 (지도 카드)

+
+ WASM 파서 + JS 모듈 · 렌더러 +
+
+
+
acadrust-dwg wasm
+
DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.
+
src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)
+
+
+
dwgParser / acadrustParser wrap
+
WASM 1회 init + parseDwgBuffer(bytes).
+
dwgParser.ts · acadrustParser.ts
+
+
+
dxf-parser npm
+
DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.
+
npm: dxf-parser
+
+
+
dxfHatchHandler patch
+
registerEntityHandler로 HATCH 보강.
+
dxfHatchHandler.js
+
+
+
dxfAdapter adapt
+
dxf-parser 출력 → Viewer2D 공통 스키마.
+
dxfAdapter.js
+
+
+
Viewer2D render
+
Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…
+
Viewer2D.js
+
+
+
three peer
+
WebGLRenderer + OrbitControls (pan/zoom).
+
npm: three ≥0.160
+
+
+
slugText + font text
+
opentype.js → GPU 텍스트. TTF를 호스트가 서빙.
+
slugText.ts · /fonts/*.ttf
+
+
+
ext2d util
+
isDwg / isDxf / is2D — 의존 zero.
+
ext2d.ts
+
+
+ + +

02 조립 파이프라인

+
+
+
+
Host
+
File · URL · Drop
+
.dwg / .dxf
+
+
+
+
ext2d
+
isDwg / isDxf
+
분기
+
+
+
+
Parser A or B
+
acadrust · dxf-parser
+
(+ hatch · adapter)
+
+
+
+
Contract
+
CadParseResult
+
공통 JSON
+
+
+
+
Viewer2D
+
.load(result)
+
three 씬
+
+
+
+ 핵심 계약: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다. + 중간은 항상 CadParseResult 하나. +
+
+ + +

03 다른 프로젝트에 옮기는 순서

+
+
+
1
+
+

모듈 소스 복사

+

+ 이 샘플의 src/viewer2d/ 전체를 복사. + npm: three, dxf-parser, opentype.js. + (원본 소스는 형제 폴더 hmwebviewer/src/viewer2d.) +

+
+
+
+
2
+
+

정적 자산

+

+ public/fonts/NanumGothic-Regular.ttf (한글 TEXT). + WASM은 소스가 ?url로 import — Vite면 assetsInclude: ['**/*.wasm']. +

+
+
+
+
3
+
+

아래 스니펫처럼 조합

+

+ 래퍼 클래스 없이 모듈을 직접 import 해도 됩니다. + 실행 가능한 전체 예: 이 프로젝트 src/main.ts. +

+
+
+
+
4
+
+

호스트 UI만 붙이기

+

+ 드롭존 · 툴바 · 레이어 패널은 모듈이 아님. + 샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음. +

+
+
+
+ + +

04 최소 조합 코드 (샘플과 동일 패턴)

+
// ① 모듈 지도 카드 → import
+import { Viewer2D } from './viewer2d/Viewer2D.js';
+import { parseDwgBuffer } from './viewer2d/dwgParser';       // acadrust-dwg
+import { parseDxfBuffer } from './viewer2d/parseDxf';         // dxf-parser+hatch+adapter
+import { isDwg, isDxf } from './viewer2d/ext2d';
+import { setDefaultFontUrl } from './viewer2d/slugText';
+
+setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
+
+// ② 렌더러
+const viewer = new Viewer2D(document.getElementById('app')!);
+
+// ③ 파서 조합
+async function parseCad(buf: ArrayBuffer, name: string) {
+  if (isDxf(name)) return parseDxfBuffer(buf);                 // dxf-parser 경로
+  if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf)); // WASM 경로
+  throw new Error('need .dwg or .dxf name');
+}
+
+// ④ 계약: parseResult → load
+async function open(file: File) {
+  const result = await parseCad(await file.arrayBuffer(), file.name);
+  viewer.load(result);
+  viewer.resize();
+  viewer.fit();
+}
+ +

05 DWG 경로 vs DXF 경로

+
+
+
.dwg 경로 wasm
+
+ parseDwgBuffer
+ → acadrustParser.init
+ → parse_dwg(bytes)
+ → CadParseResult +
+
Sample DWG 버튼으로 검증
+
+
+
.dxf 경로 js
+
+ parseDxfBuffer
+ → decodeDxf (UTF-8 / CP949…)
+ → DxfParser + HatchHandler
+ → dxfToParseResult +
+
Sample DXF 버튼으로 검증
+
+
+ +

06 샘플 위치

+
dwg-dxf-viewer-sample/   ← hmwebviewer 형제 폴더
+  src/main.ts              ← 모듈을 직접 import 해서 조립 (핵심)
+  src/viewer2d/            ← acadrust-dwg · Viewer2D · dxf-*
+  index.html               ← 툴바 + Module recipe 패널
+  public/fonts/            ← NanumGothic (slugText)
+  public/samples/          ← BasicSample.dwg · simple.dxf
+  docs/modules-dwg-dxf.html
+
+실행
+  cd ../dwg-dxf-viewer-sample   # (hmwebviewer 기준 형제)
+  npm install
+  npm run dev              → http://localhost:5173
+ +
+ DWG/DXF only · sibling of hmwebviewer + no 3D / SHP / R2 +
+
+ + diff --git a/public/fonts/NanumGothic-Regular.ttf b/public/fonts/NanumGothic-Regular.ttf new file mode 100644 index 0000000..2aa2b20 Binary files /dev/null and b/public/fonts/NanumGothic-Regular.ttf differ diff --git a/public/samples/BasicSample.dwg b/public/samples/BasicSample.dwg new file mode 100644 index 0000000..cd34d2c Binary files /dev/null and b/public/samples/BasicSample.dwg differ diff --git a/public/samples/simple.dxf b/public/samples/simple.dxf new file mode 100644 index 0000000..10fa791 --- /dev/null +++ b/public/samples/simple.dxf @@ -0,0 +1,146 @@ +0 +SECTION +2 +HEADER +9 +$ACADVER +1 +AC1015 +9 +$INSUNITS +70 +4 +0 +ENDSEC +0 +SECTION +2 +TABLES +0 +TABLE +2 +LAYER +0 +LAYER +2 +0 +70 +0 +62 +7 +0 +LAYER +2 +OUTLINE +70 +0 +62 +1 +0 +ENDTAB +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +8 +OUTLINE +10 +0.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +0.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +100.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +60.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +100.0 +20 +60.0 +30 +0.0 +11 +0.0 +21 +60.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +0.0 +20 +60.0 +30 +0.0 +11 +0.0 +21 +0.0 +31 +0.0 +0 +CIRCLE +8 +0 +10 +50.0 +20 +30.0 +30 +0.0 +40 +15.0 +0 +TEXT +8 +0 +10 +10.0 +20 +70.0 +30 +0.0 +40 +5.0 +1 +Hello DWG/DXF package +0 +ENDSEC +0 +EOF diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a6e170c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,164 @@ +/** + * ═══════════════════════════════════════════════════════════════════ + * DWG/DXF viewer sample — 모듈 조합 레시피 + * (docs/modules-dwg-dxf.html 에 정리된 모듈을 그대로 조립) + * + * 이 프로젝트는 hmwebviewer 와 형제 폴더로 분리된 독립 샘플입니다. + * ═══════════════════════════════════════════════════════════════════ + * + * ┌─ 모듈 지도 카드 ──────────────┬─ 이 파일에서의 역할 ──────────┐ + * │ acadrust-dwg (WASM) │ DWG 바이트 → parseResult │ + * │ dxf-parser + dxfHatchHandler │ DXF 텍스트 → parseResult │ + * │ dxfAdapter │ dxf-parser 출력을 공통 스키마로 │ + * │ Viewer2D │ parseResult → three 씬 렌더 │ + * │ three (peer) │ WebGLRenderer / OrbitControls │ + * │ slugText + NanumGothic.ttf │ TEXT/MTEXT 한글·라틴 표시 │ + * │ ext2d │ .dwg / .dxf 확장자 판별 │ + * └───────────────────────────────┴───────────────────────────────┘ + * + * 흐름: File/URL → (확장자 분기) → 파서 → CadParseResult → Viewer2D.load() + */ + +// ── ① 모듈 지도의 각 카드 (src/viewer2d/*) ──────────────────────── +import { Viewer2D } from './viewer2d/Viewer2D.js'; +import { parseDwgBuffer, type CadParseResult } from './viewer2d/dwgParser'; +import { parseDxfBuffer } from './viewer2d/parseDxf'; +import { is2D, isDxf, isDwg } from './viewer2d/ext2d'; +import { setDefaultFontUrl } from './viewer2d/slugText'; + +// parseDxfBuffer 내부: +// dxf-parser → HatchHandler → dxfAdapter +// parseDwgBuffer 내부: +// acadrustParser → acadrust-dwg WASM + +// ── ② 호스트 정적 자산 ────────────────────────────────────────── +setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf'); + +// ── ③ Viewer2D 마운트 ─────────────────────────────────────────── +const app = document.getElementById('app')!; +const statusEl = document.getElementById('status')!; +const layerList = document.getElementById('layerList')!; +const layersPanel = document.getElementById('layers')!; + +function setStatus(msg: string, err = false) { + statusEl.textContent = msg; + statusEl.classList.toggle('err', err); +} + +const viewer = new Viewer2D(app); +viewer.onSelect((entity: unknown) => { + if (!entity) { + setStatus('deselected'); + return; + } + const e = entity as { type?: string; layer?: string; handle?: { value?: string } }; + setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`); +}); + +// ── ④ 파서 조합 ───────────────────────────────────────────────── +async function parseCad(buf: ArrayBuffer, name: string): Promise { + if (isDxf(name)) return parseDxfBuffer(buf); + if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf)); + const head = new TextDecoder('latin1').decode( + new Uint8Array(buf, 0, Math.min(6, buf.byteLength)), + ); + if (head.startsWith('AC')) return parseDwgBuffer(new Uint8Array(buf)); + return parseDxfBuffer(buf); +} + +async function loadAndRender(buf: ArrayBuffer, name: string): Promise { + const result = await parseCad(buf, name); + viewer.load(result); + viewer.resize(); + return result; +} + +async function loadUrl(url: string, nameHint?: string) { + const name = nameHint ?? url.split('?')[0]!.split('/').pop() ?? url; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return loadAndRender(await res.arrayBuffer(), name); +} + +async function loadFile(file: File) { + if (!is2D(file.name)) throw new Error(`not .dwg/.dxf: ${file.name}`); + return loadAndRender(await file.arrayBuffer(), file.name); +} + +// ── ⑤ 호스트 UI 글루 ──────────────────────────────────────────── +function fillLayers() { + layerList.innerHTML = ''; + for (const L of viewer.getLayerInfo()) { + const label = document.createElement('label'); + label.dataset.name = L.name; + const sw = document.createElement('span'); + sw.className = 'swatch'; + sw.style.background = L.colorHex || '#888'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = L.visible; + cb.addEventListener('change', () => { + const next = new Set(); + layerList.querySelectorAll('label').forEach((row) => { + const input = row.querySelector('input') as HTMLInputElement; + if (!input.checked) next.add(row.dataset.name!); + }); + viewer.setHiddenLayers(next); + }); + const text = document.createElement('span'); + text.textContent = `${L.name} (${L.count})`; + label.append(sw, cb, text); + layerList.append(label); + } +} + +async function afterLoad(name: string, result: CadParseResult) { + setStatus(`✓ ${name} · ${result.entities?.length ?? 0} entities`); + fillLayers(); + viewer.fit(); +} + +async function run(label: string, job: () => Promise) { + setStatus(`loading ${label}…`); + try { + await afterLoad(label, await job()); + } catch (err) { + setStatus(`${label}: ${(err as Error).message}`, true); + console.error(err); + } +} + +document.getElementById('sample')!.addEventListener('click', () => { + void run('BasicSample.dwg', () => loadUrl('/samples/BasicSample.dwg', 'BasicSample.dwg')); +}); +document.getElementById('sampleDxf')!.addEventListener('click', () => { + void run('simple.dxf', () => loadUrl('/samples/simple.dxf', 'simple.dxf')); +}); +document.getElementById('file')!.addEventListener('change', (ev) => { + const file = (ev.target as HTMLInputElement).files?.[0]; + if (!file) return; + void run(file.name, () => loadFile(file)); + (ev.target as HTMLInputElement).value = ''; +}); +document.getElementById('fit')!.addEventListener('click', () => viewer.fit()); +let dark = true; +document.getElementById('theme')!.addEventListener('click', () => { + dark = !dark; + viewer.setTheme(dark); +}); +document.getElementById('layersBtn')!.addEventListener('click', () => { + layersPanel.style.display = layersPanel.style.display === 'block' ? 'none' : 'block'; +}); + +window.addEventListener('dragover', (e) => e.preventDefault()); +window.addEventListener('drop', (e) => { + e.preventDefault(); + const file = e.dataTransfer?.files?.[0]; + if (file) void run(file.name, () => loadFile(file)); +}); + +const model = new URLSearchParams(location.search).get('model'); +if (model) void run(model, () => loadUrl(model)); +else setStatus('ready · Sample DWG / DXF 또는 파일 드롭'); + +window.addEventListener('resize', () => viewer.resize()); diff --git a/src/viewer2d/Viewer2D.d.ts b/src/viewer2d/Viewer2D.d.ts new file mode 100644 index 0000000..05ea56c --- /dev/null +++ b/src/viewer2d/Viewer2D.d.ts @@ -0,0 +1,20 @@ +// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves +// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed. +export class Viewer2D { + constructor(container: HTMLElement); + /** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */ + load(result: unknown, opts?: { keepView?: boolean }): void; + /** Zoom-extents to the loaded drawing. */ + fit(): void; + /** Resync renderer/camera to the container size (call after un-hiding). */ + resize(): void; + onSelect(cb: (entity: unknown) => void): void; + setTheme(dark: boolean): void; + setGrid(visible: boolean): void; + getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[]; + setHiddenLayers(nameSet: Set): void; + getZoomPercent(): number; + onViewChange(cb: () => void): void; + startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void; + stopMeasure(): void; +} diff --git a/src/viewer2d/Viewer2D.js b/src/viewer2d/Viewer2D.js new file mode 100644 index 0000000..f70e9fb --- /dev/null +++ b/src/viewer2d/Viewer2D.js @@ -0,0 +1,2383 @@ +/** + * Viewer2D — three.js renderer for DWG/DXF parseResult (orthographic 2D drawing). + * Ported from webviewer/src/viewer/Viewer3D.js (class renamed 3D→2D) for the + * 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites. + * Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID, + * TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge), + * DIMENSION_LINEAR/ALIGNED/RADIUS/DIAMETER/ANG_3PT/ANG_2LN/ORDINATE + */ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { aciToHex } from './aciColors.js'; +import { SlugTextEngine, SlugTextBatch } from './slugText'; + +const DEFAULT_COLOR = 0xc9d1d9; +const ARC_SEGS = 64; +const ELLIPSE_SEGS = 72; +const CLICK_THRESHOLD_PX = 14; + +function stripMText(s) { + if (!s) return ''; + return s + .replace(/\\A\d+;/g, '') // \A1; vertical alignment + .replace(/\\p[^;]*;/g, '') // \p...; paragraph properties (lowercase, has ;) + .replace(/\\P/g, '\n') // \P paragraph break → newline (capital, no ;) + .replace(/\\[a-zA-Z][^;]*;/g, '') // remaining inline codes \f \H \C \W \Q \T... + .replace(/\{[^{}]*\}/g, m => stripMText(m.slice(1, -1))) + .replace(/%%d/gi, '°').replace(/%%p/gi, '±').replace(/%%c/gi, 'Ø') + .replace(/[{}]/g, '') + .replace(/[ \t]{2,}/g, ' ') // collapse runs of spaces + .replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n) + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines +} + +export class Viewer2D { + constructor(container) { + this._container = container; + this._entityMeta = []; + this._layerByHandle = new Map(); + this._layerByName = new Map(); + this._onSelectCb = null; + this._measureActive = false; + this._measureCb = null; + this._measurePtA = null; + this._measureMarkers = []; + this._textGen = 0; // invalidation token for the async Slug text flush + SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure + + this._scene = new THREE.Scene(); + this._scene.background = new THREE.Color(0x0d1117); + + const w = container.clientWidth || 800; + const h = container.clientHeight || 600; + this._camera = new THREE.OrthographicCamera(-w/2, w/2, h/2, -h/2, -10000, 10000); + this._camera.position.set(0, 0, 100); + + this._renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); + this._renderer.setPixelRatio(window.devicePixelRatio); + this._renderer.setSize(w, h); + container.appendChild(this._renderer.domElement); + + this._controls = new OrbitControls(this._camera, this._renderer.domElement); + this._controls.enableRotate = false; + this._controls.screenSpacePanning = true; + this._controls.zoomToCursor = true; + this._controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; + this._controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN }; + + const savedSpeed = localStorage.getItem('hmw:zoom-speed'); + if (savedSpeed) { + const speed = parseFloat(savedSpeed); + if (!isNaN(speed)) { + this._controls.zoomSpeed = speed; + } + } + + window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); }); + + this._group = new THREE.Group(); + this._scene.add(this._group); + + window.addEventListener('resize', () => this._onResize()); + // Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas + this._renderer.domElement.addEventListener('pointerdown', (e) => { + if (e.button === 1) e.preventDefault(); + }); + this._renderer.domElement.addEventListener('mousedown', (e) => { + this._downXY = [e.clientX, e.clientY]; + if (e.button === 1) e.preventDefault(); + }); + this._renderer.domElement.addEventListener('click', (e) => this._onClick(e)); + this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 }; + this._gridVisible = false; + this._gridMesh = null; + this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); }); + this._animate(); + } + + onSelect(cb) { this._onSelectCb = cb; } + resize() { this._onResize(); } + setZoomSpeed(speed) { if (this._controls) this._controls.zoomSpeed = speed; } + getZoomSpeed() { return this._controls ? this._controls.zoomSpeed : 1.0; } + + fit() { + const box = new THREE.Box3(); + this._group.traverse(obj => { if (obj.geometry) box.expandByObject(obj); }); + if (!box.isEmpty()) this._fit(box); + } + + load(result, opts = {}) { + this._lastResult = result; + this._selMeta = null; + this._clear(); + this._entityMeta = []; + this._buildLayerMap(result?.tables?.layers); + this._buildLinetypeMap(result); + // Sheet orientation: the active model-space viewport's VIEWTWIST rotates the + // view so a drawing stored tilted in WCS (rotated survey sheets — header UCS + // stays identity) displays with its title border upright. Applied as a + // camera-up rotation in _fit (geometry untouched → picking stays exact). + this._viewTwist = this._readViewTwist(result); + // Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy. + // _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles, + // polylines, block wires, dimension leaders/arrows) with a parallel owner-meta + // index. _pickFills: filled regions (hatch loops, SOLID quads, text quads) + // tested by even-odd point-in-polygon. _onClick raycasts these directly. + this._pickSegs = []; + this._pickSegMeta = []; + this._pickFills = []; + this._pickCurIdx = -1; + + const entities = result?.entities || []; + const lineVerts = []; + const lineColors = []; + // Dashed-linetype segments go into separate buckets keyed by dash|gap size; + // each becomes its own LineDashedMaterial LineSegments at assembly. Solid + // (Continuous / ByLayer→Continuous) segments stay in lineVerts/lineColors. + const dashBuckets = new Map(); + let curDash = null; // {key,dash,gap} for the entity currently being emitted + const box = new THREE.Box3(); + const _tmp = new THREE.Vector3(); + const pendingTexts = []; + + // Build set of user block definition handles. + // Block-def entities have ownerHandle = their block's handle. They're rendered + // via INSERT (_insertEntities) and must be SKIPPED in the main loop to avoid + // expanding the bounding box with block-local coordinates (near 0,0), which + // would make the real drawing appear as a tiny speck on zoom-extents. + const blockDefHandles = new Set(); + // Also track model/paper space handles to verify correctness + const spaceHandles = new Set(); + // Block header handle (hex) → basePoint, for nested INSERT transform composition. + const blockBaseByHex = new Map(); + const blockNameByHex = new Map(); + for (const b of (result?.tables?.blocks ?? [])) { + const name = (b.name ?? '').toLowerCase().replace(/\*/g, '').trim(); + const h = typeof b.handle === 'object' ? b.handle?.value?.toString(16) : b.handle?.toString(16); + if (!h) continue; + if (b.basePoint) blockBaseByHex.set(h, b.basePoint); + blockNameByHex.set(h, (b.name ?? '').toLowerCase()); + // System space blocks: any of these names → entity owner, render directly + if (name === 'model_space' || name === 'paper_space' || + name === 'ms' || name === 'ps' || + name.startsWith('model') || name.startsWith('paper') || + name === '') { + spaceHandles.add(h); + } else { + blockDefHandles.add(h); + } + } + // Safety: if spaceHandles is empty we couldn't identify model space → + // disable the filter entirely to avoid hiding everything. + const useBlockFilter = spaceHandles.size > 0; + this._blockCount = blockDefHandles.size; + + // Pre-group all entities by ownerHandle hex string for O(1) lookup. + // Without this, every INSERT/DIMENSION does entities.filter() = O(n) per call = O(n²) total. + const entsByOwner = new Map(); + for (const ent of entities) { + const key = ent.ownerHandle?.value?.toString(16); + if (key != null) { + if (!entsByOwner.has(key)) entsByOwner.set(key, []); + entsByOwner.get(key).push(ent); + } + } + + const expand = (x, y, z = 0) => box.expandByPoint(_tmp.set(x, y, z)); + const pushSeg = (ax, ay, bx, by, z, color) => { + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + if (curDash) { + let bk = dashBuckets.get(curDash.key); + if (!bk) { bk = { dash: curDash.dash, gap: curDash.gap, verts: [], colors: [] }; dashBuckets.set(curDash.key, bk); } + bk.verts.push(ax, ay, z, bx, by, z); + bk.colors.push(r, g, b, r, g, b); + } else { + lineVerts.push(ax, ay, z, bx, by, z); + lineColors.push(r, g, b, r, g, b); + } + if (this._pickCurIdx >= 0) { this._pickSegs.push(ax, ay, bx, by); this._pickSegMeta.push(this._pickCurIdx); } + expand(ax, ay, z); expand(bx, by, z); + }; + + for (const e of entities) { + // Skip block-definition entities — they live at block-local coords and are + // rendered at correct world positions via INSERT / _insertEntities. + // Only apply filter when we've successfully identified model/paper space handles. + const ownerHex = e.ownerHandle?.value?.toString(16); + if (useBlockFilter && ownerHex && blockDefHandles.has(ownerHex)) continue; + if (this._hiddenLayers?.size) { + const lh = e.layerHandle?.value ?? e.layerHandle; + const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName; + if (lname && this._hiddenLayers.has(lname)) continue; + } + const d = e.data || e; + const type = (e.type || e.typeName || '').toUpperCase(); + const color = this._entityColor(e); + curDash = this._resolveDash(e); + const meta = { entity: e, type, bounds: null, colStart: lineColors.length }; + this._entityMeta.push(meta); + this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below + + try { + switch (type) { + + // ── Basic geometry ──────────────────────────────────────────────── + case 'LINE': + if (d.start && d.end) { + pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color); + meta.bounds = { type:'line', x1:d.start.x, y1:d.start.y, x2:d.end.x, y2:d.end.y }; + } + break; + + case 'CIRCLE': + if (d.center && d.radius != null) { + this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg); + meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; + } + break; + + case 'ARC': + if (d.center && d.radius != null) { + this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg); + meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; + } + break; + + case 'LWPOLYLINE': + if (d.points?.length) { + const hasBulge = d.bulges?.some(b => Math.abs(b) >= 1e-6); + const closed = !!(d.closed || (d.flags & 1)); + const nSeg = closed ? d.points.length : d.points.length - 1; + if (this._polyHasWidth(e, nSeg, 1)) { + this._widePolyMesh(d.points, d.bulges, closed, e, color, 1, expand); + } else if (hasBulge) { + this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand); + } else { + this._polylineSegs(d.points, closed, d.elevation||0, color, pushSeg); + d.points.forEach(p => expand(p.x, p.y, d.elevation||0)); + } + // Note: uncovered-poly fill is only applied in _insertEntities (block + // glyphs). Model-space polylines can be huge parcels — never fill those. + meta.bounds = { type:'point', cx:d.points[0].x, cy:d.points[0].y }; + } + break; + + case 'POLYLINE': + this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg); + break; + + case 'POLYLINE_2D': { + // Old-style 2D polyline: vertices are separate VERTEX_2D entities + // owned by this polyline (point/bulge at top level, not under .data). + const kids = (entsByOwner.get(e.handle?.value?.toString(16)) ?? []) + .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); + if (kids.length >= 2) { + const pts = kids.map(v => v.point); + const bulges = kids.map(v => v.bulge || 0); + const closed = (d.flags & 1) === 1; + const elev = d.elevation || 0; + if (bulges.some(b => Math.abs(b) >= 1e-6)) { + this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand); + } else { + this._polylineSegs(pts, closed, elev, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, elev)); + } + meta.bounds = { type:'point', cx: pts[0].x, cy: pts[0].y }; + } + break; + } + + case 'POINT': { + const p = d.position || d; + if (typeof p.x === 'number') { + const s = 1; + pushSeg(p.x-s, p.y, p.x+s, p.y, p.z||0, color); + pushSeg(p.x, p.y-s, p.x, p.y+s, p.z||0, color); + meta.bounds = { type:'point', cx:p.x, cy:p.y }; + } + break; + } + + case 'ELLIPSE': + if (d.center) { + this._ellipseSegs(d, color, pushSeg); + meta.bounds = { type:'point', cx:d.center.x, cy:d.center.y }; + } + break; + + case 'SPLINE': { + const cps = d.controlPoints ?? d.fitPoints; + if (cps?.length >= 2) { + const pts = cps.length >= 3 + ? (() => { + const segs = cps.length * 10; + const result = []; + for (let i = 0; i <= segs; i++) { + const t = i / segs; + result.push(this._catmullRom(cps, t)); + } + return result; + })() + : cps; + this._polylineSegs(pts, false, 0, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; + } + break; + } + + case 'ATTRIB': + case 'ATTDEF': { + if ((d.flags ?? 0) & 1) break; // invisible attribute + const raw = e.text ?? d.text ?? d.textValue ?? d.defaultValue ?? ''; + const text = stripMText(raw); + const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; + const rotation = e.rotationAngle ?? d.rotationAngle ?? 0; + const alignH = e.horizAlignment ?? d.horizAlignment ?? 0; + const alignV = e.vertAlignment ?? d.vertAlignment ?? 0; + // When aligned (non-default H/V), the alignmentPt is the true anchor; + // insertionPt is the "first point" and differs for centered/right text. + const useAlignPt = alignH !== 0 || alignV !== 0; + const pos = useAlignPt + ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }) + : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); + if (text) { + // Force vertical center for ATTRIB — attribute values (e.g. node tags) appear centered + pendingTexts.push({ text, pos, height, rotation, color, alignH, alignV: 2 }); + meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; + this._addTextPick(text, pos, height, alignH, 2); + expand(pos.x, pos.y, 0); + } + break; + } + + case 'XLINE': + case 'RAY': { + // Infinite/semi-infinite lines — render as very long line segment + const bp = d.basePoint ?? d.point ?? d.startPoint; + const dir = d.direction ?? d.unitDir ?? d.vector; + if (bp && dir) { + const len = 1e6; + const ex = bp.x + dir.x * len, ey = bp.y + dir.y * len; + if (type === 'XLINE') { + pushSeg(bp.x - dir.x * len, bp.y - dir.y * len, ex, ey, 0, color); + } else { + pushSeg(bp.x, bp.y, ex, ey, 0, color); + } + meta.bounds = { type:'point', cx:bp.x, cy:bp.y }; + expand(bp.x, bp.y); + } + break; + } + + case 'SOLID': { + const corners = [d.corner1||d.pt1||d.point1, d.corner2||d.pt2||d.point2, + d.corner3||d.pt3||d.point3, d.corner4||d.pt4||d.point4].filter(Boolean); + if (corners.length >= 3) { + this._solidMesh(corners, color); + meta.bounds = { type:'point', cx:corners[0].x, cy:corners[0].y }; + } + break; + } + + // ── Text ────────────────────────────────────────────────────────── + case 'TEXT': + case 'MTEXT': { + const raw = e.text ?? d.text ?? ''; + const text = stripMText(raw); + const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; + let rotation = 0, alignH = 0, alignV = 0, pos; + if (type === 'TEXT') { + rotation = e.rotationAngle ?? d.rotationAngle ?? 0; + alignH = e.horizAlignment ?? d.horizAlignment ?? 0; + alignV = e.vertAlignment ?? d.vertAlignment ?? 0; + // DXF group 72 = 4 ("Middle") centers the text both horizontally AND + // vertically on the alignment point — group 73 (vertical) is ignored. + // Without this, vA=0 (baseline) placed such text half a line too high + // (titleblock labels 노선이정/설계사/… floated up). + if (alignH === 4) alignV = 2; + // DWG: when alignment is non-default, alignmentPt is the actual anchor + // (insertionPt is the "first point" which may differ from anchor) + const useAlignPt = alignH !== 0 || alignV !== 0; + pos = useAlignPt + ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x:0, y:0 }) + : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); + } else { + const xd = e.xAxisDir ?? d.xAxisDir; + if (xd) rotation = Math.atan2(xd.y, xd.x); + // MTEXT: attachmentPoint 1-3=top, 4-6=mid, 7-9=bottom + // (parsers emit the field as `attachment`; keep both names) + const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5; + alignH = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); + alignV = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); + pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }; + } + if (text) { + const entry = { text, pos, height, rotation, color, alignH, alignV }; + if (type === 'MTEXT') { + entry.linespacingFactor = e.linespacingFactor ?? d.linespacingFactor ?? 1; + entry.rectWidth = e.rectWidth ?? d.rectWidth ?? 0; + entry.rectHeight = e.rectHeight ?? d.rectHeight ?? 0; + // Background mask (DXF 90/45/63): bit0=use fill color, bit1=window color, bit4=frame + entry.bgFillFlags = e.bgFillFlags ?? d.bgFillFlags ?? 0; + entry.bgScale = e.bgScale ?? d.bgScale ?? 1.5; + entry.bgColorIndex = e.bgColorIndex ?? d.bgColorIndex; + entry.bgColorRgb = e.bgColorRgb ?? d.bgColorRgb; + } + pendingTexts.push(entry); + meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; + this._addTextPick(text, pos, height, alignH, alignV); + expand(pos.x, pos.y, 0); + } + break; + } + + // ── INSERT (block reference) ────────────────────────────────────── + case 'INSERT': { + const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint; + const sx = d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1; + const sy = d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1; + const rot = d.rotation ?? e.rotation ?? 0; + const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; + + if (bhVal != null) { + // Block definition entities sit in the flat entities array, + // identified by ownerHandle.value === block header handle + const bHandleHex = bhVal.toString(16); + const bEnts = entsByOwner.get(bHandleHex) ?? []; + if (bEnts.length > 0) { + const bh = result?.tables?.blocks?.find(b => { + const bh2 = typeof b.handle === 'object' ? b.handle?.value : b.handle; + return bh2 === bhVal; + }); + this._insertEntities( + bEnts, + { insertionPoint: ip ?? {x:0,y:0,z:0}, xScale:sx, yScale:sy, rotation:rot, basePoint: bh?.basePoint }, + color, pushSeg, pendingTexts, + { entsByOwner, blockBase: blockBaseByHex } + ); + } + } + if (ip) { meta.bounds = { type:'point', cx:ip.x, cy:ip.y }; expand(ip.x, ip.y, 0); } + break; + } + + // ── HATCH ───────────────────────────────────────────────────────── + case 'HATCH': { + const paths = d.paths ?? e.paths; + const solidFill = d.solidFill ?? e.solidFill ?? false; + if (!paths?.length) break; + + let firstPt = null; + for (const path of paths) { + if (!path.points?.length || path.points.length < 2) continue; + if (!firstPt) firstPt = path.points[0]; + const hasBulge = path.bulges?.some(b => Math.abs(b) >= 1e-6); + if (hasBulge) { + this._bulgePolySegs(path.points, path.bulges, true, color, pushSeg, expand); + } else { + this._polylineSegs(path.points, true, 0, color, pushSeg); + path.points.forEach(p => expand(p.x, p.y, 0)); + } + } + if (solidFill) this._hatchFill(paths, color); + if (firstPt) meta.bounds = { type:'point', cx:firstPt.x, cy:firstPt.y }; + break; + } + + // ── DIMENSION (all subtypes) ─────────────────────────────────────── + case 'DIMENSION': + case 'DIMENSION_LINEAR': + case 'DIMENSION_ALIGNED': + case 'DIMENSION_RADIUS': + case 'DIMENSION_DIAMETER': + case 'DIMENSION_ANG_3PT': + case 'DIMENSION_ANG_2LN': + case 'DIMENSION_ORDINATE': { + const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; + let dimEnts = []; + if (bhVal != null) { + const hex = bhVal.toString(16); + dimEnts = entsByOwner.get(hex) ?? []; + } + + if (dimEnts.length > 0) { + // Render pre-built dimension block geometry + for (const child of dimEnts) { + const ct = (child.type || child.typeName || '').toUpperCase(); + const cd = child.data || child; + const cc = this._entityColor(child) || color; + if (ct === 'LINE' && cd.start && cd.end) { + pushSeg(cd.start.x, cd.start.y, cd.end.x, cd.end.y, 0, cc); + expand(cd.start.x, cd.start.y); expand(cd.end.x, cd.end.y); + } else if (ct === 'SOLID') { + const crs = [cd.corner1||cd.pt1, cd.corner2||cd.pt2, + cd.corner3||cd.pt3, cd.corner4||cd.pt4].filter(Boolean); + if (crs.length >= 3) { this._solidMesh(crs, cc); crs.forEach(c => expand(c.x, c.y)); } + } else if (ct === 'ARC' && cd.center && cd.radius != null) { + this._arcSegs(cd.center, cd.radius, cd.startAngle??0, cd.endAngle??Math.PI*2, 0, cc, pushSeg); + } else if ((ct === 'TEXT' || ct === 'MTEXT')) { + const raw = child.text ?? cd.text ?? cd.textValue ?? ''; + const text = stripMText(raw); + const pos = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; + const ht = cd.textHeight ?? cd.height ?? child.textHeight ?? 2.5; + if (text) { pendingTexts.push({ text, pos, height:ht, rotation: cd.rotationAngle??0, color:cc, alignH:1, alignV:2 }); expand(pos.x, pos.y); } + } else if (ct === 'INSERT') { + // Arrowhead block INSERT within the dimension block: render the real + // arrow geometry; fall back to a named default for geometry-less + // system blocks (_ClosedFilled / _Dot / _Oblique / _Open / …). + const cip = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; + const crot = cd.rotation ?? child.rotation ?? 0; + const csx = cd.scaleX ?? cd.scale?.x ?? child.scale?.x ?? 1; + const csy = cd.scaleY ?? cd.scale?.y ?? child.scale?.y ?? csx; + const abh = child.blockHeaderHandle?.value ?? cd.blockHeaderHandle?.value; + const aHex = abh?.toString(16); + const aname = blockNameByHex.get(aHex) ?? ''; + const arrowEnts = aHex ? (entsByOwner.get(aHex) ?? []) : []; + if (aname.includes('dot')) { + // Dot arrowhead → filled disc (radius from the block's geometry × scale) + this._dotMesh(cip, this._arrowDotRadius(arrowEnts) * Math.abs(csx) || Math.abs(csx) * 0.5, cc); + } else if (arrowEnts.length) { + this._insertEntities( + arrowEnts, + { insertionPoint: cip, xScale: csx, yScale: csy, rotation: crot, basePoint: blockBaseByHex.get(aHex) }, + cc, pushSeg, pendingTexts, + { entsByOwner, blockBase: blockBaseByHex } + ); + } else { + this._defaultArrow(aname, cip, crot, Math.abs(csx) || 1, cc, pushSeg); + } + expand(cip.x, cip.y); + } + } + const fl = dimEnts.find(c => (c.type||c.typeName||'').toUpperCase() === 'LINE'); + const fd = fl?.data || fl; + if (fd?.start) meta.bounds = { type:'point', cx:fd.start.x, cy:fd.start.y }; + } else { + // Fallback: reconstruct from entity properties + this._renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand); + const pt10 = e.pt10 ?? d.pt10; + if (pt10) { meta.bounds = { type:'point', cx:pt10.x, cy:pt10.y }; expand(pt10.x, pt10.y); } + } + break; + } + + case 'LEADER': { + const pts = d.points ?? e.points; + if (pts?.length >= 2) { + this._polylineSegs(pts, false, 0, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + if (d.arrowheadOn ?? e.arrowheadOn ?? true) { + const p0 = pts[0], p1 = pts[1]; + const rot = Math.atan2(p1.y - p0.y, p1.x - p0.x); + const arrowSz = d.arrowSize ?? e.arrowSize ?? 2.5; + this._arrowMesh(p0, rot, arrowSz, color); + } + meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; + } + break; + } + + default: break; + } + } catch { /* skip malformed entity */ } + meta.colEnd = lineColors.length; + } + + // ── Render deferred text with bbox-proportional minimum size ────────── + if (pendingTexts.length) { + const sz = box.isEmpty() ? new THREE.Vector3(1,1,0) : box.getSize(new THREE.Vector3()); + const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1)); + const minTextH = diagSize * 0.0002; // 0.02% of diagonal → readable at fit-view + // Cap sprite count to avoid GPU/heap OOM on large drawings. With the + // per-(text,color) texture cache in _textSprite, memory scales with + // UNIQUE strings, so the cap is a sprite-object guard, not a raster one + // (3000 previously culled half the texts on table sheets, e.g. BLOCK4). + const MAX_TEXTS = 20000; + const renderTexts = pendingTexts.length > MAX_TEXTS + ? (console.warn(`텍스트 ${pendingTexts.length}개 → ${MAX_TEXTS}개로 제한`), + pendingTexts.sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS)) + : pendingTexts; + void this._drawTexts(renderTexts, minTextH); + } + + this._lineColorAttr = null; this._origColors = null; + if (lineVerts.length) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3)); + const colorAttr = new THREE.Float32BufferAttribute(lineColors, 3); + geom.setAttribute('color', colorAttr); + this._group.add(new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true }))); + this._lineColorAttr = colorAttr; + this._origColors = Float32Array.from(colorAttr.array); + } + + // Dashed linetypes (HIDDEN / CENTER / …) — one LineSegments per dash|gap + // bucket with a LineDashedMaterial. computeLineDistances() is REQUIRED for + // the dash pattern to appear; on LineSegments each 2-vertex pair dashes + // independently from its own start (correct for CAD segments). + for (const bk of dashBuckets.values()) { + if (!bk.verts.length) continue; + const dgeom = new THREE.BufferGeometry(); + dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3)); + dgeom.setAttribute('color', new THREE.Float32BufferAttribute(bk.colors, 3)); + const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({ + vertexColors: true, dashSize: bk.dash, gapSize: bk.gap, + })); + dline.computeLineDistances(); + this._group.add(dline); + } + + if (!opts.keepView && !box.isEmpty()) this._fit(box); + } + + // ── UI integration ───────────────────────────────────────────────────────── + + /** Swap canvas background for theme. dark=true → near-black, false → light. */ + setTheme(dark) { + this._scene.background = new THREE.Color(dark ? 0x0a0b0d : 0xf7f6f3); + if (this._gridVisible) this._rebuildGrid(); + } + + setGrid(visible) { + this._gridVisible = visible; + if (!visible) { + if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } + } else { + this._rebuildGrid(); + } + } + + _rebuildGrid() { + if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } + const zoom = this._camera.zoom || 1; + const cx = this._camera.position.x; + const cy = this._camera.position.y; + const hw = (this._camera.right - this._camera.left) / zoom / 2; + const hh = (this._camera.top - this._camera.bottom) / zoom / 2; + const ext = Math.max(hw, hh) * 3; + const spacing = this._niceSpacing(Math.max(hw, hh) * 2 / 20); + const [minX, maxX, minY, maxY] = [cx - ext, cx + ext, cy - ext, cy + ext]; + const isDark = this._scene.background.r < 0.5; + const color = isDark ? 0x2b2b2b : 0xbbbbbb; + const verts = []; + for (let x = Math.ceil(minX / spacing) * spacing; x <= maxX; x += spacing) verts.push(x, minY, -0.5, x, maxY, -0.5); + for (let y = Math.ceil(minY / spacing) * spacing; y <= maxY; y += spacing) verts.push(minX, y, -0.5, maxX, y, -0.5); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + this._gridMesh = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color })); + this._scene.add(this._gridMesh); + } + + _niceSpacing(raw) { + if (raw <= 0) return 1; + const exp = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / exp; + return (norm < 2 ? 1 : norm < 5 ? 2 : 5) * exp; + } + + /** Zoom % relative to fit (ortho camera zoom). */ + getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); } + + /** Subscribe to pan/zoom changes (for live zoom readout). */ + onViewChange(cb) { this._controls?.addEventListener('change', cb); } + + /** Hide entities on the given layer names (Set); re-renders keeping the view. */ + setHiddenLayers(nameSet) { + this._hiddenLayers = nameSet || new Set(); + if (this._lastResult) this.load(this._lastResult, { keepView: true }); + } + + /** Entity count, block definition count — for recent-files metadata. */ + getStats() { + return { entities: this._entityMeta.length, blocks: this._blockCount ?? 0 }; + } + + /** Capture current view as a WebP Blob. Requires preserveDrawingBuffer:true (already set). */ + snapshotWebPBlob() { + return new Promise((resolve) => { + this._renderer.render(this._scene, this._camera); + this._renderer.domElement.toBlob((b) => resolve(b), 'image/webp', 0.75); + }); + } + + /** Layer list for the Layers panel: [{ name, colorHex, count, visible }]. */ + getLayerInfo() { + const result = this._lastResult; + if (!result) return []; + const counts = new Map(); + for (const e of (result.entities || [])) { + const lh = e.layerHandle?.value ?? e.layerHandle; + const name = (lh != null && this._layerNameByHandle?.get(String(lh))) + ?? e.layer ?? e.layerName ?? '0'; + counts.set(name, (counts.get(name) || 0) + 1); + } + const hidden = this._hiddenLayers || new Set(); + return (result.tables?.layers || []).map(l => { + const name = l.name ?? l.layerName ?? '0'; + const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); + return { + name, + colorHex: '#' + ((aciToHex(aci) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')), + count: counts.get(name) || 0, + visible: !hidden.has(name), + }; + }).filter(l => l.count > 0 || !l.name.startsWith('*')); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + _buildLayerMap(layers) { + this._layerByHandle.clear(); + this._layerByName.clear(); + this._layerNameByHandle = new Map(); + this._layer0Handle = null; + if (!layers) return; + for (const l of layers) { + const handle = l.handle?.value ?? l.handle; + const name = l.name ?? l.layerName; + const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); + const hex = aciToHex(aci) ?? DEFAULT_COLOR; + if (handle != null) this._layerByHandle.set(String(handle), hex); + if (name) this._layerByName.set(name, hex); + if (handle != null && name) this._layerNameByHandle.set(String(handle), name); + if (name === '0' && handle != null) this._layer0Handle = String(handle); + } + } + + // Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the + // camera up by -viewTwist (verified against samples/11.dwg: title border upright). + _readViewTwist(result) { + const vports = result?.tables?.vports; + if (!Array.isArray(vports) || !vports.length) return 0; + const vp = vports.find(v => /active/i.test(v.name || '')) || vports[0]; + const tw = vp?.viewTwist; + return (typeof tw === 'number' && isFinite(tw)) ? tw : 0; + } + + // Build linetype lookup: pattern-by-name (signed element lengths, drawing + // units) + layer→linetype-name maps + the global LTSCALE. Consumed by + // _resolveDash to turn an entity's linetype into a dash/gap size. + _buildLinetypeMap(result) { + this._ltPatterns = new Map(); + this._layerLtByName = new Map(); + this._layerLtByHandle = new Map(); + this._globalLtscale = (result?.vars?.ltscale > 0 ? result.vars.ltscale : 1); + for (const lt of (result?.tables?.lineTypes ?? [])) { + if (lt?.name) this._ltPatterns.set(lt.name, Array.isArray(lt.pattern) ? lt.pattern : []); + } + for (const l of (result?.tables?.layers ?? [])) { + const name = l.name ?? l.layerName; + const handle = l.handle?.value ?? l.handle; + const ltn = l.lineType ?? l.linetype; + if (name && ltn) this._layerLtByName.set(name, ltn); + if (handle != null && ltn) this._layerLtByHandle.set(String(handle), ltn); + } + } + + // Resolve an entity's dash pattern → { key, dash, gap } world-unit sizes, or + // null for a solid line. BYLAYER / BYBLOCK / empty fall back to the layer's + // linetype; dash/gap = Σ|element| × LTSCALE × entity linetypeScale (CELTSCALE). + _resolveDash(e) { + if (!this._ltPatterns || this._ltPatterns.size === 0) return null; + let ltName = e.lineType ?? e.linetype; + if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' || + ltName === 'ByBlock' || ltName === 'BYBLOCK') { + const lh = e.layerHandle?.value ?? e.layerHandle; + ltName = (lh != null && this._layerLtByHandle.get(String(lh))) + || this._layerLtByName.get(e.layer ?? e.layerName) || null; + } + if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null; + const pattern = this._ltPatterns.get(ltName); + if (!pattern || pattern.length === 0) return null; + const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1); + const scale = this._globalLtscale * entScale; + let dash = 0, gap = 0; + for (const d of pattern) { + const len = Math.abs(d) * scale; + if (d > 1e-6) dash += len; + else if (d < -1e-6) gap += len; + else dash += Math.max(len, 0.01 * scale); // dot → tiny dash + } + if (dash < 1e-4 || gap < 1e-4) return null; + return { key: `${dash.toFixed(3)}|${gap.toFixed(3)}`, dash, gap }; + } + + // Resolve an entity's color inside a block, given the color inherited from the + // INSERT context. BYBLOCK and layer-0 BYLAYER entities inherit the INSERT color + // (AutoCAD's layer-0 rule); everything else uses its own color/layer. + _resolveBlockColor(entity, inherited) { + const aci = entity.entityHeader?.colorIndex + ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; + if (aci === 0) return inherited; // BYBLOCK + const lh = String(entity.layerHandle?.value ?? entity.layerHandle ?? ''); + if (aci === 256 && this._layer0Handle && lh === this._layer0Handle) return inherited; + return this._entityColor(entity); + } + + _entityColor(entity) { + const aci = entity.entityHeader?.colorIndex + ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; + if (aci != null) { + const hex = aciToHex(aci); + if (hex !== null) return hex; + } + const lh = entity.layerHandle?.value ?? entity.layerHandle; + if (lh != null) { + const c = this._layerByHandle.get(String(lh)); + if (c !== undefined) return c; + } + const ln = entity.layer ?? entity.layerName; + if (ln) { + const c = this._layerByName.get(ln); + if (c !== undefined) return c; + } + return DEFAULT_COLOR; + } + + // Filled triangle arrow mesh (replaces V-line arrowhead) + _arrowMesh(pt, rotation, size, color) { + if (!pt || size < 1e-4) return; + const w = size * 0.166; + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute([0,0,0, -size,w,0, -size,-w,0], 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute([r,g,b, r,g,b, r,g,b], 3)); + const mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })); + mesh.position.set(pt.x, pt.y, (pt.z || 0) + 0.5); + mesh.rotation.z = rotation; + this._group.add(mesh); + } + + // Default geometry for AutoCAD standard arrowhead blocks that ship no geometry + // in the file (system blocks). `name` is the lowercased block name. + _defaultArrow(name, pt, rotation, size, color, pushSeg) { + if (!pt || size < 1e-4) return; + const n = (name || '').replace(/^[_*]+/, ''); + const cos = Math.cos(rotation), sin = Math.sin(rotation); + // local→world: arrow points toward +X at the insertion point + const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos }); + if (n === 'none' || n === 'small' || n === 'integral') return; + if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') { + this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color); + return; + } + if (n === 'oblique' || n === 'archtick') { + const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5); + pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color); + return; + } + if (n === 'open' || n === 'open30' || n === 'open90') { + const w = n === 'open90' ? size : size * 0.42; + const t = P(0, 0), u = P(-size, w), v = P(-size, -w); + pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color); + pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color); + return; + } + // default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle + this._arrowMesh(pt, rotation, size, color); + } + + // Filled disc (dot arrowhead). + _dotMesh(center, radius, color) { + if (!center || !(radius > 1e-5)) return; + const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255; + const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5; + for (let i = 0; i < segs; i++) { + const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2; + verts.push(center.x, center.y, z, + center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z, + center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z); + cols.push(r, g, b, r, g, b, r, g, b); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); + this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); + } + + // Dot radius (block-local) from an arrow block's geometry, measured from its centroid. + _arrowDotRadius(ents) { + let r = 0; + for (const ae of (ents || [])) { + const ad = ae.data || ae; + if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius); + const pts = ad.points ?? ad.vertices; + if (pts?.length) { + let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length; + pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); }); + } + } + return r; + } + + _arcSegs(center, r, a0, a1, z, color, pushSeg) { + let span = a1 - a0; + if (span <= 0) span += Math.PI * 2; + const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS)); + let prev = null; + for (let i = 0; i <= steps; i++) { + const a = a0 + (span * i) / steps; + const x = center.x + r * Math.cos(a); + const y = center.y + r * Math.sin(a); + if (prev) pushSeg(prev.x, prev.y, x, y, z, color); + prev = { x, y }; + } + } + + // Centripetal CatmullRom interpolation for SPLINE + _catmullRom(pts, t) { + const n = pts.length; + const f = t * (n - 1); + const i = Math.min(Math.floor(f), n - 2); + const tt = f - i; + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + const t2 = tt * tt, t3 = t2 * tt; + return { + x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3), + y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3), + z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3), + }; + } + + _ellipseSegs(d, color, pushSeg) { + // field names: majorAxis or smAxis; ratio or axisRatio + const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 }; + const center = d.center; + const ratio = d.ratio ?? d.axisRatio ?? 1; + const startParam = d.startParam ?? 0; + const endParam = d.endParam ?? Math.PI * 2; + const mx = ma?.x ?? 1, my = ma?.y ?? 0; + const a = Math.sqrt(mx*mx + my*my); + const b = a * ratio; + const rot = Math.atan2(my, mx); + let span = endParam - startParam; + if (span <= 0) span += Math.PI * 2; + let prev = null; + for (let i = 0; i <= ELLIPSE_SEGS; i++) { + const t = startParam + (span * i) / ELLIPSE_SEGS; + const ex = a * Math.cos(t), ey = b * Math.sin(t); + const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot); + const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot); + if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color); + prev = { x, y }; + } + } + + _polylineSegs(points, closed, z, color, pushSeg) { + if (!points || points.length < 2) return; + for (let i = 0; i < points.length - 1; i++) { + pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color); + } + if (closed) { + const a = points[points.length-1], b = points[0]; + pushSeg(a.x, a.y, b.x, b.y, z, color); + } + } + + // Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant). + _segWidths(ent, i, scale = 1) { + const d = ent?.data || ent || {}; + const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale; + const dsw = (d.defaultStartWidth ?? 0) * scale; + const dew = (d.defaultEndWidth ?? 0) * scale; + const sw0 = (d.startWidths?.[i] ?? 0) * scale; + const ew0 = (d.endWidths?.[i] ?? 0) * scale; + const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw); + const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew); + return { sw: Math.abs(sw), ew: Math.abs(ew) }; + } + + _polyHasWidth(ent, nSeg, scale = 1) { + for (let i = 0; i < nSeg; i++) { + const { sw, ew } = this._segWidths(ent, i, scale); + if (sw > 1e-9 || ew > 1e-9) return true; + } + return false; + } + + // Draw one trapezoid segment (variable start/end half-width) as two triangles. + _pushWideSeg(verts, p1, p2, w0, w1) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + if (len < 1e-12) return; + const nx = -dy / len, ny = dx / len; + const h0 = w0 * 0.5, h1 = w1 * 0.5; + const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 }; + const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 }; + const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 }; + const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 }; + // two triangles a-b-c, a-c-d + verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0); + verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0); + } + + // Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any + // segment has start/end/constant width > 0 (도곽 outer frame etc.). + _widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) { + if (!points || points.length < 2) return; + const n = points.length; + const segs = closed ? n : n - 1; + const verts = []; + for (let i = 0; i < segs; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % n]; + const { sw, ew } = this._segWidths(ent, i, scale); + if (sw < 1e-9 && ew < 1e-9) { + // Zero-width segment: still emit a tiny hair so it isn't dropped entirely. + this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + continue; + } + const bulge = bulges?.[i] || 0; + if (Math.abs(bulge) < 1e-6) { + this._pushWideSeg(verts, p1, p2, sw, ew); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } else { + // Tessellate bulge arc; interpolate width along the arc. + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist = Math.hypot(dx, dy); + if (dist < 1e-9) continue; + const inc = 4 * Math.atan(bulge); + const half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half); + const apothem = radius * Math.cos(half); + const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; + const nx2 = -dy / dist, ny2 = dx / dist; + const sign = bulge > 0 ? 1 : -1; + const cx = mx2 + sign * nx2 * apothem; + const cy = my2 + sign * ny2 * apothem; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + let prev = { x: p1.x, y: p1.y }; + let prevW = sw; + for (let k = 1; k <= steps; k++) { + const t = k / steps; + const a = sa + (inc * k) / steps; + const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }; + const curW = sw + (ew - sw) * t; + this._pushWideSeg(verts, prev, cur, prevW, curW); + prev = cur; + prevW = curW; + } + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } + } + if (verts.length < 9) return; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.computeVertexNormals(); + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(color), + side: THREE.DoubleSide, + depthWrite: false, + }); + this._group.add(new THREE.Mesh(geo, mat)); + // Pick as edge segments only — do NOT register the poly as a fill loop. + // A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole + // interior as "covered" and (with a weak size check) hide every inner line. + if (this._pickCurIdx >= 0 && this._pickSegs) { + for (let i = 0; i < segs; i++) { + const a = points[i], b = points[(i + 1) % n]; + this._pickSegs.push(a.x, a.y, b.x, b.y); + this._pickSegMeta.push(this._pickCurIdx); + } + } + } + + // Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries) + _bulgePolySegs(points, bulges, closed, color, pushSeg, expand) { + const n = points.length; + const segs = closed ? n : n - 1; + for (let i = 0; i < segs; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % n]; + const bulge = bulges?.[i] || 0; + if (Math.abs(bulge) < 1e-6) { + pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } else { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist = Math.sqrt(dx*dx + dy*dy); + if (dist < 1e-9) continue; + // Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle + // directly (rather than computing both endpoint angles and resolving the + // 2π wrap) avoids drawing the major arc when |bulge| is tiny. + const inc = 4 * Math.atan(bulge); // signed; >0 = CCW + const half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half); + const apothem = radius * Math.cos(half); + const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; + const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2 + const sign = bulge > 0 ? 1 : -1; + const cx = mx2 + sign * nx2 * apothem; + const cy = my2 + sign * ny2 * apothem; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + let prev = p1; + for (let k = 1; k <= steps; k++) { + const a = sa + (inc * k) / steps; + const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a); + pushSeg(prev.x, prev.y, x, y, 0, color); + prev = { x, y }; + } + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } + } + } + + _solidMesh(corners, color) { + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const verts = [], cols = []; + const addTri = (...pts) => { + for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); } + }; + if (corners.length >= 4) { + addTri(corners[0], corners[1], corners[3]); + addTri(corners[0], corners[3], corners[2]); + } else { + addTri(corners[0], corners[1], corners[2]); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); + this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); + // Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2). + if (this._pickCurIdx >= 0 && corners.length >= 3) { + const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3); + this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] }); + } + } + + // Text has no line geometry — register its box as a pickable fill so a click + // anywhere on the text selects it. Box follows the sprite's anchor rules + // (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle). + _addTextPick(text, pos, height, alignH = 0, alignV = 0) { + if (this._pickCurIdx < 0 || !pos) return; + const rows = String(text).split('\n'); + const cols = rows.reduce((m, s) => Math.max(m, s.length), 1); + const w = cols * height * 0.62; // ~ per-glyph advance + const h = rows.length * height * 1.2; + let x0 = pos.x; + if (alignH === 2) x0 = pos.x - w; // right anchor + else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center + let y0 = pos.y; // bottom anchor + if (alignV === 3) y0 = pos.y - h; // top anchor + else if (alignV === 2) y0 = pos.y - h / 2; // middle + this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[ + { x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h }, + ]] }); + } + + // Even-odd point-in-region across ALL loops of a fill (holes exclude correctly). + _fillHit(loops, x, y) { + let inside = false; + for (const poly of loops) { + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; + } + } + return inside; + } + + // True if (x,y) lies inside ANY single loop (NOT even-odd across loops). + // Used to detect "this point belongs to hatch geometry" including hole + // interiors — even-odd would report holes as uncovered and let a later + // LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters). + _pointInAnyLoop(loops, x, y) { + for (const poly of loops) { + if (!poly?.length) continue; + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + if (c) return true; + } + return false; + } + + // Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills). + _loopArea(poly) { + let s = 0; + for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } + return Math.abs(s) / 2; + } + + // Solid-fill a polyline that is geometrically closed but not covered by any + // existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as + // LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs + // have solid HATCH — without this they render as empty outlines. + // + // Coverage test is ANY-loop containment (not even-odd). Logo blocks list + // HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd + // treats hole interiors as "uncovered", so the post-hatch polyline pass used + // to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true + // orphan outlines (ㅅ with no hatch) fill, while skipping anything that + // already belongs to a hatch loop — fill or hole. + _fillClosedPolyIfUncovered(points, bulges, color) { + if (!points || points.length < 3) return; + const a = points[0], b = points[points.length - 1]; + const gap = Math.hypot(a.x - b.x, a.y - b.y); + // Allow tiny gaps relative to polyline size (floating-point closed loops). + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of points) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + const diag = Math.hypot(maxX - minX, maxY - minY) || 1; + if (gap > diag * 1e-4 && gap > 1e-6) return; + // Area gate: logo glyph strokes are small (area ~1–100 in block units). + // Refuse huge loops even inside blocks (defensive). + let sa = 0; + for (let i = 0; i < points.length; i++) { + const p0 = points[i], p1 = points[(i + 1) % points.length]; + sa += p0.x * p1.y - p1.x * p0.y; + } + const absA = Math.abs(sa) / 2; + if (absA < 1e-6 || absA > 5000) return; + let cx = 0, cy = 0; + for (const p of points) { cx += p.x; cy += p.y; } + cx /= points.length; cy /= points.length; + for (const f of this._pickFills) { + if (this._pointInAnyLoop(f.loops, cx, cy)) return; + } + this._hatchFill([{ points, bulges: bulges || null, closed: true }], color); + } + + // Solid fill HATCH using THREE.ShapeGeometry + // Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs, + // including the closing edge — a 2-point/2-bulge loop is a full circle). + _pathPoints(path) { + const pts = path.points; + if (!pts?.length) return null; + const bulges = path.bulges; + const n = pts.length; + if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y })); + const out = []; + for (let i = 0; i < n; i++) { + const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0; + out.push({ x: p1.x, y: p1.y }); + if (Math.abs(bulge) < 1e-6) continue; + const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy); + if (dist < 1e-9) continue; + const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half); + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1; + const cx = mx + sign * nx * apo, cy = my + sign * ny * apo; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter + const a = sa + inc * j / segs; + out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }); + } + } + return out; + } + + // Solid fill with true even-odd parity across all boundary loops. + // + // DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer + + // nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship + // self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric + // nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg: + // SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo. + _hatchFill(paths, color) { + const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0); + if (totalPts > 8000) return; // safety for pathological boundaries + let polys = []; + for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); } + if (!polys.length) return; + + const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; + + // Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops). + // Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter. + if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) { + const sorted = polys.map(area).sort((a, b) => a - b); + const med = sorted[Math.floor(sorted.length / 2)] || 0; + if (med > 0) polys = polys.filter(p => area(p) <= med * 10); + if (!polys.length) return; + } + + // Picking uses even-odd across all loops (holes exclude correctly). + if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys }); + + // When does this hatch need true even-odd (vs independent union of islands)? + // - Self-intersecting loops (text-as-hatch glyph strokes) + // - Many loops (≥5): logo words like 한국도로공사 + // - Geometric nesting: a smaller loop sits inside a larger one (island/hole) + // Separate solid islands that only touch/overlap (EX logo 4 paths) must be + // UNION — even-odd would punch diamond holes at the overlaps. + const needsEvenOdd = + polys.length >= 5 || + polys.some((p) => this._polySelfIntersects(p)) || + this._hatchHasNestedLoop(polys); + + if (needsEvenOdd) { + this._hatchFillEvenOddCanvas(polys, color); + return; + } + + // Union of simple islands: one ShapeGeometry per loop (no hole punching). + const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide }); + for (const poly of polys) { + try { + const shape = new THREE.Shape(); + shape.moveTo(poly[0].x, poly[0].y); + for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y); + shape.closePath(); + this._group.add(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat)); + } catch { /* degenerate */ } + } + } + + // True if any loop is (mostly) inside a larger loop — island/hole topology. + _hatchHasNestedLoop(polys) { + if (polys.length < 2) return false; + const area = (poly) => { + let s = 0; + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length]; + s += a.x * b.y - b.x * a.y; + } + return Math.abs(s) / 2; + }; + const bboxOf = (poly) => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of poly) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; + }; + const inside = (poly, pt) => { + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + return c; + }; + const ar = polys.map(area); + const bbs = polys.map(bboxOf); + for (let i = 0; i < polys.length; i++) { + let cx = 0, cy = 0; + for (const p of polys[i]) { cx += p.x; cy += p.y; } + cx /= polys[i].length; cy /= polys[i].length; + for (let j = 0; j < polys.length; j++) { + if (i === j || ar[j] <= ar[i]) continue; + const bb = bbs[j], bi = bbs[i]; + const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY; + if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true; + } + } + return false; + } + + // Cheap self-intersection probe (adjacent edges ignored). Used only to choose + // ShapeGeometry vs even-odd canvas; false negatives still go through canvas + // when there are multiple loops. + _polySelfIntersects(poly) { + const n = poly.length; + if (n < 4) return false; + const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); + const hits = (a, b, c, d) => { + const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); + }; + // Cap checks for large loops (logo glyphs are small). + const lim = Math.min(n, 80); + for (let i = 0; i < lim; i++) { + const a = poly[i], b = poly[(i + 1) % n]; + for (let j = i + 2; j < lim; j++) { + if (i === 0 && j === n - 1) continue; + if (j === (i + n - 1) % n) continue; + if (hits(a, b, poly[j], poly[(j + 1) % n])) return true; + } + } + return false; + } + + // Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ) + // that turn even-odd into a Mercedes/pie pattern. True when shoelace area is + // also inflated above the convex hull (self-intersecting junk). + _isDiameterInflatedPath(pts) { + if (!pts || pts.length < 8) return false; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of pts) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + const w = maxX - minX, h = maxY - minY; + const minDim = Math.min(w, h); + if (!(minDim > 1e-9)) return false; + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + const thr = 0.55 * minDim; + const centerR = 0.22 * minDim; + let diamN = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len < thr) continue; + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++; + } + if (!diamN) return false; + // shoelace abs area vs convex hull — inflated ⇒ self-intersecting + let sa = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + sa += a.x * b.y - b.x * a.y; + } + const absA = Math.abs(sa) / 2; + const hull = this._convexHull(pts); + if (hull.length < 3) return false; + let ha = 0; + for (let i = 0; i < hull.length; i++) { + const a = hull[i], b = hull[(i + 1) % hull.length]; + ha += a.x * b.y - b.x * a.y; + } + ha = Math.abs(ha) / 2; + return ha > 1e-9 && absA > ha * 1.15; + } + + _convexHull(pts) { + const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y); + if (p.length <= 2) return p; + const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const lower = []; + for (const pt of p) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); + lower.push(pt); + } + const upper = []; + for (let i = p.length - 1; i >= 0; i--) { + const pt = p[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); + upper.push(pt); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); + } + + _polyCentroid(poly) { + let x = 0, y = 0; + for (const p of poly) { x += p.x; y += p.y; } + return { x: x / poly.length, y: y / poly.length }; + } + + _pointInPoly(poly, pt) { + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + return c; + } + + // Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale + // glyphs (tens of units); large site hatches still use ShapeGeometry when they + // are a single simple loop. Texture is disposed with the mesh on reload. + // + // Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/ + // Mercedes pattern from spurious center chords. Those loops are filled with + // nonzero (solid glyph + cross), then nested sibling loops punch the counters + // (ㅇ hole). Remaining loops use normal even-odd. + _hatchFillEvenOddCanvas(polys, color) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const poly of polys) { + for (const p of poly) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + } + const worldW = maxX - minX; + const worldH = maxY - minY; + if (!(worldW > 1e-12) || !(worldH > 1e-12)) return; + + // Pad 1px equivalent so AA edges aren't clipped. + const MAX = 2048; + const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64); + const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2); + const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2); + const sx = (tw - 2) / worldW; + const sy = (th - 2) / worldH; + + const canvas = document.createElement('canvas'); + canvas.width = tw; + canvas.height = th; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, tw, th); + + const toCanvas = (p) => ({ + x: 1 + (p.x - minX) * sx, + y: 1 + (maxY - p.y) * sy, + }); + const addPoly = (poly) => { + if (poly.length < 3) return; + const p0 = toCanvas(poly[0]); + ctx.moveTo(p0.x, p0.y); + for (let i = 1; i < poly.length; i++) { + const p = toCanvas(poly[i]); + ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + }; + + const solidPolys = []; + const evenPolys = []; + for (const poly of polys) { + if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly); + else evenPolys.push(poly); + } + // Self-intersecting solid outlines fail ordinary PIP for hole nesting — use + // their convex hull as the containment oracle (ㅎ outer vs ㅇ counter). + const solidHulls = solidPolys.map((p) => this._convexHull(p)); + + const isHoleOfSolid = (poly) => { + if (poly.length < 3 || !solidHulls.length) return false; + const c = this._polyCentroid(poly); + for (const hull of solidHulls) { + if (hull.length >= 3 && this._pointInPoly(hull, c)) return true; + } + return false; + }; + + // 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie). + if (solidPolys.length) { + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + for (const poly of solidPolys) addPoly(poly); + ctx.fill('nonzero'); + + // 2) Punch counters nested inside a solid glyph. + const holes = evenPolys.filter(isHoleOfSolid); + if (holes.length) { + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + for (const poly of holes) addPoly(poly); + ctx.fill('nonzero'); + ctx.globalCompositeOperation = 'source-over'; + } + } + + // 3) Remaining loops (not used as hole punches) → even-odd. + const rest = evenPolys.filter((p) => !isHoleOfSolid(p)); + if (rest.length) { + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + for (const poly of rest) addPoly(poly); + ctx.fill('evenodd'); + } + + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.NoColorSpace; + tex.magFilter = THREE.LinearFilter; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.generateMipmaps = true; + tex.needsUpdate = true; + + const mat = new THREE.MeshBasicMaterial({ + map: tex, + color: new THREE.Color(color), + transparent: true, + alphaTest: 0.4, + side: THREE.DoubleSide, + depthWrite: false, + }); + const geo = new THREE.PlaneGeometry(worldW, worldH); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0); + // PlaneGeometry is XY; our 2D world is already XY. No rotation needed. + this._group.add(mesh); + } + + // Multi-line MTEXT: one canvas with stacked lines, aligned as a block. + // alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor. + _multilineSprite(lines, pos, height, rotation, color, alignH, alignV) { + const fontSize = 96; + const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = font; + let tw = 1, asc = fontSize * 0.70, desc = fontSize * 0.20; + for (const ln of lines) { + const m = ctx.measureText(ln || ' '); + tw = Math.max(tw, Math.ceil(m.width)); + asc = Math.max(asc, m.actualBoundingBoxAscent || fontSize * 0.70); + desc = Math.max(desc, m.actualBoundingBoxDescent || fontSize * 0.20); + } + const pad = Math.ceil(fontSize * 0.15); + const step = Math.ceil(asc + desc + fontSize * 0.35); // baseline-to-baseline + canvas.width = tw + pad * 2; + canvas.height = step * lines.length + pad * 2; + ctx.font = font; // resizing the canvas clears state + + const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; + ctx.fillStyle = `rgb(${ri},${gi},${bi})`; + ctx.textBaseline = 'alphabetic'; + let ax = pad; + if (alignH === 1 || alignH === 4) { ctx.textAlign = 'center'; ax = canvas.width / 2; } + else if (alignH === 2) { ctx.textAlign = 'right'; ax = canvas.width - pad; } + else { ctx.textAlign = 'left'; ax = pad; } + for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], ax, pad + asc + i * step); + + const texture = new THREE.CanvasTexture(canvas); + const pxToWorld = height / asc; // one line's cap height == DWG text height + const vw = canvas.width * pxToWorld; + const vh = canvas.height * pxToWorld; + + // Anchor the text block so the requested datum lands on pos (block edges). + let ox = 0, oy = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x + else if (alignH === 2) ox = -vw / 2; // right edge at pos.x + if (alignV === 3) oy = -vh / 2; // top edge at pos.y + else if (alignV === 0 || alignV === 1) oy = vh / 2; // bottom edge at pos.y + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + if (rotation) mat.rotation = rotation; + const sprite = new THREE.Sprite(mat); + sprite.scale.set(vw, vh, 1); + sprite.position.set(pos.x + ox, pos.y + oy, 1); + this._group.add(sprite); + } + + // MTEXT background mask (opaque fill behind text). Flags (DXF 90): + // 0x01 = use background fill color (63) + // 0x02 = use drawing window color + // 0x10 = text frame only (R2018+) + // bgScale (DXF 45, default 1.5) = border offset factor × text height. + _drawMTextBackground(t, engine, minTextH) { + const flags = t.bgFillFlags | 0; + if (!flags) return; + const useFill = !!(flags & 0x01) || !!(flags & 0x02); + const useFrame = !!(flags & 0x10); + if (!useFill && !useFrame) return; + + const height = Math.max(t.height || 2.5, minTextH || 0); + const lines = String(t.text || '').split('\n'); + const ls = t.linespacingFactor > 0 ? t.linespacingFactor : 1; + // Match slugText line step (CAD MTEXT default ~ 5/3 of height × factor) + const lineStep = height * (5 / 3) * ls; + let maxEm = 0; + if (engine?.measureEm) { + for (const line of lines) maxEm = Math.max(maxEm, engine.measureEm(line || ' ')); + } else { + // ~0.9 em per Hangul/Latin fallback when engine unavailable + for (const line of lines) maxEm = Math.max(maxEm, (line || ' ').length * 0.9); + } + let textW = Math.max(maxEm * height, height * 0.5); + let textH = lines.length <= 1 + ? height + : height + (lines.length - 1) * lineStep; + if (t.rectWidth > 0) textW = Math.max(textW, t.rectWidth); + if (t.rectHeight > 0) textH = Math.max(textH, t.rectHeight); + + const scale = t.bgScale > 0 ? t.bgScale : 1.5; + // Scale factor expands the text bbox (1.5 → 50% larger total). + const boxW = textW * scale; + const boxH = textH * scale; + + // Text-center offset from insertion/attachment point (same rules as slug/sprite). + const alignH = t.alignH ?? 1; + const alignV = t.alignV ?? 2; + let lx = 0, ly = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) lx = textW / 2; + else if (alignH === 2) lx = -textW / 2; + if (alignV === 3) ly = -textH / 2; + else if (alignV === 0 || alignV === 1) ly = textH / 2; + + const rot = t.rotation || 0; + const cos = Math.cos(rot), sin = Math.sin(rot); + const cx = t.pos.x + lx * cos - ly * sin; + const cy = t.pos.y + lx * sin + ly * cos; + + let bgColor = 0xFFFF00; // default yellow if color missing + if (flags & 0x02) { + // Drawing window color — follow viewer theme + const dark = this._scene?.background?.r < 0.5; + bgColor = dark ? 0x0a0b0d : 0xf7f6f3; + } else if (t.bgColorRgb && (t.bgColorRgb.r != null)) { + bgColor = ((t.bgColorRgb.r & 255) << 16) | ((t.bgColorRgb.g & 255) << 8) | (t.bgColorRgb.b & 255); + } else if (t.bgColorIndex != null) { + const hex = aciToHex(t.bgColorIndex); + if (hex != null) bgColor = hex; + } + + if (useFill) { + const geo = new THREE.PlaneGeometry(boxW, boxH); + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(bgColor), + side: THREE.DoubleSide, + depthWrite: false, + transparent: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(cx, cy, 0.4); // behind text (z≈1) + mesh.rotation.z = rot; + this._group.add(mesh); + } + if (useFrame && !useFill) { + // Outline only — four edges as hairline segs (rare path) + const hw = boxW / 2, hh = boxH / 2; + const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]]; + const world = corners.map(([x, y]) => ({ + x: cx + x * cos - y * sin, + y: cy + x * sin + y * cos, + })); + // Use a thin mesh strip ring via Line if we had a helper; simple solid edges: + for (let i = 0; i < 4; i++) { + const a = world[i], b = world[i + 1]; + this._widePolyMesh( + [a, b], null, false, + { constantWidth: Math.max(height * 0.05, 0.01) }, + bgColor, 1, null, + ); + } + } + } + + // Slug vector text (GPU winding-number glyphs, see slugText.ts): one merged + // mesh per load for ALL texts. Async because the TTF loads once on demand; + // falls back to the legacy canvas sprites if the font can't be fetched. + async _drawTexts(texts, minTextH) { + const gen = this._textGen; + try { + const engine = await SlugTextEngine.shared(); + if (gen !== this._textGen) return; // a clear()/load() superseded this flush + // Background masks first (under glyphs). + for (const t of texts) { + if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH); + } + const batch = new SlugTextBatch(engine); + for (const t of texts) { + batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0); + } + const mesh = batch.build(); + if (mesh) this._group.add(mesh); + } catch (e) { + console.warn('[Viewer2D] Slug text unavailable, falling back to canvas sprites:', e?.message ?? e); + if (gen !== this._textGen) return; + for (const t of texts) { + if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH); + this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0); + } + } + } + + // alignH: 0=left, 1=center, 2=right, 4=middle-center. alignV: 0=baseline,1=bottom,2=middle,3=top. + _textSprite(text, pos, height, rotation, color, alignH = 0, alignV = 0) { + // MTEXT paragraph breaks (\P → \n) produce multi-line text: render stacked. + const lines = String(text).split('\n'); + if (lines.length > 1) { + this._multilineSprite(lines, pos, height, rotation, color, alignH, alignV); + return; + } + // Texture cache: table drawings repeat the same strings thousands of times + // ("D25", sizes, counts). Rasterizing each occurrence separately OOMs on + // dense sheets (e.g. 6k+ texts) — share one CanvasTexture per (text, color). + // Alignment/rotation are per-sprite (scale/offset/material), not per-raster. + const cacheKey = text + '' + color; + let tex = (this._texCache ??= new Map()).get(cacheKey); + if (!tex) { + const fontSize = 96; + const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = font; + const m = ctx.measureText(text); + const tw = Math.max(1, Math.ceil(m.width)); + // Actual ink extents above/below the baseline (fallback to typical font ratios). + const asc = m.actualBoundingBoxAscent || fontSize * 0.70; // ≈ cap/glyph height + const desc = m.actualBoundingBoxDescent || fontSize * 0.20; + const pad = Math.ceil(fontSize * 0.15); + // Symmetric padding → the ink bounding box stays centered in the canvas. + canvas.width = tw + pad * 2; + canvas.height = Math.ceil(asc + desc) + pad * 2; + ctx.font = font; // reset (resizing the canvas clears state) + const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; + ctx.fillStyle = `rgb(${ri},${gi},${bi})`; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(text, pad, pad + asc); + tex = { texture: new THREE.CanvasTexture(canvas), w: canvas.width, h: canvas.height, asc, pad }; + this._texCache.set(cacheKey, tex); + } + const { texture, asc, pad } = tex; + + // Size so the visible ascent (≈ DWG cap height) equals the DWG text `height`. + const vh = height * tex.h / asc; + const vw = vh * (tex.w / tex.h); + const pxToWorld = vh / tex.h; + const centerPx = tex.h / 2; + + // X: place sprite so the requested horizontal datum lands on pos.x. + let ox = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x + else if (alignH === 1 || alignH === 4) ox = 0; // center at pos.x + else if (alignH === 2) ox = -vw / 2; // right edge at pos.x + + // Y: map the alignment datum (in canvas px) to pos.y. Canvas y grows downward, + // world y upward → world offset of a canvas row = (centerPx - rowPx) * pxToWorld. + // ah=4 (DWG "middle" justification) forces both-axis centering regardless of av. + let oy = 0; + if (alignH !== 4) { + if (alignV === 0 || alignV === 1) oy = (pad + asc - centerPx) * pxToWorld; // baseline/bottom datum + else if (alignV === 3) oy = (pad - centerPx) * pxToWorld; // top datum + // alignV === 2 (middle): ink already centered → oy = 0 + } + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + if (rotation) mat.rotation = rotation; + const sprite = new THREE.Sprite(mat); + sprite.scale.set(vw, vh, 1); + sprite.position.set(pos.x + ox, pos.y + oy, 1); + this._group.add(sprite); + } + + // Render block definition entities transformed by INSERT params + _insertEntities(entities, d, color, pushSeg, pendingTexts, ctx) { + const entsByOwner = ctx?.entsByOwner; + const blockBase = ctx?.blockBase; + const parentXf = ctx?.parentXf ?? null; + const parentScale = ctx?.scale ?? 1; + const parentRot = ctx?.rot ?? 0; + const depth = ctx?.depth ?? 0; + + const ip = d.insertionPoint ?? { x: 0, y: 0 }; + const base = d.basePoint; + const sx = d.xScale ?? 1, sy = d.yScale ?? 1; + const rot = d.rotation ?? 0; + const cos = Math.cos(rot), sin = Math.sin(rot); + + // Local transform: block-local coords → parent-local (or world if top level). + const localXf = (px, py) => { + const lx = base ? px - base.x : px; + const ly = base ? py - base.y : py; + return [ + ip.x + (lx * sx) * cos - (ly * sy) * sin, + ip.y + (lx * sx) * sin + (ly * sy) * cos, + ]; + }; + // Compose with parent transform for nested INSERTs. + const xf = parentXf ? (px, py) => parentXf(...localXf(px, py)) : localXf; + // Accumulated uniform scale (for radii) and rotation (for arc/ellipse angles). + const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy)); + const rotAcc = parentRot + rot; + + for (const be of entities) { + const bd = be.data || be; + const bt = (be.type || be.typeName || '').toUpperCase(); + if (bt === 'ATTDEF') continue; // skip attribute definitions + const ecol = this._resolveBlockColor(be, color); + try { + if (bt === 'LINE' && bd.start && bd.end) { + const [ax, ay] = xf(bd.start.x, bd.start.y); + const [bx, by] = xf(bd.end.x, bd.end.y); + pushSeg(ax, ay, bx, by, 0, ecol); + + } else if ((bt === 'CIRCLE' || bt === 'ARC') && bd.center && bd.radius != null) { + const [cx, cy] = xf(bd.center.x, bd.center.y); + const r = bd.radius * scaleAcc; + const a0 = bt === 'ARC' ? (bd.startAngle ?? 0) + rotAcc : 0; + const a1 = bt === 'ARC' ? (bd.endAngle ?? Math.PI*2) + rotAcc : Math.PI*2; + this._arcSegs({ x:cx, y:cy, z:0 }, r, a0, a1, 0, ecol, pushSeg); + + } else if (bt === 'ELLIPSE' && bd.center) { + const [cx, cy] = xf(bd.center.x, bd.center.y); + const ma = bd.majorAxis ?? bd.smAxis ?? { x:1, y:0 }; + const mlen = Math.hypot(ma.x ?? 0, ma.y ?? 0) * scaleAcc; + const mang = Math.atan2(ma.y ?? 0, ma.x ?? 1) + rotAcc; + this._ellipseSegs({ + center: { x:cx, y:cy, z:0 }, + majorAxis: { x: mlen * Math.cos(mang), y: mlen * Math.sin(mang) }, + ratio: bd.ratio ?? bd.axisRatio ?? 1, + startParam: bd.startParam ?? 0, + endParam: bd.endParam ?? Math.PI*2, + }, ecol, pushSeg); + + } else if ((bt === 'LWPOLYLINE' || bt === 'POLYLINE') && (bd.points || bd.vertices)) { + const raw = bd.points ?? bd.vertices; + const pts = raw.map(p => { const [nx, ny] = xf(p.x, p.y); return { x:nx, y:ny }; }); + // Preserve per-vertex bulges through the transform (scalar, not position). + const bulges = bd.bulges ?? null; + const closed = !!(bd.closed || (bd.flags & 1)); + // Logo blocks list solid HATCH first, then the same glyph outlines as + // LWPOLYLINE. Re-stroking those glyph outlines draws chords across ㅇ + // holes — skip ONLY true re-outlines: same scale as the covering loop. + // (Not "poly smaller than loop" — that also killed 도곽 안쪽 가는 선 + // whose centroid sat inside the outer frame / a large hatch.) + let skipGlyphReoutline = false; + if (pts.length >= 2 && this._pickFills.length) { + let cx = 0, cy = 0, pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity; + for (const p of pts) { + cx += p.x; cy += p.y; + if (p.x < pMinX) pMinX = p.x; if (p.y < pMinY) pMinY = p.y; + if (p.x > pMaxX) pMaxX = p.x; if (p.y > pMaxY) pMaxY = p.y; + } + cx /= pts.length; cy /= pts.length; + const pw = Math.max(pMaxX - pMinX, 1e-9); + const ph = Math.max(pMaxY - pMinY, 1e-9); + for (const f of this._pickFills) { + if (!this._pointInAnyLoop(f.loops, cx, cy)) continue; + for (const loop of f.loops) { + if (!loop?.length) continue; + let lMinX = Infinity, lMinY = Infinity, lMaxX = -Infinity, lMaxY = -Infinity; + for (const q of loop) { + if (q.x < lMinX) lMinX = q.x; if (q.y < lMinY) lMinY = q.y; + if (q.x > lMaxX) lMaxX = q.x; if (q.y > lMaxY) lMaxY = q.y; + } + const lw = Math.max(lMaxX - lMinX, 1e-9); + const lh = Math.max(lMaxY - lMinY, 1e-9); + // Similar size both ways (re-outline of same glyph/hatch). + const similar = + pw <= lw * 1.5 && ph <= lh * 1.5 && + lw <= pw * 1.5 && lh <= ph * 1.5; + if (similar) { skipGlyphReoutline = true; break; } + } + if (skipGlyphReoutline) break; + } + } + if (!skipGlyphReoutline) { + const nSeg = closed ? pts.length : pts.length - 1; + // start/end/constant width (도곽 frame etc.) — mesh strip, scaled by INSERT + if (this._polyHasWidth(be, nSeg, scaleAcc)) { + this._widePolyMesh(pts, bulges, closed, be, ecol, scaleAcc, null); + } else { + if (bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); + else this._polylineSegs(pts, closed, 0, ecol, pushSeg); + this._fillClosedPolyIfUncovered(pts, bulges, ecol); + } + } + + } else if (bt === 'POLYLINE_2D' && entsByOwner) { + // Old-style 2D polyline inside a block: vertices are separate VERTEX_2D + // entities owned by the polyline (point/bulge at top level). + const kids = (entsByOwner.get(be.handle?.value?.toString(16)) ?? []) + .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); + if (kids.length >= 2) { + const pts = kids.map(v => { const [nx, ny] = xf(v.point.x, v.point.y); return { x:nx, y:ny }; }); + const bulges = kids.map(v => v.bulge || 0); + const closed = (bd.flags & 1) === 1; + if (bulges.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); + else this._polylineSegs(pts, closed, 0, ecol, pushSeg); + } + + } else if (bt === 'HATCH') { + const paths = bd.paths ?? be.paths; + if (paths?.length) { + const solidFill = bd.solidFill ?? be.solidFill ?? false; + const tpaths = paths.map(p => ({ + ...p, + points: (p.points ?? []).map(pt => { const [nx, ny] = xf(pt.x, pt.y); return { x:nx, y:ny }; }), + })); + // Solid fill: draw fill only (CAD does not render hatch boundaries). + // Pattern hatch: draw boundary outline as a proxy (no pattern support). + if (solidFill) { + this._hatchFill(tpaths, ecol); + } else { + for (const tp of tpaths) { + if ((tp.points?.length ?? 0) < 2) continue; + if (tp.bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(tp.points, tp.bulges, true, ecol, pushSeg); + else this._polylineSegs(tp.points, true, 0, ecol, pushSeg); + } + } + } + + } else if (bt === 'SOLID') { + const crs = [bd.corner1||bd.pt1, bd.corner2||bd.pt2, + bd.corner3||bd.pt3, bd.corner4||bd.pt4].filter(Boolean); + if (crs.length >= 3) { + const mapped = crs.map(p => { const [nx,ny] = xf(p.x, p.y); return {x:nx, y:ny, z:p.z||0}; }); + this._solidMesh(mapped, ecol); + } + + } else if (bt === 'INSERT' && entsByOwner && depth < 8) { + // Nested block reference: recurse with composed transform. + const bhVal = be.blockHeaderHandle?.value ?? bd.blockHeaderHandle?.value; + if (bhVal != null) { + const hex = bhVal.toString(16); + const childEnts = entsByOwner.get(hex) ?? []; + if (childEnts.length) { + const nip = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint ?? { x:0, y:0 }; + const nsx = bd.scaleX ?? bd.scale?.x ?? be.scale?.x ?? 1; + const nsy = bd.scaleY ?? bd.scale?.y ?? be.scale?.y ?? 1; + const nrot = bd.rotation ?? be.rotation ?? 0; + this._insertEntities( + childEnts, + { insertionPoint: nip, xScale: nsx, yScale: nsy, rotation: nrot, basePoint: blockBase?.get(hex) }, + ecol, pushSeg, pendingTexts, + { entsByOwner, blockBase, parentXf: xf, scale: scaleAcc, rot: rotAcc, depth: depth + 1 } + ); + } + } + + } else if ((bt === 'TEXT' || bt === 'MTEXT' || bt === 'ATTRIB' || bt === 'ATTDEF') && pendingTexts) { + if ((bt === 'ATTRIB' || bt === 'ATTDEF') && ((bd.flags ?? 0) & 1)) continue; + const raw = be.text ?? bd.text ?? bd.textValue ?? bd.defaultValue ?? ''; + const text = stripMText(raw); + let ah, av, rot = (bd.rotationAngle ?? 0) + rotAcc; + if (bt === 'MTEXT') { + // MTEXT anchors via attachment point 1-9 (1-3 top / 4-6 mid / 7-9 + // bottom, columns L/C/R), not horiz/vertAlignment — the fallback + // 0/0 (left-baseline) pushed table cells to the top-right. + const ap = be.attachment ?? bd.attachment ?? be.attachmentPoint ?? bd.attachmentPoint ?? 5; + ah = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); + av = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); + const xd = be.xAxisDir ?? bd.xAxisDir; + if (xd) rot = Math.atan2(xd.y, xd.x) + rotAcc; + } else { + ah = bd.horizAlignment ?? be.horizAlignment ?? 0; + av = bd.vertAlignment ?? be.vertAlignment ?? 0; + } + const useAp = bt !== 'MTEXT' && (ah !== 0 || av !== 0); + const alignPt = be.alignmentPt ?? bd.alignmentPt; + const insertPt = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint; + const tpos = useAp + ? (alignPt ?? insertPt ?? {x:0,y:0}) + : (insertPt ?? {x:0,y:0}); + const [tx, ty] = xf(tpos.x, tpos.y); + const ht = (bd.textHeight ?? bd.height ?? be.textHeight ?? 2.5) * scaleAcc; + if (text) { + const entry = { text, pos:{x:tx, y:ty}, height:ht, rotation:rot, color: ecol, alignH:ah, alignV:av }; + if (bt === 'MTEXT') { + entry.linespacingFactor = be.linespacingFactor ?? bd.linespacingFactor ?? 1; + entry.rectWidth = (be.rectWidth ?? bd.rectWidth ?? 0) * scaleAcc; + entry.rectHeight = (be.rectHeight ?? bd.rectHeight ?? 0) * scaleAcc; + entry.bgFillFlags = be.bgFillFlags ?? bd.bgFillFlags ?? 0; + entry.bgScale = be.bgScale ?? bd.bgScale ?? 1.5; + entry.bgColorIndex = be.bgColorIndex ?? bd.bgColorIndex; + entry.bgColorRgb = be.bgColorRgb ?? bd.bgColorRgb; + } + pendingTexts.push(entry); + } + } + } catch { /* skip */ } + } + } + + // Fallback dimension rendering from entity properties when no block geometry available + _renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand) { + const pt10 = e.pt10 ?? d.pt10; + const pt13 = e.pt13 ?? d.pt13; + const pt14 = e.pt14 ?? d.pt14; + const pt15 = e.pt15 ?? d.pt15; + const textMidPt = e.textMidPt ?? d.textMidPt; + const actualMeasurement = e.actualMeasurement ?? d.actualMeasurement ?? 0; + const textRot = e.textRot ?? d.textRot ?? 0; + const dimRot = e.dimRot ?? d.dimRot ?? 0; + const userText = e.userText ?? d.userText ?? ''; + + const fmtLen = (v) => Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2); + + const drawLine = (a, b) => { + if (!a || !b) return; + pushSeg(a.x, a.y, b.x, b.y, 0, color); + expand(a.x, a.y); expand(b.x, b.y); + }; + const drawArc = (cx, cy, r, sa, ea) => { + this._arcSegs({ x:cx, y:cy }, r, sa, ea, 0, color, pushSeg); + }; + const arrow = (pt, rot, sz) => this._arrowMesh(pt, rot, sz, color); + const drawText = (pos, text, height, rot) => { + if (text && pos) pendingTexts.push({ text, pos, height, rotation: rot ?? 0, color, alignH: 1, alignV: 2 }); + }; + + switch (type) { + case 'DIMENSION': + case 'DIMENSION_LINEAR': + case 'DIMENSION_ALIGNED': { + if (!pt13 || !pt14 || !pt10) return; + let d1x, d1y, d2x, d2y; + if (type === 'DIMENSION_ALIGNED') { + const ddx = pt14.x-pt13.x, ddy = pt14.y-pt13.y; + const len = Math.hypot(ddx, ddy); + if (len < 1e-9) return; + const ux = ddx/len, uy = ddy/len; + const half = actualMeasurement / 2; + d1x = pt10.x - ux*half; d1y = pt10.y - uy*half; + d2x = pt10.x + ux*half; d2y = pt10.y + uy*half; + } else { + const isV = Math.abs(Math.sin(dimRot)) > 0.5; + const half = actualMeasurement / 2; + d1x = pt10.x - (isV ? 0 : half); d1y = pt10.y - (isV ? half : 0); + d2x = pt10.x + (isV ? 0 : half); d2y = pt10.y + (isV ? half : 0); + } + const arrowSz = Math.max(actualMeasurement * 0.04, 1); + drawLine({x:d1x,y:d1y}, {x:d2x,y:d2y}); + drawLine(pt13, {x:d1x,y:d1y}); + drawLine(pt14, {x:d2x,y:d2y}); + arrow({x:d1x,y:d1y,z:0}, Math.atan2(d1y-d2y, d1x-d2x), arrowSz); + arrow({x:d2x,y:d2y,z:0}, Math.atan2(d2y-d1y, d2x-d1x), arrowSz); + drawText(textMidPt ?? {x:(d1x+d2x)/2, y:(d1y+d2y)/2}, userText || fmtLen(actualMeasurement), Math.max(arrowSz*1.5,1), textRot); + break; + } + case 'DIMENSION_RADIUS': { + if (!pt10 || !pt15) return; + const arrowSzR = Math.max(actualMeasurement * 0.05, 0.5); + drawLine(pt10, pt15); + arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzR); + drawText(textMidPt ?? {x:(pt10.x+pt15.x)/2,y:(pt10.y+pt15.y)/2}, userText || `R${fmtLen(actualMeasurement)}`, Math.max(arrowSzR*2,1)); + break; + } + case 'DIMENSION_DIAMETER': { + if (!pt10 || !pt15) return; + const opp = {x:2*pt10.x-pt15.x, y:2*pt10.y-pt15.y}; + const arrowSzD = Math.max((actualMeasurement/2)*0.05, 0.5); + drawLine(pt15, opp); + arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzD); + arrow({x:opp.x,y:opp.y,z:0}, Math.atan2(opp.y-pt10.y, opp.x-pt10.x), arrowSzD); + drawText(textMidPt ?? pt10, userText || `Ø${fmtLen(actualMeasurement)}`, Math.max(arrowSzD*2,1)); + break; + } + case 'DIMENSION_ANG_3PT': + case 'DIMENSION_ANG_2LN': { + if (!pt10 || !pt13 || !pt14) return; + const vx = pt10.x, vy = pt10.y; + const rr = Math.hypot(pt13.x-vx, pt13.y-vy); + if (rr < 1e-9) return; + let sa = Math.atan2(pt13.y-vy, pt13.x-vx); + let ea = Math.atan2(pt14.y-vy, pt14.x-vx); + let sweep = ea - sa; + while (sweep < 0) sweep += Math.PI*2; + if (sweep > Math.PI) { [sa, ea] = [ea, sa]; sweep = Math.PI*2 - sweep; } + drawArc(vx, vy, rr, sa, sa+sweep); + const arrowSzA = Math.max(rr * 0.05, 0.5); + arrow({x:vx+Math.cos(sa)*rr, y:vy+Math.sin(sa)*rr, z:0}, sa-Math.PI/2, arrowSzA); + arrow({x:vx+Math.cos(sa+sweep)*rr, y:vy+Math.sin(sa+sweep)*rr, z:0}, (sa+sweep)+Math.PI/2, arrowSzA); + const midA = sa + sweep/2; + drawText(textMidPt ?? {x:vx+Math.cos(midA)*rr*1.2, y:vy+Math.sin(midA)*rr*1.2}, + userText || `${(actualMeasurement*180/Math.PI).toFixed(1)}°`, Math.max(rr*0.1,1)); + break; + } + case 'DIMENSION_ORDINATE': { + if (!pt10 || !pt13) return; + drawLine(pt10, pt13); + drawText(textMidPt ?? pt13, userText || (actualMeasurement?.toFixed(2) ?? ''), 2.5); + break; + } + } + } + + _onClick(e) { + // ignore clicks that were really a pan/zoom drag (>5px movement) + if (this._downXY) { + const dx = e.clientX - this._downXY[0], dy = e.clientY - this._downXY[1]; + if (Math.hypot(dx, dy) > 5) return; + } + const rect = this._renderer.domElement.getBoundingClientRect(); + const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; + const cam = this._camera; + // unproject through the camera so pan (position) and zoom are accounted for + const v = new THREE.Vector3(ndcX, ndcY, 0).unproject(cam); + const wx = v.x, wy = v.y; + + if (this._measureActive) { this._doMeasure2D(wx, wy); return; } + + const thresh = CLICK_THRESHOLD_PX * (cam.right - cam.left) / (rect.width * (cam.zoom || 1)); + + // Geometric pick — raycast the actual rendered geometry, not a bounds proxy. + // 1) Nearest real line segment within the pixel threshold (lines, arcs, + // circles, polylines, block wires, dimension leaders/arrows). + let bestIdx = -1, bestDist = thresh; + const S = this._pickSegs, SM = this._pickSegMeta; + for (let i = 0, j = 0; i < S.length; i += 4, j++) { + const d = this._segDist(wx, wy, S[i], S[i + 1], S[i + 2], S[i + 3]); + if (d < bestDist) { bestDist = d; bestIdx = SM[j]; } + } + // 2) No line under the cursor → a filled region (hatch / SOLID / text box). + // Pick the smallest-area fill so text on top of a hatch wins. + if (bestIdx < 0) { + let bestArea = Infinity; + for (const f of this._pickFills) { + if (this._fillHit(f.loops, wx, wy)) { + const a = this._loopArea(f.loops[0] || []); + if (a < bestArea) { bestArea = a; bestIdx = f.metaIdx; } + } + } + } + const bestMeta = bestIdx >= 0 ? this._entityMeta[bestIdx] : null; + this._highlight(bestMeta); + this._onSelectCb?.(bestMeta ? bestMeta.entity : null); + } + + // Distance from point (px,py) to segment (ax,ay)-(bx,by). + _segDist(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (!len2) return Math.hypot(px - ax, py - ay); + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + } + + startMeasure(cb) { + this.stopMeasure(); + this._measureCb = cb; + this._measureActive = true; + } + + stopMeasure() { + this._measureActive = false; + this._measureCb = null; + this._measurePtA = null; + for (const m of this._measureMarkers) { + this._group.remove(m); + m.geometry?.dispose(); + m.material?.dispose(); + } + this._measureMarkers = []; + } + + _doMeasure2D(wx, wy) { + if (!this._measurePtA) { + this._measurePtA = { x: wx, y: wy }; + const mk = this._mkMeasureMarker(wx, wy); + this._measureMarkers.push(mk); + this._group.add(mk); + this._measureCb?.({ phase: 'a', ax: wx, ay: wy, az: 0, bx: wx, by: wy, bz: 0, d: 0 }); + } else { + const a = this._measurePtA; + const d = Math.hypot(wx - a.x, wy - a.y); + const mkB = this._mkMeasureMarker(wx, wy); + const ln = this._mkMeasureLine(a.x, a.y, wx, wy); + this._measureMarkers.push(mkB, ln); + this._group.add(mkB, ln); + this._measureCb?.({ phase: 'done', ax: a.x, ay: a.y, az: 0, bx: wx, by: wy, bz: 0, d }); + this._measurePtA = null; + } + } + + _mkMeasureMarker(x, y) { + const cam = this._camera; + const rect = this._renderer.domElement.getBoundingClientRect(); + const s = ((cam.right - cam.left) / (rect.width * (cam.zoom || 1))) * 8; + const pts = [ + new THREE.Vector3(x - s, y, 0), new THREE.Vector3(x + s, y, 0), + new THREE.Vector3(x, y - s, 0), new THREE.Vector3(x, y + s, 0), + ]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + geo.setIndex([0, 1, 2, 3]); + const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); + const m = new THREE.LineSegments(geo, mat); + m.renderOrder = 100; + return m; + } + + _mkMeasureLine(x1, y1, x2, y2) { + const geo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(x1, y1, 0), new THREE.Vector3(x2, y2, 0), + ]); + const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); + const m = new THREE.Line(geo, mat); + m.renderOrder = 100; + return m; + } + + /** Recolor the selected entity's line geometry to the accent color (deselect previous). */ + _highlight(meta) { + const attr = this._lineColorAttr, orig = this._origColors; + if (this._selMeta && attr && orig) { + const a = this._selMeta; + for (let i = a.colStart; i < a.colEnd && i < orig.length; i++) attr.array[i] = orig[i]; + } + this._selMeta = meta || null; + if (meta && attr && meta.colEnd > meta.colStart) { + const c = this._selColor; + for (let i = meta.colStart; i < meta.colEnd; i += 3) { + attr.array[i] = c.r; attr.array[i + 1] = c.g; attr.array[i + 2] = c.b; + } + } + if (attr) attr.needsUpdate = true; + } + + /** Selection highlight color (keeps in sync with the UI accent). */ + setAccent(hex) { + this._selColor = { + r: parseInt(hex.slice(1, 3), 16) / 255, + g: parseInt(hex.slice(3, 5), 16) / 255, + b: parseInt(hex.slice(5, 7), 16) / 255, + }; + if (this._selMeta) { const m = this._selMeta; this._selMeta = null; this._highlight(m); } + } + + _distToBounds(wx, wy, b) { + if (b.type === 'point') return Math.hypot(wx - b.cx, wy - b.cy); + if (b.type === 'circle') return Math.abs(Math.hypot(wx - b.cx, wy - b.cy) - b.r); + if (b.type === 'line') { + const dx = b.x2-b.x1, dy = b.y2-b.y1; + const len2 = dx*dx + dy*dy; + if (!len2) return Math.hypot(wx - b.x1, wy - b.y1); + const t = Math.max(0, Math.min(1, ((wx-b.x1)*dx + (wy-b.y1)*dy) / len2)); + return Math.hypot(wx - (b.x1+t*dx), wy - (b.y1+t*dy)); + } + return Infinity; + } + + _fit(box) { + const c = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const w = this._container.clientWidth || 800; + const h = this._container.clientHeight || 600; + const aspect = w / h; + let vw = Math.max(size.x, 1) * 1.1; + let vh = Math.max(size.y, 1) * 1.1; + if (vw / vh > aspect) vh = vw / aspect; else vw = vh * aspect; + this._camera.zoom = 1; + this._camera.left = -vw/2; this._camera.right = vw/2; + this._camera.top = vh/2; this._camera.bottom = -vh/2; + this._camera.position.set(c.x, c.y, 100); + // Un-twist the sheet: rotate the view (camera up) by -viewTwist. + const a = -(this._viewTwist || 0); + this._camera.up.set(-Math.sin(a), Math.cos(a), 0); + this._camera.updateProjectionMatrix(); + this._controls.target.set(c.x, c.y, 0); + this._controls.update(); + } + + _clear() { + for (let i = this._group.children.length - 1; i >= 0; i--) { + const o = this._group.children[i]; + o.geometry?.dispose(); + if (o.material) { o.material.map?.dispose(); o.material.dispose(); } + this._group.remove(o); + } + // Shared text textures were disposed via the sprites above (re-dispose is a + // no-op); drop the cache so the next load rasterizes fresh. + this._texCache?.clear(); + this._textGen++; // invalidate any in-flight async text flush + } + + _onResize() { + const w = this._container.clientWidth, h = this._container.clientHeight; + if (!w || !h) return; + this._renderer.setSize(w, h); + // Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords. + // Just preserve half-widths and re-adjust aspect — camera position tracks panning via OrbitControls. + const halfW = (this._camera.right - this._camera.left) / 2; + const halfH = halfW / (w / h); + this._camera.left = -halfW; this._camera.right = halfW; + this._camera.top = halfH; this._camera.bottom = -halfH; + this._camera.updateProjectionMatrix(); + } + + _animate() { + requestAnimationFrame(() => this._animate()); + this._controls.update(); + this._renderer.render(this._scene, this._camera); + } +} diff --git a/src/viewer2d/acadrust-dwg/acadrust_dwg.d.ts b/src/viewer2d/acadrust-dwg/acadrust_dwg.d.ts new file mode 100644 index 0000000..555cf76 --- /dev/null +++ b/src/viewer2d/acadrust-dwg/acadrust_dwg.d.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ + +export function parse_dwg(bytes: Uint8Array): any; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly parse_dwg: (a: number, b: number) => [number, number, number]; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/src/viewer2d/acadrust-dwg/acadrust_dwg.js b/src/viewer2d/acadrust-dwg/acadrust_dwg.js new file mode 100644 index 0000000..e01a395 --- /dev/null +++ b/src/viewer2d/acadrust-dwg/acadrust_dwg.js @@ -0,0 +1,286 @@ +/* @ts-self-types="./acadrust_dwg.d.ts" */ + +/** + * @param {Uint8Array} bytes + * @returns {any} + */ +export function parse_dwg(bytes) { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_dwg(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_92b29b0548f8b746: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_new_32b398fb48b6d94a: function() { + const ret = new Array(); + return ret; + }, + __wbg_new_7796ffc7ed656783: function() { + const ret = new Map(); + return ret; + }, + __wbg_new_da52cf8fe3429cb2: function() { + const ret = new Object(); + return ret; + }, + __wbg_set_575dd786d51585f8: function(arg0, arg1, arg2) { + const ret = arg0.set(arg1, arg2); + return ret; + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./acadrust_dwg_bg.js": import0, + }; +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('acadrust_dwg_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm b/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm new file mode 100644 index 0000000..cab0970 Binary files /dev/null and b/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm differ diff --git a/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts b/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts new file mode 100644 index 0000000..2cddf89 --- /dev/null +++ b/src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts @@ -0,0 +1,9 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const parse_dwg: (a: number, b: number) => [number, number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_start: () => void; diff --git a/src/viewer2d/acadrust-dwg/package.json b/src/viewer2d/acadrust-dwg/package.json new file mode 100644 index 0000000..870cb90 --- /dev/null +++ b/src/viewer2d/acadrust-dwg/package.json @@ -0,0 +1,17 @@ +{ + "name": "dwg-wasm", + "type": "module", + "description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape", + "version": "0.1.0", + "license": "MIT", + "files": [ + "acadrust_dwg_bg.wasm", + "acadrust_dwg.js", + "acadrust_dwg.d.ts" + ], + "main": "acadrust_dwg.js", + "types": "acadrust_dwg.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/src/viewer2d/acadrustParser.ts b/src/viewer2d/acadrustParser.ts new file mode 100644 index 0000000..75eb1ab --- /dev/null +++ b/src/viewer2d/acadrustParser.ts @@ -0,0 +1,20 @@ +/** + * acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm + * (wasm-pack --target web, committed under ./acadrust-dwg). + */ +import init, { parse_dwg } from './acadrust-dwg/acadrust_dwg.js'; +import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url'; +import type { CadParseResult } from './dwgParser'; + +let ready: Promise | null = null; + +/** Initialize the acadrust WASM module once (idempotent). */ +export function initAcadrustParser(): Promise { + if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined); + return ready; +} + +/** Parse a DWG buffer. Requires initAcadrustParser() first. */ +export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult { + return parse_dwg(bytes) as CadParseResult; +} diff --git a/src/viewer2d/aciColors.js b/src/viewer2d/aciColors.js new file mode 100644 index 0000000..59bceba --- /dev/null +++ b/src/viewer2d/aciColors.js @@ -0,0 +1,63 @@ +/** + * AutoCAD Color Index (ACI) → hex RGB integer. + * Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color. + */ + +function hsl2hex(h, s, l) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return Math.round((r + m) * 255) * 65536 + + Math.round((g + m) * 255) * 256 + + Math.round((b + m) * 255); +} + +// ACI 0-9: exact fixed colors +const FIXED = [ + null, // 0: BYBLOCK + 0xFF0000, // 1: red + 0xFFFF00, // 2: yellow + 0x00FF00, // 3: green + 0x00FFFF, // 4: cyan + 0x0000FF, // 5: blue + 0xFF00FF, // 6: magenta + 0xFFFFFF, // 7: white + 0x414141, // 8: dark gray + 0x808080, // 9: gray +]; + +// ACI 10-249: 24 hue groups × 10 saturation/lightness variants +const VARIANTS_SL = [ + [1.00, 0.50], // 0: full + [1.00, 0.28], // 1: dark + [0.65, 0.65], // 2: light pastel + [0.65, 0.38], // 3: pastel dark + [0.35, 0.80], // 4: pale + [0.35, 0.48], // 5: pale mid + [0.18, 0.68], // 6: faint light + [0.18, 0.38], // 7: faint dark + [0.00, 0.55], // 8: light gray + [0.00, 0.30], // 9: dark gray +]; + +export function aciToHex(aci) { + if (aci == null || aci === 0 || aci === 256) return null; + if (aci <= 9) return FIXED[aci] ?? null; + if (aci >= 250 && aci <= 255) { + const v = Math.round(40 + (aci - 250) * 43); + return v * 65536 + v * 256 + v; + } + if (aci >= 10 && aci <= 249) { + const idx = aci - 10; + const [s, l] = VARIANTS_SL[idx % 10]; + return hsl2hex(Math.floor(idx / 10) * 15, s, l); + } + return 0xC9D1D9; +} diff --git a/src/viewer2d/decodeDxf.ts b/src/viewer2d/decodeDxf.ts new file mode 100644 index 0000000..a487136 --- /dev/null +++ b/src/viewer2d/decodeDxf.ts @@ -0,0 +1,35 @@ +/** + * Decode a DXF buffer to text. + * + * Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE + * (e.g. ANSI_949) that no longer matches the bytes — so the header is + * unreliable. Strategy: try UTF-8 strictly (self-validating); on failure + * fall back to the header's single-byte codepage. The DXF group/value + * structure is ASCII regardless, so this only affects TEXT/MTEXT strings. + */ +export function decodeDxf(buf: ArrayBuffer): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buf); + } catch { + /* not valid UTF-8 → legacy single-byte codepage */ + } + const head = new TextDecoder('latin1').decode( + new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)), + ); + const cp = head + .match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1] + ?.trim() + .toLowerCase(); + let label = 'windows-1252'; + if (cp) { + if (/949/.test(cp)) label = 'euc-kr'; + else if (/932/.test(cp)) label = 'shift_jis'; + else if (/936/.test(cp)) label = 'gbk'; + else if (/950/.test(cp)) label = 'big5'; + } + try { + return new TextDecoder(label).decode(buf); + } catch { + return new TextDecoder('utf-8').decode(buf); + } +} diff --git a/src/viewer2d/dwgParser.ts b/src/viewer2d/dwgParser.ts new file mode 100644 index 0000000..76a842a --- /dev/null +++ b/src/viewer2d/dwgParser.ts @@ -0,0 +1,25 @@ +/** + * DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0, + * consumed unmodified; see rust/dwg-wasm). Emits the parseResult shape + * Viewer2D.load() consumes. Lazy-imported so the wasm is only fetched when a + * DWG is actually opened. + * + * History: until 2026-07-10 this module selected between acadrust and + * @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed + * after side-by-side validation (PROGRESS.md Phase 15). + */ + +export type CadParseResult = { + version?: string; + vars?: Record; + entities?: unknown[]; + tables?: Record; + stats?: Record; +}; + +/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */ +export async function parseDwgBuffer(bytes: Uint8Array): Promise { + const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser'); + await initAcadrustParser(); + return parseDwgAcadrust(bytes); +} diff --git a/src/viewer2d/dxfAdapter.d.ts b/src/viewer2d/dxfAdapter.d.ts new file mode 100644 index 0000000..14f4365 --- /dev/null +++ b/src/viewer2d/dxfAdapter.d.ts @@ -0,0 +1,7 @@ +/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */ +export function dxfToParseResult(dxf: unknown): { + version: string; + vars: Record; + entities: unknown[]; + tables: { blocks: unknown[]; layers: unknown[] }; +}; diff --git a/src/viewer2d/dxfAdapter.js b/src/viewer2d/dxfAdapter.js new file mode 100644 index 0000000..d631b72 --- /dev/null +++ b/src/viewer2d/dxfAdapter.js @@ -0,0 +1,160 @@ +/** + * dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that + * Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension + * block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF. + * + * Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the + * raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD + * handle its child entities are owned by, and INSERT/DIMENSION reference their + * block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the + * incoherent handles and synthesize one consistent integer handle PER BLOCK NAME: + * - block table entry handle = {value: H} + * - every child entity ownerHandle = {value: H} + * - INSERT.blockHeaderHandle = {value: H of insert.name} + * - DIMENSION.blockHeaderHandle = {value: H of dim.block} + * Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so + * Viewer2D's space-handle filter keeps them visible. + * + * Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but + * INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here. + * + * Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent. + */ +const DEG2RAD = Math.PI / 180; + +const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p); + +/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */ +function adaptEntity(e, nameToH) { + const o = { ...e }; + delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI + const t = (e.type || '').toUpperCase(); + + switch (t) { + case 'LINE': + if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); } + break; + + case 'POLYLINE': + case 'LWPOLYLINE': + o.type = 'LWPOLYLINE'; + o.points = (e.vertices || []).map(vec); + o.bulges = (e.vertices || []).map((v) => v.bulge || 0); + o.closed = !!e.shape || ((e.flags || 0) & 1) === 1; + break; + + case 'ARC': + case 'CIRCLE': + o.center = vec(e.center); // radius + (radian) angles already match + break; + + case 'ELLIPSE': + o.center = vec(e.center); + o.majorAxis = vec(e.majorAxisEndPoint); + o.ratio = e.axisRatio; + o.startParam = e.startAngle; + o.endParam = e.endAngle; + break; + + case 'POINT': + o.position = vec(e.position); + break; + + case 'SOLID': + case '3DFACE': { + const p = e.points || []; + if (p[0]) o.corner1 = vec(p[0]); + if (p[1]) o.corner2 = vec(p[1]); + if (p[2]) o.corner3 = vec(p[2]); + if (p[3]) o.corner4 = vec(p[3]); + o.type = 'SOLID'; + break; + } + + case 'TEXT': + case 'ATTRIB': + case 'ATTDEF': + o.insertionPoint = vec(e.startPoint || e.position); + o.alignmentPt = vec(e.endPoint); + o.rotationAngle = (e.rotation || 0) * DEG2RAD; + o.horizAlignment = e.halign || 0; + o.vertAlignment = e.valign || 0; + o.textHeight = e.textHeight ?? e.height; + break; + + case 'MTEXT': + o.insertionPoint = vec(e.position); + o.textHeight = e.height ?? e.textHeight; + if (e.directionVector) o.xAxisDir = vec(e.directionVector); + else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) }; + break; + + case 'INSERT': { + o.insertionPoint = vec(e.position); + o.scaleX = e.xScale ?? 1; + o.scaleY = e.yScale ?? 1; + o.rotation = (e.rotation || 0) * DEG2RAD; + const h = nameToH.get(e.name); + if (h != null) o.blockHeaderHandle = { value: h }; + break; + } + + case 'DIMENSION': { + const h = nameToH.get(e.block); + if (h != null) o.blockHeaderHandle = { value: h }; + break; + } + + default: + break; + } + return o; +} + +/** + * Convert a full dxf-parser result → Viewer2D parseResult. + * @param {object} dxf output of `new DxfParser().parseSync(text)` + * @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}} + */ +export function dxfToParseResult(dxf) { + const blocks = dxf.blocks || {}; + const names = Object.keys(blocks); + const nameToH = new Map(); + names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles + + const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n)); + const msH = msName != null ? nameToH.get(msName) : 0; + + const entities = []; + // Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle. + for (const e of (dxf.entities || [])) { + const o = adaptEntity(e, nameToH); + o.ownerHandle = { value: msH }; + entities.push(o); + } + // Block-definition entities → owned by their block's synthetic handle. + for (const n of names) { + const H = nameToH.get(n); + for (const e of (blocks[n].entities || [])) { + const o = adaptEntity(e, nameToH); + o.ownerHandle = { value: H }; + entities.push(o); + } + } + + const blockTable = names.map((n) => ({ + name: n, + handle: { value: nameToH.get(n) }, + basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 }, + })); + + const layerSrc = dxf.tables?.layer?.layers || {}; + const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex })); + + return { + version: 'dxf', + vars: dxf.header || {}, + entities, + tables: { blocks: blockTable, layers: layerTable }, + }; +} diff --git a/src/viewer2d/dxfHatchHandler.d.ts b/src/viewer2d/dxfHatchHandler.d.ts new file mode 100644 index 0000000..7a1eb37 --- /dev/null +++ b/src/viewer2d/dxfHatchHandler.d.ts @@ -0,0 +1,5 @@ +/** HATCH entity handler for dxf-parser's registerEntityHandler(). */ +export default class HatchHandler { + ForEntityName: string; + parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown; +} diff --git a/src/viewer2d/dxfHatchHandler.js b/src/viewer2d/dxfHatchHandler.js new file mode 100644 index 0000000..6135d11 --- /dev/null +++ b/src/viewer2d/dxfHatchHandler.js @@ -0,0 +1,190 @@ +/** + * HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops + * HATCH silently). Registered via the library's public `registerEntityHandler()`, + * so dxf-parser itself stays unmodified (MIT-clean). + * + * Emits the shape Viewer2D's HATCH renderer consumes: + * entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] } + * Viewer2D fills solid hatches (with island/hole nesting) and draws boundary + * outlines; bulge arcs on polyline boundaries are honored. + * + * Boundary kinds handled: + * - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42) + * - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points + * ELLIPSE(72=3), SPLINE(72=4, control-point polyline) + * + * Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the + * boundary outline as a proxy, matching its DWG behavior. + */ +const DEG2RAD = Math.PI / 180; +const ARC_STEPS = 48; + +function tessArc(cx, cy, r, a0, a1, ccw, out) { + let span = a1 - a0; + if (ccw) { while (span <= 0) span += Math.PI * 2; } + else { while (span >= 0) span -= Math.PI * 2; } + const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS)); + for (let i = 0; i <= steps; i++) { + const a = a0 + (span * i) / steps; + out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); + } +} + +export default class HatchHandler { + constructor() { + this.ForEntityName = 'HATCH'; + } + + parseEntity(scanner, curr) { + const entity = { type: curr.value, paths: [], solidFill: false }; + curr = scanner.next(); + while (!scanner.isEOF()) { + if (curr.code === 0) break; + switch (curr.code) { + case 70: // solid fill flag + entity.solidFill = curr.value !== 0; + break; + case 2: // pattern name + entity.patternName = curr.value; + break; + case 8: // layer + entity.layer = curr.value; + break; + case 62: // color index + entity.colorIndex = curr.value; + break; + case 91: { // number of boundary paths → parse them by count + const n = curr.value; + let g = scanner.next(); + for (let i = 0; i < n && !scanner.isEOF(); i++) { + g = this._readPath(scanner, entity, g); + } + curr = g; // g is the group following the last path's data + continue; // curr already advanced — skip the trailing next() + } + default: + break; + } + curr = scanner.next(); + } + return entity; + } + + /** Parse one boundary path starting at its 92 group; return the next group after it. */ + _readPath(scanner, entity, g) { + // g.code should be 92 (path type flag); if not, skip until we find it. + while (!scanner.isEOF() && g.code !== 92) g = scanner.next(); + const flag = g.value | 0; + const isPolyline = (flag & 2) !== 0; + const path = { points: [], bulges: [] }; + let cur = scanner.next(); + + if (isPolyline) { + let hasBulge = false; + while (cur.code === 72 || cur.code === 73) { + if (cur.code === 72) hasBulge = cur.value !== 0; + cur = scanner.next(); + } + let numVerts = 0; + if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); } + for (let v = 0; v < numVerts && !scanner.isEOF(); v++) { + let x = 0, y = 0, b = 0; + if (cur.code === 10) { x = cur.value; cur = scanner.next(); } + if (cur.code === 20) { y = cur.value; cur = scanner.next(); } + if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); } + path.points.push({ x, y }); + path.bulges.push(b); + } + } else { + let numEdges = 0; + if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); } + for (let e = 0; e < numEdges && !scanner.isEOF(); e++) { + let edgeType = 0; + if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); } + cur = this._readEdge(scanner, path, edgeType, cur); + } + } + + // Consume the per-path trailing "source boundary objects" block (97 + 330×count). + if (cur.code === 97) { + const srcN = cur.value | 0; + cur = scanner.next(); + for (let s = 0; s < srcN && !scanner.isEOF(); s++) { + if (cur.code === 330) cur = scanner.next(); + else break; + } + } + if (path.points.length >= 2) entity.paths.push(path); + return cur; + } + + /** Tessellate one edge into path.points; return the next group. */ + _readEdge(scanner, path, edgeType, cur) { + if (edgeType === 1) { + // LINE: 10/20 start, 11/21 end + let sx = 0, sy = 0, ex = 0, ey = 0; + if (cur.code === 10) { sx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { sy = cur.value; cur = scanner.next(); } + if (cur.code === 11) { ex = cur.value; cur = scanner.next(); } + if (cur.code === 21) { ey = cur.value; cur = scanner.next(); } + path.points.push({ x: sx, y: sy }); + path.points.push({ x: ex, y: ey }); // last edge's end closes the loop + } else if (edgeType === 2) { + // ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw + let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1; + if (cur.code === 10) { cx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { cy = cur.value; cur = scanner.next(); } + if (cur.code === 40) { r = cur.value; cur = scanner.next(); } + if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); } + tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points); + } else if (edgeType === 3) { + // ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw + let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1; + if (cur.code === 10) { cx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { cy = cur.value; cur = scanner.next(); } + if (cur.code === 11) { mx = cur.value; cur = scanner.next(); } + if (cur.code === 21) { my = cur.value; cur = scanner.next(); } + if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); } + if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); } + const major = Math.hypot(mx, my); + const minor = major * ratio; + const rot = Math.atan2(my, mx); + let span = a1 - a0; + if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; } + else { while (span >= 0) span -= Math.PI * 2; } + const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72)); + for (let i = 0; i <= steps; i++) { + const t = a0 + (span * i) / steps; + const ex = major * Math.cos(t), ey = minor * Math.sin(t); + path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) }); + } + } else if (edgeType === 4) { + // SPLINE: approximate by its control points (10/20). Skip knots/weights. + // 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights + let numKnots = 0, numCtrl = 0; + const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; }; + while (!scanner.isEOF()) { + if (cur.code === 95) { numKnots = readInt(); } + else if (cur.code === 96) { numCtrl = readInt(); } + else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); } + else break; + } + for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next(); + for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) { + let x = 0, y = 0; + if (cur.code === 10) { x = cur.value; cur = scanner.next(); } + if (cur.code === 20) { y = cur.value; cur = scanner.next(); } + if (cur.code === 42) cur = scanner.next(); // weight + path.points.push({ x, y }); + } + } else { + // Unknown edge type: advance one group to avoid stalling. + cur = scanner.next(); + } + return cur; + } +} diff --git a/src/viewer2d/ext2d.ts b/src/viewer2d/ext2d.ts new file mode 100644 index 0000000..3fad55a --- /dev/null +++ b/src/viewer2d/ext2d.ts @@ -0,0 +1,25 @@ +/** + * 2D file-format detection for the 2D+3D merge dispatch. + * + * 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser. + * Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer. + * Kept dependency-free so both the dropzone and the controller can import it + * without pulling in either viewer. + */ +export const DWG_EXT = /\.dwg$/i; +export const DXF_EXT = /\.dxf$/i; + +/** True if the name is a binary DWG (parsed by the WASM parser). */ +export function isDwg(name: string): boolean { + return DWG_EXT.test(name); +} + +/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */ +export function isDxf(name: string): boolean { + return DXF_EXT.test(name); +} + +/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */ +export function is2D(name: string): boolean { + return DWG_EXT.test(name) || DXF_EXT.test(name); +} diff --git a/src/viewer2d/parseDxf.ts b/src/viewer2d/parseDxf.ts new file mode 100644 index 0000000..798e473 --- /dev/null +++ b/src/viewer2d/parseDxf.ts @@ -0,0 +1,24 @@ +/** + * Parse DXF text/buffer into the same CadParseResult shape as DWG. + */ +import DxfParser from 'dxf-parser'; +import HatchHandler from './dxfHatchHandler.js'; +import { dxfToParseResult } from './dxfAdapter.js'; +import { decodeDxf } from './decodeDxf'; +import type { CadParseResult } from './dwgParser'; + +/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */ +export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult { + return parseDxfText(decodeDxf(buf)); +} + +/** Parse DXF text already decoded as a string. */ +export function parseDxfText(text: string): CadParseResult { + const parser = new DxfParser(); + // dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'. + parser.registerEntityHandler( + HatchHandler as unknown as Parameters[0], + ); + const dxf = parser.parseSync(text); + return dxfToParseResult(dxf); +} diff --git a/src/viewer2d/shims.d.ts b/src/viewer2d/shims.d.ts new file mode 100644 index 0000000..ed70828 --- /dev/null +++ b/src/viewer2d/shims.d.ts @@ -0,0 +1,20 @@ +declare module 'dxf-parser' { + export default class DxfParser { + parseSync(text: string): unknown; + registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void; + } +} + +declare module 'opentype.js' { + export function parse(buffer: ArrayBuffer): unknown; +} + +declare module '*.wasm?url' { + const url: string; + export default url; +} + +declare module '*.wasm' { + const url: string; + export default url; +} diff --git a/src/viewer2d/slugText.ts b/src/viewer2d/slugText.ts new file mode 100644 index 0000000..ab6e240 --- /dev/null +++ b/src/viewer2d/slugText.ts @@ -0,0 +1,400 @@ +/** + * slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's + * Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines", + * JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent + * dedicated to the public domain, attribution retained here). + * + * Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier + * outlines are uploaded once per UNIQUE GLYPH into a float data texture, and + * the fragment shader computes an antialiased winding number per pixel by + * intersecting horizontal + vertical rays with the glyph's curves (curves + * pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays + * razor sharp at any zoom and memory scales with glyph count, not text count. + * + * Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines + * (quadratic only), Latin + full Hangul. + */ +import * as THREE from 'three'; +import { parse as parseFont } from 'opentype.js'; + +const TEX_W = 1024; // data texture width in texels (RGBA32F) +const BANDS = 8; // bands per axis per glyph +const PAD = 0.08; // em padding around glyph quads (AA spill room) + +interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number } + +interface GlyphRec { + base: number; // texel index of the glyph header (-1 when no ink, e.g. space) + advance: number; // em units +} + +/** Default TrueType font URL (host must serve Hangul-capable TTF). */ +let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf'; + +/** Override the default font path before creating CadViewer / Viewer2D. */ +export function setDefaultFontUrl(url: string): void { + defaultFontUrl = url; + // reset shared engine so the next shared() uses the new URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (SlugTextEngine as any)._shared = null; +} + +/** One shared font engine (font fetch + parse happens once). */ +export class SlugTextEngine { + private static _shared: Promise | null = null; + + static shared(url: string = defaultFontUrl): Promise { + if (!this._shared) { + this._shared = (async () => { + const buf = await (await fetch(url)).arrayBuffer(); + return new SlugTextEngine(parseFont(buf)); + })(); + // allow a retry on transient fetch failure instead of caching the rejection + this._shared.catch(() => { (SlugTextEngine as any)._shared = null; }); + } + return this._shared; + } + + readonly capHeightEm: number; + readonly ascentEm: number; + readonly descentEm: number; + + private font: any; + private upem: number; + private glyphs = new Map(); + private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed + private used = 0; // texels consumed + private _texture: THREE.DataTexture | null = null; + private _texelsUploaded = 0; + + private constructor(font: any) { + this.font = font; + this.upem = font.unitsPerEm; + this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem; + this.ascentEm = font.ascender / this.upem; + this.descentEm = font.descender / this.upem; // negative + } + + /** Layout width of one line, in em units. */ + measureEm(line: string): number { + let w = 0; + for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance; + return w; + } + + ensureGlyph(cp: number): GlyphRec { + let rec = this.glyphs.get(cp); + if (rec) return rec; + + const glyph = this.font.charToGlyph(String.fromCodePoint(cp)); + const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem; + // getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y. + const path = glyph.getPath(0, 0, 1); + const curves: Curve[] = []; + let sx = 0, sy = 0, px = 0, py = 0; + for (const c of path.commands) { + switch (c.type) { + case 'M': px = sx = c.x; py = sy = -c.y; break; + case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break; + case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break; + case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively + const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2; + curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y }); + px = c.x; py = -c.y; break; + } + case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break; + } + } + + if (!curves.length) { + rec = { base: -1, advance }; + this.glyphs.set(cp, rec); + return rec; + } + + // Conservative bbox from all points (control points included). + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const q of curves) { + x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1); + y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1); + } + const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6); + + // Band membership (conservative, via control-point extents). + const hBands: number[][] = Array.from({ length: BANDS }, () => []); + const vBands: number[][] = Array.from({ length: BANDS }, () => []); + curves.forEach((q, i) => { + const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1); + const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1); + const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h); + for (let b = hb0; b <= hb1; b++) hBands[b].push(i); + const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w); + for (let b = vb0; b <= vb1; b++) vBands[b].push(i); + }); + + // ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ── + const listTexels = (l: number[]) => Math.ceil(l.length / 4); + const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0) + + vBands.reduce((s, l) => s + listTexels(l), 0); + const total = 2 + BANDS * 2 + idxTexels + curves.length * 2; + const base = this.alloc(total); + const d = this.data; + const put = (t: number, a: number, b: number, c: number, e: number) => { + d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e; + }; + + const curveBase = base + 2 + BANDS * 2 + idxTexels; + put(base, x0, y0, x1, y1); + put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase + + let listCursor = base + 2 + BANDS * 2; + const writeBands = (bands: number[][], texel: number) => { + for (let b = 0; b < BANDS; b++) { + put(texel + b, listCursor, bands[b].length, 0, 0); + const l = bands[b]; + for (let j = 0; j < l.length; j += 4) { + put(listCursor++, + curveBase + l[j] * 2, + j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0, + j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0, + j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0); + } + } + }; + writeBands(hBands, base + 2); + writeBands(vBands, base + 2 + BANDS); + + curves.forEach((q, i) => { + put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy); + put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0); + }); + + rec = { base, advance }; + this.glyphs.set(cp, rec); + return rec; + } + + bboxOf(rec: GlyphRec): [number, number, number, number] { + const t = rec.base * 4; + return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]]; + } + + private alloc(texels: number): number { + const need = (this.used + texels) * 4; + if (need > this.data.length) { + let cap = this.data.length; + while (cap < need) cap *= 2; + const next = new Float32Array(cap); + next.set(this.data); + this.data = next; + } + const at = this.used; + this.used += texels; + return at; + } + + /** Data texture with all glyph data uploaded (recreated when it grew). */ + texture(): THREE.DataTexture { + const rows = Math.max(1, Math.ceil(this.used / TEX_W)); + if (!this._texture || this._texelsUploaded < this.used) { + this._texture?.dispose(); + const buf = new Float32Array(TEX_W * rows * 4); + buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4))); + const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType); + tex.minFilter = THREE.NearestFilter; + tex.magFilter = THREE.NearestFilter; + tex.generateMipmaps = false; + tex.needsUpdate = true; + this._texture = tex; + this._texelsUploaded = this.used; + } + return this._texture; + } +} + +function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve { + return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 }; +} + +function clampBand(f: number): number { + return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS))); +} + +// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ── + +const VERT = /* glsl */` + attribute vec2 emuv; + attribute float gbase; + attribute vec3 tcolor; + varying vec2 vEm; + flat varying int vBase; + varying vec3 vColor; + void main() { + vEm = emuv; + vBase = int(gbase); + vColor = tcolor; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const FRAG = /* glsl */` + precision highp float; + uniform sampler2D dataTex; + varying vec2 vEm; + flat varying int vBase; + varying vec3 vColor; + layout(location = 0) out vec4 fragOut; + + vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); } + + // Lengyel's banded winding-number coverage for one ray along +x from p. + // Curves are fetched via the band's index list; 'swap' mirrors x/y for the + // vertical (+y) ray, whose winding sign flips (handedness reversal). + float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) { + vec4 band = T(bandTexel); + int count = int(band.y); + float cov = 0.0; + for (int j = 0; j < 256; j++) { + if (j >= count) break; + vec4 idx4 = T(int(band.x) + (j >> 2)); + int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w); + vec4 A = T(cb); + vec2 e = T(cb + 1).xy; + // Swap the CURVE points before subtracting: p is already in swapped + // coordinates for the vertical ray ((A.xy - p).yx would be wrong). + vec2 p1 = (swap ? A.yx : A.xy) - p; + vec2 p2 = (swap ? A.wz : A.zw) - p; + vec2 p3 = (swap ? e.yx : e.xy) - p; + + uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u; + if (code != 0u) { + vec2 a = p1 - p2 * 2.0 + p3; + vec2 b = p1 - p2; + float t1, t2; + if (abs(a.y) < 1e-6) { + float t = p1.y / (2.0 * b.y); + t1 = t; t2 = t; + } else { + float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0)); + t1 = (b.y - d) / a.y; + t2 = (b.y + d) / a.y; + } + if ((code & 1u) != 0u) { + float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x; + cov += clamp(x * ppem + 0.5, 0.0, 1.0); + } + if (code > 1u) { + float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x; + cov -= clamp(x * ppem + 0.5, 0.0, 1.0); + } + } + } + return cov; + } + + void main() { + vec4 bbox = T(vBase); + vec4 bases = T(vBase + 1); + vec2 fw = fwidth(vEm); + + vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6)); + int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1}); + int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1}); + + float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false); + float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true); + float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0); + if (alpha < 0.004) discard; + fragOut = vec4(vColor, alpha); + } +`; + +/** Accumulates positioned strings and builds one merged mesh (single draw call). */ +export class SlugTextBatch { + private pos: number[] = []; + private emuv: number[] = []; + private gbase: number[] = []; + private color: number[] = []; + private index: number[] = []; + + constructor(private engine: SlugTextEngine) {} + + /** + * alignH: 0/3/5=left datum, 1/4=center, 2=right. + * alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching + * the previous canvas-sprite behaviour). Multi-line via '\n'. + */ + add(text: string, pos: { x: number; y: number }, height: number, rotation: number, + colorInt: number, alignH: number, alignV: number): void { + const eng = this.engine; + const lines = String(text).split('\n'); + const scale = height / eng.capHeightEm; + const lineStep = height * 5 / 3; // CAD default MTEXT line spacing + const blockH = (lines.length - 1) * lineStep + height; + + // DXF group 72 = 4 ("Middle") centers both axes on the alignment point; + // group 73 (vertical) is ignored. alignH already centers horizontally (below); + // force vertical to middle here too, else vA=0 text (block-internal plain + // TEXT, e.g. titleblock labels) renders half a line too high. + if (alignH === 4) alignV = 2; + + let baseline0: number; + if (alignV === 3) baseline0 = -height; // top datum + else if (alignV === 2) baseline0 = blockH / 2 - height; // middle + else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom + + const cos = Math.cos(rotation), sin = Math.sin(rotation); + const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255; + + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + if (!line) continue; + const wEm = eng.measureEm(line); + const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0; + const oy = baseline0 - li * lineStep; + let pen = 0; + for (const ch of line) { + const rec = eng.ensureGlyph(ch.codePointAt(0)!); + if (rec.base >= 0) { + const [x0, y0, x1, y1] = this.engine.bboxOf(rec); + const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD; + const v = this.pos.length / 3; + for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) { + const lx = ox + (pen + ex) * scale; + const ly = oy + ey * scale; + this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1); + this.emuv.push(ex, ey); + this.gbase.push(rec.base); + this.color.push(r, g, b); + } + this.index.push(v, v + 1, v + 2, v, v + 2, v + 3); + } + pen += rec.advance; + } + } + } + + /** Build the merged mesh; null when nothing was added. */ + build(): THREE.Mesh | null { + if (!this.index.length) return null; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3)); + geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2)); + geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1)); + geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3)); + geo.setIndex(this.index); + const mat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, + vertexShader: VERT, + fragmentShader: FRAG, + uniforms: { dataTex: { value: this.engine.texture() } }, + transparent: true, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = true; + return mesh; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c5e5d70 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "allowJs": true + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..487cfba --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5173, + strictPort: true, + }, + optimizeDeps: { + include: ['three', 'dxf-parser', 'opentype.js'], + }, + assetsInclude: ['**/*.wasm'], +});