# 해치 경계 원호 아티팩트 — 원인 분석 및 해결 가이드 | 항목 | 내용 | |------|------| | 작성 목적 | 도면과 무관한 거대 회색 도형이 그려지는 문제의 원인 규명과 수정 내역 기록 | | 샘플 저장소 | `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 [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)