Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1

This commit is contained in:
2026-09-04 19:14:10 +09:00
15 changed files with 2404 additions and 199 deletions
+49 -10
View File
@@ -5,7 +5,17 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface DesignDrawingItem { export interface DesignDrawingItem {
id: string; id: string;
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; kind:
| "cover"
| "longitudinal"
| "cross"
| "mass_haul"
| "watershed"
| "plan"
| "landuse"
| "plan_lidar"
| "cross_standard"
| "blank";
label: string; label: string;
chainage_m: number | null; chainage_m: number | null;
confirmed: boolean; confirmed: boolean;
@@ -67,7 +77,10 @@ export interface CrossDesignInfo {
cross_slope_pct?: number; cross_slope_pct?: number;
paved?: boolean; paved?: boolean;
ditch: DitchSpec; ditch: DitchSpec;
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; road_edges?: Record<
"left" | "right",
{ offset_m: number; elevation_m: number }
>;
design_elevation_m: number; design_elevation_m: number;
cut_area_m2: number; cut_area_m2: number;
fill_area_m2: number; fill_area_m2: number;
@@ -80,7 +93,17 @@ export interface DesignDrawingResponse {
route_id: number; route_id: number;
id: string; id: string;
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; kind:
| "cover"
| "longitudinal"
| "cross"
| "mass_haul"
| "watershed"
| "plan"
| "landuse"
| "plan_lidar"
| "cross_standard"
| "blank";
label: string; label: string;
drawing: CadDrawing; drawing: CadDrawing;
confirmed: boolean; confirmed: boolean;
@@ -97,7 +120,10 @@ export interface DesignDrawingConfirmResponse {
design?: CrossDesignInfo | null; design?: CrossDesignInfo | null;
} }
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> { async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try { try {
@@ -108,14 +134,17 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
signal: controller.signal, signal: controller.signal,
}); });
const payload = (await response.json()) as T & { message?: string }; const payload = (await response.json()) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); if (!response.ok)
throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload; return payload;
} finally { } finally {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
} }
} }
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> { export function fetchDesignDrawingList(
projectId: string,
): Promise<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`); return requestJson(`/projects/${projectId}/design-drawings`);
} }
@@ -123,7 +152,9 @@ export function fetchDesignDrawing(
projectId: string, projectId: string,
drawingId: string, drawingId: string,
): Promise<DesignDrawingResponse> { ): Promise<DesignDrawingResponse> {
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
);
} }
export function confirmDesignDrawing( export function confirmDesignDrawing(
@@ -141,7 +172,10 @@ export function confirmDesignDrawing(
); );
} }
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> { export function invalidateDesignDrawing(
projectId: string,
drawingId: string,
): Promise<void> {
return requestJson( return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
{ method: "POST" }, { method: "POST" },
@@ -157,11 +191,16 @@ export interface FrameTemplateResponse {
customized: boolean; customized: boolean;
} }
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> { export function fetchFrameTemplate(
projectId: string,
): Promise<FrameTemplateResponse> {
return requestJson(`/projects/${projectId}/frame-template`); return requestJson(`/projects/${projectId}/frame-template`);
} }
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> { export function saveFrameTemplate(
projectId: string,
drawing: CadDrawing,
): Promise<void> {
return requestJson(`/projects/${projectId}/frame-template`, { return requestJson(`/projects/${projectId}/frame-template`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ drawing }), body: JSON.stringify({ drawing }),
+19 -15
View File
@@ -443,6 +443,7 @@ def build_cross_drawing(
design_elevation_m: float | None = None, design_elevation_m: float | None = None,
frame: dict[str, float] | None = None, frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0), origin: tuple[float, float] = (0.0, 0.0),
cell_frame: tuple[float, float, float, float] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -450,6 +451,10 @@ def build_cross_drawing(
설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의
bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재 bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰
그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04
사용자 확정 — 축척 1/100은 그대로, 칸만 통일).
""" """
ox, oy = origin ox, oy = origin
raw_ground = points_from_samples(source.get("samples", []), "offset_m") raw_ground = points_from_samples(source.get("samples", []), "offset_m")
@@ -511,16 +516,20 @@ def build_cross_drawing(
) )
table_bottom = table_top - cross_table_height() table_bottom = table_top - cross_table_height()
# 외곽 테두리: 단면 범위와 표를 함께 감싼다. # 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다.
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) if cell_frame is not None:
frame_top = oy + half_height + 4.0 frame_left, frame_bottom, frame_right, frame_top = cell_frame
frame_bottom = table_bottom - 4.0 else:
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
frame_left, frame_right = center_x - frame_x, center_x + frame_x
frame_top = oy + half_height + 4.0
frame_bottom = table_bottom - 4.0
corners = [ corners = [
(center_x - frame_x, frame_bottom), (frame_left, frame_bottom),
(center_x + frame_x, frame_bottom), (frame_right, frame_bottom),
(center_x + frame_x, frame_top), (frame_right, frame_top),
(center_x - frame_x, frame_top), (frame_left, frame_top),
(center_x - frame_x, frame_bottom), (frame_left, frame_bottom),
] ]
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border: if border:
@@ -542,12 +551,7 @@ def build_cross_drawing(
"x1": x1, "x1": x1,
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
# 서버 설계선을 이 안에서만 골라내는 데 쓴다. # 서버 설계선을 이 안에서만 골라내는 데 쓴다.
"frame": [ "frame": [frame_left, frame_bottom, frame_right, frame_top],
center_x - frame_x,
frame_bottom,
center_x + frame_x,
frame_top,
],
} }
], ],
"layers": [ "layers": [
@@ -0,0 +1,339 @@
"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다.
사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고
연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를
넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」.
지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`·
`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 —
지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다.
축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다.
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm).
"""
from typing import Any
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,
_TITLE_FONT_SIZE,
MM,
plan_area_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
LANDUSE_KIND = "landuse"
LANDUSE_LABEL = "용지도"
CONTOUR_LAYER_ID = "b07-landuse-contour"
PARCEL_LAYER_ID = "b07-landuse-parcel"
JIBUN_LAYER_ID = "b07-landuse-jibun"
EMD_LAYER_ID = "b07-landuse-emd"
SGG_LAYER_ID = "b07-landuse-sgg"
ROUTE_LAYER_ID = "b07-landuse-route"
LEGEND_LAYER_ID = "b07-landuse-legend"
TITLE_LAYER_ID = "b07-landuse-title"
# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경
# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」).
CONTOUR_COLOR = "#9aa3ad"
PARCEL_COLOR = "#8c6b4f"
JIBUN_COLOR = "#5c4632"
EMD_COLOR = "#2f7d4f"
SGG_COLOR = "#a63d3d"
ROUTE_COLOR = "#ffe066"
LEGEND_COLOR = TABLE_LABEL_COLOR
_ROUTE_WIDTH = 3
_SGG_WIDTH = 3
_EMD_WIDTH = 2
_JIBUN_FONT_SIZE = 1.8
_LEGEND_FONT_SIZE = 2.4
# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다.
_JIBUN_MIN_W_MM = 6.0
_JIBUN_MIN_H_MM = 3.0
_LEGEND_ROW_H = 6.0
_LEGEND_SAMPLE_W = 12.0
_LEGEND_GAP = 3.0
_LEGEND_TOP_GAP = 8.0
# 범례 항목 (표기 이름, 색, 선굵기, 파선).
_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = (
("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None),
("필지 경계", PARCEL_COLOR, 1, None),
("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]),
("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]),
("등고선", CONTOUR_COLOR, 1, None),
)
def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]:
"""고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다."""
xs = [x for x, _y in ring]
ys = [y for _x, y in ring]
return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]:
"""범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다."""
entities: list[dict[str, Any]] = []
x, y = origin
entities.append(
_text_entity(
f"{drawing_id}:legend:title",
"범 례",
x + _LEGEND_SAMPLE_W / 2.0 + 6.0,
y,
LEGEND_LAYER_ID,
_LEGEND_FONT_SIZE + 0.6,
LEGEND_COLOR,
)
)
for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS):
row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H
sample = polyline_entity(
drawing_id,
[(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)],
LEGEND_LAYER_ID,
color,
suffix=f":legend:{index}",
dash=dash,
width=width,
)
if sample:
entities.append(sample)
entities.append(
_text_entity(
f"{drawing_id}:legend:label:{index}",
label,
x + _LEGEND_SAMPLE_W + _LEGEND_GAP,
row_y,
LEGEND_LAYER_ID,
_LEGEND_FONT_SIZE,
LEGEND_COLOR,
align="left",
)
)
return entities
def _boundary_entities(
drawing_id: str,
rings: list[list[tuple[float, float]]],
layer_id: str,
color: str,
width: int,
dash: list[int] | None,
paper: Any,
tag: str,
) -> list[dict[str, Any]]:
entities: list[dict[str, Any]] = []
for index, ring in enumerate(rings):
line = polyline_entity(
drawing_id,
[paper(point) for point in ring],
layer_id,
color,
suffix=f":{tag}:{index}",
dash=dash,
width=width,
)
if line:
entities.append(line)
return entities
def build_landuse_drawing(
drawing_id: str,
label: str,
route_xy: list[tuple[float, float]],
contours: list[list[tuple[float, float]]],
parcels: list[dict[str, Any]],
emd_rings: list[list[tuple[float, float]]],
sgg_rings: list[list[tuple[float, float]]],
) -> dict[str, Any]:
"""용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다.
`parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는
라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다.
"""
everything = [
*route_xy,
*(point for line in contours for point in line),
*(point for parcel in parcels for point in parcel.get("ring") or []),
]
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]] = []
# 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다.
entities.extend(
_boundary_entities(
drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour"
)
)
# 필지 경계 + 지번.
jibun: list[dict[str, Any]] = []
for index, parcel in enumerate(parcels):
ring = parcel.get("ring") or []
label_at = parcel.get("label_at")
if len(ring) >= 2:
outline = polyline_entity(
drawing_id,
[paper(point) for point in ring],
PARCEL_LAYER_ID,
PARCEL_COLOR,
suffix=f":parcel:{index}",
)
if outline:
entities.append(outline)
elif label_at is None:
continue
if label_at is not None:
# 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다.
center = paper(tuple(label_at))
else:
paper_ring = [paper(point) for point in ring]
width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring)
height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring)
# 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다.
if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM:
continue
center = _ring_center(paper_ring)
text = (parcel.get("props") or {}).get("jibun")
if not isinstance(text, str) or not text:
continue
jibun.append(
_text_entity(
f"{drawing_id}:jibun:{index}",
text,
center[0],
center[1],
JIBUN_LAYER_ID,
_JIBUN_FONT_SIZE,
JIBUN_COLOR,
)
)
# 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다.
entities.extend(
_boundary_entities(
drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd"
)
)
entities.extend(
_boundary_entities(
drawing_id,
sgg_rings,
SGG_LAYER_ID,
SGG_COLOR,
_SGG_WIDTH,
[14, 5, 3, 5],
paper,
"sgg",
)
)
map_bbox = entities_bbox(entities)
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)
entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다.
# 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리).
if map_bbox:
column_x = map_bbox[2] + _COMPASS_MARGIN
column_top = map_bbox[3]
entities.extend(
compass_entities(
drawing_id,
(column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0),
_COMPASS_SIZE,
)
)
entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0)))
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(CONTOUR_LAYER_ID, "등고선", locked=True),
_layer(PARCEL_LAYER_ID, "필지 경계"),
_layer(JIBUN_LAYER_ID, "지번"),
_layer(EMD_LAYER_ID, "읍면동·리 경계"),
_layer(SGG_LAYER_ID, "시군구 경계"),
_layer(ROUTE_LAYER_ID, "계획노선"),
_layer(LEGEND_LAYER_ID, "범례"),
_layer(TITLE_LAYER_ID, "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}
def landuse_area_mm() -> tuple[float, float]:
"""지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽)."""
return plan_area_mm()
@@ -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),
],
}
@@ -0,0 +1,388 @@
"""B07 계획평면도 CAD 조립 — 수치등고선 배경 위에 노선·측점·구조물을 얹는다.
세 장이 같은 배경·같은 축척을 쓰고 주제만 다르다(2026-09-04 사용자 지시).
- 계획평면도(지형) : 등고선·세류선만
- 계획평면도(노선배치도): 배경 + 계획노선 + 측점
- 계획평면도(배치도) : 배경 + 계획노선 + 구조물 배치
배경 자료는 유역도와 **같은 창구**(`B07_DesignDetail_Router_Support_Basin.map_background`)
에서 온다 — 도엽 GeoJSON 읽기·좌표 환산은 한 번뿐이고 여러 도면이 그 결과를 나눠 쓴다.
축척은 지식DB 「설계제원_총괄」 측량·도면 기준 **1/1,200 고정**이다. 횡단면도와 같은
원칙으로, 한 장에 안 들어가면 축척을 줄이지 않고 **장을 나눈다**.
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm).
"""
import math
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
TABLE_LABEL_COLOR,
_layer,
_text_entity,
polyline_entity,
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
entities_bbox,
frame_entities,
scale_fields,
usable_area,
)
from config.config_system import DRAWING_SCALE_PLAN
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,200 -> 0.8333).
MM = 1000.0 / DRAWING_SCALE_PLAN
CONTOUR_LAYER_ID = "b07-plan-contour"
CONTOUR_COLOR = "#6b7684"
STREAM_LAYER_ID = "b07-plan-stream"
STREAM_COLOR = "#4d9dff"
ROUTE_LAYER_ID = "b07-plan-route"
ROUTE_COLOR = "#ffe066"
STATION_LAYER_ID = "b07-plan-station"
STATION_COLOR = "#ff9d4d"
STRUCTURE_LAYER_ID = "b07-plan-structure"
STRUCTURE_COLOR = "#ff4d4d"
TITLE_LAYER_ID = "b07-plan-title"
_ROUTE_WIDTH = 3
_TITLE_FONT_SIZE = 7.0
_FONT_SIZE = 2.2
_STATION_FONT_SIZE = 2.0
_STATION_TICK_MM = 2.5 # 측점 눈금 반길이(종이 mm)
_STRUCTURE_SIZE_MM = 3.0 # 구조물 기호 반크기(종이 mm)
_STRUCTURE_FONT_SIZE = 2.2
_TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm)
_COMPASS_SIZE = 26.0
_COMPASS_MARGIN = 12.0
# 세 장의 주제 (id 접두어, 도면명, 노선·측점·구조물을 그리는지).
PLAN_KINDS: tuple[tuple[str, str, bool, bool, bool], ...] = (
("plan_terrain", "계획평면도(지형)", False, False, False),
("plan_route", "계획평면도(노선배치도)", True, True, False),
("plan_layout", "계획평면도(배치도)", True, False, True),
)
PLAN_KIND_LABELS: dict[str, str] = {kind: label for kind, label, *_rest in PLAN_KINDS}
# 구조물 종류별 표기 — pipe_points.json 의 facility 값 기준.
_FACILITY_LABELS: dict[str, str] = {
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
"bridge": "교량",
}
def plan_area_mm() -> tuple[float, float]:
"""지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 방위표 칸과 제목 띠를 뺀다.
라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳).
"""
width, height = usable_area()
return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND)
def _chunk_span_m() -> tuple[float, float]:
"""한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기."""
area_w, area_h = plan_area_mm()
return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0)
def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]:
"""노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}.
입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를
보아야 장수가 어긋나지 않는다(종단도 분할과 같은 방식).
축척 1/1,200 은 고정이므로 한 장에 안 들어가면 **노선을 따라 장을 나눈다**
(2026-09-04 — 횡단면도와 같은 원칙). 경계 측점 1개를 중복시켜 장 사이에서 노선이
끊겨 보이지 않게 한다(납품 도면 관례).
"""
ordered = sorted(stations, key=lambda item: item[0])
if len(ordered) < 2:
span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0)
return [{"number": 1, "start_m": span[0], "end_m": span[1]}]
span_w, span_h = _chunk_span_m()
def fits(part: list[tuple[float, float, float]]) -> bool:
width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part)
height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part)
# 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다.
return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w)
chunks: list[dict[str, Any]] = []
start = 0
while start < len(ordered) - 1:
end = start + 1
while end + 1 < len(ordered) and fits(ordered[start : end + 2]):
end += 1
chunks.append(
{
"number": len(chunks) + 1,
"start_m": float(ordered[start][0]),
"end_m": float(ordered[end][0]),
}
)
start = end # 경계 측점 1개 중복
return chunks
def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str:
"""장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다."""
return kind if total <= 1 else f"{kind}_{chunk['number']}"
def plan_drawing_label(kind: str, chunk: dict[str, Any], total: int) -> str:
label = PLAN_KIND_LABELS.get(kind, kind)
return label if total <= 1 else f"{label} {chunk['number']}장"
def _structure_label(structure: dict[str, Any]) -> str:
"""구조물 표기 — 세월교·BOX암거는 이름, 배수관은 관경(mm)."""
facility = structure.get("facility")
if isinstance(facility, str) and facility in _FACILITY_LABELS:
return _FACILITY_LABELS[facility]
options = structure.get("options")
diameter = options.get("pipe_diameter_mm") if isinstance(options, dict) else None
return f"D{int(diameter)}" if isinstance(diameter, (int, float)) else "배수시설"
def _station_entities(
drawing_id: str,
stations: list[tuple[float, float, float]],
interval_m: float,
paper: Any,
) -> list[dict[str, Any]]:
"""측점 눈금과 이름(No.n+00)을 노선 위에 직각으로 세운다.
입력은 **종단 측점**(누가거리 m, x, y)이다 — 노선 정점은 수백 개라 전부 찍으면
뭉개지고, 정점의 누가거리는 측점 간격의 배수가 아니라 걸러지지도 않는다
(2026-09-04 실측: 눈금이 2개만 찍혔음).
"""
entities: list[dict[str, Any]] = []
for index, (chainage, x, y) in enumerate(stations):
point = (x, y)
before = stations[max(index - 1, 0)]
after = stations[min(index + 1, len(stations) - 1)]
dx, dy = after[1] - before[1], after[2] - before[2]
length = math.hypot(dx, dy) or 1.0
# 노선 진행 방향의 법선 — 눈금을 노선과 직각으로 세운다.
nx, ny = -dy / length, dx / length
cx, cy = paper(point)
tick = polyline_entity(
drawing_id,
[
(cx - nx * _STATION_TICK_MM, cy - ny * _STATION_TICK_MM),
(cx + nx * _STATION_TICK_MM, cy + ny * _STATION_TICK_MM),
],
STATION_LAYER_ID,
STATION_COLOR,
suffix=f":tick:{index}",
)
if tick:
entities.append(tick)
entities.append(
_text_entity(
f"{drawing_id}:station:{index}",
station_plus_label(chainage, interval_m),
cx + nx * (_STATION_TICK_MM + 1.5),
cy + ny * (_STATION_TICK_MM + 1.5),
STATION_LAYER_ID,
_STATION_FONT_SIZE,
STATION_COLOR,
)
)
return entities
def _structure_entities(
drawing_id: str,
structures: list[dict[str, Any]],
paper: Any,
box: tuple[float, float, float, float],
) -> list[dict[str, Any]]:
"""구조물 위치를 마름모 기호 + 이름으로 찍는다. 이 장의 범위 밖은 건너뛴다."""
entities: list[dict[str, Any]] = []
min_x, min_y, max_x, max_y = box
for index, structure in enumerate(structures):
x, y = structure.get("x"), structure.get("y")
if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
continue
if not (min_x <= x <= max_x and min_y <= y <= max_y):
continue
cx, cy = paper((float(x), float(y)))
size = _STRUCTURE_SIZE_MM
marker = polyline_entity(
drawing_id,
[
(cx, cy + size),
(cx + size, cy),
(cx, cy - size),
(cx - size, cy),
(cx, cy + size),
],
STRUCTURE_LAYER_ID,
STRUCTURE_COLOR,
suffix=f":structure:{index}",
)
if marker:
entities.append(marker)
entities.append(
_text_entity(
f"{drawing_id}:structure:label:{index}",
_structure_label(structure),
cx + size + 1.0,
cy,
STRUCTURE_LAYER_ID,
_STRUCTURE_FONT_SIZE,
STRUCTURE_COLOR,
)
)
return entities
def build_plan_drawing(
kind: str,
drawing_id: str,
label: str,
route_xy: list[tuple[float, float]],
stations: list[tuple[float, float, float]],
contours: list[list[tuple[float, float]]],
streams: list[list[tuple[float, float]]],
structures: list[dict[str, Any]],
interval_m: float = 20.0,
) -> dict[str, Any]:
"""계획평면도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다.
`kind` 가 세 장 중 무엇을 그릴지 정한다(`PLAN_KINDS`). 배경은 세 장이 같다.
"""
with_route, with_station, with_structure = next(
((r, s, t) for name, _label, r, s, t in PLAN_KINDS if name == kind),
(True, False, False),
)
# 도곽 배치는 세 장이 같아야 한다 — 노선을 그리지 않는 지형도도 노선을 범위에 넣는다.
everything = [
*route_xy,
*(point for line in contours for point in line),
*(point for line in streams for point in line),
]
if not everything:
raise FileNotFoundError(
"계획평면도에 그릴 좌표가 없습니다. B04 전처리에서 수치지형도 도엽을 먼저 받으세요."
)
min_x = min(x for x, _y in everything)
min_y = min(y for _x, y in everything)
max_x = max(x for x, _y in everything)
max_y = max(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]] = []
for index, line in enumerate(contours):
contour = polyline_entity(
drawing_id,
[paper(point) for point in line],
CONTOUR_LAYER_ID,
CONTOUR_COLOR,
suffix=f":contour:{index}",
)
if contour:
entities.append(contour)
for index, line in enumerate(streams):
stream = polyline_entity(
drawing_id,
[paper(point) for point in line],
STREAM_LAYER_ID,
STREAM_COLOR,
suffix=f":stream:{index}",
)
if stream:
entities.append(stream)
map_bbox = entities_bbox(entities)
# 노선·측점·구조물은 배경 위에 얹는다 — 아래에 깔리면 등고선에 묻힌다.
if with_route:
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)
if with_station and stations:
entities.extend(_station_entities(drawing_id, stations, interval_m, paper))
if with_structure:
entities.extend(
_structure_entities(drawing_id, structures, paper, (min_x, min_y, max_x, max_y))
)
# 방위표는 지형 오른쪽 칸 맨 위에 둔다(유역도와 같은 자리).
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(CONTOUR_LAYER_ID, "등고선", locked=True),
_layer(STREAM_LAYER_ID, "계류", locked=True),
_layer(ROUTE_LAYER_ID, "계획노선"),
_layer(STATION_LAYER_ID, "측점"),
_layer(STRUCTURE_LAYER_ID, "구조물"),
_layer(TITLE_LAYER_ID, "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}
@@ -84,72 +84,85 @@ def section_block_size(
return (width, top + below) return (width, top + below)
def _pack( def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]:
blocks: list[tuple[int, float, float]], start: int, rows: int """한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준."""
) -> tuple[int, list[float], list[float]]:
"""blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다.
열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을
전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정).
"""
usable_w, usable_h = usable_area() usable_w, usable_h = usable_area()
col_widths: list[float] = [] cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM
row_heights: list[float] = [0.0] * rows cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM
count = 0 return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h)
for index, (_chainage, width, height) in enumerate(blocks[start:]):
column, row = divmod(index, rows)
current = col_widths[column] if column < len(col_widths) else 0.0
new_col = max(current, width + _BLOCK_GAP_MM)
new_row = max(row_heights[row], height + _BLOCK_GAP_MM)
if sum(col_widths[:column]) + new_col > usable_w:
break
if sum(row_heights) - row_heights[row] + new_row > usable_h:
break
if column < len(col_widths):
col_widths[column] = new_col
else:
col_widths.append(new_col)
row_heights[row] = new_row
count = index + 1
return count, col_widths, row_heights
def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]: def _max_take(blocks: list[tuple[int, float, float]], start: int) -> int:
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준)."""
limit = 0
for take in range(1, len(blocks) - start + 1):
_cw, _ch, columns, rows = _grid_for(blocks[start : start + take])
if columns * rows < take:
break
limit = take
return limit
def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]:
"""장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지).
앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로
튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면
크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수
조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다.
같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다).
"""
total = len(blocks)
best_sheets = [0] * (total + 1)
best_take = [0] * (total + 1)
for start in range(total - 1, -1, -1):
limit = max(_max_take(blocks, start), 1)
choice = (total + 1, 0)
for take in range(1, limit + 1):
candidate = (best_sheets[start + take] + 1, -take)
if candidate < choice:
choice = candidate
best_sheets[start], best_take[start] = choice[0], -choice[1]
breaks: list[int] = []
start = 0
while start < total:
breaks.append(best_take[start])
start += best_take[start]
return breaks
def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]:
"""칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열. """칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열.
세로는 중심이 아니라 **아래 변**을 준다. 수량표 높이는 모든 블록이 같으므로 칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는
아래를 맞추면 같은 행의 표가 한 줄로 선다(2026-08-30 사용자: 표는 행·열을 맞춘다). 순서는 좌하단부터 열 우선).
""" """
usable_w, usable_h = usable_area() usable_w, usable_h = usable_area()
rows = len(row_heights)
slots: list[list[float]] = [] slots: list[list[float]] = []
for index in range(count): for index in range(count):
column, row = divmod(index, rows) column, row = divmod(index, rows)
x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0 slots.append(
y = -usable_h / 2.0 + sum(row_heights[:row]) [
slots.append([x, y]) -usable_w / 2.0 + column * cell_w + cell_w / 2.0,
-usable_h / 2.0 + row * cell_h,
]
)
return slots return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다. """(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에 한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다). 작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB
「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다
(2026-09-04 사용자 확정).
""" """
sheets: list[dict[str, Any]] = [] sheets: list[dict[str, Any]] = []
start = 0 start = 0
while start < len(blocks): for count in _sheet_breaks(blocks):
best: tuple[int, list[float], list[float]] = (0, [], [])
for rows in range(1, len(blocks) - start + 1):
packed = _pack(blocks, start, rows)
if packed[0] > best[0]:
best = packed
count, col_widths, row_heights = best
if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다.
count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]]
group = blocks[start : start + count] group = blocks[start : start + count]
number = len(sheets) + 1 cell_w, cell_h, _columns, rows = _grid_for(group)
chainages = [chainage for chainage, _w, _h in group] chainages = [chainage for chainage, _w, _h in group]
sheets.append( sheets.append(
{ {
@@ -157,10 +170,12 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m", "id": f"cross_s{chainages[0]:05d}m",
"number": number, "number": len(sheets) + 1,
"chainages": chainages, "chainages": chainages,
"rows": len(row_heights), "rows": max(rows, 1),
"slots": _slots(col_widths, row_heights, count), "cell_width": cell_w,
"cell_height": cell_h,
"slots": _slots(cell_w, cell_h, max(rows, 1), count),
} }
) )
start += count start += count
@@ -172,6 +187,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
entities: list[dict[str, Any]] = [] entities: list[dict[str, Any]] = []
placements: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = []
slots = sheet.get("slots") or [] slots = sheet.get("slots") or []
cell_w = float(sheet.get("cell_width") or 0.0)
cell_h = float(sheet.get("cell_height") or 0.0)
for index, section in enumerate(sections): for index, section in enumerate(sections):
if index >= len(slots): if index >= len(slots):
@@ -197,6 +214,16 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
center_x - (min_x + max_x) / 2.0, center_x - (min_x + max_x) / 2.0,
bottom_y + _BLOCK_GAP_MM / 2.0 - min_y, bottom_y + _BLOCK_GAP_MM / 2.0 - min_y,
) )
# 3) 테두리는 칸 크기로 통일한다 — 단면 크기와 무관하게 한 장 안에서 같은 크기.
cell_frame = None
if cell_w > 0.0 and cell_h > 0.0:
half = (cell_w - _BLOCK_GAP_MM) / 2.0
cell_frame = (
center_x - half,
bottom_y + _BLOCK_GAP_MM / 2.0,
center_x + half,
bottom_y + cell_h - _BLOCK_GAP_MM / 2.0,
)
placed = build_cross_drawing( placed = build_cross_drawing(
section["source"], section["source"],
seed_id, seed_id,
@@ -205,6 +232,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("quantity_table"), section.get("quantity_table"),
section.get("title", ""), section.get("title", ""),
origin=origin, origin=origin,
cell_frame=cell_frame,
) )
entities.extend(placed["entities"]) entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or []) placements.extend(placed.get("cross_placements") or [])
@@ -0,0 +1,581 @@
"""B07 표준 횡단면도 CAD 조립 — 변수 모식도에 치수를 넣고, 측구 확대도·암반선 2단을 함께 낸다.
사용자 지시(2026-09-04) — 「표준 횡단면도 좌상단에 기본값으로 B06 좌측 패널의 변수 위치
안내와 비슷한 그림을 넣고, 변수 이름 자리에 도면처럼 치수를 적을 것. 측구는 작으니 부분
확대도로. 발파·암반이면 암반선과 각도를 넣어 절토측이 2단(토사 각도 + 암반 각도)으로
표현될 것」.
배치는 B06 좌측 패널 모식도(`B06_Section_UI_Standard_Diagram.ts`)와 같다 — 좌가 절토·측구,
우가 성토, 가운데가 계획고. 다른 점은 **실치수**라는 것이다. 모식도는 위치 안내라 비율이
없지만 도면은 축척(본 그림 1/50, 측구 확대도 1/10)대로 그리고 치수선을 붙인다.
값은 B06 「표준 횡단면 설정」(`standard_cross_section`)을 그대로 읽는다 — 여기서 기하를
다시 정하지 않는다. 저장값이 없으면 config 기본값을 쓴다.
좌표 규약: 종이 mm = 실거리 m x MM. 원점은 계획고(노면 중심).
"""
import math
from typing import Any
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_Template import (
entities_bbox,
frame_entities,
scale_fields,
)
from config.config_system import (
DRAWING_SCALE_CROSS_STANDARD,
DRAWING_SCALE_DITCH_DETAIL,
STANDARD_CROSS_SECTION,
)
STANDARD_KIND = "cross_standard"
STANDARD_LABEL = "표준 횡단면도"
# 도면 좌표 = 종이 mm. 본 그림 1/50 -> 1 m = 20 mm, 측구 확대도 1/10 -> 1 m = 100 mm.
MM = 1000.0 / DRAWING_SCALE_CROSS_STANDARD
DETAIL_MM = 1000.0 / DRAWING_SCALE_DITCH_DETAIL
SECTION_LAYER_ID = "b07-std-section"
ROCK_LAYER_ID = "b07-std-rock"
DIM_LAYER_ID = "b07-std-dim"
DETAIL_LAYER_ID = "b07-std-detail"
NOTE_LAYER_ID = "b07-std-note"
TITLE_LAYER_ID = "b07-std-title"
SECTION_COLOR = "#111111"
ROCK_COLOR = "#a63d3d"
DIM_COLOR = "#2f6fb0"
DETAIL_COLOR = "#111111"
NOTE_COLOR = "#333333"
_LINE_WIDTH = 2
_TITLE_FONT_SIZE = 7.0
_LABEL_FONT_SIZE = 3.0
_DIM_FONT_SIZE = 2.6
_NOTE_FONT_SIZE = 2.8
# 그림에 세울 절·성토 높이(m) — 표준도는 실제 지형이 없으므로 대표 높이로 그린다.
_CUT_HEIGHT_M = 3.0
_FILL_HEIGHT_M = 3.0
# 암반 구간: 절토 밑에서 이만큼이 암반이고 그 위가 토사다(2단 절토).
_ROCK_HEIGHT_M = 1.5
_DIM_TICK_MM = 1.6 # 치수선 끝 눈금 반길이
_DIM_OFFSET_MM = 8.0 # 치수선을 그림에서 띄우는 거리
_DIM_GAP_MM = 6.0 # 치수선 단 사이
def _ground(kind: str, standard: dict[str, Any] | None) -> dict[str, Any]:
"""표준 횡단면 설정에서 한 지반유형 값을 꺼낸다. 없으면 config 기본값."""
stored = (standard or {}).get(kind)
if isinstance(stored, dict) and stored:
merged = dict(STANDARD_CROSS_SECTION.get(kind) or {})
merged.update(stored)
return merged
return dict(STANDARD_CROSS_SECTION.get(kind) or {})
def _number(value: Any, fallback: float) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
def _dim_entities(
drawing_id: str,
tag: str,
start: tuple[float, float],
end: tuple[float, float],
label: str,
layer_id: str = DIM_LAYER_ID,
) -> list[dict[str, Any]]:
"""치수선 한 벌(치수선 + 양끝 눈금 + 치수값). 좌표는 종이 mm."""
entities: list[dict[str, Any]] = []
line = polyline_entity(drawing_id, [start, end], layer_id, DIM_COLOR, suffix=f":dim:{tag}")
if line:
entities.append(line)
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy) or 1.0
nx, ny = -dy / length, dx / length
for index, point in enumerate((start, end)):
tick = polyline_entity(
drawing_id,
[
(point[0] - nx * _DIM_TICK_MM, point[1] - ny * _DIM_TICK_MM),
(point[0] + nx * _DIM_TICK_MM, point[1] + ny * _DIM_TICK_MM),
],
layer_id,
DIM_COLOR,
suffix=f":dim:{tag}:tick:{index}",
)
if tick:
entities.append(tick)
entities.append(
_text_entity(
f"{drawing_id}:dim:{tag}:text",
label,
(start[0] + end[0]) / 2.0 + nx * 2.2,
(start[1] + end[1]) / 2.0 + ny * 2.2,
layer_id,
_DIM_FONT_SIZE,
DIM_COLOR,
)
)
return entities
def _section_geometry(values: dict[str, Any]) -> dict[str, Any]:
"""표준 단면의 실좌표(m) 꼭짓점. 좌가 절토·측구, 우가 성토(B06 모식도와 같은 배치)."""
road = _number(values.get("road_width_m"), 3.0)
shoulder_left = _number(values.get("shoulder_left_m"), 0.5)
shoulder_right = _number(values.get("shoulder_right_m"), 0.5)
ditch = values.get("ditch") if isinstance(values.get("ditch"), dict) else {}
top_width = _number(ditch.get("top_width_m"), 0.9)
bottom_width = _number(ditch.get("bottom_width_m"), 0.3)
depth = _number(ditch.get("depth_m"), 0.3)
slope = values.get("cross_slope_pct") if isinstance(values.get("cross_slope_pct"), dict) else {}
cross_pct = _number(slope.get("max"), _number(slope.get("min"), 3.0))
cut_ratio = _number(values.get("cut_slope_ratio"), 1.0)
fill_ratio = _number(values.get("fill_slope_ratio"), 1.2)
road_left = -(road / 2.0 + shoulder_left)
road_right = road / 2.0 + shoulder_right
# 횡단경사는 측구(좌) 쪽으로 내려간다 — 노면 좌끝이 계획고보다 낮다.
drop = abs(road_left) * cross_pct / 100.0
surface = [(road_right, 0.0), (road_left, -drop)]
ditch_top_left = road_left - top_width
ditch_bottom_y = -drop - depth
inset = (top_width - bottom_width) / 2.0
ditch_line = [
(road_left, -drop),
(road_left - inset, ditch_bottom_y),
(ditch_top_left + inset, ditch_bottom_y),
(ditch_top_left, -drop),
]
cut_top = (ditch_top_left - _CUT_HEIGHT_M * cut_ratio, -drop + _CUT_HEIGHT_M)
fill_toe = (road_right + _FILL_HEIGHT_M * fill_ratio, -_FILL_HEIGHT_M)
return {
"road": road,
"shoulder_left": shoulder_left,
"shoulder_right": shoulder_right,
"top_width": top_width,
"bottom_width": bottom_width,
"depth": depth,
"cross_pct": cross_pct,
"cut_ratio": cut_ratio,
"fill_ratio": fill_ratio,
"road_left": road_left,
"road_right": road_right,
"drop": drop,
"surface": surface,
"ditch_line": ditch_line,
"ditch_top_left": ditch_top_left,
"cut_start": (ditch_top_left, -drop),
"cut_top": cut_top,
"fill_toe": fill_toe,
}
def _rock_entities(
drawing_id: str, geometry: dict[str, Any], rock_ratio: float, paper: Any
) -> list[dict[str, Any]]:
"""암반선과 2단 절토(아래=암반각, 위=토사각)를 절토측에 덧그린다."""
entities: list[dict[str, Any]] = []
start_x, start_y = geometry["cut_start"]
soil_ratio = geometry["cut_ratio"]
# 아래 단: 암반각으로 _ROCK_HEIGHT_M 만큼 올라간다.
bench = (start_x - _ROCK_HEIGHT_M * rock_ratio, start_y + _ROCK_HEIGHT_M)
# 위 단: 그 위는 토사각.
upper = (
bench[0] - (_CUT_HEIGHT_M - _ROCK_HEIGHT_M) * soil_ratio,
bench[1] + (_CUT_HEIGHT_M - _ROCK_HEIGHT_M),
)
two_stage = polyline_entity(
drawing_id,
[paper((start_x, start_y)), paper(bench), paper(upper)],
ROCK_LAYER_ID,
ROCK_COLOR,
suffix=":rock:cut",
width=_LINE_WIDTH,
)
if two_stage:
entities.append(two_stage)
# 암반선 — 2단이 갈리는 높이의 수평 파선.
boundary = polyline_entity(
drawing_id,
[paper((bench[0] - 1.5, bench[1])), paper((geometry["road_right"], bench[1]))],
ROCK_LAYER_ID,
ROCK_COLOR,
suffix=":rock:boundary",
dash=[6, 4],
)
if boundary:
entities.append(boundary)
label_x, label_y = paper((bench[0] - 1.6, bench[1]))
entities.append(
_text_entity(
f"{drawing_id}:rock:boundary:text",
"암반선",
label_x,
label_y + 2.5,
ROCK_LAYER_ID,
_LABEL_FONT_SIZE,
ROCK_COLOR,
align="right",
)
)
mid_lower = paper(((start_x + bench[0]) / 2.0, (start_y + bench[1]) / 2.0))
mid_upper = paper(((bench[0] + upper[0]) / 2.0, (bench[1] + upper[1]) / 2.0))
entities.append(
_text_entity(
f"{drawing_id}:rock:lower",
f"암반 1:{rock_ratio:g}",
mid_lower[0] - 6.0,
mid_lower[1],
ROCK_LAYER_ID,
_DIM_FONT_SIZE,
ROCK_COLOR,
align="right",
)
)
entities.append(
_text_entity(
f"{drawing_id}:rock:upper",
f"토사 1:{soil_ratio:g}",
mid_upper[0] - 6.0,
mid_upper[1],
ROCK_LAYER_ID,
_DIM_FONT_SIZE,
ROCK_COLOR,
align="right",
)
)
return entities
def _ditch_detail_entities(
drawing_id: str, geometry: dict[str, Any], origin: tuple[float, float]
) -> list[dict[str, Any]]:
"""측구 부분 확대도(1/10) — 작아서 본 그림에서는 치수를 읽을 수 없다."""
entities: list[dict[str, Any]] = []
top_width = geometry["top_width"]
bottom_width = geometry["bottom_width"]
depth = geometry["depth"]
inset = (top_width - bottom_width) / 2.0
ox, oy = origin
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * DETAIL_MM, oy + point[1] * DETAIL_MM)
shape = [
(0.0, 0.0),
(inset, -depth),
(inset + bottom_width, -depth),
(top_width, 0.0),
]
outline = polyline_entity(
drawing_id,
[paper(point) for point in shape],
DETAIL_LAYER_ID,
DETAIL_COLOR,
suffix=":detail:ditch",
width=_LINE_WIDTH,
)
if outline:
entities.append(outline)
entities.extend(
_dim_entities(
drawing_id,
"detail-top",
paper((0.0, 0.0 + 0.06)),
paper((top_width, 0.0 + 0.06)),
f"{top_width * 1000:.0f}",
DETAIL_LAYER_ID,
)
)
entities.extend(
_dim_entities(
drawing_id,
"detail-bottom",
paper((inset, -depth - 0.06)),
paper((inset + bottom_width, -depth - 0.06)),
f"{bottom_width * 1000:.0f}",
DETAIL_LAYER_ID,
)
)
entities.extend(
_dim_entities(
drawing_id,
"detail-depth",
paper((top_width + 0.08, 0.0)),
paper((top_width + 0.08, -depth)),
f"{depth * 1000:.0f}",
DETAIL_LAYER_ID,
)
)
title = paper((top_width / 2.0, 0.3))
entities.append(
_text_entity(
f"{drawing_id}:detail:title",
f"측구 상세도 (S = 1/{DRAWING_SCALE_DITCH_DETAIL})",
title[0],
title[1],
DETAIL_LAYER_ID,
_LABEL_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
return entities
def build_standard_cross_drawing(
drawing_id: str, label: str, standard: dict[str, Any] | None = None
) -> dict[str, Any]:
"""표준 횡단면도 한 장을 만든다 — 본 그림 + 치수 + 측구 확대도 + 암반 2단 + 주기."""
soil = _ground("soil", standard)
rock = _ground("rock", standard)
paved = _ground("paved", standard)
geometry = _section_geometry(soil)
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * MM, point[1] * MM)
entities: list[dict[str, Any]] = []
# 본 그림: 절토면 - 측구 - 노면 - 성토면을 한 줄로 잇는다.
outline = [
geometry["cut_top"],
*geometry["ditch_line"][::-1],
*geometry["surface"][::-1],
geometry["fill_toe"],
]
body = polyline_entity(
drawing_id,
[paper(point) for point in outline],
SECTION_LAYER_ID,
SECTION_COLOR,
suffix=":section",
width=_LINE_WIDTH,
)
if body:
entities.append(body)
# 중심선(계획고).
center = polyline_entity(
drawing_id,
[paper((0.0, 1.2)), paper((0.0, -1.2))],
SECTION_LAYER_ID,
SECTION_COLOR,
suffix=":center",
dash=[10, 3, 2, 3],
)
if center:
entities.append(center)
entities.append(
_text_entity(
f"{drawing_id}:center:text",
"계획고",
*paper((0.0, 1.45)),
SECTION_LAYER_ID,
_LABEL_FONT_SIZE,
SECTION_COLOR,
)
)
# 치수선 — 노면 아래 두 단(위: 노견·노폭·노견, 아래: 노면 전폭).
base_y = min(geometry["fill_toe"][1], -geometry["drop"] - geometry["depth"])
dim_y = base_y * MM - _DIM_OFFSET_MM
half = geometry["road"] / 2.0
entities.extend(
_dim_entities(
drawing_id,
"shoulder-left",
(geometry["road_left"] * MM, dim_y),
(-half * MM, dim_y),
f"{geometry['shoulder_left'] * 1000:.0f}",
)
)
entities.extend(
_dim_entities(
drawing_id,
"road",
(-half * MM, dim_y),
(half * MM, dim_y),
f"{geometry['road'] * 1000:.0f}",
)
)
entities.extend(
_dim_entities(
drawing_id,
"shoulder-right",
(half * MM, dim_y),
(geometry["road_right"] * MM, dim_y),
f"{geometry['shoulder_right'] * 1000:.0f}",
)
)
entities.extend(
_dim_entities(
drawing_id,
"ditch-top",
(geometry["ditch_top_left"] * MM, dim_y),
(geometry["road_left"] * MM, dim_y),
f"{geometry['top_width'] * 1000:.0f}",
)
)
roadbed_width_m = geometry["road"] + geometry["shoulder_left"] + geometry["shoulder_right"]
entities.extend(
_dim_entities(
drawing_id,
"roadbed",
(geometry["road_left"] * MM, dim_y - _DIM_GAP_MM),
(geometry["road_right"] * MM, dim_y - _DIM_GAP_MM),
f"{roadbed_width_m * 1000:.0f}",
)
)
# 경사·횡단경사 표기.
cut_mid = paper(
(
(geometry["cut_start"][0] + geometry["cut_top"][0]) / 2.0,
(geometry["cut_start"][1] + geometry["cut_top"][1]) / 2.0,
)
)
fill_mid = paper(
(
(geometry["road_right"] + geometry["fill_toe"][0]) / 2.0,
(0.0 + geometry["fill_toe"][1]) / 2.0,
)
)
entities.append(
_text_entity(
f"{drawing_id}:cut:text",
f"절토 1:{geometry['cut_ratio']:g}",
cut_mid[0] - 4.0,
cut_mid[1] + 3.0,
SECTION_LAYER_ID,
_DIM_FONT_SIZE,
SECTION_COLOR,
align="right",
)
)
entities.append(
_text_entity(
f"{drawing_id}:fill:text",
f"성토 1:{geometry['fill_ratio']:g}",
fill_mid[0] + 4.0,
fill_mid[1] + 3.0,
SECTION_LAYER_ID,
_DIM_FONT_SIZE,
SECTION_COLOR,
align="left",
)
)
entities.append(
_text_entity(
f"{drawing_id}:cross-slope:text",
f"횡단경사 {geometry['cross_pct']:g}%",
*paper((geometry["road_left"] / 2.0, 0.6)),
SECTION_LAYER_ID,
_DIM_FONT_SIZE,
SECTION_COLOR,
)
)
# 암반 구간 2단 절토.
entities.extend(
_rock_entities(drawing_id, geometry, _number(rock.get("cut_slope_ratio"), 0.4), paper)
)
body_bbox = entities_bbox(entities)
right = body_bbox[2] if body_bbox else 0.0
top = body_bbox[3] if body_bbox else 0.0
# 측구 부분 확대도 — 본 그림 오른쪽 위.
entities.extend(_ditch_detail_entities(drawing_id, geometry, (right + 28.0, top - 30.0)))
# 주기: 구간별로 달라지는 값만 적는다(기본은 토사).
notes = [
"※ 본 그림은 토사 구간 기준임.",
f"※ 암 구간 — 절토 1:{_number(rock.get('cut_slope_ratio'), 0.4):g}, "
f"L형 측구 {_number((rock.get('ditch_l_type') or {}).get('width_m'), 0.5) * 1000:.0f}"
f"×{_number((rock.get('ditch_l_type') or {}).get('depth_m'), 0.1) * 1000:.0f}mm "
"(횡단면도에서 일반·L형 중 선택).",
f"※ 포장 구간 — 절·성토 경사는 토사와 같고 횡단경사만 "
f"{_number((paved.get('cross_slope_pct') or {}).get('min'), 1.5):g}~"
f"{_number((paved.get('cross_slope_pct') or {}).get('max'), 2.0):g}% 임.",
"※ 치수 단위 mm.",
]
note_bbox = entities_bbox(entities)
note_x = note_bbox[0] if note_bbox else 0.0
note_y = (note_bbox[1] if note_bbox else 0.0) - 12.0
for index, note in enumerate(notes):
entities.append(
_text_entity(
f"{drawing_id}:note:{index}",
note,
note_x,
note_y - index * 5.0,
NOTE_LAYER_ID,
_NOTE_FONT_SIZE,
NOTE_COLOR,
align="left",
)
)
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_CROSS_STANDARD}",
max_bx,
max_by + 5.0,
TITLE_LAYER_ID,
_DIM_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_CROSS_STANDARD)),
},
)
)
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
_layer(SECTION_LAYER_ID, "표준 단면"),
_layer(ROCK_LAYER_ID, "암반선·2단 절토"),
_layer(DIM_LAYER_ID, "치수"),
_layer(DETAIL_LAYER_ID, "측구 상세도"),
_layer(NOTE_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): if isinstance(p, dict):
xs.append(float(p["x"])) xs.append(float(p["x"]))
ys.append(float(p["y"])) 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") center = shape.get("center")
if isinstance(center, dict): if isinstance(center, dict):
r = float(shape.get("radius", 0.0)) r = float(shape.get("radius", 0.0))
@@ -17,6 +17,7 @@ from B06_Section.B06_Section_Repository import (
get_cross_section_design, get_cross_section_design,
get_cross_section_designs, get_cross_section_designs,
get_longitudinal_section, get_longitudinal_section,
get_project_standard_cross_section,
merge_cross_section_design_by_round, merge_cross_section_design_by_round,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
@@ -34,14 +35,22 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_title_fields, use_title_fields,
) )
from B07_DesignDetail.B07_DesignDetail_Router_Support import ( from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
LIDAR_ID,
MASS_HAUL_ID, MASS_HAUL_ID,
PLAN_ID,
WATERSHED_ID, WATERSHED_ID,
_cross_sheet_plan, _cross_sheet_plan,
_drawing_list, _drawing_list,
_invalidate_drawing, _invalidate_drawing,
_read_drawing, _read_drawing,
_read_json,
_recompute_confirmed_design, _recompute_confirmed_design,
_store_confirmed_drawing, _store_confirmed_drawing,
landuse_source,
lidar_source,
plan_source,
watershed_source, watershed_source,
) )
from B07_DesignDetail.B07_DesignDetail_Schema import ( from B07_DesignDetail.B07_DesignDetail_Schema import (
@@ -300,6 +309,44 @@ async def get_design_drawing(
if context is None: if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason}) return JSONResponse(status_code=404, content={"status": "error", "message": reason})
source_design = await asyncio.to_thread(watershed_source, context) source_design = await asyncio.to_thread(watershed_source, context)
elif drawing_id == CROSS_STANDARD_ID:
# 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값).
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL",
(str(project_id),),
)
row = await cursor.fetchone()
source_design = (
await get_project_standard_cross_section(connection, int(row[0]), project_id)
if row
else None
)
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)
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(
landuse_source, context, longitudinal, drawing_id
)
elif PLAN_ID.fullmatch(drawing_id):
# 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다.
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(plan_source, context, longitudinal, drawing_id)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id, source_design _read_drawing, project_root, longitudinal_path, drawing_id, source_design
) )
@@ -21,56 +21,62 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import (
build_blank_drawing, build_blank_drawing,
build_cover_drawing, build_cover_drawing,
) )
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 ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing, build_longitudinal_drawing,
longitudinal_chunks, longitudinal_chunks,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
PLAN_KINDS,
build_plan_drawing,
plan_chunks,
plan_drawing_id,
plan_drawing_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
CROSS_SHEET_ID, CROSS_SHEET_ID,
build_cross_sheet, build_cross_sheet,
plan_cross_sheets, plan_cross_sheets,
section_block_size, section_block_size,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
STANDARD_LABEL,
build_standard_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS, QUANTITY_VALUE_KEYS,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_BASIN_MAX_DISTANCE_M as _BASIN_MAX_DISTANCE_M,
)
# 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04).
# 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다. # 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다.
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401
CONTOUR_FILE as CONTOUR_FILE, _BASIN_MAX_DISTANCE_M,
) CONTOUR_FILE,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( LANDUSE_ID,
STREAM_FILE as STREAM_FILE, LIDAR_ID,
) PLAN_ID,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( STREAM_FILE,
_basins_crs as _basins_crs, _basins_crs,
) _clip_segment,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _geojson_features,
_clip_segment as _clip_segment, _geojson_payload,
) _geometry_lines,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _too_far_from_route,
_geojson_features as _geojson_features, clip_line_to_box,
) landuse_source,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( lidar_source,
_geojson_payload as _geojson_payload, plan_source,
) plan_stations,
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( watershed_source,
_geometry_lines as _geometry_lines,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_too_far_from_route as _too_far_from_route,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
clip_line_to_box as clip_line_to_box,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
watershed_source as watershed_source,
) )
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_cross_files, _cross_files,
@@ -96,18 +102,12 @@ _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
MASS_HAUL_ID = "mass_haul" MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed" WATERSHED_ID = "watershed"
COVER_ID = "cover" COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),)
("blank_plan_terrain", "계획평면도(지형)"),
("blank_plan_route", "계획평면도(노선배치도)"),
("blank_plan_layout", "계획평면도(배치도)"),
("blank_plan_lidar", "계획평면도(라이다)"),
("blank_cross_standard", "표준 횡단면도"),
("blank_standard", "표준도"),
("blank_landuse", "용지도"),
)
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
@@ -151,9 +151,49 @@ def _drawing_list(
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
) )
) )
# 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다).
plan_sheets = plan_chunks(plan_stations(longitudinal))
for kind, _label, *_rest in PLAN_KINDS:
for chunk in plan_sheets:
drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets))
drawings.append(
DesignDrawingItem(
id=drawing_id,
kind="plan",
label=plan_drawing_label(kind, chunk, len(plan_sheets)),
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']}"
drawings.append(
DesignDrawingItem(
id=drawing_id,
kind="landuse",
label=LANDUSE_LABEL
if len(plan_sheets) <= 1
else f"{LANDUSE_LABEL} {chunk['number']}장",
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
)
)
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
for drawing_id, kind, label in ( for drawing_id, kind, label in (
(COVER_ID, "cover", "표지"), (COVER_ID, "cover", "표지"),
(CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL),
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"), (WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
): ):
@@ -379,8 +419,14 @@ def _read_drawing(
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
if saved.get("format") == DRAWING_FORMAT: if saved.get("format") == DRAWING_FORMAT:
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID): if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID):
kind = drawing_id # id와 kind가 같은 단장 도면 kind = drawing_id # id와 kind가 같은 단장 도면
elif PLAN_ID.fullmatch(drawing_id):
kind = "plan"
elif LANDUSE_ID.fullmatch(drawing_id):
kind = "landuse"
elif LIDAR_ID.fullmatch(drawing_id):
kind = "plan_lidar"
else: else:
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
label = str(manifest_entry.get("label") or drawing_id) label = str(manifest_entry.get("label") or drawing_id)
@@ -409,6 +455,82 @@ def _read_drawing(
None, None,
) )
if drawing_id == CROSS_STANDARD_ID:
# stored_design = B06 표준 횡단면 설정값(라우터가 실어 준다). 없으면 config 기본값.
standard = stored_design if isinstance(stored_design, dict) else None
return (
"cross_standard",
STANDARD_LABEL,
build_standard_cross_drawing(drawing_id, STANDARD_LABEL, standard),
False,
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):
raise FileNotFoundError("용지도 자료가 없습니다.")
label = str(stored_design.get("label") or LANDUSE_LABEL)
return (
"landuse",
label,
build_landuse_drawing(
drawing_id,
label,
stored_design.get("route_xy") or [],
stored_design.get("contours") or [],
stored_design.get("parcels") or [],
stored_design.get("emd_rings") or [],
stored_design.get("sgg_rings") or [],
),
False,
None,
)
if PLAN_ID.fullmatch(drawing_id):
# stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS).
if not isinstance(stored_design, dict):
raise FileNotFoundError("계획평면도 자료가 없습니다.")
longitudinal = _read_json(longitudinal_path)
interval = infer_station_interval(longitudinal.get("stations") or [])
label = str(stored_design.get("label") or drawing_id)
return (
"plan",
label,
build_plan_drawing(
str(stored_design.get("kind") or "plan_terrain"),
drawing_id,
label,
stored_design.get("route_xy") or [],
stored_design.get("stations") or [],
stored_design.get("contours") or [],
stored_design.get("streams") or [],
stored_design.get("structures") or [],
interval,
),
False,
None,
)
if drawing_id == WATERSHED_ID: if drawing_id == WATERSHED_ID:
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
if not isinstance(stored_design, dict): if not isinstance(stored_design, dict):
@@ -7,6 +7,7 @@ import json
import logging import logging
import math import math
import re import re
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,33 +15,21 @@ from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE 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_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,
plan_drawing_label,
)
from common_util.common_util_drainage_pipes import detail_basins_path from common_util.common_util_drainage_pipes import detail_basins_path
from config.config_system import DRAWING_SCALE_BASIN from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_STAGE_DIR = "B07_DesignDetail" # 도면 id 상수·빈 도면 목록은 `B07_DesignDetail_Router_Support` 한 곳이 정본이다.
# 이 모듈에 있던 같은 이름의 사본은 아무도 읽지 않으면서 값만 어긋나 지웠다(2026-09-04).
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed"
COVER_ID = "cover"
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
("blank_plan_terrain", "계획평면도(지형)"),
("blank_plan_route", "계획평면도(노선배치도)"),
("blank_plan_layout", "계획평면도(배치도)"),
("blank_plan_lidar", "계획평면도(라이다)"),
("blank_cross_standard", "표준 횡단면도"),
("blank_standard", "표준도"),
("blank_landuse", "용지도"),
)
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
def _geojson_payload(path: Path) -> dict[str, Any]: def _geojson_payload(path: Path) -> dict[str, Any]:
@@ -89,7 +78,7 @@ def _basins_crs(context: Any, payload: dict[str, Any]) -> str:
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" """LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다."""
if not isinstance(geometry, dict): if not isinstance(geometry, dict):
return [] return []
kind = geometry.get("type") kind = geometry.get("type")
@@ -104,6 +93,15 @@ def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
for part in coordinates for part in coordinates
if isinstance(part, list) if isinstance(part, list)
] ]
# 연속지적도·행정구역은 MultiPolygon 이다 — 폴리곤마다 고리를 모두 편다(2026-09-04).
if kind == "MultiPolygon":
return [
[(float(p[0]), float(p[1])) for p in ring if isinstance(p, list)]
for polygon in coordinates
if isinstance(polygon, list)
for ring in polygon
if isinstance(ring, list)
]
return [] return []
@@ -185,26 +183,423 @@ def _too_far_from_route(
return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M
@lru_cache(maxsize=8)
def _metric_lines_cached(
path_str: str, mtime_ns: int, crs: str
) -> tuple[tuple[tuple[float, float], ...], ...]:
"""도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다.
같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면
한 장 여는 데 수 초가 걸린다(등고선 수만 점). 파일이 바뀌면 mtime 이 달라져
캐시가 저절로 갈린다(`_read_template` 와 같은 방식).
"""
to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
lines: list[tuple[tuple[float, float], ...]] = []
for feature in _geojson_features(Path(path_str)):
for part in _geometry_lines(feature.get("geometry")):
converted = tuple(
(float(x), float(y))
for x, y in (to_metric.transform(point[0], point[1]) for point in part)
)
if len(converted) >= 2:
lines.append(converted)
return tuple(lines)
def _metric_lines(path: Path, crs: str) -> list[list[tuple[float, float]]]:
"""캐시된 배경 선을 쓰기 좋은 형태로 낸다. 파일이 없으면 빈 목록."""
if not path.is_file():
return []
cached = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs)
return [list(line) for line in cached]
def map_background(
project_root: Path,
crs: str,
scale: int,
area_mm: tuple[float, float],
extent_points: list[tuple[float, float]],
) -> dict[str, list[list[tuple[float, float]]]]:
"""도엽 등고선·세류선을 사업지 좌표계로 읽어 **그 도면의 도곽 크기로 절취**한다.
유역도·계획평면도·용지도가 같은 창구를 쓴다 — 자료 읽기·좌표 환산은 한 번뿐이고
(`_metric_lines` 캐시), 도면마다 다른 것은 축척과 도곽 크기뿐이다.
`extent_points` 는 그 도면의 주제(노선·유역 등) 좌표다. 도곽보다 크면 그쪽을
우선한다 — 배경만 잘리고 주제는 다 보인다.
"""
area_w_mm, area_h_mm = area_mm
half_w_m = area_w_mm / 2.0 * scale / 1000.0
half_h_m = area_h_mm / 2.0 * scale / 1000.0
if extent_points:
center_x = (min(x for x, _y in extent_points) + max(x for x, _y in extent_points)) / 2.0
center_y = (min(y for _x, y in extent_points) + max(y for _x, y in extent_points)) / 2.0
box = (
min(center_x - half_w_m, min(x for x, _y in extent_points)),
min(center_y - half_h_m, min(y for _x, y in extent_points)),
max(center_x + half_w_m, max(x for x, _y in extent_points)),
max(center_y + half_h_m, max(y for _x, y in extent_points)),
)
else:
box = (-math.inf, -math.inf, math.inf, math.inf)
sheet_dir = Path(project_root) / "B04_PreProcess" / "processed"
background: dict[str, list[list[tuple[float, float]]]] = {}
for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)):
lines: list[list[tuple[float, float]]] = []
for line in _metric_lines(sheet_dir / filename, crs):
lines.extend(clip_line_to_box(line, box))
background[key] = lines
return background
PLAN_ID = re.compile(r"^(plan_terrain|plan_route|plan_layout)(?:_(\d+))?$")
def plan_stations(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]:
"""종단 측점의 (누가거리 m, x, y) — 계획평면도 장 나눔의 유일한 기준 자료."""
stations: list[tuple[float, float, float]] = []
for station in longitudinal.get("stations") or []:
if not isinstance(station, dict):
continue
chainage = station.get("chainage_m")
x, y = station.get("center_x"), station.get("center_y")
if all(isinstance(value, (int, float)) for value in (chainage, x, y)):
stations.append((float(chainage), float(x), float(y)))
return stations
def plan_chunk_for(
longitudinal: dict[str, Any], drawing_id: str
) -> tuple[str, dict[str, Any], int]:
"""도면 id 에서 (주제, 그 장의 구간, 전체 장수)를 찾는다."""
match = PLAN_ID.fullmatch(drawing_id)
if not match:
raise ValueError("올바르지 않은 계획평면도 ID입니다.")
kind = match.group(1)
chunks = plan_chunks(plan_stations(longitudinal))
number = int(match.group(2)) if match.group(2) else 1
chunk = next((item for item in chunks if item["number"] == number), None)
if chunk is None:
raise FileNotFoundError("요청한 계획평면도 장을 찾을 수 없습니다.")
return kind, chunk, len(chunks)
def plan_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]:
"""계획평면도 한 장의 입력(노선·측점·등고선·세류선·구조물)을 사업지 CRS(m)로 모은다.
배경은 유역도와 **같은 창구**(`map_background`)를 쓴다 — 도엽 GeoJSON 읽기·좌표
환산이 캐시돼 두 도면이 자료를 나눠 쓴다(2026-09-04 사용자 지시).
장이 여럿이면 그 장의 구간(누가거리)에 드는 노선·구조물만 싣고, 배경도 그 범위로
절취한다 — 축척 1/1,200 은 고정이므로 안 들어가면 장을 나눈다.
"""
kind, chunk, total = plan_chunk_for(longitudinal, drawing_id)
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
route_xy: list[tuple[float, float]] = []
for vertex in context.vertices:
chainage = float(getattr(vertex, "chainage_m", 0.0) or 0.0)
if total > 1 and not (start_m <= chainage <= end_m):
continue
route_xy.append((vertex.x, vertex.y))
# 측점 눈금은 **종단 측점**을 쓴다 — 노선 정점은 조밀하고 누가거리가 간격의 배수가 아니다.
stations = [
station
for station in plan_stations(longitudinal)
if total <= 1 or start_m <= station[0] <= end_m
]
structures = [
structure
for structure in _plan_structures(context)
if total <= 1 or start_m <= float(structure.get("chainage_m") or -1.0) <= end_m
]
background = map_background(
Path(context.project_root),
context.crs,
DRAWING_SCALE_PLAN,
plan_area_mm(),
route_xy,
)
return {
"kind": kind,
"label": plan_drawing_label(kind, chunk, total),
"route_xy": route_xy,
"stations": stations,
"contours": background["contours"],
"streams": background["streams"],
"structures": structures,
}
def _plan_structures(context: Any) -> list[dict[str, Any]]:
"""배치도에 찍을 구조물 — B04 배수시설 정본(`pipe_points.json`)을 그대로 읽는다.
좌표는 이미 사업지 CRS(m)다(B04가 그렇게 쓴다). 없으면 빈 목록 — 배치도는 배경과
노선만으로도 열린다.
"""
path = Path(context.project_root) / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json"
points = _geojson_payload(path).get("points")
if not isinstance(points, list):
return []
return [point for point in points if isinstance(point, dict)]
LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$")
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
PARCEL_FILE = "연속지적도_bounds.geojson"
EMD_FILE = "행정구역_읍면동_bounds.geojson"
SGG_FILE = "행정구역_시군구_bounds.geojson"
def _clip_rings(
path: Path, crs: str, box: tuple[float, float, float, float]
) -> list[list[tuple[float, float]]]:
"""행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀)."""
rings: list[list[tuple[float, float]]] = []
for ring in _metric_lines(path, crs):
rings.extend(clip_line_to_box(ring, box))
return rings
def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool:
"""점이 고리 안에 드는지 (반직선 교차 판정)."""
x, y = point
inside = False
for index in range(len(ring)):
x1, y1 = ring[index - 1]
x2, y2 = ring[index]
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1:
inside = not inside
return inside
def _clip_parcels(
path: Path, crs: str, box: tuple[float, float, float, float]
) -> list[dict[str, Any]]:
"""연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함).
필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 —
피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다.
두 가지를 함께 낸다.
- `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면
산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm).
- `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이
들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측:
용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감).
"""
if not path.is_file():
return []
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
min_x, min_y, max_x, max_y = box
center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
parcels: list[dict[str, Any]] = []
for feature in _geojson_features(path):
properties = feature.get("properties") or {}
for ring in _geometry_lines(feature.get("geometry")):
converted = [
(float(x), float(y))
for x, y in (transformer.transform(point[0], point[1]) for point in ring)
]
if len(converted) < 3:
continue
if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x:
continue
if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y:
continue
parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2]
for part in parts:
parcels.append({"ring": part, "props": properties})
if not parts and _contains(converted, center):
parcels.append({"ring": [], "props": properties, "label_at": center})
return parcels
def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]:
"""용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다.
축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다.
"""
match = LANDUSE_ID.fullmatch(drawing_id)
if not match:
raise ValueError("올바르지 않은 용지도 ID입니다.")
chunks = plan_chunks(plan_stations(longitudinal))
number = int(match.group(1)) if match.group(1) else 1
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
]
background = map_background(
Path(context.project_root),
context.crs,
DRAWING_SCALE_PLAN,
plan_area_mm(),
route_xy,
)
# 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다.
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
box = (
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)),
)
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장"
return {
"label": label,
"route_xy": route_xy,
"contours": background["contours"],
"parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box),
"emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box),
"sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box),
}
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]: def watershed_source(context: Any) -> dict[str, Any]:
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 —
노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다(그 환산은
그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 CSV의 EPSG `map_background()` 안에 있다). **세부유역은 그 파일을 쓸 때 쓴 좌표계**로 돌린다 —
라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01). 좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래
미터 좌표가 나온다(2026-09-01).
""" """
basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True)
to_basin_metric = Transformer.from_crs( to_basin_metric = Transformer.from_crs(
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
) )
def metric(point: tuple[float, float]) -> tuple[float, float]:
x, y = to_metric.transform(point[0], point[1])
return (float(x), float(y))
def basin_metric(point: tuple[float, float]) -> tuple[float, float]: def basin_metric(point: tuple[float, float]) -> tuple[float, float]:
x, y = to_basin_metric.transform(point[0], point[1]) x, y = to_basin_metric.transform(point[0], point[1])
return (float(x), float(y)) return (float(x), float(y))
@@ -237,39 +632,14 @@ def watershed_source(context: Any) -> dict[str, Any]:
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
# 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다 # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
# (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기. background = map_background(
usable_w_mm, usable_h_mm = map_area_mm() Path(context.project_root),
half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 context.crs,
half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 DRAWING_SCALE_BASIN,
extent = [*route_xy, *(point for basin in basins for point in basin["ring"])] map_area_mm(),
if extent: [*route_xy, *(point for basin in basins for point in basin["ring"])],
center_x = (min(x for x, _y in extent) + max(x for x, _y in extent)) / 2.0 )
center_y = (min(y for _x, y in extent) + max(y for _x, y in extent)) / 2.0
# 노선·유역이 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다.
min_x = min(center_x - half_w_m, min(x for x, _y in extent))
max_x = max(center_x + half_w_m, max(x for x, _y in extent))
min_y = min(center_y - half_h_m, min(y for _x, y in extent))
max_y = max(center_y + half_h_m, max(y for _x, y in extent))
else:
min_x = min_y = -math.inf
max_x = max_y = math.inf
box = (min_x, min_y, max_x, max_y)
def clip(line: list[tuple[float, float]]) -> list[list[tuple[float, float]]]:
return clip_line_to_box(line, box)
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
background: dict[str, list[list[tuple[float, float]]]] = {}
for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)):
lines: list[list[tuple[float, float]]] = []
for feature in _geojson_features(sheet_dir / filename):
for part in _geometry_lines(feature.get("geometry")):
converted = [metric(point) for point in part]
if len(converted) >= 2:
lines.extend(clip(converted))
background[key] = lines
return { return {
"route_xy": route_xy, "route_xy": route_xy,
+24 -2
View File
@@ -10,7 +10,18 @@ class DesignDrawingItem(BaseModel):
id: str id: str
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] kind: Literal[
"cover",
"longitudinal",
"cross",
"mass_haul",
"watershed",
"plan",
"landuse",
"plan_lidar",
"cross_standard",
"blank",
]
label: str label: str
chainage_m: float | None = None chainage_m: float | None = None
confirmed: bool = False confirmed: bool = False
@@ -33,7 +44,18 @@ class DesignDrawingResponse(BaseModel):
route_id: int route_id: int
id: str id: str
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] kind: Literal[
"cover",
"longitudinal",
"cross",
"mass_haul",
"watershed",
"plan",
"landuse",
"plan_lidar",
"cross_standard",
"blank",
]
label: str label: str
drawing: dict[str, Any] drawing: dict[str, Any]
confirmed: bool = False confirmed: bool = False
+45 -17
View File
@@ -8,7 +8,10 @@
import { attachCollapsible } from "@ui/ui_template_collapsible"; import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import type {
CrossDesignInfo,
DesignDrawingItem,
} from "./B07_DesignDetail_Api_Fetch";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -24,19 +27,21 @@ export const DRAWING_GROUPS: readonly {
label: string; label: string;
kind?: DesignDrawingItem["kind"]; kind?: DesignDrawingItem["kind"];
blankId?: string; blankId?: string;
/** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */
idPrefix?: string;
}[] = [ }[] = [
{ label: "표지", kind: "cover" }, { label: "표지", kind: "cover" },
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, { label: "계획평면도(지형)", idPrefix: "plan_terrain" },
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, { label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, { label: "계획평면도(배치도)", idPrefix: "plan_layout" },
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, { label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
{ label: "종단면도", kind: "longitudinal" }, { label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", blankId: "blank_cross_standard" }, { label: "표준 횡단면도", kind: "cross_standard" },
{ label: "횡단면도", kind: "cross" }, { label: "횡단면도", kind: "cross" },
{ label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "토적도(유토곡선)", kind: "mass_haul" },
{ label: "유역도(배수 유역도)", kind: "watershed" }, { label: "유역도(배수 유역도)", kind: "watershed" },
{ label: "표준도", blankId: "blank_standard" }, { label: "표준도", blankId: "blank_standard" },
{ label: "용지도", blankId: "blank_landuse" }, { label: "용지도", idPrefix: "landuse" },
]; ];
/** B06 확정 산출물 기반 도면 목록 패널. */ /** B06 확정 산출물 기반 도면 목록 패널. */
@@ -64,7 +69,10 @@ export function buildDrawingSidePanel(
return panel; return panel;
} }
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const drawingButton = (
drawing: DesignDrawingItem,
label: string,
): HTMLButtonElement => {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "b07-drawing-button"; button.className = "b07-drawing-button";
@@ -81,7 +89,13 @@ export function buildDrawingSidePanel(
for (const group of DRAWING_GROUPS) { for (const group of DRAWING_GROUPS) {
const items = group.kind const items = group.kind
? drawings.filter((item) => item.kind === group.kind) ? drawings.filter((item) => item.kind === group.kind)
: drawings.filter((item) => item.id === group.blankId); : group.idPrefix
? drawings.filter(
(item) =>
item.id === group.idPrefix ||
item.id.startsWith(`${group.idPrefix}_`),
)
: drawings.filter((item) => item.id === group.blankId);
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
if (items.length <= 1) { if (items.length <= 1) {
const [drawing] = items; const [drawing] = items;
@@ -103,7 +117,8 @@ export function buildDrawingSidePanel(
const button = drawingButton(drawing, group.label); const button = drawingButton(drawing, group.label);
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank"); button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; if (drawing.kind === "blank")
button.title = "준비 중 — 도각만 표시합니다";
panel.append(button); panel.append(button);
continue; continue;
} }
@@ -125,7 +140,10 @@ export function buildDrawingSidePanel(
return panel; return panel;
} }
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = { const GROUND_TYPE_LABEL: Record<
CrossDesignInfo["ground_type"],
keyof typeof ui_locales
> = {
soil: "B06_Design_Ground_Soil", soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping", ripping_rock: "B06_Design_Ground_Ripping",
blasting_rock: "B06_Design_Ground_Blasting", blasting_rock: "B06_Design_Ground_Blasting",
@@ -147,7 +165,8 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
function ditchLabel(design: CrossDesignInfo): string { function ditchLabel(design: CrossDesignInfo): string {
const ditch = design.ditch; const ditch = design.ditch;
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
return "없음";
if (ditch.type === "l_type") if (ditch.type === "l_type")
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
@@ -183,19 +202,23 @@ export function buildDesignInfoPanel(
const heading = document.createElement("div"); const heading = document.createElement("div");
heading.className = "b07-info__heading"; heading.className = "b07-info__heading";
const stationName = document.createElement("strong"); const stationName = document.createElement("strong");
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); const scopeLabel =
scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
stationName.textContent = `${scopeLabel} ${title}`; stationName.textContent = `${scopeLabel} ${title}`;
const confirmed = design?.status === "confirmed"; const confirmed = design?.status === "confirmed";
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); badge.textContent = confirmed
? L("B07_Info_Confirmed")
: L("B07_Info_Provisional");
heading.append(stationName, badge); heading.append(stationName, badge);
panel.append(heading); panel.append(heading);
if (!design) { if (!design) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "b07-info__empty"; empty.className = "b07-info__empty";
empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); empty.textContent =
scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
panel.append(empty); panel.append(empty);
return panel; return panel;
} }
@@ -210,7 +233,9 @@ export function buildDesignInfoPanel(
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow( infoRow(
L("B07_Info_DitchSide"), L("B07_Info_DitchSide"),
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), design.ditch_side === "left"
? L("B06_Design_Ditch_Left")
: L("B06_Design_Ditch_Right"),
), ),
); );
@@ -220,7 +245,10 @@ export function buildDesignInfoPanel(
planTitle.textContent = L("B07_Info_Plan_Title"); planTitle.textContent = L("B07_Info_Plan_Title");
plan.append( plan.append(
planTitle, planTitle,
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), infoRow(
L("B07_Info_DesignElevation"),
`${design.design_elevation_m.toFixed(2)}m`,
),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
@@ -58,6 +58,9 @@ class DrainageContext:
crs: str = "EPSG:5186" crs: str = "EPSG:5186"
route_id: int | None = None route_id: int | None = None
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y) 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]: 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, crs=crs,
route_id=int(route["id"]) if route else None, route_id=int(route["id"]) if route else None,
to_lonlat=lambda x, y: transformer.transform(x, y), to_lonlat=lambda x, y: transformer.transform(x, y),
surface_params=dict(surface_params),
), ),
"", "",
) )
+7
View File
@@ -175,6 +175,13 @@ DRAWING_SCALE_MASSHAUL_H_CANDIDATES = (
DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0
DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥)
DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm)
# 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200.
# 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**.
DRAWING_SCALE_PLAN = 1200
# 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도
# 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다.
DRAWING_SCALE_CROSS_STANDARD = 50
DRAWING_SCALE_DITCH_DETAIL = 10
# 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관)
LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")