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

This commit is contained in:
2026-10-06 18:07:00 +09:00
28 changed files with 2250 additions and 109 deletions
@@ -20,8 +20,8 @@
display: flex;
flex: 1 1 auto;
flex-direction: column;
max-width: 1200px;
height: min(820px, 92vh);
min-width: 0;
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-16, 12px);
@@ -154,7 +154,7 @@
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: min(820px, 92vh);
height: 100%;
}
@media (width < 1500px) {
@@ -19,6 +19,9 @@ export interface DesignDrawingItem {
label: string;
chainage_m: number | null;
confirmed: boolean;
/** 계획평면도만 — 종류 id · 이름(PLAN 60장). */
plan_kind?: string | null;
plan_kind_name?: string | null;
}
export interface CadDrawing {
@@ -170,6 +173,51 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
);
}
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
export interface PlanKind {
id: string;
name: string;
pieces: string[];
}
export interface PlanMark {
chainage_m: number;
refuge: boolean;
spoil: boolean;
}
export interface PlanKindsState {
pieces: { id: string; name: string }[];
kinds: PlanKind[];
marks: PlanMark[];
}
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds`);
}
export function addPlanKind(
projectId: string,
name: string,
pieces: string[],
): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds`, {
method: "POST",
body: JSON.stringify({ name, pieces }),
});
}
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
method: "DELETE",
});
}
export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-marks`, {
method: "PUT",
body: JSON.stringify({ marks }),
});
}
/** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */
export interface FrameTemplateResponse {
status: string;
@@ -0,0 +1,475 @@
"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 ·
대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중
무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음).
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200).
"""
import math
from bisect import bisect_left
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
TABLE_LABEL_COLOR,
_layer,
_text_entity,
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_COMPASS_MARGIN,
_COMPASS_SIZE,
_FONT_SIZE,
_ROUTE_WIDTH,
_TITLE_FONT_SIZE,
CONTOUR_COLOR,
MM,
STREAM_COLOR,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
entities_bbox,
frame_entities,
scale_fields,
)
from config.config_system import DRAWING_SCALE_PLAN
# 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저).
PIECES: tuple[tuple[str, str, bool], ...] = (
("lidar", "라이다 배경", True),
("contour", "등고선", True),
("stream", "계류", True),
("slope_ticks", "비탈 눈금", False),
("slope_outline", "성토사면 외곽선", False),
("centerline", "중심선", False),
("ip", "IP", False),
("radius", "R", False),
("station", "측점 NO", False),
("endpoints", "시종점", False),
("refuge", "대피소 · 사토장", False),
)
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius")
# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
{
"id": "route",
"name": "노선배치도",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints"],
},
{
"id": "layout",
"name": "대피소",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"],
},
# 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신).
{
"id": "lidar",
"name": "라이다",
"pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"],
},
)
_COLORS = {
"contour": CONTOUR_COLOR,
"stream": STREAM_COLOR,
"centerline": "#ff4d4d",
"slope_outline": "#4dd8e6",
"slope_ticks": "#ff4d4d",
"ip": "#4d7dff",
"radius": "#4d7dff",
"station": "#ffe066",
"endpoints": "#ff9d4d",
"refuge": "#7ddc7d",
}
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
_LABEL_FONT = 2.5
_MAJOR_FONT = 4.5
_TICK_MM = 1.5
_CIRCLE_MM = 1.2
def layer_id(piece: str) -> str:
return f"b07-plan-{piece}"
# ── 노선 따라가기 ─────────────────────────────────────
def route_frame(
route: list[tuple[float, float, float]], chainage: float
) -> tuple[float, float, float, float]:
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
chainages = [item[0] for item in route]
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
dx, dy = x1 - x0, y1 - y0
length = math.hypot(dx, dy) or 1.0
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
chainages = [record["chainage_m"] for record in records]
index = bisect_left(chainages, chainage)
if index <= 0:
return records[0][key]
if index >= len(records):
return records[-1][key]
before, after = records[index - 1], records[index]
span = after["chainage_m"] - before["chainage_m"]
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
return before[key] + (after[key] - before[key]) * ratio
def slope_end(
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
) -> float:
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
"""
ground = sorted(
(float(item["offset_m"]), float(item["elevation_m"]))
for item in samples
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
)
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
outward = (
[p for p in points if p[0] > edge]
if side == "left"
else [p for p in points[::-1] if p[0] < edge]
)
if not ground:
return outward[-1][0] if outward else edge
offsets = [offset for offset, _z in ground]
def ground_at(offset: float) -> float:
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
end, differs = edge, False
for offset, elevation in outward:
if abs(elevation - ground_at(offset)) <= 1e-3:
return offset if differs else end
differs, end = True, offset
return end
def slope_pieces(
route: list[tuple[float, float, float]],
records: list[dict[str, Any]],
start_m: float,
end_m: float,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다.
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
(x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로
써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다.
"""
records = sorted(records, key=lambda record: record["chainage_m"])
if len(route) < 2 or not records:
return {"lines": [], "stations": []}, {"ticks": []}
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
by_chainage = {round(record["chainage_m"], 3): record for record in records}
samples = {
round(record["chainage_m"], 3)
for record in records
if start_m <= record["chainage_m"] <= end_m
}
step = start_m
while step <= end_m:
samples.add(round(step, 3))
step += TICK_STEP_M
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
ticks: list[list[list[float]]] = []
for index, chainage in enumerate(sorted(samples)):
record = by_chainage.get(chainage)
if record and all(k in record for k in ("x", "y", "nx", "ny")):
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
else:
x, y, nx, ny = route_frame(route, chainage)
offsets = {key: _interp(records, chainage, key) for key in keys}
points = {
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
for key, value in offsets.items()
}
for key in keys:
lines[key].append(points[key])
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
for side in ("left", "right"):
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
if abs(end - edge) < 0.05:
continue
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
outline = {
"lines": [lines[key] for key in keys],
"stations": [
{key: record[key] for key in ("chainage_m", *keys)}
for record in records
if start_m <= record["chainage_m"] <= end_m
],
}
return outline, {"ticks": ticks}
def curve_pieces(
curves: list[dict[str, Any]],
route: list[tuple[float, float, float]],
box: tuple[float, float, float, float],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""곡선표에서 IP(교각점 · 접선)와 R 라벨을 만든다. 번호는 노선 전체 차례(IP1 …)."""
ips: list[dict[str, Any]] = []
radii: list[dict[str, Any]] = []
min_x, min_y, max_x, max_y = box
for number, curve in enumerate(curves, start=1):
apex, start, end = curve.get("apex"), curve.get("start"), curve.get("end")
radius = curve.get("radius_m")
if not (apex and start and end) or not isinstance(radius, (int, float)):
continue
if not (min_x <= apex[0] <= max_x and min_y <= apex[1] <= max_y):
continue
ips.append(
{
"name": f"IP{number}",
"apex": list(apex[:2]),
"start": list(start[:2]),
"end": list(end[:2]),
}
)
# R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다.
middle = min(
((x, y) for _c, x, y in route),
key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]),
default=(apex[0], apex[1]),
)
dx, dy = middle[0] - apex[0], middle[1] - apex[1]
length = math.hypot(dx, dy) or 1.0
radii.append(
{
"label": f"R={round(float(radius), 1):g}",
"at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0],
}
)
return {"items": ips}, {"items": radii}
# ── 도면 조립 ─────────────────────────────────────────
def _circle(center: tuple[float, float], radius: float) -> list[tuple[float, float]]:
return [
(center[0] + radius * math.cos(a), center[1] + radius * math.sin(a))
for a in (2 * math.pi * i / 24 for i in range(25))
]
def _reading(nx: float, ny: float) -> tuple[float, float]:
"""글자 방향 — 노선 방향(법선을 돌린 것)으로 쓰되 거꾸로 서지 않게."""
tx, ty = ny, -nx
return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty)
def _piece_entities(
drawing_id: str, piece: str, data: dict[str, Any], paper: Any
) -> list[dict[str, Any]]:
layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR)
out: list[dict[str, Any]] = []
def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None:
entity = polyline_entity(
drawing_id,
[paper(p) for p in points],
layer,
color,
suffix=suffix,
width=width,
dash=dash,
)
if entity:
out.append(entity)
def text(seed: str, label: str, at: tuple[float, float], size: float, **kw: Any) -> None:
out.append(
_text_entity(
f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw
)
)
if piece == "lidar":
image, box = data.get("image"), data.get("box")
if image and box:
left, bottom = paper((box[0], box[1]))
right, top = paper((box[2], box[3]))
corners = [(left, bottom), (right, bottom), (right, top), (left, top)]
out.append(
{
"id": f"{drawing_id}:lidar",
"type": "Image",
"lineColor": "#ffffff",
"lineWidth": 1,
"layerId": layer,
"shapeData": {
"points": [{"x": x, "y": y} for x, y in corners],
"imageData": image,
},
}
)
elif piece in ("contour", "stream", "slope_outline"):
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}")
elif piece == "centerline":
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}", width=_ROUTE_WIDTH)
elif piece == "slope_ticks":
for index, points in enumerate(data.get("ticks") or []):
line(points, f":{index}")
elif piece == "ip":
for index, item in enumerate(data.get("items") or []):
line([item["start"], item["apex"], item["end"]], f":{index}", dash=[4, 2])
apex = paper(item["apex"])
out_circle = polyline_entity(
drawing_id, _circle(apex, _CIRCLE_MM), layer, color, suffix=f":c:{index}"
)
if out_circle:
out.append(out_circle)
text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT)
elif piece == "radius":
for index, item in enumerate(data.get("items") or []):
text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT)
elif piece == "station":
for index, item in enumerate(data.get("items") or []):
cx, cy = paper((item["x"], item["y"]))
nx, ny = item["nx"], item["ny"]
line(
[
(item["x"] - nx * _TICK_MM / MM, item["y"] - ny * _TICK_MM / MM),
(item["x"] + nx * _TICK_MM / MM, item["y"] + ny * _TICK_MM / MM),
],
f":{index}",
)
if item.get("major"):
circle = polyline_entity(
drawing_id, _circle((cx, cy), _CIRCLE_MM), layer, color, suffix=f":c:{index}"
)
if circle:
out.append(circle)
gap = _MAJOR_FONT * 1.8
text(
f"{index}",
item["label"],
(cx + nx * gap, cy + ny * gap),
_MAJOR_FONT,
direction=_reading(nx, ny),
)
elif piece == "endpoints":
for index, item in enumerate(data.get("items") or []):
nx, ny = item["nx"], item["ny"]
reach = 6.0 / MM # 종이 6mm 막대
line(
[
(item["x"] - nx * reach, item["y"] - ny * reach),
(item["x"] + nx * reach, item["y"] + ny * reach),
],
f":{index}",
width=2,
)
cx, cy = paper((item["x"], item["y"]))
text(f"{index}", item["label"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5)
elif piece == "refuge":
size = 2.0 # 네모 기호 반크기(종이 mm)
for index, item in enumerate(data.get("items") or []):
x, y, half = item["x"], item["y"], size / MM
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}")
cx, cy = paper((x, y))
text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left")
return out
def build_plan_kind_drawing(
drawing_id: str,
label: str,
box: tuple[float, float, float, float],
pieces: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m)."""
min_x, min_y = box[0], box[1]
def paper(point: Any) -> tuple[float, float]:
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
entities: list[dict[str, Any]] = []
for piece in PIECE_IDS:
if piece in pieces:
entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper))
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
frame_box = (0.0, 0.0, (box[2] - min_x) * MM, (box[3] - min_y) * MM)
entities.extend(
compass_entities(
drawing_id,
(
frame_box[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0,
frame_box[3] - _COMPASS_SIZE / 2.0,
),
_COMPASS_SIZE,
)
)
bbox = entities_bbox(entities) or frame_box
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,
"b07-plan-title",
_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,
"b07-plan-title",
_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))},
)
)
layers = [
_layer(layer_id(piece), name, locked=locked)
for piece, name, locked in PIECES
if piece in pieces
]
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
*layers,
_layer("b07-plan-title", "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}
+7 -14
View File
@@ -39,9 +39,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
LIDAR_ID,
MASS_HAUL_ID,
PLAN_ID,
WATERSHED_ID,
_cross_sheet_plan,
_drawing_list,
@@ -50,10 +48,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_read_json,
_store_confirmed_drawing,
landuse_source,
lidar_source,
plan_source,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
DesignDrawingConfirmResponse,
@@ -342,13 +339,16 @@ async def get_design_drawing(
if row
else None
)
elif LIDAR_ID.fullmatch(drawing_id):
# 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다.
elif PLAN_KIND_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(lidar_source, context, longitudinal, drawing_id)
designs = await _designs_by_chainage(route_id)
source_design = await asyncio.to_thread(
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
)
elif LANDUSE_ID.fullmatch(drawing_id):
# 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다.
context, reason = await load_drainage_context(project_id)
@@ -358,13 +358,6 @@ async def get_design_drawing(
source_design = await asyncio.to_thread(
landuse_source, context, longitudinal, drawing_id
)
elif PLAN_ID.fullmatch(drawing_id):
# 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다.
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing,
project_root,
@@ -0,0 +1,98 @@
"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장).
종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다.
"""
import asyncio
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES
from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
add_kind,
delete_kind,
list_kinds,
read_marks,
write_marks,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
class PlanKindRequest(BaseModel):
name: str
pieces: list[str] = Field(default_factory=list)
class PlanMark(BaseModel):
chainage_m: float = Field(ge=0)
refuge: bool = False
spoil: bool = False
class PlanMarksRequest(BaseModel):
marks: list[PlanMark] = Field(default_factory=list)
def _state(root: Any) -> dict[str, Any]:
return {
"status": "success",
"pieces": [{"id": piece, "name": name} for piece, name, _locked in PIECES],
"kinds": list_kinds(root),
"marks": read_marks(root),
}
def _error(status: int, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)})
@router.get("/{project_id}/plan-kinds")
async def get_plan_kinds(project_id: UUID) -> Any:
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리."""
root = await _project_root(project_id)
return await asyncio.to_thread(_state, root)
@router.post("/{project_id}/plan-kinds")
async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
"""종류 더하기 — 조각을 골라 이름을 붙인다."""
try:
root = await _project_root(project_id)
await asyncio.to_thread(add_kind, root, request.name, request.pieces)
return await asyncio.to_thread(_state, root)
except ValueError as exc:
return _error(400, exc)
@router.delete("/{project_id}/plan-kinds/{kind_id}")
async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
"""종류 지우기 — 기본 넷도 지울 수 있다."""
try:
root = await _project_root(project_id)
await asyncio.to_thread(delete_kind, root, kind_id)
return await asyncio.to_thread(_state, root)
except ValueError as exc:
return _error(400, exc)
except FileNotFoundError as exc:
return _error(404, exc)
@router.put("/{project_id}/plan-marks")
async def put_plan_marks(project_id: UUID, request: PlanMarksRequest) -> Any:
"""사용자가 정한 대피소 · 사토장 자리를 통째로 갈아 쓴다."""
try:
root = await _project_root(project_id)
marks = [mark.model_dump() for mark in request.marks]
await asyncio.to_thread(write_marks, root, marks)
return await asyncio.to_thread(_state, root)
except ValueError as exc:
return _error(400, exc)
@@ -28,22 +28,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
LANDUSE_LABEL,
build_landuse_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import (
LIDAR_LABEL,
build_lidar_plan_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing,
longitudinal_chunks,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
PLAN_KINDS,
build_plan_drawing,
plan_chunks,
plan_drawing_id,
plan_drawing_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
CROSS_SHEET_ID,
build_cross_sheet,
@@ -90,6 +81,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_station_map,
_write_manifest,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
PLAN_KIND_ID,
plan_kind_items,
)
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingItem,
)
@@ -151,32 +146,16 @@ def _drawing_list(
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
)
)
# 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다).
plan_sheets = plan_chunks(plan_stations(longitudinal))
for kind, _label, *_rest in PLAN_KINDS:
for chunk in plan_sheets:
drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets))
drawings.append(
DesignDrawingItem(
id=drawing_id,
kind="plan",
label=plan_drawing_label(kind, chunk, len(plan_sheets)),
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
)
)
# 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔.
for chunk in plan_sheets:
drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}"
# 계획평면도 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가) × 장(PLAN 60장).
for entry in plan_kind_items(project_root, longitudinal):
drawings.append(
DesignDrawingItem(
id=drawing_id,
kind="plan_lidar",
label=LIDAR_LABEL
if len(plan_sheets) <= 1
else f"{LIDAR_LABEL} {chunk['number']}장",
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
**entry,
kind="plan",
confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")),
)
)
plan_sheets = plan_chunks(plan_stations(longitudinal))
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
for chunk in plan_sheets:
drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}"
@@ -427,12 +406,10 @@ def _read_drawing(
if saved.get("format") == DRAWING_FORMAT:
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID):
kind = drawing_id # id와 kind가 같은 단장 도면
elif PLAN_ID.fullmatch(drawing_id):
elif PLAN_KIND_ID.fullmatch(drawing_id):
kind = "plan"
elif LANDUSE_ID.fullmatch(drawing_id):
kind = "landuse"
elif LIDAR_ID.fullmatch(drawing_id):
kind = "plan_lidar"
else:
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
label = str(manifest_entry.get("label") or drawing_id)
@@ -468,20 +445,16 @@ def _read_drawing(
None,
)
if LIDAR_ID.fullmatch(drawing_id):
# stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림.
if PLAN_KIND_ID.fullmatch(drawing_id):
# stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것.
if not isinstance(stored_design, dict):
raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.")
label = str(stored_design.get("label") or LIDAR_LABEL)
raise FileNotFoundError("계획평면도 자료가 없습니다.")
label = str(stored_design.get("label") or drawing_id)
return (
"plan_lidar",
"plan",
label,
build_lidar_plan_drawing(
drawing_id,
label,
stored_design.get("route_xy") or [],
stored_design.get("shade_image"),
stored_design.get("shade_box"),
build_plan_kind_drawing(
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
),
False,
None,
@@ -508,31 +481,6 @@ def _read_drawing(
None,
)
if PLAN_ID.fullmatch(drawing_id):
# stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS).
if not isinstance(stored_design, dict):
raise FileNotFoundError("계획평면도 자료가 없습니다.")
longitudinal = _read_json(longitudinal_path)
interval = infer_station_interval(longitudinal.get("stations") or [])
label = str(stored_design.get("label") or drawing_id)
return (
"plan",
label,
build_plan_drawing(
str(stored_design.get("kind") or "plan_terrain"),
drawing_id,
label,
stored_design.get("route_xy") or [],
stored_design.get("stations") or [],
stored_design.get("contours") or [],
stored_design.get("streams") or [],
stored_design.get("structures") or [],
interval,
),
False,
None,
)
if drawing_id == WATERSHED_ID:
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
if not isinstance(stored_design, dict):
@@ -0,0 +1,404 @@
"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장).
저장 자리(프로젝트 저장소 `B07_DesignDetail/`):
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다.
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
"""
import re
import uuid
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import (
_load_design_curves,
cross_station,
find_cross_file,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_area_mm, plan_chunks
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
PIECE_IDS,
curve_pieces,
route_frame,
slope_end,
slope_pieces,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_chunk_route,
_hillshade_for_box,
_sheet_box,
map_background,
plan_stations,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import DRAWING_SCALE_PLAN
KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$")
PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$")
_NAME_MAX = 40
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측)
def plan_title(name: str) -> str:
return f"계획평면도({name})"
# ── 종류 ─────────────────────────────────────────────
def _kinds_dir(project_root: Path) -> Path:
return _design_root(project_root) / "plan_kinds"
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷."""
folder = _kinds_dir(project_root)
if not folder.is_dir():
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
rows: list[tuple[int, str, dict[str, Any]]] = []
for path in folder.glob("*.json"):
if not KIND_ID.fullmatch(path.stem):
continue
try:
document = _read_json(path)
except (OSError, ValueError):
continue
pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS]
rows.append(
(
int(document.get("order") or 0),
path.stem,
{"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces},
)
)
return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])]
def _materialize(project_root: Path) -> list[dict[str, Any]]:
"""기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
kinds = list_kinds(project_root)
folder = _kinds_dir(project_root)
if not folder.is_dir():
for order, kind in enumerate(kinds):
atomic_write_json(
folder / f"{kind['id']}.json",
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
)
return kinds
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
text = str(name or "").strip()
if not text or len(text) > _NAME_MAX:
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
if not chosen:
raise ValueError("얹을 정보를 하나 이상 고르세요.")
kinds = _materialize(project_root)
if any(kind["name"] == text for kind in kinds):
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
kind_id = f"k{uuid.uuid4().hex[:8]}"
atomic_write_json(
_kinds_dir(project_root) / f"{kind_id}.json",
{"name": text, "pieces": chosen, "order": len(kinds)},
)
return {"id": kind_id, "name": text, "pieces": chosen}
def delete_kind(project_root: Path, kind_id: str) -> None:
if not KIND_ID.fullmatch(kind_id):
raise ValueError("올바르지 않은 종류 id 입니다.")
_materialize(project_root)
path = _kinds_dir(project_root) / f"{kind_id}.json"
if not path.is_file():
raise FileNotFoundError("지울 종류를 찾을 수 없습니다.")
path.unlink()
# ── 대피소 · 사토장 자리(사용자 지정) ─────────────────
def _marks_path(project_root: Path) -> Path:
return _design_root(project_root) / "plan_marks.json"
def read_marks(project_root: Path) -> list[dict[str, Any]]:
path = _marks_path(project_root)
if not path.is_file():
return []
try:
marks = _read_json(path).get("marks")
except (OSError, ValueError):
return []
return [mark for mark in marks or [] if isinstance(mark, dict)]
def write_marks(project_root: Path, marks: list[dict[str, Any]]) -> list[dict[str, Any]]:
clean: list[dict[str, Any]] = []
for mark in marks:
chainage = mark.get("chainage_m")
if not isinstance(chainage, (int, float)) or chainage < 0:
raise ValueError("대피소 · 사토장 자리의 측점(m)이 올바르지 않습니다.")
refuge, spoil = bool(mark.get("refuge")), bool(mark.get("spoil"))
if refuge or spoil:
clean.append(
{"chainage_m": round(float(chainage), 2), "refuge": refuge, "spoil": spoil}
)
clean.sort(key=lambda mark: mark["chainage_m"])
atomic_write_json(_marks_path(project_root), {"marks": clean})
return clean
# ── 정보 조각 ─────────────────────────────────────────
def _station_design(
longitudinal_path: Path,
longitudinal: dict[str, Any],
source: dict[str, Any],
stored: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""그 측점의 횡단 설계 — B06 저장분 우선, 없으면 기본값으로 계산(횡단면도와 같은 규칙)."""
if isinstance(stored, dict) and stored.get("design_line") and stored.get("road_edges"):
return stored
try:
return compute_cross_design(
source.get("samples", []),
design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))),
ground_type="soil",
section_mode="left_cut",
**curve_widening_args(source),
)
except (ValueError, KeyError):
return None
def slope_records(
longitudinal_path: Path,
longitudinal: dict[str, Any],
designs: dict[int | float, dict[str, Any]],
start_m: float,
end_m: float,
) -> list[dict[str, Any]]:
"""측점별 노견 끝 · 비탈 끝 오프셋(좌 + · 우 -) — 장 범위와 앞뒤 한 측점."""
cross_dir = longitudinal_path.parent.parent / "cross_sections"
chainages = sorted(chainage for chainage, _x, _y in plan_stations(longitudinal))
inside = [i for i, c in enumerate(chainages) if start_m <= c <= end_m]
if not inside:
return []
chosen = chainages[max(inside[0] - 1, 0) : inside[-1] + 2]
records: list[dict[str, Any]] = []
for chainage in chosen:
path = find_cross_file(cross_dir, chainage)
if not path.is_file():
continue
source = _read_json(path)
design = _station_design(
longitudinal_path, longitudinal, source, designs.get(cross_station(chainage))
)
edges = (design or {}).get("road_edges") or {}
try:
left_edge = float(edges["left"]["offset_m"])
right_edge = float(edges["right"]["offset_m"])
except (KeyError, TypeError, ValueError):
continue
line, samples = (design or {}).get("design_line") or [], source.get("samples") or []
record: dict[str, Any] = {
"chainage_m": float(chainage),
"left_edge_m": left_edge,
"left_end_m": slope_end(line, samples, left_edge, "left"),
"right_edge_m": right_edge,
"right_end_m": slope_end(line, samples, right_edge, "right"),
}
frame = source.get("frame") or {}
origin, axis = frame.get("origin") or {}, frame.get("left_xy")
if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin:
record.update(x=float(origin["x"]), y=float(origin["y"]), nx=axis[0], ny=axis[1])
records.append(record)
return records
def _refuge_items(
project_root: Path, route: list[tuple[float, float, float]], start_m: float, end_m: float
) -> dict[str, Any]:
"""대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능."""
spots: dict[float, set[str]] = {}
_revision, structures = load_structures(str(project_root))
for structure in structures:
label = _REFUGE_TYPES.get(structure.type_id)
if not label:
continue
chainage = structure.chainage_m
if chainage is None and structure.start_m is not None and structure.end_m is not None:
chainage = (structure.start_m + structure.end_m) / 2.0
if chainage is not None:
spots.setdefault(round(float(chainage), 1), set()).add(label)
for mark in read_marks(project_root):
key = round(float(mark["chainage_m"]), 1)
if mark.get("refuge"):
spots.setdefault(key, set()).add("대피소")
if mark.get("spoil"):
spots.setdefault(key, set()).add("사토장")
items = []
for chainage, labels in sorted(spots.items()):
if not (start_m <= chainage <= end_m) or len(route) < 2:
continue
x, y, nx, ny = route_frame(route, chainage)
items.append(
{
"chainage_m": chainage,
"label": " · ".join(name for name in ("대피소", "사토장") if name in labels),
"x": x + nx * _REFUGE_OFFSET_M,
"y": y + ny * _REFUGE_OFFSET_M,
}
)
return {"items": items}
def build_pieces(
context: Any,
longitudinal: dict[str, Any],
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]],
number: int,
wanted: list[str],
) -> tuple[dict[str, dict[str, Any]], tuple[float, float, float, float], dict[str, Any], int]:
"""그 장의 조각들을 계산해 파일로 쓰고 (조각들, 도곽 범위, 장 구간, 전체 장수)를 낸다."""
route_xy, chunk, total = _chunk_route(context, longitudinal, number)
if not route_xy:
raise FileNotFoundError("계획평면도에 그릴 노선이 없습니다.")
box = _sheet_box(route_xy)
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
project_root = Path(context.project_root)
route = sorted(
(float(vertex.chainage_m), float(vertex.x), float(vertex.y)) for vertex in context.vertices
)
stations = plan_stations(longitudinal)
interval = infer_station_interval(longitudinal.get("stations") or [])
pieces: dict[str, dict[str, Any]] = {}
if {"contour", "stream"} & set(wanted):
background = map_background(
project_root, context.crs, DRAWING_SCALE_PLAN, plan_area_mm(), route_xy
)
pieces["contour"] = {"lines": background["contours"]}
pieces["stream"] = {"lines": background["streams"]}
if "centerline" in wanted:
pieces["centerline"] = {"lines": [route_xy]}
if {"slope_outline", "slope_ticks"} & set(wanted):
records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m)
pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces(
route, records, start_m, end_m
)
if {"ip", "radius"} & set(wanted):
pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box)
if "station" in wanted:
items = []
for chainage, x, y in stations:
if not (start_m <= chainage <= end_m):
continue
_x, _y, nx, ny = route_frame(route, chainage)
label = station_no_label(chainage, interval).replace("No.", "NO.")
number_only = "+" not in label
major = number_only and int(label[3:]) % 5 == 0
items.append(
{
"chainage_m": chainage,
"x": x,
"y": y,
"nx": nx,
"ny": ny,
"label": label,
"major": major,
}
)
pieces["station"] = {"items": items}
if "endpoints" in wanted and stations:
items = []
first, last = stations[0], stations[-1]
length = last[0] - first[0]
for (chainage, x, y), word in ((first, "시점"), (last, "종점")):
if not (start_m <= chainage <= end_m):
continue
_x, _y, nx, ny = route_frame(route, chainage)
label = f"{word} {station_no_label(chainage, interval)}"
if word == "종점":
label += f" L={length:.1f}m"
items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny})
pieces["endpoints"] = {"items": items}
if "refuge" in wanted:
pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m)
if "lidar" in wanted:
try:
image, image_box = _hillshade_for_box(context, box)
pieces["lidar"] = {"image": image, "box": list(image_box)}
except (FileNotFoundError, ValueError, OSError):
pieces["lidar"] = {"image": None, "box": None}
folder = _design_root(project_root) / "plan_pieces" / str(number)
for piece, data in pieces.items():
atomic_write_json(folder / f"{piece}.json", data)
return {piece: pieces[piece] for piece in wanted if piece in pieces}, box, chunk, total
def plan_kind_source(
context: Any,
longitudinal: dict[str, Any],
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]],
drawing_id: str,
) -> dict[str, Any]:
"""종류 도면 한 장의 입력 — 그 종류가 고른 조각 + 도곽 범위 + 도면명."""
match = PLAN_KIND_ID.fullmatch(drawing_id)
if not match:
raise ValueError("올바르지 않은 계획평면도 ID입니다.")
kind = next(
(item for item in list_kinds(Path(context.project_root)) if item["id"] == match.group(1)),
None,
)
if kind is None:
raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.")
number = int(match.group(2)) if match.group(2) else 1
pieces, box, chunk, total = build_pieces(
context, longitudinal, longitudinal_path, designs, number, kind["pieces"]
)
title = plan_title(kind["name"])
return {
"label": title if total <= 1 else f"{title} {chunk['number']}장",
"box": box,
"pieces": pieces,
}
def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
"""도면 목록에 실을 종류 × 장 [{id, label, plan_kind}]."""
sheets = plan_chunks(plan_stations(longitudinal))
items = []
for kind in list_kinds(project_root):
title = plan_title(kind["name"])
for chunk in sheets:
single = len(sheets) <= 1
items.append(
{
"id": f"plan_{kind['id']}"
if single
else f"plan_{kind['id']}_{chunk['number']}",
"label": title if single else f"{title} {chunk['number']}장",
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
)
return items
@@ -26,6 +26,9 @@ class DesignDrawingItem(BaseModel):
label: str
chainage_m: float | None = None
confirmed: bool = False
# 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다.
plan_kind: str | None = None
plan_kind_name: str | None = None
class DesignDrawingListResponse(BaseModel):
+17 -1
View File
@@ -420,7 +420,23 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
devBypass,
projectId
? {
projectId,
// 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onChanged: () => {
if (!mayDiscardEdits()) return;
cad.destroy();
void renderB07DesignDetail(root);
},
}
: undefined,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
+10 -4
View File
@@ -9,6 +9,7 @@
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -28,10 +29,8 @@ export const DRAWING_GROUPS: readonly {
idPrefix?: string;
}[] = [
{ label: "표지", kind: "cover" },
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
{ label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장.
{ label: "계획평면도", kind: "plan" },
{ label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", kind: "cross_standard" },
{ label: "횡단면도", kind: "cross" },
@@ -46,6 +45,7 @@ export function buildDrawingSidePanel(
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
plan?: PlanKindsControls,
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
@@ -89,6 +89,12 @@ export function buildDrawingSidePanel(
};
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(buildPlanSection(plans, drawingButton, plan));
continue;
}
const items = group.kind
? drawings.filter((item) => item.kind === group.kind)
: group.idPrefix
@@ -0,0 +1,196 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
addPlanKind,
deletePlanKind,
fetchPlanKinds,
savePlanMarks,
type DesignDrawingItem,
type PlanKindsState,
type PlanMark,
} from "./B07_DesignDetail_Api_Fetch";
export interface PlanKindsControls {
projectId: string;
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
onChanged: () => void;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
async function run(action: () => Promise<unknown>, controls: PlanKindsControls): Promise<void> {
try {
await action();
controls.onChanged();
} catch (error) {
showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error");
}
}
function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "종류 더하기"));
const name = el("input", "b07-plan-tools__name");
name.type = "text";
name.placeholder = "종류 이름";
name.maxLength = 40;
const boxes = state.pieces.map((piece) => {
const label = el("label", "b07-plan-tools__piece");
const box = el("input", "");
box.type = "checkbox";
box.value = piece.id;
label.append(box, document.createTextNode(piece.name));
return { label, box };
});
const pieceList = el("div", "b07-plan-tools__pieces");
pieceList.append(...boxes.map((item) => item.label));
const add = el("button", "b07-plan-tools__button", "더하기");
add.type = "button";
add.addEventListener("click", () => {
const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value);
void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls);
});
form.append(name, pieceList, add);
return form;
}
function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "대피소 · 사토장 자리"));
form.append(
el(
"p",
"b07-plan-tools__hint",
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.",
),
);
const rows = el("div", "b07-plan-tools__marks");
const readers = new Map<HTMLElement, () => PlanMark>();
const addRow = (mark: PlanMark): void => {
const row = el("div", "b07-plan-tools__mark");
const chainage = el("input", "b07-plan-tools__chainage");
chainage.type = "number";
chainage.min = "0";
chainage.step = "0.1";
chainage.value = String(mark.chainage_m);
const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => {
const label = el("label", "b07-plan-tools__piece");
const box = el("input", "");
box.type = "checkbox";
box.checked = checked;
label.append(box, document.createTextNode(text));
return [label, box];
};
const [refugeLabel, refuge] = flag("대피소", mark.refuge);
const [spoilLabel, spoil] = flag("사토장", mark.spoil);
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = "이 자리 빼기";
remove.addEventListener("click", () => {
readers.delete(row);
row.remove();
});
row.append(chainage, refugeLabel, spoilLabel, remove);
readers.set(row, () => ({
chainage_m: Number(chainage.value),
refuge: refuge.checked,
spoil: spoil.checked,
}));
rows.append(row);
};
state.marks.forEach(addRow);
const more = el("button", "b07-plan-tools__button", "자리 더하기");
more.type = "button";
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
const save = el("button", "b07-plan-tools__button", "자리 저장");
save.type = "button";
save.addEventListener("click", () => {
const marks = [...readers.values()].map((read) => read());
void run(() => savePlanMarks(controls.projectId, marks), controls);
});
const actions = el("div", "b07-plan-tools__actions");
actions.append(more, save);
form.append(rows, actions);
return form;
}
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
controls?: PlanKindsControls,
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`));
const kinds = new Map<string, DesignDrawingItem[]>();
for (const item of items) {
const key = item.plan_kind ?? item.id;
kinds.set(key, [...(kinds.get(key) ?? []), item]);
}
for (const [kindId, sheets] of kinds) {
const name = sheets[0].plan_kind_name ?? sheets[0].label;
const block = el("div", "b07-plan-kind");
block.dataset.planKind = kindId;
// 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추.
const head = el("div", "b07-plan-kind__head");
const buttons = sheets.map((drawing, index) =>
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
if (controls) {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
remove.addEventListener("click", () => {
if (
!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`)
)
return;
void run(() => deletePlanKind(controls.projectId, kindId), controls);
});
head.append(remove);
}
block.append(head, ...(sheets.length > 1 ? buttons : []));
section.append(block);
}
if (controls) {
// 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
const tools = el("details", "b07-plan-tools");
tools.append(el("summary", "", "종류 더하기 · 대피소 자리"));
tools.addEventListener("toggle", () => {
if (!tools.open || tools.dataset.loaded) return;
tools.dataset.loaded = "true";
void fetchPlanKinds(controls.projectId)
.then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls)))
.catch((error: unknown) => {
delete tools.dataset.loaded;
showToast(
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
"error",
);
});
});
section.append(tools);
}
return section;
}
@@ -234,3 +234,78 @@
}
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */
.b07-plan-kind {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.b07-plan-kind__head,
.b07-plan-tools__mark,
.b07-plan-tools__actions {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.b07-plan-kind__name {
flex: 1;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b07-plan-tools {
margin-top: var(--spacing-4);
font-size: var(--text-body-sm);
}
.b07-plan-tools summary {
color: var(--color-text-muted);
cursor: pointer;
}
.b07-plan-tools__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
}
.b07-plan-tools__pieces {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
.b07-plan-tools__piece {
display: flex;
align-items: center;
gap: var(--spacing-4);
white-space: nowrap;
}
.b07-plan-tools__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b07-plan-tools__chainage {
width: 72px;
min-width: 0;
}
.b07-plan-tools__button,
.b07-plan-tools__remove {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
.b07-plan-tools__remove {
padding: 0 var(--spacing-8);
}
+94
View File
@@ -0,0 +1,94 @@
/* =============================================================================
* B08_Quantity_Summary_Data.ts
* 구조물 집계표의 줄 · 열 고르기(화면 없음 · PLAN 61-1).
*
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
*
* 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다.
* 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림.
* ========================================================================== */
import {
readGroupItems,
readSummaryItem,
type SummaryItem,
} from "../common_util/common_util_summary_item";
/** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */
export interface PlacedItem {
chainage_m?: number | null;
start_m?: number | null;
summary_item?: unknown;
group_items?: unknown;
}
/** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */
export interface ComboLabel {
group: string;
name: string;
}
export interface ComboColumn {
/** `{층}|{주인}|{키}` — 항목 고르기 `rowId` 와 같은 꼴 */
id: string;
item: SummaryItem;
group: string;
name: string;
/** M02 에 그 조합이 없음 */
missing: boolean;
}
export const comboId = (item: SummaryItem): string =>
`${item.layer}|${item.owner ?? ""}|${item.key}`;
function stationOf(thing: PlacedItem): number | null {
const value = thing.chainage_m ?? thing.start_m;
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/** 놓인 측점 — 1cm 안이면 같은 측점 · 오름차순. */
export function summaryStations(things: readonly PlacedItem[]): number[] {
const seen = new Map<number, number>();
for (const thing of things) {
const station = stationOf(thing);
if (station !== null) seen.set(Math.round(station * 100), station);
}
return [...seen.values()].sort((a, b) => a - b);
}
/** 저장된 조합 열 — 구분 · 이름 차례(같은 구분은 붙어 머리 칸을 합침). */
export function summaryColumns(
things: readonly PlacedItem[],
describe: (item: SummaryItem) => ComboLabel | null,
): ComboColumn[] {
const found = new Map<string, SummaryItem>();
for (const thing of things) {
const own = readSummaryItem(thing.summary_item);
for (const item of [...(own ? [own] : []), ...Object.values(readGroupItems(thing.group_items))])
if (!found.has(comboId(item))) found.set(comboId(item), item);
}
const columns = [...found].map(([id, item]): ComboColumn => {
const label = describe(item);
return label
? { id, item, ...label, missing: false }
: { id, item, group: "", name: item.name || item.key, missing: true };
});
return columns.sort(
(a, b) =>
a.group.localeCompare(b.group, "ko") ||
a.name.localeCompare(b.name, "ko") ||
a.id.localeCompare(b.id),
);
}
/** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */
export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> {
const spans: Array<[string, number]> = [];
for (const column of columns) {
const last = spans[spans.length - 1];
if (last && last[0] === column.group) last[1] += 1;
else spans.push([column.group, 1]);
}
return spans;
}
+69
View File
@@ -0,0 +1,69 @@
/* B08 수량산출 — 위 탭 · 구조물 집계표(PLAN 61-1). 색은 테마 변수만(밝음 · 어두움 같이). */
.b08-quantity {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.b08-quantity__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* 집계표 — 칸마다 테두리 · 머리 가운데 · 측점 열은 가로로 밀어도 붙어 있음 */
.b08-summary__wrap {
max-height: 70vh;
overflow: auto;
}
.b08-summary__table {
width: auto;
min-width: 100%;
}
.b08-summary__table th,
.b08-summary__table td {
padding: var(--spacing-4, 4px) var(--spacing-8);
border: 1px solid var(--color-border);
white-space: nowrap;
}
.b08-summary__table thead th {
position: sticky;
top: 0;
z-index: 1;
text-align: center;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
}
/* 머리 둘째 줄은 첫 줄 아래에 붙음 */
.b08-summary__table thead tr + tr th {
top: calc(1lh + 2 * var(--spacing-4, 4px) + 1px);
}
.b08-summary__table .b08-summary__station {
position: sticky;
left: 0;
z-index: 2;
text-align: center;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
}
.b08-summary__table thead .b08-summary__station {
z-index: 3;
}
.b08-summary__table td {
min-width: 80px;
background: var(--color-surface);
}
.b08-summary__name.is-missing {
color: var(--color-danger);
}
+59 -7
View File
@@ -1,29 +1,81 @@
/* =============================================================================
* B08_Quantity_UI_Page.ts
* 로그인 후 08: 5차 워크플로우 (수량 산출) — **빈 화면** (PLAN 7-3)
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
*
* - 옛 수량 산출 코드는 통째로 `old_code/B08_Quantity/` 에 있음(지우지 않음).
* - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 표·계산은 없음.
* 탭 = 구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표 — 차례대로 만듦.
* 지금은 1탭(구조물 집계표 · 61-1)만 내용 · 나머지는 빈 탭.
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
* ========================================================================== */
import "@ui/ui_template_general_layout.css";
import "./B08_Quantity_UI_Page.css";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createButton } from "@ui/ui_template_elements";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const TABS = [
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
{ id: "sheet", label: "B08_Quantity_Tab_StructureSheet" },
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
] as const;
type TabId = (typeof TABS)[number]["id"];
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void {
const view = document.createElement("div");
host.replaceChildren(view);
if (tab === "summary" && projectId) {
void renderStructureSummary(view, projectId);
return;
}
const pending = document.createElement("p");
pending.className = "ui-general-block__empty";
pending.textContent = L("B08_Quantity_Tab_Pending");
view.append(pending);
}
export function renderB08Quantity(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const note = document.createElement("p");
note.className = "b08-quantity__rebuilding";
note.textContent = L("B08_Quantity_Rebuilding");
const tabBar = document.createElement("div");
tabBar.className = "b08-quantity__tabs";
tabBar.setAttribute("role", "tablist");
const panel = document.createElement("div");
panel.className = "b08-quantity__panel";
panel.setAttribute("role", "tabpanel");
const select = (tab: TabId): void => {
tabBar.replaceChildren(
...TABS.map(({ id, label }) => {
const button = createButton({
label: L(label),
variant: id === tab ? "filled" : "ghost",
size: "sm",
onClick: () => id !== tab && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === tab));
button.dataset.b08Tab = id;
return button;
}),
);
renderTab(panel, tab, projectId);
};
const body = document.createElement("div");
body.append(note);
body.className = "b08-quantity";
body.append(tabBar, panel);
select("summary");
const layout = createWorkflowLayout({
title: L("B08_Quantity_Title"),
+125
View File
@@ -0,0 +1,125 @@
/* =============================================================================
* B08_Quantity_UI_Summary.ts
* 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 칸은 아직 비움.
*
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
import { pathOf, rowId } from "@ui/ui_template_item_picker_data";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip } from "../common_util/common_util_summary_item";
import {
comboId,
groupSpans,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
} from "./B08_Quantity_Summary_Data";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
async function stationInterval(projectId: string): Promise<number> {
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
return latest.route_params?.station_interval_m || 20;
}
function cell(tag: "th" | "td", text = "", className = ""): HTMLTableCellElement {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
return node;
}
function note(text: string): HTMLElement {
const node = document.createElement("p");
node.className = "ui-general-block__empty";
node.textContent = text;
return node;
}
function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement {
const head = cell("th", L("B08_Quantity_Summary_Station"), "b08-summary__station");
head.rowSpan = 2;
const groups = document.createElement("tr");
groups.append(head);
for (const [group, span] of groupSpans(columns)) {
const th = cell("th", group || L("B08_Quantity_Summary_MissingGroup"), "b08-summary__group");
th.colSpan = span;
groups.append(th);
}
const names = document.createElement("tr");
for (const column of columns) {
const th = cell("th", column.name, "b08-summary__name");
th.dataset.b08Combo = column.id;
th.title = summaryTooltip(column.item);
if (column.missing) th.classList.add("is-missing");
names.append(th);
}
const thead = document.createElement("thead");
thead.append(groups, names);
const tbody = document.createElement("tbody");
for (const station of stations) {
const tr = document.createElement("tr");
tr.dataset.b08Station = String(station);
tr.append(cell("th", stationLabel(station, interval), "b08-summary__station"));
for (const _ of columns) tr.append(cell("td"));
tbody.append(tr);
}
const table = document.createElement("table");
table.className = "ui-general-block__table b08-summary__table";
table.dataset.b08Summary = "table";
table.append(thead, tbody);
const wrap = document.createElement("div");
wrap.className = "ui-general-block__table-wrap b08-summary__wrap";
wrap.append(table);
return wrap;
}
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
try {
const [structures, pickerRows, pipes, interval] = await Promise.all([
fetchStructures(projectId),
loadPickerRows(),
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
fetchDetailPipePoints(projectId).then(
(response) => response.pipe_points,
() => [],
),
stationInterval(projectId).catch(() => 20),
]);
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
const describe = (item: Parameters<typeof comboId>[0]): ComboLabel | null => {
const row = m02.get(comboId(item));
if (!row) return null;
const [group = "", ...rest] = pathOf(row);
return { group, name: rest.join(" ") || group || row.key };
};
const things = [...structures.structures, ...pipes];
const stations = summaryStations(things);
const columns = summaryColumns(things, describe);
const parts: HTMLElement[] = [];
if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo")));
parts.push(
stations.length
? buildTable(stations, columns, interval)
: note(L("B08_Quantity_Summary_NoStation")),
);
host.replaceChildren(...parts);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`));
}
}
@@ -45,8 +45,10 @@ export interface DrawingTemplateOptions {
preview?: boolean;
/** 좌표 0,0 기준점 십자를 그린다 — 구조물 도면(편집 · 미리보기)만. */
originMarker?: boolean;
/** 구조물 도면 — 도각이 없으므로 작도 영역 단추 · 자리표(도각 자리) 표시를 없애고, 문서의 작도 영역 값은 무시. */
/** 구조물 도면 — 작도 영역 단추 · 자리표(도각 자리) 표시를 없애고, 문서의 작도 영역 값은 무시. */
noArea?: boolean;
/** B07 도각을 외곽 좌하단 (0,0) 에 얹음 — 잠금 층 · 보여주기만(돌려줄 때 뺌 · 저장 안 됨). */
frame?: boolean;
/** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */
noImport?: boolean;
}
@@ -89,6 +91,36 @@ export interface DrawingTemplateHandle {
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
/** 얹은 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 · 돌려줄 때 이 층을 통째로 뺌 */
const FRAME_LAYER_ID = "m02-structure-frame";
interface StructureFrame {
entities: Record<string, unknown>[];
layer: Record<string, unknown> | null;
}
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
let framePromise: Promise<StructureFrame | null> | null = null;
/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */
function fetchFrame(): Promise<StructureFrame | null> {
framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromise = null; // 다음에 다시 받음
return frame?.layer ? frame : null;
});
return framePromise;
}
/** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */
function withoutFrame(drawing: DrawingTemplateDoc): DrawingTemplateDoc {
return {
...drawing,
entities: drawing.entities.filter((e) => e.layerId !== FRAME_LAYER_ID),
layers: drawing.layers?.filter((layer) => layer.id !== FRAME_LAYER_ID),
};
}
async function importDrawingFile(
file: File,
): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> {
@@ -202,10 +234,23 @@ export function mountDrawingTemplate(
onChanged: (dirty) => options.onChanged?.(dirty),
onHostSave: hostSave,
});
const load = (drawing: DrawingTemplateDoc): void => {
let loadSeq = 0;
const load = async (drawing: DrawingTemplateDoc): Promise<void> => {
const mine = ++loadSeq;
cad.beginLoading();
const frame = options.frame ? await fetchFrame() : null;
if (mine !== loadSeq) return; // 그새 다른 도면을 실음
const own = withoutFrame(drawing);
const shown = frame?.layer
? {
...own,
entities: [...own.entities, ...frame.entities],
// 층 없는 빈 문서 — 잠금 도각 층만 남으면 새 그림이 도각 층에 떨어짐 · 기본 층(서버 `_BLANK_LAYER`)을 채움
layers: [...(own.layers?.length ? own.layers : [BLANK_LAYER]), frame.layer],
}
: drawing;
cad.load(
drawing,
shown,
null,
!readOnly && !noArea,
{},
@@ -238,7 +283,7 @@ export function mountDrawingTemplate(
importDrawingFile(file)
.then((response) => {
// 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다.
load(response.drawing);
void load(response.drawing);
options.onChanged?.(true); // 불러온 것은 아직 저장 전 — 떠날 때 묻게 함
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
})
@@ -260,7 +305,8 @@ export function mountDrawingTemplate(
}
const getDoc = async (): Promise<DrawingTemplateDoc> => {
const { drawing } = await cad.requestSave();
const { drawing: saved } = await cad.requestSave();
const drawing = options.frame ? withoutFrame(saved) : saved;
if (noArea) {
base = { ...base, ...drawing };
return base;
@@ -279,7 +325,7 @@ export function mountDrawingTemplate(
root.append(cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), cad.element);
container.append(root);
load(doc);
void load(doc);
return {
getDoc,
@@ -80,7 +80,8 @@ function mountStructure(
panelsCollapsed: true,
preview: !edit, // 보기만 = 왼쪽 끌기 화면 옮기기 · 고치기 = 누름 고르기 · 단축키
originMarker: true, // 좌표 0,0 기준점 십자
noArea: true, // 구조물도는 도각이 없음
noArea: true, // 작도 영역 칸 없음
frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장)
noImport: true, // 파일 불러오기는 도면 상세에서만(47-2)
...hooks,
});
@@ -14,7 +14,10 @@ from fastapi.responses import JSONResponse
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
entities_bbox,
frame_document,
frame_entities,
usable_bbox,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
@@ -88,6 +91,45 @@ async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
)
# 얹은 도각 층 — 화면이 돌려줄 때 이 층을 통째로 뺌 · 불러온 DXF 층(`b08-frame`)과 갈라 둠
STRUCTURE_FRAME_LAYER = "m02-structure-frame"
def _on_frame_layer(entity: dict) -> dict:
children = entity.get("children")
return {
**entity,
"layerId": STRUCTURE_FRAME_LAYER,
**({"children": [_on_frame_layer(c) for c in children]} if children else {}),
}
def structure_frame() -> dict:
"""구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층."""
placed = frame_entities("m02-structure", usable_bbox(), fit=False)
box = entities_bbox(placed)
if not box:
return {"entities": [], "layer": None}
ox, oy = box[0], box[1]
x0, y0, x1, y1 = usable_bbox()
entities = frame_entities("m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False)
return {
"entities": [_on_frame_layer(entity) for entity in entities],
"layer": {
"id": STRUCTURE_FRAME_LAYER,
"name": "도각(보기)",
"isVisible": True,
"isLocked": True,
},
}
@router.get("/structure-frame")
async def get_structure_frame() -> JSONResponse:
"""구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장)."""
return JSONResponse({"status": "success", **structure_frame()})
@router.get("/drawing-fields")
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
@@ -433,6 +433,7 @@ export function createMain(
headerSlot: slot,
originMarker: at.kind === "structure",
noArea: at.kind === "structure",
frame: at.kind === "structure", // 도각 = 보여주기만(56장)
});
if (at.kind === "structure") toDrawing.onDrawing(editor, at, doc as StructureDoc);
return;
@@ -0,0 +1,67 @@
# 62-1 홍수량 산출 방법 — 지식DB 대조 (sub8 · 2026-10-06)
견본 = `docs/raw/사용자요구/2026-10-06_홍수량_계산서_견본.png` = 울진 2공구 수리계산서 71+13 시트(지식DB 울진2공구 분석보고서 L47~51 과 값 일치).
지금 코드 = `common_util/common_util_drainage_detail.py` `size_pipe`(633~680) · `config/config_system_design.py` 165~180 · 강우 `common_util/common_util_wamis_station.py`.
## 결론
합리식 Q=(1/3.6)·f·I·A 는 견본 · 코드 · 법령 모두 같음. 갈리는 곳은 넷 — 할증(견본 1.5 · 코드 2.0 · 법정 2.0) · 도달시간식(견본 Kerby · 코드 Kirpich) · 단면 가정(견본 만관 · 코드 유효 70 %) · 조도계수(견본 0.013 · 코드 0.024). 같은 유역(견본 입력)에서 코드 방식은 Qd 가 견본의 약 3.4 배.
## 1. 항목별 방법
| 항목 | 견본(울진 2공구) | 지금 코드(배관 제안) | 현행 근거 | 다른 후보 |
|---|---|---|---|---|
| 할증 | Qd = Q × 1.5 | × 2.0 (`DRAINAGE_DESIGN_FLOW_FACTOR`) | 별표2 「최대홍수유출량의 2.0배 이상」(배수구 · 교량 · 암거) | 사방사업법 별표 1.2배(설계홍수량) · 방수로 2.0~5.0배 · 임도기술교본 1.2배(구기준 · 사용 금지) · 산림과임업기술 예제 안전율 25 % · 실무 1.2/1.5/2.0 혼재 |
| 합리식 | Q = f·rt·A/3.6 (A ㎢) | 같음(C·I·A/3.6) | KDS 44 40 00 2.6 · 유역 4.0 ㎢ 미만 | 사방 ha 식 Q = f·r·A/360(같은 식) · 산림과임업기술 Lauterburg 식 |
| 유출계수 f | 0.80 | 0.8 (`DRAINAGE_RUNOFF_COEFFICIENT`) | KDS 표 2.6-1: 가파른 산지 0.8 · 완만한 산지 0.7 · 수림 0.3 | 사방기술교본 산지 0.7 · 산림과임업기술 급한 산지 0.75~0.90 · 실무 0.6 · 0.85 (미결 No.7) |
| 유역면적 A | 0.132 ㎢(13.2 ha) 입력 | DEM 세부유역 면적 | KDS 2.3 지형도 · 현장 검토 | — |
| 도달시간 T | Kerby T = {2/3×3.28×L×(n/√s)}^0.467 (분 → 시간 표기) · L=480 m · H=185 m · s=H/L · 지체계수 n=0.70 | Kirpich tc = 0.0663·L^0.77·(H/L)^-0.385 (시간 · L ㎞) · 하한 5분 | KDS 2.4: 유입 + 유하 · 최소 5분 · **세부식 없음** | 실무 Kerby(울진 · 소광) = 지식DB 기본식 후보(종합비교 02 ④) |
| 강우강도 rt | Mononobe형 rt = (R24/24)·(24/T)^0.557 · R24 = 305.9 ㎜(건교부 2003.12 · 울진 100년) | 같은 식(`MONONOBE_EXPONENT` 0.557 · 기본) · R24 = WAMIS 2017 지역빈도 100년/24 hr · 반경 10 ㎞ 안 최대 지점 | 별표2 100년 빈도 또는 최근 5년 극한호우 · KDS 2.5 I-D-F | General형 a/(t^n+b)(`DRAINAGE_RAINFALL_IDF_METHOD`) · 최대시우량 직접(거창 · 영덕) |
| 단면 — 단면적 · 윤변 | 식은 240° 표기(A = πD²/4×240/360 + 삼각형 · P = πD×240/360) · 값은 만관(A = 0.785D² · P = πD · R = D/4) | 관경 역산 — 유효 통수단면 70 %(`DRAINAGE_FLOW_AREA_RATIO`) · R = D/4 | KDS 2.7 Manning · 표 2.7-2 원형 경제단면 H = 0.94D | 240° 유효 0.63185D² · P = 2.094D(울진 1공구 · 소광) · H/D = 0.8 부분류(거창) |
| 단면 — 유속 | Manning V = (1/n)·R^(2/3)·S^(1/2) · S = 1/20 · n = 0.013 | Manning · 경사 10° 고정 · n = 0.024 · 유속 0.8~3.0 m/s 묶음 | KDS 표 2.7-1: 콘크리트관 0.013 · 강관 0.011 · **파형강관 항목 없음** | 산림과임업기술 흄관 0.012 · 콜게이트 0.02 · 실무 파형강관 0.025(울진 1) / 0.013(소광) |
| 판정 | Q = A·V ≥ Qd → OK · 유출율 = Q/Qd(165 %) | 필요 직경 → 관경 스냅(하한 800 · 1,500 넘으면 BOX) | 별표2 관경 Ø1,000 이상(현지여건 800) | — |
## 2. 견본 역산(입력 그대로 다시 계산)
| 값 | 견본 | 다시 계산 | 비고 |
|---|---|---|---|
| T | 0.45 hr | 27.2 분 = 0.454 hr | Kerby 결과는 분 |
| rt | 116.76 | 116.2(s 반올림 0.39 쓰면 116.8) | Mononobe 0.557 확인 |
| Q · Qd | 3.43 · 5.15 | 3.41 · 5.11 | 할증 1.5 |
| A · P · R (Ø1,200) | 1.130 · 3.768 · 0.300 | 만관 1.131 · 3.770 · 0.300 | 240° 면 0.910 · 2.513 · 0.362 |
| V · Q | 7.516 · 8.493 | n=0.013 → 7.708 · 8.718 / 1/n=75 → 7.516 · 8.500 | 견본은 1/n = 75(n ≈ 0.0133) 로 셈 |
같은 입력에 지금 코드 방식: Kirpich 3.3 분 → 하한 5 분 · rt 298.7 ㎜/hr · Q 8.76 · Qd(×2.0) 17.52 ㎥/s — 견본 5.15 의 약 3.4 배.
단면 쪽(Ø1,200 · S 1/20): n 0.024 면 만관 Q 4.72 · 240° 면 4.31 ㎥/s — 견본 8.49 의 약 절반.
## 3. 견본 안의 어긋남
- 식은 240° 유효단면 · 값은 만관 — R 칸 「0.3018」 은 240° 계수(D=1 일 때 0.3017D) · 옆 값 0.300 은 만관 D/4.
- n = 0.013 표기 · 실제 계산은 1/n = 75(n ≈ 0.0133).
- 파형강관에 n 0.013 — KDS 표에 파형강관 없음 · 실무는 0.025 도 씀.
- 할증 1.5 — 현행 별표2 2.0 배보다 작음. R24 2003.12 판 — 지금 코드는 WAMIS 2017 판.
## 4. 사용자 협의 대상(62-2 · 62-3 방식 고르기 후보)
- 할증: 2.0(법정 · 지금 코드) 고정인지 · 견본처럼 표시값 선택인지.
- 도달시간: Kerby(견본 · 지식DB 후보) / Kirpich(지금 코드) — 값 차이 큼(27 분 대 5 분).
- 단면 가정: 만관 / 240° 유효 / 유효 70 % / H = 0.94D.
- 관 종류별 n: 파형강관 · 흄관 · 콘크리트관 — KDS 공백 칸(파형강관)은 출처 선택.
- 경사 S: 실측(관 양 끝 표고) / 10° 고정.
- 유출계수: 0.8 단일 / KDS 표 분류.
## 5. 근거 문서
| 내용 | 문서 · 위치 |
|---|---|
| 2.0 배 · 100년 빈도 · 극한호우 | `original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_…md` L210 · L296 · L494 · L594 · L674 |
| 사방 1.2 배 · 방수로 2.0~5.0 배 | `original/법률/사방사업법 시행규칙/별표/별표0_사방사업의 설계ㆍ시공 기준(제3조 관련).md` L44~46 |
| 구기준 1.2 배 | `original/임도기술교본/8. 배수공사/1. 암거(속도랑).md` L7 · `original/사방기술교본/1. 총론/4. 사방사업의 추진체계.md` L158 |
| 도달시간 · 최소 5분 · 강우강도 · 유출계수 표 · Manning · 조도계수 · 경제단면 | `original/표준시방서/도로공사 표준시방서 (KCS 44 00 00)/첨부/[압축] 도로설계기준(KDS 44 00 00)/KDS 44 40 00 도로배수시설(23.01).md` L130 · L151~155 · L168 · L194 · L209 |
| 합리식(Lauterburg) · 유출계수표 · Manning · 조도계수(흄관 0.012 · 콜게이트 0.02) · 안전율 25 % 예제 | `original/산림과임업기술(임도)/2. 임도/6. 시공.md` L579~690 |
| 사방 합리식 · 유출계수 산지 0.7 · 평균우량강도 | `original/사방기술교본/3. 야계사방/2-다2. 사방댐설치사업 - 불투과형 설계.md` L137~155 · `2-다3 …방수로·부속시설.md` L81 |
| 정리본 — 설계유량(합리식 · 강우 · WAMIS · 유출계수) | `technical_info/01_임도/03_계산정보/설계유량.md` |
| 정리본 — 통수단면(Manning · n 표 · 실무 n · 원형 0.94D) | `technical_info/01_임도/03_계산정보/통수단면.md` |
| 실무 대조(할증 4계보 · Kerby · Mononobe 0.557 · 240° 0.63185D²) | `original/실무문서/_종합비교/02_배수_수리계산.md` |
| 견본 출처 시트(71+13 · 1.5 · 305.9 · 만관) | `original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/_분석보고서.md` L47~55 |
| 미결 — 유출계수 · 할증 | `03_미결_및_확인사항.md` No.7 · No.16 |
@@ -0,0 +1,117 @@
# 63-2 노선 추천에 종단 포함 — 검토 (sub7 · 2026-10-06 · 코드·저장 자료 손대지 않음)
## 결론 다섯 줄
1. 노선 추천은 **원지반만** 봄 — 종단 계획선(꺾임점·종단곡선)을 세우지 않음. 토공은 「폭 4m 평평한 띠 · 비탈면 뺌」 가늠값이고, 절성토 균형은 어느 모드의 점수에도 안 들어감.
2. 종단선이 원지반에 붙는 것은 최적화가 실패해서가 아님. **「모든 측점 계획고 = 지반고」 규칙(2026-09-02 사용자 확정)** 그대로이고, 기울기 상한도 경고만 띄움. 균형 계산기(3·4순위)는 실패했을 때만 도는 예비 경로.
3. 검증_sub7 수치 — 저장된 토공은 절토 7,108㎥ · 성토 19,977㎥(다짐 기준), 부족 12,869㎥(전량 반입), **절/성 0.36**. 종단기울기 14%를 넘는 구간은 65 곳 중 38 곳(최대 79%) → 「최적화 안 됨」이 수치로 맞음.
4. 이 노선은 옆 경사가 40° 넘는 사면을 지나서 중심 계획고만 조금 바꿔도 토공이 크게 움직임. 전 구간을 **0.7m 낮추면 절 7,425 · 성 7,376㎥로 균형**(성토 −54%). 지금 균형 계산기의 잣대(종단 면적 ㎡)는 이런 차이를 못 잡음 — 중심선이 지반에 붙어 있으면 불균형을 0 으로 읽음.
5. 권하는 순서 — **안 A(종단 자동 설계에 횡단 토량 기준 균형을 넣음)를 먼저**, 그다음 안 B(추천 후보에 같은 종단 평가를 붙임). 안 B 만 하면 추천 점수와 [확인] 뒤 실제 종단이 서로 달라짐. 어느 안이든 09-02 사용자 확정 규칙을 뒤집는 일이므로 사용자 결정이 먼저.
## ① 지금 노선 추천 — 무엇으로 고르나
| 항목 | 내용 | 위치 |
|---|---|---|
| 진입 | B05 편집 [노선 추천] → 창의 모드 8개 → `POST /route/initial` | `B05_Profile_UI_RouteEdit_Recommend.ts:51-60,351-400` · `B05_Profile_Api_RouteInitial.ts:50-67` |
| 탐색 | 예상노선 ±100m 띠 · 4m 격자 · 16방향 Dijkstra · 최소 반경은 꺾기 전 직진 칸 수로 지킴 | `B05_Profile_Engine_RouteInitial_Search.py:28-60,225-233,369` |
| 고리 비용 항 | 예상노선과의 거리 · 토공(`W²·옆경사/4`, W=4m) · **지반** 종단경사² · 목표경사 편차 · 위험(옆경사 ≥35°·하천 띠) · 하천 횡단 | `Search.py:146-176,368` |
| 강제 조건 | 지반 경사가 상한을 넘는 고리는 뺌(상한을 1.0→0.93→0.85 로 낮춰 가며) · 하천 따라가기 금지 · 길이 막히면 벌점으로 바꿈 | `Search.py:306-309,356-358` · `Pool.py:59,146-159` |
| 고르기 | 후보 묶음에서 실측 지표로 고름 — earthwork=절+성 최소 · gentle=최대경사 · cost=비용지수 … | `B05_Profile_Engine_RouteInitial_Pool.py:272-285` |
| 종단 | 꺾임점·곡선 없음. `grade_line()`(상한 안으로 앞뒤로 누른 두 선의 평균)은 토공 가늠에만 씀 | `B05_Profile_Engine_RouteInitial_Metrics.py:92-100,235` |
| 토공 가늠 | 폭 4m 평평한 띠 9점 · **비탈면 뺌** · `surplus_m3`(절−성)는 계산은 하지만 점수에 안 씀 | `Metrics.py:224-240,270-271` |
| 기본값 | 상한 경사·반경은 파이썬 상수(20km/h: 9/14% · 15/12m) | `config/config_system_design.py:496-525` |
→ **판단 근거는 횡단(옆경사)과 지반 종단경사뿐**이고, 계획고·절성토 균형은 들어가지 않음.
## ② 종단선이 원지반에 붙는 까닭
| 순위 | 엔진 | 하는 일 | 균형 |
|---|---|---|---|
| **1(기본)** | `design_ground_following_profile` `B05_Profile_Engine_Grade_Profile.py:148-238` | 모든 측점이 꺾임점 · 계획고 = 지반고 · 꺾임점마다 종단곡선(L 15m), 곡선이 측점에서 지반을 지나도록 꼭짓점을 밀어 맞춤 | 없음(경고만) |
| 2 | `design_pipe_anchored_profile` `:241-366` | 배수관 측점에서만 지반에 맞춤(옛 1차 — 최대 성토 +9.36m 문제로 내려감) | 없음 |
| 3 | `design_alignment_profile` `:369-450` · `Grade_Solver.py:310-460` | DP 로 꺾임점을 고르고 최소제곱 ∫(계획−지반)² · 기울기 상한 강제 | 종단 면적 ㎡, 허용 10% |
| 4 | `design_grade_line` `B05_Profile_Engine_Grade.py:458-634` | 2차 계획법 · 구간별 면적 균형 등식 | 종단 면적 ㎡ |
- 순서는 `B05_Profile_Engine_Sections.py:317-369`. 1순위가 실패하지 않는 한 3·4는 돌지 않음.
- 기울기 위반 정책 `grade_violation_policy = warn` · 기본 지형 `special`(14%) — `config_system_design.py:632` · `Grade.py:60`.
- 덮개·교량 제어점 없음 · 배관 덮개 강제(`enforce_pipe_clearance`)는 기본 꺼짐.
- B05 화면의 종단 ㎡ 불균형 표시는 「실제 토량이 아니라」 뺐음(2026-09-03, `B05_Profile_UI_Profile_Balance.ts:8-10`). 균형은 유토곡선(횡단 ㎥)으로만 보여 줌.
지식DB 근거:
| 근거 | 내용 | 위치 |
|---|---|---|
| 별표2 Ⅰ.1.나.(4)(다) | **시공계획고는 절토량과 성토량이 균형을 이루게 하되** 피해방지·경관유지 고려 | `original/법률/…/별표2_…md` L64 |
| 별표2 Ⅰ.2.라.(1) | 종단기울기 20km/h 일반 9% · 특수 14% · 포장 시 18% | 같은 파일 L168-177 |
| 별표2 Ⅰ.2.마 | 종단곡선 20km/h R≥100m · L≥20m · 비포장 대수차 5% 이하 생략 | L187-197 |
| 교본 3장 실시설계 | 진입·진출부는 가급적 절토 · **계곡부는 양쪽 절토량이 성토되도록 계획고 결정** · 능선부 4% 이내 | `original/임도기술교본/3. …/3. 실시설계.md` L231-233,279 |
| 정리본 | 시공계획고 원칙 · 실무 지침 표 | `technical_info/01_임도/02_상세설계/종단선형.md` §4·§5 |
→ 법령은 「균형」을 계획고 결정 원칙으로 둠. 지금 1차 규칙(계획고 = 지반고)은 사용자 판단(설계자가 고쳐 쓰는 출발선)이라 법령 위반은 아니지만, 자동 제안으로는 균형과 상한 둘 다 비어 있음.
## ③ 종단 포함 안 비교
| | 안 A — 종단 자동 설계에 횡단 토량 균형 | 안 B — 추천 후보마다 종단+토공 점수 | 안 C — A+B 를 한 평가 함수로 |
|---|---|---|---|
| 하는 일 | 1차 계획선을 「지반 따라가기」에서 「상한 지키며 **횡단 ㎥** 기준 균형」으로(또는 [균형 계획선] 버튼으로 골라 씀). 측점마다 계획고 ±Δ 에 대한 절·성 면적 곡선을 미리 만들어 최적화 | 후보마다 ① 상한 안 종단 ② 비탈면을 넣은 횡단 토량 ③ 절/성 비 · 총 토량 · 반입·사토량을 재고 **새 모드「토공 균형」** 추가 | 추천 점수와 [확인] 뒤 종단이 같은 함수를 씀 — 추천에서 본 토량 = 확정 뒤 유토곡선 |
| 바뀌는 파일 | `B05_Profile_Engine_Grade_Solver.py`(목표·제약) · `B05_Profile_Engine_Grade_Profile.py`(새 진입점) · `B05_Profile_Engine_Sections.py`(순서) · `config_system_design.py`(정책) · B06 `compute_cross_design` 을 불러 쓰는 얇은 연결 하나 | `B05_Profile_Engine_RouteInitial_Metrics.py`(토공 가늠 교체) · `…_Pool.py`(GOALS·MODE) · `B05_Profile_UI_RouteEdit_Recommend.ts`(모드 단추) · 지반 횡단 표본을 DEM 에서 뽑는 함수 | A + B 파일 · 평가 함수 하나를 공용으로 |
| 계산 자리 | ③ 서버 단독 — 화면은 `base_pvi`+편집만 다시 그리므로 TS 짝 필요 없음 | ③ 서버 단독 | ③ 서버 단독 |
| 계산 시간(실측 근거) | 횡단 66장 한 벌 0.13초 → 오프셋 7단 ≈ 1초 + 최적화 · [확인] 체인에 1~3초 더함 | 후보 ≈17개(쓸기 11+모드) × 1~2초 ≈ **20~40초** 더함(지금 제한 5분) | A+B |
| 위험 | 09-02 사용자 확정 규칙 뒤집음 · 계획고가 지반 아래로 내려가 **배수관 덮개** 줄 수 있음(덮개 강제 꺼짐) · 상한을 지키면 깊은 절토(실측: 14% 만 지켜도 중심 절토 최대 6~8m · 성토 9m) | 추천 점수와 확정 뒤 종단이 다름(1차가 그대로면 균형 점수가 거짓말) · 추천 응답 느려짐 | 범위 큼 · 시험 많음 |
| 권함 | **먼저** | A 뒤에 | 최종 모습 |
곁가지(값싼 첫걸음): 안 B 의 「토공 가늠에 비탈면 넣기」만 해도 지금 earthwork 모드가 급사면 성토를 크게 낮춰 보는 흠이 줄어듦 — 다만 균형은 여전히 못 봄.
## ④ 확정 뒤 종단선 연결 영향
| 대상 | 지금 동작 | 안 A·B·C 일 때 |
|---|---|---|
| [적용] | 편집기 값과 캐시만 바꿈 · 계산 없음 (`B05_Profile_UI_RouteEdit_Initial.ts:144-152`) | 같음 |
| [확인] | `POST /route/replan` → 재확정 체인: 종단 자동 설계 → 배관 → B06 재생성(종단 **두 번** 계산) → 횡단 설계는 측점 거리로 이어 붙임 → 확정 (`B03_FileInput_Service_Chain.py:439-621`) | 새 종단이 이 체인 안에서 돎 → 시간 1~3초 더함. 계획고가 바뀌니 이어 붙인 횡단 설계의 면적도 다 바뀜 |
| 초기값(initial_snapshot) | 재확정 때 **지우고 다시 안 찍음**(편집 섞인 가짜 초기값 방지, 2026-08-29 사용자 확정 · `Service_Chain.py:440-444`) | 바뀌지 않음. 단 첫 자동 체인에서 찍히는 초기값의 종단이 새 규칙으로 바뀜 → 새 프로젝트부터 [초기화] 결과가 달라짐 |
| 사용자가 고친 종단선 | 편집은 `base_pvi` 기준 측점별 Δ. 재확정하면 **편집이 버려짐**(옮겨 붙이지 않음) · 세션 초안도 새 route_id 로 떨어짐 | 같음. 단 안 A 로 `base_pvi` 가 바뀌면, 옛 프로젝트를 다시 계산할 때 옛 Δ 가 새 기준에 얹혀 뜻이 바뀜 → 저장된 Δ 는 새 기준으로 옮기지 않는다는 규칙 필요 |
| B06 횡단 | 서버는 `design_profiles[0].samples` 보간 계획고, 화면은 `base_pvi`+편집으로 다시 계산 (`common_util_route_profile.py:46-75` · `B06_Section_Cross_Refresh.ts:215-281`) | 계획고가 바뀌면 측구 방향 · 절·성토 면적 · 포장 제안(국부 경사) · 유토곡선 · 사토/반입 계획이 다 바뀜. 모양 정보(`base_pvi` 형식)만 지키면 화면 코드는 그대로 |
| 배수관 | 덮개 강제 꺼짐 · 관 측점 = 지반 기준 | 계획고를 낮추면 관 덮개 부족 가능 → 안 A 에 덮개 제약(`facility_clearance_m`)을 켜야 함 |
## ⑤ 검증_sub7 실측 (프로젝트 6d3b2812 · 노선 231 CONFIRMED · 연장 1,078m · 측점 66)
- 이 노선은 추천을 거치지 않음(`initial_choice.json` 없음). 지반 20m 경사는 61 구간 중 31 곳이 ±14% 를 넘음(최대 −64%) · 들쭉날쭉한 등락. 시점 879.1m → 종점 833.0m(평균 −4.3%).
- 계획선: 66 측점 모두 계획고 = 지반고(오차 ≤0.0001m) · 기울기 14% 초과 38/65 구간 · 최대 79% · 종단곡선 64개 중 다수가 「직선이 짧아 L 줄임」 경고(반경 0~7m).
저장된 토공(DB `longitudinal_sections.data.mass_haul` · 절토 자연량 / 성토 다짐량):
| 구간 | 절토 ㎥ | 성토 ㎥ | 절/성 |
|---|---|---|---|
| 0–200 | 936 | 692 | 1.35 |
| 200–400 | 1,131 | 2,704 | 0.42 |
| 400–600 | 825 | 4,905 | 0.17 |
| 600–800 | 2,004 | 2,620 | 0.77 |
| 800–1,078 | 1,858 | 9,057 | 0.21 |
| **계** | **6,754**(다짐 7,108) | **19,977** | **0.36** · 부족 12,869㎥ 전량 반입 · 사토 0 |
- 성토가 큰 측점(840 · 860 · 1,020 · 500m, 측점당 1,000~1,700㎥)은 중심이 지반에 붙어 있는데도 옆 경사가 82~99%(40°+) → 내리막 쪽 성토 비탈이 지반에 잘 닿지 않아 성토가 커짐.
중심 계획고를 통째로 올리고 내린 민감도(B06 `compute_cross_design` 을 읽기 전용으로 다시 돌림 · 기본 지반 리핑암 · 평균단면법 · 자연량 — 저장값과 조건이 조금 달라 절대값은 다름):
| 계획고 이동 | 절토 ㎥ | 성토 ㎥ | 성/절 |
|---|---|---|---|
| 0 | 4,099 | 16,160 | 3.94 |
| −0.5m | 6,368 | 9,330 | 1.47 |
| **−0.7m** | **7,425** | **7,376** | **0.99** |
| −1.0m | 9,158 | 4,660 | 0.51 |
| −2.0m | 15,975 | 94 | 0.01 |
종단 기울기 상한만 지키는 계획선(20m 꺾임 · 시점 고정 · 선형계획 · 종단 면적 기준):
| 상한 | 균형 없이 절/성 ㎡·m | 중심 최대 절/성 m | 균형 넣고 중심 최대 절/성 m |
|---|---|---|---|
| 14% | 523 / 1,911 | 6.2 / 9.2 | 8.2 / 9.2 |
| 9% | 661 / 5,703 | 7.5 / 22.8 | 26.1 / 22.8 |
→ 이 지형에서 상한(14%)을 지키려면 중심 절·성토가 8~9m 까지 커짐. 「원지반에 붙인 종단」은 토공은 작게 보이지만 **기울기 위반 + 급사면 성토 쏠림**을 그대로 남김. 노선 자체(추천 단계)에서 지반 등락이 작은 길을 고르는 것과 종단 균형을 같이 봐야 하는 까닭.
## 남은 결정(사용자 · 브레인)
- 09-02 규칙(1차 계획고 = 지반고)을 유지하고 [균형 계획선]을 **골라 쓰는 단추**로 둘지, 기본값 자체를 바꿀지.
- 균형 잣대 = 횡단 ㎥(유토곡선과 같은 잣대) — 허용 비율(지금 10%) · 구간 길이(지금 전 구간 하나).
- 상한을 못 지키는 지형(이 노선)에서 상한 우선인지 토공 우선인지.
+2
View File
@@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_HaulPlan import (
)
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -638,6 +639,7 @@ app.include_router(b06_section_confirm_router, dependencies=protected_with_compa
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
app.include_router(b07_frame_router, dependencies=protected_with_company)
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
@@ -0,0 +1,72 @@
"""B08 1탭 구조물 집계표 줄 · 열 고르기 (PLAN 61-1).
`B08_Quantity_Summary_Data.ts` 를 `common_util_summary_item.ts` 와 이어 붙여 node 로 돌림.
줄 = 놓인 측점(같은 측점 한 줄 · 정렬) · 열 = 저장된 조합(본체 + 그룹 · 같은 키 한 열 · 구분 차례).
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _run(body: str):
sources = [
ROOT / "common_util" / "common_util_summary_item.ts",
ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts",
]
script = "\n".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in sources) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _item(key: str, layer: str = "system", owner=None) -> dict:
return {"layer": layer, "owner": owner, "key": key, "name": f"저장 {key}"}
THINGS = [
{"chainage_m": 120.004, "summary_item": _item("SS2")},
{"chainage_m": 40.0, "summary_item": _item("SS1"), "group_items": {"g1": _item("SS3")}},
{"chainage_m": 120.0, "summary_item": _item("SS1")}, # 같은 측점 · 같은 조합
{"chainage_m": None, "start_m": 80.0}, # 구간형 옛 저장분 = 시점
{"chainage_m": 10.0, "summary_item": {"key": "깨짐"}}, # 모양 틀림 = 미지정
{"chainage_m": 60.0, "summary_item": _item("SS9", "company", "3")}, # M02 에 없음
]
M02 = {"SS1": ("기슭막이", "메쌓기"), "SS2": ("배수관", "파형관"), "SS3": ("기슭막이", "찰쌓기")}
def test_줄_열_머리():
out = _run(
f"\nconst things = {json.dumps(THINGS, ensure_ascii=False)};"
f"\nconst m02 = {json.dumps(M02, ensure_ascii=False)};"
"\nconst describe = (item) => item.layer === 'system' && m02[item.key]"
" ? { group: m02[item.key][0], name: m02[item.key][1] } : null;"
"\nconst columns = summaryColumns(things, describe);"
"\nconsole.log(JSON.stringify({ stations: summaryStations(things),"
" columns: columns.map((c) => [c.id, c.group, c.name, c.missing]),"
" spans: groupSpans(columns) }));"
)
assert out["stations"] == [10.0, 40.0, 60.0, 80.0, 120.0]
assert out["columns"] == [
["company|3|SS9", "", "저장 SS9", True],
["system||SS1", "기슭막이", "메쌓기", False],
["system||SS3", "기슭막이", "찰쌓기", False],
["system||SS2", "배수관", "파형관", False],
]
assert out["spans"] == [["", 1], ["기슭막이", 2], ["배수관", 1]]
def test_빈_목록():
out = _run(
"\nconsole.log(JSON.stringify([summaryStations([]),"
" summaryColumns([{ chainage_m: 5 }], () => null), groupSpans([])]));"
)
assert out == [[], [], []]
+159
View File
@@ -0,0 +1,159 @@
"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장).
① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리)
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
④ 고른 조각만 도면 층으로 얹힘
"""
import json
import math
from pathlib import Path
import pytest
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
build_plan_kind_drawing,
layer_id,
slope_pieces,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
_refuge_items,
add_kind,
delete_kind,
list_kinds,
read_marks,
slope_records,
write_marks,
)
def _samples(slope: float) -> list[dict[str, float]]:
"""좌(+)가 높은 비탈 지반 — 오프셋 -20 ~ 20 m, 0.5 m 간격."""
return [
{"offset_m": i * 0.5, "elevation_m": 100.0 + slope * i * 0.5, "valid": True}
for i in range(-40, 41)
]
def _project(tmp_path: Path) -> tuple[Path, dict, dict]:
"""+y 로 곧게 가는 노선 · 측점 0 · 20 · 40 · 횡단 파일 · B06 저장 설계."""
root = tmp_path / "project"
cross_dir = root / "B06_Section" / "cross_sections"
cross_dir.mkdir(parents=True)
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
longitudinal_path.parent.mkdir(parents=True)
stations, designs = [], {}
for chainage, slope in ((0, 0.3), (20, 0.5), (40, 0.4)):
stations.append(
{"chainage_m": float(chainage), "center_x": 0.0, "center_y": float(chainage)}
)
source = {
"chainage_m": float(chainage),
"samples": _samples(slope),
# +y 진행의 좌측 = -x
"frame": {"origin": {"x": 0.0, "y": float(chainage)}, "left_xy": [-1.0, 0.0]},
}
(cross_dir / f"cross_{chainage:05d}m.json").write_text(json.dumps(source), "utf-8")
designs[chainage] = compute_cross_design(
source["samples"], 100.0, ground_type="soil", section_mode="left_cut"
)
longitudinal = {"stations": stations}
longitudinal_path.write_text(json.dumps(longitudinal), "utf-8")
return longitudinal_path, longitudinal, designs
def _meets_ground(design: dict, side: str) -> float:
"""설계선이 바깥으로 가며 지반과 다시 같아지는 첫 오프셋 — 시험 쪽 독립 계산."""
edge = design["road_edges"][side]["offset_m"]
line = sorted((p["offset_m"], p["elevation_m"]) for p in design["design_line"])
outward = (
[p for p in line if p[0] > edge]
if side == "left"
else [p for p in line[::-1] if p[0] < edge]
)
slope = SLOPES[design["_c"]]
for offset, elevation in outward:
# 지반은 직선 비탈이라 어느 오프셋에서나 식으로 바로 잰다(설계선 꺾임점 포함).
if abs(100.0 + slope * offset - elevation) < 1e-3:
return offset
raise AssertionError("설계선이 지반에 닿지 않음")
SLOPES = {0: 0.3, 20: 0.5, 40: 0.4}
def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> None:
longitudinal_path, longitudinal, designs = _project(tmp_path)
records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0)
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
outline, ticks = slope_pieces(route, records, 0.0, 40.0)
left_edge, left_end, right_edge, right_end = outline["lines"]
for chainage in (20, 40): # 견본 측점 둘
design = {**designs[chainage], "_c": chainage}
index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6)
expected = {
"left_edge": design["road_edges"]["left"]["offset_m"],
"left_end": _meets_ground(design, "left"),
"right_edge": design["road_edges"]["right"]["offset_m"],
"right_end": _meets_ground(design, "right"),
}
# 좌측은 -x 쪽 — 중심(0, c)에서 잰 거리가 횡단 설계 오프셋이다.
assert -left_edge[index][0] == pytest.approx(expected["left_edge"], abs=1e-3)
assert -left_end[index][0] == pytest.approx(expected["left_end"], abs=1e-3)
assert -right_edge[index][0] == pytest.approx(expected["right_edge"], abs=1e-3)
assert -right_end[index][0] == pytest.approx(expected["right_end"], abs=1e-3)
# 성토 · 절토 비탈이 실제로 노견 밖에 있다
assert abs(expected["left_end"]) > abs(expected["left_edge"])
assert abs(expected["right_end"]) > abs(expected["right_edge"])
# 비탈 눈금은 노견 끝에서 바깥으로 · 긴 것과 짧은 것이 섞임
lengths = {round(math.dist(*tick), 2) for tick in ticks["ticks"]}
assert len(ticks["ticks"]) > 20 and len(lengths) > 2
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
root = tmp_path / "project"
assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"]
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
assert added["pieces"] == ["centerline", "station"]
with pytest.raises(ValueError):
add_kind(root, "내 평면도", ["centerline"]) # 같은 이름
with pytest.raises(ValueError):
add_kind(root, "빈 것", [])
delete_kind(root, "terrain") # 기본 넷도 지움
again = list_kinds(root) # 다시 읽어 같음
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"]
assert again[-1]["pieces"] == ["centerline", "station"]
with pytest.raises(FileNotFoundError):
delete_kind(root, "terrain")
for kind in list(again):
delete_kind(root, kind["id"])
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
root = tmp_path / "project"
write_marks(root, [{"chainage_m": 20, "refuge": True, "spoil": True}, {"chainage_m": 5}])
assert read_marks(root) == [{"chainage_m": 20.0, "refuge": True, "spoil": True}]
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
items = _refuge_items(root, route, 0.0, 40.0)["items"]
assert [item["label"] for item in items] == ["대피소 · 사토장"]
assert items[0]["y"] == pytest.approx(20.0)
def test_drawing_has_only_chosen_pieces() -> None:
pieces = {
"centerline": {"lines": [[[0.0, 0.0], [0.0, 40.0]]]},
"radius": {"items": [{"label": "R=15", "at": [1.0, 20.0]}]},
}
drawing = build_plan_kind_drawing(
"plan_k1", "계획평면도(시험)", (-50.0, -10.0, 50.0, 50.0), pieces
)
layers = {entity.get("layerId") for entity in drawing["entities"]}
assert layer_id("centerline") in layers and layer_id("radius") in layers
assert layer_id("contour") not in layers and layer_id("station") not in layers
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
assert "R=15" in labels and "계획평면도(시험)" in labels
@@ -65,3 +65,18 @@ def test_import_garbage_is_400_not_500():
)
assert response.status_code == 400, (data, response.text)
assert response.json()["message"]
def test_structure_frame_origin_at_zero():
# 56장 — 구조물 도면에 얹는 도각: 외곽 좌하단 (0,0) · 잠금 도각 층 · 원본 양식은 그대로
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, template_entities
before = entities_bbox(template_entities())
body = client.get("/api/m02/structure-frame").json()
x0, y0, x1, y1 = entities_bbox(body["entities"])
assert abs(x0) < 1e-9 and abs(y0) < 1e-9
assert abs((x1 - x0) - (before[2] - before[0])) < 1e-6
assert body["layer"]["isLocked"] is True
assert {e["layerId"] for e in body["entities"]} == {"m02-structure-frame"}
assert body["layer"]["id"] == "m02-structure-frame" # 불러온 DXF 층(b08-frame)과 갈림
assert entities_bbox(template_entities()) == before
+1 -1
View File
@@ -71,7 +71,7 @@ function reasonOf(body: unknown, status: number): string {
}
/** `GET /api/m02/items?all=1` = `[{layer, owner, items}]` — 줄마다 `layer` · `owner` 를 붙여 하나로 폄 */
async function loadRows(): Promise<PickerRow[]> {
export async function loadRows(): Promise<PickerRow[]> {
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
const body = (await response.json().catch(() => null)) as
| { layer: PickerLayer; owner: string | null; items: PickerRow[] }[]
+17
View File
@@ -17,4 +17,21 @@ export const ui_locales_b4 = {
"제안값 넣기 — KDS 표준치수 토사 0.5 m",
"Insert suggested value — KDS standard soil 0.5 m",
],
B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"],
B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."],
B08_Quantity_Summary_Station: ["측점", "Station"],
B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"],
B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"],
B08_Quantity_Summary_Failed: [
"구조물 집계표를 불러오지 못했습니다",
"Failed to load the structure summary",
],
B08_Quantity_Summary_NoCombo: [
"저장된 구조물 조합이 없습니다 — B05 · B06 패널에서 조합을 지정 · 저장하세요.",
"No saved structure combinations — assign and save them in the B05 · B06 panel.",
],
B08_Quantity_Summary_NoStation: [
"놓인 구조물 · 배관이 없습니다.",
"No placed structures or pipes.",
],
} as const;