B06_개선 시작

This commit is contained in:
2026-07-24 20:47:22 +09:00
parent 33af8e9eef
commit 7b453d7a46
20 changed files with 1655 additions and 172 deletions
+2
View File
@@ -227,6 +227,8 @@ export interface RouteConfirmRequest {
smooth?: boolean;
surface_model_id?: number;
irregular_stations?: Array<{ chainage_m: number; structure: string }>;
/** 측점 상단측(=측구 방향) 사용자 변경분 — 3D 램프 클릭으로 지정. */
uphill_overrides?: Array<{ chainage_m: number; side: "left" | "right" }>;
}
/** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */
@@ -19,6 +19,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
from common_util.common_util_json import atomic_write_json
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA
logger = logging.getLogger(__name__)
@@ -109,6 +110,61 @@ def _append_design_profiles(
return {"id": profile["id"], **profile["summary"]}
def _annotate_pavement_suggestions(
longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None
) -> None:
"""계획선 국소 종단경사가 비포장 법정 상한을 넘는 측점에 포장 제안을 표기한다.
근거: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] — 기준(설계속도×지형) 초과
구간은 노면포장 시에 한해 상한(`paved_exception_grade_pct`)까지 허용된다.
stations에 `pavement_suggested`(bool)와 판정 근거(경사·상한)를 남기며, B06이
포장 기본값과 법정 근거 문구 표기에 그대로 사용한다. 실패해도 종횡단은 유지한다.
"""
if grade_options is None:
return
profiles = longitudinal.get("design_profiles") or []
samples = profiles[0].get("samples", []) if profiles else []
points = [
(float(s["chainage_m"]), float(s["elevation_m"]))
for s in samples
if isinstance(s.get("chainage_m"), (int, float))
and isinstance(s.get("elevation_m"), (int, float))
]
stations = longitudinal.get("stations")
if len(points) < 2 or not isinstance(stations, list):
return
criteria = FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(grade_options.design_speed_kph)
if not criteria:
return
terrain = grade_options.terrain_type if grade_options.terrain_type else "normal"
unpaved_limit = float(
criteria["max_grade_pct"].get(terrain, criteria["max_grade_pct"]["normal"])
)
def local_grade_pct(chainage: float) -> float:
"""측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다."""
worst = 0.0
for index in range(1, len(points)):
c0, z0 = points[index - 1]
c1, z1 = points[index]
if c1 < chainage - 1e-6 or c0 > chainage + 1e-6:
continue
span = c1 - c0
if span <= 1e-9:
continue
worst = max(worst, abs((z1 - z0) / span) * 100.0)
return worst
for station in stations:
chainage = station.get("chainage_m")
if not isinstance(chainage, (int, float)):
continue
grade_pct = local_grade_pct(float(chainage))
station["pavement_suggested"] = grade_pct > unpaved_limit + 1e-6
station["pavement_grade_pct"] = round(grade_pct, 2)
station["pavement_grade_limit_pct"] = round(unpaved_limit, 2)
def run_section_generation(
project_root: Path,
route_data_path: str,
@@ -152,6 +208,11 @@ def run_section_generation(
grade_options,
(result.get("options") or {}).get("station_interval_m"),
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
_annotate_pavement_suggestions(result["longitudinal"], grade_options)
except Exception:
logger.exception("B05 포장 제안 판정 실패 (종횡단은 유지)")
long_file = long_dir / "longitudinal.json"
atomic_write_json(long_file, result["longitudinal"])
long_summary = {
@@ -176,6 +176,11 @@ def generate_sections(
all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets))
all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets))
# 측점별 좌/우 상단측(등고가 높은 쪽) 판정용 마스크. B06 측구 배치 기본값과
# 3D 원형 램프 표시에 쓴다(좌=+offset, 우=-offset).
left_offset_mask = offsets > 1e-9
right_offset_mask = offsets < -1e-9
stations: list[dict[str, Any]] = []
cross_sections: list[dict[str, Any]] = []
for index, value in enumerate(station_chainage):
@@ -205,6 +210,18 @@ def generate_sections(
"left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)],
"up_xyz": [0.0, 0.0, 1.0],
}
# 상단측 판정: 유효 샘플의 좌/우 평균 표고 비교. 어느 한쪽이 전부 무효이거나
# 차이가 미미하면 None(미상) — 프론트는 좌측 기본값으로 폴백한다.
valid_row = all_cross_valid[index]
left_z = all_cross_z[index][left_offset_mask & valid_row]
right_z = all_cross_z[index][right_offset_mask & valid_row]
uphill_side: str | None = None
if left_z.size and right_z.size:
left_mean = float(np.mean(left_z))
right_mean = float(np.mean(right_z))
if abs(left_mean - right_mean) > 1e-6:
uphill_side = "left" if left_mean > right_mean else "right"
station = {
"station_id": station_id,
"chainage_m": round(float(value), 6),
@@ -214,6 +231,8 @@ def generate_sections(
"center_y": round(float(station_xy[index, 1]), 6),
"center_z": _float_or_none(center_z),
"azimuth_deg": round(azimuth, 6),
# 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다.
"uphill_side": uphill_side,
"frame": frame,
}
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
+54 -1
View File
@@ -128,6 +128,38 @@ def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenera
)
def _merge_uphill_overrides_into_longitudinal(
project_root: Path, longitudinal_file_path: str, overrides: list[dict[str, Any]]
) -> None:
"""종단 정본 파일 stations의 uphill_side를 사용자 변경값으로 덮어쓴다.
solve가 자동 판정한 상단측(=측구 방향)을 3D 램프 클릭으로 바꾼 경우, 확정 시점에
정본에 반영해 B06이 값만 읽으면 되게 한다. 사용자 지정임을 소스 필드로 남긴다.
"""
if not overrides:
return
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
if root not in path.parents or not path.is_file():
return
data = json.loads(path.read_text(encoding="utf-8"))
stations = data.get("stations")
if not isinstance(stations, list):
return
by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides}
changed = False
for station in stations:
side = by_chainage.get(round(float(station.get("chainage_m", -1.0)), 3))
if side is None:
continue
station["uphill_side"] = side
station["uphill_side_source"] = "user"
changed = True
if changed:
data["stations"] = stations
atomic_write_json(path, data)
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> None:
@@ -591,7 +623,28 @@ async def confirm_latest_route(
await _append_irregular_cross_sections(connection, project_id, latest, request)
except Exception:
logger.exception(
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): project_id=%s route_id=%s",
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): "
"project_id=%s route_id=%s",
project_id,
latest["id"],
)
# 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적.
if request.uphill_overrides:
try:
stored_path = await get_project_storage_relative_path(connection, project_id)
longitudinal = await get_longitudinal_section(
connection, project_id, latest["id"]
)
if longitudinal:
await asyncio.to_thread(
_merge_uphill_overrides_into_longitudinal,
Path(resolve_stored_project_path(stored_path)),
str(longitudinal["longitudinal_file_path"]),
[item.model_dump() for item in request.uphill_overrides],
)
except Exception:
logger.exception(
"B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s",
project_id,
latest["id"],
)
+12 -1
View File
@@ -1,6 +1,6 @@
"""B05 경로 설계 요청·응답 검증 모델."""
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -232,6 +232,15 @@ class IrregularStationInput(BaseModel):
structure: str = Field(default="")
class UphillSideOverride(BaseModel):
"""측점 상단측(=측구 방향) 사용자 변경값. 3D 원형 램프 클릭으로 지정된다."""
model_config = ConfigDict(extra="forbid")
chainage_m: float = Field(ge=0)
side: Literal["left", "right"]
class RouteConfirmRequest(BaseModel):
"""경로 확정 요청.
@@ -247,6 +256,8 @@ class RouteConfirmRequest(BaseModel):
smooth: bool = False
surface_model_id: int | None = None
irregular_stations: list[IrregularStationInput] = Field(default_factory=list)
# 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다.
uphill_overrides: list[UphillSideOverride] = Field(default_factory=list)
def extra_stations(self) -> tuple[tuple[float, str], ...]:
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
+35
View File
@@ -30,9 +30,15 @@ export interface SectionStationMarker {
center_x: number;
center_y: number;
center_z: number | null;
/** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */
uphill_side?: "left" | "right" | null;
frame: { left_xy: [number, number] };
}
// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색.
const UPHILL_LAMP_COLOR = 0xf97316;
const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af;
const COLORS: Record<RoutePointKind, number> = {
bp: 0x10b981,
ep: 0xef4444,
@@ -82,6 +88,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined;
let stationSelectionListener: ((stationId: string | null) => void) | undefined;
let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined;
let selectedStationId: string | null = null;
function allPoints(): PlacedRoutePoint[] {
@@ -259,6 +266,23 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
line.userData.stationId = station.station_id;
if (selected) line.material.linewidth = 2;
stationGroup.add(line);
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
(["left", "right"] as const).forEach((side, endIndex) => {
const active = station.uphill_side === side;
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(1.1, 14, 10),
new THREE.MeshBasicMaterial({
color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR,
}),
);
lamp.position.copy(points[endIndex]);
lamp.position.y += 0.6;
lamp.userData.uphillStationId = station.station_id;
lamp.userData.uphillSide = side;
stationGroup.add(lamp);
});
});
}
@@ -291,6 +315,14 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
updateSelected,
deleteSelected,
selectObject(object: THREE.Object3D | undefined) {
// 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정).
if (typeof object?.userData.uphillStationId === "string") {
uphillPickListener?.(
object.userData.uphillStationId,
object.userData.uphillSide as "left" | "right",
);
return;
}
if (typeof object?.userData.stationId === "string") {
selectStation(object.userData.stationId);
selectionListener?.(null);
@@ -327,6 +359,9 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
onStationSelectionChange(listener: (stationId: string | null) => void) {
stationSelectionListener = listener;
},
onUphillPick(listener: (stationId: string, side: "left" | "right") => void) {
uphillPickListener = listener;
},
dispose() {
disposeGroup(interactionGroup);
disposeGroup(routeGroup);
+114 -7
View File
@@ -208,6 +208,74 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
/* (= )
* solve가 uphill_side를 3D .
* uphill_overrides로 /DB( ) . */
const uphillSessionKey = `b05:uphill:${activeProjectId}`;
const uphillOverrides = new Map<string, "left" | "right">();
const uphillKey = (chainage: number): string => chainage.toFixed(3);
try {
const raw = window.sessionStorage.getItem(uphillSessionKey);
if (raw) {
Object.entries(JSON.parse(raw) as Record<string, "left" | "right">).forEach(
([chainage, side]) => {
if (side === "left" || side === "right") uphillOverrides.set(chainage, side);
},
);
}
} catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
}
function persistUphillOverrides(): void {
try {
window.sessionStorage.setItem(
uphillSessionKey,
JSON.stringify(Object.fromEntries(uphillOverrides)),
);
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}
/* /
* DB(latest)
* . latest를 ,
* solve· ( = , ). */
const latestCacheKey = `b05:latest:${activeProjectId}`;
function readLatestCache(): RouteLatestResponse | null {
try {
const raw = window.sessionStorage.getItem(latestCacheKey);
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
}
function writeLatestCache(value: RouteLatestResponse): void {
try {
window.sessionStorage.setItem(latestCacheKey, JSON.stringify(value));
} catch {
// 용량 초과 등 저장 실패 시 캐시를 비워 다음 진입은 DB 조회로 폴백한다.
try {
window.sessionStorage.removeItem(latestCacheKey);
} catch {
/* noop */
}
}
}
/** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */
async function loadLatest(forceFresh = false): Promise<RouteLatestResponse> {
if (!forceFresh) {
const cached = readLatestCache();
if (cached) return cached;
}
const fresh = await fetchLatestRoute(activeProjectId);
writeLatestCache(fresh);
return fresh;
}
const panel = createRoutePanel({
onSolve: () => void solve(),
onConfirm: () => void confirm(),
@@ -255,6 +323,25 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
viewer.markers.onUphillPick((stationId, side) => {
if (!currentSectionDetail) return;
const base = currentSectionDetail.longitudinal.stations.find(
(station) => station.station_id === stationId,
);
let chainage = base?.chainage_m;
if (chainage === undefined) {
const prefix = irregularStationId("");
if (stationId.startsWith(prefix)) {
const id = stationId.slice(prefix.length);
chainage = irregularStations.find((entry) => entry.id === id)?.chainage_m;
}
}
if (chainage === undefined) return;
uphillOverrides.set(uphillKey(chainage), side);
persistUphillOverrides();
renderStationLines(currentSectionDetail);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
@@ -289,10 +376,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
irregularStations,
detail.longitudinal.length_m,
);
viewer.renderStationLines(
[...detail.longitudinal.stations, ...injected],
roadWidths[panel.values().gradeClass] / 2,
);
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
const withUphill = [...detail.longitudinal.stations, ...injected].map((station) => ({
...station,
uphill_side:
uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null,
}));
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
}
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
@@ -375,6 +465,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
const cached = readLatestCache();
if (cached) {
writeLatestCache({
...cached,
surface_params: { ...cached.surface_params, contour_interval_m: interval },
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
} finally {
@@ -422,7 +520,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
});
renderLatest(await fetchLatestRoute(activeProjectId));
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
uphillOverrides.clear();
persistUphillOverrides();
renderLatest(await loadLatest(true));
await restoreSections(solved.route_id);
if (solved.cross_section_count === null) {
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
@@ -454,8 +555,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({
chainage_m: Number(chainage),
side,
})),
});
renderLatest(await fetchLatestRoute(activeProjectId));
renderLatest(await loadLatest(true));
showToast("경로를 확정했습니다.", "success");
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]);
} catch (error) {
@@ -469,7 +575,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
await Promise.all([
fetchWorkflowState(activeProjectId),
listSurfaceModels(activeProjectId),
fetchLatestRoute(activeProjectId),
// 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다.
loadLatest(),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
@@ -24,6 +24,33 @@ export interface SectionOptionDefaults {
vertical_exaggeration: number;
}
/** 측구 규격(상단폭/저폭/깊이, m). */
export interface DitchSpec {
top_width_m: number;
bottom_width_m: number;
depth_m: number;
}
/** 지반그룹 하나의 표준 횡단면 기본값 (config STANDARD_CROSS_SECTION 사본). */
export interface StandardCrossGroup {
road_width_m: number;
shoulder_left_m: number;
shoulder_right_m: number;
ditch: DitchSpec;
/** 암 그룹만 존재: L형 측구(폭/깊이, m). */
ditch_l_type?: { width_m: number; depth_m: number };
cross_slope_pct: { min: number; max: number };
fill_slope_ratio: number;
cut_slope_ratio: number;
/** 포장 그룹만 존재: 포장층 두께(m). */
pavement_thickness_m?: number;
}
/** 표준 횡단면 설정 패널 그룹 키. */
export type StandardCrossKey = "soil" | "rock" | "paved";
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
export interface SectionContextResponse {
project_id: string;
route_id: number | null;
@@ -32,6 +59,11 @@ export interface SectionContextResponse {
smooth: boolean | null;
crs_epsg: number | null;
defaults: SectionOptionDefaults;
/** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */
standard_cross_section: StandardCrossSection;
/** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */
rock_boundary_default_offset_m: number;
rock_boundary_step_m: number;
}
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
@@ -64,6 +96,8 @@ export interface SectionStation {
azimuth_deg: number | null;
center_x: number;
center_y: number;
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
uphill_side?: "left" | "right" | null;
frame: { left_xy: [number, number] };
}
@@ -147,6 +181,7 @@ export interface SectionConfirmResponse {
export type GroundType = "soil" | "ripping_rock" | "blasting_rock";
export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
export type DitchSide = "left" | "right";
export type DitchType = "standard" | "l_type";
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
export interface CrossDesign {
@@ -154,16 +189,34 @@ export interface CrossDesign {
geometry_preset: "soil" | "rock";
section_mode: SectionMode;
ditch_side: DitchSide;
/** 측구 형식(일반/L형). 양성(측구 없음)은 null. */
ditch_type: DitchType | null;
cut_slope_ratio: number;
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
ditch: { width_m: number; depth_m: number };
cross_slope_pct: number;
ditch:
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
| { type: "l_type"; width_m: number; depth_m: number }
| { type: "none" };
/** 포장 중첩 여부와 포장층 두께(포장 시). */
paved: boolean;
pavement_thickness_m?: number;
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
pavement_suggested?: boolean;
/** 노면 양 끝점(포장층 박스·노면 렌더링 기준). */
road_edges: {
left: { offset_m: number; elevation_m: number };
right: { offset_m: number; elevation_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 }>;
/** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */
rock_boundary_offset_m?: number;
}
export interface CrossDesignResponse {
@@ -177,6 +230,18 @@ export interface CrossDesignRequest {
ground_type: GroundType;
section_mode: SectionMode;
ditch_side?: DitchSide | null;
/** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */
ditch_type?: DitchType;
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
paved?: boolean;
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
standard_cross_section?: StandardCrossSection;
}
/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */
export interface CrossSectionPatch {
chainage_m: number;
rock_boundary_offset_m?: number;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
@@ -254,12 +319,18 @@ export async function computeCrossDesign(
);
}
/** 경로의 종·횡단면을 확정한다. */
/** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */
export async function confirmSections(
projectId: string,
routeId: number,
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
});
}
@@ -5,6 +5,12 @@
버튼을 누를 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
확정치로 대체된다.
표준단면 기하는 config STANDARD_CROSS_SECTION(단일 진실 원천) 읽고, B06 설정
패널 편집값(standard 인자) 오면 요청값 config 기본값 순으로 우선한다.
설계선은 도면 표준대로 횡단경사(측구 방향), 노견, 측구(사다리꼴/L형), ·성토
사면을 모두 포함하며, 면적은 지반 샘플과 설계 꼭짓점을 합친 오프셋 격자에서
사다리꼴 적분한다(측구 굴착이 절토 면적에 자연 포함).
좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=, 음수=.
경사비는 수평:수직 = ratio:1 (: 1:1.2 ratio=1.2).
"""
@@ -12,13 +18,11 @@
from typing import Any
from config.config_system import (
SECTION_CARRIAGEWAY_WIDTH_M,
SECTION_DESIGN_TEMPLATES,
SECTION_DITCH_SIDES,
SECTION_FILL_SLOPE_RATIO,
SECTION_DITCH_TYPES,
SECTION_GROUND_TYPE_PRESET,
SECTION_MODES,
SECTION_ROADBED_WIDTH_M,
STANDARD_CROSS_SECTION,
)
@@ -50,26 +54,48 @@ def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str:
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의 설계 표고를 절토/성토 규칙으로 계산한다.
def _as_float(value: Any, fallback: float) -> float:
"""패널 편집값을 안전하게 float으로 읽는다(손상 시 config 기본값)."""
try:
parsed = float(value)
except (TypeError, ValueError):
return fallback
return parsed if parsed >= 0 else fallback
절토측: 노면 가장자리에서 경사면이 위로 올라가다 지반선을 만나면 지반을 따른다.
성토측: 가장자리에서 경사면이 아래로 내려가다 지반선을 만나면 지반을 따른다.
"""
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 _resolve_group(preset_key: str, standard: dict[str, Any] | None) -> dict[str, float]:
"""config 기본값 위에 패널 편집값(standard[preset_key])을 덮어 평탄화한다."""
base = STANDARD_CROSS_SECTION[preset_key]
override = standard.get(preset_key) if isinstance(standard, dict) else None
override = override if isinstance(override, dict) else {}
base_ditch = base["ditch"]
over_ditch = override.get("ditch")
over_ditch = over_ditch if isinstance(over_ditch, dict) else {}
base_l = base.get("ditch_l_type", {})
over_l = override.get("ditch_l_type")
over_l = over_l if isinstance(over_l, dict) else {}
base_slope = base["cross_slope_pct"]
over_slope = override.get("cross_slope_pct")
over_slope = over_slope if isinstance(over_slope, dict) else {}
return {
"road_width_m": _as_float(override.get("road_width_m"), base["road_width_m"]),
"shoulder_left_m": _as_float(override.get("shoulder_left_m"), base["shoulder_left_m"]),
"shoulder_right_m": _as_float(override.get("shoulder_right_m"), base["shoulder_right_m"]),
"ditch_top_width_m": _as_float(over_ditch.get("top_width_m"), base_ditch["top_width_m"]),
"ditch_bottom_width_m": _as_float(
over_ditch.get("bottom_width_m"), base_ditch["bottom_width_m"]
),
"ditch_depth_m": _as_float(over_ditch.get("depth_m"), base_ditch["depth_m"]),
"l_ditch_width_m": _as_float(over_l.get("width_m"), base_l.get("width_m", 0.5)),
"l_ditch_depth_m": _as_float(over_l.get("depth_m"), base_l.get("depth_m", 0.1)),
# 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값).
"cross_slope_pct": _as_float(over_slope.get("min"), base_slope["min"]),
"fill_slope_ratio": _as_float(override.get("fill_slope_ratio"), base["fill_slope_ratio"]),
"cut_slope_ratio": _as_float(override.get("cut_slope_ratio"), base["cut_slope_ratio"]),
"pavement_thickness_m": _as_float(
override.get("pavement_thickness_m"), base.get("pavement_thickness_m", 0.2)
),
}
def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]:
@@ -109,6 +135,138 @@ def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, f
return cut_area, fill_area
def _ground_interpolator(valid: list[tuple[float, float]]):
"""정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프)."""
def ground_at(offset_m: float) -> float:
if offset_m <= valid[0][0]:
return valid[0][1]
if offset_m >= valid[-1][0]:
return valid[-1][1]
for index in range(1, len(valid)):
x1, z1 = valid[index]
if offset_m > x1:
continue
x0, z0 = valid[index - 1]
span = x1 - x0
if span <= 0:
return z1
ratio = (offset_m - x0) / span
return z0 + (z1 - z0) * ratio
return valid[-1][1]
return ground_at
class _SectionGeometry:
"""설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다."""
def __init__(
self,
*,
design_elevation_m: float,
group: dict[str, float],
section_mode: str,
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
) -> None:
half_road = group["road_width_m"] / 2.0
self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6)
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
# 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다.
self.has_ditch = section_mode != "both_fill"
self.ditch_type = ditch_type
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
outward = 1.0 if ditch_side == "left" else -1.0
edge_z = self.road_z(edge_offset)
if self.has_ditch:
if ditch_type == "l_type":
# L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 한 조각.
width = group["l_ditch_width_m"]
depth = group["l_ditch_depth_m"]
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * width, edge_z - depth),
]
else:
# 일반: 상단폭/저폭/깊이 사다리꼴.
top = group["ditch_top_width_m"]
bottom = min(group["ditch_bottom_width_m"], top)
depth = group["ditch_depth_m"]
inset = (top - bottom) / 2.0
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * inset, edge_z - depth),
(edge_offset + outward * (inset + bottom), edge_z - depth),
(edge_offset + outward * top, edge_z),
]
def road_z(self, offset_m: float) -> float:
"""노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면."""
return self.z_center + self.slope_per_offset * offset_m
def _slope_start(self, side: str) -> tuple[float, float]:
"""사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다."""
if side == "left":
edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent)
else:
edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent)
if side == self.ditch_side and self.ditch_points:
outer = self.ditch_points[-1]
return abs(outer[0]), outer[1]
return edge_offset, edge_z
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
extent = self.left_extent if side == "left" else self.right_extent
if abs(offset_m) <= extent + 1e-9:
return self.road_z(offset_m)
# 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
if side == self.ditch_side and self.ditch_points:
inner = abs(self.ditch_points[0][0])
outer = abs(self.ditch_points[-1][0])
if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9:
points = self.ditch_points
for index in range(1, len(points)):
x0, z0 = abs(points[index - 1][0]), points[index - 1][1]
x1, z1 = abs(points[index][0]), points[index][1]
if abs(offset_m) > x1 + 1e-9:
continue
span = x1 - x0
if span <= 1e-9:
return z1
ratio = (abs(offset_m) - x0) / span
return z0 + (z1 - z0) * ratio
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
start_dist, start_z = self._slope_start(side)
run = abs(offset_m) - start_dist
if role == "cut":
slope_line = start_z + run / self.cut_ratio
return min(slope_line, ground_m)
fill_line = start_z - run / self.fill_ratio
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
return points
def compute_cross_design(
samples: list[dict[str, Any]],
design_elevation_m: float | None,
@@ -116,28 +274,34 @@ def compute_cross_design(
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,
ditch_type: str = "standard",
paved: bool = False,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플.
design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가.
ditch_type: 일반(standard)/L형(l_type, 구간 전용).
paved: 포장 중첩 여부 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형).
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 config .
"""
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 ditch_type not in SECTION_DITCH_TYPES:
raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}")
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)
if ditch_type == "l_type" and preset_key != "rock":
raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.")
group = _resolve_group(preset_key, standard)
paved_group = _resolve_group("paved", standard)
# 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"]
resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side)
valid = sorted(
@@ -153,48 +317,93 @@ def compute_cross_design(
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
geometry = _SectionGeometry(
design_elevation_m=design_elevation_m,
group=group,
section_mode=section_mode,
ditch_side=resolved_ditch_side,
ditch_type=ditch_type,
cross_slope_pct=cross_slope_pct,
)
ground_at = _ground_interpolator(valid)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
min_offset, max_offset = valid[0][0], valid[-1][0]
merged: list[float] = [offset for offset, _z in valid]
merged.extend(point for point in geometry.breakpoints() if min_offset <= point <= max_offset)
merged = sorted(set(round(offset, 6) for offset in merged))
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,
)
for offset_m in merged:
ground_m = ground_at(offset_m)
design_z = geometry.design_z(offset_m, ground_m)
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 {
# 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
if not geometry.has_ditch:
ditch_area = 0.0
ditch_spec: dict[str, Any] = {"type": "none"}
elif ditch_type == "l_type":
ditch_area = group["l_ditch_width_m"] * group["l_ditch_depth_m"] / 2.0
ditch_spec = {
"type": "l_type",
"width_m": group["l_ditch_width_m"],
"depth_m": group["l_ditch_depth_m"],
}
else:
ditch_area = (
(group["ditch_top_width_m"] + group["ditch_bottom_width_m"])
/ 2.0
* group["ditch_depth_m"]
)
ditch_spec = {
"type": "standard",
"top_width_m": group["ditch_top_width_m"],
"bottom_width_m": group["ditch_bottom_width_m"],
"depth_m": group["ditch_depth_m"],
}
result: dict[str, Any] = {
"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},
"ditch_type": ditch_type if geometry.has_ditch else None,
"cut_slope_ratio": round(geometry.cut_ratio, 4),
"fill_slope_ratio": round(geometry.fill_ratio, 4),
"roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4),
"carriageway_width_m": round(group["road_width_m"], 4),
"cross_slope_pct": round(cross_slope_pct, 4),
"ditch": ditch_spec,
"paved": bool(paved),
# 노면 양 끝점(프론트 포장층 박스·노면 렌더링 기준).
"road_edges": {
"left": {
"offset_m": round(geometry.left_extent, 4),
"elevation_m": round(geometry.road_z(geometry.left_extent), 4),
},
"right": {
"offset_m": round(-geometry.right_extent, 4),
"elevation_m": round(geometry.road_z(-geometry.right_extent), 4),
},
},
"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,
}
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
return result
def design_elevation_from_longitudinal(
@@ -443,6 +443,93 @@ async def get_cross_sections_missing_design_chainages(
return chainages
async def merge_cross_section_design_patch(
connection: aiomysql.Connection,
*,
route_id: int,
chainage_m: float,
patch: dict[str, Any],
) -> bool:
"""측점 하나(부동소수 chainage 근사 매칭)의 data.design에 patch를 병합한다.
확정 프론트 세션 보관값( 경계선 오프셋 ) 기존 설계를 보존한 얹기
위해 사용한다. design이 없던 측점이면 patch만으로 design을 만든다.
"""
if not patch:
return False
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 = {}
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 merge_longitudinal_section_options(
connection: aiomysql.Connection,
*,
route_id: int,
options_patch: dict[str, Any],
) -> bool:
"""경로 최신 종단면 data.options에 patch를 병합 저장한다.
표준 횡단면 설정 확정 시점 옵션을 기존 생성 옵션 스냅샷을 보존한 갱신한다.
갱신 여부를 반환한다.
"""
if not options_patch:
return False
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM longitudinal_sections
WHERE route_id = %s
ORDER BY id DESC
LIMIT 1
""",
(route_id,),
)
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 = {}
options = data.get("options")
if not isinstance(options, dict):
options = {}
options.update(options_patch)
data["options"] = options
await cursor.execute(
"UPDATE longitudinal_sections SET data = %s WHERE id = %s",
(json.dumps(data, ensure_ascii=False), int(row[0])),
)
return True
async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
"""경로의 종횡단면 상태를 CONFIRMED로 변경한다."""
async with connection.cursor() as cursor:
@@ -8,7 +8,7 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -34,11 +34,14 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_longitudinal_section,
get_route_generation_source,
insert_cross_sections,
merge_cross_section_design_patch,
merge_longitudinal_section_options,
update_cross_section_design,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CrossDesignRequest,
CrossDesignResponse,
SectionConfirmRequest,
SectionConfirmResponse,
SectionContextResponse,
SectionDetailResponse,
@@ -50,7 +53,13 @@ from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import complete_stage, get_workflow_state
from config.config_db import get_db_pool
from config.config_system import FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION
from config.config_system import (
FOREST_ROAD_MIN_WIDTH_M,
SECTION_VERTICAL_EXAGGERATION,
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
STANDARD_ROCK_BOUNDARY_STEP_M,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
@@ -80,6 +89,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
long_sample_interval_m=defaults.long_sample_interval_m,
vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION,
),
standard_cross_section=STANDARD_CROSS_SECTION,
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
)
except Exception:
logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id)
@@ -305,8 +317,8 @@ async def regenerate_sections(
def _read_cross_design_inputs(
project_root: Path, longitudinal_file_path: str, chainage_m: float
) -> tuple[list[dict], float | None]:
"""측점 하나의 지반 샘플계획고를 파일에서 읽는다 (경로 이탈 검증 포함)."""
) -> tuple[list[dict], float | None, bool]:
"""측점 하나의 지반 샘플·계획고·포장 제안 여부를 파일에서 읽는다 (경로 이탈 검증 포함)."""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents:
@@ -315,6 +327,7 @@ def _read_cross_design_inputs(
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
pavement_suggested = _pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
@@ -324,40 +337,76 @@ def _read_cross_design_inputs(
samples = cross.get("samples") if isinstance(cross, dict) else None
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
return samples, design_elevation
return samples, design_elevation, pavement_suggested
def _pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
"""측점별 포장 제안(B05 solve가 법정 경사 기준으로 판정) 매핑을 만든다."""
stations = longitudinal.get("stations")
mapping: dict[float, bool] = {}
if isinstance(stations, list):
for station in stations:
suggested = station.get("pavement_suggested")
if isinstance(suggested, bool):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
return mapping
def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
"""측점별 기본 단면유형 매핑: 상단측(uphill_side)이 절토측이 되는 편절편성.
B05 solve가 자동 판정하고 사용자가 3D 램프로 바꾼 (확정 정본 병합) 그대로
소비한다. 판정 불가 측점은 매핑에서 빠지고 호출부가 좌절토로 폴백한다.
"""
stations = longitudinal.get("stations")
mapping: dict[float, str] = {}
if isinstance(stations, list):
for station in stations:
side = station.get("uphill_side")
if side in ("left", "right"):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
return mapping
def _attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
) -> None:
"""지정 설계가 없는 횡단에 기본값(토사/좌절토) 프리뷰 설계를 즉석 계산해 얹는다.
"""지정 설계가 없는 횡단에 기본값(토사 + 상단측 절토) 프리뷰 설계를 즉석 계산해 얹는다.
detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이
측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
for section in cross_sections:
if section.get("design"):
continue
try:
chainage_m = float(section.get("chainage_m", 0.0))
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(
longitudinal, float(section.get("chainage_m", 0.0))
),
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="soil",
section_mode="left_cut",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
section["design"] = design
except (ValueError, KeyError):
continue
def _compute_default_designs(
project_root: Path, longitudinal_file_path: str, chainages: list[float]
project_root: Path,
longitudinal_file_path: str,
chainages: list[float],
standard: dict[str, Any] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(토사/좌절토)으로 계산한 (chainage, design) 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 값으로 표준단면 기하를 계산한다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
@@ -366,6 +415,8 @@ def _compute_default_designs(
return []
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
@@ -376,13 +427,17 @@ def _compute_default_designs(
samples = cross.get("samples")
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="soil",
section_mode="left_cut",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
standard=standard,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
@@ -405,7 +460,7 @@ async def compute_cross_section_design(
)
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(
samples, design_elevation, pavement_suggested = await asyncio.to_thread(
_read_cross_design_inputs,
project_root,
str(longitudinal["longitudinal_file_path"]),
@@ -417,9 +472,14 @@ async def compute_cross_section_design(
ground_type=request.ground_type,
section_mode=request.section_mode,
ditch_side=request.ditch_side,
ditch_type=request.ditch_type,
paved=request.paved,
standard=request.standard_cross_section,
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
design["pavement_suggested"] = pavement_suggested
async with pool.acquire() as connection:
await connection.begin()
try:
@@ -455,11 +515,14 @@ async def compute_cross_section_design(
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
async def confirm_sections(
project_id: UUID, route_id: int
project_id: UUID,
route_id: int,
request: SectionConfirmRequest | None = Body(default=None),
) -> SectionConfirmResponse | JSONResponse:
"""경로의 종횡단면을 확정(CONFIRMED)한다.
지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 확정한다.
표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다.
"""
pool = get_db_pool()
try:
@@ -482,6 +545,7 @@ async def confirm_sections(
project_root,
str(existing["longitudinal_file_path"]),
missing,
request.standard_cross_section if request else None,
)
async with pool.acquire() as connection:
@@ -491,6 +555,25 @@ async def confirm_sections(
await update_cross_section_design(
connection, route_id=route_id, chainage_m=chainage_m, design=design
)
if request and request.standard_cross_section:
await merge_longitudinal_section_options(
connection,
route_id=route_id,
options_patch={"standard_cross_section": request.standard_cross_section},
)
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
if request and request.cross_patches:
for patch_item in request.cross_patches:
patch: dict[str, Any] = {}
if patch_item.rock_boundary_offset_m is not None:
patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m
if patch:
await merge_cross_section_design_patch(
connection,
route_id=route_id,
chainage_m=patch_item.chainage_m,
patch=patch,
)
await confirm_sections_for_route(connection, route_id)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 3)
@@ -22,6 +22,12 @@ class CrossDesignRequest(BaseModel):
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
# 측구 형식: 일반(사다리꼴)/L형. L형은 암 구간에서만 허용된다.
ditch_type: Literal["standard", "l_type"] = "standard"
# 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산한다.
paved: bool = False
# B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순.
standard_cross_section: dict[str, Any] | None = None
class CrossDesignResponse(BaseModel):
@@ -32,6 +38,23 @@ class CrossDesignResponse(BaseModel):
design: dict[str, Any]
class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
chainage_m: float = Field(..., ge=0)
# 암 경계선 오프셋(m, 지면선 기준 하향 음수 — 계획선 아님). 암 지반 측점만 의미 있다.
rock_boundary_offset_m: float | None = None
class SectionConfirmRequest(BaseModel):
"""종횡단 확정 요청. 확정 시 저장할 표준 횡단면 설정값(선택)을 함께 받는다."""
# B06 표준 횡단면 설정 패널 편집값(토사/암/포장). longitudinal_sections.data.options에 저장.
standard_cross_section: dict[str, Any] | None = None
# 측점별 세션 보관값(암 경계선 오프셋 등) — 확정 시점에 일괄 DB 병합.
cross_patches: list[CrossSectionPatch] | None = None
class SectionConfirmResponse(BaseModel):
"""종횡단 확정 결과."""
@@ -61,6 +84,11 @@ class SectionContextResponse(BaseModel):
smooth: bool | None = None
crs_epsg: int | None = None
defaults: SectionOptionDefaults
# 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본.
standard_cross_section: dict[str, Any] = Field(default_factory=dict)
# 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m).
rock_boundary_default_offset_m: float = -0.5
rock_boundary_step_m: float = 0.1
class SectionSummaryResponse(BaseModel):
@@ -1,11 +1,17 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Cross_Design.ts
* (··) .
* (····)
* · · .
*
* , ·
* onChange로 . (section.design)
* · .
* , · .
* , onChange로
* . (section.design) ·
* . ,
* · .
*
* (/) 지반에서만: 측구형식(/L형) //
* (B05 // ) .
* (RockBoundaryControl), DB에
* .
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -13,6 +19,7 @@ import type {
CrossDesign,
CrossSection,
DitchSide,
DitchType,
GroundType,
SectionMode,
} from "./B06_wf3_ProfileCross_Api_Fetch";
@@ -38,11 +45,34 @@ const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [
["left", "B06_Design_Ditch_Left"],
["right", "B06_Design_Ditch_Right"],
];
const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [
["standard", "B06_Design_DitchType_Standard"],
["l_type", "B06_Design_DitchType_LType"],
];
export interface CrossDesignChange {
ground_type: GroundType;
section_mode: SectionMode;
ditch_side: DitchSide | null;
ditch_type: DitchType;
paved: boolean;
}
/**
* . Page가 · .
* () (m, =) .
*/
export interface RockBoundaryControl {
stepM: number;
defaultOffsetM: number;
/** 세션 → design 저장값 → 기본값 순으로 현재 오프셋을 돌려준다. */
offsetFor: (section: CrossSection) => number;
adjust: (chainageM: number, deltaM: number) => void;
reset: (chainageM: number) => void;
}
function isRock(ground: GroundType): boolean {
return ground === "ripping_rock" || ground === "blasting_rock";
}
function segment<T extends string>(
@@ -72,17 +102,73 @@ function segment<T extends string>(
return wrap;
}
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */
function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b06-design__seg b06-design__rockb";
const legendEl = document.createElement("span");
legendEl.className = "b06-design__seg-legend";
legendEl.textContent = L("B06_Design_RockBoundary_Legend");
wrap.append(legendEl);
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
const readout = document.createElement("span");
readout.className = "b06-design__rockb-readout";
const currentOffset = control.offsetFor(section);
readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`;
const makeButton = (
label: string,
title: string,
className: string,
onClick: () => void,
): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__rockb-btn ${className}`;
button.textContent = label;
button.title = title;
button.addEventListener("click", onClick);
return button;
};
group.append(
makeButton("▲", `${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`, "is-up", () =>
control.adjust(section.chainage_m, control.stepM),
),
makeButton("▼", `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, "is-down", () =>
control.adjust(section.chainage_m, -control.stepM),
),
makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () =>
control.reset(section.chainage_m),
),
readout,
);
wrap.append(group);
return wrap;
}
/** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */
export function buildDesignControls(
section: CrossSection,
onChange: (chainageM: number, change: CrossDesignChange) => void,
rockBoundary?: RockBoundaryControl,
): HTMLElement {
const design = section.design;
// 기본값: 토사(soil) + 좌절토(left_cut). 미지정 측점은 확정 시 이 기본값으로 채워진다.
const state: { ground: GroundType; mode: SectionMode; ditch: DitchSide | null } = {
// 기본값: 토사(soil) + 상단측 절토(uphill_side, 미상이면 좌절토) + 일반측구 + 비포장.
const state: {
ground: GroundType;
mode: SectionMode;
ditch: DitchSide | null;
ditchType: DitchType;
paved: boolean;
} = {
ground: design?.ground_type ?? "soil",
mode: design?.section_mode ?? "left_cut",
mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"),
ditch: design?.ditch_side ?? null,
ditchType: design?.ditch_type ?? "standard",
paved: design?.paved ?? false,
};
const bar = document.createElement("div");
bar.className = "b06-design";
@@ -92,10 +178,14 @@ export function buildDesignControls(
const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill";
const emit = (): void => {
if (!state.ground || !state.mode) return;
// L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다.
if (!isRock(state.ground)) state.ditchType = "standard";
onChange(section.chainage_m, {
ground_type: state.ground,
section_mode: state.mode,
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
ditch_type: state.ditchType,
paved: state.paved,
});
};
@@ -117,6 +207,49 @@ export function buildDesignControls(
}),
);
}
// 측구형식(일반/L형): 암 지반 + 측구가 존재하는 단면(양성 제외)에서만 노출.
if (isRock(state.ground) && state.mode !== "both_fill") {
bar.append(
segment(L("B06_Design_DitchType_Legend"), DITCH_TYPE_OPTIONS, state.ditchType, (value) => {
state.ditchType = value;
emit();
}),
);
}
// 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경).
const pavedWrap = document.createElement("div");
pavedWrap.className = "b06-design__seg";
const pavedLegend = document.createElement("span");
pavedLegend.className = "b06-design__seg-legend";
pavedLegend.textContent = L("B06_Design_Paved_Legend");
const pavedButtons = document.createElement("div");
pavedButtons.className = "b06-design__seg-buttons";
const pavedButton = document.createElement("button");
pavedButton.type = "button";
pavedButton.className = `b06-design__btn${state.paved ? " b06-design__btn--active" : ""}`;
pavedButton.textContent = state.paved ? L("B06_Design_Paved_On") : L("B06_Design_Paved_Off");
pavedButton.setAttribute("aria-pressed", state.paved ? "true" : "false");
pavedButton.addEventListener("click", () => {
state.paved = !state.paved;
emit();
});
pavedButtons.append(pavedButton);
pavedWrap.append(pavedLegend, pavedButtons);
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
pavedButton.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
pavedWrap.append(badge);
}
bar.append(pavedWrap);
// 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합).
if (rockBoundary && isRock(state.ground)) {
bar.append(rockBoundaryRow(section, rockBoundary));
}
const readout = document.createElement("div");
readout.className = "b06-design__areas";
@@ -153,3 +286,58 @@ export function appendCrossDesignOverlay(
polyline.setAttribute("class", "b06-chart__design-cross");
svg.append(polyline);
}
/**
* ( + , ) .
* () **()** .
* · . offsetM = .
* .
*/
export function appendRockBoundaryOverlay(
svg: SVGSVGElement,
groundSamples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>,
offsetM: number,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const segments: string[][] = [];
let current: string[] = [];
for (const sample of groundSamples) {
const elevation = sample.elevation_m;
if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) {
if (current.length > 1) segments.push(current);
current = [];
continue;
}
current.push(`${x(sample.offset_m ?? 0)},${toDisplayY((elevation as number) + offsetM)}`);
}
if (current.length > 1) segments.push(current);
for (const points of segments) {
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", "b06-chart__rock-boundary");
svg.append(polyline);
}
}
/** 포장 측점의 노면 포장층 박스를 겹쳐 그린다 (노면 양 끝점 기준, 두께만큼 하향). */
export function appendPavementOverlay(
svg: SVGSVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
if (!design.paved || !design.road_edges) return;
const thickness = design.pavement_thickness_m ?? 0.2;
const { left, right } = design.road_edges;
const points = [
`${x(left.offset_m)},${toDisplayY(left.elevation_m)}`,
`${x(right.offset_m)},${toDisplayY(right.elevation_m)}`,
`${x(right.offset_m)},${toDisplayY(right.elevation_m - thickness)}`,
`${x(left.offset_m)},${toDisplayY(left.elevation_m - thickness)}`,
];
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute("points", points.join(" "));
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
}
@@ -10,7 +10,10 @@
import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
buildDesignControls,
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
import {
CROSS_HEIGHT,
@@ -117,6 +120,7 @@ export function createCrossSectionCard(
forcedHeightPx?: number,
designElevation?: number,
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
): HTMLElement {
const card = document.createElement("article");
card.id = `cross-${section.station_id}`;
@@ -154,7 +158,7 @@ export function createCrossSectionCard(
meta.append(kind);
header.append(title, meta);
card.append(header);
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange));
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange, rockBoundary));
const metrics = crossPlotMetrics(
section,
@@ -253,7 +257,19 @@ export function createCrossSectionCard(
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
svg,
sourceSamples,
rockBoundary.offsetFor(section),
x,
toDisplayY,
);
}
}
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
@@ -18,15 +18,24 @@ import {
import {
computeCrossDesign,
confirmSections,
type CrossSectionPatch,
fetchSectionContext,
fetchSectionDetail,
getSections,
regenerateSections,
type SectionContextResponse,
type SectionDetailResponse,
type SectionSummaryResponse,
type StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import { type CrossDesignChange, createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
import {
type CrossDesignChange,
createSectionView,
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Section_View";
import {
createStandardPanel,
type StandardPanelController,
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
import "./B06_wf3_ProfileCross_UI_Style.css";
function L(key: keyof typeof ui_locales): string {
@@ -43,54 +52,17 @@ function buildGroup(legend: string): HTMLElement {
return group;
}
function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } {
const root = document.createElement("div");
root.className = "b06-profile__info-line";
const key = document.createElement("span");
key.textContent = label;
const value = document.createElement("strong");
value.textContent = "-";
root.append(key, value);
return { root, value };
}
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b06-profile__metric";
const key = document.createElement("span");
key.className = "b06-profile__metric-key";
key.textContent = label;
const metricValue = document.createElement("span");
metricValue.className = "b06-profile__metric-val";
metricValue.textContent = value;
row.append(key, metricValue);
return row;
}
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
let stationInterval: number | undefined;
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId"));
const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter"));
const methodInfo = buildInfoLine(L("B06_Profile_Field_Method"));
const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth"));
const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs"));
routeGroup.append(
routeIdInfo.root,
filterInfo.root,
methodInfo.root,
smoothInfo.root,
crsInfo.root,
);
const resultGroup = buildGroup(L("B06_Profile_Result_Title"));
const resultBody = document.createElement("div");
resultBody.className = "b06-profile__result-body";
resultGroup.append(resultBody);
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
const standardGroup = buildGroup(L("B06_Std_Title"));
const standardPanelSlot = document.createElement("div");
standardGroup.append(standardPanelSlot);
let standardPanel: StandardPanelController | null = null;
const displayGroup = buildGroup(L("B06_Profile_Group_Display"));
const verticalExaggerationField = createInputField({
@@ -125,7 +97,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(routeGroup, resultGroup, displayGroup, actionRow);
leftForm.append(standardGroup, displayGroup, actionRow);
// 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다.
const designRequestSeq = new Map<number, number>();
@@ -133,6 +105,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
/**
* : (1) ( ),
* (2) · . .
* DB config .
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
if (!projectId || currentRouteId === null || !sectionDetail) return;
@@ -148,6 +121,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
};
sectionView.refreshCard(chainageM);
}
@@ -159,6 +134,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const response = await computeCrossDesign(projectId, currentRouteId, {
chainage_m: chainageM,
...change,
standard_cross_section: standardPanel?.getValues(),
});
if (designRequestSeq.get(chainageM) !== seq) return;
target.design = response.design;
@@ -170,27 +146,89 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
/* ()
* (sessionStorage) ,
* cross_patches로 DB(data.design.rock_boundary_offset_m) .
* · context(config) . */
let rockBoundaryDefault = -0.5;
let rockBoundaryStep = 0.1;
const rockOffsets = new Map<string, number>();
const rockKey = (chainageM: number): string => chainageM.toFixed(2);
const rockSessionKey = (): string | null =>
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null;
function loadRockOffsets(): void {
rockOffsets.clear();
const key = rockSessionKey();
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => {
if (Number.isFinite(offset)) rockOffsets.set(chainage, offset);
});
} catch {
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
}
}
function persistRockOffsets(): void {
const key = rockSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(rockOffsets)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
const rockBoundaryControl: RockBoundaryControl = {
get stepM() {
return rockBoundaryStep;
},
get defaultOffsetM() {
return rockBoundaryDefault;
},
offsetFor: (section) =>
rockOffsets.get(rockKey(section.chainage_m)) ??
section.design?.rock_boundary_offset_m ??
rockBoundaryDefault,
adjust: (chainageM, deltaM) => {
const key = rockKey(chainageM);
const stored = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
)?.design?.rock_boundary_offset_m;
const current = rockOffsets.get(key) ?? stored ?? rockBoundaryDefault;
rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100);
persistRockOffsets();
sectionView.refreshCard(chainageM);
},
reset: (chainageM) => {
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
persistRockOffsets();
sectionView.refreshCard(chainageM);
},
};
const sectionView = createSectionView((chainageM, change) => {
void handleDesignChange(chainageM, change);
});
}, rockBoundaryControl);
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
const mainArea = document.createElement("div");
mainArea.className = "b06-profile__main";
mainArea.append(sectionView.root);
function showSectionView(): void {
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
}
function renderMessage(message: string): void {
const text = document.createElement("p");
text.className = "b06-profile__empty";
text.textContent = message;
resultBody.replaceChildren(text);
}
function renderSummary(result: SectionSummaryResponse): void {
const path = result.longitudinal?.longitudinal_file_path;
resultBody.replaceChildren(
metricRow(
L("B06_Profile_Result_Length"),
result.length_m === null ? "-" : result.length_m.toFixed(2),
),
metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)),
metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"),
);
mainArea.replaceChildren(text);
}
function verticalExaggeration(): number {
@@ -214,8 +252,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
function renderSectionDetail(): void {
if (sectionDetail)
if (sectionDetail) {
showSectionView();
sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval);
}
}
async function applyCrossHalfWidth(): Promise<void> {
@@ -243,7 +283,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
await confirmSections(projectId, currentRouteId);
// 세션 보관 중인 측점별 암 경계선 오프셋을 확정 시점에 DB로 병합한다.
const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map(
([chainage, offset]) => ({
chainage_m: Number(chainage),
rock_boundary_offset_m: offset,
}),
);
await confirmSections(
projectId,
currentRouteId,
standardPanel?.getValues(),
crossPatches.length ? crossPatches : undefined,
);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
} catch (error) {
@@ -271,7 +323,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent: sectionView.root,
mainContent: mainArea,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
@@ -291,16 +343,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
return;
}
routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id);
filterInfo.value.textContent = context.filter_key ?? "-";
methodInfo.value.textContent = context.method ?? "-";
smoothInfo.value.textContent = context.smooth
? L("B06_Profile_Smooth_On")
: L("B06_Profile_Smooth_Off");
crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`;
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m);
stationInterval = context.defaults.station_interval_m;
rockBoundaryDefault = context.rock_boundary_default_offset_m;
rockBoundaryStep = context.rock_boundary_step_m;
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
standardPanel = createStandardPanel(projectId, context.standard_cross_section);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
@@ -308,19 +359,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
currentRouteId = context.route_id;
loadRockOffsets();
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
renderSummary(existing);
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as {
options?: { cross_half_width_m?: number; station_interval_m?: number };
options?: {
cross_half_width_m?: number;
station_interval_m?: number;
standard_cross_section?: StandardCrossSection;
};
} | null;
const storedOptions = summaryData?.options;
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
if (storedOptions?.standard_cross_section)
standardPanel?.applyStored(storedOptions.standard_cross_section);
const storedHalfWidth =
storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0
? storedOptions.cross_half_width_m
@@ -11,6 +11,7 @@
* ========================================================================== */
import type { CrossSection, SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch";
import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import {
createCrossSectionCard,
crossCardNaturalHeight,
@@ -34,7 +35,7 @@ import {
type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
export type { CrossDesignChange, DesignChangeHandler };
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
export interface SectionViewController {
root: HTMLElement;
@@ -50,7 +51,10 @@ export interface SectionViewController {
dispose: () => void;
}
export function createSectionView(onDesignChange?: DesignChangeHandler): SectionViewController {
export function createSectionView(
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
let currentDetail: SectionDetailResponse | null = null;
@@ -99,6 +103,7 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section
? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m)
: undefined,
onDesignChange,
rockBoundary,
);
const draw = (): void => {
@@ -0,0 +1,246 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Standard_Panel.ts
* "표준 횡단면 설정" ( / / 3).
*
* ·· · . config
* (STANDARD_CROSS_SECTION, context.standard_cross_section) ,
* (sessionStorage) . ·
* /DB에 getValues() .
*
* + L형 ,
* ( ).
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import type {
StandardCrossGroup,
StandardCrossKey,
StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
["soil", "B06_Std_Group_Soil"],
["rock", "B06_Std_Group_Rock"],
["paved", "B06_Std_Group_Paved"],
];
const SESSION_PREFIX = "b06:std-cross:";
function sessionKey(projectId: string): string {
return `${SESSION_PREFIX}${projectId}`;
}
/** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */
function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection {
return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection;
}
/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */
function readSession(projectId: string): StandardCrossSection | null {
try {
const raw = window.sessionStorage.getItem(sessionKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
}
function writeSession(projectId: string, value: StandardCrossSection): void {
try {
window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
export interface StandardPanelController {
root: HTMLElement;
/** 확정 시 백엔드/DB 저장에 쓰는 현재 편집값. */
getValues: () => StandardCrossSection;
/**
* DB에 . ,
* ( ).
*/
applyStored: (stored: StandardCrossSection) => void;
}
interface NumberFieldSpec {
label: string;
get: (group: StandardCrossGroup) => number;
set: (group: StandardCrossGroup, value: number) => void;
/** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */
only?: StandardCrossKey;
}
/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */
const FIELD_SPECS: NumberFieldSpec[] = [
{
label: "B06_Std_Field_RoadWidth",
get: (g) => g.road_width_m,
set: (g, v) => (g.road_width_m = v),
},
{
label: "B06_Std_Field_ShoulderLeft",
get: (g) => g.shoulder_left_m,
set: (g, v) => (g.shoulder_left_m = v),
},
{
label: "B06_Std_Field_ShoulderRight",
get: (g) => g.shoulder_right_m,
set: (g, v) => (g.shoulder_right_m = v),
},
{
label: "B06_Std_Field_DitchTop",
get: (g) => g.ditch.top_width_m,
set: (g, v) => (g.ditch.top_width_m = v),
},
{
label: "B06_Std_Field_DitchBottom",
get: (g) => g.ditch.bottom_width_m,
set: (g, v) => (g.ditch.bottom_width_m = v),
},
{
label: "B06_Std_Field_DitchDepth",
get: (g) => g.ditch.depth_m,
set: (g, v) => (g.ditch.depth_m = v),
},
{
label: "B06_Std_Field_LDitchWidth",
only: "rock",
get: (g) => g.ditch_l_type?.width_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 };
},
},
{
label: "B06_Std_Field_LDitchDepth",
only: "rock",
get: (g) => g.ditch_l_type?.depth_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v };
},
},
{
label: "B06_Std_Field_CutSlope",
get: (g) => g.cut_slope_ratio,
set: (g, v) => (g.cut_slope_ratio = v),
},
{
label: "B06_Std_Field_FillSlope",
get: (g) => g.fill_slope_ratio,
set: (g, v) => (g.fill_slope_ratio = v),
},
{
label: "B06_Std_Field_CrossSlopeMin",
get: (g) => g.cross_slope_pct.min,
set: (g, v) => (g.cross_slope_pct.min = v),
},
{
label: "B06_Std_Field_CrossSlopeMax",
get: (g) => g.cross_slope_pct.max,
set: (g, v) => (g.cross_slope_pct.max = v),
},
];
/**
* .
* @param projectId .
* @param defaults config에서 ( ).
*/
export function createStandardPanel(
projectId: string,
defaults: StandardCrossSection,
): StandardPanelController {
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readSession(projectId);
const hadSession = sessionValue !== null;
const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults);
const root = document.createElement("div");
root.className = "b06-std";
// 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다.
const body = document.createElement("div");
body.className = "b06-std__body";
const persist = (): void => writeSession(projectId, state);
const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => {
const group = state[key];
const fieldset = document.createElement("fieldset");
fieldset.className = "b06-std__group";
const legend = document.createElement("legend");
legend.className = "b06-std__legend";
legend.textContent = L(legendKey);
fieldset.append(legend);
const grid = document.createElement("div");
grid.className = "b06-std__grid";
for (const spec of FIELD_SPECS) {
if (spec.only && spec.only !== key) continue;
const field = createInputField({
label: L(spec.label as keyof typeof ui_locales),
type: "number",
value: String(spec.get(group)),
onInput: (raw) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
spec.set(group, parsed);
persist();
},
});
field.input.step = "0.1";
field.input.min = "0";
grid.append(field.root);
}
fieldset.append(grid);
if (key === "rock") {
const note = document.createElement("p");
note.className = "b06-std__note";
note.textContent = L("B06_Std_LType_Note");
fieldset.append(note);
}
return fieldset;
};
const renderBody = (): void => {
body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey)));
};
renderBody();
const resetButton = createButton({
label: L("B06_Std_Reset"),
variant: "ghost",
onClick: () => {
const fresh = cloneDefaults(defaults);
(Object.keys(fresh) as StandardCrossKey[]).forEach((key) => {
state[key] = fresh[key];
});
persist();
renderBody();
},
});
const actions = document.createElement("div");
actions.className = "b06-std__actions";
actions.append(resetButton);
root.append(body, actions);
return {
root,
getValues: () => state,
applyStored: (stored) => {
if (hadSession) return; // 진행 중 세션 편집값이 우선.
(Object.keys(stored) as StandardCrossKey[]).forEach((key) => {
if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup;
});
renderBody();
},
};
}
@@ -79,6 +79,54 @@
min-width: 0;
}
/* --- 표준 횡단면 설정 패널 --- */
.b06-std {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.b06-std__body {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.b06-std__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin: 0;
padding: var(--spacing-8) var(--spacing-16) var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background-color: var(--color-surface);
}
.b06-std__legend {
padding: 0 var(--spacing-8);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.b06-std__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-8);
}
.b06-std__note {
margin: 0;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
.b06-std__actions {
display: flex;
gap: var(--spacing-8);
}
/* --- 우측 결과 --- */
.b06-profile__result {
display: flex;
@@ -455,3 +503,62 @@
stroke-width: 1.8;
stroke-linejoin: round;
}
/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */
.b06-chart__rock-boundary {
fill: none;
stroke: var(--color-warning);
stroke-width: 1.6;
stroke-dasharray: 6 4;
stroke-linejoin: round;
opacity: 0.9;
}
/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */
.b06-chart__pavement {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
stroke: var(--color-text-secondary);
stroke-width: 1;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.7rem;
line-height: 1;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
.b06-design__rockb-btn:first-child {
border-left: none;
}
.b06-design__rockb-btn:hover {
color: var(--color-text);
background: var(--color-surface-raised);
}
.b06-design__rockb-btn.is-reset {
color: var(--color-warning);
}
.b06-design__rockb-readout {
padding: 0 var(--spacing-8);
font-size: 0.72rem;
font-family: var(--font-mono);
color: var(--color-warning);
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
}
+71 -11
View File
@@ -245,22 +245,80 @@ FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5}
# ─────────────────────────────────────────────────────────────────────────
# 5-4-1. 횡단 표준단면 설계 기준 (B06 WF3)
# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정)
#
# 출처: 첨부 표준횡단도(토사/암 구간). 노면폭 4.5m = 길어깨 0.5 + 차도 3.5 + 길어깨 0.5.
# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값.
# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장).
# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %.
#
# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과
# 절·성토 단면적 엔진(B06_wf3_ProfileCross_Engine_Design)이 모두 여기를 읽으며,
# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다.
# ─────────────────────────────────────────────────────────────────────────
STANDARD_CROSS_SECTION = {
"soil": {
"road_width_m": 3.0, # 노폭(차도)
"shoulder_left_m": 0.5, # 노견(좌)
"shoulder_right_m": 0.5, # 노견(우)
# 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm.
"ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
"cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향)
"fill_slope_ratio": 1.2, # 성토 1:1.2
"cut_slope_ratio": 1.0, # 절토 1:1.0
},
"rock": { # 리핑암·발파암 공유
"road_width_m": 3.0,
"shoulder_left_m": 0.5,
"shoulder_right_m": 0.5,
# 일반 측구 = 690/300/300mm.
"ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3},
# L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택).
"ditch_l_type": {"width_m": 0.5, "depth_m": 0.1},
"cross_slope_pct": {"min": 3.0, "max": 3.0},
"fill_slope_ratio": 1.2,
"cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상)
},
"paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름
"road_width_m": 3.0,
"shoulder_left_m": 0.5,
"shoulder_right_m": 0.5,
"ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
"cross_slope_pct": {"min": 1.5, "max": 2.0},
"fill_slope_ratio": 1.2,
"cut_slope_ratio": 1.0,
# 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능).
"pavement_thickness_m": 0.2,
},
}
# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m).
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5
STANDARD_ROCK_BOUNDARY_STEP_M = 0.1
# ─────────────────────────────────────────────────────────────────────────
# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3)
#
# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지).
# 지반유형 라벨은 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"))
_SCS_SOIL = STANDARD_CROSS_SECTION["soil"]
# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능.
SECTION_ROADBED_WIDTH_M = float(
os.getenv(
"SECTION_ROADBED_WIDTH_M",
str(
_SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"]
),
)
)
SECTION_CARRIAGEWAY_WIDTH_M = float(
os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"]))
)
# 성토 경사(수평:수직 = 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_FILL_SLOPE_RATIO = float(
os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"]))
)
# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다.
SECTION_GROUND_TYPE_PRESET = {
"soil": "soil",
@@ -270,6 +328,8 @@ SECTION_GROUND_TYPE_PRESET = {
# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset.
SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill")
SECTION_DITCH_SIDES = ("left", "right")
# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지).
SECTION_DITCH_TYPES = ("standard", "l_type")
# ─────────────────────────────────────────────────────────────────────────
+37
View File
@@ -813,12 +813,49 @@ export const ui_locales = {
B06_Design_Cut_Area: ["절토", "Cut"],
B06_Design_Fill_Area: ["성토", "Fill"],
B06_Design_Unset: ["미지정", "Not set"],
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
B06_Design_DitchType_Standard: ["일반", "Standard"],
B06_Design_DitchType_LType: ["L형", "L-type"],
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",
],
B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"],
B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"],
B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"],
B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"],
B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."],
B06_Profile_Confirm_NeedDesign: [
"지반유형이 지정되지 않은 측점이 있습니다.",
"Some stations have no ground type assigned.",
],
/* --- B06 표준 횡단면 설정 패널 --- */
B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"],
B06_Std_Group_Soil: ["토사 구간", "Soil section"],
B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"],
B06_Std_Group_Paved: ["포장 구간", "Paved section"],
B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"],
B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"],
B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"],
B06_Std_Field_DitchTop: ["측구 상단폭(m)", "Ditch top (m)"],
B06_Std_Field_DitchBottom: ["측구 저폭(m)", "Ditch bottom (m)"],
B06_Std_Field_DitchDepth: ["측구 깊이(m)", "Ditch depth (m)"],
B06_Std_Field_LDitchWidth: ["L형 측구 폭(m)", "L-ditch width (m)"],
B06_Std_Field_LDitchDepth: ["L형 측구 깊이(m)", "L-ditch depth (m)"],
B06_Std_Field_CrossSlopeMin: ["횡단경사 최소(%)", "Cross slope min (%)"],
B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"],
B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"],
B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"],
B06_Std_LType_Note: [
"L형 측구는 각 횡단면도에서 선택합니다.",
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
/* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],
B07_Cad_Side_Pending: [