빈 도각이던 라이다 계획평면도에 지표면 탑뷰 그림을 얹음. - 확정 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>
221 lines
7.7 KiB
Python
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),
|
|
],
|
|
}
|