"""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), ], }