Files
dxf-to-3d-gasiseol/docs/2d-3d-conversion.md
T
Claude Haiku 363c2a2a25 step3-4: 프로젝트 기본 구조 & 상세 계획 수립
- CLAUDE.md: 프로젝트 전체 가이드 및 워크플로우 정의
- CONSIDERATIONS.md: 8개 영역 고려사항 분석 (기술, 품질, 배포, UX)
- PROGRESS.md: 진행 상황 추적 및 체크리스트
- PLAN.md: 단계 5~9 상세 계획 및 에이전트 역할 정의
- 메모리: 프로젝트 정보 저장

다음: 단계 5 (에이전트/커맨드/스킬/훅 아이디어 제안)
2026-10-16 15:06:18 +09:00

81 KiB
Raw Blame History

2D → 3D 전환 노하우

DWG 도면(평면도 + 다수 단면도)을 읽어 3D 솔리드 모델로 세우는 작업의 축적 문서. 작업하면서 얻은 교훈을 계속 덧붙인다. 최신 항목이 아래로 쌓인다.

  • 대상 도면: drawings-src/04manhole1-simplify.dwg (맨홀 #1 및 출입구 일반도, 4매)
  • 파이프라인 코드: src/
  • 3D 생성 수단: EGModeler + egm CLI (D:\GiteaAll\EG-BIM_Modeler)

절차는 2d-3d-process.md 에 따로 있다. 이 문서는 왜 그렇게 하는가, 그 문서는 무엇을 어떤 순서로 하는가.


1. 교훈 #1 — 기준점을 먼저 정하면 나머지가 쉬워진다

가장 크게 시간을 아낀 판단. 도면마다 기준점을 미리 확정해두면, 이후 모든 좌표가 단순 뺄셈으로 나온다. 반대로 기준점 없이 형상부터 뽑으면 뷰마다 좌표계가 달라 정합 단계에서 전부 다시 해야 한다.

프로젝트 좌표계 (이 도면 기준)

축 방향 원점
X 전력구 길이방향 구조물 최좌단(분기구 #1 쪽 구체 외면)
Y 폭방향 전력구 중심선
Z 표고 도로 계획고 (아래로 음수)

단위는 내부 계산 mm 정수, egm에 넘기기 직전에만 m로 나눈다. mm 정수로 반올림하면 DWG의 부동소수 노이즈(-500.00000000001455)가 자동으로 흡수된다.

기준점은 도면에서 "검출"해야지 눈으로 읽으면 안 된다

Z 기준을 도로 계획고 텍스트 위치로 잡았다가 713mm 틀렸다. 텍스트는 라벨이고, 실제 지반선은 그 아래에 따로 있다.

도로 계획고 TEXT   DWG y = 483624.19    ← 라벨. 쓰면 안 됨
도로 계획고 LINE   DWG y = 483623.477   ← 정답 (레이어 `0지반`)

검증법: 기준점을 잡은 뒤 도면의 치수 숫자와 대조한다. 맞으면 확정.

구체 상단 Z = -4,907  ↔  B-B 도면 치수 4,907   ✓
기초 하단 Z = -7,757  ↔  A-A 도면 치수 7,757   ✓

기준선 후보가 둘이면 "라벨 바로 아래"로 가른다

계획고는 보통 도로/토공 두 줄이 나란히 그려진다. 둘 다 모든 뷰에 나타나므로 "가장 많은 뷰에 반복되는 수평선"만으로는 갈리지 않는다. 실제로 토공 계획고를 집어 420mm 어긋났다.

라벨은 자기 선을 가리킨다. 라벨 바로 아래에 있는 선이 그 라벨의 선이다. 출현 횟수는 잡음을 거르는 문턱으로만 쓰고, 최종 선택은 라벨 근접도로 한다.

도로 계획고 라벨  y = 483624.19
  → 도로 계획고 선 483623.477  (0.713 아래)  ← 선택
  → 토공 계획고 선 483623.057  (1.133 아래)

공통 datum이 있으면 무조건 그것부터 찾아라

이 도면은 모든 단면이 같은 y에 계획고 선을 그렸다. 그래서 단면별 Z 오프셋 계산이 통째로 사라졌다. 7개 단면에서 독립적으로 검출한 지반선이 전부 Z=0, Z=-420으로 떨어져 기준점이 교차 검증됐다.

토목 도면에서는 계획고/GL/기준선(EL.)이 이 역할을 한다. 건축이면 FL/층레벨. 단면도를 배치할 때 표고선을 맞춰 그리는 관행이 그대로 3D 정합 데이터가 된다.

다음에 이 작업을 또 한다면: 도면 작성자에게 "각 뷰에 기준점 마커를 하나씩 찍어달라"고 요청하는 게 가장 싸다. 검출 로직 전체가 필요 없어진다.


2. 교훈 #2 — 뷰 분리는 제목 bbox가 아니라 기하 간격으로

여러 도면이 한 모델스페이스에 나열된 경우, 뷰를 먼저 갈라야 한다.

실패한 방법: 제목 텍스트 위치로 Voronoi 분할 → A-A와 B-B 영역이 겹쳐 B-B 추출 결과에 A-A 기하가 섞여 들어왔다.

간격 임계값 하나로는 절대 안 된다. 넓게 잡으면 이웃이 딸려오고, 좁게 잡으면 한 뷰가 쪼개진다. 이 도면에서 둘 다 실제로 터졌다.

증상 원인
A-A 하단(기초·잡석)이 잘려나감 구체와 기초 사이가 2.1m 비어 있어 임계값 1.2m에 잘림
I-I 폭이 6,000 → 12,954 로 부풀음 4,239m 떨어진 HOOK 상세도 조각이 붙어옴

되는 방법: 잘게 쪼갠 뒤 제목으로 다시 묶는다

1. 구조 레이어 기하만 모은다
2. x ↔ y 를 번갈아 가며 더 이상 갈라지지 않을 때까지 잘게 쪼갠다 (임계값 0.2m)
3. 조각마다 "바로 위에 있는 제목"을 찾아 귀속시킨다
4. 같은 제목을 가진 조각끼리 합치면 그게 뷰다

x→y 한 번씩만 하면 부족하다. y로 나눈 뒤에야 드러나는 x 간격이 있다 — I-I 옆 상세도 조각이 정확히 그 경우였다. 안정될 때까지 교대로 돌려야 한다.

제목 귀속이 핵심 장치다. 상세도·재료표도 제목 후보에 넣어두면 자기 조각을 가져가므로, 뷰 쪽으로 새어 들어오지 않는다. 뷰 제목이 아닌 것에 귀속된 조각은 그대로 버리면 된다.

제목 후보를 고를 때 — 글자 높이로 거른다

h=0.5   시트명    (맨홀 #1 및 출입구 일반도 (1)~(4))
h=0.35  뷰 제목 · 상세도 · 재료표            ← 여기까지가 표제
h=0.30  부재 방향 라벨 (분기구 #1, 맨홀 #2)  ← 표제 아님. 걸러야 함

h=0.30짜리 방향 라벨을 안 걸렀더니 A-A 하단부를 가져가 뷰가 잘려나갔다. 또 "제목이 조각 상단보다 살짝 아래여도 허용"하는 여유값을 크게(0.6m) 잡았더니 옆에 붙은 상부 SLAB용 표제가 I-I를 통째로 훔쳐갔다. 0.2m로 줄여 해결.

분할 결과 검증은 폭·높이를 도면 치수와 대조

H-H  10,200 × 2,800   도면 표기 10,200 / 2,800  ✓
I-I   6,000 × 2,800   도면 표기  6,000 / 2,800  ✓
J-J   6,000 × 2,800   도면 표기  6,000 / 2,800  ✓
A-A  20,200 × 10,839  ↔  E-E 10,839  ← 심도 일치, 교차 검증

폭이 도면값보다 크면 잡석/기초처럼 바깥으로 삐져나온 요소이거나(정상), 이웃 뷰가 딸려온 것(버그)이다. 차이값이 딱 떨어지는지 확인하면 구분된다. 서로 다른 뷰의 심도가 같게 나오면 기준점이 맞다는 강한 방증이다.


3. 교훈 #3 — 닫힌 폴리라인을 DWG에서 만들 필요가 없다

처음엔 "AutoCAD에서 BOUNDARY로 닫힌 폴리라인을 만들어 달라"고 요청했다. 불필요했다. 3D 툴(egm)이 좌표 리스트로 폴리라인을 직접 만들 수 있으면, DWG에서는 꼭짓점 좌표만 뽑으면 된다.

DWG 파싱 → 꼭짓점 순서 계산 → egm "Polyline x,y,z ..." → Extrude → Solid
                                     ↑ 여기서 닫힌 커브가 새로 생김

즉 문제가 "닫힌 영역 찾기(어려움)"에서 "꼭짓점 순서 정렬(쉬움)"로 바뀐다. DWG 편집 권한도, DWG writer도 필요 없다.

게다가 형상이 규칙적이면 기하 대신 치수를 쓰는 게 낫다

이 도면의 구조체는 거의 전부 **직사각형 + 모따기(헌치)**다. LINE 위상을 추적하는 것보다, 치수 텍스트에서 파라미터를 뽑아 프로파일을 파라메트릭으로 생성하는 쪽이 짧고 정확하다.

단, 기하에서 뽑은 값과 치수 표기값을 서로 대조하는 게 핵심이다. 둘이 일치하면 그 단면은 신뢰할 수 있다. 실제로 B-B에서 순수 기하만으로 뽑은 2,700 / 2,100 / 2,750 / 2,150 / 150x150 / THK100 / THK300이 도면 표기와 전부 일치했다.


3.5. 교훈 #4 — DWG를 뷰별로 미리 쪼개지 마라

"뷰마다 별도 DWG로 분리해두면 편하지 않겠나"는 자연스러운 발상이지만, 손해다.

한 도면 유지 뷰별 분리
표고 정합 공짜 — 전 단면이 같은 y에 계획고선을 공유 파일마다 원점이 따로 놀아 관계가 사라짐
절단기호 ↔ 단면 매핑 SYM 좌표로 자동 연결 파일 간 참조가 끊겨 수동 매핑
뷰 분리 비용 간격 클러스터링 ~30줄 (불필요하나 위 손실이 더 크다)

이 작업에서 가장 큰 이득이 **"모든 단면이 같은 표고선에 맞춰 배치돼 있다"**였는데, 이건 한 모델스페이스에 함께 있어야만 성립한다. 분리하면 3D 정합의 절반을 버린다.

분리가 이득인 경우는 제한적이다 — 파일이 너무 커서 파싱이 안 되거나(이 도면은 380KB / 7,035 엔티티로 여유), 축척·좌표계가 다른 프로젝트가 섞여 있을 때.

대신 도면에 해주면 좋은 것

레이어 "3D-ORIGIN" 을 만들고, 각 뷰에 POINT 를 하나씩 찍어 기준점을 표시

기준점 검출 로직이 통째로 사라지고, 텍스트/선 혼동 같은 사고도 원천 차단된다. 분리보다 훨씬 싸고 효과가 크다. 여력이 되면 구조체 외곽선 레이어도 하나로 통일.


3.6. 교훈 #5 — 캡처 이미지에 좌표 변환을 실어 보내라

기하 파싱만으로는 "이 폐곡선이 구체인가 치수보조선인가" 같은 판단이 어렵다. 반대로 이미지를 보면 사람은 즉시 안다. 둘을 합치는 장치가 필요하다.

렌더 캡처 PNG를 만들 때 픽셀 ↔ DWG 좌표 변환을 사이드카로 같이 저장하면, 이미지에서 짚은 지점을 곧바로 도면 좌표(그리고 프로젝트 mm)로 환산할 수 있다.

// 04manhole1_단면B-B.json  (같은 이름의 PNG 옆에)
{
  "view": "단 면 B-B",
  "image": { "w": 1188, "h": 1400 },
  // 이미지 좌상단 픽셀에 대응하는 DWG 좌표와 픽셀당 DWG 단위
  "originDwg": [-48352.400, 483619.100],
  "unitsPerPx": 0.0043,
  "yFlipped": true,          // 화면 y는 아래로 증가, DWG y는 위로 증가
  "roadLevelY": 483623.477   // 프로젝트 Z=0 기준
}

변환:

dwgX = originDwg[0] + px * unitsPerPx
dwgY = originDwg[1] - py * unitsPerPx        // yFlipped
프로젝트 Z(mm) = round((dwgY - roadLevelY) * 1000)

변환을 "역산"하지 말고 "지정"해라 — 뷰 박스로 정확히 줌해서 캡처

렌더 결과를 놓고 축척을 되짚으면 여백·DPI·레터박싱 어딘가에서 반드시 어긋난다. 줌 rect 를 먼저 지정하고 캔버스 크기를 축척에서 역산하면 어긋날 구석이 없다.

1. 뷰 분리 결과(views.json)에 각 뷰의 DWG bbox 가 이미 있다
2. 축척을 고른다.            예: 5 mm/px
3. 캔버스 크기 = bbox 크기 / 축척
     단면 B-B  4,300 × 3,150mm  →  860 × 630 px
4. 그 rect 에 정확히 맞춰 줌 → 캡처

이러면 unitsPerPx 가 계산값이 아니라 내가 정한 값 그대로다. 뷰 박스와 캔버스의 종횡비가 자동으로 같아지므로 레터박싱 여백 보정도 필요 없다.

부수 효과로 뷰별 캡처가 자동화된다 — 분리 결과를 읽어 루프만 돌리면 된다.

이게 있으면 작업 방식이 바뀐다:

  • 추출 결과를 이미지 위에 겹쳐 그려 눈으로 검증할 수 있다
  • 애매한 형상을 이미지에서 짚으면 정확한 좌표가 나온다
  • "여기 폐곡선이 안 잡힌다" 같은 피드백을 좌표로 주고받을 수 있다

이 저장소의 Viewer2D는 이미 DWG→화면 변환을 알고 있으므로, 캡처 시 그 행렬을 JSON으로 함께 떨구기만 하면 된다. 렌더러 밖에서 역산하려 들면 축척·여백·DPI에서 반드시 어긋난다 — 변환은 렌더러가 알고 있을 때 받아 적는 게 유일하게 안전하다.


4. 도면 읽기 — 의미 추론에서 실제로 통한 단서들

단서 쓰임
제목 텍스트 높이 h=0.5 시트명, h=0.35 뷰 제목, h≤0.15 치수/주기
레이어 SYM 단면 절단 기호의 글자 라벨만 들어있다 (A~J 각 2개 = 절단선 양끝)
절단 기호 좌표쌍 두 글자의 x가 같으면 수직 절단, y가 같으면 수평 절단
재료 주기 텍스트 구체 콘크리트 fck=27MPa, 기초 콘크리트 THK=100mm → 두께·재질 파라미터 직독
치수 actualMeasurement 실측값(mm). 도면 축척과 무관하게 정확

수평 단면을 놓치지 말 것

H-H / I-I / J-J는 수직 단면이 아니라 E-E 단면 위에 표고별로 찍힌 수평 절단이다. 평면 형상을 층별로 주는 데이터라서, 내부 격실 배치를 세울 때 가장 중요하다. 절단 기호가 다른 단면도 위에 찍혀 있으면 십중팔구 수평 단면이다.


4.5. 교훈 #6 — 재질은 레이어 이름이 이미 인코딩하고 있다

재질 주기 텍스트를 지시선 따라가며 형상에 잇는 건 어렵다. 그런데 할 필요가 없다.

레이어 -전력구 / 전력구 / 집수정 / A-신규선형-구조물-맨홀 …  →  구체 콘크리트
레이어 !-기초                                          →  기초 콘크리트
레이어 잡석                                            →  잡석
레이어 -보도                                           →  보도 콘크리트

레이어가 재질을, 텍스트 주기가 그 재질의 사양(fck, THK)을 준다. 둘을 곱하면 재질 배정이 끝난다. 지시선 추적이 통째로 사라진다.

사양은 전역 상수로 박으면 안 된다 — 위치마다 다르다

src/materials.mjs 로 뷰별 사양을 뽑아보니 실제로 갈렸다.

재질 사양 적용 뷰
구체 콘크리트 fck=27MPa 전 단면 공통
기초 콘크리트 fck=18MPa, THK=100mm 전 단면 공통
잡석 THK=500mm A-A, C-C, E-E, F-F, G-G
잡석 THK=300mm B-B, D-D
보도 콘크리트 fck=27MPa A-A
보도 콘크리트 fck=21MPa E-E, F-F, G-G

잡석 두께와 보도 콘크리트 강도가 위치별로 다르다. "잡석 = THK 500" 으로 박으면 전력구 구간이 200mm 두꺼워진다. 반드시 뷰별로 읽을 것.

IFC 매핑 초안

레이어 → 재질이 잡히면 IFC 클래스·재질 배정까지 그대로 이어진다.

부재 IFC 클래스 재질 비고
구체 벽체 IfcWall Concrete C27
구체 상·하부 슬래브 IfcSlab Concrete C27
기초 콘크리트 IfcFooting Concrete C18 버림 콘크리트
보도 슬래브 IfcSlab Concrete C21/C27 뷰별 상이
잡석 부설 civil 계열 (확인 필요) Crushed stone IfcCourse/IfcEarthworksFill 후보
토목섬유 civil 계열 (확인 필요) Geotextile
전력구 전체 IfcBuiltElement 또는 civil 계열 — 집합 요소

재질 속성은 IfcMaterial + Pset_MaterialConcrete(CompressiveStrength) 로, 층상 구성(잡석 → 기초 → 구체)은 IfcMaterialLayerSet 으로 표현한다.

도면 표제란에서 같이 읽히는 설계 정보 — IfcProject 속성으로 붙이면 된다.

콘크리트 설계기준 강도   fck = 27 MPa
철근 항복강도          fy  = 400 MPa
설계법                신뢰성 기반 설계법
설계하중              한국형 표준트럭하중(KL-510)

미확인: 잡석·토목섬유에 쓸 IFC4.3 civil 엔티티 이름은 스키마를 직접 확인하고 확정할 것. 위 표의 후보는 검증 전이다. 건축계 엔티티(IfcWall/IfcSlab/ IfcFooting)와 재질 계열(IfcMaterial*, Pset_MaterialConcrete)은 IFC4 표준이라 그대로 써도 된다.


4.55. 교훈 #7 — 종단면은 "형상"이 아니라 **"구간 배정표"**로 읽어라

가장 크게 틀렸던 지점. 횡단면 하나(B-B)를 보고 "이게 이 구조물의 단면"이라 단정해 15.4m 전체에 밀었다. 실제로는 절단 위치마다 단면이 다르다.

X  -1,000 ~      0   입구 스터브   B-B  구체 2,700×2,750
X       0 ~ 15,400   전력구 본체   C-C  구체 3,800×4,600   ← 본체는 이쪽
X  18,200 ~ 19,200   출구 스터브   B-B

어디서 어디까지 어느 단면인지는 종단면에만 있다. 횡단면은 "그 지점의 모습"만 알려줄 뿐, 그게 어디까지 유효한지는 말해주지 않는다.

잘못된 순서   횡단면 → 압출
올바른 순서   종단면에서 구간 분할 → 구간별 횡단면 배정 → 압출

종단면 읽는 법 — 수평선을 Z 내림차순으로 X 범위와 함께 늘어놓는다

각 수평선의 X 범위가 곧 "그 표고가 유효한 구간"이다. 구간 경계가 바로 단면이 바뀌는 자리다.

Z= -3,157  X      0 ~  1,700 · 3,400 ~ 12,400 …   본체 구체 상단
Z= -3,557  X    550 ~ 15,250                      본체 내부 천장 (슬래브 400)
Z= -4,907  X -1,000 ~   -299                      스터브 구체 상단  ← 단면 전환
Z= -7,357  X -1,000 ~ 19,200 (전 구간)             내부 바닥 — 두 구간이 공유
Z= -7,757  X      0 ~ 15,100                      본체 구체 하단

여기서 바닥(-7,357)은 전 구간 공통이고 천장만 달라진다는 것도 바로 보인다. 횡단면만 봐서는 절대 알 수 없는 정보다.

횡단면이 인접 형상과 붙어 한 덩어리로 잡히는 경우

C-C 의 구체 외곽 폐곡선은 3,800×7,557 로 나온다 — 위로 솟은 샤프트가 같은 윤곽에 붙어 있기 때문이다. 이럴 때는 직사각으로 따로 만들되, 치수는 반드시 다른 뷰에서 읽은 실측치를 쓰고 교차검증한다.

expect('단 면 C-C', 3800, 7557, 'C-C 외곽은 샤프트 포함 형상이어야 한다');
profile('main.outer', 'YZ', rect(-1900, -7757, 1900, -3157));   // A-A 에서 읽은 표고

손으로 지어낸 숫자를 넣기 시작하면 파이프라인의 의미가 사라진다.


4.56. 교훈 #8 — 절단 기호의 좌표쌍이 단면의 평면을 결정한다

SYM 레이어의 글자쌍은 절단선 양끝이다. 두 점의 어느 좌표가 같은지가 그 단면이 어느 평면을 그리는지를 그대로 알려준다.

두 점의 x 가 같다 → 세로 절단선 → YZ 단면 (폭 방향을 본다)   B, C, D, E
두 점의 y 가 같다 → 가로 절단선 → XZ 단면 (길이 방향을 본다)  A, F, G

폭 치수만으로는 구분이 안 되는 경우가 있다. 이 환기구는 X 도 2,600, Y 도 2,600 이라 숫자가 같다. 반드시 절단 기호 좌표로 판정할 것.

압출축이 어느 방향 벽을 만드는지 항상 의식하라

YZ 단면을 X 축으로 밀면 → Y 방향 벽만 생긴다. X 방향은 열린다.
XZ 단면을 Y 축으로 밀면 → X 방향 벽만 생긴다. Y 방향은 열린다.

맨홀을 E-E(YZ)만으로 X 축 압출했더니 X 방향 벽이 없어 안이 들여다보였다.

나머지 방향 벽은 "공동의 압출 범위를 좁혀서" 만든다

두 방향 단면을 교집합할 필요가 없다. 외곽은 전 구간을 채우고 공동만 안쪽으로 줄이면 앞뒤로 벽이 남는다.

part({ id:'mh.solid',    op:'extrude', from:15400, to:18200 });  // 외곽 전폭
part({ id:'mh.cut.upper',op:'extrude', from:15800, to:17800 });  // 내벽 사이만
//                                          ↑ 좌우 400 벽이 남는다

공동의 끝이 솔리드 내부라 면이 겹치지 않는다 — 여유(OVER)도 필요 없다.


4.57. 교훈 #9 — 오목한 외곽은 "봉투"부터 만들어라

평면 분할은 오목한 윤곽의 패인 부분을 별개 면으로 뽑는다. 그 면이 되메움부인지 실제 공동인지는 크기로 알 수 없다.

이 맨홀에서 E-E 외곽(#0)은 상부 통로 자리를 오목하게 파고 지나간다. 그 영역에는 애초에 솔리드가 없다. 공동을 아무리 좁게 뚫어도 만들 벽이 없다.

mh.shell = 외곽(#0) 압출          ← 오목한 채로
mh.fill  = 통로면(#1) 압출         ← 패인 부분을 채움
mh.solid = union(shell, fill)     ← 비로소 전체 봉투
mh.body  = solid − 공동들          ← 이제 벽이 남는다

순서가 핵심이다. 봉투를 먼저 만들고 그 다음에 뚫는다. 오목한 외곽을 그대로 쓰면 "뚫을 것이 없는" 상태에서 뚫는 셈이라 아무 일도 일어나지 않는다.


4.58. 교훈 #10 — 구간이 맞닿으면 union 후 한 번만 difference

단면이 다른 구간이 이어질 때, 구간별로 따로 빼면 경계에서 반드시 구멍이 난다. 큰 쪽 공동이 여유(OVER)만큼 작은 쪽으로 넘어가 단차 벽면을 파먹기 때문이다.

✗  main.body  = main.solid  − main.void      (void 가 -200 까지 넘어감)
   stubL.body = stubL.solid − stubL.void
   → 스터브 끝벽이 사라져 안이 들여다보인다

✓  duct.solid = union(main.solid, stubL.solid)
   duct.void  = union(main.void,  stubL.void)   ← 전이점에서 딱 맞물리게
   duct.body  = duct.solid − duct.void

공동끼리는 전이점에서 겹치지도 벌어지지도 않게 나눈다. main.void: 250→15,600, stubL.void: -1,200→250.

단, 여기서 union 하는 것은 "구간 몸통"이지 "커터"가 아니다. 서로 떨어진 커터를 하나로 합치려 들면 안 된다 — 교훈 #14 를 볼 것.


4.59. 교훈 #11 — 수평 단면은 세로축이 표고가 아니다

H-H/I-I/J-J 같은 수평 절단은 도면 가로축이 프로젝트 Y, 세로축이 X 다. 그 뷰가 도면 어느 높이에 놓였는지는 임의라, 세로 좌표를 그대로 Z 로 쓰면 안 된다.

H-H  u 0~10,200      → Y -1,900~8,300   (평면도 맨홀 폭과 일치)
     v -1,725~1,075  → X 15,400~18,200  (맨홀 X 범위와 일치)

앵커에 v0 와 swapUV 를 둬서 [Y,X] 를 [X,Y] 순서로 바꾼다. 축을 뒤집거나 맞바꾸면 감김 방향이 반대가 되므로 뒤집기 횟수가 홀수면 꼭짓점 순서를 되돌려야 면적이 양수로 유지된다.

맞게 잡혔는지는 다른 뷰와 교차검증하면 바로 안다.

J-J #1  X 15,800~17,800 × Y   200~3,700   집수조 3,500   ← E-E 와 일치
J-J #2  X 15,800~17,800 × Y -1,500~0      침사조 1,500   ← E-E 와 일치
H-H #3  X 15,800~17,000 × Y  -500~1,500   환기 OPEN부    ← 주기 "2.0x1.2" 와 일치
I-I #4  X 15,800~17,000 × Y 1,800~2,800   점검구         ← 주기 "1.0x1.2" 와 일치

4.591. 교훈 #12 — 링은 못 믿어도 bbox 는 믿을 수 있다

개구부 폐곡선을 그대로 썼더니 자기교차하는 쓰레기가 나왔다.

mh.ventHole  [15950,1500] [15800,1500] [17000,-500] [17000,1500] [15800,-500] …
                                        ↑ 대각선으로 튄다

원인이 셋 겹쳤다.

  1. 개구부 윤곽이 000-구조물실선 에 모서리가 안 닫힌 채로 그려져 있다
  2. 그 자리에 -전력구 레이어 선이 겹쳐 있다
  3. 개구 기호(X 표시)와 지시선이 사각형 안을 가로지른다

평면 분할은 이 선들을 전부 경계로 삼으므로 엉뚱한 면을 따라 돈다.

사람 눈에는 멀쩡한 사각형으로 보인다. 끊긴 폭이 몇 mm라 화면에서 안 보이고, X 표시는 개구 기호로 읽고 넘어간다. 기계만 걸리는 종류의 결함이다.

대응 — bbox 로 재구성하고 폐곡선은 확인용으로만

끊기거나 오염돼도 범위(bbox)는 정확하다. 형상이 직사각형이라는 걸 알면 bbox 만으로 복원할 수 있다.

expect('단 면 H-H', 1200, 2000, '환기 OPEN부 (2.0x1.2)');   // 실재 확인만
profile('mh.ventHole', 'XY', rect(15800, -500, 17000, 1500)); // 실제 프로파일

expect 를 남겨두는 게 중요하다. bbox 값이 도면과 어긋나면 거기서 걸린다.

이런 자리를 미리 알아채는 법

--loops 출력의 꼭짓점 수가 신호다. 직사각형이어야 할 것이 4가 아니면 의심한다. 개구 기호가 들어간 면은 8, 케이블랙이 섞인 면은 114 가 나왔다.


4.6. 교훈 #13 — 조립 스펙에서 폐곡선은 "면적 순서"가 아니라 "치수"로 집어라

assemble() 이 프로파일을 고를 때 loops[0], loops[1] 처럼 순서로 집으면 도면이 조금만 바뀌어도 조용히 다른 면을 집는다. 면적 순위는 흔들린다.

폭×높이로 집는다. 그 값이 곧 도면 치수라서 사람이 읽고 검증할 수 있고, 못 찾거나 여러 개면 예외를 던져 조용한 오류가 안 생긴다.

profile('duct.outer', 'YZ', loop('단 면 B-B', 2700, 2750).ring);   // 도면 표기 그대로
profile('duct.inner', 'YZ', loop('단 면 B-B', 2100, 2150).ring);

boolean 커터는 대상보다 크게 뽑아라

압출 구간이 딱 맞으면 커터와 대상의 면이 겹쳐 boolean 이 불안정해진다. 양쪽으로 조금(여기선 200mm) 빼서 확실히 관통시킨다.

part({ id:'duct.solid', op:'extrude', from: 0,    to: 15400 });
part({ id:'duct.void',  op:'extrude', from: -200, to: 15600 });   // 양쪽 200 여유

재료 경계선을 프로파일 추출에서 빼면 면이 뭉친다

구체만 넣고 뽑으면 기초·잡석이 한 덩어리가 된다. 면이 갈리려면 경계선이 있어야 하므로 추출에는 함께 넣고, 배정은 폐곡선의 레이어를 보고 나중에 한다. 단 케이블 지지대 같은 가구는 넣으면 안 된다 — 폐곡선이 그 윤곽을 따라 돈다.


4.7. 교훈 #14 — boolean 은 이항이다. 커터는 하나씩 먹인다

BooleanDifference · BooleanUnion 플러그인은 선택 개수와 무관하게 TargetModels[0], [1] 두 개만 쓴다. 셋 이상 넘기면 나머지는 예외도 경고도 없이 무시되고 씬에 그대로 남는다.

✗  select(mh.solid); select(cut1 … cut9, add)
   BooleanDifference
   → 컷 1개만 적용. 커터 8개가 씬에 떠다닌다

emit/egm.mjs 의 fold() 가 둘씩 접는다. 중간 결과는 id~1, id~2 … 로 잡고 마지막만 id 가 된다.

✓  mh.solid  − cut.upper      → mh.body~1
   mh.body~1 − cut.lower      → mh.body~2
   …
   mh.body~8 − cut.pumpHole   → mh.body

커터를 먼저 union 해서 하나로 만드는 방법은 못 쓴다. 다리(legL/legR)와 환기 개구처럼 서로 떨어진 커터는 교차하지 않아 union 이 ArgumentNullException 으로 죽는다. 교훈 #10 에서 union 하는 것은 "구간 몸통"이지 커터가 아니다.

이 사고를 잡아내는 신호

egm status 의 modelCount 를 최종 파트 수와 대조한다. 가장 빠르다.

modelCount 11   정상 (최종 파트 11개)
modelCount 19   커터 8개가 안 먹혔다

실행 직후 한 줄이면 되므로 매번 확인한다. 형상만 보면 컷 하나가 빠진 걸 알아채기 어렵다 — 남은 커터 솔리드가 구체와 겹쳐 있어 겉보기엔 멀쩡하다.


4.8. 교훈 #15 — 뷰어 모드면 명령이 조용히 무시된다

모델러가 뷰어 모드로 떠 있으면 모델링 명령이 등록조차 안 된다. 그런데 파이프는 살아 있어서 egm status 는 정상 응답하고, ~Polyline 은 아무 말 없이 created: [] 를 낸다. "모델러가 죽었나" 로 시간을 크게 버린 함정이다.

원인은 CommandManager.cs:430-434 다. EgEditorCommand 를 상속한 명령은 EGViewport.Plugins 에 해당 플러그인이 없으면 등록에서 건너뛴다. 모델링 플러그인은 뷰포트가 한 번 렌더된 뒤 LoadProjectPluginsAsync() 로 올라오므로, 뷰어 모드거나 창이 화면 밖에 있으면 같은 증상이 난다.

작업 시작 전에 이것부터 확인한다.

egm guide | python -c "import sys,json;print(len(json.load(sys.stdin)))"
#  39  → 뷰어 모드. 보기·선택·계측만 있다
# 207  → 정상

egm guide Polyline    # "Command 'Polyline' not found" 면 뷰어 모드

39개 쪽에 남아 있는 것은 SelAll · Zoom · Delete · Distance · Isolate 같은 계열뿐이다. Polyline · Box · Extrude · Boolean* 가 통째로 빠진다. New · ModelClear 는 뷰어 모드에서도 result 에 찍히므로, "명령이 하나라도 먹으니 연결은 정상"이라는 판단이 함정이다.

CLI 로는 못 바꾼다. 모델러 UI 에서 View Mode → Edit Mode 로 놓는다 (AppModeManager.ViewerMode, 상단바 토글 또는 Display and Workspace → Interaction Mode).

Settings.json 의 ModelerMode 와 헷갈리지 말 것. 그쪽은 Assistive Features → Working Mode Setting 의 별개 값이고, 0(ViewingOnly) 인 채로도 Edit Mode 면 모델링이 된다. 명령 등록을 가르는 것은 ViewerMode 다 (TestHost/MainWindow.xaml.cs:62 가 같은 이유로 ViewerMode = false 를 세운다).


4.9. 교훈 #16 — loft 는 꺾인 자리를 못 만든다

전이부(스터브 B-B → 본체 C-C)를 세우면서 loft 를 처음 썼다. 스터브 단면에서 본체 단면까지 loft 하나로 이었더니, 결과가 도면의 "챔퍼로 꺾인 뒤 수직 단차"가 아니라 끝까지 비스듬히 올라가는 긴 경사면 하나가 됐다.

원인은 loft 를 "단면 A 에서 단면 B 로 가는 길"로 본 것이다. loft 는 두 단면 사이를 곧게 이을 뿐이다. 꺾이는 자리는 loft 가 만들어 주지 않는다 — 꺾이는 자리마다 단면을 하나씩 놓고 구간을 끊어야 그 자리가 생긴다.

챔퍼        loft         양 끝 단면이 다른 구간
수직 단차   union        앞 구간이 작은 채로 끝나고 뒤 구간이 크게 시작한다.
                        그 차이가 곧 단차 벽면이다. 따로 만들 것이 없다
종방향 헌치 별도 쐐기     XZ 삼각형을 Y 로 압출해 union 으로 되붙인다

꺾이는 X 가 다르면 loft 를 또 끊는다

이 전이부의 외곽은 옆·위가 X -300…0, 아래가 X -100…0 에서 꺾인다. 한 번에 이으면 아래 챔퍼가 100mm 짜리인데 300mm 로 늘어난다.

stubL.solid  extrude  X -1000 → -300
ch1.solid    loft     stub.outer@-300 → out.ch1@-100    옆·위만 벌어진다
ch2.solid    loft     out.ch1@-100    → out.ch2@0       아래 챔퍼가 여기서 합류
main.solid   extrude  X 0 → 15400                       ← X=0 수직 단차 자동 생성

중간 단면(X=-100)의 값은 측정한 직선 위의 내분점을 코드가 계산하게 둔다. 상수로 박으면 도면이 바뀔 때 조용히 틀린다.

단면은 2개씩만 — 3개부터 부푼다

Loft 는 단면을 N개 받지만 3개 이상이면 스플라인 보간으로 부풀 수 있다. 2단면 loft 는 직선 룰드 서피스라 그럴 일이 없다. 확인법이 간단하다 — 결과의 정점 X 값에 중간 행이 안 생기면 곧게 이어진 것이다.

정점 X:  -1000  -300  -100  0  100  400  550  15400
         └ 전부 단면을 놓은 자리. 사이에 중간 행이 없다 = 직선

링 정규화가 없으면 loft 가 비틀린다

loft 는 두 프로파일의 꼭짓점을 순서대로 잇는다. 그런데 buildLoops 의 시작 꼭짓점은 half-edge 순회가 어느 반변에서 출발했느냐에 달려 있어 프로파일마다 제각각이다. 그대로 이으면 엉뚱한 꼭짓점끼리 짝지어 면이 꼬인다.

형상만 봐서는 원인을 못 찾는다 — 링 자체는 멀쩡하기 때문이다. addProfile 이 등록할 때 정규화한다(반시계 · v 최소 → u 최소 꼭짓점에서 시작). 압출에는 영향이 없다. 닫힌 커브라 시작점이 어디든 같은 커브다.

정규화 뒤 두 내부 단면이 인덱스별로 정확히 짝을 이뤘다.

i   stub.inner        main.inner        의미
0   (-1050,-7357)     (-1500,-7357)     바닥 좌단
6   ( 1050,-5357)     ( 1500,-3707)     헌치 시작 (우)
7   (  900,-5207)     ( 1350,-3557)     헌치 끝   (우)

위상이 다르면(꼭짓점 수가 다르면) 정규화로도 안 된다. validateIR 이 평면 일치 · 꼭짓점 수 일치 · 위치 오름차순을 먼저 막는다.

검증은 부피 대조가 제일 빠르다

비틀린 loft 는 부피가 크게 어긋난다. 반대로 부피가 맞으면 꼬이지 않은 것이다. ExportByPath 로 OBJ 를 받아 발산 정리로 재고, IR 프로파일에서 계산한 기대값과 맞춘다. 두 단면 사이 면적은 t 의 2차식이라 심프슨이 정확하다.

V = L·(A0 + 4·A(½) + A1) / 6        A(t) = 꼭짓점을 t 로 보간한 다각형 면적
외곽 277.0195  −  공동 176.0020  +  헌치 쐐기 0.0315  =  기대 101.0490 m³
                                              실측  101.0492 m³   오차 0.0002%

헌치 쐐기는 공동 안쪽에 들어가는 부분만 더해진다 — 높이 d(0…150)에서 x 폭이 d, y 폭이 2(1500−d) 이므로 ∫₀¹⁵⁰ d·2(1500−d) dd = 0.0315 m³. 이 값까지 맞으면 union 도 제대로 먹은 것이다.

이음매에는 T 정점이 남는다

구간을 맞대어 붙이면 양쪽 삼각분할이 달라 공유 평면에서 변이 1:1로 안 맞는다. 이 모델에서 열린 변이 21개 나왔고 전부 이음매 평면(X=-1000 · 100 · 400)이었다. 면은 기하적으로 겹쳐 있어서 부피는 정확하다.

렌더링·부피에는 문제가 없지만 엄밀히 manifold 가 아니다. IFC 로 내보낼 때 걸릴 수 있다. 없애려면 이음매를 맞대지 말고 솔리드끼리 겹쳐 boolean 이 진짜 교차를 만들게 해야 하는데, 공동 쪽은 교훈 #10 때문에 그렇게 못 한다.


4.95. 교훈 #17 — 원은 선분과 섞지 않는다. 그리고 좌표를 쓰는 순간 앵커가 드러난다

parse.mjs 는 오랫동안 CIRCLE 을 버렸다. 넣을 때 두 갈래가 있었다.

✗  원을 다각형으로 근사해 선분 목록에 넣는다   → 평면 분할이 그 윤곽을 따라 돈다.
                                            폐곡선이 오염된다 (교훈 #12 와 같은 사고)
✓  따로 모아 지름으로 집는다                  → 원은 그 자체가 형상이다. cylinder 로 직행

원은 관통 구멍·기둥이라 애초에 "면을 가르는 경계선"이 아니다. 폐곡선 추적에 참여시킬 이유가 없다.

뷰 귀속도 따로 한다. 뷰 분리(조각 클러스터링)에 원을 넣으면 관통 구멍 하나가 두 뷰를 잇는 다리가 될 수 있다. 뷰가 다 갈린 뒤 중심 좌표로 귀속시킨다.

시트 밖을 반드시 거른다 — 블록 로컬 좌표가 섞여 있다

이 도면의 CIRCLE 253개 중 시트 안은 133개다. 나머지는 두 종류다.

x ≈ -565,472   유령 기하 (`000-구조물점선`. 선분에서 이미 겪은 것)
x ≈ 25.4       INSERT 블록 **로컬 좌표**. 원점 근처에 몰려 있다

두 번째가 새로 나온 것이다. 안 거르면 원점 근처 뷰에 남의 원이 무더기로 붙는다.

지름은 강한 식별자지만, 레이어까지 봐야 하는 경우가 있다

Ø900  -전력구 2      사다리 샤프트 보어
Ø100  환기구 4 · 배수관 1 · 금구류 2     ← 앞의 둘만 관통이다. 금구류는 철물

그래서 circles(뷰, 지름, 레이어정규식) 로 좁힐 수 있게 뒀다. 폐곡선을 치수로 집는 것과 같은 규칙이다 — 순서로 집으면 조용히 다른 것을 집는다.

좌표를 쓰기 시작하면 숨어 있던 앵커가 드러난다

평면도는 세로축이 표고가 아니라 폭방향(Y) 이다. 그런데 지금까지는 평면도에서 expect(뷰, 폭, 높이) 로 치수만 봤기 때문에 v0 앵커가 없어도 아무 문제가 없었다. CIRCLE 좌표를 쓰는 순간 보어가 Y = -9,778 로 나왔다.

치수만 보는 동안은 틀린 앵커도 안 걸린다. 수평 단면(H/I/J)에서 이미 겪은 문제인데, 평면도는 "표고 축이 맞겠거니" 하고 넘어갔던 것이다.

앵커는 지어내지 말고 뷰 범위에서 유도하고 다른 것으로 교차검증한다.

뷰 세로 -11,678 ~ -1,478 (10,200)  `-전력구` 전폭. 아래 끝이 Y=-1,900
   → v0 = -11,678 + 1,900 = -9,778
교차검증
   샤프트 사각 1,300×1,300   v -10,428..-9,128 → Y -650..+650   중심선 대칭 ✓
   Ø900 보어 2개             v -9,778          → Y 0            ✓
   맨홀 내부 2,000×7,200     v -11,278..-4,078 → Y -1,500..5,700 ✓

손으로 적은 수는 도면이 바뀌어도 안 바뀐다

이 작업의 실제 소득은 아래 한 줄이 사라진 것이다.

const BORE_R = 450;   // 원본 엔티티에서 눈으로 읽어 적음

지금은 도면에서 집고 샤프트 중심과 대조한다. 어긋나면 빌드가 죽는다.

const bores = circles('평 면 도', 900, /^-전력구$/);
if (bore.u !== cx || bore.v !== 0) throw new Error(`${s.id} 보어가 샤프트 중심과 다르다 …`);

파라메트릭으로 갈 때 이 끈이 없는 값은 파라미터로 올릴 수 없다 — 바꿔도 도면과 대조할 방법이 없기 때문이다.


4.96. 교훈 #18 — 자기교차 링은 조용히 boolean 을 무력화한다

교훈 #12 는 개구 기호(X 표시)가 링을 오염시킨다는 것이었고, 대응은 "bbox 로 재구성"이었다. 그런데 그 사고가 개구부에서만 나는 게 아니다.

맨홀 #2 구체를 재봤더니 이랬다.

기대 (IR 을 격자로 CSG 평가)   71.758 m³
실측 (모델러)                141.207 m³      ← 챔버 커터 4개가 아무것도 못 뺐다

원인은 외곽 프로파일 mh.outer (54꼭짓점) 의 자기교차 2곳이었다.

[2900,-7357]→[1800,-7607]  ×  [2900,-7607]→[1800,-7357]     하부 슬래브 개구 기호
[   0,-7357]→[-800,-7607]  ×  [   0,-7607]→[-800,-7357]

자기교차 링을 압출하면 솔리드가 쓰레기다. 그 뒤 BooleanDifference 는 예외도 경고도 없이 아무것도 못 뺀다. modelCount 는 정확히 맞는다 — 커터는 소모됐기 때문이다. 부피를 재기 전에는 알 방법이 없었다.

모델러를 의심하기 전에 격리 실험을 하라

처음엔 "커터가 대상 안에 완전히 갇히면 boolean 이 실패한다"고 의심했다. 프로브를 만들어 확인하니 정상이었다.

2×2×2 상자 − 안쪽 1×1×1 상자  →  7.0000   갇힌 커터도 정상
2×2×2 상자 − 위로 뚫는 상자    →  6.5000   겹친 만큼만 정확히

모델러가 아니라 우리가 넣은 링이 문제였다. 프로브 3줄이 잘못된 방향으로 갈 뻔한 것을 막았다.

왜 T 자 분할만으로는 부족한가

core/loops.mjs 는 T 자 교차(노드가 다른 선분 위에 얹힌 경우)만 쪼갠다. 두 선분이 중간에서 X 자로 교차하는데 그 자리에 노드가 없으면 안 쪼갠다. 개구 기호의 대각선 두 개가 정확히 그 모양이다.

이 도면의 실태 — 링 10개가 오염돼 있었다

build.mjs --loops 에 자기교차 검사를 붙이자 한 번에 드러났다.

단 면 E-E  #0  10200×10183  꼭짓점 54  ✗ 자기교차 2     ← 맨홀 외곽. 부피 2배
단 면 H-H  #3   1200× 2000  꼭짓점  8  ✗ 자기교차 6     ← 환기 OPEN부 (이미 알던 것)
단 면 I-I  #4   1200× 1000  꼭짓점  8  ✗ 자기교차 6     ← 점검구 (이미 알던 것)
단 면 A-A  #8    900×  400  꼭짓점  8  ✗ 자기교차 8     ← 샤프트 지붕 개구
…

꼭짓점 수보다 확실한 신호다. 4가 아니면 "의심"이지만, 자기교차는 그 자체로 결함이다. selfIntersections() 는 core/views.mjs 에 있고 O(n²) 라 뷰당 링 수십 개 수준에서는 공짜다.

순서

1. --loops 에서 자기교차부터 본다        오염된 링을 전부 나열
2. 레이어로 걸러지나 확인                이 도면은 X 표시가 `-전력구` 라 안 됐다
3. 직사각형이면 bbox → rect() 재구성      교훈 #12
4. 오목한 외곽이면 X 교차 분할이 필요      rect() 로 못 만든다

4.97. 교훈 #19 — 의도와 결과를 따로 재라

부피가 이상할 때 물어야 할 것은 둘이다.

우리 조립(IR)이 틀렸나?        ← 의도
모델러가 IR 대로 안 만들었나?   ← 결과

이걸 못 가르면 엉뚱한 데를 판다. 실제로 맨홀 부피를 쫓느라 손으로 기대값을 세 번 조립했는데 세 번 다 커터 범위를 놓쳐서 틀렸다. 그 값으로 모델러를 의심하고, 격리 프로브를 만들고, 커터 순서를 바꾸고 — 전부 헛수고였다.

IR 을 격자로 그대로 평가하면 손계산이 필요 없다. src/volume.mjs 가 연산 트리(union=or, difference=and-not, loft=선형보간)를 따라가며 점을 세어 "IR 이 뜻하는 부피"를 낸다. 25mm 격자에서 0.1% 안팎이라 소수 셋째 자리는 못 보지만, boolean 이 죽는 사고는 언제나 m³ 단위로 어긋나므로 충분하다.

node src/measure.mjs manhole1 --ir
파트                    부피 m³     IR 의도       차이
맨홀1_전력구_구체        100.5403   100.5350   0.0053 ✓
맨홀1_사다리샤프트_1       3.3217     3.3211   0.0006 ✓
맨홀1_전력구_출구스터브     2.9725     2.9725   0.0000 ✓
맨홀1_맨홀2_구체         65.3577    74.6819  −9.3242 ✗   ← 여기만 모델러가 흘렸다

한 줄로 "우리 조립은 맞고 모델러가 흘린다"가 확정된다. 그 전까지는 몇 시간이 걸렸다.

커터가 자기 부피보다 많이 빼면 boolean 이 죽은 것이다

가장 확실한 단일 신호다. 커터의 해석 부피는 --primitives 로 바로 나온다.

mh.cut.duct   자기 부피 9.04   실제로 뺀 것 15.28   ✗
mh.cut.lower  자기 부피 20.08  실제로 뺀 것 29.53   ✗

물리적으로 불가능하므로 형상 논쟁이 필요 없다.

면으로만 맞닿은 솔리드를 union 하지 마라

이 사고의 원인이 여기 있다. 맨홀 외곽(mh.outer)의 윗변과 상부 통로 (mh.upper)의 밑변이 둘 다 Z=-4,957 이다. union 하면 한 덩어리처럼 보이지만 실은 면으로 붙은 조각들이고, 이어지는 difference 가 조각 하나를 통째로 흘린다. 손실이 어느 커터에서든 9.43 으로 일정한 것이 그 증거다 — 커터 탓이면 값이 커터마다 달라야 한다.

✓  솔리드끼리 **부피로 겹치게** 만든 뒤 union
✗  면만 맞대고 union

단, 겹치게 만들 때 압출축 방향으로만 늘려야 한다. 프로파일을 통째로 1mm 부풀렸더니 옆으로도 커져 새 겹침이 생기고 결과가 더 나빠졌다(65.4 → 41.6).

관통구는 합치기 전에, 단순한 조각에서 뚫는다

union 결과를 뚫으면 위처럼 흘린다. 조각마다 제 몫만 뚫고 나서 합치면 정확하다.

✗  solid = union(shell, endL, endR)  →  solid − ductHole      15.28 빠짐 (기대 5.83)
✓  shell − ductHole,  endL − ductHole  →  union                mh.solid 118.008 / 의도 118.034 ✓

boolean 이 피연산자를 소모하므로 같은 커터를 두 조각에 못 쓴다. 커터를 조각 수만큼 따로 만든다. 안 겹치는 조각에는 아예 넣지 말 것 — 교차가 없으면 BooleanDifference 가 created:[] 로 죽는다.

커터는 대상 바깥면에서 시작하면 안 된다

환기구 다리 커터를 맨홀 외면(X 15,400)에서 정확히 시작했더니 46 m³ 를 파먹고 (기대 2.48), 이어진 반대쪽 커터가 부피를 59 m³ 늘렸다. 바깥으로 OVER 만큼 빼자 둘 다 1.80 으로 정확해졌다. 교훈 #13 의 "커터는 대상보다 크게"가 압출축뿐 아니라 모든 방향에 적용된다.


4.98. 교훈 #20 — 모델스페이스가 실척이라고 믿지 마라

교량 도면군을 넣으면서 처음 깨진 가정이다. 두 층이 따로 논다.

① DWG 모델단위    1 단위가 몇 mm 인가        도면군마다 다르다
② 도형 축척        그 도형이 실척으로 그려졌나  같은 도면 안에서도 다르다

① 모델단위. 맨홀군은 1 단위 = 1 m 인데 교량 삽도군은 1 단위 = 1 mm 다. core/datum.mjs 에 UNIT_MM = 1000 이 상수로 박혀 있었다. 숫자라서 「도면 고유값」으로 안 보였을 뿐이고, 그대로 두면 모든 치수가 1000× 로 틀린다. 폐곡선도 나오고 부피도 나와서 알아채기 어렵다.

확정하는 법은 도면 안에 있다 — DIMENSION 엔티티의 actualMeasurement 와 그 치수의 글자를 비교한다.

actualMeasurement 3000.00  ↔  글자 "3.000" (m)   →  1 단위 = 1 mm

같은 파일의 다른 상수들도 같은 병을 앓고 있었다. 라벨 대역 ±3, 뷰 구분 문턱 2 — 전부 「1단위=1m」를 전제한 길이다. 길이는 mm 로 적고 단위로 나눠 되돌린다.

② 도형 축척. 중앙분리대 삽도의 방호벽 단면은 5.805배로 그려져 있었다. 같은 파일의 교량폭원 단면은 1:1 이다. 도형마다 다르다.

되찾는 법 — 표기 치수 2개로 역산해서 서로 맞는지 본다. 하나만 쓰면 우연히 맞는다.

높이    포장면 y=48,812.1 → 마루 y=56,184.4 = 7,372.3 단위  ↔ 표기 1,270
바닥폭  x 58,176.9 → 61,570.8            = 3,393.9 단위  ↔ 표기   585

7372.3 / 1270 = 5.8049      3393.9 / 585 = 5.8015     차이 0.06%

옆 단면(단면B)도 같은 배율이 나온 것이 세 번째 검증이었다.

뷰의 서술이 {좌표평면 · 반전 · 앵커 · 용도} 넷에서 축척을 더해 다섯이 됐다.


4.99. 교훈 #21 — 뷰 제목은 유일 키가 아니다

뷰 분리는 「잘게 쪼갠 뒤 제목으로 다시 묶는다」(교훈 #2)였다. 그 절차는 두 가지를 전제한다. 교량 삽도에서는 둘 다 거짓이었다.

(가) 뷰 사이에 간격이 있다 — 없었다.

gap    10  25  50 100 200 400 800 1600 3200   (mm)
조각    1   1   1   1   1   1   1    1    1

임계값을 10mm 까지 좁혀도 조각이 하나다. 범인은 상세 확대 테두리 상자다 (LWPOLYLINE, Δy=5,117, y 15,965…21,082 과 20,375…25,492 가 겹친다). 상세행과 단면행을 세로로 잇는데, 구조선과 같은 레이어·같은 색이라 걸러낼 수도 없다.

(나) 제목이 뷰마다 유일하다 — 아니었다.

교량폭원    뷰 제목이 아예 없다.  표제는 시트명 하나, 나머지는 A · B · L of Road
중앙분리대  단면A · 단면B 가 각 4벌   (사례 3종 × 위치)
교량시종점  「교량 시·종점」이 6벌     (TYPE1 반일체식 ~ TYPE4 골조식)

유형별 삽도는 같은 것의 변형을 N벌 늘어놓는 형식이라 제목이 반복되는 게 정상이다. 표제 중복률을 세보면 7장 전부 47~84% 였다.

대응 — 좌표로 짚는다. cfg.viewBoxes 에 뷰마다 {box:[x0,y0,x1,y1], kind}.

viewBoxes: {
  '단면A': { box: [55000, 44000, 66000, 58000], kind: 'cross', unitMm: 1 / 5.805 },
  '단면B': { box: [70000, 44000, 82000, 58000], kind: 'cross', unitMm: 1 / 5.805 },
},

두 가지를 지킨다.

  • 조각이 아니라 선분 단위로 나눈다. 조각 단위로 나누면 뷰를 잇는 다리 조각이 통째로 한쪽에 붙는다. 선분이면 다리가 어느 상자에도 안 들어가 자연히 버려진다.
  • 양 끝이 다 상자 안에 들어와야 취한다. 한쪽만 걸치는 선은 뷰를 넘나드는 선이라 넣으면 뷰 폭·높이가 조용히 부푼다.

상자가 겹치면 같은 선이 두 뷰에 들어가므로 예외를 던진다.

box 좌표는 눈대중이 아니다. 구조선 y 를 정렬해 400mm 이상 벌어진 곳에서 끊은 뭉치 경계를 뽑고, 상자 변은 그 빈틈 안에 둔다.


4.991. 교훈 #22 — 레이어가 못 가르면 색이 가른다

toSegments 는 걸 수 있는 필터가 layerFilter 하나였다. 그런데 구조선과 치수선이 같은 레이어에 있는 도면이 있다 — 교량폭원은 둘 다 CS-CONC-MAJR 이다.

CS-CONC-MAJR 안에서
  colorIndex 0 (ByBlock)  LINE 576                ← 치수 블록 안의 선
  colorIndex 7 (ByLayer)  LINE 316 + LWPOLYLINE 161  ← 구체 윤곽

colorIndex 0 은 ByBlock 이다. 즉 블록(치수) 안에 든 기하라는 뜻이고, 그게 그대로 치수선/화살표/보조선을 가른다. 걸러내니 횡단면 3벌과 방호울타리 상세가 깨끗하게 드러났다.

무엇으로 가를지는 도면마다 다르므로 판단은 drawings/*.mjs 에 두고 코어는 술어만 받는다.

entityFilter: (e) => (e.entityHeader?.colorIndex ?? -1) === 7,

일반화 — 거를 수 있는 축을 레이어 하나로 두지 마라. 색·선종·선굵기가 레이어가 못 가르는 자리를 가른다. gaps.mjs 같은 진단 도구도 같은 필터를 걸어야 한다. 안 걸면 걸러낸 치수선이 「레이어 오분류」로 잘못 보고돼 원인 판정이 통째로 어긋난다.


4.992. 교훈 #23 — 끊김이 0인데 폐곡선도 0

중앙분리대 단면A 가 그랬다. gaps.mjs 는 방호벽 구간에 끊김을 하나도 못 찾는데 --loops 는 아무것도 안 낸다. 증상만 보면 「닫혀 있는데 안 나온다」라서 끊김 진단으로는 절대 못 잡는다.

원인은 같은 직선 위에서 겹치는 두 선이었다. 끝점이 다르므로 1단계 중복 제거를 통과한다.

방호벽 왼쪽 면  x = -292 에 두 선이 그려져 있었다
   z −48 … 30
   z   0 … 49        ← 0…30 이 겹친다

T 자 교차 분할이 노드(−48, 0, 30, 49)마다 자르면서 겹친 구간을 양쪽에서 똑같이 잘라낸다.

−48…0   0…30      (첫 선에서)
        0…30   30…49   (둘째 선에서)
                ↑ 같은 변이 두 벌

그 변에 반변이 4개가 되어 면 순회가 엉키고, 폐곡선이 통째로 안 나온다.

대응 — 분할 뒤에 한 번 더 중복 제거. buildLoops 는 원래 분할 전에만 중복을 지웠다. 순서가 문제였다.

1. 중복 제거      끝점이 똑같은 선만 지운다
2. T 자 분할
2b. 중복 제거     ← 분할이 만든 중복 변을 지운다   (추가)
3. half-edge 구성

4.993. 교훈 #24 — 게이트에 넣기 전에 **「이 도면이 대상인가」**를 판정한다

교량 도면 7장 중 6장이 어느 게이트도 통과 못 했다. 그런데 그걸 알아내는 데 게이트를 다 돌릴 필요가 없었다 — 파싱 직후 몇 초면 판정된다.

① 표기 치수가 숫자인가 기호인가. 방법론 전체가 「도면 표기 치수와 대조」에 걸려 있다. 치수가 기호면 대조할 상대가 없다.

도면 숫자 치수 기호 치수 판정
거더배치 2 62 L · β · L' · β1..β4 — 불가
교량시종점 0 6 측면도 6벌인데 전부 라벨 — 불가
교대배치 51 37 θ 가변 — 낮음
교면포장 14 2 층상 구성표 — 형상 아님
횡단경사 156 139 S% · TL 가변
교량폭원 151 10 높음 → G5 에서 막힘
중앙분리대 108 7 높음 → 관통

② 구조 윤곽이 닫히는가. 교량폭원은 치수가 충분한데도 못 세웠다. 끊김 56곳 중 **54곳이 「도면에 선 없음」**이었기 때문이다. 폭원 구성 도해라 애초에 닫힌 단면을 그리지 않는다 — 띠와 열린 외곽선뿐이다.

분리교량 폭원   경계 1  레이어 0  누락 16
폭원 띠 중      경계 0  레이어 2  누락  2
횡단면 하       경계 0  레이어 0  누락 10
폭원 띠 상      경계 1  레이어 0  누락 12
횡단면 중       경계 2  레이어 0  누락 14

「레이어 오분류」면 설정 한 줄이지만(교훈 #3), 「선 없음」이 압도적이면 그 도면은 솔리드 대상이 아니다. 이 비율이 그대로 판정 기준이다.

게이트 목록에 G1.5 도면 적격이 들어간다. 여기서 걸러야 나머지를 헛되이 채우지 않는다.


4.994. 교훈 #25 — 경계 위반은 두 번째 도면을 넣기 전엔 안 보인다

CLAUDE.md 에도 README 에도 「core/* 에 도면 고유값을 넣지 마라」가 큰 글씨로 적혀 있었다. 그런데 두 번째 도면군을 넣자 두 곳에서 이미 새 있었다.

  • core/datum.mjs 의 UNIT_MM = 1000 — 문자열이 아니라 숫자라서 안 보였다. 「'-전력구' 같은 문자열이 들어가면 잘못된 것」이라고 써둔 탓에 숫자는 통과했다.
  • rules.mjs 는 주석에 「도면도 3D도 모른다」고 적혀 있었지만 실제로는 챔퍼_시작X · 샤프트 · 맨홀_벽두께 처럼 첫 도면의 파라미터 이름에 묶여 있었다. 두 번째 도면에서 그 값이 없어 undefined 비교가 전부 실패로 떨어졌고, 위상 위반으로 판정돼 build.mjs 가 exit 1 로 죽었다 — 남의 도면 규칙에 걸려서.

대응은 규칙마다 요구 파라미터를 명시하고, 없으면 해당 없음으로 건너뛰는 것이다.

{ id: 'chamfer-order', kind: 'topology',
  needs: ['챔퍼_시작X', '챔퍼_중간X', '본체_시작X'], … }

부재 종류 태그로 규칙을 붙이는 제대로 된 해법은 아직 없다. 그때까지의 최소 조치다.

문서에 「안 들어간다」고 쓰는 것으로는 안 지켜진다. 두 번째 도면이 유일한 검사 장치다. 그래서 성격이 다른 도면을 일찍 한 장 넣어보는 값이 크다.


4.995. 교훈 #26 — 기준선 검출이 예외 없이 틀린 답을 낼 수 있다

교량 삽도 7장 중 계획고 라벨이 있는 것은 횡단경사 하나뿐이다. 그래서 여기서는 기준선 검출(교훈 #1)이 통할 줄 알았다. 통하지 않았고, 더 나쁘게 통했다.

교량 계획고  → y=54,885.8   뷰 10곳
토공 계획고  → y=54,885.8   뷰 10곳     ← 서로 다른 표고인데 같은 답

교량 계획고 와 토공 계획고 는 정의상 다른 표고다. 그 둘의 **차이(단차)**가 이 도면의 주제다. 그런데 같은 선이 나왔다. 게다가 라벨마다 국소 탐색해보면 그 y 는 어느 라벨의 선도 아니다.

라벨                        자기 아래 가장 가까운 수평선
교량 계획고 (73603, 81177)  → y=80,105.8
토공 계획고 (73221, 79089)  → y=77,983.5     ← 같은 도형, 2,122 차이 (이게 단차다)
교량 계획고 (70915, 75535)  → y=75,433.7
토공 계획고 (70888, 70084)  → y=69,836.0
…                            라벨 10개 → 국소 선 9개, 전부 다르다

왜 틀렸나 — 전제가 숨어 있었다

findLevelDatum 은 라벨 y 를 평균낸 뒤 「그 평균 바로 아래 선」을 고른다. 숨은 전제는 「라벨 하나가 선 하나를 가리킨다」, 즉 라벨이 한 곳에 모여 있다는 것이다.

유형 삽도는 같은 라벨을 도형마다 반복한다. 라벨 10개가 y 27,533~81,177 에 흩어지면 평균 56,453 은 어느 도형에도 속하지 않는 값이다. 그 값 아래에서 「출현 횟수가 많은 선」을 고르니 엉뚱한 선이 나온다.

맨홀 도면에서는 이게 안 드러났다. 11개 뷰가 같은 계획고 선을 공유해서 라벨도 한 무리로 모여 있었기 때문이다. 표본 하나로는 전제가 안 보인다.

대응 — 무리가 여럿이면 던진다

라벨 y 를 정렬해 2m 이상 벌어진 곳에서 끊는다
무리가 2개 이상  →  공통 기준선이 없는 도면이다  →  예외
Error: 표고 라벨이 4무리로 흩어져 있다 — 공통 기준선이 없는 도면이다:
  y≈29934(2개), y≈69041(1개), y≈75535(1개), y≈81177(1개).
  datum.mode:'none' 으로 두고 뷰별 anchors.v0 를 쓸 것

맨홀은 그대로 통과한다 (뷰 11곳, 총연장 61,492mm).

「조용히 틀리느니 예외를 던진다」가 검출기 자신에게도 적용된다. 검출기는 답을 내는 게 일이라 답을 내려고 한다. 전제가 깨졌는지 스스로 검사하지 않으면, 실패가 예외가 아니라 그럴듯한 숫자로 나온다. 그게 제일 비싸다.


4.996. 교훈 #27 — 검사기가 거짓 경보를 내면 검사기를 안 믿게 된다

교량 상부공을 세우고 measure --ir 을 돌렸더니 6파트 중 5개가 ✗ 였다.

교량_상부공_좌측상판   59.0400   IR 의도 61.5000   −2.4600 ✗
교량_방호울타리_1       8.3990   IR 의도  8.4625   −0.0635 ✗

모델이 틀린 게 아니었다. 모델러 값이 해석값과 정확히 같았다 — 12,300 × 240 × 20,000 = 59.04 m³. 틀린 쪽은 「의도」를 재는 volume.mjs 였다.

원인은 격자다. 25mm 격자로 두께 240 을 재면 셀 중심이 12.5, 37.5, … 로 10개 잡혀 250 두께로 계산된다 — +4.2%. 맨홀은 부재가 두꺼워 이 오차가 0.1% 수준으로 묻혔고, 얇은 교량 부재에서 처음 드러났다.

같은 방호울타리 3기가 8.4625 / 8.7125 / 8.4625 로 서로 다르게 나온 것도 같은 이유다 — Y 위치가 달라 격자에 걸리는 모양이 달랐다.

대응 — boolean 이 없으면 격자를 안 쓴다

box       변의 곱
extrude   신발끈 면적 × 길이
cylinder  πr²·길이
loft      심프슨.  꼭짓점을 선형 보간하면 단면적이 2차식이라 **정확**하다
union     멤버 bbox 가 안 겹치면 합 (면으로만 맞닿은 건 안 겹친 것)
그 밖     격자

결과가 전부 0.0000 ✓ 로 떨어졌고, 중앙분리대(loft 포함)도 4.7970(격자) → 4.8059(해석) 로 바뀌어 모델러 4.8058 과 붙었다.

출력에 어느 방법으로 나온 값인지 같이 찍는다. 격자 값을 해석 값인 양 읽으면 다음 사람이 또 몇 % 를 쫓는다.

「의도 vs 결과」 대조는 의도 쪽이 정확해야 성립한다. 근사로 재는 쪽이 경보를 내기 시작하면 사람이 ✗ 를 무시하게 되고, 그때 진짜 사고가 지나간다.


4.997. 교훈 #28 — 같은 부재가 여러 벌이면 복수형 선택기가 필요하다

loop(뷰, 폭, 높이) 는 여럿이면 던진다. 하나를 집는 게 목적이니 그게 맞다. 그런데 여러 벌인 것이 정상인 경우가 있다 — 교량 폭원 단면의 방호울타리 440×1,320 이 3기였다.

하나씩 좌표로 집으면 그 순간 손으로 적은 수가 된다(규칙 9 위반). circles() 가 이미 같은 문제를 풀고 있었으므로 같은 꼴로 loops() 를 뒀다. 개수 확인은 부르는 쪽이 한다 — 몇 기여야 하는지는 도면이 아니라 조립하는 사람이 아는 것이다.

const bars = loops('분리교량 폭원', 440, 1320);
if (bars.length !== 3) throw new Error(`방호울타리가 3기여야 하는데 ${bars.length}기`);

안 닫힌 4기째를 어떻게 되살렸나

우측 상판 바깥 방호울타리는 선 하나가 빠져 폐곡선이 안 됐다 (gaps 가 u=28,454 를 「선 없음」으로 짚는다). 없는 게 아니라 안 닫힌 것이다.

지어내지 않고 되살리는 방법 — 규칙을 실측으로 확인한 뒤 그 규칙을 쓴다.

들임(inset)  좌측 상판 좌단   bars[0].u0 − deck.u0 = 10
             좌측 상판 우단   deck.u1 − bars[1].u1 = 10   ← 같다. 규칙 확인
→ 우측 상판 우단도 10.  외면 u = 28,464 − 10 = 28,454
                        ↑ 위 끊김 좌표와 **같은 값**. 이게 재구성의 검증이다

모양은 bars[1](같은 방향 부재)을 그 자리로 옮겨 쓴다. 등급은 opaque 가 아니라 tolerant 로 둔다 — 도면 좌표를 그대로 옮긴 게 아니라 만든 것이라 그 사실이 남아야 한다.


4.998. 교훈 #29 — 한 시트에 날짜 다른 작업본이 겹쳐 그려져 있다

이번에 제일 크게 틀린 것이다. 교량시종점 삽도의 뷰 상자를 [5000, 64000, 31000, 77100] 로 잡고 뽑았더니 나온 형상이 어느 도면에도 없는 조합이었다 — 교대라고 부른 것이 실은 옆 도형의 날개벽이었고, 기초와 교각이 통째로 빠졌다.

전체 배치를 뜨고서야 봤다. 한 시트에 두 벌이 있다.

주황 테두리   TYPE1 반일체식 · TYPE2 단부슬래브 · TYPE3 일체식 · TYPE4 골조식
자홍          교량 시·종점 (벽체 전면) · (흉벽 전면) · (슬래브 끝단)   각 2벌

두 벌이 서로 어긋나게 포개져 있어서, 상자를 그 경계에 걸치면 두 도형의 선이 같이 들어온다. 폐곡선은 멀쩡히 나오고 치수도 그럴듯해서 틀렸다는 신호가 전혀 없다. 그림을 보고서야 알았다.

표제란의 날짜 도장 두 개(240820_교량시종점 · 240925_교량시종점)가 힌트였는데, 처음엔 개정 이력으로 읽고 넘겼다. 실제로는 작업한 날짜이고 날짜마다 내용이 다르다.

대응

1. 시트 전체를 먼저 뜬다            도형이 몇 벌인지 · 겹치는지
2. 도형 테두리·표제로 벌을 가른다    테두리가 있으면 그게 경계다
3. 도형 하나를 뜨고 눈으로 확인한 뒤  무엇이 들어 있는지
4. 그 다음에 상자를 잡는다

그림 없이 상자를 잡지 마라. 숫자만 보면 「폐곡선 25개, 치수 그럴듯」이라 통과한 것처럼 보인다. 이 실수는 검증표로 안 잡힌다.


4.999. 교훈 #30 — 「표기 치수가 없다」와 「못 쓴다」는 다르다

교량시종점 삽도를 숫자 치수 0개라는 이유로 「솔리드 대상 아님」으로 잘랐다. 오판이었다. 표기 치수가 없으면 검증을 못 하는 것이지 추출을 못 하는 게 아니다. 모델스페이스가 1:1 이면 좌표가 곧 mm 다.

그럼 1:1 인지 어떻게 아나 — 크기를 아는 표준 부재로 확정한다. 도면 밖에서 온 눈금이다.

폐곡선 153×182 이 4개    ← 탄성받침 150×180.  다른 게 이 치수로 4개 나란할 이유가 없다
교대 벽체 4,680×6,506     교량 교대 자릿수
상부공 형고 1,421         지간 12,752 에 L/h≈9 — 반일체식으로 타당
접속슬래브 두께 383        표준 300~400

넷이 동시에 타당해지는 배율은 1 뿐이다. 10배만 달라도 받침이 1.5 m 나 15 mm 가 된다. 이 도면 하나로 교대·교각·받침·기초·말뚝이 전부 열렸다.

「대조할 상대가 없다」는 그 도면 안에 없다는 뜻이다. 다른 도면과 표준 치수는 여전히 상대가 된다. 도면군 전체를 봐야 판정이 선다.


4.9991. 교훈 #31 — 확대 상세는 같은 뷰 안에 다른 축척으로 들어온다

교량 종단면 안에 교좌받침을 크게 그린 상세가 있다(파란 테두리, 지간—지간 표기). 같은 뷰 상자 안이라 폐곡선이 같이 나온다.

본 도형의 받침    153 × 182     ← 실제 치수
확대 상세의 받침  461 × 196     ← 축척이 다르다. 게다가 **가로·세로 배율이 다르다**

치수로 집으면 저절로 갈리지만(그게 「치수로 집는다」의 값이다), 갈린다는 사실을 확인해야 안심할 수 있다. fit() 에 X 범위 검사를 넣었다.

if (brg.some((b) => b.bbox[0] > 15000 && b.bbox[0] < 19000)) {
  throw new Error('확대 상세 안의 받침이 섞였다 — 축척이 다르다');
}

같은 부재가 1 mm 다르게 나오는 것도 여기서 처음 봤다 — 교대 5,192 / 5,191, 받침 154 / 153×3. 도면이 다른 게 아니라 DWG 부동소수를 꼭짓점마다 따로 반올림하기 때문이다. 대칭 배치된 부재에서만 드러난다. loops(…, tol) 로 열되 기본값은 0 이다 — 여는 것은 부르는 쪽의 판단이다.


4.9992. 교훈 #32 — 진단 도구가 본편과 다른 값을 내면 도구를 못 믿는다

probe 가 상부공 폭을 12,951 로, build 가 12,752 로 냈다. 199 차이.

원인 둘이 겹쳐 있었다.

(가) probe.mjs 가 projectViews 를 안 거쳤다. buildLoops 를 직접 불러 개구 기호 제거·앵커·평면 변환을 건너뛰었다. core/views.mjs 머리말이 「복사해두면 뷰어가 거짓말한다」고 경고해둔 그대로다.

(나) buildLoops 가 bbox 를 collinear 제거 전 링으로 쟀다. 상부공 윗선이 199 mm 튀어나와 있는데(끝이 아무 데도 안 닿는 매달린 변), 면 순회가 거기까지 갔다 되돌아온다. 왕복이라 면적은 0 이지만 bbox 는 부풀었다.

ring(왕복 포함) 으로 잰 bbox   12,951   ← area 는 멀쩡해서 안 걸린다
dropCollinear 뒤로 잰 bbox     12,752   ← 이게 맞다

area 만 보면 절대 못 잡는다. 폭·높이만 틀리는 오류가 있다.


4.9993. 교훈 #33 — 도면은 위치를 중심선으로도 준다

말뚝은 폐곡선이 안 된다(해치뿐). 그런데 위치는 도면에 있다 — CS-CNTL 레이어의 수직선이 말뚝 중심선이고, 윤곽 수직선 두 줄 간격이 지름이다.

지름   518                       윤곽 수직선 두 줄 간격
위치   교대 3본 · 교각 4본 · 교대 3본
       −1,744 · 213 · 1,574   /   11,007 · 12,195 · 13,538 · 14,726   /   24,604 · 25,965 · 27,922
       간격 1,957 / 1,361      —   시점과 종점이 대칭이다 (이게 검증이다)

pick 에 이걸 집는 수단이 없다. 폐곡선·원·개구부만 집는다. 그래서 스크립트로 재서 설정에 적었고, 출처는 남겼지만 「도면에서 집는다」는 아니다. circles() 를 만든 것과 같은 이유로 centerlines() 가 필요하다.

그리고 폭방향 배치는 어느 도면도 안 준다. 교대배치 삽도가 평면이지만 말뚝을 안 그리고, 종단면의 말뚝은 파단선으로 잘려 길이도 없다. 열 수·열 간격· 길이는 설계값으로 두고 그 사실을 남겼다.

형상이 없다고 정보가 없는 게 아니다. 해치·중심선·기호도 도면이 말하는 방식이다. 개구 기호(교훈 #12)를 되살린 것과 같은 종류의 이득이 남아 있다.


5. 함정 모음

DWG 쪽

  • 유령 기하: 정상 도면이 x ≈ -48,400 대에 몰려 있는데 레이어 000-구조물점선/000-구조물실선 일부가 x ≈ -565,692에 떨어져 있다. bbox·축척 계산 전에 시트 영역 밖 좌표를 먼저 버려야 한다.

  • 레이어 일관성 없음: 구조체 외곽선이 뷰마다 다른 레이어에 있다. -전력구가 대부분이지만 C-C는 기본, J-J는 집수정에 있다. 레이어 하나로 필터하면 뷰가 통째로 빠진다. 후보 레이어를 넉넉히 잡을 것.

  • 끊긴 곳은 대부분 "선이 없어서"가 아니라 "레이어가 틀려서"다. 이 도면에서 프로파일이 안 닫히는 지점 50곳을 조사했더니 전부 레이어 오분류였고, 도면에 실제로 없는 선은 하나도 없었다. 예:

    단면 J-J 침사조·집수조 격실이 안 닫힘
      (2100,-13475)-(2100,-15175)   격벽 우면        → 레이어가 `-환기구`
      (2100,-15175)-(2250,-15325)   집수조 좌하단 모따기 → `-환기구`
      (1900,-15175)-(1750,-15325)   침사조 우하단 모따기 → `-환기구`
    

    대응 비용이 100배 차이 난다 — 레이어 오분류는 설정 한 줄, 선 누락은 도면 수정이다. 진단 도구가 둘을 갈라주지 않으면 없는 도면 작업을 하게 된다.

  • 가구(furniture)를 구체 프로파일에 넣지 말 것: 케이블 지지대·철근이 같은 레이어에 있으면 폐곡선이 그 윤곽을 따라 돈다. C-C 내부 공동 꼭짓점이 114개가 나왔다. 뷰 분리용 레이어(struct)와 프로파일용 레이어(body)를 따로 둔다.

  • 기존 닫힌 폴리라인은 쓸 게 없다: LWPOLYLINE 140개 중 closed 96개인데 전부 해치 패턴/심볼 조각. 실제 구조체 프로파일은 0개.

  • 좌표 노이즈: -500.00000000001455 같은 값이 흔하다. mm 정수 반올림으로 흡수.

  • 파싱 캐시 키를 잘못 잡으면 다른 도면이 같은 내용으로 나온다. 두 번 다르게 밟았다. ① 파일명 슬러그 [^\w.-]→_ — \w 가 한글을 안 먹어 거더배치·교대배치·교량폭원 이 전부 ____ 로 뭉개졌다. ② 고쳐서 쓴 목록 인덱스 — 폴더에 파일이 하나 늘자 전부 한 칸씩 밀려 [6] 이 옆 도면 캐시를 읽었다. 인덱스는 순서에, 슬러그는 문자 집합에 의존한다. 경로 해시를 쓴다. 캐시는 틀린 답을 조용히 재사용하므로 「같은 결과가 나온다」를 우연으로 넘기지 말 것.

  • acadrust의 DIMENSION_LINEAR.pt10은 0으로 온다. 치수 정의점은 레이어 DEFPOINTS의 POINT 또는 익명 *D 블록 안에 있다. actualMeasurement와 textMidPt는 정상.

egm(EGModeler) 쪽

Modeler_AI/docs/egm-usage.md에 있는 내용 중 이 작업에서 특히 물리기 쉬운 것:

  • Extrude C=True 단독은 항상 +Z로 돌출한다. 커브 평면의 법선을 따르지 않는다. XZ/YZ 평면 커브에는 반드시 방향을 명시할 것 — Extrude D Y C=True {dist}.

  • Extrude 전에 SelByUids 필수. 선택 없이 실행하면 조용히 created:[].

  • 닫힌 Polyline(시작점=끝점)에 Enter를 붙이지 말 것. 자동 종료되므로 남은 Enter가 다음 명령으로 새어나가 오작동한다. 열린 커브는 반대로 Enter 필수.

  • SelByUid(s)는 Esc로 닫아야 한다. 안 닫으면 이후 명령이 전부 무시된다.

  • 뷰 조작 명령(SetViewmode)을 ~ 접두로 보내면 끝나지 않는다. egm status 의 command 가 "SetViewmode" 로 물린 채 남고, 그 뒤 ~SelAll·~Delete 가 전부 조용히 무시된다(씬은 그대로인데 아무 일도 안 일어난다). 접두 없는 Esc 로 푼다. run_egm.py 가 Esc·SelAll·Delete·Zoom E 를 접두 없이 보내는 이유가 이것이다 — 흉내낼 때 ~ 를 붙이지 말 것.

  • 명령은 비동기다. egm status의 command가 null이 될 때까지 대기할 것.

  • BooleanDifference/BooleanUnion은 두 개만 먹는다. 셋 이상 선택하면 나머지는 조용히 무시되고 씬에 남는다. 둘씩 접을 것 → 교훈 #14.

  • 뷰어 모드면 모델링 명령이 등록조차 안 된다. egm status는 정상 응답하는데 생성만 안 된다. egm guide 개수로 먼저 확인할 것 (39 = 뷰어) → 교훈 #15.

  • Loft 는 기본이 서피스다. 그대로 두면 boolean 이 죽는다. C=True(Cap)로 양 끝을 막아 솔리드로 받는다. Enter 는 한 번이면 된다.

  • Loft 의 L(DeleteOriginal)은 기본이 true 라 단면 커브가 자동으로 지워진다. 끄면 커브가 씬에 남아 modelCount 대조가 어긋난다. 건드리지 말 것.

  • 별도 Cap 명령은 loft 뒤처리에 못 쓴다. Cap 은 대상을 제자리에서 고치므로 새 uid 가 안 나온다(Originator_ChangeGeometry). 파이프라인이 결과를 못 잡는다.

  • Loft·Sweep2 는 선택 순서가 결과를 바꾼다. SelByUids 에 단면 순서 그대로 넘길 것.

  • 씬 초기화(SelAll→Delete)가 파이프를 죽인 적이 있다. 프로세스는 살아 있고 창도 응답하는데(Responding=True, CPU 유휴) 명령만 전부 "응답하지 않습니다"가 된다. CLI 로는 못 살린다 — 모델러를 재시작한다.

  • ExportByPath 의 경로에 _ 가 있으면 큰따옴표 없이는 조용히 실패한다. _ 는 egm 스크립트 특수문자다(SetObjectName 에서 이미 겪었다). 아래 두 줄의 차이가 "867KB OBJ" 와 "아무 일도 안 일어남" 이다.

    egm '~ExportByPath D:/MYCLAUDE_PROJECT/dwg2model/out/a.obj Enter'    # 파일 없음
    egm '~ExportByPath "D:/MYCLAUDE_PROJECT/dwg2model/out/a.obj" Enter'  # 867KB
    

    신호가 하나도 없다. egm status 정상, result 는 {"command":"ExportByPath", "created":[],"deleted":[]} 인데 이 명령은 원래 아무것도 생성하지 않으므로 created:[] 가 정상이다. 파일 존재를 직접 확인하는 수밖에 없다 — run_egm.py 의 export_obj() 가 mtime 까지 대조한다.

    같이 알아낸 것: 내보내기 전에 SelAll 이 필요하고, .obj 만 나온다 (.stl 은 확장자를 받아들이지만 파일을 안 만든다). OBJ 는 ~SetObjectName 이름이 g 그룹으로 나오고 .mtl 이 같이 떨어진다.


6. 작업 목록 — 무엇을 하고 있고, 무엇이 자동화 가능한가

파이프라인은 src/ 에 있다. 각 단계가 무슨 일을 하고, 사람 판단이 필요한지 아닌지를 아래에 정리한다. 판단이 필요 없는 단계는 전부 자동화 대상이다.

# 단계 하는 일 입력 → 출력 사람 판단
1 파싱 src/core/parse.mjs DWG를 엔티티로 읽고 LINE·LWPOLYLINE을 선분으로 편다 DWG → 엔티티 JSON 없음
2 기준점 검출 src/core/datum.mjs 여러 뷰에 반복되는 수평선에서 표고 기준을 찾고, 도면 치수와 교차검증 엔티티 → datum + 검증표 검증 항목 지정
3 뷰 분리 src/core/segment.mjs 기하를 잘게 쪼갠 뒤 표제로 다시 묶어 뷰를 만든다 엔티티 → 뷰 목록 뷰 제목 ↔ 평면 매핑
4 끊김 진단 src/gaps.mjs 프로파일이 안 닫히는 지점을 찾고 원인을 레이어 오분류 / 선 없음으로 가른다 뷰 → 끊긴 좌표 목록 없음
5 원인 규명 src/inspect.mjs 뷰 영역의 모든 엔티티를 레이어 무관하게 훑는다. 왜 안 잡히는지 볼 때 뷰 → 레이어별 실태 판정
6 프로파일 추출 src/core/loops.mjs 선분 뭉치를 평면 분할해 면(=재료 영역)을 전부 뽑는다 선분 → 폐곡선 목록 없음
7 결과 확인 src/probe.mjs 뽑힌 면의 크기·계층·레이어를 표로 본다 뷰 → 검증표 도면 치수 대조
8 조립 스펙 작성 src/ir.mjs 어느 면을 어느 축으로 밀고 무엇을 뺄지 기술 폐곡선 → IR JSON 필요
9 3D 생성 src/emit/egm.mjs IR을 모델러 명령으로 번역해 실행 IR → 솔리드 없음

자동화 관점

완전 자동 (1·4·5·6·9) — 판단이 안 들어간다. 도면이 바뀌어도 그대로 돈다.

설정만 주면 자동 (2·3·7) — 레이어 이름, 뷰 제목, 검증 항목만 도면별로 준다. 이게 src/drawings/*.mjs 한 파일에 모여 있다. 코드는 안 건드린다.

사람이 판단해야 하는 곳 (8) — 딱 한 군데다. "이 면이 구체인가 공동인가", "이 두 단면을 로프트로 이을 것인가 각각 압출할 것인가". 나머지는 기계가 한다.

즉 새 도면을 받으면 설정 한 파일 + 조립 스펙 한 번이면 끝나는 구조다. 반복 실행·검증·회귀 확인은 전부 스크립트가 맡을 수 있다.

반복 실행 훅으로 걸어두면 좋은 것

node src/run.mjs <도면>            검증 실패 시 exit 1  → CI 게이트로 바로 쓸 수 있다
node src/gaps.mjs <도면>           도면이 갱신될 때마다 끊김이 늘었는지 확인
node src/probe.mjs <도면> "<뷰>"    프로파일 치수 회귀 확인

src/run.mjs 는 검증이 깨지면 종료 코드 1을 낸다. 도면이 수정될 때마다 자동으로 돌려두면, 기준점이나 뷰 분리가 깨지는 순간 바로 잡힌다.


7. 검증 체크리스트

  • 시트 전체를 먼저 떴는가 — 도형이 몇 벌인지, 날짜 다른 작업본이 겹쳐 있지 않은지
  • 뷰 상자를 잡기 전에 그 도형만 떠서 눈으로 확인했는가
  • 같은 뷰에 확대 상세가 섞여 있지 않은가 (축척이 다르다)
  • 표기 치수가 숫자인가 (기호 L·β·θ·VAR 뿐이면 그 도면 안에는 대조 상대가 없다. 다만 다른 도면·표준 부재 치수는 여전히 상대가 된다 — 바로 버리지 말 것)
  • DWG 모델단위를 DIMENSION.actualMeasurement ↔ 치수 글자로 확정했는가
  • 도형 축척이 1:1 인가. 아니면 표기 치수 2개로 역산해 교차검증했는가
  • 시트 영역 밖 유령 기하를 버렸는가
  • 표제 중복률을 세봤는가 (높으면 제목으로 뷰를 못 가른다)
  • 레이어가 구조선과 치수선을 가르는가. 아니면 색·선종으로 걸렀는가
  • 표고 기준을 선에서 잡았는가 (텍스트 아님)
  • 기준선이 없는 도면이면 그렇다고 명시했는가 (아무 선이나 자동 선택 금지)
  • 기준선 후보가 둘일 때 라벨 근접도로 갈랐는가
  • 기준점이 도면 치수 최소 2개로 교차 검증됐는가
  • 뷰 폭·높이가 도면 표기값과 맞는가
  • 서로 다른 뷰의 심도가 일치하는가 (교차 검증)
  • 끊긴 지점의 원인을 레이어/누락으로 갈랐는가
  • 프로파일 치수가 기하·치수 양쪽에서 같은 값인가
  • Extrude에 방향(D X/D Y)을 명시했는가
  • loft 구간이 꺾이는 자리마다 끊겨 있는가 (한 loft 로 두 번 꺾을 수 없다)
  • loft 두 단면의 꼭짓점 수가 같은가 (다르면 위상을 맞춰서 넣어야 한다)
  • 실행 전 egm guide 개수가 39가 아닌가 (뷰어 모드 확인)
  • 실행 후 modelCount 가 최종 파트 수와 같은가
  • node src/measure.mjs <도면> 이 기준선과 맞는가 (비틀림·누락 컷을 잡는 가장 빠른 신호)
  • 새 형상이면 기대값을 한 번 조립해 확인하고 --save 로 얼렸는가

변경 이력

날짜 내용
2026-08-05 최초 작성. 교훈 #1~#3, 함정 모음, 작업 순서 정리
2026-08-05 교훈 #4(DWG 분리하지 말 것), #5(캡처 이미지 좌표 사이드카) 추가
2026-08-05 교훈 #2 전면 개정 — 뷰 분리를 "교대 분할 + 제목 귀속"으로 교체. 11개 뷰 전부 도면 치수와 일치
2026-08-05 파이프라인을 src/ 로 일반화 (설정/범용코드/IR 3계층). 기준점 자동 검출 + 자동 교차검증
2026-08-05 교훈 #5 보강 — 캡처는 역산 말고 "뷰 박스로 줌 + 축척에서 캔버스 역산"
2026-08-05 프로파일 추출(평면 분할) 구현. 끊김 50곳 진단 → 실제 결함은 5곳, 그중 도면 수정 필요는 1mm 스냅 오차 1건
2026-08-05 교훈 #6 추가 — 레이어가 재질을 인코딩. 사양(THK·fck)은 위치별로 다르므로 뷰별로 읽어야 함. IFC 매핑 초안
2026-08-05 첫 3D 생성 성공 — 전력구 본체(구체·기초·잡석) 15.4m. DWG→IR→egm 경로 전 구간 관통
2026-08-05 단면 오배정 발견·수정 — 본체는 C-C(3,800×4,600), 스터브만 B-B. 스터브 2기·사다리 샤프트 2기 추가(7파트)
2026-08-05 맨홀 #2 · 환기구 추가. 벽 누락 3건 수정 → 교훈 #8(절단기호↔평면), #9(봉투 union), #10(구간 경계), #11(수평단면 축) 추가
2026-08-05 전이부 구멍·환기구 다리 폭·상부 슬래브 개구부 2개 처리. 최종 11파트
2026-08-05 제작 절차를 2d-3d-process.md 로 분리. 이 문서는 노하우만
2026-08-05 독립 저장소로 이관 후 재현 실행. 교훈 #14(boolean 은 이항 — 커터 8개가 조용히 무시됐다), #15(뷰어 모드면 명령이 등록조차 안 된다) 추가
2026-08-06 Loft 백엔드 구현 + 링 정규화. 전이부(B-B→C-C)를 챔퍼·단차·종방향 헌치까지 세움. 교훈 #16(loft 는 꺾인 자리를 못 만든다) 추가. 부피 대조로 검증 — 오차 0.0002%
2026-08-06 부피 대조를 src/measure.mjs 로 자동화(손계산 → 회귀 검사). 기준선을 baselines/ 에 커밋. run_egm.py --export. 함정 추가 — ExportByPath 경로의 _ 는 따옴표 없이는 조용히 실패
2026-08-06 CIRCLE 지원 (collectCircles → circles() → cylinder). 손으로 적은 BORE_R=450 이 도면 대조로 바뀜. 평면도 v0 앵커 필요성 발견. 교훈 #17 추가
2026-08-06 구조물만 남기고(11→5파트) 전이부를 assembleDuct() 로 공유. 맨홀 #2 부피가 2배로 나오는 기존 결함 발견 — mh.outer 자기교차. 자기교차 검사 + report.mjs 뷰어 추가. 교훈 #18
2026-08-07 교량시종점 삽도로 2지간 교량 한 기 관통 — 상부공·거더 10본·교대·날개벽·교각·받침·말뚝, 95파트 820.10 m³, 의도 대비 전부 0.0000. 교훈 #29~#33 추가 (날짜별 작업본 겹침 · 표기치수 0 ≠ 사용불가 · 확대 상세 혼입 · 진단도구 불일치 · 중심선)
2026-08-07 두 번째 도면군(교량 설계지침 삽도 7장) 실증. 1장 관통(중앙분리대 방호벽 4.8058 m³ — 모델러·해석 일치), 6장은 막힌 게이트와 이유를 기록. 코어 가정 7개가 깨져 전부 수정 — 모델단위·뷰별 축척·명시 뷰 영역·datum 없음·레이어 아닌 필터·겹치는 선분 폐곡선 유실·규칙 적용범위. 교훈 #20~#25 추가