diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index accd314c..2a29b156 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -5,7 +5,14 @@ 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" + | "blank"; label: string; chainage_m: number | null; confirmed: boolean; @@ -67,7 +74,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 +90,14 @@ 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" + | "blank"; label: string; drawing: CadDrawing; confirmed: boolean; @@ -97,7 +114,10 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson(path: string, init: RequestInit = {}): Promise { +async function requestJson( + path: string, + init: RequestInit = {}, +): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -108,14 +128,17 @@ async function requestJson(path: string, init: RequestInit = {}): Promise 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 { +export function fetchDesignDrawingList( + projectId: string, +): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -123,7 +146,9 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, + ); } export function confirmDesignDrawing( @@ -141,7 +166,10 @@ export function confirmDesignDrawing( ); } -export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { +export function invalidateDesignDrawing( + projectId: string, + drawingId: string, +): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, @@ -157,11 +185,16 @@ export interface FrameTemplateResponse { customized: boolean; } -export function fetchFrameTemplate(projectId: string): Promise { +export function fetchFrameTemplate( + projectId: string, +): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { +export function saveFrameTemplate( + projectId: string, + drawing: CadDrawing, +): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py new file mode 100644 index 00000000..18da7be6 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py @@ -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), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index a46bba9a..f4777153 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -35,13 +35,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( MASS_HAUL_ID, + PLAN_ID, WATERSHED_ID, _cross_sheet_plan, _drawing_list, _invalidate_drawing, _read_drawing, + _read_json, _recompute_confirmed_design, _store_confirmed_drawing, + plan_source, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( @@ -300,6 +303,13 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif 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 ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 0381742e..ef362b2a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -26,6 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( 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, @@ -45,6 +52,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( CONTOUR_FILE as CONTOUR_FILE, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + PLAN_ID as PLAN_ID, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( STREAM_FILE as STREAM_FILE, ) @@ -69,6 +79,12 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( 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 ( + plan_source as plan_source, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + plan_stations as plan_stations, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( watershed_source as watershed_source, ) @@ -100,9 +116,6 @@ 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", "표준도"), @@ -151,6 +164,19 @@ 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")), + ) + ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), @@ -381,6 +407,8 @@ def _read_drawing( if saved.get("format") == DRAWING_FORMAT: if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID): kind = drawing_id # id와 kind가 같은 단장 도면 + elif PLAN_ID.fullmatch(drawing_id): + kind = "plan" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -409,6 +437,31 @@ def _read_drawing( 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): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index f229cf17..73c52af0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -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,19 @@ 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_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]: @@ -185,26 +172,186 @@ 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)] + + 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 +384,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, diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index f88fe71f..1fc0ce6d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -10,7 +10,7 @@ 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", "blank"] label: str chainage_m: float | None = None confirmed: bool = False @@ -33,7 +33,7 @@ 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", "blank"] label: str drawing: dict[str, Any] confirmed: bool = False diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index bf045511..9e8ef8ad 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -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,11 +27,13 @@ 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: "계획평면도(지형)", idPrefix: "plan_terrain" }, + { label: "계획평면도(노선배치도)", idPrefix: "plan_route" }, + { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, { label: "표준 횡단면도", blankId: "blank_cross_standard" }, @@ -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 = { +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`), diff --git a/config/config_system.py b/config/config_system.py index 50a826ca..de4780e4 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -175,6 +175,9 @@ 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 # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")