Posts DEM 고도 데이터를 3D 프린팅 가능한 지형 메쉬로 만들기
Post
Cancel

DEM 고도 데이터를 3D 프린팅 가능한 지형 메쉬로 만들기

높이맵 하나에서 출력 가능한 솔리드까지

공개 DEM 데이터는 결국 2차원 실수 배열이다. (rows, cols) 격자에 미터 단위 고도가 들어 있다. 이걸 화면에 띄우는 것과 3D 프린터로 뽑는 것 사이에는 생각보다 큰 간극이 있다. 슬라이서는 닫힌 다양체(watertight manifold) 만 받는다. 면이 하나라도 열려 있거나 법선이 뒤집혀 있으면 조용히 이상한 결과를 낸다.

1. 고도 격자에서 삼각형으로

높이맵을 삼각형으로 바꾸는 것 자체는 어렵지 않다. 격자 셀 하나를 두 개의 삼각형으로 쪼개면 된다.

1
2
3
4
5
6
7
8
(i, j)     (i, j+1)
   +----------+
   |        / |
   |      /   |
   |    /     |
   |  /       |
   +----------+
(i+1, j)   (i+1, j+1)

문제는 이렇게 만든 것이 면 하나라는 점이다. 종이처럼 두께가 없다. 프린팅하려면 바닥과 옆벽을 붙여 부피를 가진 솔리드로 닫아야 한다.

1
윗면(지형)  +  바닥면(평평)  +  네 옆벽  =  닫힌 솔리드

여기서 실수하기 쉬운 지점이 두 개다.

바닥이 윗면보다 반드시 아래에 있어야 한다. 고도 최저점이 음수이거나 수직 과장 배율을 크게 준 경우, 바닥 z를 상수로 고정해두면 지형 일부가 바닥을 뚫고 내려간다. 그 순간 메쉬는 자기교차 상태가 되고 슬라이서가 내부/외부를 판정하지 못한다. 바닥은 항상 min(z_top) - margin으로 잡는다.

옆벽의 감는 방향이 면마다 일관돼야 한다. 네 벽을 각각 만들다 보면 두 벽은 시계 방향, 두 벽은 반시계 방향이 되기 쉽다. 그러면 법선이 안쪽을 향하는 면이 생기고, 프린터에서 그 부분만 껍데기가 비어 나온다. 벽 생성 함수에 방향 플래그를 하나 두고 마주보는 벽 쌍에서 뒤집는 방식이 제일 실수가 적었다.

1
2
def _wall(xline, yline, ztop, zbot, flip: bool):
    ...

2. 법선은 계산해서 쓰는 게 낫다

바이너리 STL은 삼각형마다 법선 벡터를 저장한다. 그런데 대부분의 슬라이서는 저장된 법선을 무시하고 정점 순서로 재계산한다. 그래서 법선을 0으로 채워도 대체로 동작하는데, 일부 도구(특히 메쉬 검사기)는 저장된 값을 믿는다. 어차피 외적 한 번이니 계산해서 넣는다.

1
2
3
4
5
def _compute_normals(tris: np.ndarray) -> np.ndarray:
    v0, v1, v2 = tris[:, 0], tris[:, 1], tris[:, 2]
    n = np.cross(v1 - v0, v2 - v0)
    norm = np.linalg.norm(n, axis=1, keepdims=True)
    return n / np.maximum(norm, 1e-12)

np.maximum(norm, 1e-12)이 필요하다. 축퇴 삼각형(세 점이 일직선)이 격자 가장자리에서 종종 생기는데, 나누기에서 NaN이 나오면 STL 전체가 깨진 파일이 된다. 그리고 그 파일은 열릴 때까지 정상으로 보인다.

STL 바이너리 레이아웃은 numpy 구조화 dtype으로 한 번에 쓰는 게 가장 빠르다.

1
2
3
dtype = np.dtype([("normal", "<f4", (3,)),
                  ("verts",  "<f4", (3, 3)),
                  ("attr",   "<u2")])

리틀엔디언(<)을 명시해야 한다. 안 그러면 빅엔디언 환경에서 만든 파일이 다른 곳에서 안 열린다.

3. 하천을 자연스럽게 새기기

DEM 해상도로는 폭 수 미터짜리 하천이 거의 안 나타난다. OSM 하천 라인을 받아서 지형에 직접 파내는 편이 결과가 훨씬 낫다.

단순하게 하면 하천 폭만큼 고도를 상수만큼 낮추는 것인데, 그러면 단면이 직사각형이 되어 프린팅했을 때 계곡이 아니라 홈처럼 보인다. 가운데가 깊고 가장자리로 갈수록 얕아지는 부드러운 단면이 필요하다.

거리 변환으로 하천 중심선까지의 픽셀 거리를 구한 뒤, 코사인 프로파일을 씌운다.

1
2
3
4
5
norm = np.clip(dist / half_width_px, 0.0, 1.0)
profile = 0.5 * (1.0 + np.cos(np.pi * norm))   # 중심 1.0 → 가장자리 0.0
profile[dist > half_width_px] = 0.0

