Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py
eomsangdonandClaude Opus 5 18c174036b 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>
2026-09-04 19:00:38 +09:00

221 lines
7.7 KiB
Python

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