feat(B07): 계획평면도(라이다) — 지표면 음영기복 배경 1차 배치
빈 도각이던 라이다 계획평면도에 지표면 탑뷰 그림을 얹음. - 확정 DTM 격자를 도곽 범위로 잘라 음영기복 PNG 를 만들고 Image 엔티티로 실음 (북서 315도·고도 45도, 한 변 최대 1,600 px). 점구름 4,900만 점을 그대로 그리지 않음. - 어느 지표면을 쓸지는 1단계 확정값을 따름 — DrainageContext 에 surface_params 를 실어 전달. - 축척·도곽·장 나눔은 계획평면도와 같음(1/1,200) — 노선이 같은 자리에 섬. - entities_bbox 가 꼭짓점 배열(points)을 세도록 고침. 세지 않으면 그림이 도곽 계산에서 통째로 빠짐. 검증(용화_LAS): 콘텐츠 726.1x487.2 mm ≤ A1 작도영역, 그림 범위 안에 노선이 완전히 들어감, 음영기복 준비 0.4초·자료 214 KB. 능선·계곡이 눈으로 구분됨. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ export interface DesignDrawingItem {
|
||||
| "watershed"
|
||||
| "plan"
|
||||
| "landuse"
|
||||
| "plan_lidar"
|
||||
| "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
@@ -99,6 +100,7 @@ export interface DesignDrawingResponse {
|
||||
| "watershed"
|
||||
| "plan"
|
||||
| "landuse"
|
||||
| "plan_lidar"
|
||||
| "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""B07 계획평면도(라이다) CAD 조립 — 지표면 격자를 음영기복 그림으로 깔고 노선을 얹는다.
|
||||
|
||||
사용자 지시(2026-09-04) — 「라이다 계획평면도는 3D 자료를 탑뷰에서 본 그림이 필요함.
|
||||
가능한 범위에서 일단 배치해 주면 보고 개선하겠음」.
|
||||
|
||||
점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(용화_LAS 실측 4,900만 점).
|
||||
이미 만들어 둔 **지표면 격자(DTM)** 로 음영기복 이미지를 서버에서 만들어 배경으로 깐다.
|
||||
도면 틀이 이미지 요소를 받아 주므로(`Image` 엔티티) PNG 를 그대로 싣는다.
|
||||
|
||||
축척·도곽·장 나눔은 계획평면도와 같다(1/1,200 고정) — 같은 자리에 노선이 서야 한다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
TABLE_LABEL_COLOR,
|
||||
_layer,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
_FONT_SIZE,
|
||||
_ROUTE_WIDTH,
|
||||
_TITLE_FONT_SIZE,
|
||||
MM,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
LIDAR_KIND = "plan_lidar"
|
||||
LIDAR_LABEL = "계획평면도(라이다)"
|
||||
|
||||
SHADE_LAYER_ID = "b07-lidar-shade"
|
||||
ROUTE_LAYER_ID = "b07-lidar-route"
|
||||
TITLE_LAYER_ID = "b07-lidar-title"
|
||||
ROUTE_COLOR = "#ffe066"
|
||||
|
||||
# 음영기복 광원 — 도면 관행대로 북서(방위각 315°)에서 45° 높이로 비춘다.
|
||||
_AZIMUTH_DEG = 315.0
|
||||
_ALTITUDE_DEG = 45.0
|
||||
# 그림이 지나치게 커지지 않도록 한 변 최대 픽셀 수 (A1 에 인쇄하면 1,200 px 이면 충분하다).
|
||||
_MAX_PIXELS = 1600
|
||||
|
||||
|
||||
def hillshade_png(z: np.ndarray, valid: np.ndarray, resolution_m: float) -> tuple[str, int, int]:
|
||||
"""지표면 격자에서 음영기복 PNG(data URL)를 만든다. (data_url, 가로 px, 세로 px).
|
||||
|
||||
입력 `z`는 행이 남→북 순서(격자 y 오름차순)다. 그림은 위가 북이어야 하므로 뒤집는다.
|
||||
빈 칸(`valid`가 False)은 흰색으로 두어 도면에서 배경과 구분되게 한다.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
grid = np.asarray(z, dtype=np.float64)
|
||||
mask = np.asarray(valid, dtype=bool)
|
||||
if grid.ndim != 2 or grid.size == 0:
|
||||
raise ValueError("지표면 격자가 비어 있습니다.")
|
||||
|
||||
# 큰 격자는 미리 솎는다 — A1 한 장에 1,600 px 이상은 눈으로 구분되지 않는다.
|
||||
rows, columns = grid.shape
|
||||
stride = max(1, math.ceil(max(rows, columns) / _MAX_PIXELS))
|
||||
if stride > 1:
|
||||
grid = grid[::stride, ::stride]
|
||||
mask = mask[::stride, ::stride]
|
||||
resolution_m *= stride
|
||||
|
||||
filled = np.where(mask, grid, np.nan)
|
||||
# 빈 칸이 기울기를 망치지 않도록 평균으로 메운 뒤 기울기를 잰다.
|
||||
mean = float(np.nanmean(filled)) if np.isfinite(filled).any() else 0.0
|
||||
filled = np.nan_to_num(filled, nan=mean)
|
||||
dz_dy, dz_dx = np.gradient(filled, max(resolution_m, 1e-6))
|
||||
|
||||
slope = np.arctan(np.hypot(dz_dx, dz_dy))
|
||||
aspect = np.arctan2(-dz_dx, dz_dy)
|
||||
azimuth = math.radians(360.0 - _AZIMUTH_DEG + 90.0)
|
||||
altitude = math.radians(_ALTITUDE_DEG)
|
||||
shade = np.sin(altitude) * np.cos(slope) + np.cos(altitude) * np.sin(slope) * np.cos(
|
||||
azimuth - aspect
|
||||
)
|
||||
shade = np.clip(shade, 0.0, 1.0)
|
||||
# 배경이므로 완전히 검지 않게 누르되, 능선·계곡이 인쇄에서 보일 만큼은 대비를 준다
|
||||
# (2026-09-04 실측: 120~255 는 너무 흐렸음).
|
||||
pixels = (90 + 160 * shade).astype(np.uint8)
|
||||
pixels[~mask] = 255
|
||||
|
||||
image = Image.fromarray(np.flipud(pixels), mode="L")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG", optimize=True)
|
||||
data_url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return (data_url, image.width, image.height)
|
||||
|
||||
|
||||
def build_lidar_plan_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
route_xy: list[tuple[float, float]],
|
||||
shade_image: str | None,
|
||||
shade_box: tuple[float, float, float, float] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""라이다 계획평면도 한 장을 만든다.
|
||||
|
||||
`shade_box`는 음영기복 그림이 덮는 실좌표 범위(min_x, min_y, max_x, max_y)다 —
|
||||
그림 네 모서리를 그 범위 그대로 종이에 놓아야 노선과 좌표가 맞는다.
|
||||
"""
|
||||
everything = [*route_xy]
|
||||
if shade_box:
|
||||
everything.extend([(shade_box[0], shade_box[1]), (shade_box[2], shade_box[3])])
|
||||
if not everything:
|
||||
raise FileNotFoundError(
|
||||
"라이다 계획평면도에 그릴 자료가 없습니다. B04 전처리에서 지표면을 먼저 만드세요."
|
||||
)
|
||||
min_x = min(x for x, _y in everything)
|
||||
min_y = min(y for _x, y in everything)
|
||||
|
||||
def paper(point: tuple[float, float]) -> tuple[float, float]:
|
||||
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
if shade_image and shade_box:
|
||||
left, bottom = paper((shade_box[0], shade_box[1]))
|
||||
right, top = paper((shade_box[2], shade_box[3]))
|
||||
entities.append(
|
||||
{
|
||||
"id": f"{drawing_id}:shade",
|
||||
"type": "Image",
|
||||
"lineColor": "#ffffff",
|
||||
"lineWidth": 1,
|
||||
"layerId": SHADE_LAYER_ID,
|
||||
"shapeData": {
|
||||
"points": [
|
||||
{"x": left, "y": bottom},
|
||||
{"x": right, "y": bottom},
|
||||
{"x": right, "y": top},
|
||||
{"x": left, "y": top},
|
||||
],
|
||||
"imageData": shade_image,
|
||||
},
|
||||
}
|
||||
)
|
||||
route = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in route_xy],
|
||||
ROUTE_LAYER_ID,
|
||||
ROUTE_COLOR,
|
||||
width=_ROUTE_WIDTH,
|
||||
)
|
||||
if route:
|
||||
entities.append(route)
|
||||
|
||||
map_bbox = entities_bbox(entities)
|
||||
if map_bbox:
|
||||
entities.extend(
|
||||
compass_entities(
|
||||
drawing_id,
|
||||
(
|
||||
map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0,
|
||||
map_bbox[3] - _COMPASS_SIZE / 2.0,
|
||||
),
|
||||
_COMPASS_SIZE,
|
||||
)
|
||||
)
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
min_bx, _min_by, max_bx, max_by = bbox
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:title",
|
||||
label,
|
||||
(min_bx + max_bx) / 2.0,
|
||||
max_by + 12.0,
|
||||
TITLE_LAYER_ID,
|
||||
_TITLE_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:scale",
|
||||
f"S = 1/{DRAWING_SCALE_PLAN:,}",
|
||||
max_bx,
|
||||
max_by + 5.0,
|
||||
TITLE_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="right",
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id,
|
||||
entities_bbox(entities) or bbox,
|
||||
fit=False,
|
||||
fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(SHADE_LAYER_ID, "지표면 음영기복", locked=True),
|
||||
_layer(ROUTE_LAYER_ID, "계획노선"),
|
||||
_layer(TITLE_LAYER_ID, "표제"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
@@ -194,6 +194,12 @@ def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float,
|
||||
if isinstance(p, dict):
|
||||
xs.append(float(p["x"]))
|
||||
ys.append(float(p["y"]))
|
||||
# 꼭짓점 배열을 쓰는 엔티티(Image·Hatch)도 범위에 넣는다 — 넣지 않으면 라이다
|
||||
# 음영기복 그림이 도곽 계산에서 통째로 빠진다(2026-09-04).
|
||||
for vertex in shape.get("points") or []:
|
||||
if isinstance(vertex, dict) and "x" in vertex and "y" in vertex:
|
||||
xs.append(float(vertex["x"]))
|
||||
ys.append(float(vertex["y"]))
|
||||
center = shape.get("center")
|
||||
if isinstance(center, dict):
|
||||
r = float(shape.get("radius", 0.0))
|
||||
|
||||
@@ -35,6 +35,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
LANDUSE_ID,
|
||||
LIDAR_ID,
|
||||
MASS_HAUL_ID,
|
||||
PLAN_ID,
|
||||
WATERSHED_ID,
|
||||
@@ -46,6 +47,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_recompute_confirmed_design,
|
||||
_store_confirmed_drawing,
|
||||
landuse_source,
|
||||
lidar_source,
|
||||
plan_source,
|
||||
watershed_source,
|
||||
)
|
||||
@@ -305,6 +307,13 @@ async def get_design_drawing(
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
source_design = await asyncio.to_thread(watershed_source, context)
|
||||
elif LIDAR_ID.fullmatch(drawing_id):
|
||||
# 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
source_design = await asyncio.to_thread(lidar_source, context, longitudinal, drawing_id)
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
# 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
|
||||
@@ -25,6 +25,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
LANDUSE_LABEL,
|
||||
build_landuse_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import (
|
||||
LIDAR_LABEL,
|
||||
build_lidar_plan_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -59,6 +63,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
LANDUSE_ID as LANDUSE_ID,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
LIDAR_ID as LIDAR_ID,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
PLAN_ID as PLAN_ID,
|
||||
)
|
||||
@@ -89,6 +96,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
landuse_source as landuse_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
lidar_source as lidar_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
plan_source as plan_source,
|
||||
)
|
||||
@@ -126,7 +136,6 @@ COVER_ID = "cover"
|
||||
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
|
||||
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
||||
("blank_plan_lidar", "계획평면도(라이다)"),
|
||||
("blank_cross_standard", "표준 횡단면도"),
|
||||
("blank_standard", "표준도"),
|
||||
)
|
||||
@@ -186,6 +195,19 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}"
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind="plan_lidar",
|
||||
label=LIDAR_LABEL
|
||||
if len(plan_sheets) <= 1
|
||||
else f"{LIDAR_LABEL} {chunk['number']}장",
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}"
|
||||
@@ -433,6 +455,8 @@ def _read_drawing(
|
||||
kind = "plan"
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
kind = "landuse"
|
||||
elif LIDAR_ID.fullmatch(drawing_id):
|
||||
kind = "plan_lidar"
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
@@ -461,6 +485,25 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if LIDAR_ID.fullmatch(drawing_id):
|
||||
# stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림.
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or LIDAR_LABEL)
|
||||
return (
|
||||
"plan_lidar",
|
||||
label,
|
||||
build_lidar_plan_drawing(
|
||||
drawing_id,
|
||||
label,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("shade_image"),
|
||||
stored_design.get("shade_box"),
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if LANDUSE_ID.fullmatch(drawing_id):
|
||||
# stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -16,6 +16,7 @@ from pyproj import Transformer
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
plan_area_mm,
|
||||
plan_chunks,
|
||||
@@ -470,6 +471,119 @@ def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str)
|
||||
}
|
||||
|
||||
|
||||
LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$")
|
||||
|
||||
|
||||
def _sheet_box(
|
||||
route_xy: list[tuple[float, float]],
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""그 장의 도곽 범위(실좌표 m) — 계획평면도·용지도·라이다가 같은 규칙을 쓴다."""
|
||||
area_w_mm, area_h_mm = plan_area_mm()
|
||||
half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0
|
||||
center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0
|
||||
return (
|
||||
min(center_x - half_w, min(x for x, _y in route_xy)),
|
||||
min(center_y - half_h, min(y for _x, y in route_xy)),
|
||||
max(center_x + half_w, max(x for x, _y in route_xy)),
|
||||
max(center_y + half_h, max(y for _x, y in route_xy)),
|
||||
)
|
||||
|
||||
|
||||
def _chunk_route(
|
||||
context: Any, longitudinal: dict[str, Any], number: int
|
||||
) -> tuple[list[tuple[float, float]], dict[str, Any], int]:
|
||||
"""장 번호로 그 장의 노선 구간을 잘라 낸다 (계획평면도 장 나눔과 같은 기준)."""
|
||||
chunks = plan_chunks(plan_stations(longitudinal))
|
||||
chunk = next((item for item in chunks if item["number"] == number), None)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 장을 찾을 수 없습니다.")
|
||||
total = len(chunks)
|
||||
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
|
||||
route_xy = [
|
||||
(vertex.x, vertex.y)
|
||||
for vertex in context.vertices
|
||||
if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m
|
||||
]
|
||||
return route_xy, chunk, total
|
||||
|
||||
|
||||
def lidar_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]:
|
||||
"""라이다 계획평면도 한 장의 입력(노선 + 지표면 음영기복 그림)을 모은다.
|
||||
|
||||
지표면은 확정 DTM 격자(`dtm_{필터}[_smooth].npz`)를 도곽 범위로 잘라 쓴다 —
|
||||
점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(2026-09-04 사용자 지시).
|
||||
"""
|
||||
match = LIDAR_ID.fullmatch(drawing_id)
|
||||
if not match:
|
||||
raise ValueError("올바르지 않은 라이다 계획평면도 ID입니다.")
|
||||
number = int(match.group(1)) if match.group(1) else 1
|
||||
route_xy, chunk, total = _chunk_route(context, longitudinal, number)
|
||||
box = _sheet_box(route_xy)
|
||||
label = LIDAR_LABEL if total <= 1 else f"{LIDAR_LABEL} {chunk['number']}장"
|
||||
|
||||
shade_image: str | None = None
|
||||
shade_box: tuple[float, float, float, float] | None = None
|
||||
try:
|
||||
shade_image, shade_box = _hillshade_for_box(context, box)
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
# 지표면이 없어도 노선·도각은 그린다 — 빈 화면보다 낫다.
|
||||
logger.warning("B07 라이다 계획평면도: 음영기복을 만들지 못했습니다 — %s", exc)
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"route_xy": route_xy,
|
||||
"shade_image": shade_image,
|
||||
"shade_box": shade_box,
|
||||
}
|
||||
|
||||
|
||||
def _hillshade_for_box(
|
||||
context: Any, box: tuple[float, float, float, float]
|
||||
) -> tuple[str, tuple[float, float, float, float]]:
|
||||
"""확정 DTM 격자를 도곽 범위로 잘라 음영기복 PNG(data URL)와 실제 덮은 범위를 낸다."""
|
||||
import numpy as np
|
||||
|
||||
params = getattr(context, "surface_params", None) or {}
|
||||
source_filter = str(params.get("source_filter") or "csf")
|
||||
smooth = bool(params.get("smooth", True))
|
||||
models_dir = Path(context.project_root) / "B04_PreProcess" / "models"
|
||||
candidates = [models_dir / f"dtm_{source_filter}_smooth.npz"] if smooth else []
|
||||
candidates.append(models_dir / f"dtm_{source_filter}.npz")
|
||||
candidates.extend(sorted(models_dir.glob("dtm_*_smooth.npz")))
|
||||
candidates.extend(sorted(models_dir.glob("dtm_*.npz")))
|
||||
path = next((item for item in candidates if item.is_file()), None)
|
||||
if path is None:
|
||||
raise FileNotFoundError("확정 지표면 격자(DTM)가 없습니다.")
|
||||
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
grid_x = np.asarray(data["x"], dtype=np.float64)
|
||||
grid_y = np.asarray(data["y"], dtype=np.float64)
|
||||
grid_z = np.asarray(data["z"], dtype=np.float64)
|
||||
valid = np.asarray(data["valid_mask"], dtype=bool)
|
||||
resolution = float(np.asarray(data["resolution"]).reshape(-1)[0])
|
||||
|
||||
min_x, min_y, max_x, max_y = box
|
||||
columns = np.where((grid_x >= min_x) & (grid_x <= max_x))[0]
|
||||
rows = np.where((grid_y >= min_y) & (grid_y <= max_y))[0]
|
||||
if columns.size < 2 or rows.size < 2:
|
||||
raise ValueError("도곽 안에 지표면 격자가 없습니다.")
|
||||
sliced_z = grid_z[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1]
|
||||
sliced_valid = valid[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1]
|
||||
|
||||
data_url, _width, _height = hillshade_png(sliced_z, sliced_valid, resolution)
|
||||
return (
|
||||
data_url,
|
||||
(
|
||||
float(grid_x[columns[0]]),
|
||||
float(grid_y[rows[0]]),
|
||||
float(grid_x[columns[-1]]),
|
||||
float(grid_y[rows[-1]]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def watershed_source(context: Any) -> dict[str, Any]:
|
||||
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class DesignDrawingItem(BaseModel):
|
||||
"watershed",
|
||||
"plan",
|
||||
"landuse",
|
||||
"plan_lidar",
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
@@ -50,6 +51,7 @@ class DesignDrawingResponse(BaseModel):
|
||||
"watershed",
|
||||
"plan",
|
||||
"landuse",
|
||||
"plan_lidar",
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
|
||||
@@ -34,7 +34,7 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
|
||||
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
|
||||
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
|
||||
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
|
||||
{ label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
|
||||
{ label: "종단면도", kind: "longitudinal" },
|
||||
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
|
||||
@@ -58,6 +58,9 @@ class DrainageContext:
|
||||
crs: str = "EPSG:5186"
|
||||
route_id: int | None = None
|
||||
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
|
||||
# 1단계에서 확정한 지표면 선택(source_filter·method·smooth). B07 라이다 계획평면도가
|
||||
# 어느 DTM 격자로 음영기복을 만들지 고르는 데 쓴다(2026-09-04).
|
||||
surface_params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]:
|
||||
@@ -112,6 +115,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
|
||||
crs=crs,
|
||||
route_id=int(route["id"]) if route else None,
|
||||
to_lonlat=lambda x, y: transformer.transform(x, y),
|
||||
surface_params=dict(surface_params),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user