B06_개선 시작
This commit is contained in:
@@ -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가 돼도 구조물 정보는 유지).
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user