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:
2026-09-04 19:00:38 +09:00
co-authored by Claude Opus 5
parent d662b0725d
commit 18c174036b
9 changed files with 402 additions and 2 deletions
@@ -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;
@@ -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),
],
}
@@ -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))
@@ -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)
@@ -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):
@@ -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)로 모은다.
@@ -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
@@ -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" },
@@ -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),
),
"",
)