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:
@@ -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),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user