Compare commits

...
4 Commits
Author SHA1 Message Date
minsung 15ef6efdd8 docs: add HMDesign porting session report
HTML write-up of the React19+HMDesign porting session: timeline,
submodule+workspace integration, UI-to-component mapping, the
StrictMode double-mount bug and fix, and verified vs unverified
status.

Refs #2
2026-10-12 18:10:01 +09:00
minsung 96c6b780ce fix: drop StrictMode to stop double-mounting Viewer2D
Viewer2D (ported unchanged) has no dispose/teardown API — it attaches
permanent window listeners and starts its own rAF render loop in the
constructor. React StrictMode's dev-only mount→unmount→remount cycle
was constructing it twice into the same container with no way to tear
the first instance down, leaving two overlapping canvases and broken
rendering (load reported success but nothing appeared on screen).
Also defensively clear the container's children before construction
so a future remount (e.g. Fast Refresh) can't repeat this.

Refs #1
2026-10-12 17:53:33 +09:00
minsung c7a782f19c fix: use HM Label instead of bare <label> for switch fields
ai-generation-guard.mjs flagged REUSE-004 on 5 native <label>
wrappers around Switch controls in Toolbar/OptionsPanel; swapped to
@hm-design/ui's Label per AGENTS.md field-label rule. Remaining guard
findings (hidden file input, dynamic panel geometry, per-entity CAD
layer color) are documented exceptions with no matching HM component.

