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:
2026-09-04 18:38:43 +09:00
co-authored by Claude Opus 5
parent 022908abed
commit 6dd0dec93a
8 changed files with 729 additions and 92 deletions
+43 -10
View File
@@ -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,
+2 -2
View File
@@ -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
+41 -13
View File
@@ -8,7 +8,10 @@
import { attachCollapsible } from "@ui/ui_template_collapsible"; import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import type {
CrossDesignInfo,
DesignDrawingItem,
} from "./B07_DesignDetail_Api_Fetch";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -24,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,6 +89,12 @@ 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)
: group.idPrefix
? drawings.filter(
(item) =>
item.id === group.idPrefix ||
item.id.startsWith(`${group.idPrefix}_`),
)
: drawings.filter((item) => item.id === group.blankId); : drawings.filter((item) => item.id === group.blankId);
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
if (items.length <= 1) { if (items.length <= 1) {
@@ -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`),
+3
View File
@@ -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")