fix(B04): 도엽 서피스를 2D 거리보간으로 만든다 — TIN 제거

메시(TIN)로 먼저 만들고 거기서 등고선을 뽑던 순서가 계단의 원인이었다.
사용자 지시대로 2D를 먼저 완결한다:
- Delaunay TIN 제거. 표고별 거리장에서 각 셀의 가장 가까운 두 '서로 다른'
  등고 라인을 찾아 z=(L1·d2+L2·d1)/(d1+d2)로 보간한다. 같은 표고 정점
  3개짜리 평탄 삼각형이 없으므로 계단이 원리적으로 생기지 않는다.
- 폐합 등고선 안쪽은 거리보간이 분화구를 만들므로, 안에 다른 제약이 없는
  영역을 마루/웅덩이로 보고 바깥 사면 경사로 ±(간격-0.5m)까지 연장한다.
- 계곡 구조선 앵커는 1m 양자화해 같은 격자에 제약으로 굽는다.
- 등고 라벨 40개 상한(긴 것부터) — 1m 등고선이 잘게 쪼개져 라벨 수백 개가
  매 프레임 재계산되며 화면이 멈췄다(사용자 보고). 61 FPS 회복.

실측(c1bb453f): 평탄 셀 2.48%→0.02%, 최대 평탄 덩어리 2406→912㎡,
경사 2% 미만 셀 17145→14444, 생성 36.5s→8.0s, 노선 |Δz| 2.76m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 15:33:56 +09:00
co-authored by Claude Opus 5
parent cb1c5261e2
commit 31e3eb4ecf
2 changed files with 245 additions and 249 deletions
@@ -6,8 +6,12 @@ LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가
`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다.
절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정),
격자는 `SHEET_SURFACE_GRID_M`(1m). 등고선→정점 구름→Delaunay TIN 보간은 배수유역
엔진(`_Watershed_Grid`)의 검증된 경로를 그대로 쓴다.
격자는 `SHEET_SURFACE_GRID_M`(1m).
순서는 **2D 먼저, 메시는 맨 마지막**이다(2026-08-30 사용자 지시):
① 등고 라인을 격자에 굽고 ② 계곡 구조선 앵커를 제약으로 더한 뒤
③ 등고선 사이 거리 비례 보간으로 표고 격자를 만들고 ④ 마루를 캡한다.
격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다.
"""
import json
@@ -26,16 +30,10 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
grid_vertices,
write_glb,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
build_contour_cloud,
grid_spec_from_bounds,
interpolate_elevation,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_MIDLINE_MAX_M,
SHEET_SURFACE_MIDLINE_ROUNDS,
SURFACE_MAX_PREVIEW_VERTICES,
)
@@ -174,187 +172,139 @@ def _stream_breakline_vertices(
return np.vstack(collected_xy), np.concatenate(collected_z)
def _densify_between_contours(
spec: Any, burned: np.ndarray, present: list[float], cloud: Any
) -> Any:
"""등고선 사이에 2D 가상 등고라인을 반복 이분으로 만들어 정점으로 추가한다.
def _interpolate_between_contours(burned: np.ndarray, cell_m: float) -> np.ndarray:
"""2D 거리 보간 — 셀마다 가장 가까운 서로 다른 표고 두 라인 사이를 선형 보간한다.
등고선 정점만의 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 굴곡부에
계단(terrace)을 만든다. 인접 표고 라인 쌍의 등거리선(사이 등고선)을 **연속 셀
선**으로 뽑아 새 레벨로 등록하고, 그 선들 사이를 다시 이분하는 식으로
`SHEET_SURFACE_MIDLINE_ROUNDS`회 반복한다 — 5m 주곡선이면 2.5m, 1.25m 가상
등고가 생겨 TIN이 어디서든 서로 다른 표고를 잇는다(2026-08-30 사용자 지시:
2D에서 사이 등고선을 먼저 만들고 3D화).
등고선 정점 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 계단을 만든다.
여기서는 삼각망을 쓰지 않고, 지도 제작의 표준인 **등고선 사이 비례 보간**을
2D에서 직접 한다(2026-08-30 사용자 지시 — 메시는 맨 마지막):
등거리 조건은 멀리 있는 다른 표고 쌍에도 우연히 성립하므로(예: 525~530
골짜기에 555/560 중간점), "그 지점의 최근접 라인이 바로 그 쌍"일 때만 인정한다.
z = (L1·d2 + L2·d1) / (d1 + d2)
d1·L1 = 가장 가까운 등고 라인까지의 거리·표고, d2·L2 = **표고가 다른 것 중**
가장 가까운 라인. 두 라인 사이에서 z가 거리에 비례해 연속으로 변하므로 계단이
원리적으로 생기지 않는다. 라인 위(d1=0)에서는 그 표고 그대로다.
표고별 거리장을 한 번씩 구하며 **가장 작은 두 값**을 추적한다 — 표고가 서로 다른
것끼리 비교하므로 L1≠L2가 보장된다. (KD-tree로 k개 이웃을 뽑는 방식은 이웃이
전부 같은 라인의 셀이라 다른 표고를 못 찾는다.)
`burned`: 라인 셀 = 표고, 그 외 NaN. 계곡 구조선 앵커도 같은 격자에 구워 두면
같은 규칙으로 제약이 된다.
"""
from scipy.ndimage import distance_transform_edt
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ContourCloud
levels = np.unique(burned[np.isfinite(burned)])
if len(levels) < 2:
return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32)
if len(present) < 2:
return cloud
infinity = np.float32(np.inf)
first_d = np.full(burned.shape, infinity, dtype=np.float32)
first_z = np.zeros(burned.shape, dtype=np.float32)
second_d = np.full(burned.shape, infinity, dtype=np.float32)
second_z = np.zeros(burned.shape, dtype=np.float32)
for level in levels:
distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32)
beats_first = distance < first_d
# 1등이 2등으로 밀린다.
second_d = np.where(beats_first, first_d, second_d)
second_z = np.where(beats_first, first_z, second_z)
first_d = np.where(beats_first, distance, first_d)
first_z = np.where(beats_first, np.float32(level), first_z)
beats_second = ~beats_first & (distance < second_d)
second_d = np.where(beats_second, distance, second_d)
second_z = np.where(beats_second, np.float32(level), second_z)
# 라운드마다 거리장을 다시 구하되, 직전 라운드 것을 재사용한다 — 새 라인 사이의
# 이분은 (직전 레벨 거리장, 새 중간선 거리장)만 있으면 되므로 전량 재계산이 필요없다.
levels = list(present)
masks = [burned == level for level in levels]
distances = [
distance_transform_edt(~mask, sampling=spec.cell_m).astype(np.float32) for mask in masks
]
virtual: dict[float, np.ndarray] = {}
for _ in range(max(SHEET_SURFACE_MIDLINE_ROUNDS, 0)):
if len(levels) < 2:
break
best_distance = np.full(burned.shape, np.inf, dtype=np.float32)
best_index = np.full(burned.shape, -1, dtype=np.int16)
for index, distance in enumerate(distances):
take = distance < best_distance
best_distance[take] = distance[take]
best_index[take] = index
next_levels: list[float] = []
next_masks: list[np.ndarray] = []
next_distances: list[np.ndarray] = []
added_any = False
for index in range(len(levels)):
next_levels.append(levels[index])
next_masks.append(masks[index])
next_distances.append(distances[index])
if index == 0:
continue
lower, upper = distances[index - 1], distances[index]
near = (lower < SHEET_SURFACE_MIDLINE_MAX_M) & (upper < SHEET_SURFACE_MIDLINE_MAX_M)
pair_is_nearest = (best_index == index - 1) | (best_index == index)
midline = near & pair_is_nearest & (np.abs(lower - upper) <= spec.cell_m)
midline &= ~masks[index - 1] & ~masks[index]
if not midline.any():
continue
mid_level = (levels[index - 1] + levels[index]) / 2.0
virtual[mid_level] = midline
# 새 라인은 마지막(=자기 레벨) 자리 앞에 끼워 정렬을 유지한다.
next_levels.insert(-1, mid_level)
next_masks.insert(-1, midline)
next_distances.insert(
-1, distance_transform_edt(~midline, sampling=spec.cell_m).astype(np.float32)
)
added_any = True
levels, masks, distances = next_levels, next_masks, next_distances
if not added_any:
break
if not virtual:
return cloud
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
extra_xy: list[np.ndarray] = []
extra_z: list[np.ndarray] = []
for level, mask in virtual.items():
rows, cols = np.nonzero(mask)
rows, cols = rows[::2], cols[::2] # 1m 셀 → 2m 간격 정점 (TIN 비용 절제)
extra_xy.append(np.column_stack([xs[cols], ys[rows]]))
extra_z.append(np.full(len(rows), level))
added = int(sum(len(a) for a in extra_z))
logger.info(
"도엽 서피스: 가상 등고라인 %d단, 정점 %d개 추가 (원 등고 %d단)",
len(virtual),
added,
len(present),
)
return ContourCloud(
xy=np.vstack([cloud.xy, *extra_xy]),
z=np.concatenate([cloud.z, *extra_z]),
)
total = first_d + second_d
usable = np.isfinite(second_d) & (total > 1e-9)
surface = first_z.astype(np.float32)
surface[usable] = (
(
first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64)
+ second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64)
)
/ total[usable].astype(np.float64)
).astype(np.float32)
logger.info("도엽 서피스: 2D 거리보간 %d셀 (제약 표고 %d단)", surface.size, len(levels))
return surface
def _cap_flat_summits(surface: np.ndarray, cell_m: float, interval_m: float) -> int:
"""마지막 등고선 안쪽 평탄 마루를 완만한 능선 돔으로 올린다 (in-place).
def _burn_stream_anchors(
spec: Any, burned: np.ndarray, stream_features: list[dict[str, Any]]
) -> int:
"""계곡 구조선 앵커 보간값을 등고 격자에 제약으로 굽는다. 구운 셀 수 반환."""
vertices = _stream_breakline_vertices(spec, burned, stream_features)
if vertices is None:
return 0
xy, z = vertices
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
row, col, z = row[inside], col[inside], z[inside]
free = ~np.isfinite(burned[row, col]) # 등고 라인 위는 덮지 않는다
# 1m로 양자화 — 거리장을 표고별로 한 번씩 도는 방식이라 레벨 수가 곧 비용이다.
# 원천이 5m 주곡선이므로 0.5m 이내 반올림은 정확도에 영향이 없다.
burned[row[free], col[free]] = np.round(z[free]).astype(np.float32)
return int(free.sum())
다음 등고선이 없는 봉우리·능선 마루는 TIN이 마지막 등고 표고로 평탄하게 채운다
(''가 갑자기 ''로 변하는 단 — 2026-08-30 사용자 지적). 실제 마루는 그 표고와
+간격 사이이므로, 마지막 등고선 바깥 사면의 국소 경사를 안쪽으로 연장해 올린다
(상한 +간격−0.5m — 다음 등고선이 없다는 사실과 모순되지 않게). 바깥 경사를
구할 수 없으면 +간격/2 돔으로 폴백한다(2026-08-30 사용자 확정 ②a).
적용 조건: 등표고 평탄 컴포넌트이고 바깥 유효 이웃이 전부 더 낮을 때(=마루).
골짜기 바닥은 배수 방향을 모르므로 건드리지 않는다. 돋운 컴포넌트 수를 반환.
def _resolve_enclosed_interiors(
burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float
) -> int:
"""폐합 등고선 안쪽(봉우리·웅덩이)을 바깥 사면 경사로 연장한다 (in-place).
거리 보간은 "가장 가까운 서로 다른 두 라인 사이"를 채우므로, 마지막 등고선
안쪽에는 더 높은 라인이 없어 아래쪽 라인 쪽으로 끌려 **분화구처럼 파인다**.
등고선이 ''에서 점점 짧아지다 사라지는 마루가 바로 이 자리다(2026-08-30
사용자 지적).
폐합 라인 내부에 다른 표고 제약이 하나도 없으면 그 안이 마루(바깥이 낮을 때)
또는 웅덩이(바깥이 높을 때)다. 바깥 사면의 국소 경사를 안쪽으로 연장하되
±(간격−0.5m)로 제한한다 — 다음 등고선이 없다는 사실과 모순되지 않는 범위다.
처리한 영역 수를 반환한다.
"""
from scipy.ndimage import binary_dilation, distance_transform_edt, label
from scipy.ndimage import binary_dilation, binary_fill_holes, distance_transform_edt, label
finite = np.isfinite(surface)
quantized = np.round(surface * 100.0)
capped = 0
# 평탄 후보: 상하좌우 이웃 중 같은 표고가 있는 셀만 모아 컴포넌트를 짠다.
same_right = np.zeros_like(finite)
same_right[:, :-1] = finite[:, :-1] & finite[:, 1:] & (quantized[:, :-1] == quantized[:, 1:])
same_down = np.zeros_like(finite)
same_down[:-1, :] = finite[:-1, :] & finite[1:, :] & (quantized[:-1, :] == quantized[1:, :])
flat = same_right | same_down
flat[:, 1:] |= same_right[:, :-1]
flat[1:, :] |= same_down[:-1, :]
# 서로 다른 표고의 평탄면이 맞닿아 있을 수 있으므로 표고값별로 컴포넌트를 짠다.
# 값별 셀 수 25 미만은 컴포넌트도 25 미만이므로 라벨링 전에 거른다(속도).
flat_values, flat_counts = np.unique(quantized[flat], return_counts=True)
for value in flat_values[flat_counts >= 25]:
value_labels, value_count = label(flat & (quantized == value))
sizes = np.bincount(value_labels.reshape(-1))
for component_id in range(1, value_count + 1):
if sizes[component_id] < 25: # 25㎡ 미만은 시각적으로 단이 아니다
continue
rows, cols = np.nonzero(value_labels == component_id)
if (
rows.min() == 0
or cols.min() == 0
or rows.max() == surface.shape[0] - 1
or cols.max() == surface.shape[1] - 1
):
continue # 격자 가장자리에 닿으면 바깥을 모른다
# 이후 연산은 컴포넌트 bbox 창(+경사 밴드 여유)에서만 — 전체 격자 반복 회피.
band_cells = 8
window = (
slice(
max(rows.min() - band_cells, 0),
min(rows.max() + band_cells + 1, surface.shape[0]),
),
slice(
max(cols.min() - band_cells, 0),
min(cols.max() + band_cells + 1, surface.shape[1]),
),
)
component = value_labels[window] == component_id
ring = binary_dilation(component) & ~component & finite[window]
constrained = np.isfinite(burned)
band_cells = 8
limit = max(interval_m - 0.5, 0.5)
resolved = 0
for level in present:
mask = burned == level
interior = binary_fill_holes(mask) & ~mask
if not interior.any():
continue
components, count = label(interior)
for component_id in range(1, count + 1):
component = components == component_id
if (constrained & component).any():
continue # 안에 다른 제약이 있으면 마루가 아니다(보통의 감싸는 링)
ring = binary_dilation(binary_fill_holes(mask) | mask) & ~component & ~mask
ring &= np.isfinite(surface)
if not ring.any():
continue
level = float(surface[rows[0], cols[0]])
patch = surface[window]
if float(patch[ring].max()) >= level - 1e-3:
continue # 더 높은 이웃이 있으면 마루가 아니다(사면 벤치·골짜기)
outside_mean = float(surface[ring].mean())
direction = 1.0 if outside_mean < level else -1.0
inner = distance_transform_edt(component, sampling=cell_m)
peak = float(inner.max())
if peak <= 0.0:
continue
# 바깥 사면 경사 추정 — 마지막 등고선 밖 밴드의 (표고 낙차 / 거리) 평균을
# 안쪽으로 연장한다(2026-08-30 사용자 확정 ②a). 상한 +간격−0.5m:
# 다음 등고선이 없다는 사실(마루 < L+간격)과 모순되지 않게.
outer_distance = distance_transform_edt(~component, sampling=cell_m)
band = (~component) & finite[window] & (outer_distance <= band_cells * cell_m)
band &= outer_distance > 0
# 바깥 사면 경사 — 라인 밖 band_cells 이내 유효 셀의 (낙차 / 거리) 평균.
outer_distance = distance_transform_edt(~(mask | component), sampling=cell_m)
band = (outer_distance > 0) & (outer_distance <= band_cells * cell_m)
band &= np.isfinite(surface) & ~component & ~mask
if band.any():
drops = level - patch[band].astype(np.float64)
slope = float(np.mean(drops / outer_distance[band]))
slope = float(
np.mean(np.abs(level - surface[band].astype(np.float64)) / outer_distance[band])
)
else:
slope = 0.0
if slope > 1e-3:
rise = np.minimum(slope * inner[component], interval_m - 0.5)
else: # 경사를 못 구하면 +간격/2 돔 폴백
rise = (interval_m / 2.0) * (inner[component] / peak)
patch[component] += rise.astype(surface.dtype)
capped += 1
if capped:
logger.info(
"도엽 서피스: 평탄 마루 %d곳을 +%.1fm 골격 캡으로 돋움", capped, interval_m / 2.0
)
return capped
offset = np.minimum(slope * inner[component], limit)
else:
peak = float(inner.max())
offset = (limit / 2.0) * (inner[component] / peak) if peak > 0 else 0.0
surface[component] = level + direction * offset
resolved += 1
if resolved:
logger.info("도엽 서피스: 폐합 등고선 내부 %d곳을 사면 경사로 연장", resolved)
return resolved
def build_sheet_surface_model(
@@ -379,37 +329,30 @@ def build_sheet_surface_model(
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
# 저지대 제거(floor) 없이 전부 쓴다 — 종·횡단은 낮은 지반도 필요하다.
cloud = build_contour_cloud(features, None, (x_min, y_min, x_max, y_max))
if cloud.is_empty:
logger.warning("도엽 서피스: 절취 범위 안에 등고선 정점이 없습니다.")
return None
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
# 등고 간격(m) — 마루 캡 크기의 근거. 레벨이 하나뿐이면 5m(1:5,000 주곡선) 폴백.
contour_levels = np.unique(cloud.z)
interval_m = float(np.diff(contour_levels).min()) if len(contour_levels) > 1 else 5.0
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import rasterize_contours
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ContourCloud
# ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN).
burned, levels = rasterize_contours(spec, features, None)
present = [level for level in sorted(levels) if bool((burned == level).any())]
# 계단(평탄 삼각형) 해소 — 인접 등고선 사이 중간 보간선을 정점으로 추가.
cloud = _densify_between_contours(spec, burned, present, cloud)
# 계곡 구조선 — 하천중심선을 따라 등고 교차점 사이를 보간한 정점 추가.
present = sorted({level for level in levels if bool((burned == level).any())})
if len(present) < 2:
logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.")
return None
# 등고 간격(m) — 마루 캡 크기의 근거. 레벨이 하나뿐이면 5m(1:5,000 주곡선) 폴백.
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
# ② 2D — 계곡 구조선(하천중심선) 앵커 보간값을 같은 격자에 제약으로 굽는다.
stream_features = _load_features_metric(processed_dir, epsg, _STREAM_FILE)
if stream_features:
stream_vertices = _stream_breakline_vertices(spec, burned, stream_features)
if stream_vertices is not None:
logger.info("도엽 서피스: 계곡 구조선 정점 %d개 추가", len(stream_vertices[1]))
cloud = ContourCloud(
xy=np.vstack([cloud.xy, stream_vertices[0]]),
z=np.concatenate([cloud.z, stream_vertices[1]]),
)
surface = interpolate_elevation(spec, cloud) # (R, C), 북→남 행 순서, 외부 NaN
# 마지막 등고선 안쪽 평탄 마루를 완만한 돔으로 (2026-08-30 사용자 확정).
_cap_flat_summits(surface, spec.cell_m, interval_m)
burned_stream = _burn_stream_anchors(spec, burned, stream_features)
logger.info("도엽 서피스: 계곡 구조선 제약 %d", burned_stream)
# ③ 2D — 등고선 사이 거리 비례 보간(메시 없음). 여기서 나온 격자에서 1m 등고선을
# 뽑으므로 화면 등고선이 곧 2D 보간선이다(2026-08-30 사용자 지시).
surface = _interpolate_between_contours(burned, spec.cell_m)
# ④ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장 (2026-08-30 사용자 확정).
_resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m)
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.
x_coords = spec.cell_centers_x()
@@ -447,10 +390,10 @@ def build_sheet_surface_model(
write_glb(preview_path, vertices, faces, bounds)
logger.info(
"도엽 서피스 생성 완료: %d×%d 격자, 정점 %d (%.1fs)",
"도엽 서피스 생성 완료: %d×%d 격자, 등고 %d (%.1fs)",
spec.n_rows,
spec.n_cols,
cloud.xy.shape[0],
len(present),
time.monotonic() - started,
)
return {
+105 -52
View File
@@ -6,7 +6,10 @@ import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import type {
SurfaceBounds,
SurfaceModelSummary,
} from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
@@ -18,6 +21,10 @@ import {
type SurfaceCameraState,
} from "./B04_PreProcess_UI_Camera";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서
* 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */
const MAX_CONTOUR_LABELS = 40;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -225,7 +232,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
const camera = new THREE.PerspectiveCamera(
SURFACE_CAMERA_FOV,
1,
0.01,
100000,
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -262,7 +274,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
const renderable = child as
THREE.Mesh | THREE.Points | THREE.LineSegments;
renderable.geometry?.dispose();
const material = renderable.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
@@ -303,8 +316,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const fitCamera = (object: THREE.Object3D) => {
const { span } = getFitParams(object);
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2;
const aspect =
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const distance = referenceBounds
? getTopFitDistance(referenceBounds, aspect)
: span * 1.2;
controls.target.set(0, 0, 0);
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
camera.position.set(0, distance, distance * TOP_VIEW_TILT);
@@ -366,12 +382,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
const match = currentModelsList.find((m) => {
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const typeMatches =
m.model_type.toLowerCase() === activeMethod.toLowerCase();
const configuredFilter = m.generation_params?.source_filter;
const filterMatches =
(typeof configuredFilter === "string" &&
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
Boolean(
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
);
return typeMatches && filterMatches;
});
@@ -382,7 +401,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
const isSmooth =
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -429,7 +449,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
child.material.vertexColors =
child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
@@ -442,7 +463,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
statusSpan.textContent =
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
@@ -498,6 +520,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01).
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
const labelCandidates: {
level: number;
position: THREE.Vector3;
length: number;
}[] = [];
data.contours.forEach((c: any) => {
if (c.level < minH) minH = c.level;
@@ -512,47 +539,63 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
bucket.push(points[i], points[i + 1]);
}
// 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다
// 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다
// (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다.
if (isMajor && points.length > 4) {
const labelPos = points[Math.floor(points.length / 2)];
const labelDiv = document.createElement("div");
labelDiv.className = "contour-label";
labelDiv.innerText = `${Math.round(c.level)}m`;
labelDiv.style.position = "absolute";
labelDiv.style.background = "rgba(255, 255, 255, 0.85)";
labelDiv.style.border = "1px solid #d97706";
labelDiv.style.color = "#b45309";
labelDiv.style.padding = "1px 4px";
labelDiv.style.borderRadius = "3px";
labelDiv.style.fontSize = "9px";
labelDiv.style.fontWeight = "bold";
labelDiv.style.pointerEvents = "none";
labelDiv.style.zIndex = "5";
labelDiv.style.transform = "translate(-50%, -50%)";
(labelDiv as any).__updateLabelPos = () => {
if (!contourCheck.checked) {
labelDiv.style.display = "none";
return;
}
const proj = labelPos.clone().project(camera);
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight;
if (proj.z > 1) {
labelDiv.style.display = "none";
} else {
labelDiv.style.display = "block";
labelDiv.style.left = `${x}px`;
labelDiv.style.top = `${y}px`;
}
};
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = true;
let length = 0;
for (let i = 0; i < points.length - 1; i++) {
length += points[i].distanceTo(points[i + 1]);
}
labelCandidates.push({
level: c.level,
position: points[Math.floor(points.length / 2)],
length,
});
}
});
labelCandidates.sort((a, b) => b.length - a.length);
for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) {
const labelPos = candidate.position;
const labelDiv = document.createElement("div");
labelDiv.className = "contour-label";
labelDiv.innerText = `${Math.round(candidate.level)}m`;
labelDiv.style.position = "absolute";
labelDiv.style.background = "rgba(255, 255, 255, 0.85)";
labelDiv.style.border = "1px solid #d97706";
labelDiv.style.color = "#b45309";
labelDiv.style.padding = "1px 4px";
labelDiv.style.borderRadius = "3px";
labelDiv.style.fontSize = "9px";
labelDiv.style.fontWeight = "bold";
labelDiv.style.pointerEvents = "none";
labelDiv.style.zIndex = "5";
labelDiv.style.transform = "translate(-50%, -50%)";
(labelDiv as any).__updateLabelPos = () => {
if (!contourCheck.checked) {
labelDiv.style.display = "none";
return;
}
const proj = labelPos.clone().project(camera);
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight;
if (proj.z > 1) {
labelDiv.style.display = "none";
} else {
labelDiv.style.display = "block";
labelDiv.style.left = `${x}px`;
labelDiv.style.top = `${y}px`;
}
};
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = true;
}
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
[
{ points: minorPoints, color: 0xf59e0b },
@@ -632,18 +675,26 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
const metersPerPixel = targetPlaneMetersPerPixel(
dist,
viewerArea.clientHeight,
);
const roughMeters = 100 * metersPerPixel;
const prettyMeters = niceScaleDistance(roughMeters);
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
scaleLabel.textContent =
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
prettyMeters >= 1000
? `${(prettyMeters / 1000).toFixed(0)} km`
: `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
if (
labelsDirty ||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
@@ -685,7 +736,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
return;
intervalSubmit.disabled = true;
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
@@ -739,7 +791,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
if (Number.isFinite(interval) && interval > 0)
intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);