From 18c174036b3a0de7053547bf9021aa0ecff5d16e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 19:00:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84(=EB=9D=BC=EC=9D=B4=EB=8B=A4)=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=EB=A9=B4=20=EC=9D=8C=EC=98=81=EA=B8=B0?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=B0=EA=B2=BD=201=EC=B0=A8=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 도각이던 라이다 계획평면도에 지표면 탑뷰 그림을 얹음. - 확정 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) --- .../B07_DesignDetail_Api_Fetch.ts | 2 + .../B07_DesignDetail_Engine_Cad_Lidar.py | 220 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 6 + B07_DesignDetail/B07_DesignDetail_Router.py | 9 + .../B07_DesignDetail_Router_Support.py | 45 +++- .../B07_DesignDetail_Router_Support_Basin.py | 114 +++++++++ B07_DesignDetail/B07_DesignDetail_Schema.py | 2 + .../B07_DesignDetail_UI_Panels.ts | 2 +- common_util/common_util_drainage_context.py | 4 + 9 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 38a360c9..b51b8f63 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py new file mode 100644 index 00000000..726061c1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py @@ -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), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index ecec48f0..6708d945 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -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)) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index aa8b3b5c..5cd8a010 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 9fb6f583..93936c4f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -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): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 3c104371..7858a53f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -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)로 모은다. diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index d4b7fddf..dbe92954 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 74d19953..2eab42c6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -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" }, diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 2d023038..a785695f 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -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), ), "", )