Initial public release: DWG/DXF 2D viewer sample

Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D.
This commit is contained in:
minsung
2026-07-15 10:58:04 +09:00
commit 95fa6a452b
33 changed files with 6128 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+113
View File
@@ -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 |
+361
View File
@@ -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 · dont 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
View File
@@ -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>
+1102
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+361
View File
@@ -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 · dont 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.
+146
View File
@@ -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
View File
@@ -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());
+20
View File
@@ -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
View File
@@ -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>;
+286
View File
@@ -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.
+9
View File
@@ -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;
+17
View File
@@ -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/*"
]
}
+20
View File
@@ -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;
}
+63
View File
@@ -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;
}
+35
View File
@@ -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);
}
}
+25
View File
@@ -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);
}
+7
View File
@@ -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[] };
};
+160
View File
@@ -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 },
};
}
+5
View File
@@ -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;
}
+190
View File
@@ -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;
}
}
+25
View File
@@ -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);
}
+24
View File
@@ -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);
}
+20
View File
@@ -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;
}
+400
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+15
View File
@@ -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"]
}
+12
View File
@@ -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'],
});