Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ef6efdd8 | ||
|
|
96c6b780ce | ||
|
|
c7a782f19c | ||
|
|
b114581c07 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
*.wasm binary
|
||||
*.dwg binary
|
||||
*.ttf binary
|
||||
*.dxf -text
|
||||
*.patch -text
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
dist2/
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
[submodule "vendor/HMDesign"]
|
||||
path = vendor/HMDesign
|
||||
[submodule "HMDesign"]
|
||||
path = HMDesign
|
||||
url = https://gitea.hmac.kr/b25027/HMDesign.git
|
||||
branch = main
|
||||
File renamed without changes.
@@ -1,63 +0,0 @@
|
||||
# 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 정보 버튼과 상태 표시줄의 "오픈소스 고지"에서 열립니다.
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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,258 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HMDesign 적용 작업 기록 — 2026-10-12</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #1b1f24;
|
||||
--muted: #6b7280;
|
||||
--border: #e2e4e9;
|
||||
--card: #f7f8fa;
|
||||
--accent: #2563eb;
|
||||
--ok: #15803d;
|
||||
--ok-bg: #ecfdf3;
|
||||
--warn: #92400e;
|
||||
--warn-bg: #fef6e7;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--border: #30363d;
|
||||
--card: #161b22;
|
||||
--accent: #60a5fa;
|
||||
--ok: #4ade80;
|
||||
--ok-bg: #0f2a1a;
|
||||
--warn: #fbbf24;
|
||||
--warn-bg: #2a2110;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
line-height: 1.6;
|
||||
}
|
||||
main {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px 80px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.6rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
header p {
|
||||
color: var(--muted);
|
||||
margin: 0 0 32px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
margin: 40px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
p, li { font-size: 0.92rem; }
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
pre {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
pre code { background: none; border: none; padding: 0; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.meta-list { list-style: none; padding: 0; margin: 0; }
|
||||
.meta-list li { margin-bottom: 6px; }
|
||||
.meta-list b { color: var(--muted); font-weight: 500; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
ul { padding-left: 1.3em; }
|
||||
.timeline { border-left: 2px solid var(--border); padding-left: 18px; margin: 12px 0; }
|
||||
.timeline .entry { margin-bottom: 14px; }
|
||||
.timeline time { display: block; font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>HMDesign 적용 작업 기록</h1>
|
||||
<p>dwg-dxf-viewer-hm · 2026-10-12 · Gitea 이슈 <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/2">#2 hmdesign 적용</a> / <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/1">#1 디자인팀 디자인 시스템 적용</a></p>
|
||||
</header>
|
||||
|
||||
<h2>개요</h2>
|
||||
<p>
|
||||
기존 vanilla TS+Vite+Three.js DWG/DXF 뷰어(<code>dwg-dxf-viewer-sample</code>)를 건드리지 않고,
|
||||
이 저장소(<code>dwg-dxf-viewer-hm</code>)에 <b>React 19 + HMDesign 디자인 시스템</b> 기반으로
|
||||
UI/UX를 새로 구성했다. 뷰어 렌더링 엔진(<code>Viewer2D.js</code> + 파서들)은 로직을
|
||||
그대로 포팅하고, 그 위의 모든 UI(툴바·레이어/속성/옵션 패널·좌표 표시·라이선스 팝업·로딩바)는
|
||||
HMDesign 컴포넌트로 새로 디자인했다. 기존 UI의 색/아이콘/레이아웃은 가져오지 않았고,
|
||||
<b>기능만</b> 1:1 유지하는 것을 목표로 했다.
|
||||
</p>
|
||||
|
||||
<h2>작업 흐름</h2>
|
||||
<div class="timeline">
|
||||
<div class="entry">
|
||||
<time>16:59</time>
|
||||
<div><b>Gitea 퍼블리시</b> — <code>dwg-dxf-viewer-hm</code>을 public repo로 생성, 로컬 git init 후 최초 push.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>17:01</time>
|
||||
<div><b>이슈 #1 생성</b> — "디자인팀 디자인 시스템 적용": 앞으로 구현되는 UI/UX 작업을 커밋으로 연결.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>17:4x</time>
|
||||
<div><b>포팅 계획 확정</b> — 현재 디렉토리 재사용 / HMDesign은 git submodule+npm workspace로 연결 / Measure 기능 비노출 / 로딩 progress bar는 HM 추천 방식으로 신규 연결, 3가지+1가지 질문에 대한 사용자 답변을 바탕으로 계획 수립 후 승인.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>17:42</time>
|
||||
<div><b>1차 구현 커밋</b> — submodule 추가, 워크스페이스 연결, <code>src/viewer2d/*</code> 포팅, React 컴포넌트 전체 신규 작성.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>17:46</time>
|
||||
<div><b>Guard 수정 커밋</b> — <code>ai-generation-guard.mjs</code> REUSE-004 지적(네이티브 <code><label></code>)을 HM <code>Label</code>로 교체.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>17:53</time>
|
||||
<div><b>버그 수정 커밋</b> — 샘플 로드 후 캔버스에 아무것도 안 보이는 문제 원인 확정 및 수정 (아래 "발견된 버그" 참고).</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<time>18:08</time>
|
||||
<div><b>이슈 #2 생성</b> — "hmdesign 적용", 이 리포트 작성 및 커밋.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>아키텍처: HMDesign 연결</h2>
|
||||
<p>
|
||||
<code>@hm-design/ui</code>는 npm 레지스트리에 배포되지 않고 HMDesign 저장소 자신의 npm workspace
|
||||
(<code>packages/*</code>) 안에서만 resolve된다. <code>exports</code>는 전부 <code>dist/*</code>를
|
||||
가리키므로 반드시 빌드가 먼저 끝나 있어야 한다.
|
||||
</p>
|
||||
<pre><code>git submodule add https://gitea.hmac.kr/b25027/HMDesign.git HMDesign
|
||||
git submodule update --init --recursive
|
||||
cd HMDesign && npm ci && npm run build:packages && cd .. # HMDesign 자체 devDependency로 dist/ 생성
|
||||
npm install # 루트 workspaces(["HMDesign/packages/ui"])가 symlink 연결
|
||||
npm run dev</code></pre>
|
||||
<p>
|
||||
HMDesign 내부 빌드는 HMDesign 자신의 <code>node_modules</code>(Storybook/Tailwind/TS7 등)로 수행하고,
|
||||
외부 앱(이 저장소)은 빌드된 결과물만 workspace symlink로 소비한다 — 두 트리의 의존성 버전이 섞이지 않는다.
|
||||
<code>@hm-design/center-graphic</code>은 라이선스 미확정으로 사용하지 않았다.
|
||||
</p>
|
||||
<p>
|
||||
Vite 설정에서 <code>optimizeDeps.entries: ['index.html']</code>을 명시해야 한다 — 지정하지 않으면
|
||||
Vite의 의존성 스캐너가 submodule(HMDesign) 안의 다른 예제용 <code>*.html</code>까지 크롤링하다
|
||||
해석할 수 없는 <code>@/...</code> alias에서 죽는다.
|
||||
</p>
|
||||
|
||||
<h2>구현 내용</h2>
|
||||
<h3>디렉토리 구조</h3>
|
||||
<pre><code>src/
|
||||
├── main.tsx, App.tsx, index.css
|
||||
├── viewer2d/ ← 기존 엔진 전체, 로직 불변, import 경로만 조정
|
||||
├── lib/
|
||||
│ ├── optionsStore.ts ← IndexedDB 영속, 그대로 포팅
|
||||
│ └── propertyFormat.ts← 속성 포맷터만 추출 포팅 (DOM 렌더는 React로 대체)
|
||||
├── components/ ← Toolbar / LayerPanel / PropertiesPanel / OptionsPanel /
|
||||
│ DraggablePanel / CoordReadout / LicenseNotice / LoadProgress
|
||||
└── hooks/
|
||||
├── useDwgViewer.ts ← Viewer2D 인스턴스 소유 + 파일 로드 파이프라인 + 옵션 동기화
|
||||
└── useDraggablePanel.ts ← panelUtils.ts 드래그/collapse 로직의 React 훅 포팅</code></pre>
|
||||
|
||||
<h3>UI → HMDesign 컴포넌트 매핑 (요약)</h3>
|
||||
<table>
|
||||
<tr><th>원본 UI</th><th>HMDesign 매핑</th></tr>
|
||||
<tr><td>Open file / 샘플 3버튼</td><td><code>Button</code> (+숨김 <code><input type=file></code>, HM에 대응 컴포넌트 없음)</td></tr>
|
||||
<tr><td>Model/Layout 탭</td><td><code>Tabs</code></td></tr>
|
||||
<tr><td>2.5D·테마 토글</td><td><code>Switch</code> + <code>Label</code></td></tr>
|
||||
<tr><td>레이어/옵션/속성 패널 열기</td><td><code>Toggle</code></td></tr>
|
||||
<tr><td>레이어 검색/전체켜기끄기</td><td><code>FilterBar</code> / <code>FilterBarSearch</code> / <code>FilterBarActions</code></td></tr>
|
||||
<tr><td>레이어 행(색상/개수/가시성)</td><td>커스텀 — color swatch(동적 color), <code>Badge</code>, lucide <code>Eye</code>/<code>EyeOff</code></td></tr>
|
||||
<tr><td>패널 자체(드래그/접기/리사이즈)</td><td><code>DraggablePanel</code> 커스텀(HM <code>Card</code> 기반 + 포인터 드래그 훅, resize는 네이티브 CSS)</td></tr>
|
||||
<tr><td>슬라이더+숫자입력 3종</td><td><code>Slider</code> + <code>Input</code></td></tr>
|
||||
<tr><td>라이선스/크레딧</td><td><code>Dialog</code> (THIRD-PARTY-NOTICES / MPL-2.0 / patch 링크 3종 유지)</td></tr>
|
||||
<tr><td>상단 로딩바</td><td><code>Progress</code> (원본은 미연결 죽은 코드였음 — HM 추천 방식으로 신규 실연결)</td></tr>
|
||||
</table>
|
||||
<p>Navigation(GNB/LNB/Dashboard)은 채용하지 않았다 — 멀티페이지 앱이 아닌 전체화면 캔버스형 단일 도구이기 때문.</p>
|
||||
|
||||
<h2>발견된 버그 및 수정</h2>
|
||||
<div class="card">
|
||||
<p><b>증상</b>: 샘플 DWG/DXF 3개 버튼 모두 "로드완료" 상태 텍스트는 뜨지만 캔버스에 아무것도 렌더되지 않음.</p>
|
||||
<p><b>원인 확정</b>: <code>Viewer2D</code>(포팅된 vanilla 엔진)는 dispose/teardown API가 없고 생성자에서
|
||||
<code>window</code>에 영구 리스너를 걸고 자체 <code>requestAnimationFrame</code> 루프를 시작한다.
|
||||
React <code><StrictMode></code>의 개발 모드 mount→unmount→remount 더블 호출이
|
||||
<code>Viewer2D</code>를 같은 container에 두 번 생성했고, 첫 인스턴스를 정리할 방법이 없어
|
||||
캔버스가 중첩/렌더가 꼬였다.</p>
|
||||
<p><b>수정 완료·미검증</b>: <code>main.tsx</code>에서 <code><StrictMode></code> 제거,
|
||||
<code>useDwgViewer</code> 마운트 effect에서 construction 전에 <code>container.replaceChildren()</code>으로
|
||||
방어적 클리어 추가. 코드 수정은 했으나 이 세션엔 브라우저 도구가 없어 실제 화면에서 재확인은 못 함 —
|
||||
사용자 브라우저에서 재검증 필요.</p>
|
||||
</div>
|
||||
|
||||
<h2>검증 상태</h2>
|
||||
<ul>
|
||||
<li><span class="badge ok">검증됨</span> npm workspace symlink 연결 (<code>@hm-design/ui</code> resolve 확인)</li>
|
||||
<li><span class="badge ok">검증됨</span> <code>tsc --noEmit</code> 타입체크 통과</li>
|
||||
<li><span class="badge ok">검증됨</span> Vite dev 서버에서 전체 모듈 HTTP 200 서빙 확인</li>
|
||||
<li><span class="badge ok">검증됨</span> <code>ai-generation-guard.mjs</code> FAIL 0건 (NOT VERIFIED 4건은 HM에 대응 컴포넌트 없는 동적 값 — 문서화된 예외)</li>
|
||||
<li><span class="badge warn">미검증</span> 실제 브라우저 렌더링 (WASM 파싱, Three.js 캔버스, 패널 드래그/접기, IndexedDB 옵션 영속, 390~1440px 반응형) — 이 세션에 브라우저 도구 없음, 컴파일 통과는 동작 검증이 아님</li>
|
||||
</ul>
|
||||
|
||||
<h2>다음 할 일</h2>
|
||||
<ul>
|
||||
<li>StrictMode 제거 수정 후 브라우저에서 샘플 3개 재확인</li>
|
||||
<li>390/768/1280/1440px 반응형 확인</li>
|
||||
<li>기존 프로젝트 기능 체크리스트와 1:1 대조 (파일 로드, pan/zoom/orbit, Fit+F, 2.5D, 그리드, 줌속도, 선가중치, Model/Layout 탭, 엔티티 선택→속성, 줌투셀렉션, 좌표 readout, 패널 영속, 한글 텍스트, 라이선스 링크, 신규 progress bar)</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
-2
@@ -3,8 +3,7 @@
|
||||
<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:," />
|
||||
<title>DWG/DXF Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+5802
-6485
File diff suppressed because it is too large.
Load diff
+13
-21
@@ -1,42 +1,34 @@
|
||||
{
|
||||
"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).",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"vendor/HMDesign/packages/ui"
|
||||
"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"
|
||||
"build": "tsc -b --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hm-design/ui": "*",
|
||||
"dxf-parser": "1.1.2",
|
||||
"lucide-react": "1.49.0",
|
||||
"opentype.js": "2.0.0",
|
||||
"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"
|
||||
"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",
|
||||
"@types/three": "^0.185.1",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0"
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,10 +1,9 @@
|
||||
# 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 "오픈소스 고지".
|
||||
This viewer bundles third-party code. The notices below apply to the source
|
||||
tree **and** to the built site (`dist2/`, deployed at
|
||||
`https://dwg-dxf-viewer-sample.pages.dev/`), because the build embeds the
|
||||
components listed here.
|
||||
|
||||
Served copies of the license texts live under
|
||||
[`public/licenses/`](./public/licenses/) → `/licenses/…` on the deployed site.
|
||||
@@ -68,11 +67,4 @@ MPL-covered files above keep their license — which they do.
|
||||
| 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) |
|
||||
| Viewer2D / sample glue | MIT |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
+118
-424
@@ -1,439 +1,133 @@
|
||||
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"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDwgViewer } from './hooks/useDwgViewer';
|
||||
import { Toolbar } from './components/Toolbar';
|
||||
import { LayerPanel } from './components/LayerPanel';
|
||||
import { PropertiesPanel } from './components/PropertiesPanel';
|
||||
import { OptionsPanel } from './components/OptionsPanel';
|
||||
import { CoordReadout } from './components/CoordReadout';
|
||||
import { LoadProgress } from './components/LoadProgress';
|
||||
|
||||
/** 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
|
||||
}
|
||||
export default function App() {
|
||||
const v = useDwgViewer();
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [propsOpen, setPropsOpen] = useState(false);
|
||||
|
||||
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) => {
|
||||
// selecting an entity opens the Properties panel and updates the status line,
|
||||
// mirroring the original app's `viewer.onSelect` wiring in main.ts
|
||||
useEffect(() => {
|
||||
const entity = v.selectedEntity as
|
||||
| { type?: string; typeName?: string; layer?: string; layerName?: string; handle?: { value?: unknown } | unknown }
|
||||
| null;
|
||||
if (!entity) {
|
||||
setStatus({ text: "선택 해제" })
|
||||
setSelected(null)
|
||||
return
|
||||
v.setStatusMessage('deselected');
|
||||
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 ?? "-"}` })
|
||||
}, [])
|
||||
setPropsOpen(true);
|
||||
const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase();
|
||||
const layer = entity.layer ?? entity.layerName ?? '0';
|
||||
const handle = (entity.handle as { value?: unknown })?.value ?? entity.handle ?? '-';
|
||||
v.setStatusMessage(`select ${type} · layer=${layer} · h=${handle}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [v.selectedEntity]);
|
||||
|
||||
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.
|
||||
// F key → fit (ignored while typing in an input)
|
||||
useEffect(() => {
|
||||
if (viewer) fillLayers()
|
||||
}, [viewer, dark, fillLayers])
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
if (e.key === 'f' || e.key === 'F') v.fit();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [v]);
|
||||
|
||||
// ── 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)
|
||||
// drag & drop
|
||||
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 onDragOver = (e: DragEvent) => e.preventDefault();
|
||||
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)
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) void v.loadFile(file);
|
||||
};
|
||||
window.addEventListener('dragover', onDragOver);
|
||||
window.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
window.removeEventListener("dragover", onOver)
|
||||
window.removeEventListener("dragenter", onEnter)
|
||||
window.removeEventListener("dragleave", onLeave)
|
||||
window.removeEventListener("drop", onDrop)
|
||||
}
|
||||
}, [])
|
||||
window.removeEventListener('dragover', onDragOver);
|
||||
window.removeEventListener('drop', onDrop);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── 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}개` })
|
||||
}
|
||||
// ?model= query param auto-load (once)
|
||||
useEffect(() => {
|
||||
const model = new URLSearchParams(window.location.search).get('model');
|
||||
if (model) void v.loadUrl(model);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const 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)
|
||||
}
|
||||
const activeKind = v.spaces.find((s) => s.handleHex === v.activeSpace)?.kind ?? null;
|
||||
|
||||
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>
|
||||
)
|
||||
<div className="relative h-full w-full overflow-hidden bg-background">
|
||||
<div ref={v.containerRef} className="absolute inset-0" />
|
||||
|
||||
<LoadProgress active={v.status === 'loading'} value={v.loadingProgress} />
|
||||
|
||||
<Toolbar
|
||||
status={v.statusMessage}
|
||||
statusError={v.status === 'error'}
|
||||
spaces={v.spaces}
|
||||
activeSpace={v.activeSpace}
|
||||
onSelectSpace={v.selectSpace}
|
||||
onOpenFile={(file) => void v.loadFile(file)}
|
||||
onLoadSample={(url, label) => void v.loadUrl(url, label)}
|
||||
onFit={v.fit}
|
||||
is25D={v.is25D}
|
||||
onToggle25D={v.set25D}
|
||||
canvasDark={v.theme === 'dark'}
|
||||
onToggleTheme={v.toggleTheme}
|
||||
layersOpen={layersOpen}
|
||||
onToggleLayers={() => setLayersOpen((o) => !o)}
|
||||
optionsOpen={optionsOpen}
|
||||
onToggleOptions={() => setOptionsOpen((o) => !o)}
|
||||
propsOpen={propsOpen}
|
||||
onToggleProps={() => setPropsOpen((o) => !o)}
|
||||
/>
|
||||
|
||||
<CoordReadout point={v.pointerWorld} spaceKind={activeKind} />
|
||||
|
||||
<LayerPanel
|
||||
open={layersOpen}
|
||||
onClose={() => setLayersOpen(false)}
|
||||
layers={v.layers}
|
||||
hiddenLayers={v.hiddenLayers}
|
||||
onToggleLayer={v.toggleLayer}
|
||||
onShowAll={v.showAllLayers}
|
||||
onHideAll={v.hideAllLayers}
|
||||
/>
|
||||
|
||||
<OptionsPanel
|
||||
open={optionsOpen}
|
||||
onClose={() => setOptionsOpen(false)}
|
||||
options={v.options}
|
||||
is25D={v.is25D}
|
||||
lineweightClasses={v.getLineweightClasses()}
|
||||
onSetGrid={v.setGrid}
|
||||
onSetZoomSpeed={v.setZoomSpeed}
|
||||
onSetZScale={v.setZScale}
|
||||
onSetLineweightOn={v.setLineweightOn}
|
||||
onSetLineweightScale={v.setLineweightScale}
|
||||
onSet25D={v.set25D}
|
||||
onReset={v.resetOptions}
|
||||
/>
|
||||
|
||||
<PropertiesPanel
|
||||
open={propsOpen}
|
||||
onClose={() => setPropsOpen(false)}
|
||||
entity={v.selectedEntity}
|
||||
onZoomToSelection={v.zoomToSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { MouseEvent } from "react"
|
||||
import { FolderOpenIcon, InfoIcon } from "lucide-react"
|
||||
import {
|
||||
Button,
|
||||
GNB,
|
||||
GNBBrand,
|
||||
GNBMobileGroup,
|
||||
GNBMobileLink,
|
||||
GNBMobileMenu,
|
||||
GNBNav,
|
||||
GNBUtilities,
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { SAMPLE_DRAWINGS, sampleHref, type SampleDrawing } from "../lib/samples"
|
||||
|
||||
type Props = {
|
||||
onOpenFile: () => void
|
||||
onSample: (sample: SampleDrawing) => void
|
||||
onAbout: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* GNB_ONLY page header. The only destinations are the bundled sample drawings (each is also a real
|
||||
* `?model=` link); desktop shows them in the GNB Navigation Menu, mobile in GNBMobileMenu — same data.
|
||||
* File open and the about / license dialog are utilities (white ghost Buttons on the bar).
|
||||
*/
|
||||
export function AppHeader({ onOpenFile, onSample, onAbout }: Props) {
|
||||
const select = (s: SampleDrawing) => (event: MouseEvent) => {
|
||||
// Load in place; the href stays usable for "open in new tab".
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
onSample(s)
|
||||
}
|
||||
|
||||
return (
|
||||
<GNB>
|
||||
<GNBMobileMenu title="도면 메뉴">
|
||||
<GNBMobileGroup label="샘플 도면">
|
||||
{SAMPLE_DRAWINGS.map((s) => (
|
||||
<GNBMobileLink key={s.id} href={sampleHref(s)} title={s.hint} onClick={select(s)}>
|
||||
{s.label}
|
||||
</GNBMobileLink>
|
||||
))}
|
||||
</GNBMobileGroup>
|
||||
</GNBMobileMenu>
|
||||
<GNBBrand href={import.meta.env.BASE_URL}>
|
||||
<span>
|
||||
<small>HM CAD VIEWER</small>
|
||||
<strong>DWG · DXF 뷰어</strong>
|
||||
</span>
|
||||
</GNBBrand>
|
||||
<GNBNav>
|
||||
<NavigationMenu viewport={false} aria-label="주 메뉴">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem value="samples">
|
||||
<NavigationMenuTrigger>샘플 도면</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul>
|
||||
{SAMPLE_DRAWINGS.map((s) => (
|
||||
<li key={s.id}>
|
||||
<NavigationMenuLink href={sampleHref(s)} title={s.hint} onClick={select(s)}>
|
||||
{s.label}
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</GNBNav>
|
||||
<GNBUtilities>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" onClick={onOpenFile}>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
파일 열기
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>.dwg / .dxf 파일 열기 (끌어다 놓기도 가능)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="뷰어 정보 · 오픈소스 고지" onClick={onAbout}>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>뷰어 정보 · 오픈소스 고지</TooltipContent>
|
||||
</Tooltip>
|
||||
</GNBUtilities>
|
||||
</GNB>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
function formatCadCoord(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
const digits = abs >= 1e6 ? 1 : abs >= 1e3 ? 2 : abs >= 1 ? 3 : 4;
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
export interface CoordReadoutProps {
|
||||
point: { x: number; y: number; z: number } | null;
|
||||
spaceKind: 'model' | 'paper' | 'other' | null;
|
||||
}
|
||||
|
||||
export function CoordReadout({ point, spaceKind }: CoordReadoutProps) {
|
||||
const unitLabel = spaceKind === 'paper' ? 'Paper' : 'UCS';
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-3 right-3 z-10 rounded-md border bg-card/90 px-3 py-1.5 font-mono text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||
title={`마우스 위치 CAD 좌표 (${spaceKind === 'paper' ? 'Layout=Paper' : 'Model=UCS'} 도면단위)`}
|
||||
>
|
||||
X {formatCadCoord(point?.x ?? null)} · Y {formatCadCoord(point?.y ?? null)}{' '}
|
||||
<span className="opacity-60">({unitLabel})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@hm-design/ui';
|
||||
import { Button } from '@hm-design/ui';
|
||||
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
export interface DraggablePanelProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
initial: { left: number; top: number };
|
||||
width?: number;
|
||||
height?: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DraggablePanel({
|
||||
title,
|
||||
icon,
|
||||
initial,
|
||||
width = 320,
|
||||
height = 420,
|
||||
open,
|
||||
onClose,
|
||||
headerActions,
|
||||
children,
|
||||
}: DraggablePanelProps) {
|
||||
const { panelRef, style, collapsed, toggleCollapse, headerProps } = useDraggablePanel(initial);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={panelRef}
|
||||
size="sm"
|
||||
className="fixed shadow-lg overflow-hidden"
|
||||
style={{
|
||||
...style,
|
||||
width,
|
||||
height: collapsed ? 'auto' : height,
|
||||
minWidth: 250,
|
||||
minHeight: collapsed ? 'auto' : 140,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
resize: collapsed ? 'none' : 'both',
|
||||
}}
|
||||
>
|
||||
<CardHeader className="select-none border-b py-2" {...headerProps}>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={collapsed ? '펼치기' : '접기'}
|
||||
title={collapsed ? '펼치기' : '접기'}
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
{collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
{!collapsed && (
|
||||
<CardContent className="overflow-auto" style={{ height: 'calc(100% - 44px)' }}>
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"
|
||||
import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react"
|
||||
import { Button, Card, CardAction, CardHeader, CardTitle, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
|
||||
import { makePanelDraggableAndCollapsible } from "../engine/panelUtils"
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
/** Extra header content after the title (count badge…). */
|
||||
meta?: ReactNode
|
||||
/** Extra header buttons before collapse / close. */
|
||||
actions?: ReactNode
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Initial slot from the right edge of the anchor (0 = right-most). */
|
||||
slot: number
|
||||
/** Area the window is first placed in (the canvas stage). */
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Fixed starting height; omitted = fit content (capped by CSS). */
|
||||
size?: "tall" | "auto"
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const GAP = 12
|
||||
|
||||
/**
|
||||
* A floating tool window over the canvas: HM Card inside a positioned frame.
|
||||
*
|
||||
* Drag (header), collapse (header double-click / button) and focus-to-front come from the ported
|
||||
* `makePanelDraggableAndCollapsible` (src/engine/panelUtils.ts, unchanged); resizing is CSS `resize: both` on the
|
||||
* frame, as in the sample. The frame keeps a static className because panelUtils toggles `collapsed` on it, and
|
||||
* React would overwrite a changing className; panelUtils also owns its inline left/top/z-index/height. Open/closed
|
||||
* is the `hidden` attribute, so neither of those is touched.
|
||||
*/
|
||||
export function FloatingPanel({ title, icon, meta, actions, open, onClose, slot, anchorRef, size = "tall", children }: Props) {
|
||||
const frameRef = useRef<HTMLElement>(null)
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const control = useRef<{ toggleCollapse: () => void } | null>(null)
|
||||
const placed = useRef(false)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
// panelUtils has no teardown; attach once per element (StrictMode runs effects twice in development,
|
||||
// and two dblclick listeners would cancel each other out).
|
||||
if (!frame || frame.dataset.panelReady) return
|
||||
frame.dataset.panelReady = "1"
|
||||
control.current = makePanelDraggableAndCollapsible(frame, {
|
||||
handleEl: headerRef.current,
|
||||
bodyEl: bodyRef.current,
|
||||
onCollapseToggle: setCollapsed,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// First open: place the window inside the canvas stage, stacked from the right edge.
|
||||
useLayoutEffect(() => {
|
||||
const frame = frameRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!open || placed.current || !frame || !anchor) return
|
||||
placed.current = true
|
||||
const r = anchor.getBoundingClientRect()
|
||||
const w = frame.offsetWidth
|
||||
const left = Math.max(r.left + GAP, r.right - GAP - (slot + 1) * w - slot * GAP)
|
||||
frame.style.left = `${Math.round(left)}px`
|
||||
frame.style.top = `${Math.round(r.top + GAP)}px`
|
||||
}, [open, slot, anchorRef])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={frameRef}
|
||||
className="viewer-window"
|
||||
data-size={size}
|
||||
aria-label={title}
|
||||
hidden={!open}
|
||||
>
|
||||
<Card size="sm" className="viewer-window-card">
|
||||
<CardHeader ref={headerRef} className="viewer-window-header border-b">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className="flex text-muted-foreground">{icon}</span>
|
||||
<span className="truncate">{title}</span>
|
||||
{meta}
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{actions}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={collapsed ? `${title} 펼치기` : `${title} 접기`}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => control.current?.toggleCollapse()}
|
||||
>
|
||||
{collapsed ? <ChevronRightIcon aria-hidden="true" /> : <ChevronDownIcon aria-hidden="true" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{collapsed ? "펼치기" : "접기"} (제목 더블클릭)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`${title} 닫기`} onClick={onClose}>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>닫기</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<div ref={bodyRef} className="viewer-window-body">
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch } from '@hm-design/ui';
|
||||
import { Eye, EyeOff, Layers } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import type { LayerRow } from '../hooks/useDwgViewer';
|
||||
|
||||
export interface LayerPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
layers: LayerRow[];
|
||||
hiddenLayers: Set<string>;
|
||||
onToggleLayer: (name: string) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({ open, onClose, layers, hiddenLayers, onToggleLayer, onShowAll, onHideAll }: LayerPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return layers;
|
||||
return layers.filter((l) => l.name.toLowerCase().includes(q));
|
||||
}, [layers, query]);
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
title={`레이어 (${layers.length}개)`}
|
||||
icon={<Layers className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 344, top: 56 }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FilterBar>
|
||||
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
|
||||
<FilterBarActions className="ml-2 gap-1">
|
||||
<Button type="button" variant="outline" size="xs" onClick={onShowAll}>
|
||||
전체 켜기
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" onClick={onHideAll}>
|
||||
전체 끄기
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
|
||||
{layers.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">도면에 레이어 정보가 없습니다</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없습니다 ("{query}")</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((layer) => {
|
||||
const visible = !hiddenLayers.has(layer.name);
|
||||
return (
|
||||
<li
|
||||
key={layer.name}
|
||||
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-muted/50"
|
||||
style={{ opacity: visible ? 1 : 0.45 }}
|
||||
>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full border"
|
||||
style={{ background: layer.colorHex }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={layer.name}>
|
||||
{layer.name}
|
||||
</span>
|
||||
<Badge variant="secondary">{layer.count}</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={visible ? '숨기기' : '표시하기'}
|
||||
title={visible ? '숨기기' : '표시하기'}
|
||||
onClick={() => onToggleLayer(layer.name)}
|
||||
>
|
||||
{visible ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useId, useState, type RefObject } from "react"
|
||||
import { EyeIcon, EyeOffIcon, LayersIcon } from "lucide-react"
|
||||
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch, Label, ScrollArea, Switch } from "@hm-design/ui"
|
||||
import type { LayerItem } from "../engine/layerPanel"
|
||||
import { FloatingPanel } from "./FloatingPanel"
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Snapshot of Viewer2D.getLayerInfo() (visibility included). */
|
||||
layers: LayerItem[]
|
||||
onToggle: (name: string) => void
|
||||
onShowAll: () => void
|
||||
onHideAll: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Layers window. Visibility state lives in the engine: toggles go through the ported LayerPanelManager
|
||||
* (src/engine/layerPanel.ts) and the list is re-read from Viewer2D.getLayerInfo().
|
||||
* Search is the HM FilterBar applied on every keystroke (the sample filtered on `input`, same substring rule).
|
||||
*/
|
||||
export function LayersPanel({ open, onClose, anchorRef, layers, onToggle, onShowAll, onHideAll }: Props) {
|
||||
const [query, setQuery] = useState("")
|
||||
const idBase = useId()
|
||||
const q = query.trim().toLowerCase()
|
||||
const filtered = layers.filter((l) => l.name.toLowerCase().includes(q))
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
title="레이어"
|
||||
icon={<LayersIcon className="size-4" />}
|
||||
meta={<Badge variant="secondary">{layers.length}개</Badge>}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slot={1}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
<div className="viewer-window-section border-b">
|
||||
<FilterBar className="viewer-layer-filter">
|
||||
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
|
||||
<FilterBarActions>
|
||||
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onShowAll} disabled={!layers.length}>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
전체 켜기
|
||||
</Button>
|
||||
<Button type="button" variant="cancel" appearance="outline" size="sm" onClick={onHideAll} disabled={!layers.length}>
|
||||
<EyeOffIcon aria-hidden="true" />
|
||||
전체 끄기
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
</div>
|
||||
{!layers.length ? (
|
||||
<p className="viewer-empty-note">도면에 레이어 정보가 없습니다</p>
|
||||
) : !filtered.length ? (
|
||||
<p className="viewer-empty-note">검색 결과가 없습니다 ("{q}")</p>
|
||||
) : (
|
||||
<ScrollArea className="viewer-window-scroll">
|
||||
<ul className="viewer-layer-list" aria-label="레이어 목록">
|
||||
{filtered.map((l, i) => {
|
||||
const id = `${idBase}-layer-${i}`
|
||||
return (
|
||||
<li key={l.name} className="viewer-layer-row" data-visible={l.visible ? "true" : "false"}>
|
||||
{/* Layer color comes from the drawing (ACI → hex), not from the theme. */}
|
||||
<span aria-hidden="true" className="viewer-swatch" style={{ background: l.colorHex || undefined }} />
|
||||
<Label htmlFor={id} className="viewer-layer-name" title={l.name}>
|
||||
{l.name}
|
||||
</Label>
|
||||
<Badge variant="outline" title={`엔티티 ${l.count}개`}>
|
||||
{l.count}
|
||||
</Badge>
|
||||
<Switch id={id} checked={l.visible} onCheckedChange={() => onToggle(l.name)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@hm-design/ui';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
const PIPELINE_STEPS = [
|
||||
'파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)',
|
||||
'CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)',
|
||||
'Three.js 씬 구성 및 렌더링',
|
||||
];
|
||||
|
||||
export function LicenseNotice() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label="정보 및 라이선스" title="정보 및 라이선스">
|
||||
<Info />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>모듈 구성 · 라이선스 고지</DialogTitle>
|
||||
<DialogDescription>이 뷰어가 사용하는 처리 파이프라인과 서드파티 라이선스 고지입니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
|
||||
{PIPELINE_STEPS.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="flex flex-col gap-1 border-t pt-3 text-sm">
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank" rel="noreferrer">
|
||||
THIRD-PARTY-NOTICES.md
|
||||
</a>
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-MPL-2.0.txt" target="_blank" rel="noreferrer">
|
||||
acadrust MPL-2.0 전문
|
||||
</a>
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank" rel="noreferrer">
|
||||
acadrust 0.4.1 read_mesh 패치
|
||||
</a>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Progress } from '@hm-design/ui';
|
||||
|
||||
export interface LoadProgressProps {
|
||||
active: boolean;
|
||||
value: number | null;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function LoadProgress({ active, value, error }: LoadProgressProps) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<Progress
|
||||
value={value ?? undefined}
|
||||
className={`fixed inset-x-0 top-0 z-50 h-1 rounded-none ${
|
||||
value === null ? 'animate-pulse' : ''
|
||||
} ${error ? '[&>*]:bg-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+145
-125
@@ -1,148 +1,168 @@
|
||||
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"
|
||||
import { Button, Input, Label, Slider, Switch } from '@hm-design/ui';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import type { PersistedOptions } from '../hooks/useDwgViewer';
|
||||
|
||||
/** 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
|
||||
function SliderNumberRow({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
size="small"
|
||||
className="w-20"
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) onChange(v);
|
||||
}}
|
||||
/>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[value]}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
aria-label={label}
|
||||
onValueChange={([v]) => onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
export interface OptionsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: PersistedOptions;
|
||||
is25D: boolean;
|
||||
lineweightClasses: number[];
|
||||
onSetGrid: (on: boolean) => void;
|
||||
onSetZoomSpeed: (v: number) => void;
|
||||
onSetZScale: (v: number) => void;
|
||||
onSetLineweightOn: (on: boolean | null) => void;
|
||||
onSetLineweightScale: (v: number | null) => void;
|
||||
onSet25D: (on: boolean) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
export function OptionsPanel({
|
||||
open,
|
||||
onClose,
|
||||
options,
|
||||
is25D,
|
||||
lineweightClasses,
|
||||
onSetGrid,
|
||||
onSetZoomSpeed,
|
||||
onSetZScale,
|
||||
onSetLineweightOn,
|
||||
onSetLineweightScale,
|
||||
onSet25D,
|
||||
onReset,
|
||||
}: OptionsPanelProps) {
|
||||
const lwScale = options.lineweightScale ?? 11;
|
||||
const lwPreview =
|
||||
lineweightClasses.length > 0
|
||||
? lineweightClasses.map((mm) => `${mm.toFixed(2)}mm → ${(mm * lwScale).toFixed(1)}px`).join(' · ')
|
||||
: `이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 ${(0.5 * lwScale).toFixed(1)}px)`;
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
<DraggablePanel
|
||||
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>
|
||||
}
|
||||
icon={<SlidersHorizontal className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 676, top: 56 }}
|
||||
width={340}
|
||||
height={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slot={2}
|
||||
anchorRef={anchorRef}
|
||||
size="auto"
|
||||
headerActions={
|
||||
<Button type="button" variant="ghost" size="icon-xs" title="기본값으로" aria-label="기본값으로" onClick={onReset}>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">선가중치</h4>
|
||||
<Label className="w-full justify-between text-sm">
|
||||
표시 사용
|
||||
<Switch
|
||||
checked={options.lineweightOn ?? false}
|
||||
onCheckedChange={(on) => onSetLineweightOn(on)}
|
||||
/>
|
||||
</Label>
|
||||
<SliderNumberRow
|
||||
label="표시 배율 (px/mm)"
|
||||
value={lwScale}
|
||||
min={2}
|
||||
max={60}
|
||||
step={0.5}
|
||||
onChange={onSetLineweightScale}
|
||||
/>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">{lwPreview}</p>
|
||||
<p className="text-xs text-muted-foreground">AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</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`}
|
||||
<section className="space-y-3 border-t pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표시</h4>
|
||||
<Label className="w-full justify-between text-sm">
|
||||
그리드
|
||||
<Switch checked={options.grid} onCheckedChange={onSetGrid} />
|
||||
</Label>
|
||||
<SliderNumberRow
|
||||
label="줌 속도"
|
||||
value={engine.zoomSpeed}
|
||||
slider={{ min: 0.2, max: 3, step: 0.1 }}
|
||||
input={{ min: 0.1, max: 5, step: 0.1 }}
|
||||
onChange={props.onZoomSpeed}
|
||||
value={options.zoomSpeed}
|
||||
min={0.2}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={onSetZoomSpeed}
|
||||
/>
|
||||
</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} />
|
||||
<section className="space-y-3 border-t pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">2.5D</h4>
|
||||
<Label className="w-full justify-between text-sm">
|
||||
2.5D 보기
|
||||
<Switch checked={is25D} onCheckedChange={onSet25D} />
|
||||
</Label>
|
||||
<div className={is25D ? '' : 'pointer-events-none opacity-50'}>
|
||||
<SliderNumberRow
|
||||
label="Z 높이 배율"
|
||||
value={options.zScale}
|
||||
min={1}
|
||||
max={50}
|
||||
step={0.5}
|
||||
onChange={onSetZScale}
|
||||
/>
|
||||
</div>
|
||||
<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 className="text-xs text-muted-foreground">
|
||||
좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
)
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,76 @@
|
||||
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"
|
||||
import { Badge, Button } from '@hm-design/ui';
|
||||
import { Info, MousePointerClick, ZoomIn } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import { formatEntityProperties } from '../lib/propertyFormat';
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLElement | null>
|
||||
/** Entity from Viewer2D.onSelect (null = nothing selected). */
|
||||
entity: unknown
|
||||
onZoomToSelection: () => void
|
||||
function PropertyRows({ rows }: { rows: [string, string][] }) {
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between gap-3 py-1.5 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">{k}</span>
|
||||
<span className="truncate text-right font-mono text-foreground" style={{ userSelect: 'all' }} title={v}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
export interface PropertiesPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
entity: unknown;
|
||||
onZoomToSelection: () => void;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({ open, onClose, entity, onZoomToSelection }: PropertiesPanelProps) {
|
||||
const prop = formatEntityProperties(entity);
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
<DraggablePanel
|
||||
title="속성"
|
||||
icon={<ClipboardListIcon className="size-4" />}
|
||||
actions={
|
||||
prop ? (
|
||||
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onZoomToSelection}>
|
||||
<ZoomInIcon aria-hidden="true" />
|
||||
선택 확대
|
||||
icon={<Info className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 12 - 320, top: 56 }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
headerActions={
|
||||
entity ? (
|
||||
<Button type="button" variant="ghost" size="icon-xs" title="선택 확대" aria-label="선택 확대" onClick={onZoomToSelection}>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
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" />
|
||||
{!entity ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-xs text-muted-foreground">
|
||||
<MousePointerClick className="size-6 opacity-50" />
|
||||
도면에서 객체(엔티티)를 클릭하면
|
||||
<br />
|
||||
상세 속성이 표시됩니다
|
||||
</div>
|
||||
) : (
|
||||
<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 className="space-y-4">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{prop.id}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
일반 정보 (General)
|
||||
</h4>
|
||||
<PropertyRows rows={prop.general} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{prop.geometry.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
기하 속성 (Geometry)
|
||||
</h4>
|
||||
<PropertyRows rows={prop.geometry} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ScaleIcon } from "lucide-react"
|
||||
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
|
||||
import { formatCadCoord } from "../lib/cad"
|
||||
import { useValueStore, type ValueStore } from "../lib/store"
|
||||
import type { CadPoint } from "./DwgViewerCanvas"
|
||||
|
||||
export type StatusMessage = { text: string; error?: boolean }
|
||||
|
||||
type Props = {
|
||||
status: StatusMessage
|
||||
pointer: ValueStore<CadPoint | null>
|
||||
zoom: ValueStore<number | null>
|
||||
onNotices: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom status bar. Left: the status line (load / select / space messages). Right end — the screen's
|
||||
* bottom-right corner — the live CAD coordinates, so they stay in a fixed place.
|
||||
*/
|
||||
export function StatusBar({ status, pointer, zoom, onNotices }: Props) {
|
||||
return (
|
||||
<footer className="viewer-statusbar">
|
||||
<p role="status" className="viewer-status-text" data-error={status.error ? "true" : undefined} title={status.text}>
|
||||
{status.text}
|
||||
</p>
|
||||
<Button type="button" variant="link" size="sm" onClick={onNotices} className="viewer-notice-link">
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
오픈소스 고지 (MPL-2.0 포함)
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="viewer-statusbar-sep" />
|
||||
<ZoomReadout store={zoom} />
|
||||
<Separator orientation="vertical" className="viewer-statusbar-sep" />
|
||||
<CoordinateReadout store={pointer} />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoomReadout({ store }: { store: ValueStore<number | null> }) {
|
||||
const z = useValueStore(store)
|
||||
return (
|
||||
<span className="viewer-readout" title="확대 배율 (맞춤 = 100%)">
|
||||
<span className="viewer-readout-label">Zoom</span>
|
||||
{z == null ? "—" : `${z}%`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CoordinateReadout({ store }: { store: ValueStore<CadPoint | null> }) {
|
||||
const p = useValueStore(store)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="viewer-readout viewer-coords" tabIndex={0} aria-label="마우스 위치 CAD 좌표">
|
||||
<span className="viewer-readout-label">X</span>
|
||||
<span className="viewer-coord-value">{p ? formatCadCoord(p.x) : "—"}</span>
|
||||
<span className="viewer-readout-label">Y</span>
|
||||
<span className="viewer-coord-value">{p ? formatCadCoord(p.y) : "—"}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">마우스 위치 CAD 좌표 (Model = UCS, Layout = Paper 도면 단위)</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button, Label, Switch, Tabs, TabsList, TabsTrigger, Badge, Toggle } from '@hm-design/ui';
|
||||
import { FolderOpen, Scan, Layers, SlidersHorizontal, Info as InfoIcon } from 'lucide-react';
|
||||
import type { CadSpace } from '../viewer2d/cadSpaces';
|
||||
import { LicenseNotice } from './LicenseNotice';
|
||||
|
||||
export interface ToolbarProps {
|
||||
status: string;
|
||||
statusError: boolean;
|
||||
spaces: CadSpace[];
|
||||
activeSpace: string | null;
|
||||
onSelectSpace: (handleHex: string | null) => void;
|
||||
onOpenFile: (file: File) => void;
|
||||
onLoadSample: (url: string, label: string) => void;
|
||||
onFit: () => void;
|
||||
is25D: boolean;
|
||||
onToggle25D: (on: boolean) => void;
|
||||
canvasDark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
layersOpen: boolean;
|
||||
onToggleLayers: () => void;
|
||||
optionsOpen: boolean;
|
||||
onToggleOptions: () => void;
|
||||
propsOpen: boolean;
|
||||
onToggleProps: () => void;
|
||||
}
|
||||
|
||||
export function Toolbar({
|
||||
status,
|
||||
statusError,
|
||||
spaces,
|
||||
activeSpace,
|
||||
onSelectSpace,
|
||||
onOpenFile,
|
||||
onLoadSample,
|
||||
onFit,
|
||||
is25D,
|
||||
onToggle25D,
|
||||
canvasDark,
|
||||
onToggleTheme,
|
||||
layersOpen,
|
||||
onToggleLayers,
|
||||
optionsOpen,
|
||||
onToggleOptions,
|
||||
propsOpen,
|
||||
onToggleProps,
|
||||
}: ToolbarProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const showSpaceTabs = spaces.length > 1 || (spaces[0]?.entityCount ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex items-center gap-2 border-b bg-card/90 px-3 py-2 backdrop-blur [&>*]:pointer-events-auto">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".dwg,.dxf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onOpenFile(file);
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderOpen /> 파일 열기
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" title="acadrust-dwg WASM" onClick={() => onLoadSample('/samples/BasicSample.dwg', 'BasicSample.dwg')}>
|
||||
Sample DWG
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => onLoadSample('/samples/civil.dwg', 'C0060202-001-평면및종단면도.dwg')}>
|
||||
Sample Civil
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" title="dxf-parser" onClick={() => onLoadSample('/samples/simple.dxf', 'simple.dxf')}>
|
||||
Sample DXF
|
||||
</Button>
|
||||
|
||||
{showSpaceTabs && (
|
||||
<Tabs value={activeSpace ?? undefined} onValueChange={(v) => onSelectSpace(v)}>
|
||||
<TabsList>
|
||||
{spaces.map((s) => (
|
||||
<TabsTrigger key={s.handleHex} value={s.handleHex} title={`${s.name} · ${s.handleHex}`}>
|
||||
{s.label}
|
||||
{s.entityCount > 0 ? ` (${s.entityCount})` : ''}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onFit} title="Fit (F)">
|
||||
<Scan /> Fit
|
||||
</Button>
|
||||
|
||||
<Label className="gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={is25D} onCheckedChange={onToggle25D} />
|
||||
2.5D
|
||||
</Label>
|
||||
|
||||
<Label className="gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={canvasDark} onCheckedChange={onToggleTheme} />
|
||||
캔버스 테마
|
||||
</Label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Toggle pressed={layersOpen} onPressedChange={onToggleLayers} size="sm" aria-label="레이어 패널" title="Layers">
|
||||
<Layers />
|
||||
</Toggle>
|
||||
<Toggle pressed={optionsOpen} onPressedChange={onToggleOptions} size="sm" aria-label="옵션 패널" title="Options">
|
||||
<SlidersHorizontal />
|
||||
</Toggle>
|
||||
<Toggle pressed={propsOpen} onPressedChange={onToggleProps} size="sm" aria-label="속성 패널" title="Properties">
|
||||
<InfoIcon />
|
||||
</Toggle>
|
||||
<LicenseNotice />
|
||||
</div>
|
||||
|
||||
<Badge variant={statusError ? 'destructive' : 'secondary'} className="max-w-[320px] truncate">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* Layer Panel manager for dwg-dxf-viewer-sample.
|
||||
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
|
||||
*/
|
||||
|
||||
import type { Viewer2D } from './viewer2d/Viewer2D';
|
||||
|
||||
export interface LayerItem {
|
||||
name: string;
|
||||
colorHex: string;
|
||||
count: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export class LayerPanelManager {
|
||||
private viewer: Viewer2D;
|
||||
private listEl: HTMLElement;
|
||||
private countEl: HTMLElement | null;
|
||||
private searchInputEl: HTMLInputElement | null;
|
||||
private hiddenLayers: Set<string> = new Set();
|
||||
private searchQuery: string = '';
|
||||
|
||||
constructor(
|
||||
viewer: Viewer2D,
|
||||
listEl: HTMLElement,
|
||||
options?: {
|
||||
countEl?: HTMLElement | null;
|
||||
searchInputEl?: HTMLInputElement | null;
|
||||
showAllBtnEl?: HTMLElement | null;
|
||||
hideAllBtnEl?: HTMLElement | null;
|
||||
}
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this.listEl = listEl;
|
||||
this.countEl = options?.countEl || null;
|
||||
this.searchInputEl = options?.searchInputEl || null;
|
||||
|
||||
if (this.searchInputEl) {
|
||||
this.searchInputEl.addEventListener('input', (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.showAllBtnEl) {
|
||||
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
|
||||
}
|
||||
|
||||
if (options?.hideAllBtnEl) {
|
||||
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
|
||||
}
|
||||
|
||||
// Delegated click handling: bound once, so render() never has to re-attach
|
||||
// a listener per row (that was O(layers) DOM work on every single toggle).
|
||||
this.listEl.addEventListener('click', (e) => {
|
||||
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const layerName = row.dataset.name;
|
||||
if (layerName) this.toggleLayer(layerName);
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh and re-render layer list from Viewer2D state */
|
||||
public update(): void {
|
||||
this.hiddenLayers.clear();
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
if (!l.visible) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
public showAllLayers(): void {
|
||||
this.hiddenLayers.clear();
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public hideAllLayers(): void {
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public toggleLayer(name: string): void {
|
||||
if (this.hiddenLayers.has(name)) {
|
||||
this.hiddenLayers.delete(name);
|
||||
} else {
|
||||
this.hiddenLayers.add(name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRow(name);
|
||||
}
|
||||
|
||||
/** Patch one row in place — a full render() would rebuild the whole list. */
|
||||
private syncRow(name: string): void {
|
||||
const row = this.listEl?.querySelector<HTMLElement>(
|
||||
`.layer-row[data-name="${cssEscape(name)}"]`
|
||||
);
|
||||
if (!row) return;
|
||||
this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
}
|
||||
|
||||
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
|
||||
private syncRows(): void {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
|
||||
const name = row.dataset.name;
|
||||
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
});
|
||||
}
|
||||
|
||||
private paintRow(row: HTMLElement, visible: boolean): void {
|
||||
row.style.opacity = visible ? '1' : '0.4';
|
||||
const btn = row.querySelector<HTMLElement>('.eye-btn');
|
||||
if (btn) {
|
||||
btn.textContent = visible ? '👁️' : '🕶️';
|
||||
btn.title = visible ? '숨기기' : '표시하기';
|
||||
}
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
if (!this.listEl) return;
|
||||
|
||||
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
|
||||
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = `${layers.length}개`;
|
||||
}
|
||||
|
||||
if (!layers.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
도면에 레이어 정보가 없습니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = layers.filter((l) =>
|
||||
l.name.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = filtered
|
||||
.map((l) => {
|
||||
const isVisible = !this.hiddenLayers.has(l.name);
|
||||
const opacity = isVisible ? '1' : '0.4';
|
||||
const eyeIcon = isVisible ? '👁️' : '🕶️';
|
||||
const eyeTitle = isVisible ? '숨기기' : '표시하기';
|
||||
|
||||
return `
|
||||
<div class="layer-row" data-name="${escapeHtml(l.name)}"
|
||||
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
|
||||
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
|
||||
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
|
||||
${escapeHtml(l.name)}
|
||||
</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
|
||||
${l.count}
|
||||
</span>
|
||||
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
|
||||
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
|
||||
${eyeIcon}
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
// Clicks are handled by the delegated listener bound in the constructor.
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
|
||||
function cssEscape(str: string): string {
|
||||
return str.replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
/* HM Theme (public package entry) + this app's page layout.
|
||||
App rules below only lay out plain elements (frames, rows, overlays) with HM Semantic tokens. They do not
|
||||
target HM component slots; HM components are customised only through their documented tokens (--hm-table-*,
|
||||
--hm-filter-bar-*) on app-owned wrappers. */
|
||||
@import "@hm-design/ui/theme.css";
|
||||
|
||||
/* ── Page: GNB_ONLY — GNB + content surface (gnb.md §10 content contract) ─────────────────────────────── */
|
||||
.viewer-app {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100dvh;
|
||||
min-width: 320px;
|
||||
background: var(--hm-sem-bg-content);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-family: var(--hm-font-family-base);
|
||||
}
|
||||
|
||||
.viewer-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0;
|
||||
background: var(--hm-sem-bg-content);
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────────────────────────────────────────────── */
|
||||
.viewer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px var(--hm-gnb-padding-x) 10px;
|
||||
}
|
||||
.viewer-toolbar-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.viewer-toolbar-end {
|
||||
margin-left: auto;
|
||||
}
|
||||
.viewer-toolbar-sep {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* ── Stage: canvas + overlays + floating windows ───────────────────────────────────────────────── */
|
||||
.viewer-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 var(--hm-gnb-padding-x);
|
||||
border: 1px solid var(--hm-sem-stroke-input-default);
|
||||
border-radius: var(--hm-radius-container);
|
||||
overflow: hidden;
|
||||
/* Own stacking context: the floating windows' growing z-index (panelUtils) stays below portaled HM popups. */
|
||||
isolation: isolate;
|
||||
}
|
||||
.viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.viewer-canvas > canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.viewer-stage-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.viewer-stage-center > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.viewer-stage-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
}
|
||||
.viewer-loading {
|
||||
min-width: min(320px, 100%);
|
||||
}
|
||||
.viewer-empty-card {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
.viewer-drop {
|
||||
z-index: 12;
|
||||
background: var(--hm-sem-overlay-background);
|
||||
}
|
||||
.viewer-drop-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 36px;
|
||||
border: 2px dashed var(--hm-sem-action-primary);
|
||||
border-radius: var(--hm-radius-container);
|
||||
background: var(--hm-sem-bg-surface);
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Floating tool windows (FloatingPanel) ─────────────────────────────────────────────────────── */
|
||||
.viewer-window {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
height: min(460px, calc(100dvh - 160px));
|
||||
min-width: 240px;
|
||||
min-height: 120px;
|
||||
max-width: calc(100vw - 20px);
|
||||
max-height: calc(100dvh - 20px);
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
border-radius: var(--hm-radius-container);
|
||||
box-shadow: var(--hm-sem-floating-shadow);
|
||||
}
|
||||
.viewer-window[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.viewer-window[data-size="auto"] {
|
||||
height: auto;
|
||||
max-height: min(78dvh, 760px);
|
||||
}
|
||||
/* panelUtils writes an inline 42px height when collapsing; let the card header define it instead. */
|
||||
.viewer-window.collapsed {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
resize: none;
|
||||
}
|
||||
.viewer-window-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewer-window-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.viewer-window-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viewer-window-section {
|
||||
padding-inline: var(--card-spacing);
|
||||
}
|
||||
.viewer-window-section.border-b {
|
||||
padding-bottom: var(--card-spacing);
|
||||
}
|
||||
.viewer-empty-note {
|
||||
margin: 0;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.viewer-section-title {
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Layers */
|
||||
.viewer-layer-filter {
|
||||
--hm-filter-bar-search-width: 100%;
|
||||
}
|
||||
.viewer-layer-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.viewer-layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 4px var(--card-spacing);
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
}
|
||||
.viewer-layer-row[data-visible="false"] > :is(.viewer-swatch, .viewer-layer-name) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.viewer-swatch {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--hm-sem-stroke-default);
|
||||
}
|
||||
.viewer-layer-name {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.viewer-prop-id {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
}
|
||||
.viewer-prop-table {
|
||||
--hm-table-min-width: 0;
|
||||
--hm-table-font-size: 12px;
|
||||
--hm-table-line-height: 18px;
|
||||
--hm-table-header-font-size: 12px;
|
||||
--hm-table-cell-padding-y: 6px;
|
||||
--hm-table-cell-padding-x: 8px;
|
||||
}
|
||||
.viewer-prop-key {
|
||||
width: 42%;
|
||||
}
|
||||
.viewer-prop-value {
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.viewer-options {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
padding: 0 var(--card-spacing) var(--card-spacing);
|
||||
}
|
||||
.viewer-option-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.viewer-option-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
/* The Slider / Input show their own disabled look; dim only the plain text of the row. */
|
||||
.viewer-option-row[data-disabled] > .viewer-option-label,
|
||||
.viewer-option-row[data-disabled] .viewer-unit {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.viewer-option-label {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
.viewer-option-control {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.viewer-number {
|
||||
flex: 0 0 76px;
|
||||
text-align: right;
|
||||
}
|
||||
.viewer-unit {
|
||||
min-width: 36px;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-lw-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-help {
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* About dialog */
|
||||
.viewer-about {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
.viewer-recipe {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.viewer-recipe code,
|
||||
.viewer-empty-card code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ── Status bar (coordinates sit in the bottom-right corner) ───────────────────────────────────── */
|
||||
.viewer-statusbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
min-height: 40px;
|
||||
padding: 4px var(--hm-gnb-padding-x);
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer-status-text {
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer-status-text[data-error] {
|
||||
color: var(--hm-sem-status-error);
|
||||
}
|
||||
.viewer-statusbar-sep {
|
||||
align-self: stretch;
|
||||
margin-block: 8px;
|
||||
}
|
||||
.viewer-readout {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer-readout-label {
|
||||
color: var(--hm-sem-action-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.viewer-coords {
|
||||
margin-left: auto;
|
||||
}
|
||||
.viewer-coord-value {
|
||||
min-width: 6.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.viewer-status-text {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.viewer-statusbar-sep {
|
||||
display: none;
|
||||
}
|
||||
.viewer-coord-value {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* React port of dwg-dxf-viewer-sample's panelUtils.ts drag+collapse mechanics.
|
||||
* Resize is left to native CSS `resize: both` on the panel element, same as the original.
|
||||
*/
|
||||
|
||||
let globalMaxZIndex = 30;
|
||||
|
||||
const INTERACTIVE_SELECTOR = 'button, input, select, a, label, [data-panel-no-drag]';
|
||||
|
||||
export interface UseDraggablePanelResult {
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
style: React.CSSProperties;
|
||||
zIndex: number;
|
||||
collapsed: boolean;
|
||||
toggleCollapse: () => void;
|
||||
headerProps: {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onDoubleClick: (e: React.MouseEvent) => void;
|
||||
style: React.CSSProperties;
|
||||
};
|
||||
bringToFront: () => void;
|
||||
}
|
||||
|
||||
export function useDraggablePanel(initial: { left: number; top: number }): UseDraggablePanelResult {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState(initial);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [zIndex, setZIndex] = useState(() => ++globalMaxZIndex);
|
||||
const dragState = useRef<{ startX: number; startY: number; startLeft: number; startTop: number } | null>(null);
|
||||
|
||||
const bringToFront = useCallback(() => {
|
||||
setZIndex(++globalMaxZIndex);
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: PointerEvent) => {
|
||||
const st = dragState.current;
|
||||
const panel = panelRef.current;
|
||||
if (!st || !panel) return;
|
||||
const dx = e.clientX - st.startX;
|
||||
const dy = e.clientY - st.startY;
|
||||
const maxLeft = window.innerWidth - panel.offsetWidth - 10;
|
||||
const maxTop = window.innerHeight - panel.offsetHeight - 10;
|
||||
const left = Math.min(Math.max(st.startLeft + dx, 10), Math.max(10, maxLeft));
|
||||
const top = Math.min(Math.max(st.startTop + dy, 10), Math.max(10, maxTop));
|
||||
setPos({ left, top });
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragState.current = null;
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.body.style.cursor = '';
|
||||
}, [onPointerMove]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
bringToFront();
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(INTERACTIVE_SELECTOR)) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
dragState.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startLeft: panel.offsetLeft,
|
||||
startTop: panel.offsetTop,
|
||||
};
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
},
|
||||
[bringToFront, onPointerMove, onPointerUp],
|
||||
);
|
||||
|
||||
const toggleCollapse = useCallback(() => setCollapsed((c) => !c), []);
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(INTERACTIVE_SELECTOR)) return;
|
||||
toggleCollapse();
|
||||
},
|
||||
[toggleCollapse],
|
||||
);
|
||||
|
||||
return {
|
||||
panelRef,
|
||||
style: { position: 'fixed', left: pos.left, top: pos.top, zIndex },
|
||||
zIndex,
|
||||
collapsed,
|
||||
toggleCollapse,
|
||||
headerProps: {
|
||||
onPointerDown,
|
||||
onDoubleClick,
|
||||
style: { cursor: 'grab', touchAction: 'none' },
|
||||
},
|
||||
bringToFront,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Viewer2D } from '../viewer2d/Viewer2D';
|
||||
import { parseDwgBuffer } from '../viewer2d/dwgParser';
|
||||
import { parseDxfBuffer } from '../viewer2d/parseDxf';
|
||||
import { isDwg, isDxf, is2D } from '../viewer2d/ext2d';
|
||||
import { listCadSpaces, pickDefaultSpace, type CadSpace } from '../viewer2d/cadSpaces';
|
||||
import { setDefaultFontUrl } from '../viewer2d/slugText';
|
||||
import { getAllOptions, setOption } from '../lib/optionsStore';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
export interface LayerRow {
|
||||
name: string;
|
||||
colorHex: string;
|
||||
count: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface PersistedOptions {
|
||||
grid: boolean;
|
||||
zoomSpeed: number;
|
||||
zScale: number;
|
||||
lineweightOn: boolean | null;
|
||||
lineweightScale: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: PersistedOptions = {
|
||||
grid: false,
|
||||
zoomSpeed: 1,
|
||||
zScale: 1,
|
||||
lineweightOn: null,
|
||||
lineweightScale: null,
|
||||
};
|
||||
|
||||
async function parseByName(name: string, buf: ArrayBuffer) {
|
||||
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
|
||||
if (isDxf(name)) return parseDxfBuffer(buf);
|
||||
if (!is2D(name)) throw new Error('지원하지 않는 파일 형식입니다 (.dwg/.dxf만 지원)');
|
||||
return parseDxfBuffer(buf);
|
||||
}
|
||||
|
||||
export function useDwgViewer() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<Viewer2D | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<LoadStatus>('idle');
|
||||
const [statusMessage, setStatusMessage] = useState('준비됨');
|
||||
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
|
||||
|
||||
const [selectedEntity, setSelectedEntity] = useState<unknown>(null);
|
||||
const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||
|
||||
const [spaces, setSpaces] = useState<CadSpace[]>([]);
|
||||
const [activeSpace, setActiveSpace] = useState<string | null>(null);
|
||||
|
||||
const [layers, setLayers] = useState<LayerRow[]>([]);
|
||||
const [hiddenLayers, setHiddenLayersState] = useState<Set<string>>(new Set());
|
||||
|
||||
const [theme, setThemeState] = useState<'dark' | 'light'>('dark');
|
||||
const [is25D, setIs25DState] = useState(false);
|
||||
const [options, setOptions] = useState<PersistedOptions>(DEFAULT_OPTIONS);
|
||||
|
||||
const refreshLayers = useCallback(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
const info = viewer.getLayerInfo();
|
||||
setLayers(info.map((l) => ({ name: l.name, colorHex: l.colorHex, count: l.count, visible: l.visible })));
|
||||
}, []);
|
||||
|
||||
const refreshSpaces = useCallback(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
setSpaces(viewer.getSpaces());
|
||||
setActiveSpace(viewer.getActiveSpace());
|
||||
}, []);
|
||||
|
||||
// mount Viewer2D once
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
// Viewer2D has no dispose/teardown API (permanent window/canvas
|
||||
// listeners, its own rAF loop) — defensively clear any leftover canvas
|
||||
// from a prior construction (e.g. a dev Fast Refresh remount) so it
|
||||
// never stacks two live WebGL contexts in the same container.
|
||||
container.replaceChildren();
|
||||
const viewer = new Viewer2D(container);
|
||||
viewerRef.current = viewer;
|
||||
viewer.setTheme(true);
|
||||
viewer.onSelect((entity) => setSelectedEntity(entity));
|
||||
viewer.onPointerWorld((p) => setPointerWorld(p));
|
||||
|
||||
const onResize = () => viewer.resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
(async () => {
|
||||
const saved = await getAllOptions();
|
||||
const next: PersistedOptions = {
|
||||
grid: typeof saved.grid === 'boolean' ? saved.grid : DEFAULT_OPTIONS.grid,
|
||||
zoomSpeed: typeof saved.zoomSpeed === 'number' ? saved.zoomSpeed : DEFAULT_OPTIONS.zoomSpeed,
|
||||
zScale: typeof saved.zScale === 'number' ? saved.zScale : DEFAULT_OPTIONS.zScale,
|
||||
lineweightOn:
|
||||
saved.lineweightOn === true || saved.lineweightOn === false ? saved.lineweightOn : null,
|
||||
lineweightScale: typeof saved.lineweightScale === 'number' ? saved.lineweightScale : null,
|
||||
};
|
||||
setOptions(next);
|
||||
viewer.setGrid(next.grid);
|
||||
viewer.setZoomSpeed(next.zoomSpeed);
|
||||
viewer.setZScale(next.zScale);
|
||||
viewer.setLineweightEnabled(next.lineweightOn);
|
||||
viewer.setLineweightScale(next.lineweightScale);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
viewer.onPointerWorld(null);
|
||||
viewerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadParseResult = useCallback(
|
||||
(result: unknown) => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
const spaceList = listCadSpaces(result as any);
|
||||
const def = pickDefaultSpace(spaceList);
|
||||
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
|
||||
refreshSpaces();
|
||||
refreshLayers();
|
||||
setHiddenLayersState(new Set());
|
||||
setSelectedEntity(null);
|
||||
},
|
||||
[refreshSpaces, refreshLayers],
|
||||
);
|
||||
|
||||
const loadArrayBuffer = useCallback(
|
||||
async (name: string, buf: ArrayBuffer) => {
|
||||
setStatus('loading');
|
||||
setStatusMessage(`불러오는 중… (${name})`);
|
||||
setLoadingProgress(null);
|
||||
try {
|
||||
const result = await parseByName(name, buf);
|
||||
loadParseResult(result);
|
||||
setStatus('ready');
|
||||
setStatusMessage(`로드 완료: ${name}`);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setStatusMessage(err instanceof Error ? err.message : '파일을 불러오지 못했습니다');
|
||||
} finally {
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
},
|
||||
[loadParseResult],
|
||||
);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const buf = await file.arrayBuffer();
|
||||
await loadArrayBuffer(file.name, buf);
|
||||
},
|
||||
[loadArrayBuffer],
|
||||
);
|
||||
|
||||
const loadUrl = useCallback(
|
||||
async (url: string, label?: string) => {
|
||||
const name = label ?? url.split('/').pop() ?? url;
|
||||
setStatus('loading');
|
||||
setStatusMessage(`다운로드 중… (${name})`);
|
||||
setLoadingProgress(0);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
|
||||
const total = Number(res.headers.get('content-length') ?? 0);
|
||||
let buf: ArrayBuffer;
|
||||
if (total > 0 && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setLoadingProgress(Math.min(99, Math.round((received / total) * 100)));
|
||||
}
|
||||
}
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
buf = merged.buffer;
|
||||
} else {
|
||||
setLoadingProgress(null);
|
||||
buf = await res.arrayBuffer();
|
||||
}
|
||||
await loadArrayBuffer(name, buf);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setStatusMessage(err instanceof Error ? err.message : '다운로드하지 못했습니다');
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
},
|
||||
[loadArrayBuffer],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => viewerRef.current?.fit(), []);
|
||||
|
||||
const zoomToSelection = useCallback(() => viewerRef.current?.zoomToSelection(), []);
|
||||
|
||||
const selectSpace = useCallback(
|
||||
(handleHex: string | null) => {
|
||||
viewerRef.current?.setSpace(handleHex);
|
||||
refreshSpaces();
|
||||
refreshLayers();
|
||||
},
|
||||
[refreshSpaces, refreshLayers],
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
viewerRef.current?.setTheme(next === 'dark');
|
||||
refreshLayers();
|
||||
return next;
|
||||
});
|
||||
}, [refreshLayers]);
|
||||
|
||||
const setGrid = useCallback((on: boolean) => {
|
||||
viewerRef.current?.setGrid(on);
|
||||
setOptions((o) => ({ ...o, grid: on }));
|
||||
void setOption('grid', on);
|
||||
}, []);
|
||||
|
||||
const setZoomSpeed = useCallback((v: number) => {
|
||||
viewerRef.current?.setZoomSpeed(v);
|
||||
setOptions((o) => ({ ...o, zoomSpeed: v }));
|
||||
void setOption('zoomSpeed', v);
|
||||
}, []);
|
||||
|
||||
const setZScale = useCallback((v: number) => {
|
||||
viewerRef.current?.setZScale(v);
|
||||
setOptions((o) => ({ ...o, zScale: v }));
|
||||
void setOption('zScale', v);
|
||||
}, []);
|
||||
|
||||
const setLineweightOn = useCallback((on: boolean | null) => {
|
||||
viewerRef.current?.setLineweightEnabled(on);
|
||||
setOptions((o) => ({ ...o, lineweightOn: on }));
|
||||
void setOption('lineweightOn', on);
|
||||
}, []);
|
||||
|
||||
const setLineweightScale = useCallback((v: number | null) => {
|
||||
viewerRef.current?.setLineweightScale(v);
|
||||
setOptions((o) => ({ ...o, lineweightScale: v }));
|
||||
void setOption('lineweightScale', v);
|
||||
}, []);
|
||||
|
||||
const set25D = useCallback((on: boolean) => {
|
||||
viewerRef.current?.set25D(on);
|
||||
setIs25DState(on);
|
||||
}, []);
|
||||
|
||||
const resetOptions = useCallback(() => {
|
||||
setLineweightScale(null);
|
||||
setLineweightOn(null);
|
||||
setGrid(false);
|
||||
setZoomSpeed(1);
|
||||
setZScale(1);
|
||||
set25D(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const toggleLayer = useCallback((name: string) => {
|
||||
setHiddenLayersState((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAllLayers = useCallback(() => {
|
||||
setHiddenLayersState(() => {
|
||||
const next = new Set<string>();
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hideAllLayers = useCallback(() => {
|
||||
setHiddenLayersState(() => {
|
||||
const next = new Set(layers.map((l) => l.name));
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, [layers]);
|
||||
|
||||
const getLineweightClasses = useCallback(() => viewerRef.current?.getLineweightClasses() ?? [], []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewerRef,
|
||||
status,
|
||||
statusMessage,
|
||||
setStatusMessage,
|
||||
loadingProgress,
|
||||
selectedEntity,
|
||||
pointerWorld,
|
||||
spaces,
|
||||
activeSpace,
|
||||
layers,
|
||||
hiddenLayers,
|
||||
theme,
|
||||
is25D,
|
||||
options,
|
||||
loadFile,
|
||||
loadUrl,
|
||||
fit,
|
||||
zoomToSelection,
|
||||
selectSpace,
|
||||
toggleTheme,
|
||||
set25D,
|
||||
setGrid,
|
||||
setZoomSpeed,
|
||||
setZScale,
|
||||
setLineweightOn,
|
||||
setLineweightScale,
|
||||
resetOptions,
|
||||
toggleLayer,
|
||||
showAllLayers,
|
||||
hideAllLayers,
|
||||
getLineweightClasses,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDwgViewer = ReturnType<typeof useDwgViewer>;
|
||||
+8
-2
@@ -1,4 +1,10 @@
|
||||
/* 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";
|
||||
@import "@hm-design/ui/theme.css";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Entity Property Inspector module for dwg-dxf-viewer-sample.
|
||||
* Formats CAD entity properties and renders detailed property UI panels.
|
||||
* Entity Property formatter, ported unchanged from dwg-dxf-viewer-sample's
|
||||
* propertyInspector.ts (DOM-rendering half replaced by PropertiesPanel.tsx).
|
||||
*/
|
||||
|
||||
export interface FormattedProperties {
|
||||
@@ -285,74 +285,3 @@ export function formatEntityProperties(entity: any): FormattedProperties {
|
||||
const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`;
|
||||
return { id, type, handle: String(handle), layer: String(layer), general, geometry };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function renderPropertyInspector(
|
||||
bodyEl: HTMLElement,
|
||||
entity: any,
|
||||
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
|
||||
): void {
|
||||
if (!bodyEl) return;
|
||||
|
||||
if (!entity) {
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
|
||||
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
|
||||
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
|
||||
|
||||
const prop = formatEntityProperties(entity);
|
||||
|
||||
const renderRows = (rows: [string, string][]) =>
|
||||
rows
|
||||
.map(
|
||||
([k, v]) => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
|
||||
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
|
||||
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:14px">
|
||||
<!-- Entity ID Tag -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
|
||||
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
|
||||
</div>
|
||||
|
||||
<!-- General Section -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
|
||||
일반 정보 (General)
|
||||
</div>
|
||||
${renderRows(prop.general)}
|
||||
</div>
|
||||
|
||||
<!-- Geometry Section -->
|
||||
${
|
||||
prop.geometry.length > 0
|
||||
? `
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
|
||||
기하 속성 (Geometry)
|
||||
</div>
|
||||
${renderRows(prop.geometry)}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/** 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)}`
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+9
-15
@@ -1,16 +1,10 @@
|
||||
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"
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// 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>,
|
||||
)
|
||||
// No <StrictMode>: Viewer2D (ported unchanged from the vanilla app) has no
|
||||
// dispose/teardown API and attaches permanent window/canvas listeners in its
|
||||
// constructor. StrictMode's dev-only mount→unmount→remount double-invoke
|
||||
// would construct it twice into the same container with no way to tear the
|
||||
// first instance down, leaving two overlapping WebGL canvases.
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
File renamed without changes.
File renamed without changes.
Vendored
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+5
-8
@@ -1,19 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"]
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
+10
-19
@@ -1,22 +1,13 @@
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
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"],
|
||||
})
|
||||
server: { port: 5173, strictPort: true },
|
||||
// HMDesign (git submodule) has its own example/export HTML entries with their
|
||||
// own `@/...` path aliases — without pinning `entries`, Vite's dependency
|
||||
// scanner crawls every *.html under the project root and trips over them.
|
||||
optimizeDeps: { include: ['three', 'dxf-parser', 'opentype.js'], entries: ['index.html'] },
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
});
|
||||
Reference in new issue
Block a user