Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ef6efdd8 | ||
|
|
96c6b780ce | ||
|
|
c7a782f19c | ||
|
|
b114581c07 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist2/
|
||||
*.local
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "HMDesign"]
|
||||
path = HMDesign
|
||||
url = https://gitea.hmac.kr/b25027/HMDesign.git
|
||||
Submodule
+1
Submodule HMDesign added at d18e3c3380.
@@ -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><label></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 && npm ci && npm run build:packages && 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><input type=file></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><StrictMode></code>의 개발 모드 mount→unmount→remount 더블 호출이
|
||||
<code>Viewer2D</code>를 같은 container에 두 번 생성했고, 첫 인스턴스를 정리할 방법이 없어
|
||||
캔버스가 중첩/렌더가 꼬였다.</p>
|
||||
<p><b>수정 완료·미검증</b>: <code>main.tsx</code>에서 <code><StrictMode></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
@@ -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>
|
||||
Generated
+7481
File diff suppressed because it is too large.
Load diff
@@ -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.
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 />);
|
||||
Vendored
+50
@@ -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
@@ -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>;
|
||||
@@ -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.
@@ -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;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dwg-wasm",
|
||||
"type": "module",
|
||||
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"acadrust_dwg_bg.wasm",
|
||||
"acadrust_dwg.js",
|
||||
"acadrust_dwg.d.ts"
|
||||
],
|
||||
"main": "acadrust_dwg.js",
|
||||
"types": "acadrust_dwg.d.ts",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Decode a DXF buffer to text.
|
||||
*
|
||||
* Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE
|
||||
* (e.g. ANSI_949) that no longer matches the bytes — so the header is
|
||||
* unreliable. Strategy: try UTF-8 strictly (self-validating); on failure
|
||||
* fall back to the header's single-byte codepage. The DXF group/value
|
||||
* structure is ASCII regardless, so this only affects TEXT/MTEXT strings.
|
||||
*/
|
||||
export function decodeDxf(buf: ArrayBuffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
/* not valid UTF-8 → legacy single-byte codepage */
|
||||
}
|
||||
const head = new TextDecoder('latin1').decode(
|
||||
new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)),
|
||||
);
|
||||
const cp = head
|
||||
.match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
let label = 'windows-1252';
|
||||
if (cp) {
|
||||
if (/949/.test(cp)) label = 'euc-kr';
|
||||
else if (/932/.test(cp)) label = 'shift_jis';
|
||||
else if (/936/.test(cp)) label = 'gbk';
|
||||
else if (/950/.test(cp)) label = 'big5';
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(label).decode(buf);
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
|
||||
export function dxfToParseResult(dxf: unknown): {
|
||||
version: string;
|
||||
vars: Record<string, unknown>;
|
||||
entities: unknown[];
|
||||
tables: { blocks: unknown[]; layers: unknown[] };
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that
|
||||
* Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension
|
||||
* block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF.
|
||||
*
|
||||
* Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the
|
||||
* raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD
|
||||
* handle its child entities are owned by, and INSERT/DIMENSION reference their
|
||||
* block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the
|
||||
* incoherent handles and synthesize one consistent integer handle PER BLOCK NAME:
|
||||
* - block table entry handle = {value: H}
|
||||
* - every child entity ownerHandle = {value: H}
|
||||
* - INSERT.blockHeaderHandle = {value: H of insert.name}
|
||||
* - DIMENSION.blockHeaderHandle = {value: H of dim.block}
|
||||
* Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so
|
||||
* Viewer2D's space-handle filter keeps them visible.
|
||||
*
|
||||
* Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but
|
||||
* INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here.
|
||||
*
|
||||
* Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent.
|
||||
*/
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
|
||||
const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p);
|
||||
|
||||
/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */
|
||||
function adaptEntity(e, nameToH) {
|
||||
const o = { ...e };
|
||||
delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI
|
||||
const t = (e.type || '').toUpperCase();
|
||||
|
||||
switch (t) {
|
||||
case 'LINE':
|
||||
if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); }
|
||||
break;
|
||||
|
||||
case 'POLYLINE':
|
||||
case 'LWPOLYLINE':
|
||||
o.type = 'LWPOLYLINE';
|
||||
o.points = (e.vertices || []).map(vec);
|
||||
o.bulges = (e.vertices || []).map((v) => v.bulge || 0);
|
||||
o.closed = !!e.shape || ((e.flags || 0) & 1) === 1;
|
||||
break;
|
||||
|
||||
case 'ARC':
|
||||
case 'CIRCLE':
|
||||
o.center = vec(e.center); // radius + (radian) angles already match
|
||||
break;
|
||||
|
||||
case 'ELLIPSE':
|
||||
o.center = vec(e.center);
|
||||
o.majorAxis = vec(e.majorAxisEndPoint);
|
||||
o.ratio = e.axisRatio;
|
||||
o.startParam = e.startAngle;
|
||||
o.endParam = e.endAngle;
|
||||
break;
|
||||
|
||||
case 'POINT':
|
||||
o.position = vec(e.position);
|
||||
break;
|
||||
|
||||
case 'SOLID':
|
||||
case '3DFACE': {
|
||||
const p = e.points || [];
|
||||
if (p[0]) o.corner1 = vec(p[0]);
|
||||
if (p[1]) o.corner2 = vec(p[1]);
|
||||
if (p[2]) o.corner3 = vec(p[2]);
|
||||
if (p[3]) o.corner4 = vec(p[3]);
|
||||
o.type = 'SOLID';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT':
|
||||
case 'ATTRIB':
|
||||
case 'ATTDEF':
|
||||
o.insertionPoint = vec(e.startPoint || e.position);
|
||||
o.alignmentPt = vec(e.endPoint);
|
||||
o.rotationAngle = (e.rotation || 0) * DEG2RAD;
|
||||
o.horizAlignment = e.halign || 0;
|
||||
o.vertAlignment = e.valign || 0;
|
||||
o.textHeight = e.textHeight ?? e.height;
|
||||
break;
|
||||
|
||||
case 'MTEXT':
|
||||
o.insertionPoint = vec(e.position);
|
||||
o.textHeight = e.height ?? e.textHeight;
|
||||
if (e.directionVector) o.xAxisDir = vec(e.directionVector);
|
||||
else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) };
|
||||
break;
|
||||
|
||||
case 'INSERT': {
|
||||
o.insertionPoint = vec(e.position);
|
||||
o.scaleX = e.xScale ?? 1;
|
||||
o.scaleY = e.yScale ?? 1;
|
||||
o.rotation = (e.rotation || 0) * DEG2RAD;
|
||||
const h = nameToH.get(e.name);
|
||||
if (h != null) o.blockHeaderHandle = { value: h };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DIMENSION': {
|
||||
const h = nameToH.get(e.block);
|
||||
if (h != null) o.blockHeaderHandle = { value: h };
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full dxf-parser result → Viewer2D parseResult.
|
||||
* @param {object} dxf output of `new DxfParser().parseSync(text)`
|
||||
* @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}}
|
||||
*/
|
||||
export function dxfToParseResult(dxf) {
|
||||
const blocks = dxf.blocks || {};
|
||||
const names = Object.keys(blocks);
|
||||
const nameToH = new Map();
|
||||
names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles
|
||||
|
||||
const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n));
|
||||
const msH = msName != null ? nameToH.get(msName) : 0;
|
||||
|
||||
const entities = [];
|
||||
// Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle.
|
||||
for (const e of (dxf.entities || [])) {
|
||||
const o = adaptEntity(e, nameToH);
|
||||
o.ownerHandle = { value: msH };
|
||||
entities.push(o);
|
||||
}
|
||||
// Block-definition entities → owned by their block's synthetic handle.
|
||||
for (const n of names) {
|
||||
const H = nameToH.get(n);
|
||||
for (const e of (blocks[n].entities || [])) {
|
||||
const o = adaptEntity(e, nameToH);
|
||||
o.ownerHandle = { value: H };
|
||||
entities.push(o);
|
||||
}
|
||||
}
|
||||
|
||||
const blockTable = names.map((n) => ({
|
||||
name: n,
|
||||
handle: { value: nameToH.get(n) },
|
||||
basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 },
|
||||
}));
|
||||
|
||||
const layerSrc = dxf.tables?.layer?.layers || {};
|
||||
const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex }));
|
||||
|
||||
return {
|
||||
version: 'dxf',
|
||||
vars: dxf.header || {},
|
||||
entities,
|
||||
tables: { blocks: blockTable, layers: layerTable },
|
||||
};
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/** HATCH entity handler for dxf-parser's registerEntityHandler(). */
|
||||
export default class HatchHandler {
|
||||
ForEntityName: string;
|
||||
parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops
|
||||
* HATCH silently). Registered via the library's public `registerEntityHandler()`,
|
||||
* so dxf-parser itself stays unmodified (MIT-clean).
|
||||
*
|
||||
* Emits the shape Viewer2D's HATCH renderer consumes:
|
||||
* entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] }
|
||||
* Viewer2D fills solid hatches (with island/hole nesting) and draws boundary
|
||||
* outlines; bulge arcs on polyline boundaries are honored.
|
||||
*
|
||||
* Boundary kinds handled:
|
||||
* - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42)
|
||||
* - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points
|
||||
* ELLIPSE(72=3), SPLINE(72=4, control-point polyline)
|
||||
*
|
||||
* Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the
|
||||
* boundary outline as a proxy, matching its DWG behavior.
|
||||
*/
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const ARC_STEPS = 48;
|
||||
|
||||
function tessArc(cx, cy, r, a0, a1, ccw, out) {
|
||||
let span = a1 - a0;
|
||||
if (ccw) { while (span <= 0) span += Math.PI * 2; }
|
||||
else { while (span >= 0) span -= Math.PI * 2; }
|
||||
const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = a0 + (span * i) / steps;
|
||||
out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
|
||||
}
|
||||
}
|
||||
|
||||
export default class HatchHandler {
|
||||
constructor() {
|
||||
this.ForEntityName = 'HATCH';
|
||||
}
|
||||
|
||||
parseEntity(scanner, curr) {
|
||||
const entity = { type: curr.value, paths: [], solidFill: false };
|
||||
curr = scanner.next();
|
||||
while (!scanner.isEOF()) {
|
||||
if (curr.code === 0) break;
|
||||
switch (curr.code) {
|
||||
case 70: // solid fill flag
|
||||
entity.solidFill = curr.value !== 0;
|
||||
break;
|
||||
case 2: // pattern name
|
||||
entity.patternName = curr.value;
|
||||
break;
|
||||
case 8: // layer
|
||||
entity.layer = curr.value;
|
||||
break;
|
||||
case 62: // color index
|
||||
entity.colorIndex = curr.value;
|
||||
break;
|
||||
case 91: { // number of boundary paths → parse them by count
|
||||
const n = curr.value;
|
||||
let g = scanner.next();
|
||||
for (let i = 0; i < n && !scanner.isEOF(); i++) {
|
||||
g = this._readPath(scanner, entity, g);
|
||||
}
|
||||
curr = g; // g is the group following the last path's data
|
||||
continue; // curr already advanced — skip the trailing next()
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
curr = scanner.next();
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Parse one boundary path starting at its 92 group; return the next group after it. */
|
||||
_readPath(scanner, entity, g) {
|
||||
// g.code should be 92 (path type flag); if not, skip until we find it.
|
||||
while (!scanner.isEOF() && g.code !== 92) g = scanner.next();
|
||||
const flag = g.value | 0;
|
||||
const isPolyline = (flag & 2) !== 0;
|
||||
const path = { points: [], bulges: [] };
|
||||
let cur = scanner.next();
|
||||
|
||||
if (isPolyline) {
|
||||
let hasBulge = false;
|
||||
while (cur.code === 72 || cur.code === 73) {
|
||||
if (cur.code === 72) hasBulge = cur.value !== 0;
|
||||
cur = scanner.next();
|
||||
}
|
||||
let numVerts = 0;
|
||||
if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); }
|
||||
for (let v = 0; v < numVerts && !scanner.isEOF(); v++) {
|
||||
let x = 0, y = 0, b = 0;
|
||||
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
|
||||
if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); }
|
||||
path.points.push({ x, y });
|
||||
path.bulges.push(b);
|
||||
}
|
||||
} else {
|
||||
let numEdges = 0;
|
||||
if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); }
|
||||
for (let e = 0; e < numEdges && !scanner.isEOF(); e++) {
|
||||
let edgeType = 0;
|
||||
if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); }
|
||||
cur = this._readEdge(scanner, path, edgeType, cur);
|
||||
}
|
||||
}
|
||||
|
||||
// Consume the per-path trailing "source boundary objects" block (97 + 330×count).
|
||||
if (cur.code === 97) {
|
||||
const srcN = cur.value | 0;
|
||||
cur = scanner.next();
|
||||
for (let s = 0; s < srcN && !scanner.isEOF(); s++) {
|
||||
if (cur.code === 330) cur = scanner.next();
|
||||
else break;
|
||||
}
|
||||
}
|
||||
if (path.points.length >= 2) entity.paths.push(path);
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Tessellate one edge into path.points; return the next group. */
|
||||
_readEdge(scanner, path, edgeType, cur) {
|
||||
if (edgeType === 1) {
|
||||
// LINE: 10/20 start, 11/21 end
|
||||
let sx = 0, sy = 0, ex = 0, ey = 0;
|
||||
if (cur.code === 10) { sx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { sy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 11) { ex = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 21) { ey = cur.value; cur = scanner.next(); }
|
||||
path.points.push({ x: sx, y: sy });
|
||||
path.points.push({ x: ex, y: ey }); // last edge's end closes the loop
|
||||
} else if (edgeType === 2) {
|
||||
// ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw
|
||||
let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1;
|
||||
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 40) { r = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
|
||||
tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points);
|
||||
} else if (edgeType === 3) {
|
||||
// ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw
|
||||
let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1;
|
||||
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 11) { mx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 21) { my = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
|
||||
const major = Math.hypot(mx, my);
|
||||
const minor = major * ratio;
|
||||
const rot = Math.atan2(my, mx);
|
||||
let span = a1 - a0;
|
||||
if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; }
|
||||
else { while (span >= 0) span -= Math.PI * 2; }
|
||||
const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = a0 + (span * i) / steps;
|
||||
const ex = major * Math.cos(t), ey = minor * Math.sin(t);
|
||||
path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) });
|
||||
}
|
||||
} else if (edgeType === 4) {
|
||||
// SPLINE: approximate by its control points (10/20). Skip knots/weights.
|
||||
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights
|
||||
let numKnots = 0, numCtrl = 0;
|
||||
const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; };
|
||||
while (!scanner.isEOF()) {
|
||||
if (cur.code === 95) { numKnots = readInt(); }
|
||||
else if (cur.code === 96) { numCtrl = readInt(); }
|
||||
else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); }
|
||||
else break;
|
||||
}
|
||||
for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next();
|
||||
for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) {
|
||||
let x = 0, y = 0;
|
||||
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 42) cur = scanner.next(); // weight
|
||||
path.points.push({ x, y });
|
||||
}
|
||||
} else {
|
||||
// Unknown edge type: advance one group to avoid stalling.
|
||||
cur = scanner.next();
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 2D file-format detection for the 2D+3D merge dispatch.
|
||||
*
|
||||
* 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser.
|
||||
* Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer.
|
||||
* Kept dependency-free so both the dropzone and the controller can import it
|
||||
* without pulling in either viewer.
|
||||
*/
|
||||
export const DWG_EXT = /\.dwg$/i;
|
||||
export const DXF_EXT = /\.dxf$/i;
|
||||
|
||||
/** True if the name is a binary DWG (parsed by the WASM parser). */
|
||||
export function isDwg(name: string): boolean {
|
||||
return DWG_EXT.test(name);
|
||||
}
|
||||
|
||||
/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */
|
||||
export function isDxf(name: string): boolean {
|
||||
return DXF_EXT.test(name);
|
||||
}
|
||||
|
||||
/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */
|
||||
export function is2D(name: string): boolean {
|
||||
return DWG_EXT.test(name) || DXF_EXT.test(name);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Parse DXF text/buffer into the same CadParseResult shape as DWG.
|
||||
*/
|
||||
import DxfParser from 'dxf-parser';
|
||||
import HatchHandler from './dxfHatchHandler.js';
|
||||
import { dxfToParseResult } from './dxfAdapter.js';
|
||||
import { decodeDxf } from './decodeDxf';
|
||||
import type { CadParseResult } from './dwgParser';
|
||||
|
||||
/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */
|
||||
export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult {
|
||||
return parseDxfText(decodeDxf(buf));
|
||||
}
|
||||
|
||||
/** Parse DXF text already decoded as a string. */
|
||||
export function parseDxfText(text: string): CadParseResult {
|
||||
const parser = new DxfParser();
|
||||
// dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'.
|
||||
parser.registerEntityHandler(
|
||||
HatchHandler as unknown as Parameters<DxfParser['registerEntityHandler']>[0],
|
||||
);
|
||||
const dxf = parser.parseSync(text);
|
||||
return dxfToParseResult(dxf);
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'dxf-parser' {
|
||||
export default class DxfParser {
|
||||
parseSync(text: string): unknown;
|
||||
registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'opentype.js' {
|
||||
export function parse(buffer: ArrayBuffer): unknown;
|
||||
}
|
||||
|
||||
declare module '*.wasm?url' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
declare module '*.wasm' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
Reference in new issue
Block a user