Initial public release: DWG/DXF 2D viewer sample
Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,361 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
|
||||
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
|
||||
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
|
||||
--grid: rgba(14,124,134,.06);
|
||||
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
|
||||
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
|
||||
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); line-height: 1.6;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
|
||||
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
|
||||
h1 .dim { color: var(--ink-faint); font-weight: 400; }
|
||||
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
|
||||
.cta {
|
||||
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2 .num { color: var(--accent); }
|
||||
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 11px; height: 11px; border-radius: 3px; }
|
||||
.dot.wasm { background: var(--wasm); }
|
||||
.dot.js { background: var(--js); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
|
||||
.card.w { --tag: var(--wasm); }
|
||||
.card.j { --tag: var(--js); }
|
||||
.card .k {
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
|
||||
.badge.j { background: var(--accent-soft); color: var(--js); }
|
||||
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
|
||||
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
|
||||
.flow {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
|
||||
}
|
||||
.pipe { display: flex; align-items: stretch; min-width: 720px; }
|
||||
.node {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||
}
|
||||
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.node.w { border-color: var(--wasm); }
|
||||
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
|
||||
.node.hl .tier { color: var(--accent); }
|
||||
.node.w .tier { color: var(--wasm); }
|
||||
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
|
||||
.node .sub { font-size: 12px; color: var(--ink-soft); }
|
||||
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
|
||||
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
|
||||
.flow-note b { color: var(--accent); }
|
||||
pre {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
|
||||
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
|
||||
box-shadow: var(--shadow); margin: 0;
|
||||
}
|
||||
pre .c { color: var(--accent); }
|
||||
pre .w { color: var(--wasm); }
|
||||
pre .cm { color: var(--ink-faint); }
|
||||
.steps { display: grid; gap: 12px; }
|
||||
.step {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
|
||||
}
|
||||
.step .n {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
|
||||
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step h3 { margin: 0 0 4px; font-size: 15px; }
|
||||
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
||||
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="eyebrow">compose · don’t invent</div>
|
||||
<h1>DWG · DXF 모듈 조합 <span class="dim">레시피</span></h1>
|
||||
<p class="lede">
|
||||
전체 제품이 아니라, 지도에 있는 <b>재사용 모듈만</b> 골라 조립하면 2D CAD 뷰어가 됩니다.
|
||||
아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다.
|
||||
</p>
|
||||
<a class="cta" href="/">
|
||||
▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev)
|
||||
</a>
|
||||
<p style="margin:10px 0 0; font-family:var(--mono); font-size:12px; color:var(--ink-faint)">
|
||||
cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 01 modules -->
|
||||
<h2><span class="num">01</span> 조합에 쓰는 모듈 (지도 카드)</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot wasm"></i> WASM 파서</span>
|
||||
<span><i class="dot js"></i> JS 모듈 · 렌더러</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">acadrust-dwg <span class="badge w">wasm</span></div>
|
||||
<div class="d">DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.</div>
|
||||
<div class="path">src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dwgParser / acadrustParser <span class="badge j">wrap</span></div>
|
||||
<div class="d">WASM 1회 init + <code>parseDwgBuffer(bytes)</code>.</div>
|
||||
<div class="path">dwgParser.ts · acadrustParser.ts</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxf-parser <span class="badge j">npm</span></div>
|
||||
<div class="d">DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.</div>
|
||||
<div class="path">npm: dxf-parser</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfHatchHandler <span class="badge j">patch</span></div>
|
||||
<div class="d"><code>registerEntityHandler</code>로 HATCH 보강.</div>
|
||||
<div class="path">dxfHatchHandler.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfAdapter <span class="badge j">adapt</span></div>
|
||||
<div class="d">dxf-parser 출력 → Viewer2D 공통 스키마.</div>
|
||||
<div class="path">dxfAdapter.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">Viewer2D <span class="badge j">render</span></div>
|
||||
<div class="d">Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…</div>
|
||||
<div class="path">Viewer2D.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">three <span class="badge j">peer</span></div>
|
||||
<div class="d">WebGLRenderer + OrbitControls (pan/zoom).</div>
|
||||
<div class="path">npm: three ≥0.160</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">slugText + font <span class="badge j">text</span></div>
|
||||
<div class="d">opentype.js → GPU 텍스트. TTF를 호스트가 서빙.</div>
|
||||
<div class="path">slugText.ts · /fonts/*.ttf</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">ext2d <span class="badge j">util</span></div>
|
||||
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||
<div class="path">ext2d.ts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 pipeline -->
|
||||
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||
<div class="flow">
|
||||
<div class="pipe">
|
||||
<div class="node">
|
||||
<div class="tier">Host</div>
|
||||
<div class="name">File · URL · Drop</div>
|
||||
<div class="sub">.dwg / .dxf</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">ext2d</div>
|
||||
<div class="name">isDwg / isDxf</div>
|
||||
<div class="sub">분기</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node w">
|
||||
<div class="tier">Parser A or B</div>
|
||||
<div class="name">acadrust · dxf-parser</div>
|
||||
<div class="sub">(+ hatch · adapter)</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">Contract</div>
|
||||
<div class="name">CadParseResult</div>
|
||||
<div class="sub">공통 JSON</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">
|
||||
<div class="tier">Viewer2D</div>
|
||||
<div class="name">.load(result)</div>
|
||||
<div class="sub">three 씬</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-note">
|
||||
<b>핵심 계약</b>: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다.
|
||||
중간은 항상 <code>CadParseResult</code> 하나.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 recipe steps -->
|
||||
<h2><span class="num">03</span> 다른 프로젝트에 옮기는 순서</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">1</div>
|
||||
<div>
|
||||
<h3>모듈 소스 복사</h3>
|
||||
<p>
|
||||
이 샘플의 <code>src/viewer2d/</code> 전체를 복사.
|
||||
npm: <code>three</code>, <code>dxf-parser</code>, <code>opentype.js</code>.
|
||||
(원본 소스는 형제 폴더 <code>hmwebviewer/src/viewer2d</code>.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">2</div>
|
||||
<div>
|
||||
<h3>정적 자산</h3>
|
||||
<p>
|
||||
<code>public/fonts/NanumGothic-Regular.ttf</code> (한글 TEXT).
|
||||
WASM은 소스가 <code>?url</code>로 import — Vite면 <code>assetsInclude: ['**/*.wasm']</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">3</div>
|
||||
<div>
|
||||
<h3>아래 스니펫처럼 조합</h3>
|
||||
<p>
|
||||
래퍼 클래스 없이 모듈을 직접 import 해도 됩니다.
|
||||
실행 가능한 전체 예: 이 프로젝트 <code>src/main.ts</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">4</div>
|
||||
<div>
|
||||
<h3>호스트 UI만 붙이기</h3>
|
||||
<p>
|
||||
드롭존 · 툴바 · 레이어 패널은 모듈이 아님.
|
||||
샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 code -->
|
||||
<h2><span class="num">04</span> 최소 조합 코드 (샘플과 동일 패턴)</h2>
|
||||
<pre><span class="cm">// ① 모듈 지도 카드 → import</span>
|
||||
<span class="c">import</span> { Viewer2D } <span class="c">from</span> './viewer2d/Viewer2D.js';
|
||||
<span class="c">import</span> { parseDwgBuffer } <span class="c">from</span> './viewer2d/dwgParser'; <span class="cm">// acadrust-dwg</span>
|
||||
<span class="c">import</span> { parseDxfBuffer } <span class="c">from</span> './viewer2d/parseDxf'; <span class="cm">// dxf-parser+hatch+adapter</span>
|
||||
<span class="c">import</span> { isDwg, isDxf } <span class="c">from</span> './viewer2d/ext2d';
|
||||
<span class="c">import</span> { setDefaultFontUrl } <span class="c">from</span> './viewer2d/slugText';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
<span class="cm">// ② 렌더러</span>
|
||||
<span class="c">const</span> viewer = <span class="c">new</span> Viewer2D(document.getElementById('app')!);
|
||||
|
||||
<span class="cm">// ③ 파서 조합</span>
|
||||
<span class="c">async function</span> parseCad(buf: ArrayBuffer, name: string) {
|
||||
<span class="c">if</span> (isDxf(name)) <span class="c">return</span> parseDxfBuffer(buf); <span class="cm">// dxf-parser 경로</span>
|
||||
<span class="c">if</span> (isDwg(name)) <span class="c">return</span> parseDwgBuffer(<span class="c">new</span> Uint8Array(buf)); <span class="cm">// WASM 경로</span>
|
||||
<span class="c">throw new</span> Error('need .dwg or .dxf name');
|
||||
}
|
||||
|
||||
<span class="cm">// ④ 계약: parseResult → load</span>
|
||||
<span class="c">async function</span> open(file: File) {
|
||||
<span class="c">const</span> result = <span class="c">await</span> parseCad(<span class="c">await</span> file.arrayBuffer(), file.name);
|
||||
viewer.load(result);
|
||||
viewer.resize();
|
||||
viewer.fit();
|
||||
}</pre>
|
||||
|
||||
<h2><span class="num">05</span> DWG 경로 vs DXF 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">.dwg 경로 <span class="badge w">wasm</span></div>
|
||||
<div class="d">
|
||||
<code>parseDwgBuffer</code><br>
|
||||
→ acadrustParser.init<br>
|
||||
→ <span style="color:var(--wasm)">parse_dwg(bytes)</span><br>
|
||||
→ CadParseResult
|
||||
</div>
|
||||
<div class="path">Sample DWG 버튼으로 검증</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">.dxf 경로 <span class="badge j">js</span></div>
|
||||
<div class="d">
|
||||
<code>parseDxfBuffer</code><br>
|
||||
→ decodeDxf (UTF-8 / CP949…)<br>
|
||||
→ DxfParser + HatchHandler<br>
|
||||
→ dxfToParseResult
|
||||
</div>
|
||||
<div class="path">Sample DXF 버튼으로 검증</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="num">06</span> 샘플 위치</h2>
|
||||
<pre style="white-space:pre-wrap"><span class="c">dwg-dxf-viewer-sample/</span> ← 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
|
||||
|
||||
<span class="c">실행</span>
|
||||
cd ../dwg-dxf-viewer-sample # (hmwebviewer 기준 형제)
|
||||
npm install
|
||||
npm run dev → http://localhost:5173</pre>
|
||||
|
||||
<footer>
|
||||
<span>DWG/DXF only · sibling of hmwebviewer</span>
|
||||
<span>no 3D / SHP / R2</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DWG/DXF · 모듈 조합 샘플</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
|
||||
--accent: #3fb950; --wasm: #f0a03c; --line: #30363d; --danger: #f85149;
|
||||
--mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
|
||||
#app { position: fixed; inset: 0; }
|
||||
#toolbar {
|
||||
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
|
||||
}
|
||||
#toolbar > * { pointer-events: auto; }
|
||||
button, label.btn {
|
||||
background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 12px; font: 13px var(--sans); cursor: pointer;
|
||||
}
|
||||
button:hover, label.btn:hover { border-color: var(--accent); }
|
||||
button.primary { background: #238636; border-color: #2ea043; }
|
||||
button.wasm { border-color: var(--wasm); color: var(--wasm); }
|
||||
label.btn input { display: none; }
|
||||
#status {
|
||||
margin-left: auto; font: 12px var(--mono); color: var(--muted);
|
||||
background: rgba(22,27,34,.85); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 12px; max-width: min(48vw, 480px);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#status.err { color: var(--danger); }
|
||||
#recipe {
|
||||
position: fixed; bottom: 14px; left: 14px; z-index: 10; max-width: 340px;
|
||||
background: rgba(22,27,34,.94); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 12px; line-height: 1.45; color: var(--muted);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||
}
|
||||
#recipe h3 {
|
||||
margin: 0 0 8px; font: 600 11px var(--mono); letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--accent);
|
||||
}
|
||||
#recipe ol { margin: 0; padding-left: 18px; }
|
||||
#recipe li { margin-bottom: 4px; }
|
||||
#recipe code { font-family: var(--mono); font-size: 11px; color: var(--ink); }
|
||||
#recipe .mod-w { color: var(--wasm); }
|
||||
#recipe .mod-j { color: var(--accent); }
|
||||
#recipe a { color: var(--accent); font-family: var(--mono); font-size: 11px; }
|
||||
#layers {
|
||||
position: fixed; top: 56px; right: 12px; width: 200px; max-height: 50vh;
|
||||
overflow: auto; z-index: 10; background: rgba(22,27,34,.95);
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
||||
font-size: 12px; display: none;
|
||||
}
|
||||
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
|
||||
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div id="toolbar">
|
||||
<label class="btn">
|
||||
Open file
|
||||
<input id="file" type="file" accept=".dwg,.dxf" />
|
||||
</label>
|
||||
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
|
||||
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
|
||||
<button id="fit" type="button">Fit (F)</button>
|
||||
<button id="theme" type="button">Theme</button>
|
||||
<button id="layersBtn" type="button">Layers</button>
|
||||
<div id="status">ready</div>
|
||||
</div>
|
||||
|
||||
<div id="layers"><h3>Layers</h3><div id="layerList"></div></div>
|
||||
|
||||
<aside id="recipe">
|
||||
<h3>Module recipe</h3>
|
||||
<ol>
|
||||
<li><code class="mod-j">ext2d</code> — .dwg / .dxf 분기</li>
|
||||
<li><code class="mod-w">acadrust-dwg</code> 또는 <code class="mod-j">dxf-parser</code></li>
|
||||
<li><code class="mod-j">dxfAdapter</code> — DXF → 공통 스키마</li>
|
||||
<li><code class="mod-j">Viewer2D</code> + <code class="mod-j">three</code> 렌더</li>
|
||||
<li><code class="mod-j">slugText</code> + TTF 폰트</li>
|
||||
</ol>
|
||||
<p style="margin:10px 0 0">
|
||||
코드: <code>src/main.ts</code> ·
|
||||
지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1102
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
|
||||
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
|
||||
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
|
||||
--grid: rgba(14,124,134,.06);
|
||||
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
|
||||
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
|
||||
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); line-height: 1.6;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
|
||||
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
|
||||
h1 .dim { color: var(--ink-faint); font-weight: 400; }
|
||||
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
|
||||
.cta {
|
||||
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2 .num { color: var(--accent); }
|
||||
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 11px; height: 11px; border-radius: 3px; }
|
||||
.dot.wasm { background: var(--wasm); }
|
||||
.dot.js { background: var(--js); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
|
||||
.card.w { --tag: var(--wasm); }
|
||||
.card.j { --tag: var(--js); }
|
||||
.card .k {
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
|
||||
.badge.j { background: var(--accent-soft); color: var(--js); }
|
||||
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
|
||||
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
|
||||
.flow {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
|
||||
}
|
||||
.pipe { display: flex; align-items: stretch; min-width: 720px; }
|
||||
.node {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||
}
|
||||
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.node.w { border-color: var(--wasm); }
|
||||
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
|
||||
.node.hl .tier { color: var(--accent); }
|
||||
.node.w .tier { color: var(--wasm); }
|
||||
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
|
||||
.node .sub { font-size: 12px; color: var(--ink-soft); }
|
||||
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
|
||||
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
|
||||
.flow-note b { color: var(--accent); }
|
||||
pre {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
|
||||
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
|
||||
box-shadow: var(--shadow); margin: 0;
|
||||
}
|
||||
pre .c { color: var(--accent); }
|
||||
pre .w { color: var(--wasm); }
|
||||
pre .cm { color: var(--ink-faint); }
|
||||
.steps { display: grid; gap: 12px; }
|
||||
.step {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
|
||||
}
|
||||
.step .n {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
|
||||
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step h3 { margin: 0 0 4px; font-size: 15px; }
|
||||
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
||||
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="eyebrow">compose · don’t invent</div>
|
||||
<h1>DWG · DXF 모듈 조합 <span class="dim">레시피</span></h1>
|
||||
<p class="lede">
|
||||
전체 제품이 아니라, 지도에 있는 <b>재사용 모듈만</b> 골라 조립하면 2D CAD 뷰어가 됩니다.
|
||||
아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다.
|
||||
</p>
|
||||
<a class="cta" href="/">
|
||||
▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev)
|
||||
</a>
|
||||
<p style="margin:10px 0 0; font-family:var(--mono); font-size:12px; color:var(--ink-faint)">
|
||||
cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 01 modules -->
|
||||
<h2><span class="num">01</span> 조합에 쓰는 모듈 (지도 카드)</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot wasm"></i> WASM 파서</span>
|
||||
<span><i class="dot js"></i> JS 모듈 · 렌더러</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">acadrust-dwg <span class="badge w">wasm</span></div>
|
||||
<div class="d">DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.</div>
|
||||
<div class="path">src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dwgParser / acadrustParser <span class="badge j">wrap</span></div>
|
||||
<div class="d">WASM 1회 init + <code>parseDwgBuffer(bytes)</code>.</div>
|
||||
<div class="path">dwgParser.ts · acadrustParser.ts</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxf-parser <span class="badge j">npm</span></div>
|
||||
<div class="d">DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.</div>
|
||||
<div class="path">npm: dxf-parser</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfHatchHandler <span class="badge j">patch</span></div>
|
||||
<div class="d"><code>registerEntityHandler</code>로 HATCH 보강.</div>
|
||||
<div class="path">dxfHatchHandler.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfAdapter <span class="badge j">adapt</span></div>
|
||||
<div class="d">dxf-parser 출력 → Viewer2D 공통 스키마.</div>
|
||||
<div class="path">dxfAdapter.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">Viewer2D <span class="badge j">render</span></div>
|
||||
<div class="d">Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…</div>
|
||||
<div class="path">Viewer2D.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">three <span class="badge j">peer</span></div>
|
||||
<div class="d">WebGLRenderer + OrbitControls (pan/zoom).</div>
|
||||
<div class="path">npm: three ≥0.160</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">slugText + font <span class="badge j">text</span></div>
|
||||
<div class="d">opentype.js → GPU 텍스트. TTF를 호스트가 서빙.</div>
|
||||
<div class="path">slugText.ts · /fonts/*.ttf</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">ext2d <span class="badge j">util</span></div>
|
||||
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||
<div class="path">ext2d.ts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 pipeline -->
|
||||
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||
<div class="flow">
|
||||
<div class="pipe">
|
||||
<div class="node">
|
||||
<div class="tier">Host</div>
|
||||
<div class="name">File · URL · Drop</div>
|
||||
<div class="sub">.dwg / .dxf</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">ext2d</div>
|
||||
<div class="name">isDwg / isDxf</div>
|
||||
<div class="sub">분기</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node w">
|
||||
<div class="tier">Parser A or B</div>
|
||||
<div class="name">acadrust · dxf-parser</div>
|
||||
<div class="sub">(+ hatch · adapter)</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">Contract</div>
|
||||
<div class="name">CadParseResult</div>
|
||||
<div class="sub">공통 JSON</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">
|
||||
<div class="tier">Viewer2D</div>
|
||||
<div class="name">.load(result)</div>
|
||||
<div class="sub">three 씬</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-note">
|
||||
<b>핵심 계약</b>: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다.
|
||||
중간은 항상 <code>CadParseResult</code> 하나.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 recipe steps -->
|
||||
<h2><span class="num">03</span> 다른 프로젝트에 옮기는 순서</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">1</div>
|
||||
<div>
|
||||
<h3>모듈 소스 복사</h3>
|
||||
<p>
|
||||
이 샘플의 <code>src/viewer2d/</code> 전체를 복사.
|
||||
npm: <code>three</code>, <code>dxf-parser</code>, <code>opentype.js</code>.
|
||||
(원본 소스는 형제 폴더 <code>hmwebviewer/src/viewer2d</code>.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">2</div>
|
||||
<div>
|
||||
<h3>정적 자산</h3>
|
||||
<p>
|
||||
<code>public/fonts/NanumGothic-Regular.ttf</code> (한글 TEXT).
|
||||
WASM은 소스가 <code>?url</code>로 import — Vite면 <code>assetsInclude: ['**/*.wasm']</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">3</div>
|
||||
<div>
|
||||
<h3>아래 스니펫처럼 조합</h3>
|
||||
<p>
|
||||
래퍼 클래스 없이 모듈을 직접 import 해도 됩니다.
|
||||
실행 가능한 전체 예: 이 프로젝트 <code>src/main.ts</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">4</div>
|
||||
<div>
|
||||
<h3>호스트 UI만 붙이기</h3>
|
||||
<p>
|
||||
드롭존 · 툴바 · 레이어 패널은 모듈이 아님.
|
||||
샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 code -->
|
||||
<h2><span class="num">04</span> 최소 조합 코드 (샘플과 동일 패턴)</h2>
|
||||
<pre><span class="cm">// ① 모듈 지도 카드 → import</span>
|
||||
<span class="c">import</span> { Viewer2D } <span class="c">from</span> './viewer2d/Viewer2D.js';
|
||||
<span class="c">import</span> { parseDwgBuffer } <span class="c">from</span> './viewer2d/dwgParser'; <span class="cm">// acadrust-dwg</span>
|
||||
<span class="c">import</span> { parseDxfBuffer } <span class="c">from</span> './viewer2d/parseDxf'; <span class="cm">// dxf-parser+hatch+adapter</span>
|
||||
<span class="c">import</span> { isDwg, isDxf } <span class="c">from</span> './viewer2d/ext2d';
|
||||
<span class="c">import</span> { setDefaultFontUrl } <span class="c">from</span> './viewer2d/slugText';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
<span class="cm">// ② 렌더러</span>
|
||||
<span class="c">const</span> viewer = <span class="c">new</span> Viewer2D(document.getElementById('app')!);
|
||||
|
||||
<span class="cm">// ③ 파서 조합</span>
|
||||
<span class="c">async function</span> parseCad(buf: ArrayBuffer, name: string) {
|
||||
<span class="c">if</span> (isDxf(name)) <span class="c">return</span> parseDxfBuffer(buf); <span class="cm">// dxf-parser 경로</span>
|
||||
<span class="c">if</span> (isDwg(name)) <span class="c">return</span> parseDwgBuffer(<span class="c">new</span> Uint8Array(buf)); <span class="cm">// WASM 경로</span>
|
||||
<span class="c">throw new</span> Error('need .dwg or .dxf name');
|
||||
}
|
||||
|
||||
<span class="cm">// ④ 계약: parseResult → load</span>
|
||||
<span class="c">async function</span> open(file: File) {
|
||||
<span class="c">const</span> result = <span class="c">await</span> parseCad(<span class="c">await</span> file.arrayBuffer(), file.name);
|
||||
viewer.load(result);
|
||||
viewer.resize();
|
||||
viewer.fit();
|
||||
}</pre>
|
||||
|
||||
<h2><span class="num">05</span> DWG 경로 vs DXF 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">.dwg 경로 <span class="badge w">wasm</span></div>
|
||||
<div class="d">
|
||||
<code>parseDwgBuffer</code><br>
|
||||
→ acadrustParser.init<br>
|
||||
→ <span style="color:var(--wasm)">parse_dwg(bytes)</span><br>
|
||||
→ CadParseResult
|
||||
</div>
|
||||
<div class="path">Sample DWG 버튼으로 검증</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">.dxf 경로 <span class="badge j">js</span></div>
|
||||
<div class="d">
|
||||
<code>parseDxfBuffer</code><br>
|
||||
→ decodeDxf (UTF-8 / CP949…)<br>
|
||||
→ DxfParser + HatchHandler<br>
|
||||
→ dxfToParseResult
|
||||
</div>
|
||||
<div class="path">Sample DXF 버튼으로 검증</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="num">06</span> 샘플 위치</h2>
|
||||
<pre style="white-space:pre-wrap"><span class="c">dwg-dxf-viewer-sample/</span> ← 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
|
||||
|
||||
<span class="c">실행</span>
|
||||
cd ../dwg-dxf-viewer-sample # (hmwebviewer 기준 형제)
|
||||
npm install
|
||||
npm run dev → http://localhost:5173</pre>
|
||||
|
||||
<footer>
|
||||
<span>DWG/DXF only · sibling of hmwebviewer</span>
|
||||
<span>no 3D / SHP / R2</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
+164
@@ -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<CadParseResult> {
|
||||
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<CadParseResult> {
|
||||
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<string>();
|
||||
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<CadParseResult>) {
|
||||
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());
|
||||
Vendored
+20
@@ -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<string>): 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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+38
@@ -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<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
@@ -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 };
|
||||
Binary file not shown.
@@ -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;
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
/** Initialize the acadrust WASM module once (idempotent). */
|
||||
export function initAcadrustParser(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
entities?: unknown[];
|
||||
tables?: Record<string, unknown>;
|
||||
stats?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */
|
||||
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
|
||||
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
|
||||
await initAcadrustParser();
|
||||
return parseDwgAcadrust(bytes);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
|
||||
export function dxfToParseResult(dxf: unknown): {
|
||||
version: string;
|
||||
vars: Record<string, unknown>;
|
||||
entities: unknown[];
|
||||
tables: { blocks: unknown[]; layers: unknown[] };
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
Vendored
+5
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<DxfParser['registerEntityHandler']>[0],
|
||||
);
|
||||
const dxf = parser.parseSync(text);
|
||||
return dxfToParseResult(dxf);
|
||||
}
|
||||
Vendored
+20
@@ -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;
|
||||
}
|
||||
@@ -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<SlugTextEngine> | null = null;
|
||||
|
||||
static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
|
||||
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<number, GlyphRec>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
Reference in New Issue
Block a user