Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
855b7f4ad6 | ||
|
|
6533c31704 | ||
|
|
320233f6d7 | ||
|
|
aaa0972bc1 | ||
|
|
40befda905 | ||
|
|
bc8875770b | ||
|
|
948021c7ac |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
*.wasm binary
|
||||
*.dwg binary
|
||||
*.ttf binary
|
||||
*.dxf -text
|
||||
*.patch -text
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.vite/
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "vendor/HMDesign"]
|
||||
path = vendor/HMDesign
|
||||
url = https://gitea.hmac.kr/b25027/HMDesign.git
|
||||
branch = main
|
||||
@@ -0,0 +1,63 @@
|
||||
# dwg-dxf-viewer-hm
|
||||
|
||||
`dwg-dxf-viewer-sample`(vanilla TS + Three.js)의 DWG/DXF 2D 뷰어를 **React 19 + HMDesign(`@hm-design/ui`)** 로
|
||||
다시 만든 앱입니다. 뷰어 엔진(Viewer2D, acadrust-dwg WASM, dxf-parser …)은 원본 그대로 쓰고, UI만 HMDesign
|
||||
컴포넌트로 새로 구성했습니다.
|
||||
|
||||
## 실행
|
||||
|
||||
Node 20.19+ / 22.12+ (확인: Node 24.11.1, npm 11.7.0).
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules <repo> dwg-dxf-viewer-hm
|
||||
cd dwg-dxf-viewer-hm
|
||||
# submodule 없이 clone 했다면: git submodule update --init --recursive
|
||||
npm install # postinstall 이 @hm-design/ui dist 를 빌드한다
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
| 명령 | 내용 |
|
||||
|---|---|
|
||||
| `npm run build` | `tsc` + `vite build` → `dist/` |
|
||||
| `npm run preview` | 빌드 결과 미리보기 (4173) |
|
||||
| `npm run hm:build` | HMDesign 갱신 후 `@hm-design/ui` dist 강제 재빌드 |
|
||||
| `npm run guard` | HMDesign AI Generation Guard (`--scope FULL_SCREEN --mode GNB_ONLY`) |
|
||||
|
||||
`?model=/samples/civil.dwg` 처럼 주소에 붙이면 시작할 때 자동으로 엽니다.
|
||||
|
||||
## HMDesign 연결
|
||||
|
||||
- `vendor/HMDesign` = git submodule (`https://gitea.hmac.kr/b25027/HMDesign.git`, `main`)
|
||||
- 그 안의 `packages/ui` 를 이 저장소의 npm workspace 로 등록 → `@hm-design/ui` 가 submodule 을 가리킨다
|
||||
- `scripts/setup-hmdesign.mjs` (postinstall): `vendor/HMDesign/node_modules` → 이 저장소 `node_modules` junction
|
||||
(HMDesign 빌드 스크립트가 자기 루트의 typescript/shadcn 을 읽기 때문) 후 package dist 빌드. submodule 내용은 수정하지 않는다
|
||||
- `@hm-design/center-graphic` 은 쓰지 않는다
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
src/
|
||||
engine/ ← dwg-dxf-viewer-sample 에서 그대로 복사 (로직·import 경로 변경 없음)
|
||||
viewer2d/* Viewer2D.js, 파서, acadrust-dwg WASM, cadSpaces, slugText …
|
||||
layerPanel.ts LayerPanelManager (레이어 표시 상태)
|
||||
panelUtils.ts 창 드래그 / 접기
|
||||
propertyInspector.ts formatEntityProperties
|
||||
optionsStore.ts IndexedDB hmw-viewer/options
|
||||
components/
|
||||
DwgViewerCanvas.tsx Viewer2D React 래퍼 (useRef + useEffect, 옵션 → setter 단방향)
|
||||
AppHeader.tsx GNB (샘플 도면 메뉴, 파일 열기, 정보)
|
||||
ViewerToolbar.tsx Model/Layout, 맞춤 보기, 2.5D, 캔버스 테마, 창 토글
|
||||
FloatingPanel.tsx 드래그·접기·크기조절 창 (HM Card)
|
||||
LayersPanel.tsx / PropertiesPanel.tsx / OptionsPanel.tsx
|
||||
StageOverlays.tsx 빈 화면, 로딩(Progress + LoadingScreen), 드롭 안내
|
||||
StatusBar.tsx 상태 문구, Zoom, CAD 좌표(우하단), 오픈소스 고지 링크
|
||||
AboutDialog.tsx 모듈 구성 + 오픈소스 고지 (acadrust MPL-2.0, local patch)
|
||||
lib/ main.ts 의 파서 조합·좌표 포맷, topProgress.ts 의 fetch/paint 헬퍼
|
||||
public/ NanumGothic, 샘플 도면, 라이선스 원문·패치, 모듈 지도
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
acadrust 0.4.1(MPL-2.0, `read_mesh` 로컬 패치 수정본)이 WASM 으로 포함됩니다.
|
||||
고지·원문·패치: [`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md), `public/licenses/` (배포 시 `/licenses/…`).
|
||||
앱에서는 GNB 정보 버튼과 상태 표시줄의 "오픈소스 고지"에서 열립니다.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Third-party notices
|
||||
|
||||
This viewer (dwg-dxf-viewer-hm — the React/HMDesign rebuild of
|
||||
dwg-dxf-viewer-sample) bundles third-party code. The notices below apply to the
|
||||
source tree **and** to the built site (`dist/`), because the build embeds the
|
||||
components listed here. In the app they are reachable from the GNB info button
|
||||
and the status-bar link "오픈소스 고지".
|
||||
|
||||
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 / React app code | MIT |
|
||||
| React, React DOM, scheduler | MIT |
|
||||
| Radix UI (`radix-ui`), sonner, cmdk, vaul, embla-carousel, react-day-picker, react-resizable-panels, @tanstack/react-table, cn, tw-animate-css (via `@hm-design/ui`) | MIT |
|
||||
| lucide-react | ISC |
|
||||
| class-variance-authority | Apache-2.0 |
|
||||
| shadcn/ui CSS vendored by `@hm-design/ui` (`dist/vendor/shadcn-tailwind.css`) | MIT |
|
||||
| Noto Sans KR (HMDesign theme font, `dist/fonts/`) | SIL Open Font License 1.1 |
|
||||
| HMDesign (`@hm-design/ui`, git submodule `vendor/HMDesign`) | Internal design system; its package declares no license (see the submodule) |
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DWG · DXF 뷰어</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+8164
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "dwg-dxf-viewer-hm",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"description": "DWG/DXF 2D viewer: the dwg-dxf-viewer-sample engine (Viewer2D + acadrust-dwg + dxf-parser) inside a React 19 app built on HMDesign (@hm-design/ui).",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"vendor/HMDesign/packages/ui"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/setup-hmdesign.mjs",
|
||||
"hm:build": "node scripts/setup-hmdesign.mjs --force",
|
||||
"dev": "vite --port 5173 --strictPort",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview --port 4173 --strictPort",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"guard": "node vendor/HMDesign/scripts/ai-generation-guard.mjs --target . --scope FULL_SCREEN --mode GNB_ONLY --result index.html"
|
||||
},
|
||||
"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": {
|
||||
"@babel/parser": "7.29.9",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/node": "24.7.0",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/three": "0.185.1",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"fast-glob": "3.3.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
|
||||
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
|
||||
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
|
||||
--grid: rgba(14,124,134,.06);
|
||||
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
|
||||
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
|
||||
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); line-height: 1.6;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
|
||||
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
|
||||
h1 .dim { color: var(--ink-faint); font-weight: 400; }
|
||||
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
|
||||
.cta {
|
||||
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2 .num { color: var(--accent); }
|
||||
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 11px; height: 11px; border-radius: 3px; }
|
||||
.dot.wasm { background: var(--wasm); }
|
||||
.dot.js { background: var(--js); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
|
||||
.card.w { --tag: var(--wasm); }
|
||||
.card.j { --tag: var(--js); }
|
||||
.card .k {
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
|
||||
.badge.j { background: var(--accent-soft); color: var(--js); }
|
||||
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
|
||||
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
|
||||
.flow {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
|
||||
}
|
||||
.pipe { display: flex; align-items: stretch; min-width: 720px; }
|
||||
.node {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||
}
|
||||
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.node.w { border-color: var(--wasm); }
|
||||
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
|
||||
.node.hl .tier { color: var(--accent); }
|
||||
.node.w .tier { color: var(--wasm); }
|
||||
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
|
||||
.node .sub { font-size: 12px; color: var(--ink-soft); }
|
||||
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
|
||||
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
|
||||
.flow-note b { color: var(--accent); }
|
||||
pre {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
|
||||
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
|
||||
box-shadow: var(--shadow); margin: 0;
|
||||
}
|
||||
pre .c { color: var(--accent); }
|
||||
pre .w { color: var(--wasm); }
|
||||
pre .cm { color: var(--ink-faint); }
|
||||
.steps { display: grid; gap: 12px; }
|
||||
.step {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
|
||||
}
|
||||
.step .n {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
|
||||
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step h3 { margin: 0 0 4px; font-size: 15px; }
|
||||
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
||||
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="eyebrow">compose · don’t invent</div>
|
||||
<h1>DWG · DXF 모듈 조합 <span class="dim">레시피</span></h1>
|
||||
<p class="lede">
|
||||
전체 제품이 아니라, 지도에 있는 <b>재사용 모듈만</b> 골라 조립하면 2D CAD 뷰어가 됩니다.
|
||||
아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다.
|
||||
</p>
|
||||
<a class="cta" href="/">
|
||||
▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev)
|
||||
</a>
|
||||
<p style="margin:10px 0 0; font-family:var(--mono); font-size:12px; color:var(--ink-faint)">
|
||||
cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 01 modules -->
|
||||
<h2><span class="num">01</span> 조합에 쓰는 모듈 (지도 카드)</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot wasm"></i> WASM 파서</span>
|
||||
<span><i class="dot js"></i> JS 모듈 · 렌더러</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">acadrust-dwg <span class="badge w">wasm</span></div>
|
||||
<div class="d">DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.</div>
|
||||
<div class="path">src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dwgParser / acadrustParser <span class="badge j">wrap</span></div>
|
||||
<div class="d">WASM 1회 init + <code>parseDwgBuffer(bytes)</code>.</div>
|
||||
<div class="path">dwgParser.ts · acadrustParser.ts</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxf-parser <span class="badge j">npm</span></div>
|
||||
<div class="d">DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.</div>
|
||||
<div class="path">npm: dxf-parser</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfHatchHandler <span class="badge j">patch</span></div>
|
||||
<div class="d"><code>registerEntityHandler</code>로 HATCH 보강.</div>
|
||||
<div class="path">dxfHatchHandler.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfAdapter <span class="badge j">adapt</span></div>
|
||||
<div class="d">dxf-parser 출력 → Viewer2D 공통 스키마.</div>
|
||||
<div class="path">dxfAdapter.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">Viewer2D <span class="badge j">render</span></div>
|
||||
<div class="d">Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…</div>
|
||||
<div class="path">Viewer2D.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">three <span class="badge j">peer</span></div>
|
||||
<div class="d">WebGLRenderer + OrbitControls (pan/zoom).</div>
|
||||
<div class="path">npm: three ≥0.160</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">slugText + font <span class="badge j">text</span></div>
|
||||
<div class="d">opentype.js → GPU 텍스트. TTF를 호스트가 서빙.</div>
|
||||
<div class="path">slugText.ts · /fonts/*.ttf</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">ext2d <span class="badge j">util</span></div>
|
||||
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||
<div class="path">ext2d.ts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 pipeline -->
|
||||
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||
<div class="flow">
|
||||
<div class="pipe">
|
||||
<div class="node">
|
||||
<div class="tier">Host</div>
|
||||
<div class="name">File · URL · Drop</div>
|
||||
<div class="sub">.dwg / .dxf</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">ext2d</div>
|
||||
<div class="name">isDwg / isDxf</div>
|
||||
<div class="sub">분기</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node w">
|
||||
<div class="tier">Parser A or B</div>
|
||||
<div class="name">acadrust · dxf-parser</div>
|
||||
<div class="sub">(+ hatch · adapter)</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">Contract</div>
|
||||
<div class="name">CadParseResult</div>
|
||||
<div class="sub">공통 JSON</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">
|
||||
<div class="tier">Viewer2D</div>
|
||||
<div class="name">.load(result)</div>
|
||||
<div class="sub">three 씬</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-note">
|
||||
<b>핵심 계약</b>: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다.
|
||||
중간은 항상 <code>CadParseResult</code> 하나.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 recipe steps -->
|
||||
<h2><span class="num">03</span> 다른 프로젝트에 옮기는 순서</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">1</div>
|
||||
<div>
|
||||
<h3>모듈 소스 복사</h3>
|
||||
<p>
|
||||
이 샘플의 <code>src/viewer2d/</code> 전체를 복사.
|
||||
npm: <code>three</code>, <code>dxf-parser</code>, <code>opentype.js</code>.
|
||||
(원본 소스는 형제 폴더 <code>hmwebviewer/src/viewer2d</code>.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">2</div>
|
||||
<div>
|
||||
<h3>정적 자산</h3>
|
||||
<p>
|
||||
<code>public/fonts/NanumGothic-Regular.ttf</code> (한글 TEXT).
|
||||
WASM은 소스가 <code>?url</code>로 import — Vite면 <code>assetsInclude: ['**/*.wasm']</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">3</div>
|
||||
<div>
|
||||
<h3>아래 스니펫처럼 조합</h3>
|
||||
<p>
|
||||
래퍼 클래스 없이 모듈을 직접 import 해도 됩니다.
|
||||
실행 가능한 전체 예: 이 프로젝트 <code>src/main.ts</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">4</div>
|
||||
<div>
|
||||
<h3>호스트 UI만 붙이기</h3>
|
||||
<p>
|
||||
드롭존 · 툴바 · 레이어 패널은 모듈이 아님.
|
||||
샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 code -->
|
||||
<h2><span class="num">04</span> 최소 조합 코드 (샘플과 동일 패턴)</h2>
|
||||
<pre><span class="cm">// ① 모듈 지도 카드 → import</span>
|
||||
<span class="c">import</span> { Viewer2D } <span class="c">from</span> './viewer2d/Viewer2D.js';
|
||||
<span class="c">import</span> { parseDwgBuffer } <span class="c">from</span> './viewer2d/dwgParser'; <span class="cm">// acadrust-dwg</span>
|
||||
<span class="c">import</span> { parseDxfBuffer } <span class="c">from</span> './viewer2d/parseDxf'; <span class="cm">// dxf-parser+hatch+adapter</span>
|
||||
<span class="c">import</span> { isDwg, isDxf } <span class="c">from</span> './viewer2d/ext2d';
|
||||
<span class="c">import</span> { setDefaultFontUrl } <span class="c">from</span> './viewer2d/slugText';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
<span class="cm">// ② 렌더러</span>
|
||||
<span class="c">const</span> viewer = <span class="c">new</span> Viewer2D(document.getElementById('app')!);
|
||||
|
||||
<span class="cm">// ③ 파서 조합</span>
|
||||
<span class="c">async function</span> parseCad(buf: ArrayBuffer, name: string) {
|
||||
<span class="c">if</span> (isDxf(name)) <span class="c">return</span> parseDxfBuffer(buf); <span class="cm">// dxf-parser 경로</span>
|
||||
<span class="c">if</span> (isDwg(name)) <span class="c">return</span> parseDwgBuffer(<span class="c">new</span> Uint8Array(buf)); <span class="cm">// WASM 경로</span>
|
||||
<span class="c">throw new</span> Error('need .dwg or .dxf name');
|
||||
}
|
||||
|
||||
<span class="cm">// ④ 계약: parseResult → load</span>
|
||||
<span class="c">async function</span> open(file: File) {
|
||||
<span class="c">const</span> result = <span class="c">await</span> parseCad(<span class="c">await</span> file.arrayBuffer(), file.name);
|
||||
viewer.load(result);
|
||||
viewer.resize();
|
||||
viewer.fit();
|
||||
}</pre>
|
||||
|
||||
<h2><span class="num">05</span> DWG 경로 vs DXF 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">.dwg 경로 <span class="badge w">wasm</span></div>
|
||||
<div class="d">
|
||||
<code>parseDwgBuffer</code><br>
|
||||
→ acadrustParser.init<br>
|
||||
→ <span style="color:var(--wasm)">parse_dwg(bytes)</span><br>
|
||||
→ CadParseResult
|
||||
</div>
|
||||
<div class="path">Sample DWG 버튼으로 검증</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">.dxf 경로 <span class="badge j">js</span></div>
|
||||
<div class="d">
|
||||
<code>parseDxfBuffer</code><br>
|
||||
→ decodeDxf (UTF-8 / CP949…)<br>
|
||||
→ DxfParser + HatchHandler<br>
|
||||
→ dxfToParseResult
|
||||
</div>
|
||||
<div class="path">Sample DXF 버튼으로 검증</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="num">06</span> 샘플 위치</h2>
|
||||
<pre style="white-space:pre-wrap"><span class="c">dwg-dxf-viewer-sample/</span> ← hmwebviewer 형제 폴더
|
||||
src/main.ts ← 모듈을 직접 import 해서 조립 (핵심)
|
||||
src/viewer2d/ ← acadrust-dwg · Viewer2D · dxf-*
|
||||
index.html ← 툴바 + Module recipe 패널
|
||||
public/fonts/ ← NanumGothic (slugText)
|
||||
public/samples/ ← BasicSample.dwg · simple.dxf
|
||||
docs/modules-dwg-dxf.html
|
||||
|
||||
<span class="c">실행</span>
|
||||
cd ../dwg-dxf-viewer-sample # (hmwebviewer 기준 형제)
|
||||
npm install
|
||||
npm run dev → http://localhost:5173</pre>
|
||||
|
||||
<footer>
|
||||
<span>DWG/DXF only · sibling of hmwebviewer</span>
|
||||
<span>no 3D / SHP / R2</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
# Third-party notices
|
||||
|
||||
This viewer (dwg-dxf-viewer-hm — the React/HMDesign rebuild of
|
||||
dwg-dxf-viewer-sample) bundles third-party code. The notices below apply to the
|
||||
source tree **and** to the built site (`dist/`), because the build embeds the
|
||||
components listed here. In the app they are reachable from the GNB info button
|
||||
and the status-bar link "오픈소스 고지".
|
||||
|
||||
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 / React app code | MIT |
|
||||
| React, React DOM, scheduler | MIT |
|
||||
| Radix UI (`radix-ui`), sonner, cmdk, vaul, embla-carousel, react-day-picker, react-resizable-panels, @tanstack/react-table, cn, tw-animate-css (via `@hm-design/ui`) | MIT |
|
||||
| lucide-react | ISC |
|
||||
| class-variance-authority | Apache-2.0 |
|
||||
| shadcn/ui CSS vendored by `@hm-design/ui` (`dist/vendor/shadcn-tailwind.css`) | MIT |
|
||||
| Noto Sans KR (HMDesign theme font, `dist/fonts/`) | SIL Open Font License 1.1 |
|
||||
| HMDesign (`@hm-design/ui`, git submodule `vendor/HMDesign`) | Internal design system; its package declares no license (see the submodule) |
|
||||
@@ -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.
Binary file not shown.
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
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Prepare the HMDesign submodule for this app (runs as `postinstall`, or `npm run hm:build`).
|
||||
*
|
||||
* HMDesign is consumed as a git submodule (vendor/HMDesign) whose `packages/ui` is an npm workspace of this
|
||||
* repo, so `@hm-design/ui` resolves to vendor/HMDesign/packages/ui and its dependencies are installed into
|
||||
* this repo's node_modules. The package exports compiled `dist/` (JS, types, theme CSS, fonts), which Git
|
||||
* does not carry, so it has to be built once after install.
|
||||
*
|
||||
* HMDesign's own package build (vendor/HMDesign/scripts/build-package.mjs) reads TypeScript and shadcn from
|
||||
* `<HMDesign root>/node_modules/...` by absolute path. Instead of a second full `npm ci` inside the submodule
|
||||
* (Storybook and friends), point that folder at this repo's node_modules with a directory junction. The
|
||||
* submodule's .gitignore already ignores node_modules/, so its working tree stays clean.
|
||||
*
|
||||
* Nothing inside the submodule is edited.
|
||||
*/
|
||||
import { existsSync, lstatSync, symlinkSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { execFileSync } from "node:child_process"
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const hm = path.join(root, "vendor", "HMDesign")
|
||||
const uiPkg = path.join(hm, "packages", "ui")
|
||||
const force = process.argv.includes("--force")
|
||||
|
||||
if (!existsSync(path.join(uiPkg, "package.json"))) {
|
||||
console.error("[setup-hmdesign] vendor/HMDesign is empty. Run: git submodule update --init --recursive")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hmNodeModules = path.join(hm, "node_modules")
|
||||
const rootNodeModules = path.join(root, "node_modules")
|
||||
if (!existsSync(hmNodeModules)) {
|
||||
symlinkSync(rootNodeModules, hmNodeModules, "junction")
|
||||
console.log("[setup-hmdesign] linked vendor/HMDesign/node_modules -> node_modules")
|
||||
} else if (!lstatSync(hmNodeModules).isSymbolicLink()) {
|
||||
console.log("[setup-hmdesign] vendor/HMDesign/node_modules is a real folder; using it as is")
|
||||
}
|
||||
|
||||
const distEntry = path.join(uiPkg, "dist", "index.js")
|
||||
const srcIndex = path.join(uiPkg, "src", "index.ts")
|
||||
const stale = !existsSync(distEntry) || statSync(srcIndex).mtimeMs > statSync(distEntry).mtimeMs
|
||||
if (force || stale) {
|
||||
console.log("[setup-hmdesign] building @hm-design/ui (vendor/HMDesign/packages/ui/dist)")
|
||||
execFileSync(process.execPath, [path.join(hm, "scripts", "build-package.mjs"), "ui"], { cwd: uiPkg, stdio: "inherit" })
|
||||
} else {
|
||||
console.log("[setup-hmdesign] @hm-design/ui dist is up to date")
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { Input, Toaster, TooltipProvider, toast } from "@hm-design/ui"
|
||||
import type { Viewer2D } from "./engine/viewer2d/Viewer2D.js"
|
||||
import { listCadSpaces, pickDefaultSpace, type CadSpace } from "./engine/viewer2d/cadSpaces"
|
||||
import { is2D } from "./engine/viewer2d/ext2d"
|
||||
import { LayerPanelManager, type LayerItem } from "./engine/layerPanel"
|
||||
import { getAllOptions, setOption } from "./engine/optionsStore"
|
||||
import { nameFromUrl, parseCad, parserLabel } from "./lib/cad"
|
||||
import { fetchWithProgress, formatBytes, nextPaint } from "./lib/loadProgress"
|
||||
import type { SampleDrawing } from "./lib/samples"
|
||||
import { createValueStore } from "./lib/store"
|
||||
import { DwgViewerCanvas, type CadPoint, type ViewerDisplayOptions } from "./components/DwgViewerCanvas"
|
||||
import { AppHeader } from "./components/AppHeader"
|
||||
import { ViewerToolbar } from "./components/ViewerToolbar"
|
||||
import { StatusBar, type StatusMessage } from "./components/StatusBar"
|
||||
import { LayersPanel } from "./components/LayersPanel"
|
||||
import { PropertiesPanel } from "./components/PropertiesPanel"
|
||||
import { OptionsPanel, type EngineOptionState } from "./components/OptionsPanel"
|
||||
import { AboutDialog } from "./components/AboutDialog"
|
||||
import { DropOverlay, EmptyState, LoadingOverlay, type LoadState } from "./components/StageOverlays"
|
||||
|
||||
/** Persisted options (IndexedDB hmw-viewer/options). `undefined` = never set → engine default. */
|
||||
type Prefs = {
|
||||
lineweightScale?: number | null
|
||||
lineweightOn?: boolean | null
|
||||
grid: boolean
|
||||
zoomSpeed?: number
|
||||
zScale?: number
|
||||
}
|
||||
|
||||
type Source = { kind: "url"; url: string; name?: string } | { kind: "file"; file: File }
|
||||
|
||||
const positive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
|
||||
|
||||
export function App() {
|
||||
const [viewer, setViewer] = useState<Viewer2D | null>(null)
|
||||
const viewerRef = useRef<Viewer2D | null>(null)
|
||||
const layerManager = useRef<LayerPanelManager | null>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pointer = useMemo(() => createValueStore<CadPoint | null>(null), [])
|
||||
const zoom = useMemo(() => createValueStore<number | null>(null), [])
|
||||
|
||||
const [status, setStatus] = useState<StatusMessage>({ text: "준비 중…" })
|
||||
const [loadState, setLoadState] = useState<LoadState | null>(null)
|
||||
const [hasDrawing, setHasDrawing] = useState(false)
|
||||
const [spaces, setSpaces] = useState<CadSpace[]>([])
|
||||
const [activeSpace, setActiveSpace] = useState<string | null>(null)
|
||||
const [layers, setLayers] = useState<LayerItem[]>([])
|
||||
const [selected, setSelected] = useState<unknown>(null)
|
||||
const [panels, setPanels] = useState({ layers: false, props: false, options: false })
|
||||
const [aboutOpen, setAboutOpen] = useState(false)
|
||||
const aboutOpener = useRef<HTMLElement | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const [view25D, setView25D] = useState(false)
|
||||
const [prefs, setPrefs] = useState<Prefs>({ grid: false })
|
||||
const [engine, setEngine] = useState<EngineOptionState>({
|
||||
lineweightEnabled: false,
|
||||
lineweightScale: 20,
|
||||
lineweightClasses: [],
|
||||
zoomSpeed: 1,
|
||||
zScale: 1,
|
||||
})
|
||||
|
||||
const displayOptions = useMemo<ViewerDisplayOptions>(
|
||||
() => ({
|
||||
dark,
|
||||
grid: prefs.grid,
|
||||
zoomSpeed: prefs.zoomSpeed,
|
||||
lineweightEnabled: prefs.lineweightOn,
|
||||
lineweightScale: prefs.lineweightScale,
|
||||
zScale: prefs.zScale,
|
||||
view25D,
|
||||
}),
|
||||
[dark, prefs, view25D],
|
||||
)
|
||||
|
||||
// ── engine → React (reads only) ─────────────────────────────────────────────
|
||||
/** main.ts syncOptions(): put the controls back to what the engine actually uses. */
|
||||
const readEngine = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
setEngine({
|
||||
lineweightEnabled: v.getLineweightEnabled(),
|
||||
lineweightScale: v.getLineweightScale(),
|
||||
lineweightClasses: v.getLineweightClasses(),
|
||||
zoomSpeed: v.getZoomSpeed(),
|
||||
zScale: v.getZScale(),
|
||||
})
|
||||
zoom.set(v.getZoomPercent())
|
||||
}, [zoom])
|
||||
|
||||
const fillLayers = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
layerManager.current?.update()
|
||||
setLayers(v.getLayerInfo())
|
||||
}, [])
|
||||
|
||||
const fillSpaces = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
setSpaces(v.getSpaces())
|
||||
setActiveSpace(v.getActiveSpace())
|
||||
}, [])
|
||||
|
||||
const onReady = useCallback((v: Viewer2D | null) => {
|
||||
viewerRef.current = v
|
||||
// The ported manager owns the hidden-layer set and calls Viewer2D.setHiddenLayers(); its own DOM rendering
|
||||
// goes to a detached element because the list is rendered by React (LayersPanel) from getLayerInfo().
|
||||
layerManager.current = v ? new LayerPanelManager(v, document.createElement("div")) : null
|
||||
setViewer(v)
|
||||
}, [])
|
||||
|
||||
const onSelect = useCallback((entity: unknown) => {
|
||||
if (!entity) {
|
||||
setStatus({ text: "선택 해제" })
|
||||
setSelected(null)
|
||||
return
|
||||
}
|
||||
// Show the properties window automatically on selection.
|
||||
setPanels((p) => ({ ...p, props: true }))
|
||||
setSelected(entity)
|
||||
const e = entity as { type?: string; layer?: string; handle?: { value?: string } }
|
||||
setStatus({ text: `선택 ${e.type ?? "?"} · 레이어 ${e.layer ?? "-"} · 핸들 ${e.handle?.value ?? "-"}` })
|
||||
}, [])
|
||||
|
||||
const onViewChange = useCallback(() => {
|
||||
zoom.set(viewerRef.current?.getZoomPercent() ?? null)
|
||||
}, [zoom])
|
||||
|
||||
// Theme changes re-render the drawing with theme-dependent layer colors (the sample re-filled the list too).
|
||||
// Child effects run first, so the canvas has already called setTheme() here.
|
||||
useEffect(() => {
|
||||
if (viewer) fillLayers()
|
||||
}, [viewer, dark, fillLayers])
|
||||
|
||||
// ── load pipeline (main.ts ④/⑤ with staged progress) ─────────────────────────
|
||||
const loadSeq = useRef(0)
|
||||
const run = useCallback(
|
||||
async (label: string, source: Source) => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
const seq = ++loadSeq.current
|
||||
const stale = () => seq !== loadSeq.current
|
||||
setStatus({ text: `${label} 여는 중…` })
|
||||
setLoadState({ label: "도면 여는 중", value: 0, busy: true })
|
||||
try {
|
||||
let buf: ArrayBuffer
|
||||
let name: string
|
||||
if (source.kind === "url") {
|
||||
name = source.name ?? nameFromUrl(source.url)
|
||||
let lastLabel = ""
|
||||
buf = await fetchWithProgress(source.url, (loaded, total) => {
|
||||
if (stale()) return
|
||||
const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded)
|
||||
const label = `도면 내려받는 중 (${size})`
|
||||
if (label === lastLabel) return // one update per visible change, not per network chunk
|
||||
lastLabel = label
|
||||
setLoadState({ label, value: total ? Math.round(Math.min(1, loaded / total) * 50) : null, busy: true })
|
||||
})
|
||||
} else {
|
||||
name = source.file.name
|
||||
if (!is2D(name)) throw new Error(`.dwg / .dxf 파일이 아닙니다: ${name}`)
|
||||
setLoadState({ label: "파일 읽는 중", value: 10, busy: true })
|
||||
buf = await source.file.arrayBuffer()
|
||||
}
|
||||
if (stale()) return
|
||||
|
||||
// Parsing blocks the main thread (WASM / dxf-parser): commit and paint the busy state first.
|
||||
flushSync(() => setLoadState({ label: `도면 해석 중 · ${parserLabel(buf, name)}`, value: 55, busy: true }))
|
||||
await nextPaint()
|
||||
const result = await parseCad(buf, name)
|
||||
if (stale()) return
|
||||
|
||||
// Viewer2D.load() tessellates synchronously as well.
|
||||
flushSync(() => setLoadState({ label: "화면 구성 중", value: 85, busy: true }))
|
||||
await nextPaint()
|
||||
const allSpaces = listCadSpaces(result)
|
||||
const def = pickDefaultSpace(allSpaces)
|
||||
v.load(result, { spaceHandle: def?.handleHex ?? null })
|
||||
v.resize()
|
||||
readEngine()
|
||||
|
||||
const active = allSpaces.find((s) => s.handleHex === v.getActiveSpace())
|
||||
setStatus({ text: `✓ ${label} · 엔티티 ${result.entities?.length ?? 0}개${active ? ` · ${active.label}` : ""}` })
|
||||
setHasDrawing(true)
|
||||
setSelected(null)
|
||||
fillSpaces()
|
||||
fillLayers()
|
||||
v.fit()
|
||||
zoom.set(v.getZoomPercent())
|
||||
setLoadState({ label: "완료", value: 100, busy: false })
|
||||
window.setTimeout(() => {
|
||||
if (!stale()) setLoadState(null)
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
if (stale()) return
|
||||
const message = (err as Error).message
|
||||
setStatus({ text: `${label}: ${message}`, error: true })
|
||||
setLoadState(null)
|
||||
toast.error(`${label} 열기 실패`, { description: message })
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
[readEngine, fillSpaces, fillLayers, zoom],
|
||||
)
|
||||
const runRef = useRef(run)
|
||||
useEffect(() => {
|
||||
runRef.current = run
|
||||
}, [run])
|
||||
|
||||
const openAbout = useCallback(() => {
|
||||
aboutOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
setAboutOpen(true)
|
||||
}, [])
|
||||
|
||||
const loadSample = useCallback((s: SampleDrawing) => void run(s.name, { kind: "url", url: s.path, name: s.name }), [run])
|
||||
const openFile = useCallback(() => fileRef.current?.click(), [])
|
||||
const onFileChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = ev.target.files?.[0]
|
||||
if (!file) return
|
||||
void run(file.name, { kind: "file", file })
|
||||
ev.target.value = ""
|
||||
}
|
||||
|
||||
// `?model=` auto-load, once the engine exists.
|
||||
const booted = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!viewer || booted.current) return
|
||||
booted.current = true
|
||||
const model = new URLSearchParams(location.search).get("model")
|
||||
if (model) void runRef.current(model, { kind: "url", url: model })
|
||||
else setStatus({ text: "준비됨 · 도면을 열거나 화면에 끌어다 놓으세요" })
|
||||
}, [viewer])
|
||||
|
||||
// Restore saved options. IndexedDB is async; values are applied as soon as they arrive.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void getAllOptions().then((o) => {
|
||||
if (!alive) return
|
||||
setPrefs((p) => ({
|
||||
...p,
|
||||
...(positive(o.lineweightScale) ? { lineweightScale: o.lineweightScale } : {}),
|
||||
...(typeof o.lineweightOn === "boolean" ? { lineweightOn: o.lineweightOn } : {}),
|
||||
...(o.grid === true ? { grid: true } : {}),
|
||||
...(positive(o.zoomSpeed) ? { zoomSpeed: o.zoomSpeed } : {}),
|
||||
...(positive(o.zScale) ? { zScale: o.zScale } : {}),
|
||||
}))
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// F = fit (the sample's host shortcut; skipped while typing in an input).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.key === "f" || e.key === "F") && (e.target as HTMLElement)?.tagName !== "INPUT") viewerRef.current?.fit()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
// Drag & drop anywhere on the page.
|
||||
useEffect(() => {
|
||||
let depth = 0
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files")
|
||||
const onOver = (e: DragEvent) => e.preventDefault()
|
||||
const onEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return
|
||||
depth++
|
||||
setDragging(true)
|
||||
}
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return
|
||||
depth = Math.max(0, depth - 1)
|
||||
if (!depth) setDragging(false)
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
depth = 0
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) void runRef.current(file.name, { kind: "file", file })
|
||||
}
|
||||
window.addEventListener("dragover", onOver)
|
||||
window.addEventListener("dragenter", onEnter)
|
||||
window.addEventListener("dragleave", onLeave)
|
||||
window.addEventListener("drop", onDrop)
|
||||
return () => {
|
||||
window.removeEventListener("dragover", onOver)
|
||||
window.removeEventListener("dragenter", onEnter)
|
||||
window.removeEventListener("dragleave", onLeave)
|
||||
window.removeEventListener("drop", onDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── commands (React → engine) ───────────────────────────────────────────────
|
||||
const onSpace = (s: CadSpace) => {
|
||||
const v = viewerRef.current
|
||||
if (!v || s.handleHex === v.getActiveSpace()) return
|
||||
v.setSpace(s.handleHex)
|
||||
fillSpaces()
|
||||
fillLayers()
|
||||
v.fit()
|
||||
zoom.set(v.getZoomPercent())
|
||||
setStatus({ text: `공간 ${s.label} · 엔티티 ${s.entityCount}개` })
|
||||
}
|
||||
|
||||
const apply25D = (on: boolean) => {
|
||||
setView25D(on)
|
||||
setStatus({ text: on ? "2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동" : "Top 뷰" })
|
||||
}
|
||||
|
||||
const onToggleLayer = (name: string) => {
|
||||
layerManager.current?.toggleLayer(name)
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
const onShowAll = () => {
|
||||
layerManager.current?.showAllLayers()
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
const onHideAll = () => {
|
||||
layerManager.current?.hideAllLayers()
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
|
||||
// Options: state → canvas effect → engine; each change is saved to IndexedDB like the sample.
|
||||
const onLineweightScale = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, lineweightScale: v }))
|
||||
void setOption("lineweightScale", v)
|
||||
}
|
||||
const onLineweightEnabled = (on: boolean) => {
|
||||
setPrefs((p) => ({ ...p, lineweightOn: on }))
|
||||
void setOption("lineweightOn", on)
|
||||
}
|
||||
const onGrid = (on: boolean) => {
|
||||
setPrefs((p) => ({ ...p, grid: on }))
|
||||
void setOption("grid", on)
|
||||
}
|
||||
const onZoomSpeed = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, zoomSpeed: v }))
|
||||
void setOption("zoomSpeed", v)
|
||||
}
|
||||
const onZScale = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, zScale: v }))
|
||||
void setOption("zScale", v)
|
||||
}
|
||||
const onReset = () => {
|
||||
// Engine default lineweight scale, the drawing's own LWDISPLAY, grid off, zoom ×1, Z ×1, top view.
|
||||
setPrefs({ lineweightScale: null, lineweightOn: null, grid: false, zoomSpeed: 1, zScale: 1 })
|
||||
apply25D(false)
|
||||
void setOption("lineweightScale", null)
|
||||
void setOption("lineweightOn", null)
|
||||
void setOption("grid", false)
|
||||
void setOption("zoomSpeed", 1)
|
||||
void setOption("zScale", 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="viewer-app">
|
||||
<AppHeader onOpenFile={openFile} onSample={loadSample} onAbout={openAbout} />
|
||||
<main className="viewer-main" aria-label="도면 뷰어">
|
||||
<ViewerToolbar
|
||||
spaces={spaces}
|
||||
activeSpace={activeSpace}
|
||||
onSpace={onSpace}
|
||||
hasDrawing={hasDrawing}
|
||||
onFit={() => viewerRef.current?.fit()}
|
||||
view25D={view25D}
|
||||
on25D={apply25D}
|
||||
dark={dark}
|
||||
onDark={setDark}
|
||||
layersOpen={panels.layers}
|
||||
onLayers={(open) => setPanels((p) => ({ ...p, layers: open }))}
|
||||
propsOpen={panels.props}
|
||||
onProps={(open) => setPanels((p) => ({ ...p, props: open }))}
|
||||
optionsOpen={panels.options}
|
||||
onOptions={(open) => setPanels((p) => ({ ...p, options: open }))}
|
||||
/>
|
||||
<div ref={stageRef} className="viewer-stage">
|
||||
<DwgViewerCanvas
|
||||
className="viewer-canvas"
|
||||
options={displayOptions}
|
||||
onReady={onReady}
|
||||
onSelect={onSelect}
|
||||
onPointerWorld={pointer.set}
|
||||
onViewChange={onViewChange}
|
||||
onOptionsApplied={readEngine}
|
||||
/>
|
||||
{!hasDrawing && !loadState ? <EmptyState onOpenFile={openFile} onSample={loadSample} /> : null}
|
||||
{loadState ? <LoadingOverlay state={loadState} /> : null}
|
||||
{dragging ? <DropOverlay /> : null}
|
||||
<LayersPanel
|
||||
open={panels.layers}
|
||||
onClose={() => setPanels((p) => ({ ...p, layers: false }))}
|
||||
anchorRef={stageRef}
|
||||
layers={layers}
|
||||
onToggle={onToggleLayer}
|
||||
onShowAll={onShowAll}
|
||||
onHideAll={onHideAll}
|
||||
/>
|
||||
<PropertiesPanel
|
||||
open={panels.props}
|
||||
onClose={() => setPanels((p) => ({ ...p, props: false }))}
|
||||
anchorRef={stageRef}
|
||||
entity={selected}
|
||||
onZoomToSelection={() => viewerRef.current?.zoomToSelection()}
|
||||
/>
|
||||
<OptionsPanel
|
||||
open={panels.options}
|
||||
onClose={() => setPanels((p) => ({ ...p, options: false }))}
|
||||
anchorRef={stageRef}
|
||||
engine={engine}
|
||||
grid={prefs.grid}
|
||||
view25D={view25D}
|
||||
onLineweightEnabled={onLineweightEnabled}
|
||||
onLineweightScale={onLineweightScale}
|
||||
onGrid={onGrid}
|
||||
onZoomSpeed={onZoomSpeed}
|
||||
on25D={apply25D}
|
||||
onZScale={onZScale}
|
||||
onReset={onReset}
|
||||
/>
|
||||
</div>
|
||||
<StatusBar status={status} pointer={pointer} zoom={zoom} onNotices={openAbout} />
|
||||
</main>
|
||||
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
|
||||
<Input ref={fileRef} type="file" accept=".dwg,.dxf" hidden aria-label="DWG / DXF 파일 선택" onChange={onFileChange} />
|
||||
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} returnFocusRef={aboutOpener} />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { RefObject } from "react"
|
||||
import { ExternalLinkIcon } from "lucide-react"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@hm-design/ui"
|
||||
|
||||
const base = import.meta.env.BASE_URL
|
||||
|
||||
/** Served copies under public/licenses (→ /licenses/…). MPL-2.0 §3.2(b): recipients of the wasm must be told where the source is. */
|
||||
const NOTICE_LINKS = [
|
||||
{ href: `${base}licenses/THIRD-PARTY-NOTICES.md`, label: "THIRD-PARTY-NOTICES" },
|
||||
{ href: `${base}licenses/acadrust-MPL-2.0.txt`, label: "acadrust · MPL-2.0 원문" },
|
||||
{ href: `${base}licenses/acadrust-0.4.1-read_mesh.patch`, label: "acadrust local patch" },
|
||||
]
|
||||
|
||||
const COMPONENTS: [string, string][] = [
|
||||
["acadrust 0.4.1 (WASM 내장, read_mesh 로컬 패치)", "MPL-2.0 · 수정본"],
|
||||
["dwg-wasm 래퍼 · Viewer2D · 앱 코드", "MIT"],
|
||||
["three.js · dxf-parser · opentype.js", "MIT"],
|
||||
["NanumGothic (한글 TEXT/MTEXT)", "SIL OFL 1.1"],
|
||||
["React · Radix UI · sonner · cmdk 등 UI 런타임", "MIT"],
|
||||
["lucide-react", "ISC"],
|
||||
["class-variance-authority", "Apache-2.0"],
|
||||
["Noto Sans KR (HMDesign 테마 글꼴)", "SIL OFL 1.1"],
|
||||
["HMDesign (@hm-design/ui, shadcn/ui 기반)", "사내 디자인 시스템 · shadcn/ui MIT"],
|
||||
]
|
||||
|
||||
const RECIPE: [string, string][] = [
|
||||
["ext2d", ".dwg / .dxf 분기"],
|
||||
["acadrust-dwg · dxf-parser", "DWG(WASM) 또는 DXF 파싱"],
|
||||
["dxfAdapter", "DXF → 공통 스키마"],
|
||||
["Viewer2D + three", "WebGL 렌더"],
|
||||
["slugText + TTF", "TEXT/MTEXT 글리프 (NanumGothic)"],
|
||||
]
|
||||
|
||||
/**
|
||||
* About + open-source notice. The acadrust MPL-2.0 notice and its local patch must stay reachable from the UI
|
||||
* (legal requirement); the sample kept them in its bottom-left recipe panel, this app keeps them here and links
|
||||
* this dialog from the GNB and the status bar.
|
||||
*/
|
||||
export function AboutDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
returnFocusRef,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** The control that opened the dialog (there are two openers, so no single DialogTrigger). */
|
||||
returnFocusRef: RefObject<HTMLElement | null>
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="viewer-about"
|
||||
onCloseAutoFocus={(event) => {
|
||||
const opener = returnFocusRef.current
|
||||
if (opener?.isConnected) {
|
||||
event.preventDefault()
|
||||
opener.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>뷰어 정보 · 오픈소스 고지</DialogTitle>
|
||||
<DialogDescription>이 뷰어를 구성하는 모듈과, 함께 배포되는 서드파티 구성요소의 라이선스입니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="grid gap-6">
|
||||
<section className="grid gap-2">
|
||||
<h3 className="viewer-section-title">오픈소스 고지</h3>
|
||||
<Alert>
|
||||
<AlertTitle>acadrust 0.4.1 — Mozilla Public License 2.0 (수정본)</AlertTitle>
|
||||
<AlertDescription>
|
||||
DWG 파서 acadrust를 WASM으로 컴파일해 포함합니다. 큰 SubD 메쉬가 잘리지 않도록 read_mesh 배열 개수 상한을 고친 로컬
|
||||
패치가 적용되어 있으며, MPL-2.0 §3.2에 따라 원본 소스(crates.io acadrust 0.4.1)와 아래 패치로 동일한 소스를 얻을 수
|
||||
있습니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{NOTICE_LINKS.map((l) => (
|
||||
<li key={l.href}>
|
||||
<Button asChild variant="link" size="sm">
|
||||
<a href={l.href} target="_blank" rel="noopener noreferrer">
|
||||
{l.label}
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Table aria-label="서드파티 구성요소와 라이선스" className="viewer-prop-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">구성요소</TableHead>
|
||||
<TableHead scope="col">라이선스</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{COMPONENTS.map(([name, license]) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell>{license}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="viewer-section-title">구성 모듈 (Module recipe)</h3>
|
||||
<ol className="viewer-recipe">
|
||||
{RECIPE.map(([mod, role]) => (
|
||||
<li key={mod}>
|
||||
<code>{mod}</code> — {role}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button asChild variant="link" size="sm" className="justify-self-start">
|
||||
<a href={`${base}docs/modules-dwg-dxf.html`} target="_blank" rel="noopener noreferrer">
|
||||
모듈 지도 (docs/modules-dwg-dxf.html)
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</section>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="cancel" appearance="solid">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { MouseEvent } from "react"
|
||||
import { FolderOpenIcon, InfoIcon } from "lucide-react"
|
||||
import {
|
||||
Button,
|
||||
GNB,
|
||||
GNBBrand,
|
||||
GNBMobileGroup,
|
||||
GNBMobileLink,
|
||||
GNBMobileMenu,
|
||||
GNBNav,
|
||||
GNBUtilities,
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { SAMPLE_DRAWINGS, sampleHref, type SampleDrawing } from "../lib/samples"
|
||||
|
||||
type Props = {
|
||||
onOpenFile: () => void
|
||||
onSample: (sample: SampleDrawing) => void
|
||||
onAbout: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* GNB_ONLY page header. The only destinations are the bundled sample drawings (each is also a real
|
||||
* `?model=` link); desktop shows them in the GNB Navigation Menu, mobile in GNBMobileMenu — same data.
|
||||
* File open and the about / license dialog are utilities (white ghost Buttons on the bar).
|
||||
*/
|
||||
export function AppHeader({ onOpenFile, onSample, onAbout }: Props) {
|
||||
const select = (s: SampleDrawing) => (event: MouseEvent) => {
|
||||
// Load in place; the href stays usable for "open in new tab".
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
onSample(s)
|
||||
}
|
||||
|
||||
return (
|
||||
<GNB>
|
||||
<GNBMobileMenu title="도면 메뉴">
|
||||
<GNBMobileGroup label="샘플 도면">
|
||||
{SAMPLE_DRAWINGS.map((s) => (
|
||||
<GNBMobileLink key={s.id} href={sampleHref(s)} title={s.hint} onClick={select(s)}>
|
||||
{s.label}
|
||||
</GNBMobileLink>
|
||||
))}
|
||||
</GNBMobileGroup>
|
||||
</GNBMobileMenu>
|
||||
<GNBBrand href={import.meta.env.BASE_URL}>
|
||||
<span>
|
||||
<small>HM CAD VIEWER</small>
|
||||
<strong>DWG · DXF 뷰어</strong>
|
||||
</span>
|
||||
</GNBBrand>
|
||||
<GNBNav>
|
||||
<NavigationMenu viewport={false} aria-label="주 메뉴">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem value="samples">
|
||||
<NavigationMenuTrigger>샘플 도면</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul>
|
||||
{SAMPLE_DRAWINGS.map((s) => (
|
||||
<li key={s.id}>
|
||||
<NavigationMenuLink href={sampleHref(s)} title={s.hint} onClick={select(s)}>
|
||||
{s.label}
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</GNBNav>
|
||||
<GNBUtilities>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" onClick={onOpenFile}>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
파일 열기
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>.dwg / .dxf 파일 열기 (끌어다 놓기도 가능)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="뷰어 정보 · 오픈소스 고지" onClick={onAbout}>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>뷰어 정보 · 오픈소스 고지</TooltipContent>
|
||||
</Tooltip>
|
||||
</GNBUtilities>
|
||||
</GNB>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import { Viewer2D } from "../engine/viewer2d/Viewer2D.js"
|
||||
|
||||
export type CadPoint = { x: number; y: number; z: number }
|
||||
|
||||
/**
|
||||
* Display options pushed into the engine. `undefined` = leave the engine's own default alone
|
||||
* (e.g. Viewer2D restores its zoom speed from localStorage; lineweight follows the drawing's LWDISPLAY).
|
||||
* `null` for the lineweight pair is meaningful and forwarded: "follow the drawing / engine default".
|
||||
*/
|
||||
export type ViewerDisplayOptions = {
|
||||
dark: boolean
|
||||
grid: boolean
|
||||
zoomSpeed?: number
|
||||
lineweightEnabled?: boolean | null
|
||||
lineweightScale?: number | null
|
||||
zScale?: number
|
||||
view25D: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
options: ViewerDisplayOptions
|
||||
/** The instance after mount, `null` after unmount. Use it for imperative calls (load / fit / setSpace / layers). */
|
||||
onReady?: (viewer: Viewer2D | null) => void
|
||||
onSelect?: (entity: unknown) => void
|
||||
onPointerWorld?: (point: CadPoint | null) => void
|
||||
onViewChange?: () => void
|
||||
/** Fired after options were pushed into the engine, so the host can re-read engine state. */
|
||||
onOptionsApplied?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* React wrapper around the imperative Viewer2D engine (Monaco / Mapbox GL style).
|
||||
*
|
||||
* - mount: `new Viewer2D(container)` in an effect; the engine appends its own <canvas> to the empty host div,
|
||||
* so React never renders children into that div.
|
||||
* - updates: option props are diffed against what was last applied and forwarded with the engine's setters.
|
||||
* Data only flows down (props → engine) and up through the callbacks; React state is never written into
|
||||
* the engine's internals.
|
||||
* - unmount: Viewer2D has no dispose(). `releaseViewer2D` stops it from the outside without touching the
|
||||
* engine source (also needed because React StrictMode mounts effects twice in development).
|
||||
*/
|
||||
export function DwgViewerCanvas({ options, onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied, className }: Props) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [viewer, setViewer] = useState<Viewer2D | null>(null)
|
||||
const callbacks = useRef({ onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied })
|
||||
useLayoutEffect(() => {
|
||||
callbacks.current = { onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied }
|
||||
})
|
||||
// What the engine currently has for options whose getter cannot tell (null = "follow drawing").
|
||||
const applied = useRef<{ dark: boolean; grid: boolean; lineweightEnabled?: boolean | null; lineweightScale?: number | null }>({
|
||||
dark: true,
|
||||
grid: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
const instance = new Viewer2D(host)
|
||||
// Engine defaults right after construction: dark scene, grid off.
|
||||
applied.current = { dark: true, grid: false }
|
||||
instance.onSelect((entity) => callbacks.current.onSelect?.(entity))
|
||||
instance.onPointerWorld((p) => callbacks.current.onPointerWorld?.(p))
|
||||
instance.onViewChange(() => callbacks.current.onViewChange?.())
|
||||
|
||||
// The engine listens to window resize itself; the host also changes size when the toolbar wraps or the
|
||||
// layout shifts without a window resize, so observe the container too.
|
||||
const observer = new ResizeObserver(() => instance.resize())
|
||||
observer.observe(host)
|
||||
|
||||
setViewer(instance)
|
||||
callbacks.current.onReady?.(instance)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
callbacks.current.onReady?.(null)
|
||||
setViewer(null)
|
||||
releaseViewer2D(instance)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewer) return
|
||||
const a = applied.current
|
||||
if (a.dark !== options.dark) {
|
||||
viewer.setTheme(options.dark)
|
||||
a.dark = options.dark
|
||||
}
|
||||
if (a.grid !== options.grid) {
|
||||
viewer.setGrid(options.grid)
|
||||
a.grid = options.grid
|
||||
}
|
||||
if (options.zoomSpeed !== undefined && viewer.getZoomSpeed() !== options.zoomSpeed) {
|
||||
viewer.setZoomSpeed(options.zoomSpeed)
|
||||
}
|
||||
if (options.lineweightEnabled !== undefined && a.lineweightEnabled !== options.lineweightEnabled) {
|
||||
viewer.setLineweightEnabled(options.lineweightEnabled)
|
||||
a.lineweightEnabled = options.lineweightEnabled
|
||||
}
|
||||
if (options.lineweightScale !== undefined && a.lineweightScale !== options.lineweightScale) {
|
||||
viewer.setLineweightScale(options.lineweightScale)
|
||||
a.lineweightScale = options.lineweightScale
|
||||
}
|
||||
if (options.zScale !== undefined && viewer.getZScale() !== options.zScale) {
|
||||
viewer.setZScale(options.zScale)
|
||||
}
|
||||
if (viewer.is25D() !== options.view25D) {
|
||||
viewer.set25D(options.view25D)
|
||||
}
|
||||
callbacks.current.onOptionsApplied?.()
|
||||
}, [
|
||||
viewer,
|
||||
options.dark,
|
||||
options.grid,
|
||||
options.zoomSpeed,
|
||||
options.lineweightEnabled,
|
||||
options.lineweightScale,
|
||||
options.zScale,
|
||||
options.view25D,
|
||||
])
|
||||
|
||||
return <div ref={hostRef} className={className} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort teardown for an engine that has no dispose(): end the requestAnimationFrame loop, detach
|
||||
* OrbitControls, release the WebGL context and remove the canvas. The window keydown (F → fit) and resize
|
||||
* listeners Viewer2D registers in its constructor are anonymous and cannot be removed, so the methods they
|
||||
* call are turned into no-ops on this dead instance.
|
||||
*/
|
||||
function releaseViewer2D(viewer: Viewer2D) {
|
||||
const v = viewer as unknown as {
|
||||
_animate?: () => void
|
||||
_onResize?: () => void
|
||||
fit?: () => void
|
||||
_onSelectCb?: unknown
|
||||
_onPointerWorldCb?: unknown
|
||||
_controls?: { dispose?: () => void }
|
||||
_renderer?: { dispose?: () => void; forceContextLoss?: () => void; domElement?: HTMLElement }
|
||||
}
|
||||
const noop = () => {}
|
||||
v._animate = noop
|
||||
v._onResize = noop
|
||||
v.fit = noop
|
||||
v._onSelectCb = null
|
||||
v._onPointerWorldCb = null
|
||||
v._controls?.dispose?.()
|
||||
v._renderer?.dispose?.()
|
||||
v._renderer?.forceContextLoss?.()
|
||||
v._renderer?.domElement?.remove()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"
|
||||
import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react"
|
||||
import { Button, Card, CardAction, CardHeader, CardTitle, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
|
||||
import { makePanelDraggableAndCollapsible } from "../engine/panelUtils"
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
/** Extra header content after the title (count badge…). */
|
||||
meta?: ReactNode
|
||||
/** Extra header buttons before collapse / close. */
|
||||
actions?: ReactNode
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Initial slot from the right edge of the anchor (0 = right-most). */
|
||||
slot: number
|
||||
/** Area the window is first placed in (the canvas stage). */
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Fixed starting height; omitted = fit content (capped by CSS). */
|
||||
size?: "tall" | "auto"
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const GAP = 12
|
||||
|
||||
/**
|
||||
* A floating tool window over the canvas: HM Card inside a positioned frame.
|
||||
*
|
||||
* Drag (header), collapse (header double-click / button) and focus-to-front come from the ported
|
||||
* `makePanelDraggableAndCollapsible` (src/engine/panelUtils.ts, unchanged); resizing is CSS `resize: both` on the
|
||||
* frame, as in the sample. The frame keeps a static className because panelUtils toggles `collapsed` on it, and
|
||||
* React would overwrite a changing className; panelUtils also owns its inline left/top/z-index/height. Open/closed
|
||||
* is the `hidden` attribute, so neither of those is touched.
|
||||
*/
|
||||
export function FloatingPanel({ title, icon, meta, actions, open, onClose, slot, anchorRef, size = "tall", children }: Props) {
|
||||
const frameRef = useRef<HTMLElement>(null)
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const control = useRef<{ toggleCollapse: () => void } | null>(null)
|
||||
const placed = useRef(false)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
// panelUtils has no teardown; attach once per element (StrictMode runs effects twice in development,
|
||||
// and two dblclick listeners would cancel each other out).
|
||||
if (!frame || frame.dataset.panelReady) return
|
||||
frame.dataset.panelReady = "1"
|
||||
control.current = makePanelDraggableAndCollapsible(frame, {
|
||||
handleEl: headerRef.current,
|
||||
bodyEl: bodyRef.current,
|
||||
onCollapseToggle: setCollapsed,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// First open: place the window inside the canvas stage, stacked from the right edge.
|
||||
useLayoutEffect(() => {
|
||||
const frame = frameRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!open || placed.current || !frame || !anchor) return
|
||||
placed.current = true
|
||||
const r = anchor.getBoundingClientRect()
|
||||
const w = frame.offsetWidth
|
||||
const left = Math.max(r.left + GAP, r.right - GAP - (slot + 1) * w - slot * GAP)
|
||||
frame.style.left = `${Math.round(left)}px`
|
||||
frame.style.top = `${Math.round(r.top + GAP)}px`
|
||||
}, [open, slot, anchorRef])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={frameRef}
|
||||
className="viewer-window"
|
||||
data-size={size}
|
||||
aria-label={title}
|
||||
hidden={!open}
|
||||
>
|
||||
<Card size="sm" className="viewer-window-card">
|
||||
<CardHeader ref={headerRef} className="viewer-window-header border-b">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className="flex text-muted-foreground">{icon}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
{meta}
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{actions}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={collapsed ? `${title} 펼치기` : `${title} 접기`}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => control.current?.toggleCollapse()}
|
||||
>
|
||||
{collapsed ? <ChevronRightIcon aria-hidden="true" /> : <ChevronDownIcon aria-hidden="true" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{collapsed ? "펼치기" : "접기"} (제목 더블클릭)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`${title} 닫기`} onClick={onClose}>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>닫기</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<div ref={bodyRef} className="viewer-window-body">
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useId, useState, type RefObject } from "react"
|
||||
import { EyeIcon, EyeOffIcon, LayersIcon } from "lucide-react"
|
||||
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch, Label, ScrollArea, Switch } from "@hm-design/ui"
|
||||
import type { LayerItem } from "../engine/layerPanel"
|
||||
import { FloatingPanel } from "./FloatingPanel"
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Snapshot of Viewer2D.getLayerInfo() (visibility included). */
|
||||
layers: LayerItem[]
|
||||
onToggle: (name: string) => void
|
||||
onShowAll: () => void
|
||||
onHideAll: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Layers window. Visibility state lives in the engine: toggles go through the ported LayerPanelManager
|
||||
* (src/engine/layerPanel.ts) and the list is re-read from Viewer2D.getLayerInfo().
|
||||
* Search is the HM FilterBar applied on every keystroke (the sample filtered on `input`, same substring rule).
|
||||
*/
|
||||
export function LayersPanel({ open, onClose, anchorRef, layers, onToggle, onShowAll, onHideAll }: Props) {
|
||||
const [query, setQuery] = useState("")
|
||||
const idBase = useId()
|
||||
const q = query.trim().toLowerCase()
|
||||
const filtered = layers.filter((l) => l.name.toLowerCase().includes(q))
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
title="레이어"
|
||||
icon={<LayersIcon className="size-4" />}
|
||||
meta={<Badge variant="secondary">{layers.length}개</Badge>}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slot={1}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
<div className="viewer-window-section border-b">
|
||||
<FilterBar className="viewer-layer-filter">
|
||||
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
|
||||
<FilterBarActions>
|
||||
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onShowAll} disabled={!layers.length}>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
전체 켜기
|
||||
</Button>
|
||||
<Button type="button" variant="cancel" appearance="outline" size="sm" onClick={onHideAll} disabled={!layers.length}>
|
||||
<EyeOffIcon aria-hidden="true" />
|
||||
전체 끄기
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
</div>
|
||||
{!layers.length ? (
|
||||
<p className="viewer-empty-note">도면에 레이어 정보가 없습니다</p>
|
||||
) : !filtered.length ? (
|
||||
<p className="viewer-empty-note">검색 결과가 없습니다 ("{q}")</p>
|
||||
) : (
|
||||
<ScrollArea className="viewer-window-scroll">
|
||||
<ul className="viewer-layer-list" aria-label="레이어 목록">
|
||||
{filtered.map((l, i) => {
|
||||
const id = `${idBase}-layer-${i}`
|
||||
return (
|
||||
<li key={l.name} className="viewer-layer-row" data-visible={l.visible ? "true" : "false"}>
|
||||
{/* Layer color comes from the drawing (ACI → hex), not from the theme. */}
|
||||
<span aria-hidden="true" className="viewer-swatch" style={{ background: l.colorHex || undefined }} />
|
||||
<Label htmlFor={id} className="viewer-layer-name" title={l.name}>
|
||||
{l.name}
|
||||
</Label>
|
||||
<Badge variant="outline" title={`엔티티 ${l.count}개`}>
|
||||
{l.count}
|
||||
</Badge>
|
||||
<Switch id={id} checked={l.visible} onCheckedChange={() => onToggle(l.name)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useId, type RefObject } from "react"
|
||||
import { RotateCcwIcon, Settings2Icon } from "lucide-react"
|
||||
import { Badge, Button, Label, Separator, Switch, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
|
||||
import { lineweightPx } from "../lib/cad"
|
||||
import { FloatingPanel } from "./FloatingPanel"
|
||||
import { SliderField } from "./SliderField"
|
||||
|
||||
/** Values read back from the engine (Viewer2D getters) after every change / load. */
|
||||
export type EngineOptionState = {
|
||||
lineweightEnabled: boolean
|
||||
lineweightScale: number
|
||||
lineweightClasses: number[]
|
||||
zoomSpeed: number
|
||||
zScale: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
engine: EngineOptionState
|
||||
grid: boolean
|
||||
view25D: boolean
|
||||
onLineweightEnabled: (on: boolean) => void
|
||||
onLineweightScale: (pxPerMm: number) => void
|
||||
onGrid: (on: boolean) => void
|
||||
onZoomSpeed: (speed: number) => void
|
||||
on25D: (on: boolean) => void
|
||||
onZScale: (k: number) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Options window: every display option of the viewer core. Values persist in IndexedDB (hmw-viewer/options)
|
||||
* through the ported optionsStore — the host does the persisting; this component only renders and reports.
|
||||
* Lineweight scale is applied without re-parsing, so dragging the slider updates the drawing live.
|
||||
*/
|
||||
export function OptionsPanel(props: Props) {
|
||||
const { open, onClose, anchorRef, engine, grid, view25D } = props
|
||||
const id = useId()
|
||||
const s = engine.lineweightScale
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
title="옵션"
|
||||
icon={<Settings2Icon className="size-4" />}
|
||||
actions={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="cancel" appearance="outline" size="sm" onClick={props.onReset}>
|
||||
<RotateCcwIcon aria-hidden="true" />
|
||||
기본값
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>모든 옵션을 기본값으로</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slot={2}
|
||||
anchorRef={anchorRef}
|
||||
size="auto"
|
||||
>
|
||||
<div className="viewer-window-scroll viewer-options">
|
||||
<section className="viewer-option-section" aria-labelledby={`${id}-lw`}>
|
||||
<h3 id={`${id}-lw`} className="viewer-section-title">선가중치 (Lineweight)</h3>
|
||||
<div className="viewer-option-row">
|
||||
<Label htmlFor={`${id}-lw-on`} className="viewer-option-label">
|
||||
표시 (LWDISPLAY)
|
||||
</Label>
|
||||
<Switch id={`${id}-lw-on`} checked={engine.lineweightEnabled} onCheckedChange={props.onLineweightEnabled} />
|
||||
</div>
|
||||
<SliderField
|
||||
id={`${id}-lw-scale`}
|
||||
label="표시 배율"
|
||||
unit="px/mm"
|
||||
value={s}
|
||||
slider={{ min: 2, max: 60, step: 0.5 }}
|
||||
input={{ min: 1, max: 80, step: 0.5 }}
|
||||
onChange={props.onLineweightScale}
|
||||
/>
|
||||
<div className="viewer-lw-preview" aria-live="polite">
|
||||
{engine.lineweightClasses.length ? (
|
||||
engine.lineweightClasses.map((mm) => (
|
||||
<Badge key={mm} variant="outline">
|
||||
{mm.toFixed(2)}mm → {lineweightPx(mm, s)}px
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span>이 도면에는 굵은 선이 없습니다 (0.50mm라면 {lineweightPx(0.5, s)}px)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="viewer-help">
|
||||
AutoCAD "선가중치 설정 → 표시 배율"과 같습니다. 모델 공간에서 선가중치는 줌과 무관한 화면 픽셀로 그려지므로, 화면 DPI에
|
||||
맞춰 이 값을 조정합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="viewer-option-section" aria-labelledby={`${id}-display`}>
|
||||
<h3 id={`${id}-display`} className="viewer-section-title">화면 (Display)</h3>
|
||||
<div className="viewer-option-row">
|
||||
<Label htmlFor={`${id}-grid`} className="viewer-option-label">
|
||||
그리드 표시
|
||||
</Label>
|
||||
<Switch id={`${id}-grid`} checked={grid} onCheckedChange={props.onGrid} />
|
||||
</div>
|
||||
<SliderField
|
||||
id={`${id}-zoom`}
|
||||
label="줌 속도"
|
||||
value={engine.zoomSpeed}
|
||||
slider={{ min: 0.2, max: 3, step: 0.1 }}
|
||||
input={{ min: 0.1, max: 5, step: 0.1 }}
|
||||
onChange={props.onZoomSpeed}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="viewer-option-section" aria-labelledby={`${id}-25d`}>
|
||||
<h3 id={`${id}-25d`} className="viewer-section-title">2.5D 보기</h3>
|
||||
<div className="viewer-option-row">
|
||||
<Label htmlFor={`${id}-25d-on`} className="viewer-option-label">
|
||||
기울여 보기 (Top 해제)
|
||||
</Label>
|
||||
<Switch id={`${id}-25d-on`} checked={view25D} onCheckedChange={props.on25D} />
|
||||
</div>
|
||||
<SliderField
|
||||
id={`${id}-z`}
|
||||
label="높이 과장 (Z)"
|
||||
unit="×"
|
||||
value={engine.zScale}
|
||||
slider={{ min: 1, max: 50, step: 0.5 }}
|
||||
input={{ min: 1, max: 200, step: 0.5 }}
|
||||
disabled={!view25D}
|
||||
onChange={props.onZScale}
|
||||
/>
|
||||
<p className="viewer-help">
|
||||
도면은 이미 CAD 표고(Z)를 그대로 갖고 있습니다. 2.5D는 같은 직교 카메라를 남동 등각(SE Isometric)으로 돌려 그 높낮이를
|
||||
보여 줍니다 — 재파싱 없음. 부지가 넓고 기복이 작아 평평하게 보이면 Z 과장을 올립니다.{" "}
|
||||
<strong>왼쪽 드래그 = 회전, 가운데·오른쪽 드래그 = 이동.</strong>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { RefObject } from "react"
|
||||
import { ClipboardListIcon, MousePointerClickIcon, ZoomInIcon } from "lucide-react"
|
||||
import { Badge, Button, ScrollArea, Table, TableBody, TableCell, TableHead, TableRow } from "@hm-design/ui"
|
||||
import { formatEntityProperties } from "../engine/propertyInspector"
|
||||
import { FloatingPanel } from "./FloatingPanel"
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Entity from Viewer2D.onSelect (null = nothing selected). */
|
||||
entity: unknown
|
||||
onZoomToSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties window. Formatting (labels, units, per-type geometry) is the ported formatEntityProperties
|
||||
* (src/engine/propertyInspector.ts, unchanged); only the presentation is HM (Badge + Table).
|
||||
*/
|
||||
export function PropertiesPanel({ open, onClose, anchorRef, entity, onZoomToSelection }: Props) {
|
||||
const prop = entity ? formatEntityProperties(entity) : null
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
title="속성"
|
||||
icon={<ClipboardListIcon className="size-4" />}
|
||||
actions={
|
||||
prop ? (
|
||||
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onZoomToSelection}>
|
||||
<ZoomInIcon aria-hidden="true" />
|
||||
선택 확대
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slot={0}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
{!prop ? (
|
||||
<div className="viewer-empty-note">
|
||||
<MousePointerClickIcon aria-hidden="true" className="mx-auto mb-2 size-6" />
|
||||
도면에서 객체(엔티티)를 클릭하면
|
||||
<br />
|
||||
상세 속성이 표시됩니다
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="viewer-window-scroll">
|
||||
<div className="viewer-window-section grid gap-4">
|
||||
<Badge className="viewer-prop-id">{prop.id}</Badge>
|
||||
<PropertyTable caption="일반 정보 (General)" rows={prop.general} />
|
||||
{prop.geometry.length > 0 ? <PropertyTable caption="기하 속성 (Geometry)" rows={prop.geometry} /> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyTable({ caption, rows }: { caption: string; rows: [string, string][] }) {
|
||||
return (
|
||||
<section className="grid gap-1.5">
|
||||
<h3 className="viewer-section-title">{caption}</h3>
|
||||
<Table aria-label={caption} className="viewer-prop-table">
|
||||
<TableBody>
|
||||
{rows.map(([k, v], i) => (
|
||||
<TableRow key={`${k}-${i}`}>
|
||||
<TableHead scope="row" className="viewer-prop-key">
|
||||
{k}
|
||||
</TableHead>
|
||||
<TableCell className="viewer-prop-value">{v}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Input, Label, Slider } from "@hm-design/ui"
|
||||
|
||||
type Range = { min: number; max: number; step: number }
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
label: string
|
||||
value: number
|
||||
/** Slider range (coarse). */
|
||||
slider: Range
|
||||
/** Number input range (wider, as in the sample). */
|
||||
input: Range
|
||||
unit?: string
|
||||
disabled?: boolean
|
||||
/** Called with finite values > 0 only (the sample ignored anything else). */
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider + number input bound to one value, both applying on every change like the sample's `input` listeners.
|
||||
* The number field keeps its own draft text so a half-typed value ("1.") is not overwritten by the re-render.
|
||||
*/
|
||||
export function SliderField({ id, label, value, slider, input, unit, disabled, onChange }: Props) {
|
||||
const [draft, setDraft] = useState(String(value))
|
||||
useEffect(() => {
|
||||
setDraft((d) => (parseFloat(d) === value ? d : String(value)))
|
||||
}, [value])
|
||||
|
||||
const commit = (v: number) => {
|
||||
if (!Number.isFinite(v) || v <= 0) return
|
||||
onChange(v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="viewer-option-row" data-disabled={disabled ? "true" : undefined}>
|
||||
<Label htmlFor={id} className="viewer-option-label">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="viewer-option-control">
|
||||
<Slider
|
||||
aria-label={label}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
value={[value]}
|
||||
disabled={disabled}
|
||||
onValueChange={([v]) => commit(v ?? NaN)}
|
||||
/>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
size="small"
|
||||
inputMode="decimal"
|
||||
min={input.min}
|
||||
max={input.max}
|
||||
step={input.step}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
className="viewer-number"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
commit(parseFloat(e.target.value))
|
||||
}}
|
||||
/>
|
||||
{unit ? <span className="viewer-unit">{unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FileUpIcon, FolderOpenIcon } from "lucide-react"
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, LoadingScreen, Progress } from "@hm-design/ui"
|
||||
import { SAMPLE_DRAWINGS, type SampleDrawing } from "../lib/samples"
|
||||
|
||||
/** Shown on the empty canvas: the page's single brand action (open a file) plus the bundled samples. */
|
||||
export function EmptyState({ onOpenFile, onSample }: { onOpenFile: () => void; onSample: (s: SampleDrawing) => void }) {
|
||||
return (
|
||||
<div className="viewer-stage-center">
|
||||
<Card className="viewer-empty-card">
|
||||
<CardHeader>
|
||||
<CardTitle>도면을 열어 주세요</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<p className="text-muted-foreground">
|
||||
DWG · DXF 파일을 선택하거나 이 화면에 끌어다 놓습니다. 주소에 <code>?model=경로</code>를 붙이면 자동으로 엽니다.
|
||||
</p>
|
||||
<Button type="button" size="lg" onClick={onOpenFile}>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
파일 열기
|
||||
</Button>
|
||||
<div className="grid gap-2">
|
||||
<p className="viewer-section-title">샘플 도면</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SAMPLE_DRAWINGS.map((s) => (
|
||||
<Button key={s.id} type="button" variant="primary" appearance="outline" size="sm" title={s.hint} onClick={() => onSample(s)}>
|
||||
{s.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LoadState = {
|
||||
/** Text for the LoadingScreen label and the status line. */
|
||||
label: string
|
||||
/** 0–100 when measurable, null when not (no Content-Length). */
|
||||
value: number | null
|
||||
/** A stage is running (LoadingScreen shown); false only for the brief "done" frame of the Progress bar. */
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Load feedback, following HMDesign's split (loading-screen.md §2):
|
||||
* - Progress (top edge of the canvas) — the measurable part: download bytes, then stage steps.
|
||||
* - LoadingScreen (centre) — the unmeasurable part. WASM parsing and Viewer2D.load() block the main
|
||||
* thread; LoadingScreen's spinner is a CSS opacity keyframe animation the compositor can keep running.
|
||||
*/
|
||||
export function LoadingOverlay({ state }: { state: LoadState }) {
|
||||
return (
|
||||
<>
|
||||
{state.value != null ? (
|
||||
<div className="viewer-stage-progress">
|
||||
<Progress value={state.value} aria-label="도면 불러오기 진행률" />
|
||||
</div>
|
||||
) : null}
|
||||
{state.busy ? (
|
||||
<div className="viewer-stage-center viewer-stage-busy">
|
||||
<LoadingScreen className="viewer-loading">{state.label}</LoadingScreen>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop target hint while a file is dragged over the window. */
|
||||
export function DropOverlay() {
|
||||
return (
|
||||
<div className="viewer-stage-center viewer-drop" aria-hidden="true">
|
||||
<div className="viewer-drop-box">
|
||||
<FileUpIcon className="size-8" />
|
||||
<span>여기에 놓아 도면 열기 (.dwg / .dxf)</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ScaleIcon } from "lucide-react"
|
||||
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
|
||||
import { formatCadCoord } from "../lib/cad"
|
||||
import { useValueStore, type ValueStore } from "../lib/store"
|
||||
import type { CadPoint } from "./DwgViewerCanvas"
|
||||
|
||||
export type StatusMessage = { text: string; error?: boolean }
|
||||
|
||||
type Props = {
|
||||
status: StatusMessage
|
||||
pointer: ValueStore<CadPoint | null>
|
||||
zoom: ValueStore<number | null>
|
||||
onNotices: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom status bar. Left: the status line (load / select / space messages). Right end — the screen's
|
||||
* bottom-right corner — the live CAD coordinates, so they stay in a fixed place.
|
||||
*/
|
||||
export function StatusBar({ status, pointer, zoom, onNotices }: Props) {
|
||||
return (
|
||||
<footer className="viewer-statusbar">
|
||||
<p role="status" className="viewer-status-text" data-error={status.error ? "true" : undefined} title={status.text}>
|
||||
{status.text}
|
||||
</p>
|
||||
<Button type="button" variant="link" size="sm" onClick={onNotices} className="viewer-notice-link">
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
오픈소스 고지 (MPL-2.0 포함)
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="viewer-statusbar-sep" />
|
||||
<ZoomReadout store={zoom} />
|
||||
<Separator orientation="vertical" className="viewer-statusbar-sep" />
|
||||
<CoordinateReadout store={pointer} />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoomReadout({ store }: { store: ValueStore<number | null> }) {
|
||||
const z = useValueStore(store)
|
||||
return (
|
||||
<span className="viewer-readout" title="확대 배율 (맞춤 = 100%)">
|
||||
<span className="viewer-readout-label">Zoom</span>
|
||||
{z == null ? "—" : `${z}%`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CoordinateReadout({ store }: { store: ValueStore<CadPoint | null> }) {
|
||||
const p = useValueStore(store)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="viewer-readout viewer-coords" tabIndex={0} aria-label="마우스 위치 CAD 좌표">
|
||||
<span className="viewer-readout-label">X</span>
|
||||
<span className="viewer-coord-value">{p ? formatCadCoord(p.x) : "—"}</span>
|
||||
<span className="viewer-readout-label">Y</span>
|
||||
<span className="viewer-coord-value">{p ? formatCadCoord(p.y) : "—"}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">마우스 위치 CAD 좌표 (Model = UCS, Layout = Paper 도면 단위)</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BoxIcon, ClipboardListIcon, LayersIcon, MaximizeIcon, MoonIcon, Settings2Icon } from "lucide-react"
|
||||
import {
|
||||
Button,
|
||||
Separator,
|
||||
Toggle,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import type { CadSpace } from "../engine/viewer2d/cadSpaces"
|
||||
|
||||
type Props = {
|
||||
spaces: CadSpace[]
|
||||
activeSpace: string | null
|
||||
onSpace: (space: CadSpace) => void
|
||||
hasDrawing: boolean
|
||||
onFit: () => void
|
||||
view25D: boolean
|
||||
on25D: (on: boolean) => void
|
||||
dark: boolean
|
||||
onDark: (dark: boolean) => void
|
||||
layersOpen: boolean
|
||||
onLayers: (open: boolean) => void
|
||||
propsOpen: boolean
|
||||
onProps: (open: boolean) => void
|
||||
optionsOpen: boolean
|
||||
onOptions: (open: boolean) => void
|
||||
}
|
||||
|
||||
/** The sample's rule: hide the strip only when there are fewer than two spaces and the first one is empty. */
|
||||
export function shouldShowSpaces(spaces: CadSpace[]): boolean {
|
||||
return !(spaces.length < 2 && !spaces[0]?.entityCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* View controls between the GNB and the canvas: Model/Layout switch (ToggleGroup, only when the drawing has
|
||||
* spaces to switch), Fit, 2.5D, canvas theme, and the three tool windows (Toggles = open state).
|
||||
*/
|
||||
export function ViewerToolbar(p: Props) {
|
||||
const showSpaces = shouldShowSpaces(p.spaces)
|
||||
|
||||
return (
|
||||
<div className="viewer-toolbar">
|
||||
{showSpaces ? (
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
value={p.activeSpace ?? ""}
|
||||
aria-label="Model / Layout 공간"
|
||||
onValueChange={(hex) => {
|
||||
const s = p.spaces.find((x) => x.handleHex === hex)
|
||||
if (s) p.onSpace(s)
|
||||
}}
|
||||
>
|
||||
{p.spaces.map((s) => (
|
||||
<ToggleGroupItem key={s.handleHex} value={s.handleHex} title={`${s.name} · handle ${s.handleHex}`}>
|
||||
{s.label}
|
||||
{s.entityCount ? ` (${s.entityCount})` : ""}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Separator orientation="vertical" className="viewer-toolbar-sep" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="viewer-toolbar-group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={p.onFit} disabled={!p.hasDrawing}>
|
||||
<MaximizeIcon aria-hidden="true" />
|
||||
맞춤 보기
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>전체 도면 맞춤 (단축키 F)</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Toggle keeps its data-slot only when it is not a Slot child, so the tooltip anchors on a wrapper. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Toggle size="sm" variant="outline" pressed={p.view25D} onPressedChange={p.on25D}>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
2.5D 보기
|
||||
</Toggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Top 뷰를 풀고 기울여 높낮이(Z)를 봅니다 · 왼쪽 드래그=회전, 가운데/오른쪽 드래그=이동</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Toggle size="sm" variant="outline" pressed={p.dark} onPressedChange={p.onDark}>
|
||||
<MoonIcon aria-hidden="true" />
|
||||
어두운 배경
|
||||
</Toggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>도면 캔버스 테마 (어두운 / 밝은 배경)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="viewer-toolbar-group viewer-toolbar-end">
|
||||
<Toggle size="sm" variant="outline" pressed={p.layersOpen} onPressedChange={p.onLayers}>
|
||||
<LayersIcon aria-hidden="true" />
|
||||
레이어
|
||||
</Toggle>
|
||||
<Toggle size="sm" variant="outline" pressed={p.propsOpen} onPressedChange={p.onProps}>
|
||||
<ClipboardListIcon aria-hidden="true" />
|
||||
속성
|
||||
</Toggle>
|
||||
<Toggle size="sm" variant="outline" pressed={p.optionsOpen} onPressedChange={p.onOptions}>
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
옵션
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Layer Panel manager for dwg-dxf-viewer-sample.
|
||||
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
|
||||
*/
|
||||
|
||||
import type { Viewer2D } from './viewer2d/Viewer2D';
|
||||
|
||||
export interface LayerItem {
|
||||
name: string;
|
||||
colorHex: string;
|
||||
count: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export class LayerPanelManager {
|
||||
private viewer: Viewer2D;
|
||||
private listEl: HTMLElement;
|
||||
private countEl: HTMLElement | null;
|
||||
private searchInputEl: HTMLInputElement | null;
|
||||
private hiddenLayers: Set<string> = new Set();
|
||||
private searchQuery: string = '';
|
||||
|
||||
constructor(
|
||||
viewer: Viewer2D,
|
||||
listEl: HTMLElement,
|
||||
options?: {
|
||||
countEl?: HTMLElement | null;
|
||||
searchInputEl?: HTMLInputElement | null;
|
||||
showAllBtnEl?: HTMLElement | null;
|
||||
hideAllBtnEl?: HTMLElement | null;
|
||||
}
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this.listEl = listEl;
|
||||
this.countEl = options?.countEl || null;
|
||||
this.searchInputEl = options?.searchInputEl || null;
|
||||
|
||||
if (this.searchInputEl) {
|
||||
this.searchInputEl.addEventListener('input', (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.showAllBtnEl) {
|
||||
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
|
||||
}
|
||||
|
||||
if (options?.hideAllBtnEl) {
|
||||
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
|
||||
}
|
||||
|
||||
// Delegated click handling: bound once, so render() never has to re-attach
|
||||
// a listener per row (that was O(layers) DOM work on every single toggle).
|
||||
this.listEl.addEventListener('click', (e) => {
|
||||
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const layerName = row.dataset.name;
|
||||
if (layerName) this.toggleLayer(layerName);
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh and re-render layer list from Viewer2D state */
|
||||
public update(): void {
|
||||
this.hiddenLayers.clear();
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
if (!l.visible) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
public showAllLayers(): void {
|
||||
this.hiddenLayers.clear();
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public hideAllLayers(): void {
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public toggleLayer(name: string): void {
|
||||
if (this.hiddenLayers.has(name)) {
|
||||
this.hiddenLayers.delete(name);
|
||||
} else {
|
||||
this.hiddenLayers.add(name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRow(name);
|
||||
}
|
||||
|
||||
/** Patch one row in place — a full render() would rebuild the whole list. */
|
||||
private syncRow(name: string): void {
|
||||
const row = this.listEl?.querySelector<HTMLElement>(
|
||||
`.layer-row[data-name="${cssEscape(name)}"]`
|
||||
);
|
||||
if (!row) return;
|
||||
this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
}
|
||||
|
||||
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
|
||||
private syncRows(): void {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
|
||||
const name = row.dataset.name;
|
||||
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
});
|
||||
}
|
||||
|
||||
private paintRow(row: HTMLElement, visible: boolean): void {
|
||||
row.style.opacity = visible ? '1' : '0.4';
|
||||
const btn = row.querySelector<HTMLElement>('.eye-btn');
|
||||
if (btn) {
|
||||
btn.textContent = visible ? '👁️' : '🕶️';
|
||||
btn.title = visible ? '숨기기' : '표시하기';
|
||||
}
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
if (!this.listEl) return;
|
||||
|
||||
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
|
||||
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = `${layers.length}개`;
|
||||
}
|
||||
|
||||
if (!layers.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
도면에 레이어 정보가 없습니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = layers.filter((l) =>
|
||||
l.name.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = filtered
|
||||
.map((l) => {
|
||||
const isVisible = !this.hiddenLayers.has(l.name);
|
||||
const opacity = isVisible ? '1' : '0.4';
|
||||
const eyeIcon = isVisible ? '👁️' : '🕶️';
|
||||
const eyeTitle = isVisible ? '숨기기' : '표시하기';
|
||||
|
||||
return `
|
||||
<div class="layer-row" data-name="${escapeHtml(l.name)}"
|
||||
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
|
||||
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
|
||||
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
|
||||
${escapeHtml(l.name)}
|
||||
</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
|
||||
${l.count}
|
||||
</span>
|
||||
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
|
||||
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
|
||||
${eyeIcon}
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
// Clicks are handled by the delegated listener bound in the constructor.
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
|
||||
function cssEscape(str: string): string {
|
||||
return str.replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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,149 @@
|
||||
/**
|
||||
* Utility helper to make floating UI panels draggable and collapsible.
|
||||
*/
|
||||
|
||||
let globalMaxZIndex = 30;
|
||||
|
||||
export interface PanelControlOptions {
|
||||
handleEl?: HTMLElement | null;
|
||||
collapseBtnEl?: HTMLElement | null;
|
||||
bodyEl?: HTMLElement | null;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function makePanelDraggableAndCollapsible(
|
||||
panelEl: HTMLElement,
|
||||
options?: PanelControlOptions
|
||||
): { toggleCollapse: () => void; isCollapsed: () => boolean } {
|
||||
if (!panelEl) return { toggleCollapse: () => {}, isCollapsed: () => false };
|
||||
|
||||
const handleEl = (options?.handleEl || panelEl.querySelector('.hdr') || panelEl) as HTMLElement;
|
||||
const collapseBtnEl = (options?.collapseBtnEl || panelEl.querySelector('.collapse-btn')) as HTMLElement | null;
|
||||
const bodyEl = (options?.bodyEl || panelEl.querySelector('#propBody') || panelEl.querySelector('#layerBody') || panelEl.children[1]) as HTMLElement | null;
|
||||
|
||||
let isCollapsed = false;
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let initialLeft = 0;
|
||||
let initialTop = 0;
|
||||
|
||||
// Set initial drag cursor
|
||||
if (handleEl) {
|
||||
handleEl.style.cursor = 'grab';
|
||||
handleEl.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
// Focus panel to top z-index when clicked anywhere
|
||||
panelEl.addEventListener('pointerdown', () => {
|
||||
globalMaxZIndex++;
|
||||
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||
});
|
||||
|
||||
// Dragging handler
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
// Ignore clicks on buttons, inputs, or interactive controls inside the header
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button, input, select, a, label, .hdr-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
globalMaxZIndex++;
|
||||
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||
if (handleEl) handleEl.style.cursor = 'grabbing';
|
||||
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
initialLeft = rect.left;
|
||||
initialTop = rect.top;
|
||||
|
||||
// Convert CSS right/bottom to explicit left/top positioning on first drag
|
||||
panelEl.style.left = `${initialLeft}px`;
|
||||
panelEl.style.top = `${initialTop}px`;
|
||||
panelEl.style.right = 'auto';
|
||||
panelEl.style.bottom = 'auto';
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
|
||||
let newLeft = initialLeft + dx;
|
||||
let newTop = initialTop + dy;
|
||||
|
||||
// Clamp within window boundaries so panel doesn't disappear off-screen
|
||||
const maxLeft = Math.max(10, window.innerWidth - panelEl.offsetWidth - 10);
|
||||
const maxTop = Math.max(10, window.innerHeight - panelEl.offsetHeight - 10);
|
||||
|
||||
newLeft = Math.max(10, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(10, Math.min(newTop, maxTop));
|
||||
|
||||
panelEl.style.left = `${newLeft}px`;
|
||||
panelEl.style.top = `${newTop}px`;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
if (handleEl) handleEl.style.cursor = 'grab';
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
|
||||
if (handleEl) {
|
||||
handleEl.addEventListener('pointerdown', onPointerDown as EventListener);
|
||||
|
||||
// Double-click on header to toggle collapse/expand
|
||||
handleEl.addEventListener('dblclick', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button, input, select, a, label')) return;
|
||||
toggleCollapseState();
|
||||
});
|
||||
}
|
||||
|
||||
let lastHeight = '';
|
||||
|
||||
// Collapse / Expand toggle function
|
||||
const toggleCollapseState = () => {
|
||||
isCollapsed = !isCollapsed;
|
||||
panelEl.classList.toggle('collapsed', isCollapsed);
|
||||
|
||||
if (isCollapsed) {
|
||||
lastHeight = panelEl.style.height || `${panelEl.offsetHeight}px`;
|
||||
panelEl.style.height = '42px';
|
||||
if (bodyEl) bodyEl.style.display = 'none';
|
||||
} else {
|
||||
panelEl.style.height = lastHeight || '480px';
|
||||
if (bodyEl) bodyEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
if (collapseBtnEl) {
|
||||
collapseBtnEl.textContent = isCollapsed ? '▸' : '▾';
|
||||
collapseBtnEl.setAttribute('title', isCollapsed ? '펼치기' : '접기');
|
||||
}
|
||||
|
||||
if (options?.onCollapseToggle) {
|
||||
options.onCollapseToggle(isCollapsed);
|
||||
}
|
||||
};
|
||||
|
||||
if (collapseBtnEl) {
|
||||
collapseBtnEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleCollapseState();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
toggleCollapse: toggleCollapseState,
|
||||
isCollapsed: () => isCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Entity Property Inspector module for dwg-dxf-viewer-sample.
|
||||
* Formats CAD entity properties and renders detailed property UI panels.
|
||||
*/
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function renderPropertyInspector(
|
||||
bodyEl: HTMLElement,
|
||||
entity: any,
|
||||
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
|
||||
): void {
|
||||
if (!bodyEl) return;
|
||||
|
||||
if (!entity) {
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
|
||||
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
|
||||
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
|
||||
|
||||
const prop = formatEntityProperties(entity);
|
||||
|
||||
const renderRows = (rows: [string, string][]) =>
|
||||
rows
|
||||
.map(
|
||||
([k, v]) => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
|
||||
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
|
||||
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:14px">
|
||||
<!-- Entity ID Tag -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
|
||||
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
|
||||
</div>
|
||||
|
||||
<!-- General Section -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
|
||||
일반 정보 (General)
|
||||
</div>
|
||||
${renderRows(prop.general)}
|
||||
</div>
|
||||
|
||||
<!-- Geometry Section -->
|
||||
${
|
||||
prop.geometry.length > 0
|
||||
? `
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
|
||||
기하 속성 (Geometry)
|
||||
</div>
|
||||
${renderRows(prop.geometry)}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
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
@@ -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 },
|
||||
};
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* HM Theme (public package entry) + this app's page layout.
|
||||
App rules below only lay out plain elements (frames, rows, overlays) with HM Semantic tokens. They do not
|
||||
target HM component slots; HM components are customised only through their documented tokens (--hm-table-*,
|
||||
--hm-filter-bar-*) on app-owned wrappers. */
|
||||
@import "@hm-design/ui/theme.css";
|
||||
|
||||
/* ── Page: GNB_ONLY — GNB + content surface (gnb.md §10 content contract) ─────────────────────────────── */
|
||||
.viewer-app {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100dvh;
|
||||
min-width: 320px;
|
||||
background: var(--hm-sem-bg-content);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-family: var(--hm-font-family-base);
|
||||
}
|
||||
|
||||
.viewer-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0;
|
||||
background: var(--hm-sem-bg-content);
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────────────────────────────────────────────── */
|
||||
.viewer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px var(--hm-gnb-padding-x) 10px;
|
||||
}
|
||||
.viewer-toolbar-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.viewer-toolbar-end {
|
||||
margin-left: auto;
|
||||
}
|
||||
.viewer-toolbar-sep {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* ── Stage: canvas + overlays + floating windows ───────────────────────────────────────────────── */
|
||||
.viewer-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 var(--hm-gnb-padding-x);
|
||||
border: 1px solid var(--hm-sem-stroke-input-default);
|
||||
border-radius: var(--hm-radius-container);
|
||||
overflow: hidden;
|
||||
/* Own stacking context: the floating windows' growing z-index (panelUtils) stays below portaled HM popups. */
|
||||
isolation: isolate;
|
||||
}
|
||||
.viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.viewer-canvas > canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.viewer-stage-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.viewer-stage-center > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.viewer-stage-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
}
|
||||
.viewer-loading {
|
||||
min-width: min(320px, 100%);
|
||||
}
|
||||
.viewer-empty-card {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
.viewer-drop {
|
||||
z-index: 12;
|
||||
background: var(--hm-sem-overlay-background);
|
||||
}
|
||||
.viewer-drop-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 36px;
|
||||
border: 2px dashed var(--hm-sem-action-primary);
|
||||
border-radius: var(--hm-radius-container);
|
||||
background: var(--hm-sem-bg-surface);
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Floating tool windows (FloatingPanel) ─────────────────────────────────────────────────────── */
|
||||
.viewer-window {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
height: min(460px, calc(100dvh - 160px));
|
||||
min-width: 240px;
|
||||
min-height: 120px;
|
||||
max-width: calc(100vw - 20px);
|
||||
max-height: calc(100dvh - 20px);
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
border-radius: var(--hm-radius-container);
|
||||
box-shadow: var(--hm-sem-floating-shadow);
|
||||
}
|
||||
.viewer-window[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.viewer-window[data-size="auto"] {
|
||||
height: auto;
|
||||
max-height: min(78dvh, 760px);
|
||||
}
|
||||
/* panelUtils writes an inline 42px height when collapsing; let the card header define it instead. */
|
||||
.viewer-window.collapsed {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
resize: none;
|
||||
}
|
||||
.viewer-window-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewer-window-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.viewer-window-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viewer-window-section {
|
||||
padding-inline: var(--card-spacing);
|
||||
}
|
||||
.viewer-window-section.border-b {
|
||||
padding-bottom: var(--card-spacing);
|
||||
}
|
||||
.viewer-empty-note {
|
||||
margin: 0;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.viewer-section-title {
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Layers */
|
||||
.viewer-layer-filter {
|
||||
--hm-filter-bar-search-width: 100%;
|
||||
}
|
||||
.viewer-layer-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.viewer-layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 4px var(--card-spacing);
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
}
|
||||
.viewer-layer-row[data-visible="false"] > :is(.viewer-swatch, .viewer-layer-name) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.viewer-swatch {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--hm-sem-stroke-default);
|
||||
}
|
||||
.viewer-layer-name {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.viewer-prop-id {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
}
|
||||
.viewer-prop-table {
|
||||
--hm-table-min-width: 0;
|
||||
--hm-table-font-size: 12px;
|
||||
--hm-table-line-height: 18px;
|
||||
--hm-table-header-font-size: 12px;
|
||||
--hm-table-cell-padding-y: 6px;
|
||||
--hm-table-cell-padding-x: 8px;
|
||||
}
|
||||
.viewer-prop-key {
|
||||
width: 42%;
|
||||
}
|
||||
.viewer-prop-value {
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.viewer-options {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
padding: 0 var(--card-spacing) var(--card-spacing);
|
||||
}
|
||||
.viewer-option-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.viewer-option-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
/* The Slider / Input show their own disabled look; dim only the plain text of the row. */
|
||||
.viewer-option-row[data-disabled] > .viewer-option-label,
|
||||
.viewer-option-row[data-disabled] .viewer-unit {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.viewer-option-label {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
.viewer-option-control {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.viewer-number {
|
||||
flex: 0 0 76px;
|
||||
text-align: right;
|
||||
}
|
||||
.viewer-unit {
|
||||
min-width: 36px;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-lw-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-help {
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* About dialog */
|
||||
.viewer-about {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
.viewer-recipe {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.viewer-recipe code,
|
||||
.viewer-empty-card code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ── Status bar (coordinates sit in the bottom-right corner) ───────────────────────────────────── */
|
||||
.viewer-statusbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
min-height: 40px;
|
||||
padding: 4px var(--hm-gnb-padding-x);
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-status-text {
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer-status-text[data-error] {
|
||||
color: var(--hm-sem-status-error);
|
||||
}
|
||||
.viewer-statusbar-sep {
|
||||
align-self: stretch;
|
||||
margin-block: 8px;
|
||||
}
|
||||
.viewer-readout {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer-readout-label {
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.viewer-coords {
|
||||
margin-left: auto;
|
||||
}
|
||||
.viewer-coord-value {
|
||||
min-width: 6.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.viewer-status-text {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.viewer-statusbar-sep {
|
||||
display: none;
|
||||
}
|
||||
.viewer-coord-value {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Tailwind v4 + the public HM Tailwind setup (runtime-portability.md §2.1). The HM theme follows in hm-theme.css. */
|
||||
@import "tailwindcss" source("./");
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@source "../index.html";
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Host glue around the ported engine (src/engine/viewer2d), lifted from dwg-dxf-viewer-sample/src/main.ts
|
||||
* blocks ④ (parser composition) and the coordinate readout. Logic unchanged.
|
||||
*
|
||||
* File/URL → ext2d (isDwg / isDxf) → parseDwgBuffer | parseDxfBuffer → CadParseResult → Viewer2D.load()
|
||||
*/
|
||||
import { parseDwgBuffer, type CadParseResult } from "../engine/viewer2d/dwgParser"
|
||||
import { parseDxfBuffer } from "../engine/viewer2d/parseDxf"
|
||||
import { isDxf, isDwg } from "../engine/viewer2d/ext2d"
|
||||
|
||||
export type { CadParseResult }
|
||||
|
||||
/** Pick the parser by extension, falling back to the DWG magic ("AC10xx") and then DXF. */
|
||||
export async function parseCad(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
||||
if (isDxf(name)) return parseDxfBuffer(buf)
|
||||
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf))
|
||||
const head = new TextDecoder("latin1").decode(
|
||||
new Uint8Array(buf, 0, Math.min(6, buf.byteLength)),
|
||||
)
|
||||
if (head.startsWith("AC")) return parseDwgBuffer(new Uint8Array(buf))
|
||||
return parseDxfBuffer(buf)
|
||||
}
|
||||
|
||||
/** Which parser parseCad() will use — only for the loading label. */
|
||||
export function parserLabel(buf: ArrayBuffer, name: string): string {
|
||||
if (isDxf(name)) return "dxf-parser"
|
||||
if (isDwg(name)) return "acadrust WASM"
|
||||
const head = new TextDecoder("latin1").decode(new Uint8Array(buf, 0, Math.min(6, buf.byteLength)))
|
||||
return head.startsWith("AC") ? "acadrust WASM" : "dxf-parser"
|
||||
}
|
||||
|
||||
/** File name of a URL (query stripped), as main.ts loadUrl() derived it. */
|
||||
export function nameFromUrl(url: string): string {
|
||||
return url.split("?")[0]!.split("/").pop() ?? url
|
||||
}
|
||||
|
||||
/** CAD coordinate text: fewer decimals for larger magnitudes (Model = UCS, Layout = paper units). */
|
||||
export function formatCadCoord(v: number): string {
|
||||
const a = Math.abs(v)
|
||||
if (a >= 1e6) return v.toFixed(1)
|
||||
if (a >= 1e3) return v.toFixed(2)
|
||||
if (a >= 1) return v.toFixed(3)
|
||||
return v.toFixed(4)
|
||||
}
|
||||
|
||||
/** Lineweight classes → "0.30mm → 3.3px" (same rounding as main.ts syncOptions). */
|
||||
export function lineweightPx(mm: number, pxPerMm: number): number {
|
||||
return Math.round(mm * pxPerMm * 10) / 10
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Load-pipeline helpers, ported from dwg-dxf-viewer-sample/src/topProgress.ts.
|
||||
*
|
||||
* The sample drove its own NProgress-style bar from that file. Here the visual part is HMDesign's
|
||||
* (Progress for the measurable download, LoadingScreen for the synchronous parse/render stages — see
|
||||
* components/LoadingOverlay.tsx); only the non-visual helpers are kept, unchanged.
|
||||
*
|
||||
* Why the paint wait matters: `parse_dwg_json` on the WASM heap and `Viewer2D.load()` are SYNCHRONOUS.
|
||||
* While they run the main thread is blocked — no rAF, no React commit, no repaint. The loading UI for a
|
||||
* blocking stage therefore has to be committed AND painted before the stage starts (`nextPaint`). After
|
||||
* that only compositor-driven animation keeps moving; LoadingScreen's spinner is a CSS opacity keyframe
|
||||
* animation, which is the kind the compositor can run on its own.
|
||||
*/
|
||||
|
||||
/** Wait until the browser has actually painted the pending style change. */
|
||||
export function nextPaint(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
// Two frames: the first callback still runs BEFORE paint, the second one
|
||||
// only fires once the frame carrying our change has been composited.
|
||||
requestAnimationFrame(() => requestAnimationFrame(finish))
|
||||
// Backgrounded tabs never fire rAF — don't stall the load behind it.
|
||||
setTimeout(finish, 150)
|
||||
})
|
||||
}
|
||||
|
||||
/** 1.4 MB / 812 kB — short byte label for the download readout. */
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return "?"
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB`
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() that reports download progress.
|
||||
*
|
||||
* Falls back to a plain `arrayBuffer()` when the response has no body stream
|
||||
* or no Content-Length (then `total` is 0 and the caller should show an
|
||||
* indeterminate state). `loaded` is decompressed bytes while Content-Length is
|
||||
* the compressed size, so the ratio is clamped to 1.
|
||||
*/
|
||||
export async function fetchWithProgress(
|
||||
url: string,
|
||||
onProgress: (loaded: number, total: number) => void,
|
||||
): Promise<ArrayBuffer> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const total = Number(res.headers.get("content-length")) || 0
|
||||
if (!res.body || !total) {
|
||||
const buf = await res.arrayBuffer()
|
||||
onProgress(buf.byteLength, buf.byteLength)
|
||||
return buf
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let loaded = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (!value) continue
|
||||
chunks.push(value)
|
||||
loaded += value.length
|
||||
onProgress(loaded, total)
|
||||
}
|
||||
|
||||
const out = new Uint8Array(loaded)
|
||||
let offset = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset)
|
||||
offset += c.length
|
||||
}
|
||||
return out.buffer
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Bundled sample drawings (public/samples). Same three entry points as the sample's toolbar. */
|
||||
export type SampleDrawing = {
|
||||
id: string
|
||||
/** Menu label. */
|
||||
label: string
|
||||
/** Which pipeline it exercises (shown as the item's title). */
|
||||
hint: string
|
||||
/** Path under the app base, also usable as `?model=`. */
|
||||
path: string
|
||||
/** Name used for parser selection and the status line. */
|
||||
name: string
|
||||
}
|
||||
|
||||
const base = import.meta.env.BASE_URL
|
||||
|
||||
export const SAMPLE_DRAWINGS: SampleDrawing[] = [
|
||||
{ id: "dwg", label: "Sample DWG", hint: "acadrust-dwg WASM", path: `${base}samples/BasicSample.dwg`, name: "BasicSample.dwg" },
|
||||
{ id: "civil", label: "평면및종단면도.dwg", hint: "Civil Plan & Profile DWG", path: `${base}samples/civil.dwg`, name: "C0060202-001-평면및종단면도.dwg" },
|
||||
{ id: "dxf", label: "Sample DXF", hint: "dxf-parser", path: `${base}samples/simple.dxf`, name: "simple.dxf" },
|
||||
]
|
||||
|
||||
/** `?model=` link for a sample, so the menu item also works as a plain link (new tab, copy link). */
|
||||
export function sampleHref(s: SampleDrawing): string {
|
||||
return `?model=${encodeURIComponent(s.path)}`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
/**
|
||||
* Minimal external store for values the viewer emits at pointer-move / camera-change rate (CAD coordinates,
|
||||
* zoom %). Keeping them out of App state means only the small readout re-renders, not the whole screen.
|
||||
*/
|
||||
export type ValueStore<T> = {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
subscribe: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export function createValueStore<T>(initial: T): ValueStore<T> {
|
||||
let value = initial
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
get: () => value,
|
||||
set: (next) => {
|
||||
if (Object.is(next, value)) return
|
||||
value = next
|
||||
listeners.forEach((l) => l())
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useValueStore<T>(store: ValueStore<T>): T {
|
||||
return useSyncExternalStore(store.subscribe, store.get, store.get)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "./index.css"
|
||||
import "./hm-theme.css"
|
||||
import { setDefaultFontUrl } from "./engine/viewer2d/slugText"
|
||||
import { App } from "./App"
|
||||
|
||||
// Host static asset for TEXT/MTEXT glyphs (Hangul + Latin). Must be set before the first Viewer2D is
|
||||
// constructed: its constructor warms SlugTextEngine with this URL.
|
||||
setDefaultFontUrl(`${import.meta.env.BASE_URL}fonts/NanumGothic-Regular.ttf`)
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
+1
Submodule vendor/HMDesign added at d18e3c3380.
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
},
|
||||
resolve: {
|
||||
// @hm-design/ui is a linked workspace (vendor/HMDesign/packages/ui); keep one React instance.
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Only crawl this app's entry; vendor/HMDesign carries its own HTML entries (Storybook exports).
|
||||
entries: ["index.html"],
|
||||
include: ["three", "dxf-parser", "opentype.js"],
|
||||
},
|
||||
// acadrust-dwg WASM is imported with `?url` (src/engine/viewer2d/acadrustParser.ts).
|
||||
assetsInclude: ["**/*.wasm"],
|
||||
})
|
||||
Reference in new issue
Block a user