feat(viewer2d): render DWG lineweight in screen pixels

Every line was drawn 1px regardless of its CAD lineweight. Two causes:
the dwg-wasm parseResult never carried the field (acadrust reads
EntityCommon::line_weight but it was dropped in serialization), and the
renderer merged all segments into one LineSegments whose
LineBasicMaterial.linewidth WebGL ignores.

Resolve ByLayer/ByBlock/Default per entity and bucket segments by
dash|lineweight. Weighted buckets get their own LineSegments2 +
LineMaterial with worldUnits:false, so width stays constant in pixels
while zooming - AutoCAD LWDISPLAY semantics - at 8 px/mm (0.25mm = 2px).
Anything at or below the 0.25mm default stays in the hairline batch so
drawings that never assigned a weight render exactly as before.

Fat batches are instanced, so layer masking compacts the instance buffer
and lowers instanceCount instead of rewriting an index; slotOf tracks
where each segment moved so the selection highlight still finds its
vertices. Per-entity color spans (meta.spans) let the highlight repaint
across the hairline batch and every bucket it touched. Buckets past
400k segments fall back to hairline to bound GPU/heap cost.

Display follows the drawing's LWDISPLAY and can be forced from the
toolbar; the property panel now shows the entity lineweight.

Verified headless at a fixed camera on the 65k-entity road drawing:
cyan road edge 2px -> 5px, layer hide/restore returns the exact baseline
pixel counts, and a 0.35mm LWPOLYLINE selects and highlights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-08-04 17:25:11 +09:00
co-authored by Claude Opus 5
parent eec33820a6
commit c67a803e4d
12 changed files with 6922 additions and 34 deletions
+227
View File
@@ -0,0 +1,227 @@
# 개선 결과 보고 — SubDMesh + 선가중치 (2026-08-04)
| 항목 | 내용 |
|------|------|
| 작성 목적 | 같은 날 들어간 두 개선(SubDMesh 렌더링, 선가중치 렌더링)의 **측정 결과**와, 개별 문서에는 없는 **두 변경의 상호작용**을 남긴다 |
| 개별 문서 | [`subdmesh-rendering.md`](./subdmesh-rendering.md) · [`lineweight-rendering.md`](./lineweight-rendering.md) |
| 기준 도면 | `dist2/samples/대산당진 2공구_노면_20260804_143854.dwg` (37.1 MB, AC1032 / AutoCAD 2018) |
| 측정 방식 | `Viewer2D.js` 에서 `_meshSegs` · `_lwPx` 를 원본 그대로 들어내 실제 parseResult 에 돌린 값. 브라우저 픽셀 측정이 아니라 **세그먼트 회계** |
---
## 1. 요약
| | 개선 전 | 개선 후 |
|--|--------|--------|
| MESH 엔티티 파싱 | 0개 | 9개 |
| 도로면 메쉬 | 정점 100,000 / 면 14 (**깨진 값**) | 정점 105,497 / 면 185,444 / 모서리 290,931 |
| MESH 화면 출력 | 없음 | 315,633 선분 (면 wireframe) |
| parseResult 크기 | 116 MB | 130 MB (+12%) |
| 파싱 시간 | ~3.2 s | ~3.2 s (변화 없음) |
| 선가중치 | 전부 1px 헤어라인 | 3개 굵기 버킷이 fat line 으로 분리 (아래 3절) |
---
## 2. 기준 도면 프로필
파싱 결과 65,495 엔티티.
```
LWPOLYLINE 47,849 MTEXT 13,974 ATTRIB 1,504 TEXT 1,389
INSERT 752 ATTDEF 15 MESH 9 LINE 2 CIRCLE 1
```
MESH 9개:
| handle | layer | 정점 | 면 | 모서리 | 세분레벨 |
|--------|-------|------|-----|--------|---------|
| 4188 | 도로면 | 105,497 | 185,444 | 290,931 | 0 |
| 4189 | 부체도로면 | 11,712 | 12,568 | 24,226 | 0 |
| 296564 | 일반도로부체도로연결부 | 48 | 44 | 90 | 0 |
| 296766 · 296927 · 297111 · 297259 · 297433 · 297665 | 부체도로(부체도로)연결부 | 28~53 | 25~47 | 51~96 | 0 |
전부 `subdivisionLevel = 0` 삼각 TIN → Catmull-Clark 세분 미구현이 이 도면에서는
화면 차이를 만들지 않는다.
---
## 3. 개선 1 — SubDMesh
원인 분석과 수정 내용은 [`subdmesh-rendering.md`](./subdmesh-rendering.md) 에 있다.
여기에는 **결과 수치**만 남긴다.
### 3-1. 스트림 어긋남 해소
`read_mesh` 의 배열 상한을 `MAX_ARRAY_COUNT`(100,000) 에서 "남은 스트림 비트 ÷
항목 최소 인코딩 길이" 로 바꾼 결과:
| | 정점 | 면 | 모서리 | 앞쪽 면 크기 |
|--|------|-----|--------|-------------|
| 전 | 100,000 | 14 | 3 | `[64, 0, 64, 23, 0, 0, 0, 15]` |
| 후 | 105,497 | 185,444 | 290,931 | `[3, 3, 3, 3, 3, 3, 3, 3]` |
오일러 표수 V E + F = 105,497 290,931 + 185,444 = **10**. 경계를 가진
다중 컴포넌트 곡면으로 타당한 값이다 (닫힌 단일 구면이면 2).
### 3-2. 선분 생성
| 경로 | 선분 수 | 비고 |
|------|---------|------|
| `edges` (중복 제거된 모서리) | **315,633** | 실제 사용 |
| `faceList` 폴백 (면 루프 순회) | 741,072 | 2.35배. 두 경로의 그림 범위는 동일 |
- 비유한(NaN/Infinity) 좌표 **0건**
- 길이 0 선분 6건 (원본 데이터의 축퇴 모서리, 무해)
- 선분 bbox 가 메쉬 정점 bbox 와 일치 — x[151,892 … 161,087] y[477,930 … 482,146]
- z 는 선분 양 끝 표고의 중간값, 범위 1.5 ~ 68. 이 도면은 LWPOLYLINE(0~68)·
TEXT(0~54)도 같은 대역에 있어서 **메쉬만 위로 뜨지 않는다**
### 3-3. 손상 입력 방어
| 입력 | 결과 |
|------|------|
| 빈 객체 / 정점 배열 길이 2 (3의 배수 아님) | 0 선분, 예외 없음 |
| 범위 밖 모서리 인덱스 (`[0, 99, -3, 1]`) | 0 선분 (경계 검사에서 탈락) |
| 면 개수가 남은 배열보다 큼 (`[9, 0, 1]`) | 0 선분, 루프 탈출 |
| 면 개수 0 반복 (`[0, 0, 0]`) | 0 선분, **무한루프 없음** |
| 정상 삼각형 (`[3, 0, 1, 2]`) | 3 선분 |
---
## 4. 개선 2 — 선가중치
측정·판정은 [`lineweight-rendering.md`](./lineweight-rendering.md) 3절(헤드리스
Chrome `readPixels` 비교)에 있다. 이 문서는 그 결과를 다시 주장하지 않고,
아래 5절에서 **세그먼트 부하** 관점만 더한다.
이 도면의 엔티티 선가중치 원시값 분포 (i16, 1/100 mm):
```
-3 (Default) ×15,176 -1 (ByLayer) ×2,613
13 (0.13mm) × 8,794 35 (0.35mm) ×37,944 40 ×119 50 ×849
vars.lwdisplay = true vars.celweight = 29
```
---
## 5. 새로 확인된 상호작용 — 메쉬 315,633 선분이 어느 버킷에 들어가는가
두 개선이 같은 `pushSeg` 를 공유한다. 선가중치 작업이 `pushSeg`
`dash|lineweight` 버킷으로 쪼갰으므로, **메쉬 선분도 그 버킷 규칙을 탄다.**
개별 문서 어느 쪽에도 이 얘기가 없어서 실측했다.
### 5-1. 지금은 안전하다
MESH 9개 전부:
```
own lineWeight = -3 (Default), layer lineWeight = -3
```
`_lwPx``-3``LW_DEFAULT_MM`(0.25mm) → `!(0.25 > LW_HAIRLINE_MM)`**0 반환**.
즉 315,633 선분이 전부 기존 병합 `LineSegments` 헤어라인 배치에 남고,
`LineSegments2` 인스턴싱에는 **한 개도 들어가지 않는다.**
"0.25mm 이하는 헤어라인 유지" 규칙이 — 원래는 굵기 미지정 도면의 모양 보존이
목적이었는데 — 결과적으로 대형 TIN 메쉬가 fat-line 경로로 쏟아지는 것을 막고 있다.
### 5-2. 현재 버킷 부하
LINE · LWPOLYLINE · MESH 기준 (ARC/CIRCLE/HATCH 는 미포함이라 실제 값은 이보다 조금 큼):
| 굵기 px | 선분 수 | 경로 | `LW_MAX_FAT_SEGS`(400,000) 대비 |
|--------|---------|------|-------------------------------|
| 0 (헤어라인) | 1,760,253 | 병합 `LineSegments` | — |
| 2.8 (0.35mm) | 182,539 | fat line | 45.6% |
| 3.2 (0.40mm) | 4,254 | fat line | 1.1% |
| 4.0 (0.50mm) | 92,130 | fat line | 23.0% |
헤어라인 1,760,253 중 메쉬가 **315,633 (17.9%)** 을 차지한다.
### 5-3. 위험 — 메쉬 레이어에 굵기가 지정되면 버킷이 터진다
버킷 키는 `dash|lwPx` 이고 **도면 전역**이다. 도로면/부체도로면 레이어에
0.35mm 를 지정하면 메쉬 선분이 이미 182,539 선분이 있는 2.8px 버킷에 합류한다.
```
182,539 + 315,633 = 498,172 선분 = LW_MAX_FAT_SEGS 의 124.5%
```
상한을 넘으면 `bk.fat = false` 가 되어 **그 버킷 전체가 헤어라인으로 되돌아간다.**
메쉬만 헤어라인이 되는 게 아니라, 지금 정상적으로 굵게 나오는 도로 가장자리
182,539 선분까지 같이 얇아진다. `console.warn` 한 줄만 남고 화면상으로는
"선가중치가 갑자기 안 먹는" 것처럼 보인다.
**대응 후보** (지금 당장 필요하진 않다 — 이 도면은 5-1 때문에 해당 없음):
1. MESH 를 선가중치 대상에서 제외 — 메쉬 wireframe 을 굵게 그릴 실익이 없다.
`_lwPx` 호출부에서 `type === 'MESH'` 면 0 을 쓰는 게 가장 싸다.
2. 상한 초과 시 버킷 전체를 버리지 말고 **큰 기여자만** 헤어라인으로 강등.
3. `LW_MAX_FAT_SEGS` 를 GPU 능력 기준으로 잡기 — 현재 40만은 고정 상수다.
---
## 6. 회귀 검증
새 wasm 과 직전 wasm(`git show HEAD:` 로 추출)으로 나머지 샘플 6개를 파싱해
엔티티 히스토그램을 비교했다.
| 도면 | 버전 | 엔티티 | 히스토그램 |
|------|------|--------|-----------|
| BasicSample.dwg | R2018 | 7,309 | 동일 |
| bb.dwg | R2018 | 438 | 동일 |
| civil.dwg | R2000 | 17,810 | 동일 |
| C0060202-001-평면및종단면도(1) | R2000 | 17,810 | 동일 |
| C0060203-001-표준단면도(2차로) | R2007 | 2,068 | 동일 |
| dwg_18.3mb.dwg | R2018 | 82,393 | 동일 |
6개 전부 **완전 일치**. 노면 도면만 65,486 → 65,495 (+9 MESH). `read_mesh` 상한
변경이 MESH 이외 엔티티에 영향을 주지 않음을 확인한 것이다.
추가로 `tsc --noEmit`, `vite build`, 편집한 JS 3개 `node --check` 통과.
---
## 7. 반영 상태
| 대상 | 상태 |
|------|------|
| hmwebviewer `feat/subdmesh-render` `35f5b1c` | 파서 + 렌더러 + 속성 패널. **푸시 안 됨** |
| 이 저장소 `feat/subdmesh-render` `eec3382` | wasm + 속성 패널 + 문서. **푸시 안 됨** |
| 이 저장소 `src/viewer2d/Viewer2D.js` | **미커밋.** `_meshSegs` + MESH 디스패치 3군데가 선가중치/fat-line 리팩터와 hunk 가 겹쳐 분리 불가. 같은 코드가 hmwebviewer `35f5b1c` 에는 들어가 있다 |
| `dist2/` | **미갱신.** 현재 빌드에 반쯤 배선된 선가중치 UI 가 섞여 있다 |
| hmwebviewer wasm 산출물 | 미커밋. 선가중치 소스가 커밋될 때 `npm run build:dwg-wasm` 로 함께 |
커밋된 wasm 바이너리에는 선가중치 파서 필드(`lwdisplay`, `celweight`,
layer/entity `lineWeight`)도 들어 있다. 빌드 시점의 소스에 이미 있었기 때문이며,
소비 측 없는 추가 JSON 키라 무해하다.
---
## 8. 남은 과제
1. **5-3 버킷 상한** — 메쉬 레이어에 굵기가 지정된 도면이 들어오면 재현된다.
샘플을 만들어 재현시켜 두는 편이 좋다.
2. **Catmull-Clark 세분**`subdivisionLevel > 0` 메쉬는 AutoCAD 보다 각져 보인다.
기준 도면에는 해당 없어 미구현.
3. **POLYFACE MESH / POLYGON MESH** — 구형 POLYLINE flag 16/64 계열은 여전히
미지원. SubDMesh 와 별개 엔티티다.
4. **메쉬 채움(shaded) 패스**`faceList` 를 이미 내보내고 있으므로 삼각화만
하면 된다. 2D 와이어프레임 뷰어라 우선순위는 낮다.
5. **`dist2` 배포** — 선가중치 작업이 끝난 뒤
[`deploy-dist2-static-build.md`](./deploy-dist2-static-build.md) 절차로.
---
## 9. 측정 재현
```bash
# 네이티브 메쉬 진단 (면 크기가 3/4 로 균일하지 않으면 스트림 어긋남 의심)
cd D:\MYCLAUDE_PROJECT\hmwebviewer\rust\dwg-wasm
cargo run --release --bin meshprobe -- <file.dwg>
```
5절의 버킷 표는 `Viewer2D.js` 에서 `_meshSegs` · `_lwPx` 를 들어내 parseResult 에
직접 돌려 만들었다 (`LINE` / `LWPOLYLINE` / `MESH` 만 계수). `Viewer2D.js` 는 three
와 확장자 없는 TS 모듈을 import 하므로 node 에서 그대로 import 되지 않는다 —
함수 본문만 뽑아 `new Function` 으로 감싸는 방식이었다.