feat(B07): 계획평면도 3종 — 수치등고선 배경 위 노선·측점·구조물
빈 도각이던 계획평면도(지형·노선배치도·배치도)를 실제 도면으로 만듦. - 배경 공용화: map_background() 한 창구로 도엽 등고선·세류선 읽기·좌표 환산· 도곽 절취를 모음. 유역도와 계획평면도가 같은 것을 부르고, 환산 결과는 파일 mtime 을 키로 캐시(_metric_lines_cached). - 축척 1/1,200 고정(DRAWING_SCALE_PLAN) — 지식DB 「설계제원_총괄」 측량·도면 기준. 횡단면도와 같은 원칙으로 줄이지 않고 안 들어가면 장을 나눔(plan_chunks, 종단 측점 기준·경계 측점 1개 중복). - 세 장이 같은 배경·같은 도곽 배치를 쓰고 주제만 다름. 측점 눈금은 종단 측점 좌표로 찍고, 구조물은 pipe_points.json 정본을 읽어 마름모+이름으로 표기. - 도면 목록·단건 조회에 kind="plan" 추가. 화면 목록은 id 접두어로 묶어 장이 나뉘어도 한 그룹으로 보임. 검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역 739.2x499.2, 노선 실거리 630.1x214.2 m → 종이 525.12x178.46 mm(실측 0.83333 mm/m = 1/1,200 일치), 배경 등고선 180줄이 세 장 동일, 유역도 8.5초 → 계획평면도 1.3초(캐시 적중, 파일 재읽기 없음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
|||||||
export interface DesignDrawingItem {
|
export interface DesignDrawingItem {
|
||||||
id: string;
|
id: string;
|
||||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
kind:
|
||||||
|
| "cover"
|
||||||
|
| "longitudinal"
|
||||||
|
| "cross"
|
||||||
|
| "mass_haul"
|
||||||
|
| "watershed"
|
||||||
|
| "plan"
|
||||||
|
| "blank";
|
||||||
label: string;
|
label: string;
|
||||||
chainage_m: number | null;
|
chainage_m: number | null;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
@@ -67,7 +74,10 @@ export interface CrossDesignInfo {
|
|||||||
cross_slope_pct?: number;
|
cross_slope_pct?: number;
|
||||||
paved?: boolean;
|
paved?: boolean;
|
||||||
ditch: DitchSpec;
|
ditch: DitchSpec;
|
||||||
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
|
road_edges?: Record<
|
||||||
|
"left" | "right",
|
||||||
|
{ offset_m: number; elevation_m: number }
|
||||||
|
>;
|
||||||
design_elevation_m: number;
|
design_elevation_m: number;
|
||||||
cut_area_m2: number;
|
cut_area_m2: number;
|
||||||
fill_area_m2: number;
|
fill_area_m2: number;
|
||||||
@@ -80,7 +90,14 @@ export interface DesignDrawingResponse {
|
|||||||
route_id: number;
|
route_id: number;
|
||||||
id: string;
|
id: string;
|
||||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
kind:
|
||||||
|
| "cover"
|
||||||
|
| "longitudinal"
|
||||||
|
| "cross"
|
||||||
|
| "mass_haul"
|
||||||
|
| "watershed"
|
||||||
|
| "plan"
|
||||||
|
| "blank";
|
||||||
label: string;
|
label: string;
|
||||||
drawing: CadDrawing;
|
drawing: CadDrawing;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
@@ -97,7 +114,10 @@ export interface DesignDrawingConfirmResponse {
|
|||||||
design?: CrossDesignInfo | null;
|
design?: CrossDesignInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function requestJson<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
@@ -108,14 +128,17 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as T & { message?: string };
|
const payload = (await response.json()) as T & { message?: string };
|
||||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
if (!response.ok)
|
||||||
|
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||||
return payload;
|
return payload;
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
|
export function fetchDesignDrawingList(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DesignDrawingListResponse> {
|
||||||
return requestJson(`/projects/${projectId}/design-drawings`);
|
return requestJson(`/projects/${projectId}/design-drawings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +146,9 @@ export function fetchDesignDrawing(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
drawingId: string,
|
drawingId: string,
|
||||||
): Promise<DesignDrawingResponse> {
|
): Promise<DesignDrawingResponse> {
|
||||||
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
|
return requestJson(
|
||||||
|
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmDesignDrawing(
|
export function confirmDesignDrawing(
|
||||||
@@ -141,7 +166,10 @@ export function confirmDesignDrawing(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
|
export function invalidateDesignDrawing(
|
||||||
|
projectId: string,
|
||||||
|
drawingId: string,
|
||||||
|
): Promise<void> {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
@@ -157,11 +185,16 @@ export interface FrameTemplateResponse {
|
|||||||
customized: boolean;
|
customized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
|
export function fetchFrameTemplate(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<FrameTemplateResponse> {
|
||||||
return requestJson(`/projects/${projectId}/frame-template`);
|
return requestJson(`/projects/${projectId}/frame-template`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
|
export function saveFrameTemplate(
|
||||||
|
projectId: string,
|
||||||
|
drawing: CadDrawing,
|
||||||
|
): Promise<void> {
|
||||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ drawing }),
|
body: JSON.stringify({ drawing }),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -35,13 +35,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
|||||||
)
|
)
|
||||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||||
MASS_HAUL_ID,
|
MASS_HAUL_ID,
|
||||||
|
PLAN_ID,
|
||||||
WATERSHED_ID,
|
WATERSHED_ID,
|
||||||
_cross_sheet_plan,
|
_cross_sheet_plan,
|
||||||
_drawing_list,
|
_drawing_list,
|
||||||
_invalidate_drawing,
|
_invalidate_drawing,
|
||||||
_read_drawing,
|
_read_drawing,
|
||||||
|
_read_json,
|
||||||
_recompute_confirmed_design,
|
_recompute_confirmed_design,
|
||||||
_store_confirmed_drawing,
|
_store_confirmed_drawing,
|
||||||
|
plan_source,
|
||||||
watershed_source,
|
watershed_source,
|
||||||
)
|
)
|
||||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||||
@@ -300,6 +303,13 @@ async def get_design_drawing(
|
|||||||
if context is None:
|
if context is None:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||||
source_design = await asyncio.to_thread(watershed_source, context)
|
source_design = await asyncio.to_thread(watershed_source, context)
|
||||||
|
elif PLAN_ID.fullmatch(drawing_id):
|
||||||
|
# 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다.
|
||||||
|
context, reason = await load_drainage_context(project_id)
|
||||||
|
if context is None:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||||
|
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||||
|
source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id)
|
||||||
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
||||||
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
|
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
|||||||
longitudinal_chunks,
|
longitudinal_chunks,
|
||||||
)
|
)
|
||||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||||
|
PLAN_KINDS,
|
||||||
|
build_plan_drawing,
|
||||||
|
plan_chunks,
|
||||||
|
plan_drawing_id,
|
||||||
|
plan_drawing_label,
|
||||||
|
)
|
||||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||||
CROSS_SHEET_ID,
|
CROSS_SHEET_ID,
|
||||||
build_cross_sheet,
|
build_cross_sheet,
|
||||||
@@ -45,6 +52,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
|||||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||||
CONTOUR_FILE as CONTOUR_FILE,
|
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 (
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||||
STREAM_FILE as STREAM_FILE,
|
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 (
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||||
clip_line_to_box as clip_line_to_box,
|
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 (
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||||
watershed_source as watershed_source,
|
watershed_source as watershed_source,
|
||||||
)
|
)
|
||||||
@@ -100,9 +116,6 @@ COVER_ID = "cover"
|
|||||||
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||||
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
|
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
|
||||||
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
||||||
("blank_plan_terrain", "계획평면도(지형)"),
|
|
||||||
("blank_plan_route", "계획평면도(노선배치도)"),
|
|
||||||
("blank_plan_layout", "계획평면도(배치도)"),
|
|
||||||
("blank_plan_lidar", "계획평면도(라이다)"),
|
("blank_plan_lidar", "계획평면도(라이다)"),
|
||||||
("blank_cross_standard", "표준 횡단면도"),
|
("blank_cross_standard", "표준 횡단면도"),
|
||||||
("blank_standard", "표준도"),
|
("blank_standard", "표준도"),
|
||||||
@@ -151,6 +164,19 @@ def _drawing_list(
|
|||||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다).
|
||||||
|
plan_sheets = plan_chunks(plan_stations(longitudinal))
|
||||||
|
for kind, _label, *_rest in PLAN_KINDS:
|
||||||
|
for chunk in plan_sheets:
|
||||||
|
drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets))
|
||||||
|
drawings.append(
|
||||||
|
DesignDrawingItem(
|
||||||
|
id=drawing_id,
|
||||||
|
kind="plan",
|
||||||
|
label=plan_drawing_label(kind, chunk, len(plan_sheets)),
|
||||||
|
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||||
|
)
|
||||||
|
)
|
||||||
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
||||||
for drawing_id, kind, label in (
|
for drawing_id, kind, label in (
|
||||||
(COVER_ID, "cover", "표지"),
|
(COVER_ID, "cover", "표지"),
|
||||||
@@ -381,6 +407,8 @@ def _read_drawing(
|
|||||||
if saved.get("format") == DRAWING_FORMAT:
|
if saved.get("format") == DRAWING_FORMAT:
|
||||||
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID):
|
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID):
|
||||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||||
|
elif PLAN_ID.fullmatch(drawing_id):
|
||||||
|
kind = "plan"
|
||||||
else:
|
else:
|
||||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||||
label = str(manifest_entry.get("label") or drawing_id)
|
label = str(manifest_entry.get("label") or drawing_id)
|
||||||
@@ -409,6 +437,31 @@ def _read_drawing(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if PLAN_ID.fullmatch(drawing_id):
|
||||||
|
# stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS).
|
||||||
|
if not isinstance(stored_design, dict):
|
||||||
|
raise FileNotFoundError("계획평면도 자료가 없습니다.")
|
||||||
|
longitudinal = _read_json(longitudinal_path)
|
||||||
|
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||||
|
label = str(stored_design.get("label") or drawing_id)
|
||||||
|
return (
|
||||||
|
"plan",
|
||||||
|
label,
|
||||||
|
build_plan_drawing(
|
||||||
|
str(stored_design.get("kind") or "plan_terrain"),
|
||||||
|
drawing_id,
|
||||||
|
label,
|
||||||
|
stored_design.get("route_xy") or [],
|
||||||
|
stored_design.get("stations") or [],
|
||||||
|
stored_design.get("contours") or [],
|
||||||
|
stored_design.get("streams") or [],
|
||||||
|
stored_design.get("structures") or [],
|
||||||
|
interval,
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
if drawing_id == WATERSHED_ID:
|
if drawing_id == WATERSHED_ID:
|
||||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||||
if not isinstance(stored_design, dict):
|
if not isinstance(stored_design, dict):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,33 +15,19 @@ from pyproj import Transformer
|
|||||||
|
|
||||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||||
|
plan_area_mm,
|
||||||
|
plan_chunks,
|
||||||
|
plan_drawing_label,
|
||||||
|
)
|
||||||
from common_util.common_util_drainage_pipes import detail_basins_path
|
from common_util.common_util_drainage_pipes import detail_basins_path
|
||||||
from config.config_system import DRAWING_SCALE_BASIN
|
from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_STAGE_DIR = "B07_DesignDetail"
|
# 도면 id 상수·빈 도면 목록은 `B07_DesignDetail_Router_Support` 한 곳이 정본이다.
|
||||||
|
# 이 모듈에 있던 같은 이름의 사본은 아무도 읽지 않으면서 값만 어긋나 지웠다(2026-09-04).
|
||||||
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
|
||||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
|
||||||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
|
||||||
MASS_HAUL_ID = "mass_haul"
|
|
||||||
WATERSHED_ID = "watershed"
|
|
||||||
COVER_ID = "cover"
|
|
||||||
|
|
||||||
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
|
||||||
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
|
|
||||||
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
|
||||||
("blank_plan_terrain", "계획평면도(지형)"),
|
|
||||||
("blank_plan_route", "계획평면도(노선배치도)"),
|
|
||||||
("blank_plan_layout", "계획평면도(배치도)"),
|
|
||||||
("blank_plan_lidar", "계획평면도(라이다)"),
|
|
||||||
("blank_cross_standard", "표준 횡단면도"),
|
|
||||||
("blank_standard", "표준도"),
|
|
||||||
("blank_landuse", "용지도"),
|
|
||||||
)
|
|
||||||
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
|
|
||||||
|
|
||||||
|
|
||||||
def _geojson_payload(path: Path) -> dict[str, Any]:
|
def _geojson_payload(path: Path) -> dict[str, Any]:
|
||||||
@@ -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
|
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]:
|
def watershed_source(context: Any) -> dict[str, Any]:
|
||||||
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
|
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
|
||||||
|
|
||||||
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
|
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
|
||||||
|
|
||||||
되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 —
|
되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 —
|
||||||
노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은
|
노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다(그 환산은
|
||||||
그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 CSV의 EPSG
|
`map_background()` 안에 있다). **세부유역은 그 파일을 쓸 때 쓴 좌표계**로 돌린다 —
|
||||||
라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01).
|
좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래
|
||||||
|
미터 좌표가 나온다(2026-09-01).
|
||||||
"""
|
"""
|
||||||
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
|
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
|
||||||
to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True)
|
|
||||||
to_basin_metric = Transformer.from_crs(
|
to_basin_metric = Transformer.from_crs(
|
||||||
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
|
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def metric(point: tuple[float, float]) -> tuple[float, float]:
|
|
||||||
x, y = to_metric.transform(point[0], point[1])
|
|
||||||
return (float(x), float(y))
|
|
||||||
|
|
||||||
def basin_metric(point: tuple[float, float]) -> tuple[float, float]:
|
def basin_metric(point: tuple[float, float]) -> tuple[float, float]:
|
||||||
x, y = to_basin_metric.transform(point[0], point[1])
|
x, y = to_basin_metric.transform(point[0], point[1])
|
||||||
return (float(x), float(y))
|
return (float(x), float(y))
|
||||||
@@ -237,39 +384,14 @@ def watershed_source(context: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
|
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
|
||||||
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
|
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
|
||||||
# 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다
|
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
|
||||||
# (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기.
|
background = map_background(
|
||||||
usable_w_mm, usable_h_mm = map_area_mm()
|
Path(context.project_root),
|
||||||
half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
|
context.crs,
|
||||||
half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
|
DRAWING_SCALE_BASIN,
|
||||||
extent = [*route_xy, *(point for basin in basins for point in basin["ring"])]
|
map_area_mm(),
|
||||||
if extent:
|
[*route_xy, *(point for basin in basins for point in basin["ring"])],
|
||||||
center_x = (min(x for x, _y in extent) + max(x for x, _y in extent)) / 2.0
|
)
|
||||||
center_y = (min(y for _x, y in extent) + max(y for _x, y in extent)) / 2.0
|
|
||||||
# 노선·유역이 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다.
|
|
||||||
min_x = min(center_x - half_w_m, min(x for x, _y in extent))
|
|
||||||
max_x = max(center_x + half_w_m, max(x for x, _y in extent))
|
|
||||||
min_y = min(center_y - half_h_m, min(y for _x, y in extent))
|
|
||||||
max_y = max(center_y + half_h_m, max(y for _x, y in extent))
|
|
||||||
else:
|
|
||||||
min_x = min_y = -math.inf
|
|
||||||
max_x = max_y = math.inf
|
|
||||||
|
|
||||||
box = (min_x, min_y, max_x, max_y)
|
|
||||||
|
|
||||||
def clip(line: list[tuple[float, float]]) -> list[list[tuple[float, float]]]:
|
|
||||||
return clip_line_to_box(line, box)
|
|
||||||
|
|
||||||
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
|
|
||||||
background: dict[str, list[list[tuple[float, float]]]] = {}
|
|
||||||
for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)):
|
|
||||||
lines: list[list[tuple[float, float]]] = []
|
|
||||||
for feature in _geojson_features(sheet_dir / filename):
|
|
||||||
for part in _geometry_lines(feature.get("geometry")):
|
|
||||||
converted = [metric(point) for point in part]
|
|
||||||
if len(converted) >= 2:
|
|
||||||
lines.extend(clip(converted))
|
|
||||||
background[key] = lines
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"route_xy": route_xy,
|
"route_xy": route_xy,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class DesignDrawingItem(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"]
|
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"]
|
||||||
label: str
|
label: str
|
||||||
chainage_m: float | None = None
|
chainage_m: float | None = None
|
||||||
confirmed: bool = False
|
confirmed: bool = False
|
||||||
@@ -33,7 +33,7 @@ class DesignDrawingResponse(BaseModel):
|
|||||||
route_id: int
|
route_id: int
|
||||||
id: str
|
id: str
|
||||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"]
|
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"]
|
||||||
label: str
|
label: str
|
||||||
drawing: dict[str, Any]
|
drawing: dict[str, Any]
|
||||||
confirmed: bool = False
|
confirmed: bool = False
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
|
|
||||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
import type {
|
||||||
|
CrossDesignInfo,
|
||||||
|
DesignDrawingItem,
|
||||||
|
} from "./B07_DesignDetail_Api_Fetch";
|
||||||
|
|
||||||
function L(key: keyof typeof ui_locales): string {
|
function L(key: keyof typeof ui_locales): string {
|
||||||
return ui_locales[key][currentLanguageIndex];
|
return ui_locales[key][currentLanguageIndex];
|
||||||
@@ -24,11 +27,13 @@ export const DRAWING_GROUPS: readonly {
|
|||||||
label: string;
|
label: string;
|
||||||
kind?: DesignDrawingItem["kind"];
|
kind?: DesignDrawingItem["kind"];
|
||||||
blankId?: string;
|
blankId?: string;
|
||||||
|
/** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */
|
||||||
|
idPrefix?: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ label: "표지", kind: "cover" },
|
{ label: "표지", kind: "cover" },
|
||||||
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" },
|
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
|
||||||
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" },
|
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
|
||||||
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" },
|
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
|
||||||
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
|
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
|
||||||
{ label: "종단면도", kind: "longitudinal" },
|
{ label: "종단면도", kind: "longitudinal" },
|
||||||
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
|
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
|
||||||
@@ -64,7 +69,10 @@ export function buildDrawingSidePanel(
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
const drawingButton = (
|
||||||
|
drawing: DesignDrawingItem,
|
||||||
|
label: string,
|
||||||
|
): HTMLButtonElement => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "b07-drawing-button";
|
button.className = "b07-drawing-button";
|
||||||
@@ -81,7 +89,13 @@ export function buildDrawingSidePanel(
|
|||||||
for (const group of DRAWING_GROUPS) {
|
for (const group of DRAWING_GROUPS) {
|
||||||
const items = group.kind
|
const items = group.kind
|
||||||
? drawings.filter((item) => item.kind === group.kind)
|
? drawings.filter((item) => item.kind === group.kind)
|
||||||
: drawings.filter((item) => item.id === group.blankId);
|
: group.idPrefix
|
||||||
|
? drawings.filter(
|
||||||
|
(item) =>
|
||||||
|
item.id === group.idPrefix ||
|
||||||
|
item.id.startsWith(`${group.idPrefix}_`),
|
||||||
|
)
|
||||||
|
: drawings.filter((item) => item.id === group.blankId);
|
||||||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||||
if (items.length <= 1) {
|
if (items.length <= 1) {
|
||||||
const [drawing] = items;
|
const [drawing] = items;
|
||||||
@@ -103,7 +117,8 @@ export function buildDrawingSidePanel(
|
|||||||
const button = drawingButton(drawing, group.label);
|
const button = drawingButton(drawing, group.label);
|
||||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||||
button.dataset.pending = String(drawing.kind === "blank");
|
button.dataset.pending = String(drawing.kind === "blank");
|
||||||
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
|
if (drawing.kind === "blank")
|
||||||
|
button.title = "준비 중 — 도각만 표시합니다";
|
||||||
panel.append(button);
|
panel.append(button);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -125,7 +140,10 @@ export function buildDrawingSidePanel(
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
const GROUND_TYPE_LABEL: Record<
|
||||||
|
CrossDesignInfo["ground_type"],
|
||||||
|
keyof typeof ui_locales
|
||||||
|
> = {
|
||||||
soil: "B06_Design_Ground_Soil",
|
soil: "B06_Design_Ground_Soil",
|
||||||
ripping_rock: "B06_Design_Ground_Ripping",
|
ripping_rock: "B06_Design_Ground_Ripping",
|
||||||
blasting_rock: "B06_Design_Ground_Blasting",
|
blasting_rock: "B06_Design_Ground_Blasting",
|
||||||
@@ -147,7 +165,8 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
|
|||||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||||
function ditchLabel(design: CrossDesignInfo): string {
|
function ditchLabel(design: CrossDesignInfo): string {
|
||||||
const ditch = design.ditch;
|
const ditch = design.ditch;
|
||||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
|
||||||
|
return "없음";
|
||||||
if (ditch.type === "l_type")
|
if (ditch.type === "l_type")
|
||||||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||||
@@ -183,19 +202,23 @@ export function buildDesignInfoPanel(
|
|||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
heading.className = "b07-info__heading";
|
heading.className = "b07-info__heading";
|
||||||
const stationName = document.createElement("strong");
|
const stationName = document.createElement("strong");
|
||||||
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
|
const scopeLabel =
|
||||||
|
scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
|
||||||
stationName.textContent = `${scopeLabel} ${title}`;
|
stationName.textContent = `${scopeLabel} ${title}`;
|
||||||
const confirmed = design?.status === "confirmed";
|
const confirmed = design?.status === "confirmed";
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
||||||
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
|
badge.textContent = confirmed
|
||||||
|
? L("B07_Info_Confirmed")
|
||||||
|
: L("B07_Info_Provisional");
|
||||||
heading.append(stationName, badge);
|
heading.append(stationName, badge);
|
||||||
panel.append(heading);
|
panel.append(heading);
|
||||||
|
|
||||||
if (!design) {
|
if (!design) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "b07-info__empty";
|
empty.className = "b07-info__empty";
|
||||||
empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
|
empty.textContent =
|
||||||
|
scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
|
||||||
panel.append(empty);
|
panel.append(empty);
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
@@ -210,7 +233,9 @@ export function buildDesignInfoPanel(
|
|||||||
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
||||||
infoRow(
|
infoRow(
|
||||||
L("B07_Info_DitchSide"),
|
L("B07_Info_DitchSide"),
|
||||||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
design.ditch_side === "left"
|
||||||
|
? L("B06_Design_Ditch_Left")
|
||||||
|
: L("B06_Design_Ditch_Right"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,7 +245,10 @@ export function buildDesignInfoPanel(
|
|||||||
planTitle.textContent = L("B07_Info_Plan_Title");
|
planTitle.textContent = L("B07_Info_Plan_Title");
|
||||||
plan.append(
|
plan.append(
|
||||||
planTitle,
|
planTitle,
|
||||||
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
|
infoRow(
|
||||||
|
L("B07_Info_DesignElevation"),
|
||||||
|
`${design.design_elevation_m.toFixed(2)}m`,
|
||||||
|
),
|
||||||
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
||||||
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
||||||
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ DRAWING_SCALE_MASSHAUL_H_CANDIDATES = (
|
|||||||
DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0
|
DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0
|
||||||
DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥)
|
DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥)
|
||||||
DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm)
|
DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm)
|
||||||
|
# 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200.
|
||||||
|
# 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**.
|
||||||
|
DRAWING_SCALE_PLAN = 1200
|
||||||
|
|
||||||
# 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관)
|
# 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관)
|
||||||
LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")
|
LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")
|
||||||
|
|||||||
Reference in New Issue
Block a user