This commit is contained in:
2026-07-21 20:12:44 +09:00
parent f3e0d9ceee
commit b1218e9d79
17 changed files with 1523 additions and 80 deletions
@@ -117,6 +117,8 @@ export interface LongitudinalSection {
export interface CrossSection extends SectionStation {
samples: SectionSample[];
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
design?: CrossDesign;
}
export interface SectionDetailResponse {
@@ -132,6 +134,42 @@ export interface SectionConfirmResponse {
confirmed: boolean;
}
/** 측점 표준횡단 설계 지정값 (버튼 상태). */
export type GroundType = "soil" | "ripping_rock" | "blasting_rock";
export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
export type DitchSide = "left" | "right";
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
export interface CrossDesign {
ground_type: GroundType;
geometry_preset: "soil" | "rock";
section_mode: SectionMode;
ditch_side: DitchSide;
cut_slope_ratio: number;
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
ditch: { width_m: number; depth_m: number };
design_elevation_m: number;
cut_area_m2: number;
fill_area_m2: number;
ditch_area_m2: number;
design_line: Array<{ offset_m: number; elevation_m: number }>;
}
export interface CrossDesignResponse {
status: string;
chainage_m: number;
design: CrossDesign;
}
export interface CrossDesignRequest {
chainage_m: number;
ground_type: GroundType;
section_mode: SectionMode;
ditch_side?: DitchSide | null;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
const controller = new AbortController();
@@ -195,6 +233,18 @@ export async function regenerateSections(
);
}
/** 측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산·저장하고 잠정 결과를 반환한다. */
export async function computeCrossDesign(
projectId: string,
routeId: number,
request: CrossDesignRequest,
): Promise<CrossDesignResponse> {
return requestJson<CrossDesignResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design`,
{ method: "POST", body: JSON.stringify(request) },
);
}
/** 경로의 종·횡단면을 확정한다. */
export async function confirmSections(
projectId: string,
@@ -0,0 +1,236 @@
"""B06 측점 표준횡단 설계 계산 엔진.
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가
버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
확정치로 대체된다.
좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우.
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
from typing import Any
from config.config_system import (
SECTION_CARRIAGEWAY_WIDTH_M,
SECTION_DESIGN_TEMPLATES,
SECTION_DITCH_SIDES,
SECTION_FILL_SLOPE_RATIO,
SECTION_GROUND_TYPE_PRESET,
SECTION_MODES,
SECTION_ROADBED_WIDTH_M,
)
def _side_role(section_mode: str) -> tuple[str, str]:
"""단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'."""
if section_mode == "left_cut":
return "cut", "fill"
if section_mode == "right_cut":
return "fill", "cut"
if section_mode == "both_cut":
return "cut", "cut"
if section_mode == "both_fill":
return "fill", "fill"
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str:
"""측구(배수) 배치 측을 결정한다.
편절편성은 절토측이 곧 측구측이라 자동 결정하고, 양절·양성은 배수 방향을
사용자 지정(ditch_side)에 맡긴다(미지정 시 좌측 기본).
"""
if section_mode == "left_cut":
return "left"
if section_mode == "right_cut":
return "right"
if ditch_side in SECTION_DITCH_SIDES:
return ditch_side
return "left"
def _design_elevation_on_side(
offset_m: float,
ground_m: float,
role: str,
design_elevation_m: float,
half_width_m: float,
cut_slope_ratio: float,
fill_slope_ratio: float,
) -> float:
"""노체 밖 한 offset의 설계 표고를 절토/성토 규칙으로 계산한다.
절토측: 노면 가장자리에서 경사면이 위로 올라가다 지반선을 만나면 지반을 따른다.
성토측: 가장자리에서 경사면이 아래로 내려가다 지반선을 만나면 지반을 따른다.
"""
edge_distance = abs(offset_m) - half_width_m
if role == "cut":
slope_line = design_elevation_m + edge_distance / cut_slope_ratio
return min(slope_line, ground_m)
fill_line = design_elevation_m - edge_distance / fill_slope_ratio
return max(fill_line, ground_m)
def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]:
"""오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다.
diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
나눠 절·성토가 섞이지 않게 한다.
"""
cut_area = 0.0
fill_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
if d0 == 0 and d1 == 0:
continue
if d0 * d1 < 0:
# 부호 변화: 영교점에서 두 삼각형으로 분리
zero_ratio = d0 / (d0 - d1)
x_zero = x0 + width * zero_ratio
left_area = 0.5 * (x_zero - x0) * abs(d0)
right_area = 0.5 * (x1 - x_zero) * abs(d1)
if d0 > 0:
cut_area += left_area
fill_area += right_area
else:
fill_area += left_area
cut_area += right_area
continue
area = 0.5 * (d0 + d1) * width
if area >= 0:
cut_area += area
else:
fill_area += -area
return cut_area, fill_area
def compute_cross_design(
samples: list[dict[str, Any]],
design_elevation_m: float | None,
*,
ground_type: str,
section_mode: str,
ditch_side: str | None = None,
roadbed_width_m: float = SECTION_ROADBED_WIDTH_M,
fill_slope_ratio: float = SECTION_FILL_SLOPE_RATIO,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플.
design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가.
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
if section_mode not in SECTION_MODES:
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
if design_elevation_m is None:
raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.")
preset_key = SECTION_GROUND_TYPE_PRESET[ground_type]
preset = SECTION_DESIGN_TEMPLATES[preset_key]
cut_slope_ratio = float(preset["cut_slope_ratio"])
ditch_width_m = float(preset["ditch_width_m"])
ditch_depth_m = float(preset["ditch_depth_m"])
half_width_m = roadbed_width_m / 2.0
left_role, right_role = _side_role(section_mode)
resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side)
valid = sorted(
(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in samples
if s.get("valid") is not False
and s.get("offset_m") is not None
and s.get("elevation_m") is not None
),
key=lambda pair: pair[0],
)
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
offsets: list[float] = []
diffs: list[float] = []
design_line: list[dict[str, float]] = []
for offset_m, ground_m in valid:
if abs(offset_m) <= half_width_m + 1e-9:
design_z = design_elevation_m
else:
role = left_role if offset_m > 0 else right_role
design_z = _design_elevation_on_side(
offset_m,
ground_m,
role,
design_elevation_m,
half_width_m,
cut_slope_ratio,
fill_slope_ratio,
)
offsets.append(offset_m)
diffs.append(ground_m - design_z)
design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)})
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
# 측구는 절토측에서 굴착되므로 절토 단면적에 사다리꼴 근사로 가산한다.
ditch_area = ditch_depth_m * (ditch_width_m + ditch_width_m * 0.5) / 2.0
cut_area += ditch_area
return {
"ground_type": ground_type,
"geometry_preset": preset_key,
"section_mode": section_mode,
"ditch_side": resolved_ditch_side,
"cut_slope_ratio": round(cut_slope_ratio, 4),
"fill_slope_ratio": round(fill_slope_ratio, 4),
"roadbed_width_m": round(roadbed_width_m, 4),
"carriageway_width_m": round(SECTION_CARRIAGEWAY_WIDTH_M, 4),
"ditch": {"width_m": ditch_width_m, "depth_m": ditch_depth_m},
"design_elevation_m": round(float(design_elevation_m), 4),
"cut_area_m2": round(cut_area, 4),
"fill_area_m2": round(fill_area, 4),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
}
def design_elevation_from_longitudinal(
longitudinal: dict[str, Any], chainage_m: float
) -> float | None:
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
"""
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
if not isinstance(profiles, list) or not profiles:
return None
samples = [
s
for s in profiles[0].get("samples", [])
if isinstance(s.get("elevation_m"), (int, float))
and isinstance(s.get("chainage_m"), (int, float))
]
if not samples:
return None
if chainage_m <= samples[0]["chainage_m"]:
return float(samples[0]["elevation_m"])
last = samples[-1]
if chainage_m >= last["chainage_m"]:
return float(last["elevation_m"])
for index in range(1, len(samples)):
previous = samples[index - 1]
current = samples[index]
if chainage_m > current["chainage_m"]:
continue
span = current["chainage_m"] - previous["chainage_m"]
if span <= 0:
return float(current["elevation_m"])
ratio = (chainage_m - previous["chainage_m"]) / span
return float(
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
)
return float(last["elevation_m"])
@@ -268,6 +268,181 @@ async def count_cross_sections(connection: aiomysql.Connection, route_id: int) -
return int(row[0]) if row else 0
async def update_cross_section_design(
connection: aiomysql.Connection,
*,
route_id: int,
chainage_m: float,
design: dict[str, Any],
) -> bool:
"""측점 하나의 data.design(잠정 설계 지정·단면적)을 병합 저장한다.
기존 data 요약을 보존하고 design 키만 갱신한다. 대상 측점을 chainage 근사로
찾으며(부동소수 오차 허용), 갱신 여부를 반환한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM cross_sections
WHERE route_id = %s AND ABS(chainage_m - %s) < 0.01
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_m),
)
row = await cursor.fetchone()
if not row:
return False
data = row[1]
if isinstance(data, str):
data = json.loads(data)
if not isinstance(data, dict):
data = {}
data["design"] = design
await cursor.execute(
"UPDATE cross_sections SET data = %s WHERE id = %s",
(json.dumps(data, ensure_ascii=False), int(row[0])),
)
return True
async def merge_cross_section_design_by_round(
connection: aiomysql.Connection,
*,
route_id: int,
chainage_int: int,
patch: dict[str, Any],
) -> bool:
"""정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다.
B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다.
도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
)
row = await cursor.fetchone()
if not row:
return False
data = row[1]
if isinstance(data, str):
data = json.loads(data)
if not isinstance(data, dict):
data = {}
design = data.get("design")
if not isinstance(design, dict):
design = {}
design.update(patch)
data["design"] = design
await cursor.execute(
"UPDATE cross_sections SET data = %s WHERE id = %s",
(json.dumps(data, ensure_ascii=False), int(row[0])),
)
return True
async def get_cross_section_design(
connection: aiomysql.Connection, route_id: int, chainage_int: int
) -> dict[str, Any] | None:
"""정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다.
B07이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
)
row = await cursor.fetchone()
if not row or not row[0]:
return None
data = row[0]
if isinstance(data, str):
data = json.loads(data)
design = data.get("design") if isinstance(data, dict) else None
return design if isinstance(design, dict) and design.get("ground_type") else None
async def get_cross_section_designs(
connection: aiomysql.Connection, route_id: int
) -> list[dict[str, Any]]:
"""경로 측점별 저장된 설계 지정(data.design)을 chainage와 함께 반환한다.
상세 조회가 파일 기반이라 DB에만 있는 잠정 설계 지정을 화면 복원용으로 얹기 위함.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT chainage_m, data FROM cross_sections WHERE route_id = %s", (route_id,)
)
rows = await cursor.fetchall()
designs: list[dict[str, Any]] = []
for row in rows:
data = row[1]
if isinstance(data, str):
data = json.loads(data)
design = data.get("design") if isinstance(data, dict) else None
if isinstance(design, dict) and design.get("ground_type"):
designs.append({"chainage_m": float(row[0]), "design": design})
return designs
async def count_cross_sections_without_design(
connection: aiomysql.Connection, route_id: int
) -> int:
"""지반유형(data.design.ground_type)이 아직 지정되지 않은 측점 수를 반환한다.
확정 게이팅에 사용한다. JSON 함수 대신 애플리케이션에서 판정해
MariaDB JSON 함수 가용성에 의존하지 않는다.
"""
async with connection.cursor() as cursor:
await cursor.execute("SELECT data FROM cross_sections WHERE route_id = %s", (route_id,))
rows = await cursor.fetchall()
missing = 0
for row in rows:
data = row[0]
if isinstance(data, str):
data = json.loads(data)
design = data.get("design") if isinstance(data, dict) else None
if not isinstance(design, dict) or not design.get("ground_type"):
missing += 1
return missing
async def get_cross_sections_missing_design_chainages(
connection: aiomysql.Connection, route_id: int
) -> list[float]:
"""지반유형이 아직 지정되지 않은 측점의 chainage(m) 목록을 반환한다.
확정 시 기본값(토사/좌절토)으로 일괄 채우기 위해 사용한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT chainage_m, data FROM cross_sections WHERE route_id = %s", (route_id,)
)
rows = await cursor.fetchall()
chainages: list[float] = []
for row in rows:
data = row[1]
if isinstance(data, str):
data = json.loads(data)
design = data.get("design") if isinstance(data, dict) else None
if not isinstance(design, dict) or not design.get("ground_type"):
chainages.append(float(row[0]))
return chainages
async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
"""경로의 종횡단면 상태를 CONFIRMED로 변경한다."""
async with connection.cursor() as cursor:
@@ -13,22 +13,32 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
cross_filename,
prune_stale_cross_files,
run_section_generation,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
compute_cross_design,
design_elevation_from_longitudinal,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
confirm_sections_for_route,
count_cross_sections,
create_longitudinal_section,
delete_sections_for_route,
get_confirmed_route_context,
get_cross_section_designs,
get_cross_sections_missing_design_chainages,
get_latest_section_options,
get_longitudinal_section,
get_route_generation_source,
insert_cross_sections,
update_cross_section_design,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CrossDesignRequest,
CrossDesignResponse,
SectionConfirmResponse,
SectionContextResponse,
SectionDetailResponse,
@@ -154,12 +164,24 @@ async def get_section_detail(
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
designs = await get_cross_section_designs(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
detail = await asyncio.to_thread(
_read_section_detail,
project_root,
str(longitudinal["longitudinal_file_path"]),
)
# DB에만 있는 잠정 설계 지정을 chainage 근사로 각 횡단에 얹어 화면 복원을 돕는다.
for record in designs:
for section in detail["cross_sections"]:
if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01:
section["design"] = record["design"]
break
# 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다.
# (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.)
await asyncio.to_thread(
_attach_default_designs, detail["longitudinal"], detail["cross_sections"]
)
return SectionDetailResponse(**detail)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
@@ -281,11 +303,164 @@ async def regenerate_sections(
)
def _read_cross_design_inputs(
project_root: Path, longitudinal_file_path: str, chainage_m: float
) -> tuple[list[dict], float | None]:
"""측점 하나의 지반 샘플과 계획고를 파일에서 읽는다 (경로 이탈 검증 포함)."""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents:
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
if not longitudinal_path.is_file():
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples") if isinstance(cross, dict) else None
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
return samples, design_elevation
def _attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
) -> None:
"""지정 설계가 없는 횡단에 기본값(토사/좌절토) 프리뷰 설계를 즉석 계산해 얹는다.
detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이
전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다.
"""
for section in cross_sections:
if section.get("design"):
continue
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(
longitudinal, float(section.get("chainage_m", 0.0))
),
ground_type="soil",
section_mode="left_cut",
)
design["status"] = "provisional"
section["design"] = design
except (ValueError, KeyError):
continue
def _compute_default_designs(
project_root: Path, longitudinal_file_path: str, chainages: list[float]
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
return []
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
if not cross_path.is_file():
continue
try:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples")
if not isinstance(samples, list):
continue
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="soil",
section_mode="left_cut",
)
design["status"] = "provisional"
results.append((chainage_m, design))
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
return results
@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse)
async def compute_cross_section_design(
project_id: UUID, route_id: int, request: CrossDesignRequest
) -> CrossDesignResponse | JSONResponse:
"""측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산해 잠정치로 저장한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
samples, design_elevation = await asyncio.to_thread(
_read_cross_design_inputs,
project_root,
str(longitudinal["longitudinal_file_path"]),
request.chainage_m,
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=request.ground_type,
section_mode=request.section_mode,
ditch_side=request.ditch_side,
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
async with pool.acquire() as connection:
await connection.begin()
try:
updated = await update_cross_section_design(
connection,
route_id=route_id,
chainage_m=request.chainage_m,
design=design,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
if not updated:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "해당 측점 횡단 레코드가 없습니다."},
)
return CrossDesignResponse(chainage_m=request.chainage_m, design=design)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B06 측점 횡단 설계 계산 실패: project_id=%s route_id=%s", project_id, route_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."},
)
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
async def confirm_sections(
project_id: UUID, route_id: int
) -> SectionConfirmResponse | JSONResponse:
"""경로의 종횡단면을 확정(CONFIRMED)한다."""
"""경로의 종횡단면을 확정(CONFIRMED)한다.
지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -295,8 +470,27 @@ async def confirm_sections(
status_code=404,
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
# 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀).
default_designs: list[tuple[float, dict[str, Any]]] = []
if missing:
project_root = Path(resolve_stored_project_path(stored_path))
default_designs = await asyncio.to_thread(
_compute_default_designs,
project_root,
str(existing["longitudinal_file_path"]),
missing,
)
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_m, design in default_designs:
await update_cross_section_design(
connection, route_id=route_id, chainage_m=chainage_m, design=design
)
await confirm_sections_for_route(connection, route_id)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 3)
@@ -1,6 +1,6 @@
"""B06 종횡단 조회·확정 응답 검증 모델."""
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -11,6 +11,27 @@ class SectionRegenerateRequest(BaseModel):
cross_half_width_m: float = Field(..., gt=0)
class CrossDesignRequest(BaseModel):
"""측점 하나의 표준횡단 설계(지반유형·단면유형) 지정 요청.
버튼 클릭 즉시 절·성토 단면적을 계산해 잠정치로 저장한다. 편절편성은 절토측이
측구측이라 ditch_side가 무시되고, 양절·양성에서만 배수 방향으로 사용된다.
"""
chainage_m: float = Field(..., ge=0)
ground_type: Literal["soil", "ripping_rock", "blasting_rock"]
section_mode: Literal["left_cut", "right_cut", "both_cut", "both_fill"]
ditch_side: Literal["left", "right"] | None = None
class CrossDesignResponse(BaseModel):
"""측점 표준횡단 설계 계산 결과(잠정치)."""
status: str = "success"
chainage_m: float
design: dict[str, Any]
class SectionConfirmResponse(BaseModel):
"""종횡단 확정 결과."""
@@ -0,0 +1,155 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Cross_Design.ts
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치)과 설계선 오버레이.
*
* 카드 헤더 아래에 세그먼트 버튼을 배치하고, 지반유형·단면유형이 모두 선택되면
* onChange로 계산을 요청한다. 계산 결과(section.design)는 상위에서 다시 렌더될 때
* 절·성토 단면적 표시와 설계선 오버레이로 반영된다. 편절편성은 측구위치가 자동
* 결정되어 컨트롤을 숨기고, 양절·양성에서만 배수 방향 선택을 노출한다.
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type {
CrossDesign,
CrossSection,
DitchSide,
GroundType,
SectionMode,
} from "./B06_wf3_ProfileCross_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [
["soil", "B06_Design_Ground_Soil"],
["ripping_rock", "B06_Design_Ground_Ripping"],
["blasting_rock", "B06_Design_Ground_Blasting"],
];
const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [
["left_cut", "B06_Design_Mode_LeftCut"],
["right_cut", "B06_Design_Mode_RightCut"],
["both_cut", "B06_Design_Mode_BothCut"],
["both_fill", "B06_Design_Mode_BothFill"],
];
const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [
["left", "B06_Design_Ditch_Left"],
["right", "B06_Design_Ditch_Right"],
];
export interface CrossDesignChange {
ground_type: GroundType;
section_mode: SectionMode;
ditch_side: DitchSide | null;
}
function segment<T extends string>(
legend: string,
options: Array<[T, keyof typeof ui_locales]>,
selected: T | null,
onPick: (value: T) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b06-design__seg";
const legendEl = document.createElement("span");
legendEl.className = "b06-design__seg-legend";
legendEl.textContent = legend;
wrap.append(legendEl);
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
for (const [value, labelKey] of options) {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__btn${value === selected ? " b06-design__btn--active" : ""}`;
button.textContent = L(labelKey);
button.setAttribute("aria-pressed", value === selected ? "true" : "false");
button.addEventListener("click", () => onPick(value));
group.append(button);
}
wrap.append(group);
return wrap;
}
/** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */
export function buildDesignControls(
section: CrossSection,
onChange: (chainageM: number, change: CrossDesignChange) => void,
): HTMLElement {
const design = section.design;
// 기본값: 토사(soil) + 좌절토(left_cut). 미지정 측점은 확정 시 이 기본값으로 채워진다.
const state: { ground: GroundType; mode: SectionMode; ditch: DitchSide | null } = {
ground: design?.ground_type ?? "soil",
mode: design?.section_mode ?? "left_cut",
ditch: design?.ditch_side ?? null,
};
const bar = document.createElement("div");
bar.className = "b06-design";
// 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다.
bar.addEventListener("click", (event) => event.stopPropagation());
const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill";
const emit = (): void => {
if (!state.ground || !state.mode) return;
onChange(section.chainage_m, {
ground_type: state.ground,
section_mode: state.mode,
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
});
};
bar.append(
segment(L("B06_Design_Ground_Legend"), GROUND_OPTIONS, state.ground, (value) => {
state.ground = value;
emit();
}),
segment(L("B06_Design_Mode_Legend"), MODE_OPTIONS, state.mode, (value) => {
state.mode = value;
emit();
}),
);
if (needsDitch()) {
bar.append(
segment(L("B06_Design_Ditch_Legend"), DITCH_OPTIONS, state.ditch, (value) => {
state.ditch = value;
emit();
}),
);
}
const readout = document.createElement("div");
readout.className = "b06-design__areas";
if (design) {
const cut = document.createElement("span");
cut.className = "b06-design__area b06-design__area--cut";
cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}`;
const fill = document.createElement("span");
fill.className = "b06-design__area b06-design__area--fill";
fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}`;
readout.append(cut, fill);
} else {
const unset = document.createElement("span");
unset.className = "b06-design__area b06-design__area--unset";
unset.textContent = L("B06_Design_Unset");
readout.append(unset);
}
bar.append(readout);
return bar;
}
/** 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. */
export function appendCrossDesignOverlay(
svg: SVGSVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
const points = line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`);
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", "b06-chart__design-cross");
svg.append(polyline);
}
@@ -16,6 +16,7 @@ import {
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
computeCrossDesign,
confirmSections,
fetchSectionContext,
fetchSectionDetail,
@@ -25,7 +26,7 @@ import {
type SectionDetailResponse,
type SectionSummaryResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
import { type CrossDesignChange, createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
import "./B06_wf3_ProfileCross_UI_Style.css";
function L(key: keyof typeof ui_locales): string {
@@ -125,7 +126,53 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(routeGroup, resultGroup, displayGroup, actionRow);
const sectionView = createSectionView();
// 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다.
const designRequestSeq = new Map<number, number>();
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
};
sectionView.refreshCard(chainageM);
}
// (2) 서버 계산 — 최신 요청만 반영.
const seq = (designRequestSeq.get(chainageM) ?? 0) + 1;
designRequestSeq.set(chainageM, seq);
try {
const response = await computeCrossDesign(projectId, currentRouteId, {
chainage_m: chainageM,
...change,
});
if (designRequestSeq.get(chainageM) !== seq) return;
target.design = response.design;
sectionView.refreshCard(chainageM);
} catch (error) {
if (designRequestSeq.get(chainageM) !== seq) return;
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
const sectionView = createSectionView((chainageM, change) => {
void handleDesignChange(chainageM, change);
});
function renderMessage(message: string): void {
const text = document.createElement("p");
@@ -6,6 +6,14 @@ import type {
SectionDetailResponse,
SectionSample,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import {
appendCrossDesignOverlay,
buildDesignControls,
type CrossDesignChange,
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
export type { CrossDesignChange };
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
const SVG_NS = "http://www.w3.org/2000/svg";
const LONG_WIDTH = 1200;
@@ -379,6 +387,7 @@ export function createCrossSectionCard(
widthPx = CROSS_WIDTH,
heightPx = CROSS_HEIGHT,
designElevation?: number,
onDesignChange?: DesignChangeHandler,
): HTMLElement {
const card = document.createElement("article");
card.id = `cross-${section.station_id}`;
@@ -405,6 +414,7 @@ export function createCrossSectionCard(
: L("B06_Profile_View_Kind_Station");
header.append(title, kind);
card.append(header);
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange));
const sourceSamples = section.samples.filter(
(sample) =>
@@ -505,6 +515,12 @@ export function createCrossSectionCard(
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
}
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
return sample;
@@ -584,11 +600,13 @@ export interface SectionViewController {
crossHalfWidth?: number,
stationInterval?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
clear: () => void;
dispose: () => void;
}
export function createSectionView(): SectionViewController {
export function createSectionView(onDesignChange?: DesignChangeHandler): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
let currentDetail: SectionDetailResponse | null = null;
@@ -598,6 +616,10 @@ export function createSectionView(): SectionViewController {
let currentStationInterval: number | undefined;
let renderWidth = 0;
let resizeTimer = 0;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
const contentWidth = (): number => {
const style = getComputedStyle(root);
@@ -607,34 +629,55 @@ export function createSectionView(): SectionViewController {
);
};
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const buildCrossCard = (section: CrossSection): HTMLElement =>
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
cachedYScale,
(stationId) => selectStation(stationId, false),
cachedStationInterval,
currentCrossHalfWidth,
cachedCardWidth,
CROSS_HEIGHT,
currentDetail
? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m)
: undefined,
onDesignChange,
);
const draw = (): void => {
if (!currentDetail || renderWidth <= 0) return;
root.replaceChildren();
const detail = currentDetail;
const yScale = calculateYScale(detail);
const stationInterval =
cachedYScale = calculateYScale(detail);
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const longitudinalPanel = document.createElement("section");
longitudinalPanel.className = "b06-section__panel";
const longitudinalMinWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval);
const longitudinalMinWidth = longitudinalMinimumWidth(
detail.longitudinal,
cachedStationInterval,
);
longitudinalPanel.append(
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
yScale,
cachedYScale,
(stationId) => selectStation(stationId, true),
stationInterval,
cachedStationInterval,
Math.max(renderWidth, longitudinalMinWidth),
LONG_HEIGHT,
longitudinalMinWidth,
@@ -655,30 +698,25 @@ export function createSectionView(): SectionViewController {
1,
Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)),
);
const cardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
if (detail.cross_sections.length) {
detail.cross_sections.forEach((section) =>
grid.append(
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
yScale,
(stationId) => selectStation(stationId, false),
stationInterval,
currentCrossHalfWidth,
cardWidth,
CROSS_HEIGHT,
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
),
);
detail.cross_sections.forEach((section) => grid.append(buildCrossCard(section)));
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
root.append(longitudinalPanel, crossHeading, grid);
};
const refreshCard = (chainageM: number): void => {
if (!currentDetail) return;
const section = currentDetail.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return;
const existing = document.getElementById(`cross-${section.station_id}`);
if (existing) existing.replaceWith(buildCrossCard(section));
};
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
if (nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) return;
@@ -704,6 +742,7 @@ export function createSectionView(): SectionViewController {
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
clear() {
currentDetail = null;
selectedStationId = null;
@@ -365,3 +365,81 @@
fill: var(--color-danger);
font-weight: var(--font-weight-bold);
}
/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) */
.b06-design {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--spacing-8);
}
.b06-design__seg {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b06-design__seg-legend {
font-size: 0.72rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.b06-design__seg-buttons {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
overflow: hidden;
}
.b06-design__btn {
padding: 3px 8px;
font-size: 0.72rem;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
.b06-design__btn:first-child {
border-left: none;
}
.b06-design__btn--active {
color: var(--color-surface);
background: var(--color-royal-amethyst);
font-weight: var(--font-weight-medium);
}
.b06-design__areas {
display: inline-flex;
gap: var(--spacing-8);
margin-left: auto;
font-size: 0.72rem;
font-family: var(--font-mono);
}
.b06-design__area--cut {
color: rgb(220 38 38);
}
.b06-design__area--fill {
color: rgb(37 99 235);
}
.b06-design__area--unset {
color: var(--color-text-muted);
}
/* 횡단 표준단면 설계선 오버레이 */
.b06-chart__design-cross {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 1.8;
stroke-linejoin: round;
}
@@ -30,6 +30,22 @@ export interface DesignDrawingListResponse {
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
export type QuantityTable = Record<string, number | null>;
/** B06에서 지정한 설계(지반정보·계획정보). 횡단도에만 존재. status로 잠정/확정 구분. */
export interface CrossDesignInfo {
ground_type: "soil" | "ripping_rock" | "blasting_rock";
geometry_preset: "soil" | "rock";
section_mode: "left_cut" | "right_cut" | "both_cut" | "both_fill";
ditch_side: "left" | "right";
cut_slope_ratio: number;
fill_slope_ratio: number;
roadbed_width_m: number;
ditch: { width_m: number; depth_m: number };
design_elevation_m: number;
cut_area_m2: number;
fill_area_m2: number;
status?: "provisional" | "confirmed";
}
export interface DesignDrawingResponse {
status: string;
project_id: string;
@@ -40,6 +56,7 @@ export interface DesignDrawingResponse {
drawing: CadDrawing;
confirmed: boolean;
quantity_table?: QuantityTable | null;
design?: CrossDesignInfo | null;
}
export interface DesignDrawingConfirmResponse {
@@ -48,6 +65,7 @@ export interface DesignDrawingConfirmResponse {
id: string;
confirmed: boolean;
all_confirmed: boolean;
design?: CrossDesignInfo | null;
}
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -13,9 +13,15 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
compute_cross_design,
design_elevation_from_longitudinal,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_confirmed_route_context,
get_cross_section_design,
get_longitudinal_section,
merge_cross_section_design_by_round,
)
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
@@ -34,6 +40,11 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_GROUND_LAYER_ID = "b07-ground"
_GROUND_COLOR = "#f5f7fa"
# 계획선(계획 종단선/횡단 설계선) 레이어. 편집 최소화하되 구조물 부착·선 트림이 가능하도록
# 잠금 해제로 두고, 지반선과 색을 구분한다(계획=amethyst 계열).
_DESIGN_LAYER_ID = "b07-design"
_DESIGN_COLOR = "#b794f6"
_STAGE_DIR = "B07_wf4_DesignDetail"
@@ -144,9 +155,12 @@ def _line_entity(
start: tuple[float, float],
end: tuple[float, float],
layer_id: str = _GROUND_LAYER_ID,
color: str = "#f5f7fa",
color: str = _GROUND_COLOR,
) -> dict[str, Any]:
entity_id = str(uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{index}"))
# layer_id를 seed에 포함해 지반선·계획선 자식 Line의 uuid 충돌을 막는다.
entity_id = str(
uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{layer_id}:{index}")
)
return {
"id": entity_id,
"type": "Line",
@@ -160,6 +174,67 @@ def _line_entity(
}
def _points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]:
"""유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m)."""
points: list[tuple[float, float]] = []
for sample in samples:
if not isinstance(sample, dict) or not sample.get("valid", False):
continue
x = sample.get(x_key)
y = sample.get("elevation_m", sample.get("z"))
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
return points
def _polyline_entity(
drawing_id: str, points: list[tuple[float, float]], layer_id: str, color: str
) -> dict[str, Any] | None:
"""점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None)."""
if len(points) < 2:
return None
children = [
_line_entity(drawing_id, index, points[index], points[index + 1], layer_id, color)
for index in range(len(points) - 1)
]
return {
"id": str(uuid5(UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"), f"{drawing_id}:{layer_id}")),
"type": "PolyLine",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": None,
"children": children,
}
def _design_points(
source: dict[str, Any], kind: str, design_line: list[Any] | None
) -> list[tuple[float, float]]:
"""계획선 점열을 만든다. 종단=design_profiles의 계획고, 횡단=설계 design_line.
계획선 샘플은 지반선과 달리 valid 플래그가 없어 좌표 유효성만으로 판정한다.
"""
if kind == "longitudinal":
profiles = source.get("design_profiles")
if not isinstance(profiles, list) or not profiles:
return []
raw = profiles[0].get("samples", [])
x_key = "chainage_m"
else:
raw = design_line if isinstance(design_line, list) else []
x_key = "offset_m"
points: list[tuple[float, float]] = []
for point in raw:
if not isinstance(point, dict):
continue
x = point.get(x_key)
y = point.get("elevation_m")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
return points
# 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고,
# planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다.
_QUANTITY_ITEM_KEYS = (
@@ -205,40 +280,31 @@ def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
return table
def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]:
"""B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다."""
def _cad_drawing(
source: dict[str, Any],
drawing_id: str,
kind: str,
design_line: list[Any] | None = None,
) -> dict[str, Any]:
"""B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다.
지반선(b07-ground) 계획선(b07-design) 각각 별도 레이어의 PolyLine으로 emit한다.
계획선은 종단도=계획고(design_profiles), 횡단도=표준단면 design_line에서 만든다.
"""
x_key = "chainage_m" if kind == "longitudinal" else "offset_m"
points: list[tuple[float, float]] = []
for sample in source.get("samples", []):
if not isinstance(sample, dict) or not sample.get("valid", False):
continue
x = sample.get(x_key)
y = sample.get("elevation_m", sample.get("z"))
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
children = [
_line_entity(drawing_id, index, points[index], points[index + 1])
for index in range(len(points) - 1)
]
ground_points = _points_from_samples(source.get("samples", []), x_key)
design_points = _design_points(source, kind, design_line)
entities: list[dict[str, Any]] = []
if children:
entities.append(
{
"id": str(
uuid5(
UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"),
drawing_id,
)
),
"type": "PolyLine",
"lineColor": "#f5f7fa",
"lineWidth": 1,
"layerId": _GROUND_LAYER_ID,
"shapeData": None,
"children": children,
}
)
ground = _polyline_entity(drawing_id, ground_points, _GROUND_LAYER_ID, _GROUND_COLOR)
if ground:
entities.append(ground)
design = _polyline_entity(drawing_id, design_points, _DESIGN_LAYER_ID, _DESIGN_COLOR)
if design:
entities.append(design)
# 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다.
# 계획선 레이어는 편집 가능(잠금 해제)으로 두어 구조물 부착·선 트림을 허용한다.
return {
"entities": entities,
"layers": [
@@ -248,17 +314,46 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str
"isVisible": True,
"isLocked": False,
},
{
"id": _DESIGN_LAYER_ID,
"name": "Design Plan",
"isVisible": True,
"isLocked": False,
},
],
}
def _cross_design_line(
longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None
) -> list[Any] | None:
"""횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산."""
if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list):
return stored_design["design_line"]
try:
longitudinal = _read_json(longitudinal_path)
design = 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",
)
return design["design_line"]
except (ValueError, KeyError, OSError, json.JSONDecodeError):
return None
def _read_drawing(
project_root: Path, longitudinal_path: Path, drawing_id: str
project_root: Path,
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다.
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은
stored_design(없으면 기본값)에서 만든다.
"""
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
@@ -285,7 +380,14 @@ def _read_drawing(
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
label = str(source.get("label") or drawing_id)
return "cross", label, _cad_drawing(source, drawing_id, "cross"), False, _quantity_table(source)
design_line = _cross_design_line(longitudinal_path, source, stored_design)
return (
"cross",
label,
_cad_drawing(source, drawing_id, "cross", design_line),
False,
_quantity_table(source),
)
def _store_confirmed_drawing(
@@ -359,8 +461,17 @@ async def get_design_drawing(
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
if cross_match:
pool = get_db_pool()
async with pool.acquire() as connection:
design = await get_cross_section_design(
connection, route_id, int(cross_match.group(1))
)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id
_read_drawing, project_root, longitudinal_path, drawing_id, design
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -371,6 +482,7 @@ async def get_design_drawing(
drawing=drawing,
confirmed=confirmed,
quantity_table=quantity_table,
design=design,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -388,6 +500,34 @@ async def get_design_drawing(
)
def _recompute_confirmed_design(
longitudinal_path: Path, drawing_id: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
측구위치) 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
design_elevation = design_elevation_from_longitudinal(
longitudinal, float(source.get("chainage_m", 0.0))
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=designation["ground_type"],
section_mode=designation["section_mode"],
ditch_side=designation.get("ditch_side"),
)
design["status"] = "confirmed"
return design
@router.put(
"/{project_id}/design-drawings/{drawing_id}/confirm",
response_model=DesignDrawingConfirmResponse,
@@ -395,26 +535,57 @@ async def get_design_drawing(
async def confirm_design_drawing(
project_id: UUID, drawing_id: str, request: DesignDrawingConfirmRequest
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다."""
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
"""
try:
_, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
if not item:
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
# 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관.
all_confirmed = await asyncio.to_thread(
_store_confirmed_drawing,
project_root,
item,
request.drawing,
{candidate.id for candidate in items},
{candidate.id for candidate in items if candidate.kind == "cross"},
request.quantity_table,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
confirmed_design: dict[str, Any] | None = None
chainage_int: int | None = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
if item.kind == "cross" and cross_match:
chainage_int = int(cross_match.group(1))
async with pool.acquire() as connection:
designation = await get_cross_section_design(connection, route_id, chainage_int)
if designation:
try:
confirmed_design = await asyncio.to_thread(
_recompute_confirmed_design, longitudinal_path, drawing_id, designation
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s",
drawing_id,
exc_info=True,
)
async with pool.acquire() as connection:
await connection.begin()
try:
if confirmed_design is not None and chainage_int is not None:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
)
async with connection.cursor() as cursor:
if all_confirmed:
await complete_stage(cursor, str(project_id), 4)
@@ -429,6 +600,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=confirmed_design,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -453,16 +625,25 @@ async def invalidate_design_drawing(
) -> DesignDrawingInvalidateResponse | JSONResponse:
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
try:
_, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
if drawing_id not in {item.id for item in items}:
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
# 확정 도면을 편집하면 해당 측점 설계도 잠정 상태로 되돌린다.
if cross_match:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=int(cross_match.group(1)),
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 4)
await connection.commit()
@@ -37,6 +37,8 @@ class DesignDrawingResponse(BaseModel):
confirmed: bool = False
# 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None.
quantity_table: dict[str, float | None] | None = None
# B06에서 지정한 잠정 설계(지반유형·단면유형·절성토 단면적). 횡단도만, 없으면 None.
design: dict[str, Any] | None = None
class DesignDrawingConfirmRequest(BaseModel):
@@ -55,6 +57,8 @@ class DesignDrawingConfirmResponse(BaseModel):
id: str
confirmed: bool
all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
design: dict[str, Any] | None = None
class DesignDrawingInvalidateResponse(BaseModel):
@@ -33,6 +33,7 @@ import {
fetchDesignDrawingList,
invalidateDesignDrawing,
type CadDrawing,
type CrossDesignInfo,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
@@ -161,6 +162,92 @@ function buildDrawingSidePanel(
return panel;
}
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping",
blasting_rock: "B06_Design_Ground_Blasting",
};
/** 단면유형에서 절토측 표기를 유도한다. */
function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
if (mode === "left_cut") return L("B06_Design_Ditch_Left");
if (mode === "right_cut") return L("B06_Design_Ditch_Right");
if (mode === "both_cut") return L("B06_Design_Mode_BothCut");
return L("B06_Design_Mode_BothFill");
}
function infoRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b07-info__row";
const key = document.createElement("span");
key.className = "b07-info__key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b07-info__val";
val.textContent = value;
row.append(key, val);
return row;
}
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
const panel = document.createElement("div");
panel.className = "b07-info";
const heading = document.createElement("div");
heading.className = "b07-info__heading";
const stationName = document.createElement("strong");
stationName.textContent = `${L("B07_Info_Station")} ${title}`;
const confirmed = design?.status === "confirmed";
const badge = document.createElement("span");
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
heading.append(stationName, badge);
panel.append(heading);
if (!design) {
const empty = document.createElement("p");
empty.className = "b07-info__empty";
empty.textContent = L("B07_Info_NoDesign");
panel.append(empty);
return panel;
}
const ground = document.createElement("section");
ground.className = "b07-info__block";
const groundTitle = document.createElement("h4");
groundTitle.textContent = L("B07_Info_Ground_Title");
ground.append(
groundTitle,
infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])),
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
L("B07_Info_DitchSide"),
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
),
);
const plan = document.createElement("section");
plan.className = "b07-info__block";
const planTitle = document.createElement("h4");
planTitle.textContent = L("B07_Info_Plan_Title");
plan.append(
planTitle,
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
infoRow(
L("B07_Info_Ditch"),
`${design.ditch.width_m.toFixed(2)}×${design.ditch.depth_m.toFixed(2)}m`,
),
infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}`),
infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}`),
);
panel.append(ground, plan);
return panel;
}
/* -----------------------------------------------------------------------------
*
* -------------------------------------------------------------------------- */
@@ -207,9 +294,26 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed);
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
}
const title =
typeof drawing.chainage_m === "number"
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label;
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
};
const confirmButton = createButton({
label: "현재 도면 확정",
@@ -268,6 +372,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentIndex = index;
currentConfirmed = response.confirmed;
confirmButton.disabled = response.confirmed;
updateInfoPanel(drawing, response);
sendLoad(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
cadHost.dataset.loading = "false";
@@ -316,6 +421,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
confirmButton.disabled = true;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
const infoTitle =
typeof currentDrawing.chainage_m === "number"
? stationLabel(currentDrawing.chainage_m, stationInterval)
: currentDrawing.label;
infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null));
}
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
@@ -400,7 +513,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
});
confirmActions.append(tempB08Btn);
drawingPanel.append(confirmActions);
drawingPanel.append(infoPanelHost, confirmActions);
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
@@ -157,3 +157,70 @@
color: var(--color-text-muted);
text-decoration: underline;
}
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
.b07-info-host:empty {
display: none;
}
.b07-info {
margin-top: var(--spacing-16);
padding-top: var(--spacing-16);
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b07-info__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.b07-info__badge {
padding: 1px 6px;
font-size: 10px;
color: var(--color-primary);
background: var(--color-mist-violet);
border-radius: var(--radius-buttons);
}
.b07-info__badge--confirmed {
color: var(--color-surface);
background: var(--color-success);
}
.b07-info__block {
display: flex;
flex-direction: column;
gap: 2px;
}
.b07-info__block h4 {
margin: var(--spacing-8) 0 2px;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.b07-info__row {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
font-size: 0.76rem;
}
.b07-info__key {
color: var(--color-text-muted);
}
.b07-info__val {
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.b07-info__empty {
font-size: 0.76rem;
color: var(--color-text-muted);
}
-1
View File
@@ -21,7 +21,6 @@ from .config_system import (
# 글로벌 DB 풀 (앱 시작/종료 시 관리)
db_pool: Optional[aiomysql.Pool] = None
async def init_db_pool() -> aiomysql.Pool:
"""MariaDB 연결 풀 초기화"""
+28
View File
@@ -244,6 +244,34 @@ SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower()
FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5}
# ─────────────────────────────────────────────────────────────────────────
# 5-4-1. 횡단 표준단면 설계 기준 (B06 WF3)
#
# 출처: 첨부 표준횡단도(토사/암 구간). 노면폭 4.5m = 길어깨 0.5 + 차도 3.5 + 길어깨 0.5.
# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종
# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분).
# 성토 경사는 지반유형과 무관하게 고정값을 적용한다.
# ─────────────────────────────────────────────────────────────────────────
SECTION_ROADBED_WIDTH_M = float(os.getenv("SECTION_ROADBED_WIDTH_M", "4.5"))
SECTION_CARRIAGEWAY_WIDTH_M = float(os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", "3.5"))
# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정.
SECTION_FILL_SLOPE_RATIO = float(os.getenv("SECTION_FILL_SLOPE_RATIO", "1.2"))
# 표준단면 기하 프리셋: 절토경사(수평:수직=ratio:1)와 측구 규격(m).
SECTION_DESIGN_TEMPLATES = {
"soil": {"cut_slope_ratio": 1.2, "ditch_width_m": 1.0, "ditch_depth_m": 0.4},
"rock": {"cut_slope_ratio": 0.6, "ditch_width_m": 0.79, "ditch_depth_m": 0.4},
}
# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다.
SECTION_GROUND_TYPE_PRESET = {
"soil": "soil",
"ripping_rock": "rock",
"blasting_rock": "rock",
}
# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset.
SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill")
SECTION_DITCH_SIDES = ("left", "right")
# ─────────────────────────────────────────────────────────────────────────
# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2)
#
+38
View File
@@ -797,6 +797,28 @@ export const ui_locales = {
"No cross-section data to display.",
],
/* --- B06 측점 표준횡단 설계 지정 --- */
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
B06_Design_Ground_Soil: ["토사", "Soil"],
B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"],
B06_Design_Ground_Blasting: ["발파암", "Blasting rock"],
B06_Design_Mode_Legend: ["단면유형", "Section type"],
B06_Design_Mode_LeftCut: ["좌 절토", "Left cut"],
B06_Design_Mode_RightCut: ["우 절토", "Right cut"],
B06_Design_Mode_BothCut: ["양절", "Both cut"],
B06_Design_Mode_BothFill: ["양성", "Both fill"],
B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],
B06_Design_Ditch_Left: ["좌", "Left"],
B06_Design_Ditch_Right: ["우", "Right"],
B06_Design_Cut_Area: ["절토", "Cut"],
B06_Design_Fill_Area: ["성토", "Fill"],
B06_Design_Unset: ["미지정", "Not set"],
B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."],
B06_Profile_Confirm_NeedDesign: [
"지반유형이 지정되지 않은 측점이 있습니다.",
"Some stations have no ground type assigned.",
],
/* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],
B07_Cad_Side_Pending: [
@@ -805,6 +827,22 @@ export const ui_locales = {
],
B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."],
B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."],
B07_Info_Ground_Title: ["지반정보", "Ground info"],
B07_Info_Plan_Title: ["계획정보", "Plan info"],
B07_Info_GroundType: ["지반유형", "Ground type"],
B07_Info_CutSide: ["절토측", "Cut side"],
B07_Info_DitchSide: ["측구위치", "Ditch side"],
B07_Info_DesignElevation: ["계획고", "Design elevation"],
B07_Info_CutSlope: ["절토경사", "Cut slope"],
B07_Info_FillSlope: ["성토경사", "Fill slope"],
B07_Info_RoadWidth: ["노면폭", "Road width"],
B07_Info_Ditch: ["측구규격", "Ditch spec"],
B07_Info_CutArea: ["절토 단면적", "Cut area"],
B07_Info_FillArea: ["성토 단면적", "Fill area"],
B07_Info_Provisional: ["잠정", "Provisional"],
B07_Info_Confirmed: ["확정", "Confirmed"],
B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."],
B07_Info_Station: ["측점", "Station"],
/* --- B08_wf5_Quantity 수량 산출 --- */
B08_Quantity_Title: ["5차 · 수량 산출", "Step 5 · Quantity Takeoff"],