carved = elevation - depth_m_equiv * profile

0.5 * (1 + cos(πx))는 x=0에서 1, x=1에서 0이면서 양 끝의 기울기가 0이다. 이 성질이 중요하다. 선형 프로파일을 쓰면 하천 경계에 각진 능선이 생기고, 프린팅하면 그 선이 눈에 띄게 남는다.

새긴 뒤 채널 주변에만 약한 가우시안을 한 번 더 먹인다.

1
2
sigma = max(0.6, half_width_px * 0.25)
channel_mask = dist <= half_width_px * 1.5

전체에 블러를 걸면 산등성이까지 뭉개진다. 마스크를 채널 폭의 1.5배로 제한해서 하천 주변만 부드럽게 한다.

픽셀 크기가 위도에 따라 다르다

경위도 격자에서 픽셀 하나가 나타내는 실제 거리는 동서 방향과 남북 방향이 다르다. 남북은 위도와 무관하게 대략 일정하지만, 동서는 cos(위도)만큼 줄어든다. 하천 폭을 미터로 받아 픽셀로 바꿀 때 이걸 무시하면 위도 37도 근처에서 동서 방향 하천이 실제보다 25%쯤 좁게 새겨진다.

1
2
3
width_world_m, height_world_m = bbox_dimensions_m(south, north, west, east)
m_per_px_x = width_world_m / max(cols - 1, 1)
m_per_px_y = height_world_m / max(rows - 1, 1)

같은 이유로 미리보기에서 종횡비를 고정하는 옵션이 필요하다. 경위도 폭을 그대로 mm로 매핑하면 지형이 동서로 늘어난다.

4. 프린터 베드보다 큰 지형 나누기

1400×700mm 지형을 220mm 베드로 뽑으려면 타일로 쪼개야 한다. 쪼갤 때 지켜야 할 것은 하나다. 인접 타일이 경계에서 같은 정점을 공유해야 한다.

각 타일을 독립적으로 잘라 각자 보간하면 경계에서 미세하게 다른 z가 나오고, 출력물에 0.1~0.3mm 단차가 생긴다. 접착하면 티가 나고, 여러 장 이어붙이면 누적된다.

해법은 격자 인덱스 단위로 자르되 경계 인덱스를 양쪽 타일에 모두 포함시키는 것이다.

1
2
3
def _split_indices(total_cells: int, n_tiles: int) -> list[tuple[int, int]]:
    # 셀 개수를 나누고, 각 타일은 [start, end] 정점을 포함 (end가 다음 타일의 start)
    ...

이렇게 하면 경계 정점이 같은 배열 원소에서 나오므로 값이 비트 단위로 같다. 보간 오차가 개입할 여지 자체가 없어진다.

각 타일은 자기 바닥과 옆벽을 갖는 독립적인 닫힌 솔리드다. 그리고 바닥에 R{행}-C{열} 번호를 음각으로 새겼다. 타일이 20장을 넘어가면 어느 조각이 어디인지 알 수 없게 되는데, 이게 없으면 조립이 사실상 불가능하다. 음각은 바닥 평면을 유지해야 하므로 아래로만 파고, 뒤집어 봤을 때 읽히도록 좌우를 반전해서 새긴다.

5. API로 감쌀 때 미리보기를 먼저 만든다

STL 생성은 격자 크기에 따라 수십 초가 걸린다. 사용자가 축척이나 타일 수를 잘못 넣었다는 것을 그때 알면 매번 그만큼을 버린다.

그래서 엔드포인트를 둘로 나눴다.

엔드포인트하는 일소요
POST /preview격자 크기, 실제 치수, 타일 배치, 종횡비 왜곡률 계산즉시
POST /generate실제 메쉬 생성 및 STL 기록수십 초

미리보기가 반환하는 값 중 실제로 가장 자주 사람을 멈춰 세운 것은 타일 개수였다. 베드 크기를 220mm로 넣고 1400×700을 요청하면 7×4 = 28장이 나온다. 그 숫자를 미리 보면 대부분 축척을 다시 생각한다.

정리

이 작업에서 반복해서 확인한 원칙은 이렇다.

  • 메쉬는 눈으로 봐서 정상인 것과 슬라이서가 받는 것이 다르다. 닫힘과 법선 방향을 코드에서 보장한다
  • 지형에 무언가를 새길 때는 단면 프로파일의 양 끝 기울기가 0이어야 인공적인 선이 안 남는다
  • 나눌 때는 값을 다시 계산하지 말고 같은 원소를 공유시킨다
  • 오래 걸리는 작업 앞에는 항상 즉시 응답하는 미리보기를 둔다
This post is licensed under CC BY 4.0 by the author.

추론 엔진 비교를 위한 C++ 벤치마크 하네스 설계

단안 카메라에서 픽셀을 세계 좌표로 — 공선조건과 지면 평면