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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user