# 해치 경계 원호 아티팩트 — 원인 분석 및 해결 가이드

| 항목 | 내용 |
|------|------|
| 작성 목적 | 도면과 무관한 거대 회색 도형이 그려지는 문제의 원인 규명과 수정 내역 기록 |
| 샘플 저장소 | `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)
