diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index e4b4a40f..fd8932f4 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -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 { diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 655b8157..a3f96c9d 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -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);