Files
dwg-dxf-viewer-sample/docs/hatch-boundary-arc-artifact.md
minsungandClaude Opus 5 b047dc44e2 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>
2026-08-03 13:55:34 +09:00

388 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 해치 경계 원호 아티팩트 — 원인 분석 및 해결 가이드
| 항목 | 내용 |
|------|------|
| 작성 목적 | 도면과 무관한 거대 회색 도형이 그려지는 문제의 원인 규명과 수정 내역 기록 |
| 샘플 저장소 | `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)