260721
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ from .config_system import (
|
||||
|
||||
# 글로벌 DB 풀 (앱 시작/종료 시 관리)
|
||||
db_pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
|
||||
async def init_db_pool() -> aiomysql.Pool:
|
||||
"""MariaDB 연결 풀 초기화"""
|
||||
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user