Compare commits

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

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

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

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

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

Refs #1
2026-10-12 17:42:58 +09:00
72 changed files with 7271 additions and 9642 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
*.wasm binary
*.dwg binary
*.ttf binary
*.dxf -text
*.patch -text
+3 -3
View File
@@ -1,6 +1,6 @@
node_modules/
dist/
*.log
*.tsbuildinfo
dist2/
*.local
.DS_Store
.vite/
*.tsbuildinfo
+2 -3
View File
@@ -1,4 +1,3 @@
[submodule "vendor/HMDesign"]
path = vendor/HMDesign
[submodule "HMDesign"]
path = HMDesign
url = https://gitea.hmac.kr/b25027/HMDesign.git
branch = main
View File
File renamed without changes.
-63
View File
@@ -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 정보 버튼과 상태 표시줄의 "오픈소스 고지"에서 열립니다.
-78
View File
@@ -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) |
+258
View File
@@ -0,0 +1,258 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HMDesign 적용 작업 기록 — 2026-10-12</title>
<style>
:root {
--bg: #ffffff;
--fg: #1b1f24;
--muted: #6b7280;
--border: #e2e4e9;
--card: #f7f8fa;
--accent: #2563eb;
--ok: #15803d;
--ok-bg: #ecfdf3;
--warn: #92400e;
--warn-bg: #fef6e7;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--border: #30363d;
--card: #161b22;
--accent: #60a5fa;
--ok: #4ade80;
--ok-bg: #0f2a1a;
--warn: #fbbf24;
--warn-bg: #2a2110;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
line-height: 1.6;
}
main {
max-width: 880px;
margin: 0 auto;
padding: 40px 20px 80px;
}
header h1 {
font-size: 1.6rem;
margin: 0 0 4px;
}
header p {
color: var(--muted);
margin: 0 0 32px;
font-size: 0.9rem;
}
h2 {
font-size: 1.15rem;
margin: 40px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
h3 {
font-size: 1rem;
margin: 20px 0 8px;
}
p, li { font-size: 0.92rem; }
code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
pre {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-size: 0.82rem;
}
pre code { background: none; border: none; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin: 12px 0 20px;
}
th, td {
text-align: left;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th { color: var(--muted); font-weight: 600; }
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.warn { background: var(--warn-bg); color: var(--warn); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin: 12px 0;
}
.meta-list { list-style: none; padding: 0; margin: 0; }
.meta-list li { margin-bottom: 6px; }
.meta-list b { color: var(--muted); font-weight: 500; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
ul { padding-left: 1.3em; }
.timeline { border-left: 2px solid var(--border); padding-left: 18px; margin: 12px 0; }
.timeline .entry { margin-bottom: 14px; }
.timeline time { display: block; font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
</style>
</head>
<body>
<main>
<header>
<h1>HMDesign 적용 작업 기록</h1>
<p>dwg-dxf-viewer-hm · 2026-10-12 · Gitea 이슈 <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/2">#2 hmdesign 적용</a> / <a href="https://gitea.hmac.kr/kimminsung/dwg-dxf-viewer-hm/issues/1">#1 디자인팀 디자인 시스템 적용</a></p>
</header>
<h2>개요</h2>
<p>
기존 vanilla TS+Vite+Three.js DWG/DXF 뷰어(<code>dwg-dxf-viewer-sample</code>)를 건드리지 않고,
이 저장소(<code>dwg-dxf-viewer-hm</code>)에 <b>React 19 + HMDesign 디자인 시스템</b> 기반으로
UI/UX를 새로 구성했다. 뷰어 렌더링 엔진(<code>Viewer2D.js</code> + 파서들)은 로직을
그대로 포팅하고, 그 위의 모든 UI(툴바·레이어/속성/옵션 패널·좌표 표시·라이선스 팝업·로딩바)는
HMDesign 컴포넌트로 새로 디자인했다. 기존 UI의 색/아이콘/레이아웃은 가져오지 않았고,
<b>기능만</b> 1:1 유지하는 것을 목표로 했다.
</p>
<h2>작업 흐름</h2>
<div class="timeline">
<div class="entry">
<time>16:59</time>
<div><b>Gitea 퍼블리시</b> — <code>dwg-dxf-viewer-hm</code>을 public repo로 생성, 로컬 git init 후 최초 push.</div>
</div>
<div class="entry">
<time>17:01</time>
<div><b>이슈 #1 생성</b> — "디자인팀 디자인 시스템 적용": 앞으로 구현되는 UI/UX 작업을 커밋으로 연결.</div>
</div>
<div class="entry">
<time>17:4x</time>
<div><b>포팅 계획 확정</b> — 현재 디렉토리 재사용 / HMDesign은 git submodule+npm workspace로 연결 / Measure 기능 비노출 / 로딩 progress bar는 HM 추천 방식으로 신규 연결, 3가지+1가지 질문에 대한 사용자 답변을 바탕으로 계획 수립 후 승인.</div>
</div>
<div class="entry">
<time>17:42</time>
<div><b>1차 구현 커밋</b> — submodule 추가, 워크스페이스 연결, <code>src/viewer2d/*</code> 포팅, React 컴포넌트 전체 신규 작성.</div>
</div>
<div class="entry">
<time>17:46</time>
<div><b>Guard 수정 커밋</b> — <code>ai-generation-guard.mjs</code> REUSE-004 지적(네이티브 <code>&lt;label&gt;</code>)을 HM <code>Label</code>로 교체.</div>
</div>
<div class="entry">
<time>17:53</time>
<div><b>버그 수정 커밋</b> — 샘플 로드 후 캔버스에 아무것도 안 보이는 문제 원인 확정 및 수정 (아래 "발견된 버그" 참고).</div>
</div>
<div class="entry">
<time>18:08</time>
<div><b>이슈 #2 생성</b> — "hmdesign 적용", 이 리포트 작성 및 커밋.</div>
</div>
</div>
<h2>아키텍처: HMDesign 연결</h2>
<p>
<code>@hm-design/ui</code>는 npm 레지스트리에 배포되지 않고 HMDesign 저장소 자신의 npm workspace
(<code>packages/*</code>) 안에서만 resolve된다. <code>exports</code>는 전부 <code>dist/*</code>를
가리키므로 반드시 빌드가 먼저 끝나 있어야 한다.
</p>
<pre><code>git submodule add https://gitea.hmac.kr/b25027/HMDesign.git HMDesign
git submodule update --init --recursive
cd HMDesign &amp;&amp; npm ci &amp;&amp; npm run build:packages &amp;&amp; cd .. # HMDesign 자체 devDependency로 dist/ 생성
npm install # 루트 workspaces(["HMDesign/packages/ui"])가 symlink 연결
npm run dev</code></pre>
<p>
HMDesign 내부 빌드는 HMDesign 자신의 <code>node_modules</code>(Storybook/Tailwind/TS7 등)로 수행하고,
외부 앱(이 저장소)은 빌드된 결과물만 workspace symlink로 소비한다 — 두 트리의 의존성 버전이 섞이지 않는다.
<code>@hm-design/center-graphic</code>은 라이선스 미확정으로 사용하지 않았다.
</p>
<p>
Vite 설정에서 <code>optimizeDeps.entries: ['index.html']</code>을 명시해야 한다 — 지정하지 않으면
Vite의 의존성 스캐너가 submodule(HMDesign) 안의 다른 예제용 <code>*.html</code>까지 크롤링하다
해석할 수 없는 <code>@/...</code> alias에서 죽는다.
</p>
<h2>구현 내용</h2>
<h3>디렉토리 구조</h3>
<pre><code>src/
├── main.tsx, App.tsx, index.css
├── viewer2d/ ← 기존 엔진 전체, 로직 불변, import 경로만 조정
├── lib/
│ ├── optionsStore.ts ← IndexedDB 영속, 그대로 포팅
│ └── propertyFormat.ts← 속성 포맷터만 추출 포팅 (DOM 렌더는 React로 대체)
├── components/ ← Toolbar / LayerPanel / PropertiesPanel / OptionsPanel /
│ DraggablePanel / CoordReadout / LicenseNotice / LoadProgress
└── hooks/
├── useDwgViewer.ts ← Viewer2D 인스턴스 소유 + 파일 로드 파이프라인 + 옵션 동기화
└── useDraggablePanel.ts ← panelUtils.ts 드래그/collapse 로직의 React 훅 포팅</code></pre>
<h3>UI → HMDesign 컴포넌트 매핑 (요약)</h3>
<table>
<tr><th>원본 UI</th><th>HMDesign 매핑</th></tr>
<tr><td>Open file / 샘플 3버튼</td><td><code>Button</code> (+숨김 <code>&lt;input type=file&gt;</code>, HM에 대응 컴포넌트 없음)</td></tr>
<tr><td>Model/Layout 탭</td><td><code>Tabs</code></td></tr>
<tr><td>2.5D·테마 토글</td><td><code>Switch</code> + <code>Label</code></td></tr>
<tr><td>레이어/옵션/속성 패널 열기</td><td><code>Toggle</code></td></tr>
<tr><td>레이어 검색/전체켜기끄기</td><td><code>FilterBar</code> / <code>FilterBarSearch</code> / <code>FilterBarActions</code></td></tr>
<tr><td>레이어 행(색상/개수/가시성)</td><td>커스텀 — color swatch(동적 color), <code>Badge</code>, lucide <code>Eye</code>/<code>EyeOff</code></td></tr>
<tr><td>패널 자체(드래그/접기/리사이즈)</td><td><code>DraggablePanel</code> 커스텀(HM <code>Card</code> 기반 + 포인터 드래그 훅, resize는 네이티브 CSS)</td></tr>
<tr><td>슬라이더+숫자입력 3종</td><td><code>Slider</code> + <code>Input</code></td></tr>
<tr><td>라이선스/크레딧</td><td><code>Dialog</code> (THIRD-PARTY-NOTICES / MPL-2.0 / patch 링크 3종 유지)</td></tr>
<tr><td>상단 로딩바</td><td><code>Progress</code> (원본은 미연결 죽은 코드였음 — HM 추천 방식으로 신규 실연결)</td></tr>
</table>
<p>Navigation(GNB/LNB/Dashboard)은 채용하지 않았다 — 멀티페이지 앱이 아닌 전체화면 캔버스형 단일 도구이기 때문.</p>
<h2>발견된 버그 및 수정</h2>
<div class="card">
<p><b>증상</b>: 샘플 DWG/DXF 3개 버튼 모두 "로드완료" 상태 텍스트는 뜨지만 캔버스에 아무것도 렌더되지 않음.</p>
<p><b>원인 확정</b>: <code>Viewer2D</code>(포팅된 vanilla 엔진)는 dispose/teardown API가 없고 생성자에서
<code>window</code>에 영구 리스너를 걸고 자체 <code>requestAnimationFrame</code> 루프를 시작한다.
React <code>&lt;StrictMode&gt;</code>의 개발 모드 mount→unmount→remount 더블 호출이
<code>Viewer2D</code>를 같은 container에 두 번 생성했고, 첫 인스턴스를 정리할 방법이 없어
캔버스가 중첩/렌더가 꼬였다.</p>
<p><b>수정 완료·미검증</b>: <code>main.tsx</code>에서 <code>&lt;StrictMode&gt;</code> 제거,
<code>useDwgViewer</code> 마운트 effect에서 construction 전에 <code>container.replaceChildren()</code>으로
방어적 클리어 추가. 코드 수정은 했으나 이 세션엔 브라우저 도구가 없어 실제 화면에서 재확인은 못 함 —
사용자 브라우저에서 재검증 필요.</p>
</div>
<h2>검증 상태</h2>
<ul>
<li><span class="badge ok">검증됨</span> npm workspace symlink 연결 (<code>@hm-design/ui</code> resolve 확인)</li>
<li><span class="badge ok">검증됨</span> <code>tsc --noEmit</code> 타입체크 통과</li>
<li><span class="badge ok">검증됨</span> Vite dev 서버에서 전체 모듈 HTTP 200 서빙 확인</li>
<li><span class="badge ok">검증됨</span> <code>ai-generation-guard.mjs</code> FAIL 0건 (NOT VERIFIED 4건은 HM에 대응 컴포넌트 없는 동적 값 — 문서화된 예외)</li>
<li><span class="badge warn">미검증</span> 실제 브라우저 렌더링 (WASM 파싱, Three.js 캔버스, 패널 드래그/접기, IndexedDB 옵션 영속, 390~1440px 반응형) — 이 세션에 브라우저 도구 없음, 컴파일 통과는 동작 검증이 아님</li>
</ul>
<h2>다음 할 일</h2>
<ul>
<li>StrictMode 제거 수정 후 브라우저에서 샘플 3개 재확인</li>
<li>390/768/1280/1440px 반응형 확인</li>
<li>기존 프로젝트 기능 체크리스트와 1:1 대조 (파일 로드, pan/zoom/orbit, Fit+F, 2.5D, 그리드, 줌속도, 선가중치, Model/Layout 탭, 엔티티 선택→속성, 줌투셀렉션, 좌표 readout, 패널 영속, 한글 텍스트, 라이선스 링크, 신규 progress bar)</li>
</ul>
</main>
</body>
</html>
+1 -2
View File
@@ -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>
+5802 -6485
View File
File diff suppressed because it is too large. Load diff
+13 -21
View File
@@ -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"
}
}
-361
View File
@@ -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>
+5 -13
View File
@@ -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.
-48
View File
@@ -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
View File
@@ -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>
);
}
-151
View File
@@ -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>
)
}
-101
View File
@@ -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>
)
}
+24
View File
@@ -0,0 +1,24 @@
function formatCadCoord(v: number | null): string {
if (v === null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
const digits = abs >= 1e6 ? 1 : abs >= 1e3 ? 2 : abs >= 1 ? 3 : 4;
return v.toFixed(digits);
}
export interface CoordReadoutProps {
point: { x: number; y: number; z: number } | null;
spaceKind: 'model' | 'paper' | 'other' | null;
}
export function CoordReadout({ point, spaceKind }: CoordReadoutProps) {
const unitLabel = spaceKind === 'paper' ? 'Paper' : 'UCS';
return (
<div
className="pointer-events-none fixed bottom-3 right-3 z-10 rounded-md border bg-card/90 px-3 py-1.5 font-mono text-xs text-muted-foreground shadow-sm backdrop-blur"
title={`마우스 위치 CAD 좌표 (${spaceKind === 'paper' ? 'Layout=Paper' : 'Model=UCS'} 도면단위)`}
>
X {formatCadCoord(point?.x ?? null)} · Y {formatCadCoord(point?.y ?? null)}{' '}
<span className="opacity-60">({unitLabel})</span>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@hm-design/ui';
import { Button } from '@hm-design/ui';
import { ChevronDown, ChevronRight, X } from 'lucide-react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export interface DraggablePanelProps {
title: string;
icon?: ReactNode;
initial: { left: number; top: number };
width?: number;
height?: number;
open: boolean;
onClose: () => void;
headerActions?: ReactNode;
children: ReactNode;
}
export function DraggablePanel({
title,
icon,
initial,
width = 320,
height = 420,
open,
onClose,
headerActions,
children,
}: DraggablePanelProps) {
const { panelRef, style, collapsed, toggleCollapse, headerProps } = useDraggablePanel(initial);
if (!open) return null;
return (
<Card
ref={panelRef}
size="sm"
className="fixed shadow-lg overflow-hidden"
style={{
...style,
width,
height: collapsed ? 'auto' : height,
minWidth: 250,
minHeight: collapsed ? 'auto' : 140,
maxWidth: '90vw',
maxHeight: '90vh',
resize: collapsed ? 'none' : 'both',
}}
>
<CardHeader className="select-none border-b py-2" {...headerProps}>
<CardTitle className="flex items-center gap-2 text-sm">
{icon}
{title}
</CardTitle>
<CardAction className="flex items-center gap-1">
{headerActions}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={collapsed ? '펼치기' : '접기'}
title={collapsed ? '펼치기' : '접기'}
onClick={toggleCollapse}
>
{collapsed ? <ChevronRight /> : <ChevronDown />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="닫기"
title="닫기"
onClick={onClose}
>
<X />
</Button>
</CardAction>
</CardHeader>
{!collapsed && (
<CardContent className="overflow-auto" style={{ height: 'calc(100% - 44px)' }}>
{children}
</CardContent>
)}
</Card>
);
}
-151
View File
@@ -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()
}
-117
View File
@@ -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>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useMemo, useState } from 'react';
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch } from '@hm-design/ui';
import { Eye, EyeOff, Layers } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import type { LayerRow } from '../hooks/useDwgViewer';
export interface LayerPanelProps {
open: boolean;
onClose: () => void;
layers: LayerRow[];
hiddenLayers: Set<string>;
onToggleLayer: (name: string) => void;
onShowAll: () => void;
onHideAll: () => void;
}
export function LayerPanel({ open, onClose, layers, hiddenLayers, onToggleLayer, onShowAll, onHideAll }: LayerPanelProps) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return layers;
return layers.filter((l) => l.name.toLowerCase().includes(q));
}, [layers, query]);
return (
<DraggablePanel
title={`레이어 (${layers.length}개)`}
icon={<Layers className="size-4" />}
initial={{ left: window.innerWidth - 344, top: 56 }}
open={open}
onClose={onClose}
>
<div className="space-y-3">
<FilterBar>
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
<FilterBarActions className="ml-2 gap-1">
<Button type="button" variant="outline" size="xs" onClick={onShowAll}>
전체 켜기
</Button>
<Button type="button" variant="outline" size="xs" onClick={onHideAll}>
전체 끄기
</Button>
</FilterBarActions>
</FilterBar>
{layers.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">도면에 레이어 정보가 없습니다</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없습니다 ("{query}")</p>
) : (
<ul className="space-y-1">
{filtered.map((layer) => {
const visible = !hiddenLayers.has(layer.name);
return (
<li
key={layer.name}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-muted/50"
style={{ opacity: visible ? 1 : 0.45 }}
>
<span
className="size-2.5 shrink-0 rounded-full border"
style={{ background: layer.colorHex }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={layer.name}>
{layer.name}
</span>
<Badge variant="secondary">{layer.count}</Badge>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={visible ? '숨기기' : '표시하기'}
title={visible ? '숨기기' : '표시하기'}
onClick={() => onToggleLayer(layer.name)}
>
{visible ? <Eye /> : <EyeOff />}
</Button>
</li>
);
})}
</ul>
)}
</div>
</DraggablePanel>
);
}
-82
View File
@@ -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>
)
}
+50
View File
@@ -0,0 +1,50 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@hm-design/ui';
import { Info } from 'lucide-react';
const PIPELINE_STEPS = [
'파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)',
'CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)',
'Three.js 씬 구성 및 렌더링',
];
export function LicenseNotice() {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" aria-label="정보 및 라이선스" title="정보 및 라이선스">
<Info />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>모듈 구성 · 라이선스 고지</DialogTitle>
<DialogDescription>이 뷰어가 사용하는 처리 파이프라인과 서드파티 라이선스 고지입니다.</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
{PIPELINE_STEPS.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div className="flex flex-col gap-1 border-t pt-3 text-sm">
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank" rel="noreferrer">
THIRD-PARTY-NOTICES.md
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-MPL-2.0.txt" target="_blank" rel="noreferrer">
acadrust MPL-2.0 전문
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank" rel="noreferrer">
acadrust 0.4.1 read_mesh 패치
</a>
</div>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Progress } from '@hm-design/ui';
export interface LoadProgressProps {
active: boolean;
value: number | null;
error?: boolean;
}
export function LoadProgress({ active, value, error }: LoadProgressProps) {
if (!active) return null;
return (
<Progress
value={value ?? undefined}
className={`fixed inset-x-0 top-0 z-50 h-1 rounded-none ${
value === null ? 'animate-pulse' : ''
} ${error ? '[&>*]:bg-destructive' : ''}`}
/>
);
}
+145 -125
View File
@@ -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>
);
}
+58 -60
View File
@@ -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>
);
}
-70
View File
@@ -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>
)
}
-79
View File
@@ -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>
)
}
-63
View File
@@ -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>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { useRef } from 'react';
import { Button, Label, Switch, Tabs, TabsList, TabsTrigger, Badge, Toggle } from '@hm-design/ui';
import { FolderOpen, Scan, Layers, SlidersHorizontal, Info as InfoIcon } from 'lucide-react';
import type { CadSpace } from '../viewer2d/cadSpaces';
import { LicenseNotice } from './LicenseNotice';
export interface ToolbarProps {
status: string;
statusError: boolean;
spaces: CadSpace[];
activeSpace: string | null;
onSelectSpace: (handleHex: string | null) => void;
onOpenFile: (file: File) => void;
onLoadSample: (url: string, label: string) => void;
onFit: () => void;
is25D: boolean;
onToggle25D: (on: boolean) => void;
canvasDark: boolean;
onToggleTheme: () => void;
layersOpen: boolean;
onToggleLayers: () => void;
optionsOpen: boolean;
onToggleOptions: () => void;
propsOpen: boolean;
onToggleProps: () => void;
}
export function Toolbar({
status,
statusError,
spaces,
activeSpace,
onSelectSpace,
onOpenFile,
onLoadSample,
onFit,
is25D,
onToggle25D,
canvasDark,
onToggleTheme,
layersOpen,
onToggleLayers,
optionsOpen,
onToggleOptions,
propsOpen,
onToggleProps,
}: ToolbarProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const showSpaceTabs = spaces.length > 1 || (spaces[0]?.entityCount ?? 0) > 0;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex items-center gap-2 border-b bg-card/90 px-3 py-2 backdrop-blur [&>*]:pointer-events-auto">
<input
ref={fileInputRef}
type="file"
accept=".dwg,.dxf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onOpenFile(file);
e.currentTarget.value = '';
}}
/>
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<FolderOpen /> 파일 열기
</Button>
<Button type="button" variant="secondary" size="sm" title="acadrust-dwg WASM" onClick={() => onLoadSample('/samples/BasicSample.dwg', 'BasicSample.dwg')}>
Sample DWG
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => onLoadSample('/samples/civil.dwg', 'C0060202-001-평면및종단면도.dwg')}>
Sample Civil
</Button>
<Button type="button" variant="secondary" size="sm" title="dxf-parser" onClick={() => onLoadSample('/samples/simple.dxf', 'simple.dxf')}>
Sample DXF
</Button>
{showSpaceTabs && (
<Tabs value={activeSpace ?? undefined} onValueChange={(v) => onSelectSpace(v)}>
<TabsList>
{spaces.map((s) => (
<TabsTrigger key={s.handleHex} value={s.handleHex} title={`${s.name} · ${s.handleHex}`}>
{s.label}
{s.entityCount > 0 ? ` (${s.entityCount})` : ''}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Button type="button" variant="ghost" size="sm" onClick={onFit} title="Fit (F)">
<Scan /> Fit
</Button>
<Label className="gap-1.5 text-xs text-muted-foreground">
<Switch checked={is25D} onCheckedChange={onToggle25D} />
2.5D
</Label>
<Label className="gap-1.5 text-xs text-muted-foreground">
<Switch checked={canvasDark} onCheckedChange={onToggleTheme} />
캔버스 테마
</Label>
<div className="ml-auto flex items-center gap-1">
<Toggle pressed={layersOpen} onPressedChange={onToggleLayers} size="sm" aria-label="레이어 패널" title="Layers">
<Layers />
</Toggle>
<Toggle pressed={optionsOpen} onPressedChange={onToggleOptions} size="sm" aria-label="옵션 패널" title="Options">
<SlidersHorizontal />
</Toggle>
<Toggle pressed={propsOpen} onPressedChange={onToggleProps} size="sm" aria-label="속성 패널" title="Properties">
<InfoIcon />
</Toggle>
<LicenseNotice />
</div>
<Badge variant={statusError ? 'destructive' : 'secondary'} className="max-w-[320px] truncate">
{status}
</Badge>
</div>
);
}
-121
View File
@@ -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>
)
}
-195
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
-149
View File
@@ -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,
};
}
-367
View File
@@ -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;
}
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useRef, useState } from 'react';
/**
* React port of dwg-dxf-viewer-sample's panelUtils.ts drag+collapse mechanics.
* Resize is left to native CSS `resize: both` on the panel element, same as the original.
*/
let globalMaxZIndex = 30;
const INTERACTIVE_SELECTOR = 'button, input, select, a, label, [data-panel-no-drag]';
export interface UseDraggablePanelResult {
panelRef: React.RefObject<HTMLDivElement | null>;
style: React.CSSProperties;
zIndex: number;
collapsed: boolean;
toggleCollapse: () => void;
headerProps: {
onPointerDown: (e: React.PointerEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
style: React.CSSProperties;
};
bringToFront: () => void;
}
export function useDraggablePanel(initial: { left: number; top: number }): UseDraggablePanelResult {
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState(initial);
const [collapsed, setCollapsed] = useState(false);
const [zIndex, setZIndex] = useState(() => ++globalMaxZIndex);
const dragState = useRef<{ startX: number; startY: number; startLeft: number; startTop: number } | null>(null);
const bringToFront = useCallback(() => {
setZIndex(++globalMaxZIndex);
}, []);
const onPointerMove = useCallback((e: PointerEvent) => {
const st = dragState.current;
const panel = panelRef.current;
if (!st || !panel) return;
const dx = e.clientX - st.startX;
const dy = e.clientY - st.startY;
const maxLeft = window.innerWidth - panel.offsetWidth - 10;
const maxTop = window.innerHeight - panel.offsetHeight - 10;
const left = Math.min(Math.max(st.startLeft + dx, 10), Math.max(10, maxLeft));
const top = Math.min(Math.max(st.startTop + dy, 10), Math.max(10, maxTop));
setPos({ left, top });
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.body.style.cursor = '';
}, [onPointerMove]);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
bringToFront();
const target = e.target as HTMLElement;
if (target.closest(INTERACTIVE_SELECTOR)) return;
const panel = panelRef.current;
if (!panel) return;
dragState.current = {
startX: e.clientX,
startY: e.clientY,
startLeft: panel.offsetLeft,
startTop: panel.offsetTop,
};
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.body.style.cursor = 'grabbing';
},
[bringToFront, onPointerMove, onPointerUp],
);
const toggleCollapse = useCallback(() => setCollapsed((c) => !c), []);
const onDoubleClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(INTERACTIVE_SELECTOR)) return;
toggleCollapse();
},
[toggleCollapse],
);
return {
panelRef,
style: { position: 'fixed', left: pos.left, top: pos.top, zIndex },
zIndex,
collapsed,
toggleCollapse,
headerProps: {
onPointerDown,
onDoubleClick,
style: { cursor: 'grab', touchAction: 'none' },
},
bringToFront,
};
}
+342
View File
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Viewer2D } from '../viewer2d/Viewer2D';
import { parseDwgBuffer } from '../viewer2d/dwgParser';
import { parseDxfBuffer } from '../viewer2d/parseDxf';
import { isDwg, isDxf, is2D } from '../viewer2d/ext2d';
import { listCadSpaces, pickDefaultSpace, type CadSpace } from '../viewer2d/cadSpaces';
import { setDefaultFontUrl } from '../viewer2d/slugText';
import { getAllOptions, setOption } from '../lib/optionsStore';
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
export interface LayerRow {
name: string;
colorHex: string;
count: number;
visible: boolean;
}
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface PersistedOptions {
grid: boolean;
zoomSpeed: number;
zScale: number;
lineweightOn: boolean | null;
lineweightScale: number | null;
}
const DEFAULT_OPTIONS: PersistedOptions = {
grid: false,
zoomSpeed: 1,
zScale: 1,
lineweightOn: null,
lineweightScale: null,
};
async function parseByName(name: string, buf: ArrayBuffer) {
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
if (isDxf(name)) return parseDxfBuffer(buf);
if (!is2D(name)) throw new Error('지원하지 않는 파일 형식입니다 (.dwg/.dxf만 지원)');
return parseDxfBuffer(buf);
}
export function useDwgViewer() {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer2D | null>(null);
const [status, setStatus] = useState<LoadStatus>('idle');
const [statusMessage, setStatusMessage] = useState('준비됨');
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
const [selectedEntity, setSelectedEntity] = useState<unknown>(null);
const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null);
const [spaces, setSpaces] = useState<CadSpace[]>([]);
const [activeSpace, setActiveSpace] = useState<string | null>(null);
const [layers, setLayers] = useState<LayerRow[]>([]);
const [hiddenLayers, setHiddenLayersState] = useState<Set<string>>(new Set());
const [theme, setThemeState] = useState<'dark' | 'light'>('dark');
const [is25D, setIs25DState] = useState(false);
const [options, setOptions] = useState<PersistedOptions>(DEFAULT_OPTIONS);
const refreshLayers = useCallback(() => {
const viewer = viewerRef.current;
if (!viewer) return;
const info = viewer.getLayerInfo();
setLayers(info.map((l) => ({ name: l.name, colorHex: l.colorHex, count: l.count, visible: l.visible })));
}, []);
const refreshSpaces = useCallback(() => {
const viewer = viewerRef.current;
if (!viewer) return;
setSpaces(viewer.getSpaces());
setActiveSpace(viewer.getActiveSpace());
}, []);
// mount Viewer2D once
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Viewer2D has no dispose/teardown API (permanent window/canvas
// listeners, its own rAF loop) — defensively clear any leftover canvas
// from a prior construction (e.g. a dev Fast Refresh remount) so it
// never stacks two live WebGL contexts in the same container.
container.replaceChildren();
const viewer = new Viewer2D(container);
viewerRef.current = viewer;
viewer.setTheme(true);
viewer.onSelect((entity) => setSelectedEntity(entity));
viewer.onPointerWorld((p) => setPointerWorld(p));
const onResize = () => viewer.resize();
window.addEventListener('resize', onResize);
(async () => {
const saved = await getAllOptions();
const next: PersistedOptions = {
grid: typeof saved.grid === 'boolean' ? saved.grid : DEFAULT_OPTIONS.grid,
zoomSpeed: typeof saved.zoomSpeed === 'number' ? saved.zoomSpeed : DEFAULT_OPTIONS.zoomSpeed,
zScale: typeof saved.zScale === 'number' ? saved.zScale : DEFAULT_OPTIONS.zScale,
lineweightOn:
saved.lineweightOn === true || saved.lineweightOn === false ? saved.lineweightOn : null,
lineweightScale: typeof saved.lineweightScale === 'number' ? saved.lineweightScale : null,
};
setOptions(next);
viewer.setGrid(next.grid);
viewer.setZoomSpeed(next.zoomSpeed);
viewer.setZScale(next.zScale);
viewer.setLineweightEnabled(next.lineweightOn);
viewer.setLineweightScale(next.lineweightScale);
})();
return () => {
window.removeEventListener('resize', onResize);
viewer.onPointerWorld(null);
viewerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadParseResult = useCallback(
(result: unknown) => {
const viewer = viewerRef.current;
if (!viewer) return;
const spaceList = listCadSpaces(result as any);
const def = pickDefaultSpace(spaceList);
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
refreshSpaces();
refreshLayers();
setHiddenLayersState(new Set());
setSelectedEntity(null);
},
[refreshSpaces, refreshLayers],
);
const loadArrayBuffer = useCallback(
async (name: string, buf: ArrayBuffer) => {
setStatus('loading');
setStatusMessage(`불러오는 중… (${name})`);
setLoadingProgress(null);
try {
const result = await parseByName(name, buf);
loadParseResult(result);
setStatus('ready');
setStatusMessage(`로드 완료: ${name}`);
} catch (err) {
setStatus('error');
setStatusMessage(err instanceof Error ? err.message : '파일을 불러오지 못했습니다');
} finally {
setLoadingProgress(null);
}
},
[loadParseResult],
);
const loadFile = useCallback(
async (file: File) => {
const buf = await file.arrayBuffer();
await loadArrayBuffer(file.name, buf);
},
[loadArrayBuffer],
);
const loadUrl = useCallback(
async (url: string, label?: string) => {
const name = label ?? url.split('/').pop() ?? url;
setStatus('loading');
setStatusMessage(`다운로드 중… (${name})`);
setLoadingProgress(0);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
const total = Number(res.headers.get('content-length') ?? 0);
let buf: ArrayBuffer;
if (total > 0 && res.body) {
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
setLoadingProgress(Math.min(99, Math.round((received / total) * 100)));
}
}
const merged = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
buf = merged.buffer;
} else {
setLoadingProgress(null);
buf = await res.arrayBuffer();
}
await loadArrayBuffer(name, buf);
} catch (err) {
setStatus('error');
setStatusMessage(err instanceof Error ? err.message : '다운로드하지 못했습니다');
setLoadingProgress(null);
}
},
[loadArrayBuffer],
);
const fit = useCallback(() => viewerRef.current?.fit(), []);
const zoomToSelection = useCallback(() => viewerRef.current?.zoomToSelection(), []);
const selectSpace = useCallback(
(handleHex: string | null) => {
viewerRef.current?.setSpace(handleHex);
refreshSpaces();
refreshLayers();
},
[refreshSpaces, refreshLayers],
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'dark' ? 'light' : 'dark';
viewerRef.current?.setTheme(next === 'dark');
refreshLayers();
return next;
});
}, [refreshLayers]);
const setGrid = useCallback((on: boolean) => {
viewerRef.current?.setGrid(on);
setOptions((o) => ({ ...o, grid: on }));
void setOption('grid', on);
}, []);
const setZoomSpeed = useCallback((v: number) => {
viewerRef.current?.setZoomSpeed(v);
setOptions((o) => ({ ...o, zoomSpeed: v }));
void setOption('zoomSpeed', v);
}, []);
const setZScale = useCallback((v: number) => {
viewerRef.current?.setZScale(v);
setOptions((o) => ({ ...o, zScale: v }));
void setOption('zScale', v);
}, []);
const setLineweightOn = useCallback((on: boolean | null) => {
viewerRef.current?.setLineweightEnabled(on);
setOptions((o) => ({ ...o, lineweightOn: on }));
void setOption('lineweightOn', on);
}, []);
const setLineweightScale = useCallback((v: number | null) => {
viewerRef.current?.setLineweightScale(v);
setOptions((o) => ({ ...o, lineweightScale: v }));
void setOption('lineweightScale', v);
}, []);
const set25D = useCallback((on: boolean) => {
viewerRef.current?.set25D(on);
setIs25DState(on);
}, []);
const resetOptions = useCallback(() => {
setLineweightScale(null);
setLineweightOn(null);
setGrid(false);
setZoomSpeed(1);
setZScale(1);
set25D(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleLayer = useCallback((name: string) => {
setHiddenLayersState((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, []);
const showAllLayers = useCallback(() => {
setHiddenLayersState(() => {
const next = new Set<string>();
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, []);
const hideAllLayers = useCallback(() => {
setHiddenLayersState(() => {
const next = new Set(layers.map((l) => l.name));
viewerRef.current?.setHiddenLayers(next);
return next;
});
}, [layers]);
const getLineweightClasses = useCallback(() => viewerRef.current?.getLineweightClasses() ?? [], []);
return {
containerRef,
viewerRef,
status,
statusMessage,
setStatusMessage,
loadingProgress,
selectedEntity,
pointerWorld,
spaces,
activeSpace,
layers,
hiddenLayers,
theme,
is25D,
options,
loadFile,
loadUrl,
fit,
zoomToSelection,
selectSpace,
toggleTheme,
set25D,
setGrid,
setZoomSpeed,
setZScale,
setLineweightOn,
setLineweightScale,
resetOptions,
toggleLayer,
showAllLayers,
hideAllLayers,
getLineweightClasses,
};
}
export type UseDwgViewer = ReturnType<typeof useDwgViewer>;
+8 -2
View File
@@ -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;
}
-49
View File
@@ -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
}
-80
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function renderPropertyInspector(
bodyEl: HTMLElement,
entity: any,
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
): void {
if (!bodyEl) return;
if (!entity) {
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
bodyEl.innerHTML = `
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
</div>`;
return;
}
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
const prop = formatEntityProperties(entity);
const renderRows = (rows: [string, string][]) =>
rows
.map(
([k, v]) => `
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
</div>`
)
.join('');
bodyEl.innerHTML = `
<div style="padding:14px">
<!-- Entity ID Tag -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
</div>
<!-- General Section -->
<div style="margin-bottom:14px">
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
일반 정보 (General)
</div>
${renderRows(prop.general)}
</div>
<!-- Geometry Section -->
${
prop.geometry.length > 0
? `
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
기하 속성 (Geometry)
</div>
${renderRows(prop.geometry)}
</div>`
: ''
}
</div>`;
}
-25
View File
@@ -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)}`
}
-34
View File
@@ -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
View File
@@ -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.
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
View File
@@ -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
View File
@@ -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'],
});