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 {
id: string;
// 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;
chainage_m: number | null;
confirmed: boolean;
@@ -67,7 +77,10 @@ export interface CrossDesignInfo {
cross_slope_pct?: number;
paved?: boolean;
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;
cut_area_m2: number;
fill_area_m2: number;
@@ -80,7 +93,17 @@ export interface DesignDrawingResponse {
route_id: number;
id: string;
// 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;
drawing: CadDrawing;
confirmed: boolean;
@@ -97,7 +120,10 @@ export interface DesignDrawingConfirmResponse {
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 timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
@@ -108,14 +134,17 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
signal: controller.signal,
});
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;
} finally {
window.clearTimeout(timeoutId);
}
}
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
export function fetchDesignDrawingList(
projectId: string,
): Promise<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`);
}
@@ -123,7 +152,9 @@ export function fetchDesignDrawing(
projectId: string,
drawingId: string,
): Promise<DesignDrawingResponse> {
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
);
}
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(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
{ method: "POST" },
@@ -157,11 +191,16 @@ export interface FrameTemplateResponse {
customized: boolean;
}
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
export function fetchFrameTemplate(
projectId: string,
): Promise<FrameTemplateResponse> {
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`, {
method: "PUT",
body: JSON.stringify({ drawing }),
+19 -15
View File
@@ -443,6 +443,7 @@ def build_cross_drawing(
design_elevation_m: float | None = None,
frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0),
cell_frame: tuple[float, float, float, float] | None = None,
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -450,6 +451,10 @@ def build_cross_drawing(
설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의
bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰
그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04
사용자 확정 — 축척 1/100은 그대로, 칸만 통일).
"""
ox, oy = origin
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()
# 외곽 테두리: 단면 범위와 표를 함께 감싼다.
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
frame_top = oy + half_height + 4.0
frame_bottom = table_bottom - 4.0
# 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다.
if cell_frame is not None:
frame_left, frame_bottom, frame_right, frame_top = cell_frame
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 = [
(center_x - frame_x, frame_bottom),
(center_x + frame_x, frame_bottom),
(center_x + frame_x, frame_top),
(center_x - frame_x, frame_top),
(center_x - frame_x, frame_bottom),
(frame_left, frame_bottom),
(frame_right, frame_bottom),
(frame_right, frame_top),
(frame_left, frame_top),
(frame_left, frame_bottom),
]
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border:
@@ -542,12 +551,7 @@ def build_cross_drawing(
"x1": x1,
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
"frame": [
center_x - frame_x,
frame_bottom,
center_x + frame_x,
frame_top,
],
"frame": [frame_left, frame_bottom, frame_right, frame_top],
}
],
"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)
def _pack(
blocks: list[tuple[int, float, float]], start: int, rows: int
) -> tuple[int, list[float], list[float]]:
"""blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다.
열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을
전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정).
"""
def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]:
"""한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준."""
usable_w, usable_h = usable_area()
col_widths: list[float] = []
row_heights: list[float] = [0.0] * rows
count = 0
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
cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM
cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM
return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h)
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-30 사용자: 표는 행·열을 맞춘다).
칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는
순서는 좌하단부터 열 우선).
"""
usable_w, usable_h = usable_area()
rows = len(row_heights)
slots: list[list[float]] = []
for index in range(count):
column, row = divmod(index, rows)
x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0
y = -usable_h / 2.0 + sum(row_heights[:row])
slots.append([x, y])
slots.append(
[
-usable_w / 2.0 + column * cell_w + cell_w / 2.0,
-usable_h / 2.0 + row * cell_h,
]
)
return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에
담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다).
한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB
「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다
(2026-09-04 사용자 확정).
"""
sheets: list[dict[str, Any]] = []
start = 0
while start < len(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]]
for count in _sheet_breaks(blocks):
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]
sheets.append(
{
@@ -157,10 +170,12 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m",
"number": number,
"number": len(sheets) + 1,
"chainages": chainages,
"rows": len(row_heights),
"slots": _slots(col_widths, row_heights, count),
"rows": max(rows, 1),
"cell_width": cell_w,
"cell_height": cell_h,
"slots": _slots(cell_w, cell_h, max(rows, 1), 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]] = []
placements: list[dict[str, Any]] = []
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):
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,
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(
section["source"],
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("title", ""),
origin=origin,
cell_frame=cell_frame,
)
entities.extend(placed["entities"])
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):
xs.append(float(p["x"]))
ys.append(float(p["y"]))
# 꼭짓점 배열을 쓰는 엔티티(Image·Hatch)도 범위에 넣는다 — 넣지 않으면 라이다
# 음영기복 그림이 도곽 계산에서 통째로 빠진다(2026-09-04).
for vertex in shape.get("points") or []:
if isinstance(vertex, dict) and "x" in vertex and "y" in vertex:
xs.append(float(vertex["x"]))
ys.append(float(vertex["y"]))
center = shape.get("center")
if isinstance(center, dict):
r = float(shape.get("radius", 0.0))
@@ -17,6 +17,7 @@ from B06_Section.B06_Section_Repository import (
get_cross_section_design,
get_cross_section_designs,
get_longitudinal_section,
get_project_standard_cross_section,
merge_cross_section_design_by_round,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
@@ -34,14 +35,22 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
LIDAR_ID,
MASS_HAUL_ID,
PLAN_ID,
WATERSHED_ID,
_cross_sheet_plan,
_drawing_list,
_invalidate_drawing,
_read_drawing,
_read_json,
_recompute_confirmed_design,
_store_confirmed_drawing,
landuse_source,
lidar_source,
plan_source,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Schema import (
@@ -300,6 +309,44 @@ async def get_design_drawing(
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
source_design = await asyncio.to_thread(watershed_source, context)
elif 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(
_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_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 (
build_longitudinal_drawing,
longitudinal_chunks,
)
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 (
CROSS_SHEET_ID,
build_cross_sheet,
plan_cross_sheets,
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 (
QUANTITY_VALUE_KEYS,
)
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).
# 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다.
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
CONTOUR_FILE as CONTOUR_FILE,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
STREAM_FILE as STREAM_FILE,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_basins_crs as _basins_crs,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_clip_segment as _clip_segment,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_geojson_features as _geojson_features,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_geojson_payload as _geojson_payload,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_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_Basin import ( # noqa: F401
_BASIN_MAX_DISTANCE_M,
CONTOUR_FILE,
LANDUSE_ID,
LIDAR_ID,
PLAN_ID,
STREAM_FILE,
_basins_crs,
_clip_segment,
_geojson_features,
_geojson_payload,
_geometry_lines,
_too_far_from_route,
clip_line_to_box,
landuse_source,
lidar_source,
plan_source,
plan_stations,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_cross_files,
@@ -96,18 +102,12 @@ _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed"
COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(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_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),)
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
@@ -151,9 +151,49 @@ def _drawing_list(
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로 알린다(목록에는 항상 둔다).
for drawing_id, kind, label in (
(COVER_ID, "cover", "표지"),
(CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL),
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
):
@@ -379,8 +419,14 @@ def _read_drawing(
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
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가 같은 단장 도면
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:
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
label = str(manifest_entry.get("label") or drawing_id)
@@ -409,6 +455,82 @@ def _read_drawing(
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:
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
if not isinstance(stored_design, dict):
@@ -7,6 +7,7 @@ import json
import logging
import math
import re
from functools import lru_cache
from pathlib import Path
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 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 config.config_system import DRAWING_SCALE_BASIN
from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN
logger = logging.getLogger(__name__)
_STAGE_DIR = "B07_DesignDetail"
_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)
# 도면 id 상수·빈 도면 목록은 `B07_DesignDetail_Router_Support` 한 곳이 정본이다.
# 이 모듈에 있던 같은 이름의 사본은 아무도 읽지 않으면서 값만 어긋나 지웠다(2026-09-04).
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]]]:
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
"""LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다."""
if not isinstance(geometry, dict):
return []
kind = geometry.get("type")
@@ -104,6 +93,15 @@ def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
for part in coordinates
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 []
@@ -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
@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]:
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선) 사업지 좌표계** 돌린다
노선(`context.vertices`) 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역
파일을 좌표계** 돌린다 좌표계 기록 이전 저장본은 노선 CSV의 EPSG
라벨로 쓰였고, 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01).
노선(`context.vertices`) 좌표계에 있으므로 같은 자리에 겹쳐야 한다( 환산
`map_background()` 안에 있다). **세부유역은 파일을 좌표계** 돌린다
좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 라벨로 되돌려야 원래
미터 좌표가 나온다(2026-09-01).
"""
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(
"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]:
x, y = to_basin_metric.transform(point[0], point[1])
return (float(x), float(y))
@@ -237,39 +632,14 @@ def watershed_source(context: Any) -> dict[str, Any]:
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
# 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다
# (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기.
usable_w_mm, usable_h_mm = map_area_mm()
half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
extent = [*route_xy, *(point for basin in basins for point in basin["ring"])]
if extent:
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
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
background = map_background(
Path(context.project_root),
context.crs,
DRAWING_SCALE_BASIN,
map_area_mm(),
[*route_xy, *(point for basin in basins for point in basin["ring"])],
)
return {
"route_xy": route_xy,
+24 -2
View File
@@ -10,7 +10,18 @@ class DesignDrawingItem(BaseModel):
id: str
# 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
chainage_m: float | None = None
confirmed: bool = False
@@ -33,7 +44,18 @@ class DesignDrawingResponse(BaseModel):
route_id: int
id: str
# 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
drawing: dict[str, Any]
confirmed: bool = False
+45 -17
View File
@@ -8,7 +8,10 @@
import { attachCollapsible } from "@ui/ui_template_collapsible";
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 {
return ui_locales[key][currentLanguageIndex];
@@ -24,19 +27,21 @@ export const DRAWING_GROUPS: readonly {
label: string;
kind?: DesignDrawingItem["kind"];
blankId?: string;
/** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */
idPrefix?: string;
}[] = [
{ label: "표지", kind: "cover" },
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" },
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" },
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" },
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
{ label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
{ label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
{ label: "표준 횡단면도", kind: "cross_standard" },
{ label: "횡단면도", kind: "cross" },
{ label: "토적도(유토곡선)", kind: "mass_haul" },
{ label: "유역도(배수 유역도)", kind: "watershed" },
{ label: "표준도", blankId: "blank_standard" },
{ label: "용지도", blankId: "blank_landuse" },
{ label: "용지도", idPrefix: "landuse" },
];
/** B06 확정 산출물 기반 도면 목록 패널. */
@@ -64,7 +69,10 @@ export function buildDrawingSidePanel(
return panel;
}
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const drawingButton = (
drawing: DesignDrawingItem,
label: string,
): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button";
@@ -81,7 +89,13 @@ export function buildDrawingSidePanel(
for (const group of DRAWING_GROUPS) {
const items = 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) {
const [drawing] = items;
@@ -103,7 +117,8 @@ export function buildDrawingSidePanel(
const button = drawingButton(drawing, group.label);
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
if (drawing.kind === "blank")
button.title = "준비 중 — 도각만 표시합니다";
panel.append(button);
continue;
}
@@ -125,7 +140,10 @@ export function buildDrawingSidePanel(
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",
ripping_rock: "B06_Design_Ground_Ripping",
blasting_rock: "B06_Design_Ground_Blasting",
@@ -147,7 +165,8 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
function ditchLabel(design: CrossDesignInfo): string {
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")
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`;
@@ -183,19 +202,23 @@ export function buildDesignInfoPanel(
const heading = document.createElement("div");
heading.className = "b07-info__heading";
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}`;
const confirmed = design?.status === "confirmed";
const badge = document.createElement("span");
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);
panel.append(heading);
if (!design) {
const empty = document.createElement("p");
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);
return panel;
}
@@ -210,7 +233,9 @@ export function buildDesignInfoPanel(
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
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");
plan.append(
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_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
@@ -58,6 +58,9 @@ class DrainageContext:
crs: str = "EPSG:5186"
route_id: int | None = None
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
# 1단계에서 확정한 지표면 선택(source_filter·method·smooth). B07 라이다 계획평면도가
# 어느 DTM 격자로 음영기복을 만들지 고르는 데 쓴다(2026-09-04).
surface_params: dict[str, Any] = field(default_factory=dict)
async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]:
@@ -112,6 +115,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
crs=crs,
route_id=int(route["id"]) if route else None,
to_lonlat=lambda x, y: transformer.transform(x, y),
surface_params=dict(surface_params),
),
"",
)
+7
View File
@@ -175,6 +175,13 @@ DRAWING_SCALE_MASSHAUL_H_CANDIDATES = (
DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0
DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 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_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")