feat(B04): 도엽 서피스 프리뷰 메시를 NURBS 곡면으로 만든다

격자 삼각형을 그대로 이으면 1m 셀 경계가 톱니로 보인다. LAS NURBS 모델이 쓰는
fit_nurbs_spline·evaluate_nurbs_spline을 그대로 걸어 곡면을 평가한 뒤 메시를
만든다. 제어 간격은 같은 config(SURFACE_NURBS_PATCH_SIZE_M ÷ 축당 제어점 수,
최소 1셀). 무효 셀은 최근접 표고로 메워 적합하고 원래 유효 마스크로 다시 도려낸다.
적합이 실패하면 격자 메시로 되돌린다.

표고 정본(npz)은 격자 그대로다 — 종·횡단·배수는 격자를 샘플링하므로 계산값에
영향이 없다. 실측에서도 8종 지표가 모두 동일했다. 생성 83s → 108s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 16:40:31 +09:00
co-authored by Claude Opus 5
parent 8fedbd77ac
commit c2060f3b6f
@@ -27,6 +27,8 @@ from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
evaluate_nurbs_spline,
fit_nurbs_spline,
grid_faces,
grid_vertices,
write_glb,
@@ -41,6 +43,9 @@ from config.config_system import (
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_METHODS,
SURFACE_MAX_PREVIEW_VERTICES,
SURFACE_NURBS_CONTROL_POINTS_PER_AXIS,
SURFACE_NURBS_DEGREE,
SURFACE_NURBS_PATCH_SIZE_M,
)
logger = logging.getLogger(__name__)
@@ -98,13 +103,45 @@ def _load_features_metric(
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다."""
"""프리뷰용 정점·면 — 격자를 그대로 잇지 않고 **NURBS(B-spline) 곡면**으로 만든다.
격자 삼각형을 그대로 쓰면 1m 셀 경계가 계단처럼 보인다. LAS 파이프라인이 쓰는
것과 같은 곡면 적합(`fit_nurbs_spline`·`evaluate_nurbs_spline`)을 걸어 매끈한
면을 얻는다(2026-08-30 사용자 지시). **표고 정본(npz)은 격자 그대로**이고 여기서
바꾸는 것은 화면에 보이는 메시뿐이다 — 종·횡단은 격자를 샘플링한다.
적합에 쓰는 제어 간격은 LAS NURBS와 같은 config 값(패치 크기 ÷ 축당 제어점 수)이며,
제어 격자가 격자보다 성기지 않도록 최소 1셀로 묶는다.
"""
stride = 1
while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES:
stride += 1
px, py = x[::stride], y[::stride]
pz, pv = z[::stride, ::stride], valid[::stride, ::stride]
vertices = grid_vertices(px, py, pz.astype(np.float64))
pv = valid[::stride, ::stride]
cell_m = float(x[1] - x[0]) if len(x) > 1 else SHEET_SURFACE_GRID_M
control_m = max(
SURFACE_NURBS_PATCH_SIZE_M / max(SURFACE_NURBS_CONTROL_POINTS_PER_AXIS - 1, 1), cell_m
)
control_step = max(1, int(round(control_m / max(cell_m, 1e-6))))
# 곡면 적합은 결측을 못 받는다 — 무효 셀은 가장 가까운 유효 표고로 메우고,
# 메시를 자를 때 원래 유효 마스크로 다시 도려낸다.
filled = z.astype(np.float64)
if not valid.all():
from scipy.ndimage import distance_transform_edt
_, (rows, cols) = distance_transform_edt(~valid, return_indices=True)
filled = filled[rows, cols]
cx, cy = x[::control_step], y[::control_step]
cz = filled[::control_step, ::control_step]
try:
spline, z_range = fit_nurbs_spline(cx, cy, cz, SURFACE_NURBS_DEGREE)
pz = evaluate_nurbs_spline(spline, py, px, z_range, "도엽 서피스")
except Exception as exc: # noqa: BLE001 — 적합 실패 시 격자 메시로 되돌린다
logger.warning("도엽 서피스: NURBS 곡면 적합 실패(%s) — 격자 메시를 씁니다.", exc)
pz = filled[::stride, ::stride]
vertices = grid_vertices(px, py, np.asarray(pz, dtype=np.float64))
faces = grid_faces(len(py), len(px))
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))