Refs #1
2026-10-12 17:46:07 +09:00
minsung b114581c07 feat: React19+HMDesign shell with ported DWG/DXF viewer engine
Scaffolds the new app (Vite+React19+Tailwind v4) on HMDesign via git
submodule + npm workspace, ports src/viewer2d/* and optionsStore.ts
unchanged, and rebuilds the vanilla UI (toolbar/layers/properties/
options panels, coord readout, license dialog, load progress) as HM
Design System components. Panel drag/collapse ported from
panelUtils.ts as a React hook; Measure UI intentionally left
unwired per original app parity.

Typecheck and dev-server module serving verified; in-browser
rendering not yet verified in this session (no browser tool
available) — needs manual check.

Refs #1
2026-10-12 17:42:58 +09:00
55 changed files with 16758 additions and 0 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
dist2/
*.local
.DS_Store
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
[submodule "HMDesign"]
path = HMDesign
url = https://gitea.hmac.kr/b25027/HMDesign.git
Submodule
+1
Submodule HMDesign added at d18e3c3380.
+258
View File
@@ -0,0 +1,258 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HMDesign 적용 작업 기록 — 2026-10-12</title>
<style>
:root {
--bg: #ffffff;
--fg: #1b1f24;
--muted: #6b7280;
--border: #e2e4e9;
--card: #f7f8fa;
--accent: #2563eb;
--ok: #15803d;
--ok-bg: #ecfdf3;
--warn: #92400e;
--warn-bg: #fef6e7;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--border: #30363d;
--card: #161b22;
--accent: #60a5fa;
--ok: #4ade80;
--ok-bg: #0f2a1a;
--warn: #fbbf24;
--warn-bg: #2a2110;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
line-height: 1.6;
}
main {
max-width: 880px;
margin: 0 auto;
padding: 40px 20px 80px;
}
header h1 {
font-size: 1.6rem;
margin: 0 0 4px;
}
header p {
color: var(--muted);
margin: 0 0 32px;
font-size: 0.9rem;
}
h2 {
font-size: 1.15rem;
margin: 40px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
h3 {
font-size: 1rem;
margin: 20px 0 8px;
}
p, li { font-size: 0.92rem; }
code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
pre {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-size: 0.82rem;
}
pre code { background: none; border: none; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin: 12px 0 20px;
}
th, td {
text-align: left;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th { color: var(--muted); font-weight: 600; }
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.warn { background: var(--warn-bg); color: var(--warn); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin: 12px 0;
}
.meta-list { list-style: none; padding: 0; margin: 0; }
.meta-list li { margin-bottom: 6px; }
.meta-list b { color: var(--muted); font-weight: 500; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
ul { padding-left: 1.3em; }
.timeline { border-left: 2px solid var(--border); padding-left: 18px; margin: 12px 0; }
.timeline .entry { margin-bottom: 14px; }
.timeline time { display: block; font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
</style>
</head>
<body>
<main>
<header>
<h1>HMDesign 적용 작업 기록</h1>
<p>dwg-dxf-viewer-hm · 2026-10-12 · Gitea 이슈 <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/2">#2 hmdesign 적용</a> / <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/1">#1 디자인팀 디자인 시스템 적용</a></p>
</header>
<h2>개요</h2>
<p>
기존 vanilla TS+Vite+Three.js DWG/DXF 뷰어(<code>dwg-dxf-viewer-sample</code>)를 건드리지 않고,
이 저장소(<code>dwg-dxf-viewer-hm</code>)에 <b>React 19 + HMDesign 디자인 시스템</b> 기반으로
UI/UX를 새로 구성했다. 뷰어 렌더링 엔진(<code>Viewer2D.js</code> + 파서들)은 로직을
그대로 포팅하고, 그 위의 모든 UI(툴바·레이어/속성/옵션 패널·좌표 표시·라이선스 팝업·로딩바)는
HMDesign 컴포넌트로 새로 디자인했다. 기존 UI의 색/아이콘/레이아웃은 가져오지 않았고,
<b>기능만</b> 1:1 유지하는 것을 목표로 했다.
</p>
<h2>작업 흐름</h2>
<div class="timeline">
<div class="entry">
<time>16:59</time>
<div><b>Gitea 퍼블리시</b> — <code>dwg-dxf-viewer-hm</code>을 public repo로 생성, 로컬 git init 후 최초 push.</div>
</div>
<div class="entry">
<time>17:01</time>
<div><b>이슈 #1 생성</b> — "디자인팀 디자인 시스템 적용": 앞으로 구현되는 UI/UX 작업을 커밋으로 연결.</div>
</div>
<div class="entry">
<time>17:4x</time>
<div><b>포팅 계획 확정</b> — 현재 디렉토리 재사용 / HMDesign은 git submodule+npm workspace로 연결 / Measure 기능 비노출 / 로딩 progress bar는 HM 추천 방식으로 신규 연결, 3가지+1가지 질문에 대한 사용자 답변을 바탕으로 계획 수립 후 승인.</div>
</div>
<div class="entry">
<time>17:42</time>
<div><b>1차 구현 커밋</b> — submodule 추가, 워크스페이스 연결, <code>src/viewer2d/*</code> 포팅, React 컴포넌트 전체 신규 작성.</div>
</div>
<div class="entry">
<time>17:46</time>
<div><b>Guard 수정 커밋</b> — <code>ai-generation-guard.mjs</code> REUSE-004 지적(네이티브 <code>&lt;label&gt;</code>)을 HM <code>Label</code>로 교체.</div>
</div>
<div class="entry">
<time>17:53</time>
<div><b>버그 수정 커밋</b> — 샘플 로드 후 캔버스에 아무것도 안 보이는 문제 원인 확정 및 수정 (아래 "발견된 버그" 참고).</div>
</div>
<div class="entry">
<time>18:08</time>
<div><b>이슈 #2 생성</b> — "hmdesign 적용", 이 리포트 작성 및 커밋.</div>
</div>
</div>
<h2>아키텍처: HMDesign 연결</h2>
<p>
<code>@hm-design/ui</code>는 npm 레지스트리에 배포되지 않고 HMDesign 저장소 자신의 npm workspace
(<code>packages/*</code>) 안에서만 resolve된다. <code>exports</code>는 전부 <code>dist/*</code>를
가리키므로 반드시 빌드가 먼저 끝나 있어야 한다.
</p>
<pre><code>git submodule add https://gitea.hmac.kr/b25027/HMDesign.git HMDesign
git submodule update --init --recursive
cd HMDesign &amp;&amp; npm ci &amp;&amp; npm run build:packages &amp;&amp; cd .. # HMDesign 자체 devDependency로 dist/ 생성
npm install # 루트 workspaces(["HMDesign/packages/ui"])가 symlink 연결
npm run dev</code></pre>
<p>
HMDesign 내부 빌드는 HMDesign 자신의 <code>node_modules</code>(Storybook/Tailwind/TS7 등)로 수행하고,
외부 앱(이 저장소)은 빌드된 결과물만 workspace symlink로 소비한다 — 두 트리의 의존성 버전이 섞이지 않는다.
<code>@hm-design/center-graphic</code>은 라이선스 미확정으로 사용하지 않았다.
</p>
<p>
Vite 설정에서 <code>optimizeDeps.entries: ['index.html']</code>을 명시해야 한다 — 지정하지 않으면
Vite의 의존성 스캐너가 submodule(HMDesign) 안의 다른 예제용 <code>*.html</code>까지 크롤링하다
해석할 수 없는 <code>@/...</code> alias에서 죽는다.
</p>
<h2>구현 내용</h2>
<h3>디렉토리 구조</h3>
<pre><code>src/
├── main.tsx, App.tsx, index.css
├── viewer2d/ ← 기존 엔진 전체, 로직 불변, import 경로만 조정
├── lib/
│ ├── optionsStore.ts ← IndexedDB 영속, 그대로 포팅
│ └── propertyFormat.ts← 속성 포맷터만 추출 포팅 (DOM 렌더는 React로 대체)
├── components/ ← Toolbar / LayerPanel / PropertiesPanel / OptionsPanel /
│ DraggablePanel / CoordReadout / LicenseNotice / LoadProgress
└── hooks/
├── useDwgViewer.ts ← Viewer2D 인스턴스 소유 + 파일 로드 파이프라인 + 옵션 동기화
└── useDraggablePanel.ts ← panelUtils.ts 드래그/collapse 로직의 React 훅 포팅</code></pre>
<h3>UI → HMDesign 컴포넌트 매핑 (요약)</h3>
<table>
<tr><th>원본 UI</th><th>HMDesign 매핑</th></tr>
<tr><td>Open file / 샘플 3버튼</td><td><code>Button</code> (+숨김 <code>&lt;input type=file&gt;</code>, HM에 대응 컴포넌트 없음)</td></tr>
<tr><td>Model/Layout 탭</td><td><code>Tabs</code></td></tr>
<tr><td>2.5D·테마 토글</td><td><code>Switch</code> + <code>Label</code></td></tr>
<tr><td>레이어/옵션/속성 패널 열기</td><td><code>Toggle</code></td></tr>
<tr><td>레이어 검색/전체켜기끄기</td><td><code>FilterBar</code> / <code>FilterBarSearch</code> / <code>FilterBarActions</code></td></tr>
<tr><td>레이어 행(색상/개수/가시성)</td><td>커스텀 — color swatch(동적 color), <code>Badge</code>, lucide <code>Eye</code>/<code>EyeOff</code></td></tr>
<tr><td>패널 자체(드래그/접기/리사이즈)</td><td><code>DraggablePanel</code> 커스텀(HM <code>Card</code> 기반 + 포인터 드래그 훅, resize는 네이티브 CSS)</td></tr>
<tr><td>슬라이더+숫자입력 3종</td><td><code>Slider</code> + <code>Input</code></td></tr>
<tr><td>라이선스/크레딧</td><td><code>Dialog</code> (THIRD-PARTY-NOTICES / MPL-2.0 / patch 링크 3종 유지)</td></tr>
<tr><td>상단 로딩바</td><td><code>Progress</code> (원본은 미연결 죽은 코드였음 — HM 추천 방식으로 신규 실연결)</td></tr>
</table>
<p>Navigation(GNB/LNB/Dashboard)은 채용하지 않았다 — 멀티페이지 앱이 아닌 전체화면 캔버스형 단일 도구이기 때문.</p>
<h2>발견된 버그 및 수정</h2>
<div class="card">
<p><b>증상</b>: 샘플 DWG/DXF 3개 버튼 모두 "로드완료" 상태 텍스트는 뜨지만 캔버스에 아무것도 렌더되지 않음.</p>
<p><b>원인 확정</b>: <code>Viewer2D</code>(포팅된 vanilla 엔진)는 dispose/teardown API가 없고 생성자에서
<code>window</code>에 영구 리스너를 걸고 자체 <code>requestAnimationFrame</code> 루프를 시작한다.
React <code>&lt;StrictMode&gt;</code>의 개발 모드 mount→unmount→remount 더블 호출이
<code>Viewer2D</code>를 같은 container에 두 번 생성했고, 첫 인스턴스를 정리할 방법이 없어
캔버스가 중첩/렌더가 꼬였다.</p>
<p><b>수정 완료·미검증</b>: <code>main.tsx</code>에서 <code>&lt;StrictMode&gt;</code> 제거,
<code>useDwgViewer</code> 마운트 effect에서 construction 전에 <code>container.replaceChildren()</code>으로
방어적 클리어 추가. 코드 수정은 했으나 이 세션엔 브라우저 도구가 없어 실제 화면에서 재확인은 못 함 —
사용자 브라우저에서 재검증 필요.</p>
</div>
<h2>검증 상태</h2>
<ul>
<li><span class="badge ok">검증됨</span> npm workspace symlink 연결 (<code>@hm-design/ui</code> resolve 확인)</li>
<li><span class="badge ok">검증됨</span> <code>tsc --noEmit</code> 타입체크 통과</li>
<li><span class="badge ok">검증됨</span> Vite dev 서버에서 전체 모듈 HTTP 200 서빙 확인</li>
<li><span class="badge ok">검증됨</span> <code>ai-generation-guard.mjs</code> FAIL 0건 (NOT VERIFIED 4건은 HM에 대응 컴포넌트 없는 동적 값 — 문서화된 예외)</li>
<li><span class="badge warn">미검증</span> 실제 브라우저 렌더링 (WASM 파싱, Three.js 캔버스, 패널 드래그/접기, IndexedDB 옵션 영속, 390~1440px 반응형) — 이 세션에 브라우저 도구 없음, 컴파일 통과는 동작 검증이 아님</li>
</ul>
<h2>다음 할 일</h2>
<ul>
<li>StrictMode 제거 수정 후 브라우저에서 샘플 3개 재확인</li>
<li>390/768/1280/1440px 반응형 확인</li>
<li>기존 프로젝트 기능 체크리스트와 1:1 대조 (파일 로드, pan/zoom/orbit, Fit+F, 2.5D, 그리드, 줌속도, 선가중치, Model/Layout 탭, 엔티티 선택→속성, 줌투셀렉션, 좌표 readout, 패널 영속, 한글 텍스트, 라이선스 링크, 신규 progress bar)</li>
</ul>
</main>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DWG/DXF Viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7481
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "dwg-dxf-viewer-hm",
"private": true,
"version": "0.1.0",
"type": "module",
"workspaces": [
"HMDesign/packages/ui"
],
"scripts": {
"dev": "vite --port 5173 --strictPort",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@hm-design/ui": "*",
"dxf-parser": "^1.1.2",
"lucide-react": "^1.49.0",
"opentype.js": "^2.0.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"three": "^0.185.1"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/three": "^0.185.1",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "4.3.3",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
# Third-party notices
This viewer bundles third-party code. The notices below apply to the source
tree **and** to the built site (`dist2/`, deployed at
`https://dwg-dxf-viewer-sample.pages.dev/`), because the build embeds the
components listed here.
Served copies of the license texts live under
[`public/licenses/`](./public/licenses/) → `/licenses/…` on the deployed site.
---
## acadrust 0.4.1 — Mozilla Public License 2.0 — **MODIFIED**
| | |
|---|---|
| Component | `acadrust` (DWG/DXF parser, Rust) |
| Version | 0.4.1 |
| License | MPL-2.0 — full text: [`public/licenses/acadrust-MPL-2.0.txt`](./public/licenses/acadrust-MPL-2.0.txt) (`/licenses/acadrust-MPL-2.0.txt`) |
| Upstream | https://github.com/hakanaktt/acadrust · https://crates.io/crates/acadrust/0.4.1 |
| Upstream commit | `f249c2f816acf36ee51cd5533716bdd443c2517e` (from the crate's `.cargo_vcs_info.json`) |
| Author | Hakan AK |
| Shipped as | compiled into `assets/acadrust_dwg_bg-*.wasm` (Executable Form) |
**This copy is modified.** One file differs from the published crate:
```
src/io/dwg/dwg_stream_readers/object_reader/entities.rs (read_mesh array-count bounds)
```
The modification bounds `read_mesh`'s array counts by the bits remaining in the
object stream instead of the blanket 100 000-item guard, which truncated and
thereby corrupted large SubD meshes.
### How to obtain the Source Code Form (MPL-2.0 §3.2)
1. Get the unmodified crate source — `cargo vendor`, or
`https://crates.io/api/v1/crates/acadrust/0.4.1/download`, or the upstream
commit above.
2. Apply [`public/licenses/acadrust-0.4.1-read_mesh.patch`](./public/licenses/acadrust-0.4.1-read_mesh.patch)
(`/licenses/acadrust-0.4.1-read_mesh.patch` on the deployed site):
```bash
tar xf acadrust-0.4.1.crate && cd acadrust-0.4.1
patch -p1 < acadrust-0.4.1-read_mesh.patch
```
The result is the exact Source Code Form of the acadrust code compiled into the
shipped `.wasm`. Both the original and the modified file remain under MPL-2.0.
---
## dwg-wasm — MIT
The wasm-bindgen wrapper that turns acadrust output into this viewer's
`parseResult` (`hmwebviewer/rust/dwg-wasm/`). Our own code, MIT. MPL-2.0 §3.3
permits distributing this Larger Work under different terms as long as the
MPL-covered files above keep their license — which they do.
---
## Other components
| Component | License |
|---|---|
| three.js | MIT |
| dxf-parser | MIT |
| opentype.js | MIT |
| NanumGothic (`public/fonts/`) | SIL Open Font License 1.1 |
| Viewer2D / sample glue | MIT |
@@ -0,0 +1,111 @@
--- a/src/io/dwg/dwg_stream_readers/object_reader/entities.rs
+++ b/src/io/dwg/dwg_stream_readers/object_reader/entities.rs
@@ -1682,20 +1682,49 @@
MLineData { scale_factor, justification, start_point, normal, openclosed, lines_in_style, vertex_count, vertices, style_handle }
}
+/// Cap an array count against the bits actually left in the object's main
+/// stream instead of the blanket [`MAX_ARRAY_COUNT`].
+///
+/// MESH is the one entity whose arrays legitimately run past 100 000 items: a
+/// road-surface mesh in a civil drawing carries hundreds of thousands of
+/// vertices. Clamping those to the blanket cap does not merely truncate the
+/// mesh — the reader then continues from the wrong bit offset, so the face,
+/// edge and crease lists that follow decode as garbage (observed: a 100 000
+/// vertex clamp left a 14-face mesh with face sizes `[64, 0, 64, 23, 0, …]`).
+/// Bounding by the remaining bits keeps the corrupt-data protection — no
+/// unbounded allocation is possible — without mis-parsing a valid drawing.
+///
+/// `min_bits_per_item` is the shortest legal encoding of one element: a
+/// BitDouble is 2 bits at minimum (the 0.0 / 1.0 forms), so a 3BD vertex is 6
+/// and a BitLong is 2.
+fn stream_bounded_count(reader: &DwgMergedReader, raw: i32, min_bits_per_item: i64) -> i32 {
+ let raw = raw.max(0) as i64;
+ let total_bits = (reader.main().data_len() as i64).saturating_mul(8);
+ // The handle stream follows the main stream; anything past its start is not
+ // array data. handle_start() is 0 when the framing did not supply it.
+ let handle_start = reader.handle_start();
+ let end_bits = if handle_start > 0 { handle_start.min(total_bits) } else { total_bits };
+ let remaining = (end_bits - reader.position_in_bits()).max(0);
+ raw.min(remaining / min_bits_per_item.max(1)).min(i32::MAX as i64) as i32
+}
+
pub fn read_mesh(reader: &mut DwgMergedReader) -> MeshData {
let version = reader.read_bit_short();
let blend_crease = reader.read_bit();
let subdivision_level = reader.read_bit_long();
- let num_verts = safe_count(reader.read_bit_long());
+ let raw_verts = reader.read_bit_long();
+ let num_verts = stream_bounded_count(reader, raw_verts, 6);
let mut vertices = Vec::with_capacity(num_verts as usize);
for _ in 0..num_verts { vertices.push(reader.read_3bit_double()); }
- let total_face_data = safe_count(reader.read_bit_long());
+ let raw_face_data = reader.read_bit_long();
+ let total_face_data = stream_bounded_count(reader, raw_face_data, 2);
let mut faces = Vec::new();
let mut i = 0;
while i < total_face_data {
- let n = safe_count(reader.read_bit_long());
+ let raw_n = reader.read_bit_long();
+ let n = stream_bounded_count(reader, raw_n, 2);
i += 1;
let mut face = Vec::new();
for _ in 0..n {
@@ -1705,7 +1734,8 @@
faces.push(face);
}
- let num_edges = safe_count(reader.read_bit_long());
+ let raw_edges = reader.read_bit_long();
+ let num_edges = stream_bounded_count(reader, raw_edges, 4);
let mut edges = Vec::with_capacity(num_edges as usize);
for _ in 0..num_edges {
let s = reader.read_bit_long();
@@ -1713,7 +1743,8 @@
edges.push((s, e));
}
- let num_creases = safe_count(reader.read_bit_long());
+ let raw_creases = reader.read_bit_long();
+ let num_creases = stream_bounded_count(reader, raw_creases, 2);
let mut crease_values = Vec::with_capacity(num_creases as usize);
for _ in 0..num_creases { crease_values.push(reader.read_bit_double()); }
@@ -2350,14 +2381,31 @@
pub fn read_ole2frame(reader: &mut DwgMergedReader, version: DwgVersion) -> Ole2FrameData {
let ver = reader.read_bit_short();
let mode = if version.r2000_plus() { reader.read_bit_short() } else { 0 };
- // OLE binary data can be very large (embedded images/documents),
- // so don't use safe_count (100 KB cap). Use a generous 10 MB cap instead.
let data_len = (reader.read_bit_long().max(0) as usize).min(10_000_000);
let data = reader.read_bytes(data_len);
let trailing_byte = if version.r2000_plus() { reader.read_byte() } else { 3 };
- let upper_left = reader.read_3bit_double();
- let lower_right = reader.read_3bit_double();
+
+ let mut upper_left = Vector3::new(0.0, 0.0, 0.0);
+ let mut lower_right = Vector3::new(0.0, 0.0, 0.0);
+
+ // Extract 4-corner coordinates stored in the OLE binary data header starting at offset 2
+ if data.len() >= 66 {
+ let ul_x = f64::from_le_bytes(data[2..10].try_into().unwrap());
+ let ul_y = f64::from_le_bytes(data[10..18].try_into().unwrap());
+ let ul_z = f64::from_le_bytes(data[18..26].try_into().unwrap());
+ let lr_x = f64::from_le_bytes(data[50..58].try_into().unwrap());
+ let lr_y = f64::from_le_bytes(data[58..66].try_into().unwrap());
+ let lr_z = if data.len() >= 74 { f64::from_le_bytes(data[66..74].try_into().unwrap()) } else { 0.0 };
+
+ if ul_x.is_finite() && ul_y.is_finite() && lr_x.is_finite() && lr_y.is_finite()
+ && (ul_x != 0.0 || ul_y != 0.0 || lr_x != 0.0 || lr_y != 0.0) {
+ upper_left = Vector3::new(ul_x, ul_y, if ul_z.is_finite() { ul_z } else { 0.0 });
+ lower_right = Vector3::new(lr_x, lr_y, if lr_z.is_finite() { lr_z } else { 0.0 });
+ }
+ }
+
let ole_type = if version.r2000_plus() { reader.read_byte() } else { 2 };
+
Ole2FrameData { version: ver, mode, data, trailing_byte, upper_left, lower_right, ole_type }
}
+375
View File
@@ -0,0 +1,375 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under the name "LEGAL", and included in all Covered Software
distributed in Source Code Form. Except to the extent prohibited by
statute or regulation, such description must be sufficiently detailed
for a recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
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
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { useDwgViewer } from './hooks/useDwgViewer';
import { Toolbar } from './components/Toolbar';
import { LayerPanel } from './components/LayerPanel';
import { PropertiesPanel } from './components/PropertiesPanel';
import { OptionsPanel } from './components/OptionsPanel';
import { CoordReadout } from './components/CoordReadout';
import { LoadProgress } from './components/LoadProgress';
export default function App() {
const v = useDwgViewer();
const [layersOpen, setLayersOpen] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false);
const [propsOpen, setPropsOpen] = useState(false);
// selecting an entity opens the Properties panel and updates the status line,
// mirroring the original app's `viewer.onSelect` wiring in main.ts
useEffect(() => {
const entity = v.selectedEntity as
| { type?: string; typeName?: string; layer?: string; layerName?: string; handle?: { value?: unknown } | unknown }
| null;
if (!entity) {
v.setStatusMessage('deselected');
return;
}
setPropsOpen(true);
const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase();
const layer = entity.layer ?? entity.layerName ?? '0';
const handle = (entity.handle as { value?: unknown })?.value ?? entity.handle ?? '-';
v.setStatusMessage(`select ${type} · layer=${layer} · h=${handle}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [v.selectedEntity]);
// F key → fit (ignored while typing in an input)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === 'f' || e.key === 'F') v.fit();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [v]);
// drag & drop
useEffect(() => {
const onDragOver = (e: DragEvent) => e.preventDefault();
const onDrop = (e: DragEvent) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) void v.loadFile(file);
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ?model= query param auto-load (once)
useEffect(() => {
const model = new URLSearchParams(window.location.search).get('model');
if (model) void v.loadUrl(model);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const activeKind = v.spaces.find((s) => s.handleHex === v.activeSpace)?.kind ?? null;
return (
<div className="relative h-full w-full overflow-hidden bg-background">
<div ref={v.containerRef} className="absolute inset-0" />
<LoadProgress active={v.status === 'loading'} value={v.loadingProgress} />
<Toolbar
status={v.statusMessage}
statusError={v.status === 'error'}
spaces={v.spaces}
activeSpace={v.activeSpace}
onSelectSpace={v.selectSpace}
onOpenFile={(file) => void v.loadFile(file)}
onLoadSample={(url, label) => void v.loadUrl(url, label)}
onFit={v.fit}
is25D={v.is25D}
onToggle25D={v.set25D}
canvasDark={v.theme === 'dark'}
onToggleTheme={v.toggleTheme}
layersOpen={layersOpen}
onToggleLayers={() => setLayersOpen((o) => !o)}
optionsOpen={optionsOpen}
onToggleOptions={() => setOptionsOpen((o) => !o)}
propsOpen={propsOpen}
onToggleProps={() => setPropsOpen((o) => !o)}
/>
<CoordReadout point={v.pointerWorld} spaceKind={activeKind} />
<LayerPanel
open={layersOpen}
onClose={() => setLayersOpen(false)}
layers={v.layers}
hiddenLayers={v.hiddenLayers}
onToggleLayer={v.toggleLayer}
onShowAll={v.showAllLayers}
onHideAll={v.hideAllLayers}
/>
<OptionsPanel
open={optionsOpen}
onClose={() => setOptionsOpen(false)}
options={v.options}
is25D={v.is25D}
lineweightClasses={v.getLineweightClasses()}
onSetGrid={v.setGrid}
onSetZoomSpeed={v.setZoomSpeed}
onSetZScale={v.setZScale}
onSetLineweightOn={v.setLineweightOn}
onSetLineweightScale={v.setLineweightScale}
onSet25D={v.set25D}
onReset={v.resetOptions}
/>
<PropertiesPanel
open={propsOpen}
onClose={() => setPropsOpen(false)}
entity={v.selectedEntity}
onZoomToSelection={v.zoomToSelection}
/>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
function formatCadCoord(v: number | null): string {
if (v === null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
const digits = abs >= 1e6 ? 1 : abs >= 1e3 ? 2 : abs >= 1 ? 3 : 4;
return v.toFixed(digits);
}
export interface CoordReadoutProps {
point: { x: number; y: number; z: number } | null;
spaceKind: 'model' | 'paper' | 'other' | null;
}
export function CoordReadout({ point, spaceKind }: CoordReadoutProps) {
const unitLabel = spaceKind === 'paper' ? 'Paper' : 'UCS';
return (
<div
className="pointer-events-none fixed bottom-3 right-3 z-10 rounded-md border bg-card/90 px-3 py-1.5 font-mono text-xs text-muted-foreground shadow-sm backdrop-blur"
title={`마우스 위치 CAD 좌표 (${spaceKind === 'paper' ? 'Layout=Paper' : 'Model=UCS'} 도면단위)`}
>
X {formatCadCoord(point?.x ?? null)} · Y {formatCadCoord(point?.y ?? null)}{' '}
<span className="opacity-60">({unitLabel})</span>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@hm-design/ui';
import { Button } from '@hm-design/ui';
import { ChevronDown, ChevronRight, X } from 'lucide-react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export interface DraggablePanelProps {
title: string;
icon?: ReactNode;
initial: { left: number; top: number };
width?: number;
height?: number;
open: boolean;
onClose: () => void;
headerActions?: ReactNode;
children: ReactNode;
}
export function DraggablePanel({
title,
icon,
initial,
width = 320,
height = 420,
open,
onClose,
headerActions,
children,
}: DraggablePanelProps) {
const { panelRef, style, collapsed, toggleCollapse, headerProps } = useDraggablePanel(initial);
if (!open) return null;
return (
<Card
ref={panelRef}
size="sm"
className="fixed shadow-lg overflow-hidden"
style={{
...style,
width,
height: collapsed ? 'auto' : height,
minWidth: 250,
minHeight: collapsed ? 'auto' : 140,
maxWidth: '90vw',
maxHeight: '90vh',
resize: collapsed ? 'none' : 'both',
}}
>
<CardHeader className="select-none border-b py-2" {...headerProps}>
<CardTitle className="flex items-center gap-2 text-sm">
{icon}
{title}
</CardTitle>
<CardAction className="flex items-center gap-1">
{headerActions}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={collapsed ? '펼치기' : '접기'}
title={collapsed ? '펼치기' : '접기'}
onClick={toggleCollapse}
>
{collapsed ? <ChevronRight /> : <ChevronDown />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="닫기"
title="닫기"
onClick={onClose}
>
<X />
</Button>
</CardAction>
</CardHeader>
{!collapsed && (
<CardContent className="overflow-auto" style={{ height: 'calc(100% - 44px)' }}>
{children}
</CardContent>
)}
</Card>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useMemo, useState } from 'react';
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch } from '@hm-design/ui';
import { Eye, EyeOff, Layers } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import type { LayerRow } from '../hooks/useDwgViewer';
export interface LayerPanelProps {
open: boolean;
onClose: () => void;
layers: LayerRow[];
hiddenLayers: Set<string>;
onToggleLayer: (name: string) => void;
onShowAll: () => void;
onHideAll: () => void;
}
export function LayerPanel({ open, onClose, layers, hiddenLayers, onToggleLayer, onShowAll, onHideAll }: LayerPanelProps) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return layers;
return layers.filter((l) => l.name.toLowerCase().includes(q));
}, [layers, query]);
return (
<DraggablePanel
title={`레이어 (${layers.length}개)`}
icon={<Layers className="size-4" />}
initial={{ left: window.innerWidth - 344, top: 56 }}
open={open}
onClose={onClose}
>
<div className="space-y-3">
<FilterBar>
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
<FilterBarActions className="ml-2 gap-1">
<Button type="button" variant="outline" size="xs" onClick={onShowAll}>
전체 켜기
</Button>
<Button type="button" variant="outline" size="xs" onClick={onHideAll}>
전체 끄기
</Button>
</FilterBarActions>
</FilterBar>
{layers.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">도면에 레이어 정보가 없습니다</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없습니다 ("{query}")</p>
) : (
<ul className="space-y-1">
{filtered.map((layer) => {
const visible = !hiddenLayers.has(layer.name);
return (
<li
key={layer.name}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-muted/50"
style={{ opacity: visible ? 1 : 0.45 }}
>
<span
className="size-2.5 shrink-0 rounded-full border"
style={{ background: layer.colorHex }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={layer.name}>
{layer.name}
</span>
<Badge variant="secondary">{layer.count}</Badge>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={visible ? '숨기기' : '표시하기'}
title={visible ? '숨기기' : '표시하기'}
onClick={() => onToggleLayer(layer.name)}
>
{visible ? <Eye /> : <EyeOff />}
</Button>
</li>
);
})}
</ul>
)}
</div>
</DraggablePanel>
);
}
+50
View File
@@ -0,0 +1,50 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@hm-design/ui';
import { Info } from 'lucide-react';
const PIPELINE_STEPS = [
'파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)',
'CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)',
'Three.js 씬 구성 및 렌더링',
];
export function LicenseNotice() {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" aria-label="정보 및 라이선스" title="정보 및 라이선스">
<Info />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>모듈 구성 · 라이선스 고지</DialogTitle>
<DialogDescription>이 뷰어가 사용하는 처리 파이프라인과 서드파티 라이선스 고지입니다.</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
{PIPELINE_STEPS.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div className="flex flex-col gap-1 border-t pt-3 text-sm">
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank" rel="noreferrer">
THIRD-PARTY-NOTICES.md
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-MPL-2.0.txt" target="_blank" rel="noreferrer">
acadrust MPL-2.0 전문
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank" rel="noreferrer">
acadrust 0.4.1 read_mesh 패치
</a>
</div>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Progress } from '@hm-design/ui';
export interface LoadProgressProps {
active: boolean;
value: number | null;
error?: boolean;
}
export function LoadProgress({ active, value, error }: LoadProgressProps) {
if (!active) return null;
return (
<Progress
value={value ?? undefined}
className={`fixed inset-x-0 top-0 z-50 h-1 rounded-none ${
value === null ? 'animate-pulse' : ''
} ${error ? '[&>*]:bg-destructive' : ''}`}
/>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Button, Input, Label, Slider, Switch } from '@hm-design/ui';
import { RotateCcw } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import { SlidersHorizontal } from 'lucide-react';
import type { PersistedOptions } from '../hooks/useDwgViewer';
function SliderNumberRow({
label,
value,
min,
max,
step,
unit,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
unit?: string;
onChange: (v: number) => void;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">{label}</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={min}
max={max}
step={step}
value={value}
size="small"
className="w-20"
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) onChange(v);
}}
/>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
</div>
<Slider
value={[value]}
min={min}
max={max}
step={step}
aria-label={label}
onValueChange={([v]) => onChange(v)}
/>
</div>
);
}
export interface OptionsPanelProps {
open: boolean;
onClose: () => void;
options: PersistedOptions;
is25D: boolean;
lineweightClasses: number[];
onSetGrid: (on: boolean) => void;
onSetZoomSpeed: (v: number) => void;
onSetZScale: (v: number) => void;
onSetLineweightOn: (on: boolean | null) => void;
onSetLineweightScale: (v: number | null) => void;
onSet25D: (on: boolean) => void;
onReset: () => void;
}
export function OptionsPanel({
open,
onClose,
options,
is25D,
lineweightClasses,
onSetGrid,
onSetZoomSpeed,
onSetZScale,
onSetLineweightOn,
onSetLineweightScale,
onSet25D,
onReset,
}: OptionsPanelProps) {
const lwScale = options.lineweightScale ?? 11;
const lwPreview =
lineweightClasses.length > 0
? lineweightClasses.map((mm) => `${mm.toFixed(2)}mm → ${(mm * lwScale).toFixed(1)}px`).join(' · ')
: `이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 ${(0.5 * lwScale).toFixed(1)}px)`;
return (
<DraggablePanel
title="옵션"
icon={<SlidersHorizontal className="size-4" />}
initial={{ left: window.innerWidth - 676, top: 56 }}
width={340}
height={520}
open={open}
onClose={onClose}
headerActions={
<Button type="button" variant="ghost" size="icon-xs" title="기본값으로" aria-label="기본값으로" onClick={onReset}>
<RotateCcw />
</Button>
}
>
<div className="space-y-5">
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">선가중치</h4>
<Label className="w-full justify-between text-sm">
표시 사용
<Switch
checked={options.lineweightOn ?? false}
onCheckedChange={(on) => onSetLineweightOn(on)}
/>
</Label>
<SliderNumberRow
label="표시 배율 (px/mm)"
value={lwScale}
min={2}
max={60}
step={0.5}
onChange={onSetLineweightScale}
/>
<p className="text-xs text-muted-foreground">{lwPreview}</p>
<p className="text-xs text-muted-foreground">AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</p>
</section>
<section className="space-y-3 border-t pt-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표시</h4>
<Label className="w-full justify-between text-sm">
그리드
<Switch checked={options.grid} onCheckedChange={onSetGrid} />
</Label>
<SliderNumberRow
label="줌 속도"
value={options.zoomSpeed}
min={0.2}
max={3}
step={0.1}
onChange={onSetZoomSpeed}
/>
</section>
<section className="space-y-3 border-t pt-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">2.5D</h4>
<Label className="w-full justify-between text-sm">
2.5D 보기
<Switch checked={is25D} onCheckedChange={onSet25D} />
</Label>
<div className={is25D ? '' : 'pointer-events-none opacity-50'}>
<SliderNumberRow
label="Z 높이 배율"
value={options.zScale}
min={1}
max={50}
step={0.5}
onChange={onSetZScale}
/>
</div>
<p className="text-xs text-muted-foreground">
좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.
</p>
</section>
</div>
</DraggablePanel>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { Badge, Button } from '@hm-design/ui';
import { Info, MousePointerClick, ZoomIn } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import { formatEntityProperties } from '../lib/propertyFormat';
function PropertyRows({ rows }: { rows: [string, string][] }) {
return (
<div className="divide-y">
{rows.map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-3 py-1.5 text-xs">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="truncate text-right font-mono text-foreground" style={{ userSelect: 'all' }} title={v}>
{v}
</span>
</div>
))}
</div>
);
}
export interface PropertiesPanelProps {
open: boolean;
onClose: () => void;
entity: unknown;
onZoomToSelection: () => void;
}
export function PropertiesPanel({ open, onClose, entity, onZoomToSelection }: PropertiesPanelProps) {
const prop = formatEntityProperties(entity);
return (
<DraggablePanel
title="속성"
icon={<Info className="size-4" />}
initial={{ left: window.innerWidth - 12 - 320, top: 56 }}
open={open}
onClose={onClose}
headerActions={
entity ? (
<Button type="button" variant="ghost" size="icon-xs" title="선택 확대" aria-label="선택 확대" onClick={onZoomToSelection}>
<ZoomIn />
</Button>
) : null
}
>
{!entity ? (
<div className="flex flex-col items-center gap-2 py-10 text-center text-xs text-muted-foreground">
<MousePointerClick className="size-6 opacity-50" />
도면에서 객체(엔티티)를 클릭하면
<br />
상세 속성이 표시됩니다
</div>
) : (
<div className="space-y-4">
<Badge variant="outline" className="font-mono">
{prop.id}
</Badge>
<div>
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
일반 정보 (General)
</h4>
<PropertyRows rows={prop.general} />
</div>
{prop.geometry.length > 0 && (
<div>
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
기하 속성 (Geometry)
</h4>
<PropertyRows rows={prop.geometry} />
</div>
)}
</div>
)}
</DraggablePanel>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useRef } from 'react';
import { Button, Label, Switch, Tabs, TabsList, TabsTrigger, Badge, Toggle } from '@hm-design/ui';
import { FolderOpen, Scan, Layers, SlidersHorizontal, Info as InfoIcon } from 'lucide-react';
import type { CadSpace } from '../viewer2d/cadSpaces';
import { LicenseNotice } from './LicenseNotice';
export interface ToolbarProps {
status: string;
statusError: boolean;
spaces: CadSpace[];
activeSpace: string | null;
onSelectSpace: (handleHex: string | null) => void;
onOpenFile: (file: File) => void;
onLoadSample: (url: string, label: string) => void;
onFit: () => void;
is25D: boolean;
onToggle25D: (on: boolean) => void;
canvasDark: boolean;
onToggleTheme: () => void;
layersOpen: boolean;
onToggleLayers: () => void;
optionsOpen: boolean;
onToggleOptions: () => void;
propsOpen: boolean;
onToggleProps: () => void;
}
export function Toolbar({
status,
statusError,
spaces,
activeSpace,
onSelectSpace,
onOpenFile,
onLoadSample,
onFit,
is25D,
onToggle25D,
canvasDark,
onToggleTheme,
layersOpen,
onToggleLayers,
optionsOpen,
onToggleOptions,
propsOpen,
onToggleProps,
}: ToolbarProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const showSpaceTabs = spaces.length > 1 || (spaces[0]?.entityCount ?? 0) > 0;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex items-center gap-2 border-b bg-card/90 px-3 py-2 backdrop-blur [&>*]:pointer-events-auto">
<input
ref={fileInputRef}
type="file"
accept=".dwg,.dxf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onOpenFile(file);
e.currentTarget.value = '';
}}
/>
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<FolderOpen /> 파일 열기
</Button>
<Button type="button" variant="secondary" size="sm" title="acadrust-dwg WASM" onClick={() => onLoadSample('/samples/BasicSample.dwg', 'BasicSample.dwg')}>
Sample DWG
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => onLoadSample('/samples/civil.dwg', 'C0060202-001-평면및종단면도.dwg')}>
Sample Civil
</Button>
<Button type="button" variant="secondary" size="sm" title="dxf-parser" onClick={() => onLoadSample('/samples/simple.dxf', 'simple.dxf')}>
Sample DXF
</Button>
{showSpaceTabs && (
<Tabs value={activeSpace ?? undefined} onValueChange={(v) => onSelectSpace(v)}>
<TabsList>
{spaces.map((s) => (
<TabsTrigger key={s.handleHex} value={s.handleHex} title={`${s.name} · ${s.handleHex}`}>
{s.label}
{s.entityCount > 0 ? ` (${s.entityCount})` : ''}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Button type="button" variant="ghost" size="sm" onClick={onFit} title="Fit (F)">
<Scan /> Fit
</Button>
<Label className="gap-1.5 text-xs text-muted-foreground">
<Switch checked={is25D} onCheckedChange={onToggle25D} />
2.5D
</Label>
<Label className="gap-1.5 text-xs text-muted-foreground">
<Switch checked={canvasDark} onCheckedChange={onToggleTheme} />
캔버스 테마
</Label>
<div className="ml-auto flex items-center gap-1">
<Toggle pressed={layersOpen} onPressedChange={onToggleLayers} size="sm" aria-label="레이어 패널" title="Layers">
<Layers />
</Toggle>
<Toggle pressed={optionsOpen} onPressedChange={onToggleOptions} size="sm" aria-label="옵션 패널" title="Options">
<SlidersHorizontal />
</Toggle>
<Toggle pressed={propsOpen} onPressedChange={onToggleProps} size="sm" aria-label="속성 패널" title="Properties">
<InfoIcon />
</Toggle>
<LicenseNotice />
</div>
<Badge variant={statusError ? 'destructive' : 'secondary'} className="max-w-[320px] truncate">
{status}
</Badge>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useRef, useState } from 'react';
/**
* React port of dwg-dxf-viewer-sample's panelUtils.ts drag+collapse mechanics.
* Resize is left to native CSS `resize: both` on the panel element, same as the original.
*/
let globalMaxZIndex = 30;
const INTERACTIVE_SELECTOR = 'button, input, select, a, label, [data-panel-no-drag]';
export interface UseDraggablePanelResult {
panelRef: React.RefObject<HTMLDivElement | null>;
style: React.CSSProperties;
zIndex: number;
collapsed: boolean;
toggleCollapse: () => void;
headerProps: {
onPointerDown: (e: React.PointerEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
style: React.CSSProperties;
};
bringToFront: () => void;
}
export function useDraggablePanel(initial: { left: number; top: number }): UseDraggablePanelResult {
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState(initial);
const [collapsed, setCollapsed] = useState(false);
const [zIndex, setZIndex] = useState(() => ++globalMaxZIndex);
const dragState = useRef<{ startX: number; startY: number; startLeft: number; startTop: number } | null>(null);
const bringToFront = useCallback(() => {
setZIndex(++globalMaxZIndex);
}, []);
const onPointerMove = useCallback((e: PointerEvent) => {
const st = dragState.current;
const panel = panelRef.current;
if (!st || !panel) return;
const dx = e.clientX - st.startX;
const dy = e.clientY - st.startY;
const maxLeft = window.innerWidth - panel.offsetWidth - 10;
const maxTop = window.innerHeight - panel.offsetHeight - 10;
const left = Math.min(Math.max(st.startLeft + dx, 10), Math.max(10, maxLeft));
const top = Math.min(Math.max(st.startTop + dy, 10), Math.max(10, maxTop));
setPos({ left, top });
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.body.style.cursor = '';
}, [onPointerMove]);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
bringToFront();
const target = e.target as HTMLElement;
if (target.closest(INTERACTIVE_SELECTOR)) return;
const panel = panelRef.current;
if (!panel) return;
dragState.current = {
startX: e.clientX,
startY: e.clientY,
startLeft: panel.offsetLeft,
startTop: panel.offsetTop,
};
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.body.style.cursor = 'grabbing';
},
[bringToFront, onPointerMove, onPointerUp],
);
const toggleCollapse = useCallback(() => setCollapsed((c) => !c), []);
const onDoubleClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(INTERACTIVE_SELECTOR)) return;
toggleCollapse();
},
[toggleCollapse],
);
return {
panelRef,
style: { position: 'fixed', left: pos.left, top: pos.top, zIndex },
zIndex,
collapsed,
toggleCollapse,
headerProps: {
onPointerDown,
onDoubleClick,
style: { cursor: 'grab', touchAction: 'none' },
},
bringToFront,
};
}
+342
View File
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Viewer2D } from '../viewer2d/Viewer2D';
import { parseDwgBuffer } from '../viewer2d/dwgParser';
import { parseDxfBuffer } from '../viewer2d/parseDxf';
import { isDwg, isDxf, is2D } from '../viewer2d/ext2d';
import { listCadSpaces, pickDefaultSpace, type CadSpace } from '../viewer2d/cadSpaces';
import { setDefaultFontUrl } from '../viewer2d/slugText';
import { getAllOptions, setOption } from '../lib/optionsStore';
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
export interface LayerRow {
name: string;
colorHex: string;
count: number;
visible: boolean;
}
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface PersistedOptions {
grid: boolean;
zoomSpeed: number;
zScale: number;
lineweightOn: boolean | null;
lineweightScale: number | null;
}
const DEFAULT_OPTIONS: PersistedOptions = {
grid: false,
zoomSpeed: 1,
zScale: 1,
lineweightOn: null,
lineweightScale: null,
};
async function parseByName(name: string, buf: ArrayBuffer) {
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
if (isDxf(name)) return parseDxfBuffer(buf);
if (!is2D(name)) throw new Error('지원하지 않는 파일 형식입니다 (.dwg/.dxf만 지원)');
return parseDxfBuffer(buf);
}
export function useDwgViewer() {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer2D | null>(null);
const [status, setStatus] = useState<LoadStatus>('idle');
const [statusMessage, setStatusMessage] = useState('준비됨');
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
const [selectedEntity, setSelectedEntity] = useState<unknown>(null);
const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null);
const [spaces, setSpaces] = useState<CadSpace[]>([]);
const [activeSpace, setActiveSpace] = useState<string | null>(null);
const [layers, setLayers] = useState<LayerRow[]>([]);
const [hiddenLayers, setHiddenLayersState] = useState<Set<string>>(new Set());
const [theme, setThemeState] = useState<'dark' | 'light'>('dark');
const [is25D, setIs25DState] = useState(false);
const [options, setOptions] = useState<PersistedOptions>(DEFAULT_OPTIONS);
const refreshLayers = useCallback(() => {
const viewer = viewerRef.current;
if (!viewer) return;
const info = viewer.getLayerInfo();
setLayers(info.map((l) => ({ name: l.name, colorHex: l.colorHex, count: l.count, visible: l.visible })));
}, []);
const refreshSpaces = useCallback(() => {
const viewer = viewerRef.current;
if (!viewer) return;
setSpaces(viewer.getSpaces());
setActiveSpace(viewer.getActiveSpace());
}, []);
// mount Viewer2D once
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Viewer2D has no dispose/teardown API (permanent window/canvas
// listeners, its own rAF loop) — defensively clear any leftover canvas
// from a prior construction (e.g. a dev Fast Refresh remount) so it
// never stacks two live WebGL contexts in the same container.
container.replaceChildren();
const viewer = new Viewer2D(container);
viewerRef.current = viewer;
viewer.setTheme(true);
viewer.onSelect((entity) => setSelectedEntity(entity));
viewer.onPointerWorld((p) => setPointerWorld(p));
const onResize = () => viewer.resize();
window.addEventListener('resize', onResize);
(async () => {
const saved = await getAllOptions();
const next: PersistedOptions = {
grid: typeof saved.grid === 'boolean' ? saved.grid : DEFAULT_OPTIONS.grid,
zoomSpeed: typeof saved.zoomSpeed === 'number' ? saved.zoomSpeed : DEFAULT_OPTIONS.zoomSpeed,
zScale: typeof saved.zScale === 'number' ? saved.zScale : DEFAULT_OPTIONS.zScale,
lineweightOn:
saved.lineweightOn === true || saved.lineweightOn === false ? saved.lineweightOn : null,
lineweightScale: typeof saved.lineweightScale === 'number' ? saved.lineweightScale : null,
};
setOptions(next);
viewer.setGrid(next.grid);
viewer.setZoomSpeed(next.zoomSpeed);
viewer.setZScale(next.zScale);
viewer.setLineweightEnabled(next.lineweightOn);
viewer.setLineweightScale(next.lineweightScale);
})();
return () => {
window.removeEventListener('resize', onResize);
viewer.onPointerWorld(null);
viewerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadParseResult = useCallback(
(result: unknown) => {
const viewer = viewerRef.current;
if (!viewer) return;
const spaceList = listCadSpaces(result as any);
const def = pickDefaultSpace(spaceList);
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
refreshSpaces();
refreshLayers();
setHiddenLayersState(new Set());
setSelectedEntity(null);
},
[refreshSpaces, refreshLayers],
);
const loadArrayBuffer = useCallback(
async (name: string, buf: ArrayBuffer) => {
setStatus('loading');
setStatusMessage(`불러오는 중… (${name})`);
setLoadingProgress(null);
try {
const result = await parseByName(name, buf);
loadParseResult(result);
setStatus('ready');
setStatusMessage(`로드 완료: ${name}`);
} catch (err) {
setStatus('error');
setStatusMessage(err instanceof Error ? err.message : '파일을 불러오지 못했습니다');
} finally {
setLoadingProgress(null);
}
},
[loadParseResult],
);
const loadFile = useCallback(
async (file: File) => {
const buf = await file.arrayBuffer();
await loadArrayBuffer(file.name, buf);
},
[loadArrayBuffer],
);
const loadUrl = useCallback(
async (url: string, label?: string) => {
const name = label ?? url.split('/').pop() ?? url;
setStatus('loading');
setStatusMessage(`다운로드 중… (${name})`);
setLoadingProgress(0);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
const total = Number(res.headers.get('content-length') ?? 0);
let buf: ArrayBuffer;
if (total > 0 && res.body) {
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
setLoadingProgress(Math.min(99, Math.round((received / total) * 100)));
}
}
const merged = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
buf = merged.buffer;
} else {
setLoadingProgress(null);
buf = await res.arrayBuffer();
}
await loadArrayBuffer(name, buf);
} catch (err) {
setStatus('error');
setStatusMessage(err instanceof Error ? err.message : '다운로드하지 못했습니다');
setLoadingProgress(null);
}
},
[loadArrayBuffer],
);
const fit = useCallback(() => viewerRef.current?.fit(), []);
const zoomToSelection = useCallback(() => viewerRef.current?.zoomToSelection(), []);
const selectSpace = useCallback(
(handleHex: string | null) => {
viewerRef.current?.setSpace(handleHex);
refreshSpaces();
refreshLayers();
},
[refreshSpaces, refreshLayers],
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'dark' ? 'light' : 'dark';
viewerRef.current?.setTheme(next === 'dark');
refreshLayers();
return next;
});
}, [refreshLayers]);
const setGrid = useCallback((on: boolean) => {
viewerRef.current?.setGrid(on);
setOptions((o) => ({ ...o, grid: on }));
void setOption('grid', on);
}, []);
const setZoomSpeed = useCallback((v: number) => {
viewerRef.current?.setZoomSpeed(v);
setOptions((o) => ({ ...o, zoomSpeed: v }));
void setOption('zoomSpeed', v);
}, []);
const setZScale = useCallback((v: number) => {
viewerRef.current?.setZScale(v);
setOptions((o) => ({ ...o, zScale: v }));
void setOption('zScale', v);
}, []);
const setLineweightOn = useCallback((on: boolean | null) => {
viewerRef.current?.setLineweightEnabled(on);
setOptions((o) => ({ ...o, lineweightOn: on }));
void setOption('lineweightOn', on);
}, []);
const setLineweightScale = useCallback((v: number | null) => {
viewerRef.current?.setLineweightScale(v);
setOptions((o) => ({ ...o, lineweightScale: v }));
void setOption('lineweightScale', v);
}, []);
const set25D = useCallback((on: boolean) => {
viewerRef.current?.set25D(on);
setIs25DState(on);
}, []);
const resetOptions = useCallback(() => {
setLineweightScale(null);
setLineweightOn(null);
setGrid(false);
setZoomSpeed(1);
setZScale(1);
set25D(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleLayer = useCallback((name: string) => {
setHiddenLayersState((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, []);
const showAllLayers = useCallback(() => {
setHiddenLayersState(() => {
const next = new Set<string>();
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, []);
const hideAllLayers = useCallback(() => {
setHiddenLayersState(() => {
const next = new Set(layers.map((l) => l.name));
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, [layers]);
const getLineweightClasses = useCallback(() => viewerRef.current?.getLineweightClasses() ?? [], []);
return {
containerRef,
viewerRef,
status,
statusMessage,
setStatusMessage,
loadingProgress,
selectedEntity,
pointerWorld,
spaces,
activeSpace,
layers,
hiddenLayers,
theme,
is25D,
options,
loadFile,
loadUrl,
fit,
zoomToSelection,
selectSpace,
toggleTheme,
set25D,
setGrid,
setZoomSpeed,
setZScale,
setLineweightOn,
setLineweightScale,
resetOptions,
toggleLayer,
showAllLayers,
hideAllLayers,
getLineweightClasses,
};
}
export type UseDwgViewer = ReturnType<typeof useDwgViewer>;
+10
View File
@@ -0,0 +1,10 @@
@import "tailwindcss" source("./");
@import "@hm-design/ui/tailwind.css";
@import "@hm-design/ui/theme.css";
html,
body,
#root {
height: 100%;
margin: 0;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 뷰어 옵션 영구 저장소 (IndexedDB).
*
* localStorage 대신 IndexedDB 를 쓰는 이유: 값이 문자열로 강제되지 않고(숫자/불린/
* 객체 그대로), 용량 제한이 사실상 없으며, 나중에 도면별 설정처럼 구조가 커져도
* 같은 저장소를 그대로 쓸 수 있다.
*
* DB 하나(`hmw-viewer`) 안에 key-value 스토어(`options`) 하나만 둔다.
* 실패(사생활 보호 모드, 스토리지 차단 등)해도 앱이 죽지 않도록 모든 API 는
* 조용히 기본값으로 떨어진다.
*/
const DB_NAME = 'hmw-viewer';
const DB_VERSION = 1;
const STORE = 'options';
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null);
let req: IDBOpenDBRequest;
try {
req = indexedDB.open(DB_NAME, DB_VERSION);
} catch {
return resolve(null);
}
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T | null> {
return openDb().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) return resolve(null);
try {
const t = db.transaction(STORE, mode);
const req = run(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
/** 저장된 옵션 하나. 없으면 `fallback`. */
export async function getOption<T>(key: string, fallback: T): Promise<T> {
const v = await tx<T>('readonly', (s) => s.get(key));
return v === null || v === undefined ? fallback : v;
}
/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */
export async function setOption(key: string, value: unknown): Promise<void> {
await tx('readwrite', (s) => s.put(value, key));
}
/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */
export async function getAllOptions(): Promise<Record<string, unknown>> {
const keys = (await tx<IDBValidKey[]>('readonly', (s) => s.getAllKeys())) ?? [];
const vals = (await tx<unknown[]>('readonly', (s) => s.getAll())) ?? [];
const out: Record<string, unknown> = {};
keys.forEach((k, i) => { out[String(k)] = vals[i]; });
return out;
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Entity Property formatter, ported unchanged from dwg-dxf-viewer-sample's
* propertyInspector.ts (DOM-rendering half replaced by PropertiesPanel.tsx).
*/
export interface FormattedProperties {
id: string;
type: string;
handle: string;
layer: string;
general: [string, string][];
geometry: [string, string][];
}
const f2 = (v: any): string => (typeof v === 'number' ? v.toFixed(2) : (v ?? '-'));
const f3 = (v: any): string => (typeof v === 'number' ? v.toFixed(3) : (v ?? '-'));
const deg = (r: any): string => {
if (typeof r !== 'number') return '-';
let d = (r * 180) / Math.PI;
while (d < 0) d += 360;
while (d >= 360) d -= 360;
return d.toFixed(1) + '°';
};
const pt = (p: any): string => {
if (!p) return '-';
const x = typeof p.x === 'number' ? f2(p.x) : '0.00';
const y = typeof p.y === 'number' ? f2(p.y) : '0.00';
const z = typeof p.z === 'number' && Math.abs(p.z) > 1e-6 ? `, ${f2(p.z)}` : '';
return `${x}, ${y}${z}`;
};
function formatColor(colorVal: any): string {
if (colorVal == null) return 'BYLAYER';
if (typeof colorVal === 'number') {
if (colorVal === 256) return 'BYLAYER (256)';
if (colorVal === 0) return 'BYBLOCK (0)';
return `ACI ${colorVal}`;
}
return String(colorVal);
}
/** DWG lineweight: i16 in 1/100 mm, with -1/-2/-3 reserved for the inherit modes. */
function formatLineweight(raw: unknown): string {
if (typeof raw !== 'number') return 'BYLAYER';
if (raw === -1) return 'BYLAYER';
if (raw === -2) return 'BYBLOCK';
if (raw === -3) return 'DEFAULT (0.25mm)';
if (raw < 0) return String(raw);
return `${(raw / 100).toFixed(2)}mm`;
}
export function formatEntityProperties(entity: any): FormattedProperties {
if (!entity) {
return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] };
}
const d = entity.data || entity;
const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase();
const handle = entity.handle?.value ?? entity.handle ?? '-';
const layer = entity.layer ?? entity.layerName ?? '0';
const color = formatColor(entity.color ?? d.color);
const linetype = entity.lineType ?? entity.linetype ?? d.lineType ?? 'BYLAYER';
const lineweight = formatLineweight(
entity.entityHeader?.lineWeight ?? entity.lineWeight ?? entity.lineweight ?? d.lineweight,
);
const general: [string, string][] = [
['유형 (Type)', type],
['핸들 (Handle)', String(handle)],
['레이어 (Layer)', String(layer)],
['색상 (Color)', color],
['선종류 (Linetype)', String(linetype)],
['선가중치 (Lineweight)', lineweight],
];
const geometry: [string, string][] = [];
switch (type) {
case 'LINE': {
if (d.start && d.end) {
const dx = d.end.x - d.start.x;
const dy = d.end.y - d.start.y;
const dz = (d.end.z || 0) - (d.start.z || 0);
const len = Math.hypot(dx, dy, dz);
const angle = Math.atan2(dy, dx);
geometry.push(['시작점 (Start)', pt(d.start)]);
geometry.push(['끝점 (End)', pt(d.end)]);
geometry.push(['증분 (ΔX, ΔY)', `${f2(dx)}, ${f2(dy)}`]);
geometry.push(['길이 (Length)', f3(len)]);
geometry.push(['각도 (Angle)', deg(angle)]);
}
break;
}
case 'CIRCLE': {
if (d.center && d.radius != null) {
const r = d.radius;
const diameter = r * 2;
const circumference = 2 * Math.PI * r;
const area = Math.PI * r * r;
geometry.push(['중심점 (Center)', pt(d.center)]);
geometry.push(['반지름 (Radius)', f3(r)]);
geometry.push(['지름 (Diameter)', f3(diameter)]);
geometry.push(['둘레 (Circumference)', f3(circumference)]);
geometry.push(['넓이 (Area)', f3(area)]);
}
break;
}
case 'ARC': {
if (d.center && d.radius != null) {
const r = d.radius;
const sa = d.startAngle ?? 0;
const ea = d.endAngle ?? Math.PI * 2;
let sweep = ea - sa;
while (sweep < 0) sweep += Math.PI * 2;
const arcLen = sweep * r;
geometry.push(['중심점 (Center)', pt(d.center)]);
geometry.push(['반지름 (Radius)', f3(r)]);
geometry.push(['시작각 (Start Angle)', deg(sa)]);
geometry.push(['끝각 (End Angle)', deg(ea)]);
geometry.push(['중심각 (Sweep Angle)', deg(sweep)]);
geometry.push(['호 길이 (Arc Length)', f3(arcLen)]);
}
break;
}
case 'LWPOLYLINE':
case 'POLYLINE': {
const rawPts = d.points ?? d.vertices ?? [];
const isClosed = !!(d.closed || (d.flags & 1));
let totalLen = 0;
for (let i = 0; i < rawPts.length - (isClosed ? 0 : 1); i++) {
const p1 = rawPts[i];
const p2 = rawPts[(i + 1) % rawPts.length];
if (p1 && p2) {
totalLen += Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
}
geometry.push(['정점 개수 (Vertices)', String(rawPts.length)]);
geometry.push(['닫힘 여부 (Closed)', isClosed ? '예 (Closed)' : '아니오 (Open)']);
geometry.push(['총 길이 (Total Length)', f3(totalLen)]);
if (isClosed && rawPts.length >= 3) {
let shoelace = 0;
for (let i = 0; i < rawPts.length; i++) {
const p1 = rawPts[i];
const p2 = rawPts[(i + 1) % rawPts.length];
shoelace += p1.x * p2.y - p2.x * p1.y;
}
geometry.push(['면적 (Area)', f3(Math.abs(shoelace) / 2)]);
}
if (rawPts.length > 0) {
const sampleCount = Math.min(rawPts.length, 4);
for (let i = 0; i < sampleCount; i++) {
geometry.push([`정점 P${i + 1}`, pt(rawPts[i])]);
}
if (rawPts.length > sampleCount) {
geometry.push(['...', `외 ${rawPts.length - sampleCount}개 정점`]);
}
}
break;
}
// MESH (AcDbSubDMesh). dwg-wasm emits flat arrays: vertices [x,y,z,…],
// faceList [n, i0 … i(n-1)] repeated, edges [a,b,…].
case 'MESH': {
const verts: number[] = d.vertices ?? [];
const vCount = d.vertexCount ?? Math.floor(verts.length / 3);
const fCount = d.faceCount ?? 0;
const eCount = Math.floor((d.edges?.length ?? 0) / 2);
const sub = d.subdivisionLevel ?? 0;
geometry.push(['정점 개수 (Vertices)', String(vCount)]);
geometry.push(['면 개수 (Faces)', String(fCount)]);
geometry.push(['모서리 개수 (Edges)', String(eCount)]);
geometry.push(['세분 레벨 (Subdivision)', String(sub)]);
geometry.push(['크리스 혼합 (Blend Crease)', d.blendCrease ? '예 (Yes)' : '아니오 (No)']);
if (vCount > 0) {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i + 2 < verts.length; i += 3) {
const x = verts[i], y = verts[i + 1], z = verts[i + 2];
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}
geometry.push(['최소점 (Min)', pt({ x: minX, y: minY, z: minZ })]);
geometry.push(['최대점 (Max)', pt({ x: maxX, y: maxY, z: maxZ })]);
geometry.push(['표고 범위 (Z Range)', `${f3(minZ)} ~ ${f3(maxZ)}`]);
}
break;
}
case 'TEXT':
case 'MTEXT':
case 'ATTRIB': {
const content = d.text ?? entity.text ?? d.textValue ?? '-';
const height = d.textHeight ?? d.height ?? entity.textHeight;
const rot = d.rotationAngle ?? d.rotation ?? 0;
const pos = d.insertionPoint ?? d.insertionPt ?? d.alignmentPt ?? d.pos;
geometry.push(['텍스트 내용 (Content)', String(content)]);
if (height != null) geometry.push(['높이 (Height)', f3(height)]);
geometry.push(['회전각 (Rotation)', deg(rot)]);
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
break;
}
case 'INSERT': {
const name = d.name ?? d.blockName ?? entity.name ?? '-';
const pos = d.insertionPoint ?? d.insertionPt ?? entity.insertionPt;
const sx = d.xScale ?? d.scale?.x ?? 1;
const sy = d.yScale ?? d.scale?.y ?? 1;
const sz = d.zScale ?? d.scale?.z ?? 1;
const rot = d.rotation ?? 0;
geometry.push(['블록 이름 (Block)', String(name)]);
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
geometry.push(['축척 (Scale X,Y,Z)', `${f2(sx)}, ${f2(sy)}, ${f2(sz)}`]);
geometry.push(['회전각 (Rotation)', deg(rot)]);
break;
}
case 'HATCH': {
const isSolid = d.solidFill ?? entity.solidFill ?? false;
const pattern = d.patternName ?? d.name ?? (isSolid ? 'SOLID' : 'HATCH');
const paths = d.paths ?? entity.paths ?? [];
geometry.push(['패턴 (Pattern)', String(pattern)]);
geometry.push(['채우기 유형 (Type)', isSolid ? '단색 채우기 (Solid)' : '패턴 (Pattern)']);
geometry.push(['경계 루프 수 (Loops)', String(paths.length)]);
break;
}
case 'DIMENSION':
case 'DIMENSION_LINEAR':
case 'DIMENSION_ALIGNED':
case 'DIMENSION_RADIUS':
case 'DIMENSION_DIAMETER':
case 'DIMENSION_ANG_3PT':
case 'DIMENSION_ANG_2LN':
case 'DIMENSION_ORDINATE': {
const meas = d.actualMeasurement ?? entity.actualMeasurement;
const textVal = d.userText || (meas != null ? f3(meas) : '-');
const midPt = d.textMidPt ?? entity.textMidPt;
geometry.push(['치수 유형 (Dim Type)', type]);
if (meas != null) geometry.push(['측정값 (Measurement)', f3(meas)]);
geometry.push(['표시 텍스트 (Text)', String(textVal)]);
if (midPt) geometry.push(['텍스트 위치 (Text Mid)', pt(midPt)]);
break;
}
case 'ELLIPSE': {
if (d.center) {
geometry.push(['중심점 (Center)', pt(d.center)]);
if (d.majorAxis) geometry.push(['주축 벡터 (Major Axis)', pt(d.majorAxis)]);
if (d.axisRatio != null || d.ratio != null) {
geometry.push(['단축 비율 (Ratio)', f3(d.axisRatio ?? d.ratio)]);
}
}
break;
}
default: {
let count = 0;
for (const [k, v] of Object.entries(d)) {
if (typeof v === 'object' || v == null || typeof v === 'function') continue;
geometry.push([k, String(v)]);
count++;
if (count >= 12) break;
}
}
}
const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`;
return { id, type, handle: String(handle), layer: String(layer), general, geometry };
}
+10
View File
@@ -0,0 +1,10 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
// No <StrictMode>: Viewer2D (ported unchanged from the vanilla app) has no
// dispose/teardown API and attaches permanent window/canvas listeners in its
// constructor. StrictMode's dev-only mount→unmount→remount double-invoke
// would construct it twice into the same container with no way to tear the
// first instance down, leaving two overlapping WebGL canvases.
createRoot(document.getElementById('root')!).render(<App />);
+50
View File
@@ -0,0 +1,50 @@
// 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.
import type { CadSpace } from './cadSpaces';
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): 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;
getSelectedEntity(): unknown;
zoomToSelection(): void;
setAccent(hex: string): void;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
/** 2.5D (tilted 3D) view: orbit the drawing so entity elevation (Z) is visible. */
set25D(on: boolean, opts?: { keepView?: boolean }): void;
is25D(): boolean;
/** Height exaggeration for 2.5D (1 = true scale); ignored in top view. */
setZScale(k: number): void;
getZScale(): number;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
/** Lineweight display scale in px per mm (AutoCAD's display-scale slider). Default 20. */
setLineweightScale(pxPerMm: number | null): void;
getLineweightScale(): number;
/** Distinct lineweights (mm) present in the loaded drawing, ascending. */
getLineweightClasses(): number[];
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
/** Model / Paper (Layout) spaces for the loaded drawing. */
getSpaces(): CadSpace[];
getActiveSpace(): string | null;
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
/** Screen client coords → drawing world (Z=0 plane). */
screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null;
/** Live CAD coords under the cursor; null when pointer leaves the canvas. */
onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): 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
+53
View File
@@ -0,0 +1,53 @@
/* tslint:disable */
/* eslint-disable */
export function parse_dwg(bytes: Uint8Array): any;
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
*/
export function parse_dwg_json(bytes: Uint8Array): string;
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 parse_dwg_json: (a: number, b: number) => [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_free: (a: number, b: number, c: 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>;
+320
View File
@@ -0,0 +1,320 @@
/* @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]);
}
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function parse_dwg_json(bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_dwg_json(ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
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.
+11
View File
@@ -0,0 +1,11 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const parse_dwg: (a: number, b: number) => [number, number, number];
export const parse_dwg_json: (a: number, b: number) => [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_free: (a: number, b: number, c: 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/*"
]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
* (wasm-pack --target web, committed under ./acadrust-dwg).
*/
import init, { parse_dwg_json } 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.
*
* Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg`
* export that hands back a JsValue. The latter keeps the entire result live on
* the wasm heap as it builds, and the allocator's cost grows with that heap —
* a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as
* text keeps it linear: the same file now lands in ~2.5 s.
*/
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 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, isDark = true) {
if (aci == null || aci === 0 || aci === 256) return null;
if (aci === 7) return isDark ? 0xFFFFFF : 0x000000;
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;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Model Space / Paper Space (Layout) discovery for CadParseResult.
*
* DWG entities are owned by a block header handle. System blocks
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
* AutoCAD switches between. Viewer2D must render only one at a time —
* drawing both stacks survey-model geometry on top of layout sheet
* geometry and produces the "all text piled in the center" glitch.
*/
export type CadSpaceKind = 'model' | 'paper' | 'other';
export type CadSpace = {
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
id: string;
/** Display label, e.g. "Model" / "Layout" / raw block name. */
label: string;
/** Raw block name from tables.blocks. */
name: string;
/** Block handle as number when available. */
handle: number | null;
/** Hex string matching entity ownerHandle.value.toString(16). */
handleHex: string;
kind: CadSpaceKind;
entityCount: number;
};
function handleToHex(handle: unknown): string | null {
if (handle == null) return null;
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
const v = (handle as { value?: unknown }).value;
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
if (typeof v === 'string' && v.length) {
const n = Number(v);
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
}
return null;
}
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
if (typeof handle === 'string' && handle.length) {
const n = Number(handle);
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
}
return null;
}
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
return handleToHex(entity.ownerHandle);
}
function classifySpaceName(raw: string): CadSpaceKind | null {
const name = raw.toLowerCase().replace(/\*/g, '').trim();
if (!name) return null;
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
return null;
}
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
if (kind === 'model') return 'Model';
if (kind === 'paper') {
// First paper space → "Layout"; further ones keep a disambiguator.
if (paperIndex <= 1) return 'Layout';
return `Layout (${name.replace(/^\*/, '')})`;
}
return name;
}
/**
* List model/paper space blocks that own at least zero entities.
* Always includes Model/Paper headers found in tables.blocks.
*/
export function listCadSpaces(result: {
entities?: unknown[];
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
} | null | undefined): CadSpace[] {
const blocks = result?.tables?.blocks ?? [];
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
const counts = new Map<string, number>();
for (const e of entities) {
const hex = ownerHex(e);
if (!hex) continue;
counts.set(hex, (counts.get(hex) || 0) + 1);
}
const spaces: CadSpace[] = [];
let paperIndex = 0;
for (const b of blocks) {
const kind = classifySpaceName(b.name ?? '');
if (!kind) continue;
const hex = handleToHex(b.handle);
if (!hex) continue;
if (kind === 'paper') paperIndex += 1;
const handleNum =
typeof b.handle === 'number'
? b.handle
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
? Number((b.handle as { value: unknown }).value)
: null;
spaces.push({
id: hex,
label: spaceLabel(kind, b.name ?? '', paperIndex),
name: b.name ?? '',
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
handleHex: hex,
kind,
entityCount: counts.get(hex) || 0,
});
}
// Stable order: Model first, then papers in table order.
spaces.sort((a, b) => {
if (a.kind !== b.kind) {
if (a.kind === 'model') return -1;
if (b.kind === 'model') return 1;
}
return 0;
});
return spaces;
}
/**
* Default tab when a file is opened:
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
* - else Model
* - else first listed space
*/
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
if (!spaces.length) return null;
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
if (paperWithContent) return paperWithContent;
const model = spaces.find((s) => s.kind === 'model');
if (model) return model;
return spaces[0] ?? null;
}
/**
* Owners allowed when filtering to one space: the space handle itself plus
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
*/
export function buildAllowedOwnerHexes(
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
activeSpaceHex: string,
rounds = 3,
): Set<string> {
const allowed = new Set<string>([activeSpaceHex]);
for (let pass = 0; pass < rounds; pass++) {
let grew = false;
for (const ent of entities) {
const oh = ownerHex(ent);
if (!oh || !allowed.has(oh)) continue;
const h = handleToHex(ent.handle);
if (h && !allowed.has(h)) {
allowed.add(h);
grew = true;
}
}
if (!grew) break;
}
return allowed;
}
export function isSystemSpaceBlockName(name: string): boolean {
return classifySpaceName(name) != null;
}
+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);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
* vendored with a local read_mesh patch; see hmwebviewer 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).
*
* Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in
* older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings
* (e.g. BasicSample) are left unchanged.
*/
import { fixDwgKoreanText } from './fixDwgKoreanText';
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 fixDwgKoreanText(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);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
*
* Older Korean DWGs store strings in code page 949. When those bytes are
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
* (already containing Hangul syllables) are left unchanged.
*
* Apply after parse_dwg, before Viewer2D.load().
*/
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
/** Labels tried in order; first supported decoder wins. */
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
let koreanDecoder: TextDecoder | null | undefined;
function getKoreanDecoder(): TextDecoder | null {
if (koreanDecoder !== undefined) return koreanDecoder;
for (const label of KOREAN_LABELS) {
try {
koreanDecoder = new TextDecoder(label);
return koreanDecoder;
} catch {
/* label not available in this runtime */
}
}
koreanDecoder = null;
return null;
}
/**
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
* form; otherwise return `s` unchanged.
*/
export function fixCp949Mojibake(s: string): string {
if (!s) return s;
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
let hasHigh = false;
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code > 255) return s; // true Unicode beyond Latin-1
if (code > 127) hasHigh = true;
bytes[i] = code;
}
if (!hasHigh) return s; // pure ASCII
const decoder = getKoreanDecoder();
if (!decoder) return s;
let fixed: string;
try {
fixed = decoder.decode(bytes);
} catch {
return s;
}
// Accept only when we gain Hangul and the decode is clean.
if (
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
!fixed.includes('\uFFFD')
) {
return fixed;
}
return s;
}
/**
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
* in place. Returns the same object for chaining.
*/
export function fixDwgKoreanText<T>(root: T): T {
if (root == null || typeof root !== 'object') return root;
const seen = new WeakSet<object>();
const walk = (value: unknown): void => {
if (value == null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
else walk(item);
}
return;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key === 'imageDataUrl') continue;
const item = obj[key];
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
else walk(item);
}
};
walk(root);
return root;
}
+3
View File
@@ -0,0 +1,3 @@
// Auto-generated standard LIN file definitions for Icad.lin and KOSDIC.lin
export const ICAD_LIN = "; IntelliCAD Linetypes\r\n;\r\n; icad.lin\r\n; Release 1.0 \r\n; \r\n; .............................................................................\r\n;\r\n; Copyright (c) 1998 by Visio Corporation. All rights reserved.\r\n;\r\n; The Software is subject to the license agreement that accompanies \r\n; or is included with the Software, which specifies the permitted \r\n; and prohibited uses of the Software. Any unauthorized duplication \r\n; or use of Visio Corporation Software, in whole or in part, in print, \r\n; or in any other storage and retrieval system is prohibited.\r\n; \r\n; To the maximum extent permitted by applicable law, Visio Corporation\r\n; and its suppliers disclaim any and all warranties and conditions,\r\n; either express or implied, including, without limitation, implied\r\n; warranties of merchantability, fitness for a particular purpose,\r\n; title, and non-infringement, and those arising out of usage of trade\r\n; or course of dealing, concerning these materials. These materials\r\n; are provided \"as is\" without warranty of any kind.\r\n; .............................................................................\r\n\r\n\r\n\r\n*BORDER,__ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . \r\nA,0.50,-0.250,0.50,-0.250,0,-0.250\r\n*BORDER2,__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__ \r\nA,0.25,-0.125,0.25,-0.125,0,-0.125\r\n*BORDERX2,____ ____ . ____ ____ . ____ ____ . ____ ____ . \r\nA,1.0,-0.5,1.0,-0.5,0,-0.5\r\n\r\n*CENTER,____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _\r\nA,1.250,-0.25,0.25,-0.25\r\n*CENTER2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.1250,0.1250,-0.125\r\n*CENTERX2,________ __ ________ __ ________ __ ________ __ ____\r\nA,2.5,-0.50,0.50,-0.50\r\n\r\n*DASHDOT,__ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . _\r\nA,0.50,-0.250,0,-0.250\r\n*DASHDOT2,_._._._._._._._._._._._._._._._._._._._._._._._._._._._._._.\r\nA,0.25,-0.125,0,-0.125\r\n*DASHDOTX2,____ . ____ . ____ . ____ . ____ . ____ . ____ \r\nA,1.0,-0.5,0,-0.5\r\n\r\n*DASHED,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.50,-0.250\r\n*DASHED2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.250,-0.1250\r\n*DASHEDX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,1.0,-0.5\r\n\r\n*DIVIDE,____ . . ____ . . ____ . . ____ . . ____ . . ____ . . ____ . .\r\nA,0.50,-0.25,0,-0.25,0,-0.25\r\n*DIVIDE2,__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..\r\nA,0.25,-0.125,0,-0.125,0,-0.125\r\n*DIVIDEX2,________ . . ________ . . ________ . . ________ . .\r\nA,1.0,-0.5,0,-0.5,0,-0.50\r\n\r\n*DOT,. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-0.25\r\n*DOT2,................................................................\r\nA,0,-0.125\r\n*DOTX2,. . . . . . . . . . . . . . . . . . . . . \r\nA,0,-0.5\r\n\r\n*HIDDEN,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ \r\nA,0.250,-0.1250\r\n*HIDDEN2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.06250\r\n*HIDDENX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.5,-0.25\r\n\r\n*PHANTOM,______ __ __ ______ __ __ ______ __ __ ______ __ __\r\nA,1.25,-0.25,0.250,-0.25,0.25,-0.25\r\n*PHANTOM2,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.625,-0.125,0.1250,-0.125,0.125,-0.1250\r\n*PHANTOMX2,____________ ____ ____ ____________ ____ ____ \r\nA,2.5,-0.5,0.5,-0.5,0.50,-0.5\r\n\r\n*PHANTOM4,___ _ _ ___ _ _ (1/2 of Phantom2)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.3125,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n\r\n*PHANTOM8,___ _ _ ___ _ _ (1/2 of Phantom4)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.15625,-0.03125,0.03125,-0.03125,0.03125,-0.03125\r\n\r\n*BORDER4,__.__.__.__.__.__._ (1/2 of Border2) _.__.__.__.__.__.__.__.__ \r\nA,0.125,-0.0625,0.125,-0.0625,0,-0.0625\r\n\r\n*CENTER4,___ _ ___ _ ___ _ __ (1/2 of Center2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.0625,0.0625,-0.0625\r\n*CENTER8,___ _ ___ _ ___ _ __ (1/2 of Center4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.03125,0.03125,-0.03125\r\n\r\n*CENTERF,_____ _ _____ (Finer short dashes than Center) _____\r\nA,1.25,-0.125,0.125,-0.125\r\n*CENTERF2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.0625,0.0625,-0.0625\r\n;; Auto-Arch additions for finer linetypes.\r\n*CENTERF4,___ _ ___ _ ___ _ __ (1/2 of CenterF2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.03125,0.03125,-0.03125\r\n*CENTERF8,___ _ ___ _ ___ _ __ (1/2 of CenterF4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.0015625,0.0015625,-0.0015625\r\n\r\n*DASHED4,_ _ _ _ _ _ _ _ (1/2 of Dashed2)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.0625\r\n*DASHED8,_ _ _ _ _ _ _ _ (1/2 of Dashed4)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n\r\n*DEMOx2,____ ____ ____ ____ Line truncated
export const KOSDIC_LIN = ";; ------------------------------------�� �� �� ��------------------------------------\r\n;; �� �� ���� ���� ����(KOSDIC.lin)�� �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ���� �� ��Ͽ� ���ǵ� �� ������\r\n;; AutoCAD ����ڿ��� �����ϱ� ���� ���۵Ǿ����ϴ�.\r\n;; \r\n;; ǥ�ع��� : �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ�� V1.1(2006.12)\r\n;; ������� : �Ǽ�CALS/EC ������ �ѱ��Ǽ����������\r\n;;\r\n;; �����Ģ : 1/1 ��ô�� ����ũ�� 2.0mm�� ��� ������ ũ�⸦ ������\r\n;; LIB ��Ģ : AutoCAD�� ANSI����(���� ��������� ����)�� ����Ű ���� 1/10�� ������\r\n;; (��) ���� �� ���� ������ 2mm�̸� 0.2unit�� ����\r\n;; ����Ģ : �ǹ����鿡 ����� ��� �������� ��Ģ�� ���յǷ��� ��������ô�� 10�踦 ������\r\n;; (��) LTSCALE=10 (���� 8~10��), ������ô�� 1/5 �̸� LTSCALE=5x10��=50\r\n;;\r\n;; ISO���� �԰� 15��\r\n// ���� AutoCAD ISO�� 25.4����� ������ �� ����\r\n*A-ISONS01,ISO �����԰� 01 Continuous ________________________________\r\nA,1,1\r\n*A-ISONS02,ISO �����԰� 02 dash __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.47244,-.118111\r\n*A-ISONS03,ISO �����԰� 03 dash space __ __ __ __ __ __\r\nA,.47244,-.7086614\r\n*A-ISONS04,ISO �����԰� 04 long-dash dot ____ . ____ . ____ . ____ . _\r\nA,.94488,-.11811,0,-.11811\r\n*A-ISONS05,ISO �����԰� 05 long-dash double-dot ____ .. ____ .. ____ . \r\nA,.94488,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS06,ISO �����԰� 06 long-dash triple-dot ____ ... ____ ... ____\r\nA,.94488,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS07,ISO �����԰� 07 dot . . . . . . . . . . . . . . . . . . . . \r\nA,0,-.11811\r\n*A-ISONS08,ISO �����԰� 08 long-dash short-dash ____ __ ____ __ ____ _\r\nA,.94488,-.11811,.94488,-.11811\r\n*A-ISONS09,ISO �����԰� 09 long-dash double-short-dash ____ __ __ ____\r\nA,.94488,-.11811,.94488,-.11811,.94488,-.11811\r\n*A-ISONS10,ISO �����԰� 10 dash dot __ . __ . __ . __ . __ . __ . __ . \r\nA,.47244,-.11811,0,-.11811\r\n*A-ISONS11,ISO �����԰� 11 double-dash dot __ __ . __ __ . __ __ . __ _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811\r\n*A-ISONS12,ISO �����԰� 12 dash double-dot __ . . __ . . __ . . __ . . \r\nA,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS13,ISO �����԰� 13 double-dash double-dot __ __ . . __ __ . . _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS14,ISO �����԰� 14 dash triple-dot __ . . . __ . . . __ . . . _\r\nA,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS15,ISO �����԰� 15 double-dash triple-dot __ __ . . . __ __ . .\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n\r\n;; ���CAD ����\r\n*A-BORDR01,�ܰ���(�Ϲ�) Border __ __ . __ __ . __ __ . __ __ . __ __ .\r\nA,.5,-.25,.5,-.25,0,-.25\r\n*A-BORDR01H,�ܰ���(�ݹ��) Border (.5x) __.__.__.__.__.__.__.__.__.__.__.\r\nA,.25,-.125,.25,-.125,0,-.125\r\n*A-BORDR01X,�ܰ���(���) Border (2x) ____ ____ . ____ ____ . ___\r\nA,1.0,-.5,1.0,-.5,0,-.5\r\n*A-CENTR01,�߽ɼ�(�Ϲ�) Center ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,1.25,-.25,.25,-.25\r\n*A-CENTR01H,�߽ɼ�(�ݹ��) Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,.75,-.125,.125,-.125\r\n*A-CENTR01X,�߽ɼ�(���) Center (2x) ________ __ ________ __ _____\r\nA,2.5,-.5,.5,-.5\r\n*A-DASDT01,�Ĵ�����(�Ϲ�) Dash dot __ . __ . __ . __ . __ . __ . __ . __\r\nA,.5,-.25,0,-.25\r\n*A-DASDT01H,�Ĵ�����(�ݹ��) Dash dot (.5x) _._._._._._._._._._._._._._._.\r\nA,.25,-.125,0,-.125\r\n*A-DASDT01X,�Ĵ�����(���) Dash dot (2x) ____ . ____ . ____ . ___\r\nA,1.0,-.5,0,-.5\r\n*A-DASHD01,�ļ�(�Ϲ�) Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _\r\nA,.5,-.25\r\n*A-DASHD01H,�ļ�(�ݹ��) Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.25,-.125\r\n*A-DASHD01X,�ļ�(���) Dashed (2x) ____ ____ ____ ____ ____ ___\r\nA,1.0,-.5\r\n*A-DIVID01,���Ҽ�(�Ϲ�) Divide ____ . . ____ . . ____ . . ____ . . ____\r\nA,.5,-.25,0,-.25,0,-.25\r\n*A-DIVID01H,���Ҽ�(�ݹ��) Divide (.5x) __..__..__..__..__..__..__..__.._\r\nA,.25,-.125,0,-.125,0,-.125\r\n*A-DIVID01X,��� Line truncated
+390
View File
@@ -0,0 +1,390 @@
/**
* LIN File Parser & Complex Linetype Registry
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
*/
import { ICAD_LIN, KOSDIC_LIN } from './linData';
export interface LinElement {
type: 'dash' | 'gap' | 'shape' | 'text';
val?: number; // dash length (+) or gap length (-)
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
text?: string; // e.g. "L2", "GAS", "HW"
style?: string; // e.g. Standard
scale: number; // s=... (default 1.0)
rotation: number; // r=... (angle in degrees)
isAbsoluteAngle: boolean;// a=...
isUprightAngle: boolean; // u=...
xOffset: number; // x=...
yOffset: number; // y=...
}
export interface LinPattern {
name: string;
description: string;
elements: LinElement[];
patternLength: number; // total length sum of dashes + abs(gaps)
}
function parseBracketElement(inner: string): LinElement {
const parts: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (c === '"') inQuote = !inQuote;
if (c === ',' && !inQuote) {
parts.push(cur.trim());
cur = '';
} else {
cur += c;
}
}
if (cur.trim()) parts.push(cur.trim());
let type: 'shape' | 'text' = 'shape';
let shapeName: string | undefined;
let shxFile: string | undefined;
let text: string | undefined;
let style: string | undefined;
let scale = 1.0;
let rotation = 0;
let isAbsoluteAngle = false;
let isUprightAngle = false;
let xOffset = 0;
let yOffset = 0;
if (parts.length > 0) {
const first = parts[0];
if (first.startsWith('"') && first.endsWith('"')) {
type = 'text';
text = first.slice(1, -1);
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
} else {
type = 'shape';
shapeName = first;
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
}
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const eqIdx = part.indexOf('=');
if (eqIdx > 0) {
const key = part.substring(0, eqIdx).trim().toLowerCase();
const valStr = part.substring(eqIdx + 1).trim();
const val = parseFloat(valStr);
if (!isNaN(val)) {
if (key === 's') scale = val;
else if (key === 'r') { rotation = val; }
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
else if (key === 'u') { rotation = val; isUprightAngle = true; }
else if (key === 'x') xOffset = val;
else if (key === 'y') yOffset = val;
}
}
}
}
return {
type,
shapeName,
shxFile,
text,
style,
scale,
rotation,
isAbsoluteAngle,
isUprightAngle,
xOffset,
yOffset,
};
}
function tokenizeLinPattern(patternStr: string): string[] {
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
const tokens: string[] = [];
let current = '';
let inBracket = false;
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') inQuote = !inQuote;
if (ch === '[' && !inQuote) inBracket = true;
if (ch === ']' && !inQuote) inBracket = false;
if (ch === ',' && !inBracket && !inQuote) {
if (current.trim()) tokens.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) tokens.push(current.trim());
return tokens;
}
export function parseLinContent(content: string): Map<string, LinPattern> {
const result = new Map<string, LinPattern>();
const lines = content.split(/\r?\n/);
let currentName: string | null = null;
let currentDesc = '';
for (let line of lines) {
line = line.trim();
if (!line || line.startsWith(';')) continue;
if (line.startsWith('*')) {
const commaIdx = line.indexOf(',');
if (commaIdx > 0) {
currentName = line.substring(1, commaIdx).trim();
currentDesc = line.substring(commaIdx + 1).trim();
} else {
currentName = line.substring(1).trim();
currentDesc = '';
}
} else if (currentName && line.toUpperCase().startsWith('A,')) {
const tokens = tokenizeLinPattern(line);
const elements: LinElement[] = [];
let totalLength = 0;
for (const tok of tokens) {
if (tok.startsWith('[') && tok.endsWith(']')) {
const elem = parseBracketElement(tok.slice(1, -1));
elements.push(elem);
} else {
const val = parseFloat(tok);
if (!isNaN(val)) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
} else {
// Dot (val === 0)
elements.push({
type: 'dash',
val: 0.05,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += 0.05;
}
}
}
}
if (elements.length > 0) {
result.set(currentName.toUpperCase(), {
name: currentName,
description: currentDesc,
elements,
patternLength: Math.max(totalLength, 0.1),
});
}
currentName = null;
}
}
return result;
}
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
const globalRegistry = new Map<string, LinPattern>();
export function registerLinContent(content: string) {
const parsed = parseLinContent(content);
for (const [key, pattern] of parsed.entries()) {
globalRegistry.set(key, pattern);
}
}
// Warm up registry with bundled LIN files
registerLinContent(ICAD_LIN);
registerLinContent(KOSDIC_LIN);
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
'H-DICHL02': 'L2-2/0',
'H-DICHL02R': 'L2-2',
'H-DICHL01': 'L2-3/0',
'H-DICHL01R': 'L2-3',
'H-DICHL06': 'L5-1',
'H-DICHL06R': 'L5-1',
'H-DICHL05': 'L5',
'H-DICHL05R': 'L5',
'H-DICHU05': 'U5',
'H-DICHU05R': 'U5',
'H-DICHB01': 'B1',
'H-DICHB01R': 'B1',
'H-DICHM03': 'M3',
'H-DICHM03R': 'M3',
'H-DICHV01': 'V1',
'H-DICHV01R': 'V1',
'H-DICHO01': 'O1',
'H-DICHO01R': 'O1',
'H-DICHL03': 'L3',
'H-DICHL03R': 'L3',
'H-DICHL04': 'L4',
'H-DICHL04R': 'L4',
'H-DICHL08': 'L8',
'H-DICHL08R': 'L8',
};
const NO_SHAPE_LINETYPES = new Set([
'H-DICHL05',
'H-DICHL05R',
'H-DICHU05',
'H-DICHU05R',
'H-DICHB01',
'H-DICHB01R',
'H-DICHM03',
'H-DICHM03R',
'H-DICHV01',
'H-DICHV01R',
'H-DICHO01',
'H-DICHO01R',
]);
export function getLinPattern(name: string): LinPattern | undefined {
if (!name) return undefined;
const key = name.toUpperCase().trim();
const pattern = globalRegistry.get(key);
if (!pattern) return undefined;
let elements = pattern.elements;
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
if (NO_SHAPE_LINETYPES.has(key)) {
elements = elements.filter((elem) => elem.type !== 'shape');
}
const overrideText = CIVIL_LT_TEXT_MAP[key];
if (overrideText) {
elements = elements.map((elem) =>
elem.type === 'text' ? { ...elem, text: overrideText } : elem
);
}
return {
...pattern,
elements,
};
}
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
return globalRegistry;
}
export function mergeDwgLinetypePattern(
ltName: string,
dwgPattern: number[],
description?: string
): LinPattern | undefined {
if (!ltName) return undefined;
const base = getLinPattern(ltName);
if (!dwgPattern || !dwgPattern.length) return base;
if (!base) {
const elements: LinElement[] = [];
let totalLength = 0;
for (const val of dwgPattern) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
}
}
return {
name: ltName,
description: description || '',
elements,
patternLength: Math.max(totalLength, 0.1),
};
}
// Merge DWG numeric dash/gap values into base pattern elements
const newElements: LinElement[] = [];
let dwgValIdx = 0;
let totalLength = 0;
for (const elem of base.elements) {
if (elem.type === 'dash') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0
? dwgPattern[dwgValIdx]
: elem.val || 0.05;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) {
dwgValIdx++;
}
newElements.push({ ...elem, val: dwgVal });
totalLength += dwgVal;
} else if (elem.type === 'gap') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0
? dwgPattern[dwgValIdx]
: elem.val;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) {
dwgValIdx++;
}
const absVal = Math.abs(dwgVal || 0.1);
newElements.push({ ...elem, val: -absVal });
totalLength += absVal;
} else {
newElements.push({ ...elem });
}
}
return {
...base,
description: description || base.description,
elements: newElements,
patternLength: Math.max(totalLength, 0.1),
};
}
+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;
}
+403
View File
@@ -0,0 +1,403 @@
/**
* 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');
// Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is
// capHeightEm em, not 1em. Divide by it so the rendered cap height equals
// the DWG `height` field, matching the canvas-sprite fallback's ascent fit.
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" />
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"allowJs": true
},
"include": ["src/**/*", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { port: 5173, strictPort: true },
// HMDesign (git submodule) has its own example/export HTML entries with their
// own `@/...` path aliases — without pinning `entries`, Vite's dependency
// scanner crawls every *.html under the project root and trips over them.
optimizeDeps: { include: ['three', 'dxf-parser', 'opentype.js'], entries: ['index.html'] },
assetsInclude: ['**/*.wasm'],
});