fix(B04): 도엽 서피스를 라이다와 같은 원점에 놓고, 유역 저장본에 좌표계를 남긴다

① 3D 서피스 겹쳐보기 어긋남 — `write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼는데
모델마다 자기 상자를 넘겨 도엽 서피스와 라이다 지표면이 다른 원점에 섰다
(2f940d8a 실측: 수평 7.85m·높이 1.28m). 도엽 서피스가 라이다 포인트 상자
(`structured.npz`, 화면 `setReferenceBounds`와 같은 값)를 화면 원점으로 쓰게 했다.
표고 격자 상자(`bounds`)는 절취 범위 그대로 두고 `scene_bounds`를 npz에 따로 남긴다 —
등고선 API도 이 값을 먼저 써서 등고선이 메시 위에 얹힌다. 라이다 없는 사업지는 기준이
자기뿐이라 종전대로 자기 상자를 쓴다.

② 세부유역 저장본 좌표계 — `04_detailed_basins.geojson`에 변환에 쓴 좌표계를
`crs_input`으로 남기고, B07 유역도가 그 값으로 되돌린다. 기록이 없는 옛 저장본은
노선 CSV의 EPSG 라벨로 쓰였으므로 그 라벨로 되돌린다(경고 로그 + 재확정 안내).

검증: tmp/tests 42 passed (신규 test_scene_origin_and_basin_crs.py 5건).
패치 코드로 도엽 서피스를 다시 만들어 GLB 정점 상자를 대조 —
sheet [-426.58, -68.95, -386.41]~[411.42, 71.52, 379.59],
csf [-172.12, -44.06, -199.69]~[184.11, 3.22, 165.72] 로 같은 원점.
수정 전 sheet는 [-419.5, -70.24, -383.0]~[418.5, 70.24, 383.0] (자기 중심)였다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:52:27 +09:00
co-authored by Claude Opus 5
parent b50559d799
commit 0a1ebcfbb5
5 changed files with 91 additions and 25 deletions
@@ -98,6 +98,28 @@ def _load_features_metric(
return converted
def _scene_bounds(project_root: Path, bounds: np.ndarray) -> np.ndarray:
"""프리뷰 메시를 놓을 **화면 원점 기준 상자**.
`write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼아 정점을 옮긴다. 모델마다 자기
범위를 주면 도엽 서피스와 라이다 지표면이 서로 다른 원점에 서서, 겹쳐 보기·계획선이
어긋난다(2026-09-01 실측: 수평 7.85m·높이 1.28m). 그래서 라이다 포인트 상자가 있으면
그 상자를 같이 쓴다 — 화면이 기준으로 삼는 상자(`setReferenceBounds`)와 같은 값이다.
라이다가 없는 사업지(도엽만)는 기준이 이 서피스뿐이라 자기 상자를 그대로 쓴다.
"""
structured = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured.is_file():
return bounds
try:
with np.load(structured) as data:
if "bounds" not in data:
return bounds
return np.asarray(data["bounds"], dtype=float)
except (OSError, ValueError) as exc:
logger.warning("도엽 서피스: 라이다 상자를 읽지 못해 자기 범위로 놓습니다 — %s", exc)
return bounds
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
@@ -125,6 +147,7 @@ def _write_smoothed(
z: np.ndarray,
valid: np.ndarray,
bounds: np.ndarray,
scene: np.ndarray,
) -> None:
"""`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차.
@@ -180,10 +203,11 @@ def _write_smoothed(
z=sz,
valid_mask=svalid,
bounds=bounds,
scene_bounds=scene,
resolution=np.array([step], np.float32),
)
vertices, faces = _preview_mesh(sx, sy, np.nan_to_num(sz, nan=float(bounds[2, 0])), svalid)
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, bounds)
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, scene)
def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray:
@@ -339,6 +363,8 @@ def _write_method_model(
[float(finite_z.min()), float(finite_z.max())],
]
)
# 표고 격자 상자(`bounds`)는 절취 범위 그대로 두고, 화면 원점만 라이다와 맞춘다.
scene = _scene_bounds(project_root, bounds)
atomic_npz(
model_path,
x=x_coords,
@@ -346,11 +372,12 @@ def _write_method_model(
z=z_grid,
valid_mask=valid_grid,
bounds=bounds,
scene_bounds=scene,
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
)
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, bounds)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds)
write_glb(preview_path, vertices, faces, scene)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene)
return {
"model_type": "dtm",
"source_filter": source_filter,
@@ -312,7 +312,10 @@ async def put_pipe_points(
save_pipe_points, context.stored_path, signature, points, context.vertices
)
await asyncio.to_thread(
save_detail_basins, context.stored_path, _basin_features(context, detail, points)
save_detail_basins,
context.stored_path,
_basin_features(context, detail, points),
context.crs,
)
result = _payload(project_id, context, detail, points, saved=True)
result["saved_count"] = saved
+11 -11
View File
@@ -139,22 +139,22 @@ async def get_surface_model_contour(
interval,
time.monotonic() - generation_started,
)
# 화면은 이 상자의 중심을 원점 삼아 등고선을 놓는다 — 메시(glb)를 만들 때 쓴
# 상자와 같아야 등고선이 지형 위에 얹힌다. 도엽 서피스는 라이다와 원점을 맞추려
# `scene_bounds`를 따로 갖고 있으므로 그 값이 있으면 먼저 쓴다(2026-09-01).
with np.load(contour_model_path) as model_data:
if "bounds" in model_data:
if "scene_bounds" in model_data:
model_bounds = np.asarray(model_data["scene_bounds"], dtype=float)
elif "bounds" in model_data:
model_bounds = np.asarray(model_data["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
else:
with np.load(structured_path) as structured:
model_bounds = np.asarray(structured["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": str(project_id),
@@ -41,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingItem,
)
from common_util.common_util_drainage_pipes import detail_basins_path
from common_util.common_util_route_geometry import find_planned_route_file, read_planned_route
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import DRAWING_SCALE_BASIN
@@ -107,19 +108,46 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
temporary.replace(path)
def _geojson_features(path: Path) -> list[dict[str, Any]]:
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
def _geojson_payload(path: Path) -> dict[str, Any]:
"""GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict."""
if not path.is_file():
return []
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path)
return []
features = payload.get("features") if isinstance(payload, dict) else None
return {}
return payload if isinstance(payload, dict) else {}
def _geojson_features(path: Path) -> list[dict[str, Any]]:
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
features = _geojson_payload(path).get("features")
return features if isinstance(features, list) else []
def _basins_crs(context: Any, payload: dict[str, Any]) -> str:
"""저장본을 미터로 되돌릴 좌표계.
저장할 좌표계를 파일이 갖고 있으면 값이다. 없으면 좌표계 기록 이전에 저장된
파일이라 노선 CSV의 EPSG 라벨로 쓰였다 라벨로 되돌려야 왕복이 맞는다
(2026-09-01). 라벨을 읽으면 현재 사업지 좌표계로 둔다.
"""
stored = payload.get("crs_input")
if isinstance(stored, str) and stored:
return stored
route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input")
planned = read_planned_route(route_file) if route_file else None
if planned is not None and planned.epsg:
logger.warning(
"B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. "
"B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.",
planned.epsg,
)
return f"EPSG:{planned.epsg}"
return context.crs
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
if not isinstance(geometry, dict):
@@ -206,7 +234,10 @@ def watershed_source(context: Any) -> dict[str, Any]:
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
"""
to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True)
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
to_metric = Transformer.from_crs(
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
)
def metric(point: tuple[float, float]) -> tuple[float, float]:
x, y = to_metric.transform(point[0], point[1])
@@ -214,7 +245,7 @@ def watershed_source(context: Any) -> dict[str, Any]:
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
basins: list[dict[str, Any]] = []
for feature in _geojson_features(detail_basins_path(context.stored_path)):
for feature in basins_payload.get("features") or []:
properties = feature.get("properties") or {}
if properties.get("kind") != "detail_basin":
continue
+8 -3
View File
@@ -351,11 +351,16 @@ def clear_pipe_points(stored_path: str) -> bool:
return removed
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: str) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).
`crs` 좌표를 WGS84로 바꿀 **사업지 좌표계**. 되읽는 (B07 유역도) 같은
좌표계로 되돌려야 하는데, 예전에는 값을 남겨 노선 CSV의 EPSG 라벨로 되돌렸다
(2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 자리로 갔다).
"""
path = detail_basins_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
atomic_write_json(path, {"type": "FeatureCollection", "crs_input": crs, "features": features})
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
return path