Compare commits

...
7 Commits
Author SHA1 Message Date
minsungandClaude Opus 5.5 855b7f4ad6 perf(ui): update the download progress once per visible change
fetchWithProgress reports every network chunk; only re-render when the
"x MB / y MB" label actually changes (#1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:59:10 +09:00
minsungandClaude Opus 5.5 6533c31704 docs: README and third-party notices for the React app
- README: setup with the HMDesign submodule/workspace, scripts,
  source layout (engine copied as is vs. new React components).
- THIRD-PARTY-NOTICES (root + served /licenses copy): acadrust MPL-2.0
  section unchanged; intro points at this app's dist/ and the in-app
  entry points; adds the UI runtime that @hm-design/ui brings in
  (React, Radix UI, lucide-react, class-variance-authority, shadcn CSS,
  Noto Sans KR) and HMDesign itself (#1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:57:54 +09:00
minsungandClaude Opus 5.5 320233f6d7 fix(ui): open/close tool windows with the hidden attribute
panelUtils owns the window's inline left/top/z-index/height, so React
should not manage its style at all; use `hidden` (+ a CSS rule against
the frame's display:flex) instead of style.display (#1). Also drops a
dynamic style value the HMDesign guard reported as unverifiable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:57:53 +09:00
minsungandClaude Opus 5.5 aaa0972bc1 feat(ui): rebuild the viewer UI with HMDesign components
New React UI around the unchanged engine (#1). GNB_ONLY page:

- AppHeader: GNB + GNBBrand; sample drawings in GNBNav (NavigationMenu)
  and GNBMobileMenu (same data, real ?model= links); file open and
  about/notice in GNBUtilities.
- DwgViewerCanvas: useRef/useEffect wrapper. new Viewer2D(host) on
  mount, option props diffed and pushed through the engine setters,
  onSelect/onPointerWorld/onViewChange forwarded to React. Viewer2D has
  no dispose(), so teardown stops the rAF loop / WebGL context from
  outside (also needed for StrictMode double mount).
- ViewerToolbar: Model/Layout ToggleGroup (sample's visibility rule),
  Fit, 2.5D and canvas-theme Toggles, window Toggles.
- FloatingPanel: HM Card in a fixed frame; drag/collapse via the ported
  makePanelDraggableAndCollapsible, CSS resize.
- LayersPanel (FilterBar search, Switch per layer, show/hide all via
  LayerPanelManager), PropertiesPanel (formatEntityProperties → Badge +
  Table, zoom to selection), OptionsPanel (Switch/Slider/Input, IndexedDB
  through optionsStore, reset).
- Load feedback per HMDesign: Progress for the measurable part
  (download bytes, stage steps) and LoadingScreen for the blocking
  WASM parse / Viewer2D.load(), painted before each blocking stage.
- StatusBar with status line, zoom %, live CAD coordinates in the
  bottom-right corner; AboutDialog keeps the acadrust MPL-2.0 notice,
  THIRD-PARTY-NOTICES and the local patch reachable.
- Drag & drop, ?model= auto-load, F shortcut, ResizeObserver resync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:49:33 +09:00
minsungandClaude Opus 5.5 40befda905 chore: keep the acadrust patch byte-exact in checkouts
Mark *.patch as -text so core.autocrlf never rewrites the served MPL-2.0
source patch (#1); `patch -p1` needs the original LF bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:49:32 +09:00
minsungandClaude Opus 5.5 bc8875770b feat(engine): port the viewer engine and panel logic unchanged
Copy the vanilla engine from dwg-dxf-viewer-sample verbatim (#1):

- src/engine/viewer2d/* (Viewer2D.js incl. the 2.5D view, parsers,
  acadrust-dwg WASM, cadSpaces, slugText, linParser/linData,
  fixDwgKoreanText, ...)
- src/engine/{layerPanel,panelUtils,propertyInspector,optionsStore}.ts

The relative layout is kept (engine/viewer2d next to the panel files),
so not even import paths changed; `diff -r --strip-trailing-cr` against
the sample is empty. public/ (NanumGothic, samples, licenses, module
map) is copied as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:48:56 +09:00
minsungandClaude Opus 5.5 948021c7ac chore: scaffold Vite + React 19 + Tailwind v4 app on HMDesign
Start the React rebuild of the DWG/DXF viewer (#1).

- HMDesign is a git submodule (vendor/HMDesign, main) and its
  packages/ui is an npm workspace, so @hm-design/ui resolves to the
  submodule and shares this repo's node_modules.
- scripts/setup-hmdesign.mjs (postinstall) builds the package dist and
  points vendor/HMDesign/node_modules at ours with a junction, because
  HMDesign's build script reads typescript/shadcn from its own root.
  Nothing inside the submodule is modified.
- Versions follow HMDesign (react 19.3.0, vite 8.3.0, tailwindcss
  4.3.3, typescript 7.0.2) and the sample (three 0.185.1, dxf-parser
  1.1.2, opentype.js 2.0.0).
- CSS entry: tailwindcss + @hm-design/ui/tailwind.css (index.css).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:48:53 +09:00
70 changed files with 19129 additions and 0 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
*.wasm binary
*.dwg binary
*.ttf binary
*.dxf -text
*.patch -text
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
*.tsbuildinfo
.DS_Store
.vite/
+4
View File
@@ -0,0 +1,4 @@
[submodule "vendor/HMDesign"]
path = vendor/HMDesign
url = https://gitea.hmac.kr/b25027/HMDesign.git
branch = main
+63
View File
@@ -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 정보 버튼과 상태 표시줄의 "오픈소스 고지"에서 열립니다.
+78
View File
@@ -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
View File
@@ -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>
+8164
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -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"
}
}
+361
View File
@@ -0,0 +1,361 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
<style>
:root {
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
--grid: rgba(14,124,134,.06);
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
var(--bg);
color: var(--ink); font-family: var(--sans); line-height: 1.6;
}
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
h1 .dim { color: var(--ink-faint); font-weight: 400; }
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
.cta {
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
box-shadow: var(--shadow);
}
h2 {
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
}
h2 .num { color: var(--accent); }
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
.legend span { display: inline-flex; align-items: center; gap: 7px; }
.dot { width: 11px; height: 11px; border-radius: 3px; }
.dot.wasm { background: var(--wasm); }
.dot.js { background: var(--js); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
}
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
.card.w { --tag: var(--wasm); }
.card.j { --tag: var(--js); }
.card .k {
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
}
.badge {
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
padding: 2px 6px; border-radius: 4px; font-weight: 600;
}
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
.badge.j { background: var(--accent-soft); color: var(--js); }
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
.flow {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
}
.pipe { display: flex; align-items: stretch; min-width: 720px; }
.node {
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
}
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
.node.w { border-color: var(--wasm); }
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
.node.hl .tier { color: var(--accent); }
.node.w .tier { color: var(--wasm); }
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
.node .sub { font-size: 12px; color: var(--ink-soft); }
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
.flow-note b { color: var(--accent); }
pre {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
box-shadow: var(--shadow); margin: 0;
}
pre .c { color: var(--accent); }
pre .w { color: var(--wasm); }
pre .cm { color: var(--ink-faint); }
.steps { display: grid; gap: 12px; }
.step {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
}
.step .n {
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
display: flex; align-items: center; justify-content: center;
}
.step h3 { margin: 0 0 4px; font-size: 15px; }
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
footer {
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
a { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="eyebrow">compose · 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.
+78
View File
@@ -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 }
}
+375
View File
@@ -0,0 +1,375 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under the name "LEGAL", and included in all Covered Software
distributed in Source Code Form. Except to the extent prohibited by
statute or regulation, such description must be sufficiently detailed
for a recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+146
View File
@@ -0,0 +1,146 @@
0
SECTION
2
HEADER
9
$ACADVER
1
AC1015
9
$INSUNITS
70
4
0
ENDSEC
0
SECTION
2
TABLES
0
TABLE
2
LAYER
0
LAYER
2
0
70
0
62
7
0
LAYER
2
OUTLINE
70
0
62
1
0
ENDTAB
0
ENDSEC
0
SECTION
2
BLOCKS
0
ENDSEC
0
SECTION
2
ENTITIES
0
LINE
8
OUTLINE
10
0.0
20
0.0
30
0.0
11
100.0
21
0.0
31
0.0
0
LINE
8
OUTLINE
10
100.0
20
0.0
30
0.0
11
100.0
21
60.0
31
0.0
0
LINE
8
OUTLINE
10
100.0
20
60.0
30
0.0
11
0.0
21
60.0
31
0.0
0
LINE
8
OUTLINE
10
0.0
20
60.0
30
0.0
11
0.0
21
0.0
31
0.0
0
CIRCLE
8
0
10
50.0
20
30.0
30
0.0
40
15.0
0
TEXT
8
0
10
10.0
20
70.0
30
0.0
40
5.0
1
Hello DWG/DXF package
0
ENDSEC
0
EOF
+48
View File
@@ -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
View File
@@ -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>
)
}
+151
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+151
View File
@@ -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()
}
+117
View File
@@ -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>
)
}
+82
View File
@@ -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>
)
}
+148
View File
@@ -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>
)
}
+78
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+79
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+121
View File
@@ -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>
)
}
+195
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 뷰어 옵션 영구 저장소 (IndexedDB).
*
* localStorage 대신 IndexedDB 를 쓰는 이유: 값이 문자열로 강제되지 않고(숫자/불린/
* 객체 그대로), 용량 제한이 사실상 없으며, 나중에 도면별 설정처럼 구조가 커져도
* 같은 저장소를 그대로 쓸 수 있다.
*
* DB 하나(`hmw-viewer`) 안에 key-value 스토어(`options`) 하나만 둔다.
* 실패(사생활 보호 모드, 스토리지 차단 등)해도 앱이 죽지 않도록 모든 API 는
* 조용히 기본값으로 떨어진다.
*/
const DB_NAME = 'hmw-viewer';
const DB_VERSION = 1;
const STORE = 'options';
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null);
let req: IDBOpenDBRequest;
try {
req = indexedDB.open(DB_NAME, DB_VERSION);
} catch {
return resolve(null);
}
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T | null> {
return openDb().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) return resolve(null);
try {
const t = db.transaction(STORE, mode);
const req = run(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
/** 저장된 옵션 하나. 없으면 `fallback`. */
export async function getOption<T>(key: string, fallback: T): Promise<T> {
const v = await tx<T>('readonly', (s) => s.get(key));
return v === null || v === undefined ? fallback : v;
}
/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */
export async function setOption(key: string, value: unknown): Promise<void> {
await tx('readwrite', (s) => s.put(value, key));
}
/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */
export async function getAllOptions(): Promise<Record<string, unknown>> {
const keys = (await tx<IDBValidKey[]>('readonly', (s) => s.getAllKeys())) ?? [];
const vals = (await tx<unknown[]>('readonly', (s) => s.getAll())) ?? [];
const out: Record<string, unknown> = {};
keys.forEach((k, i) => { out[String(k)] = vals[i]; });
return out;
}
+149
View File
@@ -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,
};
}
+358
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>`;
}
+50
View File
@@ -0,0 +1,50 @@
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
import type { CadSpace } from './cadSpaces';
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): void;
/** Zoom-extents to the loaded drawing. */
fit(): void;
/** Resync renderer/camera to the container size (call after un-hiding). */
resize(): void;
onSelect(cb: (entity: unknown) => void): void;
getSelectedEntity(): unknown;
zoomToSelection(): void;
setAccent(hex: string): void;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
/** 2.5D (tilted 3D) view: orbit the drawing so entity elevation (Z) is visible. */
set25D(on: boolean, opts?: { keepView?: boolean }): void;
is25D(): boolean;
/** Height exaggeration for 2.5D (1 = true scale); ignored in top view. */
setZScale(k: number): void;
getZScale(): number;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
/** Lineweight display scale in px per mm (AutoCAD's display-scale slider). Default 20. */
setLineweightScale(pxPerMm: number | null): void;
getLineweightScale(): number;
/** Distinct lineweights (mm) present in the loaded drawing, ascending. */
getLineweightClasses(): number[];
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
/** Model / Paper (Layout) spaces for the loaded drawing. */
getSpaces(): CadSpace[];
getActiveSpace(): string | null;
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
/** Screen client coords → drawing world (Z=0 plane). */
screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null;
/** Live CAD coords under the cursor; null when pointer leaves the canvas. */
onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): void;
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
stopMeasure(): void;
}
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
/* tslint:disable */
/* eslint-disable */
export function parse_dwg(bytes: Uint8Array): any;
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
*/
export function parse_dwg_json(bytes: Uint8Array): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly parse_dwg: (a: number, b: number) => [number, number, number];
readonly parse_dwg_json: (a: number, b: number) => [number, number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -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 };
@@ -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/*"
]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
* (wasm-pack --target web, committed under ./acadrust-dwg).
*/
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
import type { CadParseResult } from './dwgParser';
let ready: Promise<void> | null = null;
/** Initialize the acadrust WASM module once (idempotent). */
export function initAcadrustParser(): Promise<void> {
if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined);
return ready;
}
/**
* Parse a DWG buffer. Requires initAcadrustParser() first.
*
* Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg`
* export that hands back a JsValue. The latter keeps the entire result live on
* the wasm heap as it builds, and the allocator's cost grows with that heap —
* a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as
* text keeps it linear: the same file now lands in ~2.5 s.
*/
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* AutoCAD Color Index (ACI) → hex RGB integer.
* Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color.
*/
function hsl2hex(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return Math.round((r + m) * 255) * 65536
+ Math.round((g + m) * 255) * 256
+ Math.round((b + m) * 255);
}
// ACI 0-9: exact fixed colors
const FIXED = [
null, // 0: BYBLOCK
0xFF0000, // 1: red
0xFFFF00, // 2: yellow
0x00FF00, // 3: green
0x00FFFF, // 4: cyan
0x0000FF, // 5: blue
0xFF00FF, // 6: magenta
0xFFFFFF, // 7: white
0x414141, // 8: dark gray
0x808080, // 9: gray
];
// ACI 10-249: 24 hue groups × 10 saturation/lightness variants
const VARIANTS_SL = [
[1.00, 0.50], // 0: full
[1.00, 0.28], // 1: dark
[0.65, 0.65], // 2: light pastel
[0.65, 0.38], // 3: pastel dark
[0.35, 0.80], // 4: pale
[0.35, 0.48], // 5: pale mid
[0.18, 0.68], // 6: faint light
[0.18, 0.38], // 7: faint dark
[0.00, 0.55], // 8: light gray
[0.00, 0.30], // 9: dark gray
];
export function aciToHex(aci, isDark = true) {
if (aci == null || aci === 0 || aci === 256) return null;
if (aci === 7) return isDark ? 0xFFFFFF : 0x000000;
if (aci <= 9) return FIXED[aci] ?? null;
if (aci >= 250 && aci <= 255) {
const v = Math.round(40 + (aci - 250) * 43);
return v * 65536 + v * 256 + v;
}
if (aci >= 10 && aci <= 249) {
const idx = aci - 10;
const [s, l] = VARIANTS_SL[idx % 10];
return hsl2hex(Math.floor(idx / 10) * 15, s, l);
}
return 0xC9D1D9;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Model Space / Paper Space (Layout) discovery for CadParseResult.
*
* DWG entities are owned by a block header handle. System blocks
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
* AutoCAD switches between. Viewer2D must render only one at a time —
* drawing both stacks survey-model geometry on top of layout sheet
* geometry and produces the "all text piled in the center" glitch.
*/
export type CadSpaceKind = 'model' | 'paper' | 'other';
export type CadSpace = {
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
id: string;
/** Display label, e.g. "Model" / "Layout" / raw block name. */
label: string;
/** Raw block name from tables.blocks. */
name: string;
/** Block handle as number when available. */
handle: number | null;
/** Hex string matching entity ownerHandle.value.toString(16). */
handleHex: string;
kind: CadSpaceKind;
entityCount: number;
};
function handleToHex(handle: unknown): string | null {
if (handle == null) return null;
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
const v = (handle as { value?: unknown }).value;
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
if (typeof v === 'string' && v.length) {
const n = Number(v);
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
}
return null;
}
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
if (typeof handle === 'string' && handle.length) {
const n = Number(handle);
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
}
return null;
}
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
return handleToHex(entity.ownerHandle);
}
function classifySpaceName(raw: string): CadSpaceKind | null {
const name = raw.toLowerCase().replace(/\*/g, '').trim();
if (!name) return null;
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
return null;
}
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
if (kind === 'model') return 'Model';
if (kind === 'paper') {
// First paper space → "Layout"; further ones keep a disambiguator.
if (paperIndex <= 1) return 'Layout';
return `Layout (${name.replace(/^\*/, '')})`;
}
return name;
}
/**
* List model/paper space blocks that own at least zero entities.
* Always includes Model/Paper headers found in tables.blocks.
*/
export function listCadSpaces(result: {
entities?: unknown[];
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
} | null | undefined): CadSpace[] {
const blocks = result?.tables?.blocks ?? [];
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
const counts = new Map<string, number>();
for (const e of entities) {
const hex = ownerHex(e);
if (!hex) continue;
counts.set(hex, (counts.get(hex) || 0) + 1);
}
const spaces: CadSpace[] = [];
let paperIndex = 0;
for (const b of blocks) {
const kind = classifySpaceName(b.name ?? '');
if (!kind) continue;
const hex = handleToHex(b.handle);
if (!hex) continue;
if (kind === 'paper') paperIndex += 1;
const handleNum =
typeof b.handle === 'number'
? b.handle
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
? Number((b.handle as { value: unknown }).value)
: null;
spaces.push({
id: hex,
label: spaceLabel(kind, b.name ?? '', paperIndex),
name: b.name ?? '',
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
handleHex: hex,
kind,
entityCount: counts.get(hex) || 0,
});
}
// Stable order: Model first, then papers in table order.
spaces.sort((a, b) => {
if (a.kind !== b.kind) {
if (a.kind === 'model') return -1;
if (b.kind === 'model') return 1;
}
return 0;
});
return spaces;
}
/**
* Default tab when a file is opened:
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
* - else Model
* - else first listed space
*/
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
if (!spaces.length) return null;
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
if (paperWithContent) return paperWithContent;
const model = spaces.find((s) => s.kind === 'model');
if (model) return model;
return spaces[0] ?? null;
}
/**
* Owners allowed when filtering to one space: the space handle itself plus
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
*/
export function buildAllowedOwnerHexes(
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
activeSpaceHex: string,
rounds = 3,
): Set<string> {
const allowed = new Set<string>([activeSpaceHex]);
for (let pass = 0; pass < rounds; pass++) {
let grew = false;
for (const ent of entities) {
const oh = ownerHex(ent);
if (!oh || !allowed.has(oh)) continue;
const h = handleToHex(ent.handle);
if (h && !allowed.has(h)) {
allowed.add(h);
grew = true;
}
}
if (!grew) break;
}
return allowed;
}
export function isSystemSpaceBlockName(name: string): boolean {
return classifySpaceName(name) != null;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Decode a DXF buffer to text.
*
* Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE
* (e.g. ANSI_949) that no longer matches the bytes — so the header is
* unreliable. Strategy: try UTF-8 strictly (self-validating); on failure
* fall back to the header's single-byte codepage. The DXF group/value
* structure is ASCII regardless, so this only affects TEXT/MTEXT strings.
*/
export function decodeDxf(buf: ArrayBuffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
/* not valid UTF-8 → legacy single-byte codepage */
}
const head = new TextDecoder('latin1').decode(
new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)),
);
const cp = head
.match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1]
?.trim()
.toLowerCase();
let label = 'windows-1252';
if (cp) {
if (/949/.test(cp)) label = 'euc-kr';
else if (/932/.test(cp)) label = 'shift_jis';
else if (/936/.test(cp)) label = 'gbk';
else if (/950/.test(cp)) label = 'big5';
}
try {
return new TextDecoder(label).decode(buf);
} catch {
return new TextDecoder('utf-8').decode(buf);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
* vendored with a local read_mesh patch; see hmwebviewer rust/dwg-wasm). Emits
* the parseResult shape Viewer2D.load() consumes. Lazy-imported so the wasm is
* only fetched when a DWG is actually opened.
*
* History: until 2026-07-10 this module selected between acadrust and
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
* after side-by-side validation (PROGRESS.md Phase 15).
*
* Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in
* older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings
* (e.g. BasicSample) are left unchanged.
*/
import { fixDwgKoreanText } from './fixDwgKoreanText';
export type CadParseResult = {
version?: string;
vars?: Record<string, unknown>;
entities?: unknown[];
tables?: Record<string, unknown>;
stats?: Record<string, unknown>;
};
/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
await initAcadrustParser();
return fixDwgKoreanText(parseDwgAcadrust(bytes));
}
+7
View File
@@ -0,0 +1,7 @@
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
export function dxfToParseResult(dxf: unknown): {
version: string;
vars: Record<string, unknown>;
entities: unknown[];
tables: { blocks: unknown[]; layers: unknown[] };
};
+160
View File
@@ -0,0 +1,160 @@
/**
* dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that
* Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension
* block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF.
*
* Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the
* raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD
* handle its child entities are owned by, and INSERT/DIMENSION reference their
* block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the
* incoherent handles and synthesize one consistent integer handle PER BLOCK NAME:
* - block table entry handle = {value: H}
* - every child entity ownerHandle = {value: H}
* - INSERT.blockHeaderHandle = {value: H of insert.name}
* - DIMENSION.blockHeaderHandle = {value: H of dim.block}
* Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so
* Viewer2D's space-handle filter keeps them visible.
*
* Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but
* INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here.
*
* Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent.
*/
const DEG2RAD = Math.PI / 180;
const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p);
/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */
function adaptEntity(e, nameToH) {
const o = { ...e };
delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI
const t = (e.type || '').toUpperCase();
switch (t) {
case 'LINE':
if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); }
break;
case 'POLYLINE':
case 'LWPOLYLINE':
o.type = 'LWPOLYLINE';
o.points = (e.vertices || []).map(vec);
o.bulges = (e.vertices || []).map((v) => v.bulge || 0);
o.closed = !!e.shape || ((e.flags || 0) & 1) === 1;
break;
case 'ARC':
case 'CIRCLE':
o.center = vec(e.center); // radius + (radian) angles already match
break;
case 'ELLIPSE':
o.center = vec(e.center);
o.majorAxis = vec(e.majorAxisEndPoint);
o.ratio = e.axisRatio;
o.startParam = e.startAngle;
o.endParam = e.endAngle;
break;
case 'POINT':
o.position = vec(e.position);
break;
case 'SOLID':
case '3DFACE': {
const p = e.points || [];
if (p[0]) o.corner1 = vec(p[0]);
if (p[1]) o.corner2 = vec(p[1]);
if (p[2]) o.corner3 = vec(p[2]);
if (p[3]) o.corner4 = vec(p[3]);
o.type = 'SOLID';
break;
}
case 'TEXT':
case 'ATTRIB':
case 'ATTDEF':
o.insertionPoint = vec(e.startPoint || e.position);
o.alignmentPt = vec(e.endPoint);
o.rotationAngle = (e.rotation || 0) * DEG2RAD;
o.horizAlignment = e.halign || 0;
o.vertAlignment = e.valign || 0;
o.textHeight = e.textHeight ?? e.height;
break;
case 'MTEXT':
o.insertionPoint = vec(e.position);
o.textHeight = e.height ?? e.textHeight;
if (e.directionVector) o.xAxisDir = vec(e.directionVector);
else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) };
break;
case 'INSERT': {
o.insertionPoint = vec(e.position);
o.scaleX = e.xScale ?? 1;
o.scaleY = e.yScale ?? 1;
o.rotation = (e.rotation || 0) * DEG2RAD;
const h = nameToH.get(e.name);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
case 'DIMENSION': {
const h = nameToH.get(e.block);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
default:
break;
}
return o;
}
/**
* Convert a full dxf-parser result → Viewer2D parseResult.
* @param {object} dxf output of `new DxfParser().parseSync(text)`
* @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}}
*/
export function dxfToParseResult(dxf) {
const blocks = dxf.blocks || {};
const names = Object.keys(blocks);
const nameToH = new Map();
names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles
const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n));
const msH = msName != null ? nameToH.get(msName) : 0;
const entities = [];
// Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle.
for (const e of (dxf.entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: msH };
entities.push(o);
}
// Block-definition entities → owned by their block's synthetic handle.
for (const n of names) {
const H = nameToH.get(n);
for (const e of (blocks[n].entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: H };
entities.push(o);
}
}
const blockTable = names.map((n) => ({
name: n,
handle: { value: nameToH.get(n) },
basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 },
}));
const layerSrc = dxf.tables?.layer?.layers || {};
const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex }));
return {
version: 'dxf',
vars: dxf.header || {},
entities,
tables: { blocks: blockTable, layers: layerTable },
};
}
+5
View File
@@ -0,0 +1,5 @@
/** HATCH entity handler for dxf-parser's registerEntityHandler(). */
export default class HatchHandler {
ForEntityName: string;
parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown;
}
+190
View File
@@ -0,0 +1,190 @@
/**
* HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops
* HATCH silently). Registered via the library's public `registerEntityHandler()`,
* so dxf-parser itself stays unmodified (MIT-clean).
*
* Emits the shape Viewer2D's HATCH renderer consumes:
* entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] }
* Viewer2D fills solid hatches (with island/hole nesting) and draws boundary
* outlines; bulge arcs on polyline boundaries are honored.
*
* Boundary kinds handled:
* - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42)
* - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points
* ELLIPSE(72=3), SPLINE(72=4, control-point polyline)
*
* Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the
* boundary outline as a proxy, matching its DWG behavior.
*/
const DEG2RAD = Math.PI / 180;
const ARC_STEPS = 48;
function tessArc(cx, cy, r, a0, a1, ccw, out) {
let span = a1 - a0;
if (ccw) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS));
for (let i = 0; i <= steps; i++) {
const a = a0 + (span * i) / steps;
out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
}
export default class HatchHandler {
constructor() {
this.ForEntityName = 'HATCH';
}
parseEntity(scanner, curr) {
const entity = { type: curr.value, paths: [], solidFill: false };
curr = scanner.next();
while (!scanner.isEOF()) {
if (curr.code === 0) break;
switch (curr.code) {
case 70: // solid fill flag
entity.solidFill = curr.value !== 0;
break;
case 2: // pattern name
entity.patternName = curr.value;
break;
case 8: // layer
entity.layer = curr.value;
break;
case 62: // color index
entity.colorIndex = curr.value;
break;
case 91: { // number of boundary paths → parse them by count
const n = curr.value;
let g = scanner.next();
for (let i = 0; i < n && !scanner.isEOF(); i++) {
g = this._readPath(scanner, entity, g);
}
curr = g; // g is the group following the last path's data
continue; // curr already advanced — skip the trailing next()
}
default:
break;
}
curr = scanner.next();
}
return entity;
}
/** Parse one boundary path starting at its 92 group; return the next group after it. */
_readPath(scanner, entity, g) {
// g.code should be 92 (path type flag); if not, skip until we find it.
while (!scanner.isEOF() && g.code !== 92) g = scanner.next();
const flag = g.value | 0;
const isPolyline = (flag & 2) !== 0;
const path = { points: [], bulges: [] };
let cur = scanner.next();
if (isPolyline) {
let hasBulge = false;
while (cur.code === 72 || cur.code === 73) {
if (cur.code === 72) hasBulge = cur.value !== 0;
cur = scanner.next();
}
let numVerts = 0;
if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); }
for (let v = 0; v < numVerts && !scanner.isEOF(); v++) {
let x = 0, y = 0, b = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); }
path.points.push({ x, y });
path.bulges.push(b);
}
} else {
let numEdges = 0;
if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); }
for (let e = 0; e < numEdges && !scanner.isEOF(); e++) {
let edgeType = 0;
if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); }
cur = this._readEdge(scanner, path, edgeType, cur);
}
}
// Consume the per-path trailing "source boundary objects" block (97 + 330×count).
if (cur.code === 97) {
const srcN = cur.value | 0;
cur = scanner.next();
for (let s = 0; s < srcN && !scanner.isEOF(); s++) {
if (cur.code === 330) cur = scanner.next();
else break;
}
}
if (path.points.length >= 2) entity.paths.push(path);
return cur;
}
/** Tessellate one edge into path.points; return the next group. */
_readEdge(scanner, path, edgeType, cur) {
if (edgeType === 1) {
// LINE: 10/20 start, 11/21 end
let sx = 0, sy = 0, ex = 0, ey = 0;
if (cur.code === 10) { sx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { sy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { ex = cur.value; cur = scanner.next(); }
if (cur.code === 21) { ey = cur.value; cur = scanner.next(); }
path.points.push({ x: sx, y: sy });
path.points.push({ x: ex, y: ey }); // last edge's end closes the loop
} else if (edgeType === 2) {
// ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw
let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 40) { r = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points);
} else if (edgeType === 3) {
// ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw
let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { mx = cur.value; cur = scanner.next(); }
if (cur.code === 21) { my = cur.value; cur = scanner.next(); }
if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
const major = Math.hypot(mx, my);
const minor = major * ratio;
const rot = Math.atan2(my, mx);
let span = a1 - a0;
if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72));
for (let i = 0; i <= steps; i++) {
const t = a0 + (span * i) / steps;
const ex = major * Math.cos(t), ey = minor * Math.sin(t);
path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) });
}
} else if (edgeType === 4) {
// SPLINE: approximate by its control points (10/20). Skip knots/weights.
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights
let numKnots = 0, numCtrl = 0;
const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; };
while (!scanner.isEOF()) {
if (cur.code === 95) { numKnots = readInt(); }
else if (cur.code === 96) { numCtrl = readInt(); }
else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); }
else break;
}
for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next();
for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) {
let x = 0, y = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (cur.code === 42) cur = scanner.next(); // weight
path.points.push({ x, y });
}
} else {
// Unknown edge type: advance one group to avoid stalling.
cur = scanner.next();
}
return cur;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* 2D file-format detection for the 2D+3D merge dispatch.
*
* 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser.
* Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer.
* Kept dependency-free so both the dropzone and the controller can import it
* without pulling in either viewer.
*/
export const DWG_EXT = /\.dwg$/i;
export const DXF_EXT = /\.dxf$/i;
/** True if the name is a binary DWG (parsed by the WASM parser). */
export function isDwg(name: string): boolean {
return DWG_EXT.test(name);
}
/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */
export function isDxf(name: string): boolean {
return DXF_EXT.test(name);
}
/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */
export function is2D(name: string): boolean {
return DWG_EXT.test(name) || DXF_EXT.test(name);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
*
* Older Korean DWGs store strings in code page 949. When those bytes are
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
* (already containing Hangul syllables) are left unchanged.
*
* Apply after parse_dwg, before Viewer2D.load().
*/
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
/** Labels tried in order; first supported decoder wins. */
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
let koreanDecoder: TextDecoder | null | undefined;
function getKoreanDecoder(): TextDecoder | null {
if (koreanDecoder !== undefined) return koreanDecoder;
for (const label of KOREAN_LABELS) {
try {
koreanDecoder = new TextDecoder(label);
return koreanDecoder;
} catch {
/* label not available in this runtime */
}
}
koreanDecoder = null;
return null;
}
/**
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
* form; otherwise return `s` unchanged.
*/
export function fixCp949Mojibake(s: string): string {
if (!s) return s;
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
let hasHigh = false;
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code > 255) return s; // true Unicode beyond Latin-1
if (code > 127) hasHigh = true;
bytes[i] = code;
}
if (!hasHigh) return s; // pure ASCII
const decoder = getKoreanDecoder();
if (!decoder) return s;
let fixed: string;
try {
fixed = decoder.decode(bytes);
} catch {
return s;
}
// Accept only when we gain Hangul and the decode is clean.
if (
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
!fixed.includes('\uFFFD')
) {
return fixed;
}
return s;
}
/**
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
* in place. Returns the same object for chaining.
*/
export function fixDwgKoreanText<T>(root: T): T {
if (root == null || typeof root !== 'object') return root;
const seen = new WeakSet<object>();
const walk = (value: unknown): void => {
if (value == null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
else walk(item);
}
return;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key === 'imageDataUrl') continue;
const item = obj[key];
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
else walk(item);
}
};
walk(root);
return root;
}
+3
View File
@@ -0,0 +1,3 @@
// Auto-generated standard LIN file definitions for Icad.lin and KOSDIC.lin
export const ICAD_LIN = "; IntelliCAD Linetypes\r\n;\r\n; icad.lin\r\n; Release 1.0 \r\n; \r\n; .............................................................................\r\n;\r\n; Copyright (c) 1998 by Visio Corporation. All rights reserved.\r\n;\r\n; The Software is subject to the license agreement that accompanies \r\n; or is included with the Software, which specifies the permitted \r\n; and prohibited uses of the Software. Any unauthorized duplication \r\n; or use of Visio Corporation Software, in whole or in part, in print, \r\n; or in any other storage and retrieval system is prohibited.\r\n; \r\n; To the maximum extent permitted by applicable law, Visio Corporation\r\n; and its suppliers disclaim any and all warranties and conditions,\r\n; either express or implied, including, without limitation, implied\r\n; warranties of merchantability, fitness for a particular purpose,\r\n; title, and non-infringement, and those arising out of usage of trade\r\n; or course of dealing, concerning these materials. These materials\r\n; are provided \"as is\" without warranty of any kind.\r\n; .............................................................................\r\n\r\n\r\n\r\n*BORDER,__ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . \r\nA,0.50,-0.250,0.50,-0.250,0,-0.250\r\n*BORDER2,__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__ \r\nA,0.25,-0.125,0.25,-0.125,0,-0.125\r\n*BORDERX2,____ ____ . ____ ____ . ____ ____ . ____ ____ . \r\nA,1.0,-0.5,1.0,-0.5,0,-0.5\r\n\r\n*CENTER,____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _\r\nA,1.250,-0.25,0.25,-0.25\r\n*CENTER2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.1250,0.1250,-0.125\r\n*CENTERX2,________ __ ________ __ ________ __ ________ __ ____\r\nA,2.5,-0.50,0.50,-0.50\r\n\r\n*DASHDOT,__ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . _\r\nA,0.50,-0.250,0,-0.250\r\n*DASHDOT2,_._._._._._._._._._._._._._._._._._._._._._._._._._._._._._.\r\nA,0.25,-0.125,0,-0.125\r\n*DASHDOTX2,____ . ____ . ____ . ____ . ____ . ____ . ____ \r\nA,1.0,-0.5,0,-0.5\r\n\r\n*DASHED,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.50,-0.250\r\n*DASHED2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.250,-0.1250\r\n*DASHEDX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,1.0,-0.5\r\n\r\n*DIVIDE,____ . . ____ . . ____ . . ____ . . ____ . . ____ . . ____ . .\r\nA,0.50,-0.25,0,-0.25,0,-0.25\r\n*DIVIDE2,__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..\r\nA,0.25,-0.125,0,-0.125,0,-0.125\r\n*DIVIDEX2,________ . . ________ . . ________ . . ________ . .\r\nA,1.0,-0.5,0,-0.5,0,-0.50\r\n\r\n*DOT,. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-0.25\r\n*DOT2,................................................................\r\nA,0,-0.125\r\n*DOTX2,. . . . . . . . . . . . . . . . . . . . . \r\nA,0,-0.5\r\n\r\n*HIDDEN,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ \r\nA,0.250,-0.1250\r\n*HIDDEN2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.06250\r\n*HIDDENX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.5,-0.25\r\n\r\n*PHANTOM,______ __ __ ______ __ __ ______ __ __ ______ __ __\r\nA,1.25,-0.25,0.250,-0.25,0.25,-0.25\r\n*PHANTOM2,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.625,-0.125,0.1250,-0.125,0.125,-0.1250\r\n*PHANTOMX2,____________ ____ ____ ____________ ____ ____ \r\nA,2.5,-0.5,0.5,-0.5,0.50,-0.5\r\n\r\n*PHANTOM4,___ _ _ ___ _ _ (1/2 of Phantom2)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.3125,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n\r\n*PHANTOM8,___ _ _ ___ _ _ (1/2 of Phantom4)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.15625,-0.03125,0.03125,-0.03125,0.03125,-0.03125\r\n\r\n*BORDER4,__.__.__.__.__.__._ (1/2 of Border2) _.__.__.__.__.__.__.__.__ \r\nA,0.125,-0.0625,0.125,-0.0625,0,-0.0625\r\n\r\n*CENTER4,___ _ ___ _ ___ _ __ (1/2 of Center2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.0625,0.0625,-0.0625\r\n*CENTER8,___ _ ___ _ ___ _ __ (1/2 of Center4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.03125,0.03125,-0.03125\r\n\r\n*CENTERF,_____ _ _____ (Finer short dashes than Center) _____\r\nA,1.25,-0.125,0.125,-0.125\r\n*CENTERF2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.0625,0.0625,-0.0625\r\n;; Auto-Arch additions for finer linetypes.\r\n*CENTERF4,___ _ ___ _ ___ _ __ (1/2 of CenterF2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.03125,0.03125,-0.03125\r\n*CENTERF8,___ _ ___ _ ___ _ __ (1/2 of CenterF4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.0015625,0.0015625,-0.0015625\r\n\r\n*DASHED4,_ _ _ _ _ _ _ _ (1/2 of Dashed2)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.0625\r\n*DASHED8,_ _ _ _ _ _ _ _ (1/2 of Dashed4)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n\r\n*DEMOx2,____ ____ ____ ____ Line truncated
export const KOSDIC_LIN = ";; ------------------------------------�� �� �� ��------------------------------------\r\n;; �� �� ���� ���� ����(KOSDIC.lin)�� �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ���� �� ��Ͽ� ���ǵ� �� ������\r\n;; AutoCAD ����ڿ��� �����ϱ� ���� ���۵Ǿ����ϴ�.\r\n;; \r\n;; ǥ�ع��� : �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ�� V1.1(2006.12)\r\n;; ������� : �Ǽ�CALS/EC ������ �ѱ��Ǽ����������\r\n;;\r\n;; �����Ģ : 1/1 ��ô�� ����ũ�� 2.0mm�� ��� ������ ũ�⸦ ������\r\n;; LIB ��Ģ : AutoCAD�� ANSI����(���� ��������� ����)�� ����Ű ���� 1/10�� ������\r\n;; (��) ���� �� ���� ������ 2mm�̸� 0.2unit�� ����\r\n;; ����Ģ : �ǹ����鿡 ����� ��� �������� ��Ģ�� ���յǷ��� ��������ô�� 10�踦 ������\r\n;; (��) LTSCALE=10 (���� 8~10��), ������ô�� 1/5 �̸� LTSCALE=5x10��=50\r\n;;\r\n;; ISO���� �԰� 15��\r\n// ���� AutoCAD ISO�� 25.4����� ������ �� ����\r\n*A-ISONS01,ISO �����԰� 01 Continuous ________________________________\r\nA,1,1\r\n*A-ISONS02,ISO �����԰� 02 dash __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.47244,-.118111\r\n*A-ISONS03,ISO �����԰� 03 dash space __ __ __ __ __ __\r\nA,.47244,-.7086614\r\n*A-ISONS04,ISO �����԰� 04 long-dash dot ____ . ____ . ____ . ____ . _\r\nA,.94488,-.11811,0,-.11811\r\n*A-ISONS05,ISO �����԰� 05 long-dash double-dot ____ .. ____ .. ____ . \r\nA,.94488,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS06,ISO �����԰� 06 long-dash triple-dot ____ ... ____ ... ____\r\nA,.94488,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS07,ISO �����԰� 07 dot . . . . . . . . . . . . . . . . . . . . \r\nA,0,-.11811\r\n*A-ISONS08,ISO �����԰� 08 long-dash short-dash ____ __ ____ __ ____ _\r\nA,.94488,-.11811,.94488,-.11811\r\n*A-ISONS09,ISO �����԰� 09 long-dash double-short-dash ____ __ __ ____\r\nA,.94488,-.11811,.94488,-.11811,.94488,-.11811\r\n*A-ISONS10,ISO �����԰� 10 dash dot __ . __ . __ . __ . __ . __ . __ . \r\nA,.47244,-.11811,0,-.11811\r\n*A-ISONS11,ISO �����԰� 11 double-dash dot __ __ . __ __ . __ __ . __ _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811\r\n*A-ISONS12,ISO �����԰� 12 dash double-dot __ . . __ . . __ . . __ . . \r\nA,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS13,ISO �����԰� 13 double-dash double-dot __ __ . . __ __ . . _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS14,ISO �����԰� 14 dash triple-dot __ . . . __ . . . __ . . . _\r\nA,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS15,ISO �����԰� 15 double-dash triple-dot __ __ . . . __ __ . .\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n\r\n;; ���CAD ����\r\n*A-BORDR01,�ܰ���(�Ϲ�) Border __ __ . __ __ . __ __ . __ __ . __ __ .\r\nA,.5,-.25,.5,-.25,0,-.25\r\n*A-BORDR01H,�ܰ���(�ݹ��) Border (.5x) __.__.__.__.__.__.__.__.__.__.__.\r\nA,.25,-.125,.25,-.125,0,-.125\r\n*A-BORDR01X,�ܰ���(���) Border (2x) ____ ____ . ____ ____ . ___\r\nA,1.0,-.5,1.0,-.5,0,-.5\r\n*A-CENTR01,�߽ɼ�(�Ϲ�) Center ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,1.25,-.25,.25,-.25\r\n*A-CENTR01H,�߽ɼ�(�ݹ��) Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,.75,-.125,.125,-.125\r\n*A-CENTR01X,�߽ɼ�(���) Center (2x) ________ __ ________ __ _____\r\nA,2.5,-.5,.5,-.5\r\n*A-DASDT01,�Ĵ�����(�Ϲ�) Dash dot __ . __ . __ . __ . __ . __ . __ . __\r\nA,.5,-.25,0,-.25\r\n*A-DASDT01H,�Ĵ�����(�ݹ��) Dash dot (.5x) _._._._._._._._._._._._._._._.\r\nA,.25,-.125,0,-.125\r\n*A-DASDT01X,�Ĵ�����(���) Dash dot (2x) ____ . ____ . ____ . ___\r\nA,1.0,-.5,0,-.5\r\n*A-DASHD01,�ļ�(�Ϲ�) Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _\r\nA,.5,-.25\r\n*A-DASHD01H,�ļ�(�ݹ��) Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.25,-.125\r\n*A-DASHD01X,�ļ�(���) Dashed (2x) ____ ____ ____ ____ ____ ___\r\nA,1.0,-.5\r\n*A-DIVID01,���Ҽ�(�Ϲ�) Divide ____ . . ____ . . ____ . . ____ . . ____\r\nA,.5,-.25,0,-.25,0,-.25\r\n*A-DIVID01H,���Ҽ�(�ݹ��) Divide (.5x) __..__..__..__..__..__..__..__.._\r\nA,.25,-.125,0,-.125,0,-.125\r\n*A-DIVID01X,��� Line truncated
+390
View File
@@ -0,0 +1,390 @@
/**
* LIN File Parser & Complex Linetype Registry
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
*/
import { ICAD_LIN, KOSDIC_LIN } from './linData';
export interface LinElement {
type: 'dash' | 'gap' | 'shape' | 'text';
val?: number; // dash length (+) or gap length (-)
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
text?: string; // e.g. "L2", "GAS", "HW"
style?: string; // e.g. Standard
scale: number; // s=... (default 1.0)
rotation: number; // r=... (angle in degrees)
isAbsoluteAngle: boolean;// a=...
isUprightAngle: boolean; // u=...
xOffset: number; // x=...
yOffset: number; // y=...
}
export interface LinPattern {
name: string;
description: string;
elements: LinElement[];
patternLength: number; // total length sum of dashes + abs(gaps)
}
function parseBracketElement(inner: string): LinElement {
const parts: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (c === '"') inQuote = !inQuote;
if (c === ',' && !inQuote) {
parts.push(cur.trim());
cur = '';
} else {
cur += c;
}
}
if (cur.trim()) parts.push(cur.trim());
let type: 'shape' | 'text' = 'shape';
let shapeName: string | undefined;
let shxFile: string | undefined;
let text: string | undefined;
let style: string | undefined;
let scale = 1.0;
let rotation = 0;
let isAbsoluteAngle = false;
let isUprightAngle = false;
let xOffset = 0;
let yOffset = 0;
if (parts.length > 0) {
const first = parts[0];
if (first.startsWith('"') && first.endsWith('"')) {
type = 'text';
text = first.slice(1, -1);
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
} else {
type = 'shape';
shapeName = first;
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
}
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const eqIdx = part.indexOf('=');
if (eqIdx > 0) {
const key = part.substring(0, eqIdx).trim().toLowerCase();
const valStr = part.substring(eqIdx + 1).trim();
const val = parseFloat(valStr);
if (!isNaN(val)) {
if (key === 's') scale = val;
else if (key === 'r') { rotation = val; }
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
else if (key === 'u') { rotation = val; isUprightAngle = true; }
else if (key === 'x') xOffset = val;
else if (key === 'y') yOffset = val;
}
}
}
}
return {
type,
shapeName,
shxFile,
text,
style,
scale,
rotation,
isAbsoluteAngle,
isUprightAngle,
xOffset,
yOffset,
};
}
function tokenizeLinPattern(patternStr: string): string[] {
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
const tokens: string[] = [];
let current = '';
let inBracket = false;
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') inQuote = !inQuote;
if (ch === '[' && !inQuote) inBracket = true;
if (ch === ']' && !inQuote) inBracket = false;
if (ch === ',' && !inBracket && !inQuote) {
if (current.trim()) tokens.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) tokens.push(current.trim());
return tokens;
}
export function parseLinContent(content: string): Map<string, LinPattern> {
const result = new Map<string, LinPattern>();
const lines = content.split(/\r?\n/);
let currentName: string | null = null;
let currentDesc = '';
for (let line of lines) {
line = line.trim();
if (!line || line.startsWith(';')) continue;
if (line.startsWith('*')) {
const commaIdx = line.indexOf(',');
if (commaIdx > 0) {
currentName = line.substring(1, commaIdx).trim();
currentDesc = line.substring(commaIdx + 1).trim();
} else {
currentName = line.substring(1).trim();
currentDesc = '';
}
} else if (currentName && line.toUpperCase().startsWith('A,')) {
const tokens = tokenizeLinPattern(line);
const elements: LinElement[] = [];
let totalLength = 0;
for (const tok of tokens) {
if (tok.startsWith('[') && tok.endsWith(']')) {
const elem = parseBracketElement(tok.slice(1, -1));
elements.push(elem);
} else {
const val = parseFloat(tok);
if (!isNaN(val)) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
} else {
// Dot (val === 0)
elements.push({
type: 'dash',
val: 0.05,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += 0.05;
}
}
}
}
if (elements.length > 0) {
result.set(currentName.toUpperCase(), {
name: currentName,
description: currentDesc,
elements,
patternLength: Math.max(totalLength, 0.1),
});
}
currentName = null;
}
}
return result;
}
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
const globalRegistry = new Map<string, LinPattern>();
export function registerLinContent(content: string) {
const parsed = parseLinContent(content);
for (const [key, pattern] of parsed.entries()) {
globalRegistry.set(key, pattern);
}
}
// Warm up registry with bundled LIN files
registerLinContent(ICAD_LIN);
registerLinContent(KOSDIC_LIN);
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
'H-DICHL02': 'L2-2/0',
'H-DICHL02R': 'L2-2',
'H-DICHL01': 'L2-3/0',
'H-DICHL01R': 'L2-3',
'H-DICHL06': 'L5-1',
'H-DICHL06R': 'L5-1',
'H-DICHL05': 'L5',
'H-DICHL05R': 'L5',
'H-DICHU05': 'U5',
'H-DICHU05R': 'U5',
'H-DICHB01': 'B1',
'H-DICHB01R': 'B1',
'H-DICHM03': 'M3',
'H-DICHM03R': 'M3',
'H-DICHV01': 'V1',
'H-DICHV01R': 'V1',
'H-DICHO01': 'O1',
'H-DICHO01R': 'O1',
'H-DICHL03': 'L3',
'H-DICHL03R': 'L3',
'H-DICHL04': 'L4',
'H-DICHL04R': 'L4',
'H-DICHL08': 'L8',
'H-DICHL08R': 'L8',
};
const NO_SHAPE_LINETYPES = new Set([
'H-DICHL05',
'H-DICHL05R',
'H-DICHU05',
'H-DICHU05R',
'H-DICHB01',
'H-DICHB01R',
'H-DICHM03',
'H-DICHM03R',
'H-DICHV01',
'H-DICHV01R',
'H-DICHO01',
'H-DICHO01R',
]);
export function getLinPattern(name: string): LinPattern | undefined {
if (!name) return undefined;
const key = name.toUpperCase().trim();
const pattern = globalRegistry.get(key);
if (!pattern) return undefined;
let elements = pattern.elements;
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
if (NO_SHAPE_LINETYPES.has(key)) {
elements = elements.filter((elem) => elem.type !== 'shape');
}
const overrideText = CIVIL_LT_TEXT_MAP[key];
if (overrideText) {
elements = elements.map((elem) =>
elem.type === 'text' ? { ...elem, text: overrideText } : elem
);
}
return {
...pattern,
elements,
};
}
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
return globalRegistry;
}
export function mergeDwgLinetypePattern(
ltName: string,
dwgPattern: number[],
description?: string
): LinPattern | undefined {
if (!ltName) return undefined;
const base = getLinPattern(ltName);
if (!dwgPattern || !dwgPattern.length) return base;
if (!base) {
const elements: LinElement[] = [];
let totalLength = 0;
for (const val of dwgPattern) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
}
}
return {
name: ltName,
description: description || '',
elements,
patternLength: Math.max(totalLength, 0.1),
};
}
// Merge DWG numeric dash/gap values into base pattern elements
const newElements: LinElement[] = [];
let dwgValIdx = 0;
let totalLength = 0;
for (const elem of base.elements) {
if (elem.type === 'dash') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0
? dwgPattern[dwgValIdx]
: elem.val || 0.05;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) {
dwgValIdx++;
}
newElements.push({ ...elem, val: dwgVal });
totalLength += dwgVal;
} else if (elem.type === 'gap') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0
? dwgPattern[dwgValIdx]
: elem.val;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) {
dwgValIdx++;
}
const absVal = Math.abs(dwgVal || 0.1);
newElements.push({ ...elem, val: -absVal });
totalLength += absVal;
} else {
newElements.push({ ...elem });
}
}
return {
...base,
description: description || base.description,
elements: newElements,
patternLength: Math.max(totalLength, 0.1),
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Parse DXF text/buffer into the same CadParseResult shape as DWG.
*/
import DxfParser from 'dxf-parser';
import HatchHandler from './dxfHatchHandler.js';
import { dxfToParseResult } from './dxfAdapter.js';
import { decodeDxf } from './decodeDxf';
import type { CadParseResult } from './dwgParser';
/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */
export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult {
return parseDxfText(decodeDxf(buf));
}
/** Parse DXF text already decoded as a string. */
export function parseDxfText(text: string): CadParseResult {
const parser = new DxfParser();
// dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'.
parser.registerEntityHandler(
HatchHandler as unknown as Parameters<DxfParser['registerEntityHandler']>[0],
);
const dxf = parser.parseSync(text);
return dxfToParseResult(dxf);
}
+20
View File
@@ -0,0 +1,20 @@
declare module 'dxf-parser' {
export default class DxfParser {
parseSync(text: string): unknown;
registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void;
}
}
declare module 'opentype.js' {
export function parse(buffer: ArrayBuffer): unknown;
}
declare module '*.wasm?url' {
const url: string;
export default url;
}
declare module '*.wasm' {
const url: string;
export default url;
}
+403
View File
@@ -0,0 +1,403 @@
/**
* slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's
* Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines",
* JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent
* dedicated to the public domain, attribution retained here).
*
* Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier
* outlines are uploaded once per UNIQUE GLYPH into a float data texture, and
* the fragment shader computes an antialiased winding number per pixel by
* intersecting horizontal + vertical rays with the glyph's curves (curves
* pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays
* razor sharp at any zoom and memory scales with glyph count, not text count.
*
* Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines
* (quadratic only), Latin + full Hangul.
*/
import * as THREE from 'three';
import { parse as parseFont } from 'opentype.js';
const TEX_W = 1024; // data texture width in texels (RGBA32F)
const BANDS = 8; // bands per axis per glyph
const PAD = 0.08; // em padding around glyph quads (AA spill room)
interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number }
interface GlyphRec {
base: number; // texel index of the glyph header (-1 when no ink, e.g. space)
advance: number; // em units
}
/** Default TrueType font URL (host must serve Hangul-capable TTF). */
let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf';
/** Override the default font path before creating CadViewer / Viewer2D. */
export function setDefaultFontUrl(url: string): void {
defaultFontUrl = url;
// reset shared engine so the next shared() uses the new URL
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(SlugTextEngine as any)._shared = null;
}
/** One shared font engine (font fetch + parse happens once). */
export class SlugTextEngine {
private static _shared: Promise<SlugTextEngine> | null = null;
static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
if (!this._shared) {
this._shared = (async () => {
const buf = await (await fetch(url)).arrayBuffer();
return new SlugTextEngine(parseFont(buf));
})();
// allow a retry on transient fetch failure instead of caching the rejection
this._shared.catch(() => { (SlugTextEngine as any)._shared = null; });
}
return this._shared;
}
readonly capHeightEm: number;
readonly ascentEm: number;
readonly descentEm: number;
private font: any;
private upem: number;
private glyphs = new Map<number, GlyphRec>();
private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed
private used = 0; // texels consumed
private _texture: THREE.DataTexture | null = null;
private _texelsUploaded = 0;
private constructor(font: any) {
this.font = font;
this.upem = font.unitsPerEm;
this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem;
this.ascentEm = font.ascender / this.upem;
this.descentEm = font.descender / this.upem; // negative
}
/** Layout width of one line, in em units. */
measureEm(line: string): number {
let w = 0;
for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance;
return w;
}
ensureGlyph(cp: number): GlyphRec {
let rec = this.glyphs.get(cp);
if (rec) return rec;
const glyph = this.font.charToGlyph(String.fromCodePoint(cp));
const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem;
// getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y.
const path = glyph.getPath(0, 0, 1);
const curves: Curve[] = [];
let sx = 0, sy = 0, px = 0, py = 0;
for (const c of path.commands) {
switch (c.type) {
case 'M': px = sx = c.x; py = sy = -c.y; break;
case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break;
case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break;
case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively
const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2;
curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y });
px = c.x; py = -c.y; break;
}
case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break;
}
}
if (!curves.length) {
rec = { base: -1, advance };
this.glyphs.set(cp, rec);
return rec;
}
// Conservative bbox from all points (control points included).
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const q of curves) {
x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1);
y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1);
}
const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6);
// Band membership (conservative, via control-point extents).
const hBands: number[][] = Array.from({ length: BANDS }, () => []);
const vBands: number[][] = Array.from({ length: BANDS }, () => []);
curves.forEach((q, i) => {
const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1);
const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1);
const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h);
for (let b = hb0; b <= hb1; b++) hBands[b].push(i);
const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w);
for (let b = vb0; b <= vb1; b++) vBands[b].push(i);
});
// ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ──
const listTexels = (l: number[]) => Math.ceil(l.length / 4);
const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0)
+ vBands.reduce((s, l) => s + listTexels(l), 0);
const total = 2 + BANDS * 2 + idxTexels + curves.length * 2;
const base = this.alloc(total);
const d = this.data;
const put = (t: number, a: number, b: number, c: number, e: number) => {
d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e;
};
const curveBase = base + 2 + BANDS * 2 + idxTexels;
put(base, x0, y0, x1, y1);
put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase
let listCursor = base + 2 + BANDS * 2;
const writeBands = (bands: number[][], texel: number) => {
for (let b = 0; b < BANDS; b++) {
put(texel + b, listCursor, bands[b].length, 0, 0);
const l = bands[b];
for (let j = 0; j < l.length; j += 4) {
put(listCursor++,
curveBase + l[j] * 2,
j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0,
j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0,
j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0);
}
}
};
writeBands(hBands, base + 2);
writeBands(vBands, base + 2 + BANDS);
curves.forEach((q, i) => {
put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy);
put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0);
});
rec = { base, advance };
this.glyphs.set(cp, rec);
return rec;
}
bboxOf(rec: GlyphRec): [number, number, number, number] {
const t = rec.base * 4;
return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]];
}
private alloc(texels: number): number {
const need = (this.used + texels) * 4;
if (need > this.data.length) {
let cap = this.data.length;
while (cap < need) cap *= 2;
const next = new Float32Array(cap);
next.set(this.data);
this.data = next;
}
const at = this.used;
this.used += texels;
return at;
}
/** Data texture with all glyph data uploaded (recreated when it grew). */
texture(): THREE.DataTexture {
const rows = Math.max(1, Math.ceil(this.used / TEX_W));
if (!this._texture || this._texelsUploaded < this.used) {
this._texture?.dispose();
const buf = new Float32Array(TEX_W * rows * 4);
buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4)));
const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = THREE.NearestFilter;
tex.magFilter = THREE.NearestFilter;
tex.generateMipmaps = false;
tex.needsUpdate = true;
this._texture = tex;
this._texelsUploaded = this.used;
}
return this._texture;
}
}
function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve {
return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 };
}
function clampBand(f: number): number {
return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS)));
}
// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ──
const VERT = /* glsl */`
attribute vec2 emuv;
attribute float gbase;
attribute vec3 tcolor;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
void main() {
vEm = emuv;
vBase = int(gbase);
vColor = tcolor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */`
precision highp float;
uniform sampler2D dataTex;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
layout(location = 0) out vec4 fragOut;
vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); }
// Lengyel's banded winding-number coverage for one ray along +x from p.
// Curves are fetched via the band's index list; 'swap' mirrors x/y for the
// vertical (+y) ray, whose winding sign flips (handedness reversal).
float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) {
vec4 band = T(bandTexel);
int count = int(band.y);
float cov = 0.0;
for (int j = 0; j < 256; j++) {
if (j >= count) break;
vec4 idx4 = T(int(band.x) + (j >> 2));
int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w);
vec4 A = T(cb);
vec2 e = T(cb + 1).xy;
// Swap the CURVE points before subtracting: p is already in swapped
// coordinates for the vertical ray ((A.xy - p).yx would be wrong).
vec2 p1 = (swap ? A.yx : A.xy) - p;
vec2 p2 = (swap ? A.wz : A.zw) - p;
vec2 p3 = (swap ? e.yx : e.xy) - p;
uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u;
if (code != 0u) {
vec2 a = p1 - p2 * 2.0 + p3;
vec2 b = p1 - p2;
float t1, t2;
if (abs(a.y) < 1e-6) {
float t = p1.y / (2.0 * b.y);
t1 = t; t2 = t;
} else {
float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0));
t1 = (b.y - d) / a.y;
t2 = (b.y + d) / a.y;
}
if ((code & 1u) != 0u) {
float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
cov += clamp(x * ppem + 0.5, 0.0, 1.0);
}
if (code > 1u) {
float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
cov -= clamp(x * ppem + 0.5, 0.0, 1.0);
}
}
}
return cov;
}
void main() {
vec4 bbox = T(vBase);
vec4 bases = T(vBase + 1);
vec2 fw = fwidth(vEm);
vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6));
int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1});
int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1});
float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false);
float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true);
float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0);
if (alpha < 0.004) discard;
fragOut = vec4(vColor, alpha);
}
`;
/** Accumulates positioned strings and builds one merged mesh (single draw call). */
export class SlugTextBatch {
private pos: number[] = [];
private emuv: number[] = [];
private gbase: number[] = [];
private color: number[] = [];
private index: number[] = [];
constructor(private engine: SlugTextEngine) {}
/**
* alignH: 0/3/5=left datum, 1/4=center, 2=right.
* alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching
* the previous canvas-sprite behaviour). Multi-line via '\n'.
*/
add(text: string, pos: { x: number; y: number }, height: number, rotation: number,
colorInt: number, alignH: number, alignV: number): void {
const eng = this.engine;
const lines = String(text).split('\n');
// Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is
// capHeightEm em, not 1em. Divide by it so the rendered cap height equals
// the DWG `height` field, matching the canvas-sprite fallback's ascent fit.
const scale = height / eng.capHeightEm;
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
const blockH = (lines.length - 1) * lineStep + height;
// DXF group 72 = 4 ("Middle") centers both axes on the alignment point;
// group 73 (vertical) is ignored. alignH already centers horizontally (below);
// force vertical to middle here too, else vA=0 text (block-internal plain
// TEXT, e.g. titleblock labels) renders half a line too high.
if (alignH === 4) alignV = 2;
let baseline0: number;
if (alignV === 3) baseline0 = -height; // top datum
else if (alignV === 2) baseline0 = blockH / 2 - height; // middle
else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom
const cos = Math.cos(rotation), sin = Math.sin(rotation);
const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255;
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (!line) continue;
const wEm = eng.measureEm(line);
const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0;
const oy = baseline0 - li * lineStep;
let pen = 0;
for (const ch of line) {
const rec = eng.ensureGlyph(ch.codePointAt(0)!);
if (rec.base >= 0) {
const [x0, y0, x1, y1] = this.engine.bboxOf(rec);
const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD;
const v = this.pos.length / 3;
for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) {
const lx = ox + (pen + ex) * scale;
const ly = oy + ey * scale;
this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1);
this.emuv.push(ex, ey);
this.gbase.push(rec.base);
this.color.push(r, g, b);
}
this.index.push(v, v + 1, v + 2, v, v + 2, v + 3);
}
pen += rec.advance;
}
}
}
/** Build the merged mesh; null when nothing was added. */
build(): THREE.Mesh | null {
if (!this.index.length) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2));
geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1));
geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3));
geo.setIndex(this.index);
const mat = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: { dataTex: { value: this.engine.texture() } },
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = true;
return mesh;
}
}
+367
View File
@@ -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;
}
}
+4
View File
@@ -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";
+49
View File
@@ -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
}
+80
View File
@@ -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
}
+25
View File
@@ -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)}`
}
+34
View File
@@ -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)
}
+16
View File
@@ -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>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -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"]
}
Vendored Submodule
+1
Submodule vendor/HMDesign added at d18e3c3380.
+22
View File
@@ -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"],
})