fix(viewer2d): sync dwg-wasm fix for large-DWG load and hatch arc artifacts

Mirrors hmwebviewer's rust/dwg-wasm change into the sample: the wasm
artifact plus the parser wrapper, which now goes through parse_dwg_json
and JSON.parse instead of the JsValue-returning parse_dwg.

Building the whole result as a serde_json::Value tree pushed the live
wasm heap near 2.9GB for a 19MB drawing, and the allocator's cost grows
with that heap, so parsing went quadratic — 354s, with the tab frozen
throughout. Streaming one entity at a time keeps it linear: 2.5s.

The same build also fixes three bugs that drew stray 4km grey shapes
where two CF-PATT SOLID hatches should be. Max hatch span 4655 -> 110.

Adds two docs recording both investigations, in the format of the
existing ones: measurements, the hypotheses that were ruled out, the
fixes, regression checks, and the MPL-2.0 position (acadrust is
unmodified, so no new source-disclosure obligation arises).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-08-03 13:55:34 +09:00
co-authored by Claude Opus 5
parent 7a6edd4347
commit b047dc44e2
7 changed files with 770 additions and 3 deletions
+387
View File
@@ -0,0 +1,387 @@
# 해치 경계 원호 아티팩트 — 원인 분석 및 해결 가이드
| 항목 | 내용 |
|------|------|
| 작성 목적 | 도면과 무관한 거대 회색 도형이 그려지는 문제의 원인 규명과 수정 내역 기록 |
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
| 재현 도면 | `dwg_18.3mb.dwg` (AC1032 · 82,393 entities · HATCH 87개) |
| 대조 도면 | `BasicSample.dwg`, `civil.dwg`, `bb.dwg` |
| 수정일 | 2026-08-03 |
| 상태 | **해결** — 원본/샘플 수정 완료, 배포 완료, 정답지 대조 확인 |
> 성능 문제([`large-dwg-wasm-heap-quadratic.md`](./large-dwg-wasm-heap-quadratic.md))와는 **무관한 별건**이다.
> 성능 수정 전후의 출력 JSON이 바이트 단위로 동일함을 확인했으므로, 이 아티팩트는 원래부터 있었고
> 지금까지 로딩에 6분이 걸려 아무도 확인하지 못했을 뿐이다.
---
## 1. 현상
`dwg_18.3mb.dwg`를 열면 도면(가로로 긴 종단면도 띠) 위아래로 **도면 폭의 절반에 달하는 거대한 회색 원 2개**가 세로로 맞닿은 채 그려진다.
도면 본체는 정상 렌더링되므로 파싱 실패가 아니라 특정 엔티티만 잘못 그려지는 문제다.
### 정체 파악
파싱 결과에서 엔티티별 bbox를 재어 크기 순으로 정렬했다.
```
span=4655 HATCH layer=CF-PATT aci=253 solidFill=true pattern=SOLID bb=[156687,474142,161342,478796]
span=4092 HATCH layer=CF-PATT aci=253 solidFill=true pattern=SOLID bb=[156736,478797,160828,482890]
span=110 HATCH layer=CF-PATT aci=253 ...
span=62 HATCH layer=CF-PATT aci=253 ...
span=54 HATCH layer=CF-PATT aci=253 ...
```
- 두 도형 모두 `CF-PATT` 레이어의 **SOLID 해치**, ACI 253 = 회색 → 화면의 회색과 일치
- bbox가 각각 4655×4654, 4092×4093 인 **정사각형** → 원
- 첫 번째의 `maxy=478796`과 두 번째의 `miny=478797`이 맞닿음 → 화면상 두 원이 세로로 접한 모습과 일치
- 같은 레이어의 나머지 해치는 span 110 / 62 / 54 로 정상. **이 둘만 4km급**
- 전체 해치 span 중앙값 12.23, p99 4655 → 명백한 이상치
---
## 2. 원인 ①: `sample_arc`의 진행 방향 처리
### 원본 경계 데이터
래퍼가 원호를 점으로 샘플링하기 전의 raw 경계를 네이티브에서 덤프했다.
```
HATCH handle=175470 layer=CF-PATT solid=true span=3997 paths=1
path0 flags=1 edges=21
e0 CircularArc c=(158781.6,480843.3) r=2046.9375 a0=1.7885 a1=1.8110 ccw=false
e2 CircularArc c=(158797.5,480908.4) r=2113.5414 a0=4.4722 a1=4.4800 ccw=true
e8 CircularArc c=(158773.7,480809.4) r=1988.3000 a0=1.8074 a1=1.8095 ccw=false
e10 CircularArc c=(158773.1,480807.1) r=1985.4799 a0=4.4737 a1=4.4880 ccw=true
```
전부 각도 폭(sweep)이 0.01~0.02 rad 인 **아주 짧은 호**다. 도로 선형의 완만한 곡선 조각으로 정상적인 값이다.
### 버그
```rust
// 수정 전
let mut a0 = start;
let mut a1 = end;
if !ccw {
std::mem::swap(&mut a0, &mut a1); // ← 구간 자체를 뒤집는다
}
while a1 <= a0 {
a1 += std::f64::consts::TAU; // ← 여기서 폭발
}
let sweep = a1 - a0;
```
DWG 해치 경계의 원호는 `start_angle` / `end_angle` 을 **항상 반시계 기준**으로 저장하고, `ccw` 플래그는 *경계를 어느 방향으로 따라가는지*만 나타낸다. 호 구간 자체는 바뀌지 않는다.
그런데 위 코드는 `ccw=false`일 때 두 각도를 맞바꾼다. e0의 경우:
| 단계 | a0 | a1 | sweep |
|------|-----|-----|-------|
| 원본 | 1.7885 | 1.8110 | 0.0225 rad (짧은 호) |
| swap 후 | 1.8110 | 1.7885 | — (`a1 <= a0`) |
| `+= TAU` 후 | 1.8110 | 8.0717 | **6.2607 rad ≒ 358.7°** |
**0.0225 rad짜리 짧은 호가 거의 완전한 원이 된다.** 반지름 2046.9 → 지름 4093.8, 관측된 span 4092와 정확히 일치한다.
`ccw=true`인 e2 / e10 은 swap을 타지 않아 정상이다. 즉 **`ccw=false`인 호만** 터진다.
### 수정
구간은 그대로 두고 **생성된 점 순서만 뒤집는다.**
`rust/dwg-wasm/src/lib.rs` · `sample_arc`
```rust
let a0 = start;
let mut a1 = end;
while a1 <= a0 {
a1 += std::f64::consts::TAU;
}
let sweep = a1 - a0;
let segs = ((sweep / (std::f64::consts::PI / 16.0)).ceil() as usize).clamp(6, 128);
let (sr, cr) = rot.sin_cos();
let first = points.len();
for i in 0..=segs {
let a = a0 + sweep * (i as f64) / (segs as f64);
let (sa, ca) = a.sin_cos();
let ex = rx * ca;
let ey = ry * sa;
points.push(json!({ "x": cx + ex * cr - ey * sr, "y": cy + ex * sr + ey * cr }));
bulges.push(0.0);
}
// Clockwise edge: same arc, walked the other way.
if !ccw {
points[first..].reverse();
bulges[first..].reverse();
}
```
`points` / `bulges` 에는 앞선 edge들의 점이 이미 들어있으므로, **이번 호가 추가한 구간만** 뒤집어야 한다(`first` 인덱스).
### 결과
두 해치의 점 개수가 70 → 44, 150 → 72 로 줄고 **원형 아티팩트가 사라졌다.**
---
## 3. 원인 ②: 오염된 extent가 무력화한 방어 로직
원형은 사라졌지만 span은 4035 / 3997로 거의 그대로였다. bbox를 다시 보면 성격이 바뀌어 있다.
```
span=4035 bb=[160209,474468,160245,478503] → X 36, Y 4035 (세로로 긴 얇은 띠)
span=3997 bb=[158295,478845,158339,482842] → X 44, Y 3997
```
원이 아니라 **세로 띠**다. 별개의 두 번째 문제다.
### 끝점 자체가 어긋난다
경계 덤프에서 원호의 끝점과 인접 Line의 시작점을 비교하면 y가 크게 튄다.
```
e0 CircularArc … | endpoints (158339.3,482841.9)->(158294.7,482831.5)
e1 Line (158294.7,478855.1)->(158294.8,478855.5)
└ x는 정확히 일치, y만 3976 차이
```
즉 이 원호들은 **center/radius가 실제 기하와 맞지 않는 손상된 값**이다.
코드에는 이미 이런 상황을 위한 방어 로직이 있다.
```rust
// 반지름이 경로 전체보다 압도적으로 크면 손상된 호 → 현(chord)으로 그린다
let spurious_r = |r: f64| path_extent.is_finite() && path_extent > 0.0 && r > 5.0 * path_extent;
```
문제는 `path_extent`를 재는 방식이었다.
```rust
// 수정 전 — 원호의 끝점을 center/radius에서 역산해 extent에 포함시킨다
BoundaryEdge::CircularArc(a) => {
acc(a.center.x + a.radius * a.start_angle.cos(), a.center.y + a.radius * a.start_angle.sin(), );
acc(a.center.x + a.radius * a.end_angle.cos(), a.center.y + a.radius * a.end_angle.sin(), );
}
```
**손상된 값을, 그 손상을 잡으라고 만든 잣대에 그대로 집어넣는다.** 이 경로의 Line들만 보면 실제 extent는 약 36인데, 오염된 호의 끝점까지 포함하면 4035로 부푼다. 그러면 `r=2840 > 5 × 4035 = 20175` 이 성립하지 않아 **방어 로직이 통과된다.**
### 수정
extent 측정에서 **좌표가 직접 저장된 edge만** 사용한다.
```rust
for edge in &p.edges {
match edge {
BoundaryEdge::Polyline(pl) => for v in &pl.vertices { acc(v.x, v.y, ); },
BoundaryEdge::Line(l) => { acc(l.start.x, l.start.y, ); acc(l.end.x, l.end.y, ); }
BoundaryEdge::Spline(s) => {
for v in &s.control_points { acc(v.x, v.y, ); }
for v in &s.fit_points { acc(v.x, v.y, ); }
}
// Centre/radius-derived — see above.
BoundaryEdge::CircularArc(_) | BoundaryEdge::EllipticArc(_) => {}
}
}
```
이제 extent가 36으로 잡히고 `r=2840 > 5 × 36 = 180` 이 성립해 방어 로직이 정상 작동한다.
### 결과
점 개수는 44 → 34, 72 → 42 로 더 줄었지만 **span은 4035 / 3997 그대로였다.** 방어 로직이 걸려 chord 분기를 타는 데는 성공했지만 아직 부족했다 — 세 번째 원인이 남아 있었다.
---
## 4. 원인 ③: chord 끝점도 같은 손상값으로 재계산
### 정답지 대조
AutoCAD에서 같은 영역을 연 화면과 뷰어 출력을 나란히 비교하자 성격이 분명해졌다.
| | 회색 SOLID 해치 모양 |
|---|---|
| **정답지 (AutoCAD)** | 교량 폭만큼의 **짧은 사각형**. 도로를 가로지르는 부분에서 깔끔하게 닫힘 |
| **뷰어 (수정 ②까지)** | 같은 위치에서 시작해 **아래로 길게 새어 나감** |
한쪽 끝만 엉뚱한 곳에 찍혀 도형이 늘어나는 형태다. 즉 **경계 폴리곤의 특정 점 하나가 잘못된 좌표**라는 뜻이다.
### 버그
방어 로직이 손상된 호를 걸러낸 뒤 하는 일이 문제였다.
```rust
// 수정 전
if spurious_r(a.radius) {
// Corrupt arc — connect its (correct) endpoints with a chord.
points.push(json!({ "x": a.center.x + a.radius * a.start_angle.cos(),
"y": a.center.y + a.radius * a.start_angle.sin() }));
bulges.push(0.0);
points.push(json!({ "x": a.center.x + a.radius * a.end_angle.cos(),
"y": a.center.y + a.radius * a.end_angle.sin() }));
bulges.push(0.0);
}
```
**"손상됐다"고 판정해놓고, 그 끝점을 바로 그 손상된 `center` / `radius` 로 다시 계산한다.**
기존 주석은 *"Its endpoints are still correct"* (끝점은 여전히 정확하다)를 전제한다. CXGLOGO 케이스에서는 맞았지만 `dwg_18.3mb.dwg` 에서는 **그 전제가 깨진다.** §3에서 본 대로 이 호들의 끝점은 인접 Line의 실제 이음새에서 약 3,900 단위 떨어져 있다. 그렇게 만든 "chord"는 해치를 닫는 대신 도면 4km 아래로 끌고 내려간다.
### 수정
**손상된 호는 점을 하나도 만들지 않고 통째로 버린다.**
경계는 닫힌 폴리곤이므로, 그 edge를 건너뛰면 **앞 edge의 끝점과 뒤 edge의 시작점이 곧바로 직선으로 이어진다.** 이것이 재계산 없이 얻는 진짜 chord이며, 실제 이음새 좌표를 그대로 쓰므로 손상값이 개입할 여지가 없다.
```rust
BoundaryEdge::CircularArc(a) => {
if spurious_r(a.radius) {
// Corrupt arc — emit nothing; neighbours close the chord.
} else {
sample_arc(
a.center.x, a.center.y, a.radius, a.radius,
0.0, a.start_angle, a.end_angle, a.counter_clockwise,
&mut points, &mut bulges,
);
}
}
```
`EllipticArc` 분기도 동일하게 바꿨다.
### 결과
`dwg_18.3mb.dwg` 의 최대 해치 span이 **4035 → 110** 으로 떨어졌다. 4km급 이상치가 사라지고 같은 레이어의 정상 해치(110 / 62 / 54 / 53)와 같은 크기 대역에 들어왔다. 화면상으로도 정답지와 같은 짧은 사각형으로 그려진다.
---
## 5. 배제한 가설
| 가설 | 검증 방법 | 결과 |
|------|-----------|------|
| 성능 수정의 부작용 | 성능 수정 전후 출력 JSON 바이트 비교 | 동일 — **무관** |
| 각도 부호 규약 오류 (`sin` 부호 반전 필요) | 전 도면의 모든 경계 원호에 대해, 인접 edge와의 이음새 간격을 `+sin` / `-sin` 양쪽으로 계산해 비교 | **기각** — 평균 간격 `+sin` 444.99 vs `-sin` 481.68, per-arc 승패 38:32 |
| 파서(acadrust) 전반의 원호 처리 결함 | 도면별 이음새 간격 측정 | **기각**`civil.dwg` 3.65, `bb.dwg` 1.00 으로 정상. `test19` 의 8개 호만 3870 |
부호 규약 검증에 쓴 하네스는 `rust/dwg-wasm/src/bin/arcsign.rs` 로 남겨두었다.
```bash
cargo build --release --bin arcsign
./target/release/arcsign.exe <file.dwg> [more.dwg ...]
```
---
## 6. 회귀 검증
성능 수정만 적용된 빌드와 해치 수정 3건이 모두 적용된 빌드에서 각 도면의 해치 span 상위 목록을 비교했다.
| 도면 | 수정 전 최대 span | 수정 후 최대 span | 판정 |
|------|------------------|------------------|------|
| `civil.dwg` (해치 1,397개) | 58, 57, 57, 57 | 58, 57, 57, 57 | 동일 |
| `bb.dwg` (해치 50개) | 1100, 1081, 58, 57 | 1100, 1081, 58, 57 | 동일 |
| `BasicSample.dwg` (해치 16개) | 753, 499, 293, **50** | 753, 499, 293, **41** | 1건 변화 |
| `dwg_18.3mb.dwg` (해치 87개) | **4655, 4092** | **110, 62, 54, 53** | 이상치 제거 |
- 대조 도면 3종은 최대 span이 완전히 동일하다. **회귀 없음.**
- `dwg_18.3mb.dwg` 는 4km급 이상치 2개가 사라지고, 같은 `CF-PATT` 레이어의 정상 해치들과 같은 대역으로 수렴했다.
- `BasicSample.dwg` 에서 변한 1건은 `layer=0`, 12개 path의 SOLID 해치로 bbox가 `[20,-6,71,10]``[20,-6,61,8]` 로 줄었다. 코드 주석이 언급하는 **CXGLOGO 로고 케이스**(*"real coords ≈ 21"*)에 해당하며, extent 기준이 정확해지면서 손상된 호가 제대로 걸러진 결과다.
수정 단계별 추이(`dwg_18.3mb.dwg` 기준):
| 단계 | 최대 span | 점 개수 | 형태 |
|------|----------|--------|------|
| 수정 전 | 4655 / 4092 | 70 / 150 | 거대한 원 |
| ① `sample_arc` 방향 수정 | 4035 / 3997 | 44 / 72 | 세로 띠 |
| ② extent에서 호 제외 | 4035 / 3997 | 34 / 42 | 세로 띠 |
| ③ 손상된 호 스킵 | **110** | — | 정상 |
---
## 7. 남은 과제
1. **손상 원인 자체**
acadrust가 이 호들을 잘못 읽는 것인지, DWG 파일 자체에 이상이 있는 것인지는 미확인이다. 현재 수정은 손상값을 **감지해 우회**하는 방어이지 근본 원인 제거가 아니다.
정답지 대조에서 도면의 나머지 요소는 정상이었으므로 파일 전체가 깨진 것은 아니며, 특정 호 몇 개에 국한된 문제로 보인다.
원인이 acadrust 내부로 판명되면 §8의 MPL-2.0 의무가 발생하므로, 그 경우에도 가능하면 upstream 이슈/PR 경로를 택하는 편이 낫다.
2. **`spurious_r` 의 5배 임계값**
`r > 5.0 * path_extent` 라는 기준은 경험적으로 정해진 값이다. 실제 도면에 경로 범위보다 5배 이상 큰 반지름의 정상 호가 존재하면 오탐이 나 정상 호가 지워진다.
현재 4개 도면(해치 1,550개)에서 오탐은 확인되지 않았으나, 판정을 반지름 대신 **호 끝점과 인접 edge 이음새의 간격**으로 바꾸면 더 직접적이고 안전하다. §5의 `arcsign` 하네스가 이미 그 간격을 계산한다.
3. **`EllipticArc`**
세 수정 모두 `EllipticArc` 분기에도 동일하게 적용했으나, 검증은 `CircularArc` 로만 했다. 재현 도면을 확보하지 못했다.
---
## 8. 오픈소스 라이선스 (MPL-2.0)
본 저장소의 DWG 파싱은 두 계층으로 나뉜다.
| 구성요소 | 위치 | 라이선스 | 본 작업에서 |
|----------|------|---------|------------|
| `acadrust` v0.4.1 | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **수정하지 않음** |
| `dwg-wasm` (래퍼) | `hmwebviewer/rust/dwg-wasm/` | MIT (자체 코드) | 수정함 |
| 뷰어 | `src/viewer2d/` | 자체 코드 | 수정함 |
### 현재 상태 — 소스 공개 의무 없음
MPL-2.0은 **파일 단위 카피레프트**다. 의무가 발생하는 것은 *MPL로 커버되는 파일 자체를 수정했을 때*이며, 그 경우 **수정한 그 파일들**을 MPL-2.0으로 공개해야 한다. MPL 코드를 라이브러리로 링크하기만 한 자체 코드(`dwg-wasm`, 뷰어)는 공개 대상이 아니다.
본 작업에서 수정한 파일은 전부 자체 코드이며, `rust/acadrust/` 는 한 줄도 건드리지 않았다.
```
$ git status --short rust/acadrust/
(변경 없음)
```
`Cargo.toml`의 주석도 이 전제를 명시하고 있다.
```toml
# acadrust is consumed UNMODIFIED from crates.io (MPL-2.0 — file-level copyleft;
# unmodified library use only requires keeping its license notices).
acadrust = { path = "../acadrust" }
```
따라서 **현 시점에서 새로 발생하는 소스 공개 의무는 없다.** 다만 수정 여부와 무관하게 다음은 계속 지켜야 한다.
- `rust/acadrust/LICENSE` (MPL-2.0 전문) 및 저작권 고지 유지 — 현재 유지되고 있음
- 배포물에서 acadrust 사용 사실과 라이선스를 확인할 수 있게 할 것
### 앞으로 acadrust를 수정하게 된다면
「7. 남은 과제」의 1번처럼 원인이 acadrust 내부에 있다고 판명되어 **`rust/acadrust/src/**` 를 직접 고치는 순간 의무가 발생한다.** 그 경우:
1. **수정한 파일을 MPL-2.0으로 공개해야 한다** (파일 단위이므로 `dwg-wasm`이나 뷰어까지 공개할 의무는 없다)
2. 각 파일 상단의 MPL 헤더를 유지한다
3. 어떤 파일을 어떻게 수정했는지 기록을 남긴다
4. 실무적으로는 **upstream(crates.io `acadrust`)에 이슈/PR로 올리는 편이 낫다.** 포크를 들고 있으면 버전 업마다 재적용 비용이 든다
지금처럼 **수정을 래퍼(`dwg-wasm`) 쪽에 두는 구조를 유지하는 것이 라이선스 관점에서 가장 단순하다.** 본 문서의 두 수정도 모두 래퍼에서 이루어졌다.
> 위 내용은 라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다. 배포 형태나 계약이 얽히면 별도 검토가 필요하다.
---
## 9. 한 줄 요약
> **거대 회색 도형의 정체는 `CF-PATT` 레이어의 SOLID 해치 2개이며, 원인은 세 겹이었다.
> ① `sample_arc`가 시계방향 호에서 점 순서를 뒤집는 대신 각도 구간 자체를 맞바꿔, 0.02 rad짜리 짧은 호를 359° 원으로 만들었다.
> ② 손상된 호를 걸러내는 `spurious_r` 판정의 기준 extent를 그 손상된 호의 끝점으로 계산해 방어가 무력화됐다.
> ③ 방어가 걸린 뒤에도 chord의 끝점을 같은 손상된 center/radius로 재계산해, 해치가 4km 아래로 늘어졌다.
> 손상된 호는 점을 만들지 말고 통째로 버리면 앞뒤 edge가 실제 이음새 좌표로 직접 이어지며 진짜 chord가 된다.
> 최대 해치 span 4655 → 110, 대조 도면 3종 회귀 없음.**
---
## 10. 참고 파일
- 원호 샘플링 / 경계 평탄화: [`../../hmwebviewer/rust/dwg-wasm/src/lib.rs`](../../hmwebviewer/rust/dwg-wasm/src/lib.rs) (`sample_arc`, `boundary_path_json`)
- 경계 덤프 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/hatchdump.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/hatchdump.rs)
- 각도 규약 검증 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/arcsign.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/arcsign.rs)
- 해치 렌더링(뷰어 측 `_tessLoop`): [`../src/viewer2d/dxfHatchHandler.d.ts`](../src/viewer2d/dxfHatchHandler.d.ts)
- 성능 문제(별건): [`large-dwg-wasm-heap-quadratic.md`](./large-dwg-wasm-heap-quadratic.md)
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)
+321
View File
@@ -0,0 +1,321 @@
# 대용량 DWG 로딩 멈춤 — 원인 분석 및 해결 가이드
| 항목 | 내용 |
|------|------|
| 작성 목적 | 19MB DWG 로딩이 사실상 멈추던 문제의 원인 규명과 수정 내역 기록 |
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
| 재현 도면 | `dwg_18.3mb.dwg` (19,243,300 bytes · AC1032 · 82,393 entities) |
| 대조 도면 | `BasicSample.dwg` (459KB), `civil.dwg` (5.6MB) |
| 수정일 | 2026-08-03 |
| 상태 | 원본(`hmwebviewer`) 수정 완료 / 샘플 동기화 완료 / 배포 완료 |
---
## 1. 현상
쿼리 파라미터로 대용량 DWG를 여는 링크가 "안 열린다"는 제보.
```
https://dwg-dxf-viewer-sample.pages.dev/?model=https://minio.gsimtech.com/api/viewer-test/dwg_18.3mb.dwg
```
증상은 다음과 같다.
- 상태바가 `loading dwg_18.3mb.dwg…` 에서 멈춘 채 진행이 없음
- 탭 전체가 무반응 (스크롤·클릭·패널 조작 불가)
- 크롬이 "페이지가 응답하지 않습니다" 배너를 띄우거나 탭을 종료
에러는 발생하지 않는다. 끝까지 기다리면 **약 6분(354초) 뒤 정상적으로 렌더링된다.** 즉 실패가 아니라 극단적으로 느린 것이다.
### 자주 하는 오진
아래는 모두 **원인이 아니었다.** 실측으로 하나씩 배제한 기록이다.
| 오진 | 검증 방법 | 결과 |
|------|-----------|------|
| CORS 차단 | `curl -H "Origin: https://dwg-dxf-viewer-sample.pages.dev"` | `access-control-allow-origin` 정상 응답, 200 OK |
| 쿼리 파라미터 미지원 | 배포된 번들에서 `URLSearchParams(location.search).get("model")` 확인 | 정상 동작 |
| DWG 버전 미지원 | 헤더 시그니처 `AC1032` 확인 | `BasicSample.dwg`와 동일 버전, 지원됨 |
| 파서 알고리즘 O(n²) | 네이티브 빌드로 동일 함수 실행 | **선형** (JSON 1MB당 약 37ms 일정) |
| `serde_wasm_bindgen` 경계 비용 | JSON 문자열 반환 함수를 따로 만들어 측정 | 여전히 310초 — 무관 |
| wasm 메모리 `max` 미선언으로 인한 grow 복사 | `--max-memory` / `--initial-memory` 선언 후 재측정 | 330초 — **개선 없음** |
| `dlmalloc` 자체 결함 | 전역 할당자를 `talc`로 교체 후 재측정 | 316초 — **개선 없음** |
| `opt-level = "s"` (크기 최적화) | 네이티브도 동일 프로파일을 쓴다는 점 확인 | 네이티브/wasm 공통이므로 차이 요인 아님 |
---
## 2. 계측
### 2.1 네이티브 vs wasm
`rust/dwg-wasm`에는 네이티브 바이너리를 만들 수 있으므로, 같은 코드를 두 타깃에서 돌려 비교했다.
| 파일 | JSON 출력 | 네이티브 | wasm | 배율 |
|------|-----------|---------|------|------|
| `BasicSample.dwg` | 2.7MB | 122ms | 266ms | 2.2x |
| `civil.dwg` | 16.5MB | 581ms | 11,554ms | 20x |
| `dwg_18.3mb.dwg` | 96.9MB | 3,644ms | 310,572ms | **85x** |
네이티브는 완전히 선형인데 wasm만 초선형이다. **배율 자체가 파일 크기에 비례해 커진다** — 전형적인 O(n²) 신호다.
일반적인 wasm은 네이티브 대비 1.5~3배 느린 수준이므로, 85배는 정상 범위가 아니다.
### 2.2 구간 분리
wasm 내부에 `js_sys::Date::now()` 기반 구간 타이머를 넣어 세 단계를 나눴다.
| 파일 | `read_document` (DWG 디코딩) | `document_to_parse_result` | `serde_json::to_string` |
|------|------------------------------|----------------------------|-------------------------|
| `BasicSample.dwg` | 71ms | 160ms | 18ms |
| `civil.dwg` | 77ms | **11,235ms** | 87ms |
| `dwg_18.3mb.dwg` | 413ms | **351,777ms** | 509ms |
DWG 디코딩과 직렬화는 선형이고 정상이다. **`document_to_parse_result` 하나가 전체의 99.7%를 차지하며 이 함수만 2차 곡선을 그린다.**
그런데 같은 함수의 네이티브 실행은 71 → 425 → 2,488ms로 완전히 선형이다. **로직이 원인이 아니라는 뜻이다.**
### 2.3 판별 실험 — 할당 횟수인가, 힙 크기인가
원인 후보가 "할당 횟수가 많아서"인지 "살아있는 힙이 커서"인지 가르기 위해, 같은 루프를 두 방식으로 돌렸다.
- **drop 모드** — 엔티티마다 `Value`를 만들고 즉시 버림 (할당 횟수 동일, 라이브 힙 ≈ 0)
- **keep 모드** — 만든 `Value`를 전부 보관 (할당 횟수 동일, 라이브 힙 최대)
두 모드의 **할당 횟수와 수행 작업은 완전히 같고, 오직 객체가 살아있는지만 다르다.**
| 파일 | drop 모드 | keep 모드 | 배율 |
|------|-----------|-----------|------|
| `civil.dwg` (17,758 ents) | 147ms | 3,545ms | 24x |
| `dwg_18.3mb.dwg` (82,403 ents) | **739ms** | **92,848ms** | **126x** |
drop 모드는 선형이고 빠르다 (엔티티 4.6배 증가에 시간 5배). keep 모드만 2차다.
### 2.4 메모리
네이티브 실행의 피크 워킹셋을 측정했다.
```
PEAK WORKING SET: 2,920 MB
```
**19MB DWG 하나를 파싱하는 데 2.9GB.** 원본 대비 154배다.
---
## 3. 원인
세 가지 사실이 하나로 모인다.
1. `document_to_parse_result`는 결과 전체를 `serde_json::Value` 트리로 **메모리에 쌓아 올린다.**
2. 그 트리는 원본 DWG의 약 150배 크기다 (19MB → 약 2.9GB). 좌표 하나하나가 `json!({"x":.., "y":..})`, 즉 힙 문자열 키 2개를 가진 맵으로 만들어지기 때문이다.
3. **wasm 할당자는 살아있는 힙이 커질수록 할당 1건당 비용이 증가한다.** 힙이 GB 단위로 부풀면서 뒤쪽 엔티티일수록 할당이 느려지고, 전체가 O(n²)가 된다.
네이티브에서 같은 코드가 선형인 이유는 Windows 힙이 이 패턴을 잘 견디기 때문이고, 문제는 wasm 타깃에서만 드러난다.
> 할당자를 `talc`로 바꿔도 개선되지 않았다는 점이 중요하다. 특정 할당자의 결함이 아니라 **"거대한 라이브 힙 + 수천만 건의 미세 할당"이라는 조합 자체가 wasm에서 감당되지 않는 것**이다. 따라서 해결책은 할당자 교체가 아니라 **라이브 힙을 없애는 것**이다.
메인 스레드가 이 6분을 통째로 점유하기 때문에 탭이 얼어붙고, 사용자에게는 "안 열린다"로 보인다.
---
## 4. 해결
**결과 전체를 메모리에 들고 있지 않는다.** 엔티티를 하나씩 JSON 텍스트로 변환해 출력 버퍼에 붙이고 즉시 버린다. 라이브 힙이 평평하게 유지되므로 할당 비용이 커지지 않는다.
### 4.1 Rust — 스트리밍 직렬화 경로 추가
`rust/dwg-wasm/src/lib.rs`
기존 `document_to_parse_result`는 그대로 두고(네이티브 덤프·테스트에서 계속 사용), 공통 조각을 뽑아낸 뒤 스트리밍 버전을 추가했다.
```rust
pub fn document_to_parse_result_json(
doc: &acadrust::CadDocument,
) -> Result<String, serde_json::Error> {
let ctx = build_ctx(doc);
let mut out: Vec<u8> = Vec::with_capacity(1 << 22);
// 키 순서는 `json!`이 내보내는 것과 맞춘다: serde_json은 객체를 BTreeMap으로
// 뒷받침하므로 트리 경로는 최상위 키를 정렬해 쓴다. 여기서 같은 순서를
// 유지하면 두 경로를 바이트 단위로 비교할 수 있다.
out.extend_from_slice(b"{\"entities\":[");
// 한 번에 엔티티 하나만: `scratch`를 매 회 비우므로 도면에 엔티티가
// 아무리 많아도 라이브 힙이 평평하게 유지된다.
let mut scratch: Vec<Value> = Vec::new();
let mut first = true;
for e in doc.entities() {
scratch.clear();
entity_to_json(e, &ctx, &mut scratch);
for v in &scratch {
if !first { out.push(b','); }
first = false;
serde_json::to_writer(&mut out, v)?;
}
}
out.extend_from_slice(b"]");
out.extend_from_slice(b",\"stats\":");
serde_json::to_writer(&mut out, &stats_value(doc))?;
out.extend_from_slice(b",\"tables\":");
serde_json::to_writer(&mut out, &tables_value(doc))?;
out.extend_from_slice(b",\"vars\":");
serde_json::to_writer(&mut out, &vars_value(doc))?;
out.extend_from_slice(b",\"version\":");
serde_json::to_writer(&mut out, &version_value(doc))?;
out.push(b'}');
Ok(String::from_utf8(out).expect("serde_json emits UTF-8"))
}
```
새 wasm export:
```rust
#[wasm_bindgen]
pub fn parse_dwg_json(bytes: &[u8]) -> Result<String, JsError> {
let doc = read_document(bytes).map_err(|e| JsError::new(&e))?;
document_to_parse_result_json(&doc).map_err(|e| JsError::new(&e.to_string()))
}
```
기존 `parse_dwg`(JsValue 반환)는 호환을 위해 남겨두었다.
> **키 순서 주의** — `serde_json`은 `preserve_order` 없이 객체를 `BTreeMap`으로 뒷받침하므로 `json!` 매크로가 만든 최상위 키는 알파벳 순(`entities`, `stats`, `tables`, `vars`, `version`)으로 직렬화된다. 수동 출력도 같은 순서를 지켜야 바이트 단위 비교가 성립한다. 처음 구현에서 선언 순서(`version`, `vars`, …)로 썼다가 길이는 같은데 내용이 달라 실패했다.
### 4.2 JS — `JSON.parse` 경유로 전환
`src/viewer2d/acadrustParser.ts`
```ts
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
}
```
97MB 문자열을 `JSON.parse`로 넘기는 비용은 19MB 도면 기준 약 0.6초다. 엔진의 네이티브 파서를 쓰므로 wasm에서 객체를 하나씩 만드는 것보다 훨씬 싸다.
### 4.3 빌드
```bash
cd hmwebviewer
npm run build:dwg-wasm # rust/dwg-wasm → src/viewer2d/acadrust-dwg
```
이후 `src/viewer2d/acadrust-dwg/` 4개 파일과 `acadrustParser.ts`를 샘플 저장소로 복사한다.
---
## 5. 결과
| 파일 | 이전 | 이후 (wasm + `JSON.parse`) | 개선 |
|------|------|---------------------------|------|
| `BasicSample.dwg` (459KB) | 345ms | **138ms** | 2.5x |
| `civil.dwg` (5.6MB) | 14,064ms | **446ms** | 32x |
| `dwg_18.3mb.dwg` (19MB) | **353,735ms** | **2,481ms** | **143x** |
곡선이 2차에서 선형으로 바뀌었다. 엔티티 7,309 → 17,810 → 82,393에 대해 138 → 446 → 2,481ms.
### 검증
동작이 바뀌지 않았음을 두 층위에서 확인했다.
1. **Rust 층**`document_to_parse_result` 트리 경로와 스트리밍 경로의 출력을 5개 도면에서 비교, **전부 바이트 단위 동일**
(`BasicSample.dwg`, `civil.dwg`, `bb.dwg`, `C0060203-001-표준단면도….dwg`, `dwg_18.3mb.dwg`)
2. **JS 층** — 기존 `parse_dwg` 결과와 `JSON.parse(parse_dwg_json(...))` 결과를 `assert.deepStrictEqual`로 비교, 3개 도면 통과
(u64 핸들이 BigInt로 바뀌는 등의 타입 변화가 없음을 확인)
3. `tsc --noEmit` 통과, `npm run build` 성공
---
## 6. 진단 하네스
원인 규명에 쓴 네이티브 바이너리를 회귀 테스트용으로 남겨두었다.
`rust/dwg-wasm/src/bin/prof.rs` — 구간별 시간 측정 + 두 경로 출력 동등성 비교
```bash
cd hmwebviewer/rust/dwg-wasm
cargo build --release --bin prof
./target/release/prof.exe <file.dwg>
```
출력 예:
```
file dwg_18.3mb.dwg bytes 19243300
read_document 619 ms
Value tree + to_string 2875 ms (96897608 bytes)
streaming json 1188 ms (96897608 bytes)
BYTE-IDENTICAL: true
```
`document_to_parse_result`를 수정할 일이 생기면 이걸로 두 경로가 계속 일치하는지 확인하면 된다.
---
## 7. 남은 과제
1. **렌더링 비용 미측정**
파싱은 2.5초로 해결됐지만, 그 뒤 Viewer2D가 82,393개 엔티티를 실제로 그리는 시간은 별도로 측정하지 않았다. 대용량 도면이 여전히 느리다면 여기부터 봐야 한다.
2. **메인 스레드 블로킹**
파싱 2.5초 동안 여전히 탭이 멈춘다. 6분에 비하면 수용 가능하지만, Web Worker로 옮기면 진행률 표시와 취소가 가능해진다.
3. **`parse_dwg` 정리**
기존 JsValue 반환 export는 현재 아무도 쓰지 않는다. 외부 의존이 없다고 확인되면 제거해 wasm 크기를 줄일 수 있다.
4. **메모리 상한**
스트리밍으로 라이브 힙은 없앴지만 출력 문자열 자체는 여전히 97MB이고, wasm32는 주소 공간이 4GB로 제한된다. 이보다 몇 배 큰 도면은 다시 한계에 부딪힐 수 있다. 필요해지면 청크 단위로 JS에 넘기는 방식을 검토한다.
5. **회색 원 아티팩트 (별건)**
`dwg_18.3mb.dwg` 렌더 결과에 도면과 무관한 거대한 회색 원 2개가 나타난다. `CF-PATT` 레이어의 SOLID HATCH 2개(span 4655 / 4092)이며 본 성능 수정과는 **무관하다** — 출력 JSON이 수정 전후 바이트 단위로 동일하므로 원래부터 있던 문제이고, 지금까지 6분이 걸려 아무도 확인하지 못했을 뿐이다.
별도 조사·수정 완료 (최대 해치 span 4655 → 110): [`hatch-boundary-arc-artifact.md`](./hatch-boundary-arc-artifact.md)
---
## 8. 오픈소스 라이선스 (MPL-2.0)
DWG 파싱은 두 계층으로 나뉘며, 라이선스가 다르다.
| 구성요소 | 위치 | 라이선스 | 본 작업에서 |
|----------|------|---------|------------|
| `acadrust` v0.4.1 | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **수정하지 않음** |
| `dwg-wasm` (래퍼) | `hmwebviewer/rust/dwg-wasm/` | MIT (자체 코드) | 수정함 |
| 뷰어 | `src/viewer2d/` | 자체 코드 | 수정함 |
MPL-2.0은 **파일 단위 카피레프트**로, MPL 파일 자체를 수정했을 때 그 파일들을 MPL-2.0으로 공개할 의무가 생긴다. 라이브러리로 링크하기만 한 자체 코드는 대상이 아니다.
본 성능 수정은 전부 래퍼(`rust/dwg-wasm/src/lib.rs`)와 뷰어(`src/viewer2d/acadrustParser.ts`)에서 이루어졌고 `rust/acadrust/` 는 변경하지 않았다. **따라서 새로 발생하는 소스 공개 의무는 없다.**
```
$ git status --short rust/acadrust/
(변경 없음)
```
수정 여부와 무관하게 `rust/acadrust/LICENSE` (MPL-2.0 전문)와 저작권 고지는 계속 유지해야 하며, 현재 유지되고 있다.
향후 acadrust 내부를 직접 고쳐야 하는 상황이 오면 의무가 발생한다. 상세 지침은 [`hatch-boundary-arc-artifact.md` §7](./hatch-boundary-arc-artifact.md#7-오픈소스-라이선스-mpl-20) 참고.
> 라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다.
---
## 9. 한 줄 요약
> **파서 알고리즘 문제가 아니라, 결과 전체를 `serde_json::Value` 트리로 메모리에 쌓는 구조 때문에 라이브 힙이 2.9GB까지 부풀고, 힙이 커질수록 wasm 할당자의 할당 비용이 증가해 전체가 O(n²)가 된 것이다.
> 엔티티를 하나씩 JSON 텍스트로 흘려보내 라이브 힙을 평평하게 유지하면 선형으로 돌아오며, 19MB 도면 기준 354초 → 2.5초(143배)로 개선된다.
> 출력 JSON은 바이트 단위로 동일하므로 뷰어 쪽 변경은 `JSON.parse` 한 줄뿐이다.**
---
## 10. 참고 파일
- 스트리밍 구현: [`../../hmwebviewer/rust/dwg-wasm/src/lib.rs`](../../hmwebviewer/rust/dwg-wasm/src/lib.rs) (`document_to_parse_result_json`, `parse_dwg_json`)
- 뷰어 연결부: [`../src/viewer2d/acadrustParser.ts`](../src/viewer2d/acadrustParser.ts)
- 진단 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs)
- 쿼리 파라미터 로딩부: [`../src/main.ts`](../src/main.ts) (`loadUrl`, `?model=`)
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)
+15
View File
@@ -3,15 +3,30 @@
export function parse_dwg(bytes: Uint8Array): any; export function parse_dwg(bytes: Uint8Array): any;
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
*/
export function parse_dwg_json(bytes: Uint8Array): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput { export interface InitOutput {
readonly memory: WebAssembly.Memory; readonly memory: WebAssembly.Memory;
readonly parse_dwg: (a: number, b: number) => [number, number, number]; readonly parse_dwg: (a: number, b: number) => [number, number, number];
readonly parse_dwg_json: (a: number, b: number) => [number, number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void; readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void; readonly __wbindgen_start: () => void;
} }
+34
View File
@@ -13,6 +13,40 @@ export function parse_dwg(bytes) {
} }
return takeFromExternrefTable0(ret[0]); return takeFromExternrefTable0(ret[0]);
} }
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function parse_dwg_json(bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_dwg_json(ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
function __wbg_get_imports() { function __wbg_get_imports() {
const import0 = { const import0 = {
__proto__: null, __proto__: null,
Binary file not shown.
+2
View File
@@ -2,8 +2,10 @@
/* eslint-disable */ /* eslint-disable */
export const memory: WebAssembly.Memory; export const memory: WebAssembly.Memory;
export const parse_dwg: (a: number, b: number) => [number, number, number]; export const parse_dwg: (a: number, b: number) => [number, number, number];
export const parse_dwg_json: (a: number, b: number) => [number, number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_externrefs: WebAssembly.Table; export const __wbindgen_externrefs: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void; export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_start: () => void; export const __wbindgen_start: () => void;
+11 -3
View File
@@ -2,7 +2,7 @@
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm * acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
* (wasm-pack --target web, committed under ./acadrust-dwg). * (wasm-pack --target web, committed under ./acadrust-dwg).
*/ */
import init, { parse_dwg } from './acadrust-dwg/acadrust_dwg.js'; import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url'; import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
import type { CadParseResult } from './dwgParser'; import type { CadParseResult } from './dwgParser';
@@ -14,7 +14,15 @@ export function initAcadrustParser(): Promise<void> {
return ready; return ready;
} }
/** Parse a DWG buffer. Requires initAcadrustParser() first. */ /**
* Parse a DWG buffer. Requires initAcadrustParser() first.
*
* Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg`
* export that hands back a JsValue. The latter keeps the entire result live on
* the wasm heap as it builds, and the allocator's cost grows with that heap —
* a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as
* text keeps it linear: the same file now lands in ~2.5 s.
*/
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult { export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return parse_dwg(bytes) as CadParseResult; return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
